deerflow-code/frontend-web/docs/沙箱统一写作管线-实现方案.md
2026-09-07 18:24:55 +08:00

32 KiB
Raw Permalink Blame History

沙箱统一写作管线:可实现开发方案

状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。
覆盖范围:普通沙箱 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 普通消息列表步骤条 + 右侧沙箱

统一内核中的枚举:

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 发起后台全文重写任务

建议显示条件:

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. 总体架构

flowchart LR
  A["MarkdownAiEditor<br/>选区 / 段落"] --> P
  B["ArtifactFileDetail<br/>AI 全文改写"] --> P
  C["DeepResearch<br/>局部 / 全文"] --> P

  P["Document Rewrite Pipeline<br/>冻结版本 → 构造上下文 → 流式生成<br/>校验 → 差异总结 → 按策略提交"]

  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 目录与职责

建议新增:

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/
   └─ <next_revision>_document_rewrites.py

packages/harness/deerflow/document_rewrite 不能导入 app.*,以符合项目 harness→app import firewall。app/gateway/document_rewrite_sources.py 负责把已鉴权的 Artifact/研究会话包装成内核端口。

4.2 来源端口

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 状态机

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 契约

创建全文任务:

POST /api/document-rewrites
Content-Type: application/json

普通 Artifact 请求:

{
  "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"
}

深度研究请求:

{
  "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"
}

其他接口:

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 模型、提示词与校验

管线只依赖一个流式模型端口:

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 系统约束:

仅根据原文与用户要求改写,不联网,不补充未给出的事实。
保留有效链接、代码块、表格、标题语义和用户明确要求保留的内容。
只输出完整 Markdown 正文;不得输出解释、前缀或“改写如下”。

深度研究额外约束:

不得联网。事实、数字、结论和引用只能来自当前 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

后端产出结构化差异,前端仅渲染,避免生成空泛结论:

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[];
};

推荐总结展示:

已完成《新能源汽车市场分析报告》的整体改写。

- 改写范围:全文;要求为“统一正式分析语气、减少重复”;模型:默认模型。
- 数量变化:原文 8,260 字,改写后 8,110 字,减少 1.8%;调整 14 个内容块。
- 结构变化:标题层级保持不变;未新增或删除一级标题。
- 已执行的优化:按要求合并重复表述、统一语气、调整段落衔接。
- 保留情况:保留 35 条已选来源引用,未新增外部来源。
- 风险提示:无。

[查看前后对比] [撤销到改写前版本]

“已执行的优化”仅来自用户要求和可计算的结构差异;不能无依据宣称“文章更专业、更严谨”。

6. 前端实现设计

6.1 新增模块

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。应当网络层完整读取、渲染层按动画帧合并:

const pendingDeltaRef = useRef("");
const frameRef = useRef<number | null>(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 增加:

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:
const visibleReport = activeDocumentRewrite?.isActive
  ? activeDocumentRewrite.draftContent
  : reportMarkdown;
  1. 流式期间只显示临时草稿,不提前写 session 报告。
  2. committed 后刷新/替换 session 的 report_markdown;失败后恢复旧报告。
  3. 当前“改写第一段 → 候选消息 → 确认应用”逻辑继续保留,不因全文改写而改变。

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. 核心执行伪代码

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. 测试与验收

建议新增后端测试:

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 可用全文改写;
  • 局部编辑器原文变化后不能应用过期候选。

执行基线:

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. 每一次真实全文覆盖都有版本快照,并且能够安全撤销。