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/c2console.js
CHANGED
|
@@ -13,10 +13,11 @@ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
|
|
|
13
13
|
'/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
|
|
14
14
|
'/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
|
|
15
15
|
'/api/feishu/chats', '/api/feishu/chat-members', '/api/update', '/api/claude/models',
|
|
16
|
+
'/api/groups/model-options',
|
|
16
17
|
START_PATH, CALLBACK_PATH]
|
|
17
18
|
const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
18
19
|
'/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
|
|
19
|
-
'/api/groups', '/api/groups/provider', '/api/fs/mkdir',
|
|
20
|
+
'/api/groups', '/api/groups/provider', '/api/groups/brain', '/api/fs/mkdir',
|
|
20
21
|
'/api/groups/order', '/api/groups/policy', '/api/groups/redact',
|
|
21
22
|
'/api/groups/codex-steer', '/api/groups/steer', '/api/groups/pause', '/api/groups/stop',
|
|
22
23
|
// A fresh session only for an idle, settled group (same rule as /new).
|
|
@@ -38,9 +39,18 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
|
38
39
|
// manages this exact entry (the updater refuses every other run mode).
|
|
39
40
|
'/api/update/check', '/api/update/start', '/api/update/ignore']
|
|
40
41
|
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
41
|
-
const {
|
|
42
|
+
const { groupBrain } = require('./core/brain')
|
|
43
|
+
const { codexCatalogWindows, windowForProvider } = require('./agent/modelwindows')
|
|
42
44
|
const { GROUP_COMMANDS } = require('./runtimefacts')
|
|
43
45
|
|
|
46
|
+
// What a group runs on one backend, for console rows: configured values only;
|
|
47
|
+
// what the executor finally selected is reported by the run itself.
|
|
48
|
+
function brainFields(group, name, provider, cfg) {
|
|
49
|
+
const b = groupBrain(group, name, provider, { defaultProvider: cfg.defaultProvider })
|
|
50
|
+
return { model: b.model, reasoningEffort: b.effort, groupModel: b.groupModel, groupEffort: b.groupEffort,
|
|
51
|
+
source: b.groupModel ? 'group' : b.defaultModel ? 'provider' : 'default', actualModel: null }
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(), activity = null, store = null,
|
|
45
55
|
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
|
|
46
56
|
appliedResets = () => [], now = Date.now,
|
|
@@ -222,8 +232,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
222
232
|
? { usedTokens: stats.ctx, windowTokens: null, measuredAt: null, quality: 'historical' } : null)
|
|
223
233
|
return { chatId, name: group.name || chatId.slice(-8), project: path.basename(group.workdir || '') || '—',
|
|
224
234
|
workdir: group.workdir || '', runner: provider.runner || 'claude',
|
|
225
|
-
...
|
|
226
|
-
reasoningEffort: (provider.runner === 'codex' ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL) || '',
|
|
235
|
+
...brainFields(group, providerName, provider, cfg),
|
|
227
236
|
paused: !!group.paused, ...live.groups[chatId], context }
|
|
228
237
|
})
|
|
229
238
|
const rows = getActivity().filter(row => (!query.chatId || row.chatId === query.chatId) && (!query.status || row.status === query.status))
|
|
@@ -249,8 +258,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
249
258
|
for (const group of state.groups || []) {
|
|
250
259
|
const cfg = getConfig(), source = cfg.groups?.[group.chatId] || {}
|
|
251
260
|
const provider = cfg.providers?.[group.effectiveProvider || source.provider || cfg.defaultProvider]
|
|
252
|
-
const
|
|
253
|
-
|
|
261
|
+
const name = group.effectiveProvider || source.provider || cfg.defaultProvider
|
|
262
|
+
const fields = brainFields(source, name, provider, cfg)
|
|
263
|
+
Object.assign(group, { configuredModel: fields.model, modelSource: fields.source,
|
|
264
|
+
actualModel: null, brain: { provider: name, model: fields.model, effort: fields.reasoningEffort,
|
|
265
|
+
groupModel: fields.groupModel, groupEffort: fields.groupEffort },
|
|
266
|
+
providerPrefs: source.providerPrefs || null,
|
|
267
|
+
contextWindow: provider ? windowForProvider({ ...provider, model: fields.model }, null, codexCatalogWindows()) : null })
|
|
254
268
|
}
|
|
255
269
|
state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
|
|
256
270
|
state.leftovers = { supported: false, rows: [], error: null }
|
|
@@ -290,6 +304,8 @@ function createC2Console(options = {}) {
|
|
|
290
304
|
throw new TypeError('C2 console requires explicit installation paths')
|
|
291
305
|
}
|
|
292
306
|
const facade = createC2ConsoleFacade(options)
|
|
307
|
+
// The process-wide model list (also used by /model), when the service has one.
|
|
308
|
+
facade.modelCatalog = typeof options.modelCatalog === 'function' ? options.modelCatalog : null
|
|
293
309
|
const ui = createUiServer({ configPath, host, log, getConnHealth, updater: options.updater || null,
|
|
294
310
|
onHostChange: options.onHostChange,
|
|
295
311
|
getLiveState: facade.getLiveState, control: facade.control, runtimePolicy: facade,
|
|
@@ -39,7 +39,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
39
39
|
createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
|
|
40
40
|
quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
|
|
41
41
|
createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
|
|
42
|
-
ingressWaitMs = 60_000, now = Date.now, log = () => {} } = {}) {
|
|
42
|
+
ingressWaitMs = 60_000, onModelCard = null, now = Date.now, log = () => {} } = {}) {
|
|
43
43
|
if (!Array.isArray(routes) || !routes.length ||
|
|
44
44
|
new Set(routes.map(route => route.chatId)).size !== routes.length ||
|
|
45
45
|
routes.some(route => !route || typeof route.chatId !== 'string' ||
|
|
@@ -392,6 +392,26 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
392
392
|
throw error
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
|
+
// Taps on the /model card only. They write the configuration through the
|
|
396
|
+
// same validated writer as the console and never touch a journal, receipt
|
|
397
|
+
// or session; every other card callback stays unsupported. The return
|
|
398
|
+
// value is the platform's synchronous answer (toast and replacement card).
|
|
399
|
+
function cardAction(event) {
|
|
400
|
+
let value = event?.action?.value
|
|
401
|
+
if (typeof value === 'string') { try { value = JSON.parse(value) } catch { value = null } }
|
|
402
|
+
if (typeof onModelCard !== 'function' || !value || value.a !== 'mc' || !allowed(event?.chatId)) {
|
|
403
|
+
return unsupported('card action')(event)
|
|
404
|
+
}
|
|
405
|
+
if (maintenance) return { toast: { type: 'info', content: '正在升级,稍后再点' } }
|
|
406
|
+
if (suspended.has(event.chatId)) return { toast: { type: 'warning', content: '本群暂停处理中,请管理员处理后再改' } }
|
|
407
|
+
const binding = bindings.get(event.chatId)
|
|
408
|
+
if (!binding) return { ignored: true }
|
|
409
|
+
return Promise.resolve().then(() => onModelCard({ ...event, action: { ...event.action, value } }, binding))
|
|
410
|
+
.catch(error => {
|
|
411
|
+
log(`model card ${event.chatId}: ${error?.message || String(error)}`)
|
|
412
|
+
return { toast: { type: 'error', content: '没改成 —— 请稍后再试' } }
|
|
413
|
+
})
|
|
414
|
+
}
|
|
395
415
|
function unsupported(source) {
|
|
396
416
|
return event => {
|
|
397
417
|
// An event without group identity cannot safely be attributed. It has
|
|
@@ -507,7 +527,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
507
527
|
transportStarted = true
|
|
508
528
|
await transport.platform.start(routeMessage, { onFatal: fatal,
|
|
509
529
|
onRecall: recall, onReaction: unsupported('reaction'),
|
|
510
|
-
onCardAction:
|
|
530
|
+
onCardAction: cardAction, onBoardUpdated: unsupported('board update') })
|
|
511
531
|
held()
|
|
512
532
|
if (stopPromise) throw new Error('C2 service stopped during connection')
|
|
513
533
|
return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Which model and thinking depth a group uses on one model card (backend).
|
|
3
|
+
// This is the single resolver: runner input, card footer, /help · /model ·
|
|
4
|
+
// /status, the agent manifest, the console and the activity rows all call it.
|
|
5
|
+
//
|
|
6
|
+
// Storage: groups[chatId].providerPrefs = { "<card name>": { model?, effort? } },
|
|
7
|
+
// kept per backend. A group that moves between backends keeps each backend's
|
|
8
|
+
// own choice: a model id from one backend is never passed to another, and
|
|
9
|
+
// switching back does not silently fall back to something else.
|
|
10
|
+
// A missing entry means "not chosen": the executor picks its own default.
|
|
11
|
+
// Legacy groups[chatId].model belongs only to the backend the group was on
|
|
12
|
+
// when it was written (group.provider, else defaultProvider).
|
|
13
|
+
//
|
|
14
|
+
// Only shapes are validated, never an allow list: model ids and depth names
|
|
15
|
+
// evolve upstream, and values travel as argv/env entries without a shell.
|
|
16
|
+
// A bad shape is treated as unset instead of failing the turn.
|
|
17
|
+
|
|
18
|
+
const MODEL_RE = /^[\w.\-[\]:/]{1,80}$/
|
|
19
|
+
const EFFORT_RE = /^[a-z]{2,16}$/
|
|
20
|
+
|
|
21
|
+
const isModel = (s) => typeof s === 'string' && MODEL_RE.test(s)
|
|
22
|
+
const isEffort = (s) => typeof s === 'string' && EFFORT_RE.test(s)
|
|
23
|
+
|
|
24
|
+
// Legacy card-level values. Subscription cards no longer carry them after the
|
|
25
|
+
// one-time migration; a third-party endpoint keeps its model as connection
|
|
26
|
+
// configuration (it has no model list to choose from).
|
|
27
|
+
function defaultModel(provider) {
|
|
28
|
+
const p = provider || {}
|
|
29
|
+
return p.model || (p.runner !== 'codex' && p.env && p.env.ANTHROPIC_MODEL) || ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultEffort(provider) {
|
|
33
|
+
const p = provider || {}
|
|
34
|
+
return (p.runner === 'codex' ? p.reasoningEffort : p.env && p.env.CLAUDE_CODE_EFFORT_LEVEL) || ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One entry → valid fields only; nothing valid ⇒ null.
|
|
38
|
+
function cleanPref(x) {
|
|
39
|
+
if (!x || typeof x !== 'object' || Array.isArray(x)) return null
|
|
40
|
+
const o = {}
|
|
41
|
+
if (isModel(x.model)) o.model = x.model
|
|
42
|
+
if (isEffort(x.effort)) o.effort = x.effort
|
|
43
|
+
return Object.keys(o).length ? o : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whole table: drop bad shapes, empty entries and (when known is given)
|
|
47
|
+
// entries for cards that do not exist. An empty table becomes undefined.
|
|
48
|
+
function normalizePrefs(prefs, known) {
|
|
49
|
+
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return undefined
|
|
50
|
+
const out = {}
|
|
51
|
+
for (const [name, v] of Object.entries(prefs)) {
|
|
52
|
+
if (!/^[\w.-]+$/.test(name)) continue
|
|
53
|
+
if (known && !Object.prototype.hasOwnProperty.call(known, name)) continue
|
|
54
|
+
const c = cleanPref(v)
|
|
55
|
+
if (c) out[name] = c
|
|
56
|
+
}
|
|
57
|
+
return Object.keys(out).length ? out : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param group groups[chatId] (may be empty)
|
|
62
|
+
* @param name the card actually running this turn
|
|
63
|
+
* @param provider cfg.providers[name]
|
|
64
|
+
* @param opts.defaultProvider top-level defaultProvider, only used to decide
|
|
65
|
+
* which backend a legacy group.model belongs to
|
|
66
|
+
* @returns {{ model, effort, groupModel, groupEffort, defaultModel, defaultEffort }}
|
|
67
|
+
* model / effort are effective values ('' = executor default);
|
|
68
|
+
* groupX are the group's own choices ('' = not chosen).
|
|
69
|
+
*/
|
|
70
|
+
function groupBrain(group, name, provider, { defaultProvider = '' } = {}) {
|
|
71
|
+
const g = group || {}
|
|
72
|
+
const pref = cleanPref(g.providerPrefs && typeof g.providerPrefs === 'object' ? g.providerPrefs[name] : null) || {}
|
|
73
|
+
const home = g.provider || defaultProvider || ''
|
|
74
|
+
const legacy = (!pref.model && isModel(g.model) && (!home || home === name)) ? g.model : ''
|
|
75
|
+
const groupModel = pref.model || legacy
|
|
76
|
+
const groupEffort = pref.effort || ''
|
|
77
|
+
const dm = defaultModel(provider)
|
|
78
|
+
const de = defaultEffort(provider)
|
|
79
|
+
return { model: groupModel || dm, effort: groupEffort || de, groupModel, groupEffort, defaultModel: dm, defaultEffort: de }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Move legacy group.model under the backend it belonged to (mutates the raw
|
|
83
|
+
// group). Every writer that changes group.provider calls this first, so the
|
|
84
|
+
// legacy id never attaches itself to the new backend. Returns whether it changed.
|
|
85
|
+
function migrateLegacyModel(g, defaultProvider) {
|
|
86
|
+
if (!g || typeof g !== 'object' || g.model === undefined) return false
|
|
87
|
+
const home = g.provider || defaultProvider || ''
|
|
88
|
+
if (home && isModel(g.model)) {
|
|
89
|
+
const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
|
|
90
|
+
const cur = cleanPref(prefs[home]) || {}
|
|
91
|
+
if (!cur.model) prefs[home] = { ...cur, model: g.model }
|
|
92
|
+
g.providerPrefs = prefs
|
|
93
|
+
}
|
|
94
|
+
delete g.model
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Active groups whose current backend lacks a choice (doctor and console use
|
|
99
|
+
// the same rule). A subscription card without a model and any card without a
|
|
100
|
+
// depth are listed. Paused groups are skipped.
|
|
101
|
+
// Returns [{ chatId, name, provider, lack: ['model' | 'effort'] }].
|
|
102
|
+
function missingPicks(cfg) {
|
|
103
|
+
const out = []
|
|
104
|
+
const provs = (cfg && cfg.providers) || {}
|
|
105
|
+
for (const [id, g] of Object.entries((cfg && cfg.groups) || {})) {
|
|
106
|
+
if (!g || typeof g !== 'object' || id === 'oc_example_chat_id' || g.paused === true) continue
|
|
107
|
+
const pn = g.provider || cfg.defaultProvider
|
|
108
|
+
const pv = provs[pn]
|
|
109
|
+
if (!pv) continue
|
|
110
|
+
const b = groupBrain(g, pn, pv, { defaultProvider: cfg.defaultProvider })
|
|
111
|
+
const lack = [!b.model && !pv.baseUrl ? 'model' : '', !b.effort ? 'effort' : ''].filter(Boolean)
|
|
112
|
+
if (lack.length) out.push({ chatId: id, name: g.name || '', provider: pn, lack })
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { groupBrain, normalizePrefs, cleanPref, migrateLegacyModel, missingPicks, defaultModel, defaultEffort, isModel, isEffort, MODEL_RE, EFFORT_RE }
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
const CODE = 'C2_V2_INPUT_REJECTED'
|
|
8
8
|
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
9
9
|
|
|
10
|
-
function rejectC2Input(message, { reason, notice = null } = {}) {
|
|
10
|
+
function rejectC2Input(message, { reason, notice = null, card = null } = {}) {
|
|
11
11
|
if (typeof message !== 'string' || !message ||
|
|
12
12
|
typeof reason !== 'string' || !/^[a-z][a-z-]{0,40}$/.test(reason) ||
|
|
13
|
-
notice !== null && (typeof notice !== 'string' || !notice)
|
|
13
|
+
notice !== null && (typeof notice !== 'string' || !notice) ||
|
|
14
|
+
card !== null && (typeof card !== 'object' || Array.isArray(card))) {
|
|
14
15
|
throw new TypeError('C2 input rejection needs a message, reason and optional notice')
|
|
15
16
|
}
|
|
16
17
|
const error = new Error(message)
|
|
17
18
|
error.code = CODE
|
|
18
19
|
error.reason = reason
|
|
19
20
|
error.notice = notice
|
|
21
|
+
// An optional interactive card (the /model card) sent instead of the text;
|
|
22
|
+
// the text stays the fallback when the card is not confirmed.
|
|
23
|
+
if (card) error.card = card
|
|
20
24
|
return error
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -33,10 +37,19 @@ async function answerC2InputRejection({ error, msg, platform, log = () => {} } =
|
|
|
33
37
|
if (error.notice && typeof platform?.replyOnce === 'function' &&
|
|
34
38
|
typeof msg?.chatId === 'string' && target?.chatId === msg.chatId &&
|
|
35
39
|
FEISHU_MESSAGE_ID.test(target?.messageId || '')) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (error.card && typeof platform.replyCardOnce === 'function') {
|
|
41
|
+
try {
|
|
42
|
+
const response = await platform.replyCardOnce(target, error.card)
|
|
43
|
+
notified = response?.code === 0
|
|
44
|
+
} catch {}
|
|
45
|
+
if (!notified) log(`input rejection card not confirmed (${error.reason}); sending text`)
|
|
46
|
+
}
|
|
47
|
+
if (!notified) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await platform.replyOnce(target, error.notice)
|
|
50
|
+
notified = response?.code === 0
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
40
53
|
if (!notified) log(`input rejection notice not confirmed (${error.reason})`)
|
|
41
54
|
}
|
|
42
55
|
return Object.freeze({ rejected: true, reason: error.reason, notified })
|
|
@@ -6,7 +6,7 @@ const { followupEnabled } = require('../agent/privatecore')
|
|
|
6
6
|
// process-local arguments. It neither starts a child nor persists text,
|
|
7
7
|
// credentials, callback handles, or environment values.
|
|
8
8
|
function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider,
|
|
9
|
-
config, sessionId = null, signal, extraEnv, onTurnAccepted, onSpawnFailed, onDurableEvent,
|
|
9
|
+
config, brain = null, sessionId = null, signal, extraEnv, onTurnAccepted, onSpawnFailed, onDurableEvent,
|
|
10
10
|
onEvent = () => {}, onSteerReady, onSteerLanded, log = () => {} } = {}) {
|
|
11
11
|
if (typeof prompt !== 'string' || !prompt ||
|
|
12
12
|
typeof appendSystemPrompt !== 'string' || !appendSystemPrompt ||
|
|
@@ -14,6 +14,8 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
|
|
|
14
14
|
!group || typeof group.workdir !== 'string' || !group.workdir ||
|
|
15
15
|
!provider || !['claude', 'codex'].includes(provider.runner) ||
|
|
16
16
|
!config || !config.agent || typeof config.agent !== 'object' ||
|
|
17
|
+
(brain !== null && (typeof brain !== 'object' || typeof brain.model !== 'string' ||
|
|
18
|
+
typeof brain.effort !== 'string')) ||
|
|
17
19
|
(sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
|
|
18
20
|
!signal || typeof signal.aborted !== 'boolean' ||
|
|
19
21
|
!extraEnv || typeof extraEnv !== 'object' || Array.isArray(extraEnv) ||
|
|
@@ -47,7 +49,10 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
|
|
|
47
49
|
steer: !isCodex && enabled,
|
|
48
50
|
codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
|
|
49
51
|
onSteerReady, onSteerLanded,
|
|
50
|
-
model
|
|
52
|
+
// The group's model and depth on this backend (core/brain.js); empty
|
|
53
|
+
// leaves the choice to the executor.
|
|
54
|
+
model: brain ? brain.model || undefined : group.model,
|
|
55
|
+
effort: brain ? brain.effort : '',
|
|
51
56
|
workdir: group.workdir,
|
|
52
57
|
sessionId,
|
|
53
58
|
permissionMode: group.permissionMode || 'dontAsk',
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 群命令 /model 的词法。纯函数 —— core 不认 runner 名:哪家有什么型号 / 深度,由 bin 注入的
|
|
3
|
+
// modelCatalog 给出形状 { ok, models: [{ id, efforts? }] | null, aliases: [], efforts: [] }(models 为 null = 这家
|
|
4
|
+
// 没有可选清单,型号只能在后台改)。
|
|
5
|
+
//
|
|
6
|
+
// 写法:`/model` 后面的每个词各自**精确**匹配一类(大小写不敏感,顺序随意,每类最多一个):
|
|
7
|
+
// 后端(模型卡名)· 深度(档位名)· 型号(目标后端清单里的 id 或别名)。
|
|
8
|
+
// 没有「默认」可回(模型卡上不再有默认型号 / 深度,⇒「默认 / default」不再是重置词,当认不出的词整条拒绝。
|
|
9
|
+
// ⚠ **不做模糊匹配**(口径):认不出一个词就整条拒绝、一项都不改 —— 换错模型 = 换了花的钱,
|
|
10
|
+
// 宁可让人重发一条也不猜;此前「只认第一个词、后面的悄悄扔掉」正是要修的坑(`/model claude sonnet` 丢了 sonnet)。
|
|
11
|
+
// 型号可以照显示名打:`/model opus-5.5` · `/model Opus 5.5` · `/model claude-opus-5-5` · `/model GPT-6.1 Terra` 是对应的同一个 ——
|
|
12
|
+
// 比对键见 core/modelname.js modelKey(大小写、claude- 前缀、点 / 连字符 / 空格不算),仍是拿清单**逐个精确比对**;
|
|
13
|
+
// 两个清单项归一后撞了键 ⇒ 当认不出(不替人挑)。「Opus 5.5」被空格拆成两个词:一个词后面紧跟纯版本号(5.5 / 4.8)
|
|
14
|
+
// 或变体((1M) / [1m])就并回一个词。
|
|
15
|
+
|
|
16
|
+
const { modelKey } = require('./modelname')
|
|
17
|
+
|
|
18
|
+
// 识别为「深度」这一类的词(能不能用再按目标后端 / 型号查)。Claude Code 的五档 + Codex 服务端认的两档低档。
|
|
19
|
+
const EFFORT_WORDS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
20
|
+
const VERSION_RE = /^\d+(?:[.-]\d+)*$/
|
|
21
|
+
const VARIANT_RE = /^[[(][a-z0-9]+[\])]$/i
|
|
22
|
+
|
|
23
|
+
// 「Opus 5.5 (1M)」「GPT-6.1 Terra」这类被空格拆开的显示名并回一个词:字母结尾 + 纯版本号 · 数字结尾 + 变体 ·
|
|
24
|
+
// 数字结尾 + 纯字母词(Codex 官方名称「GPT-6.1 Terra」照着打被拆成两个词、整条拒绝)。
|
|
25
|
+
// 只在型号类的词之间并:后端名 / 深度词不并、也不被并(`/model gpt-6-luna low` 的 low 仍是深度)。
|
|
26
|
+
function joinVersionWords(words, isOther) {
|
|
27
|
+
const out = []
|
|
28
|
+
for (const raw of words || []) {
|
|
29
|
+
const w = String(raw || '').trim()
|
|
30
|
+
if (!w) continue
|
|
31
|
+
const prev = out[out.length - 1]
|
|
32
|
+
const glue = prev !== undefined && !isOther(prev) && !isOther(w) && (
|
|
33
|
+
VERSION_RE.test(w) ? /[a-z]$/i.test(prev)
|
|
34
|
+
: VARIANT_RE.test(w) ? /\d$/.test(prev)
|
|
35
|
+
: /^[a-z]+$/i.test(w) && /\d$/.test(prev))
|
|
36
|
+
if (glue) out[out.length - 1] = prev + ' ' + w
|
|
37
|
+
else out.push(w)
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param words 命令后面的词
|
|
44
|
+
* @param providerNames 群里可选的模型卡名(没隐藏的)
|
|
45
|
+
* @returns {{ provider?, model?, models?, effort? } | { error: { kind: 'dup', what, a, b } }}
|
|
46
|
+
* model 原样保留大小写(查清单时再归一成清单里的写法);型号类写了不止一个 ⇒ models(调用方先逐个认,再判重)
|
|
47
|
+
*/
|
|
48
|
+
function parseModelWords(words, providerNames = []) {
|
|
49
|
+
const names = new Map(providerNames.map((n) => [String(n).toLowerCase(), n]))
|
|
50
|
+
const out = {}
|
|
51
|
+
const isOther = (w) => { const lw = w.toLowerCase(); return names.has(lw) || EFFORT_WORDS.includes(lw) }
|
|
52
|
+
for (const w of joinVersionWords(words, isOther)) {
|
|
53
|
+
const lw = w.toLowerCase()
|
|
54
|
+
if (names.has(lw)) {
|
|
55
|
+
const n = names.get(lw)
|
|
56
|
+
if (out.provider && out.provider !== n) return { error: { kind: 'dup', what: '后端', a: out.provider, b: n } }
|
|
57
|
+
out.provider = n
|
|
58
|
+
} else if (EFFORT_WORDS.includes(lw)) {
|
|
59
|
+
if (out.effort && out.effort !== lw) return { error: { kind: 'dup', what: '深度', a: out.effort, b: lw } }
|
|
60
|
+
out.effort = lw
|
|
61
|
+
} else {
|
|
62
|
+
// 型号这一类先都收下,由调用方按清单逐个认:两个词里有认不出的 ⇒ 报「认不出」(多半是打错 / 隐藏的后端名),
|
|
63
|
+
// 都认得出才报「一次只能选一个型号」—— 否则 `/model claude sonnet`(claude 被隐藏)会得到一句莫名其妙的「写了两个型号」
|
|
64
|
+
const ws = out.models || (out.models = [])
|
|
65
|
+
if (!ws.some((x) => x.toLowerCase() === lw)) ws.push(w)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (out.models && out.models.length === 1) { out.model = out.models[0]; delete out.models }
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 词 → 清单里的规范写法(id 或别名);认不出 ⇒ null。先原样比(大小写不敏感),再按比对键比 —— 键撞了不止一项 ⇒ null(不替人挑)。
|
|
73
|
+
// 实时清单带 aliasOf(opus → claude-opus-5-5)⇒ 别名认成它此刻指向的**具体型号**存下(钉住,CLI 升级不悄悄换代);
|
|
74
|
+
// 官方显示名(「GPT-6.1 Terra」)也参与比对键 —— 照卡上 / 控制台看到的名字打就认。
|
|
75
|
+
function resolveModel(word, cat) {
|
|
76
|
+
if (!word || !cat) return null
|
|
77
|
+
const lw = String(word).toLowerCase()
|
|
78
|
+
const models = (cat.models || []).map((m) => ({ id: String(m.id), name: m.name ? String(m.name) : '' }))
|
|
79
|
+
const ids = [...models.map((m) => m.id), ...(cat.aliases || []).map(String)]
|
|
80
|
+
const hit = ids.find((x) => x.toLowerCase() === lw)
|
|
81
|
+
if (hit) return hit
|
|
82
|
+
const aliasOf = cat.aliasOf && typeof cat.aliasOf === 'object' ? cat.aliasOf : {} // 别名走下面的比对键(大小写不敏感,与原样比等价)
|
|
83
|
+
const k = modelKey(word)
|
|
84
|
+
if (!k) return null
|
|
85
|
+
const keyed = new Set(ids.filter((x) => modelKey(x) === k))
|
|
86
|
+
for (const m of models) if (m.name && modelKey(m.name) === k) keyed.add(m.id)
|
|
87
|
+
for (const a of Object.keys(aliasOf)) if (modelKey(a) === k && ids.includes(aliasOf[a])) keyed.add(aliasOf[a])
|
|
88
|
+
return keyed.size === 1 ? [...keyed][0] : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 这家(某型号下)能选的深度:清单里该型号自带档位就用它的,否则用这家的通用档位
|
|
92
|
+
function effortsFor(cat, modelId) {
|
|
93
|
+
const m = cat && modelId ? (cat.models || []).find((x) => x.id === modelId) : null
|
|
94
|
+
return (m && Array.isArray(m.efforts) && m.efforts.length) ? m.efforts : ((cat && cat.efforts) || [])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 群里列给人看的型号:别名在前(短、好打),再列完整 id
|
|
98
|
+
function modelChoices(cat) {
|
|
99
|
+
if (!cat || !Array.isArray(cat.models)) return []
|
|
100
|
+
return [...(cat.aliases || []), ...cat.models.map((m) => m.id)]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 某型号的推荐深度:清单里它自带默认档(Codex defaultReasoningEffort)就用它的;否则这家的推荐档
|
|
104
|
+
// (cat.recommended.effort,Claude 系 xhigh);都不在这型号可选的档里 ⇒ high ⇒ 第一档。没有清单 ⇒ ''。
|
|
105
|
+
function recommendEffort(cat, modelId) {
|
|
106
|
+
if (!cat) return ''
|
|
107
|
+
const list = effortsFor(cat, modelId)
|
|
108
|
+
const m = modelId && Array.isArray(cat.models) ? cat.models.find((x) => x.id === modelId) : null
|
|
109
|
+
for (const e of [m && m.defEffort, cat.recommended && cat.recommended.effort, 'high']) if (e && list.includes(e)) return e
|
|
110
|
+
return list[0] || ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 补齐本群在目标后端上缺的选择(;群里 /model、模型卡、控制台群卡同一份)。没有「默认」之后,缺项不再静静跟随模型卡:
|
|
114
|
+
// 换后端且那一家**实际生效的**型号是空的(没选过、也没有迁移前遗留的卡上默认;第三方除外)⇒ 推荐型号;
|
|
115
|
+
// 换后端 / 换型号时实际生效的深度是空的、或新型号没有这一档 ⇒ 这型号的推荐档。
|
|
116
|
+
// 只调深度不补型号(补了就换脑、开新对话,与「只调深度接着聊」冲突)。返回 { model, effort, filled: ['型号','深度'] }。
|
|
117
|
+
function fillPicks({ switching = false, model, effort, currentModel = '', currentEffort = '', third = false, cat = null } = {}) {
|
|
118
|
+
const filled = []
|
|
119
|
+
const rec = cat && cat.ok !== false && cat.recommended ? cat.recommended : null
|
|
120
|
+
if (switching && model === undefined && !currentModel && !third && rec && rec.model) { model = rec.model; filled.push('型号') }
|
|
121
|
+
const next = model === undefined ? currentModel : model
|
|
122
|
+
if (effort === undefined && cat && cat.ok !== false && (switching || model !== undefined)) {
|
|
123
|
+
const effs = effortsFor(cat, next)
|
|
124
|
+
if (!currentEffort || (effs.length && !effs.includes(currentEffort))) {
|
|
125
|
+
const e = recommendEffort(cat, next)
|
|
126
|
+
if (e && e !== currentEffort) { effort = e; if (!currentEffort) filled.push('深度') }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { model, effort, filled }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { parseModelWords, resolveModel, effortsFor, modelChoices, recommendEffort, fillPicks, EFFORT_WORDS }
|
|
@@ -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 }
|