sintbot 1.4.3 → 1.4.5

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.
@@ -303,6 +303,25 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
303
303
  if (runtimePolicy) runtimePolicy.applyConfig(obj)
304
304
  else if (onConfigChange) { try { onConfigChange() } catch (e) { log('reload hook error: ' + (e && e.message)) } }
305
305
  }
306
+ // 默认模型的兜底。旧守护进程:默认模型隐藏了 / 在本进程看来没配好(如 key 是读不到的 env:X)/
307
+ // 文件里没写(跟随内置的 claude),都改成第一个可用的。持久处理流程里这会把跟随默认模型的群
308
+ // 悄悄换到另一个后端并重置会话(拖一下排序、加一个不相干的模型都会触发)⇒ 那里**只在默认模型
309
+ // 已不存在**(文件和内置都没有这个名字)时补一个,其余一律不动;隐藏默认模型由写入方直接拒绝。
310
+ // 返回 { from, to } = 这次自动改了默认模型(调用方据此如实报告),null = 没改。
311
+ const settleDefault = (raw) => {
312
+ if (!runtimePolicy) { fixDefault(raw); return null }
313
+ const current = raw.defaultProvider || DEFAULTS.defaultProvider
314
+ if ({ ...DEFAULTS.providers, ...(raw.providers || {}) }[current]) return null
315
+ const next = pickDefault(raw.providers || {})
316
+ if (!next || next === raw.defaultProvider) return null
317
+ const from = raw.defaultProvider || null
318
+ raw.defaultProvider = next
319
+ return { from, to: next }
320
+ }
321
+ // 持久处理流程下模型库写入的如实回执:这次写入重置了几个群的会话、默认模型有没有被自动补上。
322
+ const runtimeLibraryChange = (defaultChange) => runtimePolicy ? {
323
+ resetGroups: runtimePolicy.appliedResets ? runtimePolicy.appliedResets().length : 0,
324
+ ...(defaultChange ? { defaultProvider: defaultChange.to } : {}) } : null
306
325
  // 群内通知统一出口:暂停群不打扰(暂停语义=完全静默,配置变更通知也不例外 —— 2026-07-22
307
326
  // 真机踩过:暂停群仍收到「模型已更新」;operator 在控制台自己改的,群成员恢复后清单会重注入,
308
327
  // 静默无损)。唯 force:true 豁免 —— 暂停/恢复动作本身的那条告知必须送达。
@@ -344,6 +363,15 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
344
363
  } catch { /* 回填是锦上添花,读不了就空着 */ }
345
364
  const inboxDismiss = dashboard.createDismissStore({ file: inboxDismissPath || path.join(os.homedir(), '.sbot', 'inbox-dismissed.json'), log })
346
365
  let lastHealth = null // 最近一次 buildState 的健康检查,给只算收件箱的 /api/inbox 用(那里不重跑探针)
366
+ // 升级排队时还有任务、排队、未结或暂停的群(只给名字,升级弹窗据此说明在等谁)
367
+ const upgradeWaitingGroups = () => {
368
+ let live = null, groupsRaw = {}
369
+ try { live = getLiveState ? getLiveState() : null } catch { return [] }
370
+ try { groupsRaw = readRaw().groups || {} } catch {}
371
+ return Object.entries((live && live.groups) || {})
372
+ .filter(([, g]) => g && (g.busy || g.finalizing || g.waitingForCapacity || g.queue > 0 || g.pending > 0 || g.held))
373
+ .map(([chatId, g]) => ({ name: (groupsRaw[chatId] && groupsRaw[chatId].name) || chatId.slice(-8), held: !!g.held }))
374
+ }
347
375
  // 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
348
376
  const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
