feat: 新增智能体与工作流选项卡(预制/自定义、画布编辑器、执行引擎);约定每次改动提交推送并打新 tag

This commit is contained in:
xianrenge
2026-08-16 23:28:55 +08:00
parent 10ecf526ec
commit 9b9e810ff1
24 changed files with 3550 additions and 118 deletions
+544 -30
View File
@@ -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<String>,
}
#[derive(Serialize, Clone)]
pub struct ChatTitleUpdatedEvent {
pub conversation_id: String,
@@ -427,10 +438,468 @@ fn read_json_files(dir: &PathBuf) -> Result<Vec<PathBuf>, 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<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[tauri::command]
pub fn list_conversations(state: State<'_, App>) -> Result<Vec<xianren_core::Conversation>, String> {
pub fn list_agents(state: State<'_, App>) -> Result<Vec<xianren_core::Agent>, 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<xianren_core::Agent, String> {
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<usize, String> {
let db = state.core.db.lock().unwrap();
let presets: Vec<xianren_core::Agent> =
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<xianren_core::Agent> =
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<xianren_core::workflows::WorkflowNode>,
#[serde(default)]
pub edges: Vec<xianren_core::workflows::WorkflowEdge>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[tauri::command]
pub fn list_workflows(state: State<'_, App>) -> Result<Vec<xianren_core::Workflow>, 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<xianren_core::Workflow, String> {
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<usize, String> {
let db = state.core.db.lock().unwrap();
let presets: Vec<xianren_core::Workflow> =
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<xianren_core::Workflow> =
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<String>,
}
/// 运行工作流:按拓扑顺序执行节点,LLM 节点调用本地/在线大模型,逐步推送节点状态事件。
#[tauri::command]
pub async fn run_workflow(
app: AppHandle,
state: State<'_, App>,
payload: RunWorkflowPayload,
) -> Result<serde_json::Value, String> {
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<String>,
payload_model_id: &Option<String>,
) -> Result<ModelInfo, String> {
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<String, String> {
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<String, String> {
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<String>,
) -> Result<Vec<xianren_core::Conversation>, 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<String>,
agent_id: Option<String>,
) -> Result<xianren_core::Conversation, 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(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<ChatMessage>) -> Vec<ChatMessage>
history
}
/// 把会话/智能体的系统提示词与工具说明注入消息历史:
/// 系统提示词在最前,工具说明紧随其后,其余历史消息保持原顺序。
fn inject_system_prompts(
mut history: Vec<ChatMessage>,
system_prompt: Option<&str>,
tool_defs: &[ToolDef],
) -> Vec<ChatMessage> {
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();
+62
View File
@@ -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
}
]
+187
View File
@@ -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
}
]
+22
View File
@@ -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,
+455
View File
@@ -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<String, String>,
) -> Result<String, String> {
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<Vec<String>, 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<String, String>,
pub final_text: String,
}
/// 按拓扑顺序执行整个工作流图。
///
/// 节点语义:
/// - `start`:输出即工作流输入(可通过 `{{input}}` 引用);
/// - `text`:输出为其 `data.content` 静态文本;
/// - `llm`:把 `data.prompt` 模板解析后交给 `call_model`,输出为模型返回文本;
/// - `end`:把 `data.template` 解析后作为最终输出(模板为空时取上游输出)。
pub async fn execute_workflow<F, Fut>(
nodes: &[WorkflowNode],
edges: &[WorkflowEdge],
input: &str,
mut call_model: F,
) -> Result<WorkflowResult, String>
where
F: FnMut(&WorkflowNode, &str) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
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<String, String> = HashMap::new();
let mut work_outputs: Vec<String> = Vec::new();
let mut end_outputs: Vec<String> = 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<String, String>,
) -> Result<String, String> {
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
);
}
}