Skip to content

feat: session 模型/推理强度对持久化(#879) - #1139

Merged
qqqqqf-q merged 19 commits into
felinics:mainfrom
qqqqqf-q:feat/session-model-preference
Sep 7, 2026
Merged

qqqqqf-q merged 19 commits into
felinics:mainfrom
qqqqqf-q:feat/session-model-preference

Conversation

@qqqqqf-q

@qqqqqf-q qqqqqf-q commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

解决的问题

聊天中明确选择的模型和推理强度此前会在刷新、重开会话或换设备后丢失。本 PR 将二者作为会话偏好持久化;没有选择过的会话继续跟随 bot 默认。Welcome 的本机草稿和服务端最近使用种子只用于新会话,不覆盖已有会话。关联 #879。

最终行为与实现

  • Native 和 Direct 使用各自模型命名空间的会话偏好,ACP 沿用 runtime metadata;每次换模型同时归一化推理强度。
  • Picker 写入、首次创建和消息接收共用偏好解析与保存逻辑,revision 防止旧写入覆盖新值。未保存的显式选择留在界面,下一条发送继续携带。
  • /help 等没有启动模型请求的命令结束时不会扣减发送计数;旧请求结束只能确认自己的快照,不能清掉后来选择的保护。
  • Direct 点击 Default 时解析成当前配置或 catalog 指向的模型,保存、发送和刷新使用同一个值。Claude 只广告 default alias 时保留该真实选项;配置与 catalog 均未提供默认模型时不显示无法兑现的 Default 行,明确模型仍可选。
  • Native 迁移 0146 同步 canonical init,保留团队隔离、复合 FK 与成对更新;本次收尾修复没有增加数据库或 API 变更。

设计与语义见 docs/spark/2026-08-31-session-model-preference-design.md。已同步最新 main 的既有修复。

验证

  • 最终合并代码的相关前端回归 5 文件 / 183 tests 通过;新增未 begin、重复回调、send/retry/edit 两阶段结算、失败选择重试、Direct Default 保存与重载等回归。
  • 最终提交的 go test ./...、golangci-lint run ./...、前端 staged ESLint 和 UI contract 均通过;远端 CI 全部通过。Go CI 首轮遇到既有 watchdog 取消时序测试的固定错误文案断言,实际 error→retry 语义正确;相关执行链与 main 相同,同 head 重跑通过,未修改该测试。
  • AI 使用 Ego Lite 和独立真实后端验证:/help 后重新打开可同步新值;生成中改模型且保存失败,旧回答结束后仍保留新选择,下一条成功落库;Native 发送/重试/编辑;Direct Sonnet→Default→刷新→实际回复。
  • 本轮从空数据库完成迁移 1→146、完整 down→up、重复 up,验证旧数据保留、RLS、跨团队 FK/CAS 和偏好 revision;之后的收尾改动没有改变这些 SQL。
  • 未执行最终 head 的真人 QA、真实 generic ACP/Codex OAuth、IM 实际消息、手机/桌面打包客户端或生产部署。项目既有 typecheck 诊断不等于本 PR 已实现零类型错误。

明确留到后续的问题

Subagent pin 模型不同于 bot 默认模型时,如果 bot 设置 high、pin 模型支持 high 但默认 medium,可能出现界面显示 high、实际按 medium 执行。已有后端 base/head 对照证据,尚无真实 subagent 浏览器复现;本次合并接受此已知限制,另行处理,不扩展本次收尾范围。

Welcome 草稿的保留期限,以及 Cloud 平台默认模型在种子链中的层级仍按原设计后续决定。

代码量

按实际 base 8d5dfaee2 → head 753dce4b9,71 文件 +4921/-481,净 +4440,排除同步 main 的既有改动。

分类 新增 删除 净变化
生产代码,含注释与空行 1920 305 +1615
测试,含测试注释 1898 13 +1885
自动生成内容 704 163 +541
设计文档 399 0 +399
纯机械变更 0 0 0

生产代码去掉纯注释与空行后 +1437/-274,净 +1163。按完整文件词法标注后映射到 diff 行;混有代码和注释的行仍计入代码,不按主观功能占比扣减。主要用于会话存储和约束、不同 runtime 的模型解析、并发写入与发送快照,以及前端恢复/草稿/选择生命周期。没有发现本次收尾还必须引入的架构改造。

收尾修复本身为 8 文件 +307/-34,其中生产代码净增 27 行,其余主要为回归测试;没有新增依赖。

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

@qqqqqf-q
qqqqqf-q force-pushed the feat/session-model-preference branch from efc5eea to e6a9f11 Compare September 2, 2026 18:16
设计定稿(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 自带基线,
与本分支无关。
@qqqqqf-q
qqqqqf-q force-pushed the feat/session-model-preference branch from e6a9f11 to 8e4502b Compare September 3, 2026 07:02
… 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
@qqqqqf-q

qqqqqf-q commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

本轮 review 与产品讨论结论(基于 head 15f3eb2bf):

用户路径与范围

现有用户路径整体合理,维持当前设计,不新增“跟随 Bot 默认/固定选择”的标签、开关或设置。用户只需要看见当前模型和推理强度;未明确选择时使用 Bot 默认,明确选择后记住该会话的选择,这些语义由内部实现保证。

保留 welcome seed:用户在一段对话使用某个模型和强度后,新建对话自动带出最近使用的选择,减少重复操作。它只带模型和强度,不带聊天内容;本机未发送草稿仍优先。用户明确认可这一体验,它与“记住我的选择”属于连贯的用户路径,不应仅为了缩小 PR 而移除或拆出。

跨浏览器/设备不实时同步、“最后发送者赢”是当前明确接受的设计取舍,本轮不将其作为缺陷,也不改成另一套长期偏好规则。

持久化需要覆盖首次发送、恢复、retry/edit、多面板、native/direct/generic ACP 和迁移,相关跨层改动有必要。范围已到应当冻结的边界:不再借本 PR 扩展产品规则或引入额外状态管理体系。

建议修复的两个 P2

两个问题已通过独立回归用例复现,属于实现与既定用户路径不一致,不是要求调整产品设计。尚无证据表明它们高频,但建议合并前修复。

  1. Welcome seed 晚返回覆盖发送期间的显示。 useComposerPair.ts 的 seedWelcome() 直接应用异步结果,未经过现有读取保护。默认模型 A 的发送快照已捕获、附件仍在转换时,seed B 返回会把 composer 改成 B,而发送参数仍按 A 捕获。建议让 seed 读取接入现有快照保护。
  2. 附件准备期间的新选择被旧发送取消。 捕获 A 的发送快照后,用户选择 B;B 正在读取 revision 时,A 随后进入 beginSend(),推进 epoch,将 B 的保存视为过期操作取消。结果界面显示 B、服务端保留 A,刷新后 B 丢失。应区分快照之前的旧选择与快照之后的新选择,后者在发送之后保存,当前消息仍使用 A。

修复优先沿用现有同步机制,不新增用户操作、新存储或另一套状态源;若需要持续叠加标志位和特判,应先检查现有操作顺序的表达是否合理。

收尾边界与验证

建议停止条件:两个复现用例转绿、相关检查通过、完成既定核心 QA 后进入合并判断。不以“证明没有任何 bug”为目标,不将所有推测或可选体验改进升级成阻塞项;新的数据安全、权限或核心路径失败仍需单独判断。

本次 AI review 实际验证:

  • application、handlers、acp、claudecode、command 五个 Go 包测试通过。
  • 现有 33 个相关前端测试通过;新增两个针对上述时序的复现用例失败。
  • 独立 PostgreSQL 中,TestSessionModelPreferenceMigrationAndWriteFence 通过,覆盖真实迁移链往返、0146 重复执行、revision 写入保护、runtime 切换清空与 canonical init 检查。
  • 本次没有执行浏览器或真人 QA,以上结果不能替代剩余外部 Agent、Telegram、弱网等既定用户路径验收,也不解除 PR 现有的 No human QA 标记。

@qqqqqf-q

qqqqqf-q commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

已修复生成过程中改选模型、刷新后丢失选择的问题,提交 20ac19ee0。

原因是后续 picker PATCH 等待了整条回复结束。现在 Native / Direct 在偏好写入阶段结束后发送 model_preference_settled,前端只放行匹配 invocation/run/session 的保存队列;重复/错配通知不会误释放,错误和旧服务端仍保留结束兜底。没有数据库迁移或 UI 变化。

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 通过后合并。

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

- 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。
@qqqqqf-q
qqqqqf-q marked this pull request as ready for review September 7, 2026 10:11
@qqqqqf-q
qqqqqf-q requested review from a team September 7, 2026 10:11
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-07T10:23:56.472902Z 753dce4 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +338 to +340
prefModelID, prefEffort, prefErr := h.modelPrefs.ReconcileSessionModelPreference(c.Request().Context(), bot.ID, modelRef, effortRef)
if prefErr != nil {
return echo.NewHTTPError(http.StatusBadRequest, prefErr.Error())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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