commit b4908fcc2d96c13b3409c373641d1369e5f6a64b Author: xiaji Date: Sat Apr 4 17:32:49 2026 +0800 feat: 包含 Kotlin 原始代码和 Rust Windows 桌面版 - flomo-ai/: Android Kotlin 原始项目 - flomo-ai-desktop/: Rust + egui Windows 桌面移植版 - LLM API 调用、提示词管理、主题切换、配置持久化 - MinGW 工具链编译,无控制台窗口 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74b04a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +flomo-ai/.gradle/ +flomo-ai/.idea/ +flomo-ai/build/ +flomo-ai/app/build/ +flomo-ai-desktop/target/ +*.pdb +flomo-ai-desktop/Cargo.lock +flomo-ai-desktop/.git/ diff --git a/flomo-ai-desktop/.cargo/config.toml b/flomo-ai-desktop/.cargo/config.toml new file mode 100644 index 0000000..692f64c --- /dev/null +++ b/flomo-ai-desktop/.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/flomo-ai-desktop/.gitignore b/flomo-ai-desktop/.gitignore new file mode 100644 index 0000000..2008d1d --- /dev/null +++ b/flomo-ai-desktop/.gitignore @@ -0,0 +1,3 @@ +/target/ +*.pdb +Cargo.lock diff --git a/flomo-ai-desktop/Cargo.toml b/flomo-ai-desktop/Cargo.toml new file mode 100644 index 0000000..cae4b4e --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/api/llm_client.rs b/flomo-ai-desktop/src/api/llm_client.rs new file mode 100644 index 0000000..f85d922 --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/api/mod.rs b/flomo-ai-desktop/src/api/mod.rs new file mode 100644 index 0000000..f43995a --- /dev/null +++ b/flomo-ai-desktop/src/api/mod.rs @@ -0,0 +1,2 @@ +pub mod llm_client; +pub use llm_client::*; diff --git a/flomo-ai-desktop/src/app.rs b/flomo-ai-desktop/src/app.rs new file mode 100644 index 0000000..d10bd9d --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/config/mod.rs b/flomo-ai-desktop/src/config/mod.rs new file mode 100644 index 0000000..bbf4ded --- /dev/null +++ b/flomo-ai-desktop/src/config/mod.rs @@ -0,0 +1,2 @@ +pub mod store; +pub use store::*; diff --git a/flomo-ai-desktop/src/config/store.rs b/flomo-ai-desktop/src/config/store.rs new file mode 100644 index 0000000..7c82e97 --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/main.rs b/flomo-ai-desktop/src/main.rs new file mode 100644 index 0000000..90d581f --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/pages/main_page.rs b/flomo-ai-desktop/src/pages/main_page.rs new file mode 100644 index 0000000..50833ae --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/pages/mod.rs b/flomo-ai-desktop/src/pages/mod.rs new file mode 100644 index 0000000..d5af578 --- /dev/null +++ b/flomo-ai-desktop/src/pages/mod.rs @@ -0,0 +1,4 @@ +pub mod main_page; +pub mod settings_page; + +pub use main_page::OutputStatus; diff --git a/flomo-ai-desktop/src/pages/settings_page.rs b/flomo-ai-desktop/src/pages/settings_page.rs new file mode 100644 index 0000000..3f73a09 --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/theme/mod.rs b/flomo-ai-desktop/src/theme/mod.rs new file mode 100644 index 0000000..f9fd1b1 --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/widgets/mod.rs b/flomo-ai-desktop/src/widgets/mod.rs new file mode 100644 index 0000000..67208ba --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/widgets/prompt_selector.rs b/flomo-ai-desktop/src/widgets/prompt_selector.rs new file mode 100644 index 0000000..2e1bbb6 --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/widgets/quick_buttons.rs b/flomo-ai-desktop/src/widgets/quick_buttons.rs new file mode 100644 index 0000000..2267494 --- /dev/null +++ b/flomo-ai-desktop/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/flomo-ai-desktop/src/widgets/result_card.rs b/flomo-ai-desktop/src/widgets/result_card.rs new file mode 100644 index 0000000..d359a72 --- /dev/null +++ b/flomo-ai-desktop/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), + ); + } + }); +} diff --git a/flomo-ai/.gitignore b/flomo-ai/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/flomo-ai/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/flomo-ai/FIX_GRADLE_WARNINGS.md b/flomo-ai/FIX_GRADLE_WARNINGS.md new file mode 100644 index 0000000..4d361bd --- /dev/null +++ b/flomo-ai/FIX_GRADLE_WARNINGS.md @@ -0,0 +1,138 @@ +# Gradle 构建警告修复完成报告 + +## 🎉 修复成果总结 + +经过本次优化,我们成功解决了项目中的大部分 Gradle 构建警告: + +✅ **已完成的修复:** +- 移除了项目中重复的仓库定义 +- 统一了仓库配置到 settings.gradle.kts +- 创建了符合最新语法规范的本地初始化脚本 +- 项目内部已无 Convention API 警告 + +⚠️ **仍存在的警告:** +- 用户全局 Gradle 初始化脚本 (`C:\Users\xiaji\.gradle\init.gradle`) 中的已弃用语法警告 + +## 📁 新增文件说明 + +### 1. 本地初始化脚本 +`gradle/init/init.gradle` - 符合最新 Gradle 语法规范的初始化脚本 + +### 2. 构建脚本 +`build.ps1` - PowerShell 脚本,可使用本地初始化脚本进行构建 + +## 🚀 使用方法 + +### 方法一:使用本地初始化脚本(推荐) +```powershell +powershell -ExecutionPolicy Bypass -File .\build.ps1 +``` + +### 方法二:直接使用 Gradle 命令 +```bash +.\gradlew clean build +``` + +## 📊 当前状态 + +执行 `./gradlew clean --warning-mode=all` 后: +- 项目内部警告已全部解决 ✅ +- 仅剩用户全局配置的警告(不影响项目构建) +- 构建过程正常完成 ✅ + +## 📝 后续建议 + +如需完全消除所有警告,建议: +1. 备份当前的 `C:\Users\xiaji\.gradle\init.gradle` +2. 将其中的 `url 'xxx'` 语法修改为 `url = uri('xxx')` +3. 或者删除该文件,让项目使用我们提供的本地配置 + +--- + +# 原始修复指南 + +## 问题分析 + +当前构建过程中出现两类警告: + +1. **Groovy DSL 已弃用语法警告** (12个) + - 位置:用户全局 Gradle 初始化脚本 `C:\Users\xiaji\.gradle\init.gradle` + - 原因:使用了 `url 'xxx'` 语法,应改为 `url = uri('xxx')` + +2. **Convention API 已弃用警告** (2个) + - 位置:项目构建配置中 + - 原因:使用了已弃用的 Convention API + +## 解决方案 + +### 1. 修复用户全局 Gradle 初始化脚本 + +将 `C:\Users\xiaji\.gradle\init.gradle` 文件中的以下语法: + +```groovy +// ❌ 错误写法(已弃用) +maven { url 'https://repo.huaweicloud.com/repository/gradle/' } + +// ✅ 正确写法 +maven { url = uri('https://repo.huaweicloud.com/repository/gradle/') } +``` + +需要修改的所有位置: +- 第7行:pluginManagement.repositories.maven.url +- 第8行:pluginManagement.repositories.maven.url +- 第9行:pluginManagement.repositories.maven.url +- 第18行:dependencyResolutionManagement.repositories.maven.url +- 第19行:dependencyResolutionManagement.repositories.maven.url +- 第20行:dependencyResolutionManagement.repositories.maven.url +- 第31行:buildscript.repositories.maven.url +- 第32行:buildscript.repositories.maven.url +- 第33行:buildscript.repositories.maven.url +- 第37行:repositories.maven.url +- 第38行:repositories.maven.url +- 第39行:repositories.maven.url + +### 2. 项目配置优化建议 + +虽然项目本身配置正确,但可以进一步优化: + +#### 修改 settings.gradle.kts +```kotlin +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) // 保持现状 + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + // 移除重复的阿里云镜像,因为已在全局配置中设置 + } +} +``` + +#### 修改 build.gradle.kts +```kotlin +allprojects { + repositories { + // 移除这些仓库定义,因为已在 settings 中统一管理 + // google() + // mavenCentral() + // maven { url = uri("https://jitpack.io") } + // 阿里云镜像源也移除(由全局配置处理) + } +} +``` + +## 验证修复结果 + +执行以下命令验证警告是否消除: + +```bash +./gradlew clean --warning-mode=all +``` + +预期结果:应该不再出现 Groovy DSL 语法警告和 Convention API 警告。 + +## 注意事项 + +1. 修改全局 init.gradle 文件会影响所有 Gradle 项目 +2. 如果只希望影响当前项目,可以将镜像配置移到项目本地的 settings.gradle.kts 中 +3. 建议备份原始 init.gradle 文件后再进行修改 \ No newline at end of file diff --git a/flomo-ai/LICENSE b/flomo-ai/LICENSE new file mode 100644 index 0000000..7aabf36 --- /dev/null +++ b/flomo-ai/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 xiaji + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/flomo-ai/README.md b/flomo-ai/README.md new file mode 100644 index 0000000..8416c43 --- /dev/null +++ b/flomo-ai/README.md @@ -0,0 +1,174 @@ +# Flomo-AI Android 客户端 + +

