Skip to content

feat(thread-chat): 持久化后台生成,支持刷新恢复与消息版本操作 - #30

Draft
hifizz wants to merge 158 commits into
codex/tavily-proactive-research-demofrom
p0-resume-messages
Draft

feat(thread-chat): 持久化后台生成,支持刷新恢复与消息版本操作#30
hifizz wants to merge 158 commits into
codex/tavily-proactive-research-demofrom
p0-resume-messages

Conversation

@hifizz

@hifizz hifizz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

关联 Issue

Refs #29

合并依赖

Blocked by #34

PR #30 在 Issue #34 完成前不得视为完成,也不得转为 Ready for review 或合并。

计划中的合并链路:

  1. 针对 Issue 架构重构:Thread Chat 领域模型、持久化与前端状态 #34 创建独立修复 PR,并以 p0-resume-messages 为目标分支。
  2. Issue 架构重构:Thread Chat 领域模型、持久化与前端状态 #34 的领域模型、持久化、服务端命令边界、前端状态重构和规定的回归验证完成后,将该修复 PR 合入 p0-resume-messages
  3. p0-resume-messages 继续作为 PR feat(thread-chat): 持久化后台生成,支持刷新恢复与消息版本操作 #30 的汇总分支,最后由 PR feat(thread-chat): 持久化后台生成,支持刷新恢复与消息版本操作 #30 整体进入主干合并链路。

PR #30 只有在 Issue #34 已完成、相关 OpenSpec/ADR 已收口、迁移和回归验证通过后,才可以重新标记为 Ready for review。

本 PR 解决 Issue #29 中“客户端断连后任务继续执行、刷新恢复最终结果、重试不重复消息/计费”的韧性部分;request/error ID、跨层 trace、结构化 telemetry 与告警仍留在 Issue #29 后续完成。

背景

此前 thread-chat 的回复流由浏览器直接持有:用户刷新、关闭标签页或网络抖动后,前端 SSE 会断开,正在生成的 assistant 回复可能整条丢失;即使模型随后完成,页面也无法恢复最终结果。异常状态还可能留下只有 user message、没有可恢复 assistant 的“孤儿轮次”。

这个 PR 将生成任务从浏览器生命周期中解耦,同时补齐消息编辑、重新生成、版本切换、反馈和失败恢复能力。

主要改动

1. 服务端持有 generation 生命周期

  • 新增 owner-scoped branch_generations sidecar,记录 attempt、current 状态、turn snapshot、heartbeat、结构化结果、usage 和 billing 状态。
  • 调用模型前,严格持久化 user message、assistant placeholder 和 generation identity;持久化失败时不发起付费模型请求。
  • 浏览器断连只停止消费本地流,不再传播为模型取消;只有显式 Stop 才会请求服务端终止 generation。
  • 服务端独立消费完整响应流,并以事务完成终态持久化和幂等计费。
  • 通过 current-attempt CAS 防止旧 Retry/Regenerate 结果晚到后覆盖当前回复。
  • stale generation 超出租约后收敛为可重试失败,避免页面永久转圈。

2. 刷新后恢复后台状态和最终回复

  • Tree GET 会协调持久化树与 generation sidecar,合并 completed/failed/stopped 终态。
  • generation 仍在运行时,页面展示“正在后台生成,完成后显示”并轮询状态。
  • generation 完成后,正文、Markdown Artifact、联网研究结果、来源和研究计划会原子恢复。
  • P0 只恢复后台状态与最终结果,不重放刷新期间错过的 token 动画。

3. 不可变消息 DAG 与消息操作

  • 将消息历史升级为 strict schema-v2 DAG,以 parentMessageIdactiveLeafMessageId 表达回复版本。
  • 编辑 user message 会创建 sibling user + assistant;重新生成会创建 sibling assistant,不覆盖原消息。
  • 支持回复版本切换,并保留旧版本派生的子 Thread、引用、fork 和 Artifact provenance。
  • 为 user message 增加复制、重新编辑;为完成的 assistant message 增加复制、重新生成、点赞和点踩。
  • pending、streaming、error 回复不展示完成态 toolbar;失败使用独立 Retry 入口。
  • orphan user / interrupted assistant 可从原位置重试,不再让整棵树失去操作入口。

4. Message feedback 与 generation 解耦

  • 产品反馈绑定稳定的 messageId,不再要求浏览器持有 generationId
  • 新增 branch_message_feedback 和 owner-scoped message feedback API。
  • 重新生成的新 message 不继承旧反馈;切回旧版本时恢复原 message 的反馈。
  • 系统生成且标记为 done 的 assistant message 必须能在服务端反查到 completed generation。

5. 并发、所有权与一致性保护

  • branch tree、generation、Stop、版本切换和 feedback API 全部按当前登录用户隔离;非 owner 统一返回不泄露资源存在性的响应。
  • branch_trees.revision + CAS 防止旧标签页用整树快照覆盖新节点、版本选择或 Artifact。
  • generation start intent、tree revision 和 strict-v2 graph 均为显式必填契约。
  • 运行中的 generation 会阻止删除其持久化目标。

