diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f66d755..f967bbb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -157,7 +157,7 @@ devflow/ ├── crates/ │ ├── df-types/ # 核心类型、错误、常量、事件、augmentation │ ├── 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-execute/ # 执行运行时 (Shell) │ ├── df-storage/ # 存储层 (SQLite) @@ -330,23 +330,25 @@ pub struct Decision { | 节点 | 功能 | 状态 | |------|------|------| | AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 | +| AiSelfReviewNode | AI 对任务产出自审(verdict 闸门) | ✅ 已实现 | | ScriptNode | Shell/脚本执行 | ✅ 已实现 | | HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 | +| TaskAdvanceNode | 任务状态推进链唯一 status 写入路径 | ✅ 已实现 | | GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 | | HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 | | NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 | | SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 | | 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 表结构 -> 真相源:`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 -- 想法池 (V1 建表 / V2 补 promoted_to + ai_analysis + scores) @@ -362,6 +364,8 @@ CREATE TABLE ideas ( promoted_to TEXT, -- 晋升后的 project_id (V2) ai_analysis TEXT, -- AI 分析结果 JSON (V2) scores TEXT, -- 多维评分 JSON: feasibility/impact/urgency/overall (V2) + related_ids TEXT, -- 关联灵感 ID JSON 数组 (V24) + deleted_at TEXT, -- 软删回收站 (V28) created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -380,18 +384,27 @@ CREATE TABLE projects ( 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 ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', 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, assignee TEXT, workflow_def_id TEXT, -- 关联的工作流定义 ID (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, updated_at TEXT NOT NULL ); @@ -432,7 +445,8 @@ CREATE TABLE workflow_executions ( project_id TEXT, -- 关联项目 ID (V2) task_id TEXT, -- 关联任务 ID (V2) created_at TEXT NOT NULL, - completed_at TEXT + completed_at TEXT, + updated_at TEXT -- 更新时间戳,排序/增量同步 (V33) ); -- 节点执行 (V1 建表) — 工作流执行过程中每个节点的运行快照 @@ -449,7 +463,7 @@ CREATE TABLE node_executions ( completed_at TEXT ); --- AI 提供商配置 (V9 建表) — 多 Provider 统一抽象 +-- AI 提供商配置 (V9 建表 / V18 补 model_configs / V19 补 enabled + weight) CREATE TABLE ai_providers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -460,29 +474,38 @@ CREATE TABLE ai_providers ( models TEXT, -- JSON array of model names is_default INTEGER NOT NULL DEFAULT 0, 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, 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 ( id TEXT PRIMARY KEY, 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, model TEXT, models TEXT, -- 用过的所有 model JSON 数组字符串 (去重, V6) archived INTEGER NOT NULL DEFAULT 0, -- 是否归档(侧栏折叠展示, V4) + pinned INTEGER NOT NULL DEFAULT 0, -- 侧栏置顶 (V16) prompt_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, 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 ( id TEXT PRIMARY KEY, conversation_id TEXT, + message_id TEXT, -- 关联消息 (V21) tool_call_id TEXT NOT NULL, tool_name 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 requested_at TEXT NOT NULL, executed_at TEXT, - decided_by TEXT -- human/auto + decided_by TEXT, -- human/auto + subtask_id TEXT -- 归属子任务 (V36) ); -- 知识条目 (V7 建表 / V8 补 embedding / V10 补 reasoning) — 经验沉淀基本单元, 共享记忆层 @@ -510,6 +534,7 @@ CREATE TABLE knowledges ( source_ref TEXT, -- 来源实体引用(如 conv:{id}) reasoning TEXT, -- AI 提炼判断依据("为何值得沉淀"), 手动录入为 NULL (V10) embedding BLOB, -- Vec 小端字节序列化, NULL=未嵌入走 LIKE 降级 (V8) + embedding_status TEXT, -- 嵌入状态,失败可补偿重试 (V23) created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -531,6 +556,159 @@ CREATE TABLE app_settings ( 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) — 迁移版本记录 ``` diff --git a/apps/df-miniapp/package-lock.json b/apps/df-miniapp/package-lock.json index c169ef4..6f842c6 100644 --- a/apps/df-miniapp/package-lock.json +++ b/apps/df-miniapp/package-lock.json @@ -12,6 +12,7 @@ "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", + "highlight.js": "^11.11.1", "marked": "^18.0.5", "vue": "^3.4.0", "vue-i18n": "^9.0.0" @@ -4715,7 +4716,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4729,7 +4729,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4743,7 +4742,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4757,7 +4755,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4771,7 +4768,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4785,7 +4781,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4799,7 +4794,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4813,7 +4807,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4827,7 +4820,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4841,7 +4833,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4855,7 +4846,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4869,7 +4859,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4883,7 +4872,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4897,7 +4885,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4911,7 +4898,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4925,7 +4911,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4939,7 +4924,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4953,7 +4937,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4967,7 +4950,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4981,7 +4963,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4995,7 +4976,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5009,7 +4989,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5023,7 +5002,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5037,7 +5015,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5051,7 +5028,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8052,6 +8028,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", diff --git a/apps/df-miniapp/package.json b/apps/df-miniapp/package.json index 89d9a0d..91f10e5 100644 --- a/apps/df-miniapp/package.json +++ b/apps/df-miniapp/package.json @@ -15,6 +15,7 @@ "@dcloudio/uni-components": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-h5": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-4080720251125001", + "highlight.js": "^11.11.1", "marked": "^18.0.5", "vue": "^3.4.0", "vue-i18n": "^9.0.0" diff --git a/apps/df-miniapp/src/utils/mdRenderer.ts b/apps/df-miniapp/src/utils/mdRenderer.ts index 85ae98f..00144cb 100644 --- a/apps/df-miniapp/src/utils/mdRenderer.ts +++ b/apps/df-miniapp/src/utils/mdRenderer.ts @@ -1,5 +1,5 @@ /** - * marked HTML 后处理:为代码块/内联 code/链接/表格注入 inline style(P1-G)。 + * marked HTML 后处理:为代码块/内联 code/链接/表格/图片/任务列表注入 inline style。 * * 为什么后处理而非 marked 自定义 renderer: * - mp-weixin rich-text 不链接页面 CSS class,仅认节点 inline style → 必须把样式写进 HTML 属性。 @@ -11,8 +11,88 @@ * 跳转需第三方 mp-html(本次不做,记后续)。 * - rich-text 非滚动容器,overflow-y:auto/max-height 不创建滚动区 → 长代码靠 * white-space:pre-wrap + word-break:break-all 换行防撑爆,不限高滚动。 + * - 网络图须域名进微信「downloadFile 合法域名」白名单,未配置时真机不显示 + * (基础库限制,非渲染代码问题);开发者后台配白名单后即正常。 + * - 不在 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 = { + 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 = { + '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 则合并,无则新增)。 */ function styleTag(html: string, tag: string, style: string): string { 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(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") +} + +/** hljs token class → inline style(无 DOM 不能 highlightElement,逐 span 转内联) */ +function inlineHljs(html: string): string { + return html.replace(//g, (_m, cls: string) => { + const style = cls + .split(/\s+/) + .map((c) => TOKEN_STYLE[c]) + .filter(Boolean) + .join(';') + return style ? `` : '' + }) +} + +/** + * 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。 + * marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。 + */ +function highlightCodeBlocks(html: string): string { + return html.replace(/
]*)>([\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 `
${inlineHljs(value)}
` + }) +} + /** * 对 marked.parse 产出的 HTML 注入 inline style。 * - * 设计取舍:pre(块代码)给深色背景; 不给背景(透明),仅等宽+橙色 —— 避免块代码内层 code - * 与 pre 背景冲突的双层色块(块代码内 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}` 仅命中 `` 子元素)。marked 产出的 p/h1-6/li/td/th * 等不带 style 时,文本在暗色气泡上呈近黑 → 暗底黑字。故给承载正文的块级元素统一注入颜色。 * strong/em/b 等行内元素继承父级(p/li)颜色,无需逐一注入。 */ export function styleMarkdown(html: string): string { let out = html + // 任务列表勾选框:rich-text 节点白名单不含 input,替换为字符符号(勾选/未勾选) + out = out.replace(/]*type="checkbox"[^>]*>/gi, (m) => { + const checked = /checked/i.test(m) + const color = checked ? '#6bd99a' : '#666666' + return `${checked ? '☑' : '☐'}` + }) + // 块代码语法高亮(须在下方 pre/code styleTag 前执行,hljs 输出 token span 后再合并基样式) + out = highlightCodeBlocks(out) // 正文着色:块级文本容器统一浅色(rich-text 样式继承链从容器传子文本) // 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' for (const tag of ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'td']) { out = styleTag(out, tag, BODY_STYLE) @@ -54,7 +185,7 @@ export function styleMarkdown(html: string): string { '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', ) - // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块) + // code(块内 + 内联):等宽橙色,透明背景(避与 pre 双层色块;块代码 token 由高亮色覆盖) out = styleTag( out, 'code', @@ -62,6 +193,9 @@ export function styleMarkdown(html: string): string { ) // 链接:蓝下划线(rich-text 不可点击,仅展示 + selectable 长按复制);word-break 防长 URL 撑开 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(会失去表格布局语义、 // 列宽错乱),故显式声明 display:table;rich-text 非滚动容器(见文件头注释),无法建横向 // 滚动区 → 不做滚动容器,靠 max-width:100% + word-break:break-all 防超宽撑爆气泡; diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index a91703e..1a12c6c 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -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("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("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("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("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), @@ -572,31 +572,45 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { Err(r) => return Box::pin(std::future::ready(r)), }; let description = arg_str_or(&args, "description", ""); - let priority = arg_int_or(&args, "priority", 0); medium_audit("create_task", &format!("{project_id}/{title}")); 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); - match repo.get_by_id(pid).await { - Ok(Some(parent)) => { - if parent.parent_id.is_some() { - return CallToolResult::error(format!( - "父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}", - parent.parent_id - )); - } - Some(pid.to_string()) - } - Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")), - Err(e) => return err_str(e), - } + let repo = TaskRepo::new(&db); + + // 共享校验(与 GUI/IPC 同源,一处修改全局生效):queue / parent_id / content_json / priority 值域 + let queue = match df_storage::crud::normalize_queue_for_create( + args.get("queue").and_then(|v| v.as_str()), + ) { + Ok(q) => q, + Err(e) => return CallToolResult::error(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 rec = TaskRecord { id: new_id(), @@ -611,15 +625,14 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { base_branch: None, review_rounds: 0, output_json: None, - idea_id: None, + idea_id, module_id: None, - queue: "todo".to_string(), + queue, parent_id, - content_json: None, + content_json, created_at: now.clone(), updated_at: now, }; - let repo = TaskRepo::new(&db); match repo.insert(rec).await { Ok(id) => { 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)), }; 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); Box::pin(async move { let now = now_millis(); diff --git a/crates/df-nodes/src/script_node.rs b/crates/df-nodes/src/script_node.rs index f979cd1..9bb60b3 100644 --- a/crates/df-nodes/src/script_node.rs +++ b/crates/df-nodes/src/script_node.rs @@ -56,9 +56,7 @@ impl Node for ScriptNode { shell_type: Default::default(), }; - // 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。 - // - 白名单非空时:命令首词不在白名单 → 直接拒绝执行 - // - 黑名单匹配时:直接拒绝执行 + // 命令执行安全:白/黑名单校验(运行时配置优先,回退环境变量/默认黑名单)。 // 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。 let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase(); if let Some(denied) = check_command_policy(&cmd_name) { @@ -70,7 +68,7 @@ impl Node for ScriptNode { anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command); } - // 危险命令告警:匹配已知危险关键词,仅告警不阻止执行 + // 危险关键词告警:匹配到仅告警不阻止执行(与硬拒绝的白/黑名单正交) let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"]; for &kw in &dangerous_keywords { if command.contains(kw) { @@ -143,36 +141,30 @@ const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", /// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。 /// -/// 优先级(高→低): -/// 1. 运行时配置(set_script_safety_config,前端设置页写入) -/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST -/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST) -/// -/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。 -/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。 +/// 优先级(高→低):运行时配置(前端设置页写入) > 环境变量 > 默认黑名单兜底。 +/// 黑名单优先于白名单(命中黑名单总是拒绝,即便同时在白名单);命令名比较前 trim + 小写。 /// /// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。 fn check_command_policy(cmd_name: &str) -> Option<&'static str> { - // 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高) + // 运行时配置优先(前端设置页写入,优先级最高) if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() { 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)) { return Some("命令在黑名单中(运行时配置)"); } } - // 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查 + // 运行时白名单非空时,命令须在名单内(命中即放行,跳过环境变量) if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() { 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)) { return Some("命令不在白名单中(运行时配置)"); } - // 运行时白名单非空且命令在其中 → 直接放行(跳过 env var) if !list.is_empty() { return None; } } - // 步骤3:回退到环境变量(兼容旧部署) + // 回退环境变量(兼容旧部署);黑名单缺省用默认黑名单兜底 let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok(); let blacklist: Vec = match env_blacklist { Some(raw) => raw @@ -186,7 +178,7 @@ fn check_command_policy(cmd_name: &str) -> Option<&'static str> { return Some("命令在黑名单中"); } - // 白名单:非空时命令名必须在白名单中才放行 + // 环境变量白名单非空时,命令名须在名单内 if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") { let whitelist: Vec = whitelist_raw .split(',') diff --git a/crates/df-nodes/src/subflow_node.rs b/crates/df-nodes/src/subflow_node.rs index df43087..4be3b03 100644 --- a/crates/df-nodes/src/subflow_node.rs +++ b/crates/df-nodes/src/subflow_node.rs @@ -1,14 +1,14 @@ -//! SubflowNode — 嵌套子工作流节点 +//! 子工作流节点 — 加载一个子 DAG 并在当前执行上下文中递归执行 //! -//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。 //! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。 +//! 执行结果不直接展开节点,而是返回子 DAG 的 JSON 快照,由 DagExecutor 消费展开。 use async_trait::async_trait; use df_workflow::dag_def::DagDef; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; use serde::{Deserialize, Serialize}; -/// SubflowNode 配置 +/// 子工作流配置 #[derive(Debug, Clone, Serialize, Deserialize)] struct SubflowConfig { /// 子工作流 DAG 定义(内联 JSON) @@ -21,13 +21,13 @@ struct SubflowConfig { /// 最大递归深度(安全阀) const MAX_DEPTH: u32 = 10; -/// SubflowNode — 嵌套子工作流 +/// 子工作流节点 pub struct SubflowNode; #[async_trait] impl Node for SubflowNode { 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()) .map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?; @@ -84,11 +84,20 @@ impl Node for SubflowNode { }, "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" } } diff --git a/crates/df-storage/src/crud/mod.rs b/crates/df-storage/src/crud/mod.rs index 688e6c9..a455a85 100644 --- a/crates/df-storage/src/crud/mod.rs +++ b/crates/df-storage/src/crud/mod.rs @@ -6,6 +6,7 @@ //! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column) //! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo //! - [`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@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2) //! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3) @@ -31,6 +32,7 @@ mod project_service_repo; mod settings; mod task_link_repo; mod task_repo; +mod task_validation; pub use conversation_repo::*; pub use idea_eval_repo::*; @@ -43,6 +45,7 @@ pub use project_service_repo::*; pub use settings::*; pub use task_link_repo::*; pub use task_repo::*; +pub use task_validation::*; pub use module_dependency_repo::*; pub use plan_repo::*; diff --git a/crates/df-storage/src/crud/task_validation.rs b/crates/df-storage/src/crud/task_validation.rs new file mode 100644 index 0000000..12371c5 --- /dev/null +++ b/crates/df-storage/src/crud/task_validation.rs @@ -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 { + 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, String> { + match content { + Some(c) if !c.trim().is_empty() => { + serde_json::from_str::(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, 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) -> Result { + 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)" + )), + } +} diff --git a/docs/03-模块文档/df-nodes-节点集合-2026-06-12.md b/docs/03-模块文档/df-nodes-节点集合-2026-06-12.md index 88e93df..a126b69 100644 --- a/docs/03-模块文档/df-nodes-节点集合-2026-06-12.md +++ b/docs/03-模块文档/df-nodes-节点集合-2026-06-12.md @@ -1,26 +1,33 @@ # 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 | 实现状态 | |------|------|------|-----------|---------| -| AiNode | 调用 LLM 完成文本生成/分析(非流式 complete) | 否 | `ai` | ✅ 完整 | -| ScriptNode | Shell/脚本执行 | 否 | `script` | ✅ 完整 | -| HumanNode | 人工审批/确认(单选/多选) | 是 | `human` | ✅ 完整 | - -> 历史文档曾列 8 节点(AI/Script/Docker/Git/Human/Notify/HTTP/Subflow)全标骨架。实际仅 AI/Script/Human 3 节点存在,其余 5 节点(Docker/Git/Notify/HTTP/Subflow)从未实现,已从本文档删除。 +| AiNode | 调用 LLM 完成文本生成/分析(非流式 complete) | 否 | `ai` | ✅ 完整 | +| AiSelfReviewNode | AI 对任务产出自审(复用 AiNode 调用链,verdict 闸门) | 否 | `ai_self_review` | ✅ 完整 | +| ScriptNode | Shell/脚本执行(白/黑名单安全策略) | 否 | `script` | ✅ 完整 | +| HumanNode | 人工审批/确认(单选/多选) | 是 | `human` | ✅ 完整 | +| 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} }`。 - **与 AI Chat 区别**:AiNode 由 DAG Executor 自动驱动(嵌入自动化链路),非交互对话。 +### AiSelfReviewNode(ai_self_review_node.rs) + +AI 自审闭环节点:对 AiNode 产出的任务内容做二次 AI 审查,返回 `verdict`(通过/打回)闸门。 +复用 AiNode 的 provider 调用链(`ai_node_helpers`),差异在 prompt 模板与 JSON 解析。 + ### ScriptNode(script_node.rs) 执行 Shell 脚本或自定义命令,复用 `df_execute::shell::execute`(跨平台:Windows `cmd /C` / Unix `sh -c`)。 - **必填**:`command`。 - **可选**:`timeout_secs`、`working_dir`。 +- **安全策略**:命令名(首词)白/黑名单校验(运行时配置优先,回退环境变量 / 默认黑名单);危险关键词仅告警不阻止。 - **失败语义**:非零退出码视为执行失败(`bail!` 带 exit_code + stderr)。 - **输出**:`{ stdout, stderr, exit_code, duration_ms }`。 @@ -49,7 +62,7 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w 阻塞节点,订阅事件总线 → 发 `HumanApprovalRequest` → `select!` 轮询 `HumanApprovalResponse` / 超时 / 取消。 - **执行顺序**:先 `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 - Multiple → 决策数必须 ≥ 1 - `options` 空 → 允许自由文本(仅受数量约束);非空 → 每项必须 ∈ options @@ -59,25 +72,66 @@ df-nodes 提供 DevFlow 工作流引擎的内置节点。所有节点实现 df-w - **取消**:每 500ms tick 检查 `node_status.is_cancelled`。 - **输出**:`{ decision(首项,向后兼容), decisions(数组), comment }`。 +### TaskAdvanceNode(task_advance_node.rs) + +任务推进链的唯一 status 写入路径(与 IPC/AI 工具/MCP 同源):读当前 TaskRecord → 状态机 +`can_transition` 校验 → 原子写(下沉 SQL 防 TOCTOU),退回转换自动累加 `review_rounds`。 + +### GitNode(git_node.rs) + +通过 `df_execute::shell::execute` 调用本地 git CLI,`working_dir` 指定仓库路径。 +支持 branch / checkout / commit / merge / push / status / log,输出按 action 提取结构化字段。 + +### HTTPNode(http_node.rs) + +使用 reqwest 发起 GET/POST/PUT/DELETE 请求。config 提供 `method/url/headers/body/timeout_secs`, +输出 `status_code + body + content_type`。 + +### NotifyNode(notify_node.rs) + +桌面(当前为日志占位,后续接 tauri-plugin-notification)/ webhook 通知。语义:尽力而为, +通知失败不阻断工作流(输出 `success=false` 但仍 Ok 返回)。 + +### SubflowNode(subflow_node.rs) + +加载一个子 DAG 并在当前执行上下文中递归执行(复用通用流程,如「代码审查」作为子步骤)。 +返回子 DAG 的 JSON 快照供 DagExecutor 消费展开,`max_depth` 限制嵌套深度防无限递归。 + +### DockerNode(docker_node.rs) + +通过 `docker run --rm` 一次性容器执行命令,复用 `df_execute::shell::execute` 调用本地 docker CLI。 +含环境检测(本机是否有 docker)与授权校验。 + ## 文件结构 ``` crates/df-nodes/src/ -├── lib.rs — 模块入口(声明 ai_node / human_node / script_node 三个 pub mod) -├── ai_node.rs — AiNode(LLM 文本生成/分析) -├── script_node.rs — ScriptNode(Shell 执行) -└── human_node.rs — HumanNode(人工审批/确认,阻塞) +├── lib.rs — 模块入口(声明各 pub mod) +├── ai_node.rs — AiNode(LLM 文本生成/分析) +├── ai_node_helpers.rs — AiNode 纯逻辑(provider 解析/参数构造,非 Node) +├── ai_self_review_node.rs — AiSelfReviewNode(AI 自审) +├── script_node.rs — ScriptNode(Shell 执行) +├── 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 — GitNode(Git 操作) +├── http_node.rs — HttpNode(HTTP 请求) +├── notify_node.rs — NotifyNode(通知) +├── subflow_node.rs — SubflowNode(嵌套子工作流) +└── docker_node.rs — DockerNode(容器内执行) ``` ## 依赖关系 ``` -df-core - ← df-workflow (Node trait) - ← df-nodes - ← df-ai (AiNode: build_provider / LlmProvider) - ← df-execute (ScriptNode: shell::execute) - ← df-core (HumanNode: events::WorkflowEvent / SelectType) +df-workflow (Node trait / DagDef) + ← df-nodes + ← df-ai (AiNode/AiSelfReviewNode: build_provider / LlmProvider) + ← df-execute (ScriptNode/GitNode/DockerNode: shell::execute) + ← df-storage (TaskAdvanceNode: TaskRepo::advance_status_atomic) + ← df-core / df-types (HumanNode: events::WorkflowEvent / SelectType) ``` ## 相关文档 diff --git a/src-tauri/src/commands/ai/tools/task.rs b/src-tauri/src/commands/ai/tools/task.rs index 20ff9a1..a90b474 100644 --- a/src-tauri/src/commands/ai/tools/task.rs +++ b/src-tauri/src/commands/ai/tools/task.rs @@ -79,70 +79,39 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; let repo = df_storage::crud::TaskRepo::new(&db); - // ── 知识图谱 Phase 1:queue/parent_id/content_json 校验(对齐 commands::task::create_task IPC)── - // queue:白名单 + 空串默认 todo。create_task 时 status 恒 todo,仅 backlog/todo/decision - // 合法(active/done 须经 move_task_queue 流转,新建直接落违反一致性约束)。 - const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; - let queue = match args.get("queue").and_then(|v| v.as_str()) { - None | Some("") => "todo".to_string(), - Some(q) => { - let q = q.trim(); - if !TASK_QUEUE_VALUES.contains(&q) { - anyhow::bail!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES); - } - // 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法 - match q { - "backlog" | "todo" | "decision" => q.to_string(), - other => anyhow::bail!( - "新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision", - other - ), - } - } - }; - - // 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::(c) - .map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?; - Some(c.to_string()) - } - _ => None, - }; + // 共享校验(与 MCP/IPC 同源,一处修改全局生效):queue 白名单+create 一致性 / + // parent_id 1 级嵌套铁律 / content_json JSON 合法性 / priority 值域(0..=3,默认 2=medium) + let queue = df_storage::crud::normalize_queue_for_create( + args.get("queue").and_then(|v| v.as_str()), + ) + .map_err(anyhow::Error::msg)?; + let parent_id = df_storage::crud::validate_parent_id( + &repo, + args.get("parent_id").and_then(|v| v.as_str()), + ) + .await + .map_err(anyhow::Error::msg)?; + let content_json = df_storage::crud::validate_content_json( + args.get("content_json").and_then(|v| v.as_str()), + ) + .map_err(anyhow::Error::msg)?; + let priority = df_storage::crud::normalize_priority( + args.get("priority").and_then(|v| v.as_i64()), + ) + .map_err(anyhow::Error::msg)?; let record = TaskRecord { id: new_id(), project_id: project_id.to_string(), title: title.to_string(), description: args["description"].as_str().unwrap_or("").to_string(), // 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, review_rounds: 0, output_json: None, // F-260619-01 可选关联灵感(空字符串/缺省视为不关联) 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), - // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json + // 知识图谱 Phase 1 V29 三列:经上方共享校验的 queue / parent_id / content_json queue, parent_id, content_json,