主要变更: - Skill Learning 闭环系统 (9/9 AC) - Opus 4.7 模型层接入 + adaptive thinking - Prompt 工程优化 (64 审计测试) - Agent Teams 简化门控 (默认启用) - Windows Terminal 后端修复 (EncodedCommand/WT_SESSION) - TF-IDF 技能搜索精准化 (字段加权/CJK 优化) - Autonomy 系统 (/autonomy 命令) - ACP 协议完整实现 - mock.module 泄漏修复 (CI 全绿) - 152+ lint/type 修复
6.9 KiB
Bug: ModelPicker 1M 选项 key 不匹配导致幽灵选项
问题描述
用户通过 /model 选择 "Opus 4.6 (1M context)" 后:
[1m]后缀被静默丢弃,实际存储的 model 是'claude-opus-4-6'(无 1M)- 命令输出显示
Set model to Opus 4.6而非Opus 4.6 (1M context) - 再次执行
/model时,选项列表从 4 个变成 5 个,多出一个 "Opus 4.6" 幽灵选项
影响范围
所有 value 中自带 [1m] 后缀的预定义选项都受影响:
getOpus46_1MOption()— value:getModelStrings().opus46 + '[1m]'→'claude-opus-4-6[1m]'getOpus47_1MOption()— value:'opus[1m]'(firstParty)getSonnet46_1MOption()— value:'sonnet[1m]'(firstParty)getMergedOpus1MOption()— value:'opus[1m]'(firstParty)- 所有 3P provider 的 1M 变体
根因分析
涉及文件
| 文件 | 行号 | 角色 |
|---|---|---|
src/components/ModelPicker.tsx |
87-89 | marked1MValues 初始化(存储 base value) |
src/components/ModelPicker.tsx |
91-102 | handleToggle1M — Space 键切换 1M 标记 |
src/components/ModelPicker.tsx |
205-243 | handleSelect — 提交选择时的 1M 判断逻辑 |
src/utils/model/modelOptions.ts |
565-601 | getModelOptions() — custom model 追加逻辑 |
Bug 链条详解
第 1 步:marked1MValues 的 key 格式
ModelPicker.tsx:87-89:
const [marked1MValues, setMarked1MValues] = useState<Set<string>>(
() => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : [])
)
初始化时,如果当前 model 带 [1m],存入的是 去掉 [1m] 的 base value。
例如:initialValue = 'claude-opus-4-6[1m]' → set 中存 'claude-opus-4-6'
handleToggle1M(第 91-102 行)也是对 focusedValue(即 option 的 value 字段)直接操作,添加/删除的是 option 的原始 value。
第 2 步:handleSelect 中的 key 查找不匹配
ModelPicker.tsx:239-241:
const wants1M = marked1MValues.has(value) // 用 option 的完整 value 查找
const baseValue = value.replace(/\[1m\]/i, '') // 去掉 [1m]
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue // 根据 wants1M 决定
问题:value 是 select option 的原始 value,对于 getOpus46_1MOption() 来说就是 'claude-opus-4-6[1m]'。但 marked1MValues 中存的 key 是 'claude-opus-4-6'(不带 [1m])。
marked1MValues.has('claude-opus-4-6[1m]') 永远返回 false。
因此 wants1M = false,finalValue = 'claude-opus-4-6',1M 后缀被丢弃。
第 3 步:幽灵选项产生
下次打开 /model 时,initial = 'claude-opus-4-6'。
modelOptions.ts 的 getModelOptions() 第 565-601 行检查 customModel:
customModel = 'claude-opus-4-6'- 基础选项中没有 value 为
'claude-opus-4-6'的(只有'claude-opus-4-6[1m]') - 第 590 行
getKnownModelOption('claude-opus-4-6')返回一个新选项{ value: 'claude-opus-4-6', label: 'Opus 4.6', ... } - 追加到列表 → 5 个选项
最终列表:
- Default (recommended) — value:
null - Opus 4.7 (merged 1M) — value:
'opus[1m]' - Opus 4.6 (1M context) — value:
'claude-opus-4-6[1m]'(原始预定义选项) - Haiku — value:
'haiku' - Opus 4.6 — value:
'claude-opus-4-6'(幽灵选项,由 custom model 逻辑追加)
修复方案
方案 A:修复 handleSelect 中的 1M 判断逻辑(推荐)
在 ModelPicker.tsx 的 handleSelect 中,检查 1M 状态时应该用 base value 作为 key(与 marked1MValues 的存储格式一致),并且要考虑 option value 本身就带 [1m] 的情况。
修改位置:src/components/ModelPicker.tsx 第 239-241 行
当前代码:
const wants1M = marked1MValues.has(value)
const baseValue = value.replace(/\[1m\]/i, '')
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
修复思路:
const baseValue = value.replace(/\[1m\]/i, '')
const optionHas1M = has1mContext(value) // option 自带 [1m]?
const userToggled1M = marked1MValues.has(baseValue) // 用 base value 查找
// 如果 option 自带 1M 且用户没有主动关闭,或者用户主动开启了 1M
const wants1M = optionHas1M ? !userToggled1M : userToggled1M // 注意:toggle 语义需反转
// 实际上更简洁的方式:直接用 base value 查 set
const wants1M = marked1MValues.has(baseValue)
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
但这需要同时修改 handleToggle1M 和 marked1MValues 的初始化逻辑,确保三者的 key 格式统一。
方案 B:统一 marked1MValues 的 key 格式
让 marked1MValues 始终存储 base value(当前已经是这样),同时修改 handleSelect 用 base value 查找,修改 handleToggle1M 也用 base value 操作。
需要修改的位置:
-
handleToggle1M(第 91-102 行) — 当前直接用focusedValue作为 key。如果focusedValue带[1m](如'claude-opus-4-6[1m]'),存入的 key 会与初始化时的格式不一致。需要统一为 base value:const handleToggle1M = useCallback(() => { if (!focusedValue || focusedValue === NO_PREFERENCE) return const base = focusedValue.replace(/\[1m\]/i, '') // 统一用 base value setMarked1MValues(prev => { const next = new Set(prev) if (next.has(base)) { next.delete(base) } else { next.add(base) } return next }) }, [focusedValue]) -
is1MMarked判断(第 157 行) — 也需要用 base value 查找:const is1MMarked = focusedValue !== undefined && focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue.replace(/\[1m\]/i, '')) -
handleSelect(第 239 行) — 用 base value 查找:const baseValue = value.replace(/\[1m\]/i, '') const wants1M = marked1MValues.has(baseValue) const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
方案 C:让预定义 1M 选项的 value 不带 [1m]
将 getOpus46_1MOption() 等函数的 value 改为不带 [1m] 的 base value,让 1M 完全由 marked1MValues toggle 控制。这是最彻底的方案但改动最大,需要同时修改 modelOptions.ts 中所有 *_1MOption 函数。
推荐方案
方案 B:统一 marked1MValues 的 key 格式为 base value,修改 3 个位置。改动最小、最精准,不影响选项列表的结构。
验证步骤
- 选择 "Opus 4.6 (1M context)" → 确认输出为
Set model to Opus 4.6 (1M context) - 再次
/model→ 确认仍然是 4 个选项,无幽灵项 - 选择 "Opus 4.7 (1M context)" → 同样验证无幽灵项
- 手动 Space 切换 1M on/off → 确认 toggle 正常工作
- 对已带
[1m]的选项按 Space 关闭 1M → 确认存储的值不带[1m]