sintbot 1.4.14 → 1.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+ // 型号的显示名与输入比对。**只管给人看与人怎么打**:配置里存的、传给 CLI 的、账本 / 审计 / 日志 / 自知清单里写的
3
+ // 一律仍是正式 id(`claude-opus-5-5`)—— 正式 id 不能省 `claude-`、版本号是连字符不是点,写成 `opus-5.5` 交给 CLI 不保证认。
4
+ //
5
+ // 显示规则统一一种短写法:
6
+ // claude-<家族>-<主版本>[-<次版本>][-<日期>][[变体]] → 「Opus 5.5」;日期后缀不显示;变体 [1m] → 「Fable 5.1 (1M)」
7
+ // Claude Code 官方别名 opus / sonnet / haiku(指向当前最新)→「Opus(最新)」
8
+ // deepseek / GLM / MiniMax 同一规则;GPT 等其余原样(本来就短)
9
+ // ⚠ 控制台同一份:server 把 modelDisplayName.toString() 注入 console.html(占位 /*__SBOT_MODELNAME__*/)⇒ 这个函数必须**自包含**
10
+ // (不引用模块里别的变量 / 函数),改它时单测会在注入后的页面里再跑一遍同一组例子。
11
+
12
+ function modelDisplayName(id) {
13
+ const raw = String(id == null ? '' : id).trim()
14
+ if (!raw) return ''
15
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
16
+ const title = (s) => s.split('-').map((x) => x.charAt(0).toUpperCase() + x.slice(1)).join(' ')
17
+ const vt = (v) => (v ? ' (' + v.toUpperCase() + ')' : '')
18
+ const al = raw.match(/^(opus|sonnet|haiku)$/i)
19
+ if (al) return cap(al[1]) + '(最新)'
20
+ // 次版本最多 3 位且后面不再跟数字:带日期的老 id(claude-sonnet-4-20250514)不会被读成 4.20250514
21
+ const cm = raw.match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,3})(?!\d))?(?:-\d{4,})?(?:\[(\w+)\])?$/i)
22
+ if (cm) return cap(cm[1]) + ' ' + cm[2] + (cm[3] ? '.' + cm[3] : '') + vt(cm[4])
23
+ const dm = raw.match(/^deepseek-(v\d+(?:-\w+)?|chat|reasoner)(?:\[(\w+)\])?$/i)
24
+ if (dm) return 'DeepSeek ' + title(dm[1]) + vt(dm[2])
25
+ const gm = raw.match(/^(glm|chatglm)[-_]([\w.]+(?:-\w+)?)(?:\[(\w+)\])?$/i)
26
+ if (gm) return (gm[1].toLowerCase() === 'chatglm' ? 'ChatGLM' : 'GLM') + ' ' + title(gm[2]) + vt(gm[3])
27
+ const mm = raw.match(/^(MiniMax|abab)(?:-(\w[\w.-]*))?(?:\[(\w+)\])?$/)
28
+ if (mm) return (mm[1] === 'abab' ? 'ABAB' : 'MiniMax') + (mm[2] ? ' ' + title(mm[2]) : '') + vt(mm[3])
29
+ return raw
30
+ }
31
+
32
+ // 输入比对键:大小写、开头的 claude-、点 / 连字符 / 空格都不算;结尾的变体 [1m] / (1M) 单独保留(有没有 1M 是两个型号)。
33
+ // claude-opus-5-5 · opus-5.5 · opus5.5 · Opus 5.5 → opus55;claude-fable-5-1[1m] · Fable 5.1 (1M) → fable51[1m]
34
+ // 显示名「Opus(最新)」里的中文括注不是变体 ⇒ 去掉后就是别名 opus。
35
+ function modelKey(s) {
36
+ let t = String(s == null ? '' : s).trim().toLowerCase()
37
+ let v = ''
38
+ const vm = t.match(/\s*[[(]\s*([a-z0-9]+)\s*[\])]\s*$/)
39
+ if (vm) { v = vm[1]; t = t.slice(0, vm.index) }
40
+ t = t.replace(/^claude[-_\s]+/, '').replace(/[^a-z0-9]/g, '')
41
+ return t ? t + (v ? '[' + v + ']' : '') : ''
42
+ }
43
+
44
+ // 官方显示名登记:两家 CLI 的实时清单拿到就登记(src/modelcatalog.js)—— 有登记的按官方名称显示
45
+ // (Claude「Opus 5.5」、Codex「GPT-6.1-Sol」),没登记的(第三方、清单外的老型号)再按上面的规则拼。
46
+ // 进程内一份:守护进程里引擎与控制台同进程,共用;控制台页面拿 /api/state 的 modelNames。
47
+ const NAMES = new Map()
48
+ function setModelNames(list) {
49
+ for (const x of list || []) {
50
+ const id = x && String(x.id || '').trim()
51
+ const name = x && String(x.name || '').trim()
52
+ if (id && name && name.length <= 60) NAMES.set(id, name)
53
+ }
54
+ }
55
+ function modelName(id) {
56
+ const k = String(id == null ? '' : id).trim()
57
+ return (k && NAMES.get(k)) || modelDisplayName(k)
58
+ }
59
+ const knownModelNames = () => Object.fromEntries(NAMES)
60
+
61
+ module.exports = { modelDisplayName, modelKey, modelName, setModelNames, knownModelNames }
@@ -18,7 +18,12 @@ const { prepareC2RunnerInput } = require('./c2runnerinput')
18
18
  const { continueC2Successor } = require('./c2successorbridge')
