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.
@@ -10,7 +10,7 @@ const crypto = require('node:crypto')
10
10
  const fs = require('node:fs')
11
11
  const os = require('node:os')
12
12
  const path = require('node:path')
13
- const { loadConfig, applyProxyEnv, deriveNoProxy, DEFAULTS, resolveSecret } = require('../config')
13
+ const { loadConfig, migrateModelSettings, applyProxyEnv, deriveNoProxy, DEFAULTS, resolveSecret } = require('../config')
14
14
  const verify = require('../core/verify') // 凭据最近一次验证的记录:模型测试 / 平台预检写,概览读
15
15
  const projectsroot = require('../projectsroot') // 项目根目录围栏:目录浏览 / 保存绑定 / 风格提炼三处同一份判据
16
16
  const { atomicWriteJSON } = require('../core/store')
@@ -43,6 +43,9 @@ const { platformsConfigured, chatPlatform, inferChatPlatform, PLATFORMS } = requ
43
43
  const { feishuClient, fetchBotOpenId, fetchAppIdentity, fetchChats, createChat, fetchChatMembers, addChatMembers, deleteChat, sendChatText, larkErrMsg, feishuPrecheck, fetchAdminCandidates, resolveContactToOpenId, fetchOrgUsers } = require('../platform/feishu')
44
44
  const { slackPrecheck, fetchSlackChannels, sendSlackText } = require('../platform/slack') // Slack:控制台侧只用这三个助手(收发在 daemon 里)
45
45
  const { getUsage, refreshUsage } = require('../usage')
46
+ const brainMod = require('../core/brain') // a group's model and depth on one backend: the single resolver
47
+ const { applyBrainChange, auditRow } = require('../groupbrain')
48
+ const { effortsFor, fillPicks } = require('../core/modelcmd')
46
49
 
47
50
  // 反馈本默认落点(与 CLI / 群命令同一个文件 —— 三个入口一本账,不许各记各的)
48
51
  const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl')
@@ -50,6 +53,11 @@ const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl
50
53
  const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-sessions.json')
51
54
 
52
55
  const CONSOLE_HTML = path.join(__dirname, 'console.html')
56
+ // Model display names: the same function as group replies and cards, injected
57
+ // into the page (with a plain fallback when the page is opened directly).
58
+ const MODELNAME_SLOT = "/*__SBOT_MODELNAME__*/(s=>String(s==null?'':s))"
59
+ const MODELNAME_SRC = '(' + require('../core/modelname').modelDisplayName.toString() + ')'
60
+ const { knownModelNames } = require('../core/modelname')
53
61
  const LIVE_ACTIVITY_JS = fs.readFileSync(path.join(__dirname, 'liveactivity-client.js'), 'utf8')
54
62
  const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
55
63
  const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
@@ -293,7 +301,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
293
301
  if (!fs.existsSync(p)) return {}
294
302
  // A corrupted config must SURFACE, never be silently replaced — returning {}
295
303
  // here would let the next writeRaw wipe the whole file (FR11.1).
296
- try { return JSON.parse(fs.readFileSync(p, 'utf8')) }
304
+ try { return migrateModelSettings(JSON.parse(fs.readFileSync(p, 'utf8'))) }
297
305
  catch (e) { throw new Error(`配置文件损坏(${e.message}),已拒绝读写 — 请先手工修复 ${p}`) }
298
306
  }
