feat: session 模型/推理强度对持久化(#879) - #1139
Conversation
efc5eea to
e6a9f11
Compare
设计定稿(v2,2026-09-02):(模型,effort)成对单值;显式选择即记忆——来源为 用户/记忆的对才随消息携带并持久化,默认来源不携带不记忆(没选过的人继续跟随 bot 默认);welcome=草稿+种子链;多 tab 同浏览器共享 view 同步、跨浏览器最后发送者赢; 渠道 /model//reasoning 清空 session 对;ACP 对存 runtime_metadata。 附录 A 含 v1→v2 修订记录与代码证据。
- 迁移 0146:bot_sessions 加 preferred_chat_model_id/preferred_reasoning_effort (成对写、成对清,NULL=无记忆;写不碰 updated_at) - 解析链:请求 > session 记忆 > subagent pin > bot 默认 > 历史;schedule 轮跳过记忆; effort 记忆 shadow stored(不进 ReasoningRequestedEffort,不串子代理) - 写点(全经 reconcile,DB 不存非法对):picker PATCH;首发 createSession body 随 INSERT; 每轮 resolve() 内请求携带且与记忆不同才写(门=数据,渠道结构不携带); 渠道 /model//reasoning 成功后将 session 对清空(P11') - welcome 种子端点:该 bot 该用户最近一条有记忆的 native session - ACP:对存 runtime_metadata(acp_model_id/acp_reasoning_effort),PATCH 双写 agent 自报值,spawn 后回放覆盖 profile 默认(治 effort 回退) - fork 继承源对;sqlc/swagger/sdk 重生;机制测试:解析链顺序/记忆级/写回门/渠道清空
- composer 对搬入 ChatViewEntry(pairModelId/pairEffort/pairSource): 同 session 多 tab 共享同步;ref 改 computed 桥接;promoteDraft 迁移(首发不闪回) - 来源状态机:user/session 才随 send/retry/edit/createSession 携带; default/unset 省略——服务端据此区分'没选过'(跟随 bot 默认)与'选过'(记忆) - 播种:repoint 重播种不覆盖 user;welcome=本机草稿>种子端点>bot 默认; pin/运行时切换按转移表标来源;换模型落新模型默认档(P6') - picker 纯乐观,PATCH best-effort 失败静默;删 store 层 override 死代码 未经人工 QA:spec §1.4 九幕走查待做
- 触发器 spinner 延迟:加载 3 秒内不出 spinner(用户反馈:快速加载不该闪); popover 菜单内等待行保持即时(用户开着菜单主动等) - spec:P8′ 同步范围明确为 dockview 面板级;两个浏览器标签页=跨设备语义 (最后发送者赢,刷新收敛),消除'同一浏览器内'歧义
- 0146 迁移的 preferred_chat_model_id 外键改用团队期后的复合写法: (team_id, col) REFERENCES models(team_id, id) ON DELETE SET NULL (col)。 裸 SET NULL 会让 confdelsetcols 为 NULL,被 TestTeamChainReversible 守卫判为"可清 team_id 的不安全 FK"(CI Migrations 红)。0001 无需改: 其团队期通用块会把单列 FK 重写成同一形状,全新/增量两条路径收敛。 - golangci 三处:contract.go 与 service_model_preference_test.go 的 gofmt 对齐(rebase 合入遗留),service_model_selection_test.go 的 unused-receiver。 - 另两个 CI 失败与本分支无关(分支不碰 native/docker): TestSpawnAdapterGenerateWithWatchdog* 为预存竞态(本地 -race x20 全过), server-entrypoint 的 alpine:3.23 拉取失败为 runner 网络问题。
rebase 到 94d468e 后四处适配(main 侧变化引入,非行为变更): - currentBotId 变为 string|null(felinics#1131),persistComposerPair/seedWelcomePair 补 botId 空守卫; - vue-tsc 对 pairSource.value 链收窄到新不含 'user',post-await 重查改走 paneView 链(该重查是防种子竞速的必要逻辑,不是冗余); - useDelayedTrue 补 type Ref 导入,onUnmounted 改用文件已导入的 onBeforeUnmount。 chat-pane 剩余两处 TS6133(textareaEl/composerEl)为 main 自带基线, 与本分支无关。
e6a9f11 to
8e4502b
Compare
… SQL 补 team_id - sessionModelPreference:模型列为 NULL 时 effort 一并置空(半对只可能由 ON DELETE SET NULL 单列清出;否则强度会被贴到回落模型上,spec §3.7 禁止) - createWSChatSession:携带的对先经 ReconcileSessionModelPreference 再 INSERT, 与 REST 首发一致(此前已删模型 UUID=FK 违反 500、slug 退化成半对、非法 effort 原样入库) - UpdateSessionModelPreference/GetLatestSessionModelPreference 补 team_id = memoh_current_team_id()(sessions.sql 全文惯例,FORCE RLS 之下的 纵深防御),Update 加 deleted_at IS NULL 守卫
- clearPairDraft 从通用 repoint watcher 挪到 handleSend 成功路径: 此前 welcome 未发草稿仅因点开历史会话/硬刷新就被清掉,直接违反 spec P2′;现在草稿只在 welcome 首发成功(对已落库)时消费,发送失败保留 - seedWelcomePair await 后补 bot/view 同一性守卫:切 bot 后迟到的种子 不再写进另一个 bot 的 welcome(此前会跨 bot 串染并随首发携带) - 携带/省略门与草稿助手提取进 chat-pane-send.ts(carriedPairForSource/ *ComposerPairDraft/welcomeSendConsumedDraft),补 6 组机制测试——此前 该状态机零测试
- 5 个分散的重置/刷新 watcher 合并为一个:以 runtime 身份为键,同 view 身份变化即重置并重载,view 切换不重置 - 修复已有空会话切换 Agent/runtime 后残留旧模型对(旧 watcher 遇 sessionId 直接返回) - 修复 welcome seed 在默认外部 Agent stage 期间返回,把 native UUID 塞进 direct 草稿的竞态 - pair-sync 新增 invalidate(),重置时作废在途读写 - chat-pane.vue 净删约 190 行;新增 5 个状态转换测试
Claude Code 的 ModelCatalog 每次新起 CLI 等 initialize,Codex 调 model/list; 已记忆的 Web 会话每次发送都携带同一对,只在与已存对不同时才重新校验。 revision 仍每次推进,旧 picker PATCH 依旧被拦。
- 删除手写假表 + 字符串切 sqlc 源码的夹具,改用 freshMigratedDB/migrateTo/ applyCanonicalInitOnly:链可逆、0146 幂等、fence/换 runtime 清空/FK SET NULL 在真 schema 上验证 - PatchSessionModelPreference 的 expected revision 改为必填,删掉无调用方的 无条件写分支;picker 写永远是 CAS - 0146 up 注释精简并指向 0141,down 合并为一条 ALTER
|
本轮 review 与产品讨论结论(基于 head 用户路径与范围现有用户路径整体合理,维持当前设计,不新增“跟随 Bot 默认/固定选择”的标签、开关或设置。用户只需要看见当前模型和推理强度;未明确选择时使用 Bot 默认,明确选择后记住该会话的选择,这些语义由内部实现保证。 保留 welcome seed:用户在一段对话使用某个模型和强度后,新建对话自动带出最近使用的选择,减少重复操作。它只带模型和强度,不带聊天内容;本机未发送草稿仍优先。用户明确认可这一体验,它与“记住我的选择”属于连贯的用户路径,不应仅为了缩小 PR 而移除或拆出。 跨浏览器/设备不实时同步、“最后发送者赢”是当前明确接受的设计取舍,本轮不将其作为缺陷,也不改成另一套长期偏好规则。 持久化需要覆盖首次发送、恢复、retry/edit、多面板、native/direct/generic ACP 和迁移,相关跨层改动有必要。范围已到应当冻结的边界:不再借本 PR 扩展产品规则或引入额外状态管理体系。 建议修复的两个 P2两个问题已通过独立回归用例复现,属于实现与既定用户路径不一致,不是要求调整产品设计。尚无证据表明它们高频,但建议合并前修复。
修复优先沿用现有同步机制,不新增用户操作、新存储或另一套状态源;若需要持续叠加标志位和特判,应先检查现有操作顺序的表达是否合理。 收尾边界与验证建议停止条件:两个复现用例转绿、相关检查通过、完成既定核心 QA 后进入合并判断。不以“证明没有任何 bug”为目标,不将所有推测或可选体验改进升级成阻塞项;新的数据安全、权限或核心路径失败仍需单独判断。 本次 AI review 实际验证:
|
|
已修复生成过程中改选模型、刷新后丢失选择的问题,提交 原因是后续 picker PATCH 等待了整条回复结束。现在 Native / Direct 在偏好写入阶段结束后发送 AI 浏览器回归(ego-lite,隔离 QA 后端与本分支前端):暂停附件读取,以 Flash / Medium 捕获发送,再改选 Pro / Medium;放行后约 106ms 收到写入结束事件。回复仍在生成时刷新,Pro / Medium 保留,生成结束后也保留。 验证:54 个相关前端测试、Go application/handlers、改动文件 ESLint、UI contract guard、diff check 通过。完整 vue-tsc 仍有 447 条既有诊断,归一化路径与行号后未新增;本轮未补做外部 CLI/Telegram/人类 QA。新 head CI 尚待完成。 本次 +96/-0:49 行生产代码、47 行测试,13 个文件;生产代码排除独立注释/空行后 +42。新增内容主要是确认事件与现有调用链的传递,没有引入新的存储或 UI 状态机制。整体 PR 对已核验 base 为 +4234/-462,净 +3772。 我的判断:功能范围合理,已复现的保存窗口缺口已补上,支持新 head CI 通过后合并。 |
- composer-pair-sync: dirty 布尔改为 pending 在途计数 + unsaved 未保存 标记。任何失败(发送或写入)都归还计数,普通消息发送失败不再永久 关闭该 view 的服务器重校验;离线失败的 pick 仍受保护到下一次确认 发送;invalidate 只清 unsaved,计数由 settle 自然归还(避免双重计数)。 - picker PATCH 409 冲突不再被静默吞掉:write 新增 onError 出口,冲突时 dropUnsavedChoice + 立即刷新采纳服务端值,pane 显示已有三语言文案。 - retry/edit 接通 model_preference_settled:RetryLatestMessageInput / EditLatestMessageInput 透传回调,local_channel 两个 WS 处理器在拿到 runRef 处发事件,前端 send.ts 透传、pane 接 finish(false)。生成期间 的改选不再等整条回复结束才落库。 - direct runtime 写回失败降级为 warn(与 native best-effort 契约一致), 不再让一次 DB 抖动杀死用户消息。 - persist 的 PATCH 响应回写按 usesDirectRuntime 取列,与 applySessionPair 对称,native 会话不会吃到残留 external id。 测试:sync 原语 +2(失败发送恢复/冲突出口)、useComposerPair +3(失败 发送恢复/未保存保护/冲突回退/direct 双列回写)、chat-list +2(retry/edit settled 透传与错配不放行)、Go direct 写回失败回归 +1。
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 753dce4b9c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| watch( | ||
| [deps.view, deps.visible, () => deps.activeSession.value?.id, deps.runtimeIdentity], |
There was a problem hiding this comment.
Reload the pair when a command clears it
When /model set ... or /reasoning set ... is submitted from an already-open Web session, the backend clears that session's persisted pair, but this watcher observes only activeSession.id, which remains unchanged. The composer therefore retains its old user/session-sourced pair, and the next normal message carries and persists that old pair again, undoing the command's advertised switch unless the page is reloaded. Observe the preference revision/fields or explicitly invalidate and reload the composer after a successful settings command.
Useful? React with 👍 / 👎.
| prefModelID, prefEffort, prefErr := h.modelPrefs.ReconcileSessionModelPreference(c.Request().Context(), bot.ID, modelRef, effortRef) | ||
| if prefErr != nil { | ||
| return echo.NewHTTPError(http.StatusBadRequest, prefErr.Error()) |
There was a problem hiding this comment.
Classify reconciliation failures before returning them
If model reconciliation fails because a database or provider lookup is unavailable, ReconcileSessionModelPreference propagates that infrastructure error here, where it is mislabeled as a client-caused 400 and its raw diagnostic text is sent in the response. This also prevents clients from distinguishing an invalid selection from a retryable server failure. Map known invalid-model cases to a stable public 4xx error and wrap unexpected causes in an appropriate stable 5xx error; the preference PATCH path repeats the same raw-error pattern.
Useful? React with 👍 / 👎.
解决的问题
聊天中明确选择的模型和推理强度此前会在刷新、重开会话或换设备后丢失。本 PR 将二者作为会话偏好持久化;没有选择过的会话继续跟随 bot 默认。Welcome 的本机草稿和服务端最近使用种子只用于新会话,不覆盖已有会话。关联 #879。
最终行为与实现
/help等没有启动模型请求的命令结束时不会扣减发送计数;旧请求结束只能确认自己的快照,不能清掉后来选择的保护。defaultalias 时保留该真实选项;配置与 catalog 均未提供默认模型时不显示无法兑现的 Default 行,明确模型仍可选。设计与语义见
docs/spark/2026-08-31-session-model-preference-design.md。已同步最新 main 的既有修复。验证
go test ./...、golangci-lint run ./...、前端 staged ESLint 和 UI contract 均通过;远端 CI 全部通过。Go CI 首轮遇到既有 watchdog 取消时序测试的固定错误文案断言,实际 error→retry 语义正确;相关执行链与 main 相同,同 head 重跑通过,未修改该测试。/help后重新打开可同步新值;生成中改模型且保存失败,旧回答结束后仍保留新选择,下一条成功落库;Native 发送/重试/编辑;Direct Sonnet→Default→刷新→实际回复。明确留到后续的问题
Subagent pin 模型不同于 bot 默认模型时,如果 bot 设置 high、pin 模型支持 high 但默认 medium,可能出现界面显示 high、实际按 medium 执行。已有后端 base/head 对照证据,尚无真实 subagent 浏览器复现;本次合并接受此已知限制,另行处理,不扩展本次收尾范围。
Welcome 草稿的保留期限,以及 Cloud 平台默认模型在种子链中的层级仍按原设计后续决定。
代码量
按实际 base
8d5dfaee2→ head753dce4b9,71 文件 +4921/-481,净 +4440,排除同步 main 的既有改动。生产代码去掉纯注释与空行后 +1437/-274,净 +1163。按完整文件词法标注后映射到 diff 行;混有代码和注释的行仍计入代码,不按主观功能占比扣减。主要用于会话存储和约束、不同 runtime 的模型解析、并发写入与发送快照,以及前端恢复/草稿/选择生命周期。没有发现本次收尾还必须引入的架构改造。
收尾修复本身为 8 文件 +307/-34,其中生产代码净增 27 行,其余主要为回归测试;没有新增依赖。