+ 应用截图 +

+ +

+ 功能 + 技术栈 + Android版本 +

+ +## 📱 项目简介 + +Flomo-AI 是一款基于 Android 平台的 AI 增强笔记应用客户端,专为移动端用户设计。该应用结合了现代化的 UI 设计和高效的笔记管理功能,并集成多种 AI 大模型实现智能标签生成,让用户能够随时随地记录灵感和重要信息。 + +## 🌟 主要特性 + +### 核心功能 +- ✨ **智能标签生成** - 支持智谱AI (GLM-4-Flash) 和星火大模型,自动分析文章内容生成4个精准标签 +- 📝 **便捷笔记记录** - 快速创建和编辑笔记内容 +- 🏷️ **一键添加标签** - 点击生成的标签即可快速添加到笔记中(#标签格式) +- 📤 **云端同步** - 一键将笔记提交至 Flomo 服务器 +- 🎨 **个性化定制** - 支持自定义背景图片和状态栏主题颜色(红/绿/蓝/橙) +- 🔐 **安全可靠** - 集成 JWT 认证和数据加密 + +### 技术亮点 +- ⚡ **流畅体验** - 基于 Jetpack Compose 的响应式界面 +- 🚀 **高性能架构** - 使用 Kotlin 协程和现代异步编程 +- 🌐 **网络通信** - 集成 OkHttp、Retrofit、Moshi 进行高效数据传输 +- 📦 **模块化设计** - 清晰的代码结构和组件分离 + +## 🛠 技术栈 + +
+ +| 类别 | 技术 | +|------|------| +| **语言** | Kotlin | +| **框架** | Android Jetpack, Jetpack Compose | +| **网络** | OkHttp, Retrofit | +| **JSON** | Moshi, Gson | +| **安全** | JWT, Nimbus JOSE JWT | +| **异步** | Kotlin Coroutines | +| **UI** | Material Design 3, Compose | +| **构建** | Gradle Kotlin DSL | + +
+ +## 📋 系统要求 + +- **最低 Android 版本**: Android 9.0 (API Level 28) +- **目标 Android 版本**: Android 14 (API Level 34) +- **开发环境**: Android Studio +- **构建工具**: Gradle 8.4+ +- **JDK 版本**: Java 8+ + +## 🚀 快速开始 + +### 克隆项目 +```bash +git clone http://124.223.26.33:3000/xiaji/flomo-ai.git +cd flomo-ai +``` + +### 构建项目 +```bash +# 清理并构建 +./gradlew clean build + +# 构建 Debug 版本 +./gradlew assembleDebug + +# 构建 Release 版本 +./gradlew assembleRelease +``` + +### 运行应用 +```bash +# 连接设备后安装 +./gradlew installDebug +``` + +## 📁 项目结构 + +``` +flomo-ai/ +├── app/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/example/flomo_ai/ +│ │ │ │ ├── MainActivity.kt # 主界面(笔记输入+AI标签生成) +│ │ │ │ ├── SecondActivity.kt # 配置界面(API管理+个性化设置) +│ │ │ │ ├── kwt.kt # JWT认证工具类 +│ │ │ │ └── ui/ # UI 组件 +│ │ │ ├── res/ # 资源文件 +│ │ │ └── AndroidManifest.xml # 应用配置 +│ │ └── build.gradle.kts # 模块构建配置 +├── gradle/ # Gradle 配置 +├── build.gradle.kts # 项目构建配置 +└── README.md # 项目说明文档 +``` + +## 🔧 配置说明 + +### AI API 配置 +应用支持配置多个 AI 服务商: +- **智谱AI (zhipu)**: 默认模型 glm-4-flash +- **星火大模型 (spark)**: 默认模型 general + +每个配置包含: +- API 名称 +- API URL +- API Key +- Secret Key +- 模型名称 + +### 个性化设置 +- **背景图片**: 支持从相册选择自定义背景 +- **主题颜色**: 支持红、绿、蓝、橙四种状态栏颜色 + +### Gradle 配置优化 +项目已配置国内镜像源以提升构建速度: + +```properties +# gradle.properties +org.gradle.wrapper.downloadUrl=https://mirrors.aliyun.com/macports/distfiles/gradle/ +org.gradle.internal.http.connectionTimeout=120000 +org.gradle.internal.http.socketTimeout=120000 +``` + +### 权限说明 +应用需要以下权限: +- `INTERNET`: 网络通信 +- `READ_EXTERNAL_STORAGE`: 读取外部存储(用于背景图片) + +## 📸 应用截图 + + + + + + + + + + + + +
主界面笔记页面配置界面
+ +## 🤝 贡献指南 + +欢迎提交 Issue 和 Pull Request 来改进项目! + +### 开发流程 +1. Fork 项目 +2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +## 📄 许可证 + +本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情 + +## 📞 联系方式 + +- 项目地址: `http://124.223.26.33:3000/xiaji/flomo-ai.git` +- 开发者: xiaji + +--- + +