数据库与 API

新增/调整:

  • branch_generations
  • branch_message_feedback
  • branch_trees.user_id / branch_trees.revision
  • usage_records.app_generation_id
  • GET /api/branch-generations/:generationId
  • POST /api/branch-generations/:generationId/stop
  • PATCH /api/branch-trees/:treeId/active-leaf
  • PUT /api/branch-trees/:treeId/messages/:messageId/feedback

部署与兼容性

  • 部署前必须先运行 pnpm db:migrate,再发布依赖新 schema 的服务端和客户端。
  • 本实现只接受 strict schema-v2、显式 generation intent 和 revision,不保留旧线性树或 generation-scoped feedback 的兼容路径。
  • 当前阶段不会在进程崩溃后恢复同一次供应商模型执行;stale lease 会明确收敛为失败并允许重试。

详细设计与行为契约:

  • openspec/changes/persist-thread-chat-generations/
  • openspec/changes/add-thread-chat-message-actions/

验证

已覆盖:

  • generation start replay、current attempt、Stop/complete 竞态、supersede、stale lease 和幂等计费
  • tree revision CAS、跨用户隔离和 generation-vs-tree 写入竞态
  • strict message graph、编辑/重生成 patch、版本切换和 Artifact provenance
  • message feedback set/repeat/switch/clear、刷新恢复和 owner isolation
  • partial/error/empty reply、Markdown Artifact 与联网研究结果的结构化投影
  • 刷新期间后台继续生成、完成后恢复最终答案、列模式与画布模式一致性

执行检查:

  • pnpm typecheck
  • pnpm lint
  • pnpm openspec:validate
  • focused thread-chat pure/controller/database tests
  • next build --webpack
  • ego-browser nodejs localhost end-to-end verification

@cr-gpt

cr-gpt Bot commented Aug 19, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thread-chatbot Error Error Aug 26, 2026 5:22pm

@cr-gpt

cr-gpt Bot commented Aug 19, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@hifizz hifizz changed the title feat: 修复P0级问题,前端抖动后后端需要继续保存消息,不能丢失.刷新后能看到最终生成的消息 feat(thread-chat): 持久化后台生成,支持刷新恢复与消息版本操作 Aug 19, 2026
@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@hifizz

hifizz commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

[!IMPORTANT]

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7362da52-623d-49c8-bcb5-db22dd46ee55

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@coderabbitai review

@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@cr-gpt

cr-gpt Bot commented Aug 20, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

hifizz added 8 commits August 21, 2026 11:16
Before: the headless core imported persisted TextAnchor types from the branching DOM/UI module.

After: persisted anchor and locate-result contracts live in lib/thread-chat/domain; branching keeps DOM behavior and a compatibility type re-export.

Verified: pnpm typecheck; text-anchor position/exact/fuzzy regression test; manual localhost smoke switched canvas to columns and rendered one main column without runtime errors.
Before: server repositories, database schema, and the tree API imported ThreadTreeState and feedback types from the page-local app/thread-chat/core module.

After: tree, message, artifact, and feedback contracts have one canonical lib/thread-chat/domain source; the old client path is a compatibility re-export and server consumers no longer import it.

Verified: pnpm typecheck; message-graph, regeneration-patch, and message-action-availability tests; manual localhost columns-to-canvas-to-columns smoke with one main column, model selector, and no runtime error.
Before: message graph parsing, active-path selection, turn alternatives, and provenance lived under the client page core while server repositories and the tree API imported that page module.

After: all message graph rules live in lib/thread-chat/domain; the old core path is a compatibility re-export and server-side imports use the domain entry directly.

Verified: pnpm typecheck; 11 message-graph, 7 reconciliation, and 4 regeneration cases; manual localhost thread-tree dialog showed main, canvas rendered after view switch, columns restored, and no runtime error appeared.
Before: database schema and generation repositories imported lifecycle, identity, result, and reconciliation types from the client page generation folder; the reconciliation state type also reached back into app/core.

After: generation contracts live in lib/thread-chat/domain and depend only on domain tree types plus shared chat metadata; the old page path is a compatibility re-export and all server consumers use the domain entry.

Verified: pnpm typecheck; generation-persistence and reconciliation suites; local Postgres generation repository race/stop/supersede/stale/authorization/finalize test; manual localhost tree dialog, composer, and model selector smoke with no runtime error.
hifizz added 9 commits August 21, 2026 11:16
Before: a billed generation that completed without captured provider usage passed usageUnavailable=false and finalize classified it as not_billable, the state reserved for preview calls.

After: every non-preview settlement without captured usage is explicitly usage_unavailable. Abort remains unavailable even when completed-step usage exists, while preview and fully captured paid usage retain their existing behavior.

Manual case: ran the settlement handler for a completed paid generation with no captured usage. Before the fix the finalize input reported false; after it reported usageUnavailable=true. Completed, empty, aborted, failed, preview, stream-lifecycle, TypeScript, targeted ESLint, Prettier, and diff checks passed. No browser case applies.
Before: superseded audit replays preserved settled but could still downgrade usage_unavailable to not_billable when a duplicate finalize carried no usage, erasing the pending reconciliation signal.

