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

823 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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