sintbot 1.4.11 → 1.4.12
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 +15 -0
- package/CHANGELOG.json +15 -0
- package/bin/sintbot-v2.js +9 -3
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +5 -3
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +5 -3
- package/install.sh +1 -1
- package/package.json +3 -1
- package/src/agent/claudecode.js +4 -0
- package/src/agent/codexapp.js +5 -1
- package/src/c2console.js +49 -10
- package/src/core/c2durablejournal.js +10 -0
- package/src/core/v2engine.js +34 -0
- package/src/ui/console.html +12 -13
- package/src/ui/liveactivity-client.js +276 -0
- package/src/ui/liveactivity.js +193 -0
- package/src/ui/serverbase.js +17 -5
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.12",
|
|
4
|
+
"date": "2026-11-10",
|
|
5
|
+
"name": "Projects and live activity",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "Projects, concurrency and context in one panel",
|
|
9
|
+
"d": "Show project paths, runtime, configured reasoning and context together; restore row icon actions and display task tools, reply and compact metrics after selecting a group. Remove the top available-controls notice."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "Actual task details and reported context",
|
|
13
|
+
"d": "Shows requests, follow-ups, progress, replies and the latest reported context. Reuses existing events without extra model calls; details load on demand and restored samples are labeled historical."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
2
17
|
{
|
|
3
18
|
"version": "1.4.11",
|
|
4
19
|
"date": "2026-11-10",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.12",
|
|
4
|
+
"date": "2026-11-10",
|
|
5
|
+
"name": "群项目与实时活动",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "群项目、并发与上下文集中展示",
|
|
9
|
+
"d": "群项目、运行耗时、项目路径、模型思考深度与上下文集中展示;恢复群行图标操作,选择群后查看任务,详情采用工具标签、回复正文和紧凑统计。 移除顶部当前可用操作说明卡片。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "真实任务详情与可信上下文",
|
|
13
|
+
"d": "展示请求、补话、进度、回复及最近上报的上下文。使用既有事件,不额外请求模型;详情按需加载,重启后历史采样明确标注。"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
2
17
|
{
|
|
3
18
|
"version": "1.4.11",
|
|
4
19
|
"date": "2026-11-10",
|
package/bin/sintbot-v2.js
CHANGED
|
@@ -114,6 +114,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
114
114
|
let configControl
|
|
115
115
|
let updater
|
|
116
116
|
let liveTransport
|
|
117
|
+
let activity
|
|
117
118
|
let onSigint, onSigterm
|
|
118
119
|
try {
|
|
119
120
|
const migrationFence = require('../src/c2migration').loadC2MigrationFence({ stateDir, routes })
|
|
@@ -139,6 +140,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
139
140
|
// All group Engines must share this exact object: separate Store caches
|
|
140
141
|
// would overwrite sibling sessions when they save the same state file.
|
|
141
142
|
const store = new StateStore(path.join(stateDir, 'state.json'))
|
|
143
|
+
activity = require('../src/ui/liveactivity').createLiveActivity({
|
|
144
|
+
file: path.join(stateDir, 'activity-v1.json'), canWrite: assertWriterLock,
|
|
145
|
+
log: message => process.stderr.write(`Console · ${message}\n`) })
|
|
142
146
|
const closeChecks = {
|
|
143
147
|
claude: require('../src/agent/claudecode').isClosedRunnerResult,
|
|
144
148
|
'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
|
|
@@ -154,11 +158,12 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
154
158
|
const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
|
|
155
159
|
maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
|
|
156
160
|
let finishing = false
|
|
157
|
-
const finish = result => {
|
|
161
|
+
const finish = async result => {
|
|
158
162
|
if (finishing) return
|
|
159
163
|
finishing = true
|
|
160
164
|
ui?.close()
|
|
161
165
|
updater?.close()
|
|
166
|
+
await activity?.close()
|
|
162
167
|
lock.release()
|
|
163
168
|
process.stderr.write(`C2 multi-group · ${result.reason}; platform stopped=${result.platformStopped}; runner drained=${result.drained}\n`,
|
|
164
169
|
() => process.exit(result.code))
|
|
@@ -230,7 +235,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
230
235
|
return result
|
|
231
236
|
}
|
|
232
237
|
engine = new Engine({ ...cfg, groups: { [route.chatId]: route.group } }, {
|
|
233
|
-
store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
|
|
238
|
+
store, activity, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
|
|
234
239
|
durableV2Runtime: runtime, durableV2Payloads: handles,
|
|
235
240
|
durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
|
|
236
241
|
durableV2VerifyRunnerClosed: ({ result, error }) => result
|
|
@@ -284,7 +289,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
284
289
|
process.on('SIGTERM', onSigterm)
|
|
285
290
|
if (uiEnabled && !preflightOnly) {
|
|
286
291
|
ui = require('../src/c2console').createC2Console({
|
|
287
|
-
configPath: cfg._path, stateDir, host: uiHost,
|
|
292
|
+
configPath: cfg._path, stateDir, host: uiHost, activity, store,
|
|
288
293
|
getConfig: () => cfg, getBindings: () => service.bindings,
|
|
289
294
|
getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
|
|
290
295
|
getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
|
|
@@ -339,6 +344,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
339
344
|
throw error
|
|
340
345
|
} finally {
|
|
341
346
|
if (!running) {
|
|
347
|
+
await activity?.close()
|
|
342
348
|
updater?.close()
|
|
343
349
|
if (onSigint) process.off('SIGINT', onSigint)
|
|
344
350
|
if (onSigterm) process.off('SIGTERM', onSigterm)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.12 with an AI assistant
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
|
|
|
15
15
|
## Copyable request
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
Install SintBot 1.4.
|
|
18
|
+
Install SintBot 1.4.12 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
|
|
19
19
|
```
|
package/docs/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.12
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/install.md)
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The installer checks the environment, installs `sintbot@1.4.
|
|
13
|
+
The installer checks the environment, installs `sintbot@1.4.12` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.12`. No sudo is needed.
|
|
14
14
|
|
|
15
15
|
To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
|
|
16
16
|
|
|
@@ -105,7 +105,9 @@ Busy follow-ups default on for both Codex and Claude Code. Set `steer: false` to
|
|
|
105
105
|
|
|
106
106
|
## 6. Console
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
The overview combines group projects and concurrency. Each row shows the runner, state and elapsed time, project folder, model and configured reasoning effort, and the latest reported context usage. Select a group to view its tasks, then expand a task for tools, reply and usage. Context is a session sample, not cumulative token consumption; unknown capacity and older samples are labeled. Row icons provide console messages, stop and new-session controls according to the current group state.
|
|
109
|
+
|
|
110
|
+
With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group, stops tasks, starts a new conversation for an idle group, binds new groups, switches the model or project folder of an idle group, and adds or configures models. For LAN access, set a console password in its settings first, then enable LAN access.
|
|
109
111
|
|
|
110
112
|
Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. A window shows each step, reconnects by itself while the service restarts and can be left running in the background. On the first start after an upgrade, each serving group gets one card listing what changed; to turn this off, clear "Announce upgrades in groups" under Settings → Software updates.
|
|
111
113
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 用 AI 助手安装 SintBot 1.4.
|
|
1
|
+
# 用 AI 助手安装 SintBot 1.4.12
|
|
2
2
|
|
|
3
3
|
[English](../AI-ASSISTED-INSTALL.md) · 简体中文
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
## 可复制的请求
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
按一行安装指南,在这台 Mac 安装 SintBot 1.4.
|
|
18
|
+
按一行安装指南,在这台 Mac 安装 SintBot 1.4.12。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.12
|
|
2
2
|
|
|
3
3
|
[English](../install.md) · 简体中文
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
安装器会自动检查环境,把 `sintbot@1.4.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.12` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.12` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
|
@@ -105,7 +105,9 @@ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队
|
|
|
105
105
|
|
|
106
106
|
## 6. 控制台
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
概览合并群项目与并发信息,每行显示执行器、状态与耗时、项目目录、模型和配置的思考深度、最近上报的上下文用量。选择群后查看对应任务,展开任务查看工具、回复和用量。上下文是当前会话采样,不是累计消耗;容量未知或历史采样会明确标注。群行图标按当前状态提供插话、停止与新会话操作。
|
|
109
|
+
|
|
110
|
+
带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
|
|
109
111
|
|
|
110
112
|
「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级后首次启动会在每个在服务的群发一张版本公告卡,列出这一版的更新;不想播报就在「设置 → 软件更新」取消「升级后向群里播报」。
|
|
111
113
|
|
package/install.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# The function is parsed in full before installation starts, including when piped.
|
|
3
3
|
sintbot_install() (
|
|
4
4
|
set -eu
|
|
5
|
-
version=1.4.
|
|
5
|
+
version=1.4.12
|
|
6
6
|
prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
|
|
7
7
|
fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
|
|
8
8
|
case "${1:-}" in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
|
|
6
6
|
"bin": {
|
|
@@ -135,6 +135,8 @@
|
|
|
135
135
|
"src/ui/console.html",
|
|
136
136
|
"src/ui/dashboard.js",
|
|
137
137
|
"src/ui/feishulogin.js",
|
|
138
|
+
"src/ui/liveactivity-client.js",
|
|
139
|
+
"src/ui/liveactivity.js",
|
|
138
140
|
"src/ui/serverbase.js",
|
|
139
141
|
"src/ui/sessions.js",
|
|
140
142
|
"src/ui/takeover.js",
|
package/src/agent/claudecode.js
CHANGED
|
@@ -439,6 +439,10 @@ function runTurn({
|
|
|
439
439
|
ev.contextTokens = (lastMsgUsage.input_tokens || 0) + (lastMsgUsage.cache_read_input_tokens || 0) + (lastMsgUsage.cache_creation_input_tokens || 0)
|
|
440
440
|
}
|
|
441
441
|
if (ev.type === 'result') {
|
|
442
|
+
const windows = Object.values(raw.modelUsage || {}).map(value => value?.contextWindow)
|
|
443
|
+
// No model-name guesses; a single reported capacity is unambiguous.
|
|
444
|
+
if (windows.length && windows.every(value => Number.isFinite(value) && value > 0) &&
|
|
445
|
+
new Set(windows).size === 1) ev.contextWindowTokens = windows[0]
|
|
442
446
|
// 当前上下文规模 = 最后一次 API 请求的输入侧总量(未命中 + 缓存读 + 缓存写)。
|
|
443
447
|
// result.usage 是整轮跨请求聚合、不能当上下文用;逐请求数在 assistant 事件的
|
|
444
448
|
// message.usage 里(2.1.222 真机核对),兜底走 usage.iterations 末元素(同一形状)。
|
package/src/agent/codexapp.js
CHANGED
|
@@ -143,6 +143,9 @@ function runTurn(opts = {}) {
|
|
|
143
143
|
totalUsage = usageFromTotal(usage.total)
|
|
144
144
|
if (Number.isFinite(usage.last && usage.last.inputTokens) && usage.last.inputTokens > 0) contextTokens = usage.last.inputTokens
|
|
145
145
|
if (Number.isFinite(usage.modelContextWindow) && usage.modelContextWindow > 0) contextWindowTokens = usage.modelContextWindow
|
|
146
|
+
// Observational only: do not add telemetry to the durable transcript.
|
|
147
|
+
if (!completed && contextTokens > 0) legacyEmit({ type: 'system', subtype: 'context',
|
|
148
|
+
sessionId: capturedSessionId, contextTokens, contextWindowTokens })
|
|
146
149
|
}
|
|
147
150
|
const write = (obj) => {
|
|
148
151
|
if (child.stdin.destroyed) throw new Error('codex app-server stdin closed')
|
|
@@ -402,7 +405,8 @@ function runTurn(opts = {}) {
|
|
|
402
405
|
const next = appDeltaNext(messageDeltas.get(params.itemId) || '', delta)
|
|
403
406
|
if (next !== null) {
|
|
404
407
|
messageDeltas.set(params.itemId, next)
|
|
405
|
-
emit({ type: 'assistant', text: delta,
|
|
408
|
+
emit({ type: 'assistant', text: delta,
|
|
409
|
+
...(contextTokens > 0 ? { contextTokens, contextWindowTokens, contextSampled: false } : {}) })
|
|
406
410
|
}
|
|
407
411
|
} else if (action === 4) {
|
|
408
412
|
const item = params.item
|
package/src/c2console.js
CHANGED
|
@@ -8,7 +8,7 @@ const { createDurableConsoleSay } = require('./consolesay')
|
|
|
8
8
|
|
|
9
9
|
// A host facade over existing v2 authorities, not a second execution engine.
|
|
10
10
|
// The allowlist applies after the existing console's authentication boundary.
|
|
11
|
-
const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
|
|
11
|
+
const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity', '/api/activity/detail',
|
|
12
12
|
'/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
|
|
13
13
|
'/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
|
|
14
14
|
'/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
|
|
@@ -39,7 +39,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
|
39
39
|
'/api/update/check', '/api/update/start', '/api/update/ignore']
|
|
40
40
|
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
41
41
|
|
|
42
|
-
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
|
|
42
|
+
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(), activity = null, store = null,
|
|
43
43
|
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
|
|
44
44
|
appliedResets = () => [], now = Date.now,
|
|
45
45
|
// Console injection reaches a group only through the service's own ingress
|
|
@@ -76,7 +76,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
76
76
|
// flight keeps the current session.
|
|
77
77
|
function groupBusy(chatId, binding) {
|
|
78
78
|
const rt = binding.engine?.rts?.get(chatId)
|
|
79
|
-
const snapshot = binding.journal?.snapshot()
|
|
79
|
+
const snapshot = binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot()
|
|
80
80
|
return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
|
|
81
81
|
rt?.v2QueueReceiptIds?.length || snapshot?.receipts?.pending?.length ||
|
|
82
82
|
snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length ||
|
|
@@ -111,7 +111,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
111
111
|
if (!binding || binding.runtime?.closed === true) return 'not-serving'
|
|
112
112
|
const rt = binding.engine?.rts?.get(chatId)
|
|
113
113
|
if (groupBusy(chatId, binding) || rt?.v2RunningJobs || rt?.v2StopControl || rt?.v2SkipWaiter ||
|
|
114
|
-
binding.journal?.snapshot()?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
|
|
114
|
+
(binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot())?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
|
|
115
115
|
return null
|
|
116
116
|
}
|
|
117
117
|
function receiptState(chatId, receiptId) {
|
|
@@ -133,11 +133,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
133
133
|
const binding = bindings().get(chatId)
|
|
134
134
|
const hold = heldGroups.get(chatId)
|
|
135
135
|
const rt = binding?.engine?.rts.get(chatId)
|
|
136
|
-
const snapshot = binding?.journal?.snapshot()
|
|
136
|
+
const snapshot = binding?.journal?.consoleSnapshot?.() || binding?.journal?.snapshot()
|
|
137
137
|
const pending = snapshot?.receipts?.pending || []
|
|
138
138
|
const queued = snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length || 0
|
|
139
139
|
queue += queued
|
|
140
140
|
groups[chatId] = {
|
|
141
|
+
context: activity?.context(chatId, (binding?.engine?.store || store)?.getSession(chatId),
|
|
142
|
+
!!rt?.v2NormalRouteActive, rt?.v2RunnerGeneration) || null,
|
|
141
143
|
busy: !!rt?.v2NormalRouteActive,
|
|
142
144
|
finalizing: !!rt?.v2TerminalStarted,
|
|
143
145
|
followups: !!rt?.v2SteerControl && !rt?.v2SteerClosing && !rt?.v2TerminalStarted,
|
|
@@ -145,7 +147,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
145
147
|
queueFaulted: !group.paused && (!binding || (!rt?.v2NormalRouteActive && !!binding.gate?.blocked)),
|
|
146
148
|
waitingForCapacity: !!rt?.v2AdmissionPending,
|
|
147
149
|
sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
|
|
148
|
-
sessionId: binding?.engine?.store?.getSession(chatId) || '',
|
|
150
|
+
sessionId: (binding?.engine?.store || store)?.getSession(chatId) || '',
|
|
149
151
|
// Why a console injection would be refused right now (null = accepted).
|
|
150
152
|
sayBlocked: say ? sayGate(chatId) : 'not-serving',
|
|
151
153
|
// A held group needs the operator: its last result is unknown.
|
|
@@ -163,29 +165,55 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
163
165
|
function getActivity() {
|
|
164
166
|
const cfg = getConfig(), items = []
|
|
165
167
|
for (const [chatId, binding] of bindings()) {
|
|
166
|
-
const snap = binding.journal.snapshot()
|
|
168
|
+
const snap = binding.journal.consoleSnapshot?.() || binding.journal.snapshot()
|
|
167
169
|
const rt = binding.engine.rts.get(chatId)
|
|
168
170
|
const group = cfg.groups[chatId]?.name || chatId.slice(-8)
|
|
169
171
|
for (const receipt of snap.receipts.pending) {
|
|
172
|
+
if (activity?.has(chatId, receipt.id)) continue
|
|
170
173
|
const running = rt?.v2RunningJobs?.some(job => job.msg?._c2ReceiptId === receipt.id)
|
|
171
174
|
items.push({ id: receipt.id, chatId, group, ts: receipt.acceptedAt,
|
|
172
175
|
kind: running ? 'start' : 'done', status: running ? 'running' : '等待处理',
|
|
173
176
|
text: running ? '正在处理群消息' : '消息已登记,等待处理', sender: '' })
|
|
174
177
|
}
|
|
175
178
|
for (const receipt of snap.receipts.completed.slice(-30)) {
|
|
179
|
+
if (activity?.has(chatId, receipt.id)) continue
|
|
176
180
|
// A settled receipt alone does not prove successful answer delivery.
|
|
177
181
|
items.push({ id: receipt.id, chatId, group, ts: receipt.settledAt,
|
|
178
182
|
kind: 'done', status: '已结账', text: '消息处理记录已结账', sender: '' })
|
|
179
183
|
}
|
|
180
184
|
}
|
|
181
|
-
|
|
185
|
+
const recent = activity?.list({ limit: 100 }).activity || []
|
|
186
|
+
return [...recent, ...items].filter(item => cfg.groups[item.chatId])
|
|
187
|
+
.sort((a, b) => Number(a.kind !== 'start') - Number(b.kind !== 'start') || b.ts - a.ts).slice(0, 100)
|
|
188
|
+
}
|
|
189
|
+
function activityPage(query = {}) {
|
|
190
|
+
const cfg = getConfig(), live = getLiveState()
|
|
191
|
+
const meta = activity?.list({ limit: 1 }) || { epoch: 'receipts', revision: 0 }
|
|
192
|
+
const groups = Object.entries(cfg.groups || {}).map(([chatId, group]) => {
|
|
193
|
+
const binding = bindings().get(chatId), groupStore = binding?.engine?.store || store
|
|
194
|
+
const providerName = groupStore?.getProvider?.(chatId) || group.provider || cfg.defaultProvider
|
|
195
|
+
const provider = cfg.providers?.[providerName] || {}
|
|
196
|
+
const stats = groupStore?.getSessionStats?.(chatId), session = groupStore?.getSession?.(chatId)
|
|
197
|
+
const context = live.groups[chatId].context || (session && stats?.sessionId === session && Number.isFinite(stats.ctx)
|
|
198
|
+
? { usedTokens: stats.ctx, windowTokens: null, measuredAt: null, quality: 'historical' } : null)
|
|
199
|
+
return { chatId, name: group.name || chatId.slice(-8), project: path.basename(group.workdir || '') || '—',
|
|
200
|
+
workdir: group.workdir || '', runner: provider.runner || 'claude',
|
|
201
|
+
model: group.model || provider.model || provider.env?.ANTHROPIC_MODEL || providerName,
|
|
202
|
+
reasoningEffort: (provider.runner === 'codex' ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL) || '',
|
|
203
|
+
paused: !!group.paused, ...live.groups[chatId], context }
|
|
204
|
+
})
|
|
205
|
+
const rows = getActivity().filter(row => (!query.chatId || row.chatId === query.chatId) && (!query.status || row.status === query.status))
|
|
206
|
+
const offset = Math.max(0, Math.min(100, Number(query.offset) || 0)), limit = Math.max(1, Math.min(100, Number(query.limit) || 30))
|
|
207
|
+
return { epoch: meta.epoch, revision: meta.revision, storageError: !!meta.storageError, groups,
|
|
208
|
+
total: rows.length, activity: rows.slice(offset, offset + limit) }
|
|
182
209
|
}
|
|
183
210
|
return {
|
|
184
211
|
capabilities,
|
|
185
212
|
allows: (method, pathname) => (capabilities.allowed[method] || []).includes(pathname),
|
|
186
213
|
// A provider without a runner field is Claude Code, as everywhere else.
|
|
187
214
|
providerSupported: provider => SUPPORTED_RUNNERS.includes(provider?.runner || 'claude'),
|
|
188
|
-
getLiveState, getActivity,
|
|
215
|
+
getLiveState, getActivity, activityPage,
|
|
216
|
+
activityDetail(chatId, id) { return getConfig().groups?.[chatId] ? activity?.detail(chatId, id) || null : null },
|
|
189
217
|
validateConfig(raw) { held(); validateConfig(raw) },
|
|
190
218
|
beforeWrite(raw) { held(); beforeWrite(raw) },
|
|
191
219
|
applyConfig(raw) { held(); applyConfig(raw) },
|
|
@@ -241,7 +269,18 @@ function createC2Console(options = {}) {
|
|
|
241
269
|
inboxDismissPath: path.join(stateDir, 'inbox-dismissed.json'),
|
|
242
270
|
verifyFile: path.join(stateDir, 'verify.json'),
|
|
243
271
|
})
|
|
244
|
-
|
|
272
|
+
let previous = null
|
|
273
|
+
const unsubscribe = options.activity?.subscribe(() => {
|
|
274
|
+
const page = facade.activityPage({ limit: 100 })
|
|
275
|
+
const before = new Map((previous?.activity || []).map(row => [row.chatId + ':' + row.id, JSON.stringify(row)]))
|
|
276
|
+
const current = new Set(page.activity.map(row => row.chatId + ':' + row.id))
|
|
277
|
+
const changed = page.activity.filter(row => before.get(row.chatId + ':' + row.id) !== JSON.stringify(row))
|
|
278
|
+
const groups = page.groups.filter(group => JSON.stringify(previous?.groups.find(g => g.chatId === group.chatId)) !== JSON.stringify(group))
|
|
279
|
+
ui.pushEvent('activity-update', { epoch: page.epoch, revision: page.revision, storageError: page.storageError,
|
|
280
|
+
activity: changed, groups, removed: [...before.keys()].filter(id => !current.has(id)) })
|
|
281
|
+
previous = page
|
|
282
|
+
})
|
|
283
|
+
return { ...ui, facade, close() { unsubscribe?.(); ui.close() } }
|
|
245
284
|
}
|
|
246
285
|
|
|
247
286
|
module.exports = { createC2Console, createC2ConsoleFacade }
|
|
@@ -646,6 +646,16 @@ class C2DurableJournal {
|
|
|
646
646
|
effects: new Map(this.#effects), notices: new Map(this.#notices) }
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
// Bounded diagnostics: the console must not clone all effects and history
|
|
650
|
+
// at progress-event frequency. This is never accepted as recovery authority.
|
|
651
|
+
consoleSnapshot() {
|
|
652
|
+
return { cursor: this.#cursor, state: { lanes: {
|
|
653
|
+
queue: structuredClone(this.#state.lanes?.queue || {}),
|
|
654
|
+
stopIntent: structuredClone(this.#state.lanes?.stopIntent || {}) } },
|
|
655
|
+
receipts: { pending: structuredClone(this.#receipts.pending),
|
|
656
|
+
completed: structuredClone(this.#receipts.completed.slice(-30)) } }
|
|
657
|
+
}
|
|
658
|
+
|
|
649
659
|
#settlementAuthority() {
|
|
650
660
|
return { state: this.#state, receipts: this.#receipts, effects: this.#effects,
|
|
651
661
|
attempts: this.#effectAttempts, descriptors: this.#effectDescriptors,
|
package/src/core/v2engine.js
CHANGED
|
@@ -292,10 +292,17 @@ class Engine {
|
|
|
292
292
|
durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version })
|
|
293
293
|
this.projectMemory = options.projectMemory || null
|
|
294
294
|
this.repoInfo = options.repoInfo || null
|
|
295
|
+
this.activity = options.activity || null
|
|
295
296
|
this.tz = normalizeTz(cfg.timezone)
|
|
296
297
|
this.rts = new Map()
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
// Read-only diagnostics must never change admission, delivery or settlement.
|
|
301
|
+
_observeActivity(method, value) {
|
|
302
|
+
try { this.activity?.[method]?.(value) }
|
|
303
|
+
catch { this.log('Activity display update unavailable') }
|
|
304
|
+
}
|
|
305
|
+
|
|
299
306
|
shutdown() {
|
|
300
307
|
this.durableV2Runtime.close()
|
|
301
308
|
let aborted = 0
|
|
@@ -741,6 +748,7 @@ async runDurableV2Message(msg, platform) {
|
|
|
741
748
|
} catch (error) {
|
|
742
749
|
if (controller.signal.aborted && !rt.v2DispatchEffect &&
|
|
743
750
|
error?.code === 'C2_CAPACITY_WAIT_CANCELLED') {
|
|
751
|
+
this._observeActivity('unknown', { chatId: msg.chatId })
|
|
744
752
|
return { receiptId, status: 'interruptedBeforeDispatch',
|
|
745
753
|
queued: true, ingressClosed: true }
|
|
746
754
|
}
|
|
@@ -763,6 +771,7 @@ async runDurableV2Message(msg, platform) {
|
|
|
763
771
|
// siblings must not dispatch in the microtask before the service sees
|
|
764
772
|
// this rejection and begins its process-wide stop.
|
|
765
773
|
failed = true
|
|
774
|
+
this._observeActivity('unknown', { chatId: msg.chatId })
|
|
766
775
|
this.durableV2SealCapacity?.(error)
|
|
767
776
|
throw error
|
|
768
777
|
} finally {
|
|
@@ -823,6 +832,7 @@ async _runDurableV2Steer(rt, receiptId) {
|
|
|
823
832
|
invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
|
|
824
833
|
entry.accepted = accepted
|
|
825
834
|
if (accepted) {
|
|
835
|
+
try { this.activity?.merge(chatId, identity.parentReceiptId, [receiptId], entry.landed ? 'landed' : 'accepted') } catch {}
|
|
826
836
|
if (entry.landed) {
|
|
827
837
|
authority.commitSteerObservation({ ...identity, type: 'landed' })
|
|
828
838
|
}
|
|
@@ -1100,6 +1110,7 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
1100
1110
|
}
|
|
1101
1111
|
const started = await this.startDurableV2Runner(identity)
|
|
1102
1112
|
if (started.startFailureRecovered === true) {
|
|
1113
|
+
this._observeActivity('finish', { ...identity, status: 'not-started', err: '任务未启动,已通知群内' })
|
|
1103
1114
|
settled.push({ receiptId: identity.receiptId, status: 'startupNotified' })
|
|
1104
1115
|
return { receiptId: firstReceiptId, status: settled[0].status,
|
|
1105
1116
|
settledReceiptIds: settled.map(item => item.receiptId),
|
|
@@ -1170,6 +1181,8 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
1170
1181
|
}
|
|
1171
1182
|
for (const settledReceiptId of cancelled.cancelledReceiptIds ||
|
|
1172
1183
|
[identity.receiptId]) {
|
|
1184
|
+
this._observeActivity('finish', { chatId: identity.chatId, receiptId: settledReceiptId,
|
|
1185
|
+
status: control === 'recall' ? 'recalled' : 'stopped' })
|
|
1173
1186
|
settled.push({ receiptId: settledReceiptId,
|
|
1174
1187
|
status: control === 'recall' ? 'recalled' : 'stopped',
|
|
1175
1188
|
terminalEffectId: null, cancellationEventId: cancelled.eventId })
|
|
@@ -1230,6 +1243,8 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
1230
1243
|
rt.v2TurnToken = null
|
|
1231
1244
|
for (const settledReceiptId of released.settledReceiptIds ||
|
|
1232
1245
|
[identity.receiptId]) {
|
|
1246
|
+
this._observeActivity('finish', { chatId: identity.chatId, receiptId: settledReceiptId,
|
|
1247
|
+
status: 'timeout', err: '任务超过运行时限,已停止' })
|
|
1233
1248
|
settled.push({ receiptId: settledReceiptId,
|
|
1234
1249
|
status: 'timeout', terminalEffectId: null,
|
|
1235
1250
|
cancellationEventId: released.eventId })
|
|
@@ -1361,6 +1376,8 @@ stageDurableV2Message(msg, platform) {
|
|
|
1361
1376
|
const rt = this._rt(msg.chatId)
|
|
1362
1377
|
if (!rt.v2QueueReceiptIds) rt.v2QueueReceiptIds = []
|
|
1363
1378
|
rt.v2QueueReceiptIds.push(receipt.id)
|
|
1379
|
+
this._observeActivity('receive', { chatId: msg.chatId, receiptId: receipt.id,
|
|
1380
|
+
text: msg.text, sender: msg.senderName || '', acceptedAt })
|
|
1364
1381
|
return { receiptId: receipt.id, cursor: result.cursor, staged: true,
|
|
1365
1382
|
ingressClosed: !!authority.blocked }
|
|
1366
1383
|
}
|
|
@@ -1534,6 +1551,9 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1534
1551
|
throw new Error('C2 v2 runner provider unavailable')
|
|
1535
1552
|
}
|
|
1536
1553
|
rt.v2CardModelInfo = cardModelInfo(job.group, provider)
|
|
1554
|
+
this._observeActivity('start', { chatId, receiptId, receiptIds: cohort.receiptIds, generation,
|
|
1555
|
+
sessionId: this.store.getSession(chatId), model: rt.v2CardModelInfo.model,
|
|
1556
|
+
runner: provider.runner, project: projectName(job.group) })
|
|
1537
1557
|
rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
|
|
1538
1558
|
rt.v2SteerControl = null
|
|
1539
1559
|
rt.v2SteerEntries = new Map()
|
|
@@ -1579,6 +1599,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1579
1599
|
if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
|
|
1580
1600
|
if (rt.v2RunnerGeneration !== generation) return
|
|
1581
1601
|
authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
|
|
1602
|
+
try { this.activity?.merge(chatId, receiptId, [entry.identity.receiptId], 'landed') } catch {}
|
|
1582
1603
|
await rt.v2MoveCard?.(entry)
|
|
1583
1604
|
})
|
|
1584
1605
|
rt.v2SteerLandedChain = observation
|
|
@@ -1777,6 +1798,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1777
1798
|
onDurableEvent: event => {
|
|
1778
1799
|
projection.accept(event)
|
|
1779
1800
|
enqueueFrame(event)
|
|
1801
|
+
this._observeActivity('observe', { chatId, receiptId, generation, event,
|
|
1802
|
+
doing: rt.v2Doing, milestones: event.text?.includes('>>') ? progress.milestonesOf(projection.streamSnapshot().bufferText) : undefined })
|
|
1803
|
+
},
|
|
1804
|
+
onEvent: event => {
|
|
1805
|
+
if (event.type === 'system' && event.subtype === 'context')
|
|
1806
|
+
this._observeActivity('observe', { chatId, receiptId, generation, event })
|
|
1780
1807
|
},
|
|
1781
1808
|
onTurnAccepted: value => {
|
|
1782
1809
|
const ack = capability.onTurnAccepted(value)
|
|
@@ -2049,8 +2076,14 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
2049
2076
|
...progress.milestonesOf(payload.bufferText)]
|
|
2050
2077
|
.filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
|
|
2051
2078
|
.filter(m => typeof m === 'string' && m.trim())
|
|
2079
|
+
this._observeActivity('sending', { chatId, receiptId })
|
|
2052
2080
|
const completed = await path.complete({ payload, replyText, paused,
|
|
2053
2081
|
footer, footerDetail, milestones })
|
|
2082
|
+
this._observeActivity('finish', { chatId, receiptId,
|
|
2083
|
+
status: quotaFailure || closedFailure ? 'error' : payload.quiet || projected.display.silent ? 'silent' : 'answered',
|
|
2084
|
+
reply: redact(quotaFailure ? T.quotaFailure : replyText || '', { pii: job.group?.redactPii !== false }),
|
|
2085
|
+
err: quotaFailure ? T.quotaFailure : closedFailure ? terminalReplyText(payload) : '',
|
|
2086
|
+
tokens: rt.v2UsageDecision?.tokens || null, sessionId: projected.display.sessionId })
|
|
2054
2087
|
const after = authority.snapshot()
|
|
2055
2088
|
const queue = after.state?.lanes?.queue?.[chatId]?.queue
|
|
2056
2089
|
let successor = null
|
|
@@ -2269,6 +2302,7 @@ requestDurableV2Stop(chatId) {
|
|
|
2269
2302
|
payloadHandles: this.durableV2Payloads,
|
|
2270
2303
|
assertWriterLock: this.durableV2AssertWriterLock })
|
|
2271
2304
|
rt.v2QueueReceiptIds = rt.v2QueueReceiptIds.filter(id => id !== receiptId)
|
|
2305
|
+
this._observeActivity('finish', { chatId, receiptId, status: 'recalled' })
|
|
2272
2306
|
return true
|
|
2273
2307
|
} catch (error) { authority?.close(); this.log(error.message); return false }
|
|
2274
2308
|
}
|
package/src/ui/console.html
CHANGED
|
@@ -1008,7 +1008,6 @@ d.dataset.theme=t;if(l!=='en'&&l!=='zh')l=/^zh/i.test((navigator.language||''))?
|
|
|
1008
1008
|
<button data-tab="playground" class="navplay" title="试跑 — 从控制台直接跑一轮" aria-label="试跑"><svg class="ic ic-play" aria-hidden="true"><use href="#lu-play"/></svg></button>
|
|
1009
1009
|
</nav>
|
|
1010
1010
|
|
|
1011
|
-
<div id="runtimeCapabilitiesNotice" class="card" hidden style="margin-bottom:16px"><div class="lbl">当前可用操作</div><div class="hint" data-runtime-summary></div></div>
|
|
1012
1011
|
|
|
1013
1012
|
<section id="overview" class="tab on"><div class="empty" style="padding:48px;text-align:center"><span class="spin"></span> 加载中…</div></section>
|
|
1014
1013
|
<section id="providers" class="tab"></section>
|
|
@@ -1027,6 +1026,7 @@ d.dataset.theme=t;if(l!=='en'&&l!=='zh')l=/^zh/i.test((navigator.language||''))?
|
|
|
1027
1026
|
/* 令牌来源:本机环回免密时服务端注入;设了密码时注入空串,退而用登录(密码 / 飞书)签发的会话令牌 ——
|
|
1028
1027
|
记在 localStorage(服务端落盘 30 天,重启不登出);过期或被吊销的令牌只会 401 → 重新登录。 */
|
|
1029
1028
|
let TOKEN='__SBOT_TOKEN__'; // 本机环回免密时服务端注入;为空 = 要登录(⚠ 这一行的形状被服务端单测当锚点抠 token,别改)
|
|
1029
|
+
/*__LIVE_ACTIVITY__*/
|
|
1030
1030
|
const INJECTED_TOKEN=TOKEN; // 记住「是不是注入的」:注入态不显示退出登录
|
|
1031
1031
|
TOKEN=TOKEN||localStorage.getItem('sbot-token')||sessionStorage.getItem('sbot-token')||''; // 登录签发的令牌记在 localStorage(30 天)
|
|
1032
1032
|
const FEISHU_LOGIN='__SBOT_FEISHU_LOGIN__'; // 服务端算好:公网地址 + 凭证 + 管理员名单三样齐才是 '1'
|
|
@@ -1149,6 +1149,7 @@ function startCardHtml(){
|
|
|
1149
1149
|
</div>`;
|
|
1150
1150
|
}
|
|
1151
1151
|
function renderOverview(){
|
|
1152
|
+
if(typeof activityPanel!=='undefined')activityPanel.preserve();
|
|
1152
1153
|
const h=S.health;
|
|
1153
1154
|
const cap=Math.max((S.agent&&S.agent.maxConcurrent)||0,0); // 0 = 不限(无固定槽位 → 不画 gauge)
|
|
1154
1155
|
const segs=cap>0?Array.from({length:cap},()=>'<div class="seg"></div>').join(''):'';
|
|
@@ -1164,13 +1165,13 @@ function renderOverview(){
|
|
|
1164
1165
|
${liveOn?`<button class="iconbtn sm" data-ostop="${esc(g.chatId)}" title="停止当前任务(保留会话)" style="flex:none">${IC.square}</button>
|
|
1165
1166
|
<button class="iconbtn sm" data-onew="${esc(g.chatId)}" title="${S.runtimeCapabilities?'开新会话 — 仅在该群空闲、记录全部结账后可用;运行中请先停止,结束后再开(同群内 /new)':'开新会话 — 运行中会先中断当前任务并清空队列(同群内 /new)'}" style="flex:none">${IC.rotate}</button>`:''}</div>`).join('')
|
|
1166
1167
|
:'<div class="empty">还没有群项目 · 去「群·项目」配置</div>';
|
|
1167
|
-
$('#overview').innerHTML=`${startCardHtml()}<div id="ovInboxWrap">${inboxHtml()}</div><div class="grid cols2 stagger">
|
|
1168
|
+
$('#overview').innerHTML=`${startCardHtml()}<div id="ovInboxWrap">${inboxHtml()}</div><div class="grid ${S.runtimeCapabilities?'':'cols2'} stagger">
|
|
1168
1169
|
<div class="card" id="ovHealth"><div class="lbl">健康</div>
|
|
1169
1170
|
<span class="hpill ${healthClass(h)}" id="ovHealthPill"><span class="led"></span><span id="ovHealthTxt">${healthText(h)}</span></span>
|
|
1170
1171
|
<div class="hrows" id="ovHrows">${h.rows.map(r=>`<div class="hrow" data-hk="${esc(r.label)}"><span class="dot ${r.status}"></span>
|
|
1171
1172
|
<span class="k">${esc(r.label)}</span><span class="v" title="${esc(r.detail)}">${esc(r.detail)}</span></div>`).join('')}</div>
|
|
1172
1173
|
</div>
|
|
1173
|
-
|
|
1174
|
+
${S.runtimeCapabilities?'':`<div class="grid" style="gap:16px">
|
|
1174
1175
|
<div class="card">
|
|
1175
1176
|
<div class="inline" style="justify-content:space-between;align-items:baseline">
|
|
1176
1177
|
<span class="lbl" style="margin:0">并发</span>
|
|
@@ -1179,14 +1180,15 @@ function renderOverview(){
|
|
|
1179
1180
|
${cap>0?`<div class="gauge" id="ovGauge" style="margin-top:13px">${segs}</div>`:''}
|
|
1180
1181
|
</div>
|
|
1181
1182
|
<div class="card ovgroups"><div class="lbl">群 · 项目</div>${gRows}</div>
|
|
1182
|
-
</div
|
|
1183
|
+
</div>`}
|
|
1183
1184
|
</div>
|
|
1184
|
-
<h2 class="sec">实时活动</h2><div class="card" id="ovActivity">${activityHtml()}</div
|
|
1185
|
+
${S.runtimeCapabilities?'<div id="liveActivity"></div>':`<h2 class="sec">实时活动</h2><div class="card" id="ovActivity">${activityHtml()}</div>`}
|
|
1185
1186
|
${issuesHtml()}
|
|
1186
1187
|
${previewsHtml()}
|
|
1187
1188
|
${leftoversHtml()}`;
|
|
1188
1189
|
lastActSig='';
|
|
1189
1190
|
bindActivityClicks();
|
|
1191
|
+
if(S.runtimeCapabilities&&typeof activityPanel!=='undefined')activityPanel.mount();
|
|
1190
1192
|
bindInbox();
|
|
1191
1193
|
// 插话:入口挂在「群·项目」每一行的 ✎ 上 —— 动作长在对象身上,目标群由那一行决定,不必再选。
|
|
1192
1194
|
$$('#overview [data-osay]').forEach(b=>b.onclick=()=>openSay(b.dataset.osay));
|
|
@@ -1374,6 +1376,7 @@ function bindActivityClicks(){
|
|
|
1374
1376
|
let lastActSig='';
|
|
1375
1377
|
function updateOverviewLive(){
|
|
1376
1378
|
if(!S) return;
|
|
1379
|
+
if(S.runtimeCapabilities&&typeof activityPanel!=='undefined')activityPanel.live(S.live);
|
|
1377
1380
|
if(sayRefresh&&$('#sayT')) sayRefresh();
|
|
1378
1381
|
const live=S.live||{};
|
|
1379
1382
|
const active=(live.concurrency&&live.concurrency.active)||0;
|
|
@@ -2610,7 +2613,6 @@ function cronFullHtml(text){
|
|
|
2610
2613
|
}).join('');
|
|
2611
2614
|
}
|
|
2612
2615
|
const RUNTIME_UI_TEXT={
|
|
2613
|
-
"summary": "当前支持多群消息、流式卡片回复、忙时跟进、停止任务、空闲群开新会话、空闲群的控制台插话(这句话和回复都进群)、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换、模型的添加与设置,以及后台运行时的软件升级。群忙时的插话、静默插话、解除绑定、移除模型、定时任务、生图、画布与预览暂不可用。",
|
|
2614
2616
|
"groups": "可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。",
|
|
2615
2617
|
"skills": "当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。",
|
|
2616
2618
|
"cron": "当前不执行定时任务;已有定义仅供查看。",
|
|
@@ -4301,8 +4303,6 @@ updateThemeBtn();
|
|
|
4301
4303
|
⚠ 正则一律 ^…$ 整串、不加 g 标志;更具体的条目必须排在泛化条之前。 */
|
|
4302
4304
|
const I18N_EN=[
|
|
4303
4305
|
["需要处理", "Needs attention"],
|
|
4304
|
-
["当前可用操作", "Available controls"],
|
|
4305
|
-
["当前支持多群消息、流式卡片回复、忙时跟进、停止任务、空闲群开新会话、空闲群的控制台插话(这句话和回复都进群)、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换、模型的添加与设置,以及后台运行时的软件升级。群忙时的插话、静默插话、解除绑定、移除模型、定时任务、生图、画布与预览暂不可用。", "Available: group messages, streaming reply cards, busy-turn followups, stopping tasks, new sessions for idle groups, console messages to idle groups (the line and the reply both appear in the group), live status, authentication, binding new groups, switching the model (Codex / Claude Code) or project of an idle group, adding and configuring models, and software upgrades in a background run. Console messages to busy groups, silent console messages, unbinding, removing models, schedules, image generation, canvas and previews are unavailable."],
|
|
4306
4306
|
["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。", "Bind new groups and change the model, project folder, permissions, data display and busy-turn followups of a group with no running task or unsettled record. Changing the model or project starts a new conversation. Stop tasks and start new sessions in Overview; a new session needs the group to be idle. Members can also switch models with /model in the group."],
|
|
4307
4307
|
["当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。", "Feishu text messages, busy-turn followups, task replies and stopping are supported. Schedules, image generation, group message management, canvas and previews are unavailable."],
|
|
4308
4308
|
["当前不执行定时任务;已有定义仅供查看。", "Schedules are not executed. Existing definitions are shown for reference only."],
|
|
@@ -5965,10 +5965,7 @@ function disableRuntimeControl(el,reason){
|
|
|
5965
5965
|
}
|
|
5966
5966
|
}
|
|
5967
5967
|
function applyRuntimeCapabilities(){
|
|
5968
|
-
const banner=document.getElementById('runtimeCapabilitiesNotice');
|
|
5969
|
-
if(banner)banner.hidden=!(S&&S.runtimeCapabilities);
|
|
5970
5968
|
if(!S||!S.runtimeCapabilities)return;
|
|
5971
|
-
if(banner){const body=banner.querySelector('[data-runtime-summary]'),text=trText(RUNTIME_UI_TEXT.summary);if(body&&body.textContent!==text)body.textContent=text;}
|
|
5972
5969
|
document.querySelectorAll(RUNTIME_DISABLED_SELECTORS).forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
|
|
5973
5970
|
// 插话照服务端白名单放行(第 1 版只接空闲群,忙不忙在浮层里按实时状态说)
|
|
5974
5971
|
if(!runtimeAllows('POST','/api/groups/say'))document.querySelectorAll('[data-osay]').forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
|
|
@@ -6002,6 +5999,7 @@ function applyTurnEvent(a){
|
|
|
6002
5999
|
// reconnect drops, and rows the self-heal resolved to a bare '✓' without their real done
|
|
6003
6000
|
// data. Idempotent (id-keyed merge). Called on load + on every SSE (re)connect.
|
|
6004
6001
|
async function syncActivity(repaint){
|
|
6002
|
+
if(S?.runtimeCapabilities&&typeof activityPanel!=='undefined')return activityPanel.sync();
|
|
6005
6003
|
try{const d=await api('/api/activity'); if(d&&d.activity){d.activity.forEach(applyTurnEvent); if(repaint&&$('#overview').innerHTML)updateOverviewLive();}}catch(e){}
|
|
6006
6004
|
}
|
|
6007
6005
|
|
|
@@ -6012,10 +6010,11 @@ function startLive(){
|
|
|
6012
6010
|
if(esRef){try{esRef.close();}catch{}}
|
|
6013
6011
|
helloN=0;
|
|
6014
6012
|
const es=new EventSource('/api/events?token='+encodeURIComponent(TOKEN));esRef=es;
|
|
6013
|
+
es.addEventListener('activity-update',ev=>{try{activityPanel.receive(JSON.parse(ev.data));}catch{}});
|
|
6015
6014
|
// hello fires on first connect AND every native reconnect → replay the buffer to
|
|
6016
6015
|
// recover whatever SSE dropped while we were away (SSE has no replay of its own);
|
|
6017
6016
|
// 重连(非首连)还拉一次全量 state —— 断流期间的 live/conn 变化就地补齐,不整页重渲染。
|
|
6018
|
-
es.addEventListener('hello',()=>{$('#conn').classList.add('live');syncActivity(true);
|
|
6017
|
+
es.addEventListener('hello',()=>{$('#conn').classList.add('live');if(typeof activityPanel!=='undefined')activityPanel.connected(true);syncActivity(true);
|
|
6019
6018
|
if(++helloN>1)api('/api/state').then(r=>{S=r;updateDaemon();updateTopUpdateBadge();updateOverviewLive();updateConnLive();startClock();}).catch(()=>{});});
|
|
6020
6019
|
es.addEventListener('tick',ev=>{try{const d=JSON.parse(ev.data);if(S){if(d.live)S.live=d.live;if('conn' in d)S.conn=d.conn;}if(d.activity)d.activity.forEach(applyTurnEvent);updateDaemon();updateOverviewLive();updateConnLive();}catch{}});
|
|
6021
6020
|
startAutoRefresh();
|
|
@@ -6023,7 +6022,7 @@ function startLive(){
|
|
|
6023
6022
|
es.addEventListener('turn',ev=>{try{const d=JSON.parse(ev.data);applyTurnEvent(d);updateOverviewLive();
|
|
6024
6023
|
// 收件箱跟着轮次走:刚失败的要尽快出现,重说成功的要尽快消失 —— 不等 60 秒的例行刷新;只取这一块,不拉整份 state
|
|
6025
6024
|
if(d.kind==='done')refreshInbox();}catch(e){}});
|
|
6026
|
-
es.onerror=()=>{$('#conn').classList.remove('live');checkStaleAuth();};
|
|
6025
|
+
es.onerror=()=>{$('#conn').classList.remove('live');if(typeof activityPanel!=='undefined')activityPanel.connected(false);checkStaleAuth();};
|
|
6027
6026
|
}catch(e){}
|
|
6028
6027
|
}
|
|
6029
6028
|
/* SSE 断流自愈:daemon 重启会作废内存里的 token(页面 token / 登录 session 都是),
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/* Injected into the console's existing script; no framework or extra connection. */
|
|
2
|
+
const activityPanel = (() => {
|
|
3
|
+
let root, groupBody, listBody, taskBody, selected = '', count = 5
|
|
4
|
+
try { selected = sessionStorage.getItem('sintbot.activity.group') || '' } catch {}
|
|
5
|
+
let epoch = '', revision = -1, loaded = false, pending = null, frames = [], scheduled = false
|
|
6
|
+
const rows = new Map(), groups = new Map(), detailCache = new Map(), detailPending = new Set(), detailErrors = new Map(), open = new Set()
|
|
7
|
+
const groupNodes = new Map(), rowNodes = new Map(), actionPending = new Set()
|
|
8
|
+
let lang = '', error = false, connected = true, focusBeforeRender = null, groupScroll = 0
|
|
9
|
+
const key = r => r.chatId + ':' + r.id
|
|
10
|
+
const en = () => langOf() === 'en'
|
|
11
|
+
const tr = (zh, english) => en() ? english : zh
|
|
12
|
+
const labels = {
|
|
13
|
+
queued: ['排队', 'Queued'], running: ['运行中', 'Running'], sending: ['回复发送中', 'Sending reply'],
|
|
14
|
+
answered: ['已回复', 'Replied'], silent: ['无需回复', 'No reply needed'], stopped: ['已停止', 'Stopped'],
|
|
15
|
+
recalled: ['已撤回', 'Recalled'], timeout: ['已超时', 'Timed out'], error: ['失败', 'Failed'],
|
|
16
|
+
'not-started': ['未启动', 'Not started'], unknown: ['结果待核实', 'Unconfirmed'],
|
|
17
|
+
'已结账': ['历史记录已结账', 'Historical receipt settled'], '等待处理': ['等待处理', 'Waiting'],
|
|
18
|
+
}
|
|
19
|
+
const status = s => labels[s] ? tr(...labels[s]) : s
|
|
20
|
+
const active = r => ['queued', 'running', 'sending', '等待处理'].includes(r.status)
|
|
21
|
+
const num = n => n == null ? '—' : n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(n)
|
|
22
|
+
const stamp = ts => ts ? new Date(ts).toLocaleTimeString(en() ? 'en-GB' : 'zh-CN', { hour: '2-digit', minute: '2-digit', timeZone: S?.timezone || 'Asia/Shanghai' }) : '—'
|
|
23
|
+
const node = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n }
|
|
24
|
+
const text = (n, value) => { if (n.textContent !== value) n.textContent = value }
|
|
25
|
+
function schedule() {
|
|
26
|
+
if (scheduled || document.hidden) return
|
|
27
|
+
scheduled = true; requestAnimationFrame(() => { scheduled = false; paint() })
|
|
28
|
+
}
|
|
29
|
+
function setup() {
|
|
30
|
+
if (root) return
|
|
31
|
+
root = node('section', 'la'); root.setAttribute('data-i18n-skip', '')
|
|
32
|
+
const style = node('style'); style.textContent = `
|
|
33
|
+
.la{margin:20px 0}.la [hidden]{display:none!important}.la .la-panel{padding:0;overflow:hidden}
|
|
34
|
+
.la .la-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px;border-bottom:1px solid var(--line)}
|
|
35
|
+
.la h2{font-size:15px;font-weight:600;letter-spacing:0;margin:0}.la .la-capacity{font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums;text-align:right}
|
|
36
|
+
.la .la-select,.la .la-toggle{font:inherit;width:100%;border:0;border-radius:0;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:48px}
|
|
37
|
+
.la .la-group{position:relative;display:flex;align-items:center;border-bottom:1px solid var(--line);transition:background .18s ease}.la .la-select{flex:1;min-width:0;display:grid;grid-template-columns:minmax(160px,1.2fr) minmax(120px,1fr) minmax(160px,1fr);gap:20px;padding:15px 20px 15px 24px}.la .la-select>div{min-width:0}
|
|
38
|
+
.la .la-group:last-child{border-bottom:0}.la .la-group:before{content:'';position:absolute;inset:12px auto 12px 0;width:3px;border-radius:0 3px 3px 0;background:var(--ember);opacity:0;transform:scaleY(.4);transition:opacity .18s,transform .18s}
|
|
39
|
+
.la .la-select strong,.la .la-select small,.la .la-model>span,.la .la-context>span{display:block;white-space:normal;overflow-wrap:anywhere}.la .la-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}.la .la-tags .tag{font-size:10px;padding:2px 6px}.la .la-tags [data-status=running]{color:var(--ember);border-color:color-mix(in srgb,var(--ember) 30%,var(--line))}.la .la-tags [data-status=attention]{color:var(--fail)}.la .la-path{font-family:var(--mono);font-size:10.5px}
|
|
40
|
+
.la .la-group:hover,.la .la-toggle:hover{background:color-mix(in srgb,var(--ember) 4%,transparent)}
|
|
41
|
+
.la .la-group.selected{background:color-mix(in srgb,var(--ember) 7%,transparent)}.la .la-group.selected:before{opacity:1;transform:scaleY(1)}
|
|
42
|
+
.la button:focus-visible{outline:2px solid var(--ember);outline-offset:-3px}.la button:active:not(:disabled){background:color-mix(in srgb,var(--ember) 11%,transparent)}
|
|
43
|
+
.la small{display:block;color:var(--dim);font-size:11px;line-height:1.5;margin-top:4px}.la strong{font-size:13px;font-weight:600}.la .la-model{font-size:12px}.la .la-model small[data-status=running]{color:var(--ember)}.la .la-model small[data-status=attention]{color:var(--fail)}.la .la-context{font-size:12px;font-variant-numeric:tabular-nums}
|
|
44
|
+
.la .la-track{height:3px;margin-top:8px;background:var(--line);overflow:hidden;border-radius:2px}.la .la-fill{height:100%;background:var(--ember);transform-origin:left;transition:transform .3s ease-out;transform:scaleX(0)}
|
|
45
|
+
.la .la-groups{max-height:560px;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
|
|
46
|
+
.la .la-task-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:22px 0 12px}.la .la-task-head strong{font-size:14px;overflow-wrap:anywhere}
|
|
47
|
+
.la .la-actions{display:flex;gap:6px;flex-shrink:0;padding-right:24px}.la .la-actions .iconbtn{padding:0;transition:background .15s,border-color .15s,transform .15s}.la .la-actions .iconbtn:active:not(:disabled){transform:scale(.92)}.la button:disabled{cursor:default;opacity:.45}
|
|
48
|
+
.la .la-list{padding:0;overflow:hidden}.la .la-row{border-bottom:1px solid var(--line)}.la .la-row:last-child{border-bottom:0}
|
|
49
|
+
.la .la-toggle{padding:16px 20px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;transition:background .15s}
|
|
50
|
+
.la .la-title{font-size:13px;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:5px;overflow-wrap:anywhere}
|
|
51
|
+
.la .la-preview{font-size:12px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:5px}
|
|
52
|
+
.la .la-state{font-size:11px;color:var(--dim);white-space:nowrap;padding-top:4px}.la .la-state:after{content:'';display:inline-block;width:5px;height:5px;border-right:1px solid currentColor;border-bottom:1px solid currentColor;transform:rotate(45deg);margin:0 2px 3px 14px;transition:transform .18s}
|
|
53
|
+
.la [aria-expanded=true] .la-state:after{transform:rotate(225deg)}.la .la-state[data-state=running],.la .la-state[data-state=sending]{color:var(--ember)}
|
|
54
|
+
.la .la-state[data-state=error],.la .la-state[data-state=unknown],.la .la-state[data-state=timeout]{color:var(--fail)}
|
|
55
|
+
.la .la-fold{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .2s ease,opacity .2s ease}.la .la-fold.open{grid-template-rows:1fr;opacity:1}
|
|
56
|
+
.la .la-fold-inner{min-height:0;overflow:hidden}.la .la-detail{padding:0 20px 18px;font-size:13px;line-height:1.7;overflow-wrap:anywhere}
|
|
57
|
+
.la .la-detail .aexp{margin:0;max-height:360px;white-space:normal}.la .la-detail .aereply{white-space:pre-wrap}.la .la-detail .aemeta{gap:6px 16px;white-space:normal}.la .la-detail .la-source{margin-top:10px;border-top:1px solid var(--line);padding-top:8px}.la .la-source summary{cursor:pointer;color:var(--dim);font-size:11px}.la .la-source summary:focus-visible{outline:2px solid var(--ember);outline-offset:3px}.la .la-detail .la-error{color:var(--fail)}.la .la-detail p{white-space:pre-wrap;margin:8px 0}.la .la-detail h3{font-size:11px;color:var(--dim);margin:16px 0 5px;font-weight:500}.la .la-notice{font-size:12px;color:var(--dim);margin:12px 0;line-height:1.6}
|
|
58
|
+
.la .la-more{margin:12px auto 0;display:block;min-height:40px;font-size:12px}
|
|
59
|
+
@media(max-width:640px){.la .la-head{padding:16px;gap:10px}.la .la-capacity{font-size:11px}.la .la-group{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.la .la-select{grid-column:1/-1;grid-row:1;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px 16px;padding:14px 16px}.la .la-select>div:first-child{padding-right:94px;grid-column:1/-1}.la .la-actions{grid-column:2;grid-row:1;z-index:1;padding:14px 16px 0 0}.la .la-model{grid-column:1/-1}.la .la-context{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:0 8px}.la .la-context small{margin:0;font-size:10px}.la .la-track{grid-column:1/-1;margin-top:6px}.la .la-task-head{align-items:flex-start;flex-direction:column;gap:10px}.la .la-actions{flex-wrap:wrap}.la .la-toggle{grid-template-columns:minmax(0,1fr);gap:7px;padding:14px 16px}.la .la-state{white-space:normal;padding:0}.la .la-state:after{float:right}.la .la-detail{padding:0 16px 16px}}
|
|
60
|
+
@media(prefers-reduced-motion:reduce){.la *{transition:none!important;animation:none!important}}`
|
|
61
|
+
root.append(style)
|
|
62
|
+
const panel = node('div', 'card la-panel'), head = node('div', 'la-head')
|
|
63
|
+
head.append(node('h2'), node('span', 'la-capacity')); panel.append(head)
|
|
64
|
+
groupBody = node('div', 'la-groups'); panel.append(groupBody); root.append(panel)
|
|
65
|
+
const note = node('div', 'la-notice'); note.dataset.la = 'note'; note.setAttribute('role', 'status'); root.append(note)
|
|
66
|
+
taskBody = node('div', 'la-task-body'); taskBody.hidden = true
|
|
67
|
+
const bar = node('div', 'la-task-head'), title = node('strong'); title.dataset.la = 'activity'; bar.append(title)
|
|
68
|
+
taskBody.append(bar)
|
|
69
|
+
listBody = node('div', 'card la-list'); taskBody.append(listBody)
|
|
70
|
+
const more = node('button', 'btn la-more'); more.dataset.la = 'more'; more.onclick = () => { count += 5; schedule() }; taskBody.append(more)
|
|
71
|
+
root.append(taskBody)
|
|
72
|
+
}
|
|
73
|
+
async function act(action, button, chatId) {
|
|
74
|
+
if (!chatId || button.disabled) return
|
|
75
|
+
if (action === 'say') { openSay(chatId); return }
|
|
76
|
+
const actionKey = chatId + ':' + action
|
|
77
|
+
actionPending.add(actionKey); button.disabled = true
|
|
78
|
+
try {
|
|
79
|
+
const result = await api(action === 'stop' ? '/api/groups/stop' : '/api/groups/interrupt','POST',{chatId})
|
|
80
|
+
toast(action === 'new' ? tr('已开新会话','New session started') : result.ok ? tr('已请求停止,等待结束确认','Stop requested; waiting for confirmation') : tr('当前没有可停止的运行任务','No running task to stop'),'ok')
|
|
81
|
+
await sync()
|
|
82
|
+
} catch (e) { toast(e.message,'err') }
|
|
83
|
+
finally { actionPending.delete(actionKey); schedule() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function groupNode(g) {
|
|
87
|
+
let n = groupNodes.get(g.chatId)
|
|
88
|
+
if (!n) {
|
|
89
|
+
n = node('div', 'la-group'); n.dataset.chatId = g.chatId
|
|
90
|
+
const select = node('button', 'la-select'); select.type = 'button'
|
|
91
|
+
const a = node('div'), b = node('div', 'la-model'), c = node('div', 'la-context')
|
|
92
|
+
const tags = node('div','la-tags'); tags.append(node('span','tag prov'),node('span','tag'))
|
|
93
|
+
a.append(node('strong'), tags, node('small','la-path')); b.append(node('span'), node('small'))
|
|
94
|
+
const track = node('div', 'la-track'); track.append(node('div', 'la-fill'))
|
|
95
|
+
c.append(node('span'), node('small'), track); select.append(a, b, c); n.append(select)
|
|
96
|
+
const actions = node('div','la-actions')
|
|
97
|
+
for (const [action,icon] of [['say','say'],['stop','square'],['new','rotate']]) {
|
|
98
|
+
const button = node('button','iconbtn sm'); button.type = 'button'; button.dataset.action = action
|
|
99
|
+
button.innerHTML = IC[icon]; button.onclick = () => void act(action,button,g.chatId); actions.append(button)
|
|
100
|
+
}
|
|
101
|
+
n.append(actions)
|
|
102
|
+
select.onclick = () => {
|
|
103
|
+
if (selected === g.chatId) return
|
|
104
|
+
selected = g.chatId; count = 5
|
|
105
|
+
try { sessionStorage.setItem('sintbot.activity.group', selected) } catch {}
|
|
106
|
+
schedule(); requestAnimationFrame(() => {
|
|
107
|
+
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
108
|
+
taskBody.getAnimations().forEach(animation => animation.cancel())
|
|
109
|
+
taskBody.animate([{opacity:0,transform:'translateY(4px)'},{opacity:1,transform:'translateY(0)'}],{duration:180,easing:'ease-out'})
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
groupNodes.set(g.chatId, n)
|
|
114
|
+
}
|
|
115
|
+
const [a,b,c] = n.children[0].children, ctx = g.context
|
|
116
|
+
text(a.children[0], g.name); text(a.children[2], g.workdir ? shortPath(g.workdir) : g.project)
|
|
117
|
+
a.children[2].title = g.workdir || g.project
|
|
118
|
+
text(a.children[1].children[0], g.runner === 'codex' ? 'codex' : 'claude')
|
|
119
|
+
const state = a.children[1].children[1]
|
|
120
|
+
text(state, g.held || g.queueFaulted ? tr('需要处理','Needs attention') : g.paused ? tr('已暂停','Paused') : g.finalizing ? tr('收尾中','Finishing') : g.waitingForCapacity ? tr('等待容量','Waiting for capacity') : g.busy ? 'busy' : 'idle')
|
|
121
|
+
state.dataset.status = g.held || g.queueFaulted ? 'attention' : g.busy ? 'running' : 'idle'
|
|
122
|
+
if (g.busy) text(state, state.textContent + ' ' + Math.floor((g.sinceMs || 0)/1000) + 's')
|
|
123
|
+
if (g.queue) text(state, state.textContent + ' · ' + tr('排队 ','Queued ') + g.queue)
|
|
124
|
+
text(b.children[0], g.model)
|
|
125
|
+
text(b.children[1], tr('思考深度 · ','Reasoning · ') + (g.reasoningEffort || tr('默认','Default')))
|
|
126
|
+
b.children[1].title = tr('当前模型配置;未指定时使用模型默认值','Current model configuration; unspecified uses the model default')
|
|
127
|
+
const blocked = !S?.live?.running || g.paused || g.held || g.queueFaulted
|
|
128
|
+
const busy = g.busy || g.pending > 0 || g.queue > 0 || g.waitingForCapacity || g.finalizing
|
|
129
|
+
for (const [action,zh,eng] of [['say','插话','Message'],['stop','停止任务','Stop task'],['new','新会话','New session']]) {
|
|
130
|
+
const button = n.querySelector('[data-action='+action+']')
|
|
131
|
+
button.disabled = actionPending.has(g.chatId + ':' + action) || blocked || (action === 'stop' ? !busy : !!busy || action === 'say' && !!g.sayBlocked)
|
|
132
|
+
button.setAttribute('aria-label',tr(zh,eng) + ' · ' + g.name)
|
|
133
|
+
button.title = tr(zh,eng) + ' · ' + (button.disabled ? tr('当前状态不可操作','Unavailable in the current state') : action === 'say' ? tr('这句话和回复都会发进该群','Your message and reply will be sent to this group') : action === 'new' ? tr('开始新会话,清除当前会话上下文','Start a new session with fresh context') : tr('停止当前任务,保留会话','Stop the task and keep the session'))
|
|
134
|
+
}
|
|
135
|
+
const valid = ctx?.usedTokens != null, capacity = ctx?.windowTokens > 0
|
|
136
|
+
const percent = valid && capacity ? Math.round(ctx.usedTokens / ctx.windowTokens * 100) : null
|
|
137
|
+
text(c.children[0], valid ? `${num(ctx.usedTokens)}${capacity ? ' / ' + num(ctx.windowTokens) + ' · ' + percent + '%' : ' · ' + tr('容量未知','capacity unknown')}` : ctx?.quality === 'compacted' ? tr('压缩后待上报','Awaiting usage after compaction') : !g.sessionId && !g.busy ? tr('未开始','No session') : tr('待上报','Awaiting usage'))
|
|
138
|
+
const quality = ctx?.quality === 'reported' ? tr('上下文 · 最新上报','Context · Latest report') : ctx?.quality === 'historical' ? tr('上下文 · 历史采样','Context · Historical sample') : tr('上下文 · 上次上报','Context · Last report')
|
|
139
|
+
text(c.children[1], ctx ? quality + ' · ' + stamp(ctx.measuredAt) : tr('上下文','Context'))
|
|
140
|
+
c.children[2].children[0].style.transform = 'scaleX(' + (percent == null ? 0 : Math.max(0, Math.min(100, percent)) / 100) + ')'
|
|
141
|
+
n.title = g.name + ' · ' + g.project + ' · ' + g.model + ' · ' + g.chatId.slice(-6)
|
|
142
|
+
n.classList.toggle('selected',selected === g.chatId); n.children[0].setAttribute('aria-pressed', String(selected === g.chatId)); return n
|
|
143
|
+
}
|
|
144
|
+
async function showDetail(r, n) {
|
|
145
|
+
const k = key(r), body = n.querySelector('.la-detail')
|
|
146
|
+
if (!open.has(k)) return
|
|
147
|
+
if (!r.details) { text(body, tr('历史记录未保存消息和回复详情。','Message and reply details were not recorded.')); return }
|
|
148
|
+
if (detailErrors.get(k) === r.detailRevision) return
|
|
149
|
+
let d = detailCache.get(k)
|
|
150
|
+
if (!d || d.detailRevision !== r.detailRevision) {
|
|
151
|
+
if (detailPending.has(k)) return
|
|
152
|
+
detailPending.add(k)
|
|
153
|
+
text(body, tr('加载详情…','Loading details…'))
|
|
154
|
+
try {
|
|
155
|
+
const value = await api('/api/activity/detail?chatId=' + encodeURIComponent(r.chatId) + '&id=' + encodeURIComponent(r.id))
|
|
156
|
+
if (epoch !== value.epoch) return
|
|
157
|
+
d = { ...value, lang: langOf() }; detailCache.set(k, d)
|
|
158
|
+
while (detailCache.size > 40) detailCache.delete(detailCache.keys().next().value)
|
|
159
|
+
} catch { detailErrors.set(k,r.detailRevision);text(body, tr('详情暂不可用,收起后可重新打开。','Details unavailable. Close and reopen to retry.')); return }
|
|
160
|
+
finally { detailPending.delete(k) }
|
|
161
|
+
}
|
|
162
|
+
if (!open.has(k) || !n.isConnected) return
|
|
163
|
+
const sig = d.detailRevision + ':' + langOf()
|
|
164
|
+
if (body.dataset.rev === sig) return
|
|
165
|
+
body.dataset.rev = sig; body.replaceChildren()
|
|
166
|
+
const box = node('div','aexp'); body.append(box)
|
|
167
|
+
if (d.tools?.length) {
|
|
168
|
+
const tools = node('div','aetools')
|
|
169
|
+
for (const tool of d.tools) {
|
|
170
|
+
const chip = node('span','tchip'); chip.innerHTML = IC.wrench
|
|
171
|
+
chip.append(document.createTextNode(' ' + tool.name + (tool.count > 1 ? ' ×' + tool.count : ''))); tools.append(chip)
|
|
172
|
+
}
|
|
173
|
+
box.append(tools)
|
|
174
|
+
}
|
|
175
|
+
box.append(node('div',d.reply ? 'aereply' : 'faint',d.reply || (active(d) ? tr('任务进行中,等待回复…','Task in progress; awaiting reply…') : tr('(无回复内容)','(No reply content)'))))
|
|
176
|
+
if (d.err) box.append(node('p','la-error',d.err))
|
|
177
|
+
const meta = node('div','aemeta')
|
|
178
|
+
function metric(label,value) { const item = node('span'); item.append(node('b','',label+' '),document.createTextNode(value)); meta.append(item) }
|
|
179
|
+
if (d.sender) metric(tr('发送者','Sender'),d.sender)
|
|
180
|
+
metric(tr('状态','Status'),status(d.status))
|
|
181
|
+
if (d.startedAt) metric(tr('排队','Queued'),(Math.max(0,d.startedAt-d.ts)/1000).toFixed(1)+'s')
|
|
182
|
+
if (d.ms != null) metric(tr('耗时','Duration'),(d.ms/1000).toFixed(1)+'s')
|
|
183
|
+
if (d.tokens) {
|
|
184
|
+
const cr=d.tokens.cr||0,cw=d.tokens.cw||0
|
|
185
|
+
metric('tokens',`↑${num((d.tokens.in||0)+cr+cw)} ↓${num(d.tokens.out||0)}` + (cr||cw ? ' · '+tr('缓存读 ','Cache read ')+num(cr)+tr(' / 写 ',' / write ')+num(cw) : ''))
|
|
186
|
+
}
|
|
187
|
+
box.append(meta)
|
|
188
|
+
if (d.messages?.length || d.milestones?.length) {
|
|
189
|
+
const source = node('details','la-source'); source.append(node('summary','',tr('请求、补充与阶段进度','Request, follow-ups and progress')))
|
|
190
|
+
for (const message of d.messages || []) source.append(node('p','',(message.sender || '—')+' · '+(message.state==='landed'?tr('已读到','Read'):tr('已受理','Accepted'))+'\n'+message.text))
|
|
191
|
+
for (const milestone of d.milestones || []) source.append(node('p','',milestone))
|
|
192
|
+
box.append(source)
|
|
193
|
+
}
|
|
194
|
+
if (d.truncated) box.append(node('p','faint',tr('内容较长,记录已截断。','Long content was truncated in this record.')))
|
|
195
|
+
|
|
196
|
+
}
|
|
197
|
+
function rowNode(r) {
|
|
198
|
+
const k = key(r); let n = rowNodes.get(k)
|
|
199
|
+
if (!n) {
|
|
200
|
+
n = node('article', 'la-row'); const button = node('button','la-toggle'); button.type = 'button'
|
|
201
|
+
const lead = node('div'); lead.append(node('small'),node('div','la-title'),node('div','la-preview'))
|
|
202
|
+
button.append(lead,node('span','la-state')); n.append(button)
|
|
203
|
+
const fold = node('div','la-fold'), inner = node('div','la-fold-inner'), detail = node('div','la-detail')
|
|
204
|
+
fold.id = 'la-detail-' + rowNodes.size + '-' + Math.random().toString(36).slice(2,8)
|
|
205
|
+
inner.append(detail); fold.append(inner); n.append(fold)
|
|
206
|
+
button.setAttribute('aria-controls', fold.id)
|
|
207
|
+
button.onclick = () => { open.has(k) ? open.delete(k) : open.add(k);detailErrors.delete(k);schedule() }
|
|
208
|
+
rowNodes.set(k,n)
|
|
209
|
+
}
|
|
210
|
+
const b = n.children[0], lead = b.children[0], group = groups.get(r.chatId)
|
|
211
|
+
text(lead.children[0], stamp(r.ts) + ' · ' + (r.sender || '—'))
|
|
212
|
+
text(lead.children[1], r.text || tr('内容未记录','Content not recorded'))
|
|
213
|
+
text(lead.children[2], active(r) ? (r.doing || []).join(' · ') : r.preview || '')
|
|
214
|
+
text(b.children[1], status(r.status) + (r.messageCount > 1 ? ' · ' + tr('消息 ','messages ') + r.messageCount : '') + (r.ms ? ' · ' + (r.ms/1000).toFixed(1)+'s' : ''))
|
|
215
|
+
b.children[1].dataset.state = r.status; b.setAttribute('aria-expanded', String(open.has(k)))
|
|
216
|
+
n.children[1].classList.toggle('open', open.has(k)); n.children[1].inert = !open.has(k)
|
|
217
|
+
if (open.has(k)) void showDetail(r,n)
|
|
218
|
+
return n
|
|
219
|
+
}
|
|
220
|
+
function reconcile(container, nodes) {
|
|
221
|
+
const wanted = new Set(nodes)
|
|
222
|
+
for (const child of [...container.children]) if (!wanted.has(child)) child.remove()
|
|
223
|
+
nodes.forEach((n,i) => { if (container.children[i] !== n) container.insertBefore(n,container.children[i] || null) })
|
|
224
|
+
}
|
|
225
|
+
function paint() {
|
|
226
|
+
if (!root?.isConnected) return
|
|
227
|
+
lang = langOf()
|
|
228
|
+
text(root.querySelector('h2'),tr('群 · 项目','Groups · Projects'))
|
|
229
|
+
const concurrency = S?.live?.concurrency, capacity = concurrency?.max ?? S?.agent?.maxConcurrent ?? 0
|
|
230
|
+
text(root.querySelector('.la-capacity'), tr('运行 ','Running ') + (concurrency?.active ?? [...groups.values()].filter(g=>g.busy).length) + (capacity > 0 ? ' / ' + capacity : '') + ' · ' + tr('排队 ','Queued ') + (S?.live?.queue || 0))
|
|
231
|
+
if (loaded && selected && !groups.has(selected)) { selected = ''; try { sessionStorage.removeItem('sintbot.activity.group') } catch {} }
|
|
232
|
+
reconcile(groupBody, groups.size ? [...groups.values()].map(groupNode) : [node('div','empty',loaded ? tr('暂无群项目','No projects') : tr('正在同步…','Syncing…'))])
|
|
233
|
+
const group = groups.get(selected)
|
|
234
|
+
taskBody.hidden = !group
|
|
235
|
+
text(root.querySelector('[data-la=activity]'), (group?.name || '') + ' · ' + tr('任务活动','Task activity'))
|
|
236
|
+
const all = [...rows.values()].filter(r => selected && r.chatId===selected)
|
|
237
|
+
.sort((a,b) => Number(!active(a))-Number(!active(b)) || b.ts-a.ts)
|
|
238
|
+
const shown = all.filter(active).concat(all.filter(r=>!active(r)).slice(0,count))
|
|
239
|
+
reconcile(listBody, shown.length ? shown.map(rowNode) : [node('div','empty',loaded ? tr('该群暂无任务活动','No task activity for this group') : tr('正在同步…','Syncing…'))])
|
|
240
|
+
const more = root.querySelector('[data-la=more]'); more.hidden=shown.length>=all.length;text(more,tr('查看更早的任务','Show earlier tasks'))
|
|
241
|
+
const note = root.querySelector('[data-la=note]')
|
|
242
|
+
text(note, !connected ? tr('连接中断,当前为最后收到的记录','Disconnected — showing last received records') : error ? tr('部分活动记录暂不可用','Some activity records are unavailable') : !selected ? tr('选择一个群项目,查看任务活动','Select a project to view its tasks') : '')
|
|
243
|
+
note.hidden = !note.textContent
|
|
244
|
+
for (const id of groupNodes.keys()) if (!groups.has(id)) groupNodes.delete(id)
|
|
245
|
+
for (const k of rowNodes.keys()) if (!rows.has(k)) { rowNodes.delete(k); open.delete(k); detailCache.delete(k);detailErrors.delete(k) }
|
|
246
|
+
}
|
|
247
|
+
function apply(data, full) {
|
|
248
|
+
if (!data?.epoch) return
|
|
249
|
+
if (loaded && data.epoch===epoch && data.revision<revision) return
|
|
250
|
+
if (full || data.epoch!==epoch) { rows.clear(); groups.clear() }
|
|
251
|
+
if (data.epoch!==epoch) { detailCache.clear(); open.clear(); revision=-1 }
|
|
252
|
+
epoch=data.epoch;revision=data.revision;error=!!data.storageError
|
|
253
|
+
for (const r of data.activity || []) rows.set(key(r),r)
|
|
254
|
+
for (const g of data.groups || []) groups.set(g.chatId,g)
|
|
255
|
+
for (const k of data.removed || []) rows.delete(k)
|
|
256
|
+
loaded=true;schedule()
|
|
257
|
+
}
|
|
258
|
+
function sync() {
|
|
259
|
+
if (pending) return pending
|
|
260
|
+
frames=[]
|
|
261
|
+
pending=api('/api/activity?limit=100').then(data=>{
|
|
262
|
+
apply(data,true); for(const frame of frames)apply(frame,false)
|
|
263
|
+
}).catch(()=>{error=true;schedule()}).finally(()=>{pending=null;frames=[]})
|
|
264
|
+
return pending
|
|
265
|
+
}
|
|
266
|
+
document.addEventListener('visibilitychange',()=>{if(!document.hidden){schedule();void sync()}})
|
|
267
|
+
new MutationObserver(schedule).observe(document.documentElement,{attributes:true,attributeFilter:['data-lang']})
|
|
268
|
+
return {
|
|
269
|
+
preserve(){focusBeforeRender=root?.contains(document.activeElement)?document.activeElement:null;groupScroll=groupBody?.scrollTop||0},
|
|
270
|
+
mount(){setup();const target=document.getElementById('liveActivity');if(target&&root.parentNode!==target)target.append(root);groupBody.scrollTop=groupScroll;if(focusBeforeRender?.isConnected)focusBeforeRender.focus({preventScroll:true});focusBeforeRender=null;schedule();void sync()},
|
|
271
|
+
sync,
|
|
272
|
+
receive(data){if(pending){frames.push(data);if(frames.length>200)frames.shift()}else apply(data,false)},
|
|
273
|
+
live(live){for(const [id,value]of Object.entries(live?.groups||{})){const g=groups.get(id);if(g)Object.assign(g,value)}schedule()},
|
|
274
|
+
connected(value){connected=value;schedule()},
|
|
275
|
+
}
|
|
276
|
+
})()
|
|
@@ -0,0 +1,193 @@
|
|
|
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, 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, text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
|
|
42
|
+
truncated: String(text || '').length > TEXT, reply: '', tools: [], milestones: [] }
|
|
43
|
+
rows.set(key(chatId, receiptId), row); touch(row, true)
|
|
44
|
+
}
|
|
45
|
+
function merge(chatId, parentId, ids, state) {
|
|
46
|
+
const row = locate(chatId, parentId)
|
|
47
|
+
if (!row) return
|
|
48
|
+
for (const id of ids) {
|
|
49
|
+
const child = locate(chatId, id)
|
|
50
|
+
if (child && child !== row) {
|
|
51
|
+
row.messages.push(...child.messages)
|
|
52
|
+
row.receiptIds.push(...child.receiptIds)
|
|
53
|
+
rows.delete(key(chatId, child.id))
|
|
54
|
+
}
|
|
55
|
+
if (!row.receiptIds.includes(id)) row.receiptIds.push(id)
|
|
56
|
+
aliases.set(key(chatId, id), key(chatId, row.id))
|
|
57
|
+
const msg = row.messages.find(m => m.id === id)
|
|
58
|
+
if (msg) msg.state = state
|
|
59
|
+
}
|
|
60
|
+
// Bound the entire message body, not only each message, and disclose loss.
|
|
61
|
+
let budget = TEXT
|
|
62
|
+
if (row.messages.length > 64) row.truncated = true
|
|
63
|
+
row.messages = row.messages.slice(0, 64).map(m => {
|
|
64
|
+
const text = m.text.slice(0, budget); budget -= text.length
|
|
65
|
+
if (text !== m.text) row.truncated = true
|
|
66
|
+
return { ...m, text }
|
|
67
|
+
})
|
|
68
|
+
touch(row, true)
|
|
69
|
+
return row
|
|
70
|
+
}
|
|
71
|
+
function start({ chatId, receiptId, receiptIds, generation, sessionId, model, runner, project }) {
|
|
72
|
+
const row = merge(chatId, receiptId, receiptIds || [receiptId], 'landed')
|
|
73
|
+
if (!row) return
|
|
74
|
+
row.status = 'running'; row.generation = generation
|
|
75
|
+
row.startedAt ||= now(); row.sessionId = sessionId || null
|
|
76
|
+
Object.assign(row, { model: clean(model, 120), runner, project: clean(project, 120) })
|
|
77
|
+
const prior = contexts.get(chatId)
|
|
78
|
+
if (!prior || prior.sessionId !== sessionId) contexts.delete(chatId)
|
|
79
|
+
else contexts.set(chatId, { ...prior, generation, quality: 'previous' })
|
|
80
|
+
touch(row, true)
|
|
81
|
+
}
|
|
82
|
+
function observe({ chatId, receiptId, generation, event, doing, milestones }) {
|
|
83
|
+
const row = locate(chatId, receiptId)
|
|
84
|
+
if (!row || row.generation !== generation || CLOSED.has(row.status)) return
|
|
85
|
+
if (event.sessionId) row.sessionId = event.sessionId
|
|
86
|
+
const compact = event.type === 'system' && event.subtype === 'compact' || event.contextCompacted && !event.contextTokens
|
|
87
|
+
const measured = finite(event.contextTokens) && event.contextSampled !== false
|
|
88
|
+
if (compact || measured) {
|
|
89
|
+
const prior = contexts.get(chatId)
|
|
90
|
+
contexts.set(chatId, { sessionId: row.sessionId, generation,
|
|
91
|
+
usedTokens: compact ? null : event.contextTokens,
|
|
92
|
+
windowTokens: finite(event.contextWindowTokens) && event.contextWindowTokens > 0 ? event.contextWindowTokens : prior?.generation === generation ? prior.windowTokens : null,
|
|
93
|
+
measuredAt: now(), quality: compact ? 'compacted' : 'reported', source: row.runner })
|
|
94
|
+
}
|
|
95
|
+
if (event.tools) {
|
|
96
|
+
for (const tool of event.tools) {
|
|
97
|
+
const name = clean(tool.name, 100), found = row.tools.find(t => t.name === name)
|
|
98
|
+
if (found) found.count++
|
|
99
|
+
else if (row.tools.length < 50) row.tools.push({ name, count: 1 })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (doing) row.doing = doing.map(x => clean(x, 160)).slice(-3)
|
|
103
|
+
if (milestones) row.milestones = milestones.map(x => clean(x, 200)).slice(-12)
|
|
104
|
+
// No model text deltas: only user-facing milestones and tool descriptions.
|
|
105
|
+
if (compact || measured || event.tools?.length || milestones)
|
|
106
|
+
touch(row, compact, !!event.tools?.length || !!milestones)
|
|
107
|
+
}
|
|
108
|
+
function finish({ chatId, receiptId, status, reply, err, tokens, sessionId }) {
|
|
109
|
+
const row = locate(chatId, receiptId)
|
|
110
|
+
if (!row || CLOSED.has(row.status)) return
|
|
111
|
+
row.status = status; row.endedAt = now(); row.ms = row.startedAt ? row.endedAt - row.startedAt : 0
|
|
112
|
+
row.reply = clean(reply); row.err = clean(err, 1000); row.tokens = tokens || null
|
|
113
|
+
row.truncated ||= String(reply || '').length > TEXT
|
|
114
|
+
if (sessionId) row.sessionId = sessionId
|
|
115
|
+
const ctx = contexts.get(chatId)
|
|
116
|
+
if (ctx && ctx.generation === row.generation) {
|
|
117
|
+
ctx.sessionId = row.sessionId; ctx.quality = ctx.quality === 'compacted' ? 'compacted' : 'previous'
|
|
118
|
+
}
|
|
119
|
+
touch(row, true)
|
|
120
|
+
}
|
|
121
|
+
function sending({ chatId, receiptId }) {
|
|
122
|
+
const row = locate(chatId, receiptId)
|
|
123
|
+
if (row && !CLOSED.has(row.status)) { row.status = 'sending'; touch(row, true) }
|
|
124
|
+
}
|
|
125
|
+
function unknown({ chatId }) {
|
|
126
|
+
for (const row of rows.values()) if (row.chatId === chatId && !CLOSED.has(row.status)) {
|
|
127
|
+
finish({ chatId, receiptId: row.id, status: 'unknown', err: '结果尚未确认,请查看群状态' })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const summary = row => {
|
|
131
|
+
const { messages, reply, tools, milestones, receiptIds, generation, sessionId, ...rest } = row
|
|
132
|
+
return { ...rest, preview: (row.err || reply || '').slice(0, 160), messageCount: messages.length,
|
|
133
|
+
kind: CLOSED.has(row.status) ? 'done' : 'start', details: true }
|
|
134
|
+
}
|
|
135
|
+
function list({ chatId, status, offset = 0, limit = 30 } = {}) {
|
|
136
|
+
const all = [...rows.values()].filter(r => (!chatId || r.chatId === chatId) && (!status || r.status === status))
|
|
137
|
+
.sort((a, b) => Number(CLOSED.has(a.status)) - Number(CLOSED.has(b.status)) || b.ts - a.ts)
|
|
138
|
+
offset = Math.max(0, Math.min(LIMIT, Number(offset) || 0)); limit = Math.max(1, Math.min(100, Number(limit) || 30))
|
|
139
|
+
return { epoch, revision, storageError, total: all.length, activity: all.slice(offset, offset + limit).map(summary) }
|
|
140
|
+
}
|
|
141
|
+
function detail(chatId, id) {
|
|
142
|
+
const row = locate(chatId, id)
|
|
143
|
+
return row ? { epoch, revision: row.revision, ...structuredClone(row) } : null
|
|
144
|
+
}
|
|
145
|
+
function context(chatId, sessionId, busy = false, generation = null) {
|
|
146
|
+
const c = contexts.get(chatId)
|
|
147
|
+
if (!c || !(busy && generation && c.generation === generation) && (!sessionId || c.sessionId !== sessionId)) return null
|
|
148
|
+
return { ...c, quality: busy ? c.quality : c.quality === 'reported' ? 'previous' : c.quality }
|
|
149
|
+
}
|
|
150
|
+
function flush() {
|
|
151
|
+
clearTimeout(saveTimer); saveTimer = null
|
|
152
|
+
if (writing) return saving
|
|
153
|
+
if (!file || !dirty || !canWrite()) return saving
|
|
154
|
+
dirty = false
|
|
155
|
+
const data = JSON.stringify({ version: 1, rows: [...rows.values()], contexts: [...contexts] })
|
|
156
|
+
writing = true
|
|
157
|
+
saving = saving.then(async () => {
|
|
158
|
+
if (!canWrite()) return
|
|
159
|
+
if (Buffer.byteLength(data) > FILE_LIMIT) throw new Error('activity size limit')
|
|
160
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true })
|
|
161
|
+
const tmp = file + '.tmp'
|
|
162
|
+
await fs.promises.writeFile(tmp, data, { mode: 0o600 })
|
|
163
|
+
if (canWrite()) await fs.promises.rename(tmp, file)
|
|
164
|
+
storageError = false
|
|
165
|
+
}).catch(() => { storageError = true; log('Activity history could not be saved') })
|
|
166
|
+
.finally(() => {
|
|
167
|
+
writing = false
|
|
168
|
+
if (dirty && !saveTimer) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
|
|
169
|
+
})
|
|
170
|
+
return saving
|
|
171
|
+
}
|
|
172
|
+
if (file) {
|
|
173
|
+
try {
|
|
174
|
+
if (fs.statSync(file).size > FILE_LIMIT) throw new Error('activity size limit')
|
|
175
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
176
|
+
if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > LIMIT) throw new Error('activity format')
|
|
177
|
+
for (const row of data.rows) {
|
|
178
|
+
if (!row || typeof row.id !== 'string' || typeof row.chatId !== 'string' || !Array.isArray(row.receiptIds) || !Array.isArray(row.messages)) continue
|
|
179
|
+
if (!CLOSED.has(row.status)) { row.status = 'unknown'; row.err = '服务重启,结果需要核实' }
|
|
180
|
+
rows.set(key(row.chatId, row.id), row)
|
|
181
|
+
revision = Math.max(revision, Number(row.revision) || 0)
|
|
182
|
+
for (const id of row.receiptIds) aliases.set(key(row.chatId, id), key(row.chatId, row.id))
|
|
183
|
+
}
|
|
184
|
+
for (const [id, value] of (data.contexts || []).slice(0, 32)) contexts.set(id, { ...value, quality: value.quality === 'compacted' ? 'compacted' : 'historical' })
|
|
185
|
+
} catch (error) { if (error.code !== 'ENOENT') { storageError = true; log('Activity history could not be loaded') } }
|
|
186
|
+
}
|
|
187
|
+
return { receive, start, observe, finish, sending, unknown, merge, list, detail, context, flush,
|
|
188
|
+
has: (chatId, id) => !!locate(chatId, id),
|
|
189
|
+
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
|
|
190
|
+
async close() { clearTimeout(timer); listeners.clear(); await flush(); await flush(); clearTimeout(saveTimer) } }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { createLiveActivity }
|
package/src/ui/serverbase.js
CHANGED
|
@@ -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') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
|
|
@@ -840,7 +841,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
840
841
|
// 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
|
|
841
842
|
const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
|
|
842
843
|
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' : ''))))
|
|
844
|
+
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
845
|
}
|
|
845
846
|
// 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
|
|
846
847
|
// 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
|
|
@@ -867,7 +868,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
867
868
|
if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
|
|
868
869
|
// Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
|
|
869
870
|
// 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,
|
|
871
|
+
if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, runtimePolicy?.activityPage
|
|
872
|
+
? runtimePolicy.activityPage(Object.fromEntries(url.searchParams)) : { activity: recentActivity() })
|
|
873
|
+
if (p === '/api/activity/detail' && req.method === 'GET') {
|
|
874
|
+
const detail = runtimePolicy?.activityDetail?.(url.searchParams.get('chatId'), url.searchParams.get('id'))
|
|
875
|
+
return sendJSON(res, detail ? 200 : 404, detail || { error: '活动详情未记录' })
|
|
876
|
+
}
|
|
871
877
|
// 版本历史(设置页「版本历史」卡):CHANGELOG.json 随包分发 = 单一事实源,发版时更新
|
|
872
878
|
if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
|
|
873
879
|
// 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
|
|
@@ -2417,8 +2423,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2417
2423
|
const live = getLiveState ? getLiveState() : { running: false }
|
|
2418
2424
|
const conn = getConnHealth ? getConnHealth() : null // 飞书 WS 健康度随心跳带上(SSE 无重放,轮询兜底)
|
|
2419
2425
|
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 {
|
|
2426
|
+
...(runtimePolicy && !runtimePolicy.activityPage ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
|
|
2427
|
+
for (const c of sseClients) { try {
|
|
2428
|
+
if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
|
|
2429
|
+
else c.write(msg)
|
|
2430
|
+
} catch { sseClients.delete(c) } }
|
|
2422
2431
|
}, 3000)
|
|
2423
2432
|
|
|
2424
2433
|
return {
|
|
@@ -2434,7 +2443,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2434
2443
|
if (type === 'turn' && getLiveState) {
|
|
2435
2444
|
msg += `event: tick\ndata: ${JSON.stringify({ live: getLiveState(), conn: getConnHealth ? getConnHealth() : null, ts: Date.now() })}\n\n`
|
|
2436
2445
|
}
|
|
2437
|
-
for (const c of sseClients) { try {
|
|
2446
|
+
for (const c of sseClients) { try {
|
|
2447
|
+
if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
|
|
2448
|
+
else c.write(msg)
|
|
2449
|
+
} catch { sseClients.delete(c) } }
|
|
2438
2450
|
},
|
|
2439
2451
|
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
2452
|
_browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用
|