feat: 新增智能体与工作流选项卡(预制/自定义、画布编辑器、执行引擎);约定每次改动提交推送并打新 tag
This commit is contained in:
+544
-30
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user