# 沙箱统一写作管线:可实现开发方案 > 状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。 > 覆盖范围:普通沙箱 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. 每一次真实全文覆盖都有版本快照,并且能够安全撤销。