dsh-visualization 0.0.0-stage → 0.1.1
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/LICENSE +76 -0
- package/README.md +262 -2
- package/build.cjs +20 -0
- package/client.js +96 -0
- package/cordis.patch.yml +3 -0
- package/fold.cjs +835 -0
- package/icon.svg +16 -0
- package/index.js +30 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -4
- package/web/index.html +1887 -0
package/fold.cjs
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// bridge.cjs —— 把 DSH 真实会话状态喂给 instrument.html
|
|
2
|
+
//
|
|
3
|
+
// node bridge.cjs → http://127.0.0.1:8787
|
|
4
|
+
// node bridge.cjs 9000 → 换端口
|
|
5
|
+
// node bridge.cjs 8787 <sessionId> → 指定会话(默认取当前工作区最新的)
|
|
6
|
+
//
|
|
7
|
+
// 为什么需要它:页面如果是 file:// 打开的,浏览器不允许它读 ~/.dsh 下的文件。
|
|
8
|
+
// 桥用 node 读会话日志(zstd 压缩的 JSONL),折成一份小 JSON,页面每 900ms 拉一次。
|
|
9
|
+
//
|
|
10
|
+
// 数据源(只读,不写任何东西):
|
|
11
|
+
// ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
|
|
12
|
+
'use strict'
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const os = require('node:os')
|
|
16
|
+
const zlib = require('node:zlib')
|
|
17
|
+
|
|
18
|
+
const PORT = 0
|
|
19
|
+
/* CURRENT = 当前盯着的会话 id;null 表示"自动跟最新"。可以在页面上切换。 */
|
|
20
|
+
let CURRENT = null
|
|
21
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
22
|
+
const SESSIONS_ROOT = process.env.DSHVIZ_SESSIONS_ROOT || path.join(DSH_HOME, 'sessions')
|
|
23
|
+
/* 老一些的 Node 没有 zstd API(22.15 才有);没有就给出人话,而不是 TypeError */
|
|
24
|
+
const HAS_ZSTD = typeof zlib.zstdDecompressSync === 'function'
|
|
25
|
+
const PROJ_CACHE = path.join(DSH_HOME, 'storages', 'session_projcache', 'sessions')
|
|
26
|
+
const PAGE = path.join(__dirname, 'web', 'index.html')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/* 日志是「多帧 zstd」追加写的:较老的 Node 的 zstdDecompressSync 只解第一帧。
|
|
30
|
+
这里按 zstd 帧头魔数 28 B5 2F FD 切开,逐帧解压再拼起来。 */
|
|
31
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
32
|
+
|
|
33
|
+
/* 按 RFC 8878 走一遍帧结构,得到精确边界(不再靠扫魔数猜):
|
|
34
|
+
帧头 = 魔数(4) + FHD(1) + [Window_Descriptor] + [Dictionary_ID] + [Frame_Content_Size]
|
|
35
|
+
块 = 3 字节块头 + 块体(RLE 块只有 1 字节)+ ... 直到 Last_Block
|
|
36
|
+
末尾可能还有 4 字节 content checksum。
|
|
37
|
+
解析不动数据,只算长度;遇到写了一半的尾帧就停在那里。 */
|
|
38
|
+
function zstdFrameEnd(buf, off) {
|
|
39
|
+
if (off + 5 > buf.length) return -1
|
|
40
|
+
if (buf[off] !== 0x28 || buf[off + 1] !== 0xb5 || buf[off + 2] !== 0x2f || buf[off + 3] !== 0xfd) return -1
|
|
41
|
+
let p = off + 4
|
|
42
|
+
const fhd = buf[p++]
|
|
43
|
+
const fcsFlag = fhd >> 6
|
|
44
|
+
const singleSegment = (fhd >> 5) & 1
|
|
45
|
+
const hasChecksum = (fhd >> 2) & 1
|
|
46
|
+
const dictFlag = fhd & 3
|
|
47
|
+
if (!singleSegment) p += 1
|
|
48
|
+
p += [0, 1, 2, 4][dictFlag]
|
|
49
|
+
p += (fcsFlag === 0) ? (singleSegment ? 1 : 0) : [2, 4, 8][fcsFlag - 1]
|
|
50
|
+
if (p > buf.length) return -1
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (p + 3 > buf.length) return -1
|
|
53
|
+
const h = buf[p] | (buf[p + 1] << 8) | (buf[p + 2] << 16)
|
|
54
|
+
p += 3
|
|
55
|
+
const last = h & 1
|
|
56
|
+
const type = (h >> 1) & 3
|
|
57
|
+
const size = h >>> 3
|
|
58
|
+
if (type === 3) return -1 /* 保留类型,说明这不是合法的块头 */
|
|
59
|
+
p += (type === 1) ? 1 : size /* RLE 块在流里只占 1 字节 */
|
|
60
|
+
if (p > buf.length) return -1
|
|
61
|
+
if (last) break
|
|
62
|
+
}
|
|
63
|
+
if (hasChecksum) p += 4
|
|
64
|
+
if (p > buf.length) return -1
|
|
65
|
+
return p
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* 返回 [{start, end}],解析不了就停(尾帧没写完就是这种情况) */
|
|
69
|
+
function zstdFrames(buf) {
|
|
70
|
+
const out = []
|
|
71
|
+
let off = 0
|
|
72
|
+
while (off + 5 <= buf.length) {
|
|
73
|
+
const end = zstdFrameEnd(buf, off)
|
|
74
|
+
if (end < 0) break
|
|
75
|
+
out.push({ start: off, end: end })
|
|
76
|
+
off = end
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* 兼容旧接口:只要起始位置 */
|
|
82
|
+
function zstdFrameHeads(buf) {
|
|
83
|
+
const fr = zstdFrames(buf)
|
|
84
|
+
return fr.length ? fr.map(function (f) { return f.start }) : [0]
|
|
85
|
+
}
|
|
86
|
+
function unzstdAll(buf) {
|
|
87
|
+
const frames = zstdFrames(buf)
|
|
88
|
+
if (frames.length <= 1) return zlib.zstdDecompressSync(buf)
|
|
89
|
+
const parts = []
|
|
90
|
+
for (const f of frames) {
|
|
91
|
+
try { parts.push(zlib.zstdDecompressSync(buf.slice(f.start, f.end))) } catch (e) {
|
|
92
|
+
/* 单帧损坏就跳过它,别让整份日志都读不出来 */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return Buffer.concat(parts)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---------------- 找会话文件 ---------------- */
|
|
99
|
+
function workspaceDir(cwd) {
|
|
100
|
+
const enc = '--' + cwd.replace(/[:\\/]+/g, '-').replace(/^-+|-+$/g, '') + '--'
|
|
101
|
+
const p = path.join(SESSIONS_ROOT, enc)
|
|
102
|
+
if (fs.existsSync(p)) return p
|
|
103
|
+
const base = path.basename(cwd).toLowerCase()
|
|
104
|
+
const hit = fs.readdirSync(SESSIONS_ROOT).find((d) => d.toLowerCase().includes(base))
|
|
105
|
+
return hit ? path.join(SESSIONS_ROOT, hit) : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* 列出所有会话(跨工作区),按最近写盘排序 */
|
|
109
|
+
/* 会话日志文件名带版本号(session.v4.jsonl.zstd)。DSH 升版后不要直接失效,
|
|
110
|
+
取目录里版本号最大的那个。 */
|
|
111
|
+
function logFileOf(dirPath) {
|
|
112
|
+
let best = null, bestV = -1
|
|
113
|
+
try {
|
|
114
|
+
const names = fs.readdirSync(dirPath)
|
|
115
|
+
for (const nm of names) {
|
|
116
|
+
const m = /^session\.v(\d+)\.jsonl\.zstd$/.exec(nm)
|
|
117
|
+
if (!m) continue
|
|
118
|
+
const v = Number(m[1])
|
|
119
|
+
if (v > bestV) { bestV = v; best = nm }
|
|
120
|
+
}
|
|
121
|
+
} catch (e) { }
|
|
122
|
+
return best ? path.join(dirPath, best) : path.join(dirPath, 'session.v4.jsonl.zstd')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function listSessions(limit) {
|
|
126
|
+
const out = []
|
|
127
|
+
let dirs = []
|
|
128
|
+
try { dirs = fs.readdirSync(SESSIONS_ROOT) } catch (e) { return out }
|
|
129
|
+
for (const ws of dirs) {
|
|
130
|
+
let ids = []
|
|
131
|
+
try { ids = fs.readdirSync(path.join(SESSIONS_ROOT, ws)) } catch (e) { continue }
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
if (!id.startsWith('session-')) continue
|
|
134
|
+
const file = logFileOf(path.join(SESSIONS_ROOT, ws, id))
|
|
135
|
+
let st
|
|
136
|
+
try { st = fs.statSync(file) } catch (e) { continue }
|
|
137
|
+
out.push({ id, ws, file, mtime: st.mtimeMs, bytes: st.size })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
out.sort((a, b) => b.mtime - a.mtime)
|
|
141
|
+
const top = out.slice(0, limit || 40)
|
|
142
|
+
/* 从投影缓存的前几百字节里抠出真实 cwd(比解码目录名靠谱) */
|
|
143
|
+
for (const s of top) {
|
|
144
|
+
s.cwd = ''
|
|
145
|
+
try {
|
|
146
|
+
const fd = fs.openSync(path.join(PROJ_CACHE, s.id + '.json'), 'r')
|
|
147
|
+
const buf = Buffer.alloc(700)
|
|
148
|
+
const n = fs.readSync(fd, buf, 0, 700, 0)
|
|
149
|
+
fs.closeSync(fd)
|
|
150
|
+
const m = buf.slice(0, n).toString('utf8').match(/"cwd":\s*"((?:[^"\\]|\\.)*)"/)
|
|
151
|
+
if (m) s.cwd = JSON.parse('"' + m[1] + '"')
|
|
152
|
+
} catch (e) { }
|
|
153
|
+
s.short = s.id.replace('session-', '').slice(0, 8)
|
|
154
|
+
if (!s.cwd) s.cwd = s.ws.replace(/^--|--$/g, '')
|
|
155
|
+
delete s.file
|
|
156
|
+
}
|
|
157
|
+
return top
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findById(id) {
|
|
161
|
+
const want = 'session-' + String(id).replace(/^session-/, '')
|
|
162
|
+
let dirs = []
|
|
163
|
+
try { dirs = fs.readdirSync(SESSIONS_ROOT) } catch (e) { return null }
|
|
164
|
+
for (const ws of dirs) {
|
|
165
|
+
const file = logFileOf(path.join(SESSIONS_ROOT, ws, want))
|
|
166
|
+
try {
|
|
167
|
+
const st = fs.statSync(file)
|
|
168
|
+
return { id: want, ws, file, mtime: st.mtimeMs }
|
|
169
|
+
} catch (e) { }
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pickSession() {
|
|
175
|
+
if (CURRENT) {
|
|
176
|
+
const hit = findById(CURRENT)
|
|
177
|
+
if (hit) return hit
|
|
178
|
+
}
|
|
179
|
+
/* 插件跑在 harness 进程里,cwd 不是用户工作区 —— 直接跨工作区取最近写盘的那个 */
|
|
180
|
+
const list = listSessions(1)
|
|
181
|
+
if (list.length) {
|
|
182
|
+
const hit = findById(list[0].id)
|
|
183
|
+
if (hit) return hit
|
|
184
|
+
}
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---------------- 折叠事件 → 状态 ---------------- */
|
|
189
|
+
const clip = (s, n) => {
|
|
190
|
+
s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
191
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s
|
|
192
|
+
}
|
|
193
|
+
function textOf(content) {
|
|
194
|
+
if (!Array.isArray(content)) return ''
|
|
195
|
+
return content.filter((c) => c && c.type === 'text').map((c) => c.text).join(' ')
|
|
196
|
+
}
|
|
197
|
+
function shortArgs(raw, max) {
|
|
198
|
+
const lim = max || 88
|
|
199
|
+
if (!raw) return ''
|
|
200
|
+
try {
|
|
201
|
+
const o = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
202
|
+
const first = Object.entries(o).map(([k, v]) => k + '=' + (typeof v === 'string' ? v : JSON.stringify(v)))
|
|
203
|
+
return clip(first.join(' '), lim)
|
|
204
|
+
} catch (e) { return clip(raw, lim) }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* 并发度:把每次工具调用看成一段区间,扫一遍算出「同时有几个在跑」
|
|
208
|
+
返回最忙的并行数 + 重叠区间(给环上的弧段用) */
|
|
209
|
+
function concurrency(tools, winMs) {
|
|
210
|
+
const cuts = []
|
|
211
|
+
const from = Date.now() - (winMs || 120000)
|
|
212
|
+
for (const t of tools) {
|
|
213
|
+
const a = t.t
|
|
214
|
+
const b = t.t + (t.ms == null ? (Date.now() - t.t) : t.ms) // 没回来的算「还在跑」
|
|
215
|
+
if (b < from) continue
|
|
216
|
+
cuts.push([Math.max(a, from), 1], [b, -1])
|
|
217
|
+
}
|
|
218
|
+
cuts.sort((x, y) => x[0] - y[0] || x[1] - y[1])
|
|
219
|
+
let n = 0, max = 0, prev = null
|
|
220
|
+
const segs = []
|
|
221
|
+
for (const [at, d] of cuts) {
|
|
222
|
+
if (prev != null && at > prev && n >= 2) {
|
|
223
|
+
if (at - prev >= 30) segs.push({ a: prev, b: at, n })
|
|
224
|
+
}
|
|
225
|
+
n += d
|
|
226
|
+
if (n > max) max = n
|
|
227
|
+
prev = at
|
|
228
|
+
}
|
|
229
|
+
return { max, segs: segs.slice(-60) }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function makeFolder() {
|
|
233
|
+
const S = {
|
|
234
|
+
session: { id: '', cwd: '', preset: '', depth: 0, title: '', createdAt: 0 },
|
|
235
|
+
turn: 0, step: 0, turnOpen: false, turns: 0, model: '', provider: '',
|
|
236
|
+
ctxWin: 0, ctxUsed: 0,
|
|
237
|
+
tok: { in: 0, out: 0, cache: 0 },
|
|
238
|
+
tools: [], toolCount: 0, pending: new Set(),
|
|
239
|
+
jobs: [], steps: [], turns: [], pendingPrompt: '', skills: [], ticks: [], toolAgg: {},
|
|
240
|
+
approvalsList: [], changes: [], delivs: [],
|
|
241
|
+
subagents: 0, approvals: 0, todos: 0, files: 0,
|
|
242
|
+
log: [], lastEvent: 0, lastAssistant: 0, prevAssistant: 0, lastOut: 0,
|
|
243
|
+
text: '', errs: 0
|
|
244
|
+
}
|
|
245
|
+
S.turnRec = []
|
|
246
|
+
const byCall = new Map()
|
|
247
|
+
const byJob = new Map()
|
|
248
|
+
let NOWT = 0
|
|
249
|
+
let NOWTURN = 0
|
|
250
|
+
|
|
251
|
+
/* 步骤节奏:每个 step 从 step/start 到下一条 step/start(或本轮结束)的时长 */
|
|
252
|
+
const closeStep = (at) => {
|
|
253
|
+
const s = S.steps[S.steps.length - 1]
|
|
254
|
+
if (s && !s.end && at > s.t) s.end = at
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const addLog = (layer, name, detail, ms, ok) => {
|
|
258
|
+
S.log.push({
|
|
259
|
+
atMs: NOWT, turn: NOWTURN, layer, name: clip(name, 40),
|
|
260
|
+
detail: clip(detail, 120), ms: ms == null ? null : ms, ok: ok !== false
|
|
261
|
+
})
|
|
262
|
+
if (S.log.length > 400) S.log.shift()
|
|
263
|
+
return S.log[S.log.length - 1]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const push = function (e) {
|
|
267
|
+
const d = e.data || {}
|
|
268
|
+
NOWT = e.time || 0
|
|
269
|
+
if (typeof d.turn === 'number') NOWTURN = d.turn
|
|
270
|
+
S.lastEvent = Math.max(S.lastEvent, e.time || 0)
|
|
271
|
+
switch (e.type) {
|
|
272
|
+
case 'session': {
|
|
273
|
+
const sd = (e.id ? e : (e.data || {}))
|
|
274
|
+
S.session.id = sd.id || ''
|
|
275
|
+
S.session.cwd = sd.cwd || ''
|
|
276
|
+
S.session.preset = sd.agentPreset || ''
|
|
277
|
+
S.session.depth = sd.delegationDepth || 0
|
|
278
|
+
S.session.createdAt = sd.createdAt || 0
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
case 'session/title':
|
|
282
|
+
S.session.title = d.title || ''
|
|
283
|
+
break
|
|
284
|
+
case 'request/context':
|
|
285
|
+
if (d.contextWindow) S.ctxWin = d.contextWindow
|
|
286
|
+
if (d.model) S.model = d.model
|
|
287
|
+
if (d.provider) S.provider = d.provider
|
|
288
|
+
break
|
|
289
|
+
case 'turn/start':
|
|
290
|
+
S.turn = d.turn; S.step = 0; S.turnOpen = true
|
|
291
|
+
S.turnRec.push({
|
|
292
|
+
no: d.turn, start: e.time, end: 0, prompt: S.pendingPrompt || '',
|
|
293
|
+
steps: 0, tools: 0, files: 0, reason: ''
|
|
294
|
+
})
|
|
295
|
+
S.pendingPrompt = ''
|
|
296
|
+
addLog('session', 'TURN ' + d.turn, '会话开始')
|
|
297
|
+
break
|
|
298
|
+
case 'turn/end': {
|
|
299
|
+
S.turnOpen = false
|
|
300
|
+
closeStep(e.time)
|
|
301
|
+
const tr = S.turnRec[S.turnRec.length - 1]
|
|
302
|
+
if (tr && !tr.end) { tr.end = e.time; tr.reason = clip(d.reason || '', 40) }
|
|
303
|
+
addLog('session', 'TURN ' + d.turn, '结束 · ' + clip(d.reason || '', 20))
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
case 'step/start': {
|
|
307
|
+
S.turn = d.turn; S.step = d.step
|
|
308
|
+
closeStep(e.time)
|
|
309
|
+
S.steps.push({ turn: d.turn, step: d.step, t: e.time, end: 0 })
|
|
310
|
+
const t2 = S.turnRec[S.turnRec.length - 1]
|
|
311
|
+
if (t2) t2.steps++
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
case 'user/message': {
|
|
315
|
+
const txt = textOf(d.content)
|
|
316
|
+
const kind = (d.source && d.source.kind) || 'user'
|
|
317
|
+
/* 只把真人说的话当"这一轮的请求";runtime-context / time-context / skill-catalog 是系统注入的 */
|
|
318
|
+
if (kind === 'user') {
|
|
319
|
+
const cur = S.turnRec[S.turnRec.length - 1]
|
|
320
|
+
if (cur && !cur.end && !cur.prompt) cur.prompt = clip(txt, 60)
|
|
321
|
+
else S.pendingPrompt = clip(txt, 60)
|
|
322
|
+
}
|
|
323
|
+
addLog('user', kind === 'user' ? 'user' : kind, txt)
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
case 'assistant/message': {
|
|
327
|
+
S.turn = d.turn || S.turn; S.step = d.step || S.step
|
|
328
|
+
const u = d.usage
|
|
329
|
+
if (u) {
|
|
330
|
+
S.tok.in += u.inputTokens || 0
|
|
331
|
+
S.tok.out += u.outputTokens || 0
|
|
332
|
+
S.tok.cache += u.cacheReadTokens || 0
|
|
333
|
+
S.ctxUsed = u.totalTokens || S.ctxUsed
|
|
334
|
+
S.prevAssistant = S.lastAssistant
|
|
335
|
+
S.lastAssistant = e.time
|
|
336
|
+
S.lastOut = u.outputTokens || 0
|
|
337
|
+
}
|
|
338
|
+
const blocks = (d.message && d.message.content) || []
|
|
339
|
+
const txt = textOf(blocks)
|
|
340
|
+
if (txt) { S.text = clip(txt, 160); addLog('session', 'assistant', clip(txt, 90)) }
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
case 'tool/call': {
|
|
344
|
+
S.toolCount++
|
|
345
|
+
const rec = { name: d.name, args: shortArgs(d.arguments).slice(0, 40), t: e.time, ms: null, ok: true, id: d.callId, turn: d.turn || 0, step: d.step || 0 }
|
|
346
|
+
S.tools.push(rec)
|
|
347
|
+
if (S.tools.length > 1500) S.tools.shift()
|
|
348
|
+
/* 常驻记忆:全程每次调用的时间点(给环上的淡痕),以及按工具的累计 */
|
|
349
|
+
S.ticks.push(e.time)
|
|
350
|
+
if (S.ticks.length > 1500) S.ticks.shift()
|
|
351
|
+
const agg = S.toolAgg[d.name] || (S.toolAgg[d.name] = { name: d.name, count: 0, ms: 0, fail: 0, last: 0 })
|
|
352
|
+
agg.count++; agg.last = e.time
|
|
353
|
+
const curT = S.turnRec[S.turnRec.length - 1]
|
|
354
|
+
if (curT) curT.tools++
|
|
355
|
+
byCall.set(d.callId, rec)
|
|
356
|
+
S.pending.add(d.callId)
|
|
357
|
+
if (d.name === 'skill') {
|
|
358
|
+
let nm = 'skill'
|
|
359
|
+
try { nm = JSON.parse(d.arguments).name || nm } catch (err) { }
|
|
360
|
+
const sk = S.skills.find((s) => s.name === nm)
|
|
361
|
+
if (sk) { sk.uses++; sk.last = e.time } else S.skills.push({ name: nm, uses: 1, last: e.time })
|
|
362
|
+
}
|
|
363
|
+
if (d.name === 'subagent' || d.name === 'subagent_fork') S.subagents++
|
|
364
|
+
rec.entry = addLog('tool', d.name, shortArgs(d.arguments), null, true)
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
case 'tool/result': {
|
|
368
|
+
// 注意:调用 id 藏在 message.source.callId 里,data 顶层没有 toolCallId
|
|
369
|
+
const rid = d.toolCallId
|
|
370
|
+
|| (d.message && d.message.source && d.message.source.callId)
|
|
371
|
+
|| (d.message && d.message.toolCallId) || ''
|
|
372
|
+
const rec = byCall.get(rid)
|
|
373
|
+
if (rid) S.pending.delete(rid)
|
|
374
|
+
const bad = !!(d.message && d.message.isError)
|
|
375
|
+
if (bad) S.errs++
|
|
376
|
+
if (rec) {
|
|
377
|
+
rec.ms = e.time - rec.t; rec.ok = !bad
|
|
378
|
+
if (rec.entry) { rec.entry.ms = rec.ms; rec.entry.ok = !bad }
|
|
379
|
+
const agg = S.toolAgg[rec.name]
|
|
380
|
+
if (agg) { if (rec.ms != null) agg.ms += rec.ms; if (bad) agg.fail++ }
|
|
381
|
+
}
|
|
382
|
+
break
|
|
383
|
+
}
|
|
384
|
+
case 'job/registered': {
|
|
385
|
+
const j = { id: d.jobId, kind: d.kind || 'job', label: clip(d.label, 64), state: 'RUN', t: d.startedAt || e.time, end: 0, turn: NOWTURN }
|
|
386
|
+
S.jobs.push(j); byJob.set(d.jobId, j)
|
|
387
|
+
addLog('task', d.kind || 'job', j.label)
|
|
388
|
+
break
|
|
389
|
+
}
|
|
390
|
+
case 'job/settled': {
|
|
391
|
+
const j = byJob.get(d.jobId)
|
|
392
|
+
if (j) { j.state = d.status || 'DONE'; j.end = d.finishedAt || e.time }
|
|
393
|
+
addLog('task', d.kind || 'job', 'settled · ' + (d.status || ''))
|
|
394
|
+
break
|
|
395
|
+
}
|
|
396
|
+
case 'approval/asked':
|
|
397
|
+
S.approvals++
|
|
398
|
+
S.approvalsList.push({ t: e.time, id: d.id, tool: d.toolName || '', outcome: '' })
|
|
399
|
+
addLog('state', 'approval', d.toolName + ' · ' + clip(d.reason || '', 60))
|
|
400
|
+
break
|
|
401
|
+
case 'approval/decided': {
|
|
402
|
+
const ap = S.approvalsList.filter(function (x) { return x.id === d.id })[0]
|
|
403
|
+
if (ap) ap.outcome = d.outcome || 'done'
|
|
404
|
+
else S.approvalsList.push({ t: e.time, id: d.id, tool: '', outcome: d.outcome || 'done' })
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
case 'workspace/changes':
|
|
408
|
+
S.changes.push({ t: e.time, turn: d.turn || NOWTURN })
|
|
409
|
+
break
|
|
410
|
+
case 'todo/write':
|
|
411
|
+
S.todos = Array.isArray(d.todos) ? d.todos.length : S.todos
|
|
412
|
+
addLog('state', 'todo', S.todos + ' 项待办')
|
|
413
|
+
break
|
|
414
|
+
case 'deliverables/presented': {
|
|
415
|
+
const nf = Array.isArray(d.files) ? d.files.length : 0
|
|
416
|
+
S.files += nf
|
|
417
|
+
S.delivs.push({ t: e.time, turn: d.turn || NOWTURN, n: nf })
|
|
418
|
+
const curD = S.turnRec[S.turnRec.length - 1]
|
|
419
|
+
if (curD) curD.files += nf
|
|
420
|
+
}
|
|
421
|
+
addLog('state', 'present', S.files + ' 个文件')
|
|
422
|
+
break
|
|
423
|
+
default: break
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/* 输出:字段照旧,但不在累加器上做破坏性删除 —— 这样同一个折叠器可以一直 push 下去 */
|
|
427
|
+
const result = function () {
|
|
428
|
+
const out = {}
|
|
429
|
+
for (const k in S) {
|
|
430
|
+
if (k === 'turnRec' || k === 'pendingPrompt' || k === 'toolAgg') continue
|
|
431
|
+
out[k] = S[k]
|
|
432
|
+
}
|
|
433
|
+
out.jobs = S.jobs.length > 200 ? S.jobs.slice(-200) : S.jobs
|
|
434
|
+
out.steps = S.steps.length > 1200 ? S.steps.slice(-1200) : S.steps
|
|
435
|
+
out.log = S.log.length > 300 ? S.log.slice(-300) : S.log
|
|
436
|
+
out.turnsList = S.turnRec.slice(-60)
|
|
437
|
+
out.toolStats = Object.keys(S.toolAgg).map(function (k) { return S.toolAgg[k] })
|
|
438
|
+
.sort(function (a, b) { return b.count - a.count })
|
|
439
|
+
return out
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { push: push, result: result }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* 全量折叠(重放、测试用);线上走增量 */
|
|
446
|
+
function fold(events) {
|
|
447
|
+
const f = makeFolder()
|
|
448
|
+
for (const e of events) f.push(e)
|
|
449
|
+
return f.result()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* ---------------- 缓存 + 轮询 + 重放 ---------------- */
|
|
453
|
+
let cache = { key: '', at: 0, state: null, error: null }
|
|
454
|
+
let EVENTS = { key: '', list: [], file: '', frames: 0 }
|
|
455
|
+
/* REPLAY:把会话自己的历史按虚拟时钟快进播放
|
|
456
|
+
t0/t1 = 首末事件时间;elapsed = 已播放的真实毫秒;vtime = t0 + elapsed*speed */
|
|
457
|
+
let REPLAY = null
|
|
458
|
+
|
|
459
|
+
function replayAdvance() {
|
|
460
|
+
if (!REPLAY) return
|
|
461
|
+
const nowMs = Date.now()
|
|
462
|
+
const dt = nowMs - REPLAY.lastTick
|
|
463
|
+
REPLAY.lastTick = nowMs
|
|
464
|
+
if (!REPLAY.playing) return
|
|
465
|
+
if (REPLAY.mode === 'seq') {
|
|
466
|
+
/* 事件序列:每 stepMs 走一条事件,事件之间的真实间隔一律不保留 */
|
|
467
|
+
REPLAY.elapsed += dt
|
|
468
|
+
const k = Math.floor(REPLAY.elapsed / REPLAY.stepMs)
|
|
469
|
+
if (k > 0) {
|
|
470
|
+
REPLAY.elapsed -= k * REPLAY.stepMs
|
|
471
|
+
REPLAY.idx = Math.min(REPLAY.total - 1, REPLAY.idx + k)
|
|
472
|
+
if (REPLAY.idx >= REPLAY.total - 1) REPLAY.playing = false
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
REPLAY.elapsed += dt * REPLAY.speed
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function replayVTime() {
|
|
479
|
+
if (!REPLAY) return 0
|
|
480
|
+
if (REPLAY.mode === 'seq') return REPLAY.vtime || REPLAY.t0
|
|
481
|
+
const v = REPLAY.t0 + REPLAY.elapsed
|
|
482
|
+
if (v >= REPLAY.t1) {
|
|
483
|
+
REPLAY.playing = false
|
|
484
|
+
REPLAY.elapsed = REPLAY.t1 - REPLAY.t0
|
|
485
|
+
return REPLAY.t1
|
|
486
|
+
}
|
|
487
|
+
return v
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* 增量折叠:日志只追加时,把新事件 push 进已有的折叠器,不必重扫全部历史 */
|
|
491
|
+
let FOLDER = null
|
|
492
|
+
let FOLDED = 0
|
|
493
|
+
|
|
494
|
+
function refresh(force, sessionId) {
|
|
495
|
+
const s = sessionId ? (findById(sessionId) || pickSession()) : pickSession()
|
|
496
|
+
if (!s) {
|
|
497
|
+
let why
|
|
498
|
+
try {
|
|
499
|
+
if (!fs.existsSync(SESSIONS_ROOT)) why = '还没找到 DSH 会话目录(' + SESSIONS_ROOT + '),确认 DSH_HOME 是否正确'
|
|
500
|
+
else if (!fs.readdirSync(SESSIONS_ROOT).length) why = '会话目录是空的 —— 先在这个 profile 里跑一轮对话'
|
|
501
|
+
else why = '这些会话里还没有可读的日志 —— 先跑一轮对话'
|
|
502
|
+
} catch (e) { why = String(e.message || e) }
|
|
503
|
+
cache = { key: '', at: Date.now(), state: null, error: why }
|
|
504
|
+
return cache
|
|
505
|
+
}
|
|
506
|
+
if (!HAS_ZSTD) {
|
|
507
|
+
cache = {
|
|
508
|
+
key: '', at: Date.now(), state: null,
|
|
509
|
+
error: '这个运行环境不支持 zstd 解压(需要 Node ≥ 22.15),DSH VISUALIZATION 读不了会话日志。Node 版本:' +
|
|
510
|
+
process.version,
|
|
511
|
+
}
|
|
512
|
+
return cache
|
|
513
|
+
}
|
|
514
|
+
const st = fs.statSync(s.file)
|
|
515
|
+
const key = s.id + ':' + st.size + ':' + st.mtimeMs
|
|
516
|
+
if (!force && key === cache.key && !REPLAY) return cache
|
|
517
|
+
try {
|
|
518
|
+
let events
|
|
519
|
+
let readFrom = -1 /* -1 = 没读盘(命中缓存);0 = 整份重读;>0 = 只追加 */
|
|
520
|
+
if (EVENTS.key === key) events = EVENTS.list
|
|
521
|
+
else {
|
|
522
|
+
/* 日志是多帧追加的:只解「上次之后新追加的帧」,不必每次啃完 14MB */
|
|
523
|
+
const buf = fs.readFileSync(s.file)
|
|
524
|
+
const frames = zstdFrames(buf)
|
|
525
|
+
const heads = frames.length ? frames.map(function (f) { return f.start }) : [0]
|
|
526
|
+
const sameFile = EVENTS.file === s.file
|
|
527
|
+
const canAppend = sameFile && EVENTS.frames > 0 && heads.length >= EVENTS.frames
|
|
528
|
+
const from = canAppend ? EVENTS.frames : 0
|
|
529
|
+
readFrom = from
|
|
530
|
+
const keep = canAppend ? EVENTS.list.length : 0
|
|
531
|
+
const list = canAppend ? EVENTS.list : []
|
|
532
|
+
if (!canAppend) list.length = 0
|
|
533
|
+
const chunks = []
|
|
534
|
+
let used = 0, parseFails = 0
|
|
535
|
+
for (let i = from; i < heads.length; i++) {
|
|
536
|
+
const stop = frames.length ? frames[i].end : buf.length
|
|
537
|
+
const seg = buf.slice(heads[i], stop)
|
|
538
|
+
try {
|
|
539
|
+
chunks.push(zlib.zstdDecompressSync(seg))
|
|
540
|
+
used++
|
|
541
|
+
} catch (e) {
|
|
542
|
+
break /* 尾帧还没写完:停在这里,下次从这一帧继续 */
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf8') : ''
|
|
546
|
+
for (const line of text.split('\n')) {
|
|
547
|
+
if (!line) continue
|
|
548
|
+
try { list.push(JSON.parse(line)) } catch (e) { parseFails++ }
|
|
549
|
+
}
|
|
550
|
+
events = list
|
|
551
|
+
/* 帧是按魔数切出来的,理论上可能切错。发现解析失败就整包再解一次,
|
|
552
|
+
取数量多的那一份 —— 宁可慢一次,也不静默丢事件。 */
|
|
553
|
+
if (parseFails > 0) {
|
|
554
|
+
try {
|
|
555
|
+
const whole = zlib.zstdDecompressSync(buf).toString('utf8')
|
|
556
|
+
const alt = []
|
|
557
|
+
for (const line of whole.split('\n')) {
|
|
558
|
+
if (!line) continue
|
|
559
|
+
try { alt.push(JSON.parse(line)) } catch (e) { }
|
|
560
|
+
}
|
|
561
|
+
if (alt.length > list.length) {
|
|
562
|
+
events = alt
|
|
563
|
+
EVENTS = { key: EVENTS.key, list: alt, file: s.file, frames: 0 }
|
|
564
|
+
addLog && 0
|
|
565
|
+
}
|
|
566
|
+
} catch (e) { }
|
|
567
|
+
}
|
|
568
|
+
if (events !== list) { /* 已经换成整包结果 */ }
|
|
569
|
+
else EVENTS = { key, list: list, file: s.file, frames: from + used }
|
|
570
|
+
/* 文件很大却只解析出个位数事件 = 撞上写入窗口,别缓存这次结果 */
|
|
571
|
+
if (events.length < 5 && st.size > 50000) throw new Error('会话日志正在写入(读到 ' + events.length + ' 条),稍后重试')
|
|
572
|
+
if (keep === 0 && events.length === 0) throw new Error('会话日志还是空的')
|
|
573
|
+
}
|
|
574
|
+
/* 重放:只喂「虚拟时钟之前」的事件 */
|
|
575
|
+
let use = events
|
|
576
|
+
let vtime = 0
|
|
577
|
+
if (REPLAY) {
|
|
578
|
+
replayAdvance()
|
|
579
|
+
if (REPLAY.mode === 'seq') {
|
|
580
|
+
use = events.slice(0, REPLAY.idx + 1)
|
|
581
|
+
let vt = REPLAY.t0
|
|
582
|
+
for (let k = use.length - 1; k >= 0; k--) {
|
|
583
|
+
if (typeof use[k].time === 'number') { vt = use[k].time; break }
|
|
584
|
+
}
|
|
585
|
+
REPLAY.vtime = vt
|
|
586
|
+
vtime = vt
|
|
587
|
+
} else {
|
|
588
|
+
vtime = replayVTime()
|
|
589
|
+
use = events.filter((e) => typeof e.time !== 'number' || e.time <= vtime)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
let S
|
|
593
|
+
if (REPLAY) {
|
|
594
|
+
S = fold(use) /* 重放喂的是切片,只能整体折 */
|
|
595
|
+
} else {
|
|
596
|
+
if (!FOLDER || FOLDED > events.length || readFrom === 0) { FOLDER = makeFolder(); FOLDED = 0 }
|
|
597
|
+
for (let i = FOLDED; i < events.length; i++) FOLDER.push(events[i])
|
|
598
|
+
FOLDED = events.length
|
|
599
|
+
S = FOLDER.result()
|
|
600
|
+
}
|
|
601
|
+
/* 压包体:页面只用得到这几个字段(args 走 /calls 按需拉) */
|
|
602
|
+
if (S.tools) {
|
|
603
|
+
S.tools = S.tools.map(function (x) {
|
|
604
|
+
return { name: x.name, t: x.t, ms: x.ms, ok: x.ok, turn: x.turn, step: x.step }
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
S.ok = true
|
|
608
|
+
S.bridge = { port: PORT, polledAt: Date.now(), file: path.basename(path.dirname(s.file)), bytes: st.size, events: use.length }
|
|
609
|
+
S.running = S.turnOpen || S.pending.size > 0
|
|
610
|
+
S.state = !S.turnOpen ? 'IDLE' : (S.pending.size ? 'TOOL'
|
|
611
|
+
: ((Date.now() - S.lastAssistant) < 4000 && !REPLAY ? 'STREAMING' : 'THINKING'))
|
|
612
|
+
S.tps = (S.lastAssistant && S.prevAssistant && S.lastAssistant > S.prevAssistant)
|
|
613
|
+
? Math.round(S.lastOut / Math.max(0.4, (S.lastAssistant - S.prevAssistant) / 1000)) : 0
|
|
614
|
+
S.ctxPct = S.ctxWin ? Math.min(1, S.ctxUsed / S.ctxWin) : 0
|
|
615
|
+
S.conc = concurrency(S.tools, 120000)
|
|
616
|
+
S.pendingIds = [...S.pending]
|
|
617
|
+
delete S.pending
|
|
618
|
+
cache = { key, at: Date.now(), state: S, error: null }
|
|
619
|
+
} catch (e) {
|
|
620
|
+
cache = { key, at: Date.now(), state: cache.state, error: String(e.message || e) }
|
|
621
|
+
}
|
|
622
|
+
return cache
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* ---------------- HTTP ---------------- */
|
|
626
|
+
/* 真实 http origin(桌面端是 dsh-app:// 协议,浏览器打不开) */
|
|
627
|
+
let ORIGIN = ''
|
|
628
|
+
function setOrigin(o) { ORIGIN = String(o || '').replace(/\/+$/, '') }
|
|
629
|
+
|
|
630
|
+
/* 响应体:会话状态 + 自动跟随标记 + 重放射影 */
|
|
631
|
+
function stateBody(c) {
|
|
632
|
+
const body = c.state || { ok: false, error: c.error }
|
|
633
|
+
body.autoFollow = !CURRENT
|
|
634
|
+
if (ORIGIN) body.origin = ORIGIN
|
|
635
|
+
if (REPLAY) {
|
|
636
|
+
const vt = replayVTime()
|
|
637
|
+
body.replay = {
|
|
638
|
+
on: true, mode: REPLAY.mode, playing: REPLAY.playing, speed: REPLAY.speed,
|
|
639
|
+
stepMs: REPLAY.stepMs, idx: REPLAY.idx, total: REPLAY.total,
|
|
640
|
+
t0: REPLAY.t0, t1: REPLAY.t1, vtime: vt,
|
|
641
|
+
pct: REPLAY.mode === 'seq'
|
|
642
|
+
? (REPLAY.total > 1 ? REPLAY.idx / (REPLAY.total - 1) : 0)
|
|
643
|
+
: (REPLAY.t1 > REPLAY.t0 ? (vt - REPLAY.t0) / (REPLAY.t1 - REPLAY.t0) : 0),
|
|
644
|
+
done: REPLAY.mode === 'seq' ? REPLAY.idx >= REPLAY.total - 1 : vt >= REPLAY.t1,
|
|
645
|
+
}
|
|
646
|
+
} else body.replay = { on: false }
|
|
647
|
+
return body
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
651
|
+
function isLoopback(req) {
|
|
652
|
+
const ip = (req.socket && (req.socket.remoteAddress || '')) || ''
|
|
653
|
+
return LOOPBACK.has(ip)
|
|
654
|
+
}
|
|
655
|
+
let ALLOW_REMOTE = false
|
|
656
|
+
let sseClients = 0
|
|
657
|
+
function setPolicy(policy) {
|
|
658
|
+
if (policy && typeof policy.allowRemote === 'boolean') ALLOW_REMOTE = policy.allowRemote
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function handle(req, res) {
|
|
662
|
+
if (!ALLOW_REMOTE && !isLoopback(req)) {
|
|
663
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
664
|
+
res.end('DSH VISUALIZATION: 只允许本机访问(需要远程访问请在插件配置里打开 allowRemote)')
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
const full = (req.url || '/').replace(/^\/dsh-visualization/, '') || '/'
|
|
668
|
+
const url = full.split('?')[0]
|
|
669
|
+
const q = new URLSearchParams(full.split('?')[1] || '')
|
|
670
|
+
const json = (obj) => {
|
|
671
|
+
const body = Buffer.from(JSON.stringify(obj), 'utf8')
|
|
672
|
+
const ae = String(req.headers['accept-encoding'] || '')
|
|
673
|
+
/* 状态里有几千条记录,gzip 能压到 1/5,值得做 */
|
|
674
|
+
if (body.length > 2048 && /\bgzip\b/.test(ae)) {
|
|
675
|
+
res.writeHead(200, {
|
|
676
|
+
'content-type': 'application/json; charset=utf-8',
|
|
677
|
+
'cache-control': 'no-store',
|
|
678
|
+
'content-encoding': 'gzip',
|
|
679
|
+
vary: 'accept-encoding',
|
|
680
|
+
})
|
|
681
|
+
res.end(zlib.gzipSync(body))
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
685
|
+
res.end(body)
|
|
686
|
+
}
|
|
687
|
+
/* 救急:把浏览器里还开着的那一页 DOM 存回磁盘(instrument.html 被误覆盖时用) */
|
|
688
|
+
if (req.method === 'POST' && url === '/savehtml') {
|
|
689
|
+
let body = ''
|
|
690
|
+
req.on('data', (c) => { body += c })
|
|
691
|
+
req.on('end', () => {
|
|
692
|
+
try {
|
|
693
|
+
fs.writeFileSync(path.join(__dirname, 'instrument.recovered.html'), body, 'utf8')
|
|
694
|
+
json({ ok: true, bytes: body.length })
|
|
695
|
+
} catch (e) { res.writeHead(500); res.end(String(e.message)) }
|
|
696
|
+
})
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (url === '/state') {
|
|
701
|
+
const c = refresh(false, q.get('session') || null)
|
|
702
|
+
json(stateBody(c))
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
/* SSE:宿主这边一有变化就推,前端不必每 900ms 拉一次 */
|
|
706
|
+
if (url === '/events') {
|
|
707
|
+
if (sseClients >= 8) {
|
|
708
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
|
|
709
|
+
res.end('DSH VISUALIZATION: 同时打开的窗口太多了(上限 8)')
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
sseClients++
|
|
713
|
+
res.writeHead(200, {
|
|
714
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
715
|
+
'cache-control': 'no-store',
|
|
716
|
+
connection: 'keep-alive',
|
|
717
|
+
'x-accel-buffering': 'no',
|
|
718
|
+
})
|
|
719
|
+
res.write('retry: 2000\n\n')
|
|
720
|
+
const sid = q.get('session') || null
|
|
721
|
+
let last = ''
|
|
722
|
+
let closed = false
|
|
723
|
+
const tick = () => {
|
|
724
|
+
if (closed) return
|
|
725
|
+
try {
|
|
726
|
+
const c = refresh(false, sid)
|
|
727
|
+
const body = stateBody(c)
|
|
728
|
+
/* 变化判据:文件版本 + 重放进度 + 运行状态 —— 没变就不发 */
|
|
729
|
+
const key = String(c.key) + '|' + (REPLAY ? REPLAY.idx : '') + '|' +
|
|
730
|
+
(body.state || '') + '|' + (body.turn || 0) + '|' + (body.step || 0)
|
|
731
|
+
if (key === last) return
|
|
732
|
+
last = key
|
|
733
|
+
res.write('data: ' + JSON.stringify(body) + '\n\n')
|
|
734
|
+
} catch (e) { /* 单次失败不影响连接 */ }
|
|
735
|
+
}
|
|
736
|
+
tick()
|
|
737
|
+
const timer = setInterval(tick, 250)
|
|
738
|
+
const ping = setInterval(() => {
|
|
739
|
+
if (closed) return
|
|
740
|
+
try { res.write(': ping\n\n') } catch (e) { }
|
|
741
|
+
}, 15000)
|
|
742
|
+
req.on('close', () => { closed = true; sseClients = Math.max(0, sseClients - 1); clearInterval(timer); clearInterval(ping) })
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
/* 某个工具的最近几次调用(含完整参数,按需拉,不进 /state 免得放大轮询) */
|
|
746
|
+
if (url === '/calls') {
|
|
747
|
+
const tool = q.get('tool') || ''
|
|
748
|
+
const lim = Math.min(40, Number(q.get('limit') || 8))
|
|
749
|
+
refresh(false)
|
|
750
|
+
const list = EVENTS.list || []
|
|
751
|
+
const out = []
|
|
752
|
+
for (let k = list.length - 1; k >= 0 && out.length < lim; k--) {
|
|
753
|
+
const e = list[k]
|
|
754
|
+
if (e.type !== 'tool/call' || !e.data || e.data.name !== tool) continue
|
|
755
|
+
out.push({
|
|
756
|
+
t: e.time, turn: e.data.turn, step: e.data.step,
|
|
757
|
+
args: shortArgs(e.data.arguments, 240)
|
|
758
|
+
})
|
|
759
|
+
}
|
|
760
|
+
json({ tool, calls: out })
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
/* 重放:把选中会话的历史按虚拟时钟快进
|
|
764
|
+
/replay?action=start|pause|resume|restart|stop&speed=50 */
|
|
765
|
+
if (url === '/replay') {
|
|
766
|
+
const act = q.get('action') || 'start'
|
|
767
|
+
if (act === 'stop') {
|
|
768
|
+
REPLAY = null
|
|
769
|
+
cache = { key: '', at: 0, state: null, error: null }
|
|
770
|
+
json({ ok: true, replay: { on: false } })
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
if (!REPLAY || act === 'start' || act === 'restart') {
|
|
774
|
+
if (act === 'start' && !q.get('speed')) { /* 沿用已有速度 */ }
|
|
775
|
+
refresh(!REPLAY)
|
|
776
|
+
const list = EVENTS.list || []
|
|
777
|
+
if (!list.length) { json({ ok: false, error: '这个会话没有事件可重放' }); return }
|
|
778
|
+
const timed = list.filter((e) => typeof e.time === 'number')
|
|
779
|
+
const t0 = timed.length ? timed[0].time : Date.now()
|
|
780
|
+
const t1 = timed.length ? timed[timed.length - 1].time : t0
|
|
781
|
+
const speed = Math.max(1, Math.min(2000, Number(q.get('speed') || (REPLAY ? REPLAY.speed : 50)) || 50))
|
|
782
|
+
const mode = q.get('mode') || (REPLAY ? REPLAY.mode : 'seq')
|
|
783
|
+
const stepMs = Math.max(16, Math.min(3000, Number(q.get('step') || (REPLAY ? REPLAY.stepMs : 110)) || 110))
|
|
784
|
+
REPLAY = {
|
|
785
|
+
mode, t0, t1, speed, stepMs, total: list.length, idx: 0,
|
|
786
|
+
playing: true, elapsed: 0, lastTick: Date.now(), vtime: t0
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
if (act === 'pause' && REPLAY.playing) { replayAdvance(); REPLAY.playing = false }
|
|
790
|
+
if (act === 'resume' && !REPLAY.playing) { REPLAY.playing = true; REPLAY.lastTick = Date.now() }
|
|
791
|
+
const sp = Number(q.get('speed') || 0)
|
|
792
|
+
if (sp) REPLAY.speed = Math.max(1, Math.min(2000, sp))
|
|
793
|
+
const st = Number(q.get('step') || 0)
|
|
794
|
+
if (st) REPLAY.stepMs = Math.max(16, Math.min(3000, st))
|
|
795
|
+
}
|
|
796
|
+
cache = { key: '', at: 0, state: null, error: null }
|
|
797
|
+
const c = refresh(true)
|
|
798
|
+
const vt = replayVTime()
|
|
799
|
+
json({
|
|
800
|
+
ok: !!c.state,
|
|
801
|
+
replay: {
|
|
802
|
+
on: true, playing: REPLAY.playing, speed: REPLAY.speed, t0: REPLAY.t0, t1: REPLAY.t1,
|
|
803
|
+
vtime: vt, pct: REPLAY.t1 > REPLAY.t0 ? (vt - REPLAY.t0) / (REPLAY.t1 - REPLAY.t0) : 0,
|
|
804
|
+
done: vt >= REPLAY.t1
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
/* 会话列表:跨工作区,最近写盘的在前 */
|
|
810
|
+
if (url === '/sessions') {
|
|
811
|
+
json({ sessions: listSessions(40), current: (refresh(false).state || {}).session || null, auto: !CURRENT })
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
/* 切换会话:?session=<id 或空=自动跟最新> */
|
|
815
|
+
if (url === '/select') {
|
|
816
|
+
const id = q.get('session') || ''
|
|
817
|
+
CURRENT = id ? 'session-' + id.replace(/^session-/, '') : null
|
|
818
|
+
REPLAY = null /* 换会话就退出重放 */
|
|
819
|
+
cache = { key: '', at: 0, state: null, error: null }
|
|
820
|
+
const c = refresh(true)
|
|
821
|
+
json({ ok: !!c.state, current: c.state ? c.state.session : null, auto: !CURRENT, error: c.error })
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
if (url === '/' || url === '/index.html' || url === '/instrument.html') {
|
|
825
|
+
fs.readFile(PAGE, (err, buf) => {
|
|
826
|
+
if (err) { res.writeHead(500); res.end('instrument.html 读不到: ' + err.message); return }
|
|
827
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
828
|
+
res.end(buf)
|
|
829
|
+
})
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
res.writeHead(404); res.end('not found')
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
module.exports = { handle, listSessions, pickSession, refresh, fold, makeFolder, setOrigin, setPolicy, hasZstd: HAS_ZSTD }
|