299
307
  const writeRaw = (obj) => {
@@ -311,6 +319,71 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
311
319
  // 悄悄换到另一个后端并重置会话(拖一下排序、加一个不相干的模型都会触发)⇒ 那里**只在默认模型
312
320
  // 已不存在**(文件和内置都没有这个名字)时补一个,其余一律不动;隐藏默认模型由写入方直接拒绝。
313
321
  // 返回 { from, to } = 这次自动改了默认模型(调用方据此如实报告),null = 没改。
322
+ // ── Backend · model · depth of one group (group card; the old provider route
323
+ // forwards here). The same change logic as the chat /model command
324
+ // (src/groupbrain.js). Only a backend change resets the session.
325
+ const catalogFor = async (name, prov) => {
326
+ const c = runtimePolicy && runtimePolicy.modelCatalog
327
+ if (!c || !prov) return null
328
+ try { return await c(name, prov) } catch { return null }
329
+ }
330
+ // The target backend of a change as the file says now ({} when unknown).
331
+ const brainTarget = (raw, chatId, change) => {
332
+ const g = raw.groups && raw.groups[chatId]
333
+ if (!g) return {}
334
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
335
+ const home = g.provider || defName
336
+ const target = change.provider || home
337
+ return { g, defName, home, target, tProv: { ...DEFAULTS.providers, ...(raw.providers || {}) }[target] }
338
+ }
339
+ async function changeGroupBrain(chatId, change, by = 'console') {
340
+ // The model list may need a CLI handshake: fetch it first, then read,
341
+ // decide and write with no await in between, so a /model change made
342
+ // meanwhile is never overwritten by a stale copy.
343
+ const first = brainTarget(readRaw(), chatId, change)
344
+ const cat = first.tProv ? await catalogFor(first.target, first.tProv) : null
345
+ const raw = readRaw()
346
+ const { g, defName, home, target, tProv } = brainTarget(raw, chatId, change)
347
+ if (!g) return { status: 404, body: { error: 'no such group' } }
348
+ if (!tProv) return { status: 400, body: { error: `未知模型 '${target}'` } }
349
+ if (target !== first.target) return { status: 409, body: { error: '这个群的后端刚被改过,请刷新后再改' } }
350
+ if (change.provider && change.provider !== home && (tProv.hidden || (runtimePolicy && !runtimePolicy.providerSupported(tProv)))) {
351
+ return { status: 400, body: { error: `「${target}」已隐藏或不受支持,不能选` } }
352
+ }
353
+ const switching = target !== home
354
+ const tBrain = brainMod.groupBrain(g, target, tProv, { defaultProvider: defName })
355
+ let model = change.model, effort = change.effort
356
+ if (model !== undefined && model !== '' && tProv.baseUrl) return { status: 400, body: { error: '这个模型的型号是连接设置,请在「模型」页修改' } }
357
+ if (model !== undefined && model !== '' && !brainMod.isModel(model)) return { status: 400, body: { error: '型号格式不对' } }
358
+ if (effort !== undefined && effort !== '' && !brainMod.isEffort(effort)) return { status: 400, body: { error: '思考深度格式不对' } }
359
+ if (model === tBrain.model) model = undefined
360
+ if (effort === tBrain.effort && model === undefined) effort = undefined
361
+ const f = fillPicks({ switching, model, effort, currentModel: tBrain.model, currentEffort: tBrain.effort, third: !!tProv.baseUrl, cat })
362
+ model = f.model; effort = f.effort
363
+ const nextModel = model === undefined ? tBrain.model : model
364
+ if (effort) {
365
+ const known = cat && Array.isArray(cat.models) ? cat.models.find((m) => m.id === nextModel) : null
366
+ if (known && Array.isArray(known.efforts) && !known.efforts.length) return { status: 400, body: { error: '这个型号不支持调思考深度' } }
367
+ const effs = cat ? effortsFor(cat, nextModel) : []
368
+ if (change.effort !== undefined && effs.length && !effs.includes(effort)) return { status: 400, body: { error: `这个型号没有「${effort}」这档深度(可选:${effs.join(' · ')})` } }
369
+ }
370
+ if (tProv.runner === 'codex' && tProv.contextWindowTokens > 0 && nextModel) {
371
+ const maximum = codexCatalogWindows().get(nextModel)?.maxTokens
372
+ if (maximum && tProv.contextWindowTokens > maximum) return { status: 400, body: { error: `「${target}」的自定义上下文窗口超过这个型号的上限 ${maximum} token,先在「模型」页调小` } }
373
+ }
374
+ const r = applyBrainChange(raw, chatId, { provider: switching ? target : undefined, model, effort })
375
+ if (r.error) return { status: 400, body: { error: r.error } }
376
+ if (!r.changed) return { status: 200, body: { ...(await buildState()), brainChange: { changed: false } } }
377
+ try { writeRaw(raw) } catch (e) {
378
+ const m = (e && e.message) || String(e)
379
+ return { status: 400, body: { error: /settled|unresolved|未结/.test(m) ? '该群有任务进行中或记录未结账,等它结束后再改' : m } }
380
+ }
381
+ audit.append(auditRow(chatId, r, { source: 'console', by }))
382
+ const resets = runtimePolicy && runtimePolicy.appliedResets ? runtimePolicy.appliedResets() : []
383
+ return { status: 200, body: { ...(await buildState()), brainChange: { changed: true, switching: r.switching,
384
+ modelChanged: r.modelChanged, effortChanged: r.effortChanged, filled: f.filled, provider: target,
385
+ model: r.after.model, effort: r.after.effort, reset: resets.includes(chatId) } } }
386
+ }
314
387
  const settleDefault = (raw) => {
315
388
  if (!runtimePolicy) { fixDefault(raw); return null }
316
389
  const current = raw.defaultProvider || DEFAULTS.defaultProvider
@@ -514,7 +587,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
514
587
  toolQuality: p.toolQuality || '',
515
588
  effort: (p.env && p.env.CLAUDE_CODE_EFFORT_LEVEL) || '',
516
589
  reasoningEffort: p.reasoningEffort || '', // codex-only: -c model_reasoning_effort
517
- autocompactTokens: p.autocompactTokens ?? null,
590
+ autocompactTokens: p.autocompactTokens ?? 0,
591
+ reportedContextTokens: reportedWindows.get(name) || null,
592
+ contextWindowTokens: p.runner === 'codex' ? (p.contextWindowTokens || 0) : null,
593
+ contextWindowMax: p.runner === 'codex' ? (codexWindows.get(p.model)?.maxTokens || null) : null,
518
594
  contextWindow: windowForProvider(p, reportedWindows.get(name), codexWindows),
519
595
  vision: p.vision !== false, // false = 纯文本模型 ⇒ 图片走 strip-proxy 降级
520
596
  hidden: !!p.hidden,
@@ -622,6 +698,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
622
698
  health,
623
699
  providers,
624
700
  defaultProvider: raw.defaultProvider || cfg.defaultProvider,
701
+ modelNames: knownModelNames(), // official names registered from the live model lists
625
702
  groups,
626
703
  // 项目根目录:绑群弹窗的路径提示用 projectsRoots(生效值);设置页那张卡用 projectsRootsInfo
627
704
  //(每条根在不在 · 配置原文 · 被忽略的值与原因 · 哪些群在根外)。与围栏同一份判据。
@@ -644,7 +721,6 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
644
721
  permissionMode: cfg.agent.permissionMode,
645
722
  maxConcurrent: cfg.agent.maxConcurrent,
646
723
  maxTurnTimeMins: cfg.agent.maxTurnTimeMins,
647
- autocompactTokens: cfg.agent.autocompactTokens, // 长会话自动压缩阈值(默认 40 万经 config 层合并)
648
724
  ackDelayMs: cfg.agent.ackDelayMs, // 回执等待毫秒(默认 25s 经 config 层合并;界面按秒显示)
649
725
  },
650
726
  proxy: raw.proxy && raw.proxy.http ? { http: raw.proxy.http, noProxy: raw.proxy.noProxy || '' } : null,
@@ -697,6 +773,14 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
697
773
  return state
698
774
  }
