Initial commit
commit
1925a2dba7
@ -0,0 +1,3 @@
|
||||
/target
|
||||
/history
|
||||
config.ron
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "aigui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.70"
|
||||
chrono = { version = "0.4.24", features = ["serde"] }
|
||||
eframe = "0.21.3"
|
||||
egui-notify = "0.6.0"
|
||||
once_cell = "1.17.1"
|
||||
reqwest = { version = "0.11.15", features = ["json"] }
|
||||
ron = "0.8.0"
|
||||
serde = { version = "1.0.158", features = ["derive"] }
|
||||
serde_json = "1.0.94"
|
||||
tokio = { version = "1.26.0", features = ["full"] }
|
@ -0,0 +1,22 @@
|
||||
This software is licensed under the "Anyone But Richard M Stallman"
|
||||
(ABRMS) license, described below. No other licenses may apply.
|
||||
|
||||
|
||||
--------------------------------------------
|
||||
The "Anyone But Richard M Stallman" license
|
||||
--------------------------------------------
|
||||
|
||||
Do anything you want with this program, with the exceptions listed
|
||||
below under "EXCEPTIONS".
|
||||
|
||||
THIS SOFTWARE IS PROVIDED "AS IS" WITH NO WARRANTY OF ANY KIND.
|
||||
|
||||
In the unlikely event that you happen to make a zillion bucks off of
|
||||
this, then good for you; consider buying a homeless person a meal.
|
||||
|
||||
|
||||
EXCEPTIONS
|
||||
----------
|
||||
|
||||
Richard M Stallman (the guy behind GNU, etc.) may not make use of or
|
||||
redistribute this program or any of its derivatives.
|
@ -0,0 +1,3 @@
|
||||
# AI Gui
|
||||
|
||||
Crazy configurable AI gui made with egui/eframe for fun
|
@ -0,0 +1,8 @@
|
||||
# TODO
|
||||
|
||||
## Features
|
||||
- Add config editor
|
||||
- Add custom fetch header support to AI config
|
||||
|
||||
## Important
|
||||
- Make config file be in an actual config directory like ~/.config
|
@ -0,0 +1,27 @@
|
||||
(
|
||||
app: App(
|
||||
// Path to directory to save history files in. If set to `None` then history is disabled.
|
||||
history_dir: Some("./history")
|
||||
),
|
||||
ai: [
|
||||
AI(
|
||||
// Name of the AI shown to the UI
|
||||
name: "Example",
|
||||
// Generation endpoint
|
||||
url: "https://example.com/generate",
|
||||
// Request parameters, example usage below
|
||||
params: {
|
||||
// Simple string
|
||||
"model": String("example"),
|
||||
// Prompt type, allows for appening a string to the end of the prompt. Useful for stuff like `<|endofprompt|>`.
|
||||
"prompt": Prompt(Some("<|endofprompt|>")),
|
||||
// Temperature type
|
||||
"temperature": Temperature,
|
||||
// Max tokens type
|
||||
"max_tokens": MaxTokens
|
||||
},
|
||||
// JSON objects to get from response
|
||||
retriever: [ "output" ]
|
||||
)
|
||||
]
|
||||
)
|
@ -0,0 +1,105 @@
|
||||
use std::{collections::HashMap, sync::mpsc::Sender};
|
||||
|
||||
use crate::config::{ParamType, AI};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
static CLIENT: Lazy<Client> = Lazy::new(Client::new);
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub prompt: String,
|
||||
pub temperature: f32,
|
||||
pub max_tokens: i32,
|
||||
}
|
||||
|
||||
impl Default for Params {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prompt: Default::default(),
|
||||
temperature: 1.0,
|
||||
max_tokens: 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate(ai: AI, params: Params) -> Result<String> {
|
||||
let mut json_params: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
// yeah this is cursed but i could not think of a better way to do it
|
||||
for (key, val) in ai.params {
|
||||
match val {
|
||||
ParamType::String(string) => {
|
||||
json_params.insert(key, Value::from(string));
|
||||
}
|
||||
ParamType::Prompt(addition) => {
|
||||
json_params.insert(
|
||||
key,
|
||||
Value::from(format!("{}{}", params.prompt, addition.unwrap_or_default())),
|
||||
);
|
||||
}
|
||||
ParamType::Temperature => {
|
||||
json_params.insert(key, Value::from(params.temperature));
|
||||
}
|
||||
ParamType::MaxTokens => {
|
||||
json_params.insert(key, Value::from(params.max_tokens));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut res: &serde_json::Value = &CLIENT
|
||||
.post(ai.url)
|
||||
.json(&json_params)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
for retriever in ai.retriever {
|
||||
// it works but at what cost
|
||||
if let Ok(retriever) = retriever.clone().into_rust::<usize>() {
|
||||
res = res.get(retriever).context("Failed to execute retriever")?;
|
||||
} else {
|
||||
let retriever = retriever.into_rust::<String>()?;
|
||||
res = res.get(retriever).context("Failed to execute retriever")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res
|
||||
.as_str()
|
||||
.context("Output is not a valid string")?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum ResponseType {
|
||||
Error,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub struct Response {
|
||||
pub response_type: ResponseType,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
pub fn generate_with_mpsc(ai: AI, params: Params, tx: Sender<Response>) {
|
||||
tokio::spawn(async move {
|
||||
let output = match generate(ai, params).await {
|
||||
Ok(output) => Response {
|
||||
response_type: ResponseType::Success,
|
||||
output,
|
||||
},
|
||||
Err(error) => Response {
|
||||
response_type: ResponseType::Error,
|
||||
output: error.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
tx.send(output)
|
||||
.expect("Failed to send output, this should never happen!")
|
||||
});
|
||||
}
|
@ -0,0 +1,238 @@
|
||||
use std::{sync::mpsc::{self, Receiver, Sender}, time::Duration};
|
||||
|
||||
use chrono::Local;
|
||||
use eframe::{
|
||||
egui::{
|
||||
self, CentralPanel, ComboBox, DragValue, Grid, Layout, ScrollArea, Sense, Slider, TextEdit,
|
||||
Window,
|
||||
},
|
||||
emath::Align,
|
||||
epaint::Vec2,
|
||||
};
|
||||
use egui_notify::Toasts;
|
||||
|
||||
use crate::{
|
||||
ai::{generate_with_mpsc, Params, Response, ResponseType},
|
||||
config::{AI, CONFIG},
|
||||
history::{read_all_history, write_history, HistoryEntry},
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
toasts: Toasts,
|
||||
|
||||
output: String,
|
||||
selected_ai: AI,
|
||||
params: Params,
|
||||
|
||||
history_open: bool,
|
||||
|
||||
history: Vec<HistoryEntry>,
|
||||
open_history_entry: Option<HistoryEntry>,
|
||||
|
||||
tx: Sender<Response>,
|
||||
rx: Receiver<Response>,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
Self {
|
||||
toasts: Default::default(),
|
||||
output: "Awaiting generation...".to_string(),
|
||||
selected_ai: CONFIG.ai[0].clone(),
|
||||
params: Default::default(),
|
||||
history_open: false,
|
||||
history: vec![],
|
||||
open_history_entry: None,
|
||||
tx,
|
||||
rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Ok(recieved) = self.rx.try_recv() {
|
||||
if recieved.response_type == ResponseType::Success {
|
||||
self.output = recieved.output.trim().to_string();
|
||||
} else {
|
||||
self.output = "Failed to generate!".to_string();
|
||||
self.toasts
|
||||
.error("Failed to generate! Error printed to console.");
|
||||
|
||||
eprintln!("{}", recieved.output);
|
||||
}
|
||||
|
||||
match write_history(HistoryEntry {
|
||||
date: Local::now(),
|
||||
selected_ai: self.selected_ai.name.clone(),
|
||||
params: self.params.clone(),
|
||||
result: self.output.clone(),
|
||||
}) {
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
self.toasts
|
||||
.error(format!("Failed to write history! {}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
CentralPanel::default().show(ctx, |ui| {
|
||||
Window::new("History")
|
||||
.open(&mut self.history_open)
|
||||
.collapsible(false)
|
||||
.hscroll(true)
|
||||
.show(ctx, |ui| {
|
||||
if let Some(entry) = self.open_history_entry.clone() {
|
||||
ui.set_max_width(580.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// maybe make these param indicators look more like the main ui somehow
|
||||
ui.label(format!("{} - AI", entry.selected_ai));
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label(format!(
|
||||
"{} - Temperature",
|
||||
entry.params.temperature.to_string()
|
||||
));
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label(format!(
|
||||
"{} - Max Tokens",
|
||||
entry.params.max_tokens.to_string()
|
||||
));
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
|
||||
if ui.button("Back").clicked() {
|
||||
self.open_history_entry = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
|
||||
ScrollArea::vertical()
|
||||
.id_source("history_prompt_scroll")
|
||||
.show(ui, |ui| {
|
||||
ui.add(TextEdit::multiline(&mut entry.params.prompt.as_str()));
|
||||
});
|
||||
|
||||
ScrollArea::vertical()
|
||||
.id_source("history_output_scroll")
|
||||
.show(ui, |ui| {
|
||||
ui.add(TextEdit::multiline(&mut entry.result.as_str()));
|
||||
});
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
.width();
|
||||
} else {
|
||||
ScrollArea::vertical()
|
||||
.id_source("history_scroll")
|
||||
.show(ui, |ui| {
|
||||
Grid::new("history_grid").num_columns(1).show(
|
||||
ui,
|
||||
|ui| {
|
||||
for entry in &self.history {
|
||||
let response = ui
|
||||
.horizontal(|ui| {
|
||||
ui.label(&entry.selected_ai);
|
||||
ui.label(
|
||||
entry
|
||||
.date
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.response;
|
||||
|
||||
let clickable =
|
||||
ui.allocate_rect(response.rect, Sense::click());
|
||||
|
||||
if clickable.clicked() {
|
||||
self.open_history_entry = Some(entry.clone());
|
||||
}
|
||||
|
||||
ui.end_row()
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
ui.vertical(|ui| {
|
||||
if ui.button("Generate!").clicked() {
|
||||
self.output = "Generating...".to_string();
|
||||
|
||||
generate_with_mpsc(
|
||||
self.selected_ai.clone(),
|
||||
self.params.clone(),
|
||||
self.tx.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ComboBox::from_label("AI")
|
||||
.selected_text(&self.selected_ai.name)
|
||||
.show_ui(ui, |ui| {
|
||||
for ai in &CONFIG.ai {
|
||||
ui.selectable_value(&mut self.selected_ai, ai.clone(), &ai.name);
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.add(
|
||||
Slider::new(&mut self.params.temperature, 0.0..=2.0)
|
||||
.text("Temperature")
|
||||
.show_value(true),
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.add(DragValue::new(&mut self.params.max_tokens));
|
||||
ui.label("Max Tokens");
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
|
||||
ui.add_visible_ui(CONFIG.app.history_dir.is_some(), |ui| {
|
||||
if ui.button("History").clicked() {
|
||||
// add proper error handling
|
||||
self.history = read_all_history().unwrap();
|
||||
|
||||
self.history_open = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
|
||||
let size = ui.available_size();
|
||||
|
||||
ScrollArea::vertical()
|
||||
.id_source("prompt_scroll")
|
||||
.show(ui, |ui| {
|
||||
ui.add_sized(
|
||||
Vec2::new(size.x / 2.0, size.y),
|
||||
TextEdit::multiline(&mut self.params.prompt),
|
||||
);
|
||||
});
|
||||
|
||||
ScrollArea::vertical()
|
||||
.id_source("output_scroll")
|
||||
.show(ui, |ui| {
|
||||
ui.add_sized(
|
||||
Vec2::new(size.x / 2.0, size.y),
|
||||
TextEdit::multiline(&mut self.output.as_str()),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
self.toasts.show(ctx);
|
||||
ctx.request_repaint_after(Duration::from_secs(1))
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
use std::{fs, collections::HashMap};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use ron::Value;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||
let config = fs::read_to_string("./config.ron").expect("Failed to read config.ron");
|
||||
|
||||
ron::from_str(&config).expect("Failed to parse config")
|
||||
});
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
pub app: App,
|
||||
pub ai: Vec<AI>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct App {
|
||||
pub history_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, PartialEq, Debug)]
|
||||
pub struct AI {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub params: HashMap<String, ParamType>,
|
||||
pub retriever: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, PartialEq, Debug)]
|
||||
pub enum ParamType {
|
||||
String(String),
|
||||
Prompt(Option<String>),
|
||||
Temperature,
|
||||
MaxTokens
|
||||
}
|
||||
|
@ -0,0 +1,55 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
use crate::{ai::Params, config::CONFIG};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct HistoryEntry {
|
||||
pub date: DateTime<Local>,
|
||||
pub selected_ai: String,
|
||||
pub params: Params,
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
pub fn write_history(entry: HistoryEntry) -> Result<()> {
|
||||
// should be safe to unwrap as this function will never be called without it
|
||||
let history_dir = CONFIG.app.history_dir.as_ref().unwrap();
|
||||
|
||||
fs::create_dir_all(history_dir)?;
|
||||
|
||||
let json = serde_json::to_string(&entry)?;
|
||||
|
||||
fs::write(
|
||||
format!("{}/{}.json", history_dir, entry.date.timestamp()),
|
||||
json,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/*
|
||||
this should probably be async all things considered however losing out on proper error handling with ? is
|
||||
not worth it. so here's hoping that rust fs (and serde_json (meaning serde too)) is fast enough to read like 10000 files
|
||||
*/
|
||||
pub fn read_all_history() -> Result<Vec<HistoryEntry>> {
|
||||
// should be safe to unwrap as this function will never be called without it
|
||||
let history_dir = CONFIG.app.history_dir.as_ref().unwrap();
|
||||
let mut entries = vec![];
|
||||
|
||||
for dir_entry in fs::read_dir(history_dir)? {
|
||||
let dir_entry = dir_entry?;
|
||||
|
||||
if dir_entry.file_type()?.is_file() {
|
||||
let json = fs::read_to_string(dir_entry.path())?;
|
||||
let entry: HistoryEntry = serde_json::from_str(&json)?;
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b|b.date.cmp(&a.date));
|
||||
|
||||
Ok(entries)
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod ai;
|
||||
mod app;
|
||||
mod config;
|
||||
mod history;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::CONFIG;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Forces once_cell to init the config
|
||||
Lazy::force(&CONFIG);
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native("AI Gui", options, Box::new(|_| Box::new(App::default()))).unwrap();
|
||||
}
|
Reference in new issue