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.
- package/CHANGELOG.en.json +38 -0
- package/CHANGELOG.json +38 -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 +5 -3
- package/src/agent/codexcode.js +6 -4
- package/src/agent/modelwindows.js +10 -3
- package/src/agent/runner.js +5 -0
- package/src/c2configcontrol.js +29 -18
- package/src/c2console.js +22 -6
- package/src/c2multigroupservice.js +22 -2
- package/src/config.js +18 -8
- package/src/core/brain.js +117 -0
- package/src/core/c2inputrejection.js +19 -6
- package/src/core/c2runnerinput.js +10 -5
- 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 +182 -48
- package/src/ui/serverbase.js +173 -18
- package/src/modelcontext.js +0 -13
|
@@ -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 }
|
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: '' }),
|