After: one pure billing progression policy orders pending, not_billable, usage_unavailable, and settled. Superseded updates may advance with better evidence but never regress an existing billing fact.

Manual case: completed a real DB generation with usageUnavailable, superseded it with a new attempt, then replayed finalize without usage. Before the fix billing became not_billable; after it remained usage_unavailable, and the replacement attempt still finalized normally. generation-db.test.mjs, TypeScript, targeted ESLint, Prettier, and diff checks passed. No browser case applies.
Before: replay matching compared owner, tree, thread, message IDs, and model but not the logical action intent. Reusing a generation ID with a different action kind or source was incorrectly accepted as an idempotent replay.

After: the normalized intent is stored with the authoritative turnSnapshot and compared field-by-field as part of replay identity. Different kind/source/text returns generation_conflict; snapshots without intent are not guessed or migrated.

Manual case: started a persisted-turn in the configured database, then replayed the same generation/owner/tree/message/model identity as retry-orphan-user. Before the fix it returned created=false; after it returned controlled generation_conflict, while exact concurrent replays still created one attempt. Generation DB, projection/reconciliation, identity/intent contract, TypeScript, targeted ESLint, Prettier, and diff checks passed. No browser case applies.
问题:一次用户发送会经过研究路由、正式生成、工具循环或 SDK 重试,并可能展开成多次上游模型请求。此前服务端只记录请求结果和错误,无法把模型厂商后台的多条调用与同一次对话、generation 和具体用途对应起来,正在解决模型请求数量不可观测、难以定位重复调用来源的问题。

方案:在 AI SDK 模型中间件层统一记录每一次真实 generate、stream 和 embed 调用,为日志增加 callId、requestId、用途、供应商、模型、tree/generation 身份及脱敏上下文统计;覆盖主回答、研究路由、研究计划、分支标题、附件洞察和 embedding,不记录 API Key、完整提示词或用户原文。

结果:修改前,用户只看到一次发送但厂商后台可能出现多条请求,服务端无法解释每一条从何而来;修改后,每次真实上游调用都有一条可关联日志,可区分研究路由、工具步骤和重试,同时不改变用户的发送、流式展示和模型行为。类型检查与 lint 已通过。
问题:用户发送消息后,1.5 秒自动存盘可能先把树 revision 推进,而稍后返回的 /api/chat 启动响应仍携带旧 revision。客户端会被旧值回退,下一次存盘错误地得到 409,并强制刷新成“正在后台生成”;同时新的 Anthropic 兼容厂商没有返回 signed reasoning metadata,多步联网时 AI SDK 会反复告警并丢弃历史 reasoning。

方案:让客户端已知 revision 在同一页面生命周期内只单调前进,并在树被删除时清理缓存;补充旧响应不能覆盖新 revision 的回归测试。对 UMAPIS Claude 的 search、fetch、research 多步路径显式关闭 extended reasoning,普通回答、UMAPIS GPT 和其他供应商保持原策略,并补充供应商边界测试。

结果:修改前,用户等待回复时可能看到页面闪烁、误判存盘冲突、整页刷新、进入后台轮询,服务端还会累积 unsupported reasoning metadata 告警;修改后,当前页面保持原 SSE 流式展示,旧 revision 不再制造虚假 409,真正的多标签页冲突与刷新后后台恢复机制仍保留,受影响的 UMAPIS Claude 多步路径不再生成无法回放的 reasoning。普通搜索步数未改变;相关测试、类型检查和 lint 均通过,lint 仅保留 3 条既有 warning。
问题:thread-chat 虽已按 branching、chat、net、orchestration 分层,但各层仍平铺 10 到 38 个文件;消息动作、流处理、持久化、画布和工作台控件等相关实现分散,查找和维护成本高。

方案:在既有架构边界内继续按功能簇拆分 assistant/selection、actions/composer/message、boot/commands/persistence/prompt/stream/titles,以及 artifacts/canvas/columns/navigation/overlays/workspace;同步修正全部跨目录引用、路径型测试、README 与 OpenSpec 文档,并保留三个顶层入口文件作为门面。

结果:用户和开发者看到的不再是每层十几到几十个文件的平铺列表,每个功能目录最多 9 个文件,功能归属可直接从路径判断;运行逻辑保持不变,typecheck 通过,111 个非数据库 thread-chat 测试全部通过。
@hifizz
hifizz force-pushed the p0-resume-messages branch from 753c2c9 to 7ca5b4f Compare August 21, 2026 03:16
@cr-gpt

cr-gpt Bot commented Aug 21, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@hifizz
hifizz marked this pull request as draft August 21, 2026 05:45
@cr-gpt

cr-gpt Bot commented Aug 21, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@cr-gpt

cr-gpt Bot commented Aug 21, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

@cr-gpt

cr-gpt Bot commented Aug 21, 2026

Copy link
Copy Markdown

Seems you are using me but didn't get OPENAI_API_KEY seted in Variables/Secrets for this repo. you could follow readme for more information

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant