diff --git a/.zcode/plans/plan-sess_bc89c1c7-ec32-43b9-886f-70fa2ae83a13.md b/.zcode/plans/plan-sess_bc89c1c7-ec32-43b9-886f-70fa2ae83a13.md new file mode 100644 index 00000000..19779feb --- /dev/null +++ b/.zcode/plans/plan-sess_bc89c1c7-ec32-43b9-886f-70fa2ae83a13.md @@ -0,0 +1,61 @@ +# 深度研究第三轮:检索稳定性修复 + 参考文献确定性 + 页面对齐 AI 写作改造 + +## A. 两个 bug 的排查结论与修复 + +### A1. 第二次检索「网络波动」——确认是真问题(DDG 限流 + 错误语义混淆 + 零重试) + +**排查结论**(用户判断"确实是网络波动"基本正确,但代码层面放大了它): +- 「网络波动」文案不存在于任何代码中——是收集员 LLM 看到 `{"error": "No results found"}` 信封后自己编的解释 +- 真实根因:DDG html 端点对同 IP 快速连续请求限流(403/429 挑战页)→ `DDGSException` → `ddg_search/tools.py::_search_text` 把异常吞成 `[]` → 上层把「被限流」和「真没结果」混成同一个错误信封;工具层完全没有重试 + +**修复**(`packages/harness/deerflow/community/ddg_search/tools.py`): +1. `_search_text` 对异常加 2 次重试 + 指数退避(约 1.5s / 3s + 抖动) +2. 错误信封区分场景:真无结果 → `no results`;多次尝试仍失败 → `web search failed after N attempts (rate limited or network), 换一组检索词稍后重试`——让模型不再瞎猜「网络波动」 +3. 收集员 SOUL.md 行为准则加一条(同步改种子资产 + 已安装副本 `.deer-flow/agents/deep-research-collector/`):搜索返回 error 信封时先换检索词重试一轮,而不是直接告知用户网络异常 + +### A2. 参考文献缺失——代码确有隐患(当前是模型自发行为,时有时无) + +**排查结论**:后端没有任何「参考来源」追加逻辑;四个 runner 的报告 prompt 只要求内联 `[N]` 引用。出现过的参考列表是模型看到上下文头 `[N] 标题(URL)` 后**自发**补写的——弱模型下不可靠,与"信息够不够"无必然关系。 + +**修复**(prompt 指示 + 确定性兜底双保险): +1. 四个 runner(basic / detailed / deep / multi_agent)的报告 prompt 各加一条:报告最后附「## 参考来源」,按 `[N] 标题. URL` 列出正文实际引用过的来源 +2. `basic.py` 新增共享函数 `ensure_reference_section(report, context_list, materials)`:报告缺少参考小节时,解析正文引用的 `[N]` 编号,按压缩上下文顺序生成编号列表追加(无编号时列全部来源,封顶 20 条;已有参考小节则不重复追加)。在各 runner 最终 `write_text("report.md", ...)` 前调用(约 7 个调用点,跳过零材料 fallback) +3. 新增测试 `tests/test_deep_research_references.py`:helper 单测(正常追加/编号映射/不重复追加/无 URL 来源)+ runner 集成断言 + +## B. 页面改造(核心需求):左列复用 AI 写作对话形态,右列改真沙箱 + +### B1. 左列 = AI 写作式对话收集面板(仿 WritingSetupChat) + +- **空状态欢迎屏**(无消息时隐藏头部,展示欢迎卡片,WritingWelcome 式)+ 有消息后 `MessageList`(沿用现有绑 collector 线程的接线)+ 首 token 前「正在整理…」占位条 +- **输入框换成白卡片 PromptInput 套件**(与 AI 写作完全同款组件):卡片内一行「当前对话模型」Select(沿用 ModelNameSelect)+ 高级配置入口;下方 PromptInputTextarea + PromptInputFooter + PromptInputSubmit(`status` ready/streaming,流式中变停止按钮)。经典模式入口保留在高级配置对话框 +- **配置卡片进对话流**(对标 AI 写作「识别出意图后出现表单」): + - `message-list.tsx` 最小扩展(约 15 行):新增分组 `assistant:research-approval`(识别 `additional_kwargs.setup_research_approval`)+ 可选 prop `researchApprovalSlot?: ReactNode`;不传 slot 或无该载荷时零行为变化——普通对话/AI 写作完全不受影响 + - workbench:收集回合结束(awaiting_report,即"意图识别完成")时向 `displayThread` 尾部注入一条虚拟 ai 消息(WritingSetupChat 的 displayThread 同款手法),slot 传现有 `ReportConfigCard` → 卡片作为助手下一轮出现在对话流里 +- 写作进度卡、完成卡、摘要气泡、追问列表保留在列表下方卡片区(报告正文移到右侧沙箱) + +### B2. 右列 = 工作区真沙箱(Step2SandboxLayout 的 stub 线程模式) + +- 新建 `deep-research/ResearchSandboxPanel.tsx`(约 100 行,含关闭/重开)+ `useReportSandbox.ts` hook(仿 `useStep3Report` 的 stub 机制): + - threadStub:`messages = [{type:'ai', tool_calls:[{name:'write_file', args:{path:'/mnt/user-data/outputs/report.md', content: <流式报告>}}]}]`,`isLoading = job 运行中 && phase !== 'summarizing'` + - `report_delta` 首帧 → `openArtifact(write-file: 虚拟URL)` 自动展开右栏;`run.report` 每帧 upsert stub(带防抹空守卫);刷新/切换会话用 `session.reportMarkdown` 重建 + - `ArtifactFileDetail` 自带能力全部免费获得:**流式期 code 视图自动滚底 → 报告写完 isLoading 翻 false 自动切 preview + 脉冲提示**(正是「生成完直接切预览」)、Streamdown 渲染的 Markdown(比现在的好看)、另存 Word/复制/下载、默认走展示层脱敏 +- 布局:ResizablePanelGroup chat 60 / sandbox 40(对齐 ChatBox/圆桌),沙箱可关闭、头部按钮重开 +- **删除自定义 `ResearchSandbox` 组件**;legacy 会话与经典模式共用同一右栏(`run.report` 数据源相同,行为一致) +- 已知小限制(可接受):报告配图模式下沙箱内图片可能无法经 artifacts API 解析(配图默认关闭;真需要时后续把产物落 thread artifacts) + +### B3. 验证 + +- 后端:`PYTHONPATH=. uv run --no-sync pytest tests/ -k deep_research -q --ignore=tests/test_notification_templates.py` 全绿 + ruff;改完配置/SOUL 后 touch 触发 dev 服务重载 +- 前端:`pnpm typecheck` 零新增错误(基线 51) +- 手动走查:连续两次检索不再误报、弱模型报告末尾必带参考来源、页面全流程(欢迎屏 → 对话收集 → 流内配置卡 → 沙箱流式 → 自动预览 → 摘要/追问) + +## 涉及文件 + +**后端**:`community/ddg_search/tools.py`、`deep_research/runners/{basic,detailed,deep,multi_agent}.py`、收集员 SOUL.md(种子 + 已安装副本)、`tests/test_deep_research_references.py`(新) +**前端**:`components/workspace/messages/message-list.tsx`(可选 slot 分组)、`pages/deep-research/DeepResearchWorkbench.tsx`(大改)、`pages/deep-research/ResearchSandboxPanel.tsx`(新)、`pages/deep-research/useReportSandbox.ts`(新) + +## 明确不动 + +- AI 写作 / 普通对话现有组件行为(message-list.tsx 仅加可选分组,缺省零变化) +- 报告写作管线的写作逻辑本身(prompt 加一条要求 + 末尾确定性追加参考小节,均为附加式) +- 深度研究后端会话/任务/事件模型(本轮不动) \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d12c33ae..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/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/public/runtime-config.js b/frontend-web/public/runtime-config.js index f18623c5..66470d66 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -132,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/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 335346cd..1d980f6a 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -16,9 +16,11 @@ import { getStoredAuth } from "@/core/auth"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; +import { isSentimentAgentVisible } from "@/core/sentiment-agent/config"; import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks"; import { AgentCard } from "./agent-card"; +import { SentimentAgentCard } from "./sentiment-agent-card"; const PAGE_SIZE = 12; @@ -30,6 +32,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); + const sentimentAgentVisible = isSentimentAgentVisible(); // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 const isAdmin = getStoredAuth()?.system_role === "admin"; const [viewMode, setViewMode] = useViewMode("agent"); @@ -41,6 +44,9 @@ export function AgentGallery() { // 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>( @@ -70,6 +76,7 @@ export function AgentGallery() { activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin" && + activeTab !== "sentiment" && squareTabs.length > 0 && !squareTabs.some((sq) => sq.id === activeTab) ) { @@ -90,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" @@ -112,7 +123,7 @@ export function AgentGallery() { 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, @@ -148,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 })); @@ -398,7 +416,22 @@ export function AgentGallery() {
我的 - {squareTabs.map((sq) => ( + {defaultSquare && ( + + {defaultSquare.name} + + )} + {sentimentAgentVisible && ( + /* 舆情分析:前端虚拟类型,直连外部舆情问答接口。 */ + + 舆情分析 + + )} + {additionalSquares.map((sq) => ( {sq.name} @@ -512,6 +545,25 @@ export function AgentGallery() { })} + {/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑), + 接口地址与鉴权在 runtime-config.js 配置。 */} + {sentimentAgentVisible && ( + + {viewMode === "list" ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ )} + {isAdmin && ( {renderListBody({ 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="智能体名称" + /> +
+
+ +