19
19
  const { rejectC2Input } = require('./c2inputrejection')
20
20
  const progress = require('./turnprogress')
21
- const { modelContext } = require('../modelcontext')
21
+ const { groupBrain } = require('./brain')
22
+ const { modelName } = require('./modelname')
23
+ const { parseModelWords, resolveModel, effortsFor, modelChoices, fillPicks, recommendEffort } = require('./modelcmd')
24
+ const { DEFAULT_VALUE: MODEL_CARD_DEFAULT } = require('../platform/modelcard')
25
+ // A /model card answers taps for ten minutes; older cards turn read-only.
26
+ const MODEL_CARD_TTL_MS = 10 * 60 * 1000
22
27
  const { GROUP_COMMANDS, RUNTIME_ENTRY, runtimeCommand } = require('../runtimefacts')
23
28
  const NO_REPLY = 'NO_REPLY'
24
29
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
@@ -51,13 +56,16 @@ function cardDuration(ms) {
51
56
  const hours = Math.floor(minutes / 60)
52
57
  return minutes % 60 ? `${hours} 小时 ${minutes % 60} 分` : `${hours} 小时`
53
58
  }
54
- function cardModelInfo(group, provider) {
55
- if (!provider) return { model: '', effort: '' }
56
- const { model } = modelContext(group, provider)
57
- const effort = provider.runner === 'codex'
58
- ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
59
+ function cardModelInfo(brain) {
60
+ if (!brain) return { model: '', effort: '' }
59
61
  const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
60
- return { model: clean(model), effort: clean(effort) }
62
+ return { model: clean(brain.model ? modelName(brain.model) : ''), effort: clean(brain.effort) }
63
+ }
64
+ // An example depth for /model replies: high when available, else another one.
65
+ const exEffortOf = (efforts, cur) => ((efforts || []).includes('high') && cur !== 'high' ? 'high' : ((efforts || []).find(e => e !== cur) || ''))
66
+ // "codex · GPT-6-Astra · high": what this group runs on its current backend.
67
+ function brainLabel(name, brain) {
68
+ return [name, brain?.model ? modelName(brain.model) : '', brain?.effort || ''].filter(Boolean).join(' · ')
61
69
  }
62
70
  function cardModelDetail(info, result) {
63
71
  // A rejected configured effort may have been retried at the model default.
@@ -90,7 +98,7 @@ const T = {
90
98
  '· /skip — 只放弃手头这件,排队的继续;之前聊的还记得',
91
99
  '· /stop — 手头和排队的全取消;之前聊的还记得',
92
100
  '· /new — 忘掉之前聊的,从头开始(要等手头任务结束)',
93
- '· /model — 本群在用的模型;换:/model 名字(要等手头任务结束,换完之前聊的不再带着)',
101
+ '· /model — 看或改本群模型:/model high 调深度、/model 型号 换型号(都接着当前对话)、/model 后端 换后端(开新对话);要等手头任务结束',
94
102
  '· /hud — 额度与本段对话的用量',
95
103
  '· /issue 一句话 — 记下问题反馈,给管理员看',
96
104
  '· /help — 这份说明',
@@ -116,11 +124,40 @@ const T = {
116
124
  issueUsage: '用法:/issue 后面跟一句话,说清哪里不对劲\n例:/issue 刚才发的图它没理我',
117
125
  issueLogged: id => `📝 已记下 #${id}`,
118
126
  issueFailed: '⚠ 没记下来 —— 麻烦再说一次,或直接在群里说',
119
- modelInfo: (current, list, example) => `【当前模型】${current}\n【可用】${list}\n换模型:发「/model 名字」,例如 /model ${example}\n⚠ 换完是新的一段对话,之前聊的内容不再带着`,
120
- modelUnknown: (want, names) => `⚠ 没有「${want}」这个模型\n【可用】${names}\n发「/model 名字」换,名字要一模一样`,
127
+ // /model replies list backends, models and depths on separate lines, with
128
+ // examples taken from this group's real choices. Any unrecognised word
129
+ // rejects the whole command and changes nothing (no fuzzy matching).
130
+ // o = { current, names, name, choices|null|undefined, efforts, exEffort, exModel, exProvider }
131
+ // choices === null: this backend's model is set in the console (third-party endpoint).
132
+ modelInfo: (o) => {
133
+ const L = [`【本群模型】${o.current}`]
134
+ L.push(`【后端】${o.names.join(' · ')}`)
135
+ if (o.choices === null) L.push(`【${o.name} 型号】到控制台改`)
136
+ else if (o.choices && o.choices.length) L.push(`【${o.name} 型号】${o.choices.map((x) => modelName(x)).join(' · ')}`)
137
+ if (o.efforts && o.efforts.length) L.push(`【思考深度】${o.efforts.join(' · ')}`)
138
+ if (o.exEffort) L.push(`· 调深度:/model ${o.exEffort}(接着当前对话)`)
139
+ if (o.exModel) L.push(`· 换型号:/model ${modelName(o.exModel)}(接着当前对话)`)
140
+ if (o.exProvider) L.push(`· 换后端:/model ${o.exProvider}(开新对话)`)
141
+ L.push('⚠ 要等手头任务结束才能改')
142
+ return L.join('\n')
143
+ },
144
+ modelUnknownWord: (word, o) => (o.choices === null
145
+ ? `⚠ 「${word}」不是可选的后端或深度(${o.name} 的型号要到控制台改)—— 一项都没改`
146
+ : `⚠ 「${word}」不是可选的后端、型号或深度 —— 一项都没改`)
147
+ + `\n【后端】${o.names.join(' · ')}` + (o.choices && o.choices.length ? `\n【${o.name} 型号】${o.choices.map((x) => modelName(x)).join(' · ')}` : '')
148
+ + (o.efforts && o.efforts.length ? `\n【思考深度】${o.efforts.join(' · ')}` : ''),
149
+ modelDup: (what, a, b) => `⚠ 一次只能选一个${what}(写了 ${a} 和 ${b})—— 一项都没改`,
150
+ modelNoCatalog: name => `⚠ 暂时取不到 ${name} 的可选型号 —— 一项都没改;稍后再试,或请管理员到控制台改`,
151
+ modelEffortUnsupported: (model, e, list) => list.length
152
+ ? `⚠ ${modelName(model) || model} 没有「${e}」这档深度 —— 一项都没改\n【可选】${list.join(' · ')}`
153
+ : `⚠ ${modelName(model) || model} 不支持调思考深度 —— 一项都没改`,
121
154
  modelSame: current => `本群已经在用 ${current},没有变化`,
122
- modelBusy: '⚠ 手头还有任务在跑,现在换会打断它 —— 等它跑完再换,或先发 /stop',
123
- modelSwitched: current => `✅ 已切到 ${current}\n⚠ 新的一段对话从这里开始,之前聊的内容不再带着`,
155
+ modelBusy: '⚠ 手头还有任务在跑 —— 等它跑完再改,或先发 /stop',
156
+ modelSwitched: (current, note, filled) => `✅ 已切到 ${current}\n⚠ 换了后端,新的一段对话从这里开始,之前聊的内容不再带着` + (note ? ` —— ${note}` : '')
157
+ + (filled ? `\n· 本群在这个后端还没选过${filled},先用推荐的;要换就 /model 加名字` : ''),
158
+ modelChanged: (current, filled) => `✅ 已改为 ${current} —— 接着当前对话,下条消息生效\n· 对话很长时新型号可能装不下,可发 /new 重开`
159
+ + (filled ? `\n· ${filled}先用推荐的` : ''),
160
+ effortSwitched: (effort, label) => `✅ 思考深度已调为 ${effort}(${label})—— 接着当前对话,下条消息生效`,
124
161
  modelSwitchFailed: '⚠ 没换成 —— 请管理员到控制台看一下',
125
162
  stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
126
163
  stopUnavailable: '当前任务正在收尾,暂时无法停止。',
@@ -257,7 +294,9 @@ class Engine {
257
294
  durableV2AssertWriterLock, durableV2Journal,
258
295
  durableV2NormalRoute = true, durableV2StreamFrames = false,
259
296
  durableV2AcquireCapacity = null, durableV2SealCapacity = null,
260
- durableV2SwitchProvider = null, durableV2UsageProbe = null, durableV2Issues = null,
297
+ durableV2SwitchProvider = null, durableV2SetBrain = null, durableV2ModelCatalog = null,
298
+ durableV2ModelCard = null,
299
+ durableV2UsageProbe = null, durableV2Issues = null,
261
300
  durableV2Version = '', durableV2DataDir = '', durableV2ConsoleEnabled = false } = options
262
301
  const sync = f => typeof f === 'function' && f.constructor?.name !== 'AsyncFunction'
263
302
  if (!cfg || !cfg.agent || !cfg.groups || !cfg.providers ||
@@ -282,6 +321,9 @@ class Engine {
282
321
  durableV2AcquireCapacity !== null && typeof durableV2AcquireCapacity !== 'function' ||
283
322
  durableV2SealCapacity !== null && !sync(durableV2SealCapacity) ||
284
323
  durableV2SwitchProvider !== null && !sync(durableV2SwitchProvider) ||
324
+ durableV2SetBrain !== null && !sync(durableV2SetBrain) ||
325
+ durableV2ModelCatalog !== null && typeof durableV2ModelCatalog !== 'function' ||
326
+ durableV2ModelCard !== null && !sync(durableV2ModelCard) ||
285
327
  durableV2UsageProbe !== null && typeof durableV2UsageProbe !== 'function' ||
286
328
  durableV2Issues !== null && typeof durableV2Issues?.add !== 'function' ||
287
329
  typeof durableV2Version !== 'string') {
@@ -291,7 +333,8 @@ class Engine {
291
333
  durableV2StartTurn, durableV2ApplyLocalEffect, durableV2VerifyRunnerClosed,
292
334
  durableV2AssertWriterLock, durableV2Journal, durableV2NormalRoute,
293
335
  durableV2StreamFrames, durableV2AcquireCapacity, durableV2SealCapacity,
294
- durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version,
336
+ durableV2SwitchProvider, durableV2SetBrain, durableV2ModelCatalog, durableV2ModelCard,
337
+ durableV2UsageProbe, durableV2Issues, durableV2Version,
295
338
  durableV2DataDir, durableV2ConsoleEnabled })
296
339
  this.projectMemory = options.projectMemory || null
297
340
  this.repoInfo = options.repoInfo || null
@@ -450,6 +493,11 @@ _resolveProvider(chatId, group) {
450
493
  return { name, provider: this.cfg.providers[name] }
451
494
  }
452
495
 
496
+ // The group's model and depth on the backend actually running (core/brain.js).
497
+ _brain(group, name, provider) {
498
+ return groupBrain(group, name, provider, { defaultProvider: this.cfg.defaultProvider })
499
+ }
500
+
453
501
  // Chat commands are answered before any receipt, like /stop with nothing to
454
502
  // stop: nothing enters the journal and each returns its single reply as an
455
503
  // input rejection. Commands that reset a session need the group idle and its
@@ -467,8 +515,7 @@ async _durableV2Command(msg, cmd, rest) {
467
515
  const group = this._resolveGroup(msg.chatId)
468
516
  if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
469
517
  const { name, provider } = this._resolveProvider(msg.chatId, group)
470
- // A group may pin its own model; the runner uses it before the provider default.
471
- const label = (n, p) => { const { model } = modelContext(group, p); return model ? `${n} · ${model}` : n }
518
+ const label = (n, p) => brainLabel(n, this._brain(group, n, p))
472
519
  if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
473
520
  if (cmd === '/help') {
474
521
  const isCodex = provider?.runner === 'codex'
@@ -531,38 +578,247 @@ async _durableV2Command(msg, cmd, rest) {
531
578
  return reject('unsupported-command', T.unsupportedCommandWithModel)
532
579
  }
533
580
 
534
- // /model lists models, or switches through the console's configuration
535
- // writer. Switching needs this group idle and settled, and always starts a
536
- // fresh session.
537
- _durableV2ModelCommand(msg, want, group) {
581
+ // /model: backend · model · thinking depth. Words are parsed exactly
582
+ // (core/modelcmd.js); the decision is _durableV2ApplyModel (shared with the
583
+ // model card) and the write goes through the console's configuration writer.
584
+ // Every change needs this group idle and settled; only a backend change
585
+ // starts a new conversation.
586
+ async _durableV2ModelCommand(msg, want, group) {
538
587
  const reject = (reason, notice) =>
539
588
  rejectC2Input('C2 v2 model command answered before receipt', { reason, notice })
540
- const label = (name, provider, scope = {}) => {
541
- const { model } = modelContext(scope, provider)
542
- return model ? `${name} · ${model}` : name
543
- }
544
- const available = Object.entries(this.cfg.providers || {})
545
- .filter(([, provider]) => provider && !provider.hidden &&
546
- ['claude', 'codex'].includes(provider.runner || 'claude'))
547
- const { name: currentName, provider: currentProvider } = this._resolveProvider(msg.chatId, group)
548
- const current = label(currentName, currentProvider, group)
549
- const list = available.map(([name, provider]) => label(name, provider)).join(' / ')
550
- if (!want) {
551
- const example = available.find(([name]) => name !== currentName)?.[0] || currentName
552
- return reject('model-info', T.modelInfo(current, list, example))
553
- }
554
- const hit = available.find(([name]) => name.toLowerCase() === want.toLowerCase())
555
- if (!hit) return reject('model-unknown', T.modelUnknown(want, available.map(([name]) => name).join(' / ')))
556
- if (hit[0] === currentName) return reject('model-same', T.modelSame(current))
557
- if (this._durableV2GroupBusy(msg.chatId)) return reject('model-busy', T.modelBusy)
589
+ const { name, provider } = this._resolveProvider(msg.chatId, group)
590
+ const names = this._modelNames()
591
+ const words = String(want || '').split(/\s+/).map(w => w.trim()).filter(Boolean)
592
+ if (!words.length) {
593
+ const cat = await this._modelCatalog(name, provider)
594
+ const cur = this._brain(group, name, provider)
595
+ const o = this._modelOpts(names, name, cat, cur)
596
+ const concrete = cat && Array.isArray(cat.models) ? cat.models.map(m => m.id) : []
597
+ const exModel = concrete.find(c => (o.choices || []).includes(c) && c !== cur.model) || ''
598
+ const text = T.modelInfo({ ...o, current: brainLabel(name, cur),
599
+ exEffort: exEffortOf(o.efforts, cur.effort), exModel,
600
+ exProvider: names.find(n => n !== name) || '' })
601
+ // Feishu gets a tap-to-pick card; the text above is its fallback.
602
+ let card = null
603
+ if (this.durableV2ModelCard) {
604
+ try { card = this.durableV2ModelCard(await this._modelPanel(msg.chatId, group, { issuedAt: Date.now() })) } catch (error) {
605
+ this.log(`/model card not built: ${error?.message || String(error)}`)
606
+ }
607
+ }
608
+ return rejectC2Input('C2 v2 model command answered before receipt', { reason: 'model-info', notice: text, card })
609
+ }
610
+ const sender = msg.senderName || msg.userId || 'unknown'
611
+ const pw = parseModelWords(words, names)
612
+ if (pw.error) {
613
+ this._logModelReject(msg.chatId, words.join(' '), 'dup', sender)
614
+ return reject('model-dup', T.modelDup(pw.error.what, pw.error.a, pw.error.b))
615
+ }
616
+ const out = await this._durableV2ApplyModel(msg.chatId, group, pw,
617
+ { sender, said: words.join(' '), source: 'command' })
618
+ const reason = out.kind === 'reject' ? `model-${out.reason}` : out.kind === 'model' ? 'model-changed' : `model-${out.kind}`
619
+ return reject(reason, out.text)
620
+ }
621
+
622
+ // The /model card's panel state (platform-neutral; Feishu draws it with
623
+ // src/platform/modelcard.js). "Current" is the backend actually running and
624
+ // this group's choice on it. The draft (p backend, m model, e depth) starts
625
+ // as the group's choice on that backend; unchosen items are filled with the
626
+ // catalog's recommendation, so the card always shows a concrete pick. A draft
627
+ // that differs from the current one gets a preview, which enables confirm.
628
+ async _modelPanel(chatId, group, extra = {}) {
629
+ const { name, provider } = this._resolveProvider(chatId, group)
630
+ const b = this._brain(group, name, provider)
631
+ const current = brainLabel(name, b)
632
+ const mode = extra.mode || 'edit'
633
+ if (mode !== 'edit') return { mode, current: extra.current || current, receipt: extra.receipt, issuedAt: extra.issuedAt || 0 }
634
+ const names = this._modelNames()
635
+ const d0 = extra.draft || {}
636
+ const known = extra.draft && names.includes(d0.p)
637
+ const dp = known ? d0.p : name
638
+ const dProv = this.cfg.providers[dp]
639
+ const base = this._brain(group, dp, dProv)
640
+ const third = !!(dProv && dProv.baseUrl)
641
+ const draft = known ? { p: dp, m: String(d0.m || ''), e: String(d0.e || '') } : { p: dp, m: '', e: '' }
642
+ const cat = await this._modelCatalog(dp, dProv)
643
+ const rec = cat && cat.ok !== false && cat.recommended ? cat.recommended : null
644
+ let model = null // null: a third-party endpoint, whose model is set in the console
645
+ if (!draft.e) draft.e = base.effort || ''
646
+ if (!cat || cat.models !== null) {
647
+ const choices = cat && cat.ok !== false ? (cat.models || []).map(m => m.id) : []
648
+ if (!draft.m && !third) draft.m = base.model || ''
649
+ if (!draft.m && rec && rec.model) draft.m = rec.model
650
+ const options = choices.map(v => ({ value: v, label: modelName(v) }))
651
+ if (draft.m && !choices.includes(draft.m)) options.push({ value: draft.m, label: modelName(draft.m) })
652
+ model = { options, selected: draft.m }
653
+ } else draft.m = ''
654
+ const dModel = draft.m || (third ? base.model : '')
655
+ const listed = cat && Array.isArray(cat.models) ? cat.models.find(m => m.id === dModel) : null
656
+ const noDepth = !!(listed && Array.isArray(listed.efforts) && !listed.efforts.length)
657
+ const effList = cat && !noDepth ? effortsFor(cat, dModel) : []
658
+ if (noDepth) draft.e = ''
659
+ else if (!draft.e || (effList.length && !effList.includes(draft.e))) draft.e = cat ? recommendEffort(cat, dModel) : draft.e
660
+ const dEffort = draft.e
661
+ const efforts = effList.map(e => ({ value: e, label: e, selected: e === dEffort }))
662
+ let preview = null
663
+ if (dp !== name || dModel !== b.model || (dEffort || '') !== (b.effort || '')) {
664
+ preview = { to: brainLabel(dp, { model: dModel, effort: dEffort }),
665
+ note: dp !== name ? '换了后端 —— 会开新对话' : dModel !== b.model ? '换了型号 —— 接着当前对话' : '只调深度 —— 接着当前对话' }
666
+ }
667
+ return { mode, current, draft, preview, failover: '', quota: '',
668
+ backends: names.map(n => ({ name: n, selected: n === dp })),
669
+ model, efforts, issuedAt: extra.issuedAt || 0 }
670
+ }
671
+
672
+ // A tap on the /model card. value = the callback value { f, v?, d, t },
673
+ // option = the dropdown choice, who = the operator's name.
674
+ // f = p | m | e: change only the card's draft (nothing is written)
675
+ // f = ok: apply the draft once through _durableV2ApplyModel → receipt card
676
+ // f = x: close; nothing changes, the card turns grey in place
677
+ // Returns { toast?, state? }; state is drawn into a card that replaces this one.
678
+ async handleModelCard({ chatId, who = '', value = {}, option } = {}) {
679
+ const group = this.cfg.groups && this.cfg.groups[chatId]
680
+ if (!group) return { toast: { type: 'info', content: '这个群还没绑定项目' } }
681
+ const issued = Number(value.t) || 0
682
+ const now = Date.now()
683
+ if (value.f === 'x') return { toast: { type: 'info', content: '已关闭 · 模型没变' }, state: await this._modelPanel(chatId, group, { mode: 'closed' }) }
684
+ if (group.paused) return { toast: { type: 'warning', content: '本群已暂停 —— 恢复后再改' } }
685
+ if (!issued || now - issued > MODEL_CARD_TTL_MS) {
686
+ return { toast: { type: 'info', content: '这张卡已过期 —— 发 /model 重新打开' }, state: await this._modelPanel(chatId, group, { mode: 'expired' }) }
687
+ }
688
+ const d = value.d && typeof value.d === 'object' ? { p: String(value.d.p || ''), m: String(value.d.m || ''), e: String(value.d.e || '') } : null
689
+ const norm = x => (x == null || x === MODEL_CARD_DEFAULT ? '' : String(x))
690
+ if (value.f === 'p' || value.f === 'm' || value.f === 'e') {
691
+ let draft = d
692
+ if (value.f === 'p') draft = { p: norm(value.v), m: '', e: '' }
693
+ else if (value.f === 'm') draft = { ...(d || {}), m: norm(option) }
694
+ else draft = { ...(d || {}), e: norm(value.v) }
695
+ return { state: await this._modelPanel(chatId, group, { draft, issuedAt: now }) }
696
+ }
697
+ if (value.f !== 'ok' || !d) return {}
698
+ const by = String(who || '').trim() || '有人'
699
+ const before = await this._modelPanel(chatId, group)
700
+ const { name: curName } = this._resolveProvider(chatId, group)
701
+ const target = d.p || curName
702
+ const tb = this._brain(group, target, this.cfg.providers[target])
703
+ const out = await this._durableV2ApplyModel(chatId, group, {
704
+ provider: d.p && d.p !== curName ? d.p : undefined,
705
+ model: d.m && d.m !== tb.model ? d.m : undefined,
706
+ effort: d.e && d.e !== tb.effort ? d.e : undefined },
707
+ { sender: by, said: `[模型卡] ${d.p}·${d.m || '-'}·${d.e || '-'}`, source: 'card' })
708
+ if (out.kind === 'reject' && out.reason === 'busy') return { toast: { type: 'warning', content: '手头还有任务在跑 —— 等它做完再确认,或先发 /stop' } }
709
+ if (out.kind === 'reject' || out.kind === 'failed') return { toast: { type: 'error', content: String(out.text || '').split('\n')[0].replace(/^⚠\s*/, '') } }
710
+ const g2 = (this.cfg.groups && this.cfg.groups[chatId]) || group
711
+ if (out.kind === 'same') return { toast: { type: 'info', content: '已经是这个了,没有变化' }, state: await this._modelPanel(chatId, g2, { issuedAt: now }) }
712
+ const hm = fmtStamp(now, this.tz).slice(-5)
713
+ const after = await this._modelPanel(chatId, g2)
714
+ const how = out.kind === 'switched' ? '已开新对话' : '接着当前对话'
715
+ return {
716
+ toast: { type: 'success', content: `已改为 ${after.current} · ${how}` },
717
+ state: { mode: 'receipt', current: after.current,
718
+ receipt: { line: `${by} 改为 ${after.current} · ${how} · ${hm}`, before: `之前:${before.current}` } },
719
+ }
720
+ }
721
+
722
+ _modelNames() {
723
+ return Object.entries(this.cfg.providers || {})
724
+ .filter(([, p]) => p && !p.hidden && ['claude', 'codex'].includes(p.runner || 'claude'))
725
+ .map(([n]) => n)
726
+ }
727
+ async _modelCatalog(name, provider) {
728
+ if (!this.durableV2ModelCatalog) return null
729
+ try { return await this.durableV2ModelCatalog(name, provider) } catch { return null }
730
+ }
731
+ _modelOpts(names, name, cat, brain) {
732
+ return { names, name,
733
+ choices: !cat ? undefined : cat.models === null ? null : cat.ok === false ? [] : modelChoices(cat),
734
+ efforts: cat ? effortsFor(cat, brain && brain.model) : [] }
735
+ }
736
+ _logModelReject(chatId, said, reason, sender) {
737
+ this.log(`/model rejected: ${chatId} ${JSON.stringify(String(said || '')).slice(0, 80)} ${reason} (by ${sender})`)
738
+ }
739
+
740
+ // pw = { provider?, model?, models?, effort? } from parseModelWords, or the
741
+ // model card's confirmed draft. Returns { kind, reason?, text, label?, after? }
742
+ // with kind ∈ reject | failed | same | switched | model | effort.
743
+ // Missing picks on the target backend are filled with the catalog's
744
+ // recommendation (fillPicks); a depth-only change never fills a model.
745
+ async _durableV2ApplyModel(chatId, group, pw, { sender = 'unknown', said = '', source = 'command' } = {}) {
746
+ const reject = (reason, text) => { this._logModelReject(chatId, said, reason, sender); return { kind: 'reject', reason, text } }
747
+ const { name, provider } = this._resolveProvider(chatId, group)
748
+ const names = this._modelNames()
749
+ const target = pw.provider || name
750
+ // The card's draft comes back from the platform: accept only a listed backend.
751
+ if (pw.provider && !names.includes(pw.provider)) return reject('unknown', T.modelUnknownWord(target, this._modelOpts(names, name, null, null)))
752
+ const tProv = this.cfg.providers[target]
753
+ const tBrain = this._brain(group, target, tProv)
754
+ if (pw.model === '') pw = { ...pw, model: undefined }
755
+ if (pw.effort === '') pw = { ...pw, effort: undefined }
756
+ if (pw.models) {
757
+ const mcat = await this._modelCatalog(target, tProv)
758
+ const bad = pw.models.find(w => !resolveModel(w, mcat))
759
+ if (bad) return reject('unknown', T.modelUnknownWord(bad, this._modelOpts(names, target, mcat, tBrain)))
760
+ const ids = [...new Set(pw.models.map(w => resolveModel(w, mcat)))]
761
+ if (ids.length > 1) return reject('dup', T.modelDup('型号', pw.models[0], pw.models[1]))
762
+ pw = { ...pw, model: ids[0] }
763
+ delete pw.models
764
+ }
765
+ const switching = target !== name
766
+ const wantsBrain = pw.model !== undefined || pw.effort !== undefined
767
+ const cat = wantsBrain || switching ? await this._modelCatalog(target, tProv) : null
768
+ let model
769
+ if (pw.model !== undefined) {
770
+ if (cat && cat.models === null) return reject('unknown', T.modelUnknownWord(pw.model, this._modelOpts(names, target, cat, tBrain)))
771
+ if (!cat || cat.ok === false) return reject('no-catalog', T.modelNoCatalog(target))
772
+ model = resolveModel(pw.model, cat)
773
+ if (!model) return reject('unknown', T.modelUnknownWord(pw.model, this._modelOpts(names, target, cat, tBrain)))
774
+ if (model === tBrain.model) model = undefined
775
+ }
776
+ const f = fillPicks({ switching, model, effort: pw.effort, currentModel: tBrain.model,
777
+ currentEffort: tBrain.effort, third: !!(tProv && tProv.baseUrl), cat })
778
+ model = f.model
779
+ const nextModel = model === undefined ? tBrain.model : model
780
+ let effort
781
+ if (pw.effort !== undefined) {
782
+ const effs = cat ? effortsFor(cat, nextModel) : []
783
+ const known = cat && cat.models && cat.models.find(m => m.id === nextModel)
784
+ const unsupported = known && Array.isArray(known.efforts) && !known.efforts.length
785
+ if (unsupported || (effs.length && !effs.includes(pw.effort))) {
786
+ return reject('effort', T.modelEffortUnsupported(nextModel || target, pw.effort, unsupported ? [] : effs))
787
+ }
788
+ effort = pw.effort === tBrain.effort && model === undefined && !switching ? undefined : pw.effort
789
+ } else effort = f.effort
790
+ const current = brainLabel(name, this._brain(group, name, provider))
791
+ if (!switching && model === undefined && effort === undefined) return { kind: 'same', text: T.modelSame(current), label: current }
792
+ if (this._durableV2GroupBusy(chatId)) return reject('busy', T.modelBusy)
793
+ let r
558
794
  try {
559
- this.durableV2SwitchProvider(msg.chatId, hit[0])
795
+ if (this.durableV2SetBrain) {
796
+ r = this.durableV2SetBrain(chatId, { provider: switching ? target : undefined, model, effort },
797
+ { by: sender, source })
798
+ } else if (switching && model === undefined && effort === undefined) {
799
+ this.durableV2SwitchProvider(chatId, target)
800
+ } else throw new Error('Model and depth writer unavailable')
560
801
  } catch (error) {
561
- this.log(`/model switch failed for ${msg.chatId} → ${hit[0]}: ${error?.message || String(error)}`)
562
- return reject('model-failed', /settled/.test(error?.message || '') ? T.modelBusy : T.modelSwitchFailed)
802
+ this.log(`/model failed for ${chatId} → ${target}: ${error?.message || String(error)}`)
803
+ return { kind: 'failed', reason: 'failed',
804
+ text: /settled|任务进行中|空闲/.test(error?.message || '') ? T.modelBusy : T.modelSwitchFailed }
805
+ }
806
+ const g2 = (this.cfg.groups && this.cfg.groups[chatId]) || group
807
+ const after = r?.after || this._brain(g2, target, this.cfg.providers[target])
808
+ const label = brainLabel(target, after)
809
+ this.log(`/model: ${chatId} ${name}·${tBrain.model || '-'}·${tBrain.effort || '-'} → ${target}·${after.model || '-'}·${after.effort || '-'} (by ${sender}, ${source})`)
810
+ const filled = f.filled.length ? f.filled.join('和') : ''
811
+ if (switching) {
812
+ let note = ''
813
+ try {
814
+ const opts = { full: group.permissionMode === 'bypassPermissions', isCodex: tProv?.runner === 'codex' }
815
+ const h = this.projectMemory ? this.projectMemory(group.workdir, opts) : null
816
+ if (h?.note) note = h.note
817
+ } catch {}
818
+ return { kind: 'switched', text: T.modelSwitched(label, note, filled), label, after }
563
819
  }
564
- this.log(`/model: ${msg.chatId} ${currentName} → ${hit[0]} (by ${msg.senderName || 'unknown'})`)
565
- return reject('model-switched', T.modelSwitched(label(hit[0], this.cfg.providers[hit[0]])))
820
+ if (r?.modelChanged) return { kind: 'model', text: T.modelChanged(label, filled), label, after }
821
+ return { kind: 'effort', text: T.effortSwitched(after.effort, brainLabel(target, { model: after.model })), label, after }
566
822
  }
567
823
 
568
824
  async runDurableV2Message(msg, platform) {
@@ -1559,9 +1815,10 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1559
1815
  if (!provider || (provider.baseUrl && !provider.apiKey)) {
1560
1816
  throw new Error('C2 v2 runner provider unavailable')
1561
1817
  }
1562
- rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1818
+ rt.v2Brain = this._brain(job.group, name, provider)
1819
+ rt.v2CardModelInfo = cardModelInfo(rt.v2Brain)
1563
1820
  this._observeActivity('start', { chatId, receiptId, receiptIds: cohort.receiptIds, generation,
1564
- sessionId: this.store.getSession(chatId), model: rt.v2CardModelInfo.model,
1821
+ sessionId: this.store.getSession(chatId), model: rt.v2Brain.model, effort: rt.v2Brain.effort,
1565
1822
  runner: provider.runner, project: projectName(job.group) })
1566
1823
  rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1567
1824
  rt.v2SteerControl = null
@@ -1805,7 +2062,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1805
2062
  capTimer = timeoutDeadlineAt !== null ? setTimeout(onHardDeadline, timeoutMs) : null
1806
2063
  const input = prepareC2RunnerInput({ prompt,
1807
2064
  appendSystemPrompt: SYSTEM_APPENDIX, msg: job.msg, group: job.group,
1808
- provider, config: this.cfg,
2065
+ provider, config: this.cfg, brain: { model: rt.v2Brain.model, effort: rt.v2Brain.effort },
1809
2066
  sessionId: this.store.getSession(chatId) || null,
1810
2067
  signal: controller.signal, extraEnv: this.baseSkillEnv(chatId),
1811
2068
  onDurableEvent: event => {
@@ -2233,7 +2490,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2233
2490
  chatId: msg.chatId, groupName: group.name || msg.chatId,
2234
2491
  workdir: group.workdir, repo: group.repo || '', repoState: info?.state || '',
2235
2492
  repoOrigin: info?.origin || '未知', pushDenied: policy.pushDenied,
2236
- providerName, model: modelContext(group, provider).model,
2493
+ providerName, model: this._brain(group, providerName, provider).model,
2237
2494
  runtimeVersion: this.durableV2Version || '', runtimeCommand: runtimeCommand(),
2238
2495
  noVision: provider.vision === false, platformName: platform.chatLabel || '项目群',
2239
2496
  permissionTier: policy.tier, tz: this.tz, publicUrl: '', uiUrl: '', lanUrl: '' }),