diff --git a/.codex-server-diagnose.py b/.codex-server-diagnose.py new file mode 100644 index 00000000..0eb682ba --- /dev/null +++ b/.codex-server-diagnose.py @@ -0,0 +1,20 @@ +import os + +import paramiko + + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect( + "119.254.86.251", + port=22322, + username="root", + password=os.environ["TARGET_SSH_PASSWORD"], + timeout=15, + auth_timeout=15, + banner_timeout=15, +) +_, stdout, stderr = client.exec_command(os.environ["TARGET_SSH_COMMAND"], timeout=30) +print(stdout.read().decode(errors="replace"), end="") +print(stderr.read().decode(errors="replace"), end="") +client.close() diff --git a/.gitignore b/.gitignore index 7f1164a1..60964980 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # Workspace/editor -.vscode/ +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json +!.vscode/extensions.json +!.vscode/settings.json frontend-web/.vscode/ node_modules/ # Claude Code local-only settings (per-user permissions, not shared) @@ -53,6 +57,7 @@ offline-backend-20260505/bundle/stop_backend.sh # offline-backend-20260512 runtime artifacts (large files, local config, data) offline-backend-20260512/.env.example +offline-backend-20260512/.env offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md offline-backend-20260512/SHA256SUMS offline-backend-20260512/config.yaml.example @@ -64,6 +69,8 @@ offline-backend-20260512/wheelhouse/ offline-backend-20260512/backend/.deer-flow/data/*.db offline-backend-20260512/backend/.deer-flow/data/*.db-* offline-backend-20260512/backend/.deer-flow/* +offline-backend-20260512/backend/.env +offline-backend-20260512/backend/.env.local offline-backend-20260512/start_backend.sh offline-backend-20260512/stop_backend.sh offline-backend-20260512/check_backend.sh @@ -81,6 +88,63 @@ skills/ hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* +# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. +!offline-backend-20260512/backend/tests/test_gauss_database.py +!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py +!offline-backend-20260512/backend/tests/test_health_auth_bypass.py +# Keep formal Word OOXML and embedded-font regression coverage versioned. +!offline-backend-20260512/backend/tests/test_word_export.py +# Keep generated-file library aggregation and filtering regression coverage versioned. +!offline-backend-20260512/backend/tests/test_artifact_library.py +# Keep the model configuration regression coverage versioned even though +# this deployment repository otherwise ignores newly added backend tests. +!offline-backend-20260512/backend/tests/test_model_management.py +# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. +!offline-backend-20260512/backend/tests/test_forced_research_agent.py +# Keep fixed-question exact-match and streaming regression coverage versioned. +!offline-backend-20260512/backend/tests/test_fixed_questions.py +# Keep configurable es_query system-prompt routing regression coverage versioned. +!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py +# Keep the local LLMWiki vector-index and external API regression coverage versioned. +!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py +!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py +!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py +!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py +!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py +!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py +# Deep-research reliability regression coverage. +!offline-backend-20260512/backend/tests/test_deep_research_persistence.py +!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py +!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py +!offline-backend-20260512/backend/tests/test_deep_research_start_job.py +!offline-backend-20260512/backend/tests/test_summarization_human_message.py +!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py +# Independent enterprise-research persistence, HTTP and race regressions. +!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py +# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). +!offline-backend-20260512/backend/tests/test_position_artifact_cap.py +# Roundtable coordinator/seat capability isolation regressions. +!offline-backend-20260512/backend/tests/test_roundtable_role_boundary.py +# Workflow Studio: schemas / definitions / engine / run-lifecycle regressions +# plus the concurrency remediation suite (atomic resume, draft CAS, publish +# counter, event seq) — required in CI per docs/WORKFLOW_STUDIO_BACKEND_REMEDIATION_ZH.md P1-06. +!offline-backend-20260512/backend/tests/test_workflow_schemas.py +!offline-backend-20260512/backend/tests/test_workflow_definitions.py +!offline-backend-20260512/backend/tests/test_workflow_engine.py +!offline-backend-20260512/backend/tests/test_workflow_runs.py +!offline-backend-20260512/backend/tests/test_workflow_concurrency.py +!offline-backend-20260512/backend/tests/test_workflow_schema_rebuild.py +# Writing-mode first-report pipeline enforcement regressions. +!offline-backend-20260512/backend/tests/test_writing_mode_report_middleware.py +!offline-backend-20260512/backend/tests/test_writing_rewrite_prompt.py +# LLM call timeout retry classification regression coverage. +!offline-backend-20260512/backend/tests/test_llm_error_handling_middleware.py +# Writing-mode pipeline conversation-material regressions. +!offline-backend-20260512/backend/tests/test_deep_research_report_tool.py +# Report collaboration (RC-BE-000–018) regression coverage. +!offline-backend-20260512/backend/tests/test_report_collaboration_*.py +!offline-backend-20260512/backend/tests/test_ensure_orm_columns_sync.py deploy .history @@ -90,21 +154,32 @@ git-clone/ # Repo-root offline dependency pack (local only) /dist/ -# Frontend offline pack working directories -frontend-web/.offline-pack-build/ -frontend-web/.offline-extract-test/ -frontend-web-node_modules-offline-*.tar.gz - -.codegraph -codegraph.mdc +# Frontend offline pack working directories +frontend-web/.offline-pack-build/ +frontend-web/.offline-extract-test/ +frontend-web-node_modules-offline-*.tar.gz + +# Local dependency caches and backend scratch archives +/.pnpm-store/ +/offline-backend-20260512/backend/backend*.zip + +# Local offline bundles and scratch tooling +/*.tar.gz +/offline-backend-20260512/*.zip +/tmp/ + +.codegraph +codegraph.mdc docs/aidev-docs docs/backups # Frontend skills components must be tracked (excluded from the broad skills/ ignore above) !frontend-web/src/components/workspace/skills/ -# uv lockfile (not tracked per project preference) -offline-backend-20260512/backend/uv.lock +# uv lockfile (not tracked per project preference) +offline-backend-20260512/backend/uv.lock + +!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py # Local-only scratch directory (not committed) 0622/ @@ -113,4 +188,4 @@ offline-backend-20260512/backend/uv.lock __pycache__/ # misc -.DS_Store +.DS_Store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..596ef561 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.debugpy" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..fb342a87 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,66 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "DeerFlow: Backend FastAPI (8001)", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "app.gateway.app:app", + "--host", + "0.0.0.0", + "--port", + "8001" + ], + "cwd": "${workspaceFolder}/offline-backend-20260512/backend", + "python": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe", + "envFile": "${workspaceFolder}/offline-backend-20260512/.env", + "env": { + "PYTHONPATH": "${workspaceFolder}/offline-backend-20260512/backend;${workspaceFolder}/offline-backend-20260512/backend/.deer-flow/python-vendor", + "GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080", + "UV_LINK_MODE": "copy", + "WEKNORA_ADMIN_PASSWORD": "1qaz@WSX" + }, + "console": "integratedTerminal", + "redirectOutput": true, + "justMyCode": false, + "subProcess": true + }, + { + "name": "DeerFlow: Frontend Vite (5174)", + "type": "node-terminal", + "request": "launch", + "command": "npm.cmd run dev -- --host 0.0.0.0 --port 5174", + "cwd": "${workspaceFolder}/frontend-web", + "env": { + "VITE_BACKEND_BASE_URL": "http://127.0.0.1:8001", + "VITE_LANGGRAPH_BASE_URL": "http://127.0.0.1:8001/api" + }, + "serverReadyAction": { + "pattern": "Local:\\s+(https?://[^\\s]+)", + "uriFormat": "%s", + "action": "openExternally" + } + }, + { + "name": "DeerFlow: Open Frontend in Chrome", + "type": "node-terminal", + "request": "launch", + "preLaunchTask": "DeerFlow: VS Code Start", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"Start-Process 'http://127.0.0.1:5174/'\"", + "cwd": "${workspaceFolder}" + } + ], + "compounds": [ + { + "name": "DeerFlow: Full Stack (Backend + Frontend)", + "preLaunchTask": "DeerFlow: Stop Full Stack", + "configurations": [ + "DeerFlow: Backend FastAPI (8001)", + "DeerFlow: Frontend Vite (5174)" + ], + "stopAll": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..3fd2eb59 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe", + "python.terminal.activateEnvironment": true, + "typescript.tsdk": "frontend-web/node_modules/typescript/lib", + "npm.packageManager": "npm" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..0886ef70 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,175 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "DeerFlow: VS Code Start", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/scripts/vscode-start.ps1", + "-OpenBrowser" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + }, + { + "label": "DeerFlow: Start Full Stack and Wait", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/scripts/start-all-and-wait-frontend.ps1" + ], + "problemMatcher": [] + }, + { + "label": "DeerFlow: Start Full Stack", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/scripts/start-all.ps1" + ], + "problemMatcher": [] + }, + { + "label": "DeerFlow: Stop Full Stack", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/scripts/stop-all.ps1" + ], + "problemMatcher": [] + }, + { + "label": "DeerFlow: Status", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/scripts/status.ps1" + ], + "problemMatcher": [] + }, + { + "label": "DeerFlow: Check Local Dev Environment", + "type": "shell", + "command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw 'Backend virtual environment is missing. Run uv sync in offline-backend-20260512/backend first.' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; npm --prefix '${workspaceFolder}\\frontend-web' exec vite -- --version", + "options": { + "shell": { + "executable": "powershell.exe", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command" + ] + } + }, + "problemMatcher": [] + }, + { + "label": "DeerFlow: Install Frontend Dependencies", + "type": "process", + "command": "npm", + "args": [ + "ci", + "--no-audit", + "--no-fund" + ], + "options": { + "cwd": "${workspaceFolder}/frontend-web" + }, + "problemMatcher": [] + }, + { + "label": "DeerFlow: Frontend Typecheck", + "type": "process", + "command": "npm", + "args": [ + "run", + "typecheck" + ], + "options": { + "cwd": "${workspaceFolder}/frontend-web" + }, + "problemMatcher": "$tsc" + }, + { + "label": "DeerFlow: Frontend Build", + "type": "process", + "command": "npm", + "args": [ + "run", + "build" + ], + "options": { + "cwd": "${workspaceFolder}/frontend-web" + }, + "problemMatcher": [] + }, + { + "label": "DeerFlow: Backend Tests", + "type": "process", + "command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe", + "args": [ + "-m", + "pytest", + "tests/", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/offline-backend-20260512/backend", + "env": { + "PYTHONPATH": ".", + "WEKNORA_ADMIN_PASSWORD": "1qaz@WSX" + } + }, + "problemMatcher": [] + }, + { + "label": "DeerFlow: WeKnora Backend Tests", + "type": "process", + "command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe", + "args": [ + "-m", + "pytest", + "tests/test_llmwiki_weknora.py", + "-v" + ], + "options": { + "cwd": "${workspaceFolder}/offline-backend-20260512/backend", + "env": { + "PYTHONPATH": ".", + "WEKNORA_ADMIN_PASSWORD": "1qaz@WSX" + } + }, + "problemMatcher": [] + } + ] +} diff --git a/.zcode/plans/plan-sess_bc89c1c7-ec32-43b9-886f-70fa2ae83a13.md b/.zcode/plans/plan-sess_bc89c1c7-ec32-43b9-886f-70fa2ae83a13.md new file mode 100644 index 00000000..19779feb --- /dev/null +++ b/.zcode/plans/plan-sess_bc89c1c7-ec32-43b9-886f-70fa2ae83a13.md @@ -0,0 +1,61 @@ +# 深度研究第三轮:检索稳定性修复 + 参考文献确定性 + 页面对齐 AI 写作改造 + +## A. 两个 bug 的排查结论与修复 + +### A1. 第二次检索「网络波动」——确认是真问题(DDG 限流 + 错误语义混淆 + 零重试) + +**排查结论**(用户判断"确实是网络波动"基本正确,但代码层面放大了它): +- 「网络波动」文案不存在于任何代码中——是收集员 LLM 看到 `{"error": "No results found"}` 信封后自己编的解释 +- 真实根因:DDG html 端点对同 IP 快速连续请求限流(403/429 挑战页)→ `DDGSException` → `ddg_search/tools.py::_search_text` 把异常吞成 `[]` → 上层把「被限流」和「真没结果」混成同一个错误信封;工具层完全没有重试 + +**修复**(`packages/harness/deerflow/community/ddg_search/tools.py`): +1. `_search_text` 对异常加 2 次重试 + 指数退避(约 1.5s / 3s + 抖动) +2. 错误信封区分场景:真无结果 → `no results`;多次尝试仍失败 → `web search failed after N attempts (rate limited or network), 换一组检索词稍后重试`——让模型不再瞎猜「网络波动」 +3. 收集员 SOUL.md 行为准则加一条(同步改种子资产 + 已安装副本 `.deer-flow/agents/deep-research-collector/`):搜索返回 error 信封时先换检索词重试一轮,而不是直接告知用户网络异常 + +### A2. 参考文献缺失——代码确有隐患(当前是模型自发行为,时有时无) + +**排查结论**:后端没有任何「参考来源」追加逻辑;四个 runner 的报告 prompt 只要求内联 `[N]` 引用。出现过的参考列表是模型看到上下文头 `[N] 标题(URL)` 后**自发**补写的——弱模型下不可靠,与"信息够不够"无必然关系。 + +**修复**(prompt 指示 + 确定性兜底双保险): +1. 四个 runner(basic / detailed / deep / multi_agent)的报告 prompt 各加一条:报告最后附「## 参考来源」,按 `[N] 标题. URL` 列出正文实际引用过的来源 +2. `basic.py` 新增共享函数 `ensure_reference_section(report, context_list, materials)`:报告缺少参考小节时,解析正文引用的 `[N]` 编号,按压缩上下文顺序生成编号列表追加(无编号时列全部来源,封顶 20 条;已有参考小节则不重复追加)。在各 runner 最终 `write_text("report.md", ...)` 前调用(约 7 个调用点,跳过零材料 fallback) +3. 新增测试 `tests/test_deep_research_references.py`:helper 单测(正常追加/编号映射/不重复追加/无 URL 来源)+ runner 集成断言 + +## B. 页面改造(核心需求):左列复用 AI 写作对话形态,右列改真沙箱 + +### B1. 左列 = AI 写作式对话收集面板(仿 WritingSetupChat) + +- **空状态欢迎屏**(无消息时隐藏头部,展示欢迎卡片,WritingWelcome 式)+ 有消息后 `MessageList`(沿用现有绑 collector 线程的接线)+ 首 token 前「正在整理…」占位条 +- **输入框换成白卡片 PromptInput 套件**(与 AI 写作完全同款组件):卡片内一行「当前对话模型」Select(沿用 ModelNameSelect)+ 高级配置入口;下方 PromptInputTextarea + PromptInputFooter + PromptInputSubmit(`status` ready/streaming,流式中变停止按钮)。经典模式入口保留在高级配置对话框 +- **配置卡片进对话流**(对标 AI 写作「识别出意图后出现表单」): + - `message-list.tsx` 最小扩展(约 15 行):新增分组 `assistant:research-approval`(识别 `additional_kwargs.setup_research_approval`)+ 可选 prop `researchApprovalSlot?: ReactNode`;不传 slot 或无该载荷时零行为变化——普通对话/AI 写作完全不受影响 + - workbench:收集回合结束(awaiting_report,即"意图识别完成")时向 `displayThread` 尾部注入一条虚拟 ai 消息(WritingSetupChat 的 displayThread 同款手法),slot 传现有 `ReportConfigCard` → 卡片作为助手下一轮出现在对话流里 +- 写作进度卡、完成卡、摘要气泡、追问列表保留在列表下方卡片区(报告正文移到右侧沙箱) + +### B2. 右列 = 工作区真沙箱(Step2SandboxLayout 的 stub 线程模式) + +- 新建 `deep-research/ResearchSandboxPanel.tsx`(约 100 行,含关闭/重开)+ `useReportSandbox.ts` hook(仿 `useStep3Report` 的 stub 机制): + - threadStub:`messages = [{type:'ai', tool_calls:[{name:'write_file', args:{path:'/mnt/user-data/outputs/report.md', content: <流式报告>}}]}]`,`isLoading = job 运行中 && phase !== 'summarizing'` + - `report_delta` 首帧 → `openArtifact(write-file: 虚拟URL)` 自动展开右栏;`run.report` 每帧 upsert stub(带防抹空守卫);刷新/切换会话用 `session.reportMarkdown` 重建 + - `ArtifactFileDetail` 自带能力全部免费获得:**流式期 code 视图自动滚底 → 报告写完 isLoading 翻 false 自动切 preview + 脉冲提示**(正是「生成完直接切预览」)、Streamdown 渲染的 Markdown(比现在的好看)、另存 Word/复制/下载、默认走展示层脱敏 +- 布局:ResizablePanelGroup chat 60 / sandbox 40(对齐 ChatBox/圆桌),沙箱可关闭、头部按钮重开 +- **删除自定义 `ResearchSandbox` 组件**;legacy 会话与经典模式共用同一右栏(`run.report` 数据源相同,行为一致) +- 已知小限制(可接受):报告配图模式下沙箱内图片可能无法经 artifacts API 解析(配图默认关闭;真需要时后续把产物落 thread artifacts) + +### B3. 验证 + +- 后端:`PYTHONPATH=. uv run --no-sync pytest tests/ -k deep_research -q --ignore=tests/test_notification_templates.py` 全绿 + ruff;改完配置/SOUL 后 touch 触发 dev 服务重载 +- 前端:`pnpm typecheck` 零新增错误(基线 51) +- 手动走查:连续两次检索不再误报、弱模型报告末尾必带参考来源、页面全流程(欢迎屏 → 对话收集 → 流内配置卡 → 沙箱流式 → 自动预览 → 摘要/追问) + +## 涉及文件 + +**后端**:`community/ddg_search/tools.py`、`deep_research/runners/{basic,detailed,deep,multi_agent}.py`、收集员 SOUL.md(种子 + 已安装副本)、`tests/test_deep_research_references.py`(新) +**前端**:`components/workspace/messages/message-list.tsx`(可选 slot 分组)、`pages/deep-research/DeepResearchWorkbench.tsx`(大改)、`pages/deep-research/ResearchSandboxPanel.tsx`(新)、`pages/deep-research/useReportSandbox.ts`(新) + +## 明确不动 + +- AI 写作 / 普通对话现有组件行为(message-list.tsx 仅加可选分组,缺省零变化) +- 报告写作管线的写作逻辑本身(prompt 加一条要求 + 末尾确定性追加参考小节,均为附加式) +- 深度研究后端会话/任务/事件模型(本轮不动) \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d12c33ae..64d55405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,13 @@ This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI Managed together via shell scripts in `scripts/`. +## Offline Release Repository + +Linux AMD64 离线发版脚本、wheelhouse、`deploy.env` 模板和部署文档统一维护在 +`knowledge-base-bj/deerflow-offline-deployment`,不要把离线镜像或发版资产提交到 +本业务源码仓库。正式发版从该仓库执行 `scripts/build-latest-master.sh`,脚本会读取 +本仓库最新 `master` 的干净源码快照。 + ## Commands ### Full Application (from repo root) @@ -73,6 +80,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph **Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI). +**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `AGENTS.md`/`CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`. + ### Frontend The frontend (`frontend-web/`) is a React 19 + Vite SPA: @@ -81,15 +90,19 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat`, sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` reuses the engine (`AIWritingContext` + `useStream` + `AIWritingDraftPanel`); **idle keeps the classic setup layout** (`WritingSetupChat` + collapsible form) and switches to the chat workbench once writing starts; left panel `components/ai-writing-chat/WritingChatPanel.tsx` = **unified chronological feed** (`ChatFlowTimeline.tsx`, chat bubbles anchor-interleaved into the progress timeline so triggered events render below the user's message) + button-only intervention cards (`ChatInterventionCards.tsx`, inline textareas removed) + bottom unified smart composer. Intent resolution: frontend fast path + `POST /api/ai-writing/sessions/{id}/intent` → `deerflow/agents/ai_writing/intent_router.py` (per-pause action whitelist, high-cost confidence gates, payload cleaning); supports QA bypass, clarify follow-ups, control intents, and material-stage auto re-search + pending-intent resume. Tests `tests/test_ai_writing_intent.py`; plan doc `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). -- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** uses a separate overlay: every compact workspace menu item can be placed in `sidebar`(左侧菜单)、`settings`(设置和更多)or `toolbox`(右侧工具箱); `CompactToolbox` (`components/workspace/compact-toolbox.tsx`, mounted by `PageRoutes.tsx`) opens only when the square right-edge trigger is clicked, then slides a compact, content-height right-side drawer over a backdrop above global dropdowns; configured items render top-to-bottom as centered square cards with a large icon above the label while the effective layout is compact. `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. +- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答经 Gateway `POST /api/sentiment-agent/stream` 转发外部 AG-UI 网关(不经过 LangGraph),会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`)并随请求传给后台;后台按传入地址转发、关闭 TLS 校验,失败时把完整错误回传。核心模块 `src/core/sentiment-agent/`。 +- **简洁模式用户引导 (Compact-mode onboarding tour)**: 简洁模式(外观设置的「全量/简洁」= compact,无全局导航栏 + 页面侧边栏)新增 TDesign React `Guide` 组件驱动的分步引导。组件 `components/workspace/compact-mode-guide.tsx`(挂在 `ChatPage`):9 步 —— 欢迎弹窗(dialog 模式)→ 左侧功能导航 → 聊天记录 → 设置和更多 → 换肤 → 界面模式(切回全量)→ 常用提示词栏 → 提示词管理弹窗 → 输入框;高亮目标由各组件上的 `data-compact-guide` 标记定位(`workspace-nav-chat-list` / `recent-chat-list` / `workspace-nav-menu` / `appearance-controls` / `custom-prompt-menu.tsx` 的 `CustomPromptBar`(提示词栏,仅新对话落地态渲染)与其 `ManagePromptsDialog`(`prompt-dialog` 锚点)/ ChatPage 输入框容器),**启动时按目标是否存在过滤步骤**(如新用户无聊天记录、侧栏收起时自动剔除该步 —— Guide 对缺失元素会抛错,不能盲传)。**提示词管理弹窗步骤是动态目标**:弹窗平时不渲染,引导切到该步前先广播 `PROMPT_DIALOG_GUIDE_EVENT`(`custom-prompt-menu.tsx` 导出)让 `CustomPromptBar` 打开 `ManagePromptsDialog` 并**接管**(`owner: true` 期间 ESC/点遮罩不关闭,防止高亮目标消失导致 Guide 报错),轮询锚点真正挂载后才切步;离开该步 / 跳过 / 完成 / 中途卸载时以 `owner: false` 收回弹窗,4 秒打不开则跳过该步防卡死。**TDesign Guide × Radix 模态互操作**:Radix 模态弹窗打开时会把 `body` 置 `pointer-events:none`(滚动锁定),Guide 直接挂在 body 下的遮罩/高亮/弹层会一并继承失活(引导按钮点不动),`styles/tdesign-theme.css` 末尾对 `.t-guide__overlay/__highlight/__popup/__wrapper` 显式 `pointer-events:auto` 兜底。触发:① 自动 —— 简洁模式新对话落地页自动演示,直到用户显式点「完成」或「跳过」才写 localStorage `deerflow.compact-guide-finished` 记忆(浏览器级);**中途刷新/离开不记忆,下次进入还会再演示**。② 手动 —— 「设置和更多」下拉新增「界面使用引导」(该下拉仅存在于简洁模式工作区侧栏 footer):已在问答页时广播 `compact-guide:start` 窗口事件直接启动,否则写 sessionStorage 请求并导航 `/chats/new` 由挂载的引导消费(消费在定时器真正启动时才执行,规避 StrictMode effect 双跑吞请求)。`eligible` 排除 embed=1 / hideChrome=1 / chats_iframe / FORCE_COLLAPSED_SIDEBAR(无界)等同样无全局 chrome 的特殊环境;启动延迟 800ms 等侧栏宽度过渡稳定后再测量高亮框。 +- **智能体使用引导 (Agent onboarding tour, 三段式)**: 智能体「浏览管理 → 创建与配置 → 发起问答」全流程分步引导,组件 `components/workspace/agents/agent-guide.tsx`(同一 TDesign `Guide` 封装模式,与简洁模式引导共享记忆语义:**仅显式点「完成/跳过」才写 localStorage** `deerflow.agent-guide..finished`,中途刷新/离开不记忆)。**自动演示仅 create / chat 两段**(进智能体列表页不弹引导,点「创建智能体」进入新建页时才自动演示;list 段的 `AgentGuide` 传 `autoStart={false}`,只能由头部「使用引导」按钮手动触发)。三段各自挂载、各自记忆:① `list`(`agent-gallery.tsx`,/page/workspace/agents)——分区 Tabs、搜索排序、智能体卡片操作、右上「创建智能体」按钮;**完成/跳过就地结束(不跳转创建页,与 create 段无联动)**,最后一步仅提示进入创建页后会有配置引导;② `create`(`NewAgentPage.tsx`,/page/workspace/agents/new)——左侧对话式创建输入区、基本信息、运行模型+入库模板名、知识空间、SOUL.md、技能穿梭框、保存按钮(9 步);③ `chat`(`AgentChatPage.tsx`,agents/:id/chats)——左栏切换与历史、常用问题 chips、输入框(落地态/会话态两个挂点同一 `chat-input` 标记)、右侧详情面板;`eligible` 限定普通智能体对话页(`showAgentList && !isEmbedded && !taskCtxOn`,即排除通用问答原地嵌入与任务深链工作区),三段共用 `isAgentGuideEnvironment()` 排除 embed/hideChrome/无界环境。高亮锚点 `data-agent-guide`:列表页 `tabs`/`search`/`agent-card`(卡片+列表两种布局的根元素)/`new-agent-btn`,创建页 `create-chat-input`/`create-basic`/`create-model`/`create-knowledge`/`create-soul`/`create-skills`/`create-save-btn`,对话页 `chat-sidebar`/`chat-questions`/`chat-input`/`chat-detail`;同样**启动时按目标存在性过滤步骤**(新用户无卡片/无预设问题时自动剔除)。手动重放:三个页面头部各有「使用引导」按钮(列表页创建按钮左侧、创建页 header 右侧、对话页 header),广播 `agent-guide:start:` 窗口事件强制重放(忽略已看过记忆)。 - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 -- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id, or action id when `idKind:"action"` — same selector as url buttons, xdfx only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url **or host** button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **agentfx 任务深链(新系统 iframe 嵌入的智能体问答,3qfx 单智能体 lite 模式的独立复制)**: goPath=`agentfx`(`${loginBase}?password=…&taskId=…&goPath=agentfx&embed=1&hideJump=1&theme=dark-blue`)。与 3qfx 的差异:①任务详情不走外部 consumer,改调**本系统 Gateway** `GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=`(`apiFetch` 带刚登录的 DeerFlow 态;任务数据来自 TaskCOP 任务表,与任务列表/报告导入同一份);②**固定使用内置智能体** `agentfx-analyst`(不走业务映射);③路由 `task-management/plan-task-agentfx/agent/:agent_id/chats`,页面为独立复制的 `AgentFxWorkspace.tsx`(`AgentFxWorkspace`/`AgentFxIndexResolver`/`AgentFxChat` —— 左「当前任务」卡 + 1:N 对话列表 + 新建对话,`AgentChatPage taskBind` 自动发开场白;无 TaskJumpBar/清除记录/闸门/xdfx 行动卡)。模块 `core/auth/agentfx-deeplink.ts`(`isAgentfxGoPath`/`fetchAgentfxTaskDetail`/`resolveAgentfxDeeplinkTarget`);任务详情整包仍存共享键 `login.copTaskDetail`(按标签页隔离),`AgentChatPage` 的 taskBind 自恢复对 agentfx 生效。`LoginPage` 在既有深链分支前用 `isAgentfxGoPath` 分流。**内置「任务研判报告助手」**:后端 seeder `app/gateway/routers/_agentfx_seed.py` + `_agentfx_seed_assets/`(config.yaml + SOUL.md,目录存在不覆盖;app.py lifespan 在 `_sync_legacy_agents` 之前调用,自动进 agents 表 user_id IS NULL)——SOUL 规定工作流「搜集信息(`web_search`,**首步不可跳过**)→ 每次检索 JSON 原样落盘 `/mnt/user-data/workspace/hits/qN.json` → **一条命令** `task-report-build`(group `all`)技能函数转换成 14 类 `task-reports.json`(模型禁止手写该 JSON;分页技能 `task-report-{enemy,our,env,judge}` 仅用于事后补跑单页;五者都是共享引擎 `task-report-lib/convert_lib.py` 的瘦 CLI,`resolve_hit_files` 对虚拟绝对路径读空的情况自动回退到相对写法并在 `warnings` 里列出尝试过的路径,免得智能体自己写脚本排查)→ `write_file` 生成 `/mnt/user-data/outputs/report.md` → 聊天总结 → 引导入库」,硬性约束:categoryType 逐字用 14 个全称(禁缩写自造如「DQ关键事件」)、JSON 直接写汉字禁 `\uXXXX` 转义、每类必须有实质内容不许空数组/纯占位、文件一律用 `/mnt/user-data/...` 虚拟路径(防宿主路径泄漏进 tool args)。**检索依赖 `config.yaml tools.web_search`(DDG,enabled 已开;内网需换 configurable_search 时改回并填 endpoint)**。任务工作区输入框在 `taskCtxOn` 时隐藏「写作/记忆/参考文献」(InputBox `hideContextTools` prop)与「知识空间」(不传 knowledgeSelector);消息流里 write_file 文件卡片只显示文件名(`artifactDisplayName` 收敛宿主/虚拟路径)。**入库两条路径**:(a) **前端协助卡** —— `AgentChatPage`(taskBind 且非 taskGate)检测线程 artifacts 出现 `task-reports.json`(按文件名后缀匹配,兼容本地沙箱记录的宿主路径)且流式结束时,向 displayThread 尾部注入 `additional_kwargs.task_report_import_approval` 虚拟 ai 消息(`core/messages/utils.ts` 分组 `assistant:task-report-import` + `MessageList` 的 `taskReportImportSlot` 插槽渲染 `TaskReportImportCard`),点「开始入库」前端校验(`strategy-components/lib/agentfx-report-schema.ts`,14 类 + contentJson 合法 JSON + **\u 转义归一化还原为汉字**,与技能脚本同规则)后 `importTaskReport`(`strategy-components/api/task-reports.ts`)POST 导入接口整体替换;任务已有详情**不抑制**卡片(入库即整体替换,卡片文案说明覆盖语义),导入成功按 thread 记 sessionStorage(刷新不重复询问)、「暂不入库」为会话内记忆;(b) **入库技能** `task-report-import`(seeder 一并复制到 `skills/public/`,public 类技能 extensions_config 缺省即启用)—— 用户聊天里说「入库」时智能体按 SKILL.md 用 bash 跑 `scripts/task_report_import.py`(纯标准库:同样的校验规则 + 转义归一化;**默认关闭 SSL 证书校验** `ssl._create_unverified_context` + 忽略代理,适配内网自签证书;鉴权 `--token`/env `AGENTFX_IMPORT_TOKEN`→`DEERFLOW_AUTH_TOKEN`→`TOOLS_TOKEN` 优先,缺省经公共端点 `POST /api/parallel-agents/auth/token` 以 `agentfx-import` 用户现签 token——内网既有鉴权旁路;gateway 地址 env `AGENTFX_GATEWAY_URL` 或 `http://127.0.0.1:{DEER_FLOW_GATEWAY_PORT}`);`--strict` 可要求 14 类齐全,默认允许部分导入并在 stdout JSON 的 `missing_categories` 里列缺。入库成功后任务 TaskCOP 状态自动置 25(已完成分析)。**入库成功通知宿主**(iframe 嵌入场景):两条路径成功后都会 `window.top.postMessage({source: "magent-web", type: "task-report:saved", taskId: String(taskId)}, "*")`(`notifyTaskReportSaved`,`agentfx-report-schema.ts`)——卡片路径在 `TaskReportImportCard` 导入成功回调里直发;技能路径由 `AgentChatPage` 从消息流检测 `task_report_import.py` 的成功 stdout(`"success": true` + `task_id`)代发——**仅在回合结束后检测**(`!thread.isLoading` 才扫描;流式过程中 messages 每增量全量扫描会卡死页面),只看线程内**最后一次**成功入库(新一次入库触发新通知;刷新旧对话按 thread 记 sessionStorage 已通知的消息 id 不重复发)。 - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 ### Configuration diff --git a/CLAUDE.md b/CLAUDE.md index c0c669d4..ff99545b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph **Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI). +**Workflow Studio** (`deerflow/workflows/` + `/api/workflows*`) is a second, independent execution path beside the lead agent: a persisted DAG scheduler that runs published workflow graphs, streams a durable event log over SSE, and supports human-input pauses, cancellation, and crash recovery via lease reclaim. Coze Studio supplies the canvas UI only; LangGraph is used only *inside* an `agent`/`skill` node. Master switch and all limits live in `config.yaml` → `workflows`. See the backend `CLAUDE.md`「Workflow Studio」and `docs/WORKFLOW_STUDIO_BACKEND_DEV_ZH.md`. + ### Frontend The frontend (`frontend-web/`) is a React 19 + Vite SPA: @@ -82,13 +84,13 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **AI 写作·对话版(对话驱动写作台)**: a **parallel new entry** beside the classic AI-writing page (classic page/routes untouched) — route `/page/canvas/ai-writing-chat` (`OpenCanvasRoutes.tsx`), sidebar 「问答管理 → 课题研究(对话版)」. Page `open-canvas/pages/AIWritingChatPage.tsx` fully **reuses the engine** (`AIWritingContext` state machine + LangGraph `useStream`, `AIWritingHistoryProvider`, right-side `AIWritingDraftPanel`) but replaces the presentation: left = `components/ai-writing-chat/WritingChatPanel.tsx` (timeline + **button-only intervention cards** `ChatInterventionCards.tsx` — the five pause cards copied from `InterventionCard.tsx` with all inline textareas removed, confirm/action buttons kept — plus a bottom **unified smart composer**). Free text goes through two-level intent resolution (`open-canvas/api/ai-writing-intent.ts`): deterministic short-phrase fast path (确认/继续/重新搜索/重写大纲…; high-cost actions never fast-pathed) then `POST /api/ai-writing/sessions/{id}/intent` — the backend reads the graph checkpoint (authoritative pause point from the pending interrupt) and routes via `deerflow/agents/ai_writing/intent_router.py` (LLM + strict per-pause action whitelist aligned to the graph's real resume actions, confidence gates: finalize/force_finalize/覆盖式 re_search need ≥0.85 else clarify, payload key-whitelist cleaning). Results map to `submitIntervention`/control (暂停/继续/后台挂起)/QA bypass (`answer` rendered as a chat bubble, never touches graph state)/clarify (amber follow-up bubble with multi-turn context). **Two-state layout**: idle (未开始写作) renders the **classic setup experience unchanged** — `WritingSetupChat` + collapsible `AIWritingPanel` form under `WritingFormProvider` (same as `AIWritingPage`'s idle branch); once writing starts (or history view opens) it switches to the chat workbench. **Unified chronological feed** (`ChatFlowTimeline.tsx`): intent-layer chat bubbles are interleaved INTO the progress-event timeline by anchor (= `progressEvents.length` at message creation), so events triggered by a user message render **below** it (Google-AI-Studio-style single stream, not timeline-above/chat-below); composer-driven interventions suppress the duplicate 「我的要求」card (`suppressedRequirementIndices`). **素材不足自动续跑** only at `material_confirm` (the only pause the graph supports re-search from): needResearch caches the payload as a pending intent, submits `re_search + userQuery`, and auto-submits the pending intent when the new material-confirm pause arrives (cancellable pill). Stage chips + per-stage placeholder + 「当前交互对象」status badge. Backend tests `tests/test_ai_writing_intent.py`. Plan doc: `frontend-web/docs/ai-writing-对话驱动写作台-实现方案.md`. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). -- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). **简介模式** left-nav is a separate overlay: `/api/compact-menu-overrides` stores two independent sets (type `A` / `B`); each website picks one via `runtime-config.js` `VITE_COMPACT_MENU_TYPE`. - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 -- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」. -- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error. +- **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text). A premature SSE close (SDK treats it as success and restores the send button while the backend run is still occupying the thread) is recovered by `confirmOrRejoin`: hold the stop button, `joinStream` the live run, and on 409 auto-reconnect instead of a fake AI bubble. `sse_consumer` no longer ends a resumable (`on_disconnect=continue`) stream on a false-positive `is_disconnected()`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` diff --git a/browser-extension/deer-web-clipper/background.js b/browser-extension/deer-web-clipper/background.js index 104380f7..686b9660 100644 --- a/browser-extension/deer-web-clipper/background.js +++ b/browser-extension/deer-web-clipper/background.js @@ -5,6 +5,9 @@ const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001"; const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174"; let pollTimer = null; let isPolling = false; +let startupPromise = null; + +const POLLING_SCHEDULE_SETTING_KEYS = new Set(["taskPollingEnabled", "taskPollIntervalSeconds"]); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -12,6 +15,14 @@ function delay(ms) { async function getSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); + const discovered = await chrome.storage.local.get({ + discoveredApiBaseUrl: "", + discoveredWebBaseUrl: "", + }); + if (settings.autoDiscoverServiceUrl) { + settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl; + settings.webBaseUrl = discovered.discoveredWebBaseUrl || settings.webBaseUrl; + } const updates = {}; if (!settings.apiBaseUrl || settings.apiBaseUrl === LEGACY_LOCAL_API_BASE_URL) { updates.apiBaseUrl = DEER_CLIPPER_DEFAULTS.apiBaseUrl; @@ -69,10 +80,7 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { } }; - add(currentApiBase); add(webBase); - add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); - add(LEGACY_LOCAL_API_BASE_URL); try { const url = new URL(normalizeServiceBase(webBase)); @@ -85,6 +93,10 @@ function apiCandidatesForWebBase(webBase, currentApiBase) { // Ignore invalid candidates. } + add(currentApiBase); + add(DEER_CLIPPER_DEFAULTS.apiBaseUrl); + add(LEGACY_LOCAL_API_BASE_URL); + return candidates; } @@ -93,6 +105,12 @@ async function browserContextStatus(apiBaseUrl) { const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, { credentials: "include", }); + // The gateway may enforce authentication globally before this otherwise + // public discovery endpoint is reached. A 401/403 still proves that this + // candidate is the zncm API paired with the open frontend tab. + if (response.status === 401 || response.status === 403) { + return { enabled: true, authenticationRequired: true }; + } if (!response.ok) { return null; } @@ -201,9 +219,12 @@ async function discoverServiceBases(settings, force = false) { settings.webBaseUrl !== next.webBaseUrl || settings.lastServiceSyncSource !== next.lastServiceSyncSource ) { - await chrome.storage.sync.set({ - apiBaseUrl: next.apiBaseUrl, - webBaseUrl: next.webBaseUrl, + // Discovered endpoints are runtime state, not user-authored preferences. + // Keep them in storage.local so Chrome sync write quotas cannot break task + // polling. The options page may still persist explicit user choices. + await chrome.storage.local.set({ + discoveredApiBaseUrl: next.apiBaseUrl, + discoveredWebBaseUrl: next.webBaseUrl, lastServiceSyncAt: next.lastServiceSyncAt, lastServiceSyncSource: next.lastServiceSyncSource, }); @@ -826,19 +847,50 @@ async function pollOnce() { } } -async function startup() { +async function initializePolling() { let settings = await getSettings(); - settings = await discoverServiceBases(settings); - settings = await syncAuthToken(settings); + try { + settings = await discoverServiceBases(settings); + } catch (error) { + // Service discovery persists diagnostic metadata in storage.sync. Chrome can + // temporarily reject those writes after its quota is reached; polling must + // still start with the last known settings. + console.warn("zncm service discovery failed during startup", error); + } + try { + settings = await syncAuthToken(settings); + } catch (error) { + // A failed status/token write must not prevent alarms from being installed. + // pollOnce() will retry token synchronization before claiming each task. + console.warn("zncm token sync failed during startup", error); + } await refreshAlarm(settings); await scheduleNextPoll(1000); } -chrome.runtime.onInstalled.addListener(() => void startup()); -chrome.runtime.onStartup.addListener(() => void startup()); +function startup() { + if (!startupPromise) { + startupPromise = initializePolling().finally(() => { + startupPromise = null; + }); + } + return startupPromise; +} + +async function restartPollingSchedule() { + const settings = await getSettings(); + await refreshAlarm(settings); + await scheduleNextPoll(0); +} + +chrome.runtime.onInstalled.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); +chrome.runtime.onStartup.addListener(() => void startup().catch((error) => console.warn("zncm startup failed", error))); chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName === "sync") { - void startup(); + if ( + areaName === "sync" && + Object.keys(changes || {}).some((key) => POLLING_SCHEDULE_SETTING_KEYS.has(key)) + ) { + void restartPollingSchedule().catch((error) => console.warn("zncm polling reschedule failed", error)); } }); chrome.alarms.onAlarm.addListener((alarm) => { @@ -902,4 +954,4 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; }); -void startup(); +void startup().catch((error) => console.warn("zncm startup failed", error)); diff --git a/browser-extension/deer-web-clipper/background.test.cjs b/browser-extension/deer-web-clipper/background.test.cjs new file mode 100644 index 00000000..e1462ff8 --- /dev/null +++ b/browser-extension/deer-web-clipper/background.test.cjs @@ -0,0 +1,130 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const BACKGROUND_PATH = path.join(__dirname, "background.js"); + +function createHarness() { + let storageChangedListener; + let alarmCreateCount = 0; + const settings = { + apiBaseUrl: "http://localhost:8001", + webBaseUrl: "http://localhost:5174", + autoDiscoverServiceUrl: false, + accessToken: "", + tokenSyncEnabled: false, + taskPollingEnabled: true, + workerId: "test-worker", + taskPollIntervalSeconds: 5, + }; + + const chrome = { + alarms: { + clear: async () => {}, + create: async () => { + alarmCreateCount += 1; + }, + onAlarm: { addListener: () => {} }, + }, + cookies: { get: async () => null }, + runtime: { + onInstalled: { addListener: () => {} }, + onMessage: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + }, + storage: { + local: { + get: async (defaults) => ({ ...defaults }), + set: async () => {}, + }, + session: { get: async () => ({ actSessions: {} }), set: async () => {} }, + sync: { + get: async () => ({ ...settings }), + set: async (updates) => Object.assign(settings, updates), + }, + onChanged: { + addListener: (listener) => { + storageChangedListener = listener; + }, + }, + }, + tabs: { query: async () => [] }, + }; + + const context = { + chrome, + console, + fetch: async () => { + throw new Error("offline test"); + }, + importScripts: () => {}, + setTimeout: () => 1, + clearTimeout: () => {}, + URL, + DEER_CLIPPER_DEFAULTS: settings, + deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""), + deerMakeWorkerId: () => "test-worker", + deerAuthHeaders: () => ({}), + deerExtractAuthTokenFromPage: () => ({ token: "" }), + }; + context.globalThis = context; + vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, { + filename: BACKGROUND_PATH, + }); + + return { + emitStorageChange(changes) { + storageChangedListener(changes, "sync"); + }, + getAlarmCreateCount: () => alarmCreateCount, + resetAlarmCreateCount: () => { + alarmCreateCount = 0; + }, + getApiCandidates: (webBase, currentApiBase) => + vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context), + }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test("self-written sync metadata does not restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + lastTokenSyncAt: { oldValue: "before", newValue: "after" }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 0); +}); + +test("polling configuration changes restart browser task polling", async () => { + const harness = createHarness(); + await settle(); + harness.resetAlarmCreateCount(); + + harness.emitStorageChange({ + taskPollingEnabled: { oldValue: false, newValue: true }, + }); + await settle(); + + assert.equal(harness.getAlarmCreateCount(), 1); +}); + +test("service discovery prefers the API paired with the open zncm tab", async () => { + const harness = createHarness(); + + assert.deepEqual( + Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")), + [ + "http://localhost:5174", + "http://localhost:8001", + "http://47.94.209.59:2026", + "http://localhost:8001", + ].filter((value, index, values) => values.indexOf(value) === index), + ); +}); diff --git a/browser-extension/deer-web-clipper/content.js b/browser-extension/deer-web-clipper/content.js index 1c387461..abb388bc 100644 --- a/browser-extension/deer-web-clipper/content.js +++ b/browser-extension/deer-web-clipper/content.js @@ -7,6 +7,8 @@ const HOST_ID = "zncm-page-assistant-host"; const state = { enabled: true, + taskPollingEnabled: true, + webBaseUrl: "", open: false, busy: false, threadId: "", @@ -709,20 +711,58 @@ async function loadSettings() { const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS); state.enabled = Boolean(settings.pageAssistantEnabled); + state.taskPollingEnabled = Boolean(settings.taskPollingEnabled); + state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl); ensureWidget(); } chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== "sync" || !changes.pageAssistantEnabled) { + if (areaName !== "sync") { return; } - state.enabled = Boolean(changes.pageAssistantEnabled.newValue); - ensureWidget(); - if (state.enabled) { - setStatus("页面问答框已开启。"); + if (changes.pageAssistantEnabled) { + state.enabled = Boolean(changes.pageAssistantEnabled.newValue); + ensureWidget(); + if (state.enabled) { + setStatus("页面问答框已开启。"); + } + } + if (changes.taskPollingEnabled) { + state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue); + } + if (changes.webBaseUrl) { + state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl); } }); + function isZncmPage() { + const appPath = `${location.pathname}${location.hash}`.toLowerCase(); + if ( + appPath.includes("/page/workspace") || + appPath.includes("/chats/") || + appPath.includes("#/login") + ) { + return true; + } + try { + const configured = new URL(state.webBaseUrl); + return configured.origin === location.origin; + } catch { + return false; + } + } + + // MV3 service workers may be suspended between their short polling timers, + // while chrome.alarms cannot reliably run more often than every 30 seconds. + // A visible zncm tab provides a cheap wake-up signal so browser_act requests + // are normally claimed within the backend/model timeout window. + setInterval(() => { + if (!state.taskPollingEnabled || !isZncmPage()) { + return; + } + chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {}); + }, 3000); + setInterval(() => { if (!state.enabled || !ui.pageTitle) { return; diff --git a/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md b/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md new file mode 100644 index 00000000..21d1cde2 --- /dev/null +++ b/docs/WIKI_ROUNDTRIP_ACCEPTANCE_20260903.md @@ -0,0 +1,117 @@ +# Wiki 数据流转与本地编码验收(2026-09-03) + +## 结论 + +本地 BGE-M3 小批量编码已安装、配置并实际运行。已有 Wiki 的向量化、自动沉淀、 +普通库文件导出、助手库异步导入、助手库文件导出、空普通库回流及两端向量检索已实测通过。 +**技能自动生成 Wiki 尚未完成验收**:WeKnora 的两个生成模型都返回 HTTP 402 +`Insufficient Balance`。不能把后半段通过等同于全链路通过。 + +本次修改运行在本地 DeerFlow 开发服务(前端 5175、后端 8001),连接用户提供的远端 +WeKnora;没有把业务代码发布到服务器,没有提交 Git,也没有直接写 WeKnora 图数据库。 + +## 本地编码配置 + +- 实现:FastEmbed / ONNX Runtime CPU,官方 `BAAI/bge-m3` 权重。 +- 配置名:`bge-embedding-m3`,1024 维,每批 2 条,2 个 CPU 推理线程。 +- 运行依赖:FastEmbed 随标准后端依赖安装;模型必须提前放在离线目录中,仍按需加载,不影响主服务启动。 +- 权重:约 2.3 GB,持久缓存 `C:\Users\ji\.cache\deerflow\embeddings`,不进入业务仓库。 +- 配置:`llmwiki.local_wiki_index.embedding.provider: local`;可用 + `local_model_path` 指定离线权重目录,详见后端 README。 +- 查询向量在本地实时编码。已有 Wiki 在内容哈希、模型指纹匹配且向量非空时直接复用。 + 不同模型或维度不能混用;导入不静默重新编码。 +- 原始文档分段向量与模型生成后的 Wiki 文本不同,不能相互冒用。本次新建的是 Wiki + 正文的 BGE-M3 索引,不是把既有 512 维文档向量改名为 1024 维。 + +## 实际数据验收 + +来源是已有普通知识库“图片测试111”,没有改写其 Wiki 内容。 + +| 阶段 | Wiki | Wiki 向量 | 实体 | 引用关系 | +| --- | ---: | ---: | ---: | ---: | +| 普通知识库导出 | 19 | 101 | 19 | 108 | +| 助手知识库导入后导出 | 19 | 101 | 19 | 108 | +| 新建普通知识库回流后导出 | 19 | 101 | 19 | 108 | +| 知识梳理总库导出(7 个来源记录) | 19 | 101 | 19 | 108 | + +逐项断言通过: + +1. 三份包的 Wiki slug、标题、正文、摘要、页面类型、别名、分类路径、出链一致。 +2. 101 条向量的原始 float32 字节、分段编号、维度和模型指纹完全一致。 +3. 将导入前后实体 ID 映射回原 Wiki slug 后,108 条关系的端点及谓词集合完全一致。 +4. 总库导出没有因多个来源重复同步而重复导出同一当前页面的分段向量。 +5. 元数据回归测试另覆盖 tags、目录恢复、原始文档片段不计入 Wiki 数量、原生技能无 + ownership 记录时可被选中、短 Wiki 不漏向量、模型指纹不兼容时不假装向量检索。 + +普通包约 1.01 MB,助手包约 0.79 MB,回流包约 0.83 MB。普通包还带 2 个原始文档及 +108 个原始片段;助手导出提供 Wiki、Wiki 向量和关系,不重新上传原始文档触发二次生成。 + +### 检索 + +实际查询:“军方用什么工具评估极端天气带来的风险?” + +- 回流普通库 `/api/llmwiki/search` 返回 `mode=vector`,首条“国防气候评估工具”。 +- 助手库 `/api/assistant-knowledge/search` 返回 `retrieval_mode=vector`,首条同上; + 首个命中分段余弦相似度约 0.6343。 +- 浏览器实际提交总库检索,显示“向量检索 · 余弦相似度”和相同命中;不是只检查返回字段。 +- 普通库采用已有页面级聚合评分,因此页面最终分数与单分段余弦值不必相等。 +- 回流的向量写入 **DeerFlow 管理的本地 Wiki 索引**。未写入 WeKnora 原生向量数据库, + 本次结论适用于本系统问答检索,不能声称原生 WeKnora 自身的检索也已完成该向量导入。 + +### 页面 + +普通与助手库复用同一 Wiki 阅读组件:目录树、类型/标签筛选、分类面包屑、别名标签、 +正文内链、出链、反向链接及邻接关系视图。窄窗口可收起目录。 + +浏览器验证了“索引 → 国防气候评估工具”、展开关系视图、助手 Wiki 跳回普通库来源, +以及普通 Wiki 的“在知识梳理中查看”反向跳转。 + +### 验收数据位置 + +- 原有普通库:`48f669dc-433e-4aab-b5ea-897b6f7a3d2f` +- 知识梳理总库:`c3fd9841-0718-44dc-860e-aae3020fd485` +- 最终助手测试库:`489be905-08c8-40fe-8d60-42ac21dd945a` +- 最终普通回流测试库:`428162aa-03f6-4c61-8994-360f2f39e0c9` + +新建测试库名称均以“Wiki链路验收-0903-1826”开头,保留用于复核;未删除用户已有库。 +本地 `.runtime/wiki-live-result.json` 保存验收结果,`.runtime/wiki_live_acceptance.py` +保存本次验收流程,均为忽略的运行产物,不包含认证口令。 + +## 这次实际修复的问题 + +- 内置技能只有磁盘目录、没有归属记录时,被错误判定为不存在。 +- 技能应上传去代码后的知识正文,让 WeKnora 生成 Wiki,而不是伪造一个 `skill` 类型页面。 +- Wiki 生成等待使用实际 stats 接口;零页结果明确失败,不无期限等待。 +- 向量化完成后自动导出沉淀到总库,并阻断导出回调重复触发全量同步。 +- 短摘要和短正文同时存在时,旧分段阈值会将整页过滤为零向量。 +- 分类路径必须先创建 WeKnora folder 并绑定 folder_id;只传 category_path 会被服务端清空。 +- Wiki 的标签/分类/别名/原始引用保留;图节点绑定真实 Wiki,避免生成重复的空实体页面。 +- 保留归一化 float32 原始字节;导入中不能用 `section_index or index`,因为合法编号 0 会被改写。 +- 总库多个来源保留审计,但检索与导出只使用当前 Wiki 版本对应的向量,避免旧贡献污染检索。 +- 包解析改为同盘临时 SQLite,上传先落盘再异步导入,导出完成后才提供文件下载。 + +## 阻塞与未通过的范围 + +1. 技能 `knowledge-base-ingest` 已送入专门创建的普通测试库,但远端 + `deepseek-v4-flash` 和 `qwen3.6-flash` 均因余额不足失败。WeKnora 即使失败也可能把 + 文档 parse_status 标为 completed。需恢复可用生成模型后重新运行技能全链路验收。 +2. 已采用磁盘暂存和有界批处理,但本次只有约 1 MB 实测,**未做数 GB 压测、断点续传、 + 进程中断恢复验收**。大包部署仍需磁盘容量、代理 body size 和超时配置。 +3. 当前实体去重主要按类型及规范化名称;这次没有完成跨别名、歧义实体的模型对齐及 + 多来源事实冲突合并验收,不能视为这一历史需求已全面完成。 +4. 重复上传仍会保留独立来源记录。向量导出已消除旧版本重复,但上传身份幂等、部分远端 + 写入失败后的无损重试、跨实例图片资源打包仍需独立验证。 +5. 当前版本已补充自动增量链路:服务启动立即补扫,之后默认每 30 秒扫描全部普通 + 知识库;Wiki 抽取完成后无需手工点击向量化,并自动以来源快照覆盖同步到知识梳理总库。 +6. 回流当前只允许新建空 Wiki 普通库,避免覆盖已有页面。普通库现有页面列表的超大规模 + 分页与助手问答的最终生成回答不属于这轮通过结论。 + +## 自动化检查 + +- 后端相关测试:118 passed(assistant repository、Wiki sync、package roundtrip、 + WeKnora 接口、向量检索与知识库管理)。 +- 修改的 Python 功能文件 Ruff 检查通过;app.py 既有启动顺序导致的 E402 不在此次改动中处理。 +- `git diff --check` 通过(只有仓库换行提示)。 +- 前端完整 `pnpm typecheck` 未通过:57 个既有/本轮范围外错误,涉及 nextra 缺失、Markdown + 插件类型及其他模块;本轮新建/修改的 Wiki 组件、助手 API、技能面板没有出现在错误清单中。 + 浏览器实际页面与跳转已验证,但不能声称全项目构建通过。 diff --git a/docs/agentscope-多智能体报告协作工作台-交互重设计方案.md b/docs/agentscope-多智能体报告协作工作台-交互重设计方案.md new file mode 100644 index 00000000..8fc89ed0 --- /dev/null +++ b/docs/agentscope-多智能体报告协作工作台-交互重设计方案.md @@ -0,0 +1,186 @@ +# 报告协作工作台 — 交互重设计方案(v2) + +> 日期:2026-09-07 +> 状态:**待确认**(确认后按 §7 分期实施) +> 取代:现行「规则澄清 + 三张模板方案卡 + 画布为主」的交互 +> 原则:回到最初设计 —— **协调者智能体全程与用户对话的主线** + +--- + +## 1. 设计目标与总体形态 + +用户视角只有一条主线:**和一个规划师智能体对话 → 它摸清意图并规划工作流 → 确认后执行、过程在流程图上可见 → 随时插话干预、指定智能体重跑 → 最终成稿、继续对话式修改**。 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 阶段1 计划模式 阶段2/3 执行+干预 阶段4 成稿 │ +│ ┌───────────┐ ┌───────────┐ ┌──────────┐ │ +│ │ 规划师对话 │ 确认 │ 聊天时间线 │ 审稿过 │ 对话改稿 │ │ +│ │ 需求摘要卡 │ ─────→ │ +成员Tab │ ─────→ │ 版本/diff│ │ +│ │ 工作流卡 │ 冻结 │ +流程图动画│ 报告卡 │ 上游重跑 │ │ +│ └───────────┘ └───────────┘ └──────────┘ │ +│ ↑ 回到计划模式(replan)随时可触发 ↑ │ +└─────────────────────────────────────────────────────────┘ +``` + +与现行的核心差异: + +| 维度 | 现行(做偏的) | v2 | +|------|----------------|-----| +| 需求澄清 | 规则模板问句秒回 | 规划师智能体(LLM)领域化提问 | +| 工作流来源 | 3 张确定性模板 | LLM 按意图生成自定义 DAG,对话迭代修改 | +| 执行展示 | 画布为主、聊天为辅 | **聊天时间线为主**(成员 Tab 切换保留),**执行状态在流程图上动画呈现** | +| 干预 | 命令确认卡体系 | 输入框常开 → 协调者智能体分类;支持**指定智能体重跑** | +| 报告修改 | 改写候选(纯文字层) | 意图分流:润色走改写;**事实性问题触发上游节点重跑** | + +--- + +## 2. 阶段 1:计划模式 — 需求梳理与工作流规划 + +### 2.1 计划模式状态 + +- 用户发出第一条需求消息起进入**计划模式**,Header 显示徽章「📋 计划模式」,输入框提示语切换为「梳理需求中,你可以随时补充或纠正…」。 +- 计划模式在**工作流确认冻结**时结束;执行期大改可通过 replan 回到计划模式(见 §5.4)。 + +### 2.2 规划师对话(LLM,替换现行规则路由) + +- 模型:`report_collaboration.planner_model`(回退 `coordinator_model` → 默认模型,复用现有 model_resolver 冻结机制)。 +- 提问是领域化的:写市场分析就问「关注整车还是供应链 / 要不要竞品对比 / 给谁看 / 篇幅」,而非固定模板问句。 +- 澄清轮次上限沿用 `max_clarification_rounds`;用户随时可以说「直接开始」按默认假设推进。 + +### 2.3 需求摘要卡 + +信息足够后,规划师在对话中输出一张**结构化需求摘要卡**(业务卡插槽渲染):主题 / 目的 / 读者 / 角度清单 / 篇幅与重点 / 默认假设。用户逐项看,可在对话里继续改(「读者改成投资人」→ 卡片更新出新版)。 + +### 2.4 工作流生成与迭代 + +- 用户确认摘要后,规划师生成**一份**工作流方案(不是三选一):以工作流卡(成员构成、各节点使命、预计时长/成本)+ 右侧流程图预览呈现。 +- 生成约束(保证可执行):**节点类型受词表约束** — `research / verify / analyze / write / review`;自由的是结构(几条线、并行还是串行、核验深度)与每个节点的使命/角度/工具。 +- 对话式修改:「把价格策略单独拆一条线」「加一个政策风险分析」→ 规划师修订后出新版工作流卡(带变更说明),再确认。 +- 「开始执行」→ 冻结需求 + 工作流(含模型/预算快照,沿用现有 freeze 机制)。 + +--- + +## 3. 阶段 2:执行 — 过程在流程图上可见 + +### 3.1 流程图动画规格(执行状态的可视化主载体) + +节点状态 → 样式(`ReportFlowCanvas` / `ReportFlowNode`,状态源 `selectNodeStatusMap`,事件源 `node.status.changed` SSE): + +| 状态 | 节点样式 | 连入边 | +|------|----------|--------| +| planned / queued | 灰色常态 | 静态 | +| **running** | 主色描边 + **呼吸脉冲动画**(ring)+ 角标 spinner | **流动动画**(@xyflow 内置 `animated: true` 的 dash 流动) | +| **completed** | 绿色 + ✓ 角标,常驻 | 恢复静态实线 | +| failed | 红色 + 错误角标 | 红色虚线 | +| awaiting_input | 橙色 + ?角标 | 橙色静态 | +| interrupted / superseded | 半透明降级 | 灰 | + +- 语义:**边流动 = 数据正在流入该节点**(该节点执行中);节点完成即停动画换完成样式。 +- 实现成本:@xyflow 原生支持 animated 边与自定义节点 className,纯 CSS 补呼吸动画即可,无需新依赖。 + +### 3.2 聊天区(保留现有 Tab 切换) + +- 并行成员沿用 **AgentStageSelector Tab 切换**(维持现状,不改交错时间线)。 +- 每个 Tab 内是该成员的完整流式输出:思考、工具调用卡、检索过程、正文 —— 与普通智能体问答同观感(message-adapter 已具备映射能力)。 +- 协调者播报(「研究线 A 完成,核心发现 X,开始交叉核验」)进入总时间线 Tab。 + +--- + +## 4. 阶段 3:干预 — 对话即干预 + 指定重跑 + +### 4.1 输入框常开,协调者智能体分流 + +执行期输入框永远可用,所有输入先到协调者(LLM 分类,规则高精度直判兜底——复用 RC-BE-014 IntentRouter 骨架,把「规则优先」反转为「LLM 优先 + 规则快路径」): + +| 用户意图 | 协调者行为 | 生效时机 | +|----------|-----------|----------| +| 提问(「查到什么了?」) | 直接回答 | 立即,不打断 | +| 修正方向(「重点看下沉市场」) | 小改:在安全点注入 overlay,并回复说明 | 下个安全点 | +| **指定重跑**(见 §4.2) | 出确认卡 → 重开节点 attempt | 确认后 | +| 大改(砍线/加线) | 提议 replan → 回计划模式 | 用户确认 | +| 停止 | 直接取消 | 立即 | + +### 4.2 指定智能体重跑(新增核心交互) + +**场景**:用户发现某节点传给下游的信息不对(如研究线 B 的结论有误)。 + +两个入口,同一后端动作: + +1. **对话**:「研究线 B 的结果不对,重新查,重点看价格战」→ 协调者解析出 `node_id` + 新指令; +2. **画布**:点节点 → 抽屉(NodeInspectorDrawer)加「↻ 重新执行」按钮。 + +**级联规则**(复用并补全 ledger 的 targeted rework): + +``` +重跑节点 N(attempt+1,带用户新指令作为 mission overlay) + → N 完成 → 所有直接/间接依赖 N 产物的下游节点标记 superseded + → 按 DAG 依赖序逐波重跑(边动画重新流动,用户全程可见) + → 受影响章节重新生成 +``` + +- 确认卡必须展示**影响面**:「将级联重跑 核验B / 分析 / 写作 共 3 个节点,预计 +8 分钟」。 +- 成本保护:级联重跑计入 run 预算(现有 budget 机制),预算不足时明确报因。 + +--- + +## 5. 阶段 4:报告成稿与对话式修改 + +### 5.1 成稿 + +审稿通过 → 报告卡出现在时间线(点开 ReportPreviewDialog 全文),协调者播报总结(各线核心发现 → 结论如何形成)。 + +### 5.2 修改意图分流(协调者 LLM 判定) + +| 用户说 | 判定 | 动作 | +|--------|------|------| +| 「把第二章结论写得更笃定」 | 文字润色 | 改写候选(现有 rewrite API)→ 对比预览 → apply → 新版本 | +| 「这个数据不对 / 重新收集一下」 | **事实性问题** | 定位来源节点 → **触发该节点重跑(可带新角度)→ 级联 §4.2 → 受影响章节重生成 → 新版本** | +| 「再从供应链角度补一节」 | 结构性新增 | replan:回计划模式改工作流(只增不删已完成部分) | +| 「恢复到上一版」 | 版本回退 | 现有 restore API | + +- 版本链:每次 apply / restore / 级联重跑产出均产生新 head,历史完整保留(现有机制)。 +- 修改对话里协调者必须先**复述理解**(「你是想让研究线 B 换个角度重查,然后重写第三章,对吗?」)再执行,避免误触发昂贵的级联。 + +--- + +## 6. 工程改造清单 + +### 6.1 保留(本次联调已验证) + +- 持久化 13 张表 + durable SSE + `after_seq` 断线续传;幂等键 / 乐观锁 / 预算框架。 +- MessageList 复用链路(message-adapter → LangGraph Message → 业务卡插槽)。 +- AgentStageSelector Tab、commands 安全点骨架(RC-BE-014)、报告版本三件套(RC-BE-015)、前端 hooks/reducer/selectors 主体。 +- 「清空历史」「生成候选方案」等本次新增入口。 + +### 6.2 后端重做 / 新建 + +| 项 | 说明 | +|----|------| +| 规划师智能体 | LLM 多轮澄清(替换 `requirement/intent_router.py` 规则路由)+ 需求摘要结构化 + **工作流 LLM 生成**(受 §2.4 词表约束,替换 `planning/controller.py` 三模板)+ 对话式工作流修订 | +| 协调者智能体升级 | 干预分类 LLM 优先;修改意图分流(§5.2);执行前复述确认 | +| 指定重跑 + 级联 | ledger targeted rework 补全级联 superseded/重跑波次;确认卡带影响面计算 | +| AgentScope 适配器修复 | `count_tokens` 协议缺失(上下文压缩必崩);attempt 耗尽终态化(防僵尸 run) | +| 事件补齐 | 边流动所需的节点状态事件已有;补「级联影响预告」事件 | + +### 6.3 前端重做 / 新建 + +| 项 | 说明 | +|----|------| +| 计划模式 UI | Header 徽章、composer 提示切换、需求摘要卡、工作流卡(替换现三张模板卡) | +| 流程图动画 | running 节点呼吸动画 + 入边 `animated`、completed ✓ 样式、级联重跑时边动画重流(§3.1) | +| 节点抽屉 | 加「重新执行」按钮 → 确认卡 | +| 干预交互 | 执行期输入框常开;意图确认卡(含影响面);修改对话复述确认 | +| 布局 | 聊天为主、画布为辅的默认比例微调(不动整体两栏结构) | + +--- + +## 7. 实施分期 + +| 期 | 内容 | 验收 | +|----|------|------| +| **P0 地基** | AgentScope `count_tokens` 修复;attempt 耗尽终态化;级联重跑的 ledger 补全 | 执行阶段不崩、无僵尸 run、单节点重跑级联正确(后端测试) | +| **P1 计划模式 + 执行可视化** | 规划师 LLM 对话、需求摘要卡、工作流 LLM 生成与迭代、计划模式 UI、流程图动画 | 从新会话到确认工作流全程对话完成;执行时动画正确随状态流转 | +| **P2 干预 + 成稿修改** | 协调者 LLM 分类、指定重跑(两入口 + 影响面确认卡)、报告修改意图分流、上游级联重跑 | 四类干预各自走通;「数据不对→重跑→新章节→新版本」全链路 | + +每期结束跑 RC 全量测试(当前基线 233 通过)+ 前端 26 单测,不引入对其他模块的影响。 diff --git a/docs/agentscope-多智能体报告协作工作台-前端实施计划.md b/docs/agentscope-多智能体报告协作工作台-前端实施计划.md new file mode 100644 index 00000000..55f3543e --- /dev/null +++ b/docs/agentscope-多智能体报告协作工作台-前端实施计划.md @@ -0,0 +1,719 @@ +# AgentScope 2.0 多智能体报告协作工作台——前端实施计划 + +> 文档状态:前端代码已落地、契约冻结;联调等待后端运行中干预与报告改写 +> 编写日期:2026-09-02;进度更新日期:2026-09-06 +> 目标项目:`F:\react01\deeflow-code\deerflow-server\frontend-web` +> 视觉与交互参考:`F:\react01\coze-frontend\frontend\apps\coze-studio` +> 参考页面:`http://localhost:3000/work_flow?space_id=1&workflow_id=wf_88cb9f4c6642` +> 配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` +> 本文只规划新增页面,不修改现有多智能体会商页面,不复用旧 Workflow Studio 执行状态。 + +## 0. 当前进度 + +`frontend-web/src/report-collaboration/` 已实现两栏工作台、会话/方案/run API 客户端、mock driver、SSE reducer、`MessageList` 适配、方案卡、画布、节点抽屉、Composer、报告预览与历史。`RC-FE-001`~`013` 的页面骨架与契约层视为完成。 + +后端已通规划前路径(澄清 → 出方案 → 选定 → `queued` run),见后端计划 §0。前端真实执行体验仍受阻: + +- `GET /runs/{id}/stream` 已挂载(`RC-BE-011`);dispatcher/租约已落地(`RC-BE-012`),默认 `worker_enabled=false` 仍不自动领取 queued run。 +- 报告改写 / 恢复接口已由后端 `RC-BE-015` 挂载。 +- 质量角色已由 `QualityRoleKernel` 跑通(`RC-BE-013`);真实 AgentScope 模型/`web_search` 仍待后续票。 + +联调顺序:先打开 `report_collaboration.enabled` 验证规划前 REST 与 `GET /runs/{id}/stream` 重放;画布自动执行需同时打开 `worker_enabled`(默认关)。 + +## 1. 前端目标 + +新增独立的「报告协作」工作台。用户可以通过对话提出任意报告需求,查看系统生成的多个协作方案,点击方案卡片在流程图中预览协作结构,确认后观察多智能体执行,并在全过程通过统一对话框提出修改意见。 + +前端必须满足: + +1. 页面采用“左侧对话、右侧流程画布”的固定两栏布局,画布、工具栏、弹窗和抽屉参考 Coze Studio 页面风格。 +2. 消息列表必须复用 DeerFlow 现有 `MessageList`,不复制 Coze 的会话消息实现。 +3. 所有执行过程、AgentScope 团队消息、智能体回复、工具调用及其原始返回、错误、协助卡、返工和最终报告入口都通过现有消息协议按时间顺序显示在左侧对话区,不设置独立执行结果面板,也不另做“阶段摘要”替代实时消息。 +4. 候选方案、流程节点、对话进度和报告使用同一份后端事实,不各自推断状态。 +5. 用户可在规划中、运行中、等待输入和报告完成后继续对话。 +6. 用户协助卡的出现和提交都不能在前端被解释为“节点已经完成”。 +7. 页面刷新、SSE 断线重连、历史会话切换后能恢复到一致状态。 + +暂定: + +- 页面名称:`报告协作`。 +- 路由:`/page/workspace/report-collaboration`。 +- 会话路由:`/page/workspace/report-collaboration/:sessionId`。 +- 默认候选方案数量:3。 +- 点击画布节点时打开覆盖式节点说明抽屉;抽屉只展示节点职责和合同,不展示节点执行结果。 + +--- + +## 2. 范围与非目标 + +### 2.1 本期范围 + +- 新建、查看、重命名、删除协作报告会话。 +- 使用对话澄清报告需求。 +- 显示 2~3 个候选方案卡片。 +- 卡片与右侧流程图预览联动。 +- 节点说明抽屉:职责、所依据的信息类型、预期产出、工具、资料要求和验收条件。 +- 实时展示节点排队、运行、校验、等待用户、完成、返工、失败和取消。 +- 在左侧对话时间线中直接展示每个智能体的流式文本、团队对话、工具调用和工具返回,并展示审稿意见、报告文件和报告版本。 +- 并行阶段用横向的智能体选择器切换当前查看对象;选择器只控制同一份消息流的聚焦范围,不创建另一套执行结果视图。 +- 运行期间发送补充要求、重新检索、重新分析、节点重跑等指令。 +- 完成后进行报告问答、章节重写、全文润色和版本恢复。 +- 报告模板选择、Markdown 预览、Markdown/Word 下载。 +- 响应式布局、键盘操作、加载反馈和错误恢复。 + +### 2.2 明确不做 + +- 不引入 `@coze-workflow/playground` 或 `@coze-arch/coze-design`。 +- 不加载 Coze workflow definition、run 或 node run。 +- 不允许前端拖拽连线后直接改变执行逻辑。 +- 不新增第二套消息气泡、Markdown、引用和文件卡渲染器。 +- 不展示模型原始 chain-of-thought、系统/内部 prompt、密钥和被标记为内部控制信息的字段。 +- 不把用户有权查看的 AgentScope TeamSay、智能体文本、工具调用或工具返回压缩成“正在检索”等摘要卡;这些原始业务消息必须进入复用的 `MessageList`。敏感字段只能按统一脱敏策略替换,不能以摘要替代。 +- 不增加第三栏、常驻详情栏、节点执行结果栏或独立执行时间线。 +- 节点抽屉不展示节点生成正文、工具返回、证据列表、错误堆栈或 attempt 历史;这些过程统一在左侧 `MessageList` 的原生消息/工具步骤中查看。 +- 不改造现有圆桌、多智能体会商、深度研究和 AI 写作页面。 +- 不把浏览器状态作为业务事实源。 + +--- + +## 3. 参考页面行为映射 + +| 目标行为 | Coze 参考文件 | DeerFlow 目标组件 | 说明 | +|---|---|---|---| +| 两栏骨架 | `workflow-studio-layout.tsx` 的面板比例与分隔条 | `ReportCollaborationLayout.tsx` | 左对话、右画布;只保留一条可调整分隔线 | +| 顶栏 | `workflow-studio-header.tsx` | `ReportCollaborationHeader.tsx` | 替换为会话、模板、报告、停止等操作 | +| 候选卡片 | `workflow-proposal-cards.tsx` | `ReportPlanCards.tsx` | 点击预览,明确确认后才执行 | +| 右侧画布 | `workflow-canvas.tsx` | `ReportFlowCanvas.tsx` | 使用 `@xyflow/react` 重建,不引入 Coze Playground | +| 左侧消息区 | `run-conversation-panel.tsx` | `ReportConversationPanel.tsx` | 外层风格参考 Coze,内部复用 DeerFlow `MessageList` | +| 节点说明抽屉 | Coze 节点配置面板的视觉与抽屉行为 | `NodeInspectorDrawer.tsx` | 点击画布节点打开,只展示职责与输入输出合同 | +| 全程执行反馈 | 参考其进度和状态表达 | `AgentStageSelector` + DeerFlow `MessageList` | 选择智能体后直接显示其流式消息、TeamSay、工具调用与返回;不再合成为阶段摘要 | +| 状态栏 | `connection-status.tsx` | `ConnectionStatusBar.tsx` | 展示连接、重连和事件游标 | +| 对话历史 | `left-panel-tabs.tsx` | `ConversationSidebarTabs.tsx` | 对话与历史,不增加“节点资源”Tab | +| 异步反馈 | 规划进度、运行状态 | MessageList 步骤条 + 画布状态 | 操作超过 300ms 必须有反馈 | + +主要参考源: + +- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-layout.tsx` +- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-composition.tsx` +- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\workflow-studio-header.tsx` +- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\components\run-conversation\workflow-proposal-cards.tsx` +- `F:\react01\coze-frontend\frontend\apps\coze-studio\src\workflow-standalone\styles.module.less` + +若直接复制 Apache-2.0 源码片段或样式,必须保留版权头并维护来源说明;默认做行为映射和视觉重建。 + +--- + +## 4. 页面结构 + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ 返回 报告协作 / 会话标题 保存状态 模板 报告 停止/继续 更多 │ +├────────────────────────────────┬─────────────────────────────────────────┤ +│ 对话 / 历史 │ 协作流程图 │ +│ │ │ +│ DeerFlow MessageList │ @xyflow/react │ +│ · 并行时:[协调者][市场][政策] │ · 节点状态与活动连线 │ +│ · 用户与协调者/成员消息 │ · 缩放、适配视图、小地图 │ +│ · 原生 ToolCall / ToolMessage │ · 点击节点打开覆盖式说明抽屉 │ +│ · 协助卡、报告文件和版本 │ │ +│ │ │ +│ 统一输入框 │ │ +├────────────────────────────────┴─────────────────────────────────────────┤ +│ 已连接 · run_xxx · 事件 128 │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +节点抽屉覆盖在右侧画布之上,不占用第三栏: + +```text +┌─────────────────────────────────────────┐ +│ 节点名称 关闭 × │ +│ 角色 / 当前状态 │ +├─────────────────────────────────────────┤ +│ 节点职责 │ +│ 要根据什么:所需输入、上游类型、资料要求 │ +│ 要产出什么:成果类型、格式、验收条件 │ +│ 可用工具与边界 │ +│ 上下游关系 │ +├─────────────────────────────────────────┤ +│ 在对话中对此节点提出意见 │ +└─────────────────────────────────────────┘ +``` + +抽屉明确不展示实际执行结果、生成正文、工具返回、来源明细、错误堆栈或 attempt 时间线;这些信息只在左侧原生消息流中展示。 + +布局默认值: + +- 顶栏高度:56px。 +- 左侧面板:默认 480px,最小 360px,最大 720px。 +- 右侧画布:最小 480px,使用剩余空间。 +- 节点说明抽屉:覆盖画布右侧,建议宽度 420px;关闭后完整归还画布空间。 +- 状态栏:24~28px。 +- 低于 960px:左侧对话和右侧画布改为顶部按钮切换的单面板模式;节点说明仍使用覆盖式 Drawer。 +- 两栏分隔条:支持 pointer 拖动、方向键、Home、End。 + +### 4.1 原生消息流与并行智能体切换 + +“复用 `MessageList`”在本页面的含义是复用它现有的 assistant 文本、`tool_calls`、`tool` 消息、引用、文件和协助卡渲染语义;不把 AgentScope 的运行过程先解析成专用步骤条、进度摘要或阶段结果卡。 + +单个智能体: + +- `agent.reply.started` 立即创建一条稳定的 assistant 消息;`message.delta` 持续更新同一 `messageId`,所以用户会先看到流式状态、再看到逐字回复,而不是等待节点结束。 +- 工具调用创建/更新该 assistant 消息的 `tool_calls`;工具返回创建匹配 `tool_call_id` 的原生 `tool` 消息。现有 `MessageGroup` 负责把两者配对显示,不再另写工具过程渲染器。 +- AgentScope TeamSay 作为带 `agentRunId` 的普通 assistant 消息进入同一流,按到达顺序显示。它不是节点完成的证据,节点状态仍由右侧画布和后端状态机决定。 + +并行智能体: + +```text +多角度并行研究 3/4 进行中 +[ 协调者 ] [ 市场规模 ✓ ] [ 品牌竞争 ● ] [ 海外市场 ● ] [ 政策风险 ! ] +└──────────────────── 当前选中智能体的原生 MessageList ────────────────────┘ +``` + +- `AgentStageSelector` 只在并行阶段出现,横向放置在左侧对话区顶部;使用现有图标体系的头像/角色图标、名称、状态文字和非纯颜色状态标识。默认选中第一位已开始运行的成员;用户手动切换后保持选择,不因其他成员返回而抢走阅读焦点。 +- 所有成员的事件都持续接收、按每个 `agentRunId` 持久化和更新,即使当前未选中;切回即可看到完整历史与正在更新的同一条消息,不丢 token、不重新请求、不把结果拼成摘要。 +- 当前选择只将共享用户消息、协调者广播消息和目标成员消息组成一个 `BaseStream` 交给同一个 `MessageList`。切换标签仅切换该视图的消息范围,不改变 run、节点或消息的后端事实。 +- 某成员请求用户协助时,其标签显示“待回复”徽标,选择器显示待处理数;点击徽标切到该成员,原生协助工具消息仍由现有 `MessageList` 协助卡渲染。其他成员可继续运行。 +- 节点抽屉打开某并行成员时,同步聚焦对应标签;抽屉仍只展示合同。用户从抽屉或输入框发送反馈时带 `targetNodeId`/`agentRunId`,后端在安全点生效。 + +这不是把完整日志另做成调试面板:对话区展示的是模型和工具的真实业务消息;只有 chain-of-thought、内部提示词及按权限必须脱敏的字段不进入该消息流。 + +### 4.2 页面主要状态 + +| 状态 | 左侧对话区 | 右侧流程画布 | 主要操作 | +|---|---|---|---| +| `empty` | 欢迎语和输入框 | 空态 | 输入需求、选模板 | +| `clarifying` | 澄清问题和协助卡 | 需求理解节点 | 回答或补充 | +| `planning` | 用户消息和规划进度 | 骨架或需求图 | 继续补充,等待安全点生效 | +| `proposal_ready` | 2~3 张方案卡 | 点击卡片预览对应图 | 切换、修改、采用 | +| `running` | 协调者与成员原生消息、工具过程和用户反馈 | 实时节点状态 | 干预、停止、在对话中要求重跑 | +| `awaiting_input` | 等待用户卡片、原因和影响 | 对应节点显示等待 | 回答、取消 | +| `reviewing` | 审稿意见和返工过程 | 评审/返工节点状态 | 接受或补充要求 | +| `completed` | 总结、报告文件卡、版本和后续对话 | 完整执行图 | 问答、改写、导出 | +| `failed` | 错误说明和恢复建议 | 失败节点 | 通过对话重试或取消 | +| `cancelled` | 已保留成果说明 | 未完成节点取消 | 从已有成果继续或新建方案 | + +--- + +## 5. 前端目录与组件边界 + +建议新增: + +```text +frontend-web/src/report-collaboration/ + api/ + client.ts + sessions.ts + plans.ts + runs.ts + reports.ts + types.ts + components/ + ReportCollaborationLayout.tsx + ReportCollaborationHeader.tsx + ConversationSidebarTabs.tsx + ReportConversationPanel.tsx + AgentStageSelector.tsx + ReportComposer.tsx + ReportPlanCards.tsx + ReportFlowCanvas.tsx + ReportFlowNode.tsx + NodeInspectorDrawer.tsx + ReportPreviewDialog.tsx + InterventionCard.tsx + SessionHistoryPanel.tsx + ConnectionStatusBar.tsx + hooks/ + useReportCollaborationSession.ts + useReportCollaborationStream.ts + useReportCollaborationMessages.ts + useReportPlanSelection.ts + useReportPanelLayout.ts + state/ + event-normalizer.ts + event-reducer.ts + selectors.ts + message-adapter.ts + pages/ + ReportCollaborationPage.tsx + styles/ + report-collaboration.css + tests/ +``` + +修改范围: + +- `frontend-web/src/pages/WorkspaceRoutes.tsx`:增加页面和会话路由。 +- `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx`:增加导航入口。 +- `frontend-web/src/components/workspace/messages/message-list.tsx`:仅允许增加一个向后兼容的通用业务卡片插槽。 +- `frontend-web/src/core/messages/utils.ts`:如确需识别通用业务卡类型,只做集中式扩展。 + +不得把新功能放进: + +- `frontend-web/src/roundtable-planning/` +- `frontend-web/src/open-canvas/` +- Coze 项目的 `workflow-standalone/` + +--- + +## 6. 数据来源与前端状态 + +### 6.1 三层状态 + +前端状态分为: + +1. 服务器快照:session、原生 messages、plans、run、nodes、artifacts、report versions;每条消息带稳定的 `agentRunId`、`nodeRunId`、`phaseId` 和可见性标记,其中 artifacts 只用于生成左侧消息卡和报告文件,不形成独立结果面板。 +2. durable SSE reducer:从 `seq` 连续应用事件,更新服务器快照投影。 +3. 纯展示状态:预览中的方案、选中的节点、聚焦的并行 `agentRunId`、两栏宽度、节点抽屉开关、窄屏当前视图和画布位置。 + +纯展示状态不能改变执行结果。点击卡片只改变 `previewPlanId`;只有点击“采用方案”才改变服务器 `selectedPlanId`。 + +### 6.2 前端事件包 + +```ts +interface CollaborationEvent { + eventId: string; + sessionId: string; + runId?: string; + seq: number; + type: string; + timestamp: string; + data: T; +} +``` + +必须处理: + +- 快照先到、事件后到。 +- 事件重复。 +- 事件短暂乱序。 +- SSE 断线后从 `after_seq` 重放。 +- 页面切换时旧连接晚到事件。 +- token delta 多次更新同一消息。 +- TeamSay、assistant 文本、tool-call 参数 delta、tool 返回 delta 分别更新各自稳定消息,不得等节点完成后批量生成摘要。 +- 并行成员的消息可交错到达;Reducer 必须按 `agentRunId + messageId` 更新,而不是按当前被选中的标签丢弃未聚焦成员的事件。 +- 节点重新打开后出现新 attempt。 + +Reducer 以 `sessionId + runId + seq` 去重;消息行以 `messageId`、工具结果以 `toolCallId`、标签以 `agentRunId` 使用后端稳定 ID,禁止使用数组下标。 + +--- + +## 7. 分步实施计划 + +### FE-0:冻结参考视觉与行为基线 + +任务: + +1. 在 1440×900、1280×800、1024×768 打开参考页面。 +2. 保存初始态、方案态、运行态和节点说明抽屉四组截图。 +3. 记录顶栏、两栏比例、间距、圆角、边框、阴影、画布工具栏和状态色。 +4. 形成“参考组件—目标组件—允许差异”表。 +5. 明确消息列表保持 DeerFlow 原样,不纳入像素对齐。 + +交付物: + +- 视觉基线截图。 +- 视觉 token 表。 +- 页面行为清单。 + +验收:参考的每个用户可见状态都有截图或明确说明不存在。 + +### FE-1:路由、菜单和静态页面骨架 + +任务: + +1. 增加两个路由和菜单项。 +2. 创建 `ReportCollaborationPage`。 +3. 创建顶栏、左侧对话栏、右侧画布和底部连接栏。 +4. 实现可调整宽度的两栏布局,以及“专注对话/专注画布”切换。 +5. 实现窄屏单面板切换与覆盖式节点 Drawer。 + +验收: + +- 不连接后端也能呈现完整空态。 +- 375~1440px 不出现页面级横向滚动。 +- 对话/画布切换不会卸载会话状态或清空输入内容。 +- 键盘可以调整两栏分隔条。 + +### FE-2:页面设计系统与 Coze 风格重建 + +建立页面专用语义变量: + +```text +--rc-bg-page +--rc-bg-panel +--rc-bg-canvas +--rc-border +--rc-text-primary +--rc-text-secondary +--rc-accent +--rc-running +--rc-waiting +--rc-success +--rc-danger +--rc-panel-shadow +--rc-card-radius +``` + +规则: + +- 从 DeerFlow 主题 token 派生,不在组件中散落十六进制颜色。 +- 使用 Lucide SVG 图标,不使用 emoji。 +- hover/focus/展开动画控制在 150~300ms。 +- 支持 `prefers-reduced-motion`。 +- 交互状态不能只靠颜色表达。 +- 正文对比度达到 4.5:1。 +- 操作超过 300ms 显示 spinner、骨架或流式状态。 + +验收:除 MessageList 内部外,两栏布局、密度、卡片、画布工具栏、弹窗和节点抽屉达到参考基线。 + +### FE-3:API 类型和 TanStack Query 层 + +实现: + +- session CRUD。 +- messages 查询与发送。 +- plan request、plans 查询、select。 +- run 创建、查询、停止、重试。 +- command 创建、确认、取消。 +- artifacts、sources、reports、versions 查询。 + +每个写请求携带: + +- `idempotencyKey` +- `expectedRevision` + +冲突行为: + +- `409`:刷新当前快照并提示用户重新确认。 +- `422`:在对应操作附近显示具体错误。 +- `429`:显示预算/并发限制,不自动重试。 +- `5xx`:保留用户输入,允许使用同一幂等键重试。 + +验收:API 层不包含页面展示逻辑,类型与后端 OpenAPI 一致。 + +### FE-4:SSE、快照和事件 Reducer + +实现 `useReportCollaborationStream`: + +1. 先拉 session snapshot。 +2. 使用 snapshot 的 `lastSeq` 建立 SSE。 +3. 校验每个事件 session/run 身份。 +4. 按 seq 应用事件并去重。 +5. 出现序号缺口时暂停 live apply,补拉缺失事件。 +6. 断线指数退避并支持 `Last-Event-ID`。 +7. 终态后完成最后一次事件补拉再关闭连接。 + +Reducer 必须覆盖: + +- 消息流增量。 +- 计划候选和 revision。 +- node/node attempt 状态。 +- artifact 创建和校验。 +- command 分类和执行。 +- report delta 和正式版本。 +- 错误、取消、接管恢复。 +- AgentScope 原始可见消息和工具事件:`message.created/delta/completed`、`tool_call.created/delta/completed`、`tool_result.created/delta/completed`、`team_message.created/delta/completed`。 + +验收:重复事件、乱序事件和重连都不产生重复消息、重复节点或状态倒退。 + +### FE-5:复用 DeerFlow `MessageList` + +复用: + +- `frontend-web/src/components/workspace/messages/message-list.tsx` +- `frontend-web/src/components/workspace/messages/message-list-item.tsx` +- `frontend-web/src/components/workspace/messages/message-group.tsx` +- `frontend-web/src/components/workspace/messages/context.ts` + +实现无损协议适配 `message-adapter.ts`: + +1. 将已持久化的 `CollaborationMessage` 一对一映射为 LangGraph SDK `Message[]`,不将文本或工具结果二次概括。 +2. 构造最小 `BaseStream` 外观:`messages`、`isLoading`、`isThreadLoading`、`values`。 +3. 协调者、成员 AgentScope TeamSay 和成员回复都映射为普通 assistant 消息;保留 `messageId`、文本 chunk、角色/成员名称、`agentRunId`、`nodeRunId`、`phaseId`、来源事件 ID 和完成态。 +4. AgentScope 工具调用映射为该 assistant 消息的标准 `tool_calls`;调用结果映射为标准 `tool` 消息,并严格复用原有 `tool_call_id` 配对。工具参数、返回正文及增量保持原文,只有后端明确标注的脱敏字段被替换。 +5. 将来源、文件、报告 Markdown delta 和正式版本继续映射为既有引用、文件卡和普通 assistant 消息;规划卡、用户确认卡和报告替换卡仍通过一个通用业务卡片插槽渲染。 +6. 错误、恢复建议、命令影响范围和报告版本变化也必须进入同一时间线;错误堆栈以可读错误消息替代,但不能吞掉已发生的工具/成员消息。 +7. `selectMessagesForAgentRun(allMessages, focusedAgentRunId)` 只在客户端从已同步消息中选出共享消息与目标成员消息;并行选择器不得重新拉取、重新排序或改写消息。 + +约束: + +- 不使用 LangGraph `useThreadStream` 发送消息。 +- 新页面输入框调用报告协作 API。 +- 不增加多个页面专用 MessageList props。 +- 普通聊天、AI 写作、深度研究等未传插槽时必须零变化。 +- 不另建执行结果列表、节点结果面板或成果时间线。 +- 不用“运行中”“已检索 N 条”等专用摘要替代真实消息。`MessageList` 本身的流式状态和原生工具步骤是唯一过程视图。 + +验收: + +- 历史和流式消息使用同一行稳定更新。 +- 单成员首次开始回复、每次工具启动和工具返回均在 300ms 内出现可见的原生消息行或原生工具步骤;不允许持续数秒只有静态加载圈。 +- 并行标签切换后显示目标成员的完整已持久化消息和实时 delta;未选中成员持续接收,标签上的运行/待回复状态及时更新。 +- 用户上翻阅读时不强制滚底。 +- 用户发送新消息后滚到最新位置。 +- 卡片显示成功不改变后端节点状态。 + +### FE-6:需求澄清和候选方案卡片 + +`ReportPlanCards` 展示: + +- 推荐标签、策略、标题、摘要和推荐理由。 +- 分析角度和参与角色。 +- 关键步骤与质量门。 +- 预计时间、成本等级和资料范围。 +- 方案验证错误。 + +交互: + +- 点击卡片或“查看流程”只切换预览。 +- “采用此方案”调用 select API。 +- “开始协作”再次校验并创建 run。 +- 方案调整后显示新 revision;旧 revision 只读。 +- 过期 revision 返回 409 后不静默覆盖。 + +验收:连续快速切换三张卡时,画布最终显示最后一次选择,不闪回旧图。 + +### FE-7:协作流程画布 + +技术:`@xyflow/react`。可以借鉴但不直接耦合: + +- `frontend-web/src/components/ai-elements/canvas.tsx` +- `frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx` + +节点显示: + +- 节点名、角色名和分析角度。 +- 状态文字、图标和颜色。 +- 等待用户、返工、失败和 superseded 标记。 + +画布能力: + +- 自动布局。 +- 拖动节点只保存浏览器展示位置。 +- 缩放、适配视图、方向切换、小地图。 +- 活动边低强度流动动画。 +- 点击节点打开覆盖在画布上的节点说明抽屉。 +- 节点重跑、补充或纠偏一律通过左侧对话完成;抽屉只提供“在对话中对此节点提出意见”入口。 +- 方案切换后清理不属于新方案的本地坐标。 + +验收: + +- 30 个节点内流畅操作。 +- 单个 node event 不导致整个 React Flow 重挂。 +- `awaiting_input`、`reopened`、`superseded` 不与 `completed` 混淆。 + +### FE-8:节点说明抽屉与对话定位 + +点击画布节点打开 `NodeInspectorDrawer`。抽屉只展示计划合同: + +1. 节点名称、角色、分析角度和当前状态标签。 +2. 节点职责:负责解决什么问题。 +3. 要根据什么:所需输入类型、上游节点类型、资料范围和时效要求。 +4. 要产出什么:成果类型、结构要求和验收条件。 +5. 可用工具、禁止事项、预算上限。 +6. 上游与下游关系。 + +抽屉不展示: + +- 节点实际生成内容。 +- 工具调用返回值。 +- EvidenceBundle 或其他成果物正文。 +- attempt 执行时间线。 +- 错误堆栈和调试信息。 +- 报告正文。 + +交互: + +- “在对话中对此节点提出意见”关闭抽屉、聚焦左侧 Composer,并绑定 `targetNodeId` 上下文。 +- 若该节点对应正在并行运行的成员,同时绑定该成员的 `agentRunId` 并切换左侧选择器,使用户发送前能先看到该成员的真实对话与工具过程。 +- 用户仍需在对话区输入“重新检索”“重新分析”等要求;抽屉不直接发起业务变更。 +- 状态标签来自后端节点状态,但不加载节点结果接口。 +- 关闭抽屉不清空 selected node;再次点击同一节点可恢复说明。 + +报告预览不使用常驻第三栏。最终报告、章节候选和版本先作为左侧 MessageList 中的普通消息/文件卡出现;点击文件卡时复用 `ArtifactFileDetail` 或现有 Markdown 能力打开大尺寸 Dialog,关闭后回到两栏工作台。 + +验收: + +- 抽屉宽度不改变两栏布局,不挤压左侧对话区。 +- 抽屉只读取节点合同和图结构,不请求节点执行结果。 +- 所有原生成员消息、工具过程、错误和报告入口都能在左侧对话时间线找到。 + +### FE-9:全过程对话干预 + +`ReportComposer` 根据状态显示不同提示: + +- 空闲:输入报告需求。 +- 规划中:补充要求将在规划安全点应用。 +- 运行中:协调者正在判断影响范围。 +- 等待用户:优先回答等待问题。 +- 完成后:可问答、重写、润色或重新研究。 + +发送流程: + +1. 生成 idempotency key。 +2. 插入 pending 用户消息;带 `targetNodeId`/`agentRunId` 时在相关成员消息流和共享上下文中引用同一条用户消息,不重复持久化。 +3. 显示“正在识别修改范围”。 +4. `command.classified` 后展示意图和影响节点。 +5. 高成本或低置信度命令显示确认卡。 +6. `command.accepted` 后显示排队或等待安全点。 +7. 完成后关联新 node attempt 或 report version。 +8. 失败时保留原消息和重试入口。 + +典型映射: + +| 用户输入 | 预期操作 | +|---|---| +| “增加政策风险角度” | 新增角度节点和受影响下游 | +| “资料太旧,重新找” | 重新检索指定证据包 | +| “竞争格局分析不到位” | 复用证据,重新分析 | +| “重写第二章” | 只生成章节替换候选 | +| “语气更正式” | 新报告版本,不重新研究 | +| “为什么得出这个结论” | 只读问答,不修改报告 | + +验收: + +- 双击发送不创建重复命令。 +- 选中“品牌竞争分析师”后发送补充要求,只影响该成员及依赖分析,其他并行成员仍保持消息流和运行状态。 +- 运行中反馈不会让 run 或 node 误变 completed。 +- “重写第二章”不会触发全量检索。 +- “资料太旧”不会只做文字润色。 + +### FE-10:会话历史、报告版本与导出 + +- History Tab 显示标题、状态、更新时间、当前版本。 +- 新建会话不删除旧会话。 +- 会话切换时先关闭旧 SSE,再加载新快照。 +- 报告修改先生成候选,确认后创建新版本。 +- 版本恢复创建新的头版本,不删除历史。 +- 下载复用现有 Markdown/Word 能力。 +- 文件名来自报告标题,不暴露宿主机或沙箱路径。 + +验收:历史会话可完整恢复左侧消息流程、右侧节点状态和报告版本;不额外恢复节点结果面板。 + +### FE-11:测试、视觉回归和性能 + +单元测试: + +- event normalizer/reducer。 +- message adapter。 +- plan revision 和选择状态。 +- node status 和 available actions。 +- command pending/idempotency。 + +组件测试: + +- 空态、澄清、规划、候选、运行、等待、完成、失败、取消。 +- 卡片选择、节点点击、只读说明 Drawer、确认弹窗和报告预览 Dialog。 +- MessageList 通用业务卡插槽零回归。 +- 单成员流式文本、TeamSay、工具调用与工具返回的原样重放;并行成员交错到达、切换标签、切回后不中断 delta。 +- 非当前标签成员发起协助时显示待回复徽标,点击后使用原生协助卡回复到正确节点。 + +E2E: + +1. 新建会话并输入新能源汽车报告要求。 +2. 选择候选并开始。 +3. 运行中增加风险角度。 +4. 重新检索海外最新资料。 +5. 完成后重写第二章。 +6. 刷新、断网重连、切换会话。 +7. 停止和重试失败节点。 + +性能门: + +- 30 节点画布不卡顿。 +- 长消息列表使用现有优化策略。 +- SSE 高频 delta 合帧后再 setState。 +- 画布和消息区使用独立 selector,避免互相触发整树重渲染。 +- 节点抽屉只订阅节点合同和状态,不能因阶段产出更新而反复重渲染。 +- 只渲染当前聚焦成员和共享消息的可视窗口;非当前成员的消息仍写入 store,但不因 token delta 重渲染当前 `MessageList`。 + +--- + +## 8. 后端接口依赖 + +前端开发前,后端必须冻结以下接口: + +```text +POST /api/report-collaboration/sessions +GET /api/report-collaboration/sessions +GET /api/report-collaboration/sessions/{session_id} +PATCH /api/report-collaboration/sessions/{session_id} +DELETE /api/report-collaboration/sessions/{session_id} + +POST /api/report-collaboration/sessions/{session_id}/messages +GET /api/report-collaboration/sessions/{session_id}/messages +POST /api/report-collaboration/sessions/{session_id}/plan-requests +GET /api/report-collaboration/sessions/{session_id}/plans +POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select +POST /api/report-collaboration/sessions/{session_id}/runs + +GET /api/report-collaboration/runs/{run_id} +GET /api/report-collaboration/runs/{run_id}/events +GET /api/report-collaboration/runs/{run_id}/stream +POST /api/report-collaboration/runs/{run_id}/commands +POST /api/report-collaboration/runs/{run_id}/cancel +GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract + +GET /api/report-collaboration/sessions/{session_id}/reports +POST /api/report-collaboration/sessions/{session_id}/report-rewrites +POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply +``` + +前后端共享枚举: + +- session status。 +- run status。 +- node/node attempt status。 +- command intent/status。 +- event type/version。 +- artifact type/schema version。 +- report version/change type。 + +--- + +## 9. 前端开发顺序 + +页面与契约层已完成(代码在 `frontend-web/src/report-collaboration/`): + +1. `RC-FE-001`:视觉截图与行为基线。 +2. `RC-FE-002`:路由、菜单和左对话/右画布两栏空壳。 +3. `RC-FE-003`:页面 token、弹窗、抽屉和响应式。 +4. `RC-FE-004`:API 类型和 mock server。 +5. `RC-FE-005`:SSE reducer 与快照恢复。 +6. `RC-FE-006`:MessageList adapter 和通用卡片插槽。 +7. `RC-FE-007`:需求澄清与候选方案卡。 +8. `RC-FE-008`:流程画布和自定义节点。 +9. `RC-FE-009`:只读节点说明抽屉与对话目标定位。 +10. `RC-FE-010`:统一 Composer 和命令确认。 +11. `RC-FE-011`:报告预览、版本和导出。 +12. `RC-FE-012`:历史、重连和异常恢复。 +13. `RC-FE-013`:组件/E2E/视觉/性能验收。 + +依赖规则: + +- 契约已冻结为 `src/report-collaboration/api/types.ts`;后端 `RC-BE-001` 已对齐。 +- 真实 SSE 联调:后端 `RC-BE-011` 已挂载 `GET /runs/{id}/stream`;默认无后台循环时流只会重放已入库事件。 +- 报告版本操作已接后端 `RC-BE-015`(改写候选 / apply / restore)。 +- 执行画布实时状态依赖打开 `worker_enabled`;质量角色内核已在 `RC-BE-013` 落地,真实成员见 `RC-BE-017`。 + +--- + +## 10. 前端完成定义 + +页面与 mock 联调路径已落地。真实后端可联调 `GET /runs/{id}/stream`、领取/恢复(`RC-BE-012`,默认不挂 worker)、质量角色(`RC-BE-013`)以及报告改写/恢复(`RC-BE-015`)。此前以 mock driver 覆盖交互,不以「前端未编码」计未完成。 + +前端只有同时满足以下条件才算完成: + +1. 页面结构与 Coze 参考页面逐状态验收通过。 +2. 消息区复用 DeerFlow `MessageList`,其他聊天页面无回归。 +3. 候选卡、左侧对话流程、右侧流程图和报告来自同一 session/run/event 状态。 +4. 点击候选只预览,明确确认后才执行。 +5. 用户可以在规划、运行、完成后三个阶段继续对话。 +6. 协助卡显示或提交不会导致前端误标完成。 +7. 节点点击只打开说明抽屉,可查看职责、所需输入、预期输出、工具和验收条件,不展示执行结果。 +8. 节点重开、返工和 superseded 状态表达正确。 +9. SSE 重连、刷新和历史切换无重复消息和状态倒退。 +10. 报告修改以候选和版本形式完成,不直接覆盖正式报告。 +11. 页面始终只有对话与画布两个常驻区域,所有执行过程和产出入口都能在对话时间线中找到。 +12. 响应式、键盘、对比度、加载反馈和错误恢复通过验收。 diff --git a/docs/agentscope-多智能体报告协作工作台-前端对接指南.md b/docs/agentscope-多智能体报告协作工作台-前端对接指南.md new file mode 100644 index 00000000..efef7c5f --- /dev/null +++ b/docs/agentscope-多智能体报告协作工作台-前端对接指南.md @@ -0,0 +1,481 @@ +# 报告协作工作台 — 前端对接指南 + +> **适用版本:** RC-BE-000~018 已落地(2026-09-06) +> **配套文档:** +> - [前端实施计划](./agentscope-多智能体报告协作工作台-前端实施计划.md) +> - [后端实施计划](./agentscope-多智能体报告协作工作台-后端实施计划.md) +> - 模块 README:`frontend-web/src/report-collaboration/README.md` + +本文说明**前端如何对接真实后端**:环境配置、API 契约、用户流程、状态机、错误处理与联调验收。前端 API 客户端与页面已实现,通常**无需再写一套 HTTP 层**。 + +--- + +## 1. 结论速览 + +| 问题 | 答案 | +|------|------| +| 前端还要写接口吗? | **不用**。`src/report-collaboration/api/` 已封装 REST + SSE,页面通过 `getReportCollaborationDriver()` 调用。 | +| 为什么调不通? | 最常见:`config.yaml` 里 `report_collaboration.enabled: false` → 全部 **503**;或 `worker_enabled: false` → run 不入队执行。 | +| Mock 和真后端怎么切? | **Mock 已移除**。前端永远请求真实后端(`deerflow.rc-mock` / `VITE_RC_MOCK` 开关已删除);联调需 `enabled: true`。 | +| 字段会和 MySQL 对不上吗? | 前端只认 JSON 契约(`api/types.ts`),不直连表。缺列表现为后端 500/SQL 错误,部署后需**重启 Gateway** 跑 `create_all` + 列同步。 | +| 从哪进页面? | `/page/workspace/report-collaboration`(新会话)或 `/page/workspace/report-collaboration/:sessionId`。侧边栏入口默认注释,可直接 URL 访问。 | + +--- + +## 2. 架构:前端已有什么 + +``` +ReportCollaborationPage + └─ useReportCollaborationSession(唯一数据入口) + ├─ driver.getSnapshot() ← REST 冷启动 + ├─ driver.openRunStream() ← SSE 实时事件 + ├─ driver.sendMessage / createCommand / startRun … + └─ event-reducer ← 事件 → UI 状态 + +ReportConversationPanel + └─ MessageList(message-adapter 映射 CollaborationMessage → LangGraph Message) +``` + +### 2.1 目录结构 + +```text +frontend-web/src/report-collaboration/ + api/ + types.ts # 冻结契约(snake_case,与后端 Pydantic 一致) + client.ts # rcFetch:鉴权、幂等键、乐观锁、错误映射 + sessions.ts # 会话 CRUD、消息 + plans.ts # 方案请求 / 列表 / 选定 + runs.ts # Run、SSE、命令、节点合同 + reports.ts # 报告版本、改写、恢复 + driver.ts # 真实后端统一门面(Mock 驱动已移除) + state/ + event-normalizer.ts + event-reducer.ts + selectors.ts + message-adapter.ts + hooks/ + useReportCollaborationSession.ts # 快照 → SSE → reducer → 动作 + useReportCollaborationStream.ts # 断线重连、after_seq 续接 + useReportCollaborationMessages.ts + components/ … + pages/ReportCollaborationPage.tsx +``` + +### 2.2 事实源原则 + +- **冷启动:** `GET /sessions/{id}` 返回 `SessionSnapshot`(session、messages、plans、run、nodes、agent_runs、commands、report_versions、last_seq)。 +- **运行中:** `GET /runs/{id}/stream?after_seq=N` 订阅 durable SSE。 +- **断线 / 序号缺口:** `GET /runs/{id}/events?after_seq=N` REST 补拉。 +- **澄清 / 规划阶段(尚无 run):** 每 2.5s 轮询快照(后端事件主要在 run 流上)。 + +纯展示状态(预览方案、聚焦成员、面板宽度)**不**写入服务器,仅存前端。 + +--- + +## 3. 后端启用与部署 + +### 3.1 必改配置 + +文件:`offline-backend-20260512/backend/config.yaml` + +```yaml +report_collaboration: + enabled: false # 联调必须改为 true + worker_enabled: false # 要看成员执行必须 true + planner_model: null # 建议填可用模型名 + coordinator_model: null + research_model: null + writer_model: null + reviewer_model: null + # … token_budget、max_concurrent_runs_per_user 等见同文件 +``` + +| 配置 | 现象 | +|------|------| +| `enabled: false` | 所有路由 **503**,`code: REPORT_COLLABORATION_DISABLED` | +| `enabled: true`,`worker_enabled: false` | REST/SSE 可用,run 入队但不自动执行(测 API 可用,无成员流式输出) | +| 两者 `true` + 模型配置完整 | 完整联调 | + +### 3.2 MySQL / 表结构 + +生产环境 Alembic 自动升级默认关闭,启动时走 `create_all` + `_ensure_orm_columns_sync`。 + +- 迁移脚本(可选手动跑):`20260905_01`、`20260906_01`、`20260907_01` +- ORM:`packages/harness/deerflow/persistence/report_collaboration/model.py`(13 张表) +- **部署后务必重启 Gateway**,观察日志是否有 `Failed to auto-add column report_collaboration_*` + +已有 RC-003 时代旧表会通过列同步补上 `template_snapshot_json`、`budget_snapshot_json`、`usage_json`、`budget_exhausted_reason` 等可空列,并创建 `rewrite_proposals`、`audits`。 + +### 3.3 前端环境变量 + +| 变量 | 说明 | +|------|------| +| `VITE_BACKEND_BASE_URL` | Gateway 地址,默认 `http://127.0.0.1:8001` | + +请求经 `apiFetch`,自动携带登录 JWT(`credentials: "include"`)。**必须先登录 DeerFlow**。 + +--- + +## 4. API 基址与请求约定 + +### 4.1 Base URL + +```text +{VITE_BACKEND_BASE_URL}/api/report-collaboration +``` + +代码常量:`REPORT_COLLABORATION_BASE()`(`api/client.ts`)。 + +### 4.2 请求头 + +| Header | 何时携带 | 说明 | +|--------|----------|------| +| `Authorization` | 全部 | 由 `apiFetch` 注入 | +| `X-Idempotency-Key` | 写操作 | 格式 `rc-{uuid}`;重复提交返回首次结果 | +| `X-Expected-Revision` | 选方案、开 run、改会话等 | 会话 `requirement_revision` 或方案 `revision` | +| `Accept: text/event-stream` | SSE | `openRunStream` | + +### 4.3 错误响应格式 + +```json +{ + "code": "REPORT_COLLABORATION_DISABLED", + "message": "报告协作工作台未启用", + "detail": { } +} +``` + +前端映射(`api/client.ts`): + +| HTTP | 异常类 | 典型场景 | +|------|--------|----------| +| 409 | `ConflictError` | revision 过期、active run 冲突 | +| 422 | `ValidationError` | 参数/语义错误、缺幂等键 | +| 429 | `RateLimitError` | 预算耗尽、每用户并发 run 上限 | +| 5xx | `ServerError` | 可带同一幂等键重试 | +| 0 | `NetworkError` | fetch 失败 | + +### 4.4 完整路由清单(与前端 client 对齐) + +| 方法 | 路径 | 前端模块 | +|------|------|----------| +| POST | `/sessions` | `sessions.createSession` | +| GET | `/sessions` | `sessions.listSessions` | +| GET | `/sessions/{id}` | `sessions.getSessionSnapshot` | +| PATCH | `/sessions/{id}` | `sessions.updateSession` | +| DELETE | `/sessions/{id}` | `sessions.deleteSession` | +| GET | `/sessions/{id}/messages` | `sessions.listMessages` | +| POST | `/sessions/{id}/messages` | `sessions.sendMessage` | +| POST | `/sessions/{id}/plan-requests` | `plans.requestPlans` | +| GET | `/sessions/{id}/plans` | `plans.listPlans` | +| POST | `/sessions/{id}/plans/{planId}/select` | `plans.selectPlan` | +| POST | `/sessions/{id}/runs` | `runs.startRun` | +| GET | `/sessions/{id}/reports` | `reports.listReportVersions` | +| POST | `/sessions/{id}/report-rewrites` | `reports.createRewrite` | +| POST | `/sessions/{id}/report-rewrites/{id}/apply` | `reports.applyRewrite` | +| POST | `/sessions/{id}/report-versions/{id}/restore` | `reports.restoreReportVersion` | +| GET | `/runs/{id}` | `runs.getRun` | +| GET | `/runs/{id}/events` | `runs.listRunEvents` | +| GET | `/runs/{id}/stream` | `runs.openRunStream` | +| POST | `/runs/{id}/cancel` | `runs.cancelRun` | +| POST | `/runs/{id}/commands` | `runs.createCommand` | +| POST | `/runs/{id}/commands/{id}/confirm` | `runs.confirmCommand` | +| POST | `/runs/{id}/commands/{id}/cancel` | `runs.cancelCommand` | +| GET | `/runs/{id}/nodes/{nodeId}/contract` | `runs.getNodeContract` | +| GET | `/runs/{id}/artifacts` | (后端已暴露,前端按需接) | +| GET | `/runs/{id}/sources` | (后端已暴露,前端按需接) | + +--- + +## 5. 用户流程与 API 时序 + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 RC + participant API as Gateway + participant W as Worker + + U->>FE: 输入需求 + FE->>API: POST /sessions + FE->>API: POST /sessions/{id}/messages + API-->>FE: 澄清(快照 messages) + U->>FE: 回答澄清 + FE->>API: POST /messages + FE->>API: POST /plan-requests + API-->>FE: 候选方案(快照 plans) + U->>FE: 采用方案 + FE->>API: POST /plans/{id}/select + U->>FE: 开始协作 + FE->>API: POST /runs + FE->>API: GET /runs/{id}/stream + W-->>API: 持久化事件 + API-->>FE: SSE + U->>FE: 运行中干预 + FE->>API: POST /commands → confirm +``` + +### 5.1 阶段 0:进入页面 + +| 路由 | 行为 | +|------|------| +| `/page/workspace/report-collaboration` | 空态;`POST /sessions` 后跳转带 `sessionId` | +| `/page/workspace/report-collaboration/:sessionId` | 加载快照;有 `location.state.rcInitialMessage` 时自动发首条消息 | + +路由注册:`frontend-web/src/pages/WorkspaceRoutes.tsx`。 +侧边栏入口已在 `workspace-nav-chat-list.tsx` 中放开(「报告协作」项),也可直接访问 URL。 + +### 5.2 阶段 1:需求澄清(无 run) + +```http +POST /api/report-collaboration/sessions +POST /api/report-collaboration/sessions/{sessionId}/messages +Content-Type: application/json + +{ "text": "请写一份…", "target_node_id": null, "agent_run_id": null } +``` + +- `useReportCollaborationSession.send()`:当 `session.status` 不是 `running` / `awaiting_input` / `reviewing` 时走 `sendMessage`。 +- 会话状态:`empty` → `clarifying` → `planning` → `proposal_ready`。 +- 澄清问题以 `CollaborationMessage` 进入时间线;业务卡通过 `MessageList` 的 `businessCardSlot` 渲染(`additional_kwargs.report_collaboration_card`)。 + +### 5.3 阶段 2:候选方案 + +```http +POST /api/report-collaboration/sessions/{sessionId}/plan-requests +POST /api/report-collaboration/sessions/{sessionId}/plans/{planId}/select +``` + +- **预览方案**(`previewPlanId`):纯前端,不调 API。 +- **采用方案**:`select` 写 `selected_plan_id`,**不**创建 run。 +- 409:方案 revision 过期 → 前端 `refreshSnapshot`,提示用户重选。 +- 右栏 `ReportFlowCanvas` 使用 `PlanNode` / `PlanEdge` 渲染 DAG。 + +### 5.4 阶段 3:开始执行与 SSE + +```http +POST /api/report-collaboration/sessions/{sessionId}/runs +{ "plan_id": "plan-xxx" } + +GET /api/report-collaboration/runs/{runId}/stream?after_seq=0 +Accept: text/event-stream +``` + +SSE 每条 `data:` 为 JSON: + +```typescript +interface CollaborationEvent { + eventId: string; + sessionId: string; + runId?: string | null; + seq: number; + type: CollaborationEventType; + timestamp: string; + data: unknown; +} +``` + +常用 `type`(完整列表见 `api/types.ts`): + +| 类型 | 含义 | +|------|------| +| `message.created` / `message.delta` / `message.completed` | 对话镜像流式 | +| `tool_call.*` / `tool_result.*` | 工具调用与返回 | +| `team_message.*` | 成员 / 协调者广播 | +| `plan.proposed` / `plan.selected` | 方案生命周期 | +| `run.status.changed` | Run 状态 | +| `node.status.changed` / `node.progress` | 节点执行 | +| `command.confirmation.required` | 需用户确认的干预 | +| `report.version.created` | 报告版本落盘 | +| `heartbeat` | 保活 | + +### 5.5 阶段 4:运行中干预 + +```http +POST /api/report-collaboration/runs/{runId}/commands +POST /api/report-collaboration/runs/{runId}/commands/{commandId}/confirm +POST /api/report-collaboration/runs/{runId}/commands/{commandId}/cancel +POST /api/report-collaboration/runs/{runId}/cancel +``` + +- Run 活跃时 `send()` 自动走 `createCommand`(而非 `sendMessage`)。 +- 可从节点抽屉 / `AgentStageSelector` 传入 `target_node_id`、`agent_run_id`。 +- `InterventionCard` 展示 `confirmation_required` 命令;确认后调 `confirmCommand`。 + +`CommandIntent` 枚举(后端分类,前端展示 reason/impact):`answer_question`、`update_requirements`、`research_again`、`rewrite_section`、`rerun_node`、`replan`、`cancel` 等。 + +### 5.6 阶段 5:报告版本与改写 + +```http +GET /api/report-collaboration/sessions/{sessionId}/reports +POST /api/report-collaboration/sessions/{sessionId}/report-rewrites +POST /api/report-collaboration/sessions/{sessionId}/report-rewrites/{rewriteId}/apply +POST /api/report-collaboration/sessions/{sessionId}/report-versions/{versionId}/restore +``` + +- `createRewrite` 只生成候选,不直接覆盖正式版本。 +- `applyRewrite` / `restoreReportVersion` 产生新的 head 版本,历史保留。 +- `ReportPreviewDialog` 展示 `ReportVersion.markdown`。 + +--- + +## 6. 前端状态机(接入方须知) + +**不要**在页面里用零散 REST 字段拼 UI 状态。统一路径: + +```text +SessionSnapshot → applySessionSnapshot() +CollaborationEvent[] → normalizeCollaborationEvent() → applyCollaborationEvents() +→ selectors(derivePagePhase、selectPlanForCanvas、…) +``` + +### 6.1 核心 Selector + +| Selector | 用途 | +|----------|------| +| `derivePagePhase` | Composer 占位、方案卡、空态 | +| `selectPlanForCanvas` | 画布 DAG(含预览方案) | +| `selectAgentTabs` | 并行成员 Tab | +| `selectNodeStatusMap` | 节点颜色 / 状态 | +| `selectPendingConfirmationCommands` | 干预确认卡 | +| `selectHeadReportVersion` | 报告预览入口 | +| `selectStreamingAgentRunIds` | 流式成员指示 | + +### 6.2 SSE 连接策略(`useReportCollaborationStream`) + +- Run 非终态时连接;`completed` / `failed` / `cancelled` 后关流并 REST 最终补拉。 +- 断线:指数退避重连(1s~30s),`after_seq` 取 reducer 内 `lastSeq`。 +- `hasSeqGap`:暂停 live 应用,先 `listRunEvents` 回填。 + +### 6.3 乐观消息 + +用户发送时插入 `metadata.optimistic: true` 的 pending 消息;服务端镜像带相同 `idempotency_key` 时 reducer 原地替换。 + +--- + +## 7. 消息与 MessageList 复用 + +`useReportCollaborationMessages` 将 `CollaborationMessage[]` 转为 `@langchain/langgraph-sdk` 的 `Message[]`,并构造合成 `BaseStream` 供 `MessageList` 消费。 + +| 来源 | 渲染 | +|------|------| +| `human` / `ai` 文本 | 普通气泡 | +| `tool_calls` + `tool` | 现有工具卡 | +| `team_message` 事件 | 带 `name` 的成员发言 | +| 业务卡 | `businessCardSlot`(方案组、命令确认、报告可用) | + +分组键:`core/messages/utils.ts` 中 `assistant:task-report-import` 同级扩展 `assistant:business-card`,判据 `additional_kwargs.report_collaboration_card`。 + +### 7.1 在其他页面嵌入 RC(最小集) + +1. `useReportCollaborationSession(sessionId)` +2. `ThreadContext.Provider` + `MessageList` + `businessCardSlot` +3. 引入 `report-collaboration/styles/report-collaboration.css` + +--- + +## 8. 单测 + +Mock 驱动(原 `api/mock.ts`)与 `deerflow.rc-mock` / `VITE_RC_MOCK` 开关已移除,前端只请求真实后端;本地演示请直接联调后端。 + +单测: + +```bash +cd frontend-web +pnpm test:report-collaboration +``` + +--- + +## 9. 联调验收清单 + +### 9.1 准备 + +- [ ] `report_collaboration.enabled: true` +- [ ] `report_collaboration.worker_enabled: true` +- [ ] 五个 `*_model` 已配置且可用 +- [ ] Gateway 已重启(MySQL 表/列就绪) +- [ ] 前端已登录(JWT 有效) + +### 9.2 探活 + +```bash +curl -s -H "Authorization: Bearer " \ + http://127.0.0.1:8001/api/report-collaboration/sessions +``` + +期望:**200** + `{ "sessions": [...] }`,而非 503。 + +### 9.3 页面流程(浏览器 Network) + +1. 打开 `/#/page/workspace/report-collaboration` +2. 输入需求 → `POST .../sessions` **200** +3. 跳转 → `GET .../sessions/{id}` **200**,响应含 `SessionSnapshot` +4. 首条消息 → `POST .../messages` **200** +5. 请求方案 → `POST .../plan-requests` **200** +6. 采用方案 → `POST .../plans/{id}/select` **200** +7. 开始协作 → `POST .../runs` **200** +8. `GET .../runs/{id}/stream` 持续收到 SSE(`message.delta`、`node.status.changed` 等) +9. 刷新页面 → 消息与 `last_seq` 续接正确 +10. 运行中输入「重新检索」→ `POST .../commands` → 确认卡 → `confirm` **200** + +### 9.4 常见失败 + +| 现象 | 原因 | 处理 | +|------|------|------| +| 全部 503 | `enabled: false` | 改配置并重启 | +| run 一直 queued | `worker_enabled: false` | 改为 true | +| 409 选方案 | revision 过期 | 刷新后重选(前端已自动 refresh) | +| 429 | 预算/并发上限 | 调大配置或取消其他 run | +| 500 + SQL | 表缺列 | 重启 Gateway,查列同步日志 | + +--- + +## 10. 契约与类型维护 + +- **单一事实源:** `frontend-web/src/report-collaboration/api/types.ts` +- **后端镜像:** `offline-backend-20260512/backend/app/report_collaboration/contracts/` +- 修改字段须同步两份实施计划与本指南,并跑前后端相关测试。 + +`SessionSnapshot` 形状(摘要): + +```typescript +interface SessionSnapshot { + session: ReportSession; + messages: CollaborationMessage[]; + plans: ReportPlanCandidate[]; + run: ReportRun | null; + nodes: CollaborationNodeState[]; + agent_runs: AgentRun[]; + commands: CollaborationCommand[]; + report_versions: ReportVersion[]; + last_seq: number; +} +``` + +`ReportRun` 可选字段(RC-BE-016):`usage`、`budget_exhausted_reason`。 + +--- + +## 11. 后续工作(非对接阻塞) + +| 项 | 说明 | +|----|------| +| RC-BE-019 | 灰度发布与离线部署文档 | +| 侧边栏入口 | 已放开(`workspace-nav-chat-list.tsx` 的「报告协作」项) | +| iframe 嵌入 | RC 页尚未专门适配 `embed=1`,可参考现有深链模式扩展 | +| 真人盲评 | RC-BE-018 为确定性评测集,上线前仍需计划 §11.4 人工评审 | + +--- + +## 12. 相关代码索引 + +| 用途 | 路径 | +|------|------| +| API 客户端 | `frontend-web/src/report-collaboration/api/` | +| 会话 Hook | `frontend-web/src/report-collaboration/hooks/useReportCollaborationSession.ts` | +| 页面 | `frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx` | +| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | +| 后端路由 | `offline-backend-20260512/backend/app/gateway/routers/report_collaboration.py` | +| 配置 | `offline-backend-20260512/backend/config.yaml` → `report_collaboration` | +| 持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/report_collaboration/` | diff --git a/docs/agentscope-多智能体报告协作工作台-后端实施计划.md b/docs/agentscope-多智能体报告协作工作台-后端实施计划.md new file mode 100644 index 00000000..435ff195 --- /dev/null +++ b/docs/agentscope-多智能体报告协作工作台-后端实施计划.md @@ -0,0 +1,1292 @@ +# AgentScope 2.0 多智能体报告协作工作台——后端实施计划 + +> 文档状态:实施中(`RC-BE-000`~`018` 已完成;下一票 `RC-BE-019`) +> 编写日期:2026-09-05;进度更新日期:2026-09-06 +> 目标项目:`F:\react01\deeflow-code\deerflow-server\offline-backend-20260512\backend` +> 协作框架:[AgentScope 2.0](https://github.com/agentscope-ai/agentscope) +> 前端契约来源:`frontend-web/src/report-collaboration/api/types.ts`、`api/mock.ts`、`state/event-reducer.ts`(前端已完成,契约冻结) +> 尖峰基线:`offline-backend-20260512/backend/docs/AGENTSCOPE_BASELINE.md` +> 本文只规划新增的报告协作后端,不修改现有多智能体会商,不使用旧 Workflow Studio DAG 调度器。 +> 下文 §8 的 `BE-n` 与 §10 的 `RC-BE-00n` 是同一批工单的两种编号;进度以 `RC-BE-*` 为准。 + +## 0. 当前进度 + +已打通 **建会话 → 澄清需求 → 生成 2~3 套候选方案 → 选定 → 创建 `queued` run(冻结模型 + 模板 + 预算)**,以及 **TaskLedger 波次调度 + 受控成员 / 真实 AgentScope 内核 + 主张级 QualityGate + 正式报告版本/改写 + 产品 durable SSE + 租约 dispatcher/恢复 + 运行期自然语言干预 + owner/预算/审计**。主开关默认关闭:`config.yaml → report_collaboration.enabled` / `worker_enabled` 均为 `false`。 + +运行期 IntentRouter 的歧义兜底已可调用冻结的 coordinator 模型,但明确命令走高精度规则;该智能体无工具且不能调度。`POST /runs` 仍只入队。`worker_enabled` 为 false 时 lifespan **不**挂载后台循环;测试用 `dispatch_once()` / `WaveExecutor` 驱动。`GET /runs/{id}/stream` 可重放/尾随已持久化事件。生产 worker 在双开时注入 `AgentScopeMemberKernel`。20 项质量评测已在 `RC-BE-018` 落地(确定性打分,不跑真实模型)。 + +| 工单 | 状态 | 代码落点 / 缺口 | +|---|---|---| +| `RC-BE-000` | 条件完成 | `agentscope_runtime/event_adapter.py`。`agentscope==2.0.5` 仅为 optional extra,**未写入 `uv.lock`**(等纵向 POC)。 | +| `RC-BE-001` | 完成 | `app/report_collaboration/contracts/` + `execution/completion.py`。 | +| `RC-BE-002` | 完成 | `deerflow.config.report_collaboration_config`;默认双关。 | +| `RC-BE-003` | 完成 | `deerflow.persistence.report_collaboration/`,迁移 `20260905_01`。 | +| `RC-BE-004` | 完成 | `/api/report-collaboration`。改写 / apply / restore 见 `015`。SSE 见 `011`。 | +| `RC-BE-005` | 完成 | `requirement/`:规划前澄清;「直接开始」记默认假设;澄清完成 ≠ 出方案。 | +| `RC-BE-006` | 完成 | `planning/`:目录投影(无 SOUL/密钥)+ 三策略方案;select 与 start-run 独立。 | +| `RC-BE-007` | 完成 | `DeerFlowChatModelAdapter` + `freeze_plan_models`;缺模型/能力不匹配在 `POST /runs` 前 422。 | +| `RC-BE-008` | 完成 | `permission_adapter.py` + `tool_adapter.py`:角色 × 工具矩阵、注入扫描。 | +| `RC-BE-009` | 完成 | `execution/ledger.py` + `member_runner.py`:TaskLedger 按依赖波次分派;AgentScope/协调者建议不能选下一节点;空输出/非法 JSON 进入新 attempt;超限失败不完成整个 run。内核目前为 `ScriptedMemberKernel`(单测),真实模型/web_search 仍待接。 | +| `RC-BE-010` | 完成 | `execution/quality_gate.py` + `ArtifactBoard.supersede_previous`:冲突数字/缺口径拒绝;未核验或争议主张不能进分析/章节;审稿必须映射到节点或章节;覆盖矩阵缺口拒绝。Ledger 先 schema 再质检,通过才 `validated`。 | +| `RC-BE-011` | 完成 | `execution/live_hub.py`:run-scoped Live Hub、`PublishingReportCollaborationStore`(先入库再发布)、`after_seq`/`Last-Event-ID` 重放、心跳、token delta 合帧、SSE 断开不取消 run。`GET /runs/{id}/stream` 已挂载。报告正式版本见 `015`。 | +| `RC-BE-012` | 完成 | store `list_claimable` / `renew_lease` / `finalize_cancel`;`execution/{dispatcher,executor,recovery}.py`:原子 claim、心跳续租、两阶段取消、过期租约接管。恢复保留已完成节点,遗留 in-flight 标 `interrupted`/`superseded` 并开新 `planned` attempt。晚到成果 `validation_status=superseded`,不改终态。`start()` 仅在 `worker_enabled` 时挂循环;默认双关。 | +| `RC-BE-013` | 完成 | `execution/quality_roles.py`:Verifier/Analyst/Writer/Reviewer/Reviser 作为受控成员(确定性内核,非 AgentScope Team)。核验标记过期/跨包冲突;缺失角度只重开检索链;审稿 `revise` 只重开目标写作节点 + 审稿自身。`TaskResult.additional_drafts` 允许多条 VerificationResult。脚本内核仍供单测;生产 worker 见 `017`。 | +| `RC-BE-014` | 完成 | `interventions/`:运行期 IntentRouterAgent(规则优先、模型兜底且无工具)+ 确定性 ImpactAnalyzer + command CAS/safe-point。支持节点/agent 归属校验、角度定向、确认闸门、等待边界、overlay、新 attempt、成果失效及晚到结果隔离。 | +| `RC-BE-015` | 完成 | `reporting/`:run 开始冻结模板;`ReportFinalizer` 在审稿通过且 lint 后落正式版本并发 `report.version.created`;改写候选 / apply / restore 已挂路由,不再 503。 | +| `RC-BE-016` | 完成 | owner 隔离、跨会话幂等拒绝、`sanitize_for_user` + 审计落库、每用户 active run 上限、每 run 预算 fail-closed。 | +| `RC-BE-017` | 完成 | `AgentScopeMemberKernel` 真实 `reply_stream` + 工具 + 结构化成果;生产 worker 注入该内核;冒烟可跳过未安装的 `agentscope`。并发交错/租约恢复仍由 `012` 测试覆盖。 | +| `RC-BE-018` | 完成 | `app/report_collaboration/eval/`:20 项标注任务(政策/市场/企业/事件/对比各 4)、确定性指标、金标/失败实例、会商盲评对照、20 次固定任务可靠性计数。`cite_required` 与当前默认 knobs 为通过评测的推荐画像。真实模型盲评仍属上线决策,不阻塞本票。 | +| `RC-BE-019` | 未开始 | 灰度与离线发版。 | + +当前可联调路径:打开 `report_collaboration.enabled` 后,规划前轮询 `GET /sessions/{id}` 即可看到澄清消息和方案卡;`POST /runs` 只入队;`GET /runs/{id}/stream` 重放已入库事件。默认 **不**自动领取 queued run(`worker_enabled=false`)。测试可调 `dispatch_once()`。 + +## 1. 后端目标 + +新增一套基于 AgentScope 2.0 Core 的多智能体报告协作运行时,实现: + +1. 理解任意报告需求并在必要时澄清。 +2. 从当前用户可见的智能体、技能、模型和资料源中筛选能力。 +3. 生成 2~3 个真实有差异的候选协作计划。 +4. 将选定计划实例化为受 `TaskLedger` 约束的 AgentScope 任务成员,而非让自由会商自行决定流程。 +5. 通过结构化成果物完成检索、核验、分析、综合、写作、审稿和返工。 +6. 接受全过程自然语言干预,并只重开受影响的任务和下游成果。 +7. 使用数据库租约、durable events、幂等命令和版本化报告保证恢复与一致性。 +8. 使用现有 DeerFlow 登录、权限、模型、工具、知识库、报告模板和导出能力。 +9. 将所有用户可见的规划、AgentScope 团队消息、智能体流式回复、工具调用及其原始返回、分析、核验、写作、审稿、返工、错误和报告变化一对一镜像到统一对话时间线;前端不建立节点执行结果面板,也不以阶段摘要替代实时消息。 + +框架能提供协作机制,不能单独保证报告质量。质量由证据合同、来源核验、独立审稿、定向返工、模板约束和真实评测集共同保证。 + +### 1.1 前端冻结契约:后端必须适配,不要求前端回退 + +已完成的双栏前端以 `SessionSnapshot` 和 run-scoped durable SSE 为唯一事实来源;后端实现不得另造一套摘要卡或节点结果接口来补足过程。 + +| 前端已实现行为 | 后端适配要求 | +|---|---| +| run 创建前轮询 `GET /sessions/{id}` | 澄清、需求修订、候选方案与选择结果必须同步写入完整快照;不能只存在于内存任务。 | +| run 后以 `seq` 重放/订阅 SSE | 每个 run 的事件先持久化、再发布,`seq` 严格单调且可用 `after_seq` 补拉。 | +| 并行成员横向阶段卡 | 同一并行波次使用相同 `phase_id`;`node_run_id` 固定为 `${node_id}-attempt{attempt}`,不可使用前端无法反查节点的随机值。 | +| 复用 `MessageList` 展示过程 | assistant、TeamSay、工具调用、工具结果与流式增量均写入 `report_collaboration_messages` 并按原消息协议推送。 | +| 成员状态来自局部快照合并 | 前端没有 `agent_run.*` 事件;成员开始、等待输入、完成、失败、取消时必须发 `session.snapshot: {agent_runs:[...], run?:...}`。 | +| 报告在对话流中渐显 | 写作阶段发 `report.delta`;质量门和版本落库完成后才发 `report.version.created`。 | +| 节点抽屉只读 | `GET /runs/{id}/nodes/{node_id}/contract` 只返回职责合同,永不返回执行结果正文。 | + +每个写操作读取前端发送的 `X-Idempotency-Key` 与 `X-Expected-Revision`;用户消息的服务端镜像必须将同一幂等键放入 `metadata.idempotency_key`,以替换前端乐观消息而不重复显示。 + +--- + +## 2. 关键架构决策 + +### 2.1 AgentScope 用作任务执行内核,不把 Agent Team 自主会商当作调度器 + +AgentScope 官方提供 Event System、Agent Team、任务规划和 Agent Service 示例,但团队可靠性与多进程/分布式服务仍在持续完善: + +- [AgentScope README](https://github.com/agentscope-ai/agentscope/blob/main/README.md) +- [Agent Service 示例](https://github.com/agentscope-ai/agentscope/blob/main/examples/agent_service/main.py) +- [Agent Team 可靠性跟踪](https://github.com/agentscope-ai/agentscope/issues/1802) +- [多进程与分布式能力跟踪](https://github.com/agentscope-ai/agentscope/issues/1722) + +因此采用下列职责分界: + +- AgentScope Core:单个受控任务成员的模型调用、流式事件、工具调用、权限封装与结构化输出修复。 +- DeerFlow `TaskLedger`:计划实例化、依赖拓扑、并发波次、输入成果选择、状态迁移、重试、租约、取消、命令安全点与业务终态。 +- DeerFlow `ArtifactBoard` / `QualityGate`:成果物事实源、验收、失效传播、引用约束和报告版本准入。 +- `Coordinator`:受限的规划/协调智能体,只能提出 `PlanProposal`、`TaskAssignment`、`RepairRequest` 和 `ClarificationRequest`;它不能自行创建任意图、改变数据库或宣告节点完成。 +- AgentScope TeamSay:允许作为可见的协作交接文本,但不是任务分派、完成判定或数据依赖的事实来源。 +- DeerFlow:身份、权限、配置、数据库、消息、命令、作业、租约、SSE、成果、文件、报告版本和审计。 +- AgentScope Event 通过无损 adapter 转为 DeerFlow 领域事件和 LangChain 兼容消息;用户可见内容不能在 adapter 中压缩为进度摘要。 +- 不运行 AgentScope `examples/web_ui`,不使用 AgentScope Session、MessageBus 或内存对象作为恢复/重放依据,也不使用其 session SSE 作为产品 SSE。 +- 节点公开接口只返回职责合同、图关系和状态,不返回节点实际输出;实际阶段消息、工具过程和成果仍从会话消息流以原生消息、工具步骤、引用和报告文件卡的形式呈现。 + +### 2.2 新功能独立,不进入旧运行内核 + +- API 前缀:`/api/report-collaboration`。 +- 独立 session、plan、run、node run、artifact、event、command、report version 表。 +- 不读写 Workflow Studio 的 `workflow_*` 业务表。 +- 不调用 Workflow Studio 自定义 DAG scheduler。 +- 不复用 roundtable draft/job 作为新会话。 +- 可复用 Deep Research/roundtable 的基础设施模式,但不能共享状态。 + +### 2.3 流程图不是执行器 + +候选图和运行图用于: + +- 描述角色和任务依赖。 +- 展示成果物传递关系。 +- 展示当前状态和返工范围。 +- 供用户理解和选择方案。 + +真正调度由 `TaskLedger + ArtifactBoard + QualityGate` 完成。协调者只能提出服务端可验证的建议;`TaskLedger` 才能执行分派、依赖解锁、重试和终态迁移,不能返回任意 Python、任意图或数据库操作。 + +### 2.4 数据库是唯一事实源 + +- HTTP 只提交命令,不承载完整长任务。 +- SSE 只投影 durable events。 +- AgentScope Python 对象可随进程消失。 +- 恢复依赖 requirement snapshot、plan、task ledger、node runs、artifacts 和 report versions。 +- 成果物未落库并通过合同校验前,节点不能完成。 + +--- + +## 3. 总体架构 + +```mermaid +flowchart LR + UI[报告协作前端] --> API[Gateway Router] + UI <-->|SSE| API + + API --> SESS[Session/Message/Plan Store] + API --> CMD[Command Store] + API --> EVT[Event Store] + API --> REP[Report Version Store] + API --> DISP[Lease Dispatcher] + + DISP --> EXEC[Report Collaboration Executor] + EXEC --> LEDGER[Task Ledger] + EXEC --> BOARD[Typed Artifact Board] + LEDGER --> COORD[Constrained Coordinator] + LEDGER --> AS[AgentScope Core Task Workers] + COORD --> LEDGER + AS --> TEAM[Research / Verify / Analyze / Write / Review] + EXEC --> MODEL[DeerFlow Model Adapter] + EXEC --> TOOL[Allowlisted Tool Adapter] + + TOOL --> WEB[Web Search] + TOOL --> KB[Knowledge/Wiki] + TOOL --> SKILL[Approved Skills/MCP] + + BOARD --> EVT + BOARD --> REP + REP --> EXPORT[Existing Markdown/Word Export] +``` + +### 3.1 运行进程 + +开发阶段: + +- 可由 Gateway lifespan 启动单个 dispatcher/worker,便于本地联调。 +- 必须保留独立启动入口和 `worker_enabled` 开关。 + +生产阶段: + +- Gateway 仅提供 API、鉴权和 SSE。 +- 独立 worker 进程领取数据库租约并运行 AgentScope。 +- Gateway 和 worker 使用同一后端代码与数据库。 +- 多 worker 依赖数据库 claim/lease,不依赖进程内锁。 + +--- + +## 4. 包边界与目录 + +建议新增: + +```text +offline-backend-20260512/backend/app/report_collaboration/ + contracts/ + requirements.py + plans.py + nodes.py + artifacts.py + events.py + commands.py + agentscope_runtime/ + model_adapter.py + tool_adapter.py + event_adapter.py + permission_adapter.py + team_factory.py + coordinator.py + planning/ + requirement_resolver.py + agent_catalog.py + agent_selector.py + plan_controller.py + plan_assembler.py + plan_validator.py + execution/ + dispatcher.py + executor.py + cancellation.py + task_ledger.py + artifact_board.py + quality_gates.py + recovery.py + interventions/ + intent_router.py + impact_analyzer.py + command_handler.py + reporting/ + outline.py + writer.py + reviewer.py + versions.py + live_hub.py + service.py + +offline-backend-20260512/backend/app/gateway/routers/ + report_collaboration.py + +offline-backend-20260512/backend/packages/harness/deerflow/persistence/ + report_collaboration/ + model.py + base.py + sql.py + memory.py + __init__.py +``` + +依赖方向: + +- `app.report_collaboration` 可以导入 `deerflow.*` 和 `agentscope.*`。 +- `deerflow.*` 不能导入 `app.*`。 +- AgentScope 运行代码不放入 publishable harness。 +- `tests/test_harness_boundary.py` 必须继续通过。 +- AgentScope 依赖加入应用 `pyproject.toml`,不加入 Harness 公共依赖。 + +--- + +## 5. 核心领域合同 + +### 5.1 `ReportRequirementSnapshot` + +```text +topic +objective +audience +language +time_range +geography +required_angles[] +excluded_angles[] +source_constraints +report_structure_id +length_target +tone +deadline_hint +quality_priority +revision +``` + +改变研究范围的用户反馈必须创建新 revision。每个 node run 记录使用的 requirement revision,避免新旧要求混用。 + +### 5.2 `ReportPlanCandidate` + +```text +id +proposal_group_id +title +strategy +summary +rationale +estimated_duration_seconds +estimated_cost_level +requirement_revision +roles[] +nodes[] +edges[] +quality_gates[] +report_structure_id +validation +revision +``` + +模型不直接输出可执行图。模型输出 `RoleDemand`、角度和依赖建议;服务器绑定真实 agent、工具、预算、成果物类型和质量门后生成计划。 + +### 5.3 `CollaborationNodeContract` + +每个节点包含: + +- `mission` +- `input_artifact_types[]` +- `output_artifact_type` +- `allowed_tools[]` +- `source_policy` +- `acceptance_criteria[]` +- `max_attempts` +- `time_budget_seconds` +- `token_budget` +- `agent_profile_snapshot` +- `dependencies[]` + +### 5.4 强类型成果物 + +| 成果物 | 生产者 | 必要内容 | 主要消费者 | +|---|---|---|---| +| `EvidenceBundle` | Researcher | 事实、来源、日期、摘录、可信度、冲突 | Verifier、Analyst | +| `VerificationResult` | Verifier | 支持/反对证据、时效、冲突、可用结论 | Analyst、Reviewer | +| `AngleAnalysis` | Analyst | 观点、证据引用、假设、局限、缺口 | Synthesizer | +| `SynthesisOutline` | Synthesizer | 论证主线、章节、结论、证据分配 | Writer | +| `ReportSectionDraft` | Writer | 章节 Markdown、引用、证据 ID | Reviewer、总报告作者 | +| `ReviewDecision` | Reviewer | pass/revise、严重度、问题、返工目标 | Coordinator、Reviser | +| `ReportVersion` | Writer | Markdown、来源索引、模板与质量结果 | 用户、导出器 | + +TeamSay 用于协作协调,并且其用户可见文本必须原样镜像到会话消息流,便于用户实时查看各成员如何交接;影响下游和报告的内容仍必须写入成果物并通过 Pydantic/JSON Schema,不能因为一条 TeamSay 而视为节点完成。 + +### 5.5 节点状态机 + +```text +planned -> queued -> running -> validating -> completed + | | + | +-> failed + +-> awaiting_input -> queued + +-> cancel_requested -> cancelled + +-> superseded + +completed -> reopened -> queued +``` + +完成规则: + +- 产生一条 assistant 消息不等于完成。 +- 产生或提交用户协助卡不等于完成。 +- AgentScope 一次 reply 返回不等于完成。 +- 期望成果物落库、Schema 通过、验收器通过后才能完成。 +- 返工创建新 attempt,保留旧 attempt 和旧成果。 + +### 5.6 `TaskLedger`:智能体协作的确定性边界 + +每个计划节点被实例化为一条可审计的 ledger task;并行只是多个 ready task 位于同一波次,不是让多个智能体在群聊中自行决定谁做什么。 + +```text +TaskAssignment + node_id / node_run_id / phase_id / attempt + requirement_revision + allowed_input_artifact_ids[] + expected_artifact_type + acceptance_criteria + agent_profile_snapshot + allowed_tools + budgets + command_overlay_revision + +TaskResult + artifact_draft | clarification_request | repair_request | failure + cited_artifact_ids[] + source_ids[] +``` + +调度规则: + +1. `PlanAssembler` 将角色建议转为固定节点、边、成果合同和质量门;只允许无环主图。 +2. `TaskLedger` 仅在依赖的成果物均为 `validated`、未 supersede 且预算可用时使节点 ready。 +3. 每个成员只读 requirement revision、自己的 `TaskAssignment` 与允许的已验证上游成果;不能把其他成员的原始上下文、工具返回或内部 prompt 当作输入。 +4. 并行节点共享 `phase_id`,但各自有独立 `agent_run_id`;串行节点用独立 `phase_id`。成员的 `node_run_id` 一律为 `${node_id}-attempt{attempt}`。 +5. 只有 `TaskResult` 转为成果物并通过 `QualityGate` 后,ledger 才解锁下游;TeamSay 与普通文本没有解锁能力。 +6. 修复/返工创建新 attempt;旧 attempt 及其消息留存但被标为 `superseded`,不得重新进入下游。 + +### 5.7 用户命令的约束化协议 + +每句话先持久化为用户消息和 `CollaborationCommand`,再由独立的**意图理解智能体(`IntentRouterAgent`)**进行两段式判定:规则/上下文高精度路由优先,必要时调用模型并输出受 Pydantic 约束的 `IntentDecision`。它负责理解,不负责调度或执行。 + +同一能力有两种受控入口: + +| 时机 | `IntentRouterAgent` 的工作 | 后续处理 | +|---|---|---| +| 尚未开始 run | 提取主题、目标、受众、范围、时间、角度、模板和来源约束;判断真正缺失的信息 | `RequirementResolver` 写 requirement revision;`PlanController` 生成候选计划。 | +| run 正在执行或已有报告 | 结合当前选中的 node/agent、等待澄清、成果血缘和报告章节,识别用户是在回答、补角度、重检索、重分析、重跑、改章节、润色、查询还是改规划 | `ImpactAnalyzer` 计算实际影响;`TaskLedger` 在确认和安全点后执行。 | + +输入包含用户文本、session/run 状态、最新 requirement、当前 `target_node_id` / `agent_run_id`、awaiting clarification、当前报告版本和可见成果摘要;不得输入其他成员的内部 prompt、隐藏推理或无权访问的资料。 + +```text +IntentDecision + intent / confidence / normalized_instruction + scope: conversation | node | artifact_lineage | section | plan + requested_target_ids[] / requirement_patch? + requires_confirmation / cost_level / reason +``` + +服务端必须验证 `target_node_id`、`agent_run_id` 属于当前 session/run,再解析到 node 和成果物血缘;不可相信客户端传入的任意 ID。`ImpactAnalyzer` 产出实际 `impact_nodes`、失效成果和新 attempt 计划,用户所见的命令状态由它和 `TaskLedger` 写入,而非由协调者文本决定。 + +`IntentRouterAgent` 的置信度不足、目标不唯一、涉及 requirement/plan revision、预计成本为 high 或影响多个并行分支时,必须返回 `requires_confirmation=true`;只有用户确认后 `TaskLedger` 才可执行。对于处于 `awaiting_input` 的节点,明确回答优先分类为 `answer_question`,只解锁该节点的依赖链。 + +--- + +## 6. 数据库设计 + +| 表 | 用途 | 关键字段 | +|---|---|---| +| `report_collaboration_sessions` | 会话 | owner、title、status、requirement_revision、selected_plan_id、active_run_id、current_report_version_id | +| `report_collaboration_messages` | 对话原文镜像 | message_id、role、content、tool_calls_json、tool_call_id、agent_run_id、node_run_id、phase_id、visibility、metadata、command_id、created_at、completed_at | +| `report_collaboration_plans` | 候选及修订 | proposal_group_id、revision、status、graph_json、validation_json | +| `report_collaboration_runs` | 正式执行 | plan_id、status、lease_owner、lease_until、cancel_requested、next_event_seq | +| `report_collaboration_node_runs` | 节点尝试/ledger task | node_id、attempt、`node_run_id=${node_id}-attempt{attempt}`、status、contract_json、input_artifact_ids、command_overlay_revision、agent_snapshot、error、started_at、ended_at | +| `report_collaboration_agent_runs` | 实际团队成员实例 | agent_run_id、node_run_id、phase_id、role、display_name、avatar_key、status、agent_snapshot、started_at、ended_at | +| `report_collaboration_artifacts` | 成果物及血缘 | artifact_type、producer_node_run_id、requirement_revision、schema_version、content_json、validation_status、superseded_by | +| `report_collaboration_events` | durable SSE | run_id、seq、event_type、payload、created_at | +| `report_collaboration_commands` | 用户命令 | command_id、idempotency_key、intent、scope、status、expected_revision | +| `report_collaboration_report_versions` | 报告版本 | version、parent_version_id、markdown、source_index、change_summary | + +持久化要求: + +- SQL 和 memory store 共用抽象接口。 +- active run 使用数据库唯一键去重。 +- command 使用 idempotency key 去重。 +- session/run/node 命令使用行锁和 expected revision。 +- 事件 seq 在 run 行内原子分配。 +- 用户消息、协调者消息、成员 TeamSay、流式回复、工具调用和工具返回使用稳定 `message_id` 与 timeline/event seq;同一 `agentRunId` 内严格保持因果顺序,保证 `MessageList` 能以原生消息重建全过程。 +- 并行成员的消息分别归属 `agent_run_id`,同一事件流仍全量持久化;前端选择标签只能过滤/聚焦,不能影响事件写入或消息顺序。 +- 成员状态的 event 投影使用 `session.snapshot` 中的增量 `agent_runs[]` 合并;不可自定义未被前端处理的 `agent_run.*` 事件。 +- 删除 session 显式删除子资源,不依赖 SQLite cascade pragma。 +- 写事务在 commit 前完成必要序列化。 +- 不使用跨连接 post-commit refresh。 +- SQLite、MySQL、PostgreSQL 都有迁移与测试。 + +--- + +## 7. Gateway API 与事件协议 + +### 7.1 REST API + +```text +POST /api/report-collaboration/sessions +GET /api/report-collaboration/sessions +GET /api/report-collaboration/sessions/{session_id} +PATCH /api/report-collaboration/sessions/{session_id} +DELETE /api/report-collaboration/sessions/{session_id} + +POST /api/report-collaboration/sessions/{session_id}/messages +GET /api/report-collaboration/sessions/{session_id}/messages +POST /api/report-collaboration/sessions/{session_id}/plan-requests +GET /api/report-collaboration/sessions/{session_id}/plans +POST /api/report-collaboration/sessions/{session_id}/plans/{plan_id}/select +POST /api/report-collaboration/sessions/{session_id}/runs + +GET /api/report-collaboration/runs/{run_id} +GET /api/report-collaboration/runs/{run_id}/events?after_seq={seq} +GET /api/report-collaboration/runs/{run_id}/stream?after_seq={seq} +POST /api/report-collaboration/runs/{run_id}/commands +POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/confirm +POST /api/report-collaboration/runs/{run_id}/commands/{command_id}/cancel +POST /api/report-collaboration/runs/{run_id}/cancel +GET /api/report-collaboration/runs/{run_id}/nodes/{node_id}/contract + +GET /api/report-collaboration/runs/{run_id}/artifacts +GET /api/report-collaboration/runs/{run_id}/sources +GET /api/report-collaboration/sessions/{session_id}/reports +POST /api/report-collaboration/sessions/{session_id}/report-rewrites +POST /api/report-collaboration/sessions/{session_id}/report-rewrites/{id}/apply +POST /api/report-collaboration/sessions/{session_id}/report-versions/{id}/restore +``` + +`GET /sessions/{session_id}` 必须返回完整 `SessionSnapshot`: + +```text +session / messages / plans / run / nodes / agent_runs / commands / +report_versions / last_seq +``` + +写请求必须读取并回显: + +- `X-Idempotency-Key`(服务端唯一去重键;用户消息镜像写入 `metadata.idempotency_key`) +- `X-Expected-Revision`(陈旧修改返回 409,不静默覆盖) + +计划请求和 run 请求只创建 durable command/job,不在请求生命周期中等待完整模型输出。 + +节点合同接口只返回: + +- 节点名称、角色和分析角度。 +- `mission`。 +- 所需输入类型和资料策略。 +- 预期输出类型、结构和验收条件。 +- 允许工具、禁止事项和预算。 +- 上下游节点 ID。 +- 当前状态标签。 + +它不得返回节点实际生成内容、工具返回、成果物正文、错误堆栈或 attempt 时间线。重新检索、重新分析和重跑节点统一通过 `POST /runs/{run_id}/commands` 提交自然语言命令。 + +### 7.2 SSE Event Envelope + +```json +{ + "eventId": "evt_xxx", + "sessionId": "session_xxx", + "runId": "run_xxx", + "seq": 128, + "type": "node.status.changed", + "timestamp": "2026-09-02T10:00:00Z", + "data": {} +} +``` + +事件类型: + +```text +session.snapshot +message.created +message.delta +message.completed +tool_call.created +tool_call.delta +tool_call.completed +tool_result.created +tool_result.delta +tool_result.completed +team_message.created +team_message.delta +team_message.completed +requirement.updated +clarification.requested +clarification.resolved +plan.progress +plan.proposed +plan.selected +run.status.changed +node.created +node.status.changed +node.progress +node.attempt.created +artifact.created +artifact.validated +artifact.rejected +source.added +command.classified +command.confirmation.required +command.accepted +command.completed +command.failed +report.delta +report.version.created +review.created +error +heartbeat +``` + +规则: + +- 历史与实时使用相同 envelope。 +- 支持 `Last-Event-ID` 和 `after_seq`。 +- 高频 token/tool 参数增量按 50~250ms 或大小阈值合帧为**已持久化** `message.delta` / `tool_call.delta` / `tool_result.delta`;不能每 token 写数据库,也不能仅 live-only 后丢失重放。 +- 每一批增量与消息最新正文同事务写入,再向 Live Hub 发布;断线时能通过 `after_seq` 重放,快照也能读到最新 checkpoint。 +- 状态、成果、命令和报告版本必须 durable。 +- 读取事件暂时失败时保持连接并发送可恢复 warning。 +- `session.snapshot` 可以携带完整快照,也可以携带 `run`、`nodes`、`agent_runs` 的增量集合;成员状态变化必须用此事件投影。 + +`message.*` / `team_message.*` 的 `data` 必须携带 LangChain 兼容的消息字段和归属,而非仅有摘要: + +```json +{ + "message": { + "id": "msg_agent_01", + "type": "ai", + "content": "正在对销量口径做交叉核对……", + "tool_calls": [], + "name": "品牌竞争分析师", + "additional_kwargs": { + "report_collaboration": { + "agentRunId": "agent_run_brand", + "nodeRunId": "node_run_12", + "phaseId": "phase_parallel_research", + "sourceEventId": "as_evt_456" + } + } + } +} +``` + +工具调用先在同一条 `ai` 消息的 `tool_calls` 上创建/追加参数 delta;返回值随后以 `type: "tool"`、相同 `tool_call_id` 的独立消息传输。允许用户查看的正文、参数和返回必须保持原文以及原始增量边界;只有统一脱敏器标记的字段可替换,替换理由写入 metadata。每个成员在开始生成时先发送 `message.created`,因此即使首 token 尚未到达,前端也能立即显示该成员正在回复,而非静默等待。 + +`reasoning_delta` 仅可承载经策略批准的、面向用户的工作说明(如“正在比对统计口径”),不允许桥接模型隐藏推理链、系统 prompt 或内部审稿思路;如模型未提供安全的公开工作说明,省略字段,过程可见性由 TeamSay、工具步骤、来源与普通 assistant 文本承担。 + +--- + +## 8. 分步实施计划 + +工单状态见 §0。本节正文仍是验收标准,不因部分完成而删减。 + +### BE-0:AgentScope 兼容性尖峰 + +**状态:条件完成(`RC-BE-000`)。** 事件适配与 dict/真实事件单测已有;真实双模型流式与 `uv.lock` 仍属后续 POC。20 项标注评测与 20 次固定任务统计已在 `RC-BE-018` 落地。 + +先写实验和测试,不接前端,不建正式业务表。 + +验证: + +1. 当前 Python 3.12 与候选 `agentscope==2.0.5` 兼容;POC 通过后才将精确版本写入 lock,不能使用浮动 `main`。 +2. 验证单任务 worker 所需的 Agent、Tool、Event、Permission API;Agent Team 的自主调度与 Session SSE 不属于本功能的正确性依赖。 +3. `uv lock` 后检查 Pydantic、SQLAlchemy、FastAPI、OpenAI SDK 冲突。 +4. 使用当前至少两个模型完成流式回复。 +5. `TaskLedger` 分派 2 个并行 worker,完成可见 TeamSay、并发和结构化输出。 +6. 主动取消、超时、工具异常和成员异常行为可控。 +7. AgentScope Event 可稳定关联 agent、task、message、tool call,并能一对一写出 LangChain 兼容的 assistant/tool 消息。 +8. 内存状态丢失后可由 DeerFlow task ledger/artifact 重建。 +9. 连续运行 20 次固定任务,统计早停、空输出、非法 JSON、事件缺失。 +10. 从首个 `REPLY_START`、TeamSay、tool-call start、tool result 到 token delta 的首个可见事件均在指定时限内到达 adapter;并行两个成员交错输出时不串 message ID 或 tool call ID,并能按前端既定 `phase_id` / `node_run_id` 聚焦。 +11. 人为注入流式中断、未闭合 tool call、等待用户输入与恢复场景;adapter 必须补发终态 tool result,不能留下永久 loading 的工具步骤。 + +交付: + +- `AGENTSCOPE_BASELINE.md`。 +- 最小 team/model/tool/event adapter 测试。 +- Go/No-Go 结论。 + +Go 条件: + +- 结构化输出达到 POC 阈值。 +- 取消在预算时间内生效。 +- AgentScope 异常不会把任务误标完成。 +- 业务恢复不依赖 AgentScope Python 对象。 + +### BE-1:配置、依赖和总开关 + +**状态:完成(`RC-BE-002`)。** `enabled` / `worker_enabled` 默认 `false`。 + +新增 `config.yaml -> report_collaboration`: + +```text +enabled +worker_enabled +planner_model +coordinator_model +research_model +writer_model +reviewer_model +max_team_members +max_parallel_tasks +max_repair_rounds +max_node_attempts +session_timeout_seconds +node_timeout_seconds +lease_seconds +heartbeat_seconds +event_retention_days +allowed_tools +source_policy +token_budget +cost_budget +``` + +要求: + +- 默认关闭。 +- 限制在服务端强制执行。 +- 模型必须来自 `AppConfig.models`。 +- 工具必须来自 allowlist。 +- AgentScope 依赖只加入应用 `pyproject.toml`。 +- 离线 wheelhouse 和镜像在 `knowledge-base-bj/deerflow-offline-deployment` 更新,不提交到本业务仓库。 + +### BE-2:领域合同和状态机 + +**状态:完成(`RC-BE-001`)。** + +完成 Pydantic 合同: + +- requirement。 +- agent catalog projection。 +- role demand。 +- plan candidate/graph/validation。 +- node contract/status/attempt。 +- agent run(并行阶段的稳定成员身份、显示资料和状态)。 +- artifact schemas。 +- command intent/status。 +- event envelope。 +- report version/rewrite proposal。 + +测试所有非法状态迁移,尤其: + +- awaiting input 卡片出现后不能完成节点。 +- AgentScope reply 返回后无成果物不能完成节点。 +- cancelled 后晚到输出不能回写 completed。 +- completed 节点重开时必须产生新 attempt。 + +### BE-3:迁移和持久化 Store + +**状态:完成(`RC-BE-003`)。** 表前缀 `report_collaboration_*`,迁移 `20260905_01`。 + +任务: + +1. 创建数据库迁移。 +2. 实现 memory stores。 +3. 实现 SQL repositories。 +4. 注册 ORM 模型与 `create_all`。 +5. 在 app lifespan 注入 store。 +6. 完成 SQLite/MySQL/PostgreSQL 测试。 + +并发测试: + +- 同一 idempotency key 只创建一条 command。 +- 同一 session 只允许一个 active run。 +- 两个 worker 不能同时领取同一 lease。 +- event seq 唯一且递增。 +- stale revision 返回 409。 + +### BE-4:会话、消息和计划 API 骨架 + +**状态:骨架完成(`RC-BE-004`)+ SSE 已挂载(`RC-BE-011`)+ 改写/恢复已挂载(`RC-BE-015`)。** REST 会话/消息/方案/run 已挂载;`GET /runs/{id}/stream` 为 durable SSE。 + +任务: + +- session CRUD 和用户隔离。 +- `GET /sessions/{id}` 一次返回前端 `SessionSnapshot` 的全部字段;run 前的澄清/规划阶段由该快照在 2.5 秒轮询内可见。 +- message 持久化:完整保存用户可见的 assistant、TeamSay、`tool_calls` 与 `tool` 返回,并保留 `agent_run_id`/`node_run_id`/`phase_id`;不只保存阶段摘要。 +- plan request 入队。 +- plan list/select。 +- run create/query。 +- OpenAPI 响应模型。 +- 路由注册顺序和统一错误模型。 +- 按 `frontend-web/src/report-collaboration/api/types.ts` 建立契约测试:字段名保持 snake_case,枚举、事件类型、命令确认/取消端点和 revision 头不可漂移。 + +安全: + +- 不返回 agent 私有 SOUL 全文。 +- 不返回模型密钥、base URL 和工具凭据。 +- 计划卡只返回用户可见职责、能力和选择理由。 + +### BE-5:需求解析和澄清 + +**状态:完成(`RC-BE-005`)。** 规则路径;尚未接规划用 LLM。澄清完成不会自动 `plan-requests`。 + +`IntentRouterAgent`(规划前模式)与 `RequirementResolver`: + +1. 读取会话消息和最新 requirement snapshot。 +2. `IntentRouterAgent` 输出结构化需求提取、澄清判定或“可直接规划”判定;不得自行建图或启动任务。 +3. `RequirementResolver` 校验并写入主题、目标、受众、范围、角度、模板和来源限制。 +4. 判断是否缺少真正影响方案选择的必要信息。 +5. 必要时产生一个或多个独立 clarification。 +6. 用户回答后创建新 requirement revision。 +7. 达到澄清上限后使用明确假设继续,不能无限追问。 + +验收: + +- 只有主题的输入会询问真正影响报告的缺口。 +- 用户明确说“直接开始”时记录默认假设。 +- clarification resolved 不等于 plan ready 或 node completed。 + +### BE-6:智能体目录、筛选和候选计划 + +**状态:完成(`RC-BE-006`)。** 选择器为规则重排,未接规划模型。`POST /plan-requests` 在 `worker_enabled=false` 时于请求内同步生成方案(非 LLM)。 + +五阶段: + +1. `AgentCatalogProjection`:读取用户可见且启用的 agent、技能和工具元数据。 +2. `AgentSelector`:规则初筛并用模型重排,输出选择理由。 +3. `PlanController`:生成 2~3 种策略的 RoleDemand 和角度。 +4. `PlanAssembler`:绑定真实 agent、工具、预算、成果合同和质量门。 +5. `PlanValidator`:校验 agent、tool、template、依赖、并发和预算。 + +默认策略: + +- `balanced_review`:完整研究、核验、分析、写作、审稿。 +- `parallel_depth`:更多并行角度和双重核验。 +- `focused_fast`:角色较少、范围较窄,但保留引用核验和审稿。 + +要求: + +- 三种方案必须真实影响角色、范围、预算或质量门。 +- 模型不能引用目录外 agent ID。 +- 每个节点必须有成果类型和验收标准。 +- 候选图无任意循环;返工由运行时台账表达。 +- 方案保存 revision,选择和执行是两个独立命令。 + +### BE-7:AgentScope 模型适配 + +**状态:完成(`RC-BE-007`)。** 适配器鸭子类型对齐 `ChatModelBase.__call__`;运行前冻结快照。真实 Provider 冒烟仍属 `RC-BE-009` / `017`。 + +目标:让 AgentScope 使用 DeerFlow 配置的模型,不再建立第二套 Provider 配置。 + +任务: + +- 从 `AppConfig.models` 解析模型。 +- 优先实现 AgentScope ChatModelBase adapter。 +- 复用现有 Provider 超时、代理和密钥管理。 +- 转换流式 text、tool call、usage 和错误。 +- 记录每个 node attempt 的 token/费用。 +- 模型不存在或能力不匹配时在 run 前失败。 + +约束: + +- prompt、事件和日志不出现密钥。 +- 一次 run 的角色模型快照冻结,运行中不静默换模型。 +- 模型 fallback 必须记录事件和审计。 + +### BE-8:工具与权限适配 + +**状态:完成(`RC-BE-008`)。** + +第一版允许: + +- Web 检索和正文获取。 +- 用户有权限的知识库/Wiki 检索。 +- 明确批准的只读技能/MCP。 +- 成果物板读写工具。 +- 用户可见交接(TeamSay)与受控成果物/澄清工具。 + +第一版默认禁止: + +- 宿主文件系统直接访问。 +- 任意代码执行。 +- 任意 HTTP。 +- 任意数据库写入。 +- 运行期安装包或技能。 + +角色权限: + +- Researcher 不能发布报告。 +- Writer 不能把未核验证据改成已核验。 +- Reviewer 只能输出 ReviewDecision,不能覆盖报告。 +- Coordinator 只能提出受控的计划、澄清与修复建议;实际创建任务、分派、等待、返工和完成由 `TaskLedger` 服务执行。 +- 所有成员获得本次 run 的最小权限快照。 + +验收: + +- 每个角色有 allow/deny 测试。 +- 工具结果携带 source ID。 +- 网页 prompt injection 不能提升权限。 +- AgentScope 工具异常被转换为 node/tool event,不泄露内部堆栈给用户。 + +### BE-9:受控任务成员运行器(不采用自治 Team 调度) + +**状态:完成(`RC-BE-009` 脚本内核 + `RC-BE-013` 质量角色内核)。** `TaskLedger` + `WaveExecutor` + `QualityRoleKernel`。真实 AgentScope `reply_stream` / web_search 仍待接。 + +基础角色: + +- `Coordinator` +- `Researcher` +- `Verifier` +- `Analyst` +- `Synthesizer` +- `Writer` +- `Reviewer` +- `Reviser` + +动态规则: + +- 计划决定 Researcher/Analyst 数量和角度。 +- 服务器限制团队人数、并发、attempt、返工和预算。 +- 每个成员上下文只包含当前任务、需求快照和必要上游成果。 +- `Coordinator` 只接收结构化成果摘要、质量失败与用户命令影响,输出候选计划/修复建议;它不拥有自由消息总线或数据库权限。 +- `TaskLedger` 依据已验证成果、预算与并发限额逐波创建 AgentScope worker;AgentScope 不选择下一节点,也不决定任务完成。 +- 每个 worker 只能调用 `submit_artifact`、`request_clarification`、`request_repair`、允许的资料工具和可见 TeamSay;所有改变运行状态的调用都由服务端重新校验。 +- 任务完成由 QualityGate 判定,不接受文本声明。 + +事件映射: + +- AgentScope `REPLY_*` → 以原始文本 chunk 创建/更新协调者或成员的稳定 assistant 消息,保留 `agentRunId`;不得等待 reply 完成后再合成为进度摘要。 +- AgentScope TeamSay → 以成员身份创建/更新普通 assistant 消息,保留完整用户可见交接内容与因果顺序;它不会改变 ledger 状态。 +- `MODEL_CALL_*` → 内部遥测,默认不下发原始 prompt。 +- `TOOL_CALL_*` → 原生 `ai.tool_calls` 参数事件;对应工具返回 → 原生 `tool` 消息和相同 `tool_call_id`。两者在 SSE 和持久化中一一对应,供现有 `MessageGroup` 直接渲染。 +- `TaskLedger` 的分派/校验/重试/取消结果 → node/task ledger 状态;AgentScope 仅上报可验证的 TaskResult。 +- 成果工具 → artifact created/validated/rejected。 +- 每个用户可见阶段开始、进展、完成、等待、返工和失败都必须同步产生稳定的消息或状态事件,供左侧 DeerFlow `MessageList` 按原始消息顺序展示;不能要求前端读取节点结果后自行拼装过程,也不能用聚合卡替换 AgentScope 的实时可见消息。 +- agent run 的开始、等待输入、完成、失败和取消,紧邻节点状态事件发 `session.snapshot` 增量,至少包括相应的 `agent_runs[]` 和当前 `run.current_phase`。 + +验收: + +- 成员失败可定向重试或替换,不直接完成整个 run。 +- 空输出和非法 JSON 进入 repair attempt。 +- 超过上限时给出明确失败/部分完成,不无限循环。 + +### BE-10:成果物板与质量门 + +**状态:完成(`RC-BE-010`)。** `QualityGate` 为确定性规则(无二次模型调用)。模板冻结快照与正式 `ReportVersion` 落库已在 `RC-BE-015` 落地。 + +`ArtifactBoard`: + +- 按 run/node/attempt/type 保存成果。 +- 保存 schema version、需求 revision、来源引用和 validation status。 +- 只向下游提供 `validated` 成果。 +- 返工保留旧成果并标记 superseded。 + +`QualityGate`: + +- `CoverageMatrix`:把需求中的每个必选角度/关键问题映射到证据目标、分析节点和模板章节;计划和最终版本均不可漏项。 +- `EvidenceBundle`:按**主张级**保存 `claim_id`、来源 URL/文档 ID、发布/获取时间、原文摘录、事实或推断标签、适用范围、数值单位/口径、可信度与去重结果。 +- `VerificationResult`:对关键主张给出支持与反对证据、冲突、时效、来源独立性和可用结论;数字及重要事实必须经此门。 +- `AngleAnalysis`:结论只能引用验证过的 evidence ID,必须分别标示事实、推断、假设、局限和资料缺口。 +- `ReportSectionDraft`:关键事实和数字必须有 evidence ID;章节的主张与引用一一可追溯。 +- `CitationTemplateLinter`:校验模板章节、必答问题、来源格式、数字/日期/实体一致性、不可用或过期来源和未引用的关键事实。 +- `ReviewDecision`:独立于 Writer 上下文运行,问题必须有目标、严重度、证据和修复要求;不能笼统地要求“整体再优化”。 +- `ReportVersion`:覆盖矩阵、引用、模板、章节衔接、结论一致性和正式版本持久化全部通过才可创建。 + +验收: + +- 无成果、空成果和不合 Schema 的成果不能完成节点。 +- 未验证来源不能直接进入最终事实陈述。 +- Reviewer 的返工目标可映射到具体节点和章节。 +- 同一事实出现冲突或同一数字缺少口径时,QualityGate 必须拒绝或要求定向补证,不能由 Writer 擅自选择一个答案。 + +### BE-11:后台 Dispatcher、租约和恢复 + +**状态:完成(`RC-BE-012`)。** `worker_enabled` 默认仍为 false:Gateway lifespan 只有 `enabled` **且** `worker_enabled` 同时为真才 `dispatcher.start()`。测试走 `dispatch_once()` / `executor.execute()`,不要依赖后台循环。 + +复用现有成熟模式: + +- active-dedupe key。 +- dispatcher 原子 claim。 +- executor heartbeat。 +- cancel watcher。 +- 两阶段取消。 +- lease 失效接管。 +- durable event + live hub。 + +恢复: + +1. 读取 requirement、selected plan、task ledger。 +2. 保留已验证且未失效成果。 +3. 将遗留 `running` attempt 标为 interrupted。 +4. 为未完成任务创建新 attempt。 +5. 重建 AgentScope team。 +6. 注入必要成果和协调摘要。 +7. 从下一批 ready task 继续。 + +验收: + +- 杀死 worker 后可接管。 +- 已完成节点不重复检索和计费。 +- worker 丢失 lease 后停止本地模型与工具链。 +- cancel 后晚到结果只可保存为 superseded,不改变终态。 + +### BE-12:SSE 和 Live Hub + +**状态:完成(`RC-BE-011`)。** `ConversationReplica` 已实现协议镜像;产品 SSE 经 `execution/live_hub.py` 挂载。报告正式版本写入已在 `RC-BE-015` 落地。 + +任务: + +- 实现 event repository。 +- 实现 run-scoped live hub。 +- 实现历史分页和 SSE replay。 +- 支持 heartbeat、after_seq、Last-Event-ID。 +- 短暂存储读取异常时保持连接。 +- 将高频 token delta 合帧。 +- 建立 `ConversationReplica`:将 AgentScope 用户可见消息、TeamSay、assistant delta、`tool_calls`、`tool` 返回、协助请求、报告文件和领域状态写成可重放的统一消息事件。它是协议镜像而不是摘要生成器。 +- 对话镜像完整保留用户有权查看的 TeamSay、原始工具参数和返回;不包含 chain-of-thought、系统/内部 prompt、密钥、权限不可见内容和异常堆栈。脱敏在入库/下发前一次执行,不能由前端自行推断。 +- 对前端已支持的 `reasoning_delta`,仅推送由本系统生成或模型明确允许公开的短工作说明;它不等同于原始思维链,也不得用作质量门、调度或审计的唯一依据。 +- `agentRunId` 是并行消息流的稳定归属。Live Hub 始终发布所有成员事件;前端标签切换只在本地选择消息,不启动第二条运行流,也不取消未聚焦成员的 delta。 + +验收: + +- 重连不丢 durable event。 +- 重复连接不重复执行业务命令。 +- 终态前报告正式版本已写入并可读。 +- SSE 断开不取消后台 run。 +- 仅依靠会话快照和事件重放即可重建每个成员的完整原生消息、工具过程和左侧聚焦视图,不需要调用节点结果接口。 +- 在模型/工具中断、取消和接管时,所有已开始的 tool call 都有对应的 completed tool result(成功、失败或 interrupted),不会让复用的 `MessageList` 永久显示执行中。 + +### BE-13:全过程命令与影响分析 + +**状态:完成(`RC-BE-014`)。** `app/report_collaboration/interventions/` 已实现运行期 IntentRouterAgent、ImpactAnalyzer 和 RuntimeCommandService;SQL/memory store 通过 `patch_command(..., expected_statuses=...)` 对命令执行权做 CAS。置信度阈值由 `report_collaboration.intent_confirmation_threshold` 配置(默认 `0.8`)。 + +实现边界:本票负责识别、授权、影响计算、确认、定向失效、overlay 和安全重排队。`rewrite_section` / `polish_report` 已能严格保留研究链并只返工写作/审校链;replacement candidate、正式版本 apply/restore 已在 `RC-BE-015` 落地。`add_angle` 优先使用用户定向的研究分支,否则按冻结计划顺序确定性选择一个研究分支,只接入其必要下游;无研究支路立即 `rejected`。`rerun_node` 没有合法目标时不得扩成整图。需求补丁只在返工成功后写入。卡住的 `executing` 可在接管或租约超时后收回。`command.cancelled` 进入 SSE。运行中 `POST /sessions/{id}/messages` 返回 409。 + +`IntentRouterAgent`(运行中干预模式)输入: + +- 用户文本。 +- session/run 状态。 +- requirement revision。 +- 当前选中节点或章节上下文。 +- 后端动作白名单。 + +先由规则层处理明确的回答、取消和节点/章节显式指令;其余由 `IntentRouterAgent` 输出: + +```text +intent +confidence +target_type +target_ids[] +normalized_instruction +requires_confirmation +cost_level +reason +``` + +模型输出必须通过 Pydantic 校验,并经服务端 target 授权与置信度/成本闸门后才创建可执行 command;`IntentRouterAgent` 不可调用分派、取消、成果写入或报告版本工具。 + +命令类型: + +- `answer_question` +- `update_requirements` +- `add_angle` +- `research_again` +- `reanalyze` +- `rerun_node` +- `rewrite_section` +- `polish_report` +- `ask_report` +- `replan` +- `cancel` + +`ImpactAnalyzer`: + +- `answer_question`:只解析相应的 clarification;仅解锁依赖该答案的节点,其他并行节点继续。 +- `research_again`:为指定节点/角度追加或替换 EvidenceBundle,失效其验证、分析、综合和受影响章节,不误伤无关角度。 +- `reanalyze`:保留 validated evidence,只失效指定分析、综合、写作和审校分支。 +- `add_angle`:在当前冻结计划中选择一个明确目标或确定性的研究支路承接新角度,再只失效其必要下游;无可用支路时拒绝执行。运行中不暗改前端已选择的 DAG,真正新增画布节点只发生在后续重新规划产生的新 plan revision 中。 +- `rerun_node`:以新 attempt 重跑指定节点及必要下游,旧结果保留为 superseded。 +- `rewrite_section`:保留研究/分析,只生成 replacement candidate 和该章节审校;用户 apply 后才产生新版本。 +- `polish_report`:不重检索;在当前正式版本上创建新版本,并再次执行模板/引用 lint。 +- `update_requirements` / `replan`:均先展示整图影响与高成本确认;前者写入新的 requirement revision 后重开冻结计划,后者重开当前计划全部节点。生成新候选 plan/run branch 的交互留在后续规划版本能力,不能在旧 run 的 SSE 内偷偷切换 run。 +- `ask_report`:只读取当前已验证成果或报告版本,不改变任务图。 + +Safe-point: + +- 可取消的当前工具调用先停止。 +- 不可立即取消的 attempt 继续到边界,command 状态为 `accepted_waiting_boundary`。 +- 晚到输出可保存但标记 superseded,不能进入下游。 +- 每条 command 有分类、影响范围、状态和结果。 +- command overlay 仅在模型调用之间、工具返回后或节点提交成果前生效;在不可中断工具期间不能伪称已经完成修改。 + +验收: + +- 低置信度“改一下”必须追问。 +- 相同幂等键不重复创建返工任务。 +- 运行中命令不能造成 completed/cancelled 状态竞争。 +- 用户针对画布/成员卡的定向修改只影响解析出的节点和成果血缘,不能被协调者扩大为整场重跑。 + +### BE-14:报告模板、写作、审稿和版本 + +**状态:完成(`RC-BE-015`)。** 改写 / apply / restore 已挂载;正式版本在质量门通过后落库。 + +复用 `/api/report-structures`,run 开始时冻结模板快照: + +- 章节层级。 +- 每章目标与必答问题。 +- 必需表格/对比维度。 +- 语气、语言、篇幅。 +- 检索方向与允许技能。 +- 引用格式。 + +写作规则: + +- Writer 只能使用冻结模板、`CoverageMatrix` 和 validated artifacts,禁止携带 Web/知识库工具或原始检索上下文进入写作提示词。 +- 关键判断绑定 evidence ID;无法证实的内容明确标为分析、假设或局限。 +- 每章获得确定 evidence set、字数目标和必答问题,输出章节草稿及主张—引用映射。 +- 完整报告先执行 CitationTemplateLinter,再由独立 Reviewer 进行事实、逻辑、受众适配和结论审校。 +- Reviewer 输出结构化 ReviewDecision;Reviser 只修改指定问题,并由同一质量门复验。 +- 达到返工上限后报告未解决问题,不无限循环。 + +版本规则: + +- 流式 Markdown 是临时草稿,不覆盖当前正式版本。 +- 正式版本须在所有质量门通过后落库,随后才发 `report.version.created` 并令 run completed。 +- 章节改写生成 replacement candidate。 +- 用户 apply 后创建新版本。 +- restore 也创建新头版本,不破坏历史。 + +### BE-15:安全、审计和预算 + +**状态:完成(`RC-BE-016`)。** 快照与 SSE 走统一脱敏;资源 owner、跨会话幂等、预算上限与审计已落地。 + +- 所有资源校验 owner 或明确授权。 +- agent snapshot 移除密钥和不可见 prompt。 +- 工具参数/结果完整镜像到用户消息流;只有密钥、令牌、隐私字段、越权内容和明确的异常堆栈由统一脱敏器替换并记录原因。不能以大小截断或摘要替代用户有权查看的返回;超大返回以可重放 chunk 传输和按需折叠展示,正文仍可完整读取。 +- 外部内容全部视为不可信资料。 +- 每用户 active run 上限。 +- 每 run 团队、并发、检索、来源正文、模型调用、token、费用和总时长上限。 +- 审计 agent 选择、工具调用、来源、用户命令、人工确认和报告版本。 +- 不持久化、不下发原始 chain-of-thought、系统/内部 prompt 或模型隐藏推理 token;前端可显示的 `reasoning_delta` 必须是经过独立策略筛选的公开工作说明。这不影响用户可见 assistant 文本、TeamSay、工具调用与工具返回的完整镜像。 + +### BE-16:测试与质量评测 + +**状态:完成(`RC-BE-018`)。** 合同 / 适配器 / 规划 / 需求 / SSE / lease / 20 项标注评测集已有。打分器不调用模型;金标必须过门,会商风失败实例必须可复现。 + +单元测试: + +- Pydantic 合同。 +- 计划装配和验证。 +- task ledger 状态机。 +- artifact validation。 +- dependency invalidation。 +- intent router 高成本闸门。 +- AgentScope event adapter。 +- 同一 `message_id` 的 token delta、同一 `tool_call_id` 的参数/结果 delta 以及脱敏字段替换;断线重放后消息正文不得被摘要化或重复。 +- 两个 `agentRunId` 并行交错输出、其中一员等待用户协助时,消息归属、工具配对、状态和命令目标均正确。 +- `session.snapshot` 增量 `agent_runs[]` 能驱动前端阶段卡从 pending → running → waiting_input → terminal;`node_run_id` 与画布节点前缀匹配。 +- 流式中断后每个已创建 tool call 都有终态 tool result,`MessageList` 不留下旋转步骤。 + +集成测试: + +- fake model 下完整研究、核验、分析、写作、审稿、返工。 +- 用户协助卡提交后仍需成果校验。 +- 早停、空输出、非法 JSON、无引用。 +- 模型和工具超时。 +- SSE replay、取消和 worker 崩溃接管。 +- 从 `REPLY_START` 到首条消息、从 `TOOL_CALL_START` 到原生工具步骤、从工具返回到 `tool` 消息的可见时延;不得等待节点完成才出现过程。 + +真实 AgentScope 冒烟: + +- 至少两个已配置模型。 +- ledger 驱动的多 worker(不依赖 AgentScope Team 自动分派)。 +- 并发工具和 TeamSay。 +- 取消、超时、异常成员和结果修复。 + +质量评测集: + +- 不少于 20 个真实任务。 +- 覆盖政策影响、市场趋势、企业研究、事件研判、对比分析。 +- 每个任务记录必选角度、关键事实、禁止编造项、模板和评审规则。 +- 与现有多智能体会商进行盲评,并保留每项失败的可复现实例。 +- 指标:事实准确、来源质量、关键主张引用覆盖、角度覆盖、逻辑一致、模板遵循、定向修改正确率、耗时和成本。 +- 上线前为每个任务标注关键主张/数字、可接受来源、必选角度和模板约束;不能只以主观“文章更像报告”判定成功。 + +--- + +## 9. 首个纵向 POC + +POC-1 的规则路径已通(三策略差异方案、select ≠ start)。**POC-2 脚本闭环已通**(研究员 → 撰写员,协调者建议不调度)。**POC-3 质量角色已通**(核验 / 分析 / 审稿定向返工,确定性内核)。真实检索与 AgentScope 成员仍待接。**POC-4 未开始**。 + +固定任务: + +> 撰写一份新能源汽车主要品牌市场趋势分析报告,覆盖 2024 年至今的市场份额、价格策略、技术路线、海外进展、政策环境和未来 12~24 个月风险与机会,面向企业战略决策人员,要求所有关键数字给出来源。 + +### POC-1:候选计划 + +**状态:规则路径已通(`RC-BE-006`)。** 三策略差异方案、select ≠ start;尚未接规划 LLM。 + +- 生成 3 种真实有差异的方案。 +- 每个方案只引用有效 agent/tool/template。 +- 每个节点有职责、输入、输出和验收条件。 +- 选择方案不开始,显式 run 命令才开始。 + +### POC-2:三角色闭环 + +**状态:脚本路径已通(`RC-BE-009`),SSE 重放已挂载(`RC-BE-011`)。** 真实 Web 检索仍待工具实装接入运行器。 + +角色:Coordinator、Researcher、Writer。 + +- 完成真实 Web 检索。 +- 形成 EvidenceBundle。 +- 生成带来源报告。 +- durable event 可驱动前端流程图。 +- 每位成员的 TeamSay、流式文本、工具调用和工具返回可由同一 SSE 流重放,并能按 `agentRunId` 还原。 + +### POC-3:质量角色 + +**状态:完成(`RC-BE-010` 质检 + `RC-BE-013` 受控成员 + `RC-BE-017` 真实内核)。** `QualityRoleKernel` 跑通 Verifier / Analyst / Writer / Reviewer / Reviser;过期与跨包冲突写入 `VerificationResult`;缺失角度只重开检索链;审稿失败只重开目标写作节点。生产 worker 使用 `AgentScopeMemberKernel`。 + +加入 Verifier、Analyst、Reviewer、Reviser。 + +- 过期资料被识别。 +- 冲突资料被标记。 +- 缺失角度触发定向补充。 +- 报告未通过评审时只返工目标节点。 + +### POC-4:全过程干预 + +**状态:命令与 safe-point 路径完成(`RC-BE-014`)。** 增加角度、指定角度重检索、章节重写/润色的定向影响已覆盖;运行中旧 attempt 在边界后 supersede,晚到成果不能进入下游。正式报告 replacement/version 展示已在 `RC-BE-015` 落地。 + +必须演示: + +1. “增加供应链与原材料风险角度”。 +2. “海外市场资料太旧,重新检索 2026 年最新信息”。 +3. “重写第二章,结论更谨慎,不要重新检索”。 +4. 刷新页面后继续。 +5. 取消时晚到输出不改变终态。 +6. worker 崩溃后租约接管。 + +--- + +## 10. 后端开发顺序 + +已完成: + +1. `RC-BE-000`:AgentScope 版本与兼容性尖峰(条件 Go;未写入 `uv.lock`)。 +2. `RC-BE-001`:领域合同和状态机。 +3. `RC-BE-002`:配置、依赖和 feature flag。 +4. `RC-BE-003`:数据库迁移和 stores。 +5. `RC-BE-004`:session/message/plan API(改写 / 恢复见 `015`)。 +6. `RC-BE-005`:需求解析与澄清。 +7. `RC-BE-006`:agent catalog、selector、plan assembler/validator。 +8. `RC-BE-007`:AgentScope model adapter。 +9. `RC-BE-008`:tool/permission adapter。 +10. `RC-BE-009`:三角色 team POC(脚本内核 + TaskLedger 波次)。 +11. `RC-BE-010`:artifact board 主张级质量门(Coverage / 核验 / 审稿)。 +12. `RC-BE-011`:durable event、原生消息镜像、SSE 和 live hub。 +13. `RC-BE-012`:dispatcher、executor、lease、cancel、recovery。 +14. `RC-BE-013`:Verifier/Analyst/Reviewer/Reviser。 + +15. `RC-BE-014`:intent router、impact analyzer、safe-point commands(已完成)。 +16. `RC-BE-015`:report structures、report versions、rewrite candidates(已完成)。 +17. `RC-BE-016`:安全、审计、预算、监控(已完成)。 +18. `RC-BE-017`:并发、故障恢复和真实 AgentScope 测试(已完成)。 +19. `RC-BE-018`:20 项质量评测和参数调优(已完成)。 + +下一票起: + +20. `RC-BE-019`:灰度与离线发布接入。 + +编号说明:§8 的 `BE-n` 与上表 `RC-BE-00n` 在 **008 之后不完全一一对应**(例如 §8 `BE-11` dispatcher ≈ `RC-BE-012`,`BE-12` SSE ≈ `RC-BE-011`)。排期与代码以 `RC-BE-*` 为准。 + +前后端共同依赖:OpenAPI / event envelope / 共享枚举已按 `frontend-web/src/report-collaboration/api/types.ts` 冻结;前端页面与 mock 已完成。真实 SSE 可联调 `GET /runs/{id}/stream`(`RC-BE-011`);领取/恢复见 `RC-BE-012`;质量角色见 `RC-BE-013`;运行中自然语言命令见 `RC-BE-014`。正式报告改写候选与版本 apply/restore 已在 `RC-BE-015` 挂载。生产默认仍不挂后台循环(`worker_enabled=false`)。 + +--- + +## 11. 上线质量门 + +### 11.1 功能门 + +- 任一协助卡不会完成节点。 +- 任一 assistant 短消息不会完成 run。 +- 每个完成节点都有 validated artifact。 +- 每条用户反馈都有 durable command。 +- 每个正式报告都有已持久化版本。 + +### 11.2 质量门 + +- 关键事实引用覆盖率达到评测目标。 +- 引用可打开对应来源。 +- 过期、冲突、低可信来源可识别。 +- 必选角度覆盖。 +- Reviewer 问题可映射到返工节点。 +- 模板章节完整。 +- 不生成来源中不存在的数字、事件或观点归属。 + +### 11.3 可靠性门 + +- command、run create、cancel 幂等。 +- SSE 重连不丢事件。 +- worker 崩溃可接管。 +- cancelled 不被晚到结果改写。 +- 正式报告不被失败的流式草稿覆盖。 +- PostgreSQL/MySQL 并发测试通过。 + +### 11.4 决策门 + +质量盲评必须证明新方案相对现有会商在报告质量或交互效率上有明确提升。若没有提升,不以“AgentScope 已接入”作为上线理由。 + +--- + +## 12. 风险与应对 + +### 12.1 AgentScope API 快速变化 + +固定版本和 commit;所有上游对象通过 adapter;升级必须跑兼容性尖峰和真实任务评测。 + +### 12.2 Agent Team 可靠性不足 + +状态、完成条件、重试和恢复由 DeerFlow TaskLedger/ArtifactBoard 管理;AgentScope 负责协作,不决定业务终态。 + +### 12.3 多智能体成本高但质量不提升 + +按复杂度控制团队规模;快速方案也保留核验/审稿;以评测指标而不是 agent 数量验收。 + +### 12.4 智能体互相传话失真 + +TeamSay 不作为事实来源;报告事实只能引用 validated EvidenceBundle。 + +### 12.5 全过程对话产生状态竞争 + +反馈先落 durable command;使用 revision、幂等键、safe-point、依赖失效和 superseded 输出。 + +### 12.6 进程恢复后上下文不一致 + +不恢复 Python 对象;从需求、计划、台账、成果和协调摘要重建 AgentScope team。 + +### 12.7 离线依赖缺失 + +业务仓库只维护依赖锁与代码;wheelhouse、镜像和发布脚本统一进入离线发布仓库。 + +--- + +## 13. 后端完成定义 + +后端只有同时满足以下条件才算完成: + +1. 使用 AgentScope 2.0 Core 执行真实的受控任务成员,且由 DeerFlow `TaskLedger` 而非自治 Team 调度协作。 +2. 不调用旧 Workflow Studio DAG scheduler。 +3. 不改变现有多智能体会商行为。 +4. DeerFlow 数据库是状态唯一事实源。 +5. 成果物校验而不是模型文本决定节点完成。 +6. 规划、检索、分析、核验、写作、审稿、返工、错误和报告变化均可由同一对话时间线完整重建。 +7. 节点公开接口只提供职责合同和状态,不向前端暴露执行结果面板所需数据。 +8. 全过程用户反馈能准确分类、定向失效和安全生效。 +9. 规划、运行、等待、返工、取消和报告版本均有 durable event。 +10. 刷新、断线、worker 崩溃和租约接管后可恢复。 +11. 模型、工具、资料、用户和报告权限不被 AgentScope 绕过。 +12. 报告完成前已持久化正式版本并通过质量门。 +13. 真实质量评测达到上线阈值。 +14. 离线依赖和部署验证在独立发布仓库完成。 diff --git a/docs/gpt-researcher-deerflow-集成开发方案.md b/docs/gpt-researcher-deerflow-集成开发方案.md new file mode 100644 index 00000000..8bef24a9 --- /dev/null +++ b/docs/gpt-researcher-deerflow-集成开发方案.md @@ -0,0 +1,1745 @@ +# GPT Researcher 接入 DeerFlow 的可实施开发方案 + +> 文档状态:持续实现中;本次已补齐报告实时写作、对话工作台与浏览器端 Word 导出 +> 编写日期:2026-08-14 +> GPT Researcher 核对基线:[`5d84d2f5553e70a2765a8ff3a0d2672d60437ce8`](https://github.com/assafelovic/gpt-researcher/tree/5d84d2f5553e70a2765a8ff3a0d2672d60437ce8) +> 上游本地代码:`F:\react01\deeflow-code\gpt-researcher`(当前 HEAD 与上述基线一致) +> DeerFlow 基线:当前工作区代码 +> 本文只描述新增的「深度研究」功能,不改造、不依赖现有 AI 写作功能。 + +## 1. 文档目标 + +本文回答以下工程问题: + +1. GPT Researcher 的哪些功能可以在 DeerFlow 中实现。 +2. 哪些上游代码适合直接复用,哪些代码只能复用设计,哪些代码不应接入。 +3. 如何让复用代码运行在 DeerFlow 当前的 LangGraph 1.x、模型、检索、MCP、技能、用户体系和网关体系中。 +4. 后端需要新增哪些包、类、数据表、API、后台任务和测试。 +5. 前端新页面需要新增哪些路由、组件、状态和交互。 +6. 如何分阶段交付,并用明确验收标准避免“页面有了,但能力对不上”。 + +本文是实现级设计,不是简单功能介绍。文中提到的文件名、类名、接口和状态均作为后续开发的默认方案;实际编码时如需调整,应同步更新本文。 + +--- + +## 2. 结论摘要 + +### 2.1 可行性结论 + +可以做,而且不需要安装 GPT Researcher 锁定的旧版 LangGraph,更不需要把 DeerFlow 降级。 + +GPT Researcher 当前真正直接使用 LangGraph 的生产代码主要在: + +- [`multi_agents/agents/orchestrator.py`](https://github.com/assafelovic/gpt-researcher/blob/5d84d2f5553e70a2765a8ff3a0d2672d60437ce8/multi_agents/agents/orchestrator.py) +- [`multi_agents/agents/editor.py`](https://github.com/assafelovic/gpt-researcher/blob/5d84d2f5553e70a2765a8ff3a0d2672d60437ce8/multi_agents/agents/editor.py) + +其中使用的 `StateGraph`、`END`、`add_node`、`add_edge`、`add_conditional_edges`、`compile`、`ainvoke` 在 DeerFlow 当前 LangGraph 1.x 中仍有对应能力。需要适配的重点不是图 API,而是: + +- 把上游阻塞式人工反馈改成 LangGraph 1.x 的 `interrupt()` 与 `Command(resume=...)`。 +- 给图注入 DeerFlow checkpointer 和线程配置。 +- 把上游 LLM Provider 替换为 DeerFlow 的 `create_chat_model()`。 +- 把上游搜索、抓取、MCP 和文档加载全部替换为 DeerFlow 的“研究材料提供器”。 +- 把内存中的运行过程改为 DeerFlow 可恢复、可取消、可多实例接管的后台任务。 + +### 2.2 推荐接入方式 + +不建议把整个 GPT Researcher 仓库复制进来,也不建议以子进程、独立服务或 Git submodule 的形式运行。 + +推荐方式是: + +1. 将经过筛选的上游核心代码 vendoring 到 DeerFlow 的独立 `deep_research` 包。 +2. 保持上游研究流程和报告生成方法的主体结构,减少功能偏差。 +3. 在四个边界上使用 DeerFlow Adapter:模型、研究材料、事件、产物。 +4. 搜索结果一律由 DeerFlow 提供完整正文和元数据,使 GPT Researcher 不再自行联网、抓取或调用 MCP。 +5. 新建独立前端入口 `/page/strategy/qa/deep-research`,不进入 `open-canvas`,不修改 AI 写作状态机。 + +### 2.3 第一版的能力范围 + +第一版建议支持: + +- 普通研究报告。 +- 详细报告,按子主题并行研究和合并。 +- Deep Research,按宽度和深度递归研究。 +- 多智能体报告编审,包括计划、研究、写作、事实核查、修订、发布。 +- DeerFlow 搜索、知识库、技能和 MCP 作为统一信息来源。 +- 研究计划、实时进度、来源库、引用、Markdown/HTML 报告。 +- 后台执行、刷新恢复、断线重连、取消、重试、人工审核后继续。 +- 历史会话、报告追问、产物下载。 + +PDF 自动导出仍建议做成可选能力。DOCX 已改为复用前端现有 `docx` 依赖在浏览器本地生成:完成态报告导出为 Word 时会附带收集到的参考来源,不新增 Python 或 LangGraph 依赖。私有报告配图暂不嵌入 Word;若后续需要嵌图,应通过鉴权产物接口取得 Blob 后写入 `ImageRun`。 + +--- + +## 3. 范围与非目标 + +### 3.1 本期范围 + +- 新增独立「深度研究」功能和页面。 +- 复用 GPT Researcher 的研究规划、上下文处理、报告生成和多智能体编审逻辑。 +- 使用 DeerFlow 当前的信息收集能力,不使用 GPT Researcher 自带检索链路。 +- 支持 LangGraph 1.x。 +- 接入 DeerFlow 当前登录用户、权限、模型配置、持久化、后台作业、SSE 和产物体系。 +- 上游代码以固定 commit 为基线,保留来源、许可证和本地补丁说明。 + +### 3.2 明确非目标 + +- 不复用或改造 AI 写作页面、上下文、状态机和接口。 +- 不启动 GPT Researcher 自带 FastAPI/Next.js 服务。 +- 不接入 GPT Researcher 自带搜索 Provider、scraper、MCP、向量库和本地文档加载器。 +- 不在运行期动态 `pip install` 模型 Provider。 +- 不为兼容上游而降低 DeerFlow 的 LangGraph 或 LangChain 版本。 +- 不使用上游 AG2 目录的另一套多智能体实现。 +- 不在第一期提供任意用户自定义 MCP 配置或任意 URL 抓取,以免绕过 DeerFlow 安全边界。 + +--- + +## 4. 目标功能清单 + +### 4.1 研究模式 + +| 模式 | 用户用途 | GPT Researcher 基础 | DeerFlow 中的实现 | +|---|---|---|---| +| 快速研究 | 快速得到带来源的短答案 | `quick_search()` | 可同步或短后台任务,仍由 DeerFlow 收集材料 | +| 标准报告 | 一次规划、检索、压缩、成文 | `GPTResearcher.conduct_research()` + `write_report()` | 第一阶段主路径 | +| 详细报告 | 总体研究后按子主题并行展开 | `DetailedReport` | 子主题任务并行,最终合并引言和结论 | +| 深度研究 | 按 breadth/depth 递归探索 | `DeepResearchSkill` | 有界递归、分支事件、统一去重和取消 | +| 多智能体编审 | 有计划、复核、修订、事实核查 | `ChiefEditor` + `EditorAgent` | LangGraph 1.x 图,支持暂停和恢复 | +| 自定义报告 | 用户给定报告结构或提示 | upstream custom report/prompt | 已支持:受限 `custom_outline` 保存到配置快照;标题/编号列表决定详细报告和多智能体章节,其他模式作为受保护的写作结构约束 | + +### 4.2 用户可见能力 + +1. 创建研究课题。 +2. 选择报告模式、语言、语气、模型和研究强度。 +3. 查看自动生成的研究计划。 +4. 在开启人工审核时,修改或批准研究计划。 +5. 实时查看:当前阶段、搜索问题、材料数量、子主题、递归分支、写作和核查进度。 +6. 查看来源列表、来源正文摘要、来源类型、时间、相关度和被引用情况;研究停止后可调整后续追问使用的来源集合。 +7. 查看流式报告预览。 +8. 下载 Markdown、HTML 和浏览器本地生成的 Word;PDF 仍按部署能力决定是否开放。 +9. 研究完成后继续针对本次报告和材料追问。 +10. 取消正在运行的任务;刷新页面后恢复状态;服务重启后自动续跑或重新领取。 +11. 查看、重命名、删除历史研究会话。 + +### 4.3 管理和运维能力 + +- 并发、最大搜索次数、材料正文长度、递归深度和宽度限制。 +- 每用户同时运行任务数限制。 +- 模型调用和 Token 使用记录。 +- 任务租约、心跳、失败原因和重试次数。 +- 来源可追溯,报告引用能回到具体材料。 +- 不可用能力的显式降级,例如未安装 PDF 导出依赖时只展示 MD/HTML。 + +--- + +## 5. 总体架构 + +```mermaid +flowchart LR + UI["深度研究新页面"] --> API["Deep Research Gateway API"] + API --> SS["Session Store"] + API --> JS["Durable Job Store"] + API --> SSE["SSE 事件流"] + JS --> DISP["Job Dispatcher"] + DISP --> EXEC["Job Executor"] + EXEC --> ENGINE["DeepResearchEngine"] + + ENGINE --> BASIC["GPT Researcher 核心流程"] + ENGINE --> MULTI["LangGraph 1.x 多智能体图"] + + BASIC --> LLM["DeerFlow LLM Adapter"] + MULTI --> LLM + BASIC --> MATERIAL["DeerFlow Material Provider"] + MULTI --> MATERIAL + + MATERIAL --> SEARCH["现有 web_search"] + MATERIAL --> KB["现有知识库/技能"] + MATERIAL --> MCP["现有 MCP 工具"] + MATERIAL --> FETCH["现有正文获取工具"] + + ENGINE --> EVENT["Event Sink"] + EVENT --> ES["Event Store"] + ES --> SSE + ENGINE --> ART["Artifact Adapter"] + ART --> OUTPUTS["DeerFlow thread outputs"] +``` + +核心原则是“研究算法可以复用,基础设施必须由 DeerFlow 接管”。上游算法不能直接持有网络、模型、文件系统或 WebSocket,它只能依赖明确的协议接口。 + +--- + +## 6. 上游代码复用清单 + +### 6.0 上游本地源码定位 + +上游代码已克隆到: + +```text +F:\react01\deeflow-code\gpt-researcher +``` + +当前版本固定为 `5d84d2f5553e70a2765a8ff3a0d2672d60437ce8`。本章所有 `gpt_researcher/...`、`multi_agents/...` 和 `backend/...` 路径均相对于该目录;实施时以该本地目录作为 vendoring 的唯一来源,不从浮动分支重新下载文件。 + +当前实现的可执行代码是对这些控制流的 DeerFlow 适配,而非直接 import 上游包(上游 Provider、检索/抓取和 MCP 栈不能越过 DeerFlow 的安全边界)。固定 commit、许可证、逐文件映射、不可突破的适配约束和后续直接 vendoring 的补充义务,见 `offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/UPSTREAM.md`。 + +最关键的源码位置如下: + +| 用途 | 本地文件 | +|---|---| +| 研究总入口 `GPTResearcher` | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\agent.py` | +| 研究规划、并行子查询、上下文聚合 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\researcher.py` | +| 报告、引言、结论和子主题写作 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\writer.py` | +| breadth/depth 递归深度研究 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\deep_research.py` | +| 来源策展 | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\skills\curator.py` | +| 上游 LLM 调用总入口(需改成 DeerFlow Adapter) | `F:\react01\deeflow-code\gpt-researcher\gpt_researcher\utils\llm.py` | +| 多智能体总图 | `F:\react01\deeflow-code\gpt-researcher\multi_agents\agents\orchestrator.py` | +| 多智能体子主题编审图 | `F:\react01\deeflow-code\gpt-researcher\multi_agents\agents\editor.py` | +| 标准报告调用顺序 | `F:\react01\deeflow-code\gpt-researcher\backend\report_type\basic_report\basic_report.py` | +| 详细报告调用顺序 | `F:\react01\deeflow-code\gpt-researcher\backend\report_type\detailed_report\detailed_report.py` | + +后续复制到 `deerflow/agents/deep_research/vendor/` 时,必须在 `vendor/manifest.json` 保存上表中每个源文件的相对路径、上游 commit 和 SHA-256,保证可追溯和可升级。 + +### 6.1 直接 vendoring 后适配的代码 + +下表中的“复用”表示复制到 DeerFlow 仓库并保留主体算法,不表示原文件零修改。 + +| 上游路径 | 复用内容 | 本地适配点 | +|---|---|---| +| `gpt_researcher/agent.py` | `GPTResearcher` 总调度、研究、报告、子主题、引用、图像入口 | 构造函数注入 LLM、材料、事件、产物和取消令牌;移除环境变量驱动 | +| `gpt_researcher/skills/researcher.py` | `ResearchConductor` 的规划、子查询并行、正文处理、摘要和上下文聚合 | 搜索和抓取改调 `MaterialProvider`;保留完整 DeerFlow 来源元数据 | +| `gpt_researcher/skills/writer.py` | `ReportGenerator` 的报告、引言、结论、子主题和标题生成 | 调 DeerFlow LLM Adapter;事件化输出;引用绑定 source id | +| `gpt_researcher/skills/deep_research.py` | 搜索计划、递归深挖、学习点合并、访问 URL 去重 | 修复空查询变量初始化问题;注入 Adapter;增加全局预算和取消检查 | +| `gpt_researcher/skills/curator.py` | 来源筛选和排序 | 使用 DeerFlow 元数据;策展前后均记录选择原因 | +| `gpt_researcher/skills/context_manager.py` | 上下文管理接口与调用关系 | 压缩器换成 DeerFlow 实现 | +| `gpt_researcher/prompts.py` | 报告类型、研究和引用提示词族 | 保留上游语义;配置默认中文;禁止请求直接覆盖系统安全提示 | +| `gpt_researcher/actions/agent_creator.py` | 研究代理角色生成 | LLM 调用换 Adapter | +| `gpt_researcher/actions/query_processing.py` | 查询生成、子主题和查询清洗 | LLM 调用换 Adapter,输出做 Pydantic 校验 | +| `gpt_researcher/actions/report_generation.py` | 报告段落、完整报告和结论生成 | LLM 调用换 Adapter,支持分块事件 | +| `gpt_researcher/actions/markdown_processing.py` | Markdown 整理、引用和链接处理 | 加 source id 绑定,过滤危险 HTML | +| `gpt_researcher/utils/enum.py` | 报告类型、语气等枚举 | 转换成 DeerFlow 配置可序列化枚举 | +| `gpt_researcher/utils/rate_limiter.py` | 限流算法 | 接入每任务/每用户预算,不在全进程使用隐式全局 | +| `gpt_researcher/utils/workers.py` | 并发任务辅助 | 接收统一 semaphore 和取消令牌 | +| `gpt_researcher/utils/validators.py` | 部分文本和 URL 校验 | 与 DeerFlow 安全规则合并 | +| `backend/report_type/basic_report/basic_report.py` | 标准报告的调用顺序 | 改成 `BasicResearchRunner` | +| `backend/report_type/detailed_report/detailed_report.py` | 子主题拆分、并行报告、总报告拼接 | 改成 `DetailedResearchRunner`,并发受任务预算限制 | + +### 6.2 多智能体可复用代码 + +| 上游路径 | 复用内容 | 必改内容 | +|---|---|---| +| `multi_agents/agents/orchestrator.py` | 总图节点和条件路由 | LangGraph 1.x checkpointer、thread id、暂停恢复、事件输出 | +| `multi_agents/agents/editor.py` | 子主题 research-review-revise 图 | 1.x 编译、并发上限、取消和错误传播 | +| `multi_agents/agents/researcher.py` | 子主题研究代理 | DeerFlow 材料提供器 | +| `multi_agents/agents/reviewer.py` | 草稿审阅 | DeerFlow LLM Adapter,结构化输出校验 | +| `multi_agents/agents/reviser.py` | 根据评审修改 | 版本化草稿和事件记录 | +| `multi_agents/agents/writer.py` | 章节和全文写作 | DeerFlow LLM Adapter,引用校验 | +| `multi_agents/agents/fact_checker.py` | 事实核查及回环判断 | 来源必须来自 session source store;限制最大回环次数 | +| `multi_agents/agents/visualizer.py` | 图表/图片建议和位置 | 第一阶段可关闭;启用时调用 DeerFlow 图像能力 | +| `multi_agents/agents/publisher.py` | 最终拼接和发布 | 产物路径换成 Artifact Adapter | +| `multi_agents/agents/human.py` | 人工审核所处流程位置 | 不复用阻塞输入实现,重写为 `interrupt()` | +| `multi_agents/memory/research.py` | 研究状态结构 | 改成 TypedDict/Pydantic 可持久化状态 | +| `multi_agents/memory/draft.py` | 草稿和修订状态结构 | 加 revision、source ids、序列化约束 | + +多智能体流程保持如下语义: + +```mermaid +flowchart TD + B["浏览/初始材料"] --> P["生成研究计划"] + P --> H{"需要人工审核?"} + H -->|修改计划| P + H -->|批准| R["研究各子主题"] + H -->|关闭人工审核| R + R --> W["写作"] + W --> F{"事实核查通过?"} + F -->|否且未超过回环上限| W + F -->|是| V["图表与插图"] + V --> PUB["发布报告与产物"] + + subgraph SUB["每个子主题内部"] + SR["Researcher"] --> REV["Reviewer"] + REV --> OK{"通过?"} + OK -->|否| FIX["Reviser"] + FIX --> REV + end +``` + +### 6.3 只复用协议或思路,不复制实现 + +| 上游路径 | 可借鉴内容 | 不直接复制原因 | +|---|---|---| +| `gpt_researcher/retrievers/custom/custom.py` | `{url, raw_content}` 的预取材料契约 | 使用同步 `requests` 和环境变量 endpoint,缺少认证、元数据和取消 | +| `gpt_researcher/actions/retriever.py` | 检索器工厂的边界概念 | 内含大量上游 Provider 分支,与 DeerFlow 重复 | +| `gpt_researcher/context/compression.py` | 分块、相似度筛选、上下文压缩顺序 | 依赖当前 DeerFlow 未声明的 `langchain_classic`、`langchain_text_splitters` | +| `gpt_researcher/skills/image_generator.py` | 生成图片提示和插入位置 | 图片执行应走 DeerFlow 已配置工具,不能直接持有上游 Provider | +| `backend/utils.py` | MD、PDF、DOCX 转换步骤 | 原始输出目录与依赖不适合 DeerFlow 部署 | +| `multi_agents/agents/utils/file_formats.py` | 输出格式转换概念 | 需要统一路径防护和能力探测 | + +### 6.4 明确不复用的代码 + +| 上游目录 | 原因 | +|---|---| +| `gpt_researcher/retrievers/*` | 信息收集由 DeerFlow 负责,避免 Provider 重复和配置分裂 | +| `gpt_researcher/scraper/*` | 抓取、反爬、SSRF 和网络策略必须由 DeerFlow 控制 | +| `gpt_researcher/mcp/*` | DeerFlow 已有 MCP 生命周期、缓存、认证和工具白名单 | +| `gpt_researcher/document/*` | 本地文件和网页材料统一进入 DeerFlow 材料协议 | +| `gpt_researcher/vector_store/*` | 第一阶段不引入第二套向量存储;需要时接 DeerFlow 知识库 | +| `gpt_researcher/llm_provider/generic/base.py` | 存在动态 import/安装 Provider 的设计,不适合离线可重复部署 | +| `backend/server/app.py`、`server_utils.py`、`websocket_manager.py` | 是上游演示服务,认证、多租户、持久化和并发模型均不符合 DeerFlow 网关 | +| 上游 Next.js 前端 | 当前项目是 Vite + React 19,直接复制会引入第二套路由和组件体系 | +| `multi_agents/ag2/*` | 与 LangGraph 实现重复,并引入额外 Agent 框架和依赖 | +| 上游 Docker Compose | DeerFlow 已有离线部署与统一启动脚本 | + +--- + +## 7. 建议新增目录和文件 + +### 7.1 后端 Harness 层 + +```text +offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/ +├── __init__.py +├── config.py +├── types.py +├── events.py +├── cancellation.py +├── engine.py +├── runners/ +│ ├── __init__.py +│ ├── basic.py +│ ├── detailed.py +│ ├── deep.py +│ └── multi_agent.py +├── adapters/ +│ ├── __init__.py +│ ├── llm.py +│ ├── material.py +│ ├── context.py +│ ├── image.py +│ ├── artifacts.py +│ └── event_sink.py +└── vendor/ + ├── UPSTREAM.md + ├── PATCHES.md + ├── LICENSE + ├── manifest.json + ├── gpt_researcher/ + │ └── ...仅选定文件... + └── multi_agents/ + └── ...仅 LangGraph 版本选定文件... +``` + +职责边界: + +- `engine.py`:只决定使用哪个 runner,并组装 Adapter。 +- `runners/*`:把统一输入映射到上游流程,不能直接访问 FastAPI 或数据库。 +- `adapters/*`:隔离 DeerFlow 基础设施和上游代码。 +- `vendor/*`:尽量保留上游算法,所有 DeerFlow 特有代码放在 vendor 外。 +- Harness 层严禁 `import app.*`,必须继续通过 `tests/test_harness_boundary.py`。 + +### 7.2 持久化层 + +```text +offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research/ +├── __init__.py +├── models.py +└── sql.py +``` + +同时需要: + +- 在 `deerflow/persistence/models/__init__.py` 导入新 ORM model,保证 `create_all` 可发现。 +- 为正式部署增加 Alembic migration;不能只依赖开发环境的 `create_all`。 +- 表字段使用项目已有的 `PortableLongText`、`BeijingDateTime` 等兼容类型。 + +### 7.3 App/Gateway 层 + +```text +offline-backend-20260512/backend/app/gateway/ +├── routers/deep_research.py +├── deep_research_job_executor.py +└── deep_research_job_dispatcher.py +``` + +修改点: + +- `app/gateway/app.py`:注册 `deep_research.router`。 +- `app/gateway/deps.py`:创建 session/job/event/source/message repository,注册 dispatcher 和 executor 生命周期。 +- 如使用隐藏 runtime thread:线程列表接口需要排除 `metadata.hidden == true` 的内部线程。 + +### 7.4 前端 + +当前 Phase 2 已落地为与现有 Vite + React 页面目录一致的轻量实现: + +```text +frontend-web/src/pages/DeepResearchPage.tsx +frontend-web/src/strategy-components/api/deep-research.ts +frontend-web/src/pages/StrategyRoutes.tsx +frontend-web/src/core/page-layout/sidebar-menu.ts +``` + +其中 `DeepResearchPage` 同时承载创建表单、历史会话、对话式 SSE 研究过程、流式报告草稿、来源库、报告预览和 MD/HTML/Word 下载。后续在页面复杂度明显增加时,可按下方的目标目录拆分;不能为了目录形式而重复实现另一套状态或 API client。 + +```text +frontend-web/src/deep-research/ +├── routes/DeepResearchRoutes.tsx +├── pages/DeepResearchHomePage.tsx +├── pages/DeepResearchSessionPage.tsx +├── api/deep-research.ts +├── lib/types.ts +├── lib/event-reducer.ts +├── hooks/useDeepResearchSession.ts +├── hooks/useDeepResearchStream.ts +└── components/ + ├── ResearchSetupPanel.tsx + ├── SessionHistoryPanel.tsx + ├── ResearchProgressTimeline.tsx + ├── ResearchPlanPanel.tsx + ├── SourceLibraryPanel.tsx + ├── SourceDetailDrawer.tsx + ├── ReportPreview.tsx + ├── ArtifactDownloads.tsx + └── ResearchChatPanel.tsx +``` + +路由修改: + +- 当前实现已在 `frontend-web/src/pages/StrategyRoutes.tsx` 增加: + +```tsx +} /> +``` + +- 在 `frontend-web/src/core/page-layout/sidebar-menu.ts` 的「问答管理」下增加: + +```ts +{ + id: "qa-deep-research", + label: "深度研究", + icon: Telescope, + path: "/page/strategy/qa/deep-research", +} +``` + +该菜单继续经过现有动态菜单覆盖、权限过滤和敏感词展示逻辑,不单独实现另一套侧边栏。 + +--- + +## 8. 核心领域模型 + +### 8.1 研究配置 `DeepResearchConfig` + +文件:`deerflow/agents/deep_research/config.py` + +```python +class DeepResearchConfig(BaseModel): + mode: Literal["quick", "basic", "detailed", "deep", "multi_agent"] + language: str = "zh-CN" + tone: Literal["objective", "formal", "analytical", "persuasive"] = "objective" + report_format: Literal["markdown", "apa", "mla", "chicago"] = "markdown" + + fast_model: str | None = None + smart_model: str | None = None + strategic_model: str | None = None + thinking_enabled: bool = False + + max_search_results_per_query: int = 8 + max_iterations: int = 3 + max_subtopics: int = 8 + deep_breadth: int = 4 + deep_depth: int = 2 + max_concurrency: int = 4 + max_context_words: int = 24_000 + + curate_sources: bool = True + generate_images: bool = False + include_human_feedback: bool = False + max_revision_loops: int = 2 + + allowed_material_channels: list[str] = ["web_search"] + allowed_domains: list[str] = [] + custom_outline: str | None = None +``` + +约束: + +- 每个任务开始时把完整配置保存为 `config_snapshot`,任务运行中不读取可变全局配置。 +- 所有数值字段设置服务端上下限,前端限制不能代替服务端限制。 +- 模型名只允许来自 DeerFlow 已注册模型列表。 +- `allowed_material_channels` 只允许管理员配置的工具组,不接受任意 MCP server URL。 +- 不使用 `os.environ` 保存每次请求的模型、报告类型或检索器设置。 + +### 8.2 统一研究材料 `ResearchMaterial` + +文件:`deerflow/agents/deep_research/types.py` + +```python +class ResearchMaterial(BaseModel): + id: str + query: str + title: str + url: str | None = None + raw_content: str + snippet: str | None = None + source: str + source_type: Literal["web", "knowledge", "skill", "mcp", "file", "other"] + published_at: datetime | None = None + relevance_score: float | None = None + rec_uuid: str | None = None + content_hash: str + metadata: dict[str, Any] = {} +``` + +该类型比上游 `{url, raw_content}` 更完整。写入上游上下文时至少保留: + +```python +{ + "id": material.id, + "title": material.title, + "url": material.url, + "raw_content": material.raw_content, + "source": material.source, + "source_type": material.source_type, + "published_at": material.published_at, + "relevance_score": material.relevance_score, + "recUuid": material.rec_uuid, + "metadata": material.metadata, +} +``` + +必须修改上游 `ResearchConductor._search_relevant_source_urls()`:当前上游识别到 `raw_content` 后只保留 URL 和正文,会丢失标题、来源和业务记录 ID。DeerFlow 版本不得丢弃这些字段,否则报告引用无法回到当前信息收集系统。 + +### 8.3 材料提供协议 + +```python +class MaterialProvider(Protocol): + async def search( + self, + query: str, + *, + domains: list[str] | None, + limit: int, + user_id: str, + session_id: str, + cancellation: CancellationToken, + ) -> list[ResearchMaterial]: ... +``` + +`DeerFlowMaterialProvider` 的第一版实现步骤: + +1. 复用 `deerflow/community/configurable_search/tools.py` 中的 `ConfigurableSearchSettings`、`execute_search()` 和结果规范化结构。 +2. 将现有结果字段 `recUuid/content/title/url/source/time/publish_time` 映射成 `ResearchMaterial`。 +3. 如果 `content` 是完整正文,直接作为 `raw_content`,GPT Researcher 不再抓取此 URL。 +4. 如果只有简短 snippet,调用 DeerFlow 当前被允许的正文获取工具补齐;该步骤仍发生在 Adapter 内。 +5. 如配置允许知识库、技能或 MCP,则通过 `deerflow/tools/tools.py:get_available_tools()` 获取白名单工具,分别调用后归一化。 +6. 按优先级使用 `rec_uuid`、canonical URL、`content_hash` 去重。 +7. 写入 `deep_research_sources`,再把材料交给上游算法。 + +第二阶段可把材料渠道做成 registry,但不要让 vendor 代码知道渠道名称或工具实现。 + +### 8.4 研究输入与输出 + +```python +class DeepResearchRequest(BaseModel): + session_id: str + user_id: str + query: str + config: DeepResearchConfig + request_id: str + + +class DeepResearchResult(BaseModel): + report_markdown: str + report_html: str | None + source_ids: list[str] + artifact_paths: dict[str, str] + usage: dict[str, Any] + plan: dict[str, Any] | None +``` + +--- + +## 9. DeerFlow 模型适配 + +### 9.1 复用入口 + +复用当前: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py +└── create_chat_model(...) +``` + +不要复用 GPT Researcher 的 `GenericLLMProvider`。后者按 Provider 动态加载依赖,不符合本项目离线和锁定依赖的方式。 + +### 9.2 Adapter 设计 + +文件:`deerflow/agents/deep_research/adapters/llm.py` + +```python +class DeerFlowCompletionBackend: + def __init__(self, config: DeepResearchConfig, event_sink: EventSink): ... + + async def complete( + self, + *, + model_role: Literal["fast", "smart", "strategic"], + messages: list[dict[str, str]], + temperature: float | None = None, + response_schema: type[BaseModel] | None = None, + operation: str, + ) -> CompletionResult: ... +``` + +内部映射: + +```python +model_name = resolve_model_name(config, model_role) +model = create_chat_model( + name=model_name, + thinking_enabled=config.thinking_enabled, +) +response = await model.ainvoke(to_langchain_messages(messages)) +``` + +适配上游的最佳切入点是 `gpt_researcher/utils/llm.py`:保留 `get_llm()`、`create_chat_completion()`、`construct_subtopics()` 等方法签名,使大多数上游 action/skill 不需要修改;其内部委托给当前请求上下文中的 `DeerFlowCompletionBackend`。 + +### 9.3 上下文注入 + +不得通过模块全局变量保存当前用户、任务或模型。可选方案: + +- 首选:显式构造函数注入到 `GPTResearcher`、`ResearchConductor`、`ReportGenerator`。 +- 对不适合大范围改签名的上游函数:使用只在当前 async task 生效的 `contextvars.ContextVar` 保存 Adapter bundle。 + +如果使用 `ContextVar`,必须提供 `bind_adapters()` context manager,并在 executor 的 `finally` 中 reset,避免协程复用造成串任务。 + +### 9.4 Token 与费用 + +- 优先读取 LangChain `AIMessage.usage_metadata`。 +- 记录 prompt、completion、total tokens 和模型名,不记录完整系统提示或敏感材料。 +- GPT Researcher 的静态价格表只作为缺省估算;DeerFlow 没有价格配置时显示 Token,不伪造金额。 +- 每次调用产生 `model_usage` 事件,同时累加到 session 和 job snapshot。 + +### 9.5 重试规则 + +- 只保留一个明确的重试层,避免 DeerFlow Provider 重试和上游重试相乘。 +- 结构化输出解析失败可进行一次“修复 JSON”模型调用;不新增 `json-repair` 强制依赖。 +- 取消请求、权限错误、配置错误不重试。 + +--- + +## 10. 上下文压缩适配 + +GPT Researcher 上游压缩器依赖本项目未声明的旧式拆分包,因此第一版新增 `DeerFlowContextCompressor`,保持上游调用接口但不引入旧依赖。 + +建议接口: + +```python +class DeerFlowContextCompressor: + async def async_get_context( + self, + query: str, + max_results: int = 10, + cost_callback: Callable | None = None, + ) -> list[dict[str, Any]]: ... +``` + +算法: + +1. 按标题、段落和字符上限分块,不破坏 Markdown 代码块。 +2. 对每个分块保留 source id,不把多个来源拼成无法追溯的大块。 +3. 若部署已配置 embedding,按 query 相似度初筛。 +4. 没有 embedding 时,使用关键词/BM25 风格分值和来源相关度进行初筛。 +5. 超过上下文预算时,再用 `fast_model` 逐来源摘要。 +6. 摘要结果必须带 source id,禁止产生没有出处的新事实。 +7. 最终按来源多样性、发布时间和相关度组合,避免一个域名占满上下文。 + +上下文预算应同时考虑字符数、估算 token 和模型窗口,不只使用单一 word count。 + +--- + +## 11. LangGraph 1.x 适配方案 + +### 11.1 可以保留的图 API + +以下上游写法可在实现阶段通过兼容性测试确认并尽量保留: + +```python +graph = StateGraph(ResearchState) +graph.add_node("planner", planner) +graph.add_edge("browser", "planner") +graph.add_conditional_edges("fact_checker", route_after_fact_check) +graph.set_entry_point("browser") +compiled = graph.compile(checkpointer=checkpointer) +``` + +如果项目统一风格要求,可将 `set_entry_point` 改为 `START -> browser`,但不是功能必要条件,不应为了重写而扩大补丁。 + +### 11.2 人工审核的必要改造 + +上游 `HumanAgent.review_plan()` 会等待 WebSocket 或控制台 `input()`,不能在 FastAPI 后台任务中使用。改造后节点应返回 LangGraph interrupt: + +```python +from langgraph.types import interrupt + +async def human_review_node(state: MultiAgentState): + decision = interrupt({ + "kind": "research_plan_review", + "plan": state["research_plan"], + "allowed_actions": ["approve", "revise", "cancel"], + }) + return apply_review_decision(state, decision) +``` + +网关恢复任务: + +```python +from langgraph.types import Command + +await graph.ainvoke( + Command(resume=validated_feedback), + config={"configurable": {"thread_id": graph_thread_id}}, +) +``` + +规则: + +- `thread_id` 使用稳定的 `deep-research:{session_id}:{job_id}`。 +- graph 必须注入 `deerflow.runtime.checkpointer` 当前配置的 checkpointer。 +- 进入 interrupt 时,job 状态更新为 `awaiting_input`,写入 `awaiting_input` 事件,释放执行租约。 +- `/resume` 验证用户是 session owner、job 正处于等待状态、action 在本次 interrupt 白名单中。 +- 恢复请求使用 `request_id` 幂等,重复点击不能执行两次。 + +### 11.3 最大回环与取消 + +- `planner -> human -> planner` 有最大修改次数。 +- `writer -> fact_checker -> writer` 受 `max_revision_loops` 限制。 +- 子主题内部 `reviewer -> reviser -> reviewer` 同样受限制。 +- 每个节点开始、每次外部调用前后都检查 `CancellationToken`。 +- LangGraph recursion limit 必须由服务端计算并设置,不能完全接受前端参数。 + +### 11.4 兼容性测试 + +新增 `tests/test_deep_research_langgraph_1x.py`: + +1. 用 fake planner/researcher/writer 编译总图。 +2. 运行到 interrupt,断言 checkpoint 已写入。 +3. 使用 `Command(resume=...)` 恢复并完成。 +4. 测试 fact-check 回环和最大回环。 +5. 在测试中断言运行环境的 LangGraph 主版本为 1,不允许测试通过隐式旧虚拟环境。 + +该测试是 Phase 0 的硬门槛。静态核对表明上游核心图 API 可迁移,但必须用当前项目锁定环境实际执行一次 contract test 后才能合并 vendor 代码。 + +--- + +## 12. 研究引擎与 Runner + +### 12.1 统一入口 + +文件:`deerflow/agents/deep_research/engine.py` + +```python +class DeepResearchEngine: + async def run( + self, + request: DeepResearchRequest, + *, + adapters: AdapterBundle, + cancellation: CancellationToken, + ) -> DeepResearchResult: + runner = self._runners[request.config.mode] + return await runner.run(request, adapters, cancellation) +``` + +`AdapterBundle` 包含: + +```python +class AdapterBundle(NamedTuple): + llm: DeerFlowCompletionBackend + materials: MaterialProvider + context: DeerFlowContextCompressor + artifacts: ArtifactWriter + events: EventSink + images: ImageProvider | None +``` + +### 12.2 Basic runner + +顺序: + +1. `phase_changed(planning)`。 +2. 构造注入 Adapter 的 `GPTResearcher`。 +3. `conduct_research()`。 +4. 研究过程中把每个 query 和 material 写入事件/数据库。 +5. `phase_changed(writing)`。 +6. `write_report()`。 +7. Markdown 引用后处理和引用完整性校验。 +8. 写 Markdown/HTML 产物。 +9. `report_completed` 和 `artifact_created`。 + +### 12.3 Detailed runner + +复用 `DetailedReport` 的主要步骤: + +- `_initial_research` +- `_get_all_subtopics` +- `_generate_subtopic_reports` +- `_get_subtopic_report` +- `_construct_detailed_report` + +适配要求: + +- 子主题任务共享 session 来源库和全局 URL 去重集。 +- 子主题并发受统一 semaphore 限制,不为每个子主题再创建无限并发。 +- 单个子主题失败时记录 warning;是否允许部分成功由配置决定。 +- 合并时对重复引用进行稳定编号,不能每个章节独立编号后直接拼接。 + +### 12.4 Deep runner + +复用 `DeepResearchSkill` 的: + +- `generate_search_queries` +- `generate_research_plan` +- `process_research_results` +- `deep_research` +- `run` + +必须包含的本地补丁: + +1. 上游空 `serp_queries` 的提前返回分支在若干累计变量初始化前引用变量,先初始化累计变量再判断空查询。 +2. 所有嵌套 `GPTResearcher` 实例继续传递同一个 LLM、MaterialProvider、EventSink、session scope 和 CancellationToken。 +3. breadth、depth、总 query 数、总材料字符数、总模型调用数同时受硬预算限制。 +4. 每个分支产生 `deep_branch_started`、`deep_branch_completed` 或 `warning` 事件。 +5. `visited_urls` 不只按原始 URL 比较,使用 canonical URL 和 content hash。 + +### 12.5 Multi-agent runner + +复用 `ChiefEditor` 与 `EditorAgent` 的角色和图语义,执行方式改为: + +```python +graph = build_multi_agent_graph( + adapters=adapters, + checkpointer=checkpointer, + config=request.config, +) +await graph.ainvoke( + initial_state, + config={ + "configurable": {"thread_id": graph_thread_id}, + "recursion_limit": computed_limit, + }, +) +``` + +节点不得自行创建新的上游 LLM Provider 或 retriever。所有节点通过图构建函数闭包或状态依赖拿到 Adapter。 + +--- + +## 13. 持久化设计 + +### 13.1 `deep_research_sessions` + +| 字段 | 类型建议 | 说明 | +|---|---|---| +| `id` | string/UUID PK | 会话 ID | +| `user_id` | string + index | 所有者 | +| `title` | string | 默认从 query 生成,可重命名 | +| `query` | long text | 原始课题 | +| `mode` | string | basic/detailed/deep/multi_agent | +| `status` | string + index | draft/running/awaiting_input/completed/failed/cancelled | +| `config_snapshot` | JSON/long text | 创建/启动时的完整配置 | +| `runtime_thread_id` | string unique | 复用 DeerFlow thread outputs 的内部线程 | +| `plan_snapshot` | JSON/long text | 最新研究计划 | +| `report_markdown` | long text | 最新报告 | +| `report_html` | long text nullable | 清洗后的 HTML | +| `source_count` | int | 来源数快照 | +| `usage_snapshot` | JSON/long text | Token/费用统计 | +| `active_job_id` | string nullable | 当前任务 | +| `error` | long text nullable | 面向用户的错误摘要 | +| `created_at/updated_at` | BeijingDateTime | 时间 | + +### 13.2 `deep_research_jobs` + +该表复用 `deerflow.persistence.roundtable_jobs` 的成熟算法和字段思路,但第一版不直接继承或修改正在运行的 roundtable 表。 + +关键字段: + +- `id`, `session_id`, `user_id` +- `request_id`, `request_hash` +- `active_dedupe_key`,数据库唯一约束保证同一会话只有一个 active job +- `input_snapshot` +- `status`, `phase`, `progress`, `current_query` +- `lease_owner`, `lease_until` +- `attempt`, `version` +- `cancel_requested`, `cancel_requested_at` +- `error_code`, `error_message` +- `created_at`, `started_at`, `updated_at`, `finished_at` + +需要复用/等价实现的方法: + +- `compute_active_dedupe_key()` +- `try_create_or_get_active()` +- `claim_lease()` / `renew_lease()` / `release_lease()` +- 基于 `version` 的 CAS 状态更新 +- `request_cancel()` +- 过期 lease 回收 + +如果后续多个领域都需要相同作业能力,可单独提取通用 `DurableJobRepository`;首期不要为了抽象而重构现有 roundtable 代码。 + +### 13.3 `deep_research_events` + +| 字段 | 说明 | +|---|---| +| `id` | 数据库主键 | +| `job_id`, `session_id` | 索引 | +| `seq` | job 内严格递增序号,唯一约束 `(job_id, seq)` | +| `event_type` | 事件类型 | +| `phase` | 当前阶段 | +| `payload` | JSON/long text | +| `created_at` | 时间 | + +事件必须先持久化再推送 SSE。SSE 客户端断线后用 `after=seq` 或 `Last-Event-ID` 补发,不能只依赖内存队列。 + +### 13.4 `deep_research_sources` + +保存 `ResearchMaterial` 的完整字段,并增加: + +- `session_id`, `job_id` +- `selected`:是否进入最终上下文 +- `selection_reason` +- `citation_key` +- `first_seen_query` +- `created_at`, `updated_at` + +正文可很大,使用 `PortableLongText`。如果后续规模需要对象存储,可保持数据库里元数据和正文 locator,但第一版先使用现有数据库能力。 + +### 13.5 `deep_research_messages` + +用于研究完成后的追问: + +- `id`, `session_id`, `user_id` +- `role` +- `content` +- `citation_source_ids` +- `usage_snapshot` +- `created_at` + +追问只读取本 session 报告和来源;若允许再次搜索,必须通过一个显式 `allow_new_research` 参数创建新 job,不能静默联网。 + +### 13.6 产物存储 + +推荐为每个 session 创建一个隐藏的 DeerFlow runtime thread: + +- `runtime_thread_id` 写入 session。 +- thread metadata:`thread_type=deep_research`、`hidden=true`、`session_id=...`。 +- 产物写到现有线程 sandbox 的 `user-data/outputs`。 +- 下载复用 `app/gateway/routers/artifacts.py` 的用户鉴权、路径保护、列表和文件读取。 +- 普通聊天记录列表必须过滤 `hidden=true`,避免内部线程出现在聊天页。 + +固定文件名建议: + +```text +outputs/ +├── report.md +├── report.html +├── sources.json +├── research-plan.json +├── report.pdf # 可选能力 +└── report.docx # 可选能力 +``` + +--- + +## 14. 后台任务与恢复 + +### 14.1 为什么必须后台执行 + +详细报告、Deep Research 和多智能体报告可能执行数分钟,不能把完整流程绑定在单个 FastAPI 请求生命周期中。必须复用 roundtable 作业的工程模式:数据库作业、dispatcher、lease、executor、轮询式 SSE。 + +### 14.2 状态机 + +```mermaid +stateDiagram-v2 + [*] --> queued + queued --> running: dispatcher 领取 + running --> awaiting_input: LangGraph interrupt + awaiting_input --> queued: 用户批准或修改 + running --> completed: 报告与必要产物成功 + running --> failed: 不可恢复错误 + queued --> cancelled: 取消 + running --> cancelled: 检查到取消令牌 + awaiting_input --> cancelled: 取消 + running --> queued: lease 过期且允许重试 + completed --> [*] + failed --> [*] + cancelled --> [*] +``` + +`cancel_requested` 是内部标志,不是最终状态。executor 检查后清理并写成 `cancelled`。 + +### 14.3 阶段枚举 + +```text +initializing +planning +collecting +curating +compressing +deepening +writing +reviewing +exporting +done +``` + +### 14.4 Dispatcher + +`deep_research_job_dispatcher.py` 负责: + +1. 周期性扫描 `queued` 或 lease 过期的可恢复任务。 +2. 用数据库 CAS 领取,不依赖单进程锁。 +3. 设置 `lease_owner` 和 `lease_until`。 +4. 把 job 交给本进程 executor。 +5. 达到全局/每用户并发限制时保持 queued。 + +### 14.5 Executor + +`deep_research_job_executor.py` 负责: + +1. 读取不可变 `input_snapshot`。 +2. 组装 repository、Adapter、checkpointer 和取消令牌。 +3. 启动 lease heartbeat。 +4. 调用 `DeepResearchEngine.run()`。 +5. 持续更新 job phase/progress,但事件正文写入 event store。 +6. 捕获 interrupt,保存 `awaiting_input` 状态。 +7. 成功时原子更新 session、job 和最终事件。 +8. 失败时分类:配置错误、模型错误、材料错误、预算超限、内部错误。 +9. `finally` 中释放资源、重置 ContextVar、停止 heartbeat。 + +禁止把正在运行的 `asyncio.Task` 作为唯一真相保存在内存字典;内存映射只能用于本进程快速取消,数据库状态才是恢复依据。 + +--- + +## 15. 事件协议与 SSE + +### 15.1 统一事件包 + +```json +{ + "seq": 42, + "sessionId": "drs_xxx", + "jobId": "drj_xxx", + "type": "source_added", + "phase": "collecting", + "timestamp": "2026-08-14T10:00:00+08:00", + "payload": { + "sourceId": "src_xxx", + "title": "...", + "url": "https://...", + "sourceType": "web" + } +} +``` + +### 15.2 事件类型 + +| 类型 | 主要 payload | +|---|---| +| `job_status` | status、progress | +| `phase_changed` | from、to、message | +| `plan_created` | plan、subtopics | +| `query_started` | query、branch、iteration | +| `query_completed` | query、resultCount、duration | +| `source_added` | source 摘要 | +| `sources_curated` | selectedIds、removedIds、reason | +| `context_compressed` | before/after token、sourceCount | +| `deep_branch_started` | depth、breadth、query | +| `deep_branch_completed` | learnings、newQueries | +| `report_chunk` | sectionId、delta | +| `report_completed` | reportVersion、sourceCount | +| `artifact_created` | format、artifact path/name | +| `awaiting_input` | interruptId、kind、payload、allowedActions | +| `model_usage` | model、operation、token usage | +| `warning` | code、message、recoverable | +| `error` | code、message、retryable | +| `heartbeat` | server time | + +不要把模型原始思维链写入事件或返回前端。进度说明只能是系统定义的操作摘要。 + +### 15.3 SSE 实现复用点 + +前端和后端分别参考: + +- `app/gateway/routers/roundtable_jobs.py`:作业查询、取消、恢复和数据库轮询 SSE。 +- `frontend-web/src/roundtable-planning/api/roundtable-jobs.ts`:`fetch()` body reader、`AbortController` 和流式解析。 + +新功能应复制通用模式或提取无领域依赖的流解析辅助函数,不能让 deep-research API import roundtable 领域类型。 + +--- + +## 16. Gateway API 设计 + +统一前缀:`/api/deep-research` + +所有接口均使用 DeerFlow 当前认证方式,并校验 `session.user_id == current_user.id`。管理员也不默认跨用户读取报告,除非已有明确审计权限。 + +### 16.1 创建会话 + +`POST /api/deep-research/sessions` + +请求: + +```json +{ + "query": "分析国内工业大模型在设备预测性维护中的落地情况", + "title": "工业大模型预测性维护研究", + "config": { + "mode": "deep", + "language": "zh-CN", + "tone": "analytical", + "deepBreadth": 4, + "deepDepth": 2, + "curateSources": true, + "includeHumanFeedback": false, + "allowedMaterialChannels": ["web_search", "knowledge"] + } +} +``` + +响应: + +```json +{ + "id": "drs_xxx", + "status": "draft", + "title": "工业大模型预测性维护研究", + "query": "...", + "config": {}, + "createdAt": "..." +} +``` + +### 16.2 会话 CRUD + +- `GET /api/deep-research/sessions?limit=20&cursor=...&status=...` +- `GET /api/deep-research/sessions/{session_id}` +- `PATCH /api/deep-research/sessions/{session_id}`:仅 draft 可修改影响运行的配置;运行后只允许改 title。 +- `DELETE /api/deep-research/sessions/{session_id}`:运行中先拒绝或要求先取消;删除需要清理隐藏 thread 和产物。 + +列表使用游标分页,不返回完整 report 和 source 正文。 + +### 16.3 启动任务 + +`POST /api/deep-research/sessions/{session_id}/jobs` + +请求: + +```json +{ + "requestId": "client-generated-uuid" +} +``` + +响应: + +```json +{ + "jobId": "drj_xxx", + "sessionId": "drs_xxx", + "status": "queued", + "reused": false +} +``` + +幂等规则: + +- 同用户、同 session、同 requestId 和相同请求 hash 返回原 job。 +- 同 session 已有 active job 时返回该 job 并标记 `reused=true`。 +- 同 requestId 但请求 hash 不同返回 409。 + +### 16.4 Job 查询和流 + +- `GET /api/deep-research/jobs/{job_id}` +- `GET /api/deep-research/jobs/{job_id}/stream?after=42` +- `POST /api/deep-research/jobs/{job_id}/cancel` +- `POST /api/deep-research/jobs/{job_id}/resume` + +resume 请求示例: + +```json +{ + "requestId": "uuid", + "interruptId": "int_xxx", + "action": "revise", + "payload": { + "plan": [ + {"title": "市场现状", "questions": ["..."]} + ] + } +} +``` + +### 16.5 来源接口 + +- `GET /api/deep-research/sessions/{session_id}/sources?selected=all&cursor=...` +- `GET /api/deep-research/sessions/{session_id}/sources/{source_id}` +- `PATCH /api/deep-research/sessions/{session_id}/sources/{source_id}`:只允许在人工计划/材料审核阶段切换 selected。 + +正文接口需要大小限制;列表只返回摘要。 + +### 16.6 报告追问 + +`POST /api/deep-research/sessions/{session_id}/chat` + +请求: + +```json +{ + "message": "报告中哪些案例已经进入规模化生产?", + "allowNewResearch": false +} +``` + +响应使用 SSE,引用返回 `sourceIds`。`allowNewResearch=false` 时只允许使用 session 报告和来源。 + +### 16.7 导出 + +`POST /api/deep-research/sessions/{session_id}/export` + +```json +{"format": "html"} +``` + +- Markdown:总是支持。 +- HTML:总是支持,输出前清洗危险标签和链接。 +- PDF/DOCX:若 capability 未启用返回 422 + `EXPORT_FORMAT_UNAVAILABLE`,前端不展示对应按钮。 +- 可增加 `GET /api/deep-research/capabilities` 返回模式、模型、材料渠道和导出格式。 + +--- + +## 17. 前端页面设计 + +### 17.1 路由 + +```text +/page/strategy/qa/deep-research 创建页、历史列表和研究工作台 +``` + +当前版本使用单页会话选择,不需要再维护一套嵌套路由: + +```tsx +} /> +``` + +### 17.2 创建页 + +主表单: + +- 课题/问题,必填。 +- 模式:标准、详细、深度、多智能体。 +- 输出语言和语气。 +- 模型:默认使用系统配置,可展开选择 fast/smart/strategic。 +- 材料渠道:只显示后端 capabilities 返回的渠道。 +- 高级设置:每次结果数、最大子主题、深度、宽度、并发、来源策展、人工审核、图片。 +- 创建并开始。 + +历史区: + +- 标题、模式、状态、进度、来源数、更新时间。 +- 支持状态筛选、重命名、删除、继续查看。 +- 运行中的会话进入后自动连接 SSE。 + +### 17.3 工作台布局 + +桌面端建议三栏: + +```text +┌──────────────┬────────────────────────────────┬──────────────────┐ +│ 历史/阶段 │ 计划、进度时间线、报告预览 │ 来源库/来源详情 │ +│ │ │ │ +│ 新建研究 │ 底部:停止/批准/导出/报告追问 │ 引用定位 │ +└──────────────┴────────────────────────────────┴──────────────────┘ +``` + +移动端改为 tabs,不强制三栏挤压。 + +### 17.4 组件职责 + +- `ResearchProgressTimeline`:仅消费 event reducer 的 view model,不直接请求 API。 +- `ResearchPlanPanel`:展示/编辑 interrupt 中的计划;批准、修改、取消均调用 resume/cancel API。 +- `SourceLibraryPanel`:分页和筛选来源,区分 web/knowledge/skill/MCP。 +- `SourceDetailDrawer`:显示受限正文、元数据、相关查询和引用章节。 +- `ReportPreview`:渲染 Markdown,点击引用跳到 SourceDetailDrawer。 +- `ArtifactDownloads`:根据 capabilities 和 artifact 事件显示下载。 +- `ResearchChatPanel`:完成后追问,不复用 AI 写作 Composer 状态。 + +### 17.5 数据和流状态 + +- TanStack Query 管 session、job、sources 和 messages 的服务器状态。 +- `useDeepResearchStream` 只负责连接、重连、last seq 和事件派发。 +- `event-reducer.ts` 将事件转为 timeline、report delta、phase、progress、interrupt 等 UI 状态。 +- `report_completed` 后以服务端 session 内容覆盖流式拼接内容,避免断线丢 chunk。 +- SSE 断线采用有限退避;页面不可见时可以降低轮询,但不能取消后台 job。 +- 刷新页面先 GET job/session,再从 `lastEventSeq` 补流。 + +### 17.6 前端类型 + +`lib/types.ts` 至少定义: + +```ts +type ResearchMode = "quick" | "basic" | "detailed" | "deep" | "multi_agent"; +type ResearchJobStatus = + | "queued" + | "running" + | "awaiting_input" + | "completed" + | "failed" + | "cancelled"; + +interface DeepResearchEvent { + seq: number; + sessionId: string; + jobId: string; + type: DeepResearchEventType; + phase: ResearchPhase; + timestamp: string; + payload: T; +} +``` + +后端 snake_case 到前端 camelCase 的转换在 API client 层集中处理,不散落到组件。 + +--- + +## 18. 产物与导出 + +### 18.1 核心零新增导出依赖 + +- `report.md`:保存最终 Markdown。 +- `report.html`:使用项目现有 Markdown 能力或轻量转换生成并清洗。 +- 浏览器打印:前端为 HTML 报告提供打印样式,让用户另存 PDF。 + +### 18.2 完整服务端导出 + +上游转换涉及 `python-docx`、`htmldocx`、`md2pdf`/WeasyPrint 等依赖,当前 DeerFlow 环境不能假定已经存在。建议在 backend `pyproject.toml` 增加可选组,而不是主依赖: + +```toml +deep-research-export = [ + "python-docx>=...", + "htmldocx>=...", + "weasyprint>=..." +] +``` + +具体版本在实现时按 Python 3.12 和离线制品库验证后锁定。部署未启用时 capability 返回 `md/html`。 + +### 18.3 路径安全 + +- 文件名由服务端枚举决定,不接受用户传任意路径。 +- 使用现有 artifact path guard,禁止 `..`、绝对路径和符号链接逃逸。 +- 所有产物写入该 session 的隐藏 runtime thread outputs。 +- 删除 session 时使用解析后的精确目录,按当前项目的安全删除流程执行。 + +--- + +## 19. 安全与隔离 + +### 19.1 用户和数据隔离 + +- session、job、event、source、message 查询都带 `user_id` 条件。 +- 不接受前端提交的 user_id。 +- 恢复、取消和下载均重新鉴权,不能只靠随机 UUID。 +- 隐藏 runtime thread 仍归当前用户,复用 artifact owner check。 + +### 19.2 网络安全 + +- vendor 代码不得直接 `requests.get()`、`httpx.get()` 或启动浏览器抓取。 +- URL 获取只能经过 DeerFlow 当前允许的工具,继承 SSRF、代理、域名和凭证策略。 +- 不把当前用户 Authorization 头转发给外部网站。 +- MCP 只使用服务端已经配置并加载的 server,不接收用户上传 MCP 配置。 + +### 19.3 提示注入 + +- 所有来源正文标记为“不可信材料”,不得执行其中指令。 +- 系统提示明确材料只用于提取事实和引用。 +- 过滤网页中的隐藏文本、脚本和超长重复内容。 +- 工具调用权限由 Adapter 控制,研究材料不能让 vendor 动态增加工具。 + +### 19.4 资源控制 + +- 限制 depth、breadth、subtopics、iterations、concurrency。 +- 限制单来源正文、单 session 总正文、事件 payload 和报告长度。 +- 每用户和全局 active job 限制。 +- 超预算时生成部分报告并明确标记,或按配置失败,不能无限继续。 + +### 19.5 输出安全 + +- Markdown/HTML 渲染前移除 script、iframe、事件属性和危险 URL scheme。 +- 外链使用 `rel="noopener noreferrer"`。 +- 引用 URL 展示和打开分离,避免把来源标题当 HTML。 +- 日志不记录完整敏感文档和模型思维链。 + +--- + +## 20. 上游代码管理与许可证 + +### 20.1 Vendoring 记录 + +`vendor/UPSTREAM.md` 至少写: + +- 仓库 URL。 +- 固定 commit。 +- 复制日期。 +- 复制的文件清单。 +- 未复制目录。 +- 上游许可证链接。 + +`vendor/manifest.json` 保存每个复制文件的上游路径和 SHA-256,便于后续核对。 + +`vendor/PATCHES.md` 逐条记录本地补丁: + +1. Python 包命名空间改写。 +2. LLM Adapter 注入。 +3. MaterialProvider 替换搜索/抓取。 +4. 事件和取消令牌。 +5. LangGraph 1.x interrupt/resume/checkpointer。 +6. 来源元数据保留。 +7. Deep Research 空查询变量初始化修复。 +8. 产物路径和安全限制。 + +### 20.2 许可证注意事项 + +上游仓库根 `LICENSE` 是 Apache-2.0,但其项目元数据中出现过不一致的许可证声明。接入前应由项目方完成一次许可证确认。工程上至少做到: + +- vendored 目录保留上游根 LICENSE。 +- 保留原文件版权头。 +- 如上游存在 NOTICE,同步保留。 +- 对修改过的文件标记“Modified for DeerFlow”及修改范围。 +- 不把许可证不明的第三方模板或资产一起复制。 + +### 20.3 升级流程 + +1. 选择新的上游 tag/commit。 +2. 根据 manifest 对比选定文件,而不是覆盖整个 vendor 目录。 +3. 重放 `PATCHES.md` 中的小补丁。 +4. 运行 Adapter contract、LangGraph 1.x、引用和全模式测试。 +5. 人工比较同一 fake 数据下报告章节、引用和事件是否出现能力回退。 + +不建议 Git submodule:当前项目是离线部署,submodule 容易造成制品不完整,也不利于维护本地补丁。 + +--- + +## 21. 测试方案 + +本项目后端要求 TDD。每个阶段先写 fake 驱动的单元/契约测试,再接真实模型或搜索。 + +### 21.1 Harness 单元测试 + +```text +tests/test_deep_research_langgraph_1x.py +tests/test_deep_research_material_provider.py +tests/test_deep_research_prefetched_content.py +tests/test_deep_research_material_metadata.py +tests/test_deep_research_llm_adapter.py +tests/test_deep_research_context.py +tests/test_deep_research_basic_runner.py +tests/test_deep_research_detailed_runner.py +tests/test_deep_research_deep_runner.py +tests/test_deep_research_multi_agent_runner.py +tests/test_deep_research_citations.py +``` + +重点断言: + +- MaterialProvider 已返回 `raw_content` 时,vendor BrowserManager/scraper 从未被调用。 +- DeerFlow 的 `recUuid/source/published_at` 经过研究流程后仍存在。 +- 相同材料去重但不丢失命中它的多个 query。 +- fake LLM 下 basic/detailed/deep/multi_agent 都产生预期章节和引用。 +- 取消在搜索、压缩、递归和写作阶段均能停止。 +- 不存在 `app.*` 从 Harness 导入。 + +### 21.2 Persistence/Job 测试 + +```text +tests/test_deep_research_sessions.py +tests/test_deep_research_jobs.py +tests/test_deep_research_job_leases.py +tests/test_deep_research_job_cancellation.py +tests/test_deep_research_event_replay.py +tests/test_deep_research_user_isolation.py +``` + +场景: + +- 20 个并发启动请求只产生一个 active job。 +- 相同 requestId 幂等,不同 payload 冲突。 +- 两个 dispatcher 只有一个能领取 lease。 +- executor 崩溃后 lease 过期,另一个实例接管。 +- SSE 从 seq N 准确补发,无重复或前端 reducer 可幂等去重。 +- 用户 A 无法获取、取消、恢复、下载用户 B 的任务。 + +### 21.3 Gateway 测试 + +- session CRUD 和校验。 +- start/get/stream/cancel/resume。 +- resume action 白名单和 interruptId 校验。 +- source 正文大小和权限。 +- export capability 降级。 +- 隐藏 runtime thread 不出现在普通聊天列表。 + +### 21.4 前端测试 + +- 路由与侧边栏入口。 +- 创建配置序列化。 +- event reducer 对乱序、重复和补发事件幂等。 +- SSE 断线重连使用 last seq。 +- awaiting_input 正确显示审核按钮。 +- source 引用点击定位。 +- completed 时以 session 最终报告校正流式文本。 +- 不支持 PDF/DOCX 时不展示按钮。 + +### 21.5 无外网验收夹具 + +建立固定 fake 材料集和 fake LLM 响应,使 CI 不联网也能验收: + +- 至少 6 个来源,包含重复 URL、相同正文不同 URL、无 URL 知识库材料。 +- 至少 3 个子主题。 +- 一个事实核查失败后修订成功的路径。 +- 一个人工计划修改后恢复的路径。 + +--- + +## 22. 分阶段开发计划 + +### Phase 0:兼容性尖峰与边界测试 + +目标:用最少代码消除最大技术风险,不做页面。 + +任务: + +1. 添加 LangGraph 1.x fake graph contract test。 +2. 添加预取 `raw_content` 不触发上游 scraper 的 contract test。 +3. 验证 `create_chat_model()` 可通过 Adapter 完成上游一次结构化输出。 +4. 核对并锁定 vendoring 文件清单和许可证。 + +验收: + +- 当前 LangGraph 1.x 环境测试通过。 +- 不安装 `langgraph<0.3`。 +- fake MaterialProvider 能完成一次 basic research 最小闭环。 + +### Phase 1:标准报告后端 MVP + +实现状态(2026-08-14):已完成领域配置、材料/LLM/上下文/产物 Adapter、Basic runner、五类持久化表、租约式 dispatcher/executor、Gateway API 和定向测试。当前 Basic runner 是 DeerFlow 原生实现;上游 GPT Researcher 的选定源码尚未 vendoring,仍应在 Phase 3/4 的详细报告、递归研究和多智能体实现中按本文第 6 章从本地固定 commit 引入。 + +任务: + +1. 新增 config/types/cancellation/events。 +2. vendor 核心 `gpt_researcher` 选定代码。 +3. 实现 LLM、Material、Context、Artifact Adapter。 +4. 实现 Basic runner。 +5. 新增 session/source/job/event 表。 +6. 实现 session、start、get、stream、cancel API。 +7. 复用隐藏 runtime thread 产物。 + +验收: + +- DeerFlow 当前 `web_search` 材料可以生成带引用报告。 +- 上游任何 retriever/scraper/MCP 均未运行。 +- 刷新页面/API 断线后能继续获取进度。 +- 可取消,产出 MD/HTML。 + +### Phase 2:独立前端页面 + +实现状态(2026-08-14):已完成路由与菜单入口、创建表单(将所选 mode 写入后端 config)、会话历史、作业 SSE、取消/重新开始、实时来源刷新、报告预览和 MD/HTML 下载。本轮将右侧工作区改为对话式:原始课题、研究过程、流式报告草稿、最终报告与报告追问按对话顺序展示;报告 `report_chunk` 依赖持久化 SSE 重放,刷新时不重新调用模型。页面与 AI 写作页面、Context 和路由完全隔离。 + +任务: + +1. 增加路由和菜单。 +2. 创建页、历史列表和工作台。 +3. SSE hook、event reducer、进度时间线。 +4. 来源库、报告预览和产物下载。 + +验收: + +- 不进入 AI 写作页面或 context。 +- 可以完整创建、运行、刷新恢复、取消和下载。 +- 菜单覆盖、权限和敏感词展示仍正常。 + +### Phase 3:详细报告与 Deep Research + +实现状态(2026-08-14):已完成 `DetailedRunner` 和 `DeepRunner`,并由 `DeepResearchEngine` 分发;Gateway capabilities 与创建校验已开放 `detailed`、`deep`,前端模式选择与递归阶段事件也已接入。详细报告按本地上游 `gpt-researcher/backend/report_type/detailed_report/detailed_report.py` 的“子主题规划 → 分主题研究 → 章节汇编”控制流做 Adapter 化实现;递归模式按 `gpt-researcher/gpt_researcher/skills/deep_research.py` 的“分支 → learning → follow-up”控制流适配。二者不直接调用上游的模型、检索器或抓取器,而是只通过 DeerFlow 的 `AdapterBundle` 复用现有材料收集和 LangGraph 1.x 运行时。 + +任务: + +1. 接入 Detailed runner。 +2. 接入 Deep runner并修复已知空查询问题。 +3. 增加全局预算、分支事件和来源去重。 +4. 增加高级配置 UI。(已完成:仅暴露已由运行器使用的语言、语气、预算、并发、模式专属参数和 `custom_outline`。) + +验收: + +- 同一来源跨子主题稳定去重和引用。 +- 并发不超过配置。 +- depth/breadth 到限后确定性停止。 +- 部分分支失败有明确告警,不造成任务永不结束。 + +### Phase 4:LangGraph 多智能体与人工审核 + +实现状态(2026-08-14):已完成 `MultiAgentRunner`。它按本地上游 `gpt-researcher/multi_agents/agents/orchestrator.py` 的 editor → researcher → writer → reviewer 结构,用当前已安装的 LangGraph 1.x `StateGraph` 编排,不引入上游的 retriever、scraper 或额外 agent 框架。初次运行产生 plan-review `awaiting_input` 事件;`RecordingEventSink` 将 interrupt、计划和租约状态写入既有 durable job,前端可批准或提交修订,`POST /api/deep-research/jobs/{id}/resume` 重新入队后从事件日志恢复原计划并继续执行。审阅不通过时按 `max_revision_loops` 有界回到改写节点。 + +任务: + +1. vendor LangGraph 多智能体选定代码。 +2. 改造状态、checkpointer、interrupt/resume。 +3. 接事实核查与修订回环。 +4. 前端加入计划审核和修订交互。 + +验收: + +- 服务重启后仍可恢复等待中的人工审核。 +- 重复批准不会执行两次。 +- 所有回环有上限。 +- 多智能体同样只使用 DeerFlow 材料和模型。 + +### Phase 5:追问、图片和完整导出 + +实现状态(2026-08-14):图片生成和报告追问子项已完成。由于当前 DeerFlow 部署本身只有识图、没有可直接复用的文生图 Provider,深度研究新增了部署级 `deep_research.image_generation`(OpenAI 兼容 `POST {base_url}/images/generations`)适配器;默认关闭且不保存任何密钥到 session/job。开启后 capabilities 才向前端开放「为报告生成配图」开关。四种 Runner 在文本报告完成后共用 `runners/images.py`:最多四张、优先报告二级章节、请求 `b64_json`,图片保存为隐藏 runtime thread 的受限 `research-image-01..04.(png|jpg|webp)` 产物,并以 `deep-research://` 私有 URI 插入 Markdown。前端经携带现有鉴权头的会话产物接口下载成 Blob 再预览,因此不会泄露 hidden thread id,也不依赖可能过期的第三方 URL;图片服务未配或单张失败只产生可恢复 warning,文字报告照常完成。完成态报告还支持 owner-scoped `GET /messages`、`POST /chat`:问题与回答写入既有消息表,模型仅接收本报告、已选来源和近期消息;回答中的 `[[source:id]]` 仅在 id 属于该会话来源时保留并写入 citation ids。用户可在研究任务停止后以 `PATCH /sessions/{id}/sources/{source_id}` 调整“已选来源”,该集合会立即约束后续追问但不会倒改已完成的报告;运行中返回 409,以免与自动策展写入竞态。`allow_new_research=true` 明确返回 422,不会暗中重新搜集信息。DOCX 已通过浏览器端 `docx` 生成,无需部署新离线 wheel;PDF 与含私有图片嵌入的服务端文档导出仍是后续可选子项。 + +任务: + +1. 报告追问与消息持久化。(已完成) +2. DeerFlow ImageProvider 适配和图片插入。(已完成) +3. 可选 PDF/DOCX 导出依赖与 capability。 +4. 完善配额、监控和管理配置。 + +验收: + +- 追问引用来源可回溯。 +- 图片能力未配置时不影响报告主流程。 +- 导出依赖未安装时优雅降级。 + +--- + +## 23. 代码级实施顺序 + +建议按以下提交顺序实施,便于评审和回退: + +1. `test: add deep research adapter and langgraph contracts` +2. `feat: add deep research domain types and adapters` +3. `vendor: add selected gpt-researcher core at pinned commit` +4. `feat: add basic deep research runner` +5. `feat: add deep research persistence and durable jobs` +6. `feat: add deep research gateway APIs and SSE` +7. `feat: add deep research frontend route and setup page` +8. `feat: add research workbench, sources and report preview` +9. `feat: add detailed and recursive deep research modes` +10. `feat: adapt multi-agent graph to langgraph 1.x interrupts` +11. `feat: add report follow-up and optional exports` + +每个提交都应保持: + +- `uv run --no-sync pytest` 相关测试通过。 +- `uv run --no-sync ruff check` 通过。 +- `pnpm typecheck` 通过(涉及前端时)。 +- 不修改或降级 LangGraph 依赖。 +- 不引入 Harness → App 反向依赖。 + +--- + +## 24. 功能对齐检查表 + +为防止“复用了代码但功能对不上”,开发完成后逐项确认: + +| GPT Researcher 能力 | DeerFlow 对齐结果 | +|---|---| +| 自动生成研究问题 | query processing 复用,结构化校验 | +| 自动选择/创建研究角色 | agent creator 复用 | +| 多来源并行研究 | ResearchConductor 复用,来源由 DeerFlow 提供 | +| 网页抓取 | 不复用;由 DeerFlow 当前信息收集完成 | +| 来源压缩 | 接口复用,压缩器适配 | +| 来源策展 | SourceCurator 复用并保留 DeerFlow 元数据 | +| 标准报告 | Basic runner | +| 详细报告 | Detailed runner | +| 递归深度研究 | Deep runner | +| 子主题并行 | 复用,增加统一并发预算 | +| 多智能体研究、审阅、修订 | LangGraph 1.x Multi-agent runner | +| 人工计划审核 | `interrupt/Command(resume)` | +| 事实核查回环 | 复用,增加最大回环和来源约束 | +| 引言、正文、结论 | ReportGenerator 复用 | +| 引用和参考链接 | Markdown processing 复用并绑定 source id | +| 报告完成后的追问 | 已支持:仅报告/已选来源/近期消息,持久化 source-id 引用,不会隐式重新搜索;停止后可人工调整已选来源 | +| 图片生成与插入 | 已支持:部署级 OpenAI 兼容 ImageProvider,可选开关、受鉴权产物、报告内预览;未配置时优雅跳过 | +| Markdown | 核心支持 | +| HTML | 核心支持并清洗 | +| PDF/DOCX | 可选依赖能力 | +| 实时进度 | EventSink + DB events + SSE | +| 历史任务 | DeerFlow session/job 持久化 | +| 中断、恢复、取消 | DeerFlow durable job + LangGraph checkpointer | +| 模型切换 | DeerFlow `create_chat_model()` | +| 搜索 Provider | 不暴露上游 Provider,使用 DeerFlow 渠道 | +| MCP | 使用 DeerFlow 已配置 MCP,不复用上游 MCP | +| 前端 | 新建 DeerFlow React 页面,不复用上游 Next.js UI | + +--- + +## 25. 已知风险与处理 + +### 25.1 上游升级造成大补丁 + +处理:vendor 只保留核心文件,Adapter 放在 vendor 外,用 manifest 和 contract test 管理升级。 + +### 25.2 材料正文质量不一致 + +处理:MaterialProvider 区分 snippet 和正文;低于正文阈值时由 DeerFlow 正文工具补齐;记录 material quality,报告不得把 snippet 当完整证据。 + +### 25.3 递归研究费用不可控 + +处理:depth/breadth 之外增加总 query、总正文、总模型调用和 Token 硬预算;预算快到上限时先合成已有结论。 + +### 25.4 SSE 消息量过大 + +处理:正文不放事件;report chunk 做节流/合并;事件只存摘要和 locator;支持 seq 补发。 + +### 25.5 多实例重复执行 + +处理:数据库唯一 active key、lease、heartbeat、version CAS;产物写入使用临时文件后原子替换;最终状态更新幂等。 + +### 25.6 上游阻塞代码卡住事件循环 + +处理:优先异步化;无法立即改造的纯 CPU/同步转换使用 `asyncio.to_thread`;网络请求不得用同步 requests。 + +### 25.7 导出依赖影响离线部署 + +处理:MD/HTML 为核心能力,PDF/DOCX 是显式可选依赖和 capability,不因为导出阻塞核心功能上线。 + +### 25.8 许可证元数据不一致 + +处理:合入 vendor 前法律/项目方确认;保留根 LICENSE、版权头、修改说明,不复制无关资产。 + +--- + +## 26. 开发前最终确认项 + +以下选择不会阻塞本文方案,但 Phase 1 开工前应在配置层确认: + +1. 第一批允许的材料渠道是仅 `web_search`,还是同时包含知识库、技能、MCP。 +2. 默认模型角色映射:fast/smart/strategic 是否都使用当前默认模型,还是管理员分别配置。 +3. session 删除是否同步删除隐藏 runtime thread 和所有产物。 +4. 默认最大研究预算和每用户并发数。 +5. 是否在首个版本就启用人工计划审核;即使不启用,后端状态和表结构仍应预留。 +6. PDF/DOCX 是否作为首发硬需求;如果是,需要提前准备离线系统依赖和 Python wheel。 + +推荐默认值:第一版先开放 `web_search` 和已存在的内部知识材料;模型角色都映射到当前默认模型;删除会话同步删除内部产物;每用户最多 2 个 active job;人工审核随 multi-agent 阶段上线;首发只保证 MD/HTML。 + +--- + +## 27. 最终判断 + +该功能可以在当前 DeerFlow 上实现,且总体风险可控。最重要的工程决策不是“是否复用 GPT Researcher”,而是把复用边界划清: + +- 复用其已经成熟的研究规划、递归研究、报告生成和多智能体编审算法。 +- 不复用其与 DeerFlow 重叠且治理较弱的搜索、抓取、MCP、模型 Provider、演示服务和前端。 +- 用 Adapter 保证研究算法只消费 DeerFlow 提供的材料、模型、事件和产物能力。 +- 用 LangGraph 1.x 原生 interrupt/checkpointer 和 DeerFlow durable job 解决暂停、恢复、多实例与刷新续接。 +- 用独立页面和独立领域包避免影响现有 AI 写作和聊天主链路。 + +按本文 Phase 0 到 Phase 4 实施后,可以覆盖 GPT Researcher 的主要生产功能,同时保留 DeerFlow 当前信息收集体系和 LangGraph 1.x 依赖,不需要再维护一套旧运行时。 diff --git a/docs/openvibecoding-内网沙箱改造-开发方案.md b/docs/openvibecoding-内网沙箱改造-开发方案.md new file mode 100644 index 00000000..0d5c2627 --- /dev/null +++ b/docs/openvibecoding-内网沙箱改造-开发方案.md @@ -0,0 +1,304 @@ +# OpenVibeCoding 内网可视化应用生成平台:分步开发方案 + +> 文档状态:待实施 +> +> 目标环境:现有 Ubuntu 22.04 Docker 服务器;对外可用端口限定为 `8020-8030`。 +> +> 本文基于 2026-08-31 的只读巡检编写。未在目标服务器上安装、停止或修改任何服务。 + +## 1. 目标与范围 + +将 OpenVibeCoding 改造成一个使用公司内网模型、在公司服务器 Docker 沙箱中生成并运行可视化应用的工作台。用户通过对话生成项目,查看并修改文件,在浏览器中预览运行结果。 + +第一期只服务以下项目类型: + +- 数据大屏、数据看板、海报和展示型页面; +- React + Vite + TypeScript 前端; +- ECharts 图表、流程图; +- 可选的轻量 Python FastAPI 接口; +- 项目文件浏览、编辑、运行日志和实时预览。 + +第一期明确不做: + +- 腾讯云 CloudBase、SCF、TCR、CodeBuddy、云端一键部署; +- 任意语言、任意 npm/PyPI 包的无限制安装; +- 用户自定义 Dockerfile、Docker Socket 挂载、宿主机命令执行; +- 直接将沙箱暴露为独立公网端口; +- 对不可信代码提供“绝对安全”的隔离承诺。 + +## 2. 已确认的服务器基线 + +目标服务器当前具备: + +| 项目 | 巡检结果 | 判断 | +|---|---:|---| +| 操作系统 | Ubuntu 22.04.5 LTS | 支持 Docker 方案 | +| CPU | 32 vCPU | 足够第一期 | +| 内存 | 125 GiB;巡检时可用约 106 GiB | 足够第一期 | +| 根磁盘 | 2 TB;巡检时可用约 1.6 TB | 足够 Harbor、镜像与工作区 | +| Docker | 29.1.3,Docker Compose 2.40.3 | 已满足 | +| 负载 | 约 0.5 | 当前有余量 | +| `8020-8030` | 巡检时无监听、无 Docker 端口映射 | 可规划使用 | + +该服务器已承载 DeerFlow、Coze、LiteLLM、数据库和其他容器;`80`、`8000`、`8005`、`8006`、`3306`、`5432` 等端口已经被使用。因此,本项目不能占用现有端口、网络、数据库或容器名称。 + +该服务器有公网 IP,且已有多项服务绑定在 `0.0.0.0`。本方案将它视作“公网可达的共享主机”处理;云安全组和宿主机防火墙必须额外核验,不能仅凭端口监听结果假设安全。 + +## 3. 总体架构 + +```mermaid +flowchart LR + U[用户浏览器] -->|:8020| G[App Builder Gateway] + U -->|:8022 /preview//| P[Preview Router] + + G --> O[OpenVibe Web / Agent Server] + O --> M[现有内网模型网关] + O --> DB[(独立 PostgreSQL)] + O --> R[(独立 Redis)] + O --> SM[Local Sandbox Manager] + + SM --> D[Docker Engine] + D --> S1[visual-ui Sandbox] + D --> S2[visual-fullstack Sandbox] + P --> S1 + P --> S2 + + D --> H[Harbor :8023] + S1 --> N[内网 pnpm/npm 源或预置离线依赖] + S2 --> N +``` + +设计原则: + +1. 保留 OpenVibe 的对话、任务、文件树、工具卡片和 OpenCode ACP 代码代理体验。 +2. 模型请求仅由 OpenVibe 服务端发往既有内网模型网关;API Key 不进入沙箱。 +3. 每个项目运行于独立 Docker 容器和独立工作区;沙箱只运行项目代码,不承担平台控制逻辑。 +4. 浏览器预览只经过统一预览路由,不为每个项目映射新的公网端口。 +5. 先用固定模板和固定依赖清单保证稳定,后续再按白名单增加依赖。 + +## 4. 端口规划 + +端口虽然可用,但应尽量少暴露。除 8020、8022 和按需开放的 8023 外,其余服务只加入 Docker 内部网络。 + +| 端口 | 服务 | 绑定范围 | 用途 | +|---:|---|---|---| +| 8020 | `appbuilder-gateway` | 公网访问面或 VPN 白名单 | 控制台页面、`/api`、SSE/WebSocket | +| 8021 | 预留 | 仅 `127.0.0.1` | 平台调试;第一期不对外开放 | +| 8022 | `preview-router` | 公网访问面或 VPN 白名单 | 统一预览入口:`/preview//` | +| 8023 | Harbor | 仅沙箱节点、构建节点和管理员网段 | 私有镜像仓库 HTTPS Registry | +| 8024 | 预留 | 不对外开放 | 后续 npm 私服或运维服务 | +| 8025-8030 | 预留 | 不对外开放 | 扩展、迁移或故障切换 | + +### 4.1 预览路由规则 + +暂时没有可用泛域名时,使用路径型预览: + +```text +http(s)://<服务器地址>:8022/preview// +``` + +项目模板的 Vite `base` 和 HMR 路径必须由启动脚本注入为该前缀,确保图片、JS/CSS 资源及热更新不会错误请求到根路径。后续若获得 `*.preview.company.local` 内网泛域名,可切换为子域名预览,用户体验更好。 + +## 5. 镜像、模板与离线依赖策略 + +### 5.1 标准镜像 + +第一期维护两种版本化镜像,不允许用户自行构建 Dockerfile: + +| 镜像 | 用途 | 预置内容 | +|---|---|---| +| `appbuilder/visual-ui:` | 大屏、海报、纯前端 | Node.js 20、pnpm、React、Vite、TypeScript、ECharts、流程图库、统一 UI 库、Tailwind、图标与中文字体 | +| `appbuilder/visual-fullstack:` | 前端加接口 | 上述前端依赖 + Python 3.12、FastAPI、Uvicorn、Pydantic | + +流程图库第一期统一选择一种(建议 React Flow),避免同时生成多种流程图语法和样式。地图、背景图、字体等资源也应作为受版本控制的本地资产打包,不能依赖 Google Fonts 或公网 CDN。 + +### 5.2 项目模板 + +每次创建项目时从镜像中的模板初始化到 `/workspace`: + +```text +templates/ + visual-screen/ # React + ECharts + React Flow + Mock 数据 + visual-screen-api/ # visual-screen + FastAPI + /api 反向代理 + poster-page/ # 海报、营销展示、静态动效 +``` + +`visual-screen-api` 约定:前端只请求相对路径 `/api/*`;预览网关将其转发到同一沙箱的 FastAPI `8000` 端口。没有后端时保留 Mock 数据模块,生成的页面仍可运行。 + +### 5.3 依赖预置方式 + +不能仅把安装命令写进 Dockerfile 后在用户创建项目时再联网执行。正确做法是: + +1. 在受控构建环境中锁定 `package.json`、`pnpm-lock.yaml`、`requirements.lock`。 +2. 构建镜像时执行 `pnpm fetch`,在镜像保存只读 pnpm store;Python 包保存到 `/opt/wheelhouse`。 +3. 沙箱初始化使用 `pnpm install --offline --frozen-lockfile` 和 `pip install --no-index --find-links=/opt/wheelhouse`。 +4. 第一期开启“固定依赖白名单”:AI 只能使用镜像已安装的依赖;新依赖先由管理员审核、加入锁文件和下一版基础镜像。 +5. 第二期部署 Verdaccio/Nexus 等内部制品源,作为受控补充而非允许访问公网 npm/PyPI。 + +## 6. OpenVibe 改造边界 + +### 6.1 保留 + +- 前端交互:对话、任务状态、文件浏览与修改、预览、日志; +- OpenCode ACP 代码代理; +- 项目与会话领域模型; +- 代码代理的流式事件协议。 + +### 6.2 替换或关闭 + +| OpenVibe 当前能力 | 第一期开源/内网实现 | +|---|---| +| CodeBuddy | 只启用 OpenCode,配置内网 OpenAI 兼容模型网关 | +| CloudBase、SCF 沙箱 | 新增 `LocalDockerSandboxManager` | +| TCR | Harbor | +| CloudBase 环境、用户环境池 | Docker 容器、工作区卷、沙箱记录 | +| CloudBase 消息与事件持久化 | 独立 PostgreSQL;Redis 用于临时队列与状态 | +| CloudBase 存储、函数、MCP、资源管理后台 | 第一期开关关闭或移除入口 | +| CloudBase 项目模板和系统提示词 | 替换为大屏/海报/FastAPI 模板与受控依赖提示词 | + +重点改造点是将 OpenVibe 的 `scf-sandbox-manager` 和 CloudBase 生命周期调用替换为本地 Docker 生命周期。为降低前端和 OpenCode 工具改动范围,新管理器应适配现有的文件、命令、日志、健康检查与工作区信息契约,而不是改写所有 Agent 工具。 + +## 7. 沙箱安全基线 + +每个沙箱容器必须满足: + +- 非 root 用户运行;禁止 `privileged`,禁止 Docker Socket,禁止宿主机目录挂载; +- `--cap-drop=ALL`、`no-new-privileges`、`pids-limit`; +- 首期默认限制:`1 vCPU`、`3 GB 内存`、`10 GB 工作区`、60 分钟空闲回收; +- 使用独立 Docker network;默认无公网出口,只允许访问预置依赖、必要的内网 DNS 和平台回调; +- 数据库、Redis、Harbor 管理接口、宿主机管理端口不在沙箱网络可访问范围; +- 运行命令、容器生命周期、文件变更和预览访问写入审计日志; +- 平台服务由受限服务账号调用 Docker API;用户代码永远不直接获得 Docker 权限。 + +第一期为“内部可信用户的容器隔离”。若后续允许大量非可信代码或文件上传,应迁移为 Kubernetes 节点,并评估 gVisor/Kata 等更强运行时隔离。 + +## 8. 分步实施计划 + +### 阶段 0:部署前隔离与基线确认 + +**工作项** + +1. 为本项目建立独立目录、Docker Compose Project、卷和网络,命名统一为 `appbuilder-*`。 +2. 确认云安全组、iptables/nftables 规则:仅允许管理员 SSH;8020、8022、8023 仅向 VPN 或指定办公出口开放。 +3. 不复用当前服务器上已有的 MySQL、PostgreSQL、Redis、Nginx 配置;建立独立实例或独立命名空间。 +4. 创建内部 CA 证书或确定可信 HTTPS 证书方案;Docker 构建/运行节点信任 Harbor 证书。 +5. 轮换目标服务器 root 密码,改用 SSH 密钥和受限运维账号;关闭 root 密码远程登录。 + +**验收** + +- `8020-8030` 无端口冲突; +- 新容器、网络和数据卷均不影响既有 DeerFlow 等服务; +- 业务数据库和沙箱端口无法从非授权公网地址访问。 + +### 阶段 1:内网基础设施与镜像供应链 + +**工作项** + +1. 在独立 Compose 项目中部署 Harbor,主入口为 `8023`,镜像数据使用独立持久化目录。 +2. 构建并推送 `visual-ui`、`visual-fullstack` 两种基础镜像;记录镜像版本、SBOM 和依赖锁文件。 +3. 完成三个模板及离线依赖初始化脚本。 +4. 先不启动漏洞扫描;严格断网环境下后续通过离线包维护漏洞库。 +5. 预留内部 npm/PyPI 源接入方式;第一期仅使用预置依赖。 + +**验收** + +- Docker daemon 能从 Harbor 拉取指定版本镜像; +- 在禁止公网访问的测试网络中,三个模板均能启动并构建; +- 一个 ECharts 页面、一个流程图页面和一个 FastAPI `/api/health` 均可运行。 + +### 阶段 2:OpenVibe 去 CloudBase 化与内网模型接入 + +**工作项** + +1. Fork 固定版本的 OpenVibeCoding,建立 `local-platform` 改造分支,记录上游 commit 与许可证。 +2. 只保留 OpenCode Agent;将 provider 指向现有内网模型网关,模型凭证只保存在服务端环境变量或密钥服务。 +3. 将基础实体、任务、消息、流式事件的持久化迁移为独立 PostgreSQL;不再调用 CloudBase 集合。 +4. 关闭 CloudBase 环境、函数、存储、数据库、资源仪表盘和相关 MCP 入口。 +5. 将系统提示词和项目创建默认项改为“可视化页面 + 受控模板/依赖”。 + +**验收** + +- 不设置任何腾讯云、CloudBase、CodeBuddy、TCR 环境变量时,平台可启动; +- 用户发起“生成 ECharts 大屏”后,OpenCode 能调用内网模型并生成项目文件; +- 服务端日志和沙箱环境中均没有模型 API Key。 + +### 阶段 3:本地沙箱管理器与预览路由 + +**工作项** + +1. 实现 `LocalDockerSandboxManager`:创建、启动、停止、超时回收、查询状态和删除容器。 +2. 每个沙箱分配工作区卷、随机内部容器地址、模板类型和资源限制;在数据库记录 `sandbox_id`、项目、用户、镜像版本、过期时间和状态。 +3. 实现或适配沙箱内 runner:文件读写、受限命令执行、日志流、健康检查、前端端口和 FastAPI 端口发现。 +4. 实现 `preview-router`:由平台根据 `sandboxId` 维护路由,将 `/preview//` 代理到对应内部容器;支持 WebSocket/HMR。 +5. 增加容器异常退出、端口未就绪、构建失败、超时与手动重启的可见状态。 + +**验收** + +- 两个用户创建的项目存在于不同容器和不同工作区; +- 修改 ECharts 配置文件后,预览地址显示修改结果; +- FastAPI 模板的 `/api/health` 可通过前端相对路径访问; +- 沙箱不能访问 Docker Socket、宿主机数据库和非白名单网络; +- 空闲超时后容器被回收,项目文件仍可恢复。 + +### 阶段 4:产品能力收口 + +**工作项** + +1. 在文件面板支持安全的文件查看、编辑、保存、目录树刷新与下载;限制文件大小和可编辑扩展名。 +2. 在对话中增加模板选择和能力提示:大屏、流程图、海报、可选 FastAPI。 +3. 为 Agent 增加依赖白名单、图表主题、中文字体、Mock 数据、接口契约等明确提示词。 +4. 增加“运行/停止/重启/删除沙箱”和日志面板;仅项目所有者或管理员有相应权限。 +5. 增加项目快照、模板版本记录和基础审计日志。 + +**验收** + +- 从一句自然语言需求到可预览项目的完整链路可完成; +- 用户可查看并手动修改文件,刷新后改动保留; +- 页面覆盖 ECharts、流程图、响应式大屏和可选 FastAPI 接口; +- 非拥有者不能读取、修改或预览另一用户的项目。 + +### 阶段 5:上线前安全与运行验收 + +**工作项** + +1. 压测 6 个并发沙箱,并记录 CPU、内存、磁盘、容器启动时间、首屏预览时间和模型等待时间。 +2. 验证镜像保留策略、工作区配额、容器回收、数据库备份和 Harbor 备份恢复。 +3. 执行越权、路径穿越、命令注入、网络探测、资源耗尽和模型密钥泄露测试。 +4. 建立日常运维操作:新增基础依赖、构建镜像、发布模板、回滚镜像、清理工作区和审计查询。 + +**验收** + +- 并发运行时既有 DeerFlow 等服务不受明显影响; +- 任一沙箱异常退出不会影响其他项目或平台控制服务; +- 备份可恢复项目元数据、工作区和 Harbor 镜像; +- 仅开放计划中的入口端口,内部组件无不必要公网监听。 + +## 9. 首期资源与并发建议 + +由于该主机已有多项业务,首期不应按理论最大值分配: + +| 组件 | 首期限制 | +|---|---| +| Harbor | 8 vCPU、16 GB 内存、500 GB 数据盘配额 | +| OpenVibe 控制服务 | 4 vCPU、8-16 GB 内存 | +| 单个 UI 沙箱 | 1 vCPU、3 GB 内存、10 GB 工作区 | +| 单个 Fullstack 沙箱 | 1.5 vCPU、4 GB 内存、12 GB 工作区 | +| 沙箱并发 | 先限制为 6;稳定后再逐步上调 | + +当并发超过 10 个,或要面向更广泛用户开放时,应优先增加独立沙箱节点,而不是继续在本共享主机上加容器。 + +## 10. 实施顺序与交付物 + +建议按以下提交/交付顺序推进,每一步都可独立验收和回退: + +1. `docs: confirm appbuilder network, ports and security baseline` +2. `infra: add Harbor and versioned visual sandbox images` +3. `infra: add offline templates and dependency locks` +4. `feat: replace OpenVibe CloudBase persistence and model provider` +5. `feat: add local Docker sandbox manager` +6. `feat: add sandbox runner and path-based preview router` +7. `feat: add visual-screen and FastAPI project workflows` +8. `test: add sandbox isolation, preview and recovery coverage` +9. `ops: add backup, cleanup, monitoring and rollout runbook` + +达到阶段 3 即可交付“内网模型驱动、生成并预览大屏/海报页面”的 PoC;达到阶段 5 后再作为受控的内部多人平台发布。 diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 3837c075..5077f8d4 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true VITE_MOCK_COP_TASK_DETAIL=true # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 -VITE_MOCK_SQ_REPORT=true +VITE_MOCK_SQ_REPORT=false # 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。 VITE_MOCK_COP_TASK_DETAIL=true @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api +# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # CMZS后端接口 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 68e79a97..554254f4 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -56,4 +56,13 @@ # VITE_STATIC_WEBSITE_ONLY= # 部署级紧凑导航:true 时固定收起工作台左侧栏,并隐藏换肤与全量/简洁模式切换。 # VITE_FORCE_COLLAPSED_SIDEBAR=false +# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。 +# 不填或留空时按类型 A,继续使用原来的简介菜单数据。 +# VITE_COMPACT_MENU_TYPE=A # VITE_GITHUB_OAUTH_TOKEN= + +# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- +# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。 +# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。 +# 仅 .env.development 默认 true;其余环境保持 false。 +# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false diff --git a/frontend-web/.env.network b/frontend-web/.env.network index 9f770d29..a7da4f8a 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false VITE_BACKEND_BASE_URL=http://10.19.200.5:2026 VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api +# 内网环境保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # CMZS后端接口 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index a096e3b5..43085943 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api +# 线上保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # CMZS后端接口 diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 31dda6ed..67d0332b 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api +# SIT 环境保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # CMZS后端接口 diff --git a/frontend-web/docs/angular-智能体对话对接文档.md b/frontend-web/docs/angular-智能体对话对接文档.md new file mode 100644 index 00000000..05bc4855 --- /dev/null +++ b/frontend-web/docs/angular-智能体对话对接文档.md @@ -0,0 +1,1544 @@ +# Angular 对接 DeerFlow 智能体对话页 + +本文说明如何在 **其它 Angular 项目** 中对接 DeerFlow 智能体对话能力,对标页面: + +``` +http://localhost:5173/#/page/workspace/agents/roundtable-structure/chats/new +``` + +对应前端路由:`#/page/workspace/agents/{agent_id}/chats/{thread_id}` +其中 `agent_id = roundtable-structure`(展示名「结构化抽取」),`thread_id = new` 表示新建会话。 + +本文覆盖:**登录拿 Token → 列模型/切模型 → 建会话/存聊天记录 → 流式对话 → 思考内容 → 步骤条(含创建脚本/执行脚本/写入文件及全部工具)→ 用户协助卡片 → 消息列表渲染 → 停止/重连/上传/产物**。 +**不包含**智能体配置(创建/编辑智能体、技能绑定等)。 + +路径约定(均相对本仓库根目录 `deerflow-server/`): + +- 前端:`frontend-web/src/…` +- 后端:`offline-backend-20260512/backend/…`(下文若写 `app/`、`deerflow/`,都在这个 backend 目录下) +- 每节末尾有 **参考代码**;文末第 20 节是总索引。 + +--- + +## 0. 先看这一页 + +对接本质不是「调一个聊天 HTTP」,而是复用本系统的 **LangGraph 兼容协议**: + +| 能力 | 走哪条线 | Base URL | +|------|----------|----------| +| 登录、模型列表、上传、产物、追问建议 | Gateway REST | `{GATEWAY}`,例如 `http://host:8001` | +| 会话 / 流式对话 / 取消 | LangGraph Platform 兼容 API | `{GATEWAY}/api` | + +本仓库前端环境变量: + +- `VITE_BACKEND_BASE_URL` → `{GATEWAY}` +- `VITE_LANGGRAPH_BASE_URL` → `{GATEWAY}/api` + +开发机前端常见端口是 **5173 或 5174**(Vite 被占用会换端口),Gateway 默认 **8001**。 +下文用: + +```text +GATEWAY = http://localhost:8001 +LG = http://localhost:8001/api +``` + +所有对话接口都要带: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +用 Bearer **不必**再带 CSRF。不要用 cookie 登录(`/login/local` 只把 JWT 写进 HttpOnly cookie,不适合 Angular 跨域 API 客户端)。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端两个 Base URL | `frontend-web/src/core/config/index.ts`(`getBackendBaseURL` / `getLangGraphBaseURL`) | +| 环境变量注入 | `frontend-web/src/env.ts` | +| LangGraph SDK 客户端(自动带 Bearer) | `frontend-web/src/core/api/api-client.ts` | +| Gateway fetch 自动带 Authorization | `frontend-web/src/core/api/fetch-client.ts` | +| CSRF:有 Bearer 则跳过 | `offline-backend-20260512/backend/app/gateway/csrf_middleware.py` | +| 登录接口白名单 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` | + +--- + +## 1. 架构与端到端流程 + +```mermaid +sequenceDiagram + participant A as Angular + participant G as Gateway :8001 + participant R as RunManager / Lead Agent + + A->>G: POST /api/v1/auth/login/username + G-->>A: access_token (JWT, ~7 天) + A->>G: GET /api/models + G-->>A: models[] + A->>G: POST /api/threads metadata.agent_id=roundtable-structure + G-->>A: thread_id + A->>G: POST /api/threads/{id}/runs/stream (SSE) + G->>R: start_run(assistant_id, context.model_name, ...) + loop 流式 + R-->>A: event: metadata / messages / values / custom / end + end + Note over A: 按消息分组渲染气泡 + 步骤条 + 协助卡片 + A->>G: GET /api/threads/{id}/state (刷新后还原聊天记录) +``` + +`roundtable-structure` 与其它自定义智能体共用 **同一套 Lead Agent 图**。 +真正选中哪个智能体靠三层同时带上(缺一不可,建议都传): + +1. 请求体 `assistant_id = "roundtable-structure"`(文件系统/库表里的 **id**,不是中文名) +2. `context.agent_id = "roundtable-structure"` +3. 会话 `metadata.agent_id = "roundtable-structure"`(历史列表按此过滤) + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 路由挂载 AgentChatPage | `frontend-web/src/pages/WorkspaceRoutes.tsx`(`agents/:agent_id/chats/:thread_id`) | +| 对话页:`assistantId` + `context.agent_id` | `frontend-web/src/pages/AgentChatPage.tsx` | +| 聊天气泡壳 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | +| 发消息时写入 metadata.agent_id | `frontend-web/src/core/threads/hooks.ts`(`onCreated` / `thread.submit`) | +| 非内置 assistant_id → agent_id | `offline-backend-20260512/backend/app/gateway/services.py`(`build_run_config`) | +| Lead Agent 工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` | +| 智能体种子(id / 中文名 / 技能) | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` | +| 智能体 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` | + +--- + +## 2. CORS / 跨域(Angular 必读) + +Gateway 默认放行的浏览器 Origin 只有: + +- `http://localhost:5173` / `5174` / `3000` / `8080`(及对应 `127.0.0.1`) + +**Angular CLI 默认是 `http://localhost:4200`,不在白名单里。** +部署或本地对接时必须把 Angular 源加进环境变量,例如: + +```bash +GATEWAY_CORS_ORIGINS=http://localhost:4200,http://127.0.0.1:4200,https://your-angular-host +``` + +或开发期临时 `GATEWAY_CORS_ALLOW_ALL=1`(勿用于生产)。 + +**参考代码**:`offline-backend-20260512/backend/app/gateway/app.py`(搜 `GATEWAY_CORS_ORIGINS` / `local_vite_origins` / `cors_expose_headers`)。回归:`offline-backend-20260512/backend/tests/test_taskcop_cors.py`。 + +浏览器跨域读 SSE 还需要暴露这些响应头(Gateway 已配): + +- `Content-Location`(从中解析 `run_id`,取消/重连要用) +- `Location`、`Content-Disposition` + +请求请带 `credentials: include` 也可以,但 **Bearer 才是主认证**。 + +--- + +## 3. 登录:如何拿到 Token + +登录接口 **不需要** JWT。前缀:`/api/v1/auth`。 + +推荐 Angular 用 **用户名登录** 或 **上游 Token 换票**。不要用 `/login/local`。 + +### 3.1 用户名登录(推荐) + +```http +POST {GATEWAY}/api/v1/auth/login/username?password=OPTIONAL +Content-Type: application/json + +{ "username": "alice" } +``` + +- 若该账号在后台配了「登录口令」,**必须**带匹配的 `?password=` +- 若开了全局共享口令门(`config.yaml → auth_login.username_login.require_password`),未配个人口令的账号也要带共享口令 +- 未开密码门且无个人口令时,可省略 `password` + +成功响应: + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 604800, + "user_id": "", + "email": "alice@deerflow.local", + "system_role": "user", + "needs_setup": false, + "created": false, + "username": "alice" +} +``` + +`expires_in` 默认 **7 天(604800 秒)**。系统 **没有 refresh 接口**,过期后重新登录。 +JWT payload:`{ sub: user_id, exp, iat, ver }`,HS256。改密码会 bump `token_version`,旧票立刻失效。 + +后续所有请求: + +```http +Authorization: Bearer +``` + +即 ``${token_type} ${access_token}``。 + +校验当前用户: + +```http +GET {GATEWAY}/api/v1/auth/me +Authorization: Bearer +``` + +```json +{ "id": "...", "email": "...", "system_role": "user", "needs_setup": false } +``` + +401 → 清本地 token,回到登录。 + +### 3.2 上游 Token 换票 + +适用于已有统一认证、URL 里带 `authToken` 的场景: + +```http +POST {GATEWAY}/api/v1/auth/login/token?token= +Content-Type: application/json +``` + +响应形状与用户名登录相同。上游时钟偏差可能导致短暂 401,本系统前端会重试最多 3 次、间隔 3 秒;Angular 建议同样处理。 + +开关:`config.yaml → auth_login.token_login.enabled`。 + +### 3.3 登录失败 + +`detail` 可能是字符串、`{ code, message }`、或校验数组。请按字符串抽取,不要直接 `String(object)`。 + +常见: + +| HTTP | 含义 | +|------|------| +| 401 | 口令错误 / 上游 token 无效 | +| 403 `{ code: "PENDING_APPROVAL" }` | 管理员取消了该账号放行 | +| 429 | 限流 | + +### 3.4 Angular 存储建议 + +```ts +const AUTH_KEY = 'deerflow.auth'; + +interface LoginResponse { + access_token: string; + token_type: string; + expires_in: number; + user_id: string; + email: string; + system_role: string; + username?: string; +} + +function authHeader(): HttpHeaders { + const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null') as LoginResponse | null; + return new HttpHeaders({ + Authorization: `${auth?.token_type || 'Bearer'} ${auth?.access_token || ''}`, + }); +} +``` + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端登录 API | `frontend-web/src/core/auth/api.ts`(`loginByUsername` / `loginByToken`) | +| Token 存取、拼 Bearer | `frontend-web/src/core/auth/index.ts`(`getAuthorizationHeaderValue` / `setStoredAuth`) | +| 类型 `LoginByUsernameResponse` | `frontend-web/src/core/auth/index.ts` | +| 登录页(含 token 换票) | `frontend-web/src/pages/LoginPage.tsx` | +| 后端登录路由 | `offline-backend-20260512/backend/app/gateway/routers/auth.py`(`/login/username`、`/login/token`、`/me`) | +| JWT 签发 | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` | +| 请求里解析 cookie / Bearer | `offline-backend-20260512/backend/app/gateway/deps.py`(`get_current_user`) | +| 登录配置(口令门 / token_login) | `offline-backend-20260512/backend/config.yaml` → `auth_login`;schema:`deerflow/config/login_config.py` | +| 登录契约说明 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` | + +--- + +## 4. 模型列表与切换 + +智能体配置不用做;**对话时选哪个大模型**要做。 + +### 4.1 拉模型 + +```http +GET {GATEWAY}/api/models +Authorization: Bearer +``` + +```json +{ + "models": [ + { + "name": "deepseek-chat", + "model": "deepseek-chat", + "display_name": "DeepSeek Chat", + "supports_thinking": true, + "supports_reasoning_effort": false, + "supports_vision": false, + "supports_tool_calling": true, + "is_local": false, + "provider": "openai" + } + ], + "token_usage": { "enabled": false } +} +``` + +下拉框展示 `display_name`,提交时传 **`name`(不是 `model`)** 到 `context.model_name`。 + +### 4.2 思考档位(本系统输入框的 flash / thinking / pro / ultra) + +每轮 `runs/stream` 的 `context` 里带这些开关(后端白名单字段,未列入的会被丢掉): + +| 档位 | `thinking_enabled` | `is_plan_mode` | `subagent_enabled` | `reasoning_effort` | 效果 | +|------|--------------------|----------------|--------------------|--------------------|------| +| flash | false | false | false | — | 快答,尽量不思考 | +| thinking | true | false | false | `low` | 出思考块 | +| pro | true | true | false | `medium` | 思考 + 计划 Todo | +| ultra | true | true | true | `high` | 再加子任务委派 | + +若所选模型 `supports_thinking === false`,请强制 flash。 +内网部分 vLLM/Qwen 只声明了 `supports_thinking`,关思考时建议额外传 `thinking_force_disabled: true`,否则可能关不干净。 + +模型解析顺序(后端): + +1. 本轮 `context.model_name` +2. 智能体自己的 `config.yaml → model` +3. 系统配置的第一个模型 + +每轮都可以换模型;换了只影响 **下一轮**,不会改写历史。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端拉模型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` | +| 输入框模型下拉 + 档位 | `frontend-web/src/components/workspace/input-box.tsx`(`InputMode`、`ModelSelector`) | +| 档位写入 context | `frontend-web/src/core/threads/hooks.ts`(`thread.submit` 的 `context`:`thinking_enabled` / `is_plan_mode` / `subagent_enabled`) | +| 本地记住所选模型 | `frontend-web/src/core/settings/local.ts`(`deerflow.thread-model.{threadId}`) | +| 后端模型列表 | `offline-backend-20260512/backend/app/gateway/routers/models.py` | +| context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py`(`_CONTEXT_CONFIGURABLE_KEYS`) | +| 本轮实际用哪个模型 | `offline-backend-20260512/backend/app/gateway/services.py`(`_resolve_effective_run_model_name`) | +| 创建 ChatModel、关思考 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` | +| 模型配置源 | `offline-backend-20260512/backend/config.yaml` → `models[]` | + +--- + +## 5. 会话(Thread)= 聊天记录的存储单位 + +聊天记录 **不用 Angular 自己建库**。Gateway 用 LangGraph checkpointer 存消息,用 `threads_meta` 存索引(标题、owner、metadata)。 + +物理目录(了解即可):`.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`。 + +### 5.1 新建会话 + +```http +POST {LG}/threads +Authorization: Bearer +Content-Type: application/json + +{ + "assistant_id": "roundtable-structure", + "metadata": { "agent_id": "roundtable-structure" } +} +``` + +```json +{ + "thread_id": "3f2a…", + "status": "idle", + "created_at": "2026-08-18T09:00:00+08:00", + "updated_at": "…", + "metadata": { "agent_id": "roundtable-structure" }, + "values": {}, + "interrupts": {} +} +``` + +也可以不先 create:`runs/stream` 带 `if_not_exists: "create"` 时会自动建。本系统前端就是 **第一次发消息时** 由 SDK 建 thread,再 `PATCH` 写入 `metadata.agent_id`。建议 Angular **显式 create**,方便先上传文件、再发问。 + +### 5.2 补 metadata(必须,否则历史列表滤不出来) + +```http +PATCH {LG}/threads/{thread_id} +{ "metadata": { "agent_id": "roundtable-structure" } } +``` + +为 merge,不会整表覆盖。 + +### 5.3 历史列表(按智能体过滤) + +```http +POST {LG}/threads/search +{ + "metadata": { "agent_id": "roundtable-structure" }, + "limit": 50, + "offset": 0, + "query": null, + "status": null +} +``` + +返回 `ThreadResponse[]`。标题在 `values.title`(来自库里的 `display_name`)。 +`query` 按标题模糊搜。系统会话(调度、会商工人线程)默认排除。 + +条数: + +```http +POST {LG}/threads/count +(body 同上,limit/offset 忽略) +→ { "total": 12 } +``` + +列表项建议展示: + +1. `values.title`;没有则用该会话第一条用户消息截断 50 字 +2. `updated_at` + +打开某条:跳到自己的路由并 `GET …/state` 拉消息。 + +### 5.4 打开已有会话(还原聊天记录) + +优先: + +```http +GET {LG}/threads/{thread_id}/state +``` + +`values` 里至少有: + +```json +{ + "title": "抽取方案结构", + "title_provisional": false, + "messages": [ /* 见第 8 节 */ ], + "artifacts": ["/mnt/user-data/outputs/flow.json"], + "todos": [{ "content": "…", "status": "in_progress" }] +} +``` + +也可: + +```http +GET {LG}/threads/{thread_id} +``` + +带 metadata + 当前 `values`(含 messages)。 + +checkpoint 历史(一般不需要): + +```http +POST {LG}/threads/{thread_id}/history +{ "limit": 10, "before": null } +``` + +只在最新一条 history 上带 `messages`,避免重复。 + +### 5.5 删除会话 + +两步都做才干净: + +```http +DELETE {LG}/threads/{thread_id} +``` + +这会删 checkpoint / meta / 磁盘目录。本系统前端 SDK 删除后再打一次 Gateway 同路径,确保本地文件清掉。 + +### 5.6 会话状态 + +`status`:`idle` | `busy` | `interrupted` | `error` +正在跑时为 `busy`。同一会话再发会被 `multitask_strategy: "reject"` 打回 **HTTP 409**。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 线程类型 / context 类型 | `frontend-web/src/core/threads/types.ts` | +| 列表、标题、路由 | `frontend-web/src/core/threads/utils.ts`(`titleOfThread` / `pathOfThread`) | +| 分页搜索 | `frontend-web/src/core/threads/hooks.ts`(`useThreadsPage`) | +| 智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` | +| 工作区会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` | +| 建/搜/改/删/state/history | `offline-backend-20260512/backend/app/gateway/routers/threads.py` | +| ThreadResponse / Search 模型 | 同上文件内 `ThreadResponse`、`ThreadCreateRequest`、`ThreadSearchRequest` | +| 删除时清本地目录 | `frontend-web/src/core/threads/hooks.ts`(delete 后再调 Gateway DELETE) | +| 409 中文文案 | `offline-backend-20260512/backend/app/gateway/services.py`(`create_or_reject` / RunManager) | + +--- + +## 6. 发起智能体对话(核心) + +```http +POST {LG}/threads/{thread_id}/runs/stream +Authorization: Bearer +Content-Type: application/json +Accept: text/event-stream +``` + +### 6.1 请求体(与本系统 AgentChatPage 对齐) + +```json +{ + "assistant_id": "roundtable-structure", + "input": { + "messages": [ + { + "type": "human", + "content": [{ "type": "text", "text": "请根据这份方案抽取流程图" }], + "additional_kwargs": { + "client_ts": "2026-08-18T09:00:00.000Z" + } + } + ] + }, + "context": { + "agent_id": "roundtable-structure", + "model_name": "deepseek-chat", + "thinking_enabled": true, + "thinking_force_disabled": false, + "is_plan_mode": false, + "subagent_enabled": false, + "reasoning_effort": "low", + "memory_injection_enabled": true, + "rag_mode_enabled": false, + "summarization_enabled": false, + "thread_id": "" + }, + "config": { "recursion_limit": 1000 }, + "stream_mode": ["values", "messages-tuple", "updates", "custom"], + "stream_subgraphs": true, + "stream_resumable": true, + "on_disconnect": "cancel", + "multitask_strategy": "reject", + "if_not_exists": "create" +} +``` + +要点: + +- `input.messages` **只传本轮新的 human**,不要把历史整包再塞一遍(服务端 checkpointer 会拼) +- `content` 可以是纯字符串 `"你好"`,也可以是上面的 text block 数组 +- `client_ts` 可选,前端用来显示提问时间 +- `stream_mode` 里写 `messages-tuple`(LangGraph Platform 名);服务端会转成内部 `messages` 再以 SSE `event: messages` 吐出 +- `on_disconnect: "cancel"`:浏览器关 SSE 就停服务端;若希望关页后台继续跑,改 `"continue"` +- `multitask_strategy: "reject"`:会话已有进行中的 run → **409**,文案类似「当前对话还在生成回答…」 + +响应头: + +```http +Content-Type: text/event-stream +Content-Location: /api/threads/{thread_id}/runs/{run_id} +``` + +**立刻把 `run_id` 存下来**(停止、重连都靠它)。SSE 第一帧 `event: metadata` 里也会再给一次。 + +### 6.2 纯文本以外的用户消息 + +图片(模型 `supports_vision` 时): + +```json +{ + "type": "human", + "content": [ + { "type": "text", "text": "看看这张图" }, + { "type": "image_url", "image_url": { "url": "https://…/a.png" } } + ] +} +``` + +附件:先上传(第 15 节),再在 `additional_kwargs.files` 里带虚拟路径。 + +协助卡片的回复:**不要**调 LangGraph `Command(resume)`,当成 **普通新一轮 human 消息** 再 `runs/stream`(见第 12 节)。 + +### 6.3 停止生成 + +只关 EventSource **不够**,服务端可能还在跑。 + +```http +POST {LG}/threads/{thread_id}/runs/{run_id}/cancel?action=interrupt&wait=false +``` + +- 202:已受理;`wait=true` 等到停完返回 204 +- `action=interrupt`:停下,保留当前 checkpoint(可继续聊) +- `action=rollback`:回滚到本轮开始前 + +列出该会话的 run: + +```http +GET {LG}/threads/{thread_id}/runs +GET {LG}/threads/{thread_id}/runs/{run_id} +``` + +### 6.4 断线重连 + +SSE 帧带 `id:`。重连时: + +```http +GET {LG}/threads/{thread_id}/runs/{run_id}/join +Last-Event-ID: <上一帧 id> +``` + +或 POST stream 时带 `stream_resumable: true` 后由 SDK 自动 join。Gateway 重启后 join 会立刻 `event: end`,此时改拉 `/state` 渲染历史即可。 + +心跳是注释行:`: heartbeat`,忽略即可。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端 `useStream` + `thread.submit` | `frontend-web/src/core/threads/hooks.ts`(`useThreadStream`、构造 `messages` / `context`) | +| SDK 包装 | `frontend-web/src/core/api/api-client.ts` | +| stream_mode 规范化 | `frontend-web/src/core/api/stream-mode.ts` | +| 停止:`thread.stop` / `runs.cancel` | `frontend-web/src/core/threads/hooks.ts`(`stopThreadRun`) | +| 输入框停止按钮 | `frontend-web/src/components/workspace/input-box.tsx` | +| 后端 stream / cancel / join | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py`(`RunCreateRequest`、`stream_run`、`cancel_run`、`join_run`) | +| 真正起跑 | `offline-backend-20260512/backend/app/gateway/services.py`(`start_run`) | +| 后台跑图 + 发 SSE | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`run_agent`) | +| Run 生命周期 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` | +| 流式设计说明 | `offline-backend-20260512/backend/docs/STREAMING.md` | + +--- + +## 7. SSE 事件协议(Angular 必须自己解析 POST 流) + +**`EventSource` 只支持 GET,不能用来 POST `/runs/stream`。** +Angular 请用 `fetch` + `ReadableStream`(或封装好的 SSE parser)。不要用 `HttpClient` 的默认 JSON 解析。 + +### 7.1 线格式 + +``` +event: metadata +data: {"run_id":"…","thread_id":"…"} +id: 1 + +event: messages +data: [{...chunk...}, {...meta...}] +id: 2 + +event: values +data: {"title":"…","messages":[...],"artifacts":[],"todos":[]} +id: 3 + +event: custom +data: {"type":"task_running","task_id":"…","message":"…"} +id: 4 + +event: end +data: null +id: 5 +``` + +字段顺序:`event:` → `data:` → `id:` → 空行。`data` 是一行 JSON(`ensure_ascii=false`,中文直接出)。 + +### 7.2 各事件怎么用 + +| `event` | `data` | UI 该做什么 | +|---------|--------|-------------| +| `metadata` | `{ run_id, thread_id }` | 保存 run_id | +| `messages` | `[chunk, meta]` | **token 级增量**:拼 AI 正文、思考、tool_call 参数 | +| `values` | 完整 state | **权威快照**:用 `messages[]` 替换本地列表(标题、todos、artifacts 也在这) | +| `updates` | `{ nodeName: writes }` | 可忽略;或用来知道刚跑完哪个节点 | +| `custom` | 见下表 | toast / 子任务状态 | +| `end` | `null` | 结束本轮,关 loading | +| `error` | 错误对象 | 展示错误 | + +`custom` 常见: + +```json +{ "type": "task_running", "task_id": "…", "message": "正在检索…" } +{ "type": "llm_retry", "message": "模型调用失败,正在重试…" } +{ "type": "context_compacting", "message": "正在压缩上下文…" } +``` + +后两个弹 toast 即可,不要当消息气泡。 + +### 7.3 `event: messages` 的 chunk(最重要) + +`data` 是二元组: + +```json +[ + { + "type": "AIMessageChunk", + "id": "run-abc-0", + "content": "正在", + "additional_kwargs": { + "reasoning_content": "用户想抽取结构,我先读文件…" + }, + "tool_call_chunks": [ + { + "index": 0, + "id": "call_xxx", + "name": "bash", + "args": "{\"description\": \"创建脚本\"" + } + ] + }, + { + "langgraph_node": "model", + "ls_provider": "…", + "ls_model_name": "…" + } +] +``` + +合并规则(按 `chunk.id` 分桶): + +1. **正文**:把每次 `content`(字符串)往后拼;如果是 block 数组,只拼 `type==="text"` 的 `text` +2. **思考**:`additional_kwargs.reasoning_content` 一般是 **累计全文**(以最新一帧为准);若是 delta 则拼接。同时要从正文里剥 `…`(见第 10 节) +3. **工具调用**:按 `tool_call_chunks[].index` 拼 `name` / `args`(args 是 JSON **字符串碎片**,拼完再 `JSON.parse`) +4. **工具结果**:会出现 `type: "tool"` / `ToolMessage`,`tool_call_id` 对上上面的 `id`,`name` 为工具名,`content` 为结果 + +`values.messages` 到来后,用快照覆盖增量状态,避免拼错。 + +### 7.4 Angular 解析骨架 + +```ts +async function streamRun( + lgBase: string, + threadId: string, + body: unknown, + token: string, + onEvent: (event: string, data: unknown) => void, +): Promise { + const res = await fetch(`${lgBase}/threads/${threadId}/runs/stream`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify(body), + }); + if (res.status === 409) throw new Error('当前对话还在生成回答,请稍候再发送'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const location = res.headers.get('Content-Location') || ''; + const runId = location.split('/runs/')[1]; + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop() || ''; + for (const block of parts) { + if (block.startsWith(':')) continue; // heartbeat + let event = 'message'; + let data = ''; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + onEvent(event, data === 'null' ? null : JSON.parse(data)); + } + } + return runId; +} +``` + +**参考代码** + +| 说明 | 路径 | +|------|------| +| SSE 组帧(`event:` / `data:` / `id:`) | `offline-backend-20260512/backend/app/gateway/services.py`(`format_sse`、`sse_consumer`) | +| 心跳 / 订阅 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` | +| 首帧 `event: metadata` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`bridge.publish(..., "metadata", {run_id, thread_id})`) | +| `messages-tuple` → 内部 `messages` | 同上 `run_agent`;SSE 事件名 `_lg_mode_to_sse_event` | +| chunk 序列化成 `[chunk, meta]` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py`(`serialize_messages_tuple` / `serialize_channel_values`) | +| 前端消费:onCustomEvent / onUpdateEvent | `frontend-web/src/core/threads/hooks.ts` | +| 协议说明 | `offline-backend-20260512/backend/docs/STREAMING.md` | + +--- + +## 8. 消息数据结构(历史 + 实时共用) + +`values.messages[]` 每条大致如下(LangChain 序列化后): + +```json +{ + "type": "human | ai | tool", + "id": "uuid-or-run-id", + "name": "ask_clarification | bash | …", + "content": "字符串 或 content block 数组", + "tool_calls": [ + { "id": "call_xxx", "name": "bash", "args": { "description": "执行脚本", "command": "python3 a.py" } } + ], + "tool_call_id": "call_xxx", + "additional_kwargs": { + "reasoning_content": "…", + "clarification": { }, + "files": [ ], + "client_ts": "…", + "hide_from_ui": false + } +} +``` + +### 8.1 `content` 形态 + +| 形态 | 含义 | +|------|------| +| `"一段文本"` | 最常见 | +| `[{ "type": "text", "text": "…" }]` | 多段文本 | +| `[{ "type": "image_url", "image_url": { "url": "…" } }]` | 用户图 / 模型回图 | +| `[{ "type": "thinking", "thinking": "…" }]` | Anthropic 风格思考 | +| `[{ "type": "json", "json": { } }]` | 工具结构化结果 | + +### 8.2 必须隐藏、不要渲染的消息 + +- `additional_kwargs.hide_from_ui === true`(摘要压缩等) +- `name === "summary"` +- `name === "todo_reminder"` +- `type === "system"`(一般不会出现在 UI 列表) + +### 8.3 角色怎么画 + +| `type` | 默认 UI | +|--------|---------| +| `human` | 右侧用户气泡,Markdown 可关,展示附件 chips | +| `ai` 且无 `tool_calls`、有正文 | 左侧助手最终回答(Markdown) | +| `ai` 有 `tool_calls` 或仅有思考 | **不单独当最终气泡**,进入步骤条 | +| `tool` | **几乎不单独成气泡**,并进步骤条 / 协助卡片 | + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 分组、抽文本、抽思考、隐藏消息 | `frontend-web/src/core/messages/utils.ts`(`groupMessages` / `extractContentFromMessage` / `extractReasoningContentFromMessage` / `isHiddenFromUIMessage`) | +| 检索结果识别 | `frontend-web/src/core/messages/search-results.ts` | +| 序列化后的 message dict | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` | + +--- + +## 9. 消息列表如何分组渲染 + +本系统 `groupMessages()` 的规则,Angular 请原样移植,否则步骤条和协助卡片会对不齐。 + +从前往后扫 `messages`(已过滤隐藏消息): + +1. `human` → 一组 `human` +2. `ai` 且 `tool_calls` 含 `present_files` → `assistant:present-files`(正文 + 文件卡片) +3. `ai` 且 `tool_calls` 含 `task` → `assistant:subagent`(子任务卡) +4. `ai` 有思考或有其它 tool_calls → 并入(或新建)`assistant:processing`(**步骤条**) +5. 同一条 `ai` **既有思考又有最终正文、且没有 tool_calls** → processing 一组 **再** 出一组 `assistant` 气泡 +6. `tool` 且 `name === ask_clarification` → 先塞进当前 processing,再单独开 `assistant:clarification` +7. 其它 `tool` → 塞进当前 processing(用 `tool_call_id` 填步骤的 result) + +渲染顺序示例(一轮典型技能执行): + +```text +[用户] 请抽取结构 +[步骤条] + 思考 + 读取文件 SKILL.md + 创建脚本 + 执行脚本 + 写入文件 /mnt/user-data/outputs/flow.json + 展示文件 +[助手] 已生成 flow-json,可在右侧预览。 +``` + +若中途问用户: + +```text +[步骤条] … 需要你的协助 +[协助卡片] 需要你的确认 +(等待用户下一轮 human) +``` + +### 9.1 用户气泡 + +- 文本:`extractContentFromMessage`,去掉 `…` 这类内部标记 +- 附件:`additional_kwargs.files[]` → `{ filename, size, path, status }` +- 图片 block → `` +- 可选:编辑/删除(删除要改 checkpoint,对接初期可不做) + +### 9.2 助手最终回答 + +- Markdown(GFM 表格/代码块) +- 流式时按 token 追加;`values` 快照到达后用完整 `content` 替换 +- 正文里的 `` 必须剥掉(见第 10 节) +- 引用:技能检索结果会在步骤里展开,正文里可能有 `[n]` 引用角标 +- 复制 / 导出 Word 可选 + +### 9.3 流式指示 + +`busy` 且还没有最终 `assistant` 气泡时,在列表底部显示跳动的「正在生成」。 +上传文件未完成时可插一条 mock AI:`additional_kwargs.element === "task"`,文案「文件上传中,请稍候...」。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 列表入口、分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx` | +| 单条用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` | +| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` | +| 气泡/会话滚动容器 | `frontend-web/src/components/ai-elements/message.tsx`、`conversation.tsx` | +| 跳动「正在生成」 | `frontend-web/src/components/workspace/streaming-indicator.tsx` | +| 技能来源展示 | `frontend-web/src/core/skill-display/runtime.ts` | +| 导出 | `frontend-web/src/core/threads/export.ts`;Word:`frontend-web/src/components/workspace/messages/download-word-button.tsx` | +| 删除消息 | `frontend-web/src/components/workspace/messages/delete-message-button.tsx` | +| 问题跳转 | `frontend-web/src/components/workspace/question-navigator.tsx` | + +--- + +## 10. 思考内容如何对接 + +思考 **没有** 独立 SSE 事件名,混在 AI 消息里。按优先级取: + +1. `additional_kwargs.reasoning_content`(字符串,多数内网模型走这条) +2. content block `{ type: "thinking", thinking: "…" }` +3. 正文里的 `…`(含流式 **未闭合** 的 ``) + +### 10.1 剥离算法(请直接用) + +```ts +const THINK_RE = /\s*([\s\S]*?)\s*<\/think>/g; +const THINK_OPEN = ''; + +function splitThink(content: string): { answer: string; thinking: string | null } { + const parts: string[] = []; + let working = content.replace(THINK_RE, (_, t: string) => { + const s = t.trim(); + if (s) parts.push(s); + return ''; + }); + const i = working.indexOf(THINK_OPEN); + if (i !== -1 && !working.slice(i).includes('')) { + const partial = working.slice(i + THINK_OPEN.length).trim(); + if (partial) parts.push(partial); + working = working.slice(0, i); + } + return { answer: working.trim(), thinking: parts.length ? parts.join('\n\n') : null }; +} +``` + +展示用 `answer`,思考区用 `reasoning_content || thinking`。 + +### 10.2 UI 约定(与现网一致) + +- 步骤条里:思考是一种 step,标题 **「思考」**,可折叠,灯泡图标 +- 只有思考、还没有工具、还没有最终正文时:单独一块可折叠「思考」,流式时默认展开,结束后可自动收起 +- **历史刷新后思考仍在**(写在 checkpoint 的 AI 消息上),不要丢 +- 不要把思考当作用户可见的最终答案 + +`thinking_enabled: false` 时模型不应再吐思考;若仍出现 ``,继续剥掉以免露标签。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 抽思考 / 剥 ``(含未闭合) | `frontend-web/src/core/messages/utils.ts`(`extractReasoningContentFromMessage`、`splitInlineReasoning`) | +| 步骤条里的思考步 | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps` 的 `reasoning`) | +| 独立思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` | +| 文案「思考」 | `frontend-web/src/core/i18n/locales/zh-CN.ts`(`t.common.thinking`) | +| 模型思考流字段 | `offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py` | +| 关思考注入 extra_body | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` | + +--- + +## 11. 步骤条(Chain of Thought)— 请做全 + +步骤条只渲染 `assistant:processing` 组。内部只有两类 step: + +- `reasoning`:思考文本 +- `toolCall`:一次工具调用(`task` 子任务除外,走独立卡片) + +### 11.1 从消息抽出 steps + +对组内每条 `ai`: + +1. 若有思考 → push `{ type: "reasoning", reasoning }` +2. 遍历 `tool_calls`,跳过 `name === "task"` +3. 用 `tool_call.id` 在组内找对应 `type==="tool"` 的消息,作为 `result` +4. `result.content` 若是 JSON 字符串就 parse + +UI: + +- 默认只展示 **最后一个工具步骤** + 其后的思考 +- 上方折叠:「查看其他 N 个步骤」/「隐藏步骤」 +- 进行中:该步 status=`active`(转圈);有 result 后 `complete`(对勾) +- 工具参数还在流式拼接时,label 用已有 `args.description` 或占位「正在调用工具…」 + +### 11.2 步骤标题怎么来(「创建脚本 / 执行脚本 / 写入文件」在这里) + +**系统没有名为 `create_script` / `execute_script` 的工具。** +模型调用沙箱工具时 **必须先填 `args.description`**(短中文说明),步骤条 **优先展示 description**。所以你会看到: + +| 用户看到的步骤 | 实际 `tool_calls[].name` | `args` | +|----------------|--------------------------|--------| +| 创建脚本 | `bash` | `{ description: "创建脚本", command: "cat > /tmp/a.py <<'EOF' …" }` | +| 执行脚本 | `bash` | `{ description: "执行脚本", command: "python3 /tmp/a.py" }` | +| 写入文件 | `write_file` 或 `str_replace` | `{ description: "写入文件", path: "/mnt/user-data/outputs/flow.json", content: "…" }` | +| 读取技能说明 | `read_file` | `{ description: "读取技能说明", path: "/mnt/skills/public/…/SKILL.md" }` | + +`description` 是模型写的,文案不固定。没有 description 时再用下表中文底稿。 + +### 11.3 全量工具 → 步骤条映射 + +与现网 `message-group.tsx` + `zh-CN.ts toolCalls` 对齐。未知 MCP/技能脚本一律走最后一行。 + +| `name` | 默认中文标题 | 图标建议 | 展开区 | +|--------|--------------|----------|--------| +| (reasoning step) | 思考 | 灯泡 | Markdown/纯文本思考 | +| `web_search` | `知识库检索 "{query}"`;无 query 则为「知识库检索」 | 搜索 | 结果列表(title/snippet/url),可折叠「N 条检索结果」 | +| 任意工具但 result 长得像检索 `{ results: [...] }` | `搜索 “{query}”` 或「搜索相关信息」 | 搜索 | 同上 | +| 技能展示/RAG 引用模式 | 「检索参考来源」或「获取 N 条结果」 | 搜索 | 来源卡片(title/snippet/原文) | +| `image_search` | `搜索相关图片 “{query}”` | 搜索 | 缩略图网格,点开 `source_url` | +| `web_fetch` | 查看网页 | 地球 | 链接,标题从返回 Markdown 的 `# title` 抽 | +| `ls` | `args.description` 或 **列出文件夹** | 文件夹 | path chip | +| `read_file` | `args.description` 或 **读取文件** | 书本 | path chip | +| `write_file` | `args.description` 或 **写入文件** | 笔记本 | path;点击打开产物预览 | +| `str_replace` | 同上(默认也叫写入文件) | 笔记本 | path | +| `bash` | **必须用 `args.description`**;没有则 **执行命令** | 终端 | 展示 `command` 的 bash 代码块(不一定展示 stdout,stdout 在 tool result 里) | +| `glob` | `args.description` 或 `使用 “glob” 工具` | 扳手 | 可选 path/pattern | +| `grep` | `args.description` 或 `使用 “grep” 工具` | 扳手 | pattern / path | +| `browser_fetch_page` | `args.description` 或 `使用 “browser_fetch_page” 工具` | 扳手 | url | +| `browser_act` | `args.description` 或 `使用 “browser_act” 工具` | 扳手 | 动作说明 | +| `ask_clarification` | **需要你的协助** | 问号 | 空(真正交互在卡片) | +| `write_todos` | **更新 To-do 列表** | 清单 | 空;完整列表见输入框上方 Todo 面板 | +| `present_files` | 一般不进步骤条,进「展示文件」组 | 文件 | 见 13.1 | +| `update_artifact` | `args.title` 或「生成代码」/「生成文档」 | 笔 | 「代码文件」或「Markdown 文档」 | +| `view_image` | `args.description` 或 `使用 “view_image” 工具` | 扳手 | 图片路径 | +| `skill_list` | `args.description` 或 `使用 “skill_list” 工具` | 扳手 | — | +| `skill_view` | 读取某个技能 | 扳手 | 技能名 | +| `search_skills` | 检索可用技能 | 扳手 | 关键词 | +| `memory` / `hindsight_*` | 记忆读写 | 扳手 | — | +| `deep_research_progress` | `args.label` 或「正在撰写研究报告」 | 转圈/对勾 | detail + 可折叠「模型思考」;`status!=="complete"` 为进行中 | +| `task` | **不要**画在步骤条 | — | 见 13.2 子任务卡 | +| **其它 MCP / 自定义工具** | `args.description` **或** `使用 “{name}” 工具` | 扳手 | 有 path/url 可展示 | + +`roundtable-structure` 常走技能 `knowledge-base-ingest`,因此一轮里会密集出现:`read_file`(SKILL.md)→ `bash`(创建/执行脚本)→ `write_file`(产物)→ `present_files`。把 `bash.description` 原样显示,就能覆盖「创建脚本」「执行脚本」。 + +### 11.4 工具参数字段(沙箱类) + +所有沙箱工具的 **第一个参数都是 `description`**: + +```ts +interface BashArgs { description: string; command: string } +interface LsArgs { description: string; path: string } +interface ReadFileArgs { description: string; path: string } +interface WriteFileArgs { description: string; path: string; content: string } +interface StrReplaceArgs { description: string; path: string; old_str: string; new_str: string } +interface GlobArgs { description: string; pattern: string; path?: string } +interface GrepArgs { description: string; pattern: string; path?: string } +interface WebSearchArgs { query: string } +interface WebFetchArgs { url: string } +``` + +`write_file` 流式时 `content` 会变长,可做实时预览(现网点击步骤打开右侧产物面板)。 + +### 11.5 检索结果 result 形状(web_search / 技能) + +常见: + +```json +{ + "success": true, + "query": "台湾滨海防卫", + "count": 10, + "results": [ + { + "title": "…", + "content_preview": "…", + "url": "https://…", + "recUuid": "…", + "score": 0.8 + } + ] +} +``` + +字段名不统一(`content` / `page_content` / `snippet` / `m_title` / `name`),展示时做并集兜底。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| **步骤条主实现(必看)** | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps`、按 `name` 分支、`ArtifactWriteToolCall`) | +| ChainOfThought 组件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` | +| 中文底稿 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls`;类型 `frontend-web/src/core/i18n/locales/types.ts` | +| 工具一句话说明 | `frontend-web/src/core/tools/utils.ts`(`explainToolCall`) | +| 检索结果卡片 | `frontend-web/src/core/messages/search-results.ts` | +| 写文件实时预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` | +| bash / ls / read / write / glob / grep | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py`(参数都要求先填 `description`) | +| 工具装配(含 MCP、内置) | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` | +| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` | +| skill_list / skill_view / search_skills | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py` | +| 启用哪些工具 | `offline-backend-20260512/backend/config.yaml` → `tools[]` | +| 会商页步骤条(对照用,对话页以 message-group 为准) | `frontend-web/src/roundtable-planning/lib/step-display.tsx` | + +--- + +## 12. 用户协助卡片(ask_clarification) + +当模型信息不够、有多种做法、或要确认风险时,会调用工具 `ask_clarification`。 +**ClarificationMiddleware 会截住该工具,写入 ToolMessage,并 `goto=END` 结束本轮。** +这 **不是** LangGraph `interrupt()` / `Command(resume)`。用户点选项后,发 **新的 human 消息** 即可,智能体会带着选择继续。 + +### 12.1 如何识别 + +`message.type === "tool" && message.name === "ask_clarification"` + +结构化数据在: + +```json +{ + "additional_kwargs": { + "clarification": { + "question": "流程图层级按哪套业务?", + "clarification_type": "approach_choice", + "context": "报告里同时出现了任务链和目的链。", + "options": [ + { "id": "option-0", "label": "任务→目的→行为体", "description": "可选" }, + { "id": "option-1", "label": "按报告原标题层级" } + ], + "allow_custom": true, + "allow_multiple": false + } + }, + "content": "(Markdown 兜底文案,无结构化数据时用这个渲染)" +} +``` + +`clarification_type`: + +- `missing_info` 缺信息 +- `ambiguous_requirement` 需求有歧义 +- `approach_choice` 方案选择 +- `risk_confirmation` 风险确认 +- `suggestion` 建议确认 + +同一轮可以有 **多张** 独立卡片(多个 tool call)。只统计 **上一条 human 之后** 的 clarification 为「待答」。 + +### 12.2 UI(与现网文案) + +- 标题:**需要你的确认**;多张时显示 `1/N` +- 展示 `context`、`question` +- 选项做成按钮 +- `allow_multiple !== true`:单选。**仅一张待答卡时,点击即提交**;多张时只记录选择,点最后一张上的「全部发送」 +- `allow_multiple === true`:多选 + 必须点提交 +- `allow_custom !== false`(后端恒为 true):额外文本框 +- 按钮:「发送回答」/ 多张时「全部发送」 +- 历史里已答过的卡:禁用,只读回显 + +步骤条里对应一步标题仍是「需要你的协助」。 + +### 12.3 提交内容格式(必须按这个拼,模型才认) + +单张: + +```text +我选择:任务→目的→行为体 +我选择:A、B;补充:用户打的字 +(只有自定义)用户打的字 +``` + +多张: + +```text +问题1「……」:我选择:A +问题2「……」:补充:…… +``` + +然后: + +```http +POST …/runs/stream +input.messages = [{ type: "human", content: "<上面拼好的文本>" }] +``` + +不要调 `/state` 的 resume,也不要自己造 ToolMessage。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 卡片 UI、拼「我选择:」、多卡合并提交 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`ClarificationCard`、`handleSubmitAllClarifications`) | +| 识别 tool 消息 | `frontend-web/src/core/messages/utils.ts`(`isClarificationToolMessage`) | +| 工具定义 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` | +| 截住工具、写 `additional_kwargs.clarification`、goto END | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py`(`_build_payload`) | +| `allow_multiple` 解析 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` | +| 提交后又走同一套 sendMessage | `frontend-web/src/pages/AgentChatPage.tsx`、`frontend-web/src/core/threads/hooks.ts`(**不是** Command resume) | + +--- + +## 13. 其它卡片与面板 + +### 13.1 展示文件 `present_files` + +AI `tool_calls` 含 `name: "present_files"`,`args.filepaths: string[]`(必须是 `/mnt/user-data/outputs/…`)。 + +UI:Markdown 摘要 + 文件卡片。点击后: + +```http +GET {GATEWAY}/api/threads/{thread_id}/artifacts/{path} +``` + +`path` 例:`mnt/user-data/outputs/flow.json`(虚拟前缀,不要用 Windows 盘符)。 +`?download=true` 强制下载。HTML/SVG 会当附件,降低 XSS。 + +列表: + +```http +GET {GATEWAY}/api/threads/{thread_id}/artifacts +→ { "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] } +``` + +state 里的 `artifacts: string[]` 是虚拟路径列表,可与卡片同步。 + +### 13.2 子任务 `task`(ultra 档) + +`tool_calls[].name === "task"`,`args` 含 `description` / `prompt` / `subagent_type`。 +组类型 `assistant:subagent`,标题「执行 N 个子任务」(N>1 时「并行执行」)。 +`custom` 事件 `task_running` 更新进行中文案。终态看对应 tool 结果。 + +`roundtable-structure` 默认不必开 ultra;对接时可先不做,遇到 `task` 当普通扳手步骤也行。 + +### 13.3 Todo 面板(pro / ultra) + +`write_todos` 出现在步骤条;完整列表在 `values.todos`: + +```ts +interface Todo { content?: string; status?: 'pending' | 'in_progress' | 'completed' } +``` + +现网浮在输入框上方。一轮只应有一个 `in_progress`。 + +### 13.4 创建智能体 / 写作审批卡 + +`setup_agent` / `setup_writing` 带 approval kwargs。普通 `roundtable-structure` 对话 **不会出现**,可忽略。 +Deep Research 的 `setup_research_approval` 同理。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| present_files 分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`assistant:present-files`) | +| 产物 API | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 产物前端 | `frontend-web/src/components/workspace/artifacts/`(如 `artifact-file-detail.tsx`) | +| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` | +| `task` 工具 / 子代理 | `offline-backend-20260512/backend/packages/harness/deerflow/subagents/` | +| Todo 面板 | `frontend-web/src/components/workspace/todo-list.tsx`;类型 `frontend-web/src/core/todos/types.ts` | +| write_todos 中间件装配 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py`(`_build_middlewares`) | + +--- + +## 14. 标题、追问、错误态 + +### 14.1 自动标题 + +第一轮用户消息到达后,TitleMiddleware 先用问题字面做 **临时标题**(`title_provisional: true`),再异步 LLM 生成最终标题。 +听 `event: values` 的 `title` 即可,刷新列表。 + +### 14.2 追问建议(一轮结束后) + +```http +POST {GATEWAY}/api/threads/{thread_id}/suggestions +{ + "messages": [ + { "role": "user", "content": "…" }, + { "role": "assistant", "content": "…" } + ], + "n": 3, + "model_name": "deepseek-chat" +} +``` + +```json +{ "suggestions": ["是否导出 JSON?", "按另一套层级再抽一次"] } +``` + +点建议 = 再发一条 human。失败时返回空数组,不要报错挡 UI。 + +### 14.3 错误与限流 + +| 情况 | 表现 | UI | +|------|------|----| +| 会话已在生成 | HTTP 409 | toast:「当前对话还在生成回答…」,不要红框 | +| 全局并发打满 | HTTP 429 | toast 提示稍后再试 | +| 模型失败重试 | `custom.llm_retry` | 轻提示 | +| 上下文压缩 | `custom.context_compacting` | 「正在压缩上下文…」 | +| 会话不属于当前用户 | 403/404 | 回到新建 | + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 标题展示 | `frontend-web/src/components/workspace/thread-title.tsx` | +| 列表用 title | `frontend-web/src/core/threads/utils.ts`(`titleOfThread`) | +| 临时标题 + LLM 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` | +| 追问 API 调用 | `frontend-web/src/components/workspace/input-box.tsx`(`/api/threads/${threadId}/suggestions`) | +| 追问后端 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` | +| 409 toast | `frontend-web/src/core/threads/hooks.ts`(`isActiveRunConflictError` / `onError`) | +| 压缩 toast | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py`(`custom.context_compacting`) | + +--- + +## 15. 文件上传(用户侧) + +先有 `thread_id`(可先 create 空会话)。 + +```http +POST {GATEWAY}/api/threads/{thread_id}/uploads +Authorization: Bearer +Content-Type: multipart/form-data +``` + +字段名 `files`,可多文件。默认上限约 10 个、单文件 50MB、合计 100MB。 +PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。 + +```json +{ + "success": true, + "message": "Successfully uploaded 1 file(s)", + "files": [ + { + "filename": "report.docx", + "virtual_path": "/mnt/user-data/uploads/report.docx", + "markdown_virtual_path": "/mnt/user-data/uploads/report.md" + } + ], + "skipped_files": [] +} +``` + +发消息时: + +```json +"additional_kwargs": { + "files": [ + { "filename": "report.md", "path": "/mnt/user-data/uploads/report.md", "status": "uploaded" } + ] +} +``` + +限额:`GET /api/threads/{thread_id}/uploads/limits`。 +列表/删除:`GET …/uploads/list`,`DELETE …/uploads/{filename}`。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 前端上传 | `frontend-web/src/components/workspace/input-box.tsx`;预览 `frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx` | +| 后端上传路由 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` | +| 目录与虚拟路径 | `offline-backend-20260512/backend/packages/harness/deerflow/uploads/manager.py` | +| 文档转 Markdown | `offline-backend-20260512/backend/packages/harness/deerflow/utils/file_conversion.py` | +| 说明文档 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` | + +--- + +## 16. 推荐的页面结构(对标 AgentChatPage) + +不必做智能体配置侧栏。最小工作台: + +``` +┌─ 左:会话列表(threads/search by agent_id)─┬─ 中:消息列表 ─┬─ 右:产物预览 ─┐ +│ 新建对话 │ 标题(values.title)│ artifacts │ +│ 每条 title + 时间 │ 用户/步骤条/卡片/回答│ │ +│ │ Todo(可选) │ │ +│ │ 输入框:模型+档位+停止│ │ +└────────────────────────────────────────────────┴─────────────────────┴────────────────┘ +``` + +输入框建议: + +- 模型 Select(`GET /api/models`) +- 档位:快速 / 思考 / 规划(对应 flash / thinking / pro);ultra 可隐藏 +- 发送、停止(streaming 时) +- 可选:上传、追问 chips + +`roundtable-structure` 是「结构化抽取」智能体:给它方案报告或席位交付,产出 flow-json 流程图数据,常伴随写文件 + 展示文件。不要当成多智能体会商编排页(`/roundtable` 是另一套 API)。 + +**参考代码** + +| 说明 | 路径 | +|------|------| +| 对话页整页 | `frontend-web/src/pages/AgentChatPage.tsx` | +| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | +| ChatBox(标题栏 + 列表 + 输入) | `frontend-web/src/components/workspace/chats/chat-box.tsx` | +| 输入框 | `frontend-web/src/components/workspace/input-box.tsx` | +| 左栏最近会话 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx`、`agent-recent-chats.tsx` | +| 欢迎/推荐问题 | `frontend-web/src/components/workspace/agent-welcome.tsx`、`agents/agent-question-chips.tsx` | +| 智能体种子 | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/` | +| 会商编排(不要和本页搞混) | `frontend-web/src/roundtable-planning/`、`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` | +| iframe 嵌现网 UI(备选) | `frontend-web/docs/embed-iframe-chat.md` | + +--- + +## 17. 最小对接顺序(验收清单) + +1. `POST /api/v1/auth/login/username` → 存 JWT +2. CORS 已包含 Angular Origin +3. `GET /api/models` → 下拉,选中项的 `name` 写入 `context.model_name` +4. `POST /api/threads` + `metadata.agent_id=roundtable-structure` +5. `POST /api/threads/{id}/runs/stream`,`assistant_id` 与 `context.agent_id` 都等于 `roundtable-structure` +6. 解析 SSE:`metadata` → `messages` 增量 → `values` 覆盖 → `end` +7. 消息分组:用户气泡 / 步骤条 / 协助卡片 / 最终 Markdown +8. 步骤条至少覆盖:`bash`(description=创建脚本/执行脚本)、`write_file`(写入文件)、`read_file`、`ls`、`web_search`、未知工具 +9. 思考:`reasoning_content` + `` 剥离 +10. 协助卡片:选项 → 拼「我选择:」→ 新一轮 human +11. `POST /threads/search` 还原列表;`GET /state` 还原消息(含思考与步骤) +12. 停止:`POST …/runs/{run_id}/cancel` +13. 409 toast +14. `present_files` → `GET …/artifacts/{path}` + +--- + +## 18. 接口速查 + +Base:`{GATEWAY} = http://host:8001`,LangGraph `{LG} = {GATEWAY}/api`。 +除登录外均需 `Authorization: Bearer`。 + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/v1/auth/login/username` | 用户名登录 | +| POST | `/api/v1/auth/login/token?token=` | 上游 token 换票 | +| GET | `/api/v1/auth/me` | 当前用户 | +| GET | `/api/models` | 模型列表 | +| POST | `/api/threads` | 建会话 | +| POST | `/api/threads/search` | 历史列表 | +| POST | `/api/threads/count` | 历史条数 | +| GET | `/api/threads/{id}` | 会话摘要 + values | +| GET | `/api/threads/{id}/state` | 最新 checkpoint | +| PATCH | `/api/threads/{id}` | 改 metadata | +| DELETE | `/api/threads/{id}` | 删除会话 | +| POST | `/api/threads/{id}/history` | checkpoint 历史 | +| POST | `/api/threads/{id}/runs/stream` | **流式对话** | +| POST | `/api/threads/{id}/runs/{rid}/cancel` | 停止 | +| GET | `/api/threads/{id}/runs` | run 列表 | +| GET | `/api/threads/{id}/runs/{rid}/join` | 重连 SSE | +| POST | `/api/threads/{id}/uploads` | 上传 | +| GET | `/api/threads/{id}/artifacts` | 产物列表 | +| GET | `/api/threads/{id}/artifacts/{path}` | 读产物 | +| POST | `/api/threads/{id}/suggestions` | 追问 | + +可选简化入口(**无登录、不落聊天记录、没有步骤条协议**,不建议用来复刻本页): + +- `POST /api/open/chat` / `/api/open/chat/stream`(`agent_id` 或中文 `agent_name`) + +可选 iframe 嵌现网 UI(不自己画步骤条):见 `frontend-web/docs/embed-iframe-chat.md`。那是通用工作台 `/embed/chats`,不是 `agents/roundtable-structure` 路由;若必须嵌本页,可用: + +``` +{FRONTEND}/#/page/workspace/agents/roundtable-structure/chats/new?embed=1 +``` + +仍要先登录;iframe 跨域时走 `?embed=1` 可隐藏外壳。要完全自绘 UI,请走本文 REST + SSE。 + +--- + +## 19. Angular 工程注意 + +1. **POST SSE 用 fetch**,不要 `HttpClient.get` + EventSource +2. `run_id` 从 `Content-Location` 或 `event: metadata` 取;取消必须打 cancel +3. `assistant_id` 用 **`roundtable-structure`**,不要传「结构化抽取」 +4. 历史过滤靠 `metadata.agent_id`,第一轮后务必 PATCH +5. 同一 thread 未 `end` 不要再发,否则 409 +6. JWT 约 7 天,无 refresh +7. 把 `localhost:4200` 加入 `GATEWAY_CORS_ORIGINS` +8. 可用 npm 包 `@langchain/langgraph-sdk` 的 `Client`(`apiUrl: LG, defaultHeaders: { Authorization }`)少写 SSE 细节;UI 仍要自己做分组/步骤条/卡片 +9. 不要把思考、tool 结果、协助卡当成三条普通聊天记录平铺,观感会和现网差很多 +10. 步骤条 label **永远优先 `args.description`**,这样才能显示「创建脚本」「执行脚本」「写入文件」 + +--- + +## 20. 参考实现总索引 + +路径均相对仓库根 `deerflow-server/`。后端根目录 = `offline-backend-20260512/backend/`。 +各节正文末尾也有对应表;这里按主题汇总,加粗的是对接时优先打开的文件。 + +### 20.1 前端:页面与 UI + +| 主题 | 路径 | +|------|------| +| **智能体对话页** | `frontend-web/src/pages/AgentChatPage.tsx` | +| 路由 `agents/:agent_id/chats/:thread_id` | `frontend-web/src/pages/WorkspaceRoutes.tsx` | +| 对话壳(标题 / 列表 / 输入 / 产物入口) | `frontend-web/src/components/workspace/chats/chat-box.tsx` | +| **消息列表 + 协助卡片** | `frontend-web/src/components/workspace/messages/message-list.tsx` | +| 用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` | +| **步骤条(创建脚本/执行脚本/写入文件等)** | `frontend-web/src/components/workspace/messages/message-group.tsx` | +| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` | +| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` | +| 思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` | +| ChainOfThought 控件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` | +| 输入框(模型、档位、发送、停止、追问、上传) | `frontend-web/src/components/workspace/input-box.tsx` | +| Todo 条 | `frontend-web/src/components/workspace/todo-list.tsx` | +| 会话标题 | `frontend-web/src/components/workspace/thread-title.tsx` | +| 正在生成 | `frontend-web/src/components/workspace/streaming-indicator.tsx` | +| 会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` | +| 该智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` | +| 左栏 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx` | +| 产物预览 | `frontend-web/src/components/workspace/artifacts/` | +| 中文步骤文案 `toolCalls.*` | `frontend-web/src/core/i18n/locales/zh-CN.ts` | + +### 20.2 前端:协议与状态 + +| 主题 | 路径 | +|------|------| +| **发消息 / useStream / cancel / 409** | `frontend-web/src/core/threads/hooks.ts` | +| 线程类型 | `frontend-web/src/core/threads/types.ts` | +| 标题与路由 | `frontend-web/src/core/threads/utils.ts` | +| **消息分组、思考、隐藏** | `frontend-web/src/core/messages/utils.ts` | +| 检索结果归一化 | `frontend-web/src/core/messages/search-results.ts` | +| **登录 API** | `frontend-web/src/core/auth/api.ts` | +| Bearer 存取 | `frontend-web/src/core/auth/index.ts` | +| 模型 API / 类型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` | +| LangGraph Client | `frontend-web/src/core/api/api-client.ts` | +| Gateway fetch + 401 | `frontend-web/src/core/api/fetch-client.ts` | +| Base URL | `frontend-web/src/core/config/index.ts`、`frontend-web/src/env.ts` | +| 写文件流式预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` | +| 工具一句话 | `frontend-web/src/core/tools/utils.ts` | + +### 20.3 后端:HTTP + +| 主题 | 路径 | +|------|------| +| **登录 /me** | `offline-backend-20260512/backend/app/gateway/routers/auth.py` | +| JWT | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` | +| **CORS** | `offline-backend-20260512/backend/app/gateway/app.py` | +| 鉴权中间件 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` | +| **模型列表** | `offline-backend-20260512/backend/app/gateway/routers/models.py` | +| **会话 CRUD / search / state** | `offline-backend-20260512/backend/app/gateway/routers/threads.py` | +| **runs/stream / cancel / join** | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py` | +| start_run、SSE、context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py` | +| 上传 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` | +| 产物 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 追问 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` | +| 简化开放聊天(不要用来复刻本页) | `offline-backend-20260512/backend/app/gateway/routers/open_chat.py` | + +### 20.4 后端:运行时与工具 + +| 主题 | 路径 | +|------|------| +| **跑图 + 发 SSE 事件** | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py` | +| RunManager | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` | +| StreamBridge | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` | +| 消息序列化 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` | +| Lead Agent | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` | +| **协助卡中间件** | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py` | +| 协助工具 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` | +| **bash / write_file 等(description)** | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py` | +| 工具总装 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` | +| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` | +| 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` | +| 模型工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` | + +### 20.5 配置、种子、文档 + +| 主题 | 路径 | +|------|------| +| **roundtable-structure 种子** | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` | +| 同上 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` | +| 模型 / 工具开关 | `offline-backend-20260512/backend/config.yaml` | +| 流式协议 | `offline-backend-20260512/backend/docs/STREAMING.md` | +| 登录契约 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` | +| 上传 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` | +| iframe 嵌入备选 | `frontend-web/docs/embed-iframe-chat.md` | diff --git a/frontend-web/docs/position-roundtable-sandbox/README.md b/frontend-web/docs/position-roundtable-sandbox/README.md new file mode 100644 index 00000000..c3679ba5 --- /dev/null +++ b/frontend-web/docs/position-roundtable-sandbox/README.md @@ -0,0 +1,15 @@ +# 岗位会商沙箱文档 + +本目录存放**岗位会商文件预览沙箱**的对接与排障说明,独立于 `src/position-roundtable/docs/`,避免与模块内其它文档互相覆盖。 + +| 文档 | 说明 | +| --- | --- | +| [沙箱打开关闭与步骤条.md](./沙箱打开关闭与步骤条.md) | **详细版**:打开/关闭完整时序、步骤条关系、对接接口字段说明、必守规则、历史 bug 根因、排障手册、自测清单、接线示例与代码索引 | + +模块总览仍见:`frontend-web/src/position-roundtable/README.md`。 + +建议读法: + +- **从零对接新页面**:正文第 3、5、6、7、8、12、15、16 节 +- **线上排障**:第 13、14 节 +- **只要一页纸**:文末「附录 A」 diff --git a/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md b/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md new file mode 100644 index 00000000..035b5222 --- /dev/null +++ b/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md @@ -0,0 +1,988 @@ +# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南) + +> **文档位置**:`frontend-web/docs/position-roundtable-sandbox/` +> **读者**:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。 +> **最后依据**:当前仓库 `PositionRoundtablePage` + `PositionNodeChat` 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。 + +--- + +## 目录 + +1. [文档用途与读法](#1-文档用途与读法) +2. [背景:为什么岗位会商要单独做沙箱](#2-背景为什么岗位会商要单独做沙箱) +3. [用户可见的正确行为(验收标准)](#3-用户可见的正确行为验收标准) +4. [整体架构与数据流](#4-整体架构与数据流) +5. [职责划分:谁拥有什么状态](#5-职责划分谁拥有什么状态) +6. [对接接口详解](#6-对接接口详解) +7. [打开沙箱的完整步骤](#7-打开沙箱的完整步骤) +8. [关闭沙箱的完整步骤](#8-关闭沙箱的完整步骤) +9. [步骤条与沙箱的关系](#9-步骤条与沙箱的关系) +10. [流式内容如何进入沙箱(性能关键)](#10-流式内容如何进入沙箱性能关键) +11. [与普通对话页的对照](#11-与普通对话页的对照) +12. [重做页面时的必守规则](#12-重做页面时的必守规则) +13. [历史问题与根因(务必读)](#13-历史问题与根因务必读) +14. [排障手册(按症状)](#14-排障手册按症状) +15. [对接自测清单](#15-对接自测清单) +16. [最小接线示例](#16-最小接线示例) +17. [关键代码索引](#17-关键代码索引) +18. [术语表](#18-术语表) + +--- + +## 1. 文档用途与读法 + +### 1.1 这份文档解决什么问题 + +岗位会商右侧「文件预览沙箱」看起来像普通对话页的产物预览,但实现路径完全不同。历史上这块反复出过: + +- 沙箱不实时打开,要等全文写完才弹; +- 打开了但内容冻住,生成完才一口气灌进去; +- 整页卡顿,连消息列表文字都不实时; +- 同轮「重新生成」第二次写入时,步骤条变了,沙箱却不动; +- 第一次正常,关掉后再问就不实时。 + +这些 bug **特别容易在「重做页面 / 换布局 / 自己重写开沙箱逻辑」时复现**。本文把正确对接方式、时序、接口、禁区和排障步骤写全,方便下一位同事直接按文档接线,而不是再靠猜。 + +### 1.2 建议读法 + +| 你的目标 | 先读哪些章节 | +| --- | --- | +| 从零接一个新页面壳 | 第 3、5、6、7、8、12、15、16 节 | +| 只改对话子组件探测逻辑 | 第 7、9、12、13 节 | +| 线上排障 | 第 13、14 节,必要时回看第 10 节 | +| 理解为什么不能用页面 state | 第 2、10、13 节 | + +### 1.3 对照代码(以仓库为准) + +| 角色 | 路径 | +| --- | --- | +| 页面壳(唯一沙箱宿主) | `frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx` | +| 节点对话(探测 write_file、上报预览) | `frontend-web/src/position-roundtable/components/PositionNodeChat.tsx` | +| 收口对话 | `frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx` | +| 预览 UI(共享组件,勿分叉逻辑) | `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` | +| 流式正文读取 | `frontend-web/src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` | +| 普通对话页对照(步骤条自动开) | `frontend-web/src/components/workspace/messages/message-group.tsx` | +| 模块总览 | `frontend-web/src/position-roundtable/README.md` | + +--- + +## 2. 背景:为什么岗位会商要单独做沙箱 + +### 2.1 普通对话页怎么开沙箱 + +普通对话页(`ChatPage`)大致是: + +```text +ArtifactsProvider(页面级) + └─ ChatBox / MessageList + └─ message-group 渲染 write_file 步骤 + └─ 发现 path 且 autoOpen → artifacts.select + setOpen(true) + └─ 右侧 ArtifactFileDetail 读同一套 ThreadContext +``` + +要点: + +- 对话和预览共享同一套 `ThreadContext`(同一个 `useThreadStream`)。 +- 步骤条组件自己就能触发打开。 +- 流式 `content` 本来就在当前 React 树的 thread 里,不需要「把 thread 镜像到别的地方」。 + +### 2.2 岗位会商为什么不能照搬 + +岗位会商页面同时有: + +- 左侧任务 / 意图 / 流程图; +- 中间某个节点的对话(`PositionNodeChat`); +- 右侧配置抽屉; +- **以及一个页面级单例沙箱**,它是对话区的**兄弟节点**,不在 `PositionNodeChat` 的 ThreadContext 里面。 + +因此: + +1. 沙箱拿不到节点对话内部的 `thread`,除非节点主动上报; +2. 若把每次流式 thread 写进**页面级 `useState`**,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆; +3. 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自 `setOpen`。 + +所以岗位会商采用: + +```text +对话子组件探测 + 上报 + → 页面壳统一打开唯一 ArtifactFileDetail + → 流式 thread 走外部 store,只让沙箱子树订阅 +``` + +### 2.3 和「多智能体会商 Step2 沙箱」的区别 + +仓库里还有 `frontend-web/docs/roundtable-step2-sandbox-progress.md`,那是另一套圆桌 Step2 沙箱进度文档,**不是**岗位会商这条链路。对接岗位会商时以本文为准。 + +--- + +## 3. 用户可见的正确行为(验收标准) + +重做或对接完成后,下列行为必须全部成立(用户感知对齐普通对话页): + +| # | 行为 | 说明 | +| --- | --- | --- | +| A | 步骤条刚出现「写入文件」时,沙箱应已打开 | **不必等**步骤条上出现文件名 | +| B | 沙箱源码边写边滚;消息列表也实时出字 | 不能整页卡死、不能「生成完一口气刷」 | +| C | content 先于 path 时:先临时名打开,path 到了再改名 | 不闪屏、不重挂 | +| D | 同轮第二次同路径 `write_file`(重新生成) | 沙箱清空并重新流式 | +| E | 整轮结束后自动切 Markdown 预览 | 「正在生成,完成后自动预览」消失 | +| F | 用户手动关闭后,同一次写入不再自动弹 | 下一轮新写入仍可自动开 | +| G | 切岗位 / 切节点 / 新会话 / 切历史 | 沙箱关掉且不串内容 | + +**不要误判的正常现象:** + +- 流式期间沙箱停在「源码 / 编辑」视图,结束后才切「预览」——这是 `ArtifactFileDetail` 对 markdown/html 的既定行为,与普通对话页相同,**不是**打开时机 bug。 +- 步骤条长时间显示「写入文件」、没有路径,但沙箱已经打开并在滚动——content-first 模型下这是**预期**。 + +--- + +## 4. 整体架构与数据流 + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ PositionRoundtablePage(页面壳) │ +│ │ +│ state: sandboxArtifact | null │ +│ ref: sandboxArtifactRef / closedSandboxKeyRef / latestSnapshotRef │ +│ store: createSandboxThreadStore() ←── 禁止改成 useState │ +│ │ +│ handlePreviewArtifact(artifact) ←──────────────────────────────────┐ │ +│ handleNodeThreadSnapshot(...) ←───────────────────────────────┐ │ │ +│ closeSandbox() │ │ │ +│ │ │ │ +│ {sandboxOpen && ( │ │ │ +│ SandboxThreadProvider(store) │ │ │ +│ └─ ArtifactFileDetail key=sandboxCloseKey(...) │ │ │ +│ )} │ │ │ +└───────────────────────────────────────────────────────────────────│──│───┘ + │ │ +┌───────────────────────────────────────────────────────────────────│──│───┐ +│ PositionNodeChat / PositionBuiltinAgentChat │ │ │ +│ │ │ │ +│ useThreadStream → thread.messages / thread.isLoading │ │ │ +│ │ │ │ │ +│ ├─ MessageList → 步骤条(只展示 tool call) │ │ │ +│ │ │ │ │ +│ ├─ latestStreamingWriteFile(messages) │ │ │ +│ │ → onPreviewArtifact({..., auto:true}) ───────────────────┘ │ +│ │ │ +│ └─ useEffect → onThreadSnapshot({threadId, thread}) ───────────────┘ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +一句话: + +- **谁打开**:对话子组件决定「该不该开、开哪次交付」; +- **谁真正开面板**:页面壳; +- **内容怎么更新**:子组件持续上报 thread 快照 → 页面壳 store → 只有沙箱子树重渲染。 + +--- + +## 5. 职责划分:谁拥有什么状态 + +### 5.1 页面壳必须拥有 + +| 状态 / 能力 | 为什么必须在页面壳 | +| --- | --- | +| `sandboxArtifact` | 唯一预览目标;切节点时要清 | +| `closedSandboxKeyRef` | 用户关过后的抑制;跨子组件生命周期 | +| `sandboxThreadStore` | 流式内容通道;不能进大页面 state | +| `closeSandbox()` | 切岗位 / 切节点 / 新会话统一入口 | +| 唯一 `ArtifactFileDetail` | 避免双实例、关闭失效、深度异常 | + +### 5.2 对话子组件必须拥有 + +| 状态 / 能力 | 作用 | +| --- | --- | +| `autoPreviewRunArmedRef`(armed) | 只有本轮用户发送后才允许自动开 | +| `autoPreviewBaselineCallKeysRef`(baseline) | 忽略发送前已有的历史 write_file | +| `activeWriteFilePreviewRef` | 记录当前打开的 previewKey / path / toolCallId | +| `autoPreviewedArtifactKeyRef` | 同一次 identity 去重,避免每 chunk 重复上报 | +| `latestStreamingWriteFile` | 从 streaming messages 探测可预览的 write | +| `onThreadSnapshot` 上报 | 把 thread 喂给页面壳 | + +### 5.3 明确禁止 + +1. 在 `PositionNodeChat` 内再挂一套 `ArtifactFileDetail`。 +2. 让步骤条组件直接 `setOpen(true)` 作为岗位会商主路径(对照普通页可以,岗位会商不行)。 +3. 把 `onThreadSnapshot` 落到 `PositionRoundtablePage` 的 `useState`。 +4. 用「整个 threadId」做关闭抑制。 +5. 用「path 相同」复用 `previewKey`。 + +--- + +## 6. 对接接口详解 + +### 6.1 `PositionArtifactPreview` + +```ts +type PositionArtifactPreview = { + threadId: string; // 必填:LangGraph thread id + path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名路径 + name: string; // 展示名(文件名) + mimeType: string | null; // 如 text/markdown;帮助预览语言判断 + previewKey?: string; // 强烈建议:一次交付身份,推荐 `${messageId}:${toolCallId}` + auto?: boolean; // true = 流式自动打开;缺省/false = 用户点击打开 +}; +``` + +字段说明: + +| 字段 | 详细含义 | +| --- | --- | +| `threadId` | 沙箱用来匹配 `onThreadSnapshot` 的线程;也对关闭抑制键有贡献 | +| `path` | 传给 `ArtifactFileDetail.filepath`。流式阶段通常是 `write-file:...` 虚拟 URL;落盘后的手动打开可以是真实 artifacts 路径 | +| `name` | UI 展示用;临时名阶段可能是 `未命名文件.md` | +| `mimeType` | 当 path 还不稳定时,帮助把语言判成 markdown/html,避免落成 TEXT | +| `previewKey` | **一次交付的身份**。同一次 tool call 的 path 升级要保持不变;新的 tool call 必须变 | +| `auto` | 自动打开才走关闭抑制;手动打开应清抑制 | + +### 6.2 `onPreviewArtifact(artifact)` —— 对话 → 页面 + +页面壳 `handlePreviewArtifact` 当前逻辑顺序: + +1. 校验 `threadId`、`path`,缺则 toast 并 return。 +2. 计算 `artifactKey = sandboxCloseKey(artifact)`。 +3. 若 `artifact.auto === true` 且 `closedSandboxKeyRef.current === artifactKey` → **抑制,直接 return**。 +4. 清 `closedSandboxKeyRef`(手动打开或新的自动打开)。 +5. `ArtifactsContext.select(path, true)` + `setOpen(true)`。 +6. 判断是否同一预览 / 是否仅 path upgrade: + - `isSamePreview`:threadId + path + previewKey 全同 → 不重复 setState。 + - `isPathUpgrade`:previewKey 相同、path 不同 → 更新 artifact,**不要**再折叠右侧抽屉。 +7. 若不是 same preview: + - `setSandboxArtifact(artifact)`; + - **同步** `sandboxArtifactRef.current = artifact`(关键!flush 会立刻读 ref); + - `sandboxSnapshotAppliedAtRef.current = 0`(允许立刻推首帧内容)。 +8. 若 store 里已有别的 thread 快照,先 `publish(null)`。 +9. 若 `latestNodeThreadSnapshotRef` 的 threadId 匹配,立刻 `queueSandboxThreadSnapshot`。 +10. 非 path upgrade 时 `setRightPanelOpen(false)`。 + +`sandboxCloseKey`: + +```ts +if (artifact.previewKey) { + return `${artifact.threadId}:write:${artifact.previewKey}`; +} +return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`; +``` + +`ArtifactFileDetail` 的 React `key` 必须用这个 key: +**新 previewKey → 组件重挂 → 视觉上「清空再生成」。** + +### 6.3 `onThreadSnapshot({ threadId, thread })` —— 对话 → 页面 + +每次 `thread` 引用变化(消息 chunk 到达)子组件都会调: + +```ts +onThreadSnapshot?.({ threadId: activeThreadId, thread }); +``` + +页面壳应: + +1. 计算 `signature = sandboxThreadSignature(thread)`(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。 +2. 写入 `latestNodeThreadSnapshotRef`(即使沙箱还没开,打开瞬间也要用)。 +3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → **只更新 ref,不 publish**。 +4. 若一致 → `queueSandboxThreadSnapshot`(前沿节流后 `store.publish`)。 + +### 6.4 虚拟 URL 与正文读取 + +流式阶段 path 形如: + +```text +write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown +``` + +`loadArtifactContentFromToolCall`: + +1. 解析 URL 的 `message_id`、`tool_call_id`; +2. 在 `thread.messages` 里找对应 AI message 的 tool call; +3. 返回 `toolCall.args.content`。 + +结论: + +- 沙箱跟的是 **tool call 身份**,不是磁盘文件是否已经写完; +- 没有真实 path 也能显示正文; +- 临时文件名只影响展示名和语言猜测。 + +### 6.5 对话子组件 → 页面壳的其它相关回调 + +| 回调 | 用途 | +| --- | --- | +| `onPreviewArtifact` | 打开 / 更新沙箱 | +| `onThreadSnapshot` | 推送流式 thread | +| 页面壳 `closeSandbox` 传给 `ArtifactFileDetail.onClose` | 用户点关闭 | + +手动从流程图 / 产物列表打开时,也可以直接调 `handlePreviewArtifact`(通常不带 `auto`,或不带 previewKey)。 + +--- + +## 7. 打开沙箱的完整步骤 + +### 7.1 武装(arm):只有「本轮用户提交」才允许自动开 + +在 `PositionNodeChat.handleSubmit` 真正 `sendMessage` 之前: + +```text +activeWriteFilePreviewRef = null +autoPreviewBaselineCallKeysRef = writeFileCallKeys(thread.messages) // 历史 write 的 messageId:toolCallId +autoPreviewRunArmedRef = true +→ sendMessage(...) +``` + +发送失败要把 `armed` 设回 `false`。 + +为什么要 baseline? + +线程里往往已经有上几次问答写过的 `write_file`。如果只看「messages 里有没有 write_file」,一提问就会把旧文件再打开一次。baseline 的含义是:**只有这次提交之后新出现的 tool call 才能自动开沙箱**。 + +### 7.2 探测 `write_file`:不要等完整 path + +`write_file` 参数是一整段 JSON 流,**键顺序由模型决定**。 + +| 模型吐参顺序 | 步骤条表现 | 错误做法的后果 | +| --- | --- | --- | +| path / description 先到,再 content | 很快显示描述和文件名 | 普通对话页常见;等 path 也能开 | +| **content 先到,最后才 path** | 长时间只有「写入文件」,文件名很晚才出现 | 若硬等完整 path,沙箱会晚几十秒甚至一分钟 | + +岗位会商报告类 agent **经常是第二种**。 + +`latestStreamingWriteFile` 规则: + +1. 倒序扫描 AI messages 的 `tool_calls`; +2. 工具名是 `write_file` 或 `str_replace`; +3. **有完整扩展名的 path**(`hasCompleteArtifactExtension`)→ 用真实路径; +4. **尚无可用 path,但 `write_file` 的 content 已有非空字符** → 临时文件名: + - 正文头像 HTML → `未命名文件.html` + - 否则 → `未命名文件.md` +5. `str_replace` 必须等真实 path(它改已有文件); +6. 组装: + - `key = messageId:toolCallId` + - `path = write-file:虚拟URL`(write_file)或真实 path(str_replace) + - `mimeType`:markdown 则 `text/markdown` + +为什么扩展名必须完整? + +流式 path 会经历 `/a/b/report.m` → `/a/b/report.md`。若在 `.m` 时就当真实文件打开,预览语言可能被永久判成 TEXT。 + +### 7.3 上报 effect 的四个门闩 + +自动上报 `onPreviewArtifact` 前必须同时满足: + +1. `thread.isLoading === true` +2. 探测到 streaming artifact,且其 key **不在** baseline +3. 已有 `previewThreadId` +4. `autoPreviewRunArmedRef === true` + +然后处理 previewKey: + +```text +若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容) + → 复用旧 previewKey // 用于临时名 → 真实 path +否则 + → previewKey = 新的 messageId:toolCallId +``` + +去重键: + +```text +`${previewThreadId}:${previewKey}:${sourcePath}` +``` + +把 `sourcePath` 算进去,是为了:**同 previewKey 下临时名变成真实 path 时,还要再报一次**(path upgrade),但不会每来一个 content chunk 都报。 + +### 7.4 线程绑定竞态(不要误 disarm) + +首轮对话常出现: + +1. 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id); +2. 流已经开始、armed=true; +3. 会话 API 把 `node.threadId` 从 `null` 写成真实 id。 + +这是「本节点绑定自己的新线程」,**不是**切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。 + +正确:识别 `isOwnThreadBinding`(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 **直接 return,不清 armed**。 + +### 7.5 path upgrade(改名)≠ 第二次交付 + +同一次 tool call: + +```text +T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开 +T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名 +``` + +页面壳看到相同 previewKey、不同 path → `isPathUpgrade`: + +- 更新 `sandboxArtifact`; +- **不**重挂(React key 仍基于 previewKey); +- **不**再折叠右侧抽屉。 + +### 7.6 同路径二次写入(重新生成) + +同一次问答里模型可能: + +```text +1) write_file → 步骤条「写入文件」→ 打开沙箱 +2) 再 write_file 同 path,description=「重新生成优化版…」 +``` + +这是**两次 tool call**,必须两个 previewKey。 +若错误地「path 相同就复用 previewKey」: + +- 步骤条出现第二条; +- 沙箱 React key 不变; +- 用户看到「重新生成了,但沙箱没变化」。 + +正确:身份只看 tool call。 + +### 7.7 时序总图(content-first) + +```text +T0 用户发送 → armed=true,拍 baseline +T1 步骤条出现「写入文件」(尚无 path) +T1+ args.content 首字符到达 → 临时名打开沙箱,源码开始滚动 + … store 约每 160ms 推一次 thread 快照 … +T2 args.path 拼完整(含 .md)→ path upgrade,标题改真实文件名(不重挂) +T3 write 结束 / isLoading=false → 自动切 Markdown 预览 +``` + +### 7.8 时序总图(同轮二次写入) + +```text +T1 write_file#1 → previewKey=msg:tc1 → 打开沙箱 A +T2 #1 结束(可能短暂切预览) +T3 write_file#2(同 path,新 description)→ previewKey=msg:tc2 + → key 变 → 组件重挂清空 → 跟 #2 重新流式 +``` + +--- + +## 8. 关闭沙箱的完整步骤 + +### 8.1 用户手动关闭 + +`closeSandbox()` 应做: + +1. `closedSandboxKeyRef = sandboxCloseKey(current)` —— 抑制**这一次**自动再开; +2. 关闭 `ArtifactsContext.open`(若开着); +3. 清节流定时器、pending 快照; +4. `sandboxSnapshotAppliedAtRef = 0`; +5. `sandboxArtifactRef = null` + `setSandboxArtifact(null)`; +6. `sandboxThreadStore.publish(null)`。 + +抑制与 path upgrade 的关系: + +- 关掉临时名面板后,同一次 tool call 升级真实 path **仍被抑制**(同一 previewKey)——符合「用户不要看这次」; +- 下一次新的 `write_file`(新 previewKey)**可以**再自动开。 + +### 8.2 场景切换时强制关闭 + +以下操作应先调用 `closeSandbox()`,避免串台: + +- 切岗位; +- 切节点; +- 切收口阶段; +- 新建会话; +- 切换历史会话。 + +是否写入 `closedSandboxKeyRef` 可按产品决定;至少必须清面板和 store。 + +### 8.3 错误的抑制粒度(历史坑) + +若做成「关过一次就整个 thread 不再自动开」: + +- 第一次实时开正常; +- 用户关掉; +- 再问一次 → 同 thread 被永久抑制 → 看起来像「第二次不实时 / 要等生成完」。 + +正确粒度:**单次交付的 `sandboxCloseKey`(含 previewKey)**。 + +--- + +## 9. 步骤条与沙箱的关系 + +### 9.1 步骤条从哪来 + +步骤条由 `MessageList` → `message-group.tsx` 渲染 tool call: + +- 标题:优先 `args.description`;没有则 i18n 兜底「写入文件」; +- 子行:有 `args.path` 才显示路径; +- 普通对话页还会在「有 path + autoOpen」时自己 `select/setOpen`。 + +岗位会商里,步骤条**仍然只负责展示**;打开沙箱走 `onPreviewArtifact`,不要让两套逻辑抢。 + +### 9.2 对照表 + +| 你看到的步骤条 | 含义 | 沙箱应处于 | +| --- | --- | --- | +| 仅「写入文件」,无路径 | tool call 已开始;path/description 可能还没流到 | **应已打开**(临时名),源码在滚 | +| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 | +| 带自定义 description 的标题 + 路径 | 模型给了 description | 同上 | +| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 | +| 顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 | +| 生成结束,出现文件卡片 / present_files | 工具结果已落 | 通常已是预览模式;可手动再点开 | + +### 9.3 常见误解 + +**误解 1:**「步骤条出现文件名 = 打开沙箱的正确时机」 +→ 错。那是 path 到达的时机。content-first 时正确打开时机更早:content 首字节 / 工具调用已开始。 + +**误解 2:**「步骤条还在写『写入文件』,沙箱却开了,是 bug」 +→ 通常不是。两边数据源相同,但沙箱允许无 path 打开,步骤条标题可能还没 description。 + +**误解 3:**「等生成完从编辑切预览 = 沙箱打开晚了」 +→ 错。那是 `ArtifactFileDetail` 的 viewMode 策略。 + +--- + +## 10. 流式内容如何进入沙箱(性能关键) + +### 10.1 为什么不能用页面 `useState` 存线程快照 + +错误写法: + +```ts +setSandboxThreadSnapshot(snapshot) // 页面级 useState +``` + +后果: + +- 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒; +- 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……; +- 主线程占满 → 消息列表停绘、沙箱冻住; +- 纯尾随 `setTimeout(160)` 会被饿死,十几秒不 flush。 + +这同时解释了两个历史现象: + +1. 「沙箱打开了但内容不更新」; +2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。 + +### 10.2 正确做法:外部 store + 子树订阅 + +```ts +createSandboxThreadStore() + subscribe / getSnapshot / publish + +SandboxThreadProvider + useSyncExternalStore(store.subscribe, store.getSnapshot) + → ThreadContext.Provider value={{ thread, isMock:false }} + → ArtifactFileDetail +``` + +页面壳本身**不**因 snapshot 变化而重渲染;只有 Provider 及其子树重渲染。 + +### 10.3 节流必须是前沿(leading-edge) + +`SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160`: + +```text +queue(snapshot): + pending = snapshot + if 距上次 publish >= 160ms: + 立刻 flush(同步 publish) + else: + 若没有在途 timer,则 setTimeout(剩余时间) 做补尾 +flush: + 清 timer + 校验 pending.threadId === sandboxArtifactRef.threadId + publish(pending) +``` + +**不要**改回: + +```ts +if (timer != null) return; +timer = setTimeout(flush, 160); +``` + +那是纯尾随;忙主线程下会假死。 + +### 10.4 signature 去重 + +`sandboxThreadSignature` 用最近 write_file 的 path、content 长度、content 尾 32 字等拼签名。 +store.publish 时若 threadId+signature 未变,则不通知订阅者,减少无效渲染。 + +### 10.5 打开瞬间的首帧 + +`handlePreviewArtifact` 里往往会立刻 `queueSandboxThreadSnapshot(latestSnapshot)`。 +此时 React 的 `setSandboxArtifact` 可能还没提交,若 flush 只读旧的 `sandboxArtifactRef`,会因 threadId 不匹配把首帧丢掉。 + +所以打开新预览时必须: + +```ts +sandboxArtifactRef.current = artifact; // 同步 +sandboxSnapshotAppliedAtRef.current = 0; +``` + +--- + +## 11. 与普通对话页的对照 + +| 点 | 普通对话页 | 岗位会商 | +| --- | --- | --- | +| 谁触发打开 | `message-group` 内 `artifacts.select` | 子组件 `onPreviewArtifact` → 页面壳 | +| 是否要求 path | 是(有 path 才自动开) | 否(write_file + content 可临时名开) | +| thread 从哪来 | 同树 ThreadContext | `onThreadSnapshot` + 外部 store | +| 关闭抑制 | 主要靠 ArtifactsContext / 用户操作 | `closedSandboxKeyRef` 按 previewKey | +| 同 path 重写 | 依赖新的 tool call / 选择逻辑 | **必须**新 previewKey 以重挂 | +| 结束后切预览 | ArtifactFileDetail 同一套 | 同一套 | + +对齐目标是**用户感知一致**,不是代码路径完全相同。 + +普通对话页自动开的核心条件(对照用): + +```ts +artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result +``` + +它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。 + +--- + +## 12. 重做页面时的必守规则 + +按优先级: + +1. **流式 thread 快照禁止进页面级 `useState`** → 用 store + `useSyncExternalStore`。 +2. **节流用前沿 + 补尾**,不要纯尾随 timer。 +3. **不要等完整 path 才开**;`write_file` + content 即可临时名打开。 +4. **扩展名完整前不要把 path 当最终 path**。 +5. **previewKey 按 tool call,不按 path**。 +6. **打开时同步写 `sandboxArtifactRef`,并重置节流时间戳**。 +7. **主路径扫描 `thread.messages`,不要只靠 `onToolStart`**。 +8. **path upgrade 保持 previewKey,不要当第二次交付**。 +9. **关闭抑制按 sandboxCloseKey,不要按整 thread**。 +10. **沙箱单例在页面壳;子组件只上报**。 +11. **本节点 threadId 回写不要 disarm**。 +12. **「结束后才切预览」不要当打开 bug 去「修」掉共享组件**,除非产品明确要求边生成边渲染 Markdown(需单独评估性能)。 + +--- + +## 13. 历史问题与根因(务必读) + +下面每个都在真实联调里出现过。重做时若踩中,优先回来对照。 + +### 13.1 沙箱要等文件名出来才开(晚几十秒) + +**现象:** 步骤条早就「写入文件」,沙箱很晚才开;普通对话页对比显得「实时」。 + +**根因:** 探测逻辑硬等完整 `path`;而该 agent 先流 `content`、最后才流 `path`。 + +**修复:** content 首批字符即可用临时名打开;path 到了再 upgrade。 + +### 13.2 沙箱打开了,内容冻住,生成完才灌满 + +**现象:** 面板开了,但里面一直是空/旧内容;日志里 queue 很多,flush 很少。 + +**根因:** 纯尾随 160ms 定时器在忙主线程下被饿死。 + +**修复:** 前沿节流——到期就在流回调里同步 flush。 + +### 13.3 修完「内容更新」后,整页卡顿、消息也不实时 + +**现象:** 沙箱开始更新了,但页面更卡,消息列表也「攒到最后刷」。 + +**根因:** flush 走的是页面 `useState`,每 160ms 整页重渲染。以前 timer 饿死时反而「不卡」。 + +**修复:** 快照改外部 store,只让沙箱子树订阅。 + +### 13.4 第二次「重新生成」步骤有了,沙箱不变 + +**现象:** 同轮第二条二条 write 步骤,沙箱仍显示第一次完整结果。 + +**根因:** 用「path 相同」复用了第一次 previewKey,React key 不变,组件不重挂。 + +**修复:** previewKey 只按 tool call;同 path 新 call 必须新 key。 + +### 13.5 第一次实时,关掉后再问就不实时 + +**现象:** 关过沙箱后,下一轮要等生成完才开,或不开。 + +**根因:** 抑制绑在整个 threadId,或把 path upgrade / 新 call 误判成同一次。 + +**修复:** 抑制键 = `sandboxCloseKey(previewKey)`;仅抑制那一次交付。 + +### 13.6 首轮永远不开 + +**现象:** 新建节点第一次提问,armed 后很快又不满足条件。 + +**根因:** `node.threadId` 从 null 回写真实 id 时,绑定 effect 误 disarm。 + +**修复:** `isOwnThreadBinding` 时不清 armed。 + +### 13.7 预览变成纯文本 + +**现象:** 明明是 md,却按 TEXT 打开。 + +**根因:** 在 `.m` 阶段就当完整 path;或临时名没有 `.md` / 没传 language、mimeType;或 path 带空格。 + +**修复:** `hasCompleteArtifactExtension`;临时名带扩展名;URL 加 `language=markdown`;path trim。 + +### 13.8 依赖 onToolStart,线上偶发不开 + +**现象:** 本地偶发正常,联调经常不开。 + +**根因:** 后端 StreamBridge / worker 可能过滤 tools/events 类 stream mode。 + +**修复:** 主路径必须是 messages 里渐进出现的 `tool_calls`;onToolStart 只能当补充。 + +--- + +## 14. 排障手册(按症状) + +排障时建议先看 Chrome Performance / React 重渲染是否整页抖,再看消息里 tool_calls 的 args 是 path-first 还是 content-first。 + +### 14.1 沙箱开得很晚 + +检查顺序: + +1. `latestStreamingWriteFile` 是否仍要求完整 path? +2. armed 是否被 threadId 回写清掉? +3. 是否命中 `closedSandboxKey`? +4. 新 call 是否被 baseline 当成历史? +5. `previewThreadId` 是否为空(线程还没绑上)? + +### 14.2 打开了但内容空白 / 冻住 + +1. 快照是否进了页面 useState? +2. 节流是否纯尾随? +3. `SandboxThreadProvider` 是否拿到带 messages 的 thread? +4. 虚拟 URL 的 message_id / tool_call_id 是否对得上? +5. 打开时 `sandboxArtifactRef` 是否同步?(首帧是否被 drop) +6. signature 是否因实现错误一直不变,导致 publish 被去重? + +### 14.3 整页卡、消息列表也不实时 + +1. 搜页面壳是否还有 `setSandboxThreadSnapshot` / 类似大对象 state。 +2. 是否在每 chunk `console.debug` 打大对象(DevTools 打开时更卡)。 +3. 是否错误地让页面壳订阅了 store(页面壳不应 `useSyncExternalStore` 这份 store;只有 Provider 订)。 + +### 14.4 重新生成不刷新 + +1. 第二次 write 的 toolCallId 是否变了? +2. previewKey 是否仍用 path 复用? +3. `ArtifactFileDetail` 的 `key=` 是否基于 sandboxCloseKey/previewKey? + +### 14.5 关后再问异常 + +1. 打印 `closedSandboxKeyRef` 与新 artifact 的 sandboxCloseKey。 +2. 确认新一轮 send 时 armed/baseline 已重置。 +3. 确认抑制不是整 thread。 + +### 14.6 语言 / 预览模式不对 + +1. 看打开时的 path / mimeType / language 查询参数。 +2. 确认不是在扩展名不完整时打开。 +3. 确认「源码→预览」发生在 isLoading false 之后(属正常)。 + +--- + +## 15. 对接自测清单 + +重做页面后至少跑完: + +### 功能 + +- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字? +- [ ] content-first agent:无文件名阶段是否也能开? +- [ ] path 到达后:是否只改名、不整页闪、不重挂? +- [ ] 同轮第二次同路径 write:是否清空并重新流式? +- [ ] 结束后是否自动切 Markdown 预览? +- [ ] 手动点步骤条路径 / 产物卡片:能否打开正确文件? + +### 关闭与抑制 + +- [ ] 流式中手动关闭:本次是否不再自动弹? +- [ ] 关闭后再发一轮新问题:是否仍可自动开? +- [ ] path upgrade 是否不会绕过「用户刚关闭」的抑制? + +### 场景切换 + +- [ ] 切节点:沙箱关闭,旧内容不残留。 +- [ ] 切岗位:同上。 +- [ ] 新建会话 / 切历史:同上。 + +### 性能 + +- [ ] 流式中消息列表是否实时出字? +- [ ] 页面是否明显掉帧 / 卡死? +- [ ] React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应) + +--- + +## 16. 最小接线示例 + +### 16.1 页面壳(示意) + +```tsx +const [sandboxArtifact, setSandboxArtifact] = useState(null); +const sandboxArtifactRef = useRef(null); +const closedSandboxKeyRef = useRef(null); +const latestNodeThreadSnapshotRef = useRef(null); +const store = useMemo(createSandboxThreadStore, []); +// pending + timer + appliedAt 用于前沿节流(见 PositionRoundtablePage) + +function handlePreviewArtifact(artifact: PositionArtifactPreview) { + if (!artifact.threadId || !artifact.path) return; + const key = sandboxCloseKey(artifact); + if (artifact.auto && closedSandboxKeyRef.current === key) return; + closedSandboxKeyRef.current = null; + + artifactsCtx?.select(artifact.path, true); + artifactsCtx?.setOpen(true); + + const prev = sandboxArtifactRef.current; + const isSame = + prev?.threadId === artifact.threadId && + prev.path === artifact.path && + prev.previewKey === artifact.previewKey; + const isPathUpgrade = + !isSame && + Boolean(artifact.previewKey) && + prev?.threadId === artifact.threadId && + prev.previewKey === artifact.previewKey; + + if (!isSame) { + sandboxArtifactRef.current = artifact; // 必须同步 + setSandboxArtifact(artifact); + sandboxSnapshotAppliedAtRef.current = 0; + } + + if (store.getSnapshot()?.threadId !== artifact.threadId) { + store.publish(null); + } + const latest = latestNodeThreadSnapshotRef.current; + if (latest?.threadId === artifact.threadId) { + queueSandboxThreadSnapshot(latest); + } + if (!isPathUpgrade) setRightPanelOpen(false); +} + +function handleNodeThreadSnapshot(snapshot: { threadId: string; thread: BaseStream }) { + const next = { ...snapshot, signature: sandboxThreadSignature(snapshot.thread) }; + latestNodeThreadSnapshotRef.current = next; + if (sandboxArtifactRef.current?.threadId !== snapshot.threadId) return; + queueSandboxThreadSnapshot(next); +} + +function closeSandbox() { + if (sandboxArtifactRef.current) { + closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current); + } + // clear timer / pending / appliedAt + sandboxArtifactRef.current = null; + setSandboxArtifact(null); + store.publish(null); + artifactsCtx?.setOpen(false); +} + +// 渲染 +{sandboxArtifact && ( + + + +)} +``` + +### 16.2 对话子组件(示意) + +```tsx +// send 前 +activeWriteFilePreviewRef.current = null; +autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages); +autoPreviewRunArmedRef.current = true; +await sendMessage(...); + +// 探测 +const streaming = thread.isLoading ? latestStreamingWriteFile(thread.messages) : null; +const next = + streaming && !autoPreviewBaselineCallKeysRef.current.has(streaming.key) + ? streaming + : null; + +useEffect(() => { + if (!thread.isLoading || !next || !previewThreadId || !autoPreviewRunArmedRef.current) return; + + // previewKey:同 tool call 复用,否则用 next.key + // artifactKey = threadId:previewKey:sourcePath 去重 + onPreviewArtifact?.({ + threadId: previewThreadId, + path: next.path, + name: next.name, + mimeType: next.mimeType, + previewKey, + auto: true, + }); +}, [next, previewThreadId, thread.isLoading, onPreviewArtifact]); + +useEffect(() => { + onThreadSnapshot?.({ threadId: activeThreadId, thread }); +}, [activeThreadId, thread, onThreadSnapshot]); +``` + +**强烈建议:** 探测 / armed / baseline / 临时名逻辑直接复用或抽取自 `PositionNodeChat`,不要从零重写。 + +--- + +## 17. 关键代码索引 + +| 主题 | 位置 | +| --- | --- | +| 临时名 / 完整扩展名 / latestStreamingWriteFile | `PositionNodeChat.tsx` 中 `provisionalWriteFilePath`、`hasCompleteArtifactExtension`、`latestStreamingWriteFile` | +| armed / baseline / 上报 effect | `PositionNodeChat.tsx` `handleSubmit` + streaming preview `useEffect` | +| 同 tool call 判断 / 二次写入重挂 | 同上,`isSameToolCall` / `previewKey` | +| 线程绑定保护 | `PositionNodeChat.tsx` node binding `useEffect` 中 `isOwnThreadBinding` | +| sandboxCloseKey / store / 前沿节流 | `PositionRoundtablePage.tsx` `sandboxCloseKey`、`createSandboxThreadStore`、`queueSandboxThreadSnapshot` | +| 打开 / path upgrade / 抑制 | `PositionRoundtablePage.tsx` `handlePreviewArtifact` | +| 关闭 | `PositionRoundtablePage.tsx` `closeSandbox` | +| 虚拟 URL 读 content | `core/artifacts/loader.ts` `loadArtifactContentFromToolCall` | +| 源码/预览切换 | `artifact-file-detail.tsx` 中 `viewMode` + `thread.isLoading` | +| 普通页步骤条自动开 | `message-group.tsx` `write_file` 分支 | + +相关提交(背景): + +- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` + — 临时名打开、外部 store、前沿节流 +- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` + — previewKey 按 tool call,禁止按 path 复用 + +--- + +## 18. 术语表 + +| 术语 | 含义 | +| --- | --- | +| armed | 本轮用户发送后,允许自动打开沙箱的开关 | +| baseline | send 前已有的 write tool call 集合,用于忽略历史产物 | +| previewKey | 一次交付的稳定身份,推荐 `messageId:toolCallId` | +| sandboxCloseKey | 关闭抑制键,同时用作 `ArtifactFileDetail` 的 React key | +| provisional path / 临时名 | 尚无真实 path 时的 `未命名文件.md` / `.html` | +| path upgrade | 同 previewKey 下临时名升级为真实 path | +| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 | +| write-file: URL | 用 message_id + tool_call_id 定位流式 content 的虚拟路径 | +| leading-edge throttle / 前沿节流 | 到期立即在回调里 publish,而不是只挂尾随 timer | +| content-first / path-first | 模型流式 JSON 参数时 content 与 path 哪个先到 | +| isOwnThreadBinding | 本节点把自己的新 threadId 写回 props,不是切节点 | + +--- + +## 附录 A:给对接同学的一页纸摘要 + +1. 页面壳只挂一个沙箱;子组件只调用 `onPreviewArtifact` / `onThreadSnapshot`。 +2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。 +3. `write_file` 可以没有 path:临时名打开,path 到了再改名。 +4. 流式 thread 进外部 store,进页面 state 会卡死。 +5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。 +6. 同路径重新生成 = 新 previewKey = 沙箱重挂。 +7. 出问题先对照第 13、14 节,再看代码索引第 17 节。 diff --git a/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md new file mode 100644 index 00000000..8d929308 --- /dev/null +++ b/frontend-web/docs/skill-knowledge-and-assistant-knowledge.md @@ -0,0 +1,7 @@ +# 技能知识归纳与“知识梳理”助手知识库 + +管理员在「技能管理 → 技能知识归纳」中选择一个或多个技能、一个或多个目标,系统按“技能 × 目标”创建后台任务。界面提供总体进度、单项失败重试、实体/关系审核、绑定重归纳、自动同步和两种解绑动作。技能列表通过一次 bindings 查询展示归纳目标,避免逐技能请求。 + +扫描仅处理知识资料。代码、脚本、锁文件、密钥文件以及 Markdown 代码块不会进入上传、摘要、提示词或知识指纹;ZIP 只读取逐项验证通过的知识成员。自动监听只比较该知识指纹,所以纯代码改动不会产生 stale 状态。 + +知识库页面新增「普通知识库 / 助手知识库」并行入口。管理员可将系统默认入口设为 v1(普通 WeKnora)或 v2(全局“知识梳理”)。助手库无需 WeKnora 即可初始化,所有用户可只读查看和搜索 Wiki;管理员可上传本地文件、从普通库导入、重新导入或删除来源、查看版本并回滚。来源删除只移除对应贡献,共享实体与关系继续保留。 diff --git a/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md new file mode 100644 index 00000000..c2f22081 --- /dev/null +++ b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md @@ -0,0 +1,2543 @@ +# 技能归纳、WeKnora 普通知识库与助手知识库:实现设计 + +本文档用于指导大模型或开发者实现知识库体系改造。整体包含两条线:一条是“将一个或多个技能归纳到一个 WeKnora Wiki 普通知识库”;另一条是新增自研助手知识库体系。助手知识库像普通知识库一样可以创建多个库,同时系统内置一个全局大库,用于统计全库数据、承接所有 WeKnora 导入沉淀、做全局实体对齐、Wiki 展示和导入审计。 + +本文档中,技能归纳目标只能是 WeKnora Wiki 普通知识库;助手知识库不接收技能直接归纳,也不接收任意本地文件。普通知识库负责向量化、检索、Wiki 和图谱;助手知识库负责自研存储、WeKnora 导入、实体对齐、Wiki 展示、搜索和导入审计。无论 WeKnora 数据导入到哪个助手知识库,最终都必须沉淀一份到系统全局大库。 + +> 最终约束:如果本文历史章节与本节冲突,以本节和“2. 当前边界”中的约束为准。 + +## 1. 背景与目标 + +当前系统已有: + +- 技能管理:后台可管理运行中的技能,技能文件位于 `skills/public`、`skills/custom`。 +- 技能地址管理:已经在管理员技能管理页中以标签页形式接入,支持扫描技能内 `.md`、`.py` 中的地址并批量替换。 +- WeKnora / LLM Wiki 接入:前后端已有知识库、文档、Wiki 页面、Wiki 图谱等 API。 + +本次要新增一条管理员工作流: + +管理员在“技能管理”页面选择多个技能,再选择一个目标 WeKnora Wiki 普通知识库,点击归纳。系统将每个技能作为独立来源读取、解析、抽取和归纳,把结果写入对应 WeKnora 知识库,并在技能列表和技能详情中展示“该技能已被哪些知识库归纳”。如果需要归纳到多个普通知识库,通过多次操作完成。 + +用户已确认的业务约束: + +- 一次操作可以选择多个技能,但只能选择一个目标 WeKnora Wiki 普通知识库。 +- 同一个技能可以在不同时间归纳到多个知识库,所以最终关系是技能与知识库多对多。 +- 多知识库归纳通过多次操作完成;每次操作形成一批 `技能 x 单目标知识库` 任务。 +- V1 只支持归纳到可写 WeKnora Wiki 普通知识库;不支持非 Wiki、FAQ、系统会话存档、只读知识库作为技能归纳目标。 +- 技能归纳时只抽取数据、业务说明、实体、概念、对象、事件、关系等知识;Python、TypeScript、Shell 等代码和脚本类文件完全排除,不读取注释、不抽取函数、不上传、不进入 prompt。 +- 技能里的 Markdown、业务数据文件、表格、PDF、Office、图片等要纳入处理范围。 +- 需要同步三类内容:原始资料、归纳后的 Wiki、实体与关系。 +- 关系来源包括 Markdown 自然语言描述、目录和文件引用、模型自动推断。 +- 允许模型推断关系,但前端不提供逐条实体/关系预览审核。 +- 支持管理员手动点击“重新归纳”;不新增自动文件监听、定时后台同步或技能变更自动远程写入。 +- 解除关联时弹窗让用户选择:保留知识库内容,或删除该技能贡献的内容。 +- 权限仅管理员。 +- 总技能数约 200 个,需要支持批量进度、后台执行、失败重试。 +- 同一目标知识库内,不同技能抽取出的同一实体需要合并。 +- 重归纳时先生成新版本,成功后再替换旧版本;失败时旧版本继续可用。 +- 新增知识库类型区分:普通知识库是 WeKnora,助手知识库是自研存储。 +- 助手知识库支持创建多个库;其中有且只有一个系统全局大库,建议命名为“知识梳理总库”,所有用户可见。 +- 助手知识库只提供 Wiki 页面展示、搜索和数据导入;不落到 WeKnora。 +- 助手知识库数据来源只能是 WeKnora 导出包或从某个 WeKnora 知识库触发导入。 +- WeKnora 已向量化和处理完成的 Wiki、文档、实体、关系、图谱数据默认沉淀到助手知识库;如果导入的是某个自建助手库,也必须同步沉淀到全局大库。 +- 普通知识库需要提供导出或“导入到助手知识库”的功能,把 WeKnora 里已经处理好的数据导入指定助手知识库;导出数据需要包含 Wiki、文档、chunk、实体、关系、图谱。 +- 即使没有配置 WeKnora 地址,也要默认展示助手知识库列表和创建入口,而不是展示空的普通知识库页面。 +- 管理员可切换知识库产品版本:`v1` / `v2`,默认 `v1`。 +- 助手知识库普通用户只读,管理员可导入、编辑、删除、查看版本和回滚 Wiki 页面。 + +## 2. 当前边界 + +以下能力明确不做,开发实现和验收必须按这些边界收敛: + +- 不支持一次选择多个目标知识库。多知识库归纳通过多次操作完成。 +- 不支持非 Wiki 知识库、FAQ 知识库、系统会话存档知识库、只读知识库作为技能归纳目标。 +- 不直接写 WeKnora 的 Neo4j 或底层图数据库。 +- 不新增自动文件监听、定时后台同步或技能变更自动远程写入。 +- 不在前端提供逐条实体/关系预览审核。 +- 不把远端 WeKnora 内部 ID 暴露为前端业务主键。 +- 不支持技能直接归纳到助手知识库。技能先进入 WeKnora 普通知识库,再由 WeKnora 导出沉淀到助手知识库。 +- 不支持管理员向助手知识库导入任意本地文件;手动导入也必须是 WeKnora 导出包或从某个 WeKnora 知识库触发。 +- 助手知识库 V1 不做页面版本管理,只保留当前合并后的 Wiki 状态和导入审计记录。 + +## 3. 术语 + +| 术语 | 含义 | +| --- | --- | +| 技能 | DeerFlow 技能目录,通常包含 `SKILL.md`、代码、配置、示例、文档、数据文件等。 | +| 目标知识库 | 管理员选择的一个 WeKnora Wiki 普通知识库。助手知识库不是技能归纳目标。 | +| 普通知识库 | WeKnora 知识库,负责向量化、Wiki、文档检索和图谱能力。 | +| 助手知识库 | DeerFlow 自研知识库类型,支持创建多个库,用于 Wiki 展示、搜索、WeKnora 导入和实体对齐。 | +| 知识梳理总库 | 助手知识库体系中的系统全局大库,有且只有一个,自动统计和沉淀所有助手库与普通知识库导入数据,做全局实体对齐。 | +| 自建助手库 | 管理员创建的普通助手知识库,类似普通知识库列表中的一个库,可导入 WeKnora 普通知识库导出数据。 | +| 产品版本 | 知识库产品形态开关。`v1` 默认为普通知识库体验,`v2` 默认为助手知识库体验。 | +| WeKnora 导出包 | 从普通知识库导出的已处理知识集合,包含 Wiki 页面、文档、chunk、实体、关系、图谱等。 | +| 绑定 | 一个技能与一个目标知识库之间的持久关联。`skill_name + target_type + target_id` 唯一。 | +| 快照 | 某次扫描技能目录得到的内容版本,包含文件清单、摘要、抽取结果和源 digest。 | +| 贡献 | 某个技能快照对目标知识库内实体、关系、Wiki 页面、原始资料的增量内容。 | +| 规范实体 | 在同一个目标知识库内合并后的实体。多个技能可以贡献到同一个实体。 | +| 规范关系 | 在同一个目标知识库内合并后的关系。多个技能可以给同一关系提供证据。 | +| 归纳任务 | 一次管理员发起的批量操作,可包含多个目标知识库和多个技能 item。 | +| item | 归纳任务中的单个技能处理单元。 | + +## 4. 总体方案 + +整体采用“双知识库形态 + 本地持久绑定 + 后台任务 + WeKnora 投影 + 助手底库实体对齐”的架构。 + +```mermaid +flowchart LR + A[管理员选择多个技能] --> B[选择一个 WeKnora Wiki 目标知识库] + B --> C[创建归纳任务] + C --> D[逐技能扫描与解析] + D --> E[生成技能快照] + E --> F[LLM 归纳 Wiki 与关系抽取] + F --> G[按目标 WeKnora Wiki 合并实体/关系] + G --> H[写入 WeKnora 原始资料与 Wiki] + G --> I[写入关系投影文档] + H --> K[校验远端写入] + I --> K + K --> L[本地原子激活新版本] + L --> M[技能页展示已归纳知识库] + K --> N[WeKnora 导出已处理数据] + N --> O[导入指定助手知识库] + O --> P[同步沉淀到知识梳理总库] + P --> Q[全局实体对齐] + Q --> S[总库 Wiki 展示与搜索] +``` + +关键原则: + +- DeerFlow 本地数据库保存权威绑定关系、版本状态、来源贡献和远端 artifact 映射。 +- WeKnora 保存可检索知识内容,包括原文、Wiki 页面、FAQ、会话存档、关系投影文档和图谱。 +- 不直接写 WeKnora 底层图数据库;关系以 WeKnora API 能力和关系投影文档承接。 +- 重归纳使用 staged artifact,远端写入和校验成功后,本地才激活新版本。 +- 删除或解除关联只影响该技能在目标知识库中的贡献,不误删其他技能共享的实体和关系。 +- 助手知识库可以创建多个库;系统全局大库只有一个,负责汇总全库数据和做跨库实体对齐。 +- WeKnora 的已处理结果可以导入指定助手知识库;无论是否指定自建助手库,都必须同步沉淀到知识梳理总库。 +- 未配置 WeKnora 地址时,默认进入助手知识库页面,展示助手知识库列表、创建入口和知识梳理总库;与 WeKnora 相关的导出导入动作置灰或隐藏。 + +## 5. 现有代码落点 + +实现前必须先读仓库说明: + +- `D:/cmzs-clean/deerflow-code/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/CLAUDE.md` + +建议改动位置: + +| 模块 | 建议文件 | +| --- | --- | +| 后端 router | `offline-backend-20260512/backend/app/gateway/routers/skill_knowledge.py` | +| 后台任务调度 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_dispatcher.py` | +| 后台任务执行 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_executor.py` | +| 技能归纳核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/` | +| 持久化模型 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/` | +| WeKnora 客户端扩展 | `offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py` | +| 助手知识库核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/` | +| 助手知识库持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/` | +| 助手知识库 Gateway | `offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py` | +| WeKnora 导出到助手库任务 | `offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py` | +| 前端 API | `frontend-web/src/strategy-components/api/skill-knowledge.ts` | +| 助手知识库前端 API | `frontend-web/src/strategy-components/api/assistant-knowledge.ts` | +| 管理员技能页 | `frontend-web/src/pages/AdminSkillsPage.tsx` | +| 技能行组件 | `frontend-web/src/components/workspace/settings/skill-settings-page.tsx` | +| WeKnora API hooks | `frontend-web/src/strategy-components/api/llmwiki.ts` | +| 知识库产品版本设置 | `frontend-web/src/core/system-settings/api.ts` | + +注意边界: + +- `packages/harness/deerflow/*` 不能 import `app.*`。 +- 路由、鉴权、FastAPI app state 等放在 `app/gateway/*`。 +- 纯扫描、解析、抽取、合并、投影计划生成放在 `deerflow.*`。 +- 文件 IO、Office/PDF 转换、CPU 密集处理必须通过 `asyncio.to_thread`,避免阻塞事件循环。 + +## 6. WeKnora 能力使用策略 + +现有 WeKnora 客户端已支持: + +- 创建知识库。 +- 上传文件。 +- 导入 URL。 +- 创建手动文档。 +- 创建、更新、删除、查询 Wiki 页面。 +- 获取 Wiki 图谱。 + +V1 需要新增或补齐: + +- 目标知识库能力探测:是否启用 Wiki,是否可写,是否支持 GraphRAG / extract_config。 +- 目标知识库分类和能力标识:Wiki、非 Wiki、FAQ、会话存档、只读、图谱可写。 +- 导出已处理数据的能力:Wiki 页面、文档、chunk、实体、关系、图谱。 +- WeKnora 处理完成后的自动沉淀触发点:在文件向量化、Wiki 生成和图谱抽取完成后,创建“导入助手知识库”的后台任务。 +- 如果 WeKnora 当前实例 Swagger 暴露了图谱相关配置更新接口,则在目标知识库启用 `graph_enabled` 与 `extract_config`。 +- 如果未暴露稳定的实体/关系写入 API,优先使用“关系投影文档 + Wiki 关系页 + Wiki 链接”让 WeKnora 自己抽取图谱;在配置显式开启时,可通过受控 Neo4j direct writer 写入底层图数据库。 + +参考官方资料: + +- [WeKnora Knowledge Graph 文档](https://github.com/Tencent/WeKnora/blob/main/docs/KnowledgeGraph.md) +- [WeKnora knowledge-base API 文档](https://github.com/Tencent/WeKnora/blob/main/docs/api/knowledge-base.md) +- [WeKnora QA 文档](https://github.com/Tencent/WeKnora/blob/main/docs/QA.md) +- [WeKnora API 文档入口](https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md) + +实现要求: + +- 以部署实例的 Swagger 为准,不要硬编码未确认的远端 API。 +- 可以封装 `probe_capabilities()`,返回 `wiki_enabled`、`graph_enabled_supported`、`extract_config_supported`、`wiki_page_upsert_supported`、`file_upload_supported` 等能力。 +- GraphRAG 失败不能导致 Wiki 归纳整体回滚。Wiki 与原始资料成功、GraphRAG 写入失败时,绑定状态为 `partial_failed`,错误信息明确写“图谱投影失败,可重新归纳重试”。 +- 如果 WeKnora 不支持直接导出向量、chunk 或图谱原始结构,导出层应退化为读取 Wiki 页面、文档状态、Wiki graph 和可查询的图谱摘要;助手知识库不强依赖复用 WeKnora embedding 向量。 + +### 6.1 目标知识库适配策略 + +一次归纳任务允许选择多个技能和多个目标知识库。后端不要把它当成一个大事务,而是展开为矩阵: + +```text +items = selected_skills x selected_targets +``` + +每个 item 对应一个 `binding`,一个技能归纳到三个目标知识库,就生成三个 binding。父 job 负责展示整体进度,item 独立成功、失败、取消和重试。 + +当目标是自建助手库时,item 写入顺序是: + +```text +写入目标助手库 -> 生成目标库 Wiki/实体/关系 -> 创建总库沉淀任务 -> 总库实体对齐和统计 +``` + +总库沉淀失败不能让目标助手库写入结果消失,但 item 必须标记为 `partial_failed`,并允许单独重试“沉淀到总库”。 + +目标知识库需要通过 adapter 写入: + +| 目标类型 | 写入策略 | Wiki | 关系/图谱 | +| --- | --- | --- | --- | +| WeKnora Wiki 知识库 | 上传原始资料、upsert Wiki 页面、写 graph projection | 写 WeKnora Wiki | WeKnora GraphRAG + 可选 Neo4j direct writer | +| WeKnora 非 Wiki 知识库 | 上传原始资料和归纳文档,不强制生成 Wiki | 不写 WeKnora Wiki,可写本地 sidecar Wiki | graph projection 文档 + 可选 Neo4j direct writer | +| WeKnora FAQ 知识库 | 把归纳结果转换为 FAQ 条目、来源文档和关系摘要 | 不写或只写 sidecar Wiki | 关系作为 FAQ metadata、projection 文档和图谱边 | +| 系统会话存档知识库 | 把技能归纳结果作为系统会话归档条目写入 | sidecar Wiki 展示 | 关系作为归档 metadata 和图谱边 | +| 只读 WeKnora 知识库 | 不修改远端原库;写本地 sidecar artifact,并生成待推送状态 | sidecar Wiki 展示 | sidecar graph + 待推送 Neo4j plan | +| 助手知识库 | 写入指定助手库、实体对齐、生成 Wiki 页面版本;同时异步沉淀到知识梳理总库 | 写自研 Wiki | 写自研实体/关系表;总库做跨库实体对齐 | + +只读目标说明: + +- “支持只读知识库作为目标”不代表绕过远端权限强行写入。 +- 对只读 WeKnora,系统创建本地 sidecar 投影:归纳结果与该远端知识库绑定展示,但远端写入状态为 `pending_remote_write`。 +- 当管理员后续授予可写权限时,可执行“推送到远端”,把 sidecar staged artifact 写入 WeKnora。 +- 前端必须清楚展示“只读目标:已本地归纳,未写入远端”。 + +### 6.2 Neo4j 直接写入设计 + +需求确认需要支持直接写 WeKnora 的 Neo4j 或底层图数据库。该能力风险较高,必须作为受控能力实现。 + +必须满足: + +- 通过配置显式开启,例如 `weknora.graph.direct_write_enabled = true`。 +- 只允许管理员触发。 +- Neo4j 连接串、账号、库名只允许后端配置,不允许前端传入。 +- LLM 不得生成任意 Cypher 并直接执行。 +- graph writer 只能执行代码中预定义的参数化模板。 +- 所有写入使用事务。 +- 每个节点和边都带稳定业务 key、来源 binding、snapshot、job、confidence 和 evidence。 +- 支持 dry-run plan、写入审计、失败重试、回滚计划。 + +节点 key 建议: + +```text +provider = deerflow +scope = target_type + target_id +entity_key = entity_type + normalized_name 或 external_id +``` + +边 key 建议: + +```text +source_entity_key + predicate + target_entity_key + contribution_scope +``` + +Neo4j 写入只作为图谱层加速和展示,不替代本地 contribution 表。本地 contribution 表仍是删除、回滚、重建、审计的权威来源。 + +### 6.3 实体/关系预览审核 + +前端必须提供逐条实体/关系预览审核能力。审核发生在“抽取完成、写入目标前”。 + +审核对象: + +- 实体。 +- 关系。 +- 实体对齐候选。 +- 关系合并候选。 +- Wiki 页面草稿。 + +管理员操作: + +- 批准。 +- 拒绝。 +- 编辑名称、类型、谓词、别名、摘要、confidence。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 + +审核模式建议: + +| 模式 | 说明 | +| --- | --- | +| `required` | 所有实体/关系必须审核后才能写入。 | +| `auto_high_confidence` | 高置信自动通过,低置信进入审核队列。 | +| `off` | 不阻塞写入,但仍保留预览页面和事后审计。 | + +默认建议 `auto_high_confidence`。如果用户希望“必须逐条审核”,可在系统设置中改为 `required`。 + +审核记录必须入库: + +- 操作人。 +- 操作时间。 +- 原始抽取值。 +- 修改后值。 +- 通过或拒绝原因。 +- 对应 job/item/binding/snapshot。 + +### 6.4 自动同步设计 + +需要支持三类自动同步: + +- 文件监听:技能知识文件变化后自动创建归纳任务。 +- 定时同步:按管理员配置的周期重新扫描并归纳。 +- 技能变更自动远程写入:技能保存、启停、导入后触发相关 binding 同步。 + +自动同步仍需遵守: + +- 只处理非代码知识文件。 +- 代码文件变化不触发归纳。 +- 自动任务必须 debounced,避免保存过程中连续触发。 +- 自动任务必须走 staged 版本,成功后激活,失败保留旧版本。 +- 同一 binding 同一时间只能有一个 active item。 +- 审核模式为 `required` 时,自动任务停在 `awaiting_review`,不写远端。 + +建议新增配置: + +```json +{ + "skill_knowledge_auto_sync": { + "watch_enabled": true, + "schedule_enabled": true, + "schedule_cron": "0 */6 * * *", + "debounce_seconds": 120, + "write_remote_enabled": true + } +} +``` + +### 6.5 远端 ID 暴露与业务 key + +需求确认前端需要暴露 WeKnora 远端 ID,并可作为跨系统业务 key 使用。 + +实现要求: + +- API 返回 `remote_refs`,包含 provider、remote kind、remote id、slug、url。 +- 前端可以展示、复制、筛选远端 ID。 +- 与外部系统交互时,可使用 `provider + remote_id` 作为业务 key。 +- 本地数据库仍保留 `id` 作为 surrogate primary key,避免远端 ID 变更导致本地关系断裂。 + +建议响应结构: + +```json +{ + "id": "local-artifact-id", + "business_key": "weknora:wiki_page:remote-page-id", + "remote_refs": [ + { + "provider": "weknora", + "remote_kind": "wiki_page", + "remote_id": "remote-page-id", + "slug": "skills/example/overview", + "url": "/llmwiki/kb/xxx/wiki/skills/example/overview" + } + ] +} +``` + +不要把远端 ID 当作唯一存储来源;本地仍需要维护 mapping、审计和状态。 + +## 7. 数据处理范围 + +归纳一个技能时,扫描技能根目录下所有有效的非代码知识文件。代码类文件不作为知识源,也不影响知识 digest。 + +必须处理: + +| 类型 | 策略 | +| --- | --- | +| `.md` / `.mdx` / `.txt` | 直接读取,保留标题、段落、链接、表格和自然语言说明;代码块内容默认跳过,只保留“此处存在示例代码”的弱提示。 | +| `.json` / `.yaml` / `.yml` / `.toml` / `.ini` | 仅当它们承载业务数据、数据字典、枚举、模板或知识配置时处理;运行配置、依赖配置、工具入口、工程配置不入库。 | +| `.csv` / `.tsv` | 结构化读取,抽样概要 + 全量原始资料上传。 | +| `.xlsx` / `.xls` | 使用已有文件转换或表格解析能力,保留 sheet、列名、样例和统计。 | +| `.pdf` / `.docx` / `.pptx` | 复用现有 `deerflow.utils.file_conversion` 或 MarkItDown 路径转文本,同时上传原始文件。 | +| 图片 | 上传原始文件;如果已有 VLM/OCR 能力则抽取图片描述,否则记录为二进制附件。 | +| 压缩包 | 安全解压到临时目录后递归处理,必须限制大小、文件数、路径穿越、符号链接。 | +| 其他二进制 | 记录 manifest 并尝试作为原始附件上传;无法解析时不生成文本摘要。 | + +必须排除: + +- `.py` +- `.ts` +- `.tsx` +- `.js` +- `.jsx` +- `.sh` +- `.bat` +- `.ps1` +- 其他脚本、源码、构建脚本、依赖锁文件 +- `.git` +- `node_modules` +- `__pycache__` +- `.pytest_cache` +- `dist` +- `build` +- 临时缓存目录 +- 明确生成物和大体积模型文件 + +必须阻断: + +- `.env` +- 私钥、公钥私钥对、证书私钥 +- token、password、secret、access key 等高置信密钥文件 + +阻断规则是安全底线:发现疑似密钥时,该文件不能上传到 WeKnora,也不能进入 LLM prompt。任务 item 标记为 `partial_failed` 或 `failed`,前端展示“因安全策略跳过敏感文件”,并给出文件路径,不输出密钥内容。 + +代码排除规则: + +- 不读取源码内容。 +- 不抽取源码注释。 +- 不抽取函数、类、import、依赖、入口、调用链。 +- 不上传源码文件。 +- 不把源码路径作为 Wiki 知识展示;最多在管理员任务详情中显示“已跳过代码类文件数量”。 +- 计算 `source_digest` 时只包含知识文件;纯代码变更不应导致技能归纳状态变为 stale。 + +## 8. 中间表示 IR + +扫描和抽取后生成统一 IR,后续 Wiki 生成、实体合并、远端投影都基于 IR,而不是直接从文件内容临时拼装。 + +建议结构: + +```json +{ + "skill": { + "name": "example-skill", + "display_name": "示例技能", + "description": "从 SKILL.md 和元数据提取", + "source_root": "skills/public/example-skill", + "source_digest": "sha256:..." + }, + "files": [ + { + "path": "SKILL.md", + "kind": "markdown", + "sha256": "...", + "size": 12345, + "text_digest": "sha256:...", + "title": "Example Skill", + "summary": "文件摘要", + "anchors": [ + { + "id": "heading-install", + "line_start": 12, + "line_end": 36, + "text_digest": "sha256:..." + } + ], + "references": [ + { + "target_path": "data/schema.yaml", + "evidence": "Markdown link or import", + "confidence": 1.0 + } + ], + "safety": { + "status": "allowed", + "warnings": [] + } + } + ], + "entities": [ + { + "local_id": "ent_001", + "type": "tool", + "name": "web_search", + "aliases": ["网络搜索"], + "description": "用于检索外部信息的工具", + "attributes": { + "entrypoint": "config.yaml tools.web_search" + }, + "evidence": [ + { + "file_path": "config.yaml", + "line_start": 10, + "line_end": 20, + "quote": "短证据,不包含敏感信息" + } + ], + "confidence": 0.94 + } + ], + "relations": [ + { + "local_id": "rel_001", + "source_local_id": "ent_001", + "predicate": "used_by", + "target_local_id": "ent_002", + "description": "web_search 被示例技能用于首步检索", + "relation_source": "llm_inferred", + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 22, + "line_end": 28, + "quote": "短证据" + } + ], + "confidence": 0.88 + } + ], + "wiki": { + "skill_page": { + "slug": "skills/example-skill/overview", + "title": "示例技能", + "content": "Markdown Wiki 内容" + }, + "pages": [] + } +} +``` + +IR 需要版本化: + +- `extractor_version`:扫描、解析、提示词和抽取 schema 的整体版本。 +- `prompt_hash`:LLM 抽取提示词 hash。 +- `model_name`:抽取模型名。 +- `source_digest`:技能目录内容 digest。 + +只要 `source_digest + extractor_version + prompt_hash + model_name` 相同,就可以复用已有 snapshot,避免 200 个技能批量时反复抽取。 + +## 9. 关系抽取规则 + +关系来源分三类: + +| 来源 | 示例 | 置信度策略 | +| --- | --- | --- | +| 确定性结构关系 | 目录包含文件、Markdown 链接、数据文件引用、业务配置引用 | `confidence = 1.0` | +| 显式文本关系 | Markdown 中写明“本技能依赖 A”、“输出 B”、“调用接口 C” | LLM 抽取,但要求证据;通常 `0.75` 以上 | +| 模型推断关系 | 从上下文推断某工具服务于某步骤、某配置控制某行为 | 必须有证据片段和解释;无证据不得入库 | + +无人工审核的前提下,必须设置阈值: + +- `confidence >= 0.75`:写入 Wiki 关系页和图谱投影文档。 +- `0.50 <= confidence < 0.75`:只写入技能 Wiki 的“可能关系”小节,不进入规范关系。 +- `confidence < 0.50`:丢弃。 + +每条关系都必须包含: + +- `source` +- `predicate` +- `target` +- `confidence` +- `relation_source` +- `evidence` + +如果没有证据路径和证据文本,不允许写入规范关系。 + +建议谓词归一化: + +| 原始表达 | 规范 predicate | +| --- | --- | +| 依赖、需要、requires | `depends_on` | +| 调用、使用、uses | `uses` | +| 生成、输出、produces | `produces` | +| 读取、输入、consumes | `consumes` | +| 配置、控制 | `configures` | +| 包含、目录下有 | `contains` | +| 引用、链接到 | `references` | +| 替换、迁移到 | `replaces` | +| 属于 | `belongs_to` | + +实现可以先内置有限谓词表,再允许保留 `custom_predicate`,但 UI 与关系页要展示规范谓词。 + +## 10. 同一知识库内的实体合并 + +实体合并范围只在一个目标知识库内生效,不跨知识库合并。 + +规范实体 key: + +```text +kb_mapping_id + entity_type + normalized_name +``` + +`normalized_name` 规则: + +- Unicode NFKC 归一化。 +- trim。 +- 多空白折叠成单空格。 +- ASCII 字母转小写。 +- 中英文标点归一化。 +- 去除无意义包裹符号,例如反引号、引号。 + +如果实体带有明确外部 ID,可优先使用: + +```text +kb_mapping_id + entity_type + external_id_type + external_id +``` + +合并规则: + +- `normalized_name` 相同且 `entity_type` 兼容:合并。 +- 名称相同但类型不兼容:不合并,例如 `config:web_search` 和 `tool:web_search` 可保持两个实体,必要时用 `configures` 关系连接。 +- LLM 判断别名合并:只有 `confidence >= 0.90` 且有明确证据时允许。 +- 属性冲突不做最后写入覆盖,必须按来源保留 provenance。 + +实体贡献示例: + +```json +{ + "canonical_entity_id": "ce_123", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "local_entity_id": "ent_001", + "attributes": { + "entrypoint": { + "value": "config.yaml tools.web_search", + "evidence": "config.yaml:10" + } + }, + "confidence": 0.94 +} +``` + +删除贡献时: + +- 只删除该 binding 的贡献。 +- 如果规范实体仍有其他贡献,保留实体页并重新生成聚合内容。 +- 如果规范实体贡献数为 0,删除或标记归档该规范实体及其 Wiki 页面。 + +## 11. 关系合并 + +规范关系 key: + +```text +kb_mapping_id + source_canonical_entity_id + normalized_predicate + target_canonical_entity_id +``` + +合并规则: + +- 同一 key 的关系合并为一条规范关系。 +- 每个技能的证据作为 relation contribution 追加。 +- `confidence` 可存储最大值、平均值和证据数,但展示优先显示最高置信证据。 +- 如果来源实体或目标实体被删除到无贡献,相关关系也需要重新计算。 + +关系 Wiki 页面内容必须保留: + +- 关系三元组。 +- 关系说明。 +- 贡献技能列表。 +- 证据路径。 +- 置信度。 +- 最近更新时间。 + +## 12. WeKnora 内容投影 + +每个技能归纳到一个目标知识库后,按目标 adapter 写入三类内容。目标是 WeKnora 时写入 WeKnora;目标是助手知识库或只读 sidecar 时写入 DeerFlow 自研存储。 + +### 12.1 原始资料 + +原始资料分两种: + +- 可直接上传的文件:走 WeKnora 文件上传。 +- 纯文本或需要展开的内容:创建手动文档,保留路径和 fenced code。 + +原始资料命名建议: + +```text +skills/{skill_name}/source/{relative_path} +``` + +文档 metadata 建议: + +```json +{ + "source_type": "deerflow_skill", + "skill_name": "example-skill", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "source_digest": "sha256:...", + "relative_path": "SKILL.md", + "artifact_kind": "raw_source" +} +``` + +如果 WeKnora 不支持自定义 metadata,则本地 artifact 表必须保存远端 ID、slug、digest、kind 和来源关系。 + +### 12.2 技能 Wiki 页面 + +每个技能至少生成: + +```text +skills/{skill_name}/overview +skills/{skill_name}/files +skills/{skill_name}/entities +skills/{skill_name}/relations +skills/{skill_name}/evidence +``` + +内容要求: + +- `overview`:技能用途、输入、输出、依赖、使用方式、风险提示。 +- `files`:重要文件清单、每个文件摘要、关键入口。 +- `entities`:该技能贡献的实体,使用 Wiki 链接指向规范实体页。 +- `relations`:确定关系和高置信推断关系,使用 Wiki 链接指向规范关系页。 +- `evidence`:证据目录,只包含短摘录和位置,不包含敏感内容。 + +### 12.3 规范实体 Wiki 页面 + +规范实体页 slug: + +```text +entities/{entity_type}/{stable_entity_id}-{slugified_name} +``` + +页面内容: + +- 实体名称和类型。 +- 别名。 +- 归并来源技能。 +- 聚合描述。 +- 属性表,冲突属性按来源列出。 +- 相关关系列表。 +- 证据索引。 + +### 12.4 规范关系 Wiki 页面 + +规范关系页 slug: + +```text +relations/{predicate}/{stable_relation_id} +``` + +页面内容: + +- `[[source_entity]] --predicate--> [[target_entity]]` +- 中文说明。 +- 贡献技能。 +- 证据。 +- 置信度。 +- 更新时间。 + +说明:即便 WeKnora Wiki graph 只能展示无类型链接,关系类型也不能丢。类型和证据由关系页保存,Wiki 链接负责把实体页和关系页连接起来。 + +### 12.5 图谱投影文档 + +为提高 WeKnora GraphRAG 抽取成功率,每个 binding 生成一份机器可读关系文档: + +```text +skills/{skill_name}/graph-projection.jsonl +``` + +每行一个关系: + +```json +{ + "source": "web_search", + "source_type": "tool", + "predicate": "used_by", + "target": "example-skill", + "target_type": "skill", + "description": "web_search 被 example-skill 用于首步检索", + "confidence": 0.88, + "evidence": "SKILL.md:22-28" +} +``` + +同时生成一份人类可读 Markdown: + +```text +skills/{skill_name}/graph-projection.md +``` + +该文档使用短句和固定模板,降低 WeKnora 抽取歧义: + +```markdown +# 技能关系投影:example-skill + +- 实体 `web_search`(tool)与实体 `example-skill`(skill)存在 `used_by` 关系。 + - 说明:web_search 被 example-skill 用于首步检索。 + - 证据:SKILL.md:22-28 + - 置信度:0.88 +``` + +GraphRAG 可用时上传这些投影文档;Neo4j direct writer 开启时,同时把通过审核的规范实体和关系写入底层图数据库;两者都不可用时仍保留 Wiki 关系页和本地关系表。 + +## 13. 重归纳一致性 + +重归纳必须满足“成功后替换,失败不影响旧版本”。 + +流程: + +1. 读取当前 binding 和 active snapshot。 +2. 重新扫描技能目录,生成新 snapshot。 +3. 如果 digest 未变化且未强制重归纳,可以直接返回 `synced`。 +4. 在本地创建 staged artifact 记录。 +5. 写入 WeKnora 新版本原始资料、Wiki 页面、关系投影文档。 +6. 校验远端可读取或状态完成。 +7. 在一个数据库事务中: + - 将旧 artifact 标记为 `retired`。 + - 将 staged artifact 标记为 `active`。 + - 将 binding 的 `current_snapshot_id` 指向新 snapshot。 + - 更新实体和关系贡献。 + - 设置 binding 状态为 `synced` 或 `partial_failed`。 +8. 对旧远端文件执行 best-effort 清理。 +9. 清理失败只记录 warning,不回滚已经激活的新版本。 + +如果第 5 或第 6 步失败: + +- binding 仍指向旧 snapshot。 +- 旧 WeKnora 内容继续有效。 +- staged artifact 标记为 `failed`。 +- item 状态为 `failed` 或 `partial_failed`。 +- 前端显示错误,允许 retry / 重新归纳。 + +Wiki 页面建议采用稳定 slug upsert。原始资料如果 WeKnora 没有 update API,则创建新远端文档,确认成功后再删除旧远端文档。 + +## 14. 解除关联 + +解除一个技能与一个知识库的绑定时,前端必须弹窗让管理员选择: + +| 选项 | 行为 | +| --- | --- | +| 保留知识库内容 | 绑定标记为 `detached_keep`。WeKnora 远端内容、助手库 Wiki revision 或 sidecar 内容保留为冻结知识,不再随技能重归纳更新。技能页不再显示为“当前归纳”,但可在历史中显示“已解除,内容保留”。 | +| 删除该技能贡献的内容 | 删除该 binding 的原始资料、技能 Wiki 页、关系投影文档、助手库 Wiki revision contribution、sidecar artifact 和实体/关系贡献;共享实体/关系只减少该技能贡献,仍有其他贡献则重建聚合页,没有贡献才删除。 | + +如果删除远端内容部分失败: + +- 本地记录 artifact `delete_failed`。 +- binding 状态为 `detached_delete_pending_cleanup`。 +- 提供“重试清理”动作。 + +不要因为某个共享实体由该技能参与过,就删除其他技能还在使用的实体页。 + +## 15. 状态机 + +绑定状态建议: + +| 状态 | 含义 | +| --- | --- | +| `pending` | 已创建绑定,但尚未开始处理。 | +| `queued` | 已进入任务队列。 | +| `running` | 正在处理。 | +| `awaiting_review` | 抽取完成,等待管理员审核实体、关系或 Wiki 草稿。 | +| `synced` | 当前版本已成功归纳到目标知识库。 | +| `pending_remote_write` | 只读目标已完成本地 sidecar 归纳,等待远端具备写权限后推送。 | +| `stale` | 技能源文件已变化,但尚未手动重新归纳。 | +| `partial_failed` | 核心 Wiki/原始资料成功,但部分文件、图谱或清理失败。 | +| `failed` | 当前归纳失败,旧版本若存在则继续有效。 | +| `detached_keep` | 已解除关联,远端内容保留。 | +| `detached_delete_pending_cleanup` | 已解除关联,但删除远端内容未完全成功。 | +| `detached_deleted` | 已解除关联,远端贡献已清理。 | + +item 阶段建议: + +```text +scanning +parsing +summarizing +extracting +awaiting_review +merging +uploading_sources +writing_wiki +writing_graph_projection +verifying +activating +cleanup +completed +``` + +前端展示时,父任务进度由 item 数量和阶段加权计算,不要只显示一个不可解释的 spinner。 + +## 16. 持久化设计 + +下面是建议表结构,字段命名可按项目现有风格调整。 + +### 16.1 `skill_knowledge_bindings` + +保存技能与知识库的多对多关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `target_type` | `weknora` / `assistant`。 | +| `target_mode` | `wiki` / `document` / `faq` / `conversation_archive` / `readonly_sidecar`。 | +| `target_id` | 目标知识库 ID。WeKnora 使用本地 mapping ID,助手库使用全局 base ID。 | +| `remote_business_key` | 暴露给前端和外部系统的远端业务 key,例如 `weknora:kb:xxx`,可为空。 | +| `status` | 绑定状态。 | +| `active` | 是否当前有效绑定。 | +| `current_snapshot_id` | 当前激活的技能快照。 | +| `source_digest` | 最近扫描到的技能源 digest。 | +| `synced_digest` | 最近成功归纳的 digest。 | +| `last_job_id` | 最近一次父任务。 | +| `last_item_id` | 最近一次 item。 | +| `last_synced_at` | 最近成功归纳时间。 | +| `last_error_code` | 最近错误码。 | +| `last_error_message` | 最近错误摘要。 | +| `created_by` | 管理员用户。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(skill_name, target_type, target_id) +``` + +### 16.2 `skill_knowledge_snapshots` + +保存技能扫描和抽取结果。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `source_digest` | 技能目录 digest。 | +| `extractor_version` | 抽取器版本。 | +| `prompt_hash` | 提示词 hash。 | +| `model_name` | 模型名。 | +| `manifest_json` | 文件清单、hash、安全状态。 | +| `ir_json` | 抽取后的 IR;较大时可存 blob 引用。 | +| `status` | `ready` / `failed`。 | +| `counts_json` | 文件数、实体数、关系数、跳过数。 | +| `created_at` | 创建时间。 | + +约束: + +```text +unique(skill_name, source_digest, extractor_version, prompt_hash, model_name) +``` + +### 16.3 `skill_knowledge_sync_jobs` + +保存一次批量归纳任务。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_count` | 本次目标知识库数量。 | +| `targets_json` | 目标知识库列表,每项包含 `target_type`、`target_mode`、`target_id`、展示名和远端业务 key。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed` / `canceled`。 | +| `total_count` | 技能数。 | +| `success_count` | 成功数。 | +| `partial_failed_count` | 部分失败数。 | +| `failed_count` | 失败数。 | +| `cancel_requested` | 是否请求取消。 | +| `created_by` | 管理员用户。 | +| `created_at` / `started_at` / `completed_at` | 时间戳。 | + +### 16.4 `skill_knowledge_sync_items` + +保存每个技能的任务项。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `job_id` | 父任务。 | +| `binding_id` | 绑定。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `skill_name` | 技能名。 | +| `snapshot_id` | 本次输入快照。 | +| `status` | item 状态。 | +| `phase` | 当前阶段。 | +| `progress` | 0-100。 | +| `active_dedupe_key` | 活跃任务去重 key。 | +| `lease_owner` | 执行器标识。 | +| `lease_until` | 租约过期时间。 | +| `attempts` | 尝试次数。 | +| `last_error_code` | 错误码。 | +| `last_error_message` | 错误摘要。 | +| `created_at` / `updated_at` / `started_at` / `completed_at` | 时间戳。 | + +活跃去重 key: + +```text +skill_name + target_type + target_id +``` + +同一绑定同一时间只能有一个 active item。 + +### 16.5 `skill_knowledge_artifacts` + +保存本地到 WeKnora 的远端 artifact 映射。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `binding_id` | 绑定,可为空;规范实体/关系聚合页可只属于知识库。 | +| `snapshot_id` | 来源快照。 | +| `scope` | `binding` / `kb_aggregate`。 | +| `kind` | `raw_source` / `skill_wiki` / `entity_wiki` / `relation_wiki` / `graph_projection`。 | +| `stable_key` | 本地稳定 key。 | +| `remote_kind` | `file` / `manual_document` / `wiki_page`。 | +| `remote_id` | 远端 ID。后端保存完整映射,前端可按权限展示和复制。 | +| `wiki_slug` | Wiki 页面 slug。 | +| `content_hash` | 写入内容 hash。 | +| `version` | 版本号。 | +| `status` | `staged` / `active` / `retired` / `failed` / `delete_failed`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.6 `skill_knowledge_entities` + +规范实体表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `description` | 聚合描述。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, entity_type, normalized_name) +``` + +### 16.7 `skill_knowledge_entity_contributions` + +规范实体的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_entity_id` | 规范实体 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_entity_id` | IR 中的实体 ID。 | +| `attributes_json` | 该技能贡献的属性。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.8 `skill_knowledge_relations` + +规范关系表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `source_entity_id` | 源规范实体。 | +| `predicate` | 规范谓词。 | +| `target_entity_id` | 目标规范实体。 | +| `description` | 聚合说明。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, source_entity_id, predicate, target_entity_id) +``` + +### 16.9 `skill_knowledge_relation_contributions` + +规范关系的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_relation_id` | 规范关系 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_relation_id` | IR 中的关系 ID。 | +| `relation_source` | `deterministic` / `explicit_text` / `llm_inferred`。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +## 17. 后台任务设计 + +参考现有 deep research job 的持久化后台任务模式: + +- 任务入库后返回 `202 Accepted`。 +- 执行器通过 DB 原子条件 claim item。 +- 每个 item 有 `lease_owner`、`lease_until`。 +- 执行中定期 heartbeat。 +- 服务重启后可恢复过期 lease。 +- cancel 使用 `cancel_requested`,任务在阶段边界检查并安全停止。 + +并发建议: + +- 同时处理技能数:2 到 4。 +- 同时调用 WeKnora 写入:2 到 3。 +- 同时 LLM 抽取:按系统现有限流,建议默认 2。 + +200 个技能批量时: + +- 父 job 只保存聚合进度,不串行阻塞 HTTP 请求。 +- 每个技能 item 独立失败、独立重试。 +- 前端按 job polling,不为每个技能单独发 N 个请求。 +- 获取技能列表时批量查 binding summary,避免 N+1。 +- 快照缓存可复用,跨知识库只重新投影,不重复解析同一技能内容。 + +## 18. 后端 API 设计 + +新增 router: + +```text +/api/skill-knowledge +``` + +所有写接口仅管理员可用。 + +### 18.1 获取技能绑定摘要 + +```http +GET /api/skill-knowledge/bindings?skill_names=a,b,c +``` + +返回: + +```json +{ + "items": [ + { + "skill_name": "example-skill", + "knowledge_bases": [ + { + "binding_id": "bind_001", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "remote_business_key": "weknora:kb:remote-kb-id", + "status": "synced", + "last_synced_at": "2026-08-31T10:00:00Z", + "source_digest": "sha256:...", + "synced_digest": "sha256:...", + "stale": false + } + ] + } + ] +} +``` + +### 18.2 创建归纳任务 + +```http +POST /api/skill-knowledge/sync-jobs +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "targets": [ + { + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id" + }, + { + "target_type": "assistant", + "target_mode": "wiki", + "target_id": "assistant-global-base" + } + ], + "force": false +} +``` + +行为: + +- 校验管理员。 +- 校验技能存在。 +- 校验目标知识库存在,并解析目标 adapter。 +- 对只读目标创建 sidecar binding,不强行写远端。 +- 对每个 `skill x target` upsert binding。 +- 创建父 job 和 item。 +- 如果同一 binding 已有 active item,返回当前 item,不重复创建。 +- 返回 `202`。 + +返回: + +```json +{ + "job_id": "job_001", + "status": "queued", + "skill_count": 2, + "target_count": 2, + "total_count": 4, + "items": [ + { + "item_id": "item_001", + "skill_name": "skill-a", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "binding_id": "bind_001", + "status": "queued" + } + ] +} +``` + +### 18.3 查询任务 + +```http +GET /api/skill-knowledge/sync-jobs/{job_id} +``` + +返回父 job、item 列表、每个 item 当前阶段、错误摘要、绑定结果。 + +### 18.4 取消任务 + +```http +POST /api/skill-knowledge/sync-jobs/{job_id}/cancel +``` + +设置 `cancel_requested = true`。已激活成功的 item 不回滚;未完成 item 在阶段边界停止。 + +### 18.5 重试 item + +```http +POST /api/skill-knowledge/sync-items/{item_id}/retry +``` + +只允许失败或部分失败 item。重试复用原 binding。 + +### 18.6 对单个绑定重新归纳 + +```http +POST /api/skill-knowledge/bindings/{binding_id}/resync +``` + +请求: + +```json +{ + "force": true +} +``` + +行为:创建一个只包含该 binding 的新 job。 + +### 18.7 解除关联 + +建议使用显式 POST,避免 DELETE body 兼容性问题: + +```http +POST /api/skill-knowledge/bindings/{binding_id}/detach +``` + +请求: + +```json +{ + "remote_content_action": "keep" +} +``` + +或: + +```json +{ + "remote_content_action": "delete" +} +``` + +返回解除结果、清理状态和仍被共享的实体/关系数量。 + +### 18.8 知识库反查技能 + +```http +GET /api/skill-knowledge/knowledge-bases/{knowledge_base_id}/skills +``` + +用于知识库详情页展示“该知识库由哪些技能归纳而来”。 + +### 18.9 可选:源状态扫描 + +```http +POST /api/skill-knowledge/source-status +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "knowledge_base_id": "local-kb-id" +} +``` + +只做本地快速 fingerprint 比对,标记是否可能 stale,不写 WeKnora。 + +### 18.10 审核队列 + +```http +GET /api/skill-knowledge/reviews?job_id=xxx +POST /api/skill-knowledge/reviews/{review_id}/approve +POST /api/skill-knowledge/reviews/{review_id}/reject +PUT /api/skill-knowledge/reviews/{review_id} +POST /api/skill-knowledge/reviews/bulk-approve +POST /api/skill-knowledge/reviews/bulk-reject +``` + +审核通过后 item 才能进入写入阶段。审核模式为 `off` 时,接口仍保留预览和审计能力。 + +### 18.11 自动同步配置 + +```http +GET /api/skill-knowledge/auto-sync-settings +PUT /api/skill-knowledge/auto-sync-settings +POST /api/skill-knowledge/bindings/{binding_id}/enable-auto-sync +POST /api/skill-knowledge/bindings/{binding_id}/disable-auto-sync +``` + +自动同步设置只允许管理员修改。 + +## 19. 前端交互设计 + +### 19.1 技能管理页 + +在 `AdminSkillsPage.tsx` 的“技能列表”页签增加: + +- 技能行 checkbox。 +- 顶部批量操作按钮:“归纳到知识库”。 +- 支持全选当前页、清空选择。 +- 选择状态跨前端分页保留。 +- 技能较多时保持现有搜索/过滤能力。 + +点击“归纳到知识库”打开弹窗: + +- 显示已选技能数量。 +- 多选目标知识库。 +- 按类型分组展示:WeKnora Wiki、WeKnora 非 Wiki、FAQ、系统会话存档、只读 sidecar、助手知识库。 +- 只读目标要展示“本地归纳,暂不写远端”的状态说明。 +- 助手知识库作为目标时,提示“直接写入知识梳理底库,不经过 WeKnora”。 +- 二次确认文案说明:“将生成原始资料、Wiki 页面和关系内容;抽取完成后进入实体/关系预览审核。” + +提交后: + +- 创建后台 job。 +- 跳转或打开“归纳任务”抽屉/页签。 +- 展示总进度、成功、失败、部分失败。 +- 任务运行中每 2-3 秒 polling。 +- item 到达 `awaiting_review` 时,显示“待审核”并提供审核入口。 + +### 19.1.1 实体/关系审核页 + +审核页按 job 聚合展示: + +- 左侧筛选:技能、目标知识库、类型、置信度、来源。 +- 中间列表:实体、关系、实体对齐候选、关系合并候选、Wiki 草稿。 +- 右侧详情:原始值、模型解释、证据、目标写入位置。 + +管理员可以: + +- 单条批准、拒绝、编辑。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 +- 修改实体类型、名称、别名。 +- 修改关系 predicate、说明、confidence。 +- 查看远端 ID 或 sidecar ID。 + +审核完成后,任务继续写入目标知识库。 + +### 19.2 技能行提示 + +`SkillRow` 增加归纳状态区域: + +- 未归纳:不显示或显示弱提示“未归纳”。 +- 已归纳:显示 `已归纳至 N 个知识库`。 +- 展开或悬浮可看到知识库 chips: + - 知识库名称 + - 状态 + - 最近归纳时间 + - stale 标识 + +每个绑定提供动作: + +- 查看知识库。 +- 重新归纳。 +- 解除关联。 +- 失败时重试。 + +状态建议: + +| 状态 | UI 文案 | +| --- | --- | +| `synced` | 已归纳 | +| `stale` | 待重新归纳 | +| `running` | 归纳中 | +| `partial_failed` | 部分完成 | +| `failed` | 失败 | +| `detached_keep` | 已解除,内容保留 | + +### 19.3 解除关联弹窗 + +弹窗必须让管理员明确选择: + +- 保留知识库内容。 +- 删除该技能贡献的内容。 + +弹窗文案应提醒: + +- 删除只删除该技能贡献,不删除其他技能共享的实体与关系。 +- 若删除远端内容失败,可稍后重试清理。 + +### 19.4 归纳任务页签 + +建议在管理员技能管理页新增第三个页签: + +```text +技能列表 | 技能地址管理 | 归纳任务 +``` + +任务列表显示: + +- 创建时间。 +- 目标知识库。 +- 操作人。 +- 总技能数。 +- 成功 / 部分失败 / 失败。 +- 状态。 +- 操作:查看详情、取消、重试失败项。 + +详情显示 item: + +- 技能名。 +- 目标知识库。 +- 目标类型。 +- 当前阶段。 +- 进度。 +- 错误摘要。 +- 绑定状态。 + +### 19.6 自动同步设置 + +管理员可在技能归纳设置中配置: + +- 是否开启文件监听。 +- 是否开启定时同步。 +- 同步周期。 +- 自动写远端开关。 +- 审核模式。 +- 单个 binding 是否参与自动同步。 + +### 19.5 知识库详情反向展示 + +如果现有 WeKnora / LLM Wiki 页面有知识库详情入口,增加“来源技能”区域: + +- 技能名。 +- 归纳状态。 +- 最近归纳时间。 +- 查看技能。 +- 重新归纳。 + +## 20. stale 检测与自动同步 + +系统既要支持手动重新归纳,也要支持自动同步。stale 检测负责判断“源知识文件是否变化”;自动同步负责在配置允许时创建后台任务并远程写入。 + +本地标识 stale: + +- 技能编辑、启停、删除等已知写操作完成后,查找相关 binding 并标记 `stale`。 +- 技能列表加载时可做轻量 fingerprint:路径、大小、mtime。 +- 真正归纳时计算完整 SHA digest。 +- 如果归纳过程中源文件变化,最终激活前再次校验 expected digest;不一致则失败并标记 stale,避免一个版本混入两次源内容。 +- 代码文件和脚本文件不参与 fingerprint,也不触发 stale。 + +自动同步: + +- 当 `watch_enabled` 开启时,知识文件变化后自动创建相关 binding 的归纳任务。 +- 当 `schedule_enabled` 开启时,scheduler 按周期扫描并归纳 stale binding。 +- 当技能保存、导入、删除或启停时,可触发相关 binding 的自动任务。 +- 自动任务必须遵循审核模式;如果需要审核,任务停在 `awaiting_review`。 +- 自动任务必须遵循 staged 激活;失败不覆盖旧版本。 + +## 21. 权限与安全 + +权限: + +- 所有归纳、重归纳、解除关联、取消、重试接口仅管理员。 +- 读接口如果包含后台任务错误或源文件路径,也建议仅管理员。 + +文件安全: + +- 技能根必须限制在 `skills/public` 或 `skills/custom`。 +- 禁止路径穿越。 +- 默认不跟随 symlink;如果要支持 symlink,必须确认 resolved path 仍在技能根内。 +- 压缩包解压必须限制总大小、文件数量、单文件大小、目录层级。 +- 不执行技能代码。 +- 不读取或上传密钥文件。 +- 日志和错误信息不得包含文件全文、token、secret。 + +远端安全: + +- 不新增外部 URL 抓取能力。 +- 如果技能内容中包含 URL,只作为文本关系或知识内容处理,不主动访问。 +- WeKnora 调用失败时,记录错误摘要和 request id,不记录认证头。 + +## 22. LLM 抽取设计 + +抽取可分两级: + +- 小技能:一次性把文本摘要和结构信息送入模型。 +- 大技能:先按文件或章节生成局部摘要与候选实体关系,再合并。 + +提示词需要明确: + +- 只基于提供的内容抽取,不凭空编造。 +- 允许推断,但推断必须有 evidence。 +- 输出必须符合 JSON schema。 +- 不输出敏感信息。 +- 关系必须有 confidence。 +- 低置信关系要标记,不进入规范关系。 + +建议 schema: + +```json +{ + "summary": { + "purpose": "技能用途", + "inputs": ["输入"], + "outputs": ["输出"], + "usage": "使用方式", + "risks": ["风险"] + }, + "entities": [ + { + "id": "ent_001", + "type": "skill|tool|api|file|config|dataset|concept|workflow|external_system|output", + "name": "实体名", + "aliases": [], + "description": "描述", + "attributes": {}, + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 1, + "line_end": 3, + "quote": "短证据" + } + ], + "confidence": 0.9 + } + ], + "relations": [ + { + "id": "rel_001", + "source_entity_id": "ent_001", + "predicate": "uses", + "target_entity_id": "ent_002", + "description": "关系描述", + "relation_source": "explicit_text|llm_inferred", + "evidence": [], + "confidence": 0.86 + } + ] +} +``` + +模型输出后必须做程序校验: + +- JSON schema 校验。 +- entity id 存在性校验。 +- relation source/target 必须存在。 +- evidence path 必须来自本次 manifest。 +- confidence 必须在 0 到 1。 +- 敏感内容二次扫描。 + +## 23. 后端实现步骤 + +建议按以下顺序实现。 + +### 23.1 持久化层 + +新增 `deerflow.persistence.skill_knowledge`: + +- SQL model。 +- memory store,用于测试或无 DB 环境。 +- repository API。 +- migration 或 `create_all` 接入。 + +Repository API 建议: + +```python +class SkillKnowledgeStore: + async def upsert_binding(...) + async def list_bindings(...) + async def create_job(...) + async def create_items(...) + async def claim_next_item(...) + async def heartbeat_item(...) + async def complete_item(...) + async def fail_item(...) + async def create_or_get_snapshot(...) + async def stage_artifacts(...) + async def activate_snapshot(...) + async def detach_binding(...) +``` + +### 23.2 扫描与解析 + +新增: + +```text +deerflow/skill_knowledge/scanner.py +deerflow/skill_knowledge/parsers.py +deerflow/skill_knowledge/safety.py +deerflow/skill_knowledge/digest.py +``` + +输出 manifest 和初始文本块。 + +### 23.3 抽取与归纳 + +新增: + +```text +deerflow/skill_knowledge/extractor.py +deerflow/skill_knowledge/wiki_builder.py +``` + +职责: + +- 构造 prompt。 +- 调用项目已有模型封装。 +- 解析 JSON。 +- 校验 schema。 +- 构建技能 Wiki 页面内容。 + +### 23.4 实体关系合并 + +新增: + +```text +deerflow/skill_knowledge/merge.py +``` + +职责: + +- 名称归一化。 +- 实体 upsert。 +- 关系 upsert。 +- 贡献增删。 +- 共享实体/关系重建计划。 + +### 23.5 WeKnora 投影 + +新增: + +```text +deerflow/skill_knowledge/projector.py +deerflow/skill_knowledge/target_adapters.py +deerflow/skill_knowledge/graph_writer.py +``` + +职责: + +- 根据 IR 和聚合结果生成 artifact plan。 +- 按目标知识库类型选择 adapter。 +- 调用 WeKnora client 上传原文、FAQ、会话归档或 Wiki 页面。 +- 为只读目标写本地 sidecar artifact。 +- 上传 graph projection docs。 +- 在配置允许时通过受控 graph writer 写 Neo4j。 +- 校验远端状态。 + +### 23.6 Gateway + +新增: + +```text +app/gateway/routers/skill_knowledge.py +app/gateway/skill_knowledge_job_dispatcher.py +app/gateway/skill_knowledge_job_executor.py +app/gateway/skill_knowledge_watcher.py +app/gateway/skill_knowledge_scheduler.py +``` + +在 app 初始化中注册: + +- store。 +- dispatcher。 +- watcher。 +- scheduler。 +- router。 + +### 23.7 前端 + +新增: + +```text +frontend-web/src/strategy-components/api/skill-knowledge.ts +frontend-web/src/components/workspace/skills/skill-knowledge-sync-dialog.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-bindings.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-jobs-panel.tsx +``` + +修改: + +- `AdminSkillsPage.tsx`:技能列表批量选择、归纳弹窗、归纳任务页签。 +- `skill-settings-page.tsx`:`SkillRow` 展示归纳知识库 chips 和操作。 +- 知识库详情页:展示来源技能。 + +### 23.8 助手知识库 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/ +offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/ +offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py +offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py +frontend-web/src/strategy-components/api/assistant-knowledge.ts +frontend-web/src/pages/AssistantKnowledgePage.tsx +``` + +职责: + +- 初始化唯一全局助手知识库“知识梳理”。 +- 从 WeKnora 导出包导入 Wiki、文档、chunk、实体、关系、图谱。 +- 做全局实体对齐和关系合并。 +- 生成助手库 Wiki 页面。 +- 建立搜索索引。 +- 支持普通用户只读、管理员导入和维护。 +- 支持知识库产品版本 `v1` / `v2` 切换。 + +### 23.9 审核队列 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/review_model.py +frontend-web/src/components/workspace/skills/skill-knowledge-review-panel.tsx +``` + +职责: + +- 保存实体、关系、对齐候选、Wiki 草稿的审核项。 +- 支持批准、拒绝、编辑、批量处理。 +- 审核完成后唤醒对应 item 继续写入。 +- 保留完整审计。 + +## 24. 测试计划 + +后端测试: + +| 测试 | 覆盖点 | +| --- | --- | +| `test_skill_knowledge_scanner.py` | 文件扫描、排除目录、敏感文件阻断、压缩包安全。 | +| `test_skill_knowledge_extractor.py` | LLM 输出 schema 校验、低置信关系过滤、证据路径校验。 | +| `test_skill_knowledge_entity_merge.py` | 同知识库实体合并、跨知识库不合并、属性冲突 provenance。 | +| `test_skill_knowledge_repository.py` | 绑定唯一、多对多、状态机、detach keep/delete。 | +| `test_skill_knowledge_jobs.py` | job/item claim、lease、重试、取消、崩溃恢复、200 item 聚合进度。 | +| `test_skill_knowledge_weknora_projection.py` | Wiki slug upsert、artifact staged/active、GraphRAG 不可用 partial_failed。 | +| `test_skill_knowledge_api.py` | 管理员鉴权、目标知识库校验、创建任务、查询任务、解除关联。 | +| `test_skill_knowledge_target_adapters.py` | Wiki、非 Wiki、FAQ、会话存档、只读 sidecar、助手库目标写入。 | +| `test_skill_knowledge_review.py` | 实体/关系逐条审核、编辑、批量批准、审核阻塞写入。 | +| `test_skill_knowledge_auto_sync.py` | 文件监听、定时同步、代码文件变化不触发、staged 自动写入。 | +| `test_skill_knowledge_graph_writer.py` | Neo4j 参数化写入、事务、审计、回滚计划、禁用任意 Cypher。 | +| `test_assistant_knowledge_repository.py` | 唯一全局底库、来源、导入任务、实体关系贡献。 | +| `test_assistant_knowledge_import.py` | WeKnora 导出包导入、实体对齐、关系合并、搜索索引。 | +| `test_assistant_knowledge_api.py` | 普通用户只读、管理员写入、未配置 WeKnora 场景。 | + +前端测试: + +- 管理员技能页可以多选技能并打开知识库选择弹窗。 +- 知识库选择弹窗支持多选目标,包含 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- 创建 job 后显示进度并 polling。 +- 抽取后进入实体/关系审核页,审核通过后继续写入。 +- 技能行展示已归纳知识库 chips。 +- 重归纳按钮调用正确接口。 +- 解除关联弹窗必须选择 keep/delete。 +- 200 个技能绑定摘要使用批量接口,不触发 N+1。 +- 未配置 WeKnora 时仍能展示助手知识库初始化入口。 +- 普通知识库详情页可触发导入到助手知识库。 +- 助手知识库搜索、Wiki 页面、来源列表按权限展示。 + +建议命令: + +```bash +cd offline-backend-20260512/backend +PYTHONPATH=. uv run pytest tests/test_skill_knowledge_*.py -v +PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v +``` + +```bash +cd frontend-web +pnpm typecheck +pnpm test -- skill-knowledge +``` + +## 25. 验收场景 + +必须通过: + +- 管理员选择 3 个技能归纳到 2 个目标知识库,生成 1 个 job、6 个 item、6 个 binding。 +- 同一个技能随后归纳到另一个知识库,新增第二个 binding,技能行显示已归纳至 2 个知识库。 +- 技能可以直接归纳到助手知识库,并生成实体对齐、Wiki 页面版本和搜索索引。 +- 同一个技能同一个 digest 重复归纳,不产生重复 Wiki 页面或重复实体关系。 +- 两个技能在同一知识库中抽取出同名同类型实体,只生成一个规范实体页,并列出两个贡献技能。 +- 解除其中一个技能并选择删除内容后,共享实体仍保留另一个技能贡献。 +- 关系必须带 evidence 和 confidence;低于 0.75 的推断关系不进入规范关系。 +- 重归纳成功后,新版本替换旧版本;旧 artifact 进入 retired。 +- 重归纳失败时,旧版本继续显示为当前版本。 +- 解除关联选择保留内容后,远端 Wiki 和文档保留,但 binding 不再参与后续重归纳。 +- 非管理员访问写接口返回 403。 +- 非 Wiki、FAQ、系统会话存档知识库可作为目标,并通过对应 adapter 写入。 +- 只读知识库可作为目标,结果写入本地 sidecar,远端状态显示 `pending_remote_write`。 +- 直接 Neo4j 写入只在配置开启时执行,且只使用参数化 graph writer。 +- 自动文件监听和定时同步可以创建后台任务,代码文件变化不会触发同步。 +- 前端可逐条审核实体/关系,也可按策略批量处理。 +- 前端展示 WeKnora 远端 ID,并可复制业务 key。 +- WeKnora GraphRAG 不可用时,原始资料和 Wiki 成功,状态为 `partial_failed`,可重试图谱投影。 +- 200 个技能批量归纳时,页面显示整体进度,失败技能可单独重试,后端没有 N+1 查询。 +- 技能中出现 `.env` 或私钥时,该文件不上传、不进 prompt,页面显示安全跳过。 +- 技能中的 `.py`、`.ts`、`.sh` 等代码文件不读取、不上传、不摘要,代码文件变化不触发知识 stale。 +- 未配置 WeKnora 时,助手知识库仍可初始化和查看。 +- 管理员可从普通知识库触发导入到助手知识库。 +- 管理员可向助手知识库导入本地文件,安全扫描通过后生成 Wiki 页面版本。 +- WeKnora 导出包中的 Wiki、文档、chunk、实体、关系、图谱都能进入助手底库。 +- 助手底库把多个来源中的同一人或同一对象合并为一个实体 Wiki 页。 +- 助手 Wiki 支持历史版本、对比和回滚。 +- 助手知识库普通用户只读,管理员可导入、重导、删除来源。 + +## 26. 分阶段交付 + +第一阶段:数据模型与 API 骨架 + +- 建表和 store。 +- 绑定列表、创建 job、查询 job、detach API。 +- 管理员鉴权。 +- 前端技能行能展示空绑定状态。 + +第二阶段:扫描、快照与后台任务 + +- 技能目录扫描。 +- 文件 digest 和 manifest。 +- job/item 执行器、lease、heartbeat、失败重试。 +- 200 技能批量进度。 + +第三阶段:Wiki 归纳与原始资料上传 + +- LLM 摘要和 Wiki 页面生成。 +- WeKnora 原文上传。 +- Wiki page upsert。 +- staged/active 替换。 + +第四阶段:实体关系合并与关系投影 + +- 规范实体和关系表。 +- contribution 机制。 +- 关系 Wiki 页面。 +- graph projection docs。 +- GraphRAG 能力探测和 partial_failed。 + +第五阶段:完整前端体验 + +- 多选技能归纳弹窗。 +- 归纳任务页签。 +- 技能行 chips、详情、重归纳、解除关联。 +- 知识库详情来源技能反查。 + +第六阶段:安全、测试、文档收口 + +- 敏感文件阻断。 +- 压缩包安全。 +- 崩溃恢复。 +- README / CLAUDE 更新。 +- 后端和前端测试。 + +第七阶段:助手知识库与产品版本 + +- 新增唯一全局助手知识库“知识梳理”。 +- 新增 WeKnora 导出到助手库导入任务。 +- 新增技能直接导入助手知识库。 +- 新增管理员本地文件导入助手知识库。 +- 新增助手库实体对齐、关系合并和 Wiki 展示。 +- 新增助手库搜索。 +- 新增助手库 Wiki 页面版本管理、对比和回滚。 +- 新增知识库产品版本 `v1` / `v2` 切换。 +- 处理未配置 WeKnora 时的助手库默认展示。 + +第八阶段:多目标、审核、自动同步和底层图写入 + +- 支持一次选择多个目标知识库。 +- 支持非 Wiki、FAQ、系统会话存档和只读目标 adapter。 +- 支持实体/关系逐条审核。 +- 支持文件监听、定时同步、技能变更自动远程写入。 +- 支持受控 Neo4j direct writer。 +- 支持远端 WeKnora ID 作为业务 key 暴露给前端。 + +## 27. 给实现大模型的硬性指令 + +实现时必须遵守: + +- 先完整阅读根目录 `AGENTS.md`、后端 `AGENTS.md`、后端 `CLAUDE.md`。 +- 不要回滚用户已有改动。 +- 支持直接写 WeKnora 的 Neo4j,但必须通过受控 graph writer、显式配置、参数化模板、事务和审计。 +- 支持把远端 WeKnora ID 作为前端业务 key 暴露,但本地仍保留 surrogate id。 +- 不要让 `deerflow.*` import `app.*`。 +- 不要执行技能里的代码。 +- 不要读取、上传、摘要或展示技能里的代码文件、脚本文件和代码块内容。 +- 不要上传或提示词输入密钥文件。 +- 技能可以直接归纳到助手知识库,必须走同样的安全扫描、审核和实体对齐。 +- 不要创建多个助手知识库;助手知识库只有一个全局“知识梳理”底库。 +- 自动 watcher、定时同步和技能变更远程写入必须使用 staged 激活、去重、审核和失败保留旧版本。 +- 重归纳必须 staged 写入,成功后再激活。 +- 删除绑定贡献时必须按 contribution refcount 处理共享实体和关系。 +- 助手知识库需要 Wiki 页面版本管理、版本对比、回滚和导入任务审计。 +- 直接写 Neo4j 必须通过受控 graph writer 和参数化模板,不允许执行 LLM 生成的任意 Cypher。 +- 远端 WeKnora ID 可以作为前端业务 key 暴露,但本地仍要保留 surrogate id 和 mapping。 +- 后端代码变更后按仓库要求更新相关 README / CLAUDE。 +- 涉及文件编辑使用 `apply_patch` 或项目正常格式化工具,不做无关重构。 + +## 28. 实现任务清单 + +- [ ] 新增 `skill_knowledge` 持久化模型和 store。 +- [ ] 新增绑定、job、item、artifact、entity、relation、contribution 表。 +- [ ] 新增管理员 router `/api/skill-knowledge`。 +- [ ] 接入知识库能力校验,支持 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- [ ] 支持一次选择多个目标知识库,并按 `技能 x 目标` 生成 item。 +- [ ] 新增技能扫描器,支持全部非代码知识文件、安全排除和 digest。 +- [ ] 技能扫描器完全排除代码文件、脚本文件和 Markdown 代码块内容。 +- [ ] 新增 snapshot 缓存。 +- [ ] 新增 LLM 抽取器,输出 schema 校验。 +- [ ] 新增 Wiki 页面生成器。 +- [ ] 新增实体归一化与合并逻辑。 +- [ ] 新增关系归一化与合并逻辑。 +- [ ] 新增 WeKnora artifact projector。 +- [ ] 新增 target adapters。 +- [ ] 新增 graph projection 文档生成和上传。 +- [ ] 新增受控 Neo4j direct graph writer。 +- [ ] 新增实体/关系审核队列。 +- [ ] 新增后台 job dispatcher / executor。 +- [ ] 新增文件 watcher、定时同步 scheduler、技能变更自动写入触发。 +- [ ] 实现 resync 成功替换、失败保留旧版本。 +- [ ] 实现 detach keep/delete。 +- [ ] 技能变更后标记 binding stale。 +- [ ] 前端新增 `skill-knowledge.ts` API。 +- [ ] 技能管理页新增多选归纳入口。 +- [ ] 新增选择知识库弹窗。 +- [ ] 新增归纳任务页签。 +- [ ] 技能行展示已归纳知识库状态。 +- [ ] 实现重归纳、重试、取消、解除关联 UI。 +- [ ] 知识库详情展示来源技能。 +- [ ] 新增唯一全局助手知识库“知识梳理”。 +- [ ] 新增助手知识库持久化模型、来源、导入任务、Wiki、实体、关系、贡献表。 +- [ ] 新增 WeKnora 导出到助手知识库任务。 +- [ ] 支持技能直接导入助手知识库。 +- [ ] 支持管理员本地文件导入助手知识库。 +- [ ] 导出范围覆盖 Wiki、文档、chunk、实体、关系、图谱。 +- [ ] 助手知识库实现全局实体对齐和关系合并。 +- [ ] 助手知识库生成 Wiki 页面并支持搜索。 +- [ ] 助手知识库支持 Wiki 页面版本管理、对比、发布和回滚。 +- [ ] 新增知识库产品版本 `v1` / `v2` 设置,默认 `v1`。 +- [ ] 未配置 WeKnora 时仍展示助手知识库创建或初始化入口。 +- [ ] 助手知识库普通用户只读,管理员可导入、重导、删除。 +- [ ] 后端测试覆盖核心状态机和安全边界。 +- [ ] 前端测试覆盖主要交互。 +- [ ] 更新后端 README / CLAUDE 与必要前端文档。 + +## 29. 风险与处理 + +| 风险 | 处理 | +| --- | --- | +| WeKnora 图谱 API 在部署版本不稳定 | 以 Swagger 能力探测为准;Wiki 关系页和 graph projection 文档作为稳定方案。 | +| LLM 推断关系误判 | 强制 evidence、confidence 阈值、低置信不进入规范关系。 | +| 200 技能批量耗时长 | 后台 job、item 并发、snapshot 缓存、前端批量查询。 | +| 重归纳中断导致新旧混杂 | staged artifact + 本地事务激活 + 失败保留旧版本。 | +| 删除共享实体误删其他技能知识 | contribution refcount,删除只移除该 binding 贡献。 | +| 敏感信息进入知识库 | 文件级安全阻断、prompt 前二次扫描、日志脱敏。 | +| 技能目录处理阻塞 Gateway | 文件 IO 和转换统一 `asyncio.to_thread`。 | +| 技能源文件归纳中变化 | expected digest 校验,不一致则失败并标记 stale。 | + +## 30. 新增:普通知识库与助手知识库双体系 + +本节是基于新需求的补充设计。后续实现以本节为准;如果本节与前文旧的 WeKnora-only 表述冲突,优先采用本节。 + +### 30.1 产品形态 + +系统需要区分两类知识库: + +| 类型 | 存储 | 用途 | 可见性 | 写入来源 | +| --- | --- | --- | --- | --- | +| 普通知识库 | WeKnora | 文档向量化、Wiki、检索、图谱、技能归纳目标 | 按现有 WeKnora 逻辑 | 技能归纳、现有 WeKnora 导入 | +| 助手知识库 | DeerFlow 自研库 | 全局知识梳理、实体对齐、Wiki 展示、搜索、版本管理 | 所有人可见,普通用户只读 | WeKnora 导出、WeKnora 自动沉淀、技能直接归纳、管理员本地文件导入 | + +产品版本开关: + +- `v1`:默认值,优先展示现有普通知识库体验。 +- `v2`:优先展示助手知识库体验。 +- 管理员可在系统设置中切换版本。 +- 如果未配置 WeKnora,即使当前版本默认是 `v1`,页面也应展示助手知识库创建/初始化入口;WeKnora 相关能力隐藏或置灰,并提示“未配置普通知识库服务”。 + +建议新增系统设置项: + +```json +{ + "knowledge_base_product_version": "v1" +} +``` + +### 30.2 助手知识库定位 + +助手知识库只有一个全局底库,建议默认名: + +```text +知识梳理 +``` + +它不是 WeKnora 的另一个知识库,也不向 WeKnora 写数据。它是 DeerFlow 自己维护的结构化知识底库。 + +助手知识库对用户展示: + +- Wiki 页面列表。 +- Wiki 页面详情。 +- Wiki 页面版本、对比和回滚。 +- 实体页。 +- 关系页。 +- 搜索。 +- 数据导入记录。 + +助手知识库内部可以存实体、关系、贡献、来源、搜索索引,但前端主体验以 Wiki 为中心,不做复杂图数据库管理页。 + +权限: + +- 普通用户:可查看、搜索。 +- 管理员:可初始化助手知识库、从 WeKnora 导入、重新导入、删除导入来源、编辑或删除 Wiki 页面。 + +### 30.3 数据来源 + +助手知识库的数据来源包括 WeKnora、技能归纳和管理员本地文件。 + +支持四种触发方式: + +| 方式 | 说明 | +| --- | --- | +| 自动沉淀 | WeKnora 普通知识库完成文档向量化、Wiki 生成和图谱抽取后,默认创建一个导入任务,把处理结果沉淀到助手知识库。 | +| 手动导入 | 管理员在普通知识库页面点击“导入到助手知识库”或“导出到知识梳理”,本质仍是导出 WeKnora 数据后导入助手库。 | +| 技能直接归纳 | 管理员在技能管理页选择助手知识库作为目标,系统直接写入自研助手底库。 | +| 本地文件导入 | 管理员在助手知识库页面上传本地文件,系统安全扫描后抽取实体、关系和 Wiki 页面。 | + +限制: + +- 普通用户不能写入助手知识库。 +- 本地文件导入必须走敏感信息扫描、文件类型白名单、大小限制和导入审计。 +- 技能直接归纳仍然完全排除代码文件、脚本文件和 Markdown 代码块内容。 + +### 30.4 WeKnora 导出范围 + +从 WeKnora 导入助手知识库时,需要尽可能导出全部已处理数据: + +- 知识库元数据。 +- Wiki 页面。 +- Wiki 页面之间的链接。 +- 已上传文档信息。 +- 已向量化完成的 chunk 文本、chunk metadata、来源文档映射。 +- WeKnora 可读取的实体。 +- WeKnora 可读取的关系。 +- WeKnora 可读取的图谱节点和边。 +- 技能归纳产生的 source metadata,例如 `skill_name`、`binding_id`、`snapshot_id`。 + +说明: + +- 助手知识库不强依赖复用 WeKnora embedding 向量。若 WeKnora 不暴露 embedding 原始向量,导入 chunk 文本和 metadata 即可。 +- 如果 WeKnora 不暴露稳定的图谱导出 API,则使用现有 Wiki graph、关系投影文档、Wiki 关系页和可查询接口重建实体关系。 +- 导入过程要记录能力缺失:例如“已导入 Wiki 与文档,图谱 API 不可用,关系由 Wiki 关系页重建”。 + +导出包建议: + +```json +{ + "export_id": "wk_export_001", + "source": { + "type": "weknora", + "knowledge_base_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "exported_at": "2026-09-01T10:00:00Z" + }, + "wiki_pages": [], + "documents": [], + "chunks": [], + "entities": [], + "relations": [], + "graph": { + "nodes": [], + "edges": [] + } +} +``` + +### 30.5 助手库实体对齐 + +助手知识库的核心价值是把来自不同 WeKnora 普通知识库、不同技能来源、不同 Wiki 页中的同一实体合并。 + +实体对齐范围是全局助手库: + +```text +assistant_base_id + entity_type + normalized_name +``` + +如果存在更强 ID,则优先: + +```text +assistant_base_id + entity_type + external_id_type + external_id +``` + +对齐规则: + +- 确定性同一:证件号、统一社会信用代码、装备编号、系统唯一 ID 等完全一致,直接合并。 +- 高置信名称同一:同类型实体 normalized name 一致,合并。 +- 模型推断同一:允许模型判断“张三”“张三同志”“Zhang San”是否同一人,但必须给出 evidence 和 confidence。 +- `confidence >= 0.90` 的模型对齐可以自动合并。 +- `0.70 <= confidence < 0.90` 的模型对齐不自动合并,可记录为候选别名或候选关联。 +- `confidence < 0.70` 丢弃。 + +合并后必须保留来源贡献: + +- 来自哪个 WeKnora 知识库。 +- 来自哪个 Wiki 页面或 chunk。 +- 来自哪个技能 binding。 +- 证据文本。 +- 导入任务 ID。 + +属性冲突不覆盖: + +- 不同来源给出不同姓名、职务、时间、地点、数值时,按来源分组展示。 +- Wiki 页面中展示“多来源信息”而不是最后写入覆盖。 + +### 30.6 助手库关系对齐 + +关系 key: + +```text +assistant_base_id + source_assistant_entity_id + normalized_predicate + target_assistant_entity_id +``` + +关系来源: + +- WeKnora 图谱边。 +- WeKnora Wiki graph 链接。 +- 技能归纳时写入 WeKnora 的关系页。 +- graph projection 文档。 +- 模型从 WeKnora 导出包中推断的高置信关系。 + +关系合并后保留多个证据贡献。 + +关系 Wiki 页面建议: + +```text +assistant/relations/{predicate}/{stable_relation_id} +``` + +关系页内容: + +- 源实体。 +- 关系类型。 +- 目标实体。 +- 关系描述。 +- 来源知识库。 +- 来源 Wiki 页。 +- 证据和置信度。 + +### 30.7 助手库 Wiki 生成 + +助手库最终以 Wiki 页面展示。 + +页面类型: + +| 页面 | slug 示例 | 说明 | +| --- | --- | --- | +| 首页 | `assistant/home` | 知识梳理总览、实体数量、来源数量、最近导入。 | +| 来源页 | `assistant/sources/{source_id}` | 某个 WeKnora 知识库导入结果。 | +| 实体页 | `assistant/entities/{type}/{entity_id}-{name}` | 对齐后的全局实体页面。 | +| 关系页 | `assistant/relations/{predicate}/{relation_id}` | 对齐后的全局关系页面。 | +| 主题页 | `assistant/topics/{topic}` | 从 WeKnora Wiki 分类或目录生成。 | + +实体页结构: + +- 标题:实体名。 +- 类型:人物、组织、地点、装备、事件、概念、数据对象等。 +- 摘要:模型基于多来源融合生成。 +- 别名。 +- 多来源属性。 +- 相关实体。 +- 相关关系。 +- 来源 Wiki 页面。 +- 证据。 + +助手库 Wiki 页面需要版本管理。每次 WeKnora 导入、技能直接归纳、管理员本地文件导入、实体对齐重建或人工编辑,都生成新 revision。新 revision 先处于 draft 或 staged 状态,发布成功后成为 current。 + +版本能力: + +- 查看历史版本。 +- 对比两个版本。 +- 回滚到历史版本。 +- 查看版本来源:导入任务、技能归纳任务、人工编辑。 +- 查看版本发布人和发布时间。 + +### 30.8 助手库搜索 + +助手知识库需要支持搜索。 + +V1 建议实现: + +- Wiki 标题搜索。 +- 实体名称和别名搜索。 +- Wiki 正文全文搜索。 +- 来源知识库过滤。 +- 实体类型过滤。 + +实现选择: + +- SQLite 可使用 FTS5。 +- PostgreSQL 可使用 tsvector 或 trigram。 +- 如果现有持久化层不方便,先实现数据库 LIKE + normalized name 索引,后续再升级全文索引。 + +搜索结果只展示 Wiki 页面,不直接展示底层 chunk。 + +### 30.9 助手库持久化表 + +建议新增: + +```text +deerflow.persistence.assistant_knowledge +``` + +核心表: + +#### `assistant_knowledge_base` + +只允许一条 active 记录。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `name` | 默认“知识梳理”。 | +| `status` | `active` / `disabled`。 | +| `created_by` | 初始化管理员。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_sources` + +记录每个来源。来源可以是 WeKnora 知识库、技能归纳或管理员本地导入。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_type` | `weknora` / `skill` / `local_file`。 | +| `knowledge_base_mapping_id` | DeerFlow 本地 WeKnora 映射 ID,可为空。 | +| `skill_name` | 技能名,可为空。 | +| `upload_batch_id` | 本地文件导入批次,可为空。 | +| `source_name` | 来源名称。 | +| `last_import_job_id` | 最近导入任务。 | +| `status` | `active` / `import_failed` / `deleted`。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_import_jobs` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_id` | 来源 ID。 | +| `trigger` | `auto_after_weknora_indexed` / `manual_from_weknora` / `skill_direct` / `manual_file_upload` / `scheduled` / `watcher`。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed`。 | +| `phase` | `exporting` / `scanning` / `parsing` / `awaiting_review` / `aligning_entities` / `aligning_relations` / `writing_wiki` / `indexing_search` / `completed`。 | +| `counts_json` | 导入页面、实体、关系、chunk 数。 | +| `last_error_message` | 错误摘要。 | +| `created_by` | 操作人;自动任务可为空或 system。 | +| `created_at` / `completed_at` | 时间戳。 | + +#### `assistant_knowledge_wiki_pages` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `slug` | 全局唯一 slug。 | +| `title` | 标题。 | +| `page_type` | `home` / `source` / `entity` / `relation` / `topic`。 | +| `current_revision_id` | 当前发布版本。 | +| `content_markdown` | 当前版本冗余内容,便于列表和搜索。 | +| `summary` | 摘要。 | +| `status` | `active` / `deleted`。 | +| `search_text` | 搜索文本。 | +| `updated_at` | 更新时间。 | + +#### `assistant_knowledge_wiki_revisions` + +保存 Wiki 页面版本。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `page_id` | Wiki 页面。 | +| `revision_no` | 递增版本号。 | +| `content_markdown` | 版本内容。 | +| `summary` | 版本摘要。 | +| `source_id` | 来源,可为空。 | +| `import_job_id` | 导入任务,可为空。 | +| `change_type` | `import` / `skill_sync` / `manual_edit` / `entity_rebuild` / `rollback`。 | +| `status` | `draft` / `staged` / `published` / `superseded`。 | +| `created_by` | 创建人。 | +| `created_at` / `published_at` | 时间戳。 | + +#### `assistant_knowledge_entities` + +保存全局对齐后的实体。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `attributes_json` | 聚合属性。 | +| `wiki_page_id` | 实体 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_entity_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `entity_id` | 全局实体。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `source_chunk_id` | 来源 chunk,可为空。 | +| `source_skill_name` | 来源技能,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +#### `assistant_knowledge_relations` + +保存全局对齐后的关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_entity_id` | 源实体。 | +| `predicate` | 关系类型。 | +| `target_entity_id` | 目标实体。 | +| `description` | 聚合描述。 | +| `wiki_page_id` | 关系 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_relation_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `relation_id` | 全局关系。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `graph_edge_id` | WeKnora 图谱边 ID,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +### 30.10 助手库 API + +新增 router: + +```text +/api/assistant-knowledge +``` + +建议接口: + +```http +GET /api/assistant-knowledge/base +POST /api/assistant-knowledge/base/initialize +GET /api/assistant-knowledge/wiki-pages +GET /api/assistant-knowledge/wiki-pages/{slug} +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id} +POST /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id}/rollback +GET /api/assistant-knowledge/search?q=xxx&type=entity&source_id=xxx +GET /api/assistant-knowledge/entities/{id} +GET /api/assistant-knowledge/relations/{id} +GET /api/assistant-knowledge/sources +GET /api/assistant-knowledge/import-jobs +GET /api/assistant-knowledge/import-jobs/{id} +POST /api/assistant-knowledge/sources/{source_id}/reimport +POST /api/assistant-knowledge/imports/files +POST /api/assistant-knowledge/imports/skills +DELETE /api/assistant-knowledge/sources/{source_id} +``` + +新增 WeKnora 普通知识库导出或导入触发接口: + +```http +POST /api/llmwiki/knowledge-bases/{knowledge_base_id}/export-to-assistant +``` + +行为: + +- 校验管理员。 +- 校验助手知识库已初始化;未初始化则自动创建“知识梳理”或提示管理员确认创建。 +- 校验 WeKnora 知识库已完成处理。 +- 创建 `assistant_knowledge_import_jobs`。 +- 后台导出 WeKnora 数据并导入助手库。 + +本地文件导入接口: + +```http +POST /api/assistant-knowledge/imports/files +``` + +使用 multipart 上传文件。后端必须先完成安全扫描和敏感信息阻断,再进入解析、抽取、审核、实体对齐和 Wiki 版本生成。 + +技能直接导入接口: + +```http +POST /api/assistant-knowledge/imports/skills +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "force": false +} +``` + +该接口也可以由 `/api/skill-knowledge/sync-jobs` 在目标类型为 `assistant` 时内部复用。 + +系统设置接口需要支持: + +```http +GET /api/system-settings/knowledge-base-product-version +PUT /api/system-settings/knowledge-base-product-version +``` + +### 30.11 前端页面 + +知识库入口需要支持产品版本切换。 + +建议: + +- 管理员设置页提供“知识库版本”切换:`v1 普通知识库` / `v2 助手知识库`,默认 `v1`。 +- 知识库页面顶部用 Tabs 或 Segmented Control 区分“普通知识库”和“助手知识库”。 +- 未配置 WeKnora 时,普通知识库 tab 隐藏或禁用;助手知识库 tab 正常显示。 +- 助手知识库未初始化时,展示“创建知识梳理库”按钮。 +- 创建后展示 Wiki 首页、搜索框、来源列表、最近导入和页面版本入口。 +- 普通知识库详情页增加按钮:“导入到助手知识库”。 +- 普通知识库列表中可显示“已沉淀到知识梳理 / 待导入 / 导入失败”状态。 +- 助手知识库页面为管理员提供“导入本地文件”和“导入技能”入口。 + +助手知识库页面建议: + +```text +知识梳理 +├── 搜索 +├── Wiki 首页 +├── 实体 +├── 关系 +├── 来源知识库 +├── 导入记录 +└── 页面版本 +``` + +普通用户只看到搜索、Wiki、实体、关系、来源和只读版本历史。管理员额外看到导入、重新导入、删除来源、版本对比和回滚。 + +### 30.12 自动沉淀时机 + +WeKnora 普通知识库完成以下任意流程后,应触发或排队助手库导入: + +- 管理员从技能归纳到 WeKnora 成功。 +- WeKnora 文档上传并完成向量化。 +- WeKnora Wiki 页面生成或更新完成。 +- WeKnora 图谱抽取完成。 +- 管理员手动点击“导入到助手知识库”。 + +为了避免频繁重复导入: + +- 对每个 WeKnora 知识库维护 `last_export_digest`。 +- 导出前计算 Wiki 页面、文档 chunk、图谱摘要的 digest。 +- digest 未变化时跳过导入。 +- 同一 WeKnora 知识库同一时间只允许一个 active 导入任务。 + +### 30.13 与技能归纳的关系 + +技能归纳支持两条链路。 + +链路 A:先进入 WeKnora,再沉淀助手库。 + +```text +技能知识文件 + -> WeKnora 普通知识库 + -> WeKnora 向量化 / Wiki / 图谱 + -> 导出已处理数据 + -> 助手知识库“知识梳理” + -> 全局实体对齐 + -> Wiki 展示与搜索 +``` + +链路 B:直接进入助手知识库。 + +```text +技能知识文件 + -> 助手知识库“知识梳理” + -> 实体/关系预览审核 + -> 全局实体对齐 + -> Wiki 页面版本发布 + -> 搜索索引 +``` + +代码文件在第一步就被排除,不进入后续任何知识库。 + +### 30.14 新增测试与验收 + +新增后端测试: + +- 未配置 WeKnora 时,助手知识库 base 接口可用,普通知识库导入接口返回明确不可用。 +- 初始化助手知识库后,只存在一个 active base。 +- 普通用户可读助手 Wiki,写接口返回 403。 +- 管理员可从 WeKnora 知识库触发导入任务。 +- 管理员可把技能直接导入助手知识库。 +- 管理员可上传本地文件导入助手知识库。 +- WeKnora 导出包包含 Wiki、文档、chunk、实体、关系、图谱时,助手库全部导入。 +- 同一个人或同一对象来自多个 WeKnora 来源时,在助手库合并为一个实体。 +- 模型推断实体对齐 `confidence >= 0.90` 自动合并,低于阈值不合并。 +- 删除某个来源时,只删除该来源贡献;其他来源贡献仍保留。 +- 助手库搜索能命中 Wiki 标题、实体别名和正文。 +- 助手 Wiki 页面支持 revision、对比和回滚。 +- 技能归纳扫描不读取、不上传、不摘要代码文件,代码文件变化不触发 stale。 + +新增前端测试: + +- 知识库产品版本默认 `v1`。 +- 管理员可切换 `v1` / `v2`。 +- 未配置 WeKnora 时仍可看到“创建知识梳理库”。 +- 普通知识库详情页有“导入到助手知识库”按钮。 +- 助手知识库页面展示 Wiki、搜索、来源、导入记录。 +- 助手知识库页面展示页面版本、对比和回滚入口。 +- 普通用户看不到导入、删除、重新导入和回滚动作。 diff --git a/frontend-web/docs/沙箱统一写作管线-实现方案.md b/frontend-web/docs/沙箱统一写作管线-实现方案.md new file mode 100644 index 00000000..5db7335b --- /dev/null +++ b/frontend-web/docs/沙箱统一写作管线-实现方案.md @@ -0,0 +1,822 @@ +# 沙箱统一写作管线:可实现开发方案 + +> 状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。 +> 覆盖范围:普通沙箱 Markdown、深度研究 `report.md`、现有 Markdown 编辑器的选区/段落改写。 + +## 1. 目标与结论 + +目标不是再增加一个“AI 写作页面”,而是将现有的四类能力统一为一个安全、可流式、可追溯的**文档改写管线**: + +1. Markdown 编辑器选区改写; +2. Markdown 编辑器段落改写; +3. 深度研究报告的局部候选改写; +4. 沙箱中 Markdown 文件的全文改写。 + +该管线负责固定原文版本、构造上下文、逐 token 输出、校验 Markdown、生成可核验 diff、按策略提交,以及在全文覆盖时保存可撤销版本。 + +结论:需求可行,现有工程已有足够的复用基础: + +- `frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx` 已具备局部改写弹窗与人工“替换/插入/重试/追问”交互。 +- `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` 与 `frontend-web/src/open-canvas/api/rewrite.ts` 已具备 SSE 文本流消费。 +- `offline-backend-20260512/backend/app/gateway/routers/writing.py` 已具备模型流式读取实现。 +- `offline-backend-20260512/backend/app/gateway/routers/deep_research.py` 已具备报告哈希、候选内容和用户确认应用机制。 +- `frontend-web/src/components/workspace/messages/message-list.tsx`、`MessageGroup.tsx` 已可承载普通对话的工具步骤条、流式消息和文件卡片。 +- `frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx` 已经通过虚拟消息把研究进度接入普通 `MessageList`。 + +因此应该复用这些基础,建设一个执行内核;不能为全文改写另建独立聊天列表或独立写作流。 + +## 2. 产品边界与交互策略 + +### 2.1 支持范围 + +第一版仅对 `.md`、`.markdown` 文件显示“AI 全文改写”按钮。 + +不支持 PDF、DOCX、HTML、图片和二进制文件;不在改写过程中发起新的联网搜索;超大文件不静默截断后覆盖,而是提示用户选择章节改写。 + +### 2.2 范围、模式和提交策略 + +| 入口 | 文本范围 | 管线模式 | 提交策略 | 展示位置 | +| --- | --- | --- | --- | --- | +| 编辑器气泡菜单 | 当前选区 | `selection` | `preview_only` | 现有改写预览弹窗 | +| 编辑器段落菜单 | 当前段落 | `paragraph` | `preview_only` | 现有改写预览弹窗 | +| 深度研究“改写第一段/章节” | 被解析出的报告片段 | `report_section` | `manual_apply` | 普通消息列表候选稿 + 确认卡 | +| 文件右上角“AI 全文改写” | 当前完整 Markdown | `document` | `auto_commit_after_validation` | 普通消息列表步骤条 + 右侧沙箱 | + +统一内核中的枚举: + +~~~python +class RewriteMode(str, Enum): + SELECTION = "selection" + PARAGRAPH = "paragraph" + REPORT_SECTION = "report_section" + DOCUMENT = "document" + +class CommitPolicy(str, Enum): + PREVIEW_ONLY = "preview_only" + MANUAL_APPLY = "manual_apply" + AUTO_COMMIT_AFTER_VALIDATION = "auto_commit_after_validation" +~~~ + +这三类策略必须严格区分: + +- `preview_only`:只生成候选,前端编辑器确认后才改本地 TipTap 文档;不写真实沙箱文件。 +- `manual_apply`:后端保留基准哈希和候选片段,用户点确认后才将该片段应用进研究报告。 +- `auto_commit_after_validation`:全文生成完成并校验通过后,后端进行一次带哈希校验的原子覆盖;同时保存可撤销版本。 + +### 2.3 局部编辑器:保持当前体验 + +现有文件: + +- `frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx` +- `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` +- `frontend-web/src/open-canvas/api/rewrite.ts` +- `offline-backend-20260512/backend/app/gateway/routers/writing.py` + +局部改写仍然是: + +1. 选中文字或打开段落菜单; +2. 选择润色、扩写、缩写、改写等动作; +3. 在现有弹窗中流式出现候选; +4. 用户手动选择替换、插入、重试或继续追问。 + +不要让局部改写每次都往普通消息列表插完整步骤条,否则频繁编辑会淹没聊天历史。它只共享底层管线,不共享全文改写的消息展示方式。 + +需要补充本地版本保护:发起请求时保存当前完整编辑器文本哈希、选区 `from/to` 和选区文本;用户点击“替换原文”前再次检查该范围仍匹配。若用户已修改原文,提示“原文已变化,请重新生成候选稿”,禁止将过期候选替换到错误位置。 + +### 2.4 右上角“AI 全文改写” + +宿主文件: + +- `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` + +新增独立图标按钮,位置在右上角文件操作区,靠近保存、复制、下载一类操作。它**不能**替代页面中间当前 Sparkles 编辑模式: + +| 控件 | 职责 | +| --- | --- | +| 中部 Sparkles/编辑模式 | 打开 `MarkdownAiEditor`,处理选区和段落 | +| 新增右上角 AI 全文改写 | 对整个 Markdown 发起后台全文重写任务 | + +建议显示条件: + +~~~ts +const canUseFullDocumentRewrite = + isMarkdownFile && + canEditArtifact && + !isSkillFile && + !isMock && + !useWriteFileStreaming && + !hasLocalEdits; +~~~ + +若 `hasLocalEdits` 为真,按钮置灰并提示“请先保存当前修改后再进行全文改写”。后端全文任务从真实文件读取快照,不能在用户本地还有未保存修改时读取旧版本并最终覆盖新修改。 + +用户体验: + +1. 点击按钮,打开简洁配置弹窗。 +2. 弹窗显示文件名、原文字数;用户填写改写要求、选择模型、选择风格。 +3. 点击开始,左侧普通消息列表创建一组步骤条。 +4. 右侧沙箱自动切到源码视图,先显示“正在准备临时草稿,原文件尚未修改”。 +5. 新 Markdown 从空临时草稿开始逐 token 写入右侧代码区域。 +6. 校验通过后,后端原子替换真实内容;前端再同步真实 Artifact 缓存。 +7. 左侧保留最终对比总结、查看 diff 按钮和撤销按钮。 + +任务进行中,同一个文件的保存、直接编辑、局部编辑和再次全文改写必须禁用;其他文件不受影响。 + +### 2.5 深度研究报告的约束 + +普通 Markdown 和深度研究报告使用同一管线,但不能使用同一上下文策略: + +| 能力 | 普通沙箱 Markdown | 深度研究 report.md | +| --- | --- | --- | +| 输入上下文 | 原文 + 用户指令 | 原文 + 当前会话已选资料 | +| 联网 | 不联网 | 不联网 | +| 事实约束 | 不新增无依据事实 | 只能引用已选资料,禁止编造 | +| 引用校验 | 可选 | 必须校验来源 ID 白名单 | +| 最终存储 | Artifact 文件 | 研究 session 的 `report_markdown` | +| 局部改写 | 编辑器确认 | 现有确认卡片 | + +深度研究右侧的 `report.md` 是由 `ResearchSandboxPanel.tsx` 以虚拟 `write_file` 方式展示的;它不能当作普通磁盘 Artifact 直接写入。全文任务必须通过研究 session 更新 `report_markdown`,并使 `report_html` 缓存失效。 + +## 3. 总体架构 + +~~~mermaid +flowchart LR + A["MarkdownAiEditor
选区 / 段落"] --> P + B["ArtifactFileDetail
AI 全文改写"] --> P + C["DeepResearch
局部 / 全文"] --> P + + P["Document Rewrite Pipeline
冻结版本 → 构造上下文 → 流式生成
校验 → 差异总结 → 按策略提交"] + + P --> I["InlineDocumentSource"] + P --> F["ArtifactDocumentSource"] + P --> R["DeepResearchReportSource"] + + P --> E["可恢复 SSE 事件 / Job"] + E --> L["普通 MessageList"] + E --> S["右侧沙箱临时草稿"] + P --> V["内容哈希、版本快照、原子提交"] +~~~ + +设计原则: + +1. 纯执行内核不依赖 FastAPI、路由、用户身份或操作系统路径。 +2. 鉴权、线程路径解析、研究 session 获取只在 `app` 层的来源适配器中完成。 +3. 完整文档任务使用持久化后台 Job;局部弹窗可维持单次 SSE,避免过度持久化。 +4. 全文流式过程中只写临时草稿;真实内容只在最终 compare-and-swap 成功时改变。 + +不要直接把现有完整“AI 写作 Agent”塞进改写按钮。那个流程适合从主题写新文章,可能包含大纲、资料和章节生命周期;本需求是严格以当前文档为基准改写。第一版复用它的流式视觉表达和消息列表能力,但使用专门的文档改写执行内核。 + +## 4. 后端模块设计 + +### 4.1 目录与职责 + +建议新增: + +~~~text +offline-backend-20260512/backend/ +├─ packages/harness/deerflow/ +│ ├─ document_rewrite/ +│ │ ├─ __init__.py +│ │ ├─ models.py # 请求、事件、校验、差异领域模型 +│ │ ├─ ports.py # Source / Model / Store 协议 +│ │ ├─ pipeline.py # 统一编排,不导入 app.* +│ │ ├─ prompt_builder.py # 普通与深度研究提示词 +│ │ ├─ validation.py # Markdown / 引用 / 截断检查 +│ │ └─ diff_summary.py # 字数、标题、块、引用差异 +│ └─ persistence/ +│ └─ document_rewrites/ +│ ├─ base.py +│ ├─ model.py +│ └─ sql.py +├─ app/gateway/ +│ ├─ routers/document_rewrites.py +│ ├─ document_rewrite_sources.py +│ └─ document_rewrite_executor.py +└─ packages/harness/deerflow/persistence/migrations/versions/ + └─ _document_rewrites.py +~~~ + +`packages/harness/deerflow/document_rewrite` 不能导入 `app.*`,以符合项目 harness→app import firewall。`app/gateway/document_rewrite_sources.py` 负责把已鉴权的 Artifact/研究会话包装成内核端口。 + +### 4.2 来源端口 + +~~~python +class DocumentSource(Protocol): + async def read_snapshot(self) -> DocumentSnapshot: ... + async def read_current_hash(self) -> str: ... + async def commit_if_unchanged( + self, + *, + expected_hash: str, + content: str, + metadata: CommitMetadata, + ) -> CommitResult: ... + async def restore_if_unchanged( + self, + *, + expected_hash: str, + content: str, + ) -> CommitResult: ... +~~~ + +三种实现: + +| 适配器 | 使用场景 | 读取方式 | 提交方式 | +| --- | --- | --- | --- | +| `InlineDocumentSource` | 选区/段落候选 | 前端编辑器快照 | 不提交 | +| `ArtifactDocumentSource` | 普通 Markdown | 复用 Artifact 权限与虚拟路径解析 | 同目录临时文件 + `os.replace` | +| `DeepResearchReportSource` | 研究报告 | session `report_markdown` + 已选来源 | 数据库哈希条件更新 | + +`ArtifactDocumentSource` 必须复用 `app/gateway/routers/artifacts.py` 中已有的线程访问校验、虚拟路径解析逻辑,绝不允许请求体传入任意本机绝对路径。 + +### 4.3 持久化 Job、事件和版本 + +全文改写使用持久化 Job,参考 `app/gateway/deep_research_job_executor.py` 的后台任务、租约、取消监听和事件重放模式。 + +#### document_rewrite_jobs + +| 字段 | 作用 | +| --- | --- | +| `id` | `drw_xxx` Job ID | +| `user_id` | 发起人 | +| `source_kind` | `artifact` / `deep_research_report` | +| `thread_id`、`artifact_path` | 普通文件来源 | +| `research_session_id` | 研究报告来源 | +| `presentation_thread_id` | 普通消息列表归属 | +| `mode`、`commit_policy` | 范围和提交语义 | +| `instruction`、`style`、`model_name` | 用户配置 | +| `base_sha256` | 原文冻结哈希 | +| `draft_content`、`draft_sha256` | 临时流式草稿 | +| `status`、`stage` | 生命周期 | +| `validation_json`、`diff_summary_json` | 可展示结果 | +| `committed_sha256` | 成功提交后的哈希 | +| `error_message`、时间字段 | 错误、审计、取消 | + +#### document_rewrite_events + +至少包含 `job_id`、`seq`、`event_type`、`stage`、`payload_json`、`created_at`。`seq` 用于断线重连时通过 `after_seq` 重放。 + +不必将每个 token 单独入库:每 250ms 或每积累 4KB 持久化一次 `draft_content`;状态转换、警告、完成、冲突、取消必须立即写事件。重连时先发送最新草稿快照,再发送快照序号后的增量事件。 + +#### document_rewrite_versions + +保存真实全文覆盖前的原文版本,字段包含 `job_id`、`source_identity`、`version_no`、`content`、`sha256`、`reason`、`created_at`。初版每个文档保留最近 20 个版本即可。 + +索引要求: + +- `(user_id, presentation_thread_id, created_at DESC)`:恢复历史消息。 +- 真实来源标识 + 活跃状态:防止同一文档并行两个全文改写。 + +### 4.4 Job 状态机 + +~~~mermaid +stateDiagram-v2 + [*] --> queued + queued --> snapshotting + snapshotting --> generating + generating --> validating + validating --> comparing + comparing --> committing + committing --> completed + completed --> reverted + + queued --> cancelled + generating --> cancelled + snapshotting --> failed + generating --> failed + validating --> failed + committing --> conflict + committing --> failed +~~~ + +状态约束: + +- 同一真实文档只允许一个活跃 Job。 +- 生成期间取消、异常、断线,真实文档绝不能变化。 +- 提交前重新读取当前哈希;与 `base_sha256` 不一致时进入 `conflict`,草稿保留但不覆盖。 + +### 4.5 API 与 SSE 契约 + +创建全文任务: + +~~~http +POST /api/document-rewrites +Content-Type: application/json +~~~ + +普通 Artifact 请求: + +~~~json +{ + "source": { + "kind": "artifact", + "thread_id": "thread_xxx", + "path": "/mnt/user-data/outputs/report.md" + }, + "presentation_thread_id": "thread_xxx", + "mode": "document", + "instruction": "统一为正式行业分析语气,减少重复,不新增外部事实", + "style": "formal_analysis", + "model_name": "system_default" +} +~~~ + +深度研究请求: + +~~~json +{ + "source": { + "kind": "deep_research_report", + "session_id": "drs_xxx" + }, + "presentation_thread_id": "collector_thread_xxx", + "mode": "document", + "instruction": "重新组织全文逻辑,保留全部可用引用", + "style": "formal_analysis", + "model_name": "system_default" +} +~~~ + +其他接口: + +~~~text +GET /api/document-rewrites/{job_id} +GET /api/document-rewrites/{job_id}/stream?after_seq=42 +POST /api/document-rewrites/{job_id}/cancel +POST /api/document-rewrites/{job_id}/undo +GET /api/document-rewrites?presentation_thread_id={thread_id} +~~~ + +SSE 事件: + +| 事件 | 关键数据 | 前端动作 | +| --- | --- | --- | +| `job_started` | 文件名、模式 | 建立步骤条 | +| `snapshot_fixed` | 原文字数、哈希 | 完成“固定原文版本” | +| `requirements_resolved` | 模型、风格、要求 | 完成“理解改写要求” | +| `rewrite_delta` | `delta`、累计字符数 | 追加右侧临时草稿 | +| `validation_started` | 校验项 | 进入校验步骤 | +| `validation_completed` | passed、warnings | 显示校验结果 | +| `comparison_ready` | `DiffSummary` | 渲染总结数据 | +| `commit_started` | 无 | 显示安全替换中 | +| `committed` | 最终哈希、版本 ID | 刷新真实文件缓存 | +| `completed` | 完整结果 | 固化历史步骤条 | +| `conflict` / `error` / `cancelled` | 原因 | 恢复原文显示,不保存草稿 | + +局部改写兼容方案: + +- `POST /api/writing/rewrite` 的请求和 SSE 格式先不变。 +- `writing.py` 内部逐步把 prompt、模型流、基础 Markdown 校验下沉给统一管线的 `preview_only` 分支。 +- 深度研究 `/sessions/{id}/rewrite/stream` 和确认候选接口也先保持 URL、前端行为不变,再内部复用 `manual_apply` 分支。 + +这样可以先交付全文改写,不会一次性破坏已有编辑器和深度研究流程。 + +### 4.6 模型、提示词与校验 + +管线只依赖一个流式模型端口: + +~~~python +class StreamingRewriteModel(Protocol): + async def stream_text( + self, + *, + messages: list[BaseMessage], + model_name: str | None, + ) -> AsyncIterator[TextChunk]: ... +~~~ + +第一版可以有两个 adapter: + +- 普通文档复用 `writing.py` 的 chat model `astream`; +- 深度研究复用 `deep_research.py` 使用的 `DeerFlowCompletionBackend.stream_complete`。 + +先统一编排,不强制立即统一所有模型配置实现。 + +普通 Markdown 系统约束: + +~~~text +仅根据原文与用户要求改写,不联网,不补充未给出的事实。 +保留有效链接、代码块、表格、标题语义和用户明确要求保留的内容。 +只输出完整 Markdown 正文;不得输出解释、前缀或“改写如下”。 +~~~ + +深度研究额外约束: + +~~~text +不得联网。事实、数字、结论和引用只能来自当前 session 已选资料。 +只允许使用给出的 source_id;资料不足时保持谨慎表述,禁止自行补全。 +~~~ + +校验规则: + +| 类别 | 硬失败 | 告警但允许完成 | +| --- | --- | --- | +| 正文 | 空文档、仅空白、无可见内容 | 改写后与原文完全一致 | +| Markdown | 未闭合围栏、明确截断 | 标题层级跳级、章节数明显减少 | +| 深度研究来源 | 未选来源 ID、非法引用 | 引用数量下降、可能需核验的数字断言 | + +## 5. 安全提交、对比与撤销 + +### 5.1 临时草稿不等于真实文件 + +用户视觉上看到“先清空再逐 token 写入”,但真实文件的处理必须是: + +1. 读取原文并保存内容哈希、版本快照。 +2. 右侧只展示内存/Job 中的 `draft_content`。 +3. 生成完成后做校验和 diff。 +4. 提交前再次读取真实来源,并比较 `SHA-256`。 +5. 哈希一致才原子替换;否则进入冲突,不覆盖。 + +普通 Artifact 使用同目录临时文件、刷盘和 `os.replace`;深度研究通过数据库事务内的“旧哈希条件更新”提交。任何失败、取消或断流都保留真实原文。 + +### 5.2 撤销 + +撤销流程: + +1. 读取该 Job 的 `before_full_rewrite` 快照。 +2. 确认当前真实内容哈希仍等于该 Job 的 `committed_sha256`。 +3. 若用户在改写后又手工编辑,返回冲突,不盲目覆盖。 +4. 先保存当前内容为 `before_undo` 版本。 +5. 原子恢复旧版本,Job 状态改为 `reverted`,消息列表卡片同步显示“已撤销”。 + +### 5.3 结构化 DiffSummary + +后端产出结构化差异,前端仅渲染,避免生成空泛结论: + +~~~ts +type DiffSummary = { + sourceDisplayName: string; + instruction: string; + modelName: string; + originalCharCount: number; + rewrittenCharCount: number; + charChangeRatio: number; + changedBlockCount: number; + headings: { + added: string[]; + removed: string[]; + reordered: string[]; + levelWarnings: string[]; + }; + citations?: { + beforeCount: number; + afterCount: number; + retainedCount: number; + removedIds: string[]; + invalidIds: string[]; + }; + requestedOptimizations: string[]; + validationWarnings: string[]; +}; +~~~ + +推荐总结展示: + +~~~text +已完成《新能源汽车市场分析报告》的整体改写。 + +- 改写范围:全文;要求为“统一正式分析语气、减少重复”;模型:默认模型。 +- 数量变化:原文 8,260 字,改写后 8,110 字,减少 1.8%;调整 14 个内容块。 +- 结构变化:标题层级保持不变;未新增或删除一级标题。 +- 已执行的优化:按要求合并重复表述、统一语气、调整段落衔接。 +- 保留情况:保留 35 条已选来源引用,未新增外部来源。 +- 风险提示:无。 + +[查看前后对比] [撤销到改写前版本] +~~~ + +“已执行的优化”仅来自用户要求和可计算的结构差异;不能无依据宣称“文章更专业、更严谨”。 + +## 6. 前端实现设计 + +### 6.1 新增模块 + +~~~text +frontend-web/src/core/document-rewrite/ +├─ types.ts +├─ api.ts +├─ sse.ts +├─ use-document-rewrite-job.ts +├─ document-rewrite-provider.tsx +└─ document-rewrite-presentation.ts + +frontend-web/src/components/workspace/document-rewrite/ +├─ DocumentRewriteDialog.tsx +├─ DocumentRewriteProgressStep.tsx +├─ DocumentRewriteSummaryCard.tsx +└─ DocumentDiffDialog.tsx +~~~ + +职责: + +| 模块 | 职责 | +| --- | --- | +| `api.ts` | 创建、查询、取消、撤销 Job | +| `sse.ts` | SSE 解析、`after_seq` 重连 | +| `use-document-rewrite-job.ts` | Job 状态、草稿、rAF 合并 token | +| Provider | 同时向文件面板与消息列表提供同一任务状态 | +| `DocumentRewriteDialog` | 改写要求、模型、风格配置 | +| ProgressStep | 普通消息列表内的五步步骤条 | +| SummaryCard | 结构化总结、diff、撤销动作 | +| DiffDialog | 原文与改写后双栏/行级对比 | + +模型选择使用项目已有的 `tdesign-react` 选择组件,放在全文改写配置弹窗中;局部编辑器保留当前模型传递方式,后续再统一选择器。 + +### 6.2 流式平滑:每帧合并 delta + +不能因为 SSE 是 token 流就每 token 调一次 `setState`。应当网络层完整读取、渲染层按动画帧合并: + +~~~ts +const pendingDeltaRef = useRef(""); +const frameRef = useRef(null); + +function onRewriteDelta(delta: string) { + pendingDeltaRef.current += delta; + if (frameRef.current !== null) return; + + frameRef.current = requestAnimationFrame(() => { + const pending = pendingDeltaRef.current; + pendingDeltaRef.current = ""; + frameRef.current = null; + setDraftContent((previous) => previous + pending); + }); +} +~~~ + +完成、失败、取消前必须 flush 最后一段缓存。右侧代码编辑器只消费 `draftContent`;真实 Artifact 查询缓存直到 `committed` 事件才更新。 + +### 6.3 ArtifactFileDetail 改造 + +在 `artifact-file-detail.tsx` 增加: + +~~~ts +type DocumentRewriteTarget = + | { + kind: "artifact"; + threadId: string; + path: string; + presentationThreadId: string; + } + | { + kind: "deep_research_report"; + sessionId: string; + presentationThreadId: string; + }; +~~~ + +改造步骤: + +1. 加入右上角全文改写图标按钮和 `DocumentRewriteDialog`。 +2. 有活跃任务时强制 `viewMode = "code"`。 +3. 内容优先级为:`activeRewrite.draftContent ?? draftContent ?? artifactContent`。 +4. 活跃任务中代码只读,禁用保存、局部编辑和重复全文改写。 +5. 收到 `committed` 后用现有 `useUpdateArtifactContent` 或等价 Query cache 更新,使真实文件立即同步。 +6. 收到 `conflict/error/cancelled` 时废弃临时草稿显示,回到真实文件内容。 + +### 6.4 ResearchSandboxPanel 接入 + +相关文件: + +- `frontend-web/src/pages/deep-research/ResearchSandboxPanel.tsx` +- `frontend-web/src/pages/deep-research/useReportSandbox.ts` +- `frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx` + +改造: + +1. `ResearchSandboxPanel` 为虚拟 `report.md` 传入 `kind: "deep_research_report"` target。 +2. `useReportSandbox` 接受 `externalDraftOverride`: + +~~~ts +const visibleReport = activeDocumentRewrite?.isActive + ? activeDocumentRewrite.draftContent + : reportMarkdown; +~~~ + +3. 流式期间只显示临时草稿,不提前写 session 报告。 +4. `committed` 后刷新/替换 session 的 `report_markdown`;失败后恢复旧报告。 +5. 当前“改写第一段 → 候选消息 → 确认应用”逻辑继续保留,不因全文改写而改变。 + +### 6.5 普通消息列表接入 + +复用: + +- `frontend-web/src/components/workspace/messages/message-list.tsx` +- `frontend-web/src/components/workspace/messages/MessageGroup.tsx` +- `DeepResearchWorkbench.tsx` 中的虚拟消息注入模式。 + +`DocumentRewritePresentationProvider` 按 `presentation_thread_id` 读取 Job 历史并投影为: + +1. 用户消息:“AI 全文改写《xxx.md》”; +2. AI 工具调用:`document_rewrite_progress`; +3. AI 富消息:`document_rewrite_summary`。 + +`MessageGroup.tsx` 增加 `document_rewrite_progress` 分支,渲染 `DocumentRewriteProgressStep`。最终摘要渲染 `DocumentRewriteSummaryCard`,其内含“查看前后对比”和“撤销”。 + +全文步骤固定为: + +| 步骤 | 展示内容 | +| --- | --- | +| 固定原文版本 | 文件名、原文字数、快照已保存 | +| 理解改写要求 | 改写目标、模型、风格 | +| 流式重写 Markdown | 正在写入右侧 xxx.md、累计字数 | +| 校验文档 | 非空、标题、围栏、截断;报告还含来源检查 | +| 对比原文并生成说明 | 差异、风险、撤销入口 | + +步骤条必须在完成后保留,不能因为流结束被隐藏。 + +## 7. 核心执行伪代码 + +~~~python +async def run_document_rewrite(job_id: str) -> None: + job = await jobs.acquire_lease(job_id) + source = source_factory.create(job) # 已完成鉴权的来源适配器 + + await emit("snapshotting") + snapshot = await source.read_snapshot() + base_hash = sha256(snapshot.content) + await jobs.freeze_snapshot(job_id, snapshot, base_hash) + await emit("snapshot_fixed", char_count=count_chars(snapshot.content)) + + messages = prompt_builder.build( + mode=job.mode, + source_kind=job.source_kind, + original=snapshot.content, + instruction=job.instruction, + style=job.style, + research_sources=snapshot.allowed_sources, + ) + await emit("requirements_resolved", ...) + + draft = "" + async for chunk in model.stream_text(messages=messages, model_name=job.model_name): + if await jobs.cancel_requested(job_id): + await jobs.mark_cancelled(job_id) + await emit("cancelled") + return + draft += chunk.text + await jobs.append_draft_periodically(job_id, draft) + await emit("rewrite_delta", delta=chunk.text) + + await emit("validation_started") + validation = validate_markdown( + draft, + source_kind=job.source_kind, + allowed_citations=snapshot.allowed_citation_ids, + ) + if validation.has_fatal_error: + await jobs.mark_failed(job_id, validation.message) + await emit("error", ...) + return + await emit("validation_completed", ...) + + diff = build_diff_summary(snapshot.content, draft, validation) + await jobs.save_diff(job_id, diff) + await emit("comparison_ready", diff) + + if job.commit_policy != AUTO_COMMIT_AFTER_VALIDATION: + await jobs.mark_candidate_ready(job_id) + return + + await emit("commit_started") + await versions.save_before_rewrite(job, snapshot) + result = await source.commit_if_unchanged( + expected_hash=base_hash, + content=draft, + metadata=CommitMetadata(job_id=job_id), + ) + if not result.ok: + await jobs.mark_conflict(job_id) + await emit("conflict", ...) + return + + await jobs.mark_completed(job_id, result.sha256) + await emit("committed", ...) + await emit("completed", diff) +~~~ + +## 8. 实施顺序 + +### 阶段 A:后端内核与安全基础 + +1. 新增领域模型、来源端口、校验、确定性 diff。 +2. 新增 Job、事件、版本数据表与 migration。 +3. 参考深度研究 executor 完成后台任务、取消、SSE 重放。 +4. 实现普通 Artifact 读取、哈希比较、原子提交和撤销 API。 +5. 编写接口测试。 + +验收:可以通过 API 对普通 Markdown 创建全文任务;取消、失败、冲突均不改原文件;成功后可撤销。 + +### 阶段 B:普通沙箱全文 UI + +1. 新增 `core/document-rewrite` 前端状态、SSE、rAF hook。 +2. 改 `ArtifactFileDetail`:右上角按钮、配置弹窗、临时草稿、禁用态。 +3. 接入普通 `MessageList` 的步骤条、总结卡、diff 和撤销。 +4. 支持刷新后恢复 Job 状态与已完成历史。 + +验收:右侧可平滑逐 token 写入;完成后替换真实 Markdown;左侧步骤和总结保留;撤销成功恢复。 + +### 阶段 C:深度研究接入 + +1. 实现 `DeepResearchReportSource`。 +2. 由 `ResearchSandboxPanel` 传入报告 target。 +3. `useReportSandbox` 支持外部临时草稿。 +4. 对深度研究添加引用白名单校验。 +5. 保持现有报告局部候选确认卡不变,再逐步下沉公共 prompt/校验逻辑。 + +验收:报告全文改写不触发联网搜索,不允许新来源;局部报告改写依然先确认再应用。 + +### 阶段 D:局部改写内核迁移 + +1. 保持 `/api/writing/rewrite` 契约不变。 +2. 将 `writing.py` 转为 `InlineDocumentSource + preview_only` 的兼容 facade。 +3. 给 `useWritingRewrite` 增加原文快照失效检查。 +4. 回归替换、插入、重试、继续追问。 + +## 9. 测试与验收 + +建议新增后端测试: + +~~~text +offline-backend-20260512/backend/tests/ +├─ test_document_rewrite_pipeline.py +├─ test_document_rewrite_api.py +├─ test_document_rewrite_versions.py +└─ test_document_rewrite_deep_research.py +~~~ + +必须覆盖: + +- Job 状态、SSE 顺序和断线恢复; +- 空输出、围栏不闭合、截断、非法深度研究引用; +- 同一文件并发任务; +- 提交前手工修改导致的冲突; +- 取消/异常不改真实文件; +- 正常撤销、撤销前手工编辑导致的冲突; +- 旧 `/api/writing/rewrite`、深度研究 `/rewrite/stream` 兼容回归。 + +前端必须覆盖: + +- 多个 delta 在一帧合并,最终内容不丢失; +- `committed` 前 Artifact 缓存不被临时草稿污染; +- 失败/取消/冲突后右侧恢复原文; +- 活跃任务禁用同文件编辑、保存、重复改写; +- 刷新后历史步骤条与总结仍存在; +- 深度研究虚拟 `report.md` 可用全文改写; +- 局部编辑器原文变化后不能应用过期候选。 + +执行基线: + +~~~powershell +cd offline-backend-20260512/backend +$env:PYTHONPATH = "." +uv run --no-sync pytest tests/test_document_rewrite_pipeline.py -v +uv run --no-sync pytest tests/test_document_rewrite_api.py -v +uv run --no-sync ruff check app packages/harness + +cd ../../frontend-web +pnpm typecheck +pnpm build +~~~ + +## 10. 风险控制 + +| 风险 | 控制措施 | +| --- | --- | +| 用户误以为真实文件已经清空 | 明确显示“临时草稿,原文件尚未修改” | +| 流式显示仍然卡顿 | 网络完整读取 + `requestAnimationFrame` 合并渲染 | +| 改写中用户手动编辑 | `SHA-256` compare-and-swap,冲突即拒绝覆盖 | +| 输出 Markdown 不完整 | 非空、围栏、截断和标题检查 | +| 深度研究幻觉 | 来源白名单、禁止联网、引用 ID 校验 | +| 文档过大 | 第一版拒绝并提示按章节改写,不隐式截断 | +| 局部编辑污染聊天历史 | 局部只在编辑器弹窗展示,不创建完整步骤条 | +| 历史记录只存在浏览器内存 | 全文 Job/Event/Diff 持久化,按展示线程恢复 | + +## 11. 复用点清单 + +| 现有实现 | 在新方案中的用途 | +| --- | --- | +| `MarkdownAiEditor.tsx` | 保留局部编辑菜单、预览弹窗、确认动作 | +| `useWritingRewrite.ts` | 保留局部流控制,增加快照失效保护 | +| `open-canvas/api/rewrite.ts` | 保持兼容,逐步接入统一内核 | +| `routers/writing.py` | 复用当前模型流式读取,抽取 prompt/流逻辑 | +| `routers/deep_research.py` | 复用报告来源、哈希候选、来源约束 | +| `deep_research_job_executor.py` | 复用后台 Job、取消、事件重放模式 | +| `artifact-file-detail.tsx` | 复用沙箱文件操作、代码/预览、缓存同步 | +| `useUpdateArtifactContent` | 完成后更新普通 Artifact 缓存 | +| `ResearchSandboxPanel.tsx` | 复用研究报告的右侧沙箱宿主 | +| `useReportSandbox.ts` | 加入临时草稿覆盖显示 | +| `MessageList.tsx / MessageGroup.tsx` | 复用普通消息步骤条、总结消息、文件卡片能力 | +| `DeepResearchWorkbench.tsx` | 复用向普通消息列表投影过程事件的模式 | + +## 12. 完成定义 + +完成后应满足: + +1. 局部编辑依旧是编辑器弹窗中的人工确认操作。 +2. 全文改写在普通消息列表展示完整且不隐藏的步骤条。 +3. 右侧沙箱可平滑地逐 token 展示临时 Markdown 草稿。 +4. 任何中断、取消、校验失败和冲突都不损坏真实文件。 +5. 完成后用户可以看到量化差异、结构变化、来源保留和风险提示。 +6. 普通 Markdown 与深度研究报告都可全文改写,但研究报告绝不绕过已选来源约束。 +7. 每一次真实全文覆盖都有版本快照,并且能够安全撤销。 + diff --git a/frontend-web/package.json b/frontend-web/package.json index fb189ccb..28755b89 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -11,6 +11,7 @@ "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", "typecheck": "tsc --noEmit", + "test:report-collaboration": "npx esbuild src/report-collaboration/tests/event-reducer.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/event-reducer.test.mjs && npx esbuild src/report-collaboration/tests/message-adapter.test.ts --bundle --platform=node --format=esm --outfile=.runtime/unit-tests/message-adapter.test.mjs && node --test .runtime/unit-tests/event-reducer.test.mjs .runtime/unit-tests/message-adapter.test.mjs", "check:chrome109-css": "node scripts/check-chrome109-css.mjs", "check:legacy-chrome-css": "node scripts/check-chrome109-css.mjs" }, diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index f18623c5..6d6109ad 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_BRAND_WELCOME_DARK_URL: "", // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 - // VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026", + // VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow", // LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。 - // VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api", + // VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api", // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 VITE_CANVAS_LANGGRAPH_BASE_URL: "", @@ -50,6 +50,19 @@ window.__CM_RUNTIME_CONFIG__ = { // CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。 VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + // 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项, + // 不影响原多智能体会商使用的任务详情接口。 + VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer", + + // 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。 + // 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。 + VITE_8BF_ROUNDTABLE_BACK_URL: "", + + // 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。 + // 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。 + // 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。 + VITE_HOST_NAVIGATE_ORIGIN: "", + // 佳来模型对话接口。 VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", @@ -90,7 +103,7 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000", // Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。 - VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer", + VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer", // A 系统跳转地址:人物、组织、事件等列表项打开链接使用。 VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain", @@ -130,6 +143,25 @@ window.__CM_RUNTIME_CONFIG__ = { // 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。 VITE_FORCE_COLLAPSED_SIDEBAR: "false", + // 简介模式左侧菜单类型:与菜单管理页「简介模式」里的类型 A / B 对应。 + // 两套网页部署时分别填 "A" 或 "B"。不填、留空、或旧配置没有这一项,都按类型 A, + // 继续读取原来的简介菜单数据,无需改旧页面。 + VITE_COMPACT_MENU_TYPE: "A", + // GitHub OAuth Token;不使用 GitHub 相关能力时留空。 VITE_GITHUB_OAUTH_TOKEN: "", + + // 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。 + // 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。 + VITE_SENTIMENT_AGENT_VISIBLE: "true", + + // 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。 + // 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。 + VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent", + + // 舆情分析智能体:请求头 Authorization Basic Auth 用户名。 + VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296", + + // 舆情分析智能体:请求头 Authorization Basic Auth 密码。 + VITE_SENTIMENT_AGENT_AUTH_PASSWORD: "LzqqMTUxMTIwMjQxNjQ2MjY5MDUduC", }; diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index e1c5325a..1c779411 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -31,6 +31,16 @@ const requiredLegacyFallbacks = [ pattern: //, description: "Prompt textarea must not rely on :has() for its parent height", }, + { + file: "src/core/utils/mermaid.ts", + pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/, + description: "Sandbox flowcharts must use native SVG labels in legacy browsers", + }, + { + file: "src/core/utils/legacy-html-preview.ts", + pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/, + description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109", + }, ]; for (const file of cssFiles) { diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 2833dea3..679263a3 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -38,14 +38,21 @@ import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; +import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"; import { isPositionRoundtableStandaloneMode, POSITION_ROUNDTABLE_ROUTE, } from "@/position-roundtable/demo/position-roundtable-demo-config"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; +import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry"; -const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"]; +const PUBLIC_EMBED_PREFIXES = [ + "/share/", + "/public/", + "/roundtable-share/", + "/embed/knowledge/", +]; function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); @@ -102,16 +109,17 @@ function AppProviders({ children }: { children: React.ReactNode }) { forcedTheme={forcedTheme} > - - - - {!positionStandaloneMode && } - {!positionStandaloneMode && } - - {children} - - - + + + + + {!positionStandaloneMode && } + + {children} + + + + ); @@ -188,10 +196,15 @@ export function App() { } /> } /> + } + /> } /> + } /> } /> diff --git a/frontend-web/src/assets/deer.svg b/frontend-web/src/assets/deer.svg new file mode 100644 index 00000000..9bbfb291 --- /dev/null +++ b/frontend-web/src/assets/deer.svg @@ -0,0 +1,6 @@ + + + + diff --git a/frontend-web/src/assets/eightbf-roundtable/back-arrow.svg b/frontend-web/src/assets/eightbf-roundtable/back-arrow.svg new file mode 100644 index 00000000..801cd281 --- /dev/null +++ b/frontend-web/src/assets/eightbf-roundtable/back-arrow.svg @@ -0,0 +1,3 @@ + diff --git a/frontend-web/src/assets/position-roundtable/add-task.svg b/frontend-web/src/assets/position-roundtable/add-task.svg new file mode 100644 index 00000000..e27cab8f --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/add-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/agent-config-logo.svg b/frontend-web/src/assets/position-roundtable/agent-config-logo.svg new file mode 100644 index 00000000..d51eb71d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/agent-config-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/ask-question.svg b/frontend-web/src/assets/position-roundtable/ask-question.svg new file mode 100644 index 00000000..af9052eb --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/ask-question.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/collapse-panel.svg b/frontend-web/src/assets/position-roundtable/collapse-panel.svg new file mode 100644 index 00000000..9f064680 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/collapse-panel.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/double-arrow-decoration.svg b/frontend-web/src/assets/position-roundtable/double-arrow-decoration.svg new file mode 100644 index 00000000..bf85a192 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/double-arrow-decoration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/more-task.svg b/frontend-web/src/assets/position-roundtable/more-task.svg new file mode 100644 index 00000000..055a158d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/more-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/search-task.svg b/frontend-web/src/assets/position-roundtable/search-task.svg new file mode 100644 index 00000000..fc8fc78c --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/search-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/send-message.svg b/frontend-web/src/assets/position-roundtable/send-message.svg new file mode 100644 index 00000000..3a96000e --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/send-message.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/upload-file.svg b/frontend-web/src/assets/position-roundtable/upload-file.svg new file mode 100644 index 00000000..9f47e22a --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/upload-file.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/时间.svg b/frontend-web/src/assets/position-roundtable/时间.svg new file mode 100644 index 00000000..26888994 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/时间.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签1.svg b/frontend-web/src/assets/position-roundtable/标签1.svg new file mode 100644 index 00000000..780de1a5 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签2.svg b/frontend-web/src/assets/position-roundtable/标签2.svg new file mode 100644 index 00000000..7443e13d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签3.svg b/frontend-web/src/assets/position-roundtable/标签3.svg new file mode 100644 index 00000000..7d9257a5 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签4.svg b/frontend-web/src/assets/position-roundtable/标签4.svg new file mode 100644 index 00000000..2be60358 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签5.svg b/frontend-web/src/assets/position-roundtable/标签5.svg new file mode 100644 index 00000000..df814d72 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签6.svg b/frontend-web/src/assets/position-roundtable/标签6.svg new file mode 100644 index 00000000..3df12c8a --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/责任岗位.svg b/frontend-web/src/assets/position-roundtable/责任岗位.svg new file mode 100644 index 00000000..66040aa7 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/责任岗位.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/components/ai-elements/prompt-input.tsx b/frontend-web/src/components/ai-elements/prompt-input.tsx index a796b775..58dc06ea 100644 --- a/frontend-web/src/components/ai-elements/prompt-input.tsx +++ b/frontend-web/src/components/ai-elements/prompt-input.tsx @@ -56,12 +56,11 @@ import { type ChangeEventHandler, Children, type ClipboardEventHandler, - type ComponentProps, - createContext, - type FormEvent, - type FormEventHandler, - Fragment, - type HTMLAttributes, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + type HTMLAttributes, type KeyboardEventHandler, type PropsWithChildren, type ReactNode, @@ -124,9 +123,11 @@ export const usePromptInputController = () => { return ctx; }; -// Optional variants (do NOT throw). Useful for dual-mode components. -const useOptionalPromptInputController = () => - useContext(PromptInputController); +// Optional variants (do NOT throw). Useful for dual-mode components and for +// Fast Refresh transitions where a page can briefly retain the previous +// provider context instance. +export const useOptionalPromptInputController = () => + useContext(PromptInputController); export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); @@ -403,14 +404,14 @@ export function PromptInputAttachments({ return (
- {attachments.files.map((file) => ( - -
{children(file)}
-
- ))} -
+ {...props} + > + {attachments.files.map((file) => ( +
+ {children(file)} +
+ ))} + ); } diff --git a/frontend-web/src/components/ai-elements/reasoning.tsx b/frontend-web/src/components/ai-elements/reasoning.tsx index 2845edf6..d9c63e2a 100644 --- a/frontend-web/src/components/ai-elements/reasoning.tsx +++ b/frontend-web/src/components/ai-elements/reasoning.tsx @@ -165,7 +165,7 @@ export const ReasoningTrigger = memo( export type ReasoningContentProps = ComponentProps< typeof CollapsibleContent > & { - children: string; + children: ReactNode; }; export const ReasoningContent = memo( @@ -178,7 +178,11 @@ export const ReasoningContent = memo( )} {...props} > - {children} + {typeof children === "string" ? ( + {children} + ) : ( + children + )} ), ); diff --git a/frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx b/frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx new file mode 100644 index 00000000..2788cf70 --- /dev/null +++ b/frontend-web/src/components/analysis-results/AnalysisResultCardList.tsx @@ -0,0 +1,104 @@ +import { FileOutput } from "lucide-react"; +import { toast } from "sonner"; + +import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg"; +import resultTimeIcon from "@/assets/position-roundtable/时间.svg"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +import "./analysis-results.css"; +import type { AnalysisResultCardItem } from "./types"; + +function formatResultTime(value: string | undefined, fallback: string): string { + if (!value) return fallback; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +export function AnalysisResultCardList({ + items, + emptyTitle = "暂无交付产物", + emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。", + padded = true, + onOpen, +}: { + items: AnalysisResultCardItem[]; + emptyTitle?: string; + emptyDescription?: string; + padded?: boolean; + onOpen: (item: AnalysisResultCardItem) => void; +}) { + if (items.length === 0) { + return ( +
+ +

{emptyTitle}

+

+ {emptyDescription} +

+
+ ); + } + + return ( +
+ {items.map((item, index) => { + const displayedTime = formatResultTime( + item.updatedAt, + item.fallbackTime || "刚刚", + ); + const displayedProductName = item.delivered ? item.productName : "未交付"; + return ( +