sintbot 1.4.15 → 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.
- package/CHANGELOG.en.json +19 -0
- package/CHANGELOG.json +19 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/sintbot-v2.js +87 -2
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +5 -5
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +5 -5
- package/install.sh +1 -1
- package/package.json +8 -3
- package/src/agent/claudecode.js +5 -2
- package/src/agent/claudemodels.js +79 -8
- package/src/agent/codexapp.js +4 -3
- package/src/agent/codexcode.js +1 -1
- package/src/c2configcontrol.js +29 -18
- package/src/c2console.js +22 -6
- package/src/c2multigroupservice.js +22 -2
- package/src/core/brain.js +117 -0
- package/src/core/c2inputrejection.js +19 -6
- package/src/core/c2runnerinput.js +7 -2
- package/src/core/modelcmd.js +132 -0
- package/src/core/modelname.js +61 -0
- package/src/core/v2engine.js +304 -47
- package/src/groupbrain.js +135 -0
- package/src/modelcatalog.js +122 -0
- package/src/platform/feishu.js +17 -0
- package/src/platform/modelcard.js +103 -0
- package/src/ui/console.html +119 -23
- package/src/ui/serverbase.js +155 -7
- package/src/modelcontext.js +0 -13
package/src/core/v2engine.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
55
|
-
if (!
|
|
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 —
|
|
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
|
-
|
|
120
|
-
|
|
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: '⚠
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
.
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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.
|
|
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
|
|
562
|
-
return
|
|
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
|
-
|
|
565
|
-
return
|
|
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.
|
|
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.
|
|
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:
|
|
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: '' }),
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The single change logic for a group's backend · model · thinking depth.
|
|
3
|
+
// The console group card, the chat /model command and the Feishu model card
|
|
4
|
+
// all call applyBrainChange, so "did the backend change / only the model /
|
|
5
|
+
// only the depth / nothing" is decided in one place.
|
|
6
|
+
//
|
|
7
|
+
// Pure: mutates the given raw configuration object; the caller writes it
|
|
8
|
+
// through the configuration writer (which validates, resets sessions when the
|
|
9
|
+
// backend changes, replaces the file atomically and applies it).
|
|
10
|
+
|
|
11
|
+
const brainMod = require('./core/brain')
|
|
12
|
+
const { DEFAULTS } = require('./config')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param raw configuration object (mutated)
|
|
16
|
+
* @param chatId
|
|
17
|
+
* @param change { provider?, model?, effort? }
|
|
18
|
+
* provider: switch backend (omitted = keep); model / effort: string choice
|
|
19
|
+
* ('' = clear), omitted = keep. Choices go to the target backend's entry.
|
|
20
|
+
* @returns { error } | { before, after, switching, target, home, modelChanged, effortChanged, changed }
|
|
21
|
+
* error ∈ 'no-group' | 'unknown-provider' | 'bad-model' | 'bad-effort'
|
|
22
|
+
*/
|
|
23
|
+
function applyBrainChange(raw, chatId, change = {}) {
|
|
24
|
+
const { provider, model, effort } = change
|
|
25
|
+
const g = raw && raw.groups && raw.groups[chatId]
|
|
26
|
+
if (!g) return { error: 'no-group' }
|
|
27
|
+
if (provider !== undefined && !(raw.providers || {})[provider]) return { error: 'unknown-provider' }
|
|
28
|
+
if (model !== undefined && model !== '' && !brainMod.isModel(model)) return { error: 'bad-model' }
|
|
29
|
+
if (effort !== undefined && effort !== '' && !brainMod.isEffort(effort)) return { error: 'bad-effort' }
|
|
30
|
+
const defName = raw.defaultProvider || DEFAULTS.defaultProvider
|
|
31
|
+
const provAll = { ...DEFAULTS.providers, ...(raw.providers || {}) }
|
|
32
|
+
const home = g.provider || defName
|
|
33
|
+
const before = brainMod.groupBrain(g, home, provAll[home], { defaultProvider: defName })
|
|
34
|
+
// Legacy group.model first moves under its own backend, then the backend may change.
|
|
35
|
+
brainMod.migrateLegacyModel(g, defName)
|
|
36
|
+
const switching = provider !== undefined && provider !== home
|
|
37
|
+
const target = provider || home
|
|
38
|
+
if (model !== undefined || effort !== undefined) {
|
|
39
|
+
const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
|
|
40
|
+
const cur = { ...(brainMod.cleanPref(prefs[target]) || {}) }
|
|
41
|
+
if (model !== undefined) { if (model) cur.model = model; else delete cur.model }
|
|
42
|
+
if (effort !== undefined) { if (effort) cur.effort = effort; else delete cur.effort }
|
|
43
|
+
if (Object.keys(cur).length) prefs[target] = cur; else delete prefs[target]
|
|
44
|
+
g.providerPrefs = prefs
|
|
45
|
+
}
|
|
46
|
+
const np = brainMod.normalizePrefs(g.providerPrefs)
|
|
47
|
+
if (np) g.providerPrefs = np; else delete g.providerPrefs
|
|
48
|
+
if (switching) g.provider = provider
|
|
49
|
+
const after = brainMod.groupBrain(g, target, provAll[target], { defaultProvider: defName })
|
|
50
|
+
const modelChanged = !switching && before.model !== after.model
|
|
51
|
+
const effortChanged = !switching && before.effort !== after.effort
|
|
52
|
+
return { before, after, switching, target, home, modelChanged, effortChanged,
|
|
53
|
+
changed: switching || modelChanged || effortChanged }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// One audit row (console, chat command and card share the shape; source tells which).
|
|
57
|
+
function auditRow(chatId, r, { source, by }) {
|
|
58
|
+
const pick = (b) => ({ model: b.model || '', effort: b.effort || '' })
|
|
59
|
+
return { kind: 'group-model', chatId, source, by: by || '', from: { provider: r.home, ...pick(r.before) },
|
|
60
|
+
to: { provider: r.target, ...pick(r.after) }, switching: r.switching, modelChanged: r.modelChanged,
|
|
61
|
+
effortChanged: r.effortChanged, ts: Date.now() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Card-level values that move into groups: subscription cards give up model
|
|
65
|
+
// and depth; a third-party endpoint keeps its model (connection setting) and
|
|
66
|
+
// gives up depth only.
|
|
67
|
+
function movable(p) {
|
|
68
|
+
return { model: p && !p.baseUrl ? brainMod.defaultModel(p) : '', effort: p ? brainMod.defaultEffort(p) : '' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Migration step one (additive, before the service starts): every group gets,
|
|
73
|
+
* on every visible card (and its current card even when hidden), the card's
|
|
74
|
+
* current default for any item it has not chosen. Card values stay in place,
|
|
75
|
+
* so an older version that ignores providerPrefs behaves exactly as before.
|
|
76
|
+
* Idempotent. Mutates raw. @returns {{ changed, groups: string[] }}
|
|
77
|
+
*/
|
|
78
|
+
function fillGroupPicks(raw) {
|
|
79
|
+
const out = { changed: false, groups: [] }
|
|
80
|
+
if (!raw || typeof raw !== 'object') return out
|
|
81
|
+
const provs = (raw.providers && typeof raw.providers === 'object') ? raw.providers : {}
|
|
82
|
+
const defName = raw.defaultProvider || DEFAULTS.defaultProvider
|
|
83
|
+
const provAll = { ...DEFAULTS.providers, ...provs }
|
|
84
|
+
const withDefaults = Object.keys(provs).filter((n) => { const d = movable(provs[n]); return !!(d.model || d.effort) })
|
|
85
|
+
for (const [id, g] of Object.entries(raw.groups || {})) {
|
|
86
|
+
if (!g || typeof g !== 'object' || Array.isArray(g) || id === 'oc_example_chat_id') continue
|
|
87
|
+
let touched = brainMod.migrateLegacyModel(g, defName)
|
|
88
|
+
if (withDefaults.length) {
|
|
89
|
+
const home = g.provider || defName
|
|
90
|
+
const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
|
|
91
|
+
const names = new Set([home, ...Object.keys(prefs)])
|
|
92
|
+
for (const n of Object.keys(provAll)) if (provAll[n] && !provAll[n].hidden) names.add(n)
|
|
93
|
+
for (const n of names) {
|
|
94
|
+
if (!withDefaults.includes(n)) continue
|
|
95
|
+
const d = movable(provs[n])
|
|
96
|
+
const cur = { ...(brainMod.cleanPref(prefs[n]) || {}) }
|
|
97
|
+
if (!cur.model && d.model) { cur.model = d.model; touched = true }
|
|
98
|
+
if (!cur.effort && d.effort) { cur.effort = d.effort; touched = true }
|
|
99
|
+
if (Object.keys(cur).length) prefs[n] = cur
|
|
100
|
+
}
|
|
101
|
+
const np = brainMod.normalizePrefs(prefs)
|
|
102
|
+
if (np) g.providerPrefs = np; else delete g.providerPrefs
|
|
103
|
+
}
|
|
104
|
+
if (touched) out.groups.push(id)
|
|
105
|
+
}
|
|
106
|
+
out.changed = out.groups.length > 0
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Migration step two (after every group connected): remove the card-level
|
|
112
|
+
* values that step one copied into the groups. Effective values do not change
|
|
113
|
+
* for any group whose picks were filled. Idempotent. Mutates raw.
|
|
114
|
+
* @returns {{ changed, providers: string[] }}
|
|
115
|
+
*/
|
|
116
|
+
function stripCardDefaults(raw) {
|
|
117
|
+
const out = { changed: false, providers: [] }
|
|
118
|
+
const provs = raw && raw.providers && typeof raw.providers === 'object' ? raw.providers : {}
|
|
119
|
+
for (const [n, p] of Object.entries(provs)) {
|
|
120
|
+
if (!p || typeof p !== 'object') continue
|
|
121
|
+
const d = movable(p)
|
|
122
|
+
if (!d.model && !d.effort) continue
|
|
123
|
+
if (!p.baseUrl) {
|
|
124
|
+
delete p.model
|
|
125
|
+
if (p.runner !== 'codex' && p.env) delete p.env.ANTHROPIC_MODEL
|
|
126
|
+
}
|
|
127
|
+
delete p.reasoningEffort
|
|
128
|
+
if (p.env) { delete p.env.CLAUDE_CODE_EFFORT_LEVEL; if (!Object.keys(p.env).length) delete p.env }
|
|
129
|
+
out.providers.push(n)
|
|
130
|
+
}
|
|
131
|
+
out.changed = out.providers.length > 0
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { applyBrainChange, auditRow, fillGroupPicks, stripCardDefaults }
|