feat: 将 Android flomo-ai 移植为 Rust Windows 桌面应用

- 使用 egui + eframe 构建跨平台 GUI
- 实现 LLM API 调用(OpenAI 兼容格式)
- 实现提示词管理(添加/删除/选择)
- 实现快速操作按钮(错别字检查、总结、翻译、润色)
- 实现明暗主题切换
- 实现 JSON 配置持久化
- 实现剪贴板复制功能
- MinGW 工具链编译,无控制台窗口
This commit is contained in:
xiaji
2026-04-04 17:27:29 +08:00
commit 8f91bf8af4
17 changed files with 1289 additions and 0 deletions

7
.cargo/config.toml Normal file
View File

@@ -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",
]

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target/
*.pdb
Cargo.lock

25
Cargo.toml Normal file
View File

@@ -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

93
src/api/llm_client.rs Normal file
View File

@@ -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<ChatMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatChoice {
pub message: ChatMessage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionResponse {
pub choices: Vec<ChatChoice>,
}
pub fn call_llm(settings: &AppSettings, user_input: String, selected_prompt: Option<String>) -> Result<String, String> {
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())
}

2
src/api/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod llm_client;
pub use llm_client::*;

465
src/app.rs Normal file
View File

@@ -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<std::thread::JoinHandle<Result<String, String>>>,
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();
}
});
}
}

2
src/config/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod store;
pub use store::*;

119
src/config/store.rs Normal file
View File

@@ -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<HeaderConfig>,
pub prompt_configs: Vec<PromptConfig>,
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))
}

25
src/main.rs Normal file
View File

@@ -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)))),
)
}

217
src/pages/main_page.rs Normal file
View File

@@ -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);
}
}
}

4
src/pages/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod main_page;
pub mod settings_page;
pub use main_page::OutputStatus;

154
src/pages/settings_page.rs Normal file
View File

@@ -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;
}
}

42
src/theme/mod.rs Normal file
View File

@@ -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(),
}
}
}

6
src/widgets/mod.rs Normal file
View File

@@ -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::*;

View File

@@ -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<String> {
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
}

View File

@@ -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);
}
}
});
}

View File

@@ -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),
);
}
});
}