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,135 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The single change logic for a group's backend · model · thinking depth.
|
|
3
|
+
// The console group card, the chat /model command and the Feishu model card
|
|
4
|
+
// all call applyBrainChange, so "did the backend change / only the model /
|
|
5
|
+
// only the depth / nothing" is decided in one place.
|
|
6
|
+
//
|
|
7
|
+
// Pure: mutates the given raw configuration object; the caller writes it
|
|
8
|
+
// through the configuration writer (which validates, resets sessions when the
|
|
9
|
+
// backend changes, replaces the file atomically and applies it).
|
|
10
|
+
|
|
11
|
+
const brainMod = require('./core/brain')
|
|
12
|
+
const { DEFAULTS } = require('./config')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param raw configuration object (mutated)
|
|
16
|
+
* @param chatId
|
|
17
|
+
* @param change { provider?, model?, effort? }
|
|
18
|
+
* provider: switch backend (omitted = keep); model / effort: string choice
|
|
19
|
+
* ('' = clear), omitted = keep. Choices go to the target backend's entry.
|
|
20
|
+
* @returns { error } | { before, after, switching, target, home, modelChanged, effortChanged, changed }
|
|
21
|
+
* error ∈ 'no-group' | 'unknown-provider' | 'bad-model' | 'bad-effort'
|
|
22
|
+
*/
|
|
23
|
+
function applyBrainChange(raw, chatId, change = {}) {
|
|
24
|
+
const { provider, model, effort } = change
|
|
25
|
+
const g = raw && raw.groups && raw.groups[chatId]
|
|
26
|
+
if (!g) return { error: 'no-group' }
|
|
27
|
+
if (provider !== undefined && !(raw.providers || {})[provider]) return { error: 'unknown-provider' }
|
|
28
|
+
if (model !== undefined && model !== '' && !brainMod.isModel(model)) return { error: 'bad-model' }
|
|
29
|
+
if (effort !== undefined && effort !== '' && !brainMod.isEffort(effort)) return { error: 'bad-effort' }
|
|
30
|
+
const defName = raw.defaultProvider || DEFAULTS.defaultProvider
|
|
31
|
+
const provAll = { ...DEFAULTS.providers, ...(raw.providers || {}) }
|
|
32
|
+
const home = g.provider || defName
|
|
33
|
+
const before = brainMod.groupBrain(g, home, provAll[home], { defaultProvider: defName })
|
|
34
|
+
// Legacy group.model first moves under its own backend, then the backend may change.
|
|
35
|
+
brainMod.migrateLegacyModel(g, defName)
|
|
36
|
+
const switching = provider !== undefined && provider !== home
|
|
37
|
+
const target = provider || home
|
|
38
|
+
if (model !== undefined || effort !== undefined) {
|
|
39
|
+
const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
|
|
40
|
+
const cur = { ...(brainMod.cleanPref(prefs[target]) || {}) }
|
|
41
|
+
if (model !== undefined) { if (model) cur.model = model; else delete cur.model }
|
|
42
|
+
if (effort !== undefined) { if (effort) cur.effort = effort; else delete cur.effort }
|
|
43
|
+
if (Object.keys(cur).length) prefs[target] = cur; else delete prefs[target]
|
|
44
|
+
g.providerPrefs = prefs
|
|
45
|
+
}
|
|
46
|
+
const np = brainMod.normalizePrefs(g.providerPrefs)
|
|
47
|
+
if (np) g.providerPrefs = np; else delete g.providerPrefs
|
|
48
|
+
if (switching) g.provider = provider
|
|
49
|
+
const after = brainMod.groupBrain(g, target, provAll[target], { defaultProvider: defName })
|
|
50
|
+
const modelChanged = !switching && before.model !== after.model
|
|
51
|
+
const effortChanged = !switching && before.effort !== after.effort
|
|
52
|
+
return { before, after, switching, target, home, modelChanged, effortChanged,
|
|
53
|
+
changed: switching || modelChanged || effortChanged }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// One audit row (console, chat command and card share the shape; source tells which).
|
|
57
|
+
function auditRow(chatId, r, { source, by }) {
|
|
58
|
+
const pick = (b) => ({ model: b.model || '', effort: b.effort || '' })
|
|
59
|
+
return { kind: 'group-model', chatId, source, by: by || '', from: { provider: r.home, ...pick(r.before) },
|
|
60
|
+
to: { provider: r.target, ...pick(r.after) }, switching: r.switching, modelChanged: r.modelChanged,
|
|
61
|
+
effortChanged: r.effortChanged, ts: Date.now() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Card-level values that move into groups: subscription cards give up model
|
|
65
|
+
// and depth; a third-party endpoint keeps its model (connection setting) and
|
|
66
|
+
// gives up depth only.
|
|
67
|
+
function movable(p) {
|
|
68
|
+
return { model: p && !p.baseUrl ? brainMod.defaultModel(p) : '', effort: p ? brainMod.defaultEffort(p) : '' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Migration step one (additive, before the service starts): every group gets,
|
|
73
|
+
* on every visible card (and its current card even when hidden), the card's
|
|
74
|
+
* current default for any item it has not chosen. Card values stay in place,
|
|
75
|
+
* so an older version that ignores providerPrefs behaves exactly as before.
|
|
76
|
+
* Idempotent. Mutates raw. @returns {{ changed, groups: string[] }}
|
|
77
|
+
*/
|
|
78
|
+
function fillGroupPicks(raw) {
|
|
79
|
+
const out = { changed: false, groups: [] }
|
|
80
|
+
if (!raw || typeof raw !== 'object') return out
|
|
81
|
+
const provs = (raw.providers && typeof raw.providers === 'object') ? raw.providers : {}
|
|
82
|
+
const defName = raw.defaultProvider || DEFAULTS.defaultProvider
|
|
83
|
+
const provAll = { ...DEFAULTS.providers, ...provs }
|
|
84
|
+
const withDefaults = Object.keys(provs).filter((n) => { const d = movable(provs[n]); return !!(d.model || d.effort) })
|
|
85
|
+
for (const [id, g] of Object.entries(raw.groups || {})) {
|
|
86
|
+
if (!g || typeof g !== 'object' || Array.isArray(g) || id === 'oc_example_chat_id') continue
|
|
87
|
+
let touched = brainMod.migrateLegacyModel(g, defName)
|
|
88
|
+
if (withDefaults.length) {
|
|
89
|
+
const home = g.provider || defName
|
|
90
|
+
const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
|
|
91
|
+
const names = new Set([home, ...Object.keys(prefs)])
|
|
92
|
+
for (const n of Object.keys(provAll)) if (provAll[n] && !provAll[n].hidden) names.add(n)
|
|
93
|
+
for (const n of names) {
|
|
94
|
+
if (!withDefaults.includes(n)) continue
|
|
95
|
+
const d = movable(provs[n])
|
|
96
|
+
const cur = { ...(brainMod.cleanPref(prefs[n]) || {}) }
|
|
97
|
+
if (!cur.model && d.model) { cur.model = d.model; touched = true }
|
|
98
|
+
if (!cur.effort && d.effort) { cur.effort = d.effort; touched = true }
|
|
99
|
+
if (Object.keys(cur).length) prefs[n] = cur
|
|
100
|
+
}
|
|
101
|
+
const np = brainMod.normalizePrefs(prefs)
|
|
102
|
+
if (np) g.providerPrefs = np; else delete g.providerPrefs
|
|
103
|
+
}
|
|
104
|
+
if (touched) out.groups.push(id)
|
|
105
|
+
}
|
|
106
|
+
out.changed = out.groups.length > 0
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Migration step two (after every group connected): remove the card-level
|
|
112
|
+
* values that step one copied into the groups. Effective values do not change
|
|
113
|
+
* for any group whose picks were filled. Idempotent. Mutates raw.
|
|
114
|
+
* @returns {{ changed, providers: string[] }}
|
|
115
|
+
*/
|
|
116
|
+
function stripCardDefaults(raw) {
|
|
117
|
+
const out = { changed: false, providers: [] }
|
|
118
|
+
const provs = raw && raw.providers && typeof raw.providers === 'object' ? raw.providers : {}
|
|
119
|
+
for (const [n, p] of Object.entries(provs)) {
|
|
120
|
+
if (!p || typeof p !== 'object') continue
|
|
121
|
+
const d = movable(p)
|
|
122
|
+
if (!d.model && !d.effort) continue
|
|
123
|
+
if (!p.baseUrl) {
|
|
124
|
+
delete p.model
|
|
125
|
+
if (p.runner !== 'codex' && p.env) delete p.env.ANTHROPIC_MODEL
|
|
126
|
+
}
|
|
127
|
+
delete p.reasoningEffort
|
|
128
|
+
if (p.env) { delete p.env.CLAUDE_CODE_EFFORT_LEVEL; if (!Object.keys(p.env).length) delete p.env }
|
|
129
|
+
out.providers.push(n)
|
|
130
|
+
}
|
|
131
|
+
out.changed = out.providers.length > 0
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { applyBrainChange, auditRow, fillGroupPicks, stripCardDefaults }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 「这张模型卡能选什么」(;两家都改成实时清单)。bin 注入给 engine.modelCatalog,控制台同一份;core 只认返回形状:
|
|
3
|
+
// { ok, models: [{ id, name?, efforts?, defEffort? }] | null, aliases: [], aliasOf?: { 别名: 具体型号 }, efforts: [],
|
|
4
|
+
// recommended: { model, effort }, source: 'live' | 'saved' | 'builtin' }
|
|
5
|
+
// models === null ⇒ 这家没有可选清单(第三方端点):群里只能调深度,型号到后台改 —— 打错一个字下一轮就报错,
|
|
6
|
+
// 比让人在群里瞎试更稳。ok === false ⇒ 有清单但这会儿取不到(codex 探测失败、也没存过):型号这一项整条拒绝,不猜。
|
|
7
|
+
//
|
|
8
|
+
// 清单与名称一律取两家 CLI 的**实时数据**:
|
|
9
|
+
// Codex = app-server model/list(官方显示名 GPT-6.1-Sol、★ 默认型号、各型号支持与默认的深度);
|
|
10
|
+
// Claude 订阅 = Claude Code 初始化握手报的 /model 选单(claudemodels.probeModels:官方显示名、各型号支持的深度、
|
|
11
|
+
// default 指向的推荐型号;别名 opus / sonnet … 打进来存成它此刻指向的具体型号)。
|
|
12
|
+
// 都是 10 分钟缓存、过期先给旧的后台再刷(模型卡按钮回调要 3 秒内回包,现场探测要好几秒);每次拿到就存盘(cacheFile),
|
|
13
|
+
// 重启后 / 探测失败时先用存盘的(source 'saved')—— 不再退回写死在代码里、早已过时的候选。Claude 从没拿到过才用内置候选。
|
|
14
|
+
// 拿到的显示名登记进 core/modelname.js(群里回执、卡片、控制台都按官方名称显示)。
|
|
15
|
+
// recommended(没有「模型卡默认」):新绑的群、换到没选过的后端、模型卡「测试」预填 / 兜底用的推荐项 ——
|
|
16
|
+
// Codex = ★ 型号 + 它自带的默认深度;Claude 订阅 = 握手里 default 指向的型号 + xhigh(Claude Code 自己的默认档);
|
|
17
|
+
// 第三方 = 型号由模型卡定(空)+ xhigh。只用于预填与迁移,运行时不拿它悄悄兜底。
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
const { recommendEffort } = require('./core/modelcmd') // 推荐深度的口径只此一份(engine 的 /model 与模型卡同用)
|
|
22
|
+
const { setModelNames } = require('./core/modelname')
|
|
23
|
+
|
|
24
|
+
const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] // Claude Code 的五档(code.claude.com model-config)
|
|
25
|
+
// Codex 服务端真正接受的档(与控制台 RE_LEGAL 同一份口径):model/list 会过报(把 ultra 列进去),先过一道
|
|
26
|
+
const CODEX_LEGAL = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
27
|
+
const CLAUDE_ALIASES = ['opus', 'sonnet', 'haiku'] // 只在拿不到实时清单、退回内置候选时用(原样交给 CLI)
|
|
28
|
+
const CLAUDE_EFFORT = 'xhigh'
|
|
29
|
+
const TTL = 600_000
|
|
30
|
+
|
|
31
|
+
function createModelCatalog({ codexstatus, claudemodels, claudeBin = 'claude', cacheFile = null, now = () => Date.now(), log = () => {} } = {}) {
|
|
32
|
+
const live = { codex: null, claude: null } // { at, data }
|
|
33
|
+
const inflight = { codex: null, claude: null }
|
|
34
|
+
const fetchers = {
|
|
35
|
+
codex: async () => { const l = await codexstatus.listModels('codex'); return Array.isArray(l) && l.length ? l : null },
|
|
36
|
+
claude: async () => (claudemodels && typeof claudemodels.probeModels === 'function' ? claudemodels.probeModels(claudeBin) : null),
|
|
37
|
+
}
|
|
38
|
+
const register = (kind, data) => {
|
|
39
|
+
try {
|
|
40
|
+
if (kind === 'codex') setModelNames(data.map((m) => ({ id: m.model, name: m.name })))
|
|
41
|
+
else setModelNames(data.models)
|
|
42
|
+
} catch { /* 名称登记失败只影响显示 */ }
|
|
43
|
+
}
|
|
44
|
+
// 存盘的上次结果:at 记 0 ⇒ 第一次用到就后台再刷一次
|
|
45
|
+
if (cacheFile) {
|
|
46
|
+
try {
|
|
47
|
+
const saved = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
|
|
48
|
+
for (const k of ['codex', 'claude']) if (saved && saved[k] && saved[k].data) { live[k] = { at: 0, data: saved[k].data, saved: true }; register(k, saved[k].data) }
|
|
49
|
+
} catch { /* 没有 / 坏了就当没有 */ }
|
|
50
|
+
}
|
|
51
|
+
const persist = () => {
|
|
52
|
+
if (!cacheFile) return
|
|
53
|
+
try {
|
|
54
|
+
const out = {}
|
|
55
|
+
for (const k of ['codex', 'claude']) if (live[k]) out[k] = { at: live[k].at || now(), data: live[k].data }
|
|
56
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
|
|
57
|
+
const tmp = cacheFile + '.tmp'
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(out))
|
|
59
|
+
fs.renameSync(tmp, cacheFile)
|
|
60
|
+
} catch (e) { log('可选型号存盘失败: ' + (e && e.message)) }
|
|
61
|
+
}
|
|
62
|
+
const refresh = (kind) => {
|
|
63
|
+
if (!inflight[kind]) {
|
|
64
|
+
inflight[kind] = Promise.resolve().then(() => fetchers[kind]()).catch(() => null)
|
|
65
|
+
.then((data) => {
|
|
66
|
+
if (data) { live[kind] = { at: now(), data }; register(kind, data); persist() }
|
|
67
|
+
else log(`${kind === 'codex' ? 'codex' : 'Claude'} 可选型号探测失败 —— 沿用上次结果(如有)`)
|
|
68
|
+
})
|
|
69
|
+
.finally(() => { inflight[kind] = null })
|
|
70
|
+
}
|
|
71
|
+
return inflight[kind]
|
|
72
|
+
}
|
|
73
|
+
// 有结果就先用、过期了后台再刷;从没取到过(也没存盘)才等这一次
|
|
74
|
+
async function get(kind) {
|
|
75
|
+
const c = live[kind]
|
|
76
|
+
if (c) { if (now() - c.at >= TTL) refresh(kind); return c.data }
|
|
77
|
+
await refresh(kind)
|
|
78
|
+
return live[kind] ? live[kind].data : null
|
|
79
|
+
}
|
|
80
|
+
const sourceOf = (kind) => (live[kind] && live[kind].saved && !live[kind].at ? 'saved' : 'live')
|
|
81
|
+
|
|
82
|
+
async function catalog(name, provider) {
|
|
83
|
+
const p = provider || {}
|
|
84
|
+
if (p.runner === 'codex') {
|
|
85
|
+
const list = await get('codex')
|
|
86
|
+
if (!list) return { ok: false, models: [], aliases: [], efforts: CODEX_LEGAL.slice(1), recommended: { model: '', effort: '' } }
|
|
87
|
+
const models = list.map((m) => ({
|
|
88
|
+
id: m.model,
|
|
89
|
+
...(m.name ? { name: m.name } : {}),
|
|
90
|
+
efforts: (m.efforts || []).filter((e) => CODEX_LEGAL.includes(e)),
|
|
91
|
+
...(m.defEffort && CODEX_LEGAL.includes(m.defEffort) ? { defEffort: m.defEffort } : {}),
|
|
92
|
+
}))
|
|
93
|
+
const star = list.find((m) => m.isDefault) || list[0]
|
|
94
|
+
const cat = { ok: true, models, aliases: [], efforts: CODEX_LEGAL.slice(1), source: sourceOf('codex') }
|
|
95
|
+
cat.recommended = { model: star.model, effort: '' }
|
|
96
|
+
cat.recommended.effort = recommendEffort(cat, star.model)
|
|
97
|
+
return cat
|
|
98
|
+
}
|
|
99
|
+
if (!p.baseUrl) {
|
|
100
|
+
const d = await get('claude')
|
|
101
|
+
if (d && Array.isArray(d.models) && d.models.length) {
|
|
102
|
+
const models = d.models.map((m) => ({ id: m.id, name: m.name, ...(m.effort !== false && m.efforts && m.efforts.length ? { efforts: m.efforts.slice() } : {}) }))
|
|
103
|
+
const cat = { ok: true, models, aliases: [], aliasOf: { ...(d.aliasOf || {}) }, efforts: CLAUDE_EFFORTS.slice(), source: sourceOf('claude') }
|
|
104
|
+
cat.recommended = { model: d.recommended || models[0].id, effort: CLAUDE_EFFORT }
|
|
105
|
+
cat.recommended.effort = recommendEffort(cat, cat.recommended.model) // xhigh 不在这型号的档里才换
|
|
106
|
+
return cat
|
|
107
|
+
}
|
|
108
|
+
// 从没拿到过实时清单(没登录 / CLI 太老 / 超时)⇒ 内置候选 + 本机账号额外项 + 官方别名
|
|
109
|
+
let ids = []
|
|
110
|
+
try { ids = claudemodels.mergeModels(claudemodels.readExtraModels()).map((m) => m.id).filter(Boolean) } catch { ids = claudemodels.BUILTIN.map((m) => m.id).filter(Boolean) }
|
|
111
|
+
return { ok: true, models: ids.map((id) => ({ id })), aliases: CLAUDE_ALIASES.slice(), efforts: CLAUDE_EFFORTS.slice(), recommended: { model: ids[0] || '', effort: CLAUDE_EFFORT }, source: 'builtin' }
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, models: null, aliases: [], efforts: CLAUDE_EFFORTS.slice(), recommended: { model: '', effort: CLAUDE_EFFORT } }
|
|
114
|
+
}
|
|
115
|
+
// 守护进程启动时预热(有哪家的卡才探哪家):第一张模型卡 / 第一次打开群卡就不用等探测
|
|
116
|
+
catalog.warm = (kinds = ['codex']) => Promise.all(kinds.filter((k) => fetchers[k]).map((k) => refresh(k)))
|
|
117
|
+
// 原始清单(控制台 /api/codex/models · /api/claude/models 用:要 isDefault / 说明这些字段)+ 它是不是实时的
|
|
118
|
+
catalog.raw = async (kind) => ({ data: fetchers[kind] ? await get(kind) : null, source: live[kind] ? sourceOf(kind) : 'none' })
|
|
119
|
+
return catalog
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { createModelCatalog, CLAUDE_EFFORTS, CODEX_LEGAL, CLAUDE_ALIASES }
|
package/src/platform/feishu.js
CHANGED
|
@@ -848,6 +848,23 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
848
848
|
return sendText(chatId, content)
|
|
849
849
|
},
|
|
850
850
|
|
|
851
|
+
// One interactive reply under a group command (the /model card). Like
|
|
852
|
+
// replyOnce it is best-effort and never retried; the caller falls back
|
|
853
|
+
// to the text reply when this does not confirm.
|
|
854
|
+
async replyCardOnce(replyCtx, card) {
|
|
855
|
+
const mid = replyCtx && replyCtx.messageId
|
|
856
|
+
if (typeof mid !== 'string' || !/^om_[A-Za-z0-9_-]{1,124}$/.test(mid) ||
|
|
857
|
+
!card || typeof card !== 'object') {
|
|
858
|
+
throw new TypeError('Feishu card reply needs exact message target and a card')
|
|
859
|
+
}
|
|
860
|
+
return client.im.message.reply({ path: { message_id: mid },
|
|
861
|
+
data: { msg_type: 'interactive', content: JSON.stringify(card) } })
|
|
862
|
+
},
|
|
863
|
+
|
|
864
|
+
// open_id → display name (cached; null when unknown). The model card's
|
|
865
|
+
// receipt says who changed it; a card callback only carries the open_id.
|
|
866
|
+
nameOf: (openId) => senderNameOf(openId),
|
|
867
|
+
|
|
851
868
|
// 发一张静态富卡,返回 { msgId, cardId }(msgId=按钮/表情命中锚点;cardId=实体更新回填用)。
|
|
852
869
|
// 文本已由调用方在构卡时逐字段 redact(卡是 JSON 不能整体脱敏),故这里不再包 redact。
|
|
853
870
|
async sendCard(chatId, cardJson) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 群里 /model 的「模型卡」。发 /model 不带参数 ⇒ 一张能点的卡:
|
|
3
|
+
// 后端一排按钮 · 型号一个下拉 · 思考深度一排按钮 + 「确认更新」「关闭」;确认后卡片收成一行回执(卡片本身就是回执,不另发消息);
|
|
4
|
+
// 10 分钟后过期翻只读(防止往上翻聊天记录时误点到几天前的老卡)。
|
|
5
|
+
// 纯函数、零依赖:飞书卡片 schema 2.0 的 JSON 形状只在此一处;样式与通知卡同一套(撑满宽 · 标题栏底色说类别 ·
|
|
6
|
+
// 注意事项与右下角小字用 notation 灰字;⚠ 2.0 不认 note 组件)。没有这种卡的平台(企微)由调用方退回文本回执。
|
|
7
|
+
// ⚠ 脱敏:型号 / 后端名来自配置与清单,没有用户输入;结果行里的人名来自平台,同样经 opts.redact。
|
|
8
|
+
|
|
9
|
+
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
10
|
+
const DEFAULT_VALUE = '__default__' // 下拉里「跟随默认」那一项的值(选项值不能是空串)
|
|
11
|
+
|
|
12
|
+
const grey = (s, size = 'notation') => ({ tag: 'markdown', text_size: size, content: `<font color='grey'>${esc(s)}</font>` })
|
|
13
|
+
|
|
14
|
+
function button(label, { selected = false, value, disabled = false } = {}) {
|
|
15
|
+
const b = { tag: 'button', text: { tag: 'plain_text', content: label }, type: selected ? 'primary_filled' : 'default', size: 'small', width: 'default' }
|
|
16
|
+
if (disabled) b.disabled = true
|
|
17
|
+
if (value) b.behaviors = [{ type: 'callback', value }]
|
|
18
|
+
return b
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 一排按钮:flow 布局,窄屏自动折行
|
|
22
|
+
function buttonRow(buttons) {
|
|
23
|
+
return { tag: 'column_set', flex_mode: 'flow', horizontal_spacing: '6px', horizontal_align: 'left', columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 「先选、再确认」(点一下就生效容易点错):
|
|
28
|
+
* 点后端 / 选型号 / 点深度只改**卡上的草稿**(卡片重画选中状态,什么都没生效);草稿与当前不同时出一行「将改为 … —— 会不会开新对话」,
|
|
29
|
+
* 「确认更新」一次生效(只写一次、最多开一次新对话、审计一条),卡片收成一行回执;「关闭」什么都不改,卡片原地变成灰色「已关闭」。
|
|
30
|
+
* 草稿随每个回调值带回去(无状态,守护进程重启也不丢):{ a:'mc', f:'p'|'m'|'e'|'ok'|'x', v?, d:{p,m,e}, t }
|
|
31
|
+
* —— p 后端 · m 型号 · e 深度('' = 跟随默认);下拉的选中值在回调的 action.option 里。
|
|
32
|
+
* @param s {
|
|
33
|
+
* mode: 'edit' | 'receipt' | 'closed' | 'expired', current: 'claude · claude-opus-5 · xhigh'(当前生效的)
|
|
34
|
+
* failover: '', quota: '', —— 额度临时切换那一句 / 缓存额度(有才给)
|
|
35
|
+
* draft: { p, m, e }, —— edit:卡上正在选的
|
|
36
|
+
* backends: [{ name, selected }], model: { options: [{ value, label }], selected } | null(null = 这家型号到后台改),
|
|
37
|
+
* efforts: [{ value, label, selected }], —— label 里由调用方标「(默认)」
|
|
38
|
+
* preview: { to, note } | null, —— 草稿与当前不同才有;null ⇒「确认更新」灰着
|
|
39
|
+
* receipt: { line, before }, —— receipt:谁改成了什么 · 之前是什么
|
|
40
|
+
* issuedAt: 发卡 / 上次操作的时刻(写进回调值,10 分钟过期)
|
|
41
|
+
* }
|
|
42
|
+
*/
|
|
43
|
+
function buildModelCard(s, { redact } = {}) {
|
|
44
|
+
const r = typeof redact === 'function' ? (x) => String(redact(String(x))) : (x) => String(x)
|
|
45
|
+
const mode = s.mode || (s.expired ? 'expired' : 'edit')
|
|
46
|
+
const t = s.issuedAt || 0
|
|
47
|
+
const d = { p: '', m: '', e: '', ...(s.draft || {}) }
|
|
48
|
+
const cb = (f, v) => ({ a: 'mc', f, ...(v !== undefined ? { v } : {}), d, t })
|
|
49
|
+
const els = []
|
|
50
|
+
const head = (title, template, tag, tagColor) => ({
|
|
51
|
+
title: { tag: 'plain_text', content: title },
|
|
52
|
+
subtitle: { tag: 'plain_text', content: r(s.current || '') },
|
|
53
|
+
template, text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: tag }, color: tagColor }],
|
|
54
|
+
padding: '12px 12px 12px 12px',
|
|
55
|
+
})
|
|
56
|
+
let header
|
|
57
|
+
if (mode === 'receipt') {
|
|
58
|
+
// 确认之后:收成一行回执 —— 控件全收起(卡片就此「关闭」),群里留一条谁改成了什么
|
|
59
|
+
header = head('模型已更新', 'green', '已更新', 'green')
|
|
60
|
+
const rc = s.receipt || {}
|
|
61
|
+
els.push({ tag: 'markdown', text_size: 'normal', content: esc(r(rc.line || '')) })
|
|
62
|
+
if (rc.before) els.push(grey(r(rc.before)))
|
|
63
|
+
} else if (mode === 'closed') {
|
|
64
|
+
header = head('本群模型', 'grey', '已关闭', 'neutral')
|
|
65
|
+
els.push(grey('已关闭 · 模型没变', 'normal'))
|
|
66
|
+
} else if (mode === 'expired') {
|
|
67
|
+
header = head('本群模型', 'grey', '已过期', 'neutral')
|
|
68
|
+
els.push(grey('这张卡已过期,没有改任何东西 —— 发 /model 重新打开', 'normal'))
|
|
69
|
+
} else {
|
|
70
|
+
header = head('本群模型', 'indigo', '模型', 'indigo')
|
|
71
|
+
const info = []
|
|
72
|
+
if (s.failover) info.push(`**额度切换** ${esc(s.failover)}`)
|
|
73
|
+
if (s.quota) info.push(`**额度** ${esc(s.quota)}`)
|
|
74
|
+
if (info.length) els.push({ tag: 'markdown', text_size: 'normal', content: info.join('\n') })
|
|
75
|
+
const backendBtns = buttonRow((s.backends || []).map((b) => button(b.name, { selected: b.selected, value: b.selected ? null : cb('p', b.name) })))
|
|
76
|
+
let modelCtl
|
|
77
|
+
if (s.model) {
|
|
78
|
+
modelCtl = {
|
|
79
|
+
tag: 'select_static', width: 'fill', type: 'default',
|
|
80
|
+
placeholder: { tag: 'plain_text', content: '选一个型号' },
|
|
81
|
+
initial_option: s.model.selected || DEFAULT_VALUE,
|
|
82
|
+
options: s.model.options.map((o) => ({ text: { tag: 'plain_text', content: o.label }, value: o.value || DEFAULT_VALUE })),
|
|
83
|
+
behaviors: [{ type: 'callback', value: cb('m') }],
|
|
84
|
+
}
|
|
85
|
+
} else modelCtl = grey('这个后端的型号在控制台改', 'normal')
|
|
86
|
+
const effortBtns = buttonRow((s.efforts || []).map((e) => button(e.label || e.value, { selected: e.selected, value: e.selected ? null : cb('e', e.value || DEFAULT_VALUE) })))
|
|
87
|
+
// 标签在上、控件在下(手机上五个深度按钮一排放得下)
|
|
88
|
+
for (const [label, ctl] of [['后端', backendBtns], ['型号', modelCtl], ['思考深度', effortBtns]]) els.push(grey(label), ctl)
|
|
89
|
+
els.push({ tag: 'hr' })
|
|
90
|
+
if (s.preview) {
|
|
91
|
+
els.push({ tag: 'markdown', text_size: 'normal', content: `**将改为** ${esc(s.preview.to)}` })
|
|
92
|
+
if (s.preview.note) els.push(grey(s.preview.note))
|
|
93
|
+
}
|
|
94
|
+
els.push(buttonRow([
|
|
95
|
+
button('确认更新', { selected: !!s.preview, value: s.preview ? cb('ok') : null, disabled: !s.preview }),
|
|
96
|
+
button('关闭', { value: { a: 'mc', f: 'x', t } }),
|
|
97
|
+
]))
|
|
98
|
+
els.push(grey('选好后点「确认更新」才生效 · 换后端会开新对话,换型号或调深度接着当前对话 · 要等手头任务结束 · 10 分钟内有效 · 点不动时可直接发 /model high 这类文字'))
|
|
99
|
+
}
|
|
100
|
+
return { schema: '2.0', config: { width_mode: 'fill', update_multi: true }, header, body: { padding: '12px 12px 12px 12px', vertical_spacing: '8px', elements: els } }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { buildModelCard, DEFAULT_VALUE }
|