From cd77f750668587ab0945beb2e9f22b3ccbd0c95a Mon Sep 17 00:00:00 2001 From: Xianren Studio Date: Thu, 13 Aug 2026 18:18:51 +0800 Subject: [PATCH] feat: chat stats, regenerate with version history, edit & resubmit, image/file attachments --- apps/desktop/src/commands.rs | 539 ++++++++++++++++++++++++++--------- apps/desktop/src/lib.rs | 4 + crates/core/src/app.rs | 3 + crates/core/src/schema.sql | 14 + crates/core/src/sessions.rs | 178 +++++++++++- crates/engine/src/lib.rs | 2 +- crates/engine/src/manager.rs | 22 +- crates/engine/src/remote.rs | 5 +- crates/engine/src/types.rs | 57 +++- ui/src/api.ts | 40 +++ ui/src/components/Icon.tsx | 4 +- ui/src/pages/ChatPage.tsx | 487 ++++++++++++++++++++++++++++--- 12 files changed, 1156 insertions(+), 199 deletions(-) diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index d79cf37..2dfa5ee 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -5,12 +5,13 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tauri::{AppHandle, Emitter, State}; +use tokio::sync::RwLock; use xianren_api::ApiState; use xianren_core::models as models_db; use xianren_core::sessions as sessions_db; use xianren_core::settings as settings_db; use xianren_core::{CoreApp, ModelInfo, Message}; -use xianren_engine::{ChatMessage, ChatRequest, EngineConfig}; +use xianren_engine::{ChatMessage, ChatRequest, EngineConfig, EngineManager}; use xianren_engine::remote::RemoteConfig; #[derive(Serialize)] @@ -38,6 +39,8 @@ pub struct ChatSendPayload { pub model_id: String, pub content: String, pub params: ChatParams, + #[serde(default)] + pub images: Vec, } #[derive(Serialize, Clone)] @@ -53,7 +56,30 @@ pub struct ChatDoneEvent { pub content: String, pub tokens_in: Option, pub tokens_out: Option, - pub elapsed_ms: u128, + pub tokens_estimated: bool, + pub elapsed_ms: u64, + pub first_token_ms: Option, +} + +#[derive(Deserialize)] +pub struct RegeneratePayload { + pub conversation_id: String, + pub message_id: String, + pub params: ChatParams, +} + +#[derive(Deserialize)] +pub struct EditMessagePayload { + pub conversation_id: String, + pub message_id: String, + pub content: String, + pub params: ChatParams, +} + +#[derive(Serialize, Clone)] +pub struct ChatMessageUpdatedEvent { + pub conversation_id: String, + pub message: Message, } #[derive(Serialize, Clone)] @@ -460,6 +486,9 @@ pub async fn chat_send( &payload.content, None, None, + None, + None, + &payload.images, ) .map_err(|e| e.to_string())?; @@ -469,6 +498,7 @@ pub async fn chat_send( .map(|m| ChatMessage { role: m.role, content: m.content, + images: m.images, }) .collect::>(); @@ -482,103 +512,396 @@ pub async fn chat_send( }; let conversation_id = payload.conversation_id.clone(); + let history = if model.kind == "local" { + sanitize_history_for_local(history) + } else { + history + }; - // 远程 API 模型:直接调用 OpenAI 兼容端点 - if model.kind == "remote" { + tokio::spawn(run_generation_and_stream( + app, + core, + engine, + base, + conversation_id, + history, + model, + engine_bin, + payload.params, + )); + Ok(()) +} + +/// 统一的生成与流式转发:支持本地引擎 / 远程 API,统计首字延迟、token 数与耗时。 +async fn run_generation_and_stream( + app: AppHandle, + core: Arc, + engine: EngineManager, + base: Arc>>, + conversation_id: String, + history: Vec, + model: ModelInfo, + engine_bin: String, + params: ChatParams, +) { + // 本地模型:确保引擎已运行 + if model.kind == "local" && !engine.status().await.running { + let bin = PathBuf::from(&engine_bin); + if !bin.exists() { + let _ = app.emit( + "chat://error", + ChatErrorEvent { + conversation_id: conversation_id.clone(), + message: format!("engine binary not found: {},请先在设置页配置", bin.display()), + }, + ); + return; + } + let cfg = EngineConfig { + binary_path: bin, + model_path: PathBuf::from(model.file_path), + host: "127.0.0.1".into(), + ctx_size: params.ctx_size, + ngl: params.ngl, + threads: None, + log_file: core.logs_dir.join(format!("engine-{}.log", model.file_name)), + }; + if let Err(e) = engine.start(cfg).await { + let _ = app.emit( + "chat://error", + ChatErrorEvent { + conversation_id: conversation_id.clone(), + message: e.to_string(), + }, + ); + return; + } + *base.write().await = engine.base_url().await; + } + + let upstream_model = if model.kind == "remote" { + model + .api_model + .clone() + .unwrap_or_else(|| model.repo_id.clone()) + } else { + model.repo_id.clone() + }; + let req = ChatRequest { + model: upstream_model.clone(), + messages: history, + temperature: Some(params.temperature), + top_p: Some(params.top_p), + max_tokens: Some(params.max_tokens), + stream: true, + }; + + let stream_result = if model.kind == "remote" { let cfg = RemoteConfig { base_url: model.base_url.clone().unwrap_or_default(), api_key: model.api_key.clone(), - model: model - .api_model - .clone() - .unwrap_or_else(|| model.repo_id.clone()), + model: upstream_model, }; - let req = ChatRequest { - model: cfg.model.clone(), - messages: history, - temperature: Some(payload.params.temperature), - top_p: Some(payload.params.top_p), - max_tokens: Some(payload.params.max_tokens), - stream: true, - }; - tokio::spawn(async move { - match xianren_engine::stream_chat_remote(&cfg, req).await { - Ok(stream) => drain_stream_and_persist(app, core, conversation_id, stream).await, - Err(e) => { - let _ = app.emit( - "chat://error", - ChatErrorEvent { - conversation_id, - message: e.to_string(), - }, - ); - } - } - }); - return Ok(()); - } + xianren_engine::stream_chat_remote(&cfg, req).await + } else { + engine.stream_chat(req).await + }; - // 本地 GGUF 模型:确保引擎运行后走 llama-server - tokio::spawn(async move { - if !engine.status().await.running { - let bin = PathBuf::from(&engine_bin); - if !bin.exists() { - let _ = app.emit( - "chat://error", - ChatErrorEvent { - conversation_id: payload.conversation_id.clone(), - message: format!( - "engine binary not found: {},请先在设置页配置", - bin.display() - ), - }, - ); - return; - } - let cfg = EngineConfig { - binary_path: bin, - model_path: PathBuf::from(model.file_path), - host: "127.0.0.1".into(), - ctx_size: payload.params.ctx_size, - ngl: payload.params.ngl, - threads: None, - log_file: core.logs_dir.join(format!("engine-{}.log", model.file_name)), - }; - if let Err(e) = engine.start(cfg).await { - let _ = app.emit( - "chat://error", - ChatErrorEvent { - conversation_id: payload.conversation_id.clone(), - message: e.to_string(), - }, - ); - return; - } - *base.write().await = engine.base_url().await; + let mut stream = match stream_result { + Ok(s) => s, + Err(e) => { + let _ = app.emit( + "chat://error", + ChatErrorEvent { + conversation_id: conversation_id.clone(), + message: e.to_string(), + }, + ); + return; } + }; - let req = ChatRequest { - model: model.repo_id, - messages: history, - temperature: Some(payload.params.temperature), - top_p: Some(payload.params.top_p), - max_tokens: Some(payload.params.max_tokens), - stream: true, - }; + use futures::StreamExt; + let started = Instant::now(); + let mut first_token_ms: Option = None; + let mut prompt_tokens: Option = None; + let mut completion_tokens: Option = None; + let mut full = String::new(); - match engine.stream_chat(req).await { - Ok(stream) => drain_stream_and_persist(app, core, conversation_id, stream).await, + while let Some(item) = stream.next().await { + match item { + Ok(xianren_engine::ChatStreamEvent::Text(text)) => { + if first_token_ms.is_none() { + first_token_ms = Some(started.elapsed().as_millis() as u64); + } + full.push_str(&text); + let _ = app.emit( + "chat://token", + ChatTokenEvent { + conversation_id: conversation_id.clone(), + text, + }, + ); + } + Ok(xianren_engine::ChatStreamEvent::Usage { + prompt_tokens: p, + completion_tokens: c, + }) => { + prompt_tokens = Some(p as i64); + completion_tokens = Some(c as i64); + } Err(e) => { let _ = app.emit( "chat://error", ChatErrorEvent { - conversation_id, + conversation_id: conversation_id.clone(), message: e.to_string(), }, ); + return; } } - }); + } + + let elapsed_ms = started.elapsed().as_millis() as u64; + let tokens_estimated = completion_tokens.is_none(); + let tokens_out = completion_tokens.or_else(|| Some(estimate_tokens(&full) as i64)); + let db = core.db.lock().unwrap(); + let message_id = sessions_db::add_message( + &db, + &conversation_id, + "assistant", + &full, + prompt_tokens, + tokens_out, + Some(elapsed_ms as i64), + first_token_ms.map(|v| v as i64), + &[], + ) + .unwrap_or_default(); + let _ = sessions_db::touch_conversation(&db, &conversation_id); + drop(db); + let _ = app.emit( + "chat://done", + ChatDoneEvent { + conversation_id, + message_id, + content: full, + tokens_in: prompt_tokens, + tokens_out, + tokens_estimated, + elapsed_ms, + first_token_ms, + }, + ); +} + +/// 本地文本模型不支持图片:把图片替换为占位说明。 +fn sanitize_history_for_local(mut history: Vec) -> Vec { + for message in &mut history { + if !message.images.is_empty() { + for _ in &message.images { + message + .content + .push_str("\n[图片:本地文本模型暂不支持视觉输入]"); + } + message.images.clear(); + } + } + history +} + +fn estimate_tokens(text: &str) -> usize { + let mut score = 0usize; + for ch in text.chars() { + score += if ch.is_ascii() { 1 } else { 2 }; + } + score / 4 + 1 +} + +/// 重新生成某条助手回复:旧内容存入版本历史,截断后重新生成。 +#[tauri::command] +pub async fn regenerate_message( + app: AppHandle, + state: State<'_, App>, + payload: RegeneratePayload, +) -> Result<(), String> { + let core = state.core.clone(); + let engine = state.engine.clone(); + let base = state.engine_base.clone(); + + let (history, model, engine_bin, conversation_id) = { + let db = core.db.lock().unwrap(); + let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "conversation not found".to_string())?; + let messages = sessions_db::list_messages(&db, &payload.conversation_id) + .map_err(|e| e.to_string())?; + let idx = messages + .iter() + .position(|m| m.id == payload.message_id && m.role == "assistant") + .ok_or_else(|| "assistant message not found".to_string())?; + let target = &messages[idx]; + let _ = sessions_db::save_message_version( + &db, + &target.id, + &target.content, + target.tokens_out, + target.elapsed_ms, + target.first_token_ms, + ); + let (rowid, _) = sessions_db::get_message_with_rowid(&db, &target.id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "message not found".to_string())?; + sessions_db::delete_messages_after(&db, &payload.conversation_id, rowid) + .map_err(|e| e.to_string())?; + let history = messages[..idx] + .iter() + .map(|m| ChatMessage { + role: m.role.clone(), + content: m.content.clone(), + images: m.images.clone(), + }) + .collect::>(); + let model_id = conversation + .model_id + .clone() + .ok_or_else(|| "conversation has no model".to_string())?; + let model = models_db::get(&db, &model_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "model not found".to_string())?; + let bin = settings_db::get(&db, "engine_bin") + .map_err(|e| e.to_string())? + .unwrap_or_default(); + (history, model, bin, payload.conversation_id.clone()) + }; + + let history = if model.kind == "local" { + sanitize_history_for_local(history) + } else { + history + }; + tokio::spawn(run_generation_and_stream( + app, + core, + engine, + base, + conversation_id, + history, + model, + engine_bin, + payload.params, + )); + Ok(()) +} + +/// 编辑用户提问并重新提交:清空该问题之后的所有生成内容。 +#[tauri::command] +pub async fn edit_message( + app: AppHandle, + state: State<'_, App>, + payload: EditMessagePayload, +) -> Result<(), String> { + let core = state.core.clone(); + let engine = state.engine.clone(); + let base = state.engine_base.clone(); + + let (history, model, engine_bin, conversation_id) = { + let db = core.db.lock().unwrap(); + let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "conversation not found".to_string())?; + let (rowid, msg) = sessions_db::get_message_with_rowid(&db, &payload.message_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "message not found".to_string())?; + if msg.role != "user" { + return Err("只能编辑用户消息".into()); + } + sessions_db::update_message_content(&db, &payload.message_id, &payload.content) + .map_err(|e| e.to_string())?; + sessions_db::delete_messages_after(&db, &payload.conversation_id, rowid) + .map_err(|e| e.to_string())?; + let history = sessions_db::list_messages(&db, &payload.conversation_id) + .map_err(|e| e.to_string())? + .into_iter() + .map(|m| ChatMessage { + role: m.role, + content: m.content, + images: m.images, + }) + .collect::>(); + let model_id = conversation + .model_id + .clone() + .ok_or_else(|| "conversation has no model".to_string())?; + let model = models_db::get(&db, &model_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "model not found".to_string())?; + let bin = settings_db::get(&db, "engine_bin") + .map_err(|e| e.to_string())? + .unwrap_or_default(); + (history, model, bin, payload.conversation_id.clone()) + }; + + let history = if model.kind == "local" { + sanitize_history_for_local(history) + } else { + history + }; + tokio::spawn(run_generation_and_stream( + app, + core, + engine, + base, + conversation_id, + history, + model, + engine_bin, + payload.params, + )); + Ok(()) +} + +#[tauri::command] +pub fn list_message_versions( + state: State<'_, App>, + message_id: String, +) -> Result, String> { + let db = state.core.db.lock().unwrap(); + sessions_db::list_message_versions(&db, &message_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn apply_message_version( + app: AppHandle, + state: State<'_, App>, + conversation_id: String, + message_id: String, + version_id: String, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + let version = sessions_db::get_message_version(&db, &version_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "version not found".to_string())?; + sessions_db::apply_message_version( + &db, + &message_id, + &version.content, + version.tokens_out, + version.elapsed_ms, + version.first_token_ms, + ) + .map_err(|e| e.to_string())?; + let message = sessions_db::get_message(&db, &message_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "message not found".to_string())?; + let _ = app.emit( + "chat://message-updated", + ChatMessageUpdatedEvent { + conversation_id, + message, + }, + ); Ok(()) } @@ -976,60 +1299,6 @@ pub async fn list_model_files( Ok(files) } -/// 统一的流式事件处理:转发 token、错误,完成后持久化助手消息。 -async fn drain_stream_and_persist( - app: AppHandle, - core: Arc, - conversation_id: String, - mut stream: futures::stream::BoxStream<'static, xianren_engine::Result>, -) { - use futures::StreamExt; - - let started = Instant::now(); - let mut full = String::new(); - while let Some(item) = stream.next().await { - match item { - Ok(text) => { - full.push_str(&text); - let _ = app.emit( - "chat://token", - ChatTokenEvent { - conversation_id: conversation_id.clone(), - text, - }, - ); - } - Err(e) => { - let _ = app.emit( - "chat://error", - ChatErrorEvent { - conversation_id: conversation_id.clone(), - message: e.to_string(), - }, - ); - return; - } - } - } - let elapsed_ms = started.elapsed().as_millis(); - let db = core.db.lock().unwrap(); - let message_id = - sessions_db::add_message(&db, &conversation_id, "assistant", &full, None, None) - .unwrap_or_default(); - let _ = sessions_db::touch_conversation(&db, &conversation_id); - let _ = app.emit( - "chat://done", - ChatDoneEvent { - conversation_id, - message_id, - content: full, - tokens_in: None, - tokens_out: None, - elapsed_ms, - }, - ); -} - /// 根据 repo_id 或 URL 推断下载模型应存放的子目录(owner/repo)。 fn derive_repo_subdir(payload: &DownloadPayload) -> String { if let Some(repo_id) = &payload.repo_id { diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index bcde759..2d65179 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -111,6 +111,10 @@ pub fn run() { commands::engine_status, commands::deploy_model, commands::chat_send, + commands::regenerate_message, + commands::edit_message, + commands::list_message_versions, + commands::apply_message_version, commands::download_enqueue, commands::server_start, commands::server_stop, diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 7c0ac46..fa2e853 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -82,6 +82,9 @@ fn open_db(path: &Path) -> Result { ensure_column(&conn, "models", "base_url", "TEXT")?; ensure_column(&conn, "models", "api_key", "TEXT")?; ensure_column(&conn, "models", "api_model", "TEXT")?; + ensure_column(&conn, "messages", "elapsed_ms", "INTEGER")?; + ensure_column(&conn, "messages", "first_token_ms", "INTEGER")?; + ensure_column(&conn, "messages", "images_json", "TEXT NOT NULL DEFAULT '[]'")?; Ok(conn) } diff --git a/crates/core/src/schema.sql b/crates/core/src/schema.sql index 3581640..d564b3b 100644 --- a/crates/core/src/schema.sql +++ b/crates/core/src/schema.sql @@ -38,5 +38,19 @@ CREATE TABLE IF NOT EXISTS messages ( content TEXT NOT NULL, tokens_in INTEGER, tokens_out INTEGER, + elapsed_ms INTEGER, + first_token_ms INTEGER, + images_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS message_versions ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + content TEXT NOT NULL, + tokens_out INTEGER, + elapsed_ms INTEGER, + first_token_ms INTEGER, + seq INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/crates/core/src/sessions.rs b/crates/core/src/sessions.rs index cb61ba7..1a2d75f 100644 --- a/crates/core/src/sessions.rs +++ b/crates/core/src/sessions.rs @@ -20,6 +20,21 @@ pub struct Message { pub content: String, pub tokens_in: Option, pub tokens_out: Option, + pub elapsed_ms: Option, + pub first_token_ms: Option, + pub images: Vec, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MessageVersion { + pub id: String, + pub message_id: String, + pub content: String, + pub tokens_out: Option, + pub elapsed_ms: Option, + pub first_token_ms: Option, + pub seq: i64, pub created_at: String, } @@ -82,20 +97,34 @@ pub fn add_message( content: &str, tokens_in: Option, tokens_out: Option, + elapsed_ms: Option, + first_token_ms: Option, + images: &[String], ) -> Result { let id = uuid::Uuid::new_v4().to_string(); + let images_json = serde_json::to_string(images).unwrap_or_else(|_| "[]".to_string()); db.execute( - "INSERT INTO messages (id, conversation_id, role, content, tokens_in, tokens_out) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![id, conversation_id, role, content, tokens_in, tokens_out], + "INSERT INTO messages (id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + id, + conversation_id, + role, + content, + tokens_in, + tokens_out, + elapsed_ms, + first_token_ms, + images_json + ], )?; Ok(id) } pub fn list_messages(db: &Connection, conversation_id: &str) -> Result> { let mut stmt = db.prepare( - "SELECT id, conversation_id, role, content, tokens_in, tokens_out, created_at - FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC", + "SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at + FROM messages WHERE conversation_id = ?1 ORDER BY rowid ASC", )?; let rows = stmt.query_map(params![conversation_id], row_to_message)?; let mut out = Vec::new(); @@ -105,6 +134,127 @@ pub fn list_messages(db: &Connection, conversation_id: &str) -> Result Result> { + let mut stmt = db.prepare( + "SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at + FROM messages WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![id], row_to_message)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn get_message_with_rowid(db: &Connection, id: &str) -> Result> { + let mut stmt = db.prepare( + "SELECT rowid, id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at + FROM messages WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![id], |row| { + let rowid: i64 = row.get(0)?; + let msg = row_to_message(row)?; + Ok((rowid, msg)) + })?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn update_message_content(db: &Connection, id: &str, content: &str) -> Result<()> { + db.execute( + "UPDATE messages SET content = ?1 WHERE id = ?2", + params![content, id], + )?; + Ok(()) +} + +pub fn update_message_stats( + db: &Connection, + id: &str, + tokens_out: Option, + elapsed_ms: Option, + first_token_ms: Option, +) -> Result<()> { + db.execute( + "UPDATE messages SET tokens_out = ?1, elapsed_ms = ?2, first_token_ms = ?3 WHERE id = ?4", + params![tokens_out, elapsed_ms, first_token_ms, id], + )?; + Ok(()) +} + +/// 删除某条消息(含)之后的所有消息。 +pub fn delete_messages_after(db: &Connection, conversation_id: &str, rowid: i64) -> Result<()> { + db.execute( + "DELETE FROM messages WHERE conversation_id = ?1 AND rowid >= ?2", + params![conversation_id, rowid], + )?; + Ok(()) +} + +pub fn save_message_version( + db: &Connection, + message_id: &str, + content: &str, + tokens_out: Option, + elapsed_ms: Option, + first_token_ms: Option, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let seq: i64 = db.query_row( + "SELECT COUNT(*) FROM message_versions WHERE message_id = ?1", + params![message_id], + |row| row.get::<_, i64>(0), + )? + 1; + db.execute( + "INSERT INTO message_versions (id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq], + )?; + Ok(id) +} + +pub fn list_message_versions(db: &Connection, message_id: &str) -> Result> { + let mut stmt = db.prepare( + "SELECT id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq, created_at + FROM message_versions WHERE message_id = ?1 ORDER BY seq ASC", + )?; + let rows = stmt.query_map(params![message_id], row_to_version)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +pub fn get_message_version(db: &Connection, version_id: &str) -> Result> { + let mut stmt = db.prepare( + "SELECT id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq, created_at + FROM message_versions WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![version_id], row_to_version)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn apply_message_version( + db: &Connection, + message_id: &str, + content: &str, + tokens_out: Option, + elapsed_ms: Option, + first_token_ms: Option, +) -> Result<()> { + db.execute( + "UPDATE messages SET content = ?1, tokens_out = ?2, elapsed_ms = ?3, first_token_ms = ?4 WHERE id = ?5", + params![content, tokens_out, elapsed_ms, first_token_ms, message_id], + )?; + Ok(()) +} + fn row_to_conversation(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Conversation { id: row.get(0)?, @@ -117,6 +267,7 @@ fn row_to_conversation(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let images_json: String = row.get(8)?; Ok(Message { id: row.get(0)?, conversation_id: row.get(1)?, @@ -124,7 +275,22 @@ fn row_to_message(row: &rusqlite::Row<'_>) -> rusqlite::Result { content: row.get(3)?, tokens_in: row.get(4)?, tokens_out: row.get(5)?, - created_at: row.get(6)?, + elapsed_ms: row.get(6)?, + first_token_ms: row.get(7)?, + images: serde_json::from_str(&images_json).unwrap_or_default(), + created_at: row.get(9)?, }) } +fn row_to_version(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(MessageVersion { + id: row.get(0)?, + message_id: row.get(1)?, + content: row.get(2)?, + tokens_out: row.get(3)?, + elapsed_ms: row.get(4)?, + first_token_ms: row.get(5)?, + seq: row.get(6)?, + created_at: row.get(7)?, + }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff849aa..7abb517 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -6,4 +6,4 @@ pub mod types; pub use error::{EngineError, Result}; pub use manager::EngineManager; pub use remote::{stream_chat_remote, RemoteConfig}; -pub use types::{ChatMessage, ChatRequest, EngineConfig, EngineStatus}; +pub use types::{ChatMessage, ChatRequest, ChatStreamEvent, EngineConfig, EngineStatus}; diff --git a/crates/engine/src/manager.rs b/crates/engine/src/manager.rs index 37e59a2..b352f6c 100644 --- a/crates/engine/src/manager.rs +++ b/crates/engine/src/manager.rs @@ -1,5 +1,5 @@ use crate::error::{EngineError, Result}; -use crate::types::{ChatRequest, EngineConfig, EngineStatus}; +use crate::types::{ChatRequest, ChatStreamEvent, EngineConfig, EngineStatus}; use futures::Stream; use std::process::Stdio; use std::sync::Arc; @@ -145,7 +145,7 @@ impl EngineManager { pub async fn stream_chat( &self, req: ChatRequest, - ) -> Result>> { + ) -> Result>> { let guard = self.inner.lock().await; let handle = guard.as_ref().ok_or(EngineError::NotRunning)?; let url = format!("{}/v1/chat/completions", handle.base_url); @@ -211,7 +211,7 @@ pub(crate) fn sse_text_stream( + Unpin + Send + 'static, -) -> futures::stream::BoxStream<'static, Result> { +) -> futures::stream::BoxStream<'static, Result> { use futures::StreamExt; Box::pin(async_stream::stream! { @@ -251,12 +251,26 @@ pub(crate) fn sse_text_stream( closed = true; break; } + if let Some(usage) = value.get("usage") { + let prompt_tokens = usage + .get("prompt_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let completion_tokens = usage + .get("completion_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + yield Ok(ChatStreamEvent::Usage { + prompt_tokens, + completion_tokens, + }); + } let content = value .pointer("/choices/0/delta/content") .and_then(|v| v.as_str()) .or_else(|| value.get("content").and_then(|v| v.as_str())); if let Some(text) = content { - yield Ok(text.to_string()); + yield Ok(ChatStreamEvent::Text(text.to_string())); } } Err(e) => { diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index 7e6da27..e29e496 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -1,6 +1,6 @@ use crate::error::{EngineError, Result}; use crate::manager::sse_text_stream; -use crate::types::ChatRequest; +use crate::types::{ChatRequest, ChatStreamEvent}; /// OpenAI 兼容的远程模型端点配置。 #[derive(Debug, Clone)] @@ -14,7 +14,7 @@ pub struct RemoteConfig { pub async fn stream_chat_remote( cfg: &RemoteConfig, req: ChatRequest, -) -> Result>> { +) -> Result>> { let base = normalize_base(&cfg.base_url); let url = format!("{base}/chat/completions"); let client = reqwest::Client::new(); @@ -41,4 +41,3 @@ fn normalize_base(base: &str) -> String { } s } - diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index 4c0f619..a49770b 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -1,10 +1,64 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize)] +use serde::ser::SerializeMap; + +#[derive(Debug, Clone, Deserialize)] pub struct ChatMessage { pub role: String, pub content: String, + /// 图片(data URL),非空时序列化为多模态 content 数组 + #[serde(default)] + pub images: Vec, +} + +impl ChatMessage { + pub fn new(role: impl Into, content: impl Into) -> Self { + Self { + role: role.into(), + content: content.into(), + images: Vec::new(), + } + } +} + +impl Serialize for ChatMessage { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("role", &self.role)?; + if self.images.is_empty() { + map.serialize_entry("content", &self.content)?; + } else { + let mut parts = Vec::new(); + if !self.content.trim().is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": self.content, + })); + } + for image in &self.images { + parts.push(serde_json::json!({ + "type": "image_url", + "image_url": { "url": image }, + })); + } + map.serialize_entry("content", &parts)?; + } + map.end() + } +} + +/// 流式聊天事件:增量文本 或 用量统计(部分引擎在最后一条 SSE 里给出)。 +#[derive(Debug, Clone)] +pub enum ChatStreamEvent { + Text(String), + Usage { + prompt_tokens: u32, + completion_tokens: u32, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,4 +111,3 @@ pub struct EngineStatus { pub ngl: Option, pub uptime_secs: Option, } - diff --git a/ui/src/api.ts b/ui/src/api.ts index 87d121a..ba1565f 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -71,6 +71,9 @@ export interface Message { content: string; tokens_in: number | null; tokens_out: number | null; + elapsed_ms: number | null; + first_token_ms: number | null; + images: string[]; created_at: string; } @@ -87,6 +90,7 @@ export interface ChatSendPayload { model_id: string; content: string; params: ChatParams; + images: string[]; } export interface ChatTokenEvent { @@ -100,7 +104,9 @@ export interface ChatDoneEvent { content: string; tokens_in: number | null; tokens_out: number | null; + tokens_estimated: boolean; elapsed_ms: number; + first_token_ms: number | null; } export interface ChatErrorEvent { @@ -108,6 +114,22 @@ export interface ChatErrorEvent { message: string; } +export interface ChatMessageUpdatedEvent { + conversation_id: string; + message: Message; +} + +export interface MessageVersion { + id: string; + message_id: string; + content: string; + tokens_out: number | null; + elapsed_ms: number | null; + first_token_ms: number | null; + seq: number; + created_at: string; +} + export interface EngineDeployEvent { model_id: string; file_name: string; @@ -167,6 +189,24 @@ export const api = { engineStop: () => invoke("engine_stop"), engineStatus: () => invoke("engine_status"), chatSend: (payload: ChatSendPayload) => invoke("chat_send", { payload }), + regenerateMessage: ( + conversationId: string, + messageId: string, + params: ChatParams, + ) => invoke("regenerate_message", { conversationId, messageId, params }), + editMessage: ( + conversationId: string, + messageId: string, + content: string, + params: ChatParams, + ) => invoke("edit_message", { conversationId, messageId, content, params }), + listMessageVersions: (messageId: string) => + invoke("list_message_versions", { messageId }), + applyMessageVersion: ( + conversationId: string, + messageId: string, + versionId: string, + ) => invoke("apply_message_version", { conversationId, messageId, versionId }), downloadEnqueue: ( url: string, fileName: string, diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index cd568e5..1a0cd1c 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -32,6 +32,9 @@ const paths: Record = { ), "chevron-left": , "chevron-right": , + paperclip: ( + + ), }; export default function Icon({ @@ -56,4 +59,3 @@ export default function Icon({ ); } - diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index d3d5223..7e5d066 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -5,18 +5,35 @@ import { api, ChatDoneEvent, ChatErrorEvent, + ChatMessageUpdatedEvent, ChatParams, ChatTokenEvent, + MessageVersion, onEvent, } from "../api"; import { useStore } from "../store"; import Icon from "../components/Icon"; +interface Attachment { + id: string; + kind: "image" | "text"; + name: string; + size: number; + dataUrl?: string; + text?: string; +} + interface LocalMessage { id: string; role: "user" | "assistant"; content: string; streaming?: boolean; + images?: string[]; + tokensIn?: number | null; + tokensOut?: number | null; + tokensEstimated?: boolean; + elapsedMs?: number | null; + firstTokenMs?: number | null; } const defaultParams: ChatParams = { @@ -27,10 +44,22 @@ const defaultParams: ChatParams = { ngl: 99, }; +const TEXT_EXTENSIONS = new Set([ + "txt", "md", "markdown", "json", "csv", "log", "py", "js", "ts", "tsx", "jsx", + "rs", "c", "cpp", "h", "hpp", "go", "java", "kt", "html", "css", "xml", "yaml", + "yml", "toml", "ini", "sh", "ps1", "bat", "sql", "env", "gitignore", +]); + +const MAX_IMAGE_SIZE = 3 * 1024 * 1024; +const MAX_TEXT_SIZE = 100 * 1024; + export default function ChatPage() { const models = useStore((s) => s.models); const conversations = useStore((s) => s.conversations); const refreshConversations = useStore((s) => s.refreshConversations); + const engine = useStore((s) => s.engine); + const deployStates = useStore((s) => s.deployStates); + const deployProgress = useStore((s) => s.deployProgress); const [activeConvId, setActiveConvId] = useState(null); const [messages, setMessages] = useState([]); @@ -42,7 +71,24 @@ export default function ChatPage() { const [rightOpen, setRightOpen] = useState( () => localStorage.getItem("xianren-right-open") !== "0", ); + const [editingId, setEditingId] = useState(null); + const [editingDraft, setEditingDraft] = useState(""); + const [versionsFor, setVersionsFor] = useState>({}); + const [attachments, setAttachments] = useState([]); const bottomRef = useRef(null); + const fileInputRef = useRef(null); + + const engineRunning = engine?.running ?? false; + const engineModel = engine?.model ?? null; + const selectedIsRemote = useMemo( + () => models.find((m) => m.id === selectedModelId)?.kind === "remote", + [models, selectedModelId], + ); + const engineLabel = useMemo(() => { + if (!models.length) return "无可用模型"; + const m = models.find((x) => x.id === selectedModelId); + return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型"; + }, [models, selectedModelId]); function toggleRight() { setRightOpen((v) => { @@ -92,6 +138,11 @@ export default function ChatPage() { id: e.message_id, role: "assistant", content: e.content, + tokensIn: e.tokens_in, + tokensOut: e.tokens_out, + tokensEstimated: e.tokens_estimated, + elapsedMs: e.elapsed_ms, + firstTokenMs: e.first_token_ms, }; } return updated; @@ -104,26 +155,65 @@ export default function ChatPage() { setStreaming(false); setError(e.message); }); + const un4 = onEvent("chat://message-updated", (e) => { + if (e.conversation_id !== activeConvId) return; + const m = e.message; + setMessages((prev) => + prev.map((x) => + x.id === m.id + ? { + ...x, + content: m.content, + tokensIn: m.tokens_in, + tokensOut: m.tokens_out, + elapsedMs: m.elapsed_ms, + firstTokenMs: m.first_token_ms, + images: m.images, + } + : x, + ), + ); + }); return () => { un1.then((f) => f()); un2.then((f) => f()); un3.then((f) => f()); + un4.then((f) => f()); }; }, [activeConvId, refreshConversations]); + function appendStreamingPlaceholder() { + setMessages((prev) => [ + ...prev, + { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true }, + ]); + } + async function handleSend() { - const content = input.trim(); - if (!content || streaming) return; + const textParts = attachments.filter((a) => a.kind === "text"); + let content = input.trim(); + if (textParts.length > 0) { + const blocks = textParts.map( + (a) => `\n\n--- 附件:${a.name} ---\n\`\`\`\n${a.text}\n\`\`\``, + ); + content += blocks.join("\n"); + } + const images = attachments + .filter((a) => a.kind === "image" && a.dataUrl) + .map((a) => a.dataUrl!); + if (!content.trim() && images.length === 0) return; + if (streaming) return; if (!selectedModelId) { - setError("请先在模型库导入一个模型"); + setError("请先在模型管理添加一个模型"); return; } setError(null); setInput(""); + setAttachments([]); let convId = activeConvId; if (!convId) { - const conv = await api.createConversation(content.slice(0, 30)); + const conv = await api.createConversation(content.slice(0, 30) || "新会话"); convId = conv.id; setActiveConvId(convId); await refreshConversations(); @@ -131,7 +221,7 @@ export default function ChatPage() { setMessages((prev) => [ ...prev, - { id: `user-${Date.now()}`, role: "user", content }, + { id: `user-${Date.now()}`, role: "user", content, images }, { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true }, ]); setStreaming(true); @@ -142,6 +232,7 @@ export default function ChatPage() { model_id: selectedModelId, content, params, + images, }); } catch (e) { setStreaming(false); @@ -149,11 +240,96 @@ export default function ChatPage() { } } + async function handleRegenerate(msg: LocalMessage) { + if (!activeConvId || streaming) return; + setError(null); + const idx = messages.findIndex((m) => m.id === msg.id); + if (idx < 0) return; + setMessages((prev) => [...prev.slice(0, idx)]); + appendStreamingPlaceholder(); + setStreaming(true); + try { + await api.regenerateMessage(activeConvId, msg.id, params); + } catch (e) { + setStreaming(false); + setError(String(e)); + } + } + + async function handleEditSave(msg: LocalMessage) { + if (!activeConvId || streaming) return; + const content = editingDraft.trim(); + if (!content) return; + setError(null); + setEditingId(null); + const idx = messages.findIndex((m) => m.id === msg.id); + setMessages((prev) => + prev.map((m, i) => (i === idx ? { ...m, content } : m)).slice(0, idx + 1), + ); + appendStreamingPlaceholder(); + setStreaming(true); + try { + await api.editMessage(activeConvId, msg.id, content, params); + } catch (e) { + setStreaming(false); + setError(String(e)); + } + } + + async function handleToggleVersions(msg: LocalMessage) { + try { + const list = await api.listMessageVersions(msg.id); + setVersionsFor((prev) => ({ ...prev, [msg.id]: list })); + } catch (e) { + alert(String(e)); + } + } + + async function handleApplyVersion(msg: LocalMessage, version: MessageVersion) { + if (!activeConvId) return; + try { + await api.applyMessageVersion(activeConvId, msg.id, version.id); + setVersionsFor((prev) => ({ ...prev, [msg.id]: [] })); + } catch (e) { + alert(String(e)); + } + } + + async function handleFiles(files: FileList | File[]) { + for (const file of Array.from(files)) { + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + if (file.type.startsWith("image/") || ["png", "jpg", "jpeg", "webp", "gif", "bmp"].includes(ext)) { + if (file.size > MAX_IMAGE_SIZE) { + setError(`图片 ${file.name} 超过 3MB,已跳过`); + continue; + } + const dataUrl = await readAsDataUrl(file); + setAttachments((prev) => [ + ...prev, + { id: crypto.randomUUID(), kind: "image", name: file.name, size: file.size, dataUrl }, + ]); + } else if (file.type.startsWith("text/") || TEXT_EXTENSIONS.has(ext)) { + if (file.size > MAX_TEXT_SIZE) { + setError(`文件 ${file.name} 超过 100KB,已跳过`); + continue; + } + const text = await file.text(); + setAttachments((prev) => [ + ...prev, + { id: crypto.randomUUID(), kind: "text", name: file.name, size: file.size, text }, + ]); + } else { + setError(`不支持的文件类型:${file.name}(支持图片与文本类文件)`); + } + } + } + async function handleNewConversation() { const conv = await api.createConversation("新会话"); setActiveConvId(conv.id); setMessages([]); setError(null); + setVersionsFor({}); await refreshConversations(); } @@ -165,9 +341,15 @@ export default function ChatPage() { id: m.id, role: m.role as "user" | "assistant", content: m.content, + images: m.images, + tokensIn: m.tokens_in, + tokensOut: m.tokens_out, + elapsedMs: m.elapsed_ms, + firstTokenMs: m.first_token_ms, })), ); setError(null); + setVersionsFor({}); } async function handleDeleteConversation(id: string) { @@ -179,20 +361,6 @@ export default function ChatPage() { await refreshConversations(); } - const engineRunning = useStore((s) => s.engine?.running ?? false); - const engineModel = useStore((s) => s.engine?.model ?? null); - const deployStates = useStore((s) => s.deployStates); - const deployProgress = useStore((s) => s.deployProgress); - const selectedIsRemote = useMemo( - () => models.find((m) => m.id === selectedModelId)?.kind === "remote", - [models, selectedModelId], - ); - const engineLabel = useMemo(() => { - if (!models.length) return "无可用模型"; - const m = models.find((x) => x.id === selectedModelId); - return m ? `${m.file_name}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型"; - }, [models, selectedModelId]); - return (
@@ -204,7 +372,9 @@ export default function ChatPage() {
handleLoadConversation(c.id)} > @@ -230,31 +400,29 @@ export default function ChatPage() {
💬
选择左侧会话,或直接开始新的对话。
- 首次发送会自动启动推理引擎,需要已配置 llama-server 且导入模型 + 支持上传/粘贴图片与文本文件(本地文本模型仅支持文本文件)
) : null} {messages.map((m) => ( -
-
- {m.role === "user" ? ( - m.content - ) : m.content ? ( - {m.content} - ) : ( - - - 思考中… - - )} -
-
+ { + setEditingId(msg.id); + setEditingDraft(msg.content); + }} + onEditDraftChange={setEditingDraft} + onEditCancel={() => setEditingId(null)} + onEditSave={handleEditSave} + onRegenerate={handleRegenerate} + onToggleVersions={handleToggleVersions} + onApplyVersion={handleApplyVersion} + /> ))} {error ? (
@@ -265,10 +433,53 @@ export default function ChatPage() {
+ {attachments.length > 0 ? ( +
+ {attachments.map((a) => ( +
+ {a.kind === "image" ? ( + {a.name} + ) : ( + + )} + {a.name} + +
+ ))} +
+ ) : null}
+ + { + if (e.target.files) handleFiles(e.target.files); + e.target.value = ""; + }} + />