commit 8f91bf8af4ccf7fec227cf65013ade16f8fece5a Author: xiaji Date: Sat Apr 4 17:27:29 2026 +0800 feat: 将 Android flomo-ai 移植为 Rust Windows 桌面应用 - 使用 egui + eframe 构建跨平台 GUI - 实现 LLM API 调用(OpenAI 兼容格式) - 实现提示词管理(添加/删除/选择) - 实现快速操作按钮(错别字检查、总结、翻译、润色) - 实现明暗主题切换 - 实现 JSON 配置持久化 - 实现剪贴板复制功能 - MinGW 工具链编译,无控制台窗口 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..692f64c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc" +ar = "x86_64-w64-mingw32-gcc-ar" +rustflags = [ + "-C", "link-arg=-mwindows", + "-L", "native=C:\\msys64\\mingw64\\lib", +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2008d1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +*.pdb +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cae4b4e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "flomo-ai-desktop" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "flomo-ai" +path = "src/main.rs" + +[dependencies] +egui = "0.29" +eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +dirs = "5" +clipboard-win = "5" +arboard = "3" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/src/api/llm_client.rs b/src/api/llm_client.rs new file mode 100644 index 0000000..f85d922 --- /dev/null +++ b/src/api/llm_client.rs @@ -0,0 +1,93 @@ +use crate::config::AppSettings; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionRequest { + pub model: String, + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatChoice { + pub message: ChatMessage, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionResponse { + pub choices: Vec, +} + +pub fn call_llm(settings: &AppSettings, user_input: String, selected_prompt: Option) -> Result { + let mut messages = Vec::new(); + + if let Some(prompt) = selected_prompt { + if !prompt.is_empty() { + messages.push(ChatMessage { + role: "system".to_string(), + content: prompt, + }); + } + } + + messages.push(ChatMessage { + role: "user".to_string(), + content: user_input, + }); + + let request = ChatCompletionRequest { + model: settings.llm_config.model.clone(), + messages, + }; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let mut req_builder = client + .post(format!("{}/chat/completions", settings.llm_config.base_url)) + .header("Content-Type", "application/json"); + + if !settings.llm_config.api_key.is_empty() { + req_builder = req_builder.header("Authorization", format!("Bearer {}", settings.llm_config.api_key)); + } + + for header in &settings.header_configs { + if !header.key.is_empty() { + req_builder = req_builder.header(&header.key, &header.value); + } + } + + let body = serde_json::to_string(&request) + .map_err(|e| format!("Failed to serialize request: {}", e))?; + + let response = req_builder + .body(body) + .send() + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let error_body = response.text().unwrap_or_default(); + return Err(format!("API error {}: {}", status, error_body)); + } + + let response_text = response + .text() + .map_err(|e| format!("Failed to read response: {}", e))?; + + let completion: ChatCompletionResponse = serde_json::from_str(&response_text) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if completion.choices.is_empty() { + return Err("No response from API".to_string()); + } + + Ok(completion.choices[0].message.content.clone()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..f43995a --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod llm_client; +pub use llm_client::*; diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d10bd9d --- /dev/null +++ b/src/app.rs @@ -0,0 +1,465 @@ +use eframe::egui; +use crate::config::{AppSettings, load_settings, save_settings}; +use crate::theme::AppTheme; +use crate::pages::OutputStatus; + +enum Page { + Main, + Settings, +} + +pub struct FlomoAiApp { + settings: AppSettings, + input_text: String, + output_text: String, + status: OutputStatus, + selected_prompt_index: usize, + is_loading: bool, + char_count: usize, + pending_response: Option>>, + + settings_base_url: String, + settings_api_key: String, + settings_model: String, + settings_show_api_key: bool, + settings_selected_theme: crate::config::ThemeMode, + new_prompt_title: String, + new_prompt_content: String, + + current_page: Page, + theme_dirty: bool, +} + +impl FlomoAiApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + let settings = load_settings(); + let theme = AppTheme::from_mode(settings.theme_config.mode); + cc.egui_ctx.set_visuals(theme.visuals.clone()); + + Self { + settings: settings.clone(), + input_text: String::new(), + output_text: String::new(), + status: OutputStatus::Waiting, + selected_prompt_index: 0, + is_loading: false, + char_count: 0, + pending_response: None, + + settings_base_url: settings.llm_config.base_url, + settings_api_key: settings.llm_config.api_key, + settings_model: settings.llm_config.model, + settings_show_api_key: false, + settings_selected_theme: settings.theme_config.mode, + new_prompt_title: String::new(), + new_prompt_content: String::new(), + + current_page: Page::Main, + theme_dirty: false, + } + } + + fn poll_response(&mut self, ctx: &egui::Context) { + if let Some(handle) = self.pending_response.take() { + if handle.is_finished() { + match handle.join() { + Ok(Ok(text)) => { + self.output_text = text; + self.status = OutputStatus::Completed; + self.is_loading = false; + } + Ok(Err(e)) => { + self.output_text = format!("错误: {}", e); + self.status = OutputStatus::Error(e); + self.is_loading = false; + } + Err(_) => { + self.output_text = "线程错误".to_string(); + self.status = OutputStatus::Error("Thread panic".to_string()); + self.is_loading = false; + } + } + } else { + self.pending_response = Some(handle); + ctx.request_repaint(); + } + } + } + + fn send_request(&mut self, ctx: &egui::Context) { + if self.input_text.trim().is_empty() || self.is_loading { + return; + } + + self.status = OutputStatus::Connecting; + self.output_text = "正在生成...".to_string(); + self.is_loading = true; + + let prompt = if self.selected_prompt_index == 0 { + None + } else if self.selected_prompt_index <= self.settings.prompt_configs.len() { + Some(self.settings.prompt_configs[self.selected_prompt_index - 1].content.clone()) + } else { + None + }; + + let input = self.input_text.clone(); + let settings = self.settings.clone(); + let ctx_clone = ctx.clone(); + + let handle = std::thread::spawn(move || { + let result = crate::api::call_llm(&settings, input, prompt); + ctx_clone.request_repaint(); + result + }); + + self.pending_response = Some(handle); + } + + fn select_prompt_by_name(&mut self, name: &str, ctx: &egui::Context) { + let mut found = false; + for (i, prompt) in self.settings.prompt_configs.iter().enumerate() { + if prompt.title == name { + self.selected_prompt_index = i + 1; + found = true; + break; + } + } + + if !self.input_text.is_empty() && found { + self.send_request(ctx); + } + } + + fn copy_to_clipboard(&self) { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&self.output_text); + } + } + + fn save_settings(&mut self) { + self.settings.llm_config.base_url = self.settings_base_url.clone(); + self.settings.llm_config.api_key = self.settings_api_key.clone(); + self.settings.llm_config.model = self.settings_model.clone(); + self.settings.theme_config.mode = self.settings_selected_theme; + let _ = save_settings(&self.settings); + } +} + +impl eframe::App for FlomoAiApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_response(ctx); + + if self.theme_dirty { + let theme = AppTheme::from_mode(self.settings.theme_config.mode); + ctx.set_visuals(theme.visuals); + self.theme_dirty = false; + } + + match &self.current_page { + Page::Main => self.render_main(ctx), + Page::Settings => self.render_settings(ctx), + } + } +} + +impl FlomoAiApp { + fn render_main(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("AI优化").size(18.0).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("配置").clicked() { + self.settings_base_url = self.settings.llm_config.base_url.clone(); + self.settings_api_key = self.settings.llm_config.api_key.clone(); + self.settings_model = self.settings.llm_config.model.clone(); + self.settings_selected_theme = self.settings.theme_config.mode; + self.settings_show_api_key = false; + self.current_page = Page::Settings; + } + }); + }); + + ui.separator(); + + ui.label(egui::RichText::new("快速操作").size(11.0).color(egui::Color32::GRAY)); + ui.add_space(6.0); + + ui.horizontal(|ui| { + let buttons = vec![ + ("🔍", "检查错别字"), + ("📋", "总结"), + ("🌐", "翻译"), + ("✨", "润色"), + ]; + + for (emoji, name) in buttons { + let btn = egui::Button::new(egui::RichText::new(emoji).size(16.0)) + .fill(ui.style().visuals.widgets.inactive.bg_fill) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(210, 210, 220))) + .rounding(6.0) + .min_size(egui::vec2(36.0, 36.0)); + + if ui.add(btn).on_hover_text(name).clicked() { + self.select_prompt_by_name(name, ctx); + } + } + }); + + ui.add_space(14.0); + + ui.label(egui::RichText::new("提示词").size(11.0).color(egui::Color32::GRAY)); + ui.add_space(6.0); + + let selected_text = if self.selected_prompt_index == 0 { + "无系统提示词".to_string() + } else if self.selected_prompt_index <= self.settings.prompt_configs.len() { + self.settings.prompt_configs[self.selected_prompt_index - 1].title.clone() + } else { + "无系统提示词".to_string() + }; + + egui::ComboBox::from_id_salt("prompt_selector") + .selected_text(&selected_text) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.selected_prompt_index, 0, "无系统提示词"); + for (i, prompt) in self.settings.prompt_configs.iter().enumerate() { + ui.selectable_value(&mut self.selected_prompt_index, i + 1, &prompt.title); + } + }); + + ui.separator(); + + let prompt_content = if self.selected_prompt_index == 0 { + "无特殊指令".to_string() + } else if self.selected_prompt_index <= self.settings.prompt_configs.len() { + self.settings.prompt_configs[self.selected_prompt_index - 1].content.clone() + } else { + "无特殊指令".to_string() + }; + + ui.label(egui::RichText::new(&prompt_content).size(11.0).color(egui::Color32::GRAY)); + ui.add_space(14.0); + + ui.label(egui::RichText::new("输入").size(11.0).color(egui::Color32::GRAY)); + ui.add_space(4.0); + + egui::Frame::none() + .fill(ui.style().visuals.widgets.inactive.bg_fill) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(210, 210, 220))) + .rounding(6.0) + .inner_margin(egui::Margin::same(8.0)) + .show(ui, |ui: &mut egui::Ui| { + ui.add( + egui::TextEdit::multiline(&mut self.input_text) + .desired_width(f32::INFINITY) + .desired_rows(4) + .hint_text("输入待发送内容…"), + ); + }); + + self.char_count = self.input_text.chars().count(); + ui.label(format!("{}/4000", self.char_count)); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if self.is_loading { + if ui.button("停止生成").clicked() { + self.status = OutputStatus::Stopped; + self.is_loading = false; + self.pending_response = None; + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let send_btn = egui::Button::new(egui::RichText::new("➤").size(16.0)) + .fill(egui::Color32::from_rgb(100, 100, 255)) + .rounding(6.0) + .min_size(egui::vec2(42.0, 42.0)); + + if ui.add(send_btn).clicked() && !self.is_loading && !self.input_text.trim().is_empty() { + self.send_request(ctx); + } + }); + }); + + ui.add_space(14.0); + + ui.label(egui::RichText::new("优化结果").size(11.0).color(egui::Color32::GRAY)); + ui.add_space(6.0); + + let status_text = match &self.status { + OutputStatus::Waiting => "等待发送".to_string(), + OutputStatus::Connecting => "连接中…".to_string(), + OutputStatus::Completed => "已完成".to_string(), + OutputStatus::Error(_) => "发生错误".to_string(), + OutputStatus::Stopped => "已停止".to_string(), + }; + + let status_color = match &self.status { + OutputStatus::Waiting => egui::Color32::from_rgb(100, 100, 255), + OutputStatus::Connecting => egui::Color32::from_rgb(255, 165, 0), + OutputStatus::Completed => egui::Color32::from_rgb(0, 180, 0), + OutputStatus::Error(_) => egui::Color32::RED, + OutputStatus::Stopped => egui::Color32::GRAY, + }; + + ui.label(egui::RichText::new(&status_text).size(11.0).color(status_color)); + + egui::Frame::none() + .fill(ui.style().visuals.widgets.inactive.bg_fill) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(200, 200, 210))) + .inner_margin(egui::Margin::same(12.0)) + .rounding(6.0) + .show(ui, |ui: &mut egui::Ui| { + ui.add_sized([ui.available_width(), 2.0], egui::Separator::default()); + ui.add_space(8.0); + + if self.output_text.is_empty() { + ui.label(egui::RichText::new("发送消息后结果将在此显示").size(13.0).color(egui::Color32::GRAY)); + } else { + ui.add_sized( + [ui.available_width(), 150.0], + egui::TextEdit::multiline(&mut self.output_text.clone()) + .desired_width(f32::INFINITY) + .desired_rows(6), + ); + } + + ui.add_space(8.0); + + ui.horizontal(|ui: &mut egui::Ui| { + let copy_btn = egui::Button::new("复制结果") + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 100, 255))) + .rounding(4.0); + + if ui.add(copy_btn).clicked() && !self.output_text.is_empty() { + self.copy_to_clipboard(); + } + }); + }); + + ui.add_space(16.0); + }); + } + + fn render_settings(&mut self, ctx: &egui::Context) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("← 返回").clicked() { + self.save_settings(); + self.theme_dirty = true; + self.current_page = Page::Main; + } + ui.label(egui::RichText::new("配置").size(18.0).strong()); + }); + + ui.separator(); + + let mut changed = false; + + ui.label(egui::RichText::new("LLM 配置").size(14.0).strong()); + ui.add_space(8.0); + + ui.label("API Base URL"); + ui.text_edit_singleline(&mut self.settings_base_url); + ui.add_space(6.0); + + ui.label("API Key"); + ui.horizontal(|ui| { + if self.settings_show_api_key { + ui.text_edit_singleline(&mut self.settings_api_key); + } else { + let mut masked = "*".repeat(self.settings_api_key.len()); + ui.text_edit_singleline(&mut masked); + } + if ui.button(if self.settings_show_api_key { "隐藏" } else { "显示" }).clicked() { + self.settings_show_api_key = !self.settings_show_api_key; + } + }); + ui.add_space(6.0); + + ui.label("Model"); + ui.text_edit_singleline(&mut self.settings_model); + ui.add_space(12.0); + + ui.separator(); + + ui.label(egui::RichText::new("主题").size(14.0).strong()); + ui.add_space(8.0); + + ui.vertical(|ui| { + if ui.radio_value(&mut self.settings_selected_theme, crate::config::ThemeMode::Light, "浅色模式").clicked() { + changed = true; + } + if ui.radio_value(&mut self.settings_selected_theme, crate::config::ThemeMode::Dark, "深色模式").clicked() { + changed = true; + } + if ui.radio_value(&mut self.settings_selected_theme, crate::config::ThemeMode::FollowSystem, "跟随系统").clicked() { + changed = true; + } + }); + ui.add_space(12.0); + + ui.separator(); + + ui.label(egui::RichText::new("提示词管理").size(14.0).strong()); + ui.add_space(8.0); + + let prompt_count = self.settings.prompt_configs.len(); + for i in 0..prompt_count { + let title = self.settings.prompt_configs[i].title.clone(); + let content = self.settings.prompt_configs[i].content.clone(); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&title).size(13.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("删除").clicked() { + self.settings.prompt_configs.remove(i); + } + }); + }); + + ui.add_space(2.0); + ui.label(egui::RichText::new(&content).size(11.0).color(egui::Color32::GRAY)); + ui.add_space(8.0); + } + + if self.settings.prompt_configs.len() != prompt_count { + changed = true; + } + + ui.separator(); + ui.add_space(8.0); + + ui.label(egui::RichText::new("添加新提示词").size(12.0).strong()); + ui.add_space(6.0); + + ui.label("标题"); + ui.text_edit_singleline(&mut self.new_prompt_title); + ui.add_space(4.0); + + ui.label("内容"); + ui.text_edit_multiline(&mut self.new_prompt_content); + ui.add_space(6.0); + + if ui.button("添加").clicked() { + if !self.new_prompt_title.trim().is_empty() && !self.new_prompt_content.trim().is_empty() { + self.settings.prompt_configs.push(crate::config::PromptConfig { + id: format!("custom-{}", self.settings.prompt_configs.len()), + title: self.new_prompt_title.clone(), + content: self.new_prompt_content.clone(), + }); + self.new_prompt_title.clear(); + self.new_prompt_content.clear(); + changed = true; + } + } + + if changed { + self.save_settings(); + } + }); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..bbf4ded --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,2 @@ +pub mod store; +pub use store::*; diff --git a/src/config/store.rs b/src/config/store.rs new file mode 100644 index 0000000..7c82e97 --- /dev/null +++ b/src/config/store.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeaderConfig { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptConfig { + pub id: String, + pub title: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LLMConfig { + pub base_url: String, + pub api_key: String, + pub model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeConfig { + pub mode: ThemeMode, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum ThemeMode { + FollowSystem, + Light, + Dark, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + pub llm_config: LLMConfig, + pub header_configs: Vec, + pub prompt_configs: Vec, + pub theme_config: ThemeConfig, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + llm_config: LLMConfig { + base_url: "https://api.openai.com/v1".to_string(), + api_key: String::new(), + model: "gpt-4o".to_string(), + }, + header_configs: Vec::new(), + prompt_configs: vec![ + PromptConfig { + id: "default-1".to_string(), + title: "翻译助手".to_string(), + content: "将输入的文本翻译成指定语言".to_string(), + }, + PromptConfig { + id: "default-2".to_string(), + title: "代码解释".to_string(), + content: "解释代码的功能和逻辑".to_string(), + }, + PromptConfig { + id: "quick-1".to_string(), + title: "检查错别字".to_string(), + content: "请检查以下文本中的错别字并纠正:".to_string(), + }, + PromptConfig { + id: "quick-2".to_string(), + title: "总结".to_string(), + content: "请用简洁的语言总结以下文本的主要内容:".to_string(), + }, + PromptConfig { + id: "quick-3".to_string(), + title: "翻译".to_string(), + content: "请翻译以下文本:".to_string(), + }, + PromptConfig { + id: "quick-4".to_string(), + title: "润色".to_string(), + content: "请润色以下文本,使其更通顺流畅:".to_string(), + }, + ], + theme_config: ThemeConfig { + mode: ThemeMode::FollowSystem, + }, + } + } +} + +fn config_path() -> PathBuf { + let mut path = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("flomo-ai"); + path.push("settings.json"); + path +} + +pub fn load_settings() -> AppSettings { + let path = config_path(); + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(settings) => settings, + Err(_) => AppSettings::default(), + }, + Err(_) => AppSettings::default(), + } +} + +pub fn save_settings(settings: &AppSettings) -> Result<(), String> { + let path = config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {}", e))?; + } + let json = serde_json::to_string_pretty(settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + fs::write(&path, json).map_err(|e| format!("Failed to write settings: {}", e)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..90d581f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,25 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod config; +mod api; +mod theme; +mod pages; +mod app; + +use app::FlomoAiApp; + +fn main() -> eframe::Result { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([480.0, 720.0]) + .with_min_inner_size([360.0, 500.0]) + .with_title("AI优化"), + ..Default::default() + }; + + eframe::run_native( + "AI优化", + options, + Box::new(|cc| Ok(Box::new(FlomoAiApp::new(cc)))), + ) +} diff --git a/src/pages/main_page.rs b/src/pages/main_page.rs new file mode 100644 index 0000000..50833ae --- /dev/null +++ b/src/pages/main_page.rs @@ -0,0 +1,217 @@ +use egui::{Ui, Color32, RichText, TextEdit, Button, ScrollArea}; +use crate::config::AppSettings; +use crate::api::call_llm; + +pub enum OutputStatus { + Waiting, + Connecting, + Completed, + Error(String), + Stopped, +} + +pub struct MainPage { + pub input_text: String, + pub output_text: String, + pub status: OutputStatus, + pub selected_prompt_index: usize, + pub is_loading: bool, +} + +impl Default for MainPage { + fn default() -> Self { + Self { + input_text: String::new(), + output_text: String::new(), + status: OutputStatus::Waiting, + selected_prompt_index: 0, + is_loading: false, + } + } +} + +impl MainPage { + pub fn new() -> Self { + Self::default() + } + + pub fn ui(&mut self, ui: &mut Ui, settings: &AppSettings, ctx: &egui::Context) { + ScrollArea::vertical().show(ui, |ui| { + self.render_content(ui, settings, ctx); + }); + } + + fn render_content(&mut self, ui: &mut Ui, settings: &AppSettings, ctx: &egui::Context) { + ui.add_space(16.0); + + ui.label(RichText::new("快速操作").size(11.0).color(Color32::GRAY)); + ui.add_space(6.0); + + ui.horizontal(|ui| { + let buttons = vec![ + ("🔍", "检查错别字"), + ("📋", "总结"), + ("🌐", "翻译"), + ("✨", "润色"), + ]; + + for (emoji, name) in buttons { + let btn = Button::new(RichText::new(emoji).size(16.0)) + .fill(Color32::from_rgb(245, 245, 247)) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(210, 210, 220))) + .rounding(6.0) + .min_size(egui::vec2(36.0, 36.0)); + + if ui.add(btn).on_hover_text(name).clicked() { + self.select_prompt_by_name(name, settings, ctx); + } + } + }); + + ui.add_space(14.0); + + ui.label(RichText::new("提示词").size(11.0).color(Color32::GRAY)); + ui.add_space(6.0); + + let selected_text = if self.selected_prompt_index == 0 { + "无系统提示词".to_string() + } else if self.selected_prompt_index <= settings.prompt_configs.len() { + settings.prompt_configs[self.selected_prompt_index - 1].title.clone() + } else { + "无系统提示词".to_string() + }; + + egui::ComboBox::from_id_salt("prompt_selector") + .selected_text(&selected_text) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.selected_prompt_index, 0, "无系统提示词"); + for (i, prompt) in settings.prompt_configs.iter().enumerate() { + ui.selectable_value(&mut self.selected_prompt_index, i + 1, &prompt.title); + } + }); + + ui.separator(); + + let prompt_content = if self.selected_prompt_index == 0 { + "无特殊指令".to_string() + } else if self.selected_prompt_index <= settings.prompt_configs.len() { + settings.prompt_configs[self.selected_prompt_index - 1].content.clone() + } else { + "无特殊指令".to_string() + }; + + ui.label(RichText::new(&prompt_content).size(11.0).color(Color32::GRAY)); + + ui.add_space(14.0); + + ui.label(RichText::new("优化结果").size(11.0).color(Color32::GRAY)); + ui.add_space(6.0); + + let status_text = match &self.status { + OutputStatus::Waiting => "等待发送".to_string(), + OutputStatus::Connecting => "连接中…".to_string(), + OutputStatus::Completed => "已完成".to_string(), + OutputStatus::Error(_) => "发生错误".to_string(), + OutputStatus::Stopped => "已停止".to_string(), + }; + + let status_color = match &self.status { + OutputStatus::Waiting => Color32::from_rgb(100, 100, 255), + OutputStatus::Connecting => Color32::from_rgb(255, 165, 0), + OutputStatus::Completed => Color32::from_rgb(0, 180, 0), + OutputStatus::Error(_) => Color32::RED, + OutputStatus::Stopped => Color32::GRAY, + }; + + ui.label(RichText::new(&status_text).size(11.0).color(status_color)); + + let card_frame = egui::Frame::none() + .fill(ui.style().visuals.widgets.inactive.bg_fill) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(200, 200, 210))) + .inner_margin(egui::Margin::same(12.0)) + .rounding(6.0); + + card_frame.show(ui, |ui: &mut Ui| { + ui.add_sized([ui.available_width(), 2.0], egui::Separator::default()); + ui.add_space(8.0); + + if self.output_text.is_empty() { + ui.label(RichText::new("发送消息后结果将在此显示").size(13.0).color(Color32::GRAY)); + } else { + ui.add_sized( + [ui.available_width(), 150.0], + TextEdit::multiline(&mut self.output_text.clone()) + .desired_width(f32::INFINITY) + .desired_rows(6), + ); + } + + ui.add_space(8.0); + + ui.horizontal(|ui: &mut Ui| { + let copy_btn = Button::new("复制结果") + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(100, 100, 255))) + .rounding(4.0); + + if ui.add(copy_btn).clicked() && !self.output_text.is_empty() { + self.copy_to_clipboard(); + } + }); + }); + + ui.add_space(16.0); + } + + pub fn send_request(&mut self, settings: &AppSettings, ctx: egui::Context) { + if self.input_text.trim().is_empty() || self.is_loading { + return; + } + + self.status = OutputStatus::Connecting; + self.output_text = "正在生成...".to_string(); + self.is_loading = true; + + let prompt = if self.selected_prompt_index == 0 { + None + } else if self.selected_prompt_index <= settings.prompt_configs.len() { + Some(settings.prompt_configs[self.selected_prompt_index - 1].content.clone()) + } else { + None + }; + + let input = self.input_text.clone(); + let settings = settings.clone(); + + let handle = std::thread::spawn(move || { + let result = call_llm(&settings, input, prompt); + ctx.request_repaint(); + result + }); + + std::thread::spawn(move || { + let _ = handle.join(); + }); + } + + fn select_prompt_by_name(&mut self, name: &str, settings: &AppSettings, ctx: &egui::Context) { + let mut found = false; + for (i, prompt) in settings.prompt_configs.iter().enumerate() { + if prompt.title == name { + self.selected_prompt_index = i + 1; + found = true; + break; + } + } + + if !self.input_text.is_empty() && found { + self.send_request(settings, ctx.clone()); + } + } + + fn copy_to_clipboard(&self) { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&self.output_text); + } + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs new file mode 100644 index 0000000..d5af578 --- /dev/null +++ b/src/pages/mod.rs @@ -0,0 +1,4 @@ +pub mod main_page; +pub mod settings_page; + +pub use main_page::OutputStatus; diff --git a/src/pages/settings_page.rs b/src/pages/settings_page.rs new file mode 100644 index 0000000..3f73a09 --- /dev/null +++ b/src/pages/settings_page.rs @@ -0,0 +1,154 @@ +use egui::{Ui, Color32, RichText, ScrollArea}; +use crate::config::{AppSettings, LLMConfig, PromptConfig, ThemeMode}; + +pub struct SettingsPage { + pub base_url: String, + pub api_key: String, + pub model: String, + pub show_api_key: bool, + pub selected_theme: ThemeMode, + pub new_prompt_title: String, + pub new_prompt_content: String, +} + +impl Default for SettingsPage { + fn default() -> Self { + Self { + base_url: "https://api.openai.com/v1".to_string(), + api_key: String::new(), + model: "gpt-4o".to_string(), + show_api_key: false, + selected_theme: ThemeMode::FollowSystem, + new_prompt_title: String::new(), + new_prompt_content: String::new(), + } + } +} + +impl SettingsPage { + pub fn new(settings: &AppSettings) -> Self { + Self { + base_url: settings.llm_config.base_url.clone(), + api_key: settings.llm_config.api_key.clone(), + model: settings.llm_config.model.clone(), + show_api_key: false, + selected_theme: settings.theme_config.mode, + new_prompt_title: String::new(), + new_prompt_content: String::new(), + } + } + + pub fn ui(&mut self, ui: &mut Ui, settings: &mut AppSettings) -> bool { + let mut changed = false; + + ScrollArea::vertical().show(ui, |ui| { + ui.add_space(16.0); + + ui.label(RichText::new("LLM 配置").size(14.0).strong()); + ui.add_space(8.0); + + ui.label("API Base URL"); + ui.text_edit_singleline(&mut self.base_url); + ui.add_space(6.0); + + ui.label("API Key"); + ui.horizontal(|ui| { + if self.show_api_key { + ui.text_edit_singleline(&mut self.api_key); + } else { + let mut masked = "*".repeat(self.api_key.len()); + ui.text_edit_singleline(&mut masked); + } + if ui.button(if self.show_api_key { "隐藏" } else { "显示" }).clicked() { + self.show_api_key = !self.show_api_key; + } + }); + ui.add_space(6.0); + + ui.label("Model"); + ui.text_edit_singleline(&mut self.model); + ui.add_space(12.0); + + ui.separator(); + + ui.label(RichText::new("主题").size(14.0).strong()); + ui.add_space(8.0); + + ui.vertical(|ui| { + let resp = ui.radio_value(&mut self.selected_theme, ThemeMode::Light, "浅色模式"); + if resp.clicked() && resp.changed() { changed = true; } + let resp = ui.radio_value(&mut self.selected_theme, ThemeMode::Dark, "深色模式"); + if resp.clicked() && resp.changed() { changed = true; } + let resp = ui.radio_value(&mut self.selected_theme, ThemeMode::FollowSystem, "跟随系统"); + if resp.clicked() && resp.changed() { changed = true; } + }); + ui.add_space(12.0); + + ui.separator(); + + ui.label(RichText::new("提示词管理").size(14.0).strong()); + ui.add_space(8.0); + + let prompt_count = settings.prompt_configs.len(); + for i in 0..prompt_count { + let title = settings.prompt_configs[i].title.clone(); + let content = settings.prompt_configs[i].content.clone(); + + ui.horizontal(|ui| { + ui.label(RichText::new(&title).size(13.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("删除").clicked() { + settings.prompt_configs.remove(i); + } + }); + }); + + ui.add_space(2.0); + ui.label(RichText::new(&content).size(11.0).color(Color32::GRAY)); + ui.add_space(8.0); + } + + if settings.prompt_configs.len() != prompt_count { + changed = true; + } + + ui.separator(); + ui.add_space(8.0); + + ui.label(RichText::new("添加新提示词").size(12.0).strong()); + ui.add_space(6.0); + + ui.label("标题"); + ui.text_edit_singleline(&mut self.new_prompt_title); + ui.add_space(4.0); + + ui.label("内容"); + ui.text_edit_multiline(&mut self.new_prompt_content); + ui.add_space(6.0); + + if ui.button("添加").clicked() { + if !self.new_prompt_title.trim().is_empty() && !self.new_prompt_content.trim().is_empty() { + settings.prompt_configs.push(PromptConfig { + id: format!("custom-{}", settings.prompt_configs.len()), + title: self.new_prompt_title.clone(), + content: self.new_prompt_content.clone(), + }); + self.new_prompt_title.clear(); + self.new_prompt_content.clear(); + changed = true; + } + } + }); + + changed + } + + pub fn apply_to_settings(&self, settings: &mut AppSettings) { + settings.llm_config = LLMConfig { + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + model: self.model.clone(), + }; + settings.theme_config.mode = self.selected_theme; + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs new file mode 100644 index 0000000..f9fd1b1 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,42 @@ +use crate::config::ThemeMode; +use egui::{Visuals, Color32}; + +pub struct AppTheme { + pub visuals: Visuals, +} + +impl AppTheme { + pub fn light() -> Self { + let mut visuals = Visuals::light(); + visuals.override_text_color = Some(Color32::from_rgb(30, 30, 30)); + visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(245, 245, 247); + visuals.widgets.noninteractive.fg_stroke.color = Color32::from_rgb(100, 100, 110); + visuals.widgets.inactive.bg_fill = Color32::from_rgb(255, 255, 255); + visuals.widgets.inactive.weak_bg_fill = Color32::from_rgb(255, 255, 255); + visuals.widgets.active.bg_fill = Color32::from_rgb(100, 100, 255); + visuals.selection.bg_fill = Color32::from_rgba_premultiplied(100, 100, 255, 50); + visuals.hyperlink_color = Color32::from_rgb(100, 100, 255); + Self { visuals } + } + + pub fn dark() -> Self { + let mut visuals = Visuals::dark(); + visuals.override_text_color = Some(Color32::from_rgb(230, 230, 235)); + visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(28, 28, 30); + visuals.widgets.noninteractive.fg_stroke.color = Color32::from_rgb(140, 140, 150); + visuals.widgets.inactive.bg_fill = Color32::from_rgb(44, 44, 46); + visuals.widgets.inactive.weak_bg_fill = Color32::from_rgb(44, 44, 46); + visuals.widgets.active.bg_fill = Color32::from_rgb(120, 120, 255); + visuals.selection.bg_fill = Color32::from_rgba_premultiplied(120, 120, 255, 60); + visuals.hyperlink_color = Color32::from_rgb(120, 120, 255); + Self { visuals } + } + + pub fn from_mode(mode: ThemeMode) -> Self { + match mode { + ThemeMode::Light => Self::light(), + ThemeMode::Dark => Self::dark(), + ThemeMode::FollowSystem => Self::light(), + } + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..67208ba --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,6 @@ +pub mod quick_buttons; +pub mod prompt_selector; +pub mod result_card; +pub use quick_buttons::*; +pub use prompt_selector::*; +pub use result_card::*; diff --git a/src/widgets/prompt_selector.rs b/src/widgets/prompt_selector.rs new file mode 100644 index 0000000..2e1bbb6 --- /dev/null +++ b/src/widgets/prompt_selector.rs @@ -0,0 +1,49 @@ +use egui::{Ui, Color32, RichText, ComboBox}; +use crate::config::PromptConfig; + +pub fn render_prompt_selector( + ui: &mut Ui, + prompts: &[PromptConfig], + selected_index: &mut usize, +) -> Option { + let mut selected_content = None; + + ui.label(RichText::new("提示词").size(11.0).color(Color32::GRAY)); + + ComboBox::from_id_source("prompt_selector") + .selected_text( + if *selected_index < prompts.len() { + prompts[*selected_index].title.clone() + } else { + "无系统提示词".to_string() + } + ) + .show_ui(ui, |ui| { + ui.selectable_value(selected_index, 0, "无系统提示词"); + for (i, prompt) in prompts.iter().enumerate() { + ui.selectable_value(selected_index, i + 1, &prompt.title); + } + }); + + ui.separator(); + + let content = if *selected_index == 0 { + "无特殊指令" + } else if *selected_index <= prompts.len() { + &prompts[*selected_index - 1].content + } else { + "无特殊指令" + }; + + ui.label(RichText::new(content).size(11.0).color(Color32::GRAY).max_width(f32::INFINITY)); + + selected_content = if *selected_index == 0 { + None + } else if *selected_index <= prompts.len() { + Some(prompts[*selected_index - 1].content.clone()) + } else { + None + }; + + selected_content +} diff --git a/src/widgets/quick_buttons.rs b/src/widgets/quick_buttons.rs new file mode 100644 index 0000000..2267494 --- /dev/null +++ b/src/widgets/quick_buttons.rs @@ -0,0 +1,32 @@ +use egui::{Ui, Color32, RichText, Response}; + +pub fn quick_button(ui: &mut Ui, emoji: &str, tooltip: &str) -> Response { + let button_size = egui::vec2(36.0, 36.0); + ui.allocate_response(button_size, egui::Sense::click()) + .on_hover_text(tooltip) +} + +pub fn render_quick_buttons(ui: &mut Ui, on_click: impl Fn(&str)) { + ui.horizontal(|ui| { + ui.label(RichText::new("快速操作").size(11.0).color(Color32::GRAY)); + }); + + ui.horizontal(|ui| { + let buttons = vec![ + ("🔍", "检查错别字"), + ("📋", "总结"), + ("🌐", "翻译"), + ("✨", "润色"), + ]; + + for (emoji, name) in buttons { + let response = ui.add_sized([36.0, 36.0], egui::Button::new(RichText::new(emoji).size(16.0)) + .fill(ui.style().visuals.widgets.inactive.bg_fill) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(200, 200, 210))) + ); + if response.clicked() { + on_click(name); + } + } + }); +} diff --git a/src/widgets/result_card.rs b/src/widgets/result_card.rs new file mode 100644 index 0000000..d359a72 --- /dev/null +++ b/src/widgets/result_card.rs @@ -0,0 +1,44 @@ +use egui::{Ui, Color32, RichText}; + +pub fn render_result_card( + ui: &mut Ui, + status: &str, + output: &str, + is_loading: bool, +) { + let frame = egui::Frame::new() + .fill(ui.style().visuals.widgets.inactive.bg_fill) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(200, 200, 210))) + .inner_margin(12.0) + .rounding(6.0); + + frame.show(ui, |ui| { + ui.add_sized([ui.available_width(), 2.0], egui::Separator::default()); + + let status_color = if is_loading { + Color32::from_rgb(255, 165, 0) + } else if status == "已完成" { + Color32::from_rgb(0, 180, 0) + } else if status == "发生错误" { + Color32::RED + } else { + Color32::from_rgb(100, 100, 255) + }; + + ui.label(RichText::new(status).size(11.0).color(status_color)); + + ui.add_space(6.0); + + if output.is_empty() { + ui.label(RichText::new("发送消息后结果将在此显示").size(13.0).color(Color32::GRAY)); + } else { + ui.add_sized( + [ui.available_width(), 150.0], + egui::TextEdit::multiline(&mut output.to_string()) + .desired_width(f32::INFINITY) + .desired_rows(6) + .text_style(egui::TextStyle::Body), + ); + } + }); +}