diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ba51135 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# 项目维护约定 + +## 项目简介 + +Xianren Studio(仙人工作室):Tauri 2 + React 的 Windows 本地大模型桌面应用(对标 LM Studio)。功能与代码结构见: + +- `docs/FEATURES.md` —— 功能记录(权威,含改动记录) +- `docs/ARCHITECTURE.md` —— 代码组织与布局、数据流、事件通道、数据库结构 + +## 强制约定 + +**每次改动功能后,必须同步更新文档:** + +1. 功能新增 / 修改 / 删除 → 更新 `docs/FEATURES.md` 中对应章节,并在「改动记录」追加一条(日期 + 内容)。 +2. 涉及代码结构、模块职责、数据流、事件通道、数据库表/字段 → 更新 `docs/ARCHITECTURE.md` 对应章节。 +3. 涉及技术栈、目录结构、构建方式 → 同步更新 `README.md`。 + +## 构建命令 + +```powershell +# 开发模式 +npm --prefix apps/desktop run dev + +# 正式版(必须带 custom-protocol 特性,否则前端不内嵌) +npm run build --prefix ui +cargo build --release -p xianren-desktop --features custom-protocol +``` + +## 常见注意事项 + +- 数据库旧库迁移依赖 `crates/core/src/app.rs` 中 `ensure_column` 增量补列;新增列时同时更新 `schema.sql` 与 `ensure_column` 清单。 +- 新增 Tauri 命令必须在 `apps/desktop/src/lib.rs` 的 `invoke_handler` 注册,并在 `ui/src/api.ts` 封装。 +- 新增前后端事件必须成对:后端 `emit` 与前端 `onEvent`,并在 `docs/ARCHITECTURE.md` 事件通道表登记。 +- 数据目录在 `%APPDATA%\XianrenStudio`(SQLite:`xianren.db`)。 diff --git a/README.md b/README.md index 0b9d229..4bde102 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,14 @@ ## 当前功能 -- **模型管理**:启动时自动扫描模型目录(可手动重新扫描),支持导入本地 GGUF、添加 OpenAI 兼容在线 API 模型;本地模型可一键「部署」后台加载,部署成功后对话页可直接选用 +- **模型管理**:启动时自动扫描模型目录(可手动重新扫描),支持导入本地 GGUF、添加 OpenAI 兼容在线 API 模型(需「启用」后才会出现在对话页可选列表);本地模型可一键「部署」后台加载并查看进度 - **模型广场**:搜索 Hugging Face / ModelScope 上的 GGUF 模型,按量化版本一键下载(ModelScope 文件自动附带 SHA256 校验),下载进度实时显示 -- **聊天**:流式输出、Markdown/代码高亮、采样参数调节、多会话管理;本地模型与在线 API 模型统一入口 +- **聊天**:流式输出、Markdown/代码高亮、采样参数调节、多会话管理;右侧栏「可选大模型服务」只列出有部署状态的大模型(运行中/启动中/出错,彩色状态点)与已启用的在线 API 模型,模型名不显示 `.gguf` 后缀;每条回答下方显示所用模型名;回答完成后自动预测用户可能说的话(可点击填入输入框,条数与开关可在设置调整);切换选项卡后保持会话与大模型等状态 +- **对话细节**:空会话复用(已有无消息的新建对话时不再新建)、回答重新生成/版本历史/编辑重提、思考过程展示、会话置顶收藏与导入导出 - **本地 API 服务**:OpenAI 兼容端点(/v1/models、/v1/chat/completions、/v1/embeddings),仅本机监听,可选 API Key +完整功能清单见 [docs/FEATURES.md](./docs/FEATURES.md),代码组织与数据流见 [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)。 + ## 技术栈 - 桌面壳:Tauri 2(Rust)+ WebView2 @@ -27,7 +30,7 @@ crates/download 分片断点续传下载器 crates/api OpenAI 兼容本地 API 服务(axum) ui/ React 前端 scripts/ 构建/下载脚本 -docs/ 产品与技术方案 +docs/ 产品与技术方案、功能记录(FEATURES.md)、代码架构(ARCHITECTURE.md) ``` ## 环境要求(Windows) @@ -80,6 +83,7 @@ cargo build --release -p xianren-desktop --features custom-protocol | `scripts/build-llama.ps1` | 从源码编译 llama.cpp(需要 CMake,可选 CUDA/Vulkan) | | `scripts/download-test-model.ps1` | 下载迷你 GGUF 测试模型(支持 HF 镜像/ModelScope) | | `scripts/generate-icons.ps1` | 重新生成应用图标 | +| `scripts/test_mcp_server.py` | 本地 MCP 测试服务器(Streamable HTTP,提供时间/回声工具,用于验证工具页与对话工具调用) | ## 引擎与模型目录 diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 42696c8..424170e 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -1,4 +1,6 @@ use crate::App; +use crate::mcp_client; +use crate::tools::{self, MarkerFilter, ToolDef}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -8,8 +10,10 @@ use tauri::{AppHandle, Emitter, State}; use tokio::sync::RwLock; use xianren_api::ApiState; use xianren_core::models as models_db; +use xianren_core::mcp_servers as mcp_db; use xianren_core::sessions as sessions_db; use xianren_core::settings as settings_db; +use xianren_core::skills as skills_db; use xianren_core::{CoreApp, ModelInfo, Message}; use xianren_engine::{ChatMessage, ChatRequest, EngineConfig, EngineManager}; use xianren_engine::remote::RemoteConfig; @@ -54,7 +58,10 @@ pub struct ChatTokenEvent { pub struct ChatDoneEvent { pub conversation_id: String, pub message_id: String, + pub model_id: String, pub content: String, + pub reasoning: String, + pub stopped: bool, pub tokens_in: Option, pub tokens_out: Option, pub tokens_estimated: bool, @@ -62,6 +69,39 @@ pub struct ChatDoneEvent { pub first_token_ms: Option, } +#[derive(Serialize, Clone)] +pub struct ChatSuggestionsEvent { + pub conversation_id: String, + pub message_id: String, + pub suggestions: Vec, +} + +#[derive(Serialize, Clone)] +pub struct ChatToolStatusEvent { + pub conversation_id: String, + pub label: String, + pub status: String, +} + +#[derive(Serialize, Clone)] +pub struct ChatTitleUpdatedEvent { + pub conversation_id: String, + pub title: String, +} + +#[derive(Serialize, Clone)] +pub struct ChatReasoningEvent { + pub conversation_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TavilyResult { + pub title: String, + pub url: String, + pub content: String, +} + #[derive(Deserialize)] pub struct RegeneratePayload { pub conversation_id: String, @@ -126,6 +166,37 @@ pub struct DownloadProgressEvent { pub status: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecommendedFile { + pub path: String, + #[serde(default)] + pub quant: Option, + #[serde(default)] + pub size_gb: Option, + #[serde(default)] + pub size: Option, + /// 可选的下载链接列表(多个链接时可选择镜像下载) + #[serde(default)] + pub links: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecommendedModel { + pub id: String, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub params: Option, + #[serde(default)] + pub architecture: Option, + #[serde(default)] + pub vision: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub files: Vec, +} + #[tauri::command] pub fn app_info(state: State<'_, App>) -> AppInfo { let db = state.core.db.lock().unwrap(); @@ -194,6 +265,13 @@ pub fn remove_model(state: State<'_, App>, id: String) -> Result<(), String> { models_db::remove(&db, &id).map_err(|e| e.to_string()) } +/// 启用/停用模型(在线 API 模型需启用后才会出现在聊天页可选列表)。 +#[tauri::command] +pub fn set_model_enabled(state: State<'_, App>, id: String, enabled: bool) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + models_db::set_enabled(&db, &id, enabled).map_err(|e| e.to_string()) +} + #[tauri::command] pub fn settings_get(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); @@ -207,6 +285,148 @@ pub fn settings_set(state: State<'_, App>, key: String, value: String) -> Result settings_db::set(&db, &key, &value).map_err(|e| e.to_string()) } +/// 读取推荐模型列表目录下所有 JSON 文件,返回合并后的推荐模型数组。 +/// 目录为空时自动写入软件自带的默认推荐列表。 +#[tauri::command] +pub fn list_recommended_models(state: State<'_, App>) -> Result, String> { + let dir = resolve_recommend_dir(&state.core); + let mut json_files = read_json_files(&dir)?; + if json_files.is_empty() { + let default_path = dir.join("default_recommendations.json"); + std::fs::write(&default_path, include_str!("default_recommendations.json")) + .map_err(|e| format!("写入默认推荐列表失败:{e}"))?; + json_files.push(default_path); + } + let mut out = Vec::new(); + for path in json_files { + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("读取 {} 失败:{e}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| format!("解析 {} 失败:{e}", path.display()))?; + let models = value + .get("models") + .and_then(|v| v.as_array()) + .or_else(|| value.as_array()); + if let Some(arr) = models { + for item in arr { + if let Ok(m) = serde_json::from_value::(item.clone()) { + out.push(m); + } + } + } + } + Ok(out) +} + +/// 手动上传推荐模型列表 JSON 到配置目录。 +#[tauri::command] +pub fn import_recommendations( + state: State<'_, App>, + file_name: String, + content: String, +) -> Result<(), String> { + let name = file_name + .rsplit(['/', '\\']) + .next() + .unwrap_or(&file_name) + .trim() + .to_string(); + if !name.to_lowercase().ends_with(".json") { + return Err("仅支持 .json 文件".into()); + } + let value: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| format!("JSON 解析失败:{e}"))?; + let has_models = value + .get("models") + .map(|m| m.is_array()) + .unwrap_or(false) + || value.is_array(); + if !has_models { + return Err("JSON 中缺少 models 数组,请检查文件格式".into()); + } + let dir = resolve_recommend_dir(&state.core); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = dir.join(&name); + std::fs::write(&path, content.as_bytes()).map_err(|e| format!("保存失败:{e}"))?; + Ok(()) +} + +/// 实时抓取模型详情页的原始 HTML(用于模型广场“详情”弹窗展示)。 +#[tauri::command] +pub async fn fetch_model_page( + state: State<'_, App>, + repo_id: String, + source: String, +) -> Result { + let page_url = if source.eq_ignore_ascii_case("modelscope") { + format!("https://modelscope.cn/models/{repo_id}") + } else { + let db = state.core.db.lock().unwrap(); + let endpoint = settings_db::get(&db, "hf_endpoint") + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "https://hf-mirror.com".to_string()); + drop(db); + format!("{}/{}", endpoint.trim_end_matches('/'), repo_id) + }; + + let client = reqwest::Client::new(); + let resp = client + .get(&page_url) + .header("User-Agent", "xianren-studio") + .send() + .await + .map_err(|e| format!("请求失败:{e}"))?; + if !resp.status().is_success() { + return Err(format!("页面请求失败:HTTP {}", resp.status())); + } + let html = resp.text().await.map_err(|e| format!("读取页面失败:{e}"))?; + + // 注入 ,让 iframe 内的相对资源(CSS/JS/图片)按原站解析 + let base_tag = format!("", page_url.trim_end_matches('/')); + let lower = html.to_lowercase(); + let with_base = if let Some(pos) = lower.find("') + .map(|p| pos + p + 1) + .unwrap_or(html.len()); + let mut s = html.clone(); + s.insert_str(after, &base_tag); + s + } else { + format!("{base_tag}{html}") + }; + Ok(with_base) +} + +/// 解析推荐模型列表目录(设置优先,缺省为数据目录下 recommendations)。 +fn resolve_recommend_dir(core: &CoreApp) -> PathBuf { + let db = core.db.lock().unwrap(); + settings_db::get(&db, "recommend_dir") + .ok() + .flatten() + .map(PathBuf::from) + .unwrap_or_else(|| core.data_dir.join("recommendations")) +} + +/// 列出目录下所有 .json 文件(排序)。 +fn read_json_files(dir: &PathBuf) -> Result, String> { + std::fs::create_dir_all(dir).map_err(|e| format!("创建目录失败:{e}"))?; + let entries = std::fs::read_dir(dir).map_err(|e| format!("读取目录失败:{e}"))?; + let mut files = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.extension() + .map(|ext| ext.eq_ignore_ascii_case("json")) + .unwrap_or(false) + }) + .collect::>(); + files.sort(); + Ok(files) +} + #[tauri::command] pub fn list_conversations(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); @@ -219,8 +439,17 @@ pub fn create_conversation( title: String, model_id: Option, ) -> Result { - let id = uuid::Uuid::new_v4().to_string(); let db = state.core.db.lock().unwrap(); + // 若已存在还没有任何消息的空会话,直接复用,不再新建 + if let Some(existing) = sessions_db::find_empty_conversation(&db).map_err(|e| e.to_string())? { + if let Some(mid) = &model_id { + let _ = sessions_db::update_conversation_model(&db, &existing.id, mid); + } + return sessions_db::get_conversation(&db, &existing.id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "conversation not found".to_string()); + } + let id = uuid::Uuid::new_v4().to_string(); sessions_db::create_conversation(&db, &id, &title, model_id.as_deref(), None) .map_err(|e| e.to_string())?; sessions_db::get_conversation(&db, &id) @@ -228,6 +457,85 @@ pub fn create_conversation( .ok_or_else(|| "conversation not found".to_string()) } +#[derive(Deserialize)] +pub struct ImportedMessage { + pub role: String, + pub content: String, + #[serde(default)] + pub images: Vec, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Deserialize)] +pub struct ImportConversationPayload { + pub title: String, + #[serde(default)] + pub model_id: Option, + #[serde(default)] + pub messages: Vec, +} + +#[tauri::command] +pub fn rename_conversation( + state: State<'_, App>, + id: String, + title: String, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + sessions_db::update_conversation_title(&db, &id, &title).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_conversation_pinned( + state: State<'_, App>, + id: String, + pinned: bool, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + sessions_db::set_conversation_pinned(&db, &id, pinned).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_conversation_favorite( + state: State<'_, App>, + id: String, + favorite: bool, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + sessions_db::set_conversation_favorite(&db, &id, favorite).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn import_conversation( + state: State<'_, App>, + payload: ImportConversationPayload, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let title = if payload.title.trim().is_empty() { + "导入对话".to_string() + } else { + payload.title.trim().to_string() + }; + let db = state.core.db.lock().unwrap(); + sessions_db::create_conversation(&db, &id, &title, payload.model_id.as_deref(), None) + .map_err(|e| e.to_string())?; + for m in &payload.messages { + sessions_db::import_message( + &db, + &id, + &m.role, + &m.content, + &m.images, + m.created_at.as_deref(), + ) + .map_err(|e| e.to_string())?; + } + sessions_db::get_conversation(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "conversation not found".to_string()) +} + #[tauri::command] pub fn delete_conversation(state: State<'_, App>, id: String) -> Result<(), String> { let db = state.core.db.lock().unwrap(); @@ -378,7 +686,7 @@ pub async fn deploy_model( tokio::spawn(async move { let cfg = EngineConfig { binary_path: bin, - model_path: PathBuf::from(model.file_path), + model_path: PathBuf::from(model.file_path.clone()), host: "127.0.0.1".into(), ctx_size: params.ctx_size, ngl: params.ngl, @@ -467,7 +775,16 @@ pub async fn chat_send( let engine = state.engine.clone(); let base = state.engine_base.clone(); - let (history, model, engine_bin, user_message_id) = { + let ( + history, + model, + engine_bin, + user_message_id, + conversation_tools, + tavily_key, + search_enabled, + tool_defs, + ) = { let db = core.db.lock().unwrap(); if sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? @@ -490,6 +807,7 @@ pub async fn chat_send( &db, &payload.conversation_id, "user", + Some(&payload.model_id), &payload.content, None, None, @@ -515,33 +833,188 @@ pub async fn chat_send( let bin = settings_db::get(&db, "engine_bin") .map_err(|e| e.to_string())? .unwrap_or_default(); - (history, model, bin, user_message_id) + 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 tavily_key = settings_db::get(&db, "tavily_api_key") + .ok() + .flatten() + .unwrap_or_default(); + let search_enabled = settings_db::get(&db, "tool_web_search_enabled") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false); + let tool_defs = resolve_conversation_tool_defs(&db, &conversation.tools); + ( + history, + model, + bin, + user_message_id, + conversation.tools.clone(), + tavily_key, + search_enabled, + tool_defs, + ) }; let conversation_id = payload.conversation_id.clone(); - let history = if model.kind == "local" { + let mut history = if model.kind == "local" { sanitize_history_for_local(history) } else { history }; - tokio::spawn(run_generation_and_stream( - app, - core, - engine, - base, - conversation_id.clone(), - history, - model, - engine_bin, - payload.params, - )); + // 注入可用工具说明(技能 / MCP) + if !tool_defs.is_empty() { + let sys = tools::build_tools_system_prompt(&tool_defs); + if !sys.is_empty() { + history.insert(0, ChatMessage::new("system", sys)); + } + } + + // 联网搜索工具:会话启用了 web_search、全局开关开启且已配置 API Key 时,自动搜索并注入上下文 + if conversation_tools.iter().any(|t| t == "web_search") + && search_enabled + && !tavily_key.trim().is_empty() + { + let query: String = payload.content.chars().take(300).collect(); + match tavily_search(&tavily_key, &query).await { + Ok(results) if !results.is_empty() => { + let ctx = format_search_context(&results); + if let Some(last) = history.iter_mut().rev().find(|m| m.role == "user") { + let original = last.content.clone(); + last.content = format!("[联网搜索结果]\n{ctx}\n\n用户问题:{original}"); + } + } + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "web search failed"), + } + } + + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let cancel_flags = state.cancel_flags.clone(); + let flag_conv = conversation_id.clone(); + let task_conv = conversation_id.clone(); + cancel_flags.lock().await.insert(flag_conv.clone(), cancel_tx); + tokio::spawn(async move { + let _ = run_generation_and_stream( + app, + core, + engine, + base, + task_conv, + history, + model, + engine_bin, + payload.params, + tool_defs, + cancel_rx, + ) + .await; + cancel_flags.lock().await.remove(&flag_conv); + }); Ok(serde_json::json!({ "conversation_id": conversation_id, "message_id": user_message_id, })) } +/// 停止指定会话正在进行的生成。 +#[tauri::command] +pub async fn chat_stop(state: State<'_, App>, conversation_id: String) -> Result<(), String> { + let flags = state.cancel_flags.lock().await; + if let Some(tx) = flags.get(&conversation_id) { + let _ = tx.send(true); + } + Ok(()) +} + +/// 设置会话启用的工具列表。 +#[tauri::command] +pub fn set_conversation_tools( + state: State<'_, App>, + id: String, + tools: Vec, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + sessions_db::update_conversation_tools(&db, &id, &tools).map_err(|e| e.to_string()) +} + +/// 手动触发一次 Tavily 联网搜索(用于工具页测试与展示)。 +#[tauri::command] +pub async fn web_search( + state: State<'_, App>, + query: String, +) -> Result, String> { + let api_key = { + let db = state.core.db.lock().unwrap(); + settings_db::get(&db, "tavily_api_key") + .ok() + .flatten() + .unwrap_or_default() + }; + if api_key.trim().is_empty() { + return Err("未配置 Tavily API Key,请先在“工具”页填写".into()); + } + tavily_search(&api_key, &query).await +} + +/// 调用 Tavily Search API 搜索并返回结构化结果。 +async fn tavily_search(api_key: &str, query: &str) -> Result, String> { + let client = reqwest::Client::new(); + let resp = client + .post("https://api.tavily.com/search") + .json(&serde_json::json!({ + "api_key": api_key, + "query": query, + "max_results": 5, + "search_depth": "basic", + })) + .send() + .await + .map_err(|e| format!("Tavily 请求失败:{e}"))?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Tavily 返回 {}:{}", status, body)); + } + let value: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Tavily 响应解析失败:{e}"))?; + let results = value + .get("results") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + Ok(results + .into_iter() + .filter_map(|r| { + Some(TavilyResult { + title: r.get("title")?.as_str()?.to_string(), + url: r.get("url")?.as_str()?.to_string(), + content: r.get("content")?.as_str()?.to_string(), + }) + }) + .collect()) +} + +/// 把搜索结果格式化为注入模型的参考资料文本。 +fn format_search_context(results: &[TavilyResult]) -> String { + let mut out = String::new(); + for (i, r) in results.iter().take(5).enumerate() { + out.push_str(&format!( + "{}. {}\n 链接:{}\n {}\n", + i + 1, + r.title, + r.url, + truncate_text(&r.content, 300) + )); + } + out +} + /// 统一的生成与流式转发:支持本地引擎 / 远程 API,统计首字延迟、token 数与耗时。 async fn run_generation_and_stream( app: AppHandle, @@ -553,7 +1026,16 @@ async fn run_generation_and_stream( model: ModelInfo, engine_bin: String, params: ChatParams, + tools: Vec, + mut cancel_rx: tokio::sync::watch::Receiver, ) { + // 首轮对话标记与首条用户内容:供生成完成后自动生成标题使用 + let is_first_round = history.len() == 1; + let first_user = history + .first() + .map(|m| m.content.clone()) + .unwrap_or_default(); + // 本地模型:确保引擎已运行 if model.kind == "local" && !engine.status().await.running { let bin = PathBuf::from(&engine_bin); @@ -569,7 +1051,7 @@ async fn run_generation_and_stream( } let cfg = EngineConfig { binary_path: bin, - model_path: PathBuf::from(model.file_path), + model_path: PathBuf::from(model.file_path.clone()), host: "127.0.0.1".into(), ctx_size: params.ctx_size, ngl: params.ngl, @@ -597,6 +1079,8 @@ async fn run_generation_and_stream( } else { model.repo_id.clone() }; + let suggest_history = history.clone(); + let tool_upstream = upstream_model.clone(); let req = ChatRequest { model: upstream_model.clone(), messages: history, @@ -637,51 +1121,170 @@ async fn run_generation_and_stream( let mut prompt_tokens: Option = None; let mut completion_tokens: Option = None; let mut full = String::new(); + let mut reasoning_full = String::new(); + let mut stopped = false; + let mut marker_filter = MarkerFilter::new(); + let use_tools = !tools.is_empty(); - 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); + loop { + tokio::select! { + item = stream.next() => { + match item { + Some(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 visible = if use_tools { + marker_filter.push(&text) + } else { + text + }; + if visible.is_empty() { + continue; + } + let _ = app.emit( + "chat://token", + ChatTokenEvent { + conversation_id: conversation_id.clone(), + text: visible, + }, + ); + } + Some(Ok(xianren_engine::ChatStreamEvent::Reasoning(text))) => { + reasoning_full.push_str(&text); + let _ = app.emit( + "chat://reasoning", + ChatReasoningEvent { + conversation_id: conversation_id.clone(), + text, + }, + ); + } + Some(Ok(xianren_engine::ChatStreamEvent::Usage { + prompt_tokens: p, + completion_tokens: c, + })) => { + prompt_tokens = Some(p as i64); + completion_tokens = Some(c as i64); + } + Some(Err(e)) => { + let _ = app.emit( + "chat://error", + ChatErrorEvent { + conversation_id: conversation_id.clone(), + message: e.to_string(), + }, + ); + return; + } + None => break, } - full.push_str(&text); + } + changed = cancel_rx.changed() => { + if changed.is_err() || *cancel_rx.borrow() { + stopped = true; + break; + } + } + } + } + let leftover = marker_filter.flush(); + if use_tools && !leftover.is_empty() { + let _ = app.emit( + "chat://token", + ChatTokenEvent { + conversation_id: conversation_id.clone(), + text: leftover, + }, + ); + } + + // 工具循环:检测模型输出中的工具标记,执行工具并把结果回填给模型继续生成(最多 3 轮) + let mut raw_text = full.clone(); + let mut final_content = if use_tools { + tools::strip_markers(&full) + } else { + full.clone() + }; + if use_tools { + let tool_history = suggest_history.clone(); + for _ in 0..3 { + let markers: Vec = tools::extract_markers(&raw_text) + .into_iter() + .filter(|m| tools::parse_marker(m).is_some()) + .collect(); + if markers.is_empty() { + break; + } + for m in &markers { let _ = app.emit( - "chat://token", - ChatTokenEvent { + "chat://tool-status", + ChatToolStatusEvent { conversation_id: conversation_id.clone(), - text, + label: m.clone(), + status: "running".into(), }, ); } - 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.clone(), - message: e.to_string(), - }, - ); - return; + let mut call = |msgs: Vec| { + let req = ChatRequest { + model: tool_upstream.clone(), + messages: msgs, + temperature: Some(params.temperature), + top_p: Some(params.top_p), + max_tokens: Some(params.max_tokens), + stream: false, + }; + let model2 = model.clone(); + let engine2 = engine.clone(); + let upstream2 = tool_upstream.clone(); + async move { + if model2.kind == "remote" { + let cfg = RemoteConfig { + base_url: model2.base_url.clone().unwrap_or_default(), + api_key: model2.api_key.clone(), + model: upstream2, + }; + xianren_engine::chat_remote(&cfg, req) + .await + .map_err(|e| e.to_string()) + } else { + engine2.chat(req).await.map_err(|e| e.to_string()) + } + } + }; + match tools::run_tool_round(&mut call, &tool_history, &raw_text, &tools).await { + Ok((new_raw, new_clean)) => { + raw_text = new_raw; + final_content = new_clean; + } + Err(e) => { + tracing::warn!(conversation_id = %conversation_id, error = %e, "tool round failed"); + break; + } } + let _ = app.emit( + "chat://tool-status", + ChatToolStatusEvent { + conversation_id: conversation_id.clone(), + label: "tools".into(), + status: "done".into(), + }, + ); } } 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 tokens_out = completion_tokens.or_else(|| Some(estimate_tokens(&final_content) as i64)); let db = core.db.lock().unwrap(); let message_id = sessions_db::add_message( &db, &conversation_id, "assistant", - &full, + Some(&model.id), + &final_content, prompt_tokens, tokens_out, Some(elapsed_ms as i64), @@ -691,12 +1294,16 @@ async fn run_generation_and_stream( .unwrap_or_default(); let _ = sessions_db::touch_conversation(&db, &conversation_id); drop(db); + let first_assistant = final_content.clone(); let _ = app.emit( "chat://done", ChatDoneEvent { - conversation_id, - message_id, - content: full, + conversation_id: conversation_id.clone(), + message_id: message_id.clone(), + model_id: model.id.clone(), + content: final_content, + reasoning: reasoning_full, + stopped, tokens_in: prompt_tokens, tokens_out, tokens_estimated, @@ -704,6 +1311,44 @@ async fn run_generation_and_stream( first_token_ms, }, ); + + // 自动生成会话标题(设置中可关闭):回复生成完成后,调用模型生成标题并覆盖会话标题 + let title_app = app.clone(); + let title_core = core.clone(); + let title_engine = engine.clone(); + let title_model = model.clone(); + let suggest_answer = first_assistant.clone(); + let suggest_app = app.clone(); + let suggest_core = core.clone(); + let suggest_engine = engine.clone(); + let suggest_model = model.clone(); + let suggest_conv = conversation_id.clone(); + tokio::spawn(async move { + let _ = maybe_generate_conversation_title( + title_app, + title_core, + title_engine, + title_model, + conversation_id, + is_first_round, + first_user, + first_assistant, + ) + .await; + }); + tokio::spawn(async move { + let _ = maybe_generate_suggestions( + suggest_app, + suggest_core, + suggest_engine, + suggest_model, + suggest_conv, + message_id, + suggest_history, + suggest_answer, + ) + .await; + }); } /// 本地文本模型不支持图片:把图片替换为占位说明。 @@ -721,6 +1366,430 @@ fn sanitize_history_for_local(mut history: Vec) -> Vec history } +/// 自动生成会话标题:设置开启且为首轮对话时,调用当前模型生成简短标题并覆盖。 +async fn maybe_generate_conversation_title( + app: AppHandle, + core: Arc, + engine: EngineManager, + model: ModelInfo, + conversation_id: String, + is_first_round: bool, + first_user: String, + first_assistant: String, +) { + // 设置开关,默认开启 + let auto_title = { + let db = core.db.lock().unwrap(); + settings_db::get(&db, "auto_title") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(true) + }; + if !auto_title { + return; + } + // 仅在首轮(1 条用户消息 + 本轮回复)生成,避免后续每轮都改标题 + if !is_first_round { + return; + } + + let prompt = format!( + "请为这段对话生成一个简短标题,要求:简洁、概括对话主题,不超过20个汉字;只输出标题本身,不要引号、不要解释。\n\n用户:{}\n\n助手:{}", + truncate_text(&first_user, 300), + truncate_text(&first_assistant, 200), + ); + + 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: vec![ + ChatMessage::new("system", "你是一个对话标题生成助手,只输出简短标题。"), + ChatMessage::new("user", prompt), + ], + temperature: Some(0.3), + top_p: None, + max_tokens: Some(64), + stream: false, + }; + + let title_raw = if model.kind == "remote" { + let cfg = RemoteConfig { + base_url: model.base_url.clone().unwrap_or_default(), + api_key: model.api_key.clone(), + model: upstream_model, + }; + xianren_engine::chat_remote(&cfg, req).await + } else { + engine.chat(req).await + }; + + let title_raw = match title_raw { + Ok(t) => t, + Err(e) => { + tracing::warn!( + conversation_id = %conversation_id, + error = %e, + "auto title generation failed" + ); + return; + } + }; + let title = sanitize_title(&title_raw); + if title.is_empty() { + tracing::warn!(conversation_id = %conversation_id, "auto title returned empty"); + return; + } + + if let Err(e) = { + let db = core.db.lock().unwrap(); + sessions_db::update_conversation_title(&db, &conversation_id, &title) + } { + tracing::warn!(error = %e, "failed to update conversation title"); + return; + } + let _ = app.emit( + "chat://title-updated", + ChatTitleUpdatedEvent { + conversation_id, + title, + }, + ); +} + +/// 回复完成后,预测用户接下来可能发送的简短消息(设置可关闭/调整条数)。 +/// 所有模型统一思路:要求输出 JSON 数组,解析验证失败或条数不足时最多重试 3 次。 +async fn maybe_generate_suggestions( + app: AppHandle, + core: Arc, + engine: EngineManager, + model: ModelInfo, + conversation_id: String, + message_id: String, + history: Vec, + last_answer: String, +) { + let (enabled, count) = { + let db = core.db.lock().unwrap(); + let enabled = settings_db::get(&db, "suggest_enabled") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(true); + let count = settings_db::get(&db, "suggest_count") + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .unwrap_or(3) + .clamp(1, 5); + (enabled, count) + }; + if !enabled { + return; + } + + let upstream_model = if model.kind == "remote" { + model + .api_model + .clone() + .unwrap_or_else(|| model.repo_id.clone()) + } else { + model.repo_id.clone() + }; + let recent: Vec<&ChatMessage> = history.iter().rev().take(6).collect(); + + // 最多重试 3 次:解析出 JSON 且条数达标即停止,否则保留已解析到的最好结果继续重试 + let mut suggestions: Vec = Vec::new(); + for _ in 0..3 { + if let Some(raw) = call_suggestion_model( + &model, + &engine, + &upstream_model, + &recent, + &last_answer, + count, + ) + .await + { + let parsed = parse_suggestions(&raw, count, &last_answer); + if parsed.len() > suggestions.len() { + suggestions = parsed; + } + if suggestions.len() >= count { + break; + } + } + } + suggestions.truncate(count); + + if suggestions.is_empty() { + tracing::warn!( + conversation_id = %conversation_id, + model_kind = %model.kind, + "suggestion generation returned empty" + ); + return; + } + let _ = app.emit( + "chat://suggestions", + ChatSuggestionsEvent { + conversation_id, + message_id, + suggestions, + }, + ); +} + +/// 调用模型生成预测文本:统一要求输出 JSON 字符串数组。 +async fn call_suggestion_model( + model: &ModelInfo, + engine: &EngineManager, + upstream_model: &str, + recent: &[&ChatMessage], + last_answer: &str, + count: usize, +) -> Option { + let mut prompt = Vec::new(); + prompt.push(ChatMessage::new( + "system", + &format!( + "你是对话续写助手。请站在用户的角度,根据对话历史,预测用户接下来最可能输入的消息。\n\ + 要求:\n\ + 1. 必须恰好输出 {count} 条,数量要准确;\n\ + 2. 每条不超过 30 字,简短自然,像用户真实打字;\n\ + 3. 内容要和 AI 刚给出的回答相关,但要以用户的口吻,不要摘录、重复或拼接 AI 回答中的原句;\n\ + 4. 只输出一个 JSON 字符串数组,例如 [\"消息一\",\"消息二\",\"消息三\"],不要输出任何其他文字。" + ), + )); + for m in recent.iter().rev() { + prompt.push(ChatMessage::new(&m.role, &truncate_text(&m.content, 300))); + } + prompt.push(ChatMessage::new( + "assistant", + &truncate_text(last_answer, 400), + )); + + let req = ChatRequest { + model: upstream_model.to_string(), + messages: prompt, + temperature: Some(0.7), + top_p: None, + max_tokens: Some(512), + stream: false, + }; + let call = async { + if model.kind == "remote" { + let cfg = RemoteConfig { + base_url: model.base_url.clone().unwrap_or_default(), + api_key: model.api_key.clone(), + model: upstream_model.to_string(), + }; + xianren_engine::chat_remote(&cfg, req).await + } else { + engine.chat(req).await + } + }; + match tokio::time::timeout(std::time::Duration::from_secs(60), call).await { + Ok(Ok(t)) => Some(t), + Ok(Err(e)) => { + tracing::warn!(error = %e, kind = %model.kind, "suggestion generation failed"); + None + } + Err(_) => { + tracing::warn!(kind = %model.kind, "suggestion generation timed out"); + None + } + } +} + +/// 解析模型输出的预测列表:优先解析 JSON 数组,失败时按行拆分兜底; +/// 过滤元信息行与“AI 回答原文片段”,去重并截断到 30 字,保留前 count 条。 +fn parse_suggestions(raw: &str, count: usize, last_answer: &str) -> Vec { + let mut out = Vec::new(); + let push = |s: String, out: &mut Vec| { + let s = s + .trim() + .trim_matches(|c| matches!(c, '"' | '\'' | '“' | '”' | '「' | '」' | '『' | '』')) + .trim() + .to_string(); + if s.is_empty() || looks_like_meta_line(&s) || looks_like_answer_fragment(&s, last_answer) { + return; + } + let t: String = s.chars().take(30).collect(); + if !out.contains(&t) { + out.push(t); + } + }; + + if let Some(items) = extract_suggestion_json(raw) { + for item in items { + push(item, &mut out); + if out.len() >= count { + break; + } + } + } + + // JSON 解析未凑够时,再用按行拆分兜底(如模型输出多行普通文本) + for line in raw.lines() { + if out.len() >= count { + break; + } + let line = line.trim(); + if line.is_empty() || line.starts_with('[') || line.starts_with('{') { + continue; + } + push(strip_list_marker(line), &mut out); + } + out +} + +/// 判断一行是否属于模型输出的“元信息”而不是预测内容。 +fn looks_like_meta_line(s: &str) -> bool { + let t = s + .trim_start_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace()) + .trim_start(); + t.is_empty() + || [ + "以下是", + "下面是", + "这些是", + "用户可能说", + "用户可能会说", + "预测", + "我预测", + "我认为用户", + "模型回答", + "AI回答", + "AI 回答", + "对话历史", + "输出", + ] + .iter() + .any(|p| t.starts_with(p)) +} + +/// 判断候选内容是否是从 AI 回答里截取的片段(避免把回答原文当预测)。 +fn looks_like_answer_fragment(candidate: &str, answer: &str) -> bool { + let c = candidate.trim(); + let a = answer.trim(); + if c.is_empty() || a.is_empty() { + return false; + } + // 候选是回答的子串 + if a.contains(c) { + return true; + } + // 候选与回答开头重叠较多(模型接着回答继续写) + let c_chars: Vec = c.chars().collect(); + let a_chars: Vec = a.chars().collect(); + let limit = c_chars.len().min(a_chars.len()).min(12); + if limit >= 4 { + let common = c_chars + .iter() + .zip(a_chars.iter()) + .take(limit) + .take_while(|(x, y)| x == y) + .count(); + if common >= 4 && common * 2 >= c_chars.len().min(12) { + return true; + } + } + false +} + +/// 从模型输出中提取 JSON 字符串数组(兼容 ```json 代码围栏)。 +fn extract_suggestion_json(raw: &str) -> Option> { + let mut t = raw.trim(); + if t.starts_with("```") { + let body = &t[3..]; + let body = body.strip_prefix("json").unwrap_or(body); + let end = body.rfind("```").unwrap_or(body.len()); + t = body[..end].trim(); + } + let start = t.find('[')?; + let end = t.rfind(']')?; + if end <= start { + return None; + } + let value: serde_json::Value = serde_json::from_str(&t[start..=end]).ok()?; + let arr = value.as_array()?; + Some( + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + ) +} + +/// 去掉行首的编号或列表符号(如 1. / 1、 / - / •)。 +fn strip_list_marker(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut start = 0usize; + if chars.first().is_some_and(|c| c.is_ascii_digit()) { + let mut j = 1; + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + if j < chars.len() && matches!(chars[j], '.' | '、' | ')' | ')' | ':') { + start = j + 1; + } + } else if matches!(chars.first(), Some('-') | Some('•') | Some('·') | Some('*') | Some('>')) { + start = 1; + } + chars[start..].iter().collect::().trim().to_string() +} + +/// 截断文本,避免标题提示词过长。 +fn truncate_text(text: &str, max_chars: usize) -> String { + let text = text.trim(); + if text.chars().count() > max_chars { + text.chars().take(max_chars).collect() + } else { + text.to_string() + } +} + +/// 清洗模型输出的标题:去掉前缀、成对引号、多余空白并限制长度。 +fn sanitize_title(raw: &str) -> String { + let mut s = raw.trim().to_string(); + for prefix in ["标题:", "标题:", "Title:", "title:"] { + if s.starts_with(prefix) { + s = s[prefix.len()..].trim().to_string(); + break; + } + } + let quotes = ['"', '\'', '“', '”', '「', '」', '『', '』', '《', '》', '(', ')']; + loop { + let chars: Vec = s.chars().collect(); + if chars.len() >= 2 + && quotes.contains(&chars[0]) + && quotes.contains(&chars[chars.len() - 1]) + { + s = chars[1..chars.len() - 1].iter().collect::(); + } else { + break; + } + } + let mut out = String::new(); + for ch in s.chars() { + if ch.is_whitespace() { + if !out.is_empty() && !out.ends_with(' ') { + out.push(' '); + } + } else { + out.push(ch); + } + } + out.trim().chars().take(30).collect() +} + fn estimate_tokens(text: &str) -> usize { let mut score = 0usize; for ch in text.chars() { @@ -755,7 +1824,7 @@ pub async fn regenerate_message( let engine = state.engine.clone(); let base = state.engine_base.clone(); - let (history, model, engine_bin, conversation_id) = { + let (history, model, engine_bin, conversation_id, tool_defs) = { let db = core.db.lock().unwrap(); let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? @@ -795,25 +1864,42 @@ pub async fn regenerate_message( 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 tool_defs = resolve_conversation_tool_defs(&db, &conversation.tools); + (history, model, bin, payload.conversation_id.clone(), tool_defs) }; - let history = if model.kind == "local" { + let mut 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, - )); + if !tool_defs.is_empty() { + let sys = tools::build_tools_system_prompt(&tool_defs); + if !sys.is_empty() { + history.insert(0, ChatMessage::new("system", sys)); + } + } + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let cancel_flags = state.cancel_flags.clone(); + let flag_conv = conversation_id.clone(); + cancel_flags.lock().await.insert(flag_conv.clone(), cancel_tx); + tokio::spawn(async move { + let _ = run_generation_and_stream( + app, + core, + engine, + base, + conversation_id, + history, + model, + engine_bin, + payload.params, + tool_defs, + cancel_rx, + ) + .await; + cancel_flags.lock().await.remove(&flag_conv); + }); Ok(()) } @@ -828,7 +1914,7 @@ pub async fn edit_message( let engine = state.engine.clone(); let base = state.engine_base.clone(); - let (history, model, engine_bin, conversation_id) = { + let (history, model, engine_bin, conversation_id, tool_defs) = { let db = core.db.lock().unwrap(); let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? @@ -860,25 +1946,42 @@ pub async fn edit_message( 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 tool_defs = resolve_conversation_tool_defs(&db, &conversation.tools); + (history, model, bin, payload.conversation_id.clone(), tool_defs) }; - let history = if model.kind == "local" { + let mut 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, - )); + if !tool_defs.is_empty() { + let sys = tools::build_tools_system_prompt(&tool_defs); + if !sys.is_empty() { + history.insert(0, ChatMessage::new("system", sys)); + } + } + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let cancel_flags = state.cancel_flags.clone(); + let flag_conv = conversation_id.clone(); + cancel_flags.lock().await.insert(flag_conv.clone(), cancel_tx); + tokio::spawn(async move { + let _ = run_generation_and_stream( + app, + core, + engine, + base, + conversation_id, + history, + model, + engine_bin, + payload.params, + tool_defs, + cancel_rx, + ) + .await; + cancel_flags.lock().await.remove(&flag_conv); + }); Ok(()) } @@ -926,24 +2029,47 @@ pub fn apply_message_version( } #[tauri::command] -pub fn download_enqueue( +pub async fn download_enqueue( app: AppHandle, state: State<'_, App>, payload: DownloadPayload, ) -> Result { - let id = uuid::Uuid::new_v4().to_string(); - let core = state.core.clone(); - // 模型按 /// 组织,不直接放模型目录根 - let subdir = derive_repo_subdir(&payload); - let dest_dir = core.models_dir.join(&subdir); - std::fs::create_dir_all(&dest_dir) - .map_err(|e| format!("failed to create model directory {}: {e}", dest_dir.display()))?; - let dest = dest_dir.join(&payload.file_name); - let opts = xianren_download::DownloadOptions { - url: payload.url.clone(), - dest, - expected_sha256: payload.sha256.clone(), - chunk_size: 8 * 1024 * 1024, + // 同步部分包一层保护:任何 panic 都转为错误返回,避免拖垮整个进程 + let prepared = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let id = uuid::Uuid::new_v4().to_string(); + let core = state.core.clone(); + // 下载根目录:模型目录下的 download/models(默认),不存在时自动创建 + let model_dir = { + let db = core.db.lock().unwrap(); + settings_db::get(&db, "model_dir") + .ok() + .flatten() + .map(PathBuf::from) + .unwrap_or_else(|| core.models_dir.clone()) + }; + let subdir = derive_repo_subdir(&payload); + // 推荐/搜索结果下载(带 repo_id):直接按 username/modelname 存到模型目录下; + // 手动直链下载(无 repo_id):存到模型目录的 download/models 下 + let dest_dir = if payload.repo_id.is_some() { + model_dir.join(&subdir) + } else { + model_dir.join("download").join("models").join(&subdir) + }; + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("failed to create model directory {}: {e}", dest_dir.display()))?; + let dest = dest_dir.join(&payload.file_name); + let opts = xianren_download::DownloadOptions { + url: payload.url.clone(), + dest, + expected_sha256: payload.sha256.clone(), + chunk_size: 8 * 1024 * 1024, + }; + Ok::<_, String>((id, core, opts)) + })); + let (id, core, opts) = match prepared { + Ok(Ok(v)) => v, + Ok(Err(e)) => return Err(e), + Err(_) => return Err("创建下载任务时发生内部错误".into()), }; let pid = payload; let app2 = app.clone(); @@ -951,6 +2077,21 @@ pub fn download_enqueue( let progress_url = pid.url.clone(); let progress_name = pid.file_name.clone(); + let _ = app.emit( + "download://started", + DownloadProgressEvent { + id: id.clone(), + url: pid.url.clone(), + file_name: pid.file_name.clone(), + downloaded: 0, + total: None, + percent: Some(0.0), + speed_bps: 0, + status: "started".into(), + }, + ); + tracing::info!(url = %pid.url, dest = %opts.dest.display(), "download task enqueued"); + tokio::spawn(async move { let tid = task_id.clone(); let progress = move |pr: xianren_download::DownloadProgress| { @@ -1008,6 +2149,7 @@ pub fn download_enqueue( ); } Err(e) => { + tracing::warn!(error = %e, "download failed"); let _ = app.emit( "download://error", DownloadProgressEvent { @@ -1092,6 +2234,243 @@ pub fn add_remote_model( .ok_or_else(|| "model not found after insert".to_string()) } +// ---------- 技能(skill)工具 ---------- + +#[tauri::command] +pub fn list_skills(state: State<'_, App>) -> Result, String> { + let db = state.core.db.lock().unwrap(); + skills_db::list(&db).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_skill( + state: State<'_, App>, + name: String, + description: String, + content: String, + enabled: bool, +) -> Result { + let name = name.trim(); + if name.is_empty() || content.trim().is_empty() { + return Err("技能名称与内容均不能为空".into()); + } + let db = state.core.db.lock().unwrap(); + let id = skills_db::insert(&db, name, description.trim(), content.trim(), enabled) + .map_err(|e| e.to_string())?; + skills_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "skill not found after insert".to_string()) +} + +#[tauri::command] +pub fn update_skill( + state: State<'_, App>, + id: String, + name: String, + description: String, + content: String, + enabled: bool, +) -> Result<(), String> { + if name.trim().is_empty() || content.trim().is_empty() { + return Err("技能名称与内容均不能为空".into()); + } + let db = state.core.db.lock().unwrap(); + skills_db::update(&db, &id, name.trim(), description.trim(), content.trim(), enabled) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_skill(state: State<'_, App>, id: String) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + skills_db::remove(&db, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_skill_enabled(state: State<'_, App>, id: String, enabled: bool) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + skills_db::set_enabled(&db, &id, enabled).map_err(|e| e.to_string()) +} + +/// 测试技能:返回技能内容,供工具页预览。 +#[tauri::command] +pub fn test_skill(state: State<'_, App>, id: String) -> Result { + let db = state.core.db.lock().unwrap(); + skills_db::get(&db, &id) + .map_err(|e| e.to_string())? + .map(|s| format!("技能「{}」内容:\n{}", s.name, s.content)) + .ok_or_else(|| "技能不存在".into()) +} + +// ---------- MCP 服务 ---------- + +#[tauri::command] +pub fn list_mcp_servers( + state: State<'_, App>, +) -> Result, String> { + let db = state.core.db.lock().unwrap(); + mcp_db::list(&db).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_mcp_server( + state: State<'_, App>, + name: String, + description: String, + url: String, + auth_token: String, + enabled: bool, +) -> Result { + let name = name.trim(); + let url = url.trim(); + if name.is_empty() || url.is_empty() { + return Err("名称与地址均不能为空".into()); + } + let db = state.core.db.lock().unwrap(); + let id = mcp_db::insert(&db, name, description.trim(), url, auth_token.trim(), enabled) + .map_err(|e| e.to_string())?; + mcp_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "mcp server not found after insert".to_string()) +} + +#[tauri::command] +pub fn update_mcp_server( + state: State<'_, App>, + id: String, + name: String, + description: String, + url: String, + auth_token: String, + enabled: bool, +) -> Result<(), String> { + if name.trim().is_empty() || url.trim().is_empty() { + return Err("名称与地址均不能为空".into()); + } + let db = state.core.db.lock().unwrap(); + mcp_db::update( + &db, + &id, + name.trim(), + description.trim(), + url.trim(), + auth_token.trim(), + enabled, + ) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_mcp_server(state: State<'_, App>, id: String) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + mcp_db::remove(&db, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_mcp_server_enabled( + state: State<'_, App>, + id: String, + enabled: bool, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + mcp_db::set_enabled(&db, &id, enabled).map_err(|e| e.to_string()) +} + +/// 测试 MCP 服务连通性:初始化会话并读取服务端信息。 +#[tauri::command] +pub async fn mcp_test_server(state: State<'_, App>, id: String) -> Result { + let (name, url, token) = { + let db = state.core.db.lock().unwrap(); + let s = mcp_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "MCP 服务不存在".to_string())?; + (s.name, s.url, s.auth_token) + }; + let info = mcp_client::initialize(&url, &token).await?; + let server_name = info + .get("serverInfo") + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("未知服务"); + Ok(format!("「{name}」连接成功,服务端:{server_name}")) +} + +/// 列出 MCP 服务端可用工具。 +#[tauri::command] +pub async fn mcp_list_tools( + state: State<'_, App>, + id: String, +) -> Result, String> { + let (url, token) = { + let db = state.core.db.lock().unwrap(); + let s = mcp_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "MCP 服务不存在".to_string())?; + (s.url, s.auth_token) + }; + mcp_client::list_tools(&url, &token).await +} + +/// 手动调用 MCP 工具(工具页测试用)。 +#[tauri::command] +pub async fn mcp_call_tool( + state: State<'_, App>, + id: String, + tool_name: String, + args: String, +) -> Result { + let (url, token) = { + let db = state.core.db.lock().unwrap(); + let s = mcp_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "MCP 服务不存在".to_string())?; + (s.url, s.auth_token) + }; + let tools = mcp_client::list_tools(&url, &token).await?; + let info = tools + .iter() + .find(|t| t.name == tool_name) + .ok_or_else(|| format!("服务中未找到工具:{tool_name}"))?; + let arguments = tools::build_arguments(&info.input_schema, Some(args.as_str())); + mcp_client::call_tool(&url, &token, &tool_name, arguments).await +} + +/// 根据会话启用的工具列表解析出可执行工具(只包含已启用的技能/MCP)。 +fn resolve_conversation_tool_defs(db: &rusqlite::Connection, tools: &[String]) -> Vec { + let mut out = Vec::new(); + for t in tools { + if let Some(id) = t.strip_prefix("skill:") { + if let Ok(Some(s)) = skills_db::get(db, id) { + if s.enabled { + out.push(ToolDef { + kind: "skill".into(), + id: s.id, + name: s.name, + description: s.description, + content: s.content, + url: String::new(), + auth_token: String::new(), + }); + } + } + } else if let Some(id) = t.strip_prefix("mcp:") { + if let Ok(Some(m)) = mcp_db::get(db, id) { + if m.enabled { + out.push(ToolDef { + kind: "mcp".into(), + id: m.id, + name: m.name, + description: m.description, + content: String::new(), + url: m.url, + auth_token: m.auth_token, + }); + } + } + } + } + out +} + /// 在模型广场搜索模型仓库(HuggingFace / ModelScope)。 #[tauri::command] pub async fn search_models( @@ -1100,13 +2479,16 @@ pub async fn search_models( ) -> Result, String> { let client = reqwest::Client::new(); let mut results = Vec::new(); + let trimmed = query.trim(); + // 空关键词时返回热门榜(前 40 个),否则返回搜索结果(前 30 个) + let limit = if trimmed.is_empty() { 40 } else { 30 }; match source.as_str() { "modelscope" => { let body = serde_json::json!({ "page_number": 1, - "page_size": 30, - "search": query.trim(), + "page_size": limit, + "search": trimmed, }); let resp = client .put("https://www.modelscope.cn/api/v1/models") @@ -1158,17 +2540,19 @@ pub async fn search_models( } } _ => { - let url = reqwest::Url::parse_with_params( - "https://huggingface.co/api/models", - &[ - ("search", query.trim()), - ("limit", "30"), - ("library", "gguf"), - ("sort", "downloads"), - ("direction", "-1"), - ], - ) - .map_err(|e| e.to_string())?; + let limit_str = limit.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("library", "gguf"), + ("sort", "downloads"), + ("direction", "-1"), + ("limit", &limit_str), + ]; + if !trimmed.is_empty() { + params.push(("search", trimmed)); + } + let url = + reqwest::Url::parse_with_params("https://huggingface.co/api/models", ¶ms) + .map_err(|e| e.to_string())?; let resp = client .get(url) .header("User-Agent", "xianren-studio") diff --git a/apps/desktop/src/default_recommendations.json b/apps/desktop/src/default_recommendations.json new file mode 100644 index 0000000..2495a39 --- /dev/null +++ b/apps/desktop/src/default_recommendations.json @@ -0,0 +1,519 @@ +{ + "name": "内置热门推荐", + "models": [ + { + "id": "Qwen/Qwen2.5-7B-Instruct-GGUF", + "source": "huggingface", + "params": "7B", + "architecture": "dense", + "vision": false, + "description": "Qwen2.5 指令微调模型,中文与通用对话能力强", + "files": [ + { + "path": "qwen2.5-7b-instruct-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 4.68, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/master/qwen2.5-7b-instruct-q4_k_m.gguf" + ] + }, + { + "path": "qwen2.5-7b-instruct-q5_k_m.gguf", + "quant": "Q5_K_M", + "size_gb": 5.52, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q5_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/master/qwen2.5-7b-instruct-q5_k_m.gguf" + ] + }, + { + "path": "qwen2.5-7b-instruct-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 8.22, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q8_0.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/master/qwen2.5-7b-instruct-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen2.5-14B-Instruct-GGUF", + "source": "huggingface", + "params": "14B", + "architecture": "dense", + "vision": false, + "description": "Qwen2.5 中等规模指令微调模型", + "files": [ + { + "path": "qwen2.5-14b-instruct-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 9.02, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-14B-Instruct-GGUF/resolve/main/qwen2.5-14b-instruct-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-14B-Instruct-GGUF/resolve/master/qwen2.5-14b-instruct-q4_k_m.gguf" + ] + }, + { + "path": "qwen2.5-14b-instruct-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 15.8, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-14B-Instruct-GGUF/resolve/main/qwen2.5-14b-instruct-q8_0.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-14B-Instruct-GGUF/resolve/master/qwen2.5-14b-instruct-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen2.5-32B-Instruct-GGUF", + "source": "modelscope", + "params": "32B", + "architecture": "dense", + "vision": false, + "description": "Qwen2.5 大参数指令微调模型", + "files": [ + { + "path": "qwen2.5-32b-instruct-q3_k_m.gguf", + "quant": "Q3_K_M", + "size_gb": 13.6, + "links": [ + "https://modelscope.cn/models/Qwen/Qwen2.5-32B-Instruct-GGUF/resolve/master/qwen2.5-32b-instruct-q3_k_m.gguf" + ] + }, + { + "path": "qwen2.5-32b-instruct-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 19.9, + "links": [ + "https://modelscope.cn/models/Qwen/Qwen2.5-32B-Instruct-GGUF/resolve/master/qwen2.5-32b-instruct-q4_k_m.gguf" + ] + }, + { + "path": "qwen2.5-32b-instruct-q5_k_m.gguf", + "quant": "Q5_K_M", + "size_gb": 23.4, + "links": [ + "https://modelscope.cn/models/Qwen/Qwen2.5-32B-Instruct-GGUF/resolve/master/qwen2.5-32b-instruct-q5_k_m.gguf" + ] + }, + { + "path": "qwen2.5-32b-instruct-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 34.3, + "links": [ + "https://modelscope.cn/models/Qwen/Qwen2.5-32B-Instruct-GGUF/resolve/master/qwen2.5-32b-instruct-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen2.5-72B-Instruct-GGUF", + "source": "huggingface", + "params": "72B", + "architecture": "dense", + "vision": false, + "description": "Qwen2.5 旗舰指令微调模型", + "files": [ + { + "path": "qwen2.5-72b-instruct-q2_k.gguf", + "quant": "Q2_K", + "size_gb": 29.6, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/main/qwen2.5-72b-instruct-q2_k.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/master/qwen2.5-72b-instruct-q2_k.gguf" + ] + }, + { + "path": "qwen2.5-72b-instruct-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 43.6, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/main/qwen2.5-72b-instruct-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/master/qwen2.5-72b-instruct-q4_k_m.gguf" + ] + }, + { + "path": "qwen2.5-72b-instruct-q5_k_m.gguf", + "quant": "Q5_K_M", + "size_gb": 51.3, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/main/qwen2.5-72b-instruct-q5_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/master/qwen2.5-72b-instruct-q5_k_m.gguf" + ] + }, + { + "path": "qwen2.5-72b-instruct-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 75.6, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/main/qwen2.5-72b-instruct-q8_0.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-72B-Instruct-GGUF/resolve/master/qwen2.5-72b-instruct-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF", + "source": "huggingface", + "params": "7B", + "architecture": "dense", + "vision": false, + "description": "Qwen2.5 代码指令微调模型", + "files": [ + { + "path": "qwen2.5-coder-7b-instruct-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 4.68, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/master/qwen2.5-coder-7b-instruct-q4_k_m.gguf" + ] + }, + { + "path": "qwen2.5-coder-7b-instruct-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 8.22, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q8_0.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/master/qwen2.5-coder-7b-instruct-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen2.5-VL-7B-Instruct-GGUF", + "source": "huggingface", + "params": "7B", + "architecture": "dense", + "vision": true, + "description": "Qwen2.5 视觉语言模型,支持图片理解", + "files": [ + { + "path": "qwen2.5-vl-7b-instruct-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 6.4, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/qwen2.5-vl-7b-instruct-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-VL-7B-Instruct-GGUF/resolve/master/qwen2.5-vl-7b-instruct-q4_k_m.gguf" + ] + }, + { + "path": "qwen2.5-vl-7b-instruct-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 11.2, + "links": [ + "https://hf-mirror.com/Qwen/Qwen2.5-VL-7B-Instruct-GGUF/resolve/main/qwen2.5-vl-7b-instruct-q8_0.gguf", + "https://modelscope.cn/models/Qwen/Qwen2.5-VL-7B-Instruct-GGUF/resolve/master/qwen2.5-vl-7b-instruct-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen3-8B-GGUF", + "source": "huggingface", + "params": "8B", + "architecture": "dense", + "vision": false, + "description": "Qwen3 新一代通用对话模型", + "files": [ + { + "path": "qwen3-8b-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 5.35, + "links": [ + "https://hf-mirror.com/Qwen/Qwen3-8B-GGUF/resolve/main/qwen3-8b-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen3-8B-GGUF/resolve/master/qwen3-8b-q4_k_m.gguf" + ] + }, + { + "path": "qwen3-8b-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 8.76, + "links": [ + "https://hf-mirror.com/Qwen/Qwen3-8B-GGUF/resolve/main/qwen3-8b-q8_0.gguf", + "https://modelscope.cn/models/Qwen/Qwen3-8B-GGUF/resolve/master/qwen3-8b-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen3-30B-A3B-GGUF", + "source": "modelscope", + "params": "30B (MoE-A3B)", + "architecture": "moe", + "vision": false, + "description": "Qwen3 MoE 模型,激活仅 3B,性价比高", + "files": [ + { + "path": "qwen3-30b-a3b-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 18.8, + "links": [ + "https://modelscope.cn/models/Qwen/Qwen3-30B-A3B-GGUF/resolve/master/qwen3-30b-a3b-q4_k_m.gguf" + ] + }, + { + "path": "qwen3-30b-a3b-q8_0.gguf", + "quant": "Q8_0", + "size_gb": 32.9, + "links": [ + "https://modelscope.cn/models/Qwen/Qwen3-30B-A3B-GGUF/resolve/master/qwen3-30b-a3b-q8_0.gguf" + ] + } + ] + }, + { + "id": "Qwen/Qwen3-235B-A22B-GGUF", + "source": "huggingface", + "params": "235B (MoE-A22B)", + "architecture": "moe", + "vision": false, + "description": "Qwen3 旗舰 MoE 模型,激活仅 22B", + "files": [ + { + "path": "qwen3-235b-a22b-q3_k_m.gguf", + "quant": "Q3_K_M", + "size_gb": 96.4, + "links": [ + "https://hf-mirror.com/Qwen/Qwen3-235B-A22B-GGUF/resolve/main/qwen3-235b-a22b-q3_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen3-235B-A22B-GGUF/resolve/master/qwen3-235b-a22b-q3_k_m.gguf" + ] + }, + { + "path": "qwen3-235b-a22b-q4_k_m.gguf", + "quant": "Q4_K_M", + "size_gb": 132.7, + "links": [ + "https://hf-mirror.com/Qwen/Qwen3-235B-A22B-GGUF/resolve/main/qwen3-235b-a22b-q4_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen3-235B-A22B-GGUF/resolve/master/qwen3-235b-a22b-q4_k_m.gguf" + ] + }, + { + "path": "qwen3-235b-a22b-q5_k_m.gguf", + "quant": "Q5_K_M", + "size_gb": 160.6, + "links": [ + "https://hf-mirror.com/Qwen/Qwen3-235B-A22B-GGUF/resolve/main/qwen3-235b-a22b-q5_k_m.gguf", + "https://modelscope.cn/models/Qwen/Qwen3-235B-A22B-GGUF/resolve/master/qwen3-235b-a22b-q5_k_m.gguf" + ] + } + ] + }, + { + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF", + "source": "huggingface", + "params": "7B", + "architecture": "dense", + "vision": false, + "description": "DeepSeek-R1 蒸馏版,推理能力强", + "files": [ + { + "path": "DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 4.68, + "links": [ + "https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF/resolve/main/DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf", + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF/resolve/master/DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf" + ] + }, + { + "path": "DeepSeek-R1-Distill-Qwen-7B-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 8.22, + "links": [ + "https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF/resolve/main/DeepSeek-R1-Distill-Qwen-7B-Q8_0.gguf", + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B-GGUF/resolve/master/DeepSeek-R1-Distill-Qwen-7B-Q8_0.gguf" + ] + } + ] + }, + { + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B-GGUF", + "source": "modelscope", + "params": "14B", + "architecture": "dense", + "vision": false, + "description": "DeepSeek-R1 蒸馏版,推理与通用能力均衡", + "files": [ + { + "path": "DeepSeek-R1-Distill-Qwen-14B-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 9.02, + "links": [ + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B-GGUF/resolve/master/DeepSeek-R1-Distill-Qwen-14B-Q4_K_M.gguf" + ] + }, + { + "path": "DeepSeek-R1-Distill-Qwen-14B-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 15.8, + "links": [ + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B-GGUF/resolve/master/DeepSeek-R1-Distill-Qwen-14B-Q8_0.gguf" + ] + } + ] + }, + { + "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B-GGUF", + "source": "huggingface", + "params": "32B", + "architecture": "dense", + "vision": false, + "description": "DeepSeek-R1 蒸馏版,强推理与高性价比", + "files": [ + { + "path": "DeepSeek-R1-Distill-Qwen-32B-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 19.9, + "links": [ + "https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B-GGUF/resolve/main/DeepSeek-R1-Distill-Qwen-32B-Q4_K_M.gguf", + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B-GGUF/resolve/master/DeepSeek-R1-Distill-Qwen-32B-Q4_K_M.gguf" + ] + }, + { + "path": "DeepSeek-R1-Distill-Qwen-32B-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 34.3, + "links": [ + "https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B-GGUF/resolve/main/DeepSeek-R1-Distill-Qwen-32B-Q8_0.gguf", + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B-GGUF/resolve/master/DeepSeek-R1-Distill-Qwen-32B-Q8_0.gguf" + ] + } + ] + }, + { + "id": "deepseek-ai/DeepSeek-R1-Distill-Llama-8B-GGUF", + "source": "huggingface", + "params": "8B", + "architecture": "dense", + "vision": false, + "description": "DeepSeek-R1 蒸馏 Llama 版", + "files": [ + { + "path": "DeepSeek-R1-Distill-Llama-8B-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 5.35, + "links": [ + "https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Llama-8B-GGUF/resolve/main/DeepSeek-R1-Distill-Llama-8B-Q4_K_M.gguf", + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Llama-8B-GGUF/resolve/master/DeepSeek-R1-Distill-Llama-8B-Q4_K_M.gguf" + ] + }, + { + "path": "DeepSeek-R1-Distill-Llama-8B-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 8.76, + "links": [ + "https://hf-mirror.com/deepseek-ai/DeepSeek-R1-Distill-Llama-8B-GGUF/resolve/main/DeepSeek-R1-Distill-Llama-8B-Q8_0.gguf", + "https://modelscope.cn/models/deepseek-ai/DeepSeek-R1-Distill-Llama-8B-GGUF/resolve/master/DeepSeek-R1-Distill-Llama-8B-Q8_0.gguf" + ] + } + ] + }, + { + "id": "unsloth/Llama-3.2-1B-Instruct-GGUF", + "source": "huggingface", + "params": "1B", + "architecture": "dense", + "vision": false, + "description": "Meta Llama 3.2 轻量指令模型,适合低配设备", + "files": [ + { + "path": "Llama-3.2-1B-Instruct-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 0.78, + "links": [ + "https://hf-mirror.com/unsloth/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf" + ] + }, + { + "path": "Llama-3.2-1B-Instruct-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 1.33, + "links": [ + "https://hf-mirror.com/unsloth/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q8_0.gguf" + ] + } + ] + }, + { + "id": "unsloth/Llama-3.2-3B-Instruct-GGUF", + "source": "huggingface", + "params": "3B", + "architecture": "dense", + "vision": false, + "description": "Meta Llama 3.2 小型指令模型", + "files": [ + { + "path": "Llama-3.2-3B-Instruct-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 2.02, + "links": [ + "https://hf-mirror.com/unsloth/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf" + ] + }, + { + "path": "Llama-3.2-3B-Instruct-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 3.5, + "links": [ + "https://hf-mirror.com/unsloth/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q8_0.gguf" + ] + } + ] + }, + { + "id": "unsloth/Mistral-7B-Instruct-v0.3-GGUF", + "source": "huggingface", + "params": "7B", + "architecture": "dense", + "vision": false, + "description": "Mistral 7B v0.3 指令模型", + "files": [ + { + "path": "Mistral-7B-Instruct-v0.3-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 4.37, + "links": [ + "https://hf-mirror.com/unsloth/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3-Q4_K_M.gguf" + ] + }, + { + "path": "Mistral-7B-Instruct-v0.3-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 7.6, + "links": [ + "https://hf-mirror.com/unsloth/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3-Q8_0.gguf" + ] + } + ] + }, + { + "id": "unsloth/Phi-3-mini-4k-instruct-GGUF", + "source": "huggingface", + "params": "3.8B", + "architecture": "dense", + "vision": false, + "description": "微软 Phi-3 mini 指令模型", + "files": [ + { + "path": "Phi-3-mini-4k-instruct-Q4_K_M.gguf", + "quant": "Q4_K_M", + "size_gb": 2.35, + "links": [ + "https://hf-mirror.com/unsloth/Phi-3-mini-4k-instruct-GGUF/resolve/main/Phi-3-mini-4k-instruct-Q4_K_M.gguf" + ] + }, + { + "path": "Phi-3-mini-4k-instruct-Q8_0.gguf", + "quant": "Q8_0", + "size_gb": 4.1, + "links": [ + "https://hf-mirror.com/unsloth/Phi-3-mini-4k-instruct-GGUF/resolve/main/Phi-3-mini-4k-instruct-Q8_0.gguf" + ] + } + ] + } + ] +} diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 7402083..960982c 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -1,5 +1,8 @@ mod commands; +mod mcp_client; +mod tools; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use tauri::{Emitter, Manager}; @@ -14,6 +17,9 @@ pub struct App { pub engine: EngineManager, pub engine_base: Arc>>, pub server: tokio::sync::Mutex>, + /// 会话 -> 生成取消信号(watch 发送端) + pub cancel_flags: + Arc>>>, } pub fn run() { @@ -22,6 +28,26 @@ pub fn run() { .join("XianrenStudio"); let logs_dir = data_dir.join("logs"); let _ = std::fs::create_dir_all(&logs_dir); + // 全局 panic 钩子:任何 panic 都会记录到日志目录,便于定位崩溃原因 + let panic_log = logs_dir.join("panic.log"); + std::panic::set_hook(Box::new(move |info| { + let msg = format!( + "[pid={}] thread '{}' panicked: {}\n{}", + std::process::id(), + std::thread::current().name().unwrap_or(""), + info, + std::backtrace::Backtrace::force_capture(), + ); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&panic_log) + .and_then(|mut f| { + use std::io::Write; + f.write_all(msg.as_bytes()) + }); + eprintln!("{msg}"); + })); let file_appender = tracing_appender::rolling::daily(&logs_dir, "app.log"); let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); tracing_subscriber::fmt() @@ -45,6 +71,7 @@ pub fn run() { engine: EngineManager::new(), engine_base: Arc::new(RwLock::new(None)), server: tokio::sync::Mutex::new(None), + cancel_flags: Arc::new(tokio::sync::Mutex::new(HashMap::new())), }, ); @@ -105,14 +132,38 @@ pub fn run() { commands::list_models, commands::import_model, commands::remove_model, + commands::set_model_enabled, commands::scan_models, commands::add_remote_model, + commands::list_skills, + commands::add_skill, + commands::update_skill, + commands::remove_skill, + commands::set_skill_enabled, + commands::test_skill, + commands::list_mcp_servers, + commands::add_mcp_server, + commands::update_mcp_server, + commands::remove_mcp_server, + commands::set_mcp_server_enabled, + commands::mcp_test_server, + commands::mcp_list_tools, + commands::mcp_call_tool, commands::search_models, commands::list_model_files, + commands::list_recommended_models, + commands::import_recommendations, + commands::fetch_model_page, commands::settings_get, commands::settings_set, commands::list_conversations, commands::create_conversation, + commands::rename_conversation, + commands::set_conversation_pinned, + commands::set_conversation_favorite, + commands::import_conversation, + commands::set_conversation_tools, + commands::web_search, commands::delete_conversation, commands::get_messages, commands::engine_start, @@ -120,6 +171,7 @@ pub fn run() { commands::engine_status, commands::deploy_model, commands::chat_send, + commands::chat_stop, commands::regenerate_message, commands::edit_message, commands::list_message_versions, diff --git a/apps/desktop/src/mcp_client.rs b/apps/desktop/src/mcp_client.rs new file mode 100644 index 0000000..d2e6606 --- /dev/null +++ b/apps/desktop/src/mcp_client.rs @@ -0,0 +1,217 @@ +//! 极简 MCP(Model Context Protocol)客户端 —— Streamable HTTP 传输。 +//! 支持 initialize / tools/list / tools/call 三个方法,足以支撑对话工具调用。 + +use serde::Serialize; +use serde_json::{json, Value}; +use std::time::Duration; + +const PROTOCOL_VERSION: &str = "2025-03-26"; + +#[derive(Debug, Clone, Serialize)] +pub struct McpToolInfo { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +/// 发起一次 JSON-RPC 请求并返回 result(若响应含 error 则返回 Err)。 +pub async fn mcp_request( + url: &str, + auth_token: &str, + method: &str, + params: Value, +) -> Result { + let client = reqwest::Client::new(); + let mut builder = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .timeout(Duration::from_secs(30)); + if !auth_token.trim().is_empty() { + builder = builder.header( + "Authorization", + format!("Bearer {}", auth_token.trim()), + ); + } + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let resp = builder + .json(&body) + .send() + .await + .map_err(|e| format!("MCP 请求失败:{e}"))?; + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("MCP 响应读取失败:{e}"))?; + let value = parse_json_rpc(&text)?; + if !status.is_success() { + return Err(format!("MCP 服务返回 HTTP {}:{}", status, text.chars().take(200).collect::())); + } + if let Some(err) = value.get("error") { + return Err(format!("MCP 错误:{err}")); + } + Ok(value.get("result").cloned().unwrap_or(Value::Null)) +} + +/// 解析 JSON-RPC 响应,兼容 text/event-stream(逐行 data: 消息)与纯 JSON。 +fn parse_json_rpc(text: &str) -> Result { + let trimmed = text.trim_start(); + if trimmed.starts_with("data:") { + for line in text.lines() { + let line = line.trim(); + if let Some(data) = line.strip_prefix("data:") { + let data = data.trim(); + if data.is_empty() || data.starts_with("[DONE]") { + continue; + } + if let Ok(v) = serde_json::from_str::(data) { + if v.get("result").is_some() || v.get("error").is_some() { + return Ok(v); + } + } + } + } + return Err("SSE 响应中没有找到 JSON-RPC 结果".into()); + } + serde_json::from_str::(text).map_err(|e| { + format!( + "MCP 响应解析失败:{e};原文:{}", + text.chars().take(200).collect::() + ) + }) +} + +/// 初始化会话(部分服务要求先 initialize 再调用其他方法)。 +pub async fn initialize(url: &str, auth_token: &str) -> Result { + mcp_request( + url, + auth_token, + "initialize", + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "xianren-studio", "version": "0.1.0" }, + }), + ) + .await +} + +/// 列出服务端可用工具。 +pub async fn list_tools(url: &str, auth_token: &str) -> Result, String> { + let _ = initialize(url, auth_token).await?; + let result = mcp_request(url, auth_token, "tools/list", json!({})).await?; + let tools = result + .get("tools") + .and_then(|t| t.as_array()) + .cloned() + .unwrap_or_default(); + Ok(tools + .into_iter() + .filter_map(|t| { + Some(McpToolInfo { + name: t.get("name")?.as_str()?.to_string(), + description: t + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(), + input_schema: t.get("inputSchema").cloned().unwrap_or(json!({})), + }) + }) + .collect()) +} + +/// 调用服务端工具,返回文本结果。 +pub async fn call_tool( + url: &str, + auth_token: &str, + tool_name: &str, + arguments: Value, +) -> Result { + let _ = initialize(url, auth_token).await?; + let result = mcp_request( + url, + auth_token, + "tools/call", + json!({ "name": tool_name, "arguments": arguments }), + ) + .await?; + // 标准 MCP:content 数组 [{type:"text", text:"..."}] + if let Some(content) = result.get("content").and_then(|c| c.as_array()) { + let texts: Vec = content + .iter() + .filter_map(|c| c.get("text").and_then(|t| t.as_str()).map(|s| s.to_string())) + .collect(); + if !texts.is_empty() { + return Ok(texts.join("\n")); + } + } + // 部分实现直接返回 structuredContent 或原始对象 + if let Some(sc) = result.get("structuredContent") { + return Ok(sc.to_string()); + } + Ok(result.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[tokio::test] + async fn mcp_client_list_and_call_against_local_server() { + let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../scripts/test_mcp_server.py"); + let port = 18765; + let mut child = match Command::new("python") + .arg(&script) + .arg(port.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(_) => { + eprintln!("python not available, skipping MCP integration test"); + return; + } + }; + let url = format!("http://127.0.0.1:{port}/mcp"); + let client = reqwest::Client::new(); + let mut ready = false; + for _ in 0..50 { + if client.get(&url).send().await.is_ok() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + if !ready { + let _ = child.kill(); + eprintln!("test MCP server did not start, skipping"); + return; + } + + let tools = list_tools(&url, "").await.expect("tools/list 失败"); + assert_eq!(tools.len(), 2); + assert!(tools.iter().any(|t| t.name == "get_current_time")); + + let time_result = call_tool(&url, "", "get_current_time", json!({})) + .await + .expect("tools/call get_current_time 失败"); + assert!(time_result.len() >= 8); + + let echo_result = call_tool(&url, "", "echo_text", json!({ "text": "hello-mcp" })) + .await + .expect("tools/call echo_text 失败"); + assert!(echo_result.contains("hello-mcp")); + + let _ = child.kill(); + } +} diff --git a/apps/desktop/src/tools.rs b/apps/desktop/src/tools.rs new file mode 100644 index 0000000..541575d --- /dev/null +++ b/apps/desktop/src/tools.rs @@ -0,0 +1,369 @@ +//! 大模型工具协议:技能(skill)与 MCP 工具。 +//! +//! 约定:系统提示词中列出可用工具,模型如需使用则在回复中单独输出一行标记: +//! - 技能:`[[skill:技能名]]` 或 `[[skill:技能名:参数]]` +//! - MCP :`[[mcp:服务名:工具名]]` 或 `[[mcp:服务名:工具名:参数]]` +//! 后端执行工具后把结果回填给模型,让模型基于结果继续回答。 + +use crate::mcp_client; +use serde::Serialize; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Serialize)] +pub struct ToolDef { + /// "skill" | "mcp" + pub kind: String, + pub id: String, + pub name: String, + pub description: String, + /// 技能说明内容(仅 skill 使用) + pub content: String, + /// MCP 端点(仅 mcp 使用) + pub url: String, + pub auth_token: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ToolMarker { + Skill { name: String, args: Option }, + Mcp { server: String, tool: String, args: Option }, +} + +/// 构建注入对话的系统提示词,向模型说明可用工具与调用格式。 +pub fn build_tools_system_prompt(tools: &[ToolDef]) -> String { + if tools.is_empty() { + return String::new(); + } + let mut out = String::from( + "你可以使用以下工具来帮助回答。如需要使用某个工具,请在回复中**单独输出一行标记**:\n", + ); + for t in tools { + match t.kind.as_str() { + "skill" => { + out.push_str(&format!("- [[skill:{}]]:{}\n", t.name, t.description)); + } + "mcp" => { + out.push_str(&format!( + "- [[mcp:{}:工具名]]:{}(工具名见对应服务说明)\n", + t.name, t.description + )); + } + _ => {} + } + } + out.push_str( + "工具执行后,系统会把结果返回给你,请基于结果继续回答。不要解释标记本身,也不要输出与工具无关的内容。", + ); + out +} + +/// 从文本中提取所有 `[[...]]` 标记内容。 +pub fn extract_markers(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find("[[") { + let after = &rest[start + 2..]; + if let Some(end) = after.find("]]") { + let inner = after[..end].trim(); + if !inner.is_empty() && !inner.contains('\n') { + out.push(inner.to_string()); + } + rest = &after[end + 2..]; + } else { + break; + } + } + out +} + +/// 去掉文本中的工具标记(整行标记丢弃,行内标记替换为空)。 +pub fn strip_markers(text: &str) -> String { + let mut out = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("[[") && trimmed.ends_with("]]") && !trimmed[2..trimmed.len() - 2].contains("[[") { + continue; + } + out.push(strip_inline_markers(line)); + } + out.join("\n").trim().to_string() +} + +fn strip_inline_markers(text: &str) -> String { + let mut out = String::new(); + let mut rest = text; + loop { + match rest.find("[[") { + Some(start) => { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find("]]") { + Some(end) => { + out.push(' '); + rest = &after[end + 2..]; + } + None => { + out.push_str("[["); // 不完整标记原样保留 + out.push_str(after); + break; + } + } + } + None => { + out.push_str(rest); + break; + } + } + } + out.split_whitespace().collect::>().join(" ") +} + +/// 解析标记内容为结构化工具调用。 +pub fn parse_marker(inner: &str) -> Option { + let parts: Vec<&str> = inner.splitn(4, ':').collect(); + let kind = parts.first()?.trim().to_ascii_lowercase(); + match kind.as_str() { + "skill" => { + let name = parts.get(1)?.trim().to_string(); + if name.is_empty() { + return None; + } + let args = parts.get(2).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + Some(ToolMarker::Skill { name, args }) + } + "mcp" => { + let server = parts.get(1)?.trim().to_string(); + let tool = parts.get(2)?.trim().to_string(); + if server.is_empty() || tool.is_empty() { + return None; + } + let args = parts.get(3).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + Some(ToolMarker::Mcp { server, tool, args }) + } + _ => None, + } +} + +/// 执行一个工具标记,返回给模型回填的结果文本。 +pub async fn execute_tool_marker(marker: &ToolMarker, tools: &[ToolDef]) -> Result { + match marker { + ToolMarker::Skill { name, args } => { + let def = tools + .iter() + .find(|t| t.kind == "skill" && t.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("未找到技能:{name}"))?; + let mut r = format!("技能「{}」说明:\n{}", def.name, def.content); + if let Some(a) = args { + r.push_str(&format!("\n调用参数:{a}")); + } + Ok(r) + } + ToolMarker::Mcp { server, tool, args } => { + let def = tools + .iter() + .find(|t| t.kind == "mcp" && t.name.eq_ignore_ascii_case(server)) + .ok_or_else(|| format!("未找到 MCP 服务:{server}"))?; + let tools_list = mcp_client::list_tools(&def.url, &def.auth_token).await?; + let info = tools_list + .iter() + .find(|t| t.name == *tool) + .ok_or_else(|| format!("MCP 服务「{server}」中没有工具:{tool}"))?; + let arguments = build_arguments(&info.input_schema, args.as_deref()); + let result = mcp_client::call_tool(&def.url, &def.auth_token, tool, arguments).await?; + Ok(format!("MCP 工具「{server}/{tool}」返回:\n{result}")) + } + } +} + +/// 根据工具入参 schema 构造 arguments;无 schema 时用常见文本参数名兜底。 +pub fn build_arguments(schema: &Value, args: Option<&str>) -> Value { + let Some(args) = args.filter(|a| !a.trim().is_empty()) else { + return json!({}); + }; + if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { + if props.len() == 1 { + let (name, p) = props.iter().next().unwrap(); + if p.get("type").and_then(|t| t.as_str()) == Some("string") { + return json!({ name: args }); + } + } + for key in ["input", "text", "query", "message", "content", "prompt", "q"] { + if let Some(p) = props.get(key) { + if p.get("type").and_then(|t| t.as_str()) == Some("string") { + return json!({ key: args }); + } + } + } + } + json!({ "input": args }) +} + +/// 流式标记过滤器:把 `[[...]]` 标记从发给前端的文本中扣住,避免用户看到标记闪烁。 +#[derive(Default)] +pub struct MarkerFilter { + buffer: String, +} + +impl MarkerFilter { + pub fn new() -> Self { + Self::default() + } + + /// 输入一段增量文本,返回可以安全发给前端的文本(标记会被扣住)。 + pub fn push(&mut self, text: &str) -> String { + self.buffer.push_str(text); + let mut emit = String::new(); + loop { + if let Some(end) = self.buffer.find("]]") { + let before = &self.buffer[..end]; + if let Some(start) = before.rfind("[[") { + let inner = &before[start + 2..]; + if !inner.contains('\n') && inner.chars().count() <= 200 { + // 完整标记:丢弃 [[...]],保留 ]] 之后的内容 + emit.push_str(&self.buffer[end + 2..]); + self.buffer.clear(); + continue; + } + } + } + // 最后一个换行之后可能在形成未完成的标记:只保留尾部,释放前面内容 + if let Some(last_nl) = self.buffer.rfind('\n') { + let tail = &self.buffer[last_nl + 1..]; + if let Some(tstart) = tail.find("[[") { + if !tail[tstart + 2..].contains("]]") && tail[tstart + 2..].chars().count() <= 200 { + emit.push_str(&self.buffer[..=last_nl]); + self.buffer = tail.to_string(); + break; + } + } + } + // 缓冲过长仍未形成标记:全部释放 + if self.buffer.chars().count() > 300 { + emit.push_str(&self.buffer); + self.buffer.clear(); + } + break; + } + emit + } + + /// 流结束时的残留缓冲。 + pub fn flush(&mut self) -> String { + std::mem::take(&mut self.buffer) + } +} + +/// 执行一轮工具调用并把结果回填给模型。 +/// 输入本轮原始文本(含标记),返回 (合并后的原始文本, 合并后的清理文本)。 +/// `call` 是模型调用闭包(便于测试):输入消息列表,返回非流式文本。 +pub async fn run_tool_round( + call: &mut F, + history: &[xianren_engine::ChatMessage], + raw_text: &str, + tools: &[ToolDef], +) -> Result<(String, String), String> +where + F: FnMut(Vec) -> Fut, + Fut: std::future::Future>, +{ + let markers = extract_markers(raw_text); + if markers.is_empty() { + return Ok((raw_text.to_string(), strip_markers(raw_text))); + } + let mut feed = String::new(); + for m in markers { + match parse_marker(&m) { + Some(tm) => match execute_tool_marker(&tm, tools).await { + Ok(r) => feed.push_str(&format!("工具调用「{m}」结果:\n{r}\n")), + Err(e) => feed.push_str(&format!("工具调用「{m}」失败:{e}\n")), + }, + None => feed.push_str(&format!("无法识别的工具标记:{m}\n")), + } + } + let mut msgs = history.to_vec(); + msgs.push(xianren_engine::types::ChatMessage::new( + "assistant", + &strip_markers(raw_text), + )); + msgs.push(xianren_engine::types::ChatMessage::new( + "user", + &format!( + "[工具执行结果]\n{feed}\n请根据以上工具结果继续完成回答;如果已经可以回答,直接给出最终回答,不要再输出工具标记。" + ), + )); + let cont_raw = call(msgs).await?; + let combined_raw = format!("{}\n{}", raw_text.trim(), cont_raw.trim()); + let combined_clean = strip_markers(&combined_raw); + Ok((combined_raw, combined_clean)) +} + +#[cfg(test)] +mod tests { + use super::*; + use xianren_engine::ChatMessage; + + #[test] + fn marker_parse_and_strip() { + let m = parse_marker("skill:翻译助手:把这段翻译成英文").unwrap(); + match m { + ToolMarker::Skill { name, args } => { + assert_eq!(name, "翻译助手"); + assert_eq!(args.as_deref(), Some("把这段翻译成英文")); + } + _ => panic!("wrong variant"), + } + let m2 = parse_marker("mcp:天气服务:get_weather:北京").unwrap(); + match m2 { + ToolMarker::Mcp { server, tool, args } => { + assert_eq!(server, "天气服务"); + assert_eq!(tool, "get_weather"); + assert_eq!(args.as_deref(), Some("北京")); + } + _ => panic!("wrong variant"), + } + let raw = "好的,我查一下。\n[[skill:翻译助手]]\n然后我继续回答。[[mcp:服务:工具]]结束"; + let cleaned = strip_markers(raw); + assert!(!cleaned.contains("[[")); + assert!(cleaned.contains("好的")); + assert!(cleaned.contains("然后我继续回答")); + assert_eq!(extract_markers(raw).len(), 2); + } + + #[test] + fn marker_filter_holds_markers() { + let mut f = MarkerFilter::new(); + let mut visible = String::new(); + for piece in ["好的,我先", "查一下。\n[[skill:翻译", "助手]]\n继续回答"] { + visible.push_str(&f.push(piece)); + } + visible.push_str(&f.flush()); + assert!(!visible.contains("[[")); + assert!(visible.contains("继续回答")); + } + + #[tokio::test] + async fn tool_round_executes_and_continues() { + let tools = vec![ToolDef { + kind: "skill".into(), + id: "1".into(), + name: "翻译助手".into(), + description: "中英互译".into(), + content: "你是一位专业中英互译助手。".into(), + url: String::new(), + auth_token: String::new(), + }]; + let history: Vec = vec![ChatMessage::new("user", "帮我翻译 hello")]; + let mut call = |msgs: Vec| async move { + let joined: String = msgs.iter().map(|m| m.content.clone()).collect::>().join("|"); + assert!(joined.contains("翻译助手")); + assert!(joined.contains("你是一位专业中英互译助手")); + Ok("翻译结果:你好".to_string()) + }; + let (_, final_text) = + run_tool_round(&mut call, &history, "我先查一下。\n[[skill:翻译助手]]", &tools) + .await + .unwrap(); + assert!(final_text.contains("翻译结果:你好")); + assert!(!final_text.contains("[[")); + } +} diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 9f2263c..34f7221 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -27,11 +27,13 @@ impl CoreApp { let models_dir = data_dir.join("models"); let engines_dir = data_dir.join("engines"); let logs_dir = data_dir.join("logs"); + let recommend_dir = data_dir.join("recommendations"); std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&models_dir)?; std::fs::create_dir_all(&engines_dir)?; std::fs::create_dir_all(&logs_dir)?; + std::fs::create_dir_all(&recommend_dir)?; let db_path = data_dir.join("xianren.db"); let conn = open_db(&db_path)?; @@ -66,9 +68,25 @@ impl CoreApp { ("api_key", String::new()), ("api_enabled", "false".to_string()), ("upload_max_mb", "10".to_string()), + ("auto_title", "true".to_string()), + ("suggest_enabled", "true".to_string()), + ("suggest_count", "3".to_string()), + ( + "tavily_api_key", + "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".to_string(), + ), + ("tool_web_search_enabled", "true".to_string()), + ( + "recommend_dir", + self.data_dir + .join("recommendations") + .to_string_lossy() + .to_string(), + ), ]; for (key, value) in defaults { - let _ = settings::set(&db, key, &value); + // 仅补默认值,不覆盖用户已保存的设置 + let _ = settings::insert_default(&db, key, &value); } } } @@ -83,9 +101,14 @@ 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, "models", "enabled", "INTEGER NOT NULL DEFAULT 0")?; 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 '[]'")?; + ensure_column(&conn, "messages", "model_id", "TEXT")?; + ensure_column(&conn, "conversations", "pinned", "INTEGER NOT NULL DEFAULT 0")?; + ensure_column(&conn, "conversations", "favorite", "INTEGER NOT NULL DEFAULT 0")?; + ensure_column(&conn, "conversations", "tools_json", "TEXT NOT NULL DEFAULT '[]'")?; Ok(conn) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3d3d8eb..d399810 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,8 +1,10 @@ pub mod app; pub mod error; +pub mod mcp_servers; pub mod models; pub mod sessions; pub mod settings; +pub mod skills; pub use app::CoreApp; pub use error::{CoreError, Result}; diff --git a/crates/core/src/mcp_servers.rs b/crates/core/src/mcp_servers.rs new file mode 100644 index 0000000..81aaddf --- /dev/null +++ b/crates/core/src/mcp_servers.rs @@ -0,0 +1,95 @@ +use crate::error::Result; +use rusqlite::{params, Connection}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct McpServer { + pub id: String, + pub name: String, + pub description: String, + pub url: String, + pub auth_token: String, + pub enabled: bool, + pub created_at: String, +} + +pub fn list(db: &Connection) -> Result> { + let mut stmt = db.prepare( + "SELECT id, name, description, url, auth_token, enabled, created_at FROM mcp_servers ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], row_to_server)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +pub fn get(db: &Connection, id: &str) -> Result> { + let mut stmt = db.prepare( + "SELECT id, name, description, url, auth_token, enabled, created_at FROM mcp_servers WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![id], row_to_server)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn insert( + db: &Connection, + name: &str, + description: &str, + url: &str, + auth_token: &str, + enabled: bool, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + db.execute( + "INSERT INTO mcp_servers (id, name, description, url, auth_token, enabled) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![id, name, description, url, auth_token, enabled as i32], + )?; + Ok(id) +} + +pub fn update( + db: &Connection, + id: &str, + name: &str, + description: &str, + url: &str, + auth_token: &str, + enabled: bool, +) -> Result<()> { + db.execute( + "UPDATE mcp_servers SET name = ?1, description = ?2, url = ?3, auth_token = ?4, enabled = ?5 WHERE id = ?6", + params![name, description, url, auth_token, enabled as i32, id], + )?; + Ok(()) +} + +pub fn remove(db: &Connection, id: &str) -> Result<()> { + db.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> { + db.execute( + "UPDATE mcp_servers SET enabled = ?1 WHERE id = ?2", + params![enabled as i32, id], + )?; + Ok(()) +} + +fn row_to_server(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let enabled: i32 = row.get(5)?; + Ok(McpServer { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + url: row.get(3)?, + auth_token: row.get(4)?, + enabled: enabled != 0, + created_at: row.get(6)?, + }) +} diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index 5e47add..1fb182b 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -10,6 +10,7 @@ pub struct ModelInfo { pub repo_id: String, pub source: String, pub kind: String, + pub enabled: bool, pub file_name: String, pub file_path: String, pub file_size: i64, @@ -73,7 +74,7 @@ pub fn insert( pub fn list(db: &Connection) -> Result> { let mut stmt = db.prepare( - "SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at + "SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at, enabled FROM models ORDER BY created_at DESC", )?; let rows = stmt.query_map([], row_to_model)?; @@ -86,7 +87,7 @@ pub fn list(db: &Connection) -> Result> { pub fn get(db: &Connection, id: &str) -> Result> { let mut stmt = db.prepare( - "SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at + "SELECT id, repo_id, source, kind, file_name, file_path, file_size, quant, family, status, sha256, base_url, api_key, api_model, meta_json, created_at, enabled FROM models WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], row_to_model)?; @@ -101,6 +102,14 @@ pub fn remove(db: &Connection, id: &str) -> Result<()> { Ok(()) } +pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> { + db.execute( + "UPDATE models SET enabled = ?1 WHERE id = ?2", + params![enabled as i32, id], + )?; + Ok(()) +} + pub fn update_file_info(db: &Connection, id: &str, size: i64, status: &str) -> Result<()> { db.execute( "UPDATE models SET file_size = ?1, status = ?2 WHERE id = ?3", @@ -192,11 +201,13 @@ fn collect_gguf_files(dir: &Path, out: &mut Vec) -> Result<()> { fn row_to_model(row: &rusqlite::Row<'_>) -> rusqlite::Result { let meta_json: String = row.get(14)?; + let enabled: i32 = row.get(16)?; Ok(ModelInfo { id: row.get(0)?, repo_id: row.get(1)?, source: row.get(2)?, kind: row.get(3)?, + enabled: enabled != 0, file_name: row.get(4)?, file_path: row.get(5)?, file_size: row.get(6)?, diff --git a/crates/core/src/schema.sql b/crates/core/src/schema.sql index d564b3b..58d37dd 100644 --- a/crates/core/src/schema.sql +++ b/crates/core/src/schema.sql @@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS models ( repo_id TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'local', kind TEXT NOT NULL DEFAULT 'local', + enabled INTEGER NOT NULL DEFAULT 0, file_name TEXT NOT NULL, file_path TEXT NOT NULL, file_size INTEGER NOT NULL DEFAULT 0, @@ -27,6 +28,9 @@ CREATE TABLE IF NOT EXISTS conversations ( title TEXT NOT NULL DEFAULT '新会话', model_id TEXT, system_prompt TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + favorite INTEGER NOT NULL DEFAULT 0, + tools_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -35,6 +39,7 @@ CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL, + model_id TEXT, content TEXT NOT NULL, tokens_in INTEGER, tokens_out INTEGER, @@ -54,3 +59,22 @@ CREATE TABLE IF NOT EXISTS message_versions ( seq INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + auth_token TEXT NOT NULL DEFAULT '', + enabled 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 55986a9..bc21b4a 100644 --- a/crates/core/src/sessions.rs +++ b/crates/core/src/sessions.rs @@ -8,6 +8,9 @@ pub struct Conversation { pub title: String, pub model_id: Option, pub system_prompt: Option, + pub pinned: bool, + pub favorite: bool, + pub tools: Vec, pub created_at: String, pub updated_at: String, } @@ -17,6 +20,7 @@ pub struct Message { pub id: String, pub conversation_id: String, pub role: String, + pub model_id: Option, pub content: String, pub tokens_in: Option, pub tokens_out: Option, @@ -54,8 +58,8 @@ pub fn create_conversation( pub fn list_conversations(db: &Connection) -> Result> { let mut stmt = db.prepare( - "SELECT id, title, model_id, system_prompt, created_at, updated_at - FROM conversations ORDER BY updated_at DESC", + "SELECT id, title, model_id, system_prompt, pinned, favorite, tools_json, created_at, updated_at + FROM conversations ORDER BY pinned DESC, updated_at DESC", )?; let rows = stmt.query_map([], row_to_conversation)?; let mut out = Vec::new(); @@ -67,7 +71,7 @@ pub fn list_conversations(db: &Connection) -> Result> { pub fn get_conversation(db: &Connection, id: &str) -> Result> { let mut stmt = db.prepare( - "SELECT id, title, model_id, system_prompt, created_at, updated_at + "SELECT id, title, model_id, system_prompt, pinned, favorite, tools_json, created_at, updated_at FROM conversations WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], row_to_conversation)?; @@ -77,6 +81,22 @@ pub fn get_conversation(db: &Connection, id: &str) -> Result Result> { + let mut stmt = db.prepare( + "SELECT c.id, c.title, c.model_id, c.system_prompt, c.pinned, c.favorite, c.tools_json, c.created_at, c.updated_at + FROM conversations c + WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id) + ORDER BY c.updated_at DESC + LIMIT 1", + )?; + let mut rows = stmt.query_map([], row_to_conversation)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + pub fn touch_conversation(db: &Connection, id: &str) -> Result<()> { db.execute( "UPDATE conversations SET updated_at = datetime('now') WHERE id = ?1", @@ -93,6 +113,39 @@ pub fn update_conversation_model(db: &Connection, id: &str, model_id: &str) -> R Ok(()) } +pub fn update_conversation_title(db: &Connection, id: &str, title: &str) -> Result<()> { + db.execute( + "UPDATE conversations SET title = ?1 WHERE id = ?2", + params![title, id], + )?; + Ok(()) +} + +pub fn set_conversation_pinned(db: &Connection, id: &str, pinned: bool) -> Result<()> { + db.execute( + "UPDATE conversations SET pinned = ?1 WHERE id = ?2", + params![pinned as i32, id], + )?; + Ok(()) +} + +pub fn set_conversation_favorite(db: &Connection, id: &str, favorite: bool) -> Result<()> { + db.execute( + "UPDATE conversations SET favorite = ?1 WHERE id = ?2", + params![favorite as i32, id], + )?; + Ok(()) +} + +pub fn update_conversation_tools(db: &Connection, id: &str, tools: &[String]) -> Result<()> { + let tools_json = serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string()); + db.execute( + "UPDATE conversations SET tools_json = ?1 WHERE id = ?2", + params![tools_json, id], + )?; + Ok(()) +} + pub fn delete_conversation(db: &Connection, id: &str) -> Result<()> { db.execute("DELETE FROM conversations WHERE id = ?1", params![id])?; Ok(()) @@ -102,6 +155,7 @@ pub fn add_message( db: &Connection, conversation_id: &str, role: &str, + model_id: Option<&str>, content: &str, tokens_in: Option, tokens_out: Option, @@ -112,12 +166,13 @@ pub fn add_message( 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, elapsed_ms, first_token_ms, images_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO messages (id, conversation_id, role, model_id, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ id, conversation_id, role, + model_id, content, tokens_in, tokens_out, @@ -129,9 +184,28 @@ pub fn add_message( Ok(id) } +/// 导入对话时插入消息(可指定创建时间)。 +pub fn import_message( + db: &Connection, + conversation_id: &str, + role: &str, + content: &str, + images: &[String], + created_at: Option<&str>, +) -> 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, images_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, COALESCE(?6, datetime('now')))", + params![id, conversation_id, role, content, images_json, created_at], + )?; + 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, elapsed_ms, first_token_ms, images_json, created_at + "SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, model_id FROM messages WHERE conversation_id = ?1 ORDER BY rowid ASC", )?; let rows = stmt.query_map(params![conversation_id], row_to_message)?; @@ -144,7 +218,7 @@ 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 + "SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, model_id FROM messages WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], row_to_message)?; @@ -156,11 +230,11 @@ pub fn get_message(db: &Connection, id: &str) -> Result> { pub fn get_message_with_rowid(db: &Connection, id: &str) -> 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, rowid + "SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at, model_id, rowid FROM messages WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], |row| { - let rowid: i64 = row.get(10)?; + let rowid: i64 = row.get(11)?; let msg = row_to_message(row)?; Ok((rowid, msg)) })?; @@ -277,13 +351,19 @@ pub fn apply_message_version( } fn row_to_conversation(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let pinned: i32 = row.get(4)?; + let favorite: i32 = row.get(5)?; + let tools_json: String = row.get(6)?; Ok(Conversation { id: row.get(0)?, title: row.get(1)?, model_id: row.get(2)?, system_prompt: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, + pinned: pinned != 0, + favorite: favorite != 0, + tools: serde_json::from_str(&tools_json).unwrap_or_default(), + created_at: row.get(7)?, + updated_at: row.get(8)?, }) } @@ -293,6 +373,7 @@ fn row_to_message(row: &rusqlite::Row<'_>) -> rusqlite::Result { id: row.get(0)?, conversation_id: row.get(1)?, role: row.get(2)?, + model_id: row.get(10)?, content: row.get(3)?, tokens_in: row.get(4)?, tokens_out: row.get(5)?, diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index ba8fdcb..f5bdd22 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -19,6 +19,15 @@ pub fn set(db: &Connection, key: &str, value: &str) -> Result<()> { Ok(()) } +/// 仅当键不存在时写入默认值,避免覆盖用户已保存的设置。 +pub fn insert_default(db: &Connection, key: &str, value: &str) -> Result<()> { + db.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) +} + pub fn all(db: &Connection) -> Result> { let mut stmt = db.prepare("SELECT key, value FROM settings ORDER BY key")?; let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; @@ -28,4 +37,3 @@ pub fn all(db: &Connection) -> Result> { } Ok(out) } - diff --git a/crates/core/src/skills.rs b/crates/core/src/skills.rs new file mode 100644 index 0000000..d3be4eb --- /dev/null +++ b/crates/core/src/skills.rs @@ -0,0 +1,119 @@ +use crate::error::Result; +use rusqlite::{params, Connection}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Skill { + pub id: String, + pub name: String, + pub description: String, + pub content: String, + pub enabled: bool, + pub created_at: String, +} + +pub fn list(db: &Connection) -> Result> { + let mut stmt = db.prepare( + "SELECT id, name, description, content, enabled, created_at FROM skills ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], row_to_skill)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +pub fn get(db: &Connection, id: &str) -> Result> { + let mut stmt = db.prepare( + "SELECT id, name, description, content, enabled, created_at FROM skills WHERE id = ?1", + )?; + let mut rows = stmt.query_map(params![id], row_to_skill)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn insert( + db: &Connection, + name: &str, + description: &str, + content: &str, + enabled: bool, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + db.execute( + "INSERT INTO skills (id, name, description, content, enabled) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, name, description, content, enabled as i32], + )?; + Ok(id) +} + +pub fn update( + db: &Connection, + id: &str, + name: &str, + description: &str, + content: &str, + enabled: bool, +) -> Result<()> { + db.execute( + "UPDATE skills SET name = ?1, description = ?2, content = ?3, enabled = ?4 WHERE id = ?5", + params![name, description, content, enabled as i32, id], + )?; + Ok(()) +} + +pub fn remove(db: &Connection, id: &str) -> Result<()> { + db.execute("DELETE FROM skills WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> { + db.execute( + "UPDATE skills SET enabled = ?1 WHERE id = ?2", + params![enabled as i32, id], + )?; + Ok(()) +} + +fn row_to_skill(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let enabled: i32 = row.get(4)?; + Ok(Skill { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + content: row.get(3)?, + enabled: enabled != 0, + created_at: row.get(5)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("schema.sql")).unwrap(); + conn + } + + #[test] + fn skill_crud() { + let db = test_db(); + let id = insert(&db, "翻译助手", "中英互译", "你是一位专业翻译。", true).unwrap(); + let items = list(&db).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "翻译助手"); + update(&db, &id, "翻译助手v2", "中英互译", "新内容", false).unwrap(); + let s = get(&db, &id).unwrap().unwrap(); + assert_eq!(s.content, "新内容"); + assert!(!s.enabled); + set_enabled(&db, &id, true).unwrap(); + assert!(get(&db, &id).unwrap().unwrap().enabled); + remove(&db, &id).unwrap(); + assert!(list(&db).unwrap().is_empty()); + } +} diff --git a/crates/download/src/manager.rs b/crates/download/src/manager.rs index 7aefad2..c82dae3 100644 --- a/crates/download/src/manager.rs +++ b/crates/download/src/manager.rs @@ -4,12 +4,14 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use tokio::io::AsyncWriteExt; use tokio::sync::Semaphore; const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024; const MAX_CONCURRENCY: usize = 8; +// 部分 CDN(如 ModelScope)会拦截非浏览器 User-Agent 的 GET 请求(403),需伪装浏览器标识 +const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; #[derive(Debug, Clone)] pub struct DownloadOptions { @@ -60,7 +62,12 @@ pub async fn download( opts: DownloadOptions, on_progress: impl Fn(DownloadProgress) + Send + Sync + 'static, ) -> Result { - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .user_agent(BROWSER_UA) + .connect_timeout(Duration::from_secs(20)) + .timeout(Duration::from_secs(300)) + .build() + .unwrap_or_default(); if opts.dest.exists() { if let Some(expected) = &opts.expected_sha256 { @@ -313,4 +320,3 @@ fn report( speed_bps: (downloaded as f64 / started.elapsed().as_secs_f64().max(0.001)) as u64, }); } - diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7abb517..42e45c0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5,5 +5,5 @@ pub mod types; pub use error::{EngineError, Result}; pub use manager::EngineManager; -pub use remote::{stream_chat_remote, RemoteConfig}; +pub use remote::{chat_remote, stream_chat_remote, RemoteConfig}; pub use types::{ChatMessage, ChatRequest, ChatStreamEvent, EngineConfig, EngineStatus}; diff --git a/crates/engine/src/manager.rs b/crates/engine/src/manager.rs index b352f6c..cbe9de2 100644 --- a/crates/engine/src/manager.rs +++ b/crates/engine/src/manager.rs @@ -271,6 +271,11 @@ pub(crate) fn sse_text_stream( .or_else(|| value.get("content").and_then(|v| v.as_str())); if let Some(text) = content { yield Ok(ChatStreamEvent::Text(text.to_string())); + } else if let Some(reasoning) = value + .pointer("/choices/0/delta/reasoning_content") + .and_then(|v| v.as_str()) + { + yield Ok(ChatStreamEvent::Reasoning(reasoning.to_string())); } } Err(e) => { diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index e29e496..7bf1e24 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -33,6 +33,32 @@ pub async fn stream_chat_remote( Ok(sse_text_stream(response.bytes_stream())) } +/// 调用远程 OpenAI 兼容 API 并返回完整的非流式文本。 +pub async fn chat_remote(cfg: &RemoteConfig, req: ChatRequest) -> Result { + let base = normalize_base(&cfg.base_url); + let url = format!("{base}/chat/completions"); + let client = reqwest::Client::new(); + let mut builder = client.post(&url).json(&req); + if let Some(key) = &cfg.api_key { + if !key.trim().is_empty() { + builder = builder.bearer_auth(key); + } + } + let response = builder.send().await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(EngineError::EngineHttp(status, body)); + } + let value: serde_json::Value = response.json().await?; + let text = value + .pointer("/choices/0/message/content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + Ok(text) +} + /// 把用户填写的 base url 归一化为 `:///v1` 形式。 fn normalize_base(base: &str) -> String { let mut s = base.trim().trim_end_matches('/').to_string(); diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index a49770b..34deac4 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -55,6 +55,8 @@ impl Serialize for ChatMessage { #[derive(Debug, Clone)] pub enum ChatStreamEvent { Text(String), + /// 推理模型(如 Qwen3.5/DeepSeek-R1)的思考过程增量文本 + Reasoning(String), Usage { prompt_tokens: u32, completion_tokens: u32, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..465931d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,246 @@ +# Xianren Studio(仙人工作室)代码组织与布局 + +> 本文档描述代码仓库的组织结构、模块职责、数据流与关键实现。**功能变更后如涉及结构、流程或数据模型,必须同步更新本文档。** + +最后更新:2026-08-16 + +--- + +## 1. 总览 + +技术栈:Tauri 2(Rust 壳 + WebView2) + React 18 / TypeScript / Vite / Tailwind,推理引擎为 llama.cpp 的 `llama-server` 子进程。 + +``` +xianren_studio/ +├── apps/desktop/ Tauri 桌面壳(Rust 命令层、tauri.conf.json、推荐模型默认 JSON) +├── crates/core/ 领域核心:SQLite(模型注册表、会话、消息、设置) +├── crates/engine/ llama-server 生命周期 + 流式/非流式聊天(本地与远程 OpenAI 兼容) +├── crates/download/ 分片断点续传下载器 +├── crates/api/ OpenAI 兼容本地 API 服务(axum) +├── ui/ React 前端 +├── scripts/ 构建 / 引擎与测试模型下载脚本 +├── docs/ 产品与技术方案、功能记录、本文档 +└── AGENTS.md 开发/维护约定(含文档同步规则) +``` + +--- + +## 2. 分层与数据流 + +``` +React UI(ui/src) + │ Tauri invoke(api.ts 封装)+ 事件监听(onEvent) + ▼ +apps/desktop/src/commands.rs ← 所有 Tauri 命令(invoke_handler 注册于 lib.rs) + │ + ├── crates/core(SQLite:models / sessions / settings) + ├── crates/engine(llama-server 子进程 / 远程 OpenAI API) + ├── crates/download(下载任务) + └── crates/api(本地 API 服务) +``` + +前端通过 `ui/src/api.ts` 的 `api.*` 方法调用后端命令;后端通过 Tauri `emit` 向前端推送事件(见 §7 事件通道)。 + +--- + +## 3. 前端结构(`ui/`) + +``` +ui/src/ +├── main.tsx 入口 +├── App.tsx 导航栏、路由(7 个页面)、全局事件订阅(引擎/下载/模型/建议) +├── api.ts Tauri invoke 封装 + 全部请求/事件 TypeScript 类型 +├── store.ts zustand 全局状态(见下) +├── styles.css 全局样式 +├── components/Icon.tsx SVG 图标库 +└── pages/ + ├── ChatPage.tsx 对话页(最大的页面,含会话快照、消息气泡、模型下拉、建议标签) + ├── ModelsPage.tsx 模型管理(本地部署 + 在线 API 启用开关) + ├── ModelPlazaPage.tsx 模型广场(HF/ModelScope 搜索与下载) + ├── TasksPage.tsx 任务(下载/部署进度) + ├── ToolsPage.tsx 工具(Tavily 联网搜索) + ├── ServerPage.tsx 服务管理(本地 OpenAI API) + └── SettingsPage.tsx 设置 +``` + +### 3.1 全局状态(`store.ts`) + +主要状态字段: + +| 字段 | 说明 | +| --- | --- | +| `models` | 模型列表(含 `enabled`、`kind`、`file_name` 等) | +| `engine` | 引擎状态(running / model / port) | +| `deployStates` / `deployProgress` | 各模型部署状态(loading/ready/error)与进度 | +| `chatSuggestionsByMessage` | 消息 ID → 预测建议列表(全局接收,跨选项卡不丢) | +| `conversations` | 会话列表 | +| `tasks` | 下载/部署任务 | +| `server` | 本地 API 服务状态 | + +### 3.2 会话快照(`ChatPage.tsx`) + +切换选项卡时 React Router 会卸载页面,因此 `ChatPage.tsx` 顶部维护了模块级 `chatSession` 对象(当前会话、所选模型、消息、输入草稿、请求参数、面板开合、工具、版本、附件),每次渲染后写回;重新挂载时用快照初始化,实现「切走再回来保持原状」。 + +--- + +## 4. 后端命令层(`apps/desktop`) + +### 4.1 `lib.rs` + +- `run()`:初始化数据目录(`%APPDATA%\XianrenStudio`)、日志、panic 钩子;构建 Tauri 应用并注册 `invoke_handler`。 +- 数据目录:`logs/`、`models/`、`engines/`、`recommendations/`。 + +### 4.2 `commands.rs`(命令分组) + +| 分组 | 命令 | +| --- | --- | +| 应用/设置 | `app_info`、`settings_get`、`settings_set` | +| 模型 | `list_models`、`import_model`、`remove_model`、`set_model_enabled`、`scan_models`、`add_remote_model` | +| 模型广场 | `search_models`、`list_model_files`、`list_recommended_models`、`import_recommendations`、`fetch_model_page` | +| 会话 | `list_conversations`、`create_conversation`、`rename_conversation`、`set_conversation_pinned`、`set_conversation_favorite`、`import_conversation`、`set_conversation_tools`、`delete_conversation`、`get_messages` | +| 消息 | `chat_send`、`chat_stop`、`regenerate_message`、`edit_message`、`list_message_versions`、`apply_message_version` | +| 引擎 | `engine_start`、`engine_stop`、`engine_status`、`deploy_model` | +| 工具 | `web_search`(Tavily)、`list_skills`、`add_skill`、`update_skill`、`remove_skill`、`set_skill_enabled`、`test_skill`、`list_mcp_servers`、`add_mcp_server`、`update_mcp_server`、`remove_mcp_server`、`set_mcp_server_enabled`、`mcp_test_server`、`mcp_list_tools`、`mcp_call_tool` | +| 下载 | `download_enqueue` | +| 服务 | `server_start`、`server_stop`、`server_status` | +| 其他 | `report_error`、`open_path` | + +**聊天相关核心函数(均在 `commands.rs`):** + +- `chat_send`:写入用户消息 → 构建历史 → 拉起后台生成任务。 +- `run_generation_and_stream`:统一生成入口(本地引擎 / 远程 API),流式转发 token、统计用量、写库、发 `chat://done`,随后触发标题生成与建议生成。 +- `maybe_generate_suggestions` → `call_suggestion_model` → `parse_suggestions`:回答完成后预测用户接下来可能说的话。统一要求模型输出 JSON 数组;最多重试 3 次;过滤元信息行与回答原文片段;每条 ≤ 30 字。 +- `maybe_generate_conversation_title`:首轮对话自动生成标题。 +- `create_conversation`:先查找空会话(`find_empty_conversation`)复用,没有才新建。 + +**工具协议(技能 / MCP):** + +- `apps/desktop/src/tools.rs`:`ToolDef`、标记解析(`[[skill:名]]` / `[[mcp:服务:工具:参数]]`)、`MarkerFilter`(流式输出时过滤标记)、`run_tool_round`(执行工具并回填模型,最多 3 轮)、系统提示词构建。 +- `apps/desktop/src/mcp_client.rs`:MCP Streamable HTTP 客户端(`initialize` / `tools/list` / `tools/call`,兼容 SSE 响应)。 +- 聊天链路:`chat_send` / `regenerate_message` / `edit_message` 解析会话工具并注入系统提示词 → `run_generation_and_stream` 流式输出时过滤标记 → 完成后执行工具循环并保存最终内容。 + +--- + +## 5. 核心库(`crates/core`) + +### 5.1 `app.rs` + +- `CoreApp`:持有 SQLite 连接(`Mutex`)与数据目录。 +- `seed_default_settings`:启动时用 `insert_default` 补齐缺失的设置键(不覆盖用户已保存值)。 +- `open_db`:执行 `schema.sql` + **增量迁移** `ensure_column`(为旧库补充新列,幂等)。 + +### 5.2 `schema.sql`(表结构) + +| 表 | 关键字段 | 说明 | +| --- | --- | --- | +| `models` | `kind`(local/remote)、`enabled`、`file_name`、`file_path`、`status`、`base_url`、`api_key`、`api_model`、`meta_json` | 模型注册表;`enabled` 决定在线 API 模型是否出现在聊天页可选列表 | +| `settings` | `key`/`value` | 键值设置 | +| `conversations` | `title`、`model_id`、`pinned`、`favorite`、`tools_json` | 会话 | +| `messages` | `role`、`model_id`、`content`、`tokens_in/out`、`elapsed_ms`、`first_token_ms`、`images_json` | 消息;`model_id` 记录该回答所用模型(回答下方展示模型名) | +| `message_versions` | `content`、`tokens_out`、`seq` | 重新生成前的旧版本 | +| `skills` | `name`、`description`、`content`、`enabled` | 技能工具库 | +| `mcp_servers` | `name`、`description`、`url`、`auth_token`、`enabled` | MCP 服务配置 | + +迁移清单(`ensure_column`):`models.kind/base_url/api_key/api_model/enabled`、`messages.elapsed_ms/first_token_ms/images_json/model_id`、`conversations.pinned/favorite/tools_json`。 + +### 5.3 `models.rs` + +模型 CRUD、`set_enabled`(启用/停用)、`scan_directory`(扫描 GGUF 目录)、量化猜测 `guess_quant`。 + +### 5.4 `sessions.rs` + +会话/消息/版本 CRUD;`find_empty_conversation`(新建对话复用空会话);`import_message`(导入对话用,可指定创建时间)。 + +### 5.5 `settings.rs` + +`get/set/insert_default/all`。 + +--- + +## 6. 引擎与远程调用(`crates/engine`) + +### 6.1 `manager.rs`(`EngineManager`) + +- `start`:以子进程启动 llama-server(`CREATE_NO_WINDOW`),轮询 `/health` 等待就绪;最多 180s。 +- `stop`:先请求 `/shutdown`,超时再 kill。 +- `stream_chat` / `chat`:流式 / 非流式聊天(`/v1/chat/completions`)。 +- `status`:返回 running / port / model。 + +### 6.2 `remote.rs` + +- `stream_chat_remote` / `chat_remote`:OpenAI 兼容远程 API(Bearer 认证)。 +- `normalize_base`:Base URL 自动补 `/v1`。 + +### 6.3 `types.rs` + +- `ChatMessage`:role/content/images,多模态时序列化为 content 数组。 +- `ChatRequest`:model / messages / temperature / top_p / max_tokens / stream。 +- `ChatStreamEvent`:Text / Reasoning(思考过程)/ Usage。 + +--- + +## 7. 事件通道(Tauri emit → 前端 onEvent) + +| 事件 | 方向 | 说明 | +| --- | --- | --- | +| `chat://token` | 后端→前端 | 流式增量文本 | +| `chat://reasoning` | 后端→前端 | 思考过程增量 | +| `chat://done` | 后端→前端 | 回答完成(含 message_id、model_id、用量) | +| `chat://suggestions` | 后端→前端 | 预测建议列表(全局订阅,存入 store) | +| `chat://tool-status` | 后端→前端 | 工具调用状态(running/done),界面提示 | +| `chat://message-updated` | 后端→前端 | 消息内容更新(恢复版本后) | +| `chat://title-updated` | 后端→前端 | 标题更新 | +| `chat://error` | 后端→前端 | 生成错误 | +| `engine://status` | 后端→前端 | 引擎状态变化 | +| `engine://deploy` | 后端→前端 | 部署状态(loading/ready/error) | +| `engine://deploy-progress` | 后端→前端 | 部署进度百分比/阶段 | +| `models://updated` | 后端→前端 | 模型列表变化 | +| `download://started/progress/done/error` | 后端→前端 | 下载任务进度 | +| `server://status` | 后端→前端 | 本地 API 服务状态 | + +--- + +## 8. 设置项(设置键 / 默认值) + +定义于 `crates/core/src/app.rs` 的 `seed_default_settings`: + +| 键 | 默认值 | 用途 | +| --- | --- | --- | +| `model_dir` | 数据目录/models | 模型目录 | +| `engine_bin` | engines/cpu/llama-server.exe | llama-server 路径 | +| `backend` | auto | 后端(auto/cpu/cuda/vulkan) | +| `hf_endpoint` | https://hf-mirror.com | 模型下载源 | +| `api_port` / `api_key` / `api_enabled` | 1234 / 空 / false | 本地 API 服务 | +| `upload_max_mb` | 10 | 上传大小上限 | +| `auto_title` | true | 自动生成标题 | +| `suggest_enabled` | true | 预测用户接下来说的话开关 | +| `suggest_count` | 3 | 预测条数(1–5) | +| `tavily_api_key` | (内置演示值) | 联网搜索 API Key | +| `tool_web_search_enabled` | true | 联网搜索工具开关 | +| `recommend_dir` | 数据目录/recommendations | 推荐列表目录 | + +--- + +## 9. 构建与运行 + +```powershell +# 开发模式(自动拉起 Vite + Tauri) +npm --prefix apps/desktop run dev + +# 正式版(必须带 custom-protocol 特性,前端资源才会内嵌进 exe) +npm run build --prefix ui +cargo build --release -p xianren-desktop --features custom-protocol + +# 或一键打包(tauri build 会自动启用该特性并打 NSIS 安装包) +npm --prefix apps/desktop run build +``` + +> 注意:不带 `custom-protocol` 编译出的 release exe **不会内嵌前端页面**,直接运行会白屏,因此正式发布必须带该特性。 + +--- + +## 10. 文档维护约定 + +- 功能变化 → 更新 `docs/FEATURES.md` 对应章节,并在「改动记录」追加。 +- 结构/流程/数据模型变化 → 更新本文档对应章节。 +- 大版本信息(技术栈、目录说明)变化 → 同步更新 `README.md`。 diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..44077d8 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,159 @@ +# Xianren Studio(仙人工作室)功能记录 + +> 本文档是**当前实现功能**的权威记录。每次功能新增、修改或删除,都必须同步更新本文档: +> - 修改对应功能章节的描述; +> - 在文末「改动记录」中追加一条说明(日期 + 改动内容)。 +> +> 代码组织与布局见 [ARCHITECTURE.md](./ARCHITECTURE.md)。 + +最后更新:2026-08-16 + +--- + +## 1. 页面总览 + +应用共 7 个选项卡(导航左侧栏): + +| 选项卡 | 路由 | 页面 | +| --- | --- | --- | +| 对话 | `/chat` | `ui/src/pages/ChatPage.tsx` | +| 模型管理 | `/` | `ui/src/pages/ModelsPage.tsx` | +| 模型广场 | `/plaza` | `ui/src/pages/ModelPlazaPage.tsx` | +| 任务 | `/tasks` | `ui/src/pages/TasksPage.tsx` | +| 工具 | `/tools` | `ui/src/pages/ToolsPage.tsx` | +| 服务管理 | `/server` | `ui/src/pages/ServerPage.tsx` | +| 设置 | `/settings` | `ui/src/pages/SettingsPage.tsx` | + +--- + +## 2. 对话页(`/chat`) + +### 2.1 会话列表(左侧) + +- 多会话管理:新建对话、删除、修改标题、置顶、收藏、导出 JSON、导入 JSON、分享(复制为文本)。 +- **空会话复用**:点击「新建对话」时,若已存在一个没有任何消息的空会话(数据库 `messages` 表中无该会话消息),则不新建,直接复用最近的那个空会话;否则才真正创建。该逻辑在后端 `create_conversation` 命令中实现。 + +### 2.2 右侧参数面板 + +- **当前大模型**:显示当前选中模型的名称(不带 `.gguf` 后缀),附带「本地 / 在线API」标记。 +- **可选大模型服务**(自定义下拉列表): + - 只列出**有部署/运行状态**的大模型:本地模型按部署状态展示(运行中=绿点、启动中=灰点、出错=红点);在线 API 模型需在「模型管理」中**启用**后才会出现(绿点,视为始终可用)。 + - 没有任何可用模型时显示占位文案:`暂无,请进模型管理部署`。 + - 模型名一律不显示 `.gguf` 后缀;在线 API 模型带「API」小标签。 + - 切换选项卡再回来时,**保持上次选择的会话与大模型**,不会自动换模型(模型选择逻辑只在所选模型被删除时才回退)。 +- **工具**:联网搜索(Tavily)开关。 +- **请求参数**:temperature、top_p、max_tokens 滑块。 +- 聊天页**不再提供**「启动引擎 / 停止引擎」按钮,也**不再有部署参数**(ctx_size、GPU 层数),部署统一到「模型管理」页完成。 + +### 2.3 消息区 + +- 流式输出、Markdown 渲染、代码高亮、思考过程折叠展示、消息统计(首字延迟 / tokens / tok/s / 总耗时)。 +- **每条助手回答下方用浅色小字显示该回答实际使用的大模型名**(数据来自 `messages.model_id`,加载历史会话也能还原)。 +- 支持复制、重新生成、版本历史(保存旧版本并可恢复)、编辑用户消息后重新提交。 +- 支持粘贴/上传图片与文本文件(本地文本模型会把图片替换为占位说明)。 + +### 2.4 预测用户接下来说的话(建议标签) + +- 大模型回答完成后,自动调用**同一个模型**预测用户接下来最可能输入的几条简短消息,以可点击标签显示在该回答下方。 +- 点击标签自动填入输入框并聚焦,等待用户编辑或发送。 +- 生成方式:统一要求模型输出 **JSON 字符串数组**(所有模型一致,不区分本地/远程);解析失败或条数不足时**最多重试 3 次**,保留已解析到的最好结果。 +- 每条建议**不超过 30 字**,且过滤两类内容:模型输出的元信息行(“以下是…”“预测…”等)、与 AI 回答原文重叠的片段。 +- 可在「设置 → 对话」中关闭该功能(默认开启)或调整条数(默认 3,范围 1–5)。 +- 建议事件在 App 全局接收并存入共享状态,切换选项卡后回来仍能看到。 + +### 2.6 对话工具(技能 / MCP / 联网搜索) + +- 右侧面板「工具」区域可按会话勾选:联网搜索(Tavily)、已启用的技能、已启用的 MCP 服务(每个会话独立记忆)。 +- 后端向模型注入可用工具说明;模型如需使用工具,在回复中**单独输出一行标记**: + - 技能:`[[skill:技能名]]`(可带参数 `[[skill:技能名:参数]]`) + - MCP :`[[mcp:服务名:工具名:参数]]` +- 流式输出时标记会被过滤不显示;生成完成后系统执行工具并把结果回填给模型继续回答(最多 3 轮工具循环)。 +- 工具调用期间输入框上方显示「正在调用工具:…」提示。 + +### 2.5 会话连续性 + +- 切换选项卡会卸载聊天页,但页面内维护一份**模块级会话快照**(`ChatPage.tsx` 中的 `chatSession`),保存:当前会话、所选模型、消息列表、输入草稿、请求参数、右侧面板开合、工具勾选、版本数据、附件。 +- 切走再回来时自动恢复上述状态;若离开时回答仍在生成中,回到页面会从数据库拉取该回答的最终结果。 + +--- + +## 3. 模型管理页(`/`) + +- **本地模型**:启动时自动扫描模型目录(可手动重新扫描);支持导入本地 GGUF 文件;可「部署」(后台加载 llama-server 并显示进度)或「停止」;可打开所在目录、移除。 +- **在线 API 模型**(OpenAI 兼容):添加时填写显示名称、Base URL、API Key(可选)、上游模型 ID。 + - 新增 **启用/停用** 开关(数据库 `models.enabled` 字段):**只有启用后的在线 API 模型才会出现在聊天页的「可选大模型服务」列表中**;未启用显示为「未启用」。 +- 部署状态:loading(加载中)、ready(就绪)、error(出错),在任务页也有对应记录。 + +--- + +## 4. 模型广场页(`/plaza`) + +- 搜索 Hugging Face / ModelScope 上的 GGUF 模型,支持热门榜(空关键词)。 +- 查看仓库的 GGUF 量化版本文件列表,选择版本一键下载(ModelScope 文件自动附带 SHA256 校验)。 +- 推荐模型列表:软件内置默认推荐(`apps/desktop/src/default_recommendations.json`),也支持在设置中上传自定义 JSON;空目录时自动写入默认列表。 +- 模型详情页可内嵌抓取展示(`fetch_model_page`)。 + +--- + +## 5. 任务页(`/tasks`) + +- 展示下载任务与部署任务的实时进度、状态(进行中 / 完成 / 失败)。 +- 空状态提示:暂无任务时引导去模型广场下载或部署本地模型。 + +--- + +## 6. 工具页(`/tools`) + +- **联网搜索**:配置 Tavily API Key,支持手动测试搜索;对话中的「联网搜索」开关依赖该配置。 +- **技能(Skill)**: + - 新增/编辑/删除技能:名称、描述(给模型看)、内容(指令/知识,调用时注入给模型)。 + - 启用/停用开关;「测试」按钮可预览技能内容。 +- **MCP 服务**: + - 新增/编辑/删除服务:名称、描述、端点地址(Streamable HTTP)、认证 Token(Bearer)。 + - 「测试连接」验证连通性;「列出工具」读取服务端 `tools/list` 并展示每个工具; + 每个工具可填参数并「调用」测试(走 `tools/call`)。 +- 对话中的技能/MCP 工具需在工具页**启用**后,才会出现在对话页右侧面板供按会话勾选。 + +--- + +## 7. 服务管理页(`/server`) + +- OpenAI 兼容的本地 API 服务:`/v1/models`、`/v1/chat/completions`(SSE 流式)、`/v1/embeddings`。 +- 可设置端口与 API Key,仅本机监听;可启动/停止并查看状态。 + +--- + +## 8. 设置页(`/settings`) + +- **引擎与路径**:模型目录、llama-server 路径、模型下载源(hf-mirror / huggingface.co / modelscope.cn)、默认后端(auto/cpu/cuda/vulkan)、上传大小上限。 +- **对话**: + - 自动生成对话标题(默认开):首次回复后自动生成并覆盖标题。 + - **自动预测用户接下来说的话**(默认开):见 2.4。 + - **预测条数**(默认 3,范围 1–5)。 +- **推荐模型列表**:推荐 JSON 目录(可打开、可上传覆盖)。 +- **关于**:版本、平台、数据目录、日志目录(可打开)、引擎是否可用。 + +设置键完整列表见 [ARCHITECTURE.md](./ARCHITECTURE.md#82-设置项设置键默认值)。 + +--- + +## 9. 改动记录 + +> 按时间倒序追加;每次改动功能都要在此登记。 + +### 2026-08-16 + +- 工具页新增「技能(Skill)」与「MCP 服务」管理;对话工具协议上线:模型用 `[[skill:…]]` / `[[mcp:…]]` 标记调用技能与 MCP 工具,系统执行后回填结果(最多 3 轮)。新增本地 MCP 测试服务器 `scripts/test_mcp_server.py` 与 3 个示例技能。 +- 会话列表「更多操作」菜单:鼠标移出按钮/菜单所在区域时自动关闭,避免遮挡其他对话标题的查看与操作。 +- 新建对话时复用已有空会话(无消息的会话),避免堆积多个空的「新会话」。 +- 聊天页会话快照:切换选项卡后保持上次的会话、大模型、消息、输入草稿等状态;回答生成中途切走再回来会拉取最终结果。 +- 预测建议统一为 JSON 输出:所有模型同一套提示词与解析,解析失败/条数不足时最多重试 3 次;每条不超过 30 字,过滤元信息行与回答原文片段;建议事件改为全局接收(跨选项卡不丢)。 +- 本地模型不再单独区分提示词策略(与在线 API 模型完全一致)。 +- 对话页右侧栏重构: + - 移除「启动引擎 / 停止引擎」按钮与「部署参数」(ctx_size、ngl)滑块,部署统一到模型管理页; + - 新增「可选大模型服务」列表:只显示有部署状态的大模型(绿=运行、灰=启动中、红=出错),空列表提示「暂无,请进模型管理部署」; + - 在线 API 模型需在模型管理启用后才出现在该列表(新增 `models.enabled` 字段与启用/停用开关); + - 「当前大模型」与列表中模型名均不显示 `.gguf` 后缀。 +- 每条助手回答下方以浅色字体显示所用大模型名(`messages` 表新增 `model_id` 字段,含旧库迁移)。 +- 对话页新增「预测用户接下来说的话」功能(设置可开关/调条数),点击预测标签自动填入输入框。 +- 配置并启用了 DeepSeek-V4-Flash 在线模型(Base URL `https://api.deepseek.com`,本机数据库内配置)。 diff --git a/scripts/test_mcp_server.py b/scripts/test_mcp_server.py new file mode 100644 index 0000000..419f409 --- /dev/null +++ b/scripts/test_mcp_server.py @@ -0,0 +1,105 @@ +"""本地 MCP 测试服务器(Streamable HTTP / JSON-RPC POST)。 + +用法:python scripts/test_mcp_server.py [端口] 默认 8765 +提供两个工具: +- get_current_time:返回当前时间(无参数) +- echo_text:原样返回 text 参数 +""" + +import json +import sys +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class McpHandler(BaseHTTPRequestHandler): + def do_GET(self): + # 供健康检查/就绪探测使用 + self._json({"ok": True}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode("utf-8") + try: + req = json.loads(body) + except Exception: + self._json( + { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": "Parse error"}, + } + ) + return + method = req.get("method") + rid = req.get("id") + if method == "initialize": + result = { + "protocolVersion": "2025-03-26", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test-mcp-server", "version": "1.0.0"}, + } + self._json({"jsonrpc": "2.0", "id": rid, "result": result}) + elif method == "tools/list": + result = { + "tools": [ + { + "name": "get_current_time", + "description": "获取当前时间", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "echo_text", + "description": "原样返回输入的文本", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + }, + ] + } + self._json({"jsonrpc": "2.0", "id": rid, "result": result}) + elif method == "tools/call": + params = req.get("params", {}) + name = params.get("name") + args = params.get("arguments", {}) + if name == "get_current_time": + content = [{"type": "text", "text": time.strftime("%Y-%m-%d %H:%M:%S")}] + elif name == "echo_text": + content = [{"type": "text", "text": str(args.get("text", ""))}] + else: + self._json( + { + "jsonrpc": "2.0", + "id": rid, + "error": {"code": -32602, "message": f"unknown tool {name}"}, + } + ) + return + self._json({"jsonrpc": "2.0", "id": rid, "result": {"content": content}}) + else: + self._json( + { + "jsonrpc": "2.0", + "id": rid, + "error": {"code": -32601, "message": f"unknown method {method}"}, + } + ) + + def _json(self, obj): + data = json.dumps(obj).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 + print(f"test-mcp-server listening on http://127.0.0.1:{port}/mcp") + HTTPServer(("127.0.0.1", port), McpHandler).serve_forever() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a0e302d..f0ab80c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,18 +1,28 @@ import { useEffect, useState } from "react"; import { NavLink, Route, Routes } from "react-router-dom"; -import { EngineDeployEvent, EngineDeployProgressEvent, onEvent } from "./api"; +import { + ChatSuggestionsEvent, + DownloadProgressEvent, + EngineDeployEvent, + EngineDeployProgressEvent, + onEvent, +} from "./api"; import { useStore } from "./store"; import Icon from "./components/Icon"; import ModelsPage from "./pages/ModelsPage"; import ChatPage from "./pages/ChatPage"; import ModelPlazaPage from "./pages/ModelPlazaPage"; +import ToolsPage from "./pages/ToolsPage"; import ServerPage from "./pages/ServerPage"; import SettingsPage from "./pages/SettingsPage"; +import TasksPage from "./pages/TasksPage"; const navItems = [ { to: "/chat", label: "对话", icon: "chat" }, { to: "/", label: "模型管理", icon: "box", end: true }, { to: "/plaza", label: "模型广场", icon: "plaza" }, + { to: "/tasks", label: "任务", icon: "tasks" }, + { to: "/tools", label: "工具", icon: "wrench" }, { to: "/server", label: "服务管理", icon: "server" }, { to: "/settings", label: "设置", icon: "settings" }, ]; @@ -27,6 +37,10 @@ export default function App() { const refreshConversations = useStore((s) => s.refreshConversations); const setDeployState = useStore((s) => s.setDeployState); const setDeployProgress = useStore((s) => s.setDeployProgress); + const setChatSuggestions = useStore((s) => s.setChatSuggestions); + const upsertTask = useStore((s) => s.upsertTask); + const tasks = useStore((s) => s.tasks); + const runningTaskCount = tasks.filter((t) => t.status === "running").length; function toggleCollapsed() { setCollapsed((v) => { @@ -43,22 +57,102 @@ export default function App() { refreshConversations(); const un1 = onEvent("engine://status", () => refreshEngine()); const un2 = onEvent("server://status", () => refreshServer()); - const un3 = onEvent("download://done", () => refreshModels()); + const un3start = onEvent("download://started", (e) => { + upsertTask({ + id: e.id, + kind: "download", + title: e.file_name, + status: "running", + percent: 0, + stage: "等待开始", + message: null, + updated_at: Date.now(), + }); + }); + const un3 = onEvent("download://progress", (e) => { + upsertTask({ + id: e.id, + kind: "download", + title: e.file_name, + status: "running", + percent: e.percent, + stage: e.speed_bps > 0 ? `${fmtSpeed(e.speed_bps)}` : "下载中", + message: null, + updated_at: Date.now(), + }); + }); + const un3b = onEvent("download://done", (e) => { + upsertTask({ + id: e.id, + kind: "download", + title: e.file_name, + status: "done", + percent: 100, + stage: "已完成", + message: null, + updated_at: Date.now(), + }); + refreshModels(); + }); + const un3c = onEvent("download://error", (e) => { + upsertTask({ + id: e.id, + kind: "download", + title: e.file_name, + status: "error", + percent: e.percent, + stage: "下载失败", + message: e.status, + updated_at: Date.now(), + }); + }); const un4 = onEvent("models://updated", () => refreshModels()); const un5 = onEvent("engine://deploy", (e) => { setDeployState(e.model_id, e.state); + upsertTask({ + id: `deploy:${e.model_id}`, + kind: "deploy", + title: e.file_name, + status: e.state === "loading" ? "running" : e.state === "ready" ? "done" : "error", + percent: e.state === "ready" ? 100 : null, + stage: + e.state === "ready" + ? "部署完成" + : e.state === "error" + ? "部署失败" + : "模型加载中", + message: e.message, + updated_at: Date.now(), + }); refreshEngine(); }); const un6 = onEvent("engine://deploy-progress", (e) => { setDeployProgress(e.model_id, { percent: e.percent, stage: e.stage }); + upsertTask({ + id: `deploy:${e.model_id}`, + kind: "deploy", + title: e.file_name, + status: "running", + percent: e.percent, + stage: e.stage, + message: null, + updated_at: Date.now(), + }); + }); + const un7 = onEvent("chat://suggestions", (e) => { + setChatSuggestions(e.message_id, e.suggestions); }); return () => { un1.then((f) => f()); un2.then((f) => f()); + un3start.then((f) => f()); un3.then((f) => f()); + un3b.then((f) => f()); + un3c.then((f) => f()); un4.then((f) => f()); un5.then((f) => f()); un6.then((f) => f()); + un7.then((f) => f()); }; }, [ refreshModels, @@ -67,6 +161,8 @@ export default function App() { refreshConversations, setDeployState, setDeployProgress, + setChatSuggestions, + upsertTask, ]); return ( @@ -96,7 +192,7 @@ export default function App() { end={item.end} title={item.label} className={({ isActive }) => - `flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${ + `relative flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${ collapsed ? "justify-center px-0" : "" } ${ isActive @@ -107,6 +203,15 @@ export default function App() { > {!collapsed ? {item.label} : null} + {item.to === "/tasks" && runningTaskCount > 0 ? ( + collapsed ? ( + + ) : ( + + {runningTaskCount} + + ) + ) : null} ))} @@ -123,6 +228,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> @@ -131,6 +238,11 @@ export default function App() { ); } +function fmtSpeed(bps: number) { + const mbps = bps / 1024 / 1024; + return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`; +} + function EngineChip({ collapsed }: { collapsed: boolean }) { const engine = useStore((s) => s.engine); const running = engine?.running ?? false; diff --git a/ui/src/api.ts b/ui/src/api.ts index 92ab8e8..80a9ee1 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -33,6 +33,7 @@ export interface ModelInfo { repo_id: string; source: string; kind: string; + enabled: boolean; file_name: string; file_path: string; file_size: number; @@ -62,6 +63,49 @@ export interface ModelFile { sha256: string | null; } +export interface Skill { + id: string; + name: string; + description: string; + content: string; + enabled: boolean; + created_at: string; +} + +export interface McpServer { + id: string; + name: string; + description: string; + url: string; + auth_token: string; + enabled: boolean; + created_at: string; +} + +export interface McpToolInfo { + name: string; + description: string; + input_schema: Record; +} + +export interface RecommendedFile { + path: string; + quant: string | null; + size_gb: number | null; + size: string | null; + links: string[]; +} + +export interface RecommendedModel { + id: string; + source: string | null; + params: string | null; + architecture: string | null; + vision: boolean | null; + description: string | null; + files: RecommendedFile[]; +} + export interface EngineStatus { running: boolean; port: number | null; @@ -78,6 +122,9 @@ export interface Conversation { title: string; model_id: string | null; system_prompt: string | null; + pinned: boolean; + favorite: boolean; + tools: string[]; created_at: string; updated_at: string; } @@ -86,6 +133,7 @@ export interface Message { id: string; conversation_id: string; role: string; + model_id: string | null; content: string; tokens_in: number | null; tokens_out: number | null; @@ -116,10 +164,18 @@ export interface ChatTokenEvent { text: string; } +export interface ChatReasoningEvent { + conversation_id: string; + text: string; +} + export interface ChatDoneEvent { conversation_id: string; message_id: string; + model_id: string; content: string; + reasoning: string; + stopped: boolean; tokens_in: number | null; tokens_out: number | null; tokens_estimated: boolean; @@ -127,6 +183,18 @@ export interface ChatDoneEvent { first_token_ms: number | null; } +export interface ChatSuggestionsEvent { + conversation_id: string; + message_id: string; + suggestions: string[]; +} + +export interface ChatToolStatusEvent { + conversation_id: string; + label: string; + status: string; +} + export interface ChatErrorEvent { conversation_id: string; message: string; @@ -137,6 +205,11 @@ export interface ChatMessageUpdatedEvent { message: Message; } +export interface ChatTitleUpdatedEvent { + conversation_id: string; + title: string; +} + export interface MessageVersion { id: string; message_id: string; @@ -183,19 +256,83 @@ export const api = { listModels: () => invoke("list_models"), importModel: (path: string) => invoke("import_model", { path }), removeModel: (id: string) => invoke("remove_model", { id }), + setModelEnabled: (id: string, enabled: boolean) => + invoke("set_model_enabled", { id, enabled }), scanModels: () => invoke("scan_models"), addRemoteModel: (name: string, baseUrl: string, apiKey: string, apiModel: string) => invoke("add_remote_model", { name, baseUrl, apiKey, apiModel }), + listSkills: () => invoke("list_skills"), + addSkill: (name: string, description: string, content: string, enabled: boolean) => + invoke("add_skill", { name, description, content, enabled }), + updateSkill: ( + id: string, + name: string, + description: string, + content: string, + enabled: boolean, + ) => invoke("update_skill", { id, name, description, content, enabled }), + removeSkill: (id: string) => invoke("remove_skill", { id }), + setSkillEnabled: (id: string, enabled: boolean) => + invoke("set_skill_enabled", { id, enabled }), + testSkill: (id: string) => invoke("test_skill", { id }), + listMcpServers: () => invoke("list_mcp_servers"), + addMcpServer: ( + name: string, + description: string, + url: string, + authToken: string, + enabled: boolean, + ) => invoke("add_mcp_server", { name, description, url, authToken, enabled }), + updateMcpServer: ( + id: string, + name: string, + description: string, + url: string, + authToken: string, + enabled: boolean, + ) => invoke("update_mcp_server", { id, name, description, url, authToken, enabled }), + removeMcpServer: (id: string) => invoke("remove_mcp_server", { id }), + setMcpServerEnabled: (id: string, enabled: boolean) => + invoke("set_mcp_server_enabled", { id, enabled }), + mcpTestServer: (id: string) => invoke("mcp_test_server", { id }), + mcpListTools: (id: string) => invoke("mcp_list_tools", { id }), + mcpCallTool: (id: string, toolName: string, args: string) => + invoke("mcp_call_tool", { id, toolName, args }), searchModels: (query: string, source: string) => invoke("search_models", { query, source }), listModelFiles: (repoId: string, source: string) => invoke("list_model_files", { repoId, source }), + listRecommendedModels: () => invoke("list_recommended_models"), + importRecommendations: (fileName: string, content: string) => + invoke("import_recommendations", { fileName, content }), + fetchModelPage: (repoId: string, source: string) => + invoke("fetch_model_page", { repoId, source }), settingsGet: () => invoke>("settings_get"), settingsSet: (key: string, value: string) => invoke("settings_set", { key, value }), listConversations: () => invoke("list_conversations"), createConversation: (title: string, modelId?: string) => invoke("create_conversation", { title, modelId: modelId ?? null }), + renameConversation: (id: string, title: string) => + invoke("rename_conversation", { id, title }), + setConversationPinned: (id: string, pinned: boolean) => + invoke("set_conversation_pinned", { id, pinned }), + setConversationFavorite: (id: string, favorite: boolean) => + invoke("set_conversation_favorite", { id, favorite }), + importConversation: (payload: { + title: string; + model_id: string | null; + messages: { + role: string; + content: string; + images: string[]; + created_at: string | null; + }[]; + }) => invoke("import_conversation", { payload }), + setConversationTools: (id: string, tools: string[]) => + invoke("set_conversation_tools", { id, tools }), + webSearch: (query: string) => + invoke<{ title: string; url: string; content: string }[]>("web_search", { query }), deleteConversation: (id: string) => invoke("delete_conversation", { id }), getMessages: (conversationId: string) => @@ -208,6 +345,8 @@ export const api = { engineStatus: () => invoke("engine_status"), chatSend: (payload: ChatSendPayload) => invoke<{ conversation_id: string; message_id: string }>("chat_send", { payload }), + chatStop: (conversationId: string) => + invoke("chat_stop", { conversationId }), regenerateMessage: ( conversationId: string, messageId: string, diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index 1a0cd1c..fc78c25 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -17,6 +17,55 @@ const paths: Record = { ), + tasks: ( + <> + + + + + ), + download: ( + <> + + + + + ), + folder: ( + <> + + + ), + copy: ( + <> + + + + ), + "more-horizontal": ( + <> + + + + + ), + trash: ( + <> + + + + + ), + edit: , + pause: ( + <> + + + + ), + wrench: ( + + ), server: ( <> @@ -32,6 +81,7 @@ const paths: Record = { ), "chevron-left": , "chevron-right": , + "chevron-down": , paperclip: ( ), diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index e2e4d1b..05ac4fc 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { @@ -6,8 +6,16 @@ import { ChatDoneEvent, ChatErrorEvent, ChatMessageUpdatedEvent, + ChatReasoningEvent, + ChatToolStatusEvent, + ChatTitleUpdatedEvent, ChatParams, ChatTokenEvent, + Conversation, + Message, + ModelInfo, + McpServer, + Skill, MessageVersion, onEvent, } from "../api"; @@ -26,7 +34,11 @@ interface Attachment { interface LocalMessage { id: string; role: "user" | "assistant"; + modelName?: string; content: string; + reasoning?: string; + stopped?: boolean; + createdAt?: string; streaming?: boolean; images?: string[]; tokensIn?: number | null; @@ -39,54 +51,168 @@ interface LocalMessage { const defaultParams: ChatParams = { temperature: 0.7, top_p: 0.9, - max_tokens: 2048, + max_tokens: 4096, ctx_size: 4096, ngl: 99, }; +// 会话快照:切换选项卡时 ChatPage 会卸载,通过模块级缓存保持对话/模型等状态的连续性。 +interface ChatSessionSnapshot { + activeConvId: string | null; + selectedModelId: string; + messages: LocalMessage[]; + input: string; + params: ChatParams; + rightOpen: boolean; + selectedTools: string[]; + versionsFor: Record; + attachments: Attachment[]; +} + +const chatSession: ChatSessionSnapshot = { + activeConvId: null, + selectedModelId: "", + messages: [], + input: "", + params: defaultParams, + rightOpen: localStorage.getItem("xianren-right-open") !== "0", + selectedTools: [], + versionsFor: {}, + attachments: [], +}; + 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", ]); +function displayModelName(name: string) { + return name.replace(/\.gguf$/i, ""); +} + +type ModelRuntimeState = "ready" | "loading" | "error"; + 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 suggestionsByMessage = useStore((s) => s.chatSuggestionsByMessage); + const conversations = useStore((s) => s.conversations); + const refreshConversations = useStore((s) => s.refreshConversations); - const [activeConvId, setActiveConvId] = useState(null); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); + const [activeConvId, setActiveConvId] = useState(chatSession.activeConvId); + const [messages, setMessages] = useState(chatSession.messages); + const [input, setInput] = useState(chatSession.input); const [streaming, setStreaming] = useState(false); const [error, setError] = useState(null); - const [params, setParams] = useState(defaultParams); - const [selectedModelId, setSelectedModelId] = useState(""); - const [rightOpen, setRightOpen] = useState( - () => localStorage.getItem("xianren-right-open") !== "0", - ); + const [params, setParams] = useState(chatSession.params); + const [selectedModelId, setSelectedModelId] = useState(chatSession.selectedModelId); + const [rightOpen, setRightOpen] = useState(chatSession.rightOpen); const [editingId, setEditingId] = useState(null); const [editingDraft, setEditingDraft] = useState(""); - const [versionsFor, setVersionsFor] = useState>({}); - const [attachments, setAttachments] = useState([]); + const [versionsFor, setVersionsFor] = useState>( + chatSession.versionsFor, + ); + const [attachments, setAttachments] = useState(chatSession.attachments); const [uploadLimitMb, setUploadLimitMb] = useState(10); + const [selectedTools, setSelectedTools] = useState(chatSession.selectedTools); + const [webSearchEnabled, setWebSearchEnabled] = useState(true); + const [toolStatus, setToolStatus] = useState(null); + const [toolSkills, setToolSkills] = useState([]); + const [toolMcpServers, setToolMcpServers] = useState([]); + const [menuOpenFor, setMenuOpenFor] = useState(null); + const [modelListOpen, setModelListOpen] = useState(false); const bottomRef = useRef(null); const fileInputRef = useRef(null); + const importInputRef = useRef(null); + const inputRef = useRef(null); + const modelListRef = useRef(null); const engineRunning = engine?.running ?? false; const engineModel = engine?.model ?? null; - const selectedIsRemote = useMemo( - () => models.find((m) => m.id === selectedModelId)?.kind === "remote", + // 可选大模型服务:只列出有部署/运行状态的大模型。 + // 本地模型按引擎状态(运行中=绿、加载中=灰、出错=红),在线 API 模型需在模型管理启用。 + const availableModels = useMemo(() => { + const out: { model: ModelInfo; state: ModelRuntimeState }[] = []; + for (const m of models) { + if (m.kind === "remote") { + if (m.enabled) out.push({ model: m, state: "ready" }); + } else if (engineRunning && engineModel === m.file_name) { + out.push({ model: m, state: "ready" }); + } else if (deployStates[m.id] === "loading") { + out.push({ model: m, state: "loading" }); + } else if (deployStates[m.id] === "error") { + out.push({ model: m, state: "error" }); + } + } + return out; + }, [models, engineRunning, engineModel, deployStates]); + const engineLabel = useMemo(() => { + if (!availableModels.length) return "暂无可用大模型"; + const m = models.find((x) => x.id === selectedModelId); + return m ? `${displayModelName(m.file_name)}${m.kind === "remote" ? " · 在线API" : " · 本地"}` : "选择模型"; + }, [availableModels, models, selectedModelId]); + const selectedModel = useMemo( + () => models.find((m) => m.id === selectedModelId), [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]); + const selectedAvailable = useMemo( + () => availableModels.find((x) => x.model.id === selectedModelId), + [availableModels, selectedModelId], + ); + const modelNameById = useMemo(() => { + const map = new Map(); + for (const m of models) map.set(m.id, displayModelName(m.file_name)); + return map; + }, [models]); + const resolveModelName = useCallback((id?: string | null) => { + if (!id) return undefined; + return modelNameById.get(id) ?? id; + }, [modelNameById]); + const mapServerMessages = useCallback( + (msgs: Message[]) => + msgs.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + modelName: resolveModelName(m.model_id), + content: m.content, + images: m.images, + createdAt: m.created_at, + tokensIn: m.tokens_in, + tokensOut: m.tokens_out, + elapsedMs: m.elapsed_ms, + firstTokenMs: m.first_token_ms, + })), + [resolveModelName], + ); + + // 每次渲染后把当前状态写回快照,确保切走选项卡再回来时保持连续性 + useEffect(() => { + chatSession.activeConvId = activeConvId; + chatSession.selectedModelId = selectedModelId; + chatSession.messages = messages; + chatSession.input = input; + chatSession.params = params; + chatSession.rightOpen = rightOpen; + chatSession.selectedTools = selectedTools; + chatSession.versionsFor = versionsFor; + chatSession.attachments = attachments; + }); + + // 若上次离开时回答还在生成中(快照里有流式占位),回到对话页后从数据库拉取最终结果 + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + restoredRef.current = true; + if (!activeConvId) return; + const last = chatSession.messages[chatSession.messages.length - 1]; + if (!last || !last.streaming) return; + api + .getMessages(activeConvId) + .then((msgs) => setMessages(mapServerMessages(msgs))) + .catch(() => {}); + }, [activeConvId, mapServerMessages]); function toggleRight() { setRightOpen((v) => { @@ -97,16 +223,35 @@ export default function ChatPage() { } useEffect(() => { - if (models.length > 0 && !selectedModelId) { - setSelectedModelId(models[0].id); + // 上次选择的大模型仍存在时保持不动,避免切换选项卡/状态刷新后被自动换掉 + if (selectedModelId && models.some((m) => m.id === selectedModelId)) return; + if (availableModels.length === 0) { + if (selectedModelId) setSelectedModelId(""); + return; } - }, [models, selectedModelId]); + if (!availableModels.some((x) => x.model.id === selectedModelId)) { + setSelectedModelId(availableModels[0].model.id); + } + }, [models, availableModels, selectedModelId]); + + useEffect(() => { + function onMouseDown(e: MouseEvent) { + if (modelListRef.current && !modelListRef.current.contains(e.target as Node)) { + setModelListOpen(false); + } + } + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, []); useEffect(() => { api.settingsGet().then((s) => { const v = Number(s.upload_max_mb); if (v > 0) setUploadLimitMb(v); + setWebSearchEnabled(s.tool_web_search_enabled !== "false"); }); + api.listSkills().then(setToolSkills).catch(() => {}); + api.listMcpServers().then(setToolMcpServers).catch(() => {}); }, []); useEffect(() => { @@ -133,6 +278,31 @@ export default function ChatPage() { return updated; }); }); + const un1r = onEvent("chat://reasoning", (e) => { + if (e.conversation_id !== activeConvId) return; + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (!last || last.role !== "assistant") { + return [ + ...prev, + { + id: `tmp-${Date.now()}`, + role: "assistant", + content: "", + reasoning: e.text, + streaming: true, + }, + ]; + } + const updated = [...prev]; + updated[updated.length - 1] = { + ...last, + reasoning: (last.reasoning ?? "") + e.text, + streaming: true, + }; + return updated; + }); + }); const un2 = onEvent("chat://done", (e) => { if (e.conversation_id !== activeConvId) return; setMessages((prev) => { @@ -142,17 +312,36 @@ export default function ChatPage() { updated[updated.length - 1] = { id: e.message_id, role: "assistant", + modelName: resolveModelName(e.model_id), content: e.content, + reasoning: e.reasoning, + stopped: e.stopped, tokensIn: e.tokens_in, tokensOut: e.tokens_out, tokensEstimated: e.tokens_estimated, elapsedMs: e.elapsed_ms, firstTokenMs: e.first_token_ms, }; + } else if (!updated.some((x) => x.id === e.message_id)) { + // 流式占位可能因切换选项卡丢失,直接补上完成的回答 + updated.push({ + id: e.message_id, + role: "assistant", + modelName: resolveModelName(e.model_id), + content: e.content, + reasoning: e.reasoning, + stopped: e.stopped, + tokensIn: e.tokens_in, + tokensOut: e.tokens_out, + tokensEstimated: e.tokens_estimated, + elapsedMs: e.elapsed_ms, + firstTokenMs: e.first_token_ms, + }); } return updated; }); setStreaming(false); + setToolStatus(null); refreshConversations(); }); const un3 = onEvent("chat://error", (e) => { @@ -168,6 +357,7 @@ export default function ChatPage() { x.id === m.id ? { ...x, + modelName: resolveModelName(m.model_id) ?? x.modelName, content: m.content, tokensIn: m.tokens_in, tokensOut: m.tokens_out, @@ -179,18 +369,36 @@ export default function ChatPage() { ), ); }); + const un5 = onEvent("chat://title-updated", () => { + refreshConversations(); + }); + const un6 = onEvent("chat://tool-status", (e) => { + if (e.conversation_id !== activeConvId) return; + setToolStatus( + e.status === "done" ? null : `正在调用工具:${e.label}`, + ); + }); return () => { un1.then((f) => f()); + un1r.then((f) => f()); un2.then((f) => f()); un3.then((f) => f()); un4.then((f) => f()); + un5.then((f) => f()); + un6.then((f) => f()); }; - }, [activeConvId, refreshConversations]); + }, [activeConvId, refreshConversations, resolveModelName]); - function appendStreamingPlaceholder() { + function appendStreamingPlaceholder(modelName?: string) { setMessages((prev) => [ ...prev, - { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true }, + { + id: `assistant-${Date.now()}`, + role: "assistant", + modelName, + content: "", + streaming: true, + }, ]); } @@ -209,7 +417,7 @@ export default function ChatPage() { if (!content.trim() && images.length === 0) return; if (streaming) return; if (!selectedModelId) { - setError("请先在模型管理添加一个模型"); + setError("暂无可用大模型,请先在模型管理部署本地模型或启用在线 API 模型"); return; } setError(null); @@ -220,6 +428,9 @@ export default function ChatPage() { if (!convId) { const conv = await api.createConversation(content.slice(0, 30) || "新会话"); convId = conv.id; + if (selectedTools.length > 0) { + await api.setConversationTools(convId, selectedTools); + } setActiveConvId(convId); await refreshConversations(); } @@ -227,8 +438,14 @@ export default function ChatPage() { const tempUserId = `user-${Date.now()}`; setMessages((prev) => [ ...prev, - { id: tempUserId, role: "user", content, images }, - { id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true }, + { id: tempUserId, role: "user", content, images, createdAt: new Date().toISOString() }, + { + id: `assistant-${Date.now()}`, + role: "assistant", + modelName: selectedModel ? displayModelName(selectedModel.file_name) : undefined, + content: "", + streaming: true, + }, ]); setStreaming(true); @@ -251,13 +468,27 @@ export default function ChatPage() { } } + async function handleStop() { + if (!activeConvId) return; + try { + await api.chatStop(activeConvId); + } catch { + // 停止失败不影响界面 + } + } + + function handleUseSuggestion(text: string) { + setInput(text); + inputRef.current?.focus(); + } + 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(); + appendStreamingPlaceholder(msg.modelName); setStreaming(true); try { await api.regenerateMessage(activeConvId, msg.id, params); @@ -277,7 +508,9 @@ export default function ChatPage() { setMessages((prev) => prev.map((m, i) => (i === idx ? { ...m, content } : m)).slice(0, idx + 1), ); - appendStreamingPlaceholder(); + appendStreamingPlaceholder( + selectedModel ? displayModelName(selectedModel.file_name) : undefined, + ); setStreaming(true); try { await api.editMessage(activeConvId, msg.id, content, params); @@ -338,6 +571,9 @@ export default function ChatPage() { async function handleNewConversation() { const conv = await api.createConversation("新会话", selectedModelId || undefined); + if (selectedTools.length > 0) { + await api.setConversationTools(conv.id, selectedTools); + } setActiveConvId(conv.id); setMessages([]); setError(null); @@ -347,23 +583,29 @@ export default function ChatPage() { async function handleLoadConversation(id: string) { setActiveConvId(id); + const conv = conversations.find((c) => c.id === id); + setSelectedTools(conv?.tools ?? []); const msgs = await api.getMessages(id); - setMessages( - msgs.map((m) => ({ - 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, - })), - ); + setMessages(mapServerMessages(msgs)); setError(null); setVersionsFor({}); } + async function toggleTool(tool: string) { + const next = selectedTools.includes(tool) + ? selectedTools.filter((t) => t !== tool) + : [...selectedTools, tool]; + setSelectedTools(next); + if (activeConvId) { + try { + await api.setConversationTools(activeConvId, next); + await refreshConversations(); + } catch (e) { + alert(String(e)); + } + } + } + async function handleDeleteConversation(id: string) { await api.deleteConversation(id); if (activeConvId === id) { @@ -373,6 +615,103 @@ export default function ChatPage() { await refreshConversations(); } + async function handleRenameConversation(c: Conversation) { + const title = prompt("修改标题", c.title); + if (!title || !title.trim()) return; + try { + await api.renameConversation(c.id, title.trim()); + await refreshConversations(); + } catch (e) { + alert(String(e)); + } + } + + async function handleTogglePin(c: Conversation) { + try { + await api.setConversationPinned(c.id, !c.pinned); + await refreshConversations(); + } catch (e) { + alert(String(e)); + } + } + + async function handleToggleFavorite(c: Conversation) { + try { + await api.setConversationFavorite(c.id, !c.favorite); + await refreshConversations(); + } catch (e) { + alert(String(e)); + } + } + + async function handleExportConversation(c: Conversation) { + try { + const msgs = await api.getMessages(c.id); + const data = { + app: "xianren-studio", + type: "conversation", + version: 1, + title: c.title, + model_id: c.model_id, + created_at: c.created_at, + messages: msgs.map((m) => ({ + role: m.role, + content: m.content, + images: m.images, + created_at: m.created_at, + })), + }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${c.title.replace(/[\\/:*?"<>|]/g, "_")}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + alert(String(e)); + } + } + + async function handleImportConversation(file: File) { + try { + const text = await file.text(); + const data = JSON.parse(text); + const messages = Array.isArray(data.messages) + ? data.messages.map((m: { role?: unknown; content?: unknown; images?: unknown; created_at?: unknown }) => ({ + role: String(m?.role ?? "user"), + content: String(m?.content ?? ""), + images: Array.isArray(m?.images) ? m.images.map(String) : [], + created_at: typeof m?.created_at === "string" ? m.created_at : null, + })) + : []; + await api.importConversation({ + title: String(data.title ?? "导入对话"), + model_id: typeof data.model_id === "string" ? data.model_id : null, + messages, + }); + await refreshConversations(); + alert("导入成功"); + } catch (e) { + alert(`导入失败:${String(e)}`); + } finally { + if (importInputRef.current) importInputRef.current.value = ""; + } + } + + async function handleShareConversation(c: Conversation) { + try { + const msgs = await api.getMessages(c.id); + const text = msgs + .map((m) => `${m.role === "user" ? "我" : "AI"}:\n${m.content}`) + .join("\n\n"); + await navigator.clipboard.writeText(`【${c.title}】\n\n${text}`); + alert("对话内容已复制到剪贴板"); + } catch (e) { + alert(String(e)); + } + } + return (
@@ -388,21 +727,127 @@ export default function ChatPage() { ? "bg-panel-2 text-white" : "text-slate-300 hover:bg-panel-2/60" }`} - onClick={() => handleLoadConversation(c.id)} + onClick={() => { + setMenuOpenFor(null); + handleLoadConversation(c.id); + }} > - {c.title} - + + {menuOpenFor === c.id ? ( +
e.stopPropagation()} + > + +
+ +
+ +
+ +
+ +
+ +
+ ) : null} + +
))}
+ { + if (e.target.files && e.target.files.length > 0) { + handleImportConversation(e.target.files[0]); + } + }} + />
@@ -424,6 +869,7 @@ export default function ChatPage() { editingId={editingId} editingDraft={editingDraft} versions={versionsFor[m.id] ?? null} + suggestions={suggestionsByMessage[m.id] ?? []} onEditStart={(msg) => { setEditingId(msg.id); setEditingDraft(msg.content); @@ -434,6 +880,7 @@ export default function ChatPage() { onRegenerate={handleRegenerate} onToggleVersions={handleToggleVersions} onApplyVersion={handleApplyVersion} + onUseSuggestion={handleUseSuggestion} /> ))} {error ? ( @@ -445,6 +892,9 @@ export default function ChatPage() {
+ {toolStatus ? ( +
{toolStatus}
+ ) : null} {attachments.length > 0 ? (
{attachments.map((a) => ( @@ -490,6 +940,7 @@ export default function ChatPage() { }} />