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/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e2541f15 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Never send source-control history, local dependencies, runtime state, or +# credentials to the Docker daemon. The offline images are built from the +# repository root by deploy/1panel-offline/Dockerfile.*. +.git +.runtime +.history +tmp +dist + +# Credentials and environment-specific configuration are supplied at runtime +# through the offline delivery package, never copied into an image layer. +**/.env +**/.env.* +offline-backend-20260512/backend/.deer-flow +offline-backend-20260512/backend/logs +offline-backend-20260512/backend/docker-data +offline-backend-20260512/backend/dist +offline-backend-20260512/backend/wheelhouse +offline-backend-20260512/backend/offline-wheels +offline-backend-20260512/backend/.venv +offline-backend-20260512/backend/.pytest_cache +offline-backend-20260512/backend/.ruff_cache + +# Reproducibly install frontend dependencies inside the image instead. +frontend-web/node_modules +frontend-web/dist +frontend-web/.vite +frontend-web/.offline-pack-build +frontend-web/.offline-extract-test +frontend-web/node_modules-offline-*.tar.gz + +# Editor and operating-system metadata. +**/__pycache__ +**/*.py[cod] +**/.DS_Store diff --git a/.gitignore b/.gitignore index aee22e47..b7e4cc9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Workspace/editor -.vscode/ -frontend-web/.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) .claude/settings.local.json @@ -52,8 +56,9 @@ offline-backend-20260505/bundle/start_backend.sh 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/DEPLOY_BACKEND_OFFLINE_ZH.md +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 offline-backend-20260512/data/ @@ -61,12 +66,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar offline-backend-20260512/extensions_config.json offline-backend-20260512/skills/ 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/start_backend.sh -offline-backend-20260512/stop_backend.sh -offline-backend-20260512/check_backend.sh +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 hermes-agent-main/ @@ -77,11 +84,16 @@ offline-backend-20260512/backend/.deer-flow/* # Per-user skill_evolution overrides — runtime data written by the API offline-backend-20260512/backend/skill_evolution.json -skills/ -hermes-agent-main/ +skills/ +hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* -deploy +offline-backend-20260512/backend/tests/* +!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +!offline-backend-20260512/backend/tests/test_health_auth_bypass.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 +deploy .history # Cloned third-party repos (local only) @@ -90,12 +102,17 @@ 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/ - -.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 + +.codegraph +codegraph.mdc docs/aidev-docs docs/backups @@ -112,4 +129,4 @@ offline-backend-20260512/backend/uv.lock __pycache__/ # misc -.DS_Store \ No newline at end of file +.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/= b/= deleted file mode 100644 index e69de29b..00000000 diff --git a/AGENTS.md b/AGENTS.md index d12c33ae..5cbfebb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **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). +- **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。 - **敏感词管理 (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`. 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/frontend-web/.env.example b/frontend-web/.env.example index eea12c4c..68e79a97 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -37,6 +37,10 @@ # 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。 # 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。 # VITE_MOCK_COP_TASK_DETAIL=false +# VITE_POSITION_ROUNDTABLE_DEMO_MODE=false +# true=免登录直达岗位分析,完全使用前端 JSON,不请求任何后台接口。 +# 仅限离线演示环境;生产环境请保持 false。 +# VITE_POSITION_ROUNDTABLE_STANDALONE_MODE=false # MaxKey 鉴权根地址(无界 onlineTicket 换 token) # VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn diff --git a/frontend-web/design-qa.md b/frontend-web/design-qa.md new file mode 100644 index 00000000..7260e9cb --- /dev/null +++ b/frontend-web/design-qa.md @@ -0,0 +1,46 @@ +**Comparison target** + +- Source visual truth (dark intelligent-agent list): `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-75f6ce1c-cd70-4b8c-a80a-a3d100105c69.png` +- Additional source references: `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-00567484-ba8a-4595-b386-52e1467ae9cf.png` (segmented switch), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-02808803-05ba-4f2c-be50-c72b484a9f06.png` (business-chain node card), `C:/Users/董宏凯/AppData/Local/Temp/codex-clipboard-adf38fec-7baf-4d01-9c73-bdbae94b774e.png` (business-chain selector) +- Implementation screenshots: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-light.png`, `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-dark.png` +- Full-view comparison evidence: `C:/Users/董宏凯/AppData/Local/Temp/position-roundtable-revised-comparison.png` +- Viewport: 1280 × 720. Light route: `#/page/strategy/qa/position-roundtable`; dark embedded route: `#/page/strategy/qa/position-roundtable?embed=1&theme=dark`. +- State: local browser has no authenticated API session. The original intent API is invoked and reports `Authentication required`; business-chain and agent endpoints return no usable data. + +**Findings** + +- [P1] Populated agent and business-chain states cannot be compared yet. + Location: right agent list and non-planning-role chain diagram. + Evidence: reference images show populated cards and nodes; the implementation has `0 个` agents and no available business chain in the unauthenticated preview. + Impact: card density, selected-card indicator, chain node flow, and agent-detail transition cannot be truthfully verified against live data. + Fix: verify the route in an authenticated session with at least one public chain and tagged agents. + +**Open Questions** + +- The final business-chain stage arrangement is still pending product confirmation. The compact graph maps whatever stage configuration is returned, while intentionally omitting dependency-label text as requested. + +**Implementation Checklist** + +1. Left task panel is now display-only and retains its independent position-roundtable task adapter. +2. Business-chain selection uses the original roundtable card/radio/relationship-entry pattern. +3. Every position uses the original `useStep1Intent` interface and `Step1Panel` message list/input panel; only the position header changes. +4. The right switch now uses the exact `课题研究 / ai写作` segmented-control treatment (light gray track, white active state; dark blue glass equivalent). +5. The embedded layout widens the left panel to 360px and applies the deep-blue right-panel tokens used by the reference. +6. Other roles render a taller top-to-bottom card-node flow without dependency labels. The canvas supports mouse-drag panning, and each node retains only a name plus a single-line description. + +**Follow-up Polish** + +- [P3] If live content is unusually long, tune the compact flow node width or horizontal scrolling after seeing the production chain data. + +**Comparison history** + +- Iteration 1: task fields were editable and the right TDesign card tabs looked unlike the reference. Replaced the task form with display-only information and replaced the tabs with the existing `课题研究 / ai写作` segmented switch. +- Iteration 2: business-chain selection was a dropdown and the flow was vertical avatar chips. Replaced it with original-roundtable selection cards and compact business-chain configuration-style node cards with link lines but no dependency labels. +- Iteration 3: dark right-panel background differed from the supplied deep-blue reference. Applied the `#032650` / `#042b59` / `#2b8ffe` token family and captured the revised embedded dark state in the full-view comparison. +- Iteration 4: removed the custom disabled input from non-planning positions and reused the original roundtable input panel. Enlarged the left flow viewport, retained its connection lines, and removed node level/seat/status metadata. +- Iteration 5: converted the compact flow from left-to-right to top-to-bottom. Added mouse-drag panning and single-line description truncation; populated-chain visual QA remains blocked by the unauthenticated preview. +- Iteration 6: constrained the flow canvas to its card viewport, narrowed nodes from 190px to 158px, and realigned branch gaps plus connector anchors to prevent clipping and offset lines. + +**Final result** + +blocked diff --git a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md new file mode 100644 index 00000000..62503eed --- /dev/null +++ b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md @@ -0,0 +1,362 @@ +# 岗位会商交付闭环与内置智能体实现计划 + +> 页面:`/page/strategy/qa/position-roundtable` +> +> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。 + +## 实施核对(2026-07-21) + +本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为: + +```text +任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链 +→ 分阶段席位直接问答/交付 Markdown → 全部有效交付 +→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner) +→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口 +``` + +- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。 +- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。 +- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。 +- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。 +- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。 +- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。 +- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。 +- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。 +- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。 + +设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。 + +## 数据持久化与灾难恢复(2026-07-23) + +岗位会商现在使用“两层持久化”,两层职责不同: + +1. LangGraph checkpoint/thread 是智能体继续推理与流式问答的执行源。 +2. `position_roundtable_sessions` / `position_roundtable_nodes` 是页面历史的数据库恢复源。 + +数据库恢复源会保存: + +- 情报分析岗完整消息、步骤时间线和未发送输入草稿; +- 每个业务链岗位的完整消息和未发送输入草稿; +- 方案总结、行动规划两个收口智能体的完整消息和未发送输入草稿; +- 已确认意图、冻结任务与业务链快照、节点状态、版本、驳回记录; +- Markdown / JSON 等文本产物正文(单文件最多 400 万字符)和文件元数据。 + +因此即使线上重启后 checkpoint 或沙箱 outputs 卷丢失,历史页仍能从数据库展示对话与产物正文,后续收口智能体也能继续读取数据库中的有效上游交付。新任务在意图识别开始前就会创建 Session,用户不必等到“启动业务链”才获得持久化保护。历史下拉支持删除,删除会显式清理 Session 与全部节点恢复快照,不依赖 SQLite 外键开关。 + +对应迁移:`20260723_05_position_roundtable_conversations.py`。部署时必须执行到 Alembic head。 + +## 0. 核心结论 + +- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。 + - 可复用:内置智能体 ID、总结报告 prompt 组织方式、Markdown 报告产物、流式对话、报告续问与文件预览能力。 + - 需要改造:输入材料从“总控共识 + 各席位交付”改成“任务信息 + 意图结果 + 已通过的岗位产物 + 业务链快照”,并把结果存到岗位会商 Session 下。 +- 行动规划智能体建议作为新的内置智能体新增。 + - 输入:方案总结报告、各岗位最终有效产物、业务链阶段信息、任务意图。 + - 输出:行动规划报告、结构化子任务清单。 +- 这个模式仍然不需要总控智能体。情报分析岗负责意图识别,业务链节点直接调用各自配置的智能体,最终再由内置总结/规划智能体收口。 +- 驳回不删除历史内容,只改变“当前有效版本”的状态;被驳回节点和后续节点需要重新交付,既保留可追溯性,也方便后续返回查看。 + +## 1. 目标业务流程 + +1. 情报分析岗进入页面,查看任务信息并完成意图识别。 +2. 用户点击“确认意图并启动业务链”。 +3. 启动前做校验: + - 未选择业务链:提示“请先选择业务链”。 + - 业务链存在未配置岗位的席位:提示并引导去业务链配置页。 + - 业务链席位缺少智能体:提示具体席位名称。 + - 意图尚未完成:提示先完成任务意图识别。 +4. 校验通过后冻结业务链快照,初始化岗位会商节点。 +5. 各岗位只对分配给本岗位的智能体进行问答。 +6. 节点问答完成后形成交付产物,右侧产物卡片显示“已完成 / 未完成 / 进行中 / 已驳回 / 已失效”。 +7. 左侧流程图显示各节点状态,节点可点击查看该节点对话与产物。 +8. 流程图右上角提供“查看”按钮,打开弹窗展示更完整的业务链流程图。 +9. 当业务链中所有岗位产物都处于有效已交付状态后,开放“方案总结”。 +10. 方案总结完成后开放“行动规划”。 +11. 即使进入方案总结或行动规划,用户仍可返回产物列表执行驳回。 +12. 若驳回早期阶段产物,后续阶段产物、方案总结、行动规划都一起失效并回到待重新完成状态。 + +## 2. 状态设计 + +### 2.1 节点状态 + +现有节点状态需要扩展或映射为更贴近页面的展示状态: + +| 展示状态 | 建议内部状态 | 含义 | +| --- | --- | --- | +| 未完成 | `ready` / `locked` / `rejected` | 尚未有效交付,或被驳回后等待重做 | +| 进行中 | `running` | 当前智能体正在回答或产物正在生成 | +| 已完成 | `done` | 当前节点已有有效交付 | +| 已失效 | `stale` | 上游被更新或驳回,当前结果不可作为最终材料 | +| 异常 | `error` | 生成或保存失败 | + +建议新增 `rejected` 状态,便于区分“从未完成”和“被驳回后待重做”。如果第一轮想少改后端,也可以先用 `ready + rejection_record` 表示被驳回。 + +### 2.2 产物状态 + +产物状态建议独立于节点状态保存,因为一个节点可能同时有回答文本和多个文件产物。 + +| 状态 | 含义 | +| --- | --- | +| `pending` | 还没有交付 | +| `running` | 正在生成 | +| `delivered` | 已交付且当前有效 | +| `rejected` | 被用户驳回 | +| `stale` | 受上游驳回或重做影响,已经失效 | + +### 2.3 内置收口节点 + +在流程图中把两个内置节点追加到业务链末尾: + +1. `builtin:summary`:方案总结智能体。 +2. `builtin:action-plan`:行动规划智能体。 + +这两个节点不属于普通业务链席位,但要和普通节点一样显示状态、支持点击查看结果。 + +## 3. 数据模型调整 + +### 3.1 Session 扩展 + +`position_roundtable_sessions` 已使用下列持久化字段: + +| 字段 | 含义 | +| --- | --- | +| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 | +| `summary_thread_id` | 方案总结智能体线程 | +| `summary_snapshot` | 最新有效总结报告快照 | +| `action_plan_thread_id` | 行动规划智能体线程 | +| `action_plan_snapshot` | 最新有效行动规划快照 | +| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 | + +### 3.2 Node 扩展 + +`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射): + +| 字段 | 含义 | +| --- | --- | +| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 | +| `rejection_count` | 当前节点累计驳回次数 | +| `last_rejection` | 最新驳回标题、理由、操作者、时间 | +| `revision` | 当前有效交付版本 | +| `invalidated_by` | 导致当前节点失效的上游节点 key | + +### 3.3 Deliverable 新表(后续可选) + +当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。 + +| 字段 | 含义 | +| --- | --- | +| `id` | 产物 ID | +| `session_id` | 会话 ID | +| `node_key` | 来源节点 | +| `agent_id` | 来源智能体 | +| `position_id` | 来源岗位 | +| `title` | 产物名称 | +| `kind` | `answer / file / summary / action_plan` | +| `content` | 文本产物内容摘要或正文 | +| `artifact_path` | 文件产物虚拟路径 | +| `thread_id` | 所属线程 | +| `status` | `pending / running / delivered / rejected / stale` | +| `revision` | 来源节点版本 | +| `rejection_title` | 驳回标题 | +| `rejection_reason` | 驳回理由 | +| `created_at` | 创建时间 | +| `updated_at` | 更新时间 | + +当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。 + +## 4. 驳回与级联失效规则 + +### 4.1 基本驳回 + +用户点击产物卡片的“驳回”后弹窗填写: + +- 驳回标题。 +- 驳回理由。 + +提交后: + +- 当前产物状态改为 `rejected`。 +- 当前节点状态改为 `rejected` 或 `ready`。 +- 当前节点的 `latest_answer` 和历史文件不删除,但不再作为有效交付输入。 +- 记录 `last_rejection`,用于页面展示和后续追踪。 + +### 4.2 级联失效 + +如果驳回的节点位于第 N 阶段: + +- 同阶段其他节点不自动驳回,除非业务链未来明确配置了依赖关系。 +- 第 N+1 阶段及之后所有节点的有效产物全部标记为 `stale`。 +- 后续节点状态回到未完成展示态。 +- `builtin:summary` 和 `builtin:action-plan` 如果已经生成,也标记为 `stale`。 +- 页面允许用户继续查看旧报告和旧行动规划,但必须提示“上游产物已变更,需要重新生成”。 + +### 4.3 重新交付 + +被驳回节点重新问答并完成后: + +- 当前节点产生新的 `revision`。 +- 当前节点产物状态变为 `delivered`。 +- 只有当该阶段所有必要节点都重新有效交付后,下一阶段才重新解锁。 +- 方案总结和行动规划必须基于最新有效产物重新生成。 + +## 5. 接口规划 + +### 5.1 启动与校验 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `POST` | `/api/position-roundtable/sessions/{id}/validate-activation` | 校验业务链、岗位、智能体、意图状态 | +| `POST` | `/api/position-roundtable/sessions/{id}/activate` | 校验通过后冻结链条并初始化节点 | + +第一轮也可以先把校验合并在 `activate` 内,前端根据后端返回的错误码提示。 + +### 5.2 产物 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) | +| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 | +| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 | +| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 | + +### 5.3 方案总结与行动规划 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 | +| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 | +| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 | +| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 | +| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 | +| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 | + +可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。 + +## 6. 前端实施步骤 + +### 阶段 1:状态与启动校验(已完成) + +- 扩展 `PositionNodeStatus` 和状态显示映射。 +- “确认意图并启动业务链”前补齐校验提示。 +- 后端 `activate` 返回未配置岗位、缺智能体、未选择链条等具体错误。 +- 左侧流程图节点右上角添加状态小图标。 +- 节点样式调整为上方智能体名称、下方岗位名称,移除左侧 logo。 + +验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。 + +### 阶段 2:流程图弹窗(已完成) + +- 左侧保留小型从上到下流程图。 +- 流程图右上角增加“查看”按钮。 +- 点击后打开弹窗,展示更大的流程图。 +- 弹窗内支持点击节点查看对话与产物,保持和主页面选中节点同步。 + +验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。 + +### 阶段 3:产物卡片与预览(已完成) + +- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。 +- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。 +- 点击卡片打开详情弹窗。 +- 文本产物直接展示正文,文件产物复用现有 `ArtifactPreviewModal`。 +- 每个有效产物提供“驳回”入口。 + +验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。 + +### 阶段 4:驳回弹窗与级联失效(已完成) + +- 新增 `RejectDeliverableDialog`。 +- 驳回表单包含标题和理由,均必填。 +- 后端事务内完成当前产物驳回、当前节点状态更新、后续阶段级联失效、总结和行动规划失效。 +- 前端刷新 Session、Node、Deliverable 和流程图状态。 +- 被驳回节点在对话区显示驳回信息,提示用户重新交付。 + +验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。 + +### 阶段 5:方案总结智能体(已完成) + +- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。 +- 输入材料改为岗位会商材料: + - 任务信息。 + - 情报分析岗意图结果。 + - 业务链快照。 + - 所有状态为 `delivered` 的岗位产物。 + - 已驳回或失效产物只作为历史参考,不进入默认总结材料。 +- 方案总结节点追加到流程图末尾。 +- 所有产物有效交付后才允许点击“生成方案总结”。 +- 总结结果写入 `summary_snapshot`,同时进入产物列表。 + +验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。 + +### 阶段 6:行动规划智能体(已完成) + +- 新增内置智能体 ID,建议先命名为 `position-action-planner`。 +- 新增行动规划 prompt: + - 读取方案总结报告。 + - 读取各岗位最终有效产物。 + - 拆解为若干子任务。 + - 每个子任务包含名称、目标、输入依据、责任岗位建议、前置依赖、交付物、验收标准。 +- 输出两类产物: + - `行动规划报告.md`。 + - `action-plan-subtasks.json`。 +- 行动规划节点追加到方案总结之后。 +- 方案总结有效完成后才允许启动行动规划。 + +验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。 + +### 阶段 7:返回修改与重新生成(已完成) + +- 用户在方案总结或行动规划视图中仍可切回“交付产物”。 +- 驳回后自动显示需要重做的节点。 +- 重做节点完成后,允许重新生成方案总结和行动规划。 +- 旧总结、旧行动规划保留历史查看,但状态显示“已失效”。 + +验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。 + +### 阶段 8:测试与回归(已完成) + +- 后端测试: + - activate 校验。 + - 节点完成后产物入库。 + - 驳回当前节点。 + - 驳回第一阶段时后续阶段级联失效。 + - 总结和行动规划失效。 +- 前端测试: + - 启动校验提示。 + - 状态图标显示。 + - 产物卡片筛选和预览。 + - 驳回弹窗必填校验。 +- 回归验证: + - 原多智能体会商页面不受影响。 + - 业务链普通配置入口不显示岗位配置。 + - 岗位会商页面刷新恢复正常。 + - 深色模式和嵌入模式布局不溢出。 + +## 7. 推荐提交拆分 + +1. `feat(position-roundtable): add delivery status model` +2. `feat(position-roundtable): validate chain activation` +3. `feat(position-roundtable): add workflow status graph` +4. `feat(position-roundtable): add deliverable cards and preview` +5. `feat(position-roundtable): support rejection cascade` +6. `feat(position-roundtable): add summary builtin node` +7. `feat(position-roundtable): add action planning builtin node` +8. `test(position-roundtable): cover delivery workflow` + +## 8. 第一轮建议范围 + +第一轮优先做最小可闭环: + +```text +意图识别 +→ 启动校验 +→ 业务链节点状态 +→ 节点产物列表 +→ 产物详情查看 +→ 驳回与级联失效 +→ 全部交付后生成方案总结 +→ 基于总结生成行动规划 +``` + +如果时间紧,行动规划可以先生成 Markdown 报告,结构化 `subtasks.json` 放到第二轮;但数据模型和 UI 入口第一轮就要预留,否则后面会返工。 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/沙箱统一写作管线-实现方案.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/index.html b/frontend-web/index.html index 26b8f4a6..fab13fa2 100644 --- a/frontend-web/index.html +++ b/frontend-web/index.html @@ -16,7 +16,7 @@ CM 助手 - +
diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json index 4b51ccb8..a590dbe8 100644 --- a/frontend-web/package-lock.json +++ b/frontend-web/package-lock.json @@ -70,6 +70,7 @@ "hast": "^1.0.0", "katex": "^0.16.28", "lucide-react": "^0.562.0", + "mermaid": "^11.15.0", "motion": "^12.26.2", "nanoid": "^5.1.6", "next-themes": "^0.4.6", @@ -104,6 +105,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.0.4", + "lightningcss": "^1.30.2", "postcss": "^8.5.3", "tailwindcss": "^4.0.15", "tw-animate-css": "^1.4.0", @@ -460,37 +462,11 @@ "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==" }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", - "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", - "dependencies": { - "@chevrotain/gast": "12.0.0", - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/gast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", - "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", - "dependencies": { - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", - "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==" - }, "node_modules/@chevrotain/types": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", - "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==" - }, - "node_modules/@chevrotain/utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", - "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==" + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" }, "node_modules/@codemirror/autocomplete": { "version": "6.20.1", @@ -1732,11 +1708,12 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" }, "node_modules/@mermaid-js/parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", - "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", "dependencies": { - "langium": "^4.0.0" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@opentelemetry/api": { @@ -4274,6 +4251,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -5650,32 +5687,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chevrotain": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", - "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "dependencies": { - "@chevrotain/cst-dts-gen": "12.0.0", - "@chevrotain/gast": "12.0.0", - "@chevrotain/regexp-to-ast": "12.0.0", - "@chevrotain/types": "12.0.0", - "@chevrotain/utils": "12.0.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz", - "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==", - "dependencies": { - "lodash-es": "^4.18.1" - }, - "peerDependencies": { - "chevrotain": "^12.0.0" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "license": "Apache-2.0", @@ -6547,6 +6558,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", @@ -7354,7 +7375,9 @@ } }, "node_modules/katex": { - "version": "0.16.28", + "version": "0.16.47", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -7372,23 +7395,6 @@ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, - "node_modules/langium": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz", - "integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==", - "dependencies": { - "@chevrotain/regexp-to-ast": "~12.0.0", - "chevrotain": "~12.0.0", - "chevrotain-allstar": "~0.4.3", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.1.0" - }, - "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" - } - }, "node_modules/langsmith": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.0.tgz", @@ -8100,43 +8106,32 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" }, "node_modules/mermaid": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", - "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "version": "11.16.0", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.0", + "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", - "katex": "^0.16.25", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", "khroma": "^2.1.0", - "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" - } - }, - "node_modules/mermaid/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "node_modules/micromark": { @@ -10414,49 +10409,6 @@ "vite": ">=5.0.0" } }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/frontend-web/public/brand-assets/brand-logo-dark.png b/frontend-web/public/brand-assets/brand-logo-dark.png new file mode 100644 index 00000000..426f78a9 Binary files /dev/null and b/frontend-web/public/brand-assets/brand-logo-dark.png differ diff --git a/frontend-web/public/brand-assets/brand-logo-light.png b/frontend-web/public/brand-assets/brand-logo-light.png new file mode 100644 index 00000000..53168237 Binary files /dev/null and b/frontend-web/public/brand-assets/brand-logo-light.png differ diff --git a/frontend-web/public/brand-assets/brand-logo-lightblue.png b/frontend-web/public/brand-assets/brand-logo-lightblue.png new file mode 100644 index 00000000..c25098e4 Binary files /dev/null and b/frontend-web/public/brand-assets/brand-logo-lightblue.png differ diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 54e4bc09..66470d66 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -7,6 +7,30 @@ // 4. 这里的默认值来自 .env.network;如某个环境不用某项,可以留空字符串。 // 5. 布尔开关也用字符串形式填写:"true" / "false"。 window.__CM_RUNTIME_CONFIG__ = { + // 品牌图片相对路径会以 runtime-config.js 所在目录为基准解析。 + // Nginx 以子路径部署时不需要修改前端图片路径。 + // 打包后可直接替换 dist/brand-assets/ 内的同名 PNG。 + // 也可以把下方图片地址改为其他 PNG 地址,无需重新打包。 + // 是否显示整块欢迎页图标区域:"true" 显示,"false" 隐藏,默认隐藏。 + VITE_WELCOME_LOGO_VISIBLE: "false", + // 深色主题顶栏 Logo 位置:"center" 显示在中间,"left" 显示在左上角,默认中间。 + VITE_DARK_LOGO_POSITION: "center", + // 红白主题的顶栏系统 Logo 图片地址。 + VITE_BRAND_LOGO_LIGHT_URL: "brand-assets/brand-logo-light.png", + // 浅蓝主题的顶栏系统 Logo 图片地址。 + VITE_BRAND_LOGO_LIGHTBLUE_URL: "brand-assets/brand-logo-lightblue.png", + // 绿色主题可单独替换 Logo;默认复用浅色 Logo。 + VITE_BRAND_LOGO_LIGHTGREEN_URL: "brand-assets/brand-logo-light.png", + // 深色主题的顶栏系统 Logo 图片地址。 + VITE_BRAND_LOGO_DARK_URL: "brand-assets/brand-logo-dark.png", + // 红白主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 + VITE_BRAND_WELCOME_LIGHT_URL: "", + // 浅蓝主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 + VITE_BRAND_WELCOME_LIGHTBLUE_URL: "", + VITE_BRAND_WELCOME_LIGHTGREEN_URL: "", + // 深色主题的欢迎页 Logo 图片地址:为空时显示原有 CM 文字和渐变背景。 + VITE_BRAND_WELCOME_DARK_URL: "", + // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 // VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026", @@ -16,6 +40,10 @@ window.__CM_RUNTIME_CONFIG__ = { // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 VITE_CANVAS_LANGGRAPH_BASE_URL: "", + // routeKey 短路由的用户名直登口令:例如 #/login/admin?routeKey=a。 + // 填值时优先使用这里的 password;留空时使用 URL 中的 ?password=。 + VITE_ROUTE_LOGIN_PASSWORD: "", + // 轻应用「新建笔记」系列接口地址。 VITE_NOTE_API_BASE_URL: "http://47.88.25.99:8000", @@ -71,7 +99,7 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes", // MaxKey 鉴权地址:onlineTicket 换 token 使用。 - VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn", + VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn", // PG 中任务 iframe 地址。 VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn", @@ -85,6 +113,13 @@ window.__CM_RUNTIME_CONFIG__ = { // 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。 VITE_MOCK_COP_TASK_DETAIL: "false", + // 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。 + // 默认关闭,避免演示历史记录出现在正常生产账号中。 + VITE_POSITION_ROUNDTABLE_DEMO_MODE: "false", + // 纯前端岗位分析演示:跳过登录,直接进入岗位分析页,并禁止所有后台请求。 + // 仅用于离线展台/演示环境,生产环境必须保持 false。 + VITE_POSITION_ROUNDTABLE_STANDALONE_MODE: "false", + // 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖, // 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。 @@ -97,4 +132,18 @@ window.__CM_RUNTIME_CONFIG__ = { // GitHub OAuth Token;不使用 GitHub 相关能力时留空。 VITE_GITHUB_OAUTH_TOKEN: "", + + // 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。 + // 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。 + VITE_SENTIMENT_AGENT_VISIBLE: "true", + + // 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。 + // 留空时该智能体对话页会提示未配置。 + VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/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/src/App.tsx b/frontend-web/src/App.tsx index e3978689..2833dea3 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -37,6 +37,11 @@ import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; 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 { + isPositionRoundtableStandaloneMode, + POSITION_ROUNDTABLE_ROUTE, +} from "@/position-roundtable/demo/position-roundtable-demo-config"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -46,6 +51,7 @@ function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); const publicEmbedTheme = usePublicEmbedTheme(); + const positionStandaloneMode = isPositionRoundtableStandaloneMode(); // iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页 // 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。 useEffect(() => { @@ -54,14 +60,18 @@ function AppProviders({ children }: { children: React.ReactNode }) { // 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作 // (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。 useEffect(() => { + if (positionStandaloneMode) return; void fetchYLogConfig(); - }, []); + }, [positionStandaloneMode]); const forcedTheme = useMemo(() => { // iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。 // 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。 const embedTheme = resolveForcedEmbedTheme(location.search); if (embedTheme) return embedTheme; - const routeTheme = resolveForcedThemeForPath(location.pathname); + const routeTheme = resolveForcedThemeForPath( + location.pathname, + location.search, + ); if (routeTheme) return routeTheme; if (location.pathname === "/embed/roundtable") { const wp = readWujieProps(); @@ -86,7 +96,7 @@ function AppProviders({ children }: { children: React.ReactNode }) { attribute="class" storageKey="strategy-theme" defaultTheme="light" - themes={["light", "dark", "lightblue", "lightgreen"]} + themes={["light", "dark", "lightblue", "lightgreen", "iceblue", "lightyellow"]} enableSystem={false} disableTransitionOnChange forcedTheme={forcedTheme} @@ -95,8 +105,8 @@ function AppProviders({ children }: { children: React.ReactNode }) { - - + {!positionStandaloneMode && } + {!positionStandaloneMode && } {children} @@ -114,17 +124,38 @@ function AppProviders({ children }: { children: React.ReactNode }) { */ function RootRedirect() { const { search } = useLocation(); + if (isPositionRoundtableStandaloneMode()) { + return ; + } return ; } export function App() { + const positionStandaloneMode = isPositionRoundtableStandaloneMode(); + const standaloneLoginRedirect = ( + + ); + const pageElement = positionStandaloneMode ? ( + + + + ) : ( + + ); + return ( } /> - } /> - } /> + } + /> + } + /> } /> } /> } /> @@ -161,7 +192,7 @@ export function App() { path="/roundtable-dashboard" element={} /> - } /> + } /> diff --git a/frontend-web/src/assets/img/brand-logo-dark.png b/frontend-web/src/assets/img/brand-logo-dark.png new file mode 100644 index 00000000..426f78a9 Binary files /dev/null and b/frontend-web/src/assets/img/brand-logo-dark.png differ diff --git a/frontend-web/src/assets/img/brand-logo-light.png b/frontend-web/src/assets/img/brand-logo-light.png new file mode 100644 index 00000000..53168237 Binary files /dev/null and b/frontend-web/src/assets/img/brand-logo-light.png differ diff --git a/frontend-web/src/assets/img/brand-logo-lightblue.png b/frontend-web/src/assets/img/brand-logo-lightblue.png new file mode 100644 index 00000000..c25098e4 Binary files /dev/null and b/frontend-web/src/assets/img/brand-logo-lightblue.png differ 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/down-logo.svg b/frontend-web/src/assets/position-roundtable/down-logo.svg new file mode 100644 index 00000000..eafe014d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/down-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/frontend-web/src/assets/position-roundtable/edit-task.svg b/frontend-web/src/assets/position-roundtable/edit-task.svg new file mode 100644 index 00000000..ab6762a2 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/edit-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/expand-logo.svg b/frontend-web/src/assets/position-roundtable/expand-logo.svg new file mode 100644 index 00000000..a1b533ac --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/expand-logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + 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/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index e6eb7677..73f6a7e2 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -8,6 +8,17 @@ flex-shrink: 0; } +/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */ +.sidebar-contnet--header-hidden { + top: 0; + height: 100vh; +} + +.sidebar-contnet--header-hidden .item-nav-content { + height: calc(100vh - 20px); + min-height: calc(100vh - 20px); +} + .sidebar-contents { width: 98px; min-height: calc(100% - 20px); @@ -564,7 +575,59 @@ background: #ffffff; } +/* Ice blue has a deliberately light banner. Keep top-level navigation and + its active marker dark enough to remain readable against #9BCEF1. */ +html.iceblue .light-topnav-item { + color: rgba(24, 50, 74, 0.78); +} + +html.iceblue .light-topnav-item:hover, +html.iceblue .light-topnav-item--active, +html.iceblue .light-topnav-item--active:hover { + color: #18324a; +} + +html.iceblue .light-topnav-item--active::after { + background: #3d6e96; +} + +/* Pale yellow has the same light-banner contrast requirement as ice blue. */ +html.lightyellow .light-topnav-item { + color: rgba(48, 61, 35, 0.78); +} + +html.lightyellow .light-topnav-item:hover, +html.lightyellow .light-topnav-item--active, +html.lightyellow .light-topnav-item--active:hover { + color: #303d23; +} + +html.lightyellow .light-topnav-item--active::after { + background: #4c6330; +} + /* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */ +/* Runtime themes use their configurable header gradient rather than the + red/blue banner artwork. Navigation copy follows an automatically chosen + contrasting foreground so both pale and deep brand colours remain legible. */ +html[data-custom-theme="true"] .light-topnav-item, +html[data-custom-theme="true"] .top-submenu-item { + color: var(--navigation-foreground); +} + +html[data-custom-theme="true"] .light-topnav-item:hover, +html[data-custom-theme="true"] .light-topnav-item--active, +html[data-custom-theme="true"] .light-topnav-item--active:hover, +html[data-custom-theme="true"] .top-submenu-item:hover, +html[data-custom-theme="true"] .top-submenu-item--active { + color: var(--navigation-foreground); + text-shadow: none; +} + +html[data-custom-theme="true"] .light-topnav-item--active::after { + background: var(--navigation-foreground); +} + .light-sidebar { position: fixed; left: 0; @@ -587,6 +650,12 @@ width: 236px; } +/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */ +.light-sidebar--header-hidden { + top: 0; + height: 100vh; +} + /* 二级列:固定 96px,竖排图标卡片 + 底部设置 */ .light-sidebar__primary { flex: 0 0 96px; diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 4851a5d5..3d5bda73 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -17,9 +17,15 @@ type PageSidebarDarkProps = { /** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */ collapsed: boolean; onToggleCollapsed: () => void; + /** 全局 Header 隐藏时,侧边栏从视口顶部开始占满高度。 */ + headerHidden?: boolean; }; -export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) { +export function PageSidebarDark({ + collapsed, + onToggleCollapsed, + headerHidden = false, +}: PageSidebarDarkProps) { const isAdmin = getStoredAuth()?.system_role === "admin"; const visibleMenuItems = useSidebarMenuItems(!!isAdmin); const inWujieEnv = isWujieEnv(); @@ -196,7 +202,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar ); - // collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口 + // 收起时主侧栏不占宽度,保留左下角入口以便随时恢复。 if (collapsed) { return ( ); } return ( - )} - {/* ── Narrow screens ( - - - - - - - - - 智能体信息 - {headerActions} - - -
{body}
-
-
+ {!embedded && ( + <> + {/* ── Narrow screens ( + + + + + + + + + 智能体信息 + {headerActions} + + +
{body}
+
+
+ + )} {/* ── Secondary surfaces ───────────────────────────────────────────── */} ("mine"); const [search, setSearch] = useState(""); + const [tagIds, setTagIds] = useState([]); const [sort, setSort] = useState("time_desc"); - const [statusFilter, setStatusFilter] = useState("all"); const { data: publishSquares } = usePublishSquares(); // Each configured publish square (发布广场) becomes its own browse tab; the // backend always returns at least one (the built-in default). const squareTabs = publishSquares?.squares ?? []; + // 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型, + // 其余自定义广场继续保持后端给出的顺序。 + const [defaultSquare, ...additionalSquares] = squareTabs; // Publisher filter: click a card's 发布者 to keep only that user's agents. // {id} is the owner user_id (server filter), {label} the desensitized name. const [publisher, setPublisher] = useState<{ id: string; label: string } | null>( @@ -61,9 +59,10 @@ export function AgentGallery() { // frontend's desensitize map, so typing the displayed abbreviation (e.g. // "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the // filters change, every tab returns to its first page. + const tagKey = tagIds.join(","); useEffect(() => { setPages({}); - }, [search, sort, publisher?.id, statusFilter]); + }, [search, tagKey, sort, publisher?.id]); // If the active square tab disappears (admin removed that square), fall back // to "我的" so the gallery never points at a dead tab. @@ -77,6 +76,7 @@ export function AgentGallery() { activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin" && + activeTab !== "sentiment" && squareTabs.length > 0 && !squareTabs.some((sq) => sq.id === activeTab) ) { @@ -97,9 +97,13 @@ export function AgentGallery() { // Only the active tab fetches. "广场" maps to the server "square" scope // (built-in/published, never private), "我的" to "mine" (owned + favorited), - // "内置" to "builtin" (admin-only, user_id IS NULL). + // "内置" to "builtin" (admin-only, user_id IS NULL). 「舆情分析」是前端虚拟 + // 类型,不查后端列表。 const isSquareTab = - activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin"; + activeTab !== "mine" && + activeTab !== "private" && + activeTab !== "builtin" && + activeTab !== "sentiment"; const mainScope = activeTab === "mine" ? "mine" @@ -115,10 +119,11 @@ export function AgentGallery() { } = useAgentsPaginated({ scope: mainScope, search, + tagIds, sort, page: mainPage, pageSize: PAGE_SIZE, - enabled: activeTab !== "private", + enabled: activeTab !== "private" && activeTab !== "sentiment", owner: publisher?.id, // Each square tab fetches only that square's agents. square: isSquareTab ? activeTab : undefined, @@ -132,6 +137,7 @@ export function AgentGallery() { error: privateError, } = usePrivateAgentsPaginated(privatePassword, { search, + tagIds, sort, page: pages.private ?? 1, pageSize: PAGE_SIZE, @@ -153,9 +159,16 @@ export function AgentGallery() { // password has been verified. Until then we show the gate (or the lock // prompt) and hide the keyword/tag search bar — there is no list to filter. const privateUnlocked = privateFeatureUsable && !!privatePassword; - const showSearchBar = activeTab !== "private" || privateUnlocked; + const showSearchBar = + activeTab !== "sentiment" && + (activeTab !== "private" || privateUnlocked); - const activeTotal = activeTab === "private" ? privateTotal : mainTotal; + const activeTotal = + activeTab === "sentiment" + ? 1 + : activeTab === "private" + ? privateTotal + : mainTotal; const setTabPage = (tab: string, page: number) => setPages((prev) => ({ ...prev, [tab]: page })); @@ -182,22 +195,21 @@ export function AgentGallery() { const handlePublisherClick = (p: { id: string; label: string }) => setPublisher(p); - const filterByStatus = (agent: Agent) => { - if (statusFilter === "published") return agent.published && !agent.is_private; - if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin; - if (statusFilter === "private") return agent.is_private; - if (statusFilter === "builtin") return agent.builtin; - return true; - }; - - const renderGrid = (list: Agent[]) => { - const visibleAgents = list.filter(filterByStatus); - if (visibleAgents.length === 0 && list.length > 0) { - return renderEmpty("当前状态筛选下没有智能体。"); - } - return ( -
- {visibleAgents.map((agent) => ( + const renderGrid = (list: Agent[]) => + viewMode === "list" ? ( +
+ {list.map((agent) => ( + + ))} +
+ ) : ( +
+ {list.map((agent) => ( ); - }; /** Loading / empty / (grid + pagination) for one paginated tab. */ const renderListBody = (opts: { @@ -271,17 +282,20 @@ export function AgentGallery() { }; const renderEmpty = (description: string, showNewButton = false) => ( -
- +
+
+ +
-

{t.agents.emptyTitle}

-

+

{t.agents.emptyTitle}

+

{description}

{showNewButton && (
@@ -393,99 +402,58 @@ export function AgentGallery() { value={activeTab} onValueChange={(v) => { setActiveTab(v); - // Each tab starts from a clean slate instead of carrying filters over. + // Each tab starts from a clean slate: switching tabs clears the + // keyword / tag / sort / publisher filters instead of carrying them + // over. The TagSearchBar's own input box is reset via its key below. setSearch(""); + setTagIds([]); setSort("time_desc"); - setStatusFilter("all"); setPublisher(null); }} className="flex min-h-0 flex-1 flex-col overflow-hidden" > -
-
- - - 我的 - - {squareTabs.map((sq) => ( +
+
+ + 我的 + {defaultSquare && ( + {defaultSquare.name} + + )} + {sentimentAgentVisible && ( + /* 舆情分析:前端虚拟类型,直连外部舆情问答接口。 */ + + 舆情分析 + + )} + {additionalSquares.map((sq) => ( + {sq.name} ))} {privateFeatureAvailable && ( - + 私密广场 )} {isAdmin && ( - + 内置智能体 )} - {showSearchBar && ( -
-
- - setSearch(event.target.value)} - placeholder="搜索智能体名称或描述" - className="h-11 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-primary/50 focus-visible:ring-primary/15 dark:border-slate-700 dark:bg-slate-900" - /> - {(activeTab === "private" ? isPrivateFetching : mainFetching) && ( - - )} -
- - +
+ + 共 {activeTotal} 个 + + {activeTab === "private" && privatePassword && (
- )} +
+ {showSearchBar && ( + setSort(v as AgentSort)}> + + + + + + 最新创建 + 最早创建 + 最近更新 + 最早更新 + 名称 A-Z + 名称 Z-A + + + } + /> + )} {publisher && ( -
+
筛选发布者:
)} -
- 共 {activeTotal} 个智能体 -
{squareTabs.map((sq) => ( {renderListBody({ list: mainAgents, @@ -536,7 +533,7 @@ export function AgentGallery() { ))} - + {renderListBody({ list: mainAgents, total: mainTotal, @@ -548,8 +545,27 @@ export function AgentGallery() { })} + {/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑), + 接口地址与鉴权在 runtime-config.js 配置。 */} + {sentimentAgentVisible && ( + + {viewMode === "list" ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ )} + {isAdmin && ( - + {renderListBody({ list: mainAgents, total: mainTotal, @@ -564,7 +580,7 @@ export function AgentGallery() { {privateFeatureAvailable && ( {renderPrivateTab()} diff --git a/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx index c5cf8ae1..18951da6 100644 --- a/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-module-sidebar.tsx @@ -206,7 +206,7 @@ export function AgentModuleSidebar({ const renderAgentCard = (agent: Agent, keyPrefix: string) => { const href = `${routeBase}/agents/${agent.id}/chats/new`; const isActive = agent.id === activeAgentId; - const { from, to } = agentColor(agent.id, isDark); + const { from, to, foreground } = agentColor(agent.id, theme); return ( diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx index 3bb0a56b..0f954f10 100644 --- a/frontend-web/src/components/workspace/agents/agent-selector.tsx +++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx @@ -93,43 +93,47 @@ export function AgentSelector({ return ( <> - - {iconMode ? ( - - - - ) : ( + {iconMode ? ( + +
+ + + +
+
+ ) : ( + )} - )} - +
+ )} 选择 Agent diff --git a/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx b/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx new file mode 100644 index 00000000..f7fc3b22 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/sentiment-agent-card.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { BotIcon, MessageSquareIcon, Radio, SquarePenIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config"; +import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog"; + +/** + * 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents), + * 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage), + * 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。 + */ +export function SentimentAgentCard({ + layout = "grid", + className, +}: { + layout?: "grid" | "list"; + className?: string; +}) { + const router = useRouter(); + const [profile, setProfile] = useState(() => readSentimentAgentProfile()); + const [editOpen, setEditOpen] = useState(false); + + // 编辑弹窗也可能挂在对话页,保存后广播事件,这里即时刷新卡片。 + useEffect(() => { + const onChange = () => setProfile(readSentimentAgentProfile()); + window.addEventListener("sentiment-agent-profile-changed", onChange); + return () => + window.removeEventListener("sentiment-agent-profile-changed", onChange); + }, []); + + const handleChat = () => { + router.push(`/page/workspace/agents/${SENTIMENT_AGENT_ID}/chats/new`); + }; + + const editButton = ( + + ); + + const chatButton = ( + + ); + + const badge = ( + + + 舆情分析 + + ); + + const dialog = ( + + ); + + if (layout === "list") { + return ( +
+
+ +
+
+
+ + {desensitize(profile.name)} + + {badge} +
+

+ {desensitize(profile.description)} +

+
+
+ {editButton} + {chatButton} +
+ {dialog} +
+ ); + } + + return ( + + +
+
+
+ +
+
+ + {desensitize(profile.name)} + +
{badge}
+
+
+
+ {editButton} +
+
+ + {desensitize(profile.description)} + +
+ + {chatButton} + + {dialog} +
+ ); +} diff --git a/frontend-web/src/components/workspace/agents/sentiment-agent-profile-dialog.tsx b/frontend-web/src/components/workspace/agents/sentiment-agent-profile-dialog.tsx new file mode 100644 index 00000000..f88d1438 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/sentiment-agent-profile-dialog.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + DEFAULT_SENTIMENT_AGENT_PROFILE, + type SentimentAgentProfile, + readSentimentAgentProfile, + saveSentimentAgentProfile, +} from "@/core/sentiment-agent/profile"; + +/** + * 舆情分析智能体的「编辑信息」弹窗:修改名称 / 简介并保存到 localStorage。 + * 智能体管理页的卡片和对话页顶栏共用。 + */ +export function SentimentAgentProfileDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + useEffect(() => { + if (!open) return; + const profile = readSentimentAgentProfile(); + setName(profile.name); + setDescription(profile.description); + }, [open]); + + const handleSave = () => { + const next: SentimentAgentProfile = { + name: name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name, + description: + description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description, + }; + saveSentimentAgentProfile(next); + toast.success("舆情分析智能体信息已更新"); + onOpenChange(false); + }; + + return ( + + + + 编辑舆情分析智能体 + +
+
+ + setName(e.target.value)} + placeholder="智能体名称" + /> +
+
+ +