commit 39f5468436f8210e650e278f2d1adaec33e4eec2 Author: yhqe Date: Thu Jun 4 19:36:27 2026 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..698eac9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +*.exe +*.d +*.pdb +*.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f2ccdb1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "actual" +version = "0.1.0" +edition = "2021" + +[dependencies] +eframe = "0.29" +egui = "0.29" +tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] } +reqwest = { version = "0.12", features = ["json", "stream"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +futures-util = "0.3" +anyhow = "1.0" +egui_commonmark = "0.18" +image = "0.25" \ No newline at end of file diff --git a/assets/creep2.ttf b/assets/creep2.ttf new file mode 100644 index 0000000..ec3d0f0 Binary files /dev/null and b/assets/creep2.ttf differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..f792472 Binary files /dev/null and b/assets/icon.png differ diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d20c390 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,660 @@ +use anyhow::{anyhow, Result}; +use eframe::egui; +use egui::{FontData, FontDefinitions, FontFamily}; +use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; +use futures_util::StreamExt; +use image::GenericImageView; +use serde::{Deserialize, Serialize}; +use std::{ + sync::{Arc, Mutex}, + thread, +}; + +fn load_icon() -> egui::IconData { + let bytes = include_bytes!("../assets/icon.png"); + let img = image::load_from_memory(bytes).expect("icon.png must be a valid image"); + let img = img.to_rgba8(); + let (w, h) = img.dimensions(); + egui::IconData { + rgba: img.into_raw(), + width: w, + height: h, + } +} + +fn main() -> eframe::Result<()> { + let icon = load_icon(); + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_icon(icon), + ..Default::default() + }; + eframe::run_native( + "Actual Computer", + native_options, + Box::new(|_cc| Ok(Box::new(ChatApp::default()))), + ) +} + +#[derive(Debug)] +struct ChatApp { + // Connection config + base_url: String, // e.g. http://127.0.0.1:1234/v1 + api_key: String, // if your server ignores it, can be empty + model: String, // e.g. mdl_2eb3129c7de908b3 + + // UI state + user_input: String, + messages: Vec<(Role, String)>, // (role, content) + status: String, + + // Streaming output (written by worker thread) + streaming_text: Arc>, + is_streaming: bool, + + // theme stuff + styled: bool, + + // markdown + md_cache: CommonMarkCache, + + // fuck you + stream_done_rx: Option>>, +} + +#[derive(Debug, Clone, Copy)] +enum Role { + User, + Assistant, +} + +impl Role { + fn as_str(&self) -> &'static str { + match self { + Role::User => "user", + Role::Assistant => "assistant", + } + } +} + +impl Default for Role { + fn default() -> Self { + Role::User + } +} + +impl Default for ChatApp { + fn default() -> Self { + Self { + base_url: "http://127.0.0.1:8080/v1".to_string(), + api_key: "".to_string(), + model: "mdl_2eb3129c7de908b3".to_string(), + user_input: "".to_string(), + messages: vec![], + status: "Idle".to_string(), + streaming_text: Arc::new(Mutex::new(String::new())), + is_streaming: false, + styled: false, + md_cache: CommonMarkCache::default(), + stream_done_rx: None, + } + } +} + +impl eframe::App for ChatApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if !self.styled { + ChatApp::apply_terminal_glass_style(ctx); + self.styled = true; + } + + if let Some(rx) = &self.stream_done_rx { + if let Ok(_res) = rx.try_recv() { + // stream is finished -> commit assistant message and re-enable sending + self.finish_streaming(); // your existing method + self.stream_done_rx = None; + } + } + + // keep repainting while streaming so text updates + if self.is_streaming { + ctx.request_repaint(); + } + + /*egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("Actual Computer"); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Base URL:"); + ui.text_edit_singleline(&mut self.base_url); + }); + ui.horizontal(|ui| { + ui.label("Model:"); + ui.text_edit_singleline(&mut self.model); + }); + ui.horizontal(|ui| { + ui.label("API key:"); + ui.add(egui::TextEdit::singleline(&mut self.api_key).password(true)); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Chat history + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .max_height(320.0) + .show(ui, |ui| { + for (role, content) in &self.messages { + let prefix = match role { + Role::User => "You", + Role::Assistant => "Assistant", + }; + ui.label(format!("{prefix}: {content}")); + ui.add_space(4.0); + } + + // Streaming assistant text (in-progress) + let cur = self.streaming_text.lock().unwrap().clone(); + if !cur.is_empty() { + ui.label(format!("Assistant (streaming): {cur}")); + } + }); + + ui.add_space(8.0); + ui.separator(); + + // Input area + ui.label("Message:"); + let input = ui.add( + egui::TextEdit::multiline(&mut self.user_input) + .desired_rows(3) + .hint_text("Type a message and press Send…"), + ); + + ui.horizontal(|ui| { + let send_clicked = ui + .add_enabled(!self.is_streaming, egui::Button::new("Send")) + .clicked(); + + let enter_pressed = input.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && !ui.input(|i| i.modifiers.shift); + + if (send_clicked || enter_pressed) && !self.user_input.trim().is_empty() { + let user_text = self.user_input.trim().to_string(); + self.user_input.clear(); + + // append user message to history + self.messages.push((Role::User, user_text.clone())); + + // clear streaming buffer + *self.streaming_text.lock().unwrap() = String::new(); + + // start background streaming request + self.status = "Streaming…".to_string(); + self.is_streaming = true; + + let base_url = self.base_url.clone(); + let api_key = self.api_key.clone(); + let model = self.model.clone(); + let history = self + .messages + .iter() + .map(|(r, c)| ChatMessage { + role: r.as_str().to_string(), + content: c.clone(), + }) + .collect::>(); + + let streaming_text = self.streaming_text.clone(); + let streaming_text_clone = Arc::clone(&streaming_text); + let done_flag = Arc::new(Mutex::new(false)); + let done_flag2 = done_flag.clone(); + + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let res = rt.block_on(stream_chat_openai_compat( + &base_url, + &api_key, + &model, + history, + streaming_text_clone, + )); + + // mark done; we can't mutate UI state directly from this thread, + // but we'll encode done-ness in the buffer as a sentinel line on error. + if let Err(e) = res { + let mut buf = streaming_text.lock().unwrap(); + buf.push_str(&format!("\n\n[error: {e}]")); + } + + *done_flag2.lock().unwrap() = true; + }); + + // We can't join the thread; instead we will check for completion by heuristic: + // when the stream ends it stops appending; we’ll also toggle off when user clicks "Stop". + // For simplicity, we implement a "Finish" check below using a small delay heuristic. + self.status = "Streaming… (close window to quit)".to_string(); + + // store done flag in status? (kept minimal; see note below) + // In a more complete app, you'd use channels to signal completion. + std::mem::drop(done_flag); + } + + if ui + .add_enabled(self.is_streaming, egui::Button::new("Stop (local)")) + .clicked() + { + // This doesn't cancel the HTTP request (minimal app). + // It only stops repainting and commits what we have. + self.finish_streaming(); + } + }); + + ui.add_space(4.0); + ui.label(format!("Status: {}", self.status)); + + // If we are streaming but the server already ended (heuristic): + // if buffer ends with "[DONE]" we finish automatically. + if self.is_streaming { + let cur = self.streaming_text.lock().unwrap().clone(); + if cur.contains("\n[DONE]") || cur.ends_with("[DONE]") { + self.finish_streaming(); + } + } + });*/ + + egui::TopBottomPanel::top("top").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Actual Computer") + .monospace() + .color(egui::Color32::from_rgb(40, 200, 160)), + ); + ui.separator(); + ui.label( + egui::RichText::new(&self.status) + .monospace() + .color(egui::Color32::from_gray(180)), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Clear").clicked() && !self.is_streaming { + self.messages.clear(); + *self.streaming_text.lock().unwrap() = String::new(); + } + }); + }); + + // Connection settings stay minimal but hidden + egui::CollapsingHeader::new("Connection") + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Base URL"); + ui.text_edit_singleline(&mut self.base_url); + }); + ui.horizontal(|ui| { + ui.label("Model"); + ui.text_edit_singleline(&mut self.model); + }); + ui.horizontal(|ui| { + ui.label("API Key"); + ui.add(egui::TextEdit::singleline(&mut self.api_key).password(true)); + }); + }); + }); + + egui::TopBottomPanel::bottom("composer").show(ctx, |ui| { + ui.horizontal(|ui| { + let send = ui + .add_enabled(!self.is_streaming, egui::Button::new("SEND")) + .clicked(); + + let edit = ui.add( + egui::TextEdit::singleline(&mut self.user_input) + .hint_text("Type here… (enter to send)"), + ); + + let enter = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if (send || enter) && !self.user_input.trim().is_empty() && !self.is_streaming { + let user_text = self.user_input.trim().to_string(); + self.user_input.clear(); + + // append user message to history + self.messages.push((Role::User, user_text.clone())); + + // clear streaming buffer + *self.streaming_text.lock().unwrap() = String::new(); + + // start background streaming request + self.status = "Streaming…".to_string(); + self.is_streaming = true; + + let base_url = self.base_url.clone(); + let api_key = self.api_key.clone(); + let model = self.model.clone(); + let history = self + .messages + .iter() + .map(|(r, c)| ChatMessage { + role: r.as_str().to_string(), + content: c.clone(), + }) + .collect::>(); + + let streaming_text = self.streaming_text.clone(); + let streaming_text_clone = Arc::clone(&streaming_text); + let done_flag = Arc::new(Mutex::new(false)); + let done_flag2 = done_flag.clone(); + + let (done_tx, done_rx) = std::sync::mpsc::channel(); + self.stream_done_rx = Some(done_rx); + + thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let res = rt.block_on(stream_chat_openai_compat( + &base_url, + &api_key, + &model, + history, + streaming_text_clone, + )); + + // still append error text if you want + if let Err(e) = &res { + let mut buf = streaming_text.lock().unwrap(); + buf.push_str(&format!("\n\n[error: {e}]")); + } + + // IMPORTANT: notify UI we're done (success or error) + let _ = done_tx.send(res.map(|_| ())); + }); + + // We can't join the thread; instead we will check for completion by heuristic: + // when the stream ends it stops appending; we’ll also toggle off when user clicks "Stop". + // For simplicity, we implement a "Finish" check below using a small delay heuristic. + self.status = "Streaming… (close window to quit)".to_string(); + + // store done flag in status? (kept minimal; see note below) + // In a more complete app, you'd use channels to signal completion. + std::mem::drop(done_flag); + } + + if self.is_streaming { + ui.add(egui::Spinner::new()); + } + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::vertical() + .stick_to_bottom(true) + .show(ui, |ui| { + for (i, (role, content)) in self.messages.iter().enumerate() { + message_panel(ui, &mut self.md_cache, *role, content, ("msg", i)); + } + + // streaming message + let cur = self.streaming_text.lock().unwrap().clone(); + if !cur.is_empty() { + message_panel(ui, &mut self.md_cache, Role::Assistant, &cur, "streaming"); + } + }); + }); + } +} + +impl ChatApp { + fn finish_streaming(&mut self) { + let text = self.streaming_text.lock().unwrap().clone(); + let cleaned = text.replace("\n[Done]", "").trim().to_string(); + if !cleaned.is_empty() { + self.messages.push((Role::Assistant, cleaned)); + } + *self.streaming_text.lock().unwrap() = String::new(); + self.is_streaming = false; + self.status = "Idle".to_string(); + } + + fn apply_terminal_glass_style(ctx: &egui::Context) { + // Dark visuals tuned for a "terminal glass" look + let mut v = egui::Visuals::dark(); + v.override_text_color = Some(egui::Color32::from_gray(220)); + v.panel_fill = egui::Color32::from_rgb(10, 12, 16); + v.window_fill = egui::Color32::from_rgb(10, 12, 16); + v.faint_bg_color = egui::Color32::from_rgb(14, 16, 22); + + // Accent (borders, selection, etc.) + v.selection.bg_fill = egui::Color32::from_rgb(40, 200, 160); + v.selection.stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(40, 200, 160)); + v.widgets.active.bg_fill = egui::Color32::from_rgb(14, 18, 26); + v.widgets.hovered.bg_fill = egui::Color32::from_rgb(16, 22, 32); + v.widgets.inactive.bg_fill = egui::Color32::from_rgb(12, 14, 20); + + // Thin, crisp strokes + v.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(220)); + v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(220)); + v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(40, 200, 160)); + v.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(40, 200, 160)); + + // Use square-ish corners for a "minimal tool" vibe + v.window_rounding = egui::Rounding::same(6.0); + v.menu_rounding = egui::Rounding::same(6.0); + v.widgets.noninteractive.rounding = egui::Rounding::same(6.0); + v.widgets.inactive.rounding = egui::Rounding::same(6.0); + v.widgets.hovered.rounding = egui::Rounding::same(6.0); + v.widgets.active.rounding = egui::Rounding::same(6.0); + + ctx.set_visuals(v); + + // Fonts: monospace-first. If you want a true bitmap look, add a bitmap TTF to assets. + // Example choices: "PxPlus IBM VGA8", "Cozette", "Terminus", etc. (many are TTF/OTF variants). + let mut fonts = FontDefinitions::default(); + + // OPTIONAL: include a custom font file. + // Put a TTF at: assets/bitmap.ttf and uncomment below. + // + // fonts.font_data.insert( + // "bitmap".to_string(), + // FontData::from_static(include_bytes!("../assets/bitmap.ttf")), + // ); + // + // fonts + // .families + // .get_mut(&FontFamily::Monospace) + // .unwrap() + // .insert(0, "bitmap".to_string()); + // + // fonts + // .families + // .get_mut(&FontFamily::Proportional) + // .unwrap() + // .insert(0, "bitmap".to_string()); + + fonts.font_data.insert( + "bitmap".to_string(), + FontData::from_static(include_bytes!("../assets/creep2.ttf")), + ); + + // Even without a custom font, force monospace everywhere: + // fonts.families.insert(FontFamily::Proportional, vec!["monospace".to_string()]); + // fonts.families.insert(FontFamily::Monospace, vec!["monospace".to_string()]); + + ctx.set_fonts(fonts); + + // Slightly larger base size for readability + ctx.set_style({ + let mut s = (*ctx.style()).clone(); + s.text_styles + .insert(egui::TextStyle::Body, egui::FontId::monospace(15.0)); + s.text_styles + .insert(egui::TextStyle::Button, egui::FontId::monospace(15.0)); + s.text_styles + .insert(egui::TextStyle::Small, egui::FontId::monospace(13.0)); + s + }); + } +} + +fn message_panel( + ui: &mut egui::Ui, + cache: &mut egui_commonmark::CommonMarkCache, + role: Role, + text: &str, + unique_id: impl std::hash::Hash, +) { + let accent = egui::Color32::from_rgb(40, 200, 160); + let border = egui::Stroke::new(1.0, accent); + let fill = match role { + Role::User => egui::Color32::from_rgb(12, 18, 22), + Role::Assistant => egui::Color32::from_rgb(12, 12, 18), + }; + + egui::Frame::none() + .fill(fill) + .stroke(border) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .rounding(egui::Rounding::same(6.0)) + .show(ui, |ui| { + let header = match role { + Role::User => "YOU", + Role::Assistant => "AI", + }; + ui.label( + egui::RichText::new(header) + .color(accent) + .monospace() + .size(12.0), + ); + ui.add_space(6.0); + + // IMPORTANT: give each markdown render a stable unique id + ui.push_id(unique_id, |ui| { + let viewer = egui_commonmark::CommonMarkViewer::new(); + viewer.show(ui, cache, text); + }); + }); + + ui.add_space(8.0); +} + +#[derive(Serialize, Clone)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + stream: bool, + + // Optional OpenAI-style params (keep minimal but matches your example shape) + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + top_p: Option, +} + +#[derive(Deserialize, Debug)] +struct StreamChunk { + choices: Vec, +} + +#[derive(Deserialize, Debug)] +struct Choice { + delta: Delta, + #[allow(dead_code)] + finish_reason: Option, + #[allow(dead_code)] + index: usize, +} + +#[derive(Deserialize, Debug)] +struct Delta { + #[serde(default)] + content: Option, + #[serde(default)] + role: Option, +} + +// Streams an OpenAI-compatible chat.completions SSE response. +// Expects lines like: `data: {...json...}` and `data: [DONE]`. +async fn stream_chat_openai_compat( + base_url: &str, + api_key: &str, + model: &str, + messages: Vec, + streaming_text: Arc>, +) -> Result<()> { + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + + let req_body = ChatRequest { + model: model.to_string(), + messages, + stream: true, + temperature: Some(0.7), + top_p: Some(0.95), + }; + + let client = reqwest::Client::new(); + let mut req = client.post(url).json(&req_body); + + // OpenAI-compatible auth header (safe even if ignored by your server) + if !api_key.trim().is_empty() { + req = req.bearer_auth(api_key.trim()); + } + + let resp = req.send().await?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("HTTP {status}: {text}")); + } + + let mut stream = resp.bytes_stream(); + + // We'll parse by lines; simplest: accumulate into a String and split on '\n' + let mut buffer = String::new(); + + while let Some(item) = stream.next().await { + let bytes = item?; + let chunk_str = String::from_utf8_lossy(&bytes); + buffer.push_str(&chunk_str); + + while let Some(pos) = buffer.find('\n') { + let mut line = buffer[..pos].to_string(); + buffer = buffer[pos + 1..].to_string(); + + line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + if let Some(rest) = line.strip_prefix("data:") { + let data = rest.trim(); + + if data == "[DONE]" { + let mut out = streaming_text.lock().unwrap(); + out.push_str("\n"); + return Ok(()); + } + + // Parse JSON chunk + let parsed: StreamChunk = serde_json::from_str(data)?; + if let Some(choice) = parsed.choices.get(0) { + if let Some(token) = &choice.delta.content { + let mut out = streaming_text.lock().unwrap(); + out.push_str(token); + } + } + } + } + } + + Ok(()) +}