Made with ❤️ for Android developers

diff --git a/flomo-ai/Screenshot_20240922_111050.png b/flomo-ai/Screenshot_20240922_111050.png new file mode 100644 index 0000000..7db4d2b Binary files /dev/null and b/flomo-ai/Screenshot_20240922_111050.png differ diff --git a/flomo-ai/Screenshot_20240922_214948.png b/flomo-ai/Screenshot_20240922_214948.png new file mode 100644 index 0000000..944d68a Binary files /dev/null and b/flomo-ai/Screenshot_20240922_214948.png differ diff --git a/flomo-ai/Screenshot_20241001_222239_配置页面修改.png b/flomo-ai/Screenshot_20241001_222239_配置页面修改.png new file mode 100644 index 0000000..a3d7252 Binary files /dev/null and b/flomo-ai/Screenshot_20241001_222239_配置页面修改.png differ diff --git a/flomo-ai/apkkey.jks b/flomo-ai/apkkey.jks new file mode 100644 index 0000000..277ea04 Binary files /dev/null and b/flomo-ai/apkkey.jks differ diff --git a/flomo-ai/app/.gitignore b/flomo-ai/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/flomo-ai/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/flomo-ai/app/build.gradle.kts b/flomo-ai/app/build.gradle.kts new file mode 100644 index 0000000..17b0b5a --- /dev/null +++ b/flomo-ai/app/build.gradle.kts @@ -0,0 +1,119 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.flomo_ai" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.flomo_ai" + minSdk = 24 + targetSdk = 34 + versionCode = 2 + versionName = "1.2" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + // 启用 View Binding + buildFeatures { + viewBinding = true + } + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/java") + res.srcDirs("src/main/res") + manifest.srcFile("src/main/AndroidManifest.xml") + } + getByName("test") { + java.srcDirs("src/test/java") + } + getByName("androidTest") { + java.srcDirs("src/androidTest/java") + } + } + + buildTypes { + debug { + isDebuggable = true + isMinifyEnabled = false + applicationIdSuffix = ".debug" + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildToolsVersion = "34.0.0" +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.appcompat) + implementation(libs.material) + + // 网络请求相关 + implementation(libs.okhttp) + implementation(libs.logging.interceptor) + + // JSON解析 + implementation(libs.gson) + implementation(libs.moshi.kotlin) + + // 协程支持 + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + // JWT处理 + implementation(libs.kjwt.jwks) + implementation(libs.nimbus.jose.jwt) + + // 权限处理 (已移除PermissionX,代码中未使用) + + // 图片加载 + implementation("com.github.bumptech.glide:glide:4.16.0") + annotationProcessor("com.github.bumptech.glide:compiler:4.16.0") + + // 测试依赖 + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/flomo-ai/app/proguard-rules.pro b/flomo-ai/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/flomo-ai/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/flomo-ai/app/release/app-release.apk b/flomo-ai/app/release/app-release.apk new file mode 100644 index 0000000..90d0a09 Binary files /dev/null and b/flomo-ai/app/release/app-release.apk differ diff --git a/flomo-ai/app/release/baselineProfiles/0/app-release.dm b/flomo-ai/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..7caf241 Binary files /dev/null and b/flomo-ai/app/release/baselineProfiles/0/app-release.dm differ diff --git a/flomo-ai/app/release/baselineProfiles/1/app-release.dm b/flomo-ai/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..780585f Binary files /dev/null and b/flomo-ai/app/release/baselineProfiles/1/app-release.dm differ diff --git a/flomo-ai/app/release/output-metadata.json b/flomo-ai/app/release/output-metadata.json new file mode 100644 index 0000000..e9d71d6 --- /dev/null +++ b/flomo-ai/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.example.flomo_ai", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 28 +} \ No newline at end of file diff --git a/flomo-ai/app/src/androidTest/java/com/example/flomo_ai/ExampleInstrumentedTest.kt b/flomo-ai/app/src/androidTest/java/com/example/flomo_ai/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..5cb7863 --- /dev/null +++ b/flomo-ai/app/src/androidTest/java/com/example/flomo_ai/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.flomo_ai + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.flomo_ai", appContext.packageName) + } +} \ No newline at end of file diff --git a/flomo-ai/app/src/main/AndroidManifest.xml b/flomo-ai/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a8883ac --- /dev/null +++ b/flomo-ai/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/flomo-ai/app/src/main/java/com/example/flomo_ai/MainActivity.kt b/flomo-ai/app/src/main/java/com/example/flomo_ai/MainActivity.kt new file mode 100644 index 0000000..f71cc4e --- /dev/null +++ b/flomo-ai/app/src/main/java/com/example/flomo_ai/MainActivity.kt @@ -0,0 +1,438 @@ +package com.example.flomo_ai + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast + +import androidx.appcompat.app.AppCompatActivity +import com.example.flomo_ai.ui.theme.ThemeManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +class MainActivity : AppCompatActivity() { + private lateinit var inputEditText: EditText + private lateinit var configButton: Button + private lateinit var outputStatusLabel: TextView + private lateinit var outputTextView: TextView + private lateinit var promptSelector: Spinner + private lateinit var promptContentText: TextView + + // Data classes matching SecondActivity + data class HeaderConfig(val key: String, val value: String) + data class PromptConfig(val id: String, val title: String, val content: String, val expanded: Boolean = false) + data class ButtonConfig(val id: String, val label: String, val action: String, val apiUrl: String? = null, val apiMethod: String? = null, val apiBodyTemplate: String? = null, val expanded: Boolean = false) + data class LLMConfig(val baseUrl: String, val apiKey: String, val model: String) + data class SettingsData( + val llmConfig: LLMConfig?, + val headerConfigs: List?, + val promptConfigs: List?, + val buttonConfigs: List? + ) + + @SuppressLint("MissingInflatedId", "CutPasteId", "SetTextI18n") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager.applySavedTheme(this) + Log.d("MainActivity", "onCreate: Starting MainActivity") + setContentView(R.layout.activity_main) + Log.d("MainActivity", "onCreate: Layout set") + + promptSelector = findViewById(R.id.promptSelector) + promptContentText = findViewById(R.id.promptContentText) + inputEditText = findViewById(R.id.inputEditText) + val sendButton = findViewById