699
775
 
776
+ // A card without its own model (subscription) is tried with the catalog's
777
+ // recommendation, the same one a newly bound group starts with.
778
+ async function recommendedModel(name, provider) {
779
+ if (!provider || provider.baseUrl) return undefined
780
+ const cat = await catalogFor(name, provider)
781
+ return (cat && cat.recommended && cat.recommended.model) || undefined
782
+ }
783
+
700
784
  async function testProvider(name, prompt) {
701
785
  const cfg = loadConfig(configPath); applyProxyEnv(cfg)
702
786
  const provider = cfg.providers[name]
@@ -716,7 +800,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
716
800
  bin: cfg.agent.bin,
717
801
  prompt: prompt || verify.TRIAL_PROMPT,
718
802
  provider,
719
- model: provider.model,
803
+ model: provider.model || await recommendedModel(name, provider),
720
804
  permissionMode: cfg.agent.permissionMode,
721
805
  signal: ac.signal, // a one-line reply — needs no tools, so no allow list
722
806
  })
@@ -726,7 +810,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
726
810
  lease?.assertWritable()
727
811
  const ok = !(result && result.isError)
728
812
  verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
729
- return { ok, model: provider.model, reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
813
+ return { ok, model: provider.model || '', reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
730
814
  } catch (e) {
731
815
  closed ||= isClosedRunnerError(e)
732
816
  if (!lease) verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
@@ -871,7 +955,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
871
955
  // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
872
956
  const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
873
957
  const injectTok = (authConfigured() && !localBypass) ? '' : token
874
- return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : '').replace('/*__LIVE_ACTIVITY__*/', () => LIVE_ACTIVITY_JS))))
958
+ return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : '').replace('/*__LIVE_ACTIVITY__*/', () => LIVE_ACTIVITY_JS).replace(MODELNAME_SLOT, () => MODELNAME_SRC))))
875
959
  }
