feat: parallel LLM calls with 3 result cards

This commit is contained in:
xiaji
2026-05-08 22:39:05 +08:00
parent 969f4b0d66
commit c2e3ca257e
2 changed files with 141 additions and 64 deletions

View File

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

View File

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