349
377
  try {
@@ -496,15 +524,19 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
496
524
  const hasCodex = providers.some((p) => !p.hidden && p.runner === 'codex')
497
525
  const cv = hasCodex ? await probeCodexCached() : null
498
526
  // 「可用模型」= 能用的,不只是配了的(1.0.3;此前没登录的 Claude 订阅、试跑失败的 key 都算在内)。
499
- // key 型看最近一次验证:失败的不算,还没验证的先算上;Claude 订阅与 codex 看本机登录态,探不出来(没装 / 超时)不扣。
500
- // 两个登录探测都带缓存(claude 60s · codex 60s),概览每分钟刷新也不会多 spawn。
527
+ // key 型看最近一次验证:失败的不算,还没验证的先算上;Claude 订阅看本机登录态,探不出来(没装 / 超时)不扣。
528
+ // codex 用服务端判据(与 doctor、卡片「验证登录」同一个探针,按 bin 共用一份缓存):`codex login status`
529
+ // 只读本地文件,令牌被吊销后照样「已登录」。只有服务端明确拒绝(dead)才扣;探不到(没装 / 超时 / 连不上)不扣。
530
+ // ⚠ 概览**绝不等**这个探针(起进程出网,冷缓存 2s、连不上 12s):peekAuthProbe 只读 10 分钟内的缓存,
531
+ // 没有就按「未验证」算(不扣)并在后台刷新一次,下一次重建就是真结果。claude 那条是 60s 缓存的本地调用,照旧等。
501
532
  const live = providers.filter((p) => !p.hidden && p.configured)
502
533
  const subLogin = live.some((p) => !p.baseUrl && p.runner !== 'codex') ? await claudestatus.cachedLoginStatus(cfg.agent.bin).catch(() => null) : null
503
- const cxLogin = live.some((p) => p.runner === 'codex') ? await codexstatus.cachedLoginStatus('codex').catch(() => null) : null
534
+ const cxLogin = live.some((p) => p.runner === 'codex') ? codexstatus.peekAuthProbe('codex', 10 * 60_000) : null
504
535
  const loggedOut = (st) => !!(st && st.ok && st.loggedIn === false)
505
536
  const unusable = (p) => {
506
537
  if (p.baseUrl) { const v = verify.lookup(verify.providerFp(cfg.providers[p.name] || {}), vopts); return !!(v && !v.ok) }
507
- return loggedOut(p.runner === 'codex' ? cxLogin : subLogin)
538
+ if (p.runner === 'codex') return !!(cxLogin && cxLogin.dead)
539
+ return loggedOut(subLogin)
508
540
  }
509
541
  const badProv = live.filter(unusable).map((p) => p.name)
510
542
  const usableProv = live.length - badProv.length
@@ -516,7 +548,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
516
548
  ...(v ? [{ status: v.ok ? 'ok' : 'fail', label: 'Claude CLI', detail: v.ok ? v.version : v.error }] : []),
517
549
  ...(cv ? [{ status: cv.ok ? 'ok' : 'fail', label: 'Codex CLI', detail: cv.ok ? `${cv.version} · GPT 订阅` : cv.error }] : []),
518
550
  { status: !usableProv ? 'fail' : badProv.length ? 'warn' : 'ok', label: '可用模型',
519
- detail: (usableProv ? `${usableProv} 个能用${hasCodex ? '(含 Codex)' : ''}` : '无可用模型 — 去「模型 · 库」页添加 / 测试')
551
+ detail: (usableProv ? `${usableProv} 个能用${live.some((p) => p.runner === 'codex' && !badProv.includes(p.name)) ? '(含 Codex)' : ''}` : '无可用模型 — 去「模型 · 库」页添加 / 测试')
520
552
  + (badProv.length ? `;${badProv.join('、')} 没登录或验证失败` : '') },
521
553
  { status: usableGroups ? 'ok' : 'warn', label: '可用群 / 项目', detail: usableGroups ? `${usableGroups} 个已绑定` : '无绑定群/项目 — 去「群·项目」页创建' },
522
554
  ...connRows,
@@ -839,6 +871,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
839
871
  if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
840
872
  // 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
841
873
  if (p === '/api/changelog-en' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelogEn() })