876
960
  // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
877
961
  // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
@@ -973,7 +1057,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
973
1057
  }
974
1058
  // upsert a provider (add or edit — the ⚙ config panel + 添加模型 both use this)
975
1059
  if (p === '/api/providers/add' && req.method === 'POST') {
976
- const { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, autocompactTokens, hidden, vision, modelClear, runner } = await body(req)
1060
+ let { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, autocompactTokens, contextWindowTokens, hidden, vision, modelClear, runner } = await body(req)
977
1061
  if (!name || !/^[\w.-]+$/.test(name)) return sendJSON(res, 400, { error: '名称必填,且只用字母/数字/._-' })
978
1062
  // 持久处理流程只跑 Codex / Claude Code:别的后端名不能被下面的白名单悄悄改写成 Claude 存进去。
979
1063
  if (runtimePolicy && runner !== undefined && runner !== '' && !runtimePolicy.providerSupported({ runner })) {
@@ -984,6 +1068,13 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
984
1068
  const prev = raw.providers[name] ? { model: raw.providers[name].model || '', re: raw.providers[name].reasoningEffort || '' } : null
985
1069
  const pr = { ...(raw.providers[name] || {}) }
986
1070
  if (baseUrl !== undefined) pr.baseUrl = baseUrl || undefined
1071
+ // Durable hosts: model and depth belong to each group (group card / /model).
1072
+ // A subscription card takes neither; a third-party endpoint keeps its
1073
+ // model as connection configuration and takes no depth.
1074
+ if (runtimePolicy) {
1075
+ effort = undefined; reasoningEffort = undefined
1076
+ if (!pr.baseUrl) { model = undefined; modelClear = undefined }
1077
+ }
987
1078
  if (model) pr.model = model
988
1079
  // 「默认(跟随 Claude Code)」= 不指定型号 ⇒ spawn 时不设 ANTHROPIC_MODEL,用 CLI 自己的默认。
989
1080
  // 空 model 与「没传 model」要分开,所以用显式的 modelClear,而不是 `model !== undefined`
@@ -1013,6 +1104,27 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1013
1104
  // 可执行文件(src/agent/runner.js 的派发),存进去一个随便的字符串等于给自己开一道门。
1014
1105
  // 不传 ⇒ 保持原值(编辑已有 codex provider 的常态,别把它降级成 claude 后端)。
1015
1106
  if (runner !== undefined) { if (runner === 'codex') pr.runner = 'codex'; else delete pr.runner }
1107
+ if (contextWindowTokens !== undefined) {
1108
+ if (pr.runner !== 'codex') return sendJSON(res, 400, { error: '上下文窗口设置仅适用于 Codex' })
1109
+ if (!Number.isSafeInteger(contextWindowTokens) || contextWindowTokens < 0 ||
1110
+ (contextWindowTokens > 0 && (contextWindowTokens < 100_000 || contextWindowTokens > 1_050_000))) {
1111
+ return sendJSON(res, 400, { error: '上下文窗口须为 0 或 100000–1050000 之间的整数' })
1112
+ }
1113
+ pr.contextWindowTokens = contextWindowTokens
1114
+ }
1115
+ if (pr.runner !== 'codex') delete pr.contextWindowTokens
1116
+ if (pr.contextWindowTokens > 0) {
1117
+ // Check every model a group runs on this card (and the card's own, if any).
1118
+ const windows = codexCatalogWindows()
1119
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
1120
+ const models = new Set([pr.model, ...Object.entries(raw.groups || {})
1121
+ .filter(([id, g]) => id !== 'oc_example_chat_id' && g && (g.provider || defName) === name)
1122
+ .map(([, g]) => brainMod.groupBrain(g, name, pr, { defaultProvider: defName }).model)].filter(Boolean))
1123
+ for (const m of models) {
1124
+ const maximum = windows.get(m)?.maxTokens
1125
+ if (maximum && pr.contextWindowTokens > maximum) return sendJSON(res, 400, { error: `上下文窗口超过型号 ${m} 的目录上限 ${maximum} token` })
1126
+ }
1127
+ }
1016
1128
  raw.providers[name] = pr
1017
1129
  const defaultChange = settleDefault(raw); writeRaw(raw)
1018
1130
  // 变更联动(仅编辑已有 provider 时):受影响群 = 用着它(显式或经 defaultProvider)
@@ -1209,15 +1321,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1209
1321
  for (const k of ['permissionMode', 'maxConcurrent', 'maxTurnTimeMins']) if (k in patch) raw.agent[k] = patch[k]
1210
1322
  // 机器人自称(顶层 brand):空或默认名就不落盘,和安装向导 --full 的写法一致
1211
1323
  if ('brand' in patch) { const b = String(patch.brand || '').trim().slice(0, 40); if (b && b !== 'SintBot') raw.brand = b; else delete raw.brand }
1212
- // 旧全局压缩阈值:0 = 交给执行器默认,否则限 10万–100万(CLI 旗子的文档区间;区间外它会拒参数,
1213
- // 每轮必死 ⇒ 存前就挡住,别让坏值落盘)。
1214
- if ('autocompactTokens' in patch) {
1215
- const n = Number(patch.autocompactTokens)
1216
- if (!Number.isInteger(n) || n < 0 || (n > 0 && (n < 100_000 || n > 1_000_000))) {
1217
- return sendJSON(res, 400, { error: '压缩阈值须为 0(关闭)或 100000–1000000 之间的整数(token 数)' })
1218
- }
1219
- raw.agent.autocompactTokens = n
1220
- }
1324
+ if ('autocompactTokens' in patch) return sendJSON(res, 400, { error: '请在模型卡片中设置压缩策略' })
1221
1325
  // 回执等待:非负整数毫秒即可,0 = 不发。不设上限 —— 大于单轮超时只是永不触发,无害。
1222
1326
  if ('ackDelayMs' in patch) {
1223
1327
  const n = Number(patch.ackDelayMs)
@@ -1726,6 +1830,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1726
1830
  // Fail here, not at the first task: a binding to a missing
1727
1831
  // directory would only surface when a turn tries to run in it.
1728
1832
  if (!fs.existsSync(workdir)) return sendJSON(res, 400, { error: `workdir 不存在: ${workdir}` })
1833
+ // The model list is fetched before the read below, so no await sits
1834
+ // between reading and writing the file (a /model change made meanwhile
1835
+ // must not be overwritten).
1836
+ let preName = '', preCat = null
1837
+ if (runtimePolicy) {
1838
+ try {
1839
+ const r0 = readRaw()
1840
+ preName = provider || (r0.groups && r0.groups[chatId] && r0.groups[chatId].provider) || r0.defaultProvider || DEFAULTS.defaultProvider
1841
+ const p0 = { ...DEFAULTS.providers, ...(r0.providers || {}) }[preName]
1842
+ if (p0) preCat = await catalogFor(preName, p0)
1843
+ } catch { /* the read below reports a broken file */ }
1844
+ }
1729
1845
  const raw = readRaw(); raw.groups = raw.groups || {}; delete raw.groups.oc_example_chat_id
1730
1846
  // 已绑定的群重存:旧对象在下面整个展开再覆盖(见 next),这里只是取出来比目录有没有改
1731
1847
  const prev = raw.groups[chatId] || {}
@@ -1755,6 +1871,20 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1755
1871
  // ⚠ 不按「那个平台配了凭据没」拒绝:纯配置模式(先绑群、后填凭据)一直是允许的;没配的平台由 doctor 的群行报「收不到消息」
1756
1872
  next.platform = PLATFORMS.includes(platform) ? platform : chatPlatformOf(raw, chatId)
1757
1873
  raw.groups[chatId] = next
1874
+ if (runtimePolicy) {
1875
+ // A new binding or another backend starts with the recommended model
1876
+ // and depth where the group has not chosen (same rule as /model).
1877
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
1878
+ const tName = next.provider || defName
1879
+ const tProv = { ...DEFAULTS.providers, ...(raw.providers || {}) }[tName]
1880
+ if (tProv && (isNewBinding || tName !== (prev.provider || defName))) {
1881
+ brainMod.migrateLegacyModel(next, defName)
1882
+ const b = brainMod.groupBrain(next, tName, tProv, { defaultProvider: defName })
1883
+ const f = fillPicks({ switching: true, currentModel: b.model, currentEffort: b.effort,
1884
+ third: !!tProv.baseUrl, cat: tName === preName ? preCat : null })
1885
+ if (f.model !== undefined || f.effort !== undefined) applyBrainChange(raw, chatId, { model: f.model, effort: f.effort })
1886
+ }
1887
+ }
1758
1888
  writeRaw(raw)
1759
1889
  // First-time binding: drop a confirmation into the group (best-effort) so a
1760
1890
  // fresh chat is immediately alive and members know how to use it.
@@ -2261,6 +2391,19 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2261
2391
  }
2262
2392
  // real-time per-group model switch: persist to config (+ hot-reload) AND set
2263
2393
  // the live runtime override so it takes effect on the very next turn.
2394
+ if (p === '/api/groups/brain' && req.method === 'POST') {
2395
+ const { chatId, provider, model, effort } = await body(req)
2396
+ const pick = (v) => (v === undefined || v === null ? undefined : String(v))
2397
+ const out = await changeGroupBrain(chatId, { provider: pick(provider) || undefined, model: pick(model), effort: pick(effort) })
2398
+ return sendJSON(res, out.status, out.body)
2399
+ }
2400
+ if (p === '/api/groups/provider' && req.method === 'POST' && runtimePolicy) {
2401
+ // Older console pages: same writer as the group card.
2402
+ const { chatId, provider } = await body(req)
2403
+ const out = await changeGroupBrain(chatId, { provider: provider ? String(provider) : undefined })
2404
+ if (out.status === 200 && out.body.brainChange) out.body.switched = { chatId, provider, live: true }
2405
+ return sendJSON(res, out.status, out.body)
2406
+ }
2264
2407
  if (p === '/api/groups/provider' && req.method === 'POST') {
2265
2408
  const { chatId, provider } = await body(req); const raw = readRaw()
2266
2409
  if (!raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
@@ -2337,6 +2480,17 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2337
2480
  }
2338
2481
  // Claude 模型下拉的候选。**未登录不给任何型号**(产品决定:没登录就别列)。
2339
2482
  // 已登录时 = 内置候选 + 本机账号的额外可选项;为什么不是官方实时清单,见 claudemodels.js 头部。
2483
+ // What one backend offers on the group card: models (official names,
2484
+ // supported depths), recommendation. models:null = a third-party
2485
+ // endpoint whose model is set on the model card.
2486
+ if (p === '/api/groups/model-options' && req.method === 'GET') {
2487
+ const name = url.searchParams.get('provider') || ''
2488
+ const raw = readRaw()
2489
+ const prov = { ...DEFAULTS.providers, ...(raw.providers || {}) }[name]
2490
+ if (!prov) return sendJSON(res, 404, { error: `未知模型 '${name}'` })
2491
+ const cat = await catalogFor(name, prov)
2492
+ return sendJSON(res, 200, cat || { ok: false, models: prov.baseUrl ? null : [], efforts: [], recommended: { model: '', effort: '' } })
2493
+ }
2340
2494
  if (p === '/api/claude/models' && req.method === 'GET') {
2341
2495
  const cfg = loadConfig(configPath)
2342
2496
  const login = await claudestatus.cachedLoginStatus(cfg.agent.bin)
@@ -2389,7 +2543,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2389
2543
  bin: cfg.agent.bin,
2390
2544
  prompt,
2391
2545
  provider,
2392
- model: provider.model,
2546
+ model: (pgGroup && brainMod.groupBrain(pgGroup, provName, provider, { defaultProvider: cfg.defaultProvider }).model) || provider.model || await recommendedModel(provName, provider),
2547
+ effort: (pgGroup && brainMod.groupBrain(pgGroup, provName, provider, { defaultProvider: cfg.defaultProvider }).effort) || '',
2393
2548
  workdir: workdir || undefined,
2394
2549
  sessionId: sessionId || undefined,
2395
2550
  permissionMode: (pgGroup && pgGroup.permissionMode) || 'dontAsk', // 试跑继承匹配群的模式(无全局默认,unset ⇒ dontAsk)
@@ -1,13 +0,0 @@
1
- 'use strict'
2
-
3
- // Configuration projection shared by chat, cards and the console. A configured
4
- // model is not proof of what an executor ultimately selected or fell back to.
5
- function modelContext(group = {}, provider = {}) {
6
- group ||= {}; provider ||= {}
7
- const environment = provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL
8
- const model = group.model || provider.model || environment || ''
9
- return { model, source: group.model ? 'group' : provider.model ? 'provider' : environment ? 'environment' : 'default',
10
- actualModel: null }
11
- }
12
-
13
- module.exports = { modelContext }