Skip to content

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

Draft
qqqqqf-q wants to merge 16 commits into
felinics:mainfrom
qqqqqf-q:feat/session-model-preference
Draft

feat: session 模型/推理强度对持久化(#879)#1139
qqqqqf-q wants to merge 16 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

解决什么问题

聊天框顶上选的模型和推理强度此前只活在 pane 组件内存里:刷新、关标签、换设备都会悄悄回到 bot 默认;welcome 页选完没发,选择还会串进老对话(issue #879)。

本 PR 把这个选择变成 session 的持久属性,且只对明确选过的会话生效:

  • 选过的会话:在哪打开都显示上次的值——刷新、重开、换手机一样。
  • 没选过的会话:继续跟随 bot 默认,管理员改默认即时生效(与今天完全一致)。
  • welcome 页:选择是这台机器上的草稿,只进即将新建的会话;另有一条"我最近实际在用的"服务端种子。
  • 模型+强度是一个对:换模型,强度落到新模型的默认档,不跨模型记强度。
  • 弱网:选了立刻显示、永不弹回;任何一条消息被服务端接收后这个对永久生效。
  • Telegram 用户零感知;被 web 选过的会话到 Telegram 继续时沿用该模型,在 Telegram 里 /model 则清掉这个对、回到 bot 默认链。
  • ACP 会话:强度/模型选择持久化,进程重建不再回退 profile 默认。

设计稿(含用户路径、语义、推导、取舍)在 docs/spark/2026-08-31-session-model-preference-design.md,附录 A 有 v1→v2 修订记录与全部代码证据。

实现要点

  • 存储:bot_sessionspreferred_chat_model_id / preferred_reasoning_effort(迁移 0146,成对写清,NULL=无记忆,写入不碰 updated_at)。ACP 对存 runtime_metadata(ACP 模型 id 是 agent 命名空间字符串,进不了 FK 列)。
  • 解析链:请求携带 > session 记忆 > subagent pin > bot 默认 > 历史;schedule 轮跳过记忆;effort 记忆在 stored 之上、requested 之下(不冒充请求值,不串子代理)。
  • 写点(全部经 reconcile,DB 不存非法对):picker PATCH / 首发 createSession 随 INSERT / 每轮 resolve() 内"请求携带且与记忆不同"才写 / 渠道 /model/reasoning 清空。
  • 前端:对搬入 ChatViewEntry(pairModelId/pairEffort/pairSource),同 session 多 tab 共享同步;仅 user/session 来源随消息携带,default/unset 省略——服务端据此区分"没选过"与"选过"。

人工 QA 进展(2026-09-02,独立测试栈 memoh-dev-b :19082)

已走路径(全部符合预期):

  1. 种子链:新 bot(默认 flash)→ 改 pro 发消息开 session → New Session 回 welcome 显示 pro(最近 session 种子)✓
  2. 草稿优先:welcome 手动改回 flash → 其他 tab 回 welcome 仍 flash(本机草稿 > 服务端种子)✓
  3. session 隔离:回到 pro 的 session 显示 pro,不被 welcome 草稿污染 ✓
  4. 草稿不跨设备:新开浏览器实例,pro session 仍显示 pro(服务端对生效,草稿只在本机)✓
  5. 跨标签页语义:tab A 改 flash,tab B 不实时变、刷新后一致(= spec 跨设备语义;spec 措辞已按此实测修正)✓
  6. effort 成对:effort 改 high,两个 tab 刷新均 high ✓

QA 中修掉的问题:composer 触发器 spinner 改为加载超 3 秒才显示(此前每次加载都闪)。另确认 welcome 出现的"flash Medium"=本机草稿(设计内行为,非 bug)。

未走路径:P6′(换模型强度落新默认档)、P5′(ACP max 不回退)、P11′(Telegram /model 清空,需真渠道)、P8′(dockview 面板级实时同步,浏览器双 tab 已覆盖其降级语义)、P7′(弱网)。

遗留决策(不阻塞本 PR,待拍):welcome 草稿永存到首发是否软化(TTL/首发即清);Cloud fork 的平台默认模型在种子链中的层级(合入后 sync 时对齐)。

测试

  • 新增机制测试:解析链顺序(记忆级)、reasoning 记忆级(含非法档落默认)、写回门(携带才写/值同不写/disable 与 NULL 归一)、渠道清空、subagent pin 让位。
  • go build / go vet / go test ./internal/... 全绿;前端 vue-tsc / eslint / vitest 绿(预存基线红与 flaky 测试均与本分支零 diff);迁移经 migrate up→down→up 全链路 + 团队守卫测试验证(0146 复合 FK + NOT VALID,与 0141/0142 同款)。
  • 依赖:无新增。

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.
(上方 QA 进展为部分路径的人工验证;P6′/P5′/P11′ 走完后此行才可移除。)

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

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

收尾边界与验证

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

本次 AI review 实际验证:

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

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