32 KiB
沙箱统一写作管线:可实现开发方案
状态:设计稿;本文件只定义实施方案,不包含本轮业务代码改动。
覆盖范围:普通沙箱 Markdown、深度研究report.md、现有 Markdown 编辑器的选区/段落改写。
1. 目标与结论
目标不是再增加一个“AI 写作页面”,而是将现有的四类能力统一为一个安全、可流式、可追溯的文档改写管线:
- Markdown 编辑器选区改写;
- Markdown 编辑器段落改写;
- 深度研究报告的局部候选改写;
- 沙箱中 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.tsxfrontend-web/src/open-canvas/hooks/useWritingRewrite.tsfrontend-web/src/open-canvas/api/rewrite.tsoffline-backend-20260512/backend/app/gateway/routers/writing.py
局部改写仍然是:
- 选中文字或打开段落菜单;
- 选择润色、扩写、缩写、改写等动作;
- 在现有弹窗中流式出现候选;
- 用户手动选择替换、插入、重试或继续追问。
不要让局部改写每次都往普通消息列表插完整步骤条,否则频繁编辑会淹没聊天历史。它只共享底层管线,不共享全文改写的消息展示方式。
需要补充本地版本保护:发起请求时保存当前完整编辑器文本哈希、选区 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 为真,按钮置灰并提示“请先保存当前修改后再进行全文改写”。后端全文任务从真实文件读取快照,不能在用户本地还有未保存修改时读取旧版本并最终覆盖新修改。
用户体验:
- 点击按钮,打开简洁配置弹窗。
- 弹窗显示文件名、原文字数;用户填写改写要求、选择模型、选择风格。
- 点击开始,左侧普通消息列表创建一组步骤条。
- 右侧沙箱自动切到源码视图,先显示“正在准备临时草稿,原文件尚未修改”。
- 新 Markdown 从空临时草稿开始逐 token 写入右侧代码区域。
- 校验通过后,后端原子替换真实内容;前端再同步真实 Artifact 缓存。
- 左侧保留最终对比总结、查看 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["内容哈希、版本快照、原子提交"]
设计原则:
- 纯执行内核不依赖 FastAPI、路由、用户身份或操作系统路径。
- 鉴权、线程路径解析、研究 session 获取只在
app层的来源适配器中完成。 - 完整文档任务使用持久化后台 Job;局部弹窗可维持单次 SSE,避免过度持久化。
- 全文流式过程中只写临时草稿;真实内容只在最终 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 modelastream; - 深度研究复用
deep_research.py使用的DeerFlowCompletionBackend.stream_complete。
先统一编排,不强制立即统一所有模型配置实现。
普通 Markdown 系统约束:
仅根据原文与用户要求改写,不联网,不补充未给出的事实。
保留有效链接、代码块、表格、标题语义和用户明确要求保留的内容。
只输出完整 Markdown 正文;不得输出解释、前缀或“改写如下”。
深度研究额外约束:
不得联网。事实、数字、结论和引用只能来自当前 session 已选资料。
只允许使用给出的 source_id;资料不足时保持谨慎表述,禁止自行补全。
校验规则:
| 类别 | 硬失败 | 告警但允许完成 |
|---|---|---|
| 正文 | 空文档、仅空白、无可见内容 | 改写后与原文完全一致 |
| Markdown | 未闭合围栏、明确截断 | 标题层级跳级、章节数明显减少 |
| 深度研究来源 | 未选来源 ID、非法引用 | 引用数量下降、可能需核验的数字断言 |
5. 安全提交、对比与撤销
5.1 临时草稿不等于真实文件
用户视觉上看到“先清空再逐 token 写入”,但真实文件的处理必须是:
- 读取原文并保存内容哈希、版本快照。
- 右侧只展示内存/Job 中的
draft_content。 - 生成完成后做校验和 diff。
- 提交前再次读取真实来源,并比较
SHA-256。 - 哈希一致才原子替换;否则进入冲突,不覆盖。
普通 Artifact 使用同目录临时文件、刷盘和 os.replace;深度研究通过数据库事务内的“旧哈希条件更新”提交。任何失败、取消或断流都保留真实原文。
5.2 撤销
撤销流程:
- 读取该 Job 的
before_full_rewrite快照。 - 确认当前真实内容哈希仍等于该 Job 的
committed_sha256。 - 若用户在改写后又手工编辑,返回冲突,不盲目覆盖。
- 先保存当前内容为
before_undo版本。 - 原子恢复旧版本,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;
};
改造步骤:
- 加入右上角全文改写图标按钮和
DocumentRewriteDialog。 - 有活跃任务时强制
viewMode = "code"。 - 内容优先级为:
activeRewrite.draftContent ?? draftContent ?? artifactContent。 - 活跃任务中代码只读,禁用保存、局部编辑和重复全文改写。
- 收到
committed后用现有useUpdateArtifactContent或等价 Query cache 更新,使真实文件立即同步。 - 收到
conflict/error/cancelled时废弃临时草稿显示,回到真实文件内容。
6.4 ResearchSandboxPanel 接入
相关文件:
frontend-web/src/pages/deep-research/ResearchSandboxPanel.tsxfrontend-web/src/pages/deep-research/useReportSandbox.tsfrontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx
改造:
ResearchSandboxPanel为虚拟report.md传入kind: "deep_research_report"target。useReportSandbox接受externalDraftOverride:
const visibleReport = activeDocumentRewrite?.isActive
? activeDocumentRewrite.draftContent
: reportMarkdown;
- 流式期间只显示临时草稿,不提前写 session 报告。
committed后刷新/替换 session 的report_markdown;失败后恢复旧报告。- 当前“改写第一段 → 候选消息 → 确认应用”逻辑继续保留,不因全文改写而改变。
6.5 普通消息列表接入
复用:
frontend-web/src/components/workspace/messages/message-list.tsxfrontend-web/src/components/workspace/messages/MessageGroup.tsxDeepResearchWorkbench.tsx中的虚拟消息注入模式。
DocumentRewritePresentationProvider 按 presentation_thread_id 读取 Job 历史并投影为:
- 用户消息:“AI 全文改写《xxx.md》”;
- AI 工具调用:
document_rewrite_progress; - 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:后端内核与安全基础
- 新增领域模型、来源端口、校验、确定性 diff。
- 新增 Job、事件、版本数据表与 migration。
- 参考深度研究 executor 完成后台任务、取消、SSE 重放。
- 实现普通 Artifact 读取、哈希比较、原子提交和撤销 API。
- 编写接口测试。
验收:可以通过 API 对普通 Markdown 创建全文任务;取消、失败、冲突均不改原文件;成功后可撤销。
阶段 B:普通沙箱全文 UI
- 新增
core/document-rewrite前端状态、SSE、rAF hook。 - 改
ArtifactFileDetail:右上角按钮、配置弹窗、临时草稿、禁用态。 - 接入普通
MessageList的步骤条、总结卡、diff 和撤销。 - 支持刷新后恢复 Job 状态与已完成历史。
验收:右侧可平滑逐 token 写入;完成后替换真实 Markdown;左侧步骤和总结保留;撤销成功恢复。
阶段 C:深度研究接入
- 实现
DeepResearchReportSource。 - 由
ResearchSandboxPanel传入报告 target。 useReportSandbox支持外部临时草稿。- 对深度研究添加引用白名单校验。
- 保持现有报告局部候选确认卡不变,再逐步下沉公共 prompt/校验逻辑。
验收:报告全文改写不触发联网搜索,不允许新来源;局部报告改写依然先确认再应用。
阶段 D:局部改写内核迁移
- 保持
/api/writing/rewrite契约不变。 - 将
writing.py转为InlineDocumentSource + preview_only的兼容 facade。 - 给
useWritingRewrite增加原文快照失效检查。 - 回归替换、插入、重试、继续追问。
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. 完成定义
完成后应满足:
- 局部编辑依旧是编辑器弹窗中的人工确认操作。
- 全文改写在普通消息列表展示完整且不隐藏的步骤条。
- 右侧沙箱可平滑地逐 token 展示临时 Markdown 草稿。
- 任何中断、取消、校验失败和冲突都不损坏真实文件。
- 完成后用户可以看到量化差异、结构变化、来源保留和风险提示。
- 普通 Markdown 与深度研究报告都可全文改写,但研究报告绝不绕过已选来源约束。
- 每一次真实全文覆盖都有版本快照,并且能够安全撤销。