优化: miniapp渲染(图片/checkbox/代码高亮) + MCP规则共享下沉 + 文档重写 + 节点风格对齐

This commit is contained in:
lxy
2026-08-08 21:39:30 +08:00
parent 9eb2995a74
commit e7228231d1
11 changed files with 587 additions and 167 deletions
+190 -12
View File
@@ -157,7 +157,7 @@ devflow/
├── crates/ ├── crates/
│ ├── df-types/ # 核心类型、错误、常量、事件、augmentation │ ├── df-types/ # 核心类型、错误、常量、事件、augmentation
│ ├── df-workflow/ # 工作流 DAG 引擎 (核心) │ ├── df-workflow/ # 工作流 DAG 引擎 (核心)
│ ├── df-nodes/ # 内置节点集合 (AI/Script/Human) │ ├── df-nodes/ # 内置节点集合 (AI/Script/Human/Git/HTTP/Notify/Subflow/Docker/自审/推进)
│ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator) │ ├── df-ai/ # AI 编排层 (Multi-Provider/Coordinator)
│ ├── df-execute/ # 执行运行时 (Shell) │ ├── df-execute/ # 执行运行时 (Shell)
│ ├── df-storage/ # 存储层 (SQLite) │ ├── df-storage/ # 存储层 (SQLite)
@@ -330,23 +330,25 @@ pub struct Decision {
| 节点 | 功能 | 状态 | | 节点 | 功能 | 状态 |
|------|------|------| |------|------|------|
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 | | AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 |
| ScriptNode | Shell/脚本执行 | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 |
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 | | GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 | | HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 | | NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 | | SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 | | DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
> `crates/df-nodes/src/` 含 ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。 > `crates/df-nodes/src/` 含 10 个 `impl Node`(ai/ai_self_review/script/human/task_advance/git/http/notify/subflow/docker,全部已实现)。
## 六、数据模型 ## 六、数据模型
### SQLite 表结构 ### SQLite 表结构
> 真相源:`crates/df-storage/src/migrations.rs`V1-V13,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。 > 真相源:`crates/df-storage/src/migrations.rs`V1-V41,幂等迁移)。下表对齐真实 schema,字段/类型/默认值/外键与迁移文件一致。Rust 结构体见 `crates/df-storage/src/models.rs`。
**13 张业务表**(另含内部表 `schema_version` 记录迁移版本)。 **24 张业务表**(另含内部表 `schema_version` 记录迁移版本)。
```sql ```sql
-- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores) -- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores)
@@ -362,6 +364,8 @@ CREATE TABLE ideas (
promoted_to TEXT, -- 晋升后的 project_id (V2) promoted_to TEXT, -- 晋升后的 project_id (V2)
ai_analysis TEXT, -- AI 分析结果 JSON (V2) ai_analysis TEXT, -- AI 分析结果 JSON (V2)
scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2) scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2)
related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24)
deleted_at TEXT, -- 软删回收站 (V28)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -380,18 +384,27 @@ CREATE TABLE projects (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch) -- 任务 (V1 建表 / V2 补 workflow_def_id + base_branch / V20 补 idea_id / V14 补 deleted_at /
-- V15 补 review_rounds / V17 补 output_json / V29 补 queue+parent_id+content_json / V41 补 module_id)
CREATE TABLE tasks ( CREATE TABLE tasks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id), project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled status TEXT NOT NULL DEFAULT 'todo', -- todo/in_progress/in_review/testing/done/blocked/cancelled
priority INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 2, -- 0=critical..3=low,默认 2=medium
branch_name TEXT, branch_name TEXT,
assignee TEXT, assignee TEXT,
workflow_def_id TEXT, -- 关联的工作流定义 ID (V2) workflow_def_id TEXT, -- 关联的工作流定义 ID (V2)
base_branch TEXT, -- 基础分支 (V2) base_branch TEXT, -- 基础分支 (V2)
idea_id TEXT REFERENCES ideas(id), -- 关联灵感 (V20)
review_rounds INTEGER NOT NULL DEFAULT 0, -- review 退回累计轮数 (V15)
output_json TEXT, -- AiNode 自审闭环产出 (V17)
queue TEXT NOT NULL DEFAULT 'todo', -- 管理池 backlog/todo/decision/active/done (V29)
parent_id TEXT REFERENCES tasks(id), -- 父任务(限 1 级嵌套) (V29)
content_json TEXT, -- 结构化需求规格 JSON (V29)
module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL, -- 关联工程 (V41)
deleted_at TEXT, -- 软删回收站 (V14)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -432,7 +445,8 @@ CREATE TABLE workflow_executions (
project_id TEXT, -- 关联项目 ID (V2) project_id TEXT, -- 关联项目 ID (V2)
task_id TEXT, -- 关联任务 ID (V2) task_id TEXT, -- 关联任务 ID (V2)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
completed_at TEXT completed_at TEXT,
updated_at TEXT -- 更新时间戳,排序/增量同步 (V33)
); );
-- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照 -- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照
@@ -449,7 +463,7 @@ CREATE TABLE node_executions (
completed_at TEXT completed_at TEXT
); );
-- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象 -- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight)
CREATE TABLE ai_providers ( CREATE TABLE ai_providers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -460,29 +474,38 @@ CREATE TABLE ai_providers (
models TEXT, -- JSON array of model names models TEXT, -- JSON array of model names
is_default INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0,
config TEXT, -- JSON extra config config TEXT, -- JSON extra config
model_configs TEXT, -- 模型能力配置 JSON (V18)
enabled INTEGER NOT NULL DEFAULT 1, -- 负载均衡池开关 (V19)
weight INTEGER NOT NULL DEFAULT 50, -- 负载均衡权重 (V19)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models) -- AI 对话 (V3 建表 / V4 补 archived / V5 补 prompt_tokens + completion_tokens / V6 补 models /
-- V16 补 pinned / V32 补 pinned_goals / V33 补 pending_approvals)
CREATE TABLE ai_conversations ( CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT, title TEXT,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage messages TEXT NOT NULL DEFAULT '[]', -- JSON array of ChatMessage (V21 起消息拆分到 ai_messages)
provider_id TEXT, provider_id TEXT,
model TEXT, model TEXT,
models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6) models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6)
archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4) archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4)
pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16)
prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5) prompt_tokens INTEGER, -- 输入 token 累计 (流式 usage 落库, V5)
completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5) completion_tokens INTEGER, -- 输出 token 累计 (流式 usage 落库, V5)
pinned_goals TEXT DEFAULT '[]', -- 目标钉扎持久化 (V32)
pending_approvals TEXT DEFAULT '{}', -- 挂起审批快照,重启恢复 (V33)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 工具执行审计 (V9 建表) — Agent 工具调用的审批/执行留痕 -- AI 工具执行审计 (V9 建表 / V21 补 message_id / V27 状态统一 executed→completed / V36 补 subtask_id)
-- Agent 工具调用的审批/执行留痕
CREATE TABLE ai_tool_executions ( CREATE TABLE ai_tool_executions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
conversation_id TEXT, conversation_id TEXT,
message_id TEXT, -- 关联消息 (V21)
tool_call_id TEXT NOT NULL, tool_call_id TEXT NOT NULL,
tool_name TEXT NOT NULL, tool_name TEXT NOT NULL,
arguments TEXT NOT NULL, arguments TEXT NOT NULL,
@@ -491,7 +514,8 @@ CREATE TABLE ai_tool_executions (
risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high risk_level TEXT NOT NULL DEFAULT 'medium', -- low/medium/high
requested_at TEXT NOT NULL, requested_at TEXT NOT NULL,
executed_at TEXT, executed_at TEXT,
decided_by TEXT -- human/auto decided_by TEXT, -- human/auto
subtask_id TEXT -- 归属子任务 (V36)
); );
-- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层 -- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层
@@ -510,6 +534,7 @@ CREATE TABLE knowledges (
source_ref TEXT, -- 来源实体引用(如 conv:{id}) source_ref TEXT, -- 来源实体引用(如 conv:{id})
reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10) reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10)
embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8) embedding BLOB, -- Vec<f32> 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8)
embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23)
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -531,6 +556,159 @@ CREATE TABLE app_settings (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- AI 消息 (V9 新库建表 / V21 老库补建 / V36 补 subtask_id / V38 补 token 列 / V39 补 cache/reason 列)
-- V21 起 ChatMessage 逐条拆行存储,支撑消息级 token / 分页 / 溯源
CREATE TABLE ai_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
parts TEXT,
tool_call_id TEXT,
tool_calls TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'active',
reasoning_content TEXT,
timestamp INTEGER,
subtask_id TEXT, -- 归属子任务 (V36)
created_at TEXT NOT NULL,
prompt_tokens INTEGER, -- 消息级输入 token (V38)
completion_tokens INTEGER, -- 消息级输出 token (V38)
prompt_cache_hit_tokens INTEGER, -- 缓存命中 token (V39)
prompt_cache_miss_tokens INTEGER, -- 缓存未命中 token (V39)
reasoning_tokens INTEGER, -- 思考 token (V39)
UNIQUE(conversation_id, seq)
);
-- 灵感评估历史 (V22 建表 / V25 补唯一约束) — 追加型审计,version 单调递增
CREATE TABLE idea_evaluations (
id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL,
version INTEGER NOT NULL,
ai_analysis TEXT,
scores TEXT,
score REAL,
evaluated_by TEXT,
evaluated_at TEXT NOT NULL,
UNIQUE(idea_id, version) -- 评估版本并发重复兜底 (V25)
);
-- 任务横向关联 (V29 建表) — 任务间 depends_on/blocks/relates_to,循环检测走应用层
CREATE TABLE task_links (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES tasks(id),
target_id TEXT NOT NULL REFERENCES tasks(id),
link_type TEXT NOT NULL,
remark TEXT,
created_at TEXT NOT NULL
);
-- 项目事件流 (V30 建表) — 跨实体统一事件,追加型审计(只增不改)
CREATE TABLE project_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
from_state TEXT,
to_state TEXT,
context_json TEXT,
source TEXT, -- ai/human/system
conversation_id TEXT,
created_at TEXT NOT NULL
);
-- 项目基础设施配置 (V31 建表) — 记录"项目用了什么基础设施",不存敏感凭证
CREATE TABLE project_services (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
service_type TEXT NOT NULL, -- mysql/postgresql/sqlite/redis/mongodb/mq/api/other
endpoint TEXT,
config_json TEXT,
environment TEXT NOT NULL DEFAULT 'development',
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程 (V34 建表 / V40 补 description + status) — 项目下多工程(Monorepo/微服务/前后端分离)
CREATE TABLE project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
git_url TEXT,
stack TEXT,
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT, -- 工程职责描述 (V40)
status TEXT, -- active/archived (V40)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 工程依赖 (V35 建表) — 工程间依赖边,支撑依赖图
CREATE TABLE module_dependencies (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
from_module_id TEXT NOT NULL REFERENCES project_modules(id),
to_module_id TEXT NOT NULL REFERENCES project_modules(id),
dep_type TEXT NOT NULL DEFAULT 'library', -- library/api/mq/shared/custom
label TEXT,
created_at TEXT NOT NULL
);
-- 多 Agent 并行执行 (V36 建表) — ai_plans/ai_subtasks/ai_conflicts 三表 + ai_messages/ai_tool_executions 补 subtask_id
CREATE TABLE ai_plans (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
user_message_id TEXT,
status TEXT NOT NULL DEFAULT 'planning',
subtask_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_subtasks (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
persona_id TEXT,
intent TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
layer INTEGER NOT NULL DEFAULT 0,
deps TEXT,
branch TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE ai_conflicts (
id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
file_path TEXT NOT NULL DEFAULT '',
conflict_type TEXT NOT NULL DEFAULT 'file',
subtask_a TEXT,
subtask_b TEXT,
diff_a TEXT,
diff_b TEXT,
resolution TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
created_at TEXT NOT NULL,
resolved_at TEXT
);
-- 对话版本化快照 (V37 建表)
CREATE TABLE conversation_checkpoints (
id TEXT PRIMARY KEY,
conv_id TEXT NOT NULL,
snapshot TEXT NOT NULL,
token_total INTEGER NOT NULL,
label TEXT,
created_at TEXT NOT NULL
);
-- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录 -- 内部表: schema_version (version INTEGER PRIMARY KEY) — 迁移版本记录
``` ```
+10 -25
View File
@@ -12,6 +12,7 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
@@ -4715,7 +4716,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4729,7 +4729,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4743,7 +4742,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4757,7 +4755,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4771,7 +4768,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4785,7 +4781,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4799,7 +4794,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4813,7 +4807,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4827,7 +4820,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4841,7 +4833,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4855,7 +4846,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4869,7 +4859,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4883,7 +4872,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4897,7 +4885,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4911,7 +4898,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4925,7 +4911,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4939,7 +4924,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4953,7 +4937,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4967,7 +4950,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4981,7 +4963,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4995,7 +4976,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5009,7 +4989,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5023,7 +5002,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5037,7 +5015,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5051,7 +5028,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -8052,6 +8028,15 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001",
"highlight.js": "^11.11.1",
"marked": "^18.0.5", "marked": "^18.0.5",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.0.0" "vue-i18n": "^9.0.0"
+139 -5
View File
@@ -1,5 +1,5 @@
/** /**
* marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G) * marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。
* *
* 为什么后处理而非 marked 自定义 renderer: * 为什么后处理而非 marked 自定义 renderer:
* - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。 * - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。
@@ -11,8 +11,88 @@
* 跳转需第三方 mp-html(本次不做,记后续)。 * 跳转需第三方 mp-html(本次不做,记后续)。
* - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠 * - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠
* white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。 * white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。
* - <img> 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示
* (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。
* - <input> 不在 rich-text 节点白名单 → GFM 任务列表勾选框被剥离成纯文本,见下替换。
*/ */
// 语法高亮:highlight.js core + 按需注册常用语言(包体积敏感,不引全量)。
// 无 DOM 不可用 highlightElement,hljs 产出的 token class 由 inlineHljs 转 inline style。
import hljs from 'highlight.js/lib/core'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import markdown from 'highlight.js/lib/languages/markdown'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import bash from 'highlight.js/lib/languages/bash'
import sql from 'highlight.js/lib/languages/sql'
import yaml from 'highlight.js/lib/languages/yaml'
import diff from 'highlight.js/lib/languages/diff'
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('css', css)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('rust', rust)
hljs.registerLanguage('go', go)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('diff', diff)
// 围栏 info 常见别名 → 已注册语言(hljs.getLanguage 自带部分别名,这里补齐业务常用)
const LANG_ALIASES: Record<string, string> = {
js: 'javascript', ts: 'typescript', py: 'python',
sh: 'bash', shell: 'bash', yml: 'yaml', md: 'markdown',
html: 'xml', vue: 'xml', svg: 'xml',
}
/** hljs token class → inline style(rich-text 认 style 不认 class;色板对齐桌面 github-dark) */
const TOKEN_STYLE: Record<string, string> = {
'hljs-keyword': 'color:#ff7b72',
'hljs-doctag': 'color:#ff7b72',
'hljs-meta': 'color:#ff7b72',
'hljs-type': 'color:#ff7b72',
'hljs-template-tag': 'color:#ff7b72',
'hljs-template-variable': 'color:#ff7b72',
'hljs-title': 'color:#d2a8ff',
'hljs-attr': 'color:#79c0ff',
'hljs-attribute': 'color:#79c0ff',
'hljs-literal': 'color:#79c0ff',
'hljs-number': 'color:#79c0ff',
'hljs-operator': 'color:#79c0ff',
'hljs-variable': 'color:#79c0ff',
'hljs-selector-attr': 'color:#79c0ff',
'hljs-selector-class': 'color:#79c0ff',
'hljs-selector-id': 'color:#79c0ff',
'hljs-regexp': 'color:#a5d6ff',
'hljs-string': 'color:#a5d6ff',
'hljs-built_in': 'color:#ffa657',
'hljs-symbol': 'color:#ffa657',
'hljs-comment': 'color:#8b949e',
'hljs-code': 'color:#8b949e',
'hljs-formula': 'color:#8b949e',
'hljs-name': 'color:#7ee787',
'hljs-quote': 'color:#7ee787',
'hljs-selector-tag': 'color:#7ee787',
'hljs-selector-pseudo': 'color:#7ee787',
'hljs-section': 'color:#1f6feb;font-weight:bold',
'hljs-bullet': 'color:#f2cc60',
'hljs-addition': 'color:#aff5b4;background-color:#033a16',
'hljs-deletion': 'color:#ffdcd7;background-color:#67060c',
'hljs-emphasis': 'font-style:italic',
'hljs-strong': 'font-weight:bold',
}
/** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */ /** 给指定标签注入 inline style(已有 style 则合并,无则新增)。 */
function styleTag(html: string, tag: string, style: string): string { function styleTag(html: string, tag: string, style: string): string {
const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g') const re = new RegExp(`<${tag}(\\s[^>]*)?>`, 'g')
@@ -28,22 +108,73 @@ function styleTag(html: string, tag: string, style: string): string {
}) })
} }
/** 反转 marked 对代码块内容的 HTML 转义,还原原始代码文本供 hljs 高亮 */
function unescapeCode(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */
function inlineHljs(html: string): string {
return html.replace(/<span class="([^"]+)">/g, (_m, cls: string) => {
const style = cls
.split(/\s+/)
.map((c) => TOKEN_STYLE[c])
.filter(Boolean)
.join(';')
return style ? `<span style="${style}">` : '<span>'
})
}
/**
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
*/
function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : ''
const lang = LANG_ALIASES[langRaw] ?? langRaw
if (!lang || !hljs.getLanguage(lang)) return m
let value: string
try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} catch {
return m
}
return `<pre><code${attrs}>${inlineHljs(value)}</code></pre>`
})
}
/** /**
* 对 marked.parse 产出的 HTML 注入 inline style。 * 对 marked.parse 产出的 HTML 注入 inline style。
* *
* 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code * 设计取舍:pre(块代码)给深色背景;<code> 不给背景(透明),仅等宽 + 橙色 —— 避免块代码内层 code
* 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读) * 与 pre 背景冲突的双层色块(块代码内 code 透明继承 pre 深底,内联 code 橙色等宽在消息底上也可读);
* 块代码经 hljs 高亮后,关键字/字符串等 token 由内联色覆盖,未命中 token 的文本保留 code 基色。
* *
* 正文颜色(P1-1 真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text` * 正文颜色(真机确认):rich-text 只认节点 inline style,不继承页面 CSS `.msg text`
* (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th * (模板的 `.msg text{color:#e0e0e0}` 仅命中 `<text>` 子元素)。marked 产出的 p/h1-6/li/td/th
* 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。 * 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。
* strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。 * strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。
*/ */
export function styleMarkdown(html: string): string { export function styleMarkdown(html: string): string {
let out = html let out = html
// 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选)
out = out.replace(/<input[^>]*type="checkbox"[^>]*>/gi, (m) => {
const checked = /checked/i.test(m)
const color = checked ? '#6bd99a' : '#666666'
return `<span style="color:${color};margin-right:4px">${checked ? '☑' : '☐'}</span>`
})
// 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式)
out = highlightCodeBlocks(out)
// 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本) // 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本)
// word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的 // word-break:break-all 防长 URL/长单词撑开气泡(rich-text 内文本不继承外层 .msg 的
// word-break,须 inline 注入;对齐 mdRenderer 文件头"rich-text 只认 inline style")。 // word-break,须 inline 注入;对齐文件头"rich-text 只认 inline style")。
const BODY_STYLE = 'color:#e0e0e0;word-break:break-all' const BODY_STYLE = 'color:#e0e0e0;word-break:break-all'
for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) { for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) {
out = styleTag(out, tag, BODY_STYLE) out = styleTag(out, tag, BODY_STYLE)
@@ -54,7 +185,7 @@ export function styleMarkdown(html: string): string {
'pre', 'pre',
'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0', 'display:block;background-color:#1a1a1a;color:#d4d4d4;padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-all;font-family:monospace;font-size:12px;margin:6px 0',
) )
// code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块) // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖)
out = styleTag( out = styleTag(
out, out,
'code', 'code',
@@ -62,6 +193,9 @@ export function styleMarkdown(html: string): string {
) )
// 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开 // 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开
out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all') out = styleTag(out, 'a', 'color:#4a9eff;text-decoration:underline;word-break:break-all')
// 图片:max-width 防超宽撑爆气泡;block 去基线间隙;圆角与气泡一致。
// 网络图显示依赖微信「downloadFile 合法域名」白名单(见文件头),非渲染问题。
out = styleTag(out, 'img', 'max-width:100%;display:block;border-radius:6px')
// 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、 // 表格:显式 table 布局 + 等宽字列。注意不能用 display:block(会失去表格布局语义、
// 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向 // 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向
// 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡; // 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡;
+41 -28
View File
@@ -110,13 +110,13 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory), spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ─── // ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks), spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title"]), Medium, create_task), spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 idea_id 关联灵感、queue 管理池(默认 todo,新建仅 backlog/todo/decision)、parent_id 父任务 ID(限 1 级嵌套,父任务自身不能是子任务)、content_json 结构化需求规格(须合法 JSON))", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 2=medium,值域 0..=3)"), "idea_id": opt_str_field("关联灵感 ID(可空)"), "queue": opt_str_field("管理池(可空,默认 todo,新建仅 backlog/todo/decision)"), "parent_id": opt_str_field("父任务 ID(可空,限 1 级嵌套)"), "content_json": opt_str_field("结构化需求规格 JSON(可空,须合法 JSON)")}), &["project_id", "title"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task), spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task), spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task), spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ─── // ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas), spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title"]), Medium, create_idea), spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 1)")}), &["title"]), Medium, create_idea),
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea), spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea),
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea), spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea), spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
@@ -572,31 +572,45 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 0);
medium_audit("create_task", &format!("{project_id}/{title}")); medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move { Box::pin(async move {
// parent_id 可选(arg_str_or 给 "" 哨兵,空串视为 None)。非空时校验 1 级嵌套铁律:
// 父任务存在 + 父任务自身无 parent_id(防孙任务),违反返回明确错误(与 IPC create_task 同规则)。
let parent_id_raw = arg_str_or(&args, "parent_id", "");
let parent_id = if parent_id_raw.trim().is_empty() {
None
} else {
let pid = parent_id_raw.trim();
let repo = TaskRepo::new(&db); let repo = TaskRepo::new(&db);
match repo.get_by_id(pid).await {
Ok(Some(parent)) => { // 共享校验(与 GUI/IPC 同源,一处修改全局生效):queue / parent_id / content_json / priority 值域
if parent.parent_id.is_some() { let queue = match df_storage::crud::normalize_queue_for_create(
return CallToolResult::error(format!( args.get("queue").and_then(|v| v.as_str()),
"父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}", ) {
parent.parent_id Ok(q) => q,
)); Err(e) => return CallToolResult::error(e),
}
Some(pid.to_string())
}
Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")),
Err(e) => return err_str(e),
}
}; };
let parent_id = match df_storage::crud::validate_parent_id(
&repo,
args.get("parent_id").and_then(|v| v.as_str()),
)
.await
{
Ok(pid) => pid,
Err(e) => return CallToolResult::error(e),
};
let content_json = match df_storage::crud::validate_content_json(
args.get("content_json").and_then(|v| v.as_str()),
) {
Ok(c) => c,
Err(e) => return CallToolResult::error(e),
};
let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()),
) {
Ok(p) => p,
Err(e) => return CallToolResult::error(e),
};
// 可选关联灵感(空串/缺省视为不关联,与 GUI create_task 同语义)
let idea_id = args
.get("idea_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from);
let now = now_millis(); let now = now_millis();
let rec = TaskRecord { let rec = TaskRecord {
id: new_id(), id: new_id(),
@@ -611,15 +625,14 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id,
module_id: None, module_id: None,
queue: "todo".to_string(), queue,
parent_id, parent_id,
content_json: None, content_json,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
let repo = TaskRepo::new(&db);
match repo.insert(rec).await { match repo.insert(rec).await {
Ok(id) => { Ok(id) => {
let created = repo.get_by_id(&id).await.ok().flatten(); let created = repo.get_by_id(&id).await.ok().flatten();
@@ -766,7 +779,7 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 0); let priority = arg_int_or(&args, "priority", 1);
medium_audit("create_idea", &title); medium_audit("create_idea", &title);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
+8 -16
View File
@@ -56,9 +56,7 @@ impl Node for ScriptNode {
shell_type: Default::default(), shell_type: Default::default(),
}; };
// 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。 // 命令执行安全:白/黑名单校验(运行时配置优先,回退环境变量/默认黑名单)。
// - 白名单非空时:命令首词不在白名单 → 直接拒绝执行
// - 黑名单匹配时:直接拒绝执行
// 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。 // 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。
let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase(); let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase();
if let Some(denied) = check_command_policy(&cmd_name) { if let Some(denied) = check_command_policy(&cmd_name) {
@@ -70,7 +68,7 @@ impl Node for ScriptNode {
anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command); anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command);
} }
// 危险命令告警:匹配已知危险关键词,仅告警不阻止执行 // 危险关键词告警:匹配到仅告警不阻止执行(与硬拒绝的白/黑名单正交)
let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"]; let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"];
for &kw in &dangerous_keywords { for &kw in &dangerous_keywords {
if command.contains(kw) { if command.contains(kw) {
@@ -143,36 +141,30 @@ const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs",
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。 /// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
/// ///
/// 优先级(高→低): /// 优先级(高→低):运行时配置(前端设置页写入) > 环境变量 > 默认黑名单兜底。
/// 1. 运行时配置(set_script_safety_config,前端设置页写入) /// 黑名单优先于白名单(命中黑名单总是拒绝,即便同时在白名单);命令名比较前 trim + 小写。
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
///
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
/// ///
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。 /// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
fn check_command_policy(cmd_name: &str) -> Option<&'static str> { fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高) // 运行时配置优先(前端设置页写入,优先级最高)
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() { if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) { if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令在黑名单中(运行时配置)"); return Some("命令在黑名单中(运行时配置)");
} }
} }
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查 // 运行时白名单非空时,命令须在名单内(命中即放行,跳过环境变量)
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() { if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) { if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
return Some("命令不在白名单中(运行时配置)"); return Some("命令不在白名单中(运行时配置)");
} }
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
if !list.is_empty() { if !list.is_empty() {
return None; return None;
} }
} }
// 步骤3:回退环境变量(兼容旧部署) // 回退环境变量(兼容旧部署);黑名单缺省用默认黑名单兜底
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok(); let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
let blacklist: Vec<String> = match env_blacklist { let blacklist: Vec<String> = match env_blacklist {
Some(raw) => raw Some(raw) => raw
@@ -186,7 +178,7 @@ fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
return Some("命令在黑名单中"); return Some("命令在黑名单中");
} }
// 白名单:非空时命令名须在名单中才放行 // 环境变量白名单非空时,命令名须在名单
if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") { if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") {
let whitelist: Vec<String> = whitelist_raw let whitelist: Vec<String> = whitelist_raw
.split(',') .split(',')
+16 -7
View File
@@ -1,14 +1,14 @@
//! SubflowNode — 嵌套子工作流节点 //! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行
//! //!
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。 //! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。
use async_trait::async_trait; use async_trait::async_trait;
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// SubflowNode 配置 /// 子工作流配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct SubflowConfig { struct SubflowConfig {
/// 子工作流 DAG 定义(内联 JSON) /// 子工作流 DAG 定义(内联 JSON)
@@ -21,13 +21,13 @@ struct SubflowConfig {
/// 最大递归深度(安全阀) /// 最大递归深度(安全阀)
const MAX_DEPTH: u32 = 10; const MAX_DEPTH: u32 = 10;
/// SubflowNode — 嵌套子工作流 /// 子工作流节点
pub struct SubflowNode; pub struct SubflowNode;
#[async_trait] #[async_trait]
impl Node for SubflowNode { impl Node for SubflowNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult { async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("SubflowNode 执行: {}", ctx.node_id); tracing::info!("SubflowNode 执行: node_id={}", ctx.node_id);
let config: SubflowConfig = serde_json::from_value(ctx.config.clone()) let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?; .map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
@@ -84,11 +84,20 @@ impl Node for SubflowNode {
}, },
"required": ["dag"] "required": ["dag"]
}), }),
output: serde_json::json!({}), output: serde_json::json!({
"type": "object",
"properties": {
"subflow": { "type": "boolean" },
"node_count": { "type": "integer" },
"edge_count": { "type": "integer" },
"max_depth": { "type": "integer" },
"dag": { "type": "object" }
}
}),
} }
} }
fn node_type(&self) -> &'static str { fn node_type(&self) -> &str {
"subflow" "subflow"
} }
} }
+3
View File
@@ -6,6 +6,7 @@
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column) //! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo //! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口) //! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
//! - [`mod@task_validation`]:任务创建参数校验(queue 白名单/parent_id 嵌套铁律/content_json),GUI 与 MCP 工具共享
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1) //! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2) //! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3) //! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
@@ -31,6 +32,7 @@ mod project_service_repo;
mod settings; mod settings;
mod task_link_repo; mod task_link_repo;
mod task_repo; mod task_repo;
mod task_validation;
pub use conversation_repo::*; pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
@@ -43,6 +45,7 @@ pub use project_service_repo::*;
pub use settings::*; pub use settings::*;
pub use task_link_repo::*; pub use task_link_repo::*;
pub use task_repo::*; pub use task_repo::*;
pub use task_validation::*;
pub use module_dependency_repo::*; pub use module_dependency_repo::*;
pub use plan_repo::*; pub use plan_repo::*;
@@ -0,0 +1,82 @@
//! 任务创建参数校验(共享层)
//!
//! GUI AI 工具(ai/tools/task.rs)与 MCP 工具(crates/df-mcp/src/tools.rs)曾各自内联
//! 一套 create_task 校验(queue 白名单 / parent_id 嵌套铁律 / content_json 合法性),
//! 演进易漂移。抽到本模块后两套工具同源复用,一处修改全局生效。
use crate::crud::task_repo::TaskRepo;
/// queue 合法值白名单(queue 是独立管理维度,与 status 执行维度正交)
pub const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
/// 默认优先级(medium)— 新任务默认中优先级,与 IPC create_task 一致
pub const DEFAULT_TASK_PRIORITY: i32 = 2;
/// 校验并归一化 create_task 的 queue:空串/缺省 → "todo";
/// 白名单校验 + create 一致性(新建仅 backlog/todo/decision,active/done 须经 move_task_queue 流转)。
pub fn normalize_queue_for_create(queue: Option<&str>) -> Result<String, String> {
match queue {
None | Some("") => Ok("todo".to_string()),
Some(q) => {
let q = q.trim();
if !TASK_QUEUE_VALUES.contains(&q) {
return Err(format!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES));
}
match q {
"backlog" | "todo" | "decision" => Ok(q.to_string()),
other => Err(format!(
"新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision",
other
)),
}
}
}
}
/// 校验 content_json(非空时须是合法 JSON),返回规范化值(None=未传)。
pub fn validate_content_json(content: Option<&str>) -> Result<Option<String>, String> {
match content {
Some(c) if !c.trim().is_empty() => {
serde_json::from_str::<serde_json::Value>(c)
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
Ok(Some(c.to_string()))
}
_ => Ok(None),
}
}
/// 校验 parent_id 1 级嵌套铁律:父任务存在 + 父任务自身不是子任务(防孙任务)。
/// 返回归一化 parent_id(空串/缺省 → None)。
pub async fn validate_parent_id(
repo: &TaskRepo,
parent_id: Option<&str>,
) -> Result<Option<String>, String> {
let pid = parent_id
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(pid) = &pid {
let parent = repo
.get_by_id(pid)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
if parent.parent_id.is_some() {
return Err(format!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
parent.parent_id
));
}
}
Ok(pid)
}
/// 校验并归一化 create_task 的 priority:缺省 → DEFAULT_TASK_PRIORITY;越界值拒绝(值域 0..=3)。
pub fn normalize_priority(priority: Option<i64>) -> Result<i32, String> {
match priority {
None => Ok(DEFAULT_TASK_PRIORITY),
Some(p) if (0..=3).contains(&p) => Ok(p as i32),
Some(p) => Err(format!(
"非法 priority 值 {p},合法值: 整数 0..=3(0=critical, 1=high, 2=medium, 3=low)"
)),
}
}
@@ -1,26 +1,33 @@
# df-nodes 节点集合 # df-nodes 节点集合
> 创建: 2026-06-10 | 最后更新: 2026-06-15 > 创建: 2026-06-10 | 最后更新: 2026-08-08
--- ---
## 概述 ## 概述
df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-workflow 的 `Node` trait df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-workflow 的 `Node` trait
(`execute` / `schema` / `node_type`),由 DAG Executor 按拓扑序驱动。
## 当前状态 ## 当前状态
3 种节点全部完整实现`execute()` 非 stub有真实逻辑 + 单测覆盖)。 10 个节点全部完整实现(`execute()` 非 stub,有真实逻辑 + 单测覆盖),另有 `task_state_machine`
(状态机)与 `task_workflow_templates`(流程模板)两个支撑模块。
## 节点清单 ## 节点清单
| 节点 | 功能 | 阻塞 | node_type | 实现状态 | | 节点 | 功能 | 阻塞 | node_type | 实现状态 |
|------|------|------|-----------|---------| |------|------|------|-----------|---------|
| AiNode | 调用 LLM 完成文本生成/分析非流式 complete | 否 | `ai` | ✅ 完整 | | AiNode | 调用 LLM 完成文本生成/分析(非流式 complete) | 否 | `ai` | ✅ 完整 |
| ScriptNode | Shell/脚本执行 | 否 | `script` | ✅ 完整 | | AiSelfReviewNode | AI 对任务产出自审(复用 AiNode 调用链,verdict 闸门) | 否 | `ai_self_review` | ✅ 完整 |
| HumanNode | 人工审批/确认(单选/多选) | | `human` | ✅ 完整 | | ScriptNode | Shell/脚本执行(白/黑名单安全策略) | | `script` | ✅ 完整 |
| HumanNode | 人工审批/确认(单选/多选) | 是 | `human` | ✅ 完整 |
> 历史文档曾列 8 节点(AI/Script/Docker/Git/Human/Notify/HTTP/Subflow)全标骨架。实际仅 AI/Script/Human 3 节点存在,其余 5 节点(Docker/Git/Notify/HTTP/Subflow)从未实现,已从本文档删除。 | TaskAdvanceNode | 任务状态推进链唯一 status 写入路径(状态机 + CAS) | 否 | `task_advance` | ✅ 完整 |
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | 否 | `git` | ✅ 完整 |
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | 否 | `http` | ✅ 完整 |
| NotifyNode | 通知(桌面日志 / webhook,尽力而为不阻断) | 否 | `notify` | ✅ 完整 |
| SubflowNode | 嵌套子工作流(返回子 DAG 快照,深度限制防递归) | 否 | `subflow` | ✅ 完整 |
| DockerNode | Docker 容器内执行命令(`docker run --rm`,环境检测+授权) | 否 | `docker` | ✅ 完整 |
## 节点详述 ## 节点详述
@@ -35,12 +42,18 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w
- **输出**`{ text, model, usage: {prompt_tokens, completion_tokens, total_tokens} }` - **输出**`{ text, model, usage: {prompt_tokens, completion_tokens, total_tokens} }`
- **与 AI Chat 区别**AiNode 由 DAG Executor 自动驱动(嵌入自动化链路),非交互对话。 - **与 AI Chat 区别**AiNode 由 DAG Executor 自动驱动(嵌入自动化链路),非交互对话。
### AiSelfReviewNodeai_self_review_node.rs
AI 自审闭环节点:对 AiNode 产出的任务内容做二次 AI 审查,返回 `verdict`(通过/打回)闸门。
复用 AiNode 的 provider 调用链(`ai_node_helpers`),差异在 prompt 模板与 JSON 解析。
### ScriptNodescript_node.rs ### ScriptNodescript_node.rs
执行 Shell 脚本或自定义命令,复用 `df_execute::shell::execute`(跨平台:Windows `cmd /C` / Unix `sh -c`)。 执行 Shell 脚本或自定义命令,复用 `df_execute::shell::execute`(跨平台:Windows `cmd /C` / Unix `sh -c`)。
- **必填**`command` - **必填**`command`
- **可选**`timeout_secs``working_dir` - **可选**`timeout_secs``working_dir`
- **安全策略**:命令名(首词)白/黑名单校验(运行时配置优先,回退环境变量 / 默认黑名单);危险关键词仅告警不阻止。
- **失败语义**:非零退出码视为执行失败(`bail!` 带 exit_code + stderr)。 - **失败语义**:非零退出码视为执行失败(`bail!` 带 exit_code + stderr)。
- **输出**`{ stdout, stderr, exit_code, duration_ms }` - **输出**`{ stdout, stderr, exit_code, duration_ms }`
@@ -49,7 +62,7 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w
阻塞节点,订阅事件总线 → 发 `HumanApprovalRequest``select!` 轮询 `HumanApprovalResponse` / 超时 / 取消。 阻塞节点,订阅事件总线 → 发 `HumanApprovalRequest``select!` 轮询 `HumanApprovalResponse` / 超时 / 取消。
- **执行顺序**:先 `subscribe()``send(Request)`(broadcast 不回放,反序会丢 Response 死等到超时);`send` 必须 `await`(否则 Future 不 poll、Request 不进 channel)。 - **执行顺序**:先 `subscribe()``send(Request)`(broadcast 不回放,反序会丢 Response 死等到超时);`send` 必须 `await`(否则 Future 不 poll、Request 不进 channel)。
- **审批模式**F-260615-01`select_type` 缺省 `Single`,非 `"multiple"` 一律按 Single 处理。 - **审批模式**`select_type` 缺省 `Single`,非 `"multiple"` 一律按 Single 处理。
- Single → 决策数必须 = 1 - Single → 决策数必须 = 1
- Multiple → 决策数必须 ≥ 1 - Multiple → 决策数必须 ≥ 1
- `options` 空 → 允许自由文本(仅受数量约束);非空 → 每项必须 ∈ options - `options` 空 → 允许自由文本(仅受数量约束);非空 → 每项必须 ∈ options
@@ -59,25 +72,66 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w
- **取消**:每 500ms tick 检查 `node_status.is_cancelled` - **取消**:每 500ms tick 检查 `node_status.is_cancelled`
- **输出**`{ decision(首项,向后兼容), decisions(数组), comment }` - **输出**`{ decision(首项,向后兼容), decisions(数组), comment }`
### TaskAdvanceNodetask_advance_node.rs
任务推进链的唯一 status 写入路径(与 IPC/AI 工具/MCP 同源):读当前 TaskRecord → 状态机
`can_transition` 校验 → 原子写(下沉 SQL 防 TOCTOU),退回转换自动累加 `review_rounds`
### GitNodegit_node.rs
通过 `df_execute::shell::execute` 调用本地 git CLI`working_dir` 指定仓库路径。
支持 branch / checkout / commit / merge / push / status / log,输出按 action 提取结构化字段。
### HTTPNodehttp_node.rs
使用 reqwest 发起 GET/POST/PUT/DELETE 请求。config 提供 `method/url/headers/body/timeout_secs`
输出 `status_code + body + content_type`
### NotifyNodenotify_node.rs
桌面(当前为日志占位,后续接 tauri-plugin-notification/ webhook 通知。语义:尽力而为,
通知失败不阻断工作流(输出 `success=false` 但仍 Ok 返回)。
### SubflowNodesubflow_node.rs
加载一个子 DAG 并在当前执行上下文中递归执行(复用通用流程,如「代码审查」作为子步骤)。
返回子 DAG 的 JSON 快照供 DagExecutor 消费展开,`max_depth` 限制嵌套深度防无限递归。
### DockerNodedocker_node.rs
通过 `docker run --rm` 一次性容器执行命令,复用 `df_execute::shell::execute` 调用本地 docker CLI。
含环境检测(本机是否有 docker)与授权校验。
## 文件结构 ## 文件结构
``` ```
crates/df-nodes/src/ crates/df-nodes/src/
├── lib.rs — 模块入口(声明 ai_node / human_node / script_node 三个 pub mod ├── lib.rs — 模块入口(声明 pub mod
├── ai_node.rs — AiNodeLLM 文本生成/分析) ├── ai_node.rs — AiNodeLLM 文本生成/分析)
├── ai_node_helpers.rs — AiNode 纯逻辑(provider 解析/参数构造,非 Node
├── ai_self_review_node.rs — AiSelfReviewNodeAI 自审)
├── script_node.rs — ScriptNodeShell 执行) ├── script_node.rs — ScriptNodeShell 执行)
── human_node.rs — HumanNode(人工审批/确认阻塞) ── human_node.rs — HumanNode(人工审批/确认,阻塞)
├── human_node_helpers.rs — HumanNode 纯逻辑(拒绝语义判定,非 Node)
├── task_advance_node.rs — TaskAdvanceNode(任务状态推进链)
├── task_state_machine.rs — 任务状态机(can_transition 等,非 Node
├── task_workflow_templates.rs — 流程模板(testing 等模板,非 Node
├── git_node.rs — GitNodeGit 操作)
├── http_node.rs — HttpNodeHTTP 请求)
├── notify_node.rs — NotifyNode(通知)
├── subflow_node.rs — SubflowNode(嵌套子工作流)
└── docker_node.rs — DockerNode(容器内执行)
``` ```
## 依赖关系 ## 依赖关系
``` ```
df-core df-workflow (Node trait / DagDef)
← df-workflow (Node trait)
← df-nodes ← df-nodes
← df-ai (AiNode: build_provider / LlmProvider) ← df-ai (AiNode/AiSelfReviewNode: build_provider / LlmProvider)
← df-execute (ScriptNode: shell::execute) ← df-execute (ScriptNode/GitNode/DockerNode: shell::execute)
← df-core (HumanNode: events::WorkflowEvent / SelectType) ← df-storage (TaskAdvanceNode: TaskRepo::advance_status_atomic)
← df-core / df-types (HumanNode: events::WorkflowEvent / SelectType)
``` ```
## 相关文档 ## 相关文档
+22 -53
View File
@@ -79,70 +79,39 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::TaskRepo::new(&db); let repo = df_storage::crud::TaskRepo::new(&db);
// ── 知识图谱 Phase 1:queue/parent_id/content_json 校验(对齐 commands::task::create_task IPC)── // 共享校验(与 MCP/IPC 同源,一处修改全局生效):queue 白名单+create 一致性 /
// queue:白名单 + 空串默认 todo。create_task 时 status 恒 todo,仅 backlog/todo/decision // parent_id 1 级嵌套铁律 / content_json JSON 合法性 / priority 值域(0..=3,默认 2=medium)
// 合法(active/done 须经 move_task_queue 流转,新建直接落违反一致性约束)。 let queue = df_storage::crud::normalize_queue_for_create(
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; args.get("queue").and_then(|v| v.as_str()),
let queue = match args.get("queue").and_then(|v| v.as_str()) { )
None | Some("") => "todo".to_string(), .map_err(anyhow::Error::msg)?;
Some(q) => { let parent_id = df_storage::crud::validate_parent_id(
let q = q.trim(); &repo,
if !TASK_QUEUE_VALUES.contains(&q) { args.get("parent_id").and_then(|v| v.as_str()),
anyhow::bail!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES); )
} .await
// 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法 .map_err(anyhow::Error::msg)?;
match q { let content_json = df_storage::crud::validate_content_json(
"backlog" | "todo" | "decision" => q.to_string(), args.get("content_json").and_then(|v| v.as_str()),
other => anyhow::bail!( )
"新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision", .map_err(anyhow::Error::msg)?;
other let priority = df_storage::crud::normalize_priority(
), args.get("priority").and_then(|v| v.as_i64()),
} )
} .map_err(anyhow::Error::msg)?;
};
// parent_id:1 级嵌套铁律(对标设计 §2.1 D2)。空串/缺省=None;
// 非空时校验父任务存在 + 父任务自身无 parent_id(防孙任务)。
let parent_id = args
.get("parent_id")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(pid) = &parent_id {
let parent = repo
.get_by_id(pid)
.await?
.ok_or_else(|| anyhow::anyhow!("父任务 {pid} 不存在,无法创建子任务"))?;
if parent.parent_id.is_some() {
anyhow::bail!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
parent.parent_id
);
}
}
// content_json:轻量 JSON 合法性校验(非空时须是合法 JSON),结构细节由 AI 消费层负责。
let content_json = match args.get("content_json").and_then(|v| v.as_str()) {
Some(c) if !c.trim().is_empty() => {
serde_json::from_str::<serde_json::Value>(c)
.map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?;
Some(c.to_string())
}
_ => None,
};
let record = TaskRecord { let record = TaskRecord {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(), id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(), description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32, status: TaskStatus::Todo, priority,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联) // F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
module_id: args.get("module_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), module_id: args.get("module_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json // 知识图谱 Phase 1 V29 三列:经上方共享校验的 queue / parent_id / content_json
queue, queue,
parent_id, parent_id,
content_json, content_json,