新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -6,12 +6,104 @@
//! 与 AI Chat侧边栏交互对话的区别AI Node 由 DAG Executor 自动驱动,
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
use std::collections::HashMap;
use async_trait::async_trait;
use df_ai::anthropic_compat::AnthropicCompatProvider;
use df_ai::openai_compat::OpenAICompatProvider;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// AI 节点解析后的参数execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
#[derive(Debug)]
struct AiNodeParams {
base_url: String,
api_key: String,
prompt: String,
system_prompt: Option<String>,
model: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
/// 协议类型openai_compat默认/ anthropicGLM 订阅 / Claude 官方)
protocol: String,
/// model 为空时的占位,避免 provider 构造 panic
default_model: String,
}
/// 从节点 config + 上游输入解析 AI 节点参数
///
/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。
/// model 为空时 default_model 兜底为 "gpt-4o-mini"。protocol 默认 openai_compat。
fn parse_params(
config: &serde_json::Value,
inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> {
// ── provider 配置(必填)──
let base_url = config
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
.to_string();
let api_key = config
.get("api_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
.to_string();
// ── prompt必填优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(|s| s.to_string())
.or_else(|| {
config
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: promptconfig 或上游输入均无)"))?;
// ── 可选参数 ──
let model = config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let temperature = config
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let max_tokens = config
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let system_prompt = config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let protocol = config
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("openai_compat")
.to_string();
// default_model留空时给一个占位避免 provider 构造 panic
let default_model = if model.is_empty() {
"gpt-4o-mini".to_string()
} else {
model.clone()
};
Ok(AiNodeParams {
base_url,
api_key,
prompt,
system_prompt,
model,
temperature,
max_tokens,
protocol,
default_model,
})
}
/// AI 节点
pub struct AiNode;
@@ -20,90 +112,23 @@ impl Node for AiNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// ── provider 配置(必填)──
let base_url = ctx
.config
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
.to_string();
let p = parse_params(&ctx.config, &ctx.inputs)?;
let api_key = ctx
.config
.get("api_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
.to_string();
// ── prompt必填优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = ctx
.inputs
.get("prompt")
.and_then(|o| o.data.as_str())
.map(|s| s.to_string())
.or_else(|| {
ctx.config
.get("prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: promptconfig 或上游输入均无)"))?;
// ── 可选参数 ──
let model = ctx
.config
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let temperature = ctx
.config
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let max_tokens = ctx
.config
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let system_prompt = ctx
.config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 协议类型:默认 openai_compat可设 anthropicGLM 订阅端点 / Claude 官方)
let protocol = ctx
.config
.get("protocol")
.and_then(|v| v.as_str())
.unwrap_or("openai_compat")
.to_string();
// default_model留空时给一个占位避免 provider 构造 panic
let default_model = if model.is_empty() {
"gpt-4o-mini".to_string()
} else {
model.clone()
};
let provider: Box<dyn LlmProvider> = match protocol.as_str() {
"anthropic" => Box::new(AnthropicCompatProvider::new(&base_url, &api_key, &default_model)),
_ => Box::new(OpenAICompatProvider::new(&base_url, &api_key, &default_model)),
};
let provider: Box<dyn LlmProvider> =
df_ai::build_provider(&p.protocol, &p.base_url, &p.api_key, &p.default_model);
// ── 构建消息 ──
let mut messages = Vec::with_capacity(2);
if let Some(sys) = system_prompt {
if let Some(sys) = p.system_prompt {
messages.push(ChatMessage::system(sys));
}
messages.push(ChatMessage::user(prompt));
messages.push(ChatMessage::user(p.prompt));
let request = CompletionRequest {
model: model.clone(),
model: p.model.clone(),
messages,
temperature,
max_tokens,
temperature: p.temperature,
max_tokens: p.max_tokens,
stream: false,
tools: None,
tool_choice: None,
@@ -111,8 +136,8 @@ impl Node for AiNode {
tracing::info!(
"AiNode 调用 LLM: model={}, base_url={}",
default_model,
base_url
p.default_model,
p.base_url
);
let response = provider.complete(request).await?;
@@ -148,7 +173,7 @@ impl Node for AiNode {
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
"max_tokens": { "type": "integer", "description": "最大生成 token可选anthropic 协议无值时默认 4096" }
},
"required": ["prompt", "base_url", "api_key"]
"required": ["base_url", "api_key"]
}),
output: serde_json::json!({
"type": "object",
@@ -165,3 +190,137 @@ impl Node for AiNode {
"ai"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 造带基础三字段(base_url/api_key/prompt)的 config,overrides 覆盖或追加
fn config_with(overrides: serde_json::Value) -> serde_json::Value {
let mut base = json!({
"base_url": "https://api.example.com",
"api_key": "sk-test",
"prompt": "config-prompt"
});
if let (serde_json::Value::Object(b), serde_json::Value::Object(o)) = (&mut base, overrides) {
for (k, v) in o {
b.insert(k, v);
}
}
base
}
fn empty_inputs() -> HashMap<String, NodeOutput> {
HashMap::new()
}
#[test]
fn missing_base_url_errors() {
let config = json!({ "api_key": "k", "prompt": "p" });
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
assert!(err.contains("base_url"), "缺 base_url 应报错, 实际: {}", err);
}
#[test]
fn missing_api_key_errors() {
let config = json!({ "base_url": "http://x", "prompt": "p" });
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
assert!(err.contains("api_key"), "缺 api_key 应报错, 实际: {}", err);
}
#[test]
fn missing_prompt_errors() {
let config = json!({ "base_url": "http://x", "api_key": "k" });
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
assert!(err.contains("prompt"), "缺 prompt 应报错, 实际: {}", err);
}
#[test]
fn prompt_from_config_when_no_upstream() {
let p = parse_params(&config_with(json!({})), &empty_inputs()).unwrap();
assert_eq!(p.prompt, "config-prompt");
}
#[test]
fn prompt_prefers_upstream_input_over_config() {
let mut inputs = empty_inputs();
inputs.insert(
"prompt".to_string(),
NodeOutput::from_value(json!("upstream-prompt")),
);
let p = parse_params(&config_with(json!({})), &inputs).unwrap();
assert_eq!(p.prompt, "upstream-prompt", "上游输入应优先于 config.prompt");
}
#[test]
fn defaults_when_optional_fields_missing() {
let p = parse_params(&config_with(json!({})), &empty_inputs()).unwrap();
assert_eq!(p.model, "");
assert_eq!(p.default_model, "gpt-4o-mini", "model 空时 default_model 兜底");
assert_eq!(p.protocol, "openai_compat", "protocol 默认 openai_compat");
assert_eq!(p.temperature, None);
assert_eq!(p.max_tokens, None);
assert_eq!(p.system_prompt, None);
}
#[test]
fn optional_fields_parsed_when_present() {
let p = parse_params(
&config_with(json!({
"model": "glm-4",
"protocol": "anthropic",
"temperature": 0.3,
"max_tokens": 1024,
"system_prompt": "你是助手"
})),
&empty_inputs(),
)
.unwrap();
assert_eq!(p.model, "glm-4");
assert_eq!(p.default_model, "glm-4", "model 非空时 default_model = model");
assert_eq!(p.protocol, "anthropic");
assert_eq!(p.temperature, Some(0.3));
assert_eq!(p.max_tokens, Some(1024));
assert_eq!(p.system_prompt.as_deref(), Some("你是助手"));
}
/// 真调 GLM 验证 provider 调用层parse_params 已由上方单测覆盖,此处补 complete 端到端)
///
/// `#[ignore]`:需真实 GLM 配置env var默认不跑。
/// 跑法:`GLM_BASE_URL=... GLM_API_KEY=... GLM_MODEL=glm-4-flash \
/// cargo test -p df-nodes --lib glm_live_complete -- --ignored --nocapture`
/// env var 缺失 → 跳过(非失败)。
#[ignore = "需真实 GLM 配置(env var)"]
#[tokio::test]
async fn glm_live_complete() {
let base_url = std::env::var("GLM_BASE_URL").ok().filter(|s| !s.is_empty());
let api_key = std::env::var("GLM_API_KEY").ok().filter(|s| !s.is_empty());
let (base_url, api_key) = match (base_url, api_key) {
(Some(b), Some(k)) => (b, k),
_ => {
eprintln!("跳过: 未设 GLM_BASE_URL / GLM_API_KEY env var");
return;
}
};
let model = std::env::var("GLM_MODEL").unwrap_or_else(|_| "glm-4-flash".to_string());
let provider: Box<dyn LlmProvider> =
df_ai::build_provider("openai_compat", &base_url, &api_key, &model);
let request = CompletionRequest {
model: model.clone(),
messages: vec![ChatMessage::user("只回复两个字:通过")],
temperature: Some(0.0),
max_tokens: Some(16),
stream: false,
tools: None,
tool_choice: None,
};
let response = provider.complete(request).await.expect("GLM 调用失败");
assert!(!response.text.is_empty(), "GLM 返回空文本");
println!(
"GLM 响应: model={}, text={}, usage={:?}",
response.model, response.text, response.usage
);
}
}

View File

@@ -1,41 +0,0 @@
//! Docker 节点 — 在容器中执行任务
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Docker 节点
pub struct DockerNode;
#[async_trait]
impl Node for DockerNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 接入 df-execute 的 Docker 执行器
tracing::info!("DockerNode 执行: 在容器中运行");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string" },
"command": { "type": "string" },
"env": { "type": "object" }
},
"required": ["image"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"stdout": { "type": "string" },
"exit_code": { "type": "integer" }
}
}),
}
}
fn node_type(&self) -> &str {
"docker"
}
}

View File

@@ -1,41 +0,0 @@
//! Git 节点 — 执行 Git 操作(克隆、提交、推送、合并等)
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Git 节点
pub struct GitNode;
#[async_trait]
impl Node for GitNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 接入 df-execute 的 Git 操作
tracing::info!("GitNode 执行: Git 操作");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["clone", "commit", "push", "merge", "checkout"] },
"repo": { "type": "string" },
"branch": { "type": "string" }
},
"required": ["action"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"success": { "type": "boolean" },
"message": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"git"
}
}

View File

@@ -1,43 +0,0 @@
//! HTTP 节点 — 发起 HTTP 请求
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// HTTP 节点
pub struct HttpNode;
#[async_trait]
impl Node for HttpNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现HTTP请求逻辑
tracing::info!("HttpNode 执行: 发送 HTTP 请求");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] },
"url": { "type": "string" },
"headers": { "type": "object" },
"body": {}
},
"required": ["method", "url"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"status": { "type": "integer" },
"body": {},
"headers": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"http"
}
}

View File

@@ -1,10 +1,5 @@
//! df-nodes: 内置节点集合 — AI、脚本、Docker、Git、人工审批、HTTP、子流程、通知
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
pub mod ai_node;
pub mod docker_node;
pub mod git_node;
pub mod http_node;
pub mod human_node;
pub mod notify_node;
pub mod script_node;
pub mod subflow_node;

View File

@@ -1,41 +0,0 @@
//! 通知节点 — 发送通知(邮件、飞书、钉钉等)
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 通知节点
pub struct NotifyNode;
#[async_trait]
impl Node for NotifyNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现通知发送逻辑
tracing::info!("NotifyNode 执行: 发送通知");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"channel": { "type": "string", "enum": ["email", "feishu", "dingtalk", "webhook"] },
"to": { "type": "string" },
"title": { "type": "string" },
"body": { "type": "string" }
},
"required": ["channel", "to", "body"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"success": { "type": "boolean" }
}
}),
}
}
fn node_type(&self) -> &str {
"notify"
}
}

View File

@@ -1,39 +0,0 @@
//! 子流程节点 — 嵌套执行另一个工作流
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 子流程节点
pub struct SubflowNode;
#[async_trait]
impl Node for SubflowNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
// TODO: 实现子工作流加载与执行
tracing::info!("SubflowNode 执行: 启动子工作流");
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"workflow_id": { "type": "string" },
"inputs": { "type": "object" }
},
"required": ["workflow_id"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"outputs": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"subflow"
}
}