sintbot 1.4.11 → 1.4.13
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 +54 -0
- package/CHANGELOG.json +54 -0
- package/bin/sintbot-v2.js +12 -3
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +14 -4
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +12 -4
- package/install.sh +1 -1
- package/package.json +7 -1
- package/src/agent/claudecode.js +6 -0
- package/src/agent/codexapp.js +5 -1
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +3 -1
- package/src/c2console.js +85 -15
- package/src/c2updater.js +13 -5
- package/src/consolesay.js +6 -6
- package/src/consoletrials.js +43 -0
- package/src/core/c2cardterminalpath.js +2 -1
- package/src/core/c2durablejournal.js +10 -0
- package/src/core/v2engine.js +70 -19
- package/src/feishucli.js +13 -1
- package/src/modelcontext.js +13 -0
- package/src/platform/feishu.js +29 -9
- package/src/recordedanswers.js +27 -0
- package/src/runtimefacts.js +11 -0
- package/src/ui/console.html +49 -29
- package/src/ui/liveactivity-client.js +276 -0
- package/src/ui/liveactivity.js +194 -0
- package/src/ui/serverbase.js +41 -14
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// An observational read model, never an execution/recovery authority. All
|
|
4
|
+
// terminal labels are supplied by the existing acknowledged engine paths.
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { randomUUID } = require('node:crypto')
|
|
8
|
+
const { redact } = require('../core/redact')
|
|
9
|
+
const LIMIT = 256, TEXT = 16384, FILE_LIMIT = 12 * 1024 * 1024
|
|
10
|
+
const CLOSED = new Set(['answered', 'silent', 'stopped', 'recalled', 'timeout', 'error', 'not-started', 'unknown'])
|
|
11
|
+
const clean = (s, n = TEXT) => redact(String(s || ''), { pii: false }).slice(0, n)
|
|
12
|
+
const finite = n => Number.isFinite(n) && n >= 0
|
|
13
|
+
|
|
14
|
+
function createLiveActivity({ file, now = Date.now, log = () => {}, canWrite = () => true } = {}) {
|
|
15
|
+
const epoch = randomUUID(), rows = new Map(), aliases = new Map(), contexts = new Map(), listeners = new Set()
|
|
16
|
+
let revision = 0, timer, saveTimer, saving = Promise.resolve(), writing = false, dirty = false, storageError = false
|
|
17
|
+
const key = (chatId, id) => chatId + ':' + id
|
|
18
|
+
const locate = (chatId, id) => rows.get(aliases.get(key(chatId, id)) || key(chatId, id))
|
|
19
|
+
function changed(urgent = false) {
|
|
20
|
+
revision++; dirty = true
|
|
21
|
+
if (!timer) timer = setTimeout(() => {
|
|
22
|
+
timer = null
|
|
23
|
+
for (const fn of listeners) { try { fn() } catch {} }
|
|
24
|
+
}, urgent ? 0 : 150).unref()
|
|
25
|
+
if (!saveTimer && file) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
|
|
26
|
+
}
|
|
27
|
+
function touch(row, urgent = false, details = true) {
|
|
28
|
+
row.updatedAt = now(); row.revision = revision + 1
|
|
29
|
+
if (details) row.detailRevision = row.revision
|
|
30
|
+
changed(urgent)
|
|
31
|
+
while (rows.size > LIMIT) {
|
|
32
|
+
const oldest = [...rows.values()].sort((a, b) => Number(!CLOSED.has(a.status)) - Number(!CLOSED.has(b.status)) || a.updatedAt - b.updatedAt)[0]
|
|
33
|
+
rows.delete(key(oldest.chatId, oldest.id))
|
|
34
|
+
for (const id of oldest.receiptIds) aliases.delete(key(oldest.chatId, id))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function receive({ chatId, receiptId, sourceMessageId, text, sender, acceptedAt }) {
|
|
38
|
+
if (locate(chatId, receiptId)) return
|
|
39
|
+
const row = { id: receiptId, chatId, receiptIds: [receiptId], ts: acceptedAt || now(),
|
|
40
|
+
status: 'queued', text: clean(text, 160), sender: clean(sender, 120),
|
|
41
|
+
messages: [{ id: receiptId, sourceMessageId: typeof sourceMessageId === 'string' ? sourceMessageId : null,
|
|
42
|
+
text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
|
|
43
|
+
truncated: String(text || '').length > TEXT, reply: '', tools: [], milestones: [] }
|
|
44
|
+
rows.set(key(chatId, receiptId), row); touch(row, true)
|
|
45
|
+
}
|
|
46
|
+
function merge(chatId, parentId, ids, state) {
|
|
47
|
+
const row = locate(chatId, parentId)
|
|
48
|
+
if (!row) return
|
|
49
|
+
for (const id of ids) {
|
|
50
|
+
const child = locate(chatId, id)
|
|
51
|
+
if (child && child !== row) {
|
|
52
|
+
row.messages.push(...child.messages)
|
|
53
|
+
row.receiptIds.push(...child.receiptIds)
|
|
54
|
+
rows.delete(key(chatId, child.id))
|
|
55
|
+
}
|
|
56
|
+
if (!row.receiptIds.includes(id)) row.receiptIds.push(id)
|
|
57
|
+
aliases.set(key(chatId, id), key(chatId, row.id))
|
|
58
|
+
const msg = row.messages.find(m => m.id === id)
|
|
59
|
+
if (msg) msg.state = state
|
|
60
|
+
}
|
|
61
|
+
// Bound the entire message body, not only each message, and disclose loss.
|
|
62
|
+
let budget = TEXT
|
|
63
|
+
if (row.messages.length > 64) row.truncated = true
|
|
64
|
+
row.messages = row.messages.slice(0, 64).map(m => {
|
|
65
|
+
const text = m.text.slice(0, budget); budget -= text.length
|
|
66
|
+
if (text !== m.text) row.truncated = true
|
|
67
|
+
return { ...m, text }
|
|
68
|
+
})
|
|
69
|
+
touch(row, true)
|
|
70
|
+
return row
|
|
71
|
+
}
|
|
72
|
+
function start({ chatId, receiptId, receiptIds, generation, sessionId, model, runner, project }) {
|
|
73
|
+
const row = merge(chatId, receiptId, receiptIds || [receiptId], 'landed')
|
|
74
|
+
if (!row) return
|
|
75
|
+
row.status = 'running'; row.generation = generation
|
|
76
|
+
row.startedAt ||= now(); row.sessionId = sessionId || null
|
|
77
|
+
Object.assign(row, { model: clean(model, 120), runner, project: clean(project, 120) })
|
|
78
|
+
const prior = contexts.get(chatId)
|
|
79
|
+
if (!prior || prior.sessionId !== sessionId) contexts.delete(chatId)
|
|
80
|
+
else contexts.set(chatId, { ...prior, generation, quality: 'previous' })
|
|
81
|
+
touch(row, true)
|
|
82
|
+
}
|
|
83
|
+
function observe({ chatId, receiptId, generation, event, doing, milestones }) {
|
|
84
|
+
const row = locate(chatId, receiptId)
|
|
85
|
+
if (!row || row.generation !== generation || CLOSED.has(row.status)) return
|
|
86
|
+
if (event.sessionId) row.sessionId = event.sessionId
|
|
87
|
+
const compact = event.type === 'system' && event.subtype === 'compact' || event.contextCompacted && !event.contextTokens
|
|
88
|
+
const measured = finite(event.contextTokens) && event.contextSampled !== false
|
|
89
|
+
if (compact || measured) {
|
|
90
|
+
const prior = contexts.get(chatId)
|
|
91
|
+
contexts.set(chatId, { sessionId: row.sessionId, generation,
|
|
92
|
+
usedTokens: compact ? null : event.contextTokens,
|
|
93
|
+
windowTokens: finite(event.contextWindowTokens) && event.contextWindowTokens > 0 ? event.contextWindowTokens : prior?.generation === generation ? prior.windowTokens : null,
|
|
94
|
+
measuredAt: now(), quality: compact ? 'compacted' : 'reported', source: row.runner })
|
|
95
|
+
}
|
|
96
|
+
if (event.tools) {
|
|
97
|
+
for (const tool of event.tools) {
|
|
98
|
+
const name = clean(tool.name, 100), found = row.tools.find(t => t.name === name)
|
|
99
|
+
if (found) found.count++
|
|
100
|
+
else if (row.tools.length < 50) row.tools.push({ name, count: 1 })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (doing) row.doing = doing.map(x => clean(x, 160)).slice(-3)
|
|
104
|
+
if (milestones) row.milestones = milestones.map(x => clean(x, 200)).slice(-12)
|
|
105
|
+
// No model text deltas: only user-facing milestones and tool descriptions.
|
|
106
|
+
if (compact || measured || event.tools?.length || milestones)
|
|
107
|
+
touch(row, compact, !!event.tools?.length || !!milestones)
|
|
108
|
+
}
|
|
109
|
+
function finish({ chatId, receiptId, status, reply, err, tokens, sessionId }) {
|
|
110
|
+
const row = locate(chatId, receiptId)
|
|
111
|
+
if (!row || CLOSED.has(row.status)) return
|
|
112
|
+
row.status = status; row.endedAt = now(); row.ms = row.startedAt ? row.endedAt - row.startedAt : 0
|
|
113
|
+
row.reply = clean(reply); row.err = clean(err, 1000); row.tokens = tokens || null
|
|
114
|
+
row.truncated ||= String(reply || '').length > TEXT
|
|
115
|
+
if (sessionId) row.sessionId = sessionId
|
|
116
|
+
const ctx = contexts.get(chatId)
|
|
117
|
+
if (ctx && ctx.generation === row.generation) {
|
|
118
|
+
ctx.sessionId = row.sessionId; ctx.quality = ctx.quality === 'compacted' ? 'compacted' : 'previous'
|
|
119
|
+
}
|
|
120
|
+
touch(row, true)
|
|
121
|
+
}
|
|
122
|
+
function sending({ chatId, receiptId }) {
|
|
123
|
+
const row = locate(chatId, receiptId)
|
|
124
|
+
if (row && !CLOSED.has(row.status)) { row.status = 'sending'; touch(row, true) }
|
|
125
|
+
}
|
|
126
|
+
function unknown({ chatId }) {
|
|
127
|
+
for (const row of rows.values()) if (row.chatId === chatId && !CLOSED.has(row.status)) {
|
|
128
|
+
finish({ chatId, receiptId: row.id, status: 'unknown', err: '结果尚未确认,请查看群状态' })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const summary = row => {
|
|
132
|
+
const { messages, reply, tools, milestones, receiptIds, generation, sessionId, ...rest } = row
|
|
133
|
+
return { ...rest, preview: (row.err || reply || '').slice(0, 160), messageCount: messages.length,
|
|
134
|
+
kind: CLOSED.has(row.status) ? 'done' : 'start', details: true }
|
|
135
|
+
}
|
|
136
|
+
function list({ chatId, status, offset = 0, limit = 30 } = {}) {
|
|
137
|
+
const all = [...rows.values()].filter(r => (!chatId || r.chatId === chatId) && (!status || r.status === status))
|
|
138
|
+
.sort((a, b) => Number(CLOSED.has(a.status)) - Number(CLOSED.has(b.status)) || b.ts - a.ts)
|
|
139
|
+
offset = Math.max(0, Math.min(LIMIT, Number(offset) || 0)); limit = Math.max(1, Math.min(100, Number(limit) || 30))
|
|
140
|
+
return { epoch, revision, storageError, total: all.length, activity: all.slice(offset, offset + limit).map(summary) }
|
|
141
|
+
}
|
|
142
|
+
function detail(chatId, id) {
|
|
143
|
+
const row = locate(chatId, id)
|
|
144
|
+
return row ? { epoch, revision: row.revision, ...structuredClone(row) } : null
|
|
145
|
+
}
|
|
146
|
+
function context(chatId, sessionId, busy = false, generation = null) {
|
|
147
|
+
const c = contexts.get(chatId)
|
|
148
|
+
if (!c || !(busy && generation && c.generation === generation) && (!sessionId || c.sessionId !== sessionId)) return null
|
|
149
|
+
return { ...c, quality: busy ? c.quality : c.quality === 'reported' ? 'previous' : c.quality }
|
|
150
|
+
}
|
|
151
|
+
function flush() {
|
|
152
|
+
clearTimeout(saveTimer); saveTimer = null
|
|
153
|
+
if (writing) return saving
|
|
154
|
+
if (!file || !dirty || !canWrite()) return saving
|
|
155
|
+
dirty = false
|
|
156
|
+
const data = JSON.stringify({ version: 1, rows: [...rows.values()], contexts: [...contexts] })
|
|
157
|
+
writing = true
|
|
158
|
+
saving = saving.then(async () => {
|
|
159
|
+
if (!canWrite()) return
|
|
160
|
+
if (Buffer.byteLength(data) > FILE_LIMIT) throw new Error('activity size limit')
|
|
161
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true })
|
|
162
|
+
const tmp = file + '.tmp'
|
|
163
|
+
await fs.promises.writeFile(tmp, data, { mode: 0o600 })
|
|
164
|
+
if (canWrite()) await fs.promises.rename(tmp, file)
|
|
165
|
+
storageError = false
|
|
166
|
+
}).catch(() => { storageError = true; log('Activity history could not be saved') })
|
|
167
|
+
.finally(() => {
|
|
168
|
+
writing = false
|
|
169
|
+
if (dirty && !saveTimer) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
|
|
170
|
+
})
|
|
171
|
+
return saving
|
|
172
|
+
}
|
|
173
|
+
if (file) {
|
|
174
|
+
try {
|
|
175
|
+
if (fs.statSync(file).size > FILE_LIMIT) throw new Error('activity size limit')
|
|
176
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
177
|
+
if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > LIMIT) throw new Error('activity format')
|
|
178
|
+
for (const row of data.rows) {
|
|
179
|
+
if (!row || typeof row.id !== 'string' || typeof row.chatId !== 'string' || !Array.isArray(row.receiptIds) || !Array.isArray(row.messages)) continue
|
|
180
|
+
if (!CLOSED.has(row.status)) { row.status = 'unknown'; row.err = '服务重启,结果需要核实' }
|
|
181
|
+
rows.set(key(row.chatId, row.id), row)
|
|
182
|
+
revision = Math.max(revision, Number(row.revision) || 0)
|
|
183
|
+
for (const id of row.receiptIds) aliases.set(key(row.chatId, id), key(row.chatId, row.id))
|
|
184
|
+
}
|
|
185
|
+
for (const [id, value] of (data.contexts || []).slice(0, 32)) contexts.set(id, { ...value, quality: value.quality === 'compacted' ? 'compacted' : 'historical' })
|
|
186
|
+
} catch (error) { if (error.code !== 'ENOENT') { storageError = true; log('Activity history could not be loaded') } }
|
|
187
|
+
}
|
|
188
|
+
return { receive, start, observe, finish, sending, unknown, merge, list, detail, context, flush,
|
|
189
|
+
has: (chatId, id) => !!locate(chatId, id),
|
|
190
|
+
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
|
|
191
|
+
async close() { clearTimeout(timer); listeners.clear(); await flush(); await flush(); clearTimeout(saveTimer) } }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { createLiveActivity }
|
package/src/ui/serverbase.js
CHANGED
|
@@ -18,7 +18,7 @@ const { createLedger } = require('../core/ledger')
|
|
|
18
18
|
const { normalizeTz, isValidTz, tzOffsetMin, tzParts } = require('../core/time')
|
|
19
19
|
// Runner dispatch (Claude vs Codex by provider.runner) — so 测试/试跑 run codex
|
|
20
20
|
// providers on the Codex CLI, not Claude. Same module the daemon/CLI use.
|
|
21
|
-
const { runTurn, probeVersion } = require('../agent/runner')
|
|
21
|
+
const { runTurn, probeVersion, isClosedRunnerResult, isClosedRunnerError } = require('../agent/runner')
|
|
22
22
|
const codexstatus = require('../agent/codexstatus') // codex 订阅登录态 + 用量(卡片「验证登录」)
|
|
23
23
|
const claudestatus = require('../agent/claudestatus') // Claude 订阅登录态(卡片「检测登录」· doctor 同源)
|
|
24
24
|
const claudemodels = require('../agent/claudemodels') // Claude 模型候选(仅登录后给)
|
|
@@ -49,6 +49,7 @@ const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl
|
|
|
49
49
|
const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-sessions.json')
|
|
50
50
|
|
|
51
51
|
const CONSOLE_HTML = path.join(__dirname, 'console.html')
|
|
52
|
+
const LIVE_ACTIVITY_JS = fs.readFileSync(path.join(__dirname, 'liveactivity-client.js'), 'utf8')
|
|
52
53
|
const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
|
|
53
54
|
const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
|
|
54
55
|
const dashboard = require('./dashboard') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
|
|
@@ -404,9 +405,9 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
404
405
|
// each click doesn't spawn a `claude --version` child.
|
|
405
406
|
let probeCache = null // { val, ts }
|
|
406
407
|
const probeCached = async (bin) => {
|
|
407
|
-
if (probeCache && Date.now() - probeCache.ts < 60_000) return probeCache.val
|
|
408
|
+
if (probeCache && probeCache.bin === bin && Date.now() - probeCache.ts < 60_000) return probeCache.val
|
|
408
409
|
const val = await probeVersion(bin)
|
|
409
|
-
probeCache = { val, ts: Date.now() }
|
|
410
|
+
probeCache = { val, bin, ts: Date.now() }
|
|
410
411
|
return val
|
|
411
412
|
}
|
|
412
413
|
// Same 60s cache for the codex CLI probe — only run when a codex provider exists.
|
|
@@ -521,7 +522,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
521
522
|
icons: g.workdir ? design.projectIconCard(g.workdir) : null, crons: (Array.isArray(g.crons) ? g.crons : []).map((c) => ({ ...c, human: describeTask(c, nowTzStr), expired: cronExpired(c.at) })), exists: g.workdir ? fs.existsSync(g.workdir) : false }))
|
|
522
523
|
// Console overview health — deliberately minimal: backend CLI 探针 + "有无可用模型/群"。
|
|
523
524
|
// Codex CLI 行仅在配了 codex provider 时出现(与 doctor 一致);`sbot doctor` 保留全量。
|
|
524
|
-
const
|
|
525
|
+
const hasClaude = providers.some((p) => !p.hidden && p.runner !== 'codex')
|
|
526
|
+
const v = !runtimePolicy || hasClaude ? await probeCached(cfg.agent.bin) : null
|
|
525
527
|
const hasCodex = providers.some((p) => !p.hidden && p.runner === 'codex')
|
|
526
528
|
const cv = hasCodex ? await probeCodexCached() : null
|
|
527
529
|
// 「可用模型」= 能用的,不只是配了的(1.0.3;此前没登录的 Claude 订阅、试跑失败的 key 都算在内)。
|
|
@@ -683,14 +685,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
683
685
|
const cfg = loadConfig(configPath); applyProxyEnv(cfg)
|
|
684
686
|
const provider = cfg.providers[name]
|
|
685
687
|
if (!provider) return { ok: false, error: `unknown provider '${name}'` }
|
|
688
|
+
if (provider.hidden) return { ok: false, error: '模型已隐藏,不能试运行' }
|
|
686
689
|
if (provider.baseUrl && !provider.apiKey) return { ok: false, error: 'no apiKey' }
|
|
687
690
|
const ac = new AbortController()
|
|
691
|
+
let lease, closed = false
|
|
692
|
+
try { lease = runtimePolicy ? await runtimePolicy.beginTrial(ac) : null }
|
|
693
|
+
catch (error) { return { ok: false, error: error.message } }
|
|
688
694
|
// codex 跑整轮 GPT(冷启动 + reasoning)远慢于 Anthropic 的快问快答;给更长的测试超时。
|
|
689
695
|
// 中止现在真生效(codexcode 强制收尾),超时到点能掐断,不会再干等到自然结束。
|
|
690
696
|
const timer = setTimeout(() => ac.abort(), provider.runner === 'codex' ? 180000 : 60000)
|
|
691
697
|
const t0 = Date.now()
|
|
692
698
|
try {
|
|
693
|
-
const
|
|
699
|
+
const execution = await runTurn({
|
|
694
700
|
bin: cfg.agent.bin,
|
|
695
701
|
prompt: prompt || verify.TRIAL_PROMPT,
|
|
696
702
|
provider,
|
|
@@ -698,13 +704,22 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
698
704
|
permissionMode: cfg.agent.permissionMode,
|
|
699
705
|
signal: ac.signal, // a one-line reply — needs no tools, so no allow list
|
|
700
706
|
})
|
|
707
|
+
closed = isClosedRunnerResult(execution)
|
|
708
|
+
const { result } = execution
|
|
709
|
+
if (lease && !closed) throw new Error('试运行进程关闭状态未知,结果未保存,并发名额已保留')
|
|
710
|
+
lease?.assertWritable()
|
|
701
711
|
const ok = !(result && result.isError)
|
|
702
712
|
verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
|
|
703
713
|
return { ok, model: provider.model, reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
|
|
704
714
|
} catch (e) {
|
|
705
|
-
|
|
715
|
+
closed ||= isClosedRunnerError(e)
|
|
716
|
+
if (!lease) verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
|
|
717
|
+
else if (closed) {
|
|
718
|
+
try { lease.assertWritable(); verify.recordTurn(provider, false, e.message || String(e), vopts) }
|
|
719
|
+
catch { /* lease loss must never write verification evidence */ }
|
|
720
|
+
}
|
|
706
721
|
return { ok: false, error: (e && e.message) || String(e), ms: Date.now() - t0 }
|
|
707
|
-
} finally { clearTimeout(timer) }
|
|
722
|
+
} finally { clearTimeout(timer); lease?.finish(closed) }
|
|
708
723
|
}
|
|
709
724
|
|
|
710
725
|
// ── request handling ──────────────────────────────────────────────────────
|
|
@@ -840,7 +855,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
840
855
|
// 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
|
|
841
856
|
const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
|
|
842
857
|
const injectTok = (authConfigured() && !localBypass) ? '' : token
|
|
843
|
-
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' : ''))))
|
|
858
|
+
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))))
|
|
844
859
|
}
|
|
845
860
|
// 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
|
|
846
861
|
// 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
|
|
@@ -867,7 +882,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
867
882
|
if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
|
|
868
883
|
// Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
|
|
869
884
|
// Lightweight + kept off /api/state so mutations don't re-ship it every write.
|
|
870
|
-
if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200,
|
|
885
|
+
if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, runtimePolicy?.activityPage
|
|
886
|
+
? runtimePolicy.activityPage(Object.fromEntries(url.searchParams)) : { activity: recentActivity() })
|
|
887
|
+
if (p === '/api/activity/detail' && req.method === 'GET') {
|
|
888
|
+
const detail = runtimePolicy?.activityDetail?.(url.searchParams.get('chatId'), url.searchParams.get('id'))
|
|
889
|
+
return sendJSON(res, detail ? 200 : 404, detail || { error: '活动详情未记录' })
|
|
890
|
+
}
|
|
871
891
|
// 版本历史(设置页「版本历史」卡):CHANGELOG.json 随包分发 = 单一事实源,发版时更新
|
|
872
892
|
if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
|
|
873
893
|
// 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
|
|
@@ -1113,7 +1133,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
1113
1133
|
const why = { 'no-daemon': '守护进程没在跑(纯配置模式)—— 插话需要它在线', paused: '这个群项目已暂停,先恢复再插话', unbound: '没有这个群项目', 'inject-failed': '注入失败(守护进程侧报错,看日志)',
|
|
1114
1134
|
// 持久处理流程(第 1 版插话)的拒绝原因。shown-failed 之前的几条在发群消息之前就拒,群里没有任何字;
|
|
1115
1135
|
// 从 shown-failed 起,群里可能已经有那行「【管理员】…」
|
|
1116
|
-
busy: '
|
|
1136
|
+
busy: '该群正在停止、跳过或处理未结记录,请稍后再插话',
|
|
1137
|
+
'queue-full': '该群等待处理的消息已满,请稍后再插话',
|
|
1117
1138
|
held: '该群上一个任务结果不明,已暂停处理、等待人工确认;插话未执行',
|
|
1118
1139
|
recovering: '该群上一个任务出错,正在收尾;插话未执行,请稍后再试',
|
|
1119
1140
|
upgrading: '正在升级,插话未执行,请一分钟后再试',
|
|
@@ -1124,7 +1145,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
1124
1145
|
'request-id': '这次提交缺少有效的提交编号,请刷新控制台后再试',
|
|
1125
1146
|
empty: '内容不能为空',
|
|
1126
1147
|
'shown-failed': '群里那行「【管理员】…」没能确认发出(失败或超时),插话未执行;即使群里随后出现这行,它也不会被处理',
|
|
1127
|
-
'busy-after-post': '
|
|
1148
|
+
'busy-after-post': '这行字发进群时该群正在停止或处理未结记录,这句没有处理;稍后原样再点一次插入,会沿用群里已有的那行',
|
|
1128
1149
|
refused: '该群拒收了这句(原因已回复在群里那行下面);插话未执行',
|
|
1129
1150
|
quarantined: '这句的来源已被隔离,未执行',
|
|
1130
1151
|
unconfirmed: '这行字已发进群,但没在 5 秒内确认登记,结果以群里和「实时活动」为准;原样再点一次插入不会重复处理' }[r.reason] || '插话失败'
|
|
@@ -2417,8 +2438,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2417
2438
|
const live = getLiveState ? getLiveState() : { running: false }
|
|
2418
2439
|
const conn = getConnHealth ? getConnHealth() : null // 飞书 WS 健康度随心跳带上(SSE 无重放,轮询兜底)
|
|
2419
2440
|
const msg = `event: tick\ndata: ${JSON.stringify({ live, conn, ts: Date.now(),
|
|
2420
|
-
...(runtimePolicy ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
|
|
2421
|
-
for (const c of sseClients) { try {
|
|
2441
|
+
...(runtimePolicy && !runtimePolicy.activityPage ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
|
|
2442
|
+
for (const c of sseClients) { try {
|
|
2443
|
+
if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
|
|
2444
|
+
else c.write(msg)
|
|
2445
|
+
} catch { sseClients.delete(c) } }
|
|
2422
2446
|
}, 3000)
|
|
2423
2447
|
|
|
2424
2448
|
return {
|
|
@@ -2434,7 +2458,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2434
2458
|
if (type === 'turn' && getLiveState) {
|
|
2435
2459
|
msg += `event: tick\ndata: ${JSON.stringify({ live: getLiveState(), conn: getConnHealth ? getConnHealth() : null, ts: Date.now() })}\n\n`
|
|
2436
2460
|
}
|
|
2437
|
-
for (const c of sseClients) { try {
|
|
2461
|
+
for (const c of sseClients) { try {
|
|
2462
|
+
if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
|
|
2463
|
+
else c.write(msg)
|
|
2464
|
+
} catch { sseClients.delete(c) } }
|
|
2438
2465
|
},
|
|
2439
2466
|
close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
|
|
2440
2467
|
_browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用
|