feat(thread-chat): 持久化后台生成,支持刷新恢复与消息版本操作 - #30
Conversation
|
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
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 |
|
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 |
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
|
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 |
|
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 review |
|
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 |
|
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 |
|
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 |
|
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 |
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.
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 测试全部通过。
753c2c9 to
7ca5b4f
Compare
|
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 |
|
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 |
|
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 |
|
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 |
关联 Issue
Refs #29
合并依赖
Blocked by #34
PR #30 在 Issue #34 完成前不得视为完成,也不得转为 Ready for review 或合并。
计划中的合并链路:
p0-resume-messages为目标分支。p0-resume-messages。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 生命周期
branch_generationssidecar,记录 attempt、current 状态、turn snapshot、heartbeat、结构化结果、usage 和 billing 状态。2. 刷新后恢复后台状态和最终回复
3. 不可变消息 DAG 与消息操作
parentMessageId和activeLeafMessageId表达回复版本。4. Message feedback 与 generation 解耦
messageId,不再要求浏览器持有generationId。branch_message_feedback和 owner-scoped message feedback API。done的 assistant message 必须能在服务端反查到 completed generation。5. 并发、所有权与一致性保护
branch_trees.revision+ CAS 防止旧标签页用整树快照覆盖新节点、版本选择或 Artifact。数据库与 API
新增/调整:
branch_generationsbranch_message_feedbackbranch_trees.user_id/branch_trees.revisionusage_records.app_generation_idGET /api/branch-generations/:generationIdPOST /api/branch-generations/:generationId/stopPATCH /api/branch-trees/:treeId/active-leafPUT /api/branch-trees/:treeId/messages/:messageId/feedback部署与兼容性
pnpm db:migrate,再发布依赖新 schema 的服务端和客户端。详细设计与行为契约:
openspec/changes/persist-thread-chat-generations/openspec/changes/add-thread-chat-message-actions/验证
已覆盖:
执行检查:
pnpm typecheckpnpm lintpnpm openspec:validatenext build --webpackego-browser nodejslocalhost end-to-end verification