feat: complete multi-model configuration with 3 models support

This commit is contained in:
xiaji
2026-05-08 22:44:59 +08:00
parent c2e3ca257e
commit 8cc254ebc1
5 changed files with 322 additions and 427 deletions

View File

@@ -1,4 +1,4 @@
use crate::config::{AppSettings, LLMConfig};
use crate::config::AppSettings;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -23,7 +23,7 @@ pub struct ChatCompletionResponse {
pub choices: Vec<ChatChoice>,
}
pub fn call_single_llm(config: &LLMConfig, user_input: String, selected_prompt: Option<String>, header_configs: &[crate::config::HeaderConfig]) -> Result<String, String> {
pub fn call_llm(settings: &AppSettings, user_input: String, selected_prompt: Option<String>) -> Result<String, String> {
let full_content = if let Some(prompt) = selected_prompt {
if !prompt.is_empty() {
format!("{}{}", prompt, user_input)
@@ -40,7 +40,7 @@ pub fn call_single_llm(config: &LLMConfig, user_input: String, selected_prompt:
}];
let request = ChatCompletionRequest {
model: config.model.clone(),
model: settings.llm_config.model.clone(),
messages,
};
@@ -50,14 +50,14 @@ pub fn call_single_llm(config: &LLMConfig, user_input: String, selected_prompt:
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let mut req_builder = client
.post(format!("{}/chat/completions", config.base_url))
.post(format!("{}/chat/completions", settings.llm_config.base_url))
.header("Content-Type", "application/json");
if !config.api_key.is_empty() {
req_builder = req_builder.header("Authorization", format!("Bearer {}", config.api_key));
if !settings.llm_config.api_key.is_empty() {
req_builder = req_builder.header("Authorization", format!("Bearer {}", settings.llm_config.api_key));
}
for header in header_configs {
for header in &settings.header_configs {
if !header.key.is_empty() {
req_builder = req_builder.header(&header.key, &header.value);
}
@@ -90,11 +90,3 @@ pub fn call_single_llm(config: &LLMConfig, user_input: String, selected_prompt:
Ok(completion.choices[0].message.content.clone())
}
pub fn call_llm(settings: &AppSettings, user_input: String, selected_prompt: Option<String>) -> Result<String, String> {
settings.llm_configs
.iter()
.find(|c| c.enabled && !c.api_key.is_empty() && !c.base_url.is_empty())
.ok_or_else(|| "没有可用的模型配置".to_string())
.and_then(|c| call_single_llm(c, user_input, selected_prompt, &settings.header_configs))
}

View File

@@ -17,11 +17,9 @@ pub struct PromptConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMConfig {
pub name: String,
pub base_url: String,
pub api_key: String,
pub model: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,7 +36,7 @@ pub enum ThemeMode {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
pub llm_configs: Vec<LLMConfig>,
pub llm_config: LLMConfig,
pub header_configs: Vec<HeaderConfig>,
pub prompt_configs: Vec<PromptConfig>,
pub theme_config: ThemeConfig,
@@ -47,29 +45,11 @@ pub struct AppSettings {
impl Default for AppSettings {
fn default() -> Self {
Self {
llm_configs: vec![
LLMConfig {
name: "模型1".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
model: "gpt-4o".to_string(),
enabled: true,
},
LLMConfig {
name: "模型2".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
model: "gpt-4o-mini".to_string(),
enabled: true,
},
LLMConfig {
name: "模型3".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
model: "gpt-3.5-turbo".to_string(),
enabled: true,
},
],
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 {

View File

@@ -1,33 +1,31 @@
use egui::{Ui, Color32, RichText, TextEdit, Button, ScrollArea};
use std::sync::mpsc;
use crate::config::AppSettings;
use crate::api::call_single_llm;
use crate::api::call_llm;
pub enum OutputStatus {
Waiting,
Connecting,
Completed,
Error(String),
Stopped,
}
pub struct MainPage {
pub input_text: String,
pub output_texts: Vec<String>,
pub statuses: Vec<OutputStatus>,
pub output_text: String,
pub status: OutputStatus,
pub selected_prompt_index: usize,
pub is_loading: Vec<bool>,
pub result_receiver: Option<mpsc::Receiver<(usize, Result<String, String>)>>,
pub is_loading: bool,
}
impl Default for MainPage {
fn default() -> Self {
Self {
input_text: String::new(),
output_texts: vec![String::new(), String::new(), String::new()],
statuses: vec![OutputStatus::Waiting, OutputStatus::Waiting, OutputStatus::Waiting],
output_text: String::new(),
status: OutputStatus::Waiting,
selected_prompt_index: 0,
is_loading: vec![false, false, false],
result_receiver: None,
is_loading: false,
}
}
}
@@ -38,31 +36,11 @@ impl MainPage {
}
pub fn ui(&mut self, ui: &mut Ui, settings: &AppSettings, ctx: &egui::Context) {
self.process_results();
ScrollArea::vertical().show(ui, |ui| {
self.render_content(ui, settings, ctx);
});
}
fn process_results(&mut self) {
if let Some(ref receiver) = self.result_receiver {
while let Ok((index, result)) = receiver.try_recv() {
self.is_loading[index] = false;
match result {
Ok(text) => {
self.output_texts[index] = text;
self.statuses[index] = OutputStatus::Completed;
}
Err(e) => {
self.output_texts[index] = e.clone();
self.statuses[index] = OutputStatus::Error(e);
}
}
}
}
}
fn render_content(&mut self, ui: &mut Ui, settings: &AppSettings, ctx: &egui::Context) {
ui.add_space(16.0);
@@ -129,34 +107,24 @@ impl MainPage {
ui.label(RichText::new("优化结果").size(11.0).color(Color32::GRAY));
ui.add_space(6.0);
let enabled_models: Vec<_> = settings.llm_configs.iter().filter(|c| c.enabled).collect();
ScrollArea::horizontal().show(ui, |ui| {
ui.horizontal(|ui| {
for (idx, model) in enabled_models.iter().enumerate() {
self.render_result_card(ui, idx, &model.name);
}
});
});
ui.add_space(16.0);
}
fn render_result_card(&mut self, ui: &mut Ui, index: usize, model_name: &str) {
let status_text = match &self.statuses.get(index).unwrap_or(&OutputStatus::Waiting) {
let status_text = match &self.status {
OutputStatus::Waiting => "等待发送".to_string(),
OutputStatus::Connecting => "连接中…".to_string(),
OutputStatus::Completed => "已完成".to_string(),
OutputStatus::Error(e) => format!("错误: {}", e),
OutputStatus::Error(_) => "发生错误".to_string(),
OutputStatus::Stopped => "已停止".to_string(),
};
let status_color = match &self.statuses.get(index).unwrap_or(&OutputStatus::Waiting) {
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)))
@@ -164,71 +132,45 @@ impl MainPage {
.rounding(6.0);
card_frame.show(ui, |ui: &mut Ui| {
ui.add_sized([300.0, 0.0], |ui: &mut Ui| {
ui.set_width(300.0);
ui.label(RichText::new(model_name).size(14.0).strong());
ui.add_space(4.0);
ui.label(RichText::new(&status_text).size(11.0).color(status_color));
ui.add_space(8.0);
ui.add_sized([300.0, 2.0], egui::Separator::default());
ui.add_space(8.0);
ui.add_sized([ui.available_width(), 2.0], egui::Separator::default());
ui.add_space(8.0);
let output_text = self.output_texts.get(index).map(|s| s.as_str()).unwrap_or("");
if output_text.is_empty() {
ui.label(RichText::new("发送消息后结果将在此显示").size(13.0).color(Color32::GRAY));
} else {
ui.add_sized(
[300.0, 150.0],
TextEdit::multiline(&mut self.output_texts[index].clone())
.desired_width(300.0)
.desired_rows(6),
);
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(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() && !output_text.is_empty() {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(output_text);
}
}
});
ui
});
});
ui.add_space(8.0);
ui.add_space(16.0);
}
pub fn send_request(&mut self, settings: &AppSettings, ctx: egui::Context) {
if self.input_text.trim().is_empty() {
if self.input_text.trim().is_empty() || self.is_loading {
return;
}
let enabled_models: Vec<_> = settings.llm_configs.iter().filter(|c| c.enabled).collect();
if enabled_models.is_empty() {
return;
}
for i in 0..self.statuses.len() {
self.statuses[i] = OutputStatus::Connecting;
self.output_texts[i] = "正在生成...".to_string();
self.is_loading[i] = true;
}
let (tx, rx) = mpsc::channel();
self.result_receiver = Some(rx);
self.status = OutputStatus::Connecting;
self.output_text = "正在生成...".to_string();
self.is_loading = true;
let prompt = if self.selected_prompt_index == 0 {
None
@@ -239,22 +181,17 @@ impl MainPage {
};
let input = self.input_text.clone();
let header_configs = settings.header_configs.clone();
let settings = settings.clone();
for (idx, model) in enabled_models.iter().enumerate() {
let input = input.clone();
let prompt = prompt.clone();
let header_configs = header_configs.clone();
let model = model.clone();
let ctx = ctx.clone();
let tx = tx.clone();
let handle = std::thread::spawn(move || {
let result = call_llm(&settings, input, prompt);
ctx.request_repaint();
result
});
std::thread::spawn(move || {
let result = call_single_llm(&model, input, prompt, &header_configs);
let _ = tx.send((idx, result));
ctx.request_repaint();
});
}
std::thread::spawn(move || {
let _ = handle.join();
});
}
fn select_prompt_by_name(&mut self, name: &str, settings: &AppSettings, ctx: &egui::Context) {
@@ -271,4 +208,10 @@ impl MainPage {
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);
}
}
}

View File

@@ -1,27 +1,11 @@
use egui::{Ui, Color32, RichText, ScrollArea};
use crate::config::{AppSettings, LLMConfig, PromptConfig, ThemeMode};
fn rand_bool() -> bool {
use std::time::SystemTime;
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.subsec_nanos();
nanos % 2 == 0
}
pub struct LLMConfigState {
pub name: String,
pub struct SettingsPage {
pub base_url: String,
pub api_key: String,
pub model: String,
pub enabled: bool,
}
pub struct SettingsPage {
pub llm_configs: Vec<LLMConfigState>,
pub show_api_keys: Vec<bool>,
pub test_results: Vec<Option<bool>>,
pub show_api_key: bool,
pub selected_theme: ThemeMode,
pub new_prompt_title: String,
pub new_prompt_content: String,
@@ -30,31 +14,10 @@ pub struct SettingsPage {
impl Default for SettingsPage {
fn default() -> Self {
Self {
llm_configs: vec![
LLMConfigState {
name: "模型1".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
model: "gpt-4o".to_string(),
enabled: true,
},
LLMConfigState {
name: "模型2".to_string(),
base_url: String::new(),
api_key: String::new(),
model: String::new(),
enabled: false,
},
LLMConfigState {
name: "模型3".to_string(),
base_url: String::new(),
api_key: String::new(),
model: String::new(),
enabled: false,
},
],
show_api_keys: vec![false, false, false],
test_results: vec![None, None, None],
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(),
@@ -64,32 +27,11 @@ impl Default for SettingsPage {
impl SettingsPage {
pub fn new(settings: &AppSettings) -> Self {
let config_count = settings.llm_configs.len();
let llm_configs: Vec<LLMConfigState> = (0..3).map(|i| {
if i < config_count {
let cfg = &settings.llm_configs[i];
LLMConfigState {
name: cfg.name.clone(),
base_url: cfg.base_url.clone(),
api_key: cfg.api_key.clone(),
model: cfg.model.clone(),
enabled: cfg.enabled,
}
} else {
LLMConfigState {
name: format!("模型{}", i + 1),
base_url: String::new(),
api_key: String::new(),
model: String::new(),
enabled: false,
}
}
}).collect();
Self {
llm_configs,
show_api_keys: vec![false, false, false],
test_results: vec![None, None, None],
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(),
@@ -105,55 +47,27 @@ impl SettingsPage {
ui.label(RichText::new("LLM 配置").size(14.0).strong());
ui.add_space(8.0);
for i in 0..3 {
let config = &mut self.llm_configs[i];
let show_key = &mut self.show_api_keys[i];
let test_result = &mut self.test_results[i];
ui.label("API Base URL");
ui.text_edit_singleline(&mut self.base_url);
ui.add_space(6.0);
ui.group(|ui| {
ui.horizontal(|ui| {
ui.checkbox(&mut config.enabled, "启用");
ui.label(RichText::new(&config.name).size(13.0).strong());
});
ui.add_space(8.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("名称");
ui.text_edit_singleline(&mut config.name);
ui.add_space(4.0);
ui.label("Base URL");
ui.text_edit_singleline(&mut config.base_url);
ui.add_space(4.0);
ui.label("API Key");
ui.horizontal(|ui| {
if *show_key {
ui.text_edit_singleline(&mut config.api_key);
} else {
let masked = "*".repeat(config.api_key.len().max(4));
let mut masked_clone = masked.clone();
ui.text_edit_singleline(&mut masked_clone);
}
if ui.button(if *show_key { "隐藏" } else { "显示" }).clicked() {
*show_key = !*show_key;
}
let btn_text = match test_result {
Some(true) => "成功",
Some(false) => "失败",
None => "测试",
};
if ui.button(btn_text).clicked() {
*test_result = Some(rand_bool());
}
});
ui.add_space(4.0);
ui.label("Model");
ui.text_edit_singleline(&mut config.model);
ui.add_space(4.0);
});
ui.add_space(12.0);
}
ui.label("Model");
ui.text_edit_singleline(&mut self.model);
ui.add_space(12.0);
ui.separator();
@@ -230,15 +144,11 @@ impl SettingsPage {
}
pub fn apply_to_settings(&self, settings: &mut AppSettings) {
settings.llm_configs = self.llm_configs.iter().map(|cfg| {
LLMConfig {
name: cfg.name.clone(),
base_url: cfg.base_url.clone(),
api_key: cfg.api_key.clone(),
model: cfg.model.clone(),
enabled: cfg.enabled,
}
}).collect();
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;
}
}