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.
@@ -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(纯逻辑)
@@ -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
@@ -625,6 +698,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
625
698
  health,
626
699
  providers,
627
700
  defaultProvider: raw.defaultProvider || cfg.defaultProvider,
701
+ modelNames: knownModelNames(), // official names registered from the live model lists
628
702
  groups,
629
703
  // 项目根目录:绑群弹窗的路径提示用 projectsRoots(生效值);设置页那张卡用 projectsRootsInfo
630
704
  //(每条根在不在 · 配置原文 · 被忽略的值与原因 · 哪些群在根外)。与围栏同一份判据。
@@ -699,6 +773,14 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
699
773
  return state
700
774
  }
701
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
+
702
784
  async function testProvider(name, prompt) {
703
785
  const cfg = loadConfig(configPath); applyProxyEnv(cfg)
704
786
  const provider = cfg.providers[name]
@@ -718,7 +800,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
718
800
  bin: cfg.agent.bin,
719
801
  prompt: prompt || verify.TRIAL_PROMPT,
720
802
  provider,
721
- model: provider.model,
803
+ model: provider.model || await recommendedModel(name, provider),
722
804
  permissionMode: cfg.agent.permissionMode,
723
805
  signal: ac.signal, // a one-line reply — needs no tools, so no allow list
724
806
  })
@@ -728,7 +810,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
728
810
  lease?.assertWritable()
729
811
  const ok = !(result && result.isError)
730
812
  verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
731
- 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 }
732
814
  } catch (e) {
733
815
  closed ||= isClosedRunnerError(e)
734
816
  if (!lease) verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
@@ -873,7 +955,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
873
955
  // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
874
956
  const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
875
957
  const injectTok = (authConfigured() && !localBypass) ? '' : token
876
- 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))))
877
959
  }
878
960
  // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
879
961
  // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
@@ -975,7 +1057,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
975
1057
  }
976
1058
  // upsert a provider (add or edit — the ⚙ config panel + 添加模型 both use this)
977
1059
  if (p === '/api/providers/add' && req.method === 'POST') {
978
- const { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, autocompactTokens, contextWindowTokens, 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)
979
1061
  if (!name || !/^[\w.-]+$/.test(name)) return sendJSON(res, 400, { error: '名称必填,且只用字母/数字/._-' })
980
1062
  // 持久处理流程只跑 Codex / Claude Code:别的后端名不能被下面的白名单悄悄改写成 Claude 存进去。
981
1063
  if (runtimePolicy && runner !== undefined && runner !== '' && !runtimePolicy.providerSupported({ runner })) {
@@ -986,6 +1068,13 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
986
1068
  const prev = raw.providers[name] ? { model: raw.providers[name].model || '', re: raw.providers[name].reasoningEffort || '' } : null
987
1069
  const pr = { ...(raw.providers[name] || {}) }
988
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
+ }
989
1078
  if (model) pr.model = model
990
1079
  // 「默认(跟随 Claude Code)」= 不指定型号 ⇒ spawn 时不设 ANTHROPIC_MODEL,用 CLI 自己的默认。
991
1080
  // 空 model 与「没传 model」要分开,所以用显式的 modelClear,而不是 `model !== undefined`
@@ -1025,8 +1114,16 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1025
1114
  }
1026
1115
  if (pr.runner !== 'codex') delete pr.contextWindowTokens
1027
1116
  if (pr.contextWindowTokens > 0) {
1028
- const maximum = codexCatalogWindows().get(pr.model)?.maxTokens
1029
- if (maximum && pr.contextWindowTokens > maximum) return sendJSON(res, 400, { error: `上下文窗口超过当前模型目录上限 ${maximum} token` })
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
+ }
1030
1127
  }
1031
1128
  raw.providers[name] = pr
1032
1129
  const defaultChange = settleDefault(raw); writeRaw(raw)
@@ -1733,6 +1830,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1733
1830
  // Fail here, not at the first task: a binding to a missing
1734
1831
  // directory would only surface when a turn tries to run in it.
1735
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
+ }
1736
1845
  const raw = readRaw(); raw.groups = raw.groups || {}; delete raw.groups.oc_example_chat_id
1737
1846
  // 已绑定的群重存:旧对象在下面整个展开再覆盖(见 next),这里只是取出来比目录有没有改
1738
1847
  const prev = raw.groups[chatId] || {}
@@ -1762,6 +1871,20 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1762
1871
  // ⚠ 不按「那个平台配了凭据没」拒绝:纯配置模式(先绑群、后填凭据)一直是允许的;没配的平台由 doctor 的群行报「收不到消息」
1763
1872
  next.platform = PLATFORMS.includes(platform) ? platform : chatPlatformOf(raw, chatId)
1764
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
+ }
1765
1888
  writeRaw(raw)
1766
1889
  // First-time binding: drop a confirmation into the group (best-effort) so a
1767
1890
  // fresh chat is immediately alive and members know how to use it.
@@ -2268,6 +2391,19 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2268
2391
  }
2269
2392
  // real-time per-group model switch: persist to config (+ hot-reload) AND set
2270
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
+ }
2271
2407
  if (p === '/api/groups/provider' && req.method === 'POST') {
2272
2408
  const { chatId, provider } = await body(req); const raw = readRaw()
2273
2409
  if (!raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
@@ -2344,6 +2480,17 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2344
2480
  }
2345
2481
  // Claude 模型下拉的候选。**未登录不给任何型号**(产品决定:没登录就别列)。
2346
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
+ }
2347
2494
  if (p === '/api/claude/models' && req.method === 'GET') {
2348
2495
  const cfg = loadConfig(configPath)
2349
2496
  const login = await claudestatus.cachedLoginStatus(cfg.agent.bin)
@@ -2396,7 +2543,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2396
2543
  bin: cfg.agent.bin,
2397
2544
  prompt,
2398
2545
  provider,
2399
- 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) || '',
2400
2548
  workdir: workdir || undefined,
2401
2549
  sessionId: sessionId || undefined,
2402
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 }