From 39f5468436f8210e650e278f2d1adaec33e4eec2 Mon Sep 17 00:00:00 2001 From: yhqe Date: Thu, 4 Jun 2026 19:36:27 -0500 Subject: [PATCH] first commit --- .gitignore | 5 + Cargo.toml | 16 ++ assets/creep2.ttf | Bin 0 -> 13128 bytes assets/icon.png | Bin 0 -> 17924 bytes src/main.rs | 660 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 681 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 assets/creep2.ttf create mode 100644 assets/icon.png create mode 100644 src/main.rs 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 0000000000000000000000000000000000000000..ec3d0f0e4ad6aa7c2fc2ad28b00adc4c215b7194 GIT binary patch literal 13128 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 Qx(nphOI6u&Y16R(1LLJ%g8%>k literal 0 HcmV?d00001 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(()) +}