874
+ // 升级进度:控制台的升级弹窗在升级期间每一两秒读一次。只读,只给升级状态和还有任务的群名,
875
+ // 不重算整份 state(那里有健康探针);服务重启的那几秒连不上,由页面当作「正在重启」处理。
876
+ if (p === '/api/update' && req.method === 'GET') {
877
+ if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
878
+ return sendJSON(res, 200, { ...updater.status(), waitingGroups: upgradeWaitingGroups() })
879
+ }
842
880
  if (p === '/api/update/check' && req.method === 'POST') {
843
881
  if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
844
882
  return sendJSON(res, 200, await updater.check())
@@ -900,6 +938,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
900
938
  if (p === '/api/providers/add' && req.method === 'POST') {
901
939
  const { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, hidden, vision, modelClear, runner } = await body(req)
902
940
  if (!name || !/^[\w.-]+$/.test(name)) return sendJSON(res, 400, { error: '名称必填,且只用字母/数字/._-' })
941
+ // 持久处理流程只跑 Codex / Claude Code:别的后端名不能被下面的白名单悄悄改写成 Claude 存进去。
942
+ if (runtimePolicy && runner !== undefined && runner !== '' && !runtimePolicy.providerSupported({ runner })) {
943
+ return sendJSON(res, 400, { error: '当前只能添加或修改 Codex 或 Claude Code 模型' })
944
+ }
903
945
  const raw = readRaw(); raw.providers = raw.providers || {}
904
946
  // 编辑前快照:model / 推理强度变了要联动受影响群(通知 + model 变更重置会话)
905
947
  const prev = raw.providers[name] ? { model: raw.providers[name].model || '', re: raw.providers[name].reasoningEffort || '' } : null
@@ -928,7 +970,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
928
970
  // 不传 ⇒ 保持原值(编辑已有 codex provider 的常态,别把它降级成 claude 后端)。
929
971
  if (runner !== undefined) { if (runner === 'codex') pr.runner = 'codex'; else delete pr.runner }
930
972
  raw.providers[name] = pr
931
- fixDefault(raw); writeRaw(raw)
973
+ const defaultChange = settleDefault(raw); writeRaw(raw)
932
974
  // 变更联动(仅编辑已有 provider 时):受影响群 = 用着它(显式或经 defaultProvider)
933
975
  // 且无 per-group model 覆盖的群。model 变 = 换脑 → 按「切模型即重置」的既有拍板,
934
976
  // 重置这些群会话 + 群内一行通知;仅推理强度变 → 通知不重置。daemon 未运行(无
@@ -952,7 +994,9 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
952
994
  } catch (e) { log('providerChange notify: ' + (e && e.message)) }
953
995
  }
954
996
  }
955
- return sendJSON(res, 200, { ...(await buildState()), providerChange: { modelChanged: !!modelChanged, reChanged: !!reChanged, affected: affected.length, live } })
997
+ // 持久处理流程:会话重置由配置写入方按「谁实际在用、型号/后端/端点是否变了」决定并已完成,
998
+ // 这里如实报它重置了几个群、默认模型有没有被补上(上面的 affected 是旧守护进程的通知口径)。
999
+ return sendJSON(res, 200, { ...(await buildState()), providerChange: { modelChanged: !!modelChanged, reChanged: !!reChanged, affected: affected.length, live, ...(runtimeLibraryChange(defaultChange) || {}) } })
956
1000
  }
957
1001
  if (p === '/api/providers' && req.method === 'DELETE') {
958
1002
  const name = url.searchParams.get('name'); const raw = readRaw()
@@ -965,13 +1009,17 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
965
1009
  return sendJSON(res, 400, { error: `「${name}」是内置默认模型,移不掉 —— 不想让它出现在列表里就用「隐藏」` })
966
1010
  }
967
1011
  if (raw.providers) delete raw.providers[name]
968
- fixDefault(raw); writeRaw(raw); return sendJSON(res, 200, await buildState())
1012
+ const defaultChange = settleDefault(raw); writeRaw(raw)
1013
+ const change = runtimeLibraryChange(defaultChange)
1014
+ return sendJSON(res, 200, { ...(await buildState()), ...(change ? { providerChange: change } : {}) })
969
1015
  }
970
1016
  if (p === '/api/providers/hidden' && req.method === 'POST') {
971
1017
  const { name, hidden } = await body(req); const raw = readRaw()
972
1018
  if (!raw.providers || !raw.providers[name]) return sendJSON(res, 404, { error: 'no such provider' })
973
1019
  if (hidden) raw.providers[name].hidden = true; else delete raw.providers[name].hidden
974
- fixDefault(raw); writeRaw(raw); return sendJSON(res, 200, await buildState())
1020
+ const defaultChange = settleDefault(raw); writeRaw(raw)
1021
+ const change = runtimeLibraryChange(defaultChange)
1022
+ return sendJSON(res, 200, { ...(await buildState()), ...(change ? { providerChange: change } : {}) })
975
1023
  }
976
1024
  if (p === '/api/providers/order' && req.method === 'POST') {
977
1025
  const { order } = await body(req); const raw = readRaw()
@@ -980,8 +1028,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
980
1028
  for (const n of order) if (raw.providers[n]) next[n] = raw.providers[n]
981
1029
  for (const [n, v] of Object.entries(raw.providers)) if (!(n in next)) next[n] = v // keep any not listed
982
1030
  raw.providers = next
983
- fixDefault(raw) // ordering doesn't set default; just keep the invisible fallback valid
1031
+ // ordering doesn't set default; just keep the invisible fallback valid
1032
+ // (durable mode: only when the default no longer exists — see settleDefault)
1033
+ const defaultChange = settleDefault(raw)
984
1034
  writeRaw(raw)
1035
+ const change = runtimeLibraryChange(defaultChange)
1036
+ if (change) return sendJSON(res, 200, { ...(await buildState()), providerChange: change })
985
1037
  }
986
1038
  return sendJSON(res, 200, await buildState())
987
1039
  }
@@ -2235,19 +2287,22 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2235
2287
  }
2236
2288
  return sendJSON(res, 200, { ...claudemodels.listModels({ loggedIn: !!(login && login.ok && login.loggedIn) }), login })
2237
2289
  }
2238
- // codex 卡片「验证登录」:确认订阅登录态(codex login status)+ 取订阅用量
2239
- // (app-server account/rateLimits/read)。都无需 API key;用量缓存 60s(app-server 有 spawn 开销)。
2290
+ // codex 卡片「验证登录」:带令牌问一次服务端(app-server account/rateLimits/read),登录态 + 订阅用量
2291
+ // 一次拿到。无需 API key;探针缓存 60s(app-server 有 spawn 开销)。
2240
2292
  if (p === '/api/codex/verify' && req.method === 'POST') {
2241
2293
  // force = 卡片额度行的 ↻:点按钮就是要此刻的数,绕过下面的 60s 去抖
2242
2294
  // (去抖是给开局自动探测用的 —— app-server spawn 不便宜,但手点一次的开销该认)。
2243
2295
  const { force } = await body(req).catch(() => ({}))
2244
- const login = await codexstatus.loginStatus('codex')
2245
- let usage = null
2246
- if (login.loggedIn) {
2247
- // reuse a fresh (<60s) usage probe if we have one — the app-server spawn isn't free
2248
- if (!force && codexStatusCache && codexStatusCache.usage && Date.now() - codexStatusCache.at < 60_000) usage = codexStatusCache.usage
2249
- else usage = await codexstatus.rateLimits('codex')
2250
- }
2296
+ // 登录态与额度合成**一次**带令牌的服务端询问(额度接口)。此前先 `codex login status` 再取额度 ——
2297
+ // 前者只读本地文件,令牌被吊销后照样「已登录」,卡片一直绿着。现在服务端明确拒绝 ⇒ dead:true、loggedIn:false;
2298
+ // 探不到(超时)⇒ verified:false,保留上一份额度、文案「未验证」,不当成未登录。
2299
+ if (force) codexstatus.clearCache()
2300
+ const probe = await codexstatus.cachedAuthProbe('codex')
2301
+ const file = codexstatus.readAuthFile()
2302
+ const d = codexstatus.describeLogin(probe, file)
2303
+ const login = { ok: true, verified: !!probe.verified, loggedIn: !!(probe.ok || (!probe.verified && file.exists && !file.expired)), dead: !!probe.dead, mode: file.mode || 'chatgpt', level: d.level, text: d.text }
2304
+ let usage = probe.usage || null
2305
+ if (!usage && !probe.dead && codexStatusCache && codexStatusCache.usage) usage = codexStatusCache.usage // 探不到时保住旧额度(同前端那条「同一份状态两个消费者」的教训)
2251
2306
  codexStatusCache = { at: Date.now(), login, usage } // cached into /api/state so the card survives a page reload
2252
2307
  return sendJSON(res, 200, { login, usage })
2253
2308
  }