diff --git a/AGENTS.md b/AGENTS.md index ba51135..7b529e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,12 @@ Xianren Studio(仙人工作室):Tauri 2 + React 的 Windows 本地大模 2. 涉及代码结构、模块职责、数据流、事件通道、数据库表/字段 → 更新 `docs/ARCHITECTURE.md` 对应章节。 3. 涉及技术栈、目录结构、构建方式 → 同步更新 `README.md`。 +**每次代码改动后,必须提交并推送到远程仓库,且每次推送都打一个新的 tag:** + +1. 完成一轮代码改动(含文档同步)后,`git add -A` 并提交,提交信息用中文简要描述本次改动。 +2. 推送到远程 `origin/main`。 +3. 以现有最新 tag 为基准递增版本号(如 `v1.0.0` → `v1.1.0`),创建带注释的新 tag 并推送 tag。 + ## 构建命令 ```powershell diff --git a/README.md b/README.md index 4bde102..f7f4b9d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ - **模型管理**:启动时自动扫描模型目录(可手动重新扫描),支持导入本地 GGUF、添加 OpenAI 兼容在线 API 模型(需「启用」后才会出现在对话页可选列表);本地模型可一键「部署」后台加载并查看进度 - **模型广场**:搜索 Hugging Face / ModelScope 上的 GGUF 模型,按量化版本一键下载(ModelScope 文件自动附带 SHA256 校验),下载进度实时显示 - **聊天**:流式输出、Markdown/代码高亮、采样参数调节、多会话管理;右侧栏「可选大模型服务」只列出有部署状态的大模型(运行中/启动中/出错,彩色状态点)与已启用的在线 API 模型,模型名不显示 `.gguf` 后缀;每条回答下方显示所用模型名;回答完成后自动预测用户可能说的话(可点击填入输入框,条数与开关可在设置调整);切换选项卡后保持会话与大模型等状态 +- **智能体**:内置预制智能体(通用助手、代码专家、写作助手、翻译官、数据分析师、提示词优化师)并支持自定义,每个智能体拥有独立人设(系统提示词)与专属会话区,可单独对话干活;支持本地与在线大模型,可恢复被删除的预制智能体 +- **工作流**:画布式可视化编排(开始 → 大模型/文本 → 输出,节点连线传参),内置翻译、总结、两步润色、写作助手等预制工作流并支持自定义;一键运行,支持本地与在线大模型,可在画布上实时查看每个节点的输出 - **对话细节**:空会话复用(已有无消息的新建对话时不再新建)、回答重新生成/版本历史/编辑重提、思考过程展示、会话置顶收藏与导入导出 - **本地 API 服务**:OpenAI 兼容端点(/v1/models、/v1/chat/completions、/v1/embeddings),仅本机监听,可选 API Key diff --git a/apps/desktop/src/commands.rs b/apps/desktop/src/commands.rs index 424170e..dd4a51a 100644 --- a/apps/desktop/src/commands.rs +++ b/apps/desktop/src/commands.rs @@ -9,11 +9,13 @@ use std::time::Instant; use tauri::{AppHandle, Emitter, State}; use tokio::sync::RwLock; use xianren_api::ApiState; +use xianren_core::agents as agents_db; 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::workflows as workflows_db; use xianren_core::{CoreApp, ModelInfo, Message}; use xianren_engine::{ChatMessage, ChatRequest, EngineConfig, EngineManager}; use xianren_engine::remote::RemoteConfig; @@ -83,6 +85,15 @@ pub struct ChatToolStatusEvent { pub status: String, } +#[derive(Serialize, Clone)] +pub struct WorkflowNodeStatusEvent { + pub workflow_id: String, + pub node_id: String, + pub label: String, + pub status: String, + pub text: Option, +} + #[derive(Serialize, Clone)] pub struct ChatTitleUpdatedEvent { pub conversation_id: String, @@ -427,10 +438,468 @@ fn read_json_files(dir: &PathBuf) -> Result, String> { Ok(files) } +/// 内置预制智能体列表(随应用发布,首次启动写入数据库,可随时恢复)。 +const DEFAULT_AGENTS_JSON: &str = include_str!("default_agents.json"); + +#[derive(Deserialize)] +pub struct AgentInput { + pub name: String, + #[serde(default)] + pub icon: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub system_prompt: String, + #[serde(default)] + pub model_id: Option, + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + #[tauri::command] -pub fn list_conversations(state: State<'_, App>) -> Result, String> { +pub fn list_agents(state: State<'_, App>) -> Result, String> { let db = state.core.db.lock().unwrap(); - sessions_db::list_conversations(&db).map_err(|e| e.to_string()) + agents_db::list(&db).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_agent( + state: State<'_, App>, + input: AgentInput, +) -> Result { + let db = state.core.db.lock().unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let agent = xianren_core::Agent { + id, + kind: "custom".to_string(), + name: input.name.trim().to_string(), + icon: if input.icon.trim().is_empty() { + "🤖".to_string() + } else { + input.icon.trim().to_string() + }, + description: input.description.trim().to_string(), + system_prompt: input.system_prompt.trim().to_string(), + model_id: input.model_id, + enabled: input.enabled, + created_at: String::new(), + updated_at: String::new(), + }; + if agent.name.is_empty() { + return Err("智能体名称不能为空".into()); + } + agents_db::insert(&db, &agent).map_err(|e| e.to_string())?; + agents_db::get(&db, &agent.id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "agent not found".to_string()) +} + +#[tauri::command] +pub fn update_agent( + state: State<'_, App>, + id: String, + input: AgentInput, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + let existing = agents_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "智能体不存在".to_string())?; + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("智能体名称不能为空".into()); + } + let agent = xianren_core::Agent { + id, + kind: existing.kind, + name, + icon: if input.icon.trim().is_empty() { + existing.icon + } else { + input.icon.trim().to_string() + }, + description: input.description.trim().to_string(), + system_prompt: input.system_prompt.trim().to_string(), + model_id: input.model_id, + enabled: input.enabled, + created_at: existing.created_at, + updated_at: String::new(), + }; + agents_db::update(&db, &agent).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_agent(state: State<'_, App>, id: String) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + // 删除智能体时一并清理它名下的所有会话(消息随外键级联删除) + let conversations = sessions_db::list_conversations(&db, Some(&id)).map_err(|e| e.to_string())?; + for c in conversations { + sessions_db::delete_conversation(&db, &c.id).map_err(|e| e.to_string())?; + } + agents_db::delete(&db, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_agent_enabled( + state: State<'_, App>, + id: String, + enabled: bool, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + agents_db::set_enabled(&db, &id, enabled).map_err(|e| e.to_string()) +} + +/// 恢复缺失的预制智能体(已存在或已被用户删除的同名预制体不会重复写入)。 +#[tauri::command] +pub fn restore_preset_agents(state: State<'_, App>) -> Result { + let db = state.core.db.lock().unwrap(); + let presets: Vec = + serde_json::from_str(DEFAULT_AGENTS_JSON).unwrap_or_default(); + agents_db::insert_presets_if_missing(&db, &presets).map_err(|e| e.to_string()) +} + +/// 首次启动时写入预制智能体(只执行一次,之后尊重用户删除)。 +pub fn seed_preset_agents(db: &rusqlite::Connection) -> usize { + let seeded = settings_db::get(db, "preset_agents_seeded") + .ok() + .flatten() + .map(|v| v == "1") + .unwrap_or(false); + if seeded { + return 0; + } + let presets: Vec = + serde_json::from_str(DEFAULT_AGENTS_JSON).unwrap_or_default(); + let inserted = agents_db::insert_presets_if_missing(db, &presets).unwrap_or(0); + let _ = settings_db::set(db, "preset_agents_seeded", "1"); + inserted +} + +/// 内置预制工作流(随应用发布,首次启动写入数据库,可随时恢复)。 +const DEFAULT_WORKFLOWS_JSON: &str = include_str!("default_workflows.json"); + +#[derive(Deserialize)] +pub struct WorkflowInput { + pub name: String, + #[serde(default)] + pub icon: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(default)] + pub model_id: Option, + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[tauri::command] +pub fn list_workflows(state: State<'_, App>) -> Result, String> { + let db = state.core.db.lock().unwrap(); + workflows_db::list(&db).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn add_workflow( + state: State<'_, App>, + input: WorkflowInput, +) -> Result { + let db = state.core.db.lock().unwrap(); + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("工作流名称不能为空".into()); + } + let id = uuid::Uuid::new_v4().to_string(); + let workflow = xianren_core::Workflow { + id, + kind: "custom".to_string(), + name, + icon: if input.icon.trim().is_empty() { + "🔀".to_string() + } else { + input.icon.trim().to_string() + }, + description: input.description.trim().to_string(), + nodes: input.nodes, + edges: input.edges, + model_id: input.model_id, + enabled: input.enabled, + created_at: String::new(), + updated_at: String::new(), + }; + workflows_db::insert(&db, &workflow).map_err(|e| e.to_string())?; + workflows_db::get(&db, &workflow.id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "workflow not found".to_string()) +} + +#[tauri::command] +pub fn update_workflow( + state: State<'_, App>, + id: String, + input: WorkflowInput, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + let existing = workflows_db::get(&db, &id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作流不存在".to_string())?; + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("工作流名称不能为空".into()); + } + let workflow = xianren_core::Workflow { + id, + kind: existing.kind, + name, + icon: if input.icon.trim().is_empty() { + existing.icon + } else { + input.icon.trim().to_string() + }, + description: input.description.trim().to_string(), + nodes: input.nodes, + edges: input.edges, + model_id: input.model_id, + enabled: input.enabled, + created_at: existing.created_at, + updated_at: String::new(), + }; + workflows_db::update(&db, &workflow).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn remove_workflow(state: State<'_, App>, id: String) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + workflows_db::delete(&db, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_workflow_enabled( + state: State<'_, App>, + id: String, + enabled: bool, +) -> Result<(), String> { + let db = state.core.db.lock().unwrap(); + workflows_db::set_enabled(&db, &id, enabled).map_err(|e| e.to_string()) +} + +/// 恢复缺失的预制工作流(已存在或已被用户删除的同名预制体不会重复写入)。 +#[tauri::command] +pub fn restore_preset_workflows(state: State<'_, App>) -> Result { + let db = state.core.db.lock().unwrap(); + let presets: Vec = + serde_json::from_str(DEFAULT_WORKFLOWS_JSON).unwrap_or_default(); + workflows_db::insert_presets_if_missing(&db, &presets).map_err(|e| e.to_string()) +} + +/// 首次启动时写入预制工作流(只执行一次,之后尊重用户删除)。 +pub fn seed_preset_workflows(db: &rusqlite::Connection) -> usize { + let seeded = settings_db::get(db, "preset_workflows_seeded") + .ok() + .flatten() + .map(|v| v == "1") + .unwrap_or(false); + if seeded { + return 0; + } + let presets: Vec = + serde_json::from_str(DEFAULT_WORKFLOWS_JSON).unwrap_or_default(); + let inserted = workflows_db::insert_presets_if_missing(db, &presets).unwrap_or(0); + let _ = settings_db::set(db, "preset_workflows_seeded", "1"); + inserted +} + +#[derive(Deserialize)] +pub struct RunWorkflowPayload { + pub workflow_id: String, + #[serde(default)] + pub input: String, + #[serde(default)] + pub model_id: Option, +} + +/// 运行工作流:按拓扑顺序执行节点,LLM 节点调用本地/在线大模型,逐步推送节点状态事件。 +#[tauri::command] +pub async fn run_workflow( + app: AppHandle, + state: State<'_, App>, + payload: RunWorkflowPayload, +) -> Result { + let core = state.core.clone(); + let engine = state.engine.clone(); + let (nodes, edges, wf_model_id) = { + let db = core.db.lock().unwrap(); + let wf = workflows_db::get(&db, &payload.workflow_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| "工作流不存在".to_string())?; + (wf.nodes, wf.edges, wf.model_id) + }; + + let result = crate::workflow::execute_workflow( + &nodes, + &edges, + &payload.input, + |node, prompt| { + let app = app.clone(); + let core = core.clone(); + let engine = engine.clone(); + let workflow_id = payload.workflow_id.clone(); + let wf_model_id = wf_model_id.clone(); + let payload_model_id = payload.model_id.clone(); + let node = node.clone(); + let prompt = prompt.to_string(); + async move { + let model = { + let db = core.db.lock().unwrap(); + resolve_workflow_node_model(&db, &node, &wf_model_id, &payload_model_id)? + }; + call_workflow_node_model( + &app, + &engine, + &node, + &prompt, + &workflow_id, + &model, + ) + .await + } + }, + ) + .await?; + + Ok(serde_json::json!({ + "workflow_id": payload.workflow_id, + "results": result.results, + "final": result.final_text, + })) +} + +/// 解析 LLM 节点使用的大模型:节点配置 → 工作流默认 → 运行参数 → 第一个本地/已启用在线模型。 +fn resolve_workflow_node_model( + db: &rusqlite::Connection, + node: &xianren_core::workflows::WorkflowNode, + wf_model_id: &Option, + payload_model_id: &Option, +) -> Result { + let node_model = node + .data + .get("model_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + for candidate in [node_model, wf_model_id.clone(), payload_model_id.clone()] + .into_iter() + .flatten() + { + if let Some(m) = models_db::get(db, &candidate).map_err(|e| e.to_string())? { + return Ok(m); + } + } + let all = models_db::list(db).map_err(|e| e.to_string())?; + if let Some(m) = all.iter().find(|m| m.kind == "local") { + return Ok(m.clone()); + } + if let Some(m) = all.iter().find(|m| m.kind == "remote" && m.enabled) { + return Ok(m.clone()); + } + Err("没有可用的大模型:请先在模型管理部署本地模型或启用在线 API 模型".into()) +} + +/// 调用模型执行一个 LLM 节点(非流式),前后推送节点状态事件。 +async fn call_workflow_node_model( + app: &AppHandle, + engine: &EngineManager, + node: &xianren_core::workflows::WorkflowNode, + prompt: &str, + workflow_id: &str, + model: &ModelInfo, +) -> Result { + let _ = app.emit( + "workflow://node-status", + WorkflowNodeStatusEvent { + workflow_id: workflow_id.to_string(), + node_id: node.id.clone(), + label: node.label.clone(), + status: "running".to_string(), + text: None, + }, + ); + let temperature = node + .data + .get("temperature") + .and_then(|v| v.as_f64()) + .unwrap_or(0.7) as f32; + let max_tokens = node + .data + .get("max_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(2048) + .clamp(64, 8192) as u32; + let text = call_model_text(engine, model, prompt, temperature, max_tokens) + .await + .map_err(|e| format!("节点「{}」调用大模型失败:{e}", node.label))?; + let _ = app.emit( + "workflow://node-status", + WorkflowNodeStatusEvent { + workflow_id: workflow_id.to_string(), + node_id: node.id.clone(), + label: node.label.clone(), + status: "done".to_string(), + text: Some(text.clone()), + }, + ); + Ok(text) +} + +/// 调用本地引擎或远程 OpenAI 兼容 API 完成一次非流式对话(工作流 LLM 节点复用)。 +pub async fn call_model_text( + engine: &EngineManager, + model: &ModelInfo, + prompt: &str, + temperature: f32, + max_tokens: u32, +) -> Result { + 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("user", prompt)], + temperature: Some(temperature), + top_p: None, + max_tokens: Some(max_tokens), + stream: false, + }; + let result = 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 + }; + result.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn list_conversations( + state: State<'_, App>, + agent_id: Option, +) -> Result, String> { + let db = state.core.db.lock().unwrap(); + sessions_db::list_conversations(&db, agent_id.as_deref()).map_err(|e| e.to_string()) } #[tauri::command] @@ -438,10 +907,13 @@ pub fn create_conversation( state: State<'_, App>, title: String, model_id: Option, + agent_id: Option, ) -> Result { 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(existing) = sessions_db::find_empty_conversation(&db, agent_id.as_deref()) + .map_err(|e| e.to_string())? + { if let Some(mid) = &model_id { let _ = sessions_db::update_conversation_model(&db, &existing.id, mid); } @@ -449,9 +921,30 @@ pub fn create_conversation( .map_err(|e| e.to_string())? .ok_or_else(|| "conversation not found".to_string()); } + // 智能体会话:继承智能体的系统提示词,未指定模型时回退到智能体默认模型 + let agent = match &agent_id { + Some(aid) => agents_db::get(&db, aid).map_err(|e| e.to_string())?, + None => None, + }; + let effective_model = model_id.or_else(|| { + agent + .as_ref() + .and_then(|a| a.model_id.clone()) + }); + let system_prompt = agent + .as_ref() + .map(|a| a.system_prompt.trim().to_string()) + .filter(|s| !s.is_empty()); 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::create_conversation( + &db, + &id, + &title, + effective_model.as_deref(), + system_prompt.as_deref(), + agent_id.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()) @@ -518,7 +1011,7 @@ pub fn import_conversation( 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) + sessions_db::create_conversation(&db, &id, &title, payload.model_id.as_deref(), None, None) .map_err(|e| e.to_string())?; for m in &payload.messages { sessions_db::import_message( @@ -781,6 +1274,7 @@ pub async fn chat_send( engine_bin, user_message_id, conversation_tools, + system_prompt, tavily_key, search_enabled, tool_defs, @@ -797,6 +1291,7 @@ pub async fn chat_send( &title, Some(&payload.model_id), None, + None, ) .map_err(|e| e.to_string())?; } @@ -852,6 +1347,7 @@ pub async fn chat_send( bin, user_message_id, conversation.tools.clone(), + conversation.system_prompt.clone(), tavily_key, search_enabled, tool_defs, @@ -865,13 +1361,8 @@ pub async fn chat_send( history }; - // 注入可用工具说明(技能 / 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)); - } - } + // 注入智能体/会话系统提示词与可用工具说明(技能 / MCP) + history = inject_system_prompts(history, system_prompt.as_deref(), &tool_defs); // 联网搜索工具:会话启用了 web_search、全局开关开启且已配置 API Key 时,自动搜索并注入上下文 if conversation_tools.iter().any(|t| t == "web_search") @@ -1366,6 +1857,25 @@ fn sanitize_history_for_local(mut history: Vec) -> Vec history } +/// 把会话/智能体的系统提示词与工具说明注入消息历史: +/// 系统提示词在最前,工具说明紧随其后,其余历史消息保持原顺序。 +fn inject_system_prompts( + mut history: Vec, + system_prompt: Option<&str>, + tool_defs: &[ToolDef], +) -> Vec { + let mut index = 0; + if let Some(prompt) = system_prompt.map(str::trim).filter(|s| !s.is_empty()) { + history.insert(0, ChatMessage::new("system", prompt)); + index = 1; + } + let tools_prompt = tools::build_tools_system_prompt(tool_defs); + if !tools_prompt.is_empty() { + history.insert(index, ChatMessage::new("system", &tools_prompt)); + } + history +} + /// 自动生成会话标题:设置开启且为首轮对话时,调用当前模型生成简短标题并覆盖。 async fn maybe_generate_conversation_title( app: AppHandle, @@ -1824,7 +2334,7 @@ pub async fn regenerate_message( let engine = state.engine.clone(); let base = state.engine_base.clone(); - let (history, model, engine_bin, conversation_id, tool_defs) = { + let (history, model, engine_bin, conversation_id, system_prompt, tool_defs) = { let db = core.db.lock().unwrap(); let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? @@ -1865,7 +2375,14 @@ pub async fn regenerate_message( .map_err(|e| e.to_string())? .unwrap_or_default(); let tool_defs = resolve_conversation_tool_defs(&db, &conversation.tools); - (history, model, bin, payload.conversation_id.clone(), tool_defs) + ( + history, + model, + bin, + payload.conversation_id.clone(), + conversation.system_prompt.clone(), + tool_defs, + ) }; let mut history = if model.kind == "local" { @@ -1873,12 +2390,7 @@ pub async fn regenerate_message( } else { history }; - 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)); - } - } + history = inject_system_prompts(history, system_prompt.as_deref(), &tool_defs); let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); let cancel_flags = state.cancel_flags.clone(); let flag_conv = conversation_id.clone(); @@ -1914,7 +2426,7 @@ pub async fn edit_message( let engine = state.engine.clone(); let base = state.engine_base.clone(); - let (history, model, engine_bin, conversation_id, tool_defs) = { + let (history, model, engine_bin, conversation_id, system_prompt, tool_defs) = { let db = core.db.lock().unwrap(); let conversation = sessions_db::get_conversation(&db, &payload.conversation_id) .map_err(|e| e.to_string())? @@ -1947,7 +2459,14 @@ pub async fn edit_message( .map_err(|e| e.to_string())? .unwrap_or_default(); let tool_defs = resolve_conversation_tool_defs(&db, &conversation.tools); - (history, model, bin, payload.conversation_id.clone(), tool_defs) + ( + history, + model, + bin, + payload.conversation_id.clone(), + conversation.system_prompt.clone(), + tool_defs, + ) }; let mut history = if model.kind == "local" { @@ -1955,12 +2474,7 @@ pub async fn edit_message( } else { history }; - 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)); - } - } + history = inject_system_prompts(history, system_prompt.as_deref(), &tool_defs); let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); let cancel_flags = state.cancel_flags.clone(); let flag_conv = conversation_id.clone(); diff --git a/apps/desktop/src/default_agents.json b/apps/desktop/src/default_agents.json new file mode 100644 index 0000000..f023692 --- /dev/null +++ b/apps/desktop/src/default_agents.json @@ -0,0 +1,62 @@ +[ + { + "id": "preset-general-assistant", + "kind": "preset", + "name": "通用助手", + "icon": "🤖", + "description": "全能型智能体,回答问题、分析信息、处理日常任务", + "system_prompt": "你是一个全能型 AI 助手「仙人工作室 · 通用助手」。你的目标是准确、清晰、友好地帮助用户解决各种问题。回答时:\n1. 先理解用户意图,必要时主动询问关键信息;\n2. 用简洁有条理的中文回答,重要结论放前面;\n3. 涉及代码时给出可直接运行的完整示例;\n4. 不确定的信息要明确说明,不要编造。", + "model_id": null, + "enabled": true + }, + { + "id": "preset-code-expert", + "kind": "preset", + "name": "代码专家", + "icon": "💻", + "description": "资深软件工程师,擅长编程、调试、架构与代码评审", + "system_prompt": "你是一位资深软件工程师,精通 Rust、TypeScript/React、Python、C++ 等语言,熟悉 Tauri、Web 开发、数据库设计与系统编程。请用中文回答,注重工程实践:\n1. 先给出思路与关键取舍,再贴可运行的代码;\n2. 代码包含必要的注释与错误处理;\n3. 指出潜在的性能、安全与可维护性问题;\n4. 不确定的 API 行为请标注需验证,不要凭空杜撰。", + "model_id": null, + "enabled": true + }, + { + "id": "preset-writing-assistant", + "kind": "preset", + "name": "写作助手", + "icon": "✍️", + "description": "中文写作与润色专家,覆盖报告、邮件、文章、演讲稿等", + "system_prompt": "你是一位专业的中文写作与编辑专家,擅长各类文体:报告、方案、邮件、公众号文章、演讲稿、小说等。\n1. 输出前先简要说明结构安排;\n2. 语言流畅自然、逻辑清晰,符合中文表达习惯;\n3. 根据用户需求调整正式度与篇幅;\n4. 主动给出润色前后的对比,方便用户理解改动。", + "model_id": null, + "enabled": true + }, + { + "id": "preset-translator", + "kind": "preset", + "name": "翻译官", + "icon": "🌐", + "description": "多语言互译,擅长技术、商务与学术文本", + "system_prompt": "你是一名专业翻译,支持中英日韩等多语言互译,尤其擅长技术、商务与学术文本。\n1. 保持原文意思、语气与格式,术语翻译准确;\n2. 中文译成英文时使用地道表达,英文译成中文时符合中文习惯;\n3. 对歧义处给出两种译法并说明;\n4. 如用户要求,可附术语表或注释。", + "model_id": null, + "enabled": true + }, + { + "id": "preset-data-analyst", + "kind": "preset", + "name": "数据分析师", + "icon": "📊", + "description": "精通统计、SQL 与 Python 数据分析的可视化专家", + "system_prompt": "你是一名资深数据分析师,精通统计学、SQL 与 Python 数据分析(pandas、matplotlib 等)。\n1. 回答以分析结论为先,再展示方法与依据;\n2. 涉及数据时给出可运行的 SQL/Python 代码与结果解读;\n3. 指出数据质量风险与常见陷阱;\n4. 可视化建议要具体(图表类型、X/Y 轴含义)。", + "model_id": null, + "enabled": true + }, + { + "id": "preset-prompt-engineer", + "kind": "preset", + "name": "提示词优化师", + "icon": "🎯", + "description": "提示词工程专家,设计并优化 system prompt 与 few-shot 示例", + "system_prompt": "你是提示词工程专家,帮助用户设计、优化大模型提示词(system prompt / few-shot / 思维链)。\n1. 先明确目标、受众与约束条件;\n2. 给出结构化、可直接复制的完整提示词;\n3. 解释每一步设计意图与潜在改进点;\n4. 提供多版本对比(简洁版 / 详细版)与评测建议。", + "model_id": null, + "enabled": true + } +] diff --git a/apps/desktop/src/default_workflows.json b/apps/desktop/src/default_workflows.json new file mode 100644 index 0000000..d0dbaab --- /dev/null +++ b/apps/desktop/src/default_workflows.json @@ -0,0 +1,187 @@ +[ + { + "id": "preset-wf-translator", + "kind": "preset", + "name": "翻译助手", + "icon": "🌐", + "description": "输入原文,调用大模型翻译成英文(单模型节点案例)", + "nodes": [ + { + "id": "n_start", + "type": "start", + "label": "开始", + "position": { "x": 70, "y": 190 }, + "data": {} + }, + { + "id": "n_llm", + "type": "llm", + "label": "翻译为英文", + "position": { "x": 350, "y": 170 }, + "data": { + "prompt": "你是一名专业翻译。请把下面的内容翻译成英文,保持原意与语气:\n\n{{input}}", + "model_id": null, + "temperature": 0.4, + "max_tokens": 2048 + } + }, + { + "id": "n_end", + "type": "end", + "label": "输出", + "position": { "x": 650, "y": 190 }, + "data": { "template": "{{n_llm}}" } + } + ], + "edges": [ + { "id": "e1", "from": "n_start", "to": "n_llm" }, + { "id": "e2", "from": "n_llm", "to": "n_end" } + ], + "model_id": null, + "enabled": true + }, + { + "id": "preset-wf-summarizer", + "kind": "preset", + "name": "内容总结", + "icon": "📝", + "description": "输入长文本,输出要点总结(单模型节点案例)", + "nodes": [ + { + "id": "n_start", + "type": "start", + "label": "开始", + "position": { "x": 70, "y": 190 }, + "data": {} + }, + { + "id": "n_llm", + "type": "llm", + "label": "提炼要点", + "position": { "x": 350, "y": 170 }, + "data": { + "prompt": "请总结下面内容的要点,用中文分条列出,每条不超过 30 字:\n\n{{input}}", + "model_id": null, + "temperature": 0.3, + "max_tokens": 1024 + } + }, + { + "id": "n_end", + "type": "end", + "label": "输出", + "position": { "x": 650, "y": 190 }, + "data": { "template": "{{n_llm}}" } + } + ], + "edges": [ + { "id": "e1", "from": "n_start", "to": "n_llm" }, + { "id": "e2", "from": "n_llm", "to": "n_end" } + ], + "model_id": null, + "enabled": true + }, + { + "id": "preset-wf-polish-chain", + "kind": "preset", + "name": "两步润色", + "icon": "✨", + "description": "先润色再校对,两个大模型节点串联执行(链式案例)", + "nodes": [ + { + "id": "n_start", + "type": "start", + "label": "开始", + "position": { "x": 70, "y": 190 }, + "data": {} + }, + { + "id": "n_llm1", + "type": "llm", + "label": "润色", + "position": { "x": 340, "y": 150 }, + "data": { + "prompt": "请润色下面的文字,使表达更流畅、正式:\n\n{{input}}", + "model_id": null, + "temperature": 0.5, + "max_tokens": 2048 + } + }, + { + "id": "n_llm2", + "type": "llm", + "label": "校对精简", + "position": { "x": 610, "y": 150 }, + "data": { + "prompt": "请校对并精简下面这段文字,修正语病,删除冗余,保持原意:\n\n{{n_llm1}}", + "model_id": null, + "temperature": 0.3, + "max_tokens": 2048 + } + }, + { + "id": "n_end", + "type": "end", + "label": "输出", + "position": { "x": 880, "y": 190 }, + "data": { "template": "{{n_llm2}}" } + } + ], + "edges": [ + { "id": "e1", "from": "n_start", "to": "n_llm1" }, + { "id": "e2", "from": "n_llm1", "to": "n_llm2" }, + { "id": "e3", "from": "n_llm2", "to": "n_end" } + ], + "model_id": null, + "enabled": true + }, + { + "id": "preset-wf-writer", + "kind": "preset", + "name": "写作助手", + "icon": "✍️", + "description": "文本节点注入风格要求,再交给大模型改写(文本节点案例)", + "nodes": [ + { + "id": "n_start", + "type": "start", + "label": "开始", + "position": { "x": 70, "y": 150 }, + "data": {} + }, + { + "id": "n_text", + "type": "text", + "label": "风格要求", + "position": { "x": 70, "y": 380 }, + "data": { "content": "风格要求:面向技术读者,语言简洁准确,使用短句,避免空话套话。" } + }, + { + "id": "n_llm", + "type": "llm", + "label": "按风格改写", + "position": { "x": 350, "y": 170 }, + "data": { + "prompt": "请结合下面的风格要求,改写原文:\n{{n_text}}\n\n原文:\n{{input}}", + "model_id": null, + "temperature": 0.5, + "max_tokens": 2048 + } + }, + { + "id": "n_end", + "type": "end", + "label": "输出", + "position": { "x": 650, "y": 190 }, + "data": { "template": "{{n_llm}}" } + } + ], + "edges": [ + { "id": "e1", "from": "n_start", "to": "n_llm" }, + { "id": "e2", "from": "n_text", "to": "n_llm" }, + { "id": "e3", "from": "n_llm", "to": "n_end" } + ], + "model_id": null, + "enabled": true + } +] diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 960982c..a89c2ba 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod mcp_client; mod tools; +mod workflow; use std::collections::HashMap; use std::sync::Arc; @@ -124,11 +125,32 @@ pub fn run() { tracing::warn!(error = %e, "auto scan failed"); } } + let seeded_agents = commands::seed_preset_agents(&db); + if seeded_agents > 0 { + tracing::info!(seeded = seeded_agents, "preset agents seeded"); + } + let seeded_workflows = commands::seed_preset_workflows(&db); + if seeded_workflows > 0 { + tracing::info!(seeded = seeded_workflows, "preset workflows seeded"); + } let _ = app.emit("models://updated", ()); Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::app_info, + commands::list_agents, + commands::add_agent, + commands::update_agent, + commands::remove_agent, + commands::set_agent_enabled, + commands::restore_preset_agents, + commands::list_workflows, + commands::add_workflow, + commands::update_workflow, + commands::remove_workflow, + commands::set_workflow_enabled, + commands::restore_preset_workflows, + commands::run_workflow, commands::list_models, commands::import_model, commands::remove_model, diff --git a/apps/desktop/src/workflow.rs b/apps/desktop/src/workflow.rs new file mode 100644 index 0000000..6cbfaa0 --- /dev/null +++ b/apps/desktop/src/workflow.rs @@ -0,0 +1,455 @@ +//! 工作流执行引擎:节点图(DAG)的拓扑执行、模板变量解析与结果收集。 +//! +//! 设计上不依赖具体模型/网络:`execute_workflow` 通过传入的 `call_model` +//! 闭包调用大模型,因此可在单元测试里用假模型把「图执行逻辑」完整跑通。 + +use std::collections::{HashMap, HashSet}; +use std::future::Future; + +use xianren_core::workflows::{WorkflowEdge, WorkflowNode}; + +/// 解析模板:支持 `{{input}}`(工作流输入)与 `{{节点id}}`(上游节点输出)。 +pub fn resolve_template( + template: &str, + input: &str, + outputs: &HashMap, +) -> Result { + let mut out = String::new(); + let mut rest = template; + while let Some(start) = rest.find("{{") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find("}}") { + Some(end) => { + let key = after[..end].trim(); + if key == "input" { + out.push_str(input); + } else if let Some(value) = outputs.get(key) { + out.push_str(value); + } else { + let mut msg = "模板引用了不存在的变量 {{".to_string(); + msg.push_str(key); + msg.push_str("}}(该节点可能尚未执行或不存在)"); + return Err(msg); + } + rest = &after[end + 2..]; + } + None => { + out.push_str("{{"); + rest = after; + } + } + } + out.push_str(rest); + Ok(out) +} + +/// Kahn 拓扑排序,返回节点 id 执行顺序;存在环或引用未知节点时报错。 +pub fn topo_order( + nodes: &[WorkflowNode], + edges: &[WorkflowEdge], +) -> Result, String> { + let ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + let mut indegree: HashMap<&str, usize> = nodes.iter().map(|n| (n.id.as_str(), 0)).collect(); + let mut adj: HashMap<&str, Vec<&str>> = HashMap::new(); + for edge in edges { + if !ids.contains(edge.from_node.as_str()) { + return Err(format!("连线引用了不存在的节点:{}", edge.from_node)); + } + if !ids.contains(edge.to_node.as_str()) { + return Err(format!("连线引用了不存在的节点:{}", edge.to_node)); + } + if edge.from_node == edge.to_node { + return Err("节点不能连接自身".into()); + } + *indegree.entry(edge.to_node.as_str()).or_default() += 1; + adj.entry(edge.from_node.as_str()) + .or_default() + .push(edge.to_node.as_str()); + } + let mut queue: Vec<&str> = indegree + .iter() + .filter(|(_, d)| **d == 0) + .map(|(k, _)| *k) + .collect(); + queue.sort_unstable(); + let mut order = Vec::new(); + while let Some(id) = queue.pop() { + order.push(id.to_string()); + if let Some(nexts) = adj.get(id) { + for next in nexts { + let d = indegree.get_mut(next).expect("known node"); + *d -= 1; + if *d == 0 { + queue.push(next); + } + } + queue.sort_unstable(); + } + } + if order.len() != nodes.len() { + return Err("工作流存在循环依赖,无法执行".into()); + } + Ok(order) +} + +#[derive(Debug)] +pub struct WorkflowResult { + pub results: HashMap, + pub final_text: String, +} + +/// 按拓扑顺序执行整个工作流图。 +/// +/// 节点语义: +/// - `start`:输出即工作流输入(可通过 `{{input}}` 引用); +/// - `text`:输出为其 `data.content` 静态文本; +/// - `llm`:把 `data.prompt` 模板解析后交给 `call_model`,输出为模型返回文本; +/// - `end`:把 `data.template` 解析后作为最终输出(模板为空时取上游输出)。 +pub async fn execute_workflow( + nodes: &[WorkflowNode], + edges: &[WorkflowEdge], + input: &str, + mut call_model: F, +) -> Result +where + F: FnMut(&WorkflowNode, &str) -> Fut, + Fut: Future>, +{ + let order = topo_order(nodes, edges)?; + let by_id: HashMap<&str, &WorkflowNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + let mut outputs: HashMap = HashMap::new(); + let mut work_outputs: Vec = Vec::new(); + let mut end_outputs: Vec = Vec::new(); + + for id in &order { + let node: &WorkflowNode = by_id.get(id.as_str()).copied().expect("ordered node exists"); + match node.kind.as_str() { + "start" => { + outputs.insert(id.clone(), input.to_string()); + } + "text" => { + let content = node + .data + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + outputs.insert(id.clone(), content.to_string()); + work_outputs.push(content.to_string()); + } + "llm" => { + let raw_prompt = node + .data + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let prompt = resolve_template(raw_prompt, input, &outputs)?; + if prompt.trim().is_empty() { + return Err(format!("节点「{}」的提示词为空", node.label)); + } + let text = call_model(node, &prompt).await?; + outputs.insert(id.clone(), text.clone()); + work_outputs.push(text); + } + "end" => { + let raw_template = node + .data + .get("template") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let resolved = if raw_template.trim().is_empty() { + upstream_output(edges, id, &outputs)? + } else { + resolve_template(raw_template, input, &outputs)? + }; + outputs.insert(id.clone(), resolved.clone()); + end_outputs.push(resolved); + } + other => return Err(format!("未知节点类型:{other}")), + } + } + + let final_text = if !end_outputs.is_empty() { + end_outputs.join("\n\n") + } else { + work_outputs.last().cloned().unwrap_or_default() + }; + Ok(WorkflowResult { + results: outputs, + final_text, + }) +} + +/// 结束节点模板为空时,取直接上游节点的输出(多个时按连线顺序拼接)。 +fn upstream_output( + edges: &[WorkflowEdge], + node_id: &str, + outputs: &HashMap, +) -> Result { + let upstreams: Vec<&str> = edges + .iter() + .filter(|e| e.to_node == node_id) + .map(|e| e.from_node.as_str()) + .collect(); + if upstreams.is_empty() { + return Err("结束节点没有上游输入,请连接上游节点或在模板中引用 {{input}}".into()); + } + let mut parts = Vec::new(); + for id in upstreams { + if let Some(value) = outputs.get(id) { + parts.push(value.clone()); + } + } + if parts.is_empty() { + return Err("结束节点的上游节点尚未产生输出".into()); + } + Ok(parts.join("\n\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use xianren_core::workflows::{NodePosition, WorkflowNode}; + use xianren_core::workflows::WorkflowEdge; + + fn node(id: &str, kind: &str, label: &str, data: serde_json::Value) -> WorkflowNode { + WorkflowNode { + id: id.to_string(), + kind: kind.to_string(), + label: label.to_string(), + position: NodePosition { x: 0.0, y: 0.0 }, + data, + } + } + + fn edge(id: &str, from: &str, to: &str) -> WorkflowEdge { + WorkflowEdge { + id: id.to_string(), + from_node: from.to_string(), + to_node: to.to_string(), + } + } + + fn llm_data(prompt: &str) -> serde_json::Value { + serde_json::json!({ "prompt": prompt }) + } + + fn end_data(template: &str) -> serde_json::Value { + serde_json::json!({ "template": template }) + } + + #[tokio::test] + async fn single_llm_workflow_runs() { + let nodes = vec![ + node("n_start", "start", "开始", serde_json::json!({})), + node("n_llm", "llm", "翻译", llm_data("把下面内容翻译成英文:\n{{input}}")), + node("n_end", "end", "输出", end_data("{{n_llm}}")), + ]; + let edges = vec![edge("e1", "n_start", "n_llm"), edge("e2", "n_llm", "n_end")]; + let calls = Arc::new(AtomicUsize::new(0)); + let calls2 = calls.clone(); + let result = execute_workflow(&nodes, &edges, "你好世界", |_node, prompt| { + calls2.fetch_add(1, Ordering::SeqCst); + let prompt = prompt.to_string(); + async move { Ok(format!("[EN] {prompt}")) } + }) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(result.final_text.contains("[EN] 把下面内容翻译成英文:")); + assert!(result.final_text.contains("你好世界")); + } + + #[tokio::test] + async fn two_step_chain_passes_output_forward() { + let nodes = vec![ + node("n_start", "start", "开始", serde_json::json!({})), + node("n_llm1", "llm", "润色", llm_data("润色:\n{{input}}")), + node("n_llm2", "llm", "校对", llm_data("校对以下内容:\n{{n_llm1}}")), + node("n_end", "end", "输出", end_data("{{n_llm2}}")), + ]; + let edges = vec![ + edge("e1", "n_start", "n_llm1"), + edge("e2", "n_llm1", "n_llm2"), + edge("e3", "n_llm2", "n_end"), + ]; + let seen = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let seen2 = seen.clone(); + let result = execute_workflow(&nodes, &edges, "原稿", |node, prompt| { + let seen = seen2.clone(); + let label = node.label.clone(); + let prompt = prompt.to_string(); + async move { + seen.lock().await.push(format!("{label}|{prompt}")); + Ok(format!("[{label}处理完成]")) + } + }) + .await + .unwrap(); + let seen = seen.lock().await; + assert_eq!(seen.len(), 2); + // 第二个节点的提示词必须包含第一个节点的输出 + assert!(seen[1].contains("[润色处理完成]")); + assert!(seen[1].contains("校对以下内容")); + assert_eq!(result.final_text, "[校对处理完成]"); + } + + #[tokio::test] + async fn text_node_combines_with_input() { + let nodes = vec![ + node("n_start", "start", "开始", serde_json::json!({})), + node( + "n_text", + "text", + "风格", + serde_json::json!({ "content": "风格要求:面向技术读者,简洁。" }), + ), + node( + "n_llm", + "llm", + "改写", + llm_data("结合以下风格改写原文:\n{{n_text}}\n\n原文:\n{{input}}"), + ), + node("n_end", "end", "输出", end_data("{{n_llm}}")), + ]; + let edges = vec![ + edge("e1", "n_start", "n_llm"), + edge("e2", "n_text", "n_llm"), + edge("e3", "n_llm", "n_end"), + ]; + let prompt_seen = Arc::new(tokio::sync::Mutex::new(String::new())); + let prompt_seen2 = prompt_seen.clone(); + execute_workflow(&nodes, &edges, "原文内容", |_node, prompt| { + let prompt_seen = prompt_seen2.clone(); + let prompt = prompt.to_string(); + async move { + *prompt_seen.lock().await = prompt.clone(); + Ok("改写完成".to_string()) + } + }) + .await + .unwrap(); + let seen = prompt_seen.lock().await; + assert!(seen.contains("风格要求:面向技术读者,简洁。")); + assert!(seen.contains("原文内容")); + } + + #[tokio::test] + async fn cycle_is_rejected() { + let nodes = vec![ + node("a", "llm", "A", llm_data("{{b}}")), + node("b", "llm", "B", llm_data("{{a}}")), + ]; + let edges = vec![edge("e1", "a", "b"), edge("e2", "b", "a")]; + let err = execute_workflow(&nodes, &edges, "x", |_, _| async move { + Ok("unused".to_string()) + }) + .await + .unwrap_err(); + assert!(err.contains("循环")); + } + + #[tokio::test] + async fn missing_variable_is_rejected() { + let nodes = vec![ + node("a", "llm", "A", llm_data("{{nope}}")), + node("b", "end", "输出", end_data("{{a}}")), + ]; + let edges = vec![edge("e1", "a", "b")]; + let err = execute_workflow(&nodes, &edges, "x", |_, _| async move { + Ok("unused".to_string()) + }) + .await + .unwrap_err(); + assert!(err.contains("不存在的变量")); + } + + /// 端到端冒烟:读取真实数据目录中已配置/启用的大模型(优先在线 API), + /// 用真实模型调用跑通一个「翻译」工作流。默认忽略,需要联网与已配置模型: + /// cargo test -p xianren-desktop --lib -- --ignored --nocapture workflow::tests::real_model_translation_workflow_e2e + #[tokio::test] + #[ignore = "需要真实大模型(读取用户数据目录配置并联网调用)"] + async fn real_model_translation_workflow_e2e() { + let core = xianren_core::CoreApp::init(None).expect("open app db"); + let model = { + let db = core.db.lock().unwrap(); + let all = xianren_core::models::list(&db).expect("list models"); + all.iter() + .find(|m| m.kind == "remote" && m.enabled) + .or_else(|| all.iter().find(|m| m.kind == "local")) + .cloned() + }; + let Some(model) = model else { + eprintln!("SKIP: 数据目录没有可用模型,跳过真实模型端到端测试"); + return; + }; + eprintln!("使用模型:{}(kind={})", model.file_name, model.kind); + + let nodes = vec![ + WorkflowNode { + id: "n_start".into(), + kind: "start".into(), + label: "开始".into(), + position: NodePosition { x: 0.0, y: 0.0 }, + data: serde_json::json!({}), + }, + WorkflowNode { + id: "n_llm".into(), + kind: "llm".into(), + label: "翻译为英文".into(), + position: NodePosition { x: 0.0, y: 0.0 }, + data: serde_json::json!({ + "prompt": "你是一名专业翻译。请把下面的内容翻译成英文,只输出译文:\n\n{{input}}", + "temperature": 0.4, + "max_tokens": 1024, + }), + }, + WorkflowNode { + id: "n_end".into(), + kind: "end".into(), + label: "输出".into(), + position: NodePosition { x: 0.0, y: 0.0 }, + data: serde_json::json!({ "template": "{{n_llm}}" }), + }, + ]; + let edges = vec![ + WorkflowEdge { + id: "e1".into(), + from_node: "n_start".into(), + to_node: "n_llm".into(), + }, + WorkflowEdge { + id: "e2".into(), + from_node: "n_llm".into(), + to_node: "n_end".into(), + }, + ]; + let engine = xianren_engine::EngineManager::new(); + let result = execute_workflow(&nodes, &edges, "仙人工作室是一款本地大模型桌面应用", |_node, prompt| { + let engine = engine.clone(); + let model = model.clone(); + let prompt = prompt.to_string(); + async move { + crate::commands::call_model_text(&engine, &model, &prompt, 0.4, 1024).await + } + }) + .await + .expect("workflow runs"); + + eprintln!("最终输出:\n{}", result.final_text); + assert!(!result.final_text.trim().is_empty(), "模型输出不能为空"); + // 英文翻译结果应包含关键英文词 + assert!( + result.final_text.to_lowercase().contains("desktop") + || result.final_text.to_lowercase().contains("studio") + || result.final_text.to_lowercase().contains("local") + || result.final_text.to_lowercase().contains("model"), + "翻译结果不符合预期:{}", + result.final_text + ); + } +} diff --git a/crates/core/src/agents.rs b/crates/core/src/agents.rs new file mode 100644 index 0000000..5e3a1d1 --- /dev/null +++ b/crates/core/src/agents.rs @@ -0,0 +1,128 @@ +use crate::error::Result; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Agent { + pub id: String, + pub kind: String, + pub name: String, + pub icon: String, + pub description: String, + pub system_prompt: String, + pub model_id: Option, + pub enabled: bool, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +const SELECT_COLUMNS: &str = "id, kind, name, icon, description, system_prompt, model_id, enabled, created_at, updated_at"; + +pub fn list(db: &Connection) -> Result> { + let mut stmt = db.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM agents + ORDER BY (kind = 'preset') DESC, updated_at DESC, created_at DESC" + ))?; + let rows = stmt.query_map([], row_to_agent)?; + 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(&format!( + "SELECT {SELECT_COLUMNS} FROM agents WHERE id = ?1" + ))?; + let mut rows = stmt.query_map(params![id], row_to_agent)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn insert(db: &Connection, agent: &Agent) -> Result<()> { + db.execute( + "INSERT INTO agents (id, kind, name, icon, description, system_prompt, model_id, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now'))", + params![ + agent.id, + agent.kind, + agent.name, + agent.icon, + agent.description, + agent.system_prompt, + agent.model_id, + agent.enabled as i32, + ], + )?; + Ok(()) +} + +pub fn update(db: &Connection, agent: &Agent) -> Result<()> { + db.execute( + "UPDATE agents SET kind = ?1, name = ?2, icon = ?3, description = ?4, + system_prompt = ?5, model_id = ?6, enabled = ?7, updated_at = datetime('now') + WHERE id = ?8", + params![ + agent.kind, + agent.name, + agent.icon, + agent.description, + agent.system_prompt, + agent.model_id, + agent.enabled as i32, + agent.id, + ], + )?; + Ok(()) +} + +pub fn delete(db: &Connection, id: &str) -> Result<()> { + db.execute("DELETE FROM agents WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> { + db.execute( + "UPDATE agents SET enabled = ?1, updated_at = datetime('now') WHERE id = ?2", + params![enabled as i32, id], + )?; + Ok(()) +} + +/// 批量写入预制智能体(按 id 忽略已存在的,方便“恢复预制智能体”重复调用)。 +pub fn insert_presets_if_missing(db: &Connection, presets: &[Agent]) -> Result { + let mut inserted = 0; + for p in presets { + let exists: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM agents WHERE id = ?1)", + params![p.id], + |row| row.get(0), + )?; + if !exists { + insert(db, p)?; + inserted += 1; + } + } + Ok(inserted) +} + +fn row_to_agent(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let enabled: i32 = row.get(7)?; + Ok(Agent { + id: row.get(0)?, + kind: row.get(1)?, + name: row.get(2)?, + icon: row.get(3)?, + description: row.get(4)?, + system_prompt: row.get(5)?, + model_id: row.get(6)?, + enabled: enabled != 0, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) +} diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 34f7221..4ab728f 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -109,6 +109,7 @@ fn open_db(path: &Path) -> Result { 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 '[]'")?; + ensure_column(&conn, "conversations", "agent_id", "TEXT")?; Ok(conn) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d399810..96e7f8c 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agents; pub mod app; pub mod error; pub mod mcp_servers; @@ -5,8 +6,11 @@ pub mod models; pub mod sessions; pub mod settings; pub mod skills; +pub mod workflows; pub use app::CoreApp; pub use error::{CoreError, Result}; pub use models::ModelInfo; +pub use agents::Agent; pub use sessions::{Conversation, Message}; +pub use workflows::Workflow; diff --git a/crates/core/src/schema.sql b/crates/core/src/schema.sql index 58d37dd..b4ab62d 100644 --- a/crates/core/src/schema.sql +++ b/crates/core/src/schema.sql @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS conversations ( title TEXT NOT NULL DEFAULT '新会话', model_id TEXT, system_prompt TEXT, + agent_id TEXT, pinned INTEGER NOT NULL DEFAULT 0, favorite INTEGER NOT NULL DEFAULT 0, tools_json TEXT NOT NULL DEFAULT '[]', @@ -35,6 +36,33 @@ CREATE TABLE IF NOT EXISTS conversations ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'custom', + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🤖', + description TEXT NOT NULL DEFAULT '', + system_prompt TEXT NOT NULL DEFAULT '', + model_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'custom', + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🔀', + description TEXT NOT NULL DEFAULT '', + nodes_json TEXT NOT NULL DEFAULT '[]', + edges_json TEXT NOT NULL DEFAULT '[]', + model_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, diff --git a/crates/core/src/sessions.rs b/crates/core/src/sessions.rs index bc21b4a..6664c17 100644 --- a/crates/core/src/sessions.rs +++ b/crates/core/src/sessions.rs @@ -8,6 +8,7 @@ pub struct Conversation { pub title: String, pub model_id: Option, pub system_prompt: Option, + pub agent_id: Option, pub pinned: bool, pub favorite: bool, pub tools: Vec, @@ -48,20 +49,25 @@ pub fn create_conversation( title: &str, model_id: Option<&str>, system_prompt: Option<&str>, + agent_id: Option<&str>, ) -> Result<()> { db.execute( - "INSERT INTO conversations (id, title, model_id, system_prompt) VALUES (?1, ?2, ?3, ?4)", - params![id, title, model_id, system_prompt], + "INSERT INTO conversations (id, title, model_id, system_prompt, agent_id) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, title, model_id, system_prompt, agent_id], )?; Ok(()) } -pub fn list_conversations(db: &Connection) -> Result> { +/// 列出指定作用域的会话:`agent_id = None` 时只返回普通对话(agent_id 为空), +/// `Some(id)` 时只返回该智能体的对话。 +pub fn list_conversations(db: &Connection, agent_id: Option<&str>) -> Result> { let mut stmt = db.prepare( - "SELECT id, title, model_id, system_prompt, pinned, favorite, tools_json, created_at, updated_at - FROM conversations ORDER BY pinned DESC, updated_at DESC", + "SELECT id, title, model_id, system_prompt, agent_id, pinned, favorite, tools_json, created_at, updated_at + FROM conversations + WHERE (?1 IS NULL AND agent_id IS NULL) OR agent_id = ?1 + ORDER BY pinned DESC, updated_at DESC", )?; - let rows = stmt.query_map([], row_to_conversation)?; + let rows = stmt.query_map(params![agent_id], row_to_conversation)?; let mut out = Vec::new(); for row in rows { out.push(row?); @@ -71,7 +77,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, pinned, favorite, tools_json, created_at, updated_at + "SELECT id, title, model_id, system_prompt, agent_id, pinned, favorite, tools_json, created_at, updated_at FROM conversations WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], row_to_conversation)?; @@ -81,16 +87,21 @@ pub fn get_conversation(db: &Connection, id: &str) -> Result Result> { +/// 查找指定作用域(普通对话或某个智能体)下最近一个还没有任何消息的空会话, +/// 用于“新建对话”时复用,避免产生多个空会话。 +pub fn find_empty_conversation( + db: &Connection, + agent_id: Option<&str>, +) -> 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 + "SELECT c.id, c.title, c.model_id, c.system_prompt, c.agent_id, 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) + AND ((?1 IS NULL AND c.agent_id IS NULL) OR c.agent_id = ?1) ORDER BY c.updated_at DESC LIMIT 1", )?; - let mut rows = stmt.query_map([], row_to_conversation)?; + let mut rows = stmt.query_map(params![agent_id], row_to_conversation)?; match rows.next() { Some(row) => Ok(Some(row?)), None => Ok(None), @@ -351,19 +362,20 @@ 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)?; + let pinned: i32 = row.get(5)?; + let favorite: i32 = row.get(6)?; + let tools_json: String = row.get(7)?; Ok(Conversation { id: row.get(0)?, title: row.get(1)?, model_id: row.get(2)?, system_prompt: row.get(3)?, + agent_id: row.get(4)?, 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)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, }) } diff --git a/crates/core/src/workflows.rs b/crates/core/src/workflows.rs new file mode 100644 index 0000000..24143e8 --- /dev/null +++ b/crates/core/src/workflows.rs @@ -0,0 +1,162 @@ +use crate::error::Result; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +/// 画布节点位置(前端画布坐标)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodePosition { + pub x: f64, + pub y: f64, +} + +/// 节点类型:start=开始(提供 {{input}})、llm=大模型调用、text=静态文本、end=结束输出。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowNode { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub label: String, + pub position: NodePosition, + #[serde(default)] + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowEdge { + pub id: String, + #[serde(rename = "from")] + pub from_node: String, + #[serde(rename = "to")] + pub to_node: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workflow { + pub id: String, + pub kind: String, + pub name: String, + pub icon: String, + pub description: String, + pub nodes: Vec, + pub edges: Vec, + pub model_id: Option, + pub enabled: bool, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub updated_at: String, +} + +const SELECT_COLUMNS: &str = + "id, kind, name, icon, description, nodes_json, edges_json, model_id, enabled, created_at, updated_at"; + +pub fn list(db: &Connection) -> Result> { + let mut stmt = db.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM workflows + ORDER BY (kind = 'preset') DESC, updated_at DESC, created_at DESC" + ))?; + let rows = stmt.query_map([], row_to_workflow)?; + 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(&format!( + "SELECT {SELECT_COLUMNS} FROM workflows WHERE id = ?1" + ))?; + let mut rows = stmt.query_map(params![id], row_to_workflow)?; + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } +} + +pub fn insert(db: &Connection, workflow: &Workflow) -> Result<()> { + db.execute( + "INSERT INTO workflows (id, kind, name, icon, description, nodes_json, edges_json, model_id, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'), datetime('now'))", + params![ + workflow.id, + workflow.kind, + workflow.name, + workflow.icon, + workflow.description, + serde_json::to_string(&workflow.nodes).unwrap_or_else(|_| "[]".to_string()), + serde_json::to_string(&workflow.edges).unwrap_or_else(|_| "[]".to_string()), + workflow.model_id, + workflow.enabled as i32, + ], + )?; + Ok(()) +} + +pub fn update(db: &Connection, workflow: &Workflow) -> Result<()> { + db.execute( + "UPDATE workflows SET kind = ?1, name = ?2, icon = ?3, description = ?4, + nodes_json = ?5, edges_json = ?6, model_id = ?7, enabled = ?8, updated_at = datetime('now') + WHERE id = ?9", + params![ + workflow.kind, + workflow.name, + workflow.icon, + workflow.description, + serde_json::to_string(&workflow.nodes).unwrap_or_else(|_| "[]".to_string()), + serde_json::to_string(&workflow.edges).unwrap_or_else(|_| "[]".to_string()), + workflow.model_id, + workflow.enabled as i32, + workflow.id, + ], + )?; + Ok(()) +} + +pub fn delete(db: &Connection, id: &str) -> Result<()> { + db.execute("DELETE FROM workflows WHERE id = ?1", params![id])?; + Ok(()) +} + +pub fn set_enabled(db: &Connection, id: &str, enabled: bool) -> Result<()> { + db.execute( + "UPDATE workflows SET enabled = ?1, updated_at = datetime('now') WHERE id = ?2", + params![enabled as i32, id], + )?; + Ok(()) +} + +pub fn insert_presets_if_missing(db: &Connection, presets: &[Workflow]) -> Result { + let mut inserted = 0; + for p in presets { + let exists: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM workflows WHERE id = ?1)", + params![p.id], + |row| row.get(0), + )?; + if !exists { + insert(db, p)?; + inserted += 1; + } + } + Ok(inserted) +} + +fn row_to_workflow(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let nodes_json: String = row.get(5)?; + let edges_json: String = row.get(6)?; + let enabled: i32 = row.get(8)?; + Ok(Workflow { + id: row.get(0)?, + kind: row.get(1)?, + name: row.get(2)?, + icon: row.get(3)?, + description: row.get(4)?, + nodes: serde_json::from_str(&nodes_json).unwrap_or_default(), + edges: serde_json::from_str(&edges_json).unwrap_or_default(), + model_id: row.get(7)?, + enabled: enabled != 0, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 465931d..8718315 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,8 +12,8 @@ ``` xianren_studio/ -├── apps/desktop/ Tauri 桌面壳(Rust 命令层、tauri.conf.json、推荐模型默认 JSON) -├── crates/core/ 领域核心:SQLite(模型注册表、会话、消息、设置) +├── apps/desktop/ Tauri 桌面壳(Rust 命令层、tauri.conf.json、推荐模型/预制智能体/预制工作流默认 JSON、工作流执行引擎 workflow.rs) +├── crates/core/ 领域核心:SQLite(模型注册表、智能体、工作流、会话、消息、设置) ├── crates/engine/ llama-server 生命周期 + 流式/非流式聊天(本地与远程 OpenAI 兼容) ├── crates/download/ 分片断点续传下载器 ├── crates/api/ OpenAI 兼容本地 API 服务(axum) @@ -33,7 +33,7 @@ React UI(ui/src) ▼ apps/desktop/src/commands.rs ← 所有 Tauri 命令(invoke_handler 注册于 lib.rs) │ - ├── crates/core(SQLite:models / sessions / settings) + ├── crates/core(SQLite:models / agents / workflows / sessions / settings) ├── crates/engine(llama-server 子进程 / 远程 OpenAI API) ├── crates/download(下载任务) └── crates/api(本地 API 服务) @@ -48,13 +48,17 @@ apps/desktop/src/commands.rs ← 所有 Tauri 命令(invoke_handler 注册于 ``` ui/src/ ├── main.tsx 入口 -├── App.tsx 导航栏、路由(7 个页面)、全局事件订阅(引擎/下载/模型/建议) +├── App.tsx 导航栏、路由(9 个页面)、全局事件订阅(引擎/下载/模型/建议) ├── api.ts Tauri invoke 封装 + 全部请求/事件 TypeScript 类型 ├── store.ts zustand 全局状态(见下) ├── styles.css 全局样式 ├── components/Icon.tsx SVG 图标库 └── pages/ ├── ChatPage.tsx 对话页(最大的页面,含会话快照、消息气泡、模型下拉、建议标签) + ├── AgentsPage.tsx 智能体列表(预制/自定义分组、新建/编辑/删除/恢复预制) + ├── AgentChatPage.tsx 智能体会话页(按路由加载智能体并渲染 ChatPage 智能体模式) + ├── WorkflowsPage.tsx 工作流列表(预制/自定义分组、新建/删除/恢复预制) + ├── WorkflowCanvasPage.tsx 工作流画布(节点拖拽/连线、节点编辑、运行与结果展示) ├── ModelsPage.tsx 模型管理(本地部署 + 在线 API 启用开关) ├── ModelPlazaPage.tsx 模型广场(HF/ModelScope 搜索与下载) ├── TasksPage.tsx 任务(下载/部署进度) @@ -69,6 +73,9 @@ ui/src/ | 字段 | 说明 | | --- | --- | +| `agents` / `agentsLoaded` | 智能体列表(预制 + 自定义)与加载标记 | +| `agentConversations` | 智能体 id → 会话列表(各智能体会话隔离,key 为智能体 id) | +| `workflows` / `workflowsLoaded` | 工作流列表(预制 + 自定义)与加载标记 | | `models` | 模型列表(含 `enabled`、`kind`、`file_name` 等) | | `engine` | 引擎状态(running / model / port) | | `deployStates` / `deployProgress` | 各模型部署状态(loading/ready/error)与进度 | @@ -94,10 +101,12 @@ ui/src/ | 分组 | 命令 | | --- | --- | +| 智能体 | `list_agents`、`add_agent`、`update_agent`、`remove_agent`、`set_agent_enabled`、`restore_preset_agents` | +| 工作流 | `list_workflows`、`add_workflow`、`update_workflow`、`remove_workflow`、`set_workflow_enabled`、`restore_preset_workflows`、`run_workflow` | | 应用/设置 | `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` | +| 会话 | `list_conversations`(可按 `agent_id` 过滤)、`create_conversation`(可选 `agent_id`,自动继承智能体系统提示词与默认模型)、`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` | @@ -109,9 +118,19 @@ ui/src/ - `chat_send`:写入用户消息 → 构建历史 → 拉起后台生成任务。 - `run_generation_and_stream`:统一生成入口(本地引擎 / 远程 API),流式转发 token、统计用量、写库、发 `chat://done`,随后触发标题生成与建议生成。 +- `inject_system_prompts`:把会话/智能体系统提示词(`conversations.system_prompt`)与工具说明统一注入消息历史最前;`chat_send` / `regenerate_message` / `edit_message` 三条链路共用。 - `maybe_generate_suggestions` → `call_suggestion_model` → `parse_suggestions`:回答完成后预测用户接下来可能说的话。统一要求模型输出 JSON 数组;最多重试 3 次;过滤元信息行与回答原文片段;每条 ≤ 30 字。 - `maybe_generate_conversation_title`:首轮对话自动生成标题。 -- `create_conversation`:先查找空会话(`find_empty_conversation`)复用,没有才新建。 +- `create_conversation`:先在指定作用域(普通对话或某智能体)查找空会话(`find_empty_conversation`)复用,没有才新建;智能体会话写入该智能体的系统提示词与默认模型。 +- `seed_preset_agents`:启动时首次写入 `default_agents.json` 中的预制智能体(`settings.preset_agents_seeded` 标记只执行一次);`restore_preset_agents` 命令可随时补齐缺失预制体。 +- `run_workflow`:加载工作流节点/连线 → 交给 `workflow.rs` 执行引擎按拓扑顺序运行;LLM 节点经 `resolve_workflow_node_model` 解析模型(节点 → 工作流默认 → 运行参数 → 自动回退),`call_model_text` 非流式调用本地引擎或远程 API,逐节点推送 `workflow://node-status` 事件。 +- `seed_preset_workflows`:启动时首次写入 `default_workflows.json` 中的预制工作流(`settings.preset_workflows_seeded` 标记只执行一次);`restore_preset_workflows` 命令可随时补齐缺失预制体。 + +**工作流执行引擎(`apps/desktop/src/workflow.rs`):** + +- `topo_order`:Kahn 拓扑排序,检测循环 / 未知节点 / 自连。 +- `resolve_template`:解析 `{{input}}` 与 `{{节点id}}` 变量。 +- `execute_workflow`:按拓扑顺序执行 start / text / llm / end 节点,收集各节点输出并汇总最终结果;通过注入的 `call_model` 闭包调用模型,与具体模型解耦(单元测试用假模型跑通 5 个案例,另有真实模型端到端测试 `real_model_translation_workflow_e2e`)。 **工具协议(技能 / MCP):** @@ -135,13 +154,15 @@ ui/src/ | --- | --- | --- | | `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` | 会话 | +| `conversations` | `title`、`model_id`、`system_prompt`、`agent_id`、`pinned`、`favorite`、`tools_json` | 会话;`agent_id` 为空表示普通对话,否则属于对应智能体 | +| `agents` | `kind`(preset/custom)、`name`、`icon`、`description`、`system_prompt`、`model_id`、`enabled` | 智能体(预制 + 自定义) | +| `workflows` | `kind`(preset/custom)、`name`、`icon`、`description`、`nodes_json`、`edges_json`、`model_id`、`enabled` | 工作流(节点/连线以 JSON 存储,节点含 type/label/position/data) | | `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`。 +迁移清单(`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/agent_id`。 ### 5.3 `models.rs` @@ -151,10 +172,18 @@ ui/src/ 会话/消息/版本 CRUD;`find_empty_conversation`(新建对话复用空会话);`import_message`(导入对话用,可指定创建时间)。 -### 5.5 `settings.rs` +### 5.5 `agents.rs` + +智能体 CRUD(`list/get/insert/update/delete/set_enabled`)与预制体批量写入 `insert_presets_if_missing`(按 id 忽略已存在,供首次种子与「恢复预制智能体」复用)。 + +### 5.6 `settings.rs` `get/set/insert_default/all`。 +### 5.7 `workflows.rs` + +工作流 CRUD(`list/get/insert/update/delete/set_enabled`)与预制体批量写入 `insert_presets_if_missing`(按 id 忽略已存在);节点 `WorkflowNode`(type=start/llm/text/end、position、data)与连线 `WorkflowEdge` 以 JSON 列存储,序列化/反序列化在读写时完成。 + --- ## 6. 引擎与远程调用(`crates/engine`) @@ -197,6 +226,7 @@ ui/src/ | `models://updated` | 后端→前端 | 模型列表变化 | | `download://started/progress/done/error` | 后端→前端 | 下载任务进度 | | `server://status` | 后端→前端 | 本地 API 服务状态 | +| `workflow://node-status` | 后端→前端 | 工作流节点运行状态(running/done/error)与输出文本 | --- @@ -218,6 +248,8 @@ ui/src/ | `tavily_api_key` | (内置演示值) | 联网搜索 API Key | | `tool_web_search_enabled` | true | 联网搜索工具开关 | | `recommend_dir` | 数据目录/recommendations | 推荐列表目录 | +| `preset_agents_seeded` | 首次启动后为 `1` | 预制智能体是否已写入(内部标记,避免覆盖用户删除) | +| `preset_workflows_seeded` | 首次启动后为 `1` | 预制工作流是否已写入(内部标记,避免覆盖用户删除) | --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 44077d8..ef8428c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -12,11 +12,13 @@ ## 1. 页面总览 -应用共 7 个选项卡(导航左侧栏): +应用共 9 个选项卡(导航左侧栏): | 选项卡 | 路由 | 页面 | | --- | --- | --- | | 对话 | `/chat` | `ui/src/pages/ChatPage.tsx` | +| 智能体 | `/agents`、`/agents/:agentId` | `ui/src/pages/AgentsPage.tsx`、`ui/src/pages/AgentChatPage.tsx` | +| 工作流 | `/workflows`、`/workflows/:workflowId` | `ui/src/pages/WorkflowsPage.tsx`、`ui/src/pages/WorkflowCanvasPage.tsx` | | 模型管理 | `/` | `ui/src/pages/ModelsPage.tsx` | | 模型广场 | `/plaza` | `ui/src/pages/ModelPlazaPage.tsx` | | 任务 | `/tasks` | `ui/src/pages/TasksPage.tsx` | @@ -77,7 +79,52 @@ --- -## 3. 模型管理页(`/`) +## 3. 智能体页(`/agents`) + +### 3.1 智能体列表 + +- 左侧导航新增「智能体」选项卡,页面分为「预制智能体」与「自定义智能体」两组。 +- **预制智能体**:随应用内置 6 个(通用助手、代码专家、写作助手、翻译官、数据分析师、提示词优化师),首次启动自动写入数据库(`settings` 键 `preset_agents_seeded` 标记,之后尊重用户删除);可通过「恢复预制智能体」一键找回已删除的预制体。 +- **自定义智能体**:支持新增 / 编辑 / 删除 / 启用停用。字段:名称、图标(emoji)、描述、系统提示词、默认大模型(可选)、启用开关。 +- 删除智能体会同时删除其名下所有会话(消息随外键级联删除)。 + +### 3.2 智能体会话 + +- 点击智能体卡片「开始对话」进入 `/agents/:agentId`,该页面复用聊天页完整能力(流式输出、重新生成、版本历史、工具、预测建议等)。 +- **会话隔离**:`conversations` 表新增 `agent_id` 字段。普通对话页只显示 `agent_id` 为空的会话,每个智能体只显示属于自己的会话,互不干扰。 +- 新建智能体会话时自动写入该智能体的**系统提示词**(人设)与默认大模型;对话时仍可在右侧参数面板切换任意可用大模型(本地 / 在线 API 均可)。 +- 系统提示词在 `chat_send` / `regenerate_message` / `edit_message` 三条链路统一注入到消息历史最前(工具说明紧随其后),重新生成与编辑重提同样生效。 + +--- + +## 4. 工作流页(`/workflows`) + +### 4.1 工作流列表 + +- 左侧导航新增「工作流」选项卡,页面分为「预制工作流」与「自定义工作流」两组,支持新建 / 删除 / 启用停用 / 恢复预制。 +- **预制工作流**:随应用内置 4 个(翻译助手、内容总结、两步润色、写作助手),首次启动自动写入数据库(`settings` 键 `preset_workflows_seeded` 标记,之后尊重用户删除);「恢复预制工作流」可一键找回。 +- 新建工作流会生成「开始 → 大模型 → 输出」的默认画布,随后进入画布编辑器。 + +### 4.2 画布编辑器(`/workflows/:workflowId`) + +- 左侧节点面板可添加 4 类节点: + - **开始**:工作流入口,提供运行输入(提示词中可用 `{{input}}` 引用); + - **大模型**:配置提示词模板、节点级大模型(可选)、temperature、max_tokens,提示词可用 `{{input}}` 与 `{{节点id}}` 引用上游节点输出; + - **文本**:静态文本内容(如风格要求),可被其他节点引用; + - **输出**:决定最终输出(模板留空时自动取上游输出)。 +- 节点可拖动布局;点击节点右侧圆点拖到另一节点左侧圆点建立连线(数据流),点击连线删除;删除节点时自动清理相关连线。 +- 右侧面板:运行工作流(输入内容 + 可选大模型)、节点设置、工作流设置(图标/描述/默认大模型/启用)、各节点输出列表。 +- 运行前自动保存画布;后端按拓扑顺序执行,`workflow://node-status` 事件实时推送每个节点 running/done 状态与输出。 + +### 4.3 执行引擎 + +- `apps/desktop/src/workflow.rs`:DAG 拓扑排序(Kahn,检测循环/未知节点/自连)、模板变量解析(`{{input}}`、`{{节点id}}`)、逐节点执行与结果收集;不依赖具体模型,通过注入的 `call_model` 闭包调用模型,便于单元测试。 +- LLM 节点模型解析顺序:节点配置 → 工作流默认模型 → 运行参数模型 → 第一个本地模型 / 第一个已启用在线模型。 +- 已内置 5 个执行引擎单元测试(单节点、链式传参、文本节点组合、循环检测、缺失变量报错)与 1 个真实模型端到端测试(`real_model_translation_workflow_e2e`,用本机已配置的在线模型跑通「翻译」工作流,默认忽略、显式运行)。 + +--- + +## 5. 模型管理页(`/`) - **本地模型**:启动时自动扫描模型目录(可手动重新扫描);支持导入本地 GGUF 文件;可「部署」(后台加载 llama-server 并显示进度)或「停止」;可打开所在目录、移除。 - **在线 API 模型**(OpenAI 兼容):添加时填写显示名称、Base URL、API Key(可选)、上游模型 ID。 @@ -86,7 +133,7 @@ --- -## 4. 模型广场页(`/plaza`) +## 6. 模型广场页(`/plaza`) - 搜索 Hugging Face / ModelScope 上的 GGUF 模型,支持热门榜(空关键词)。 - 查看仓库的 GGUF 量化版本文件列表,选择版本一键下载(ModelScope 文件自动附带 SHA256 校验)。 @@ -95,14 +142,14 @@ --- -## 5. 任务页(`/tasks`) +## 7. 任务页(`/tasks`) - 展示下载任务与部署任务的实时进度、状态(进行中 / 完成 / 失败)。 - 空状态提示:暂无任务时引导去模型广场下载或部署本地模型。 --- -## 6. 工具页(`/tools`) +## 8. 工具页(`/tools`) - **联网搜索**:配置 Tavily API Key,支持手动测试搜索;对话中的「联网搜索」开关依赖该配置。 - **技能(Skill)**: @@ -116,14 +163,14 @@ --- -## 7. 服务管理页(`/server`) +## 9. 服务管理页(`/server`) - OpenAI 兼容的本地 API 服务:`/v1/models`、`/v1/chat/completions`(SSE 流式)、`/v1/embeddings`。 - 可设置端口与 API Key,仅本机监听;可启动/停止并查看状态。 --- -## 8. 设置页(`/settings`) +## 10. 设置页(`/settings`) - **引擎与路径**:模型目录、llama-server 路径、模型下载源(hf-mirror / huggingface.co / modelscope.cn)、默认后端(auto/cpu/cuda/vulkan)、上传大小上限。 - **对话**: @@ -137,12 +184,14 @@ --- -## 9. 改动记录 +## 11. 改动记录 > 按时间倒序追加;每次改动功能都要在此登记。 ### 2026-08-16 +- 新增「工作流」选项卡:画布式节点编辑器(开始/大模型/文本/输出,节点连线传参、拖动布局),内置 4 个预制工作流(翻译助手、内容总结、两步润色、写作助手)并支持自定义与恢复;新增 `workflows` 表与 `run_workflow` 命令,后端 `apps/desktop/src/workflow.rs` 执行引擎支持拓扑排序、模板变量(`{{input}}`/`{{节点id}}`)、循环检测,LLM 节点支持本地/在线大模型(节点 → 工作流 → 运行参数 → 自动回退);`workflow://node-status` 事件实时推送节点状态;5 个引擎单元测试 + 1 个真实模型端到端测试全部跑通。 +- 新增「智能体」选项卡:内置 6 个预制智能体 + 支持自定义智能体(名称/图标/描述/系统提示词/默认模型/启用开关);每个智能体拥有独立会话区(`conversations.agent_id` 隔离),新建会话自动继承智能体系统提示词与默认模型;系统提示词统一注入 `chat_send` / `regenerate_message` / `edit_message` 链路;删除智能体时级联删除其会话;支持「恢复预制智能体」。 - 工具页新增「技能(Skill)」与「MCP 服务」管理;对话工具协议上线:模型用 `[[skill:…]]` / `[[mcp:…]]` 标记调用技能与 MCP 工具,系统执行后回填结果(最多 3 轮)。新增本地 MCP 测试服务器 `scripts/test_mcp_server.py` 与 3 个示例技能。 - 会话列表「更多操作」菜单:鼠标移出按钮/菜单所在区域时自动关闭,避免遮挡其他对话标题的查看与操作。 - 新建对话时复用已有空会话(无消息的会话),避免堆积多个空的「新会话」。 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f0ab80c..49dd8eb 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,9 +16,15 @@ import ToolsPage from "./pages/ToolsPage"; import ServerPage from "./pages/ServerPage"; import SettingsPage from "./pages/SettingsPage"; import TasksPage from "./pages/TasksPage"; +import AgentsPage from "./pages/AgentsPage"; +import AgentChatPage from "./pages/AgentChatPage"; +import WorkflowsPage from "./pages/WorkflowsPage"; +import WorkflowCanvasPage from "./pages/WorkflowCanvasPage"; const navItems = [ { to: "/chat", label: "对话", icon: "chat" }, + { to: "/agents", label: "智能体", icon: "bot" }, + { to: "/workflows", label: "工作流", icon: "workflow" }, { to: "/", label: "模型管理", icon: "box", end: true }, { to: "/plaza", label: "模型广场", icon: "plaza" }, { to: "/tasks", label: "任务", icon: "tasks" }, @@ -35,6 +41,8 @@ export default function App() { const refreshEngine = useStore((s) => s.refreshEngine); const refreshServer = useStore((s) => s.refreshServer); const refreshConversations = useStore((s) => s.refreshConversations); + const refreshAgents = useStore((s) => s.refreshAgents); + const refreshWorkflows = useStore((s) => s.refreshWorkflows); const setDeployState = useStore((s) => s.setDeployState); const setDeployProgress = useStore((s) => s.setDeployProgress); const setChatSuggestions = useStore((s) => s.setChatSuggestions); @@ -55,6 +63,8 @@ export default function App() { refreshEngine(); refreshServer(); refreshConversations(); + refreshAgents(); + refreshWorkflows(); const un1 = onEvent("engine://status", () => refreshEngine()); const un2 = onEvent("server://status", () => refreshServer()); const un3start = onEvent("download://started", (e) => { @@ -159,6 +169,8 @@ export default function App() { refreshEngine, refreshServer, refreshConversations, + refreshAgents, + refreshWorkflows, setDeployState, setDeployProgress, setChatSuggestions, @@ -227,6 +239,10 @@ export default function App() { } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api.ts b/ui/src/api.ts index 80a9ee1..0db2035 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -72,6 +72,92 @@ export interface Skill { created_at: string; } +export interface Agent { + id: string; + kind: string; + name: string; + icon: string; + description: string; + system_prompt: string; + model_id: string | null; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface AgentInput { + name: string; + icon: string; + description: string; + system_prompt: string; + model_id: string | null; + enabled: boolean; +} + +export interface WorkflowNodePosition { + x: number; + y: number; +} + +export interface WorkflowNode { + id: string; + type: string; + label: string; + position: WorkflowNodePosition; + data: { + prompt?: string; + content?: string; + template?: string; + model_id?: string | null; + temperature?: number; + max_tokens?: number; + }; +} + +export interface WorkflowEdge { + id: string; + from: string; + to: string; +} + +export interface Workflow { + id: string; + kind: string; + name: string; + icon: string; + description: string; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + model_id: string | null; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface WorkflowInput { + name: string; + icon: string; + description: string; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + model_id: string | null; + enabled: boolean; +} + +export interface WorkflowRunResult { + workflow_id: string; + results: Record; + final: string; +} + +export interface WorkflowNodeStatusEvent { + workflow_id: string; + node_id: string; + label: string; + status: string; + text: string | null; +} + export interface McpServer { id: string; name: string; @@ -122,6 +208,7 @@ export interface Conversation { title: string; model_id: string | null; system_prompt: string | null; + agent_id: string | null; pinned: boolean; favorite: boolean; tools: string[]; @@ -253,6 +340,27 @@ export interface ServerStatus { export const api = { appInfo: () => invoke("app_info"), + listAgents: () => invoke("list_agents"), + addAgent: (input: AgentInput) => invoke("add_agent", { input }), + updateAgent: (id: string, input: AgentInput) => + invoke("update_agent", { id, input }), + removeAgent: (id: string) => invoke("remove_agent", { id }), + setAgentEnabled: (id: string, enabled: boolean) => + invoke("set_agent_enabled", { id, enabled }), + restorePresetAgents: () => invoke("restore_preset_agents"), + listWorkflows: () => invoke("list_workflows"), + addWorkflow: (input: WorkflowInput) => invoke("add_workflow", { input }), + updateWorkflow: (id: string, input: WorkflowInput) => + invoke("update_workflow", { id, input }), + removeWorkflow: (id: string) => invoke("remove_workflow", { id }), + setWorkflowEnabled: (id: string, enabled: boolean) => + invoke("set_workflow_enabled", { id, enabled }), + restorePresetWorkflows: () => invoke("restore_preset_workflows"), + runWorkflow: (payload: { + workflow_id: string; + input: string; + model_id: string | null; + }) => invoke("run_workflow", { payload }), listModels: () => invoke("list_models"), importModel: (path: string) => invoke("import_model", { path }), removeModel: (id: string) => invoke("remove_model", { id }), @@ -310,9 +418,14 @@ export const api = { 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 }), + listConversations: (agentId?: string) => + invoke("list_conversations", { agentId: agentId ?? null }), + createConversation: (title: string, modelId?: string, agentId?: string) => + invoke("create_conversation", { + title, + modelId: modelId ?? null, + agentId: agentId ?? null, + }), renameConversation: (id: string, title: string) => invoke("rename_conversation", { id, title }), setConversationPinned: (id: string, pinned: boolean) => diff --git a/ui/src/components/Icon.tsx b/ui/src/components/Icon.tsx index fc78c25..b55cc83 100644 --- a/ui/src/components/Icon.tsx +++ b/ui/src/components/Icon.tsx @@ -2,6 +2,24 @@ import type { ReactNode } from "react"; const paths: Record = { chat: , + bot: ( + <> + + + + + + + ), + workflow: ( + <> + + + + + + + ), box: ( <> diff --git a/ui/src/pages/AgentChatPage.tsx b/ui/src/pages/AgentChatPage.tsx new file mode 100644 index 0000000..e8a816c --- /dev/null +++ b/ui/src/pages/AgentChatPage.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useStore } from "../store"; +import ChatPage from "./ChatPage"; + +export default function AgentChatPage() { + const { agentId } = useParams<{ agentId: string }>(); + const agents = useStore((s) => s.agents); + const agentsLoaded = useStore((s) => s.agentsLoaded); + const refreshAgents = useStore((s) => s.refreshAgents); + + useEffect(() => { + refreshAgents(); + }, [refreshAgents]); + + if (!agentsLoaded) { + return ( +
+ 正在加载智能体… +
+ ); + } + + const agent = agents.find((a) => a.id === agentId); + if (!agent) { + return ( +
+
🤖
+ 智能体不存在或已被删除 + + 返回智能体列表 + +
+ ); + } + + return ; +} diff --git a/ui/src/pages/AgentsPage.tsx b/ui/src/pages/AgentsPage.tsx new file mode 100644 index 0000000..7bd5800 --- /dev/null +++ b/ui/src/pages/AgentsPage.tsx @@ -0,0 +1,385 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { api, Agent, AgentInput } from "../api"; +import { useStore } from "../store"; +import Icon from "../components/Icon"; + +interface EditorState { + editing: Agent | null; + name: string; + icon: string; + description: string; + system_prompt: string; + model_id: string | null; + enabled: boolean; +} + +function emptyEditor(): EditorState { + return { + editing: null, + name: "", + icon: "🤖", + description: "", + system_prompt: "", + model_id: null, + enabled: true, + }; +} + +export default function AgentsPage() { + const agents = useStore((s) => s.agents); + const refreshAgents = useStore((s) => s.refreshAgents); + const refreshModels = useStore((s) => s.refreshModels); + const models = useStore((s) => s.models); + const navigate = useNavigate(); + const [editor, setEditor] = useState(null); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + refreshAgents(); + refreshModels(); + }, [refreshAgents, refreshModels]); + + const presets = useMemo(() => agents.filter((a) => a.kind === "preset"), [agents]); + const customs = useMemo(() => agents.filter((a) => a.kind === "custom"), [agents]); + + const modelNameById = useMemo(() => { + const map = new Map(); + for (const m of models) { + map.set(m.id, m.file_name.replace(/\.gguf$/i, "")); + } + return map; + }, [models]); + + function startEdit(agent: Agent) { + setEditor({ + editing: agent, + name: agent.name, + icon: agent.icon, + description: agent.description, + system_prompt: agent.system_prompt, + model_id: agent.model_id, + enabled: agent.enabled, + }); + } + + async function handleSave() { + if (!editor) return; + if (!editor.name.trim()) { + setMsg("请填写智能体名称"); + return; + } + const input: AgentInput = { + name: editor.name.trim(), + icon: editor.icon.trim() || "🤖", + description: editor.description.trim(), + system_prompt: editor.system_prompt.trim(), + model_id: editor.model_id || null, + enabled: editor.enabled, + }; + setBusy(true); + try { + if (editor.editing) { + await api.updateAgent(editor.editing.id, input); + setMsg("智能体已保存"); + } else { + await api.addAgent(input); + setMsg("智能体已创建"); + } + setEditor(null); + await refreshAgents(); + } catch (e) { + setMsg(`保存失败:${String(e)}`); + } finally { + setBusy(false); + } + } + + async function handleToggle(agent: Agent) { + try { + await api.setAgentEnabled(agent.id, !agent.enabled); + await refreshAgents(); + } catch (e) { + setMsg(`操作失败:${String(e)}`); + } + } + + async function handleDelete(agent: Agent) { + if (!confirm(`确定删除智能体「${agent.name}」吗?\n其名下所有会话将一并删除。`)) return; + try { + await api.removeAgent(agent.id); + setMsg("智能体已删除"); + await refreshAgents(); + } catch (e) { + setMsg(`删除失败:${String(e)}`); + } + } + + async function handleRestorePresets() { + setBusy(true); + try { + const n = await api.restorePresetAgents(); + setMsg(n > 0 ? `已恢复 ${n} 个预制智能体` : "预制智能体已齐全,无需恢复"); + await refreshAgents(); + } catch (e) { + setMsg(`恢复失败:${String(e)}`); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

智能体

+

+ 每个智能体拥有独立的人设与系统提示词,可单独开始完整对话;支持本地与在线大模型,对话时可按会话启用工具 +

+
+
+ + +
+
+ + {msg ? ( +
+ {msg} + +
+ ) : null} + + {editor ? ( +
+
+ {editor.editing ? "编辑智能体" : "新建智能体"} +
+
+ + + +
+ +