dsh-speedometer 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +103 -2
- package/cordis.patch.yml +10 -0
- package/docs/preview.png +0 -0
- package/docs/recent.png +0 -0
- package/icon.png +0 -0
- package/lib/index.js +1005 -0
- package/lib/widget.js +1447 -0
- package/package.json +28 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Speedometer
|
|
3
|
+
*
|
|
4
|
+
* Measures generation speed at the `llm/stream` layer, so it is provider
|
|
5
|
+
* agnostic: DeepSeek official, any OpenAI-compatible endpoint and local
|
|
6
|
+
* models all pass through the same hook. Completed attempts are grouped by
|
|
7
|
+
* session and served to the in-page widget over SSE.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const ROUTE = '/dsh-speed'
|
|
15
|
+
const WIDGET_ROUTE = `${ROUTE}/widget.js`
|
|
16
|
+
const EVENTS_ROUTE = `${ROUTE}/events`
|
|
17
|
+
const STATE_ROUTE = `${ROUTE}/state`
|
|
18
|
+
const VERSION = '0.1.0'
|
|
19
|
+
const MAX_SESSIONS = 40
|
|
20
|
+
const MAX_ATTEMPTS = 1000
|
|
21
|
+
const MAX_SAMPLES = 220
|
|
22
|
+
const TICK_MS = 180
|
|
23
|
+
const WINDOW_MS = 1600
|
|
24
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
25
|
+
const HISTORY_FILE = path.join(DSH_HOME, 'dsh-speedometer', 'history.json')
|
|
26
|
+
|
|
27
|
+
const DESKTOP_WIDGET_ROW_TEXT =
|
|
28
|
+
'(function(){try{var d=document.body||document.head||document.documentElement;if(!d)return;' +
|
|
29
|
+
'if(document.getElementById("dsh-speedometer-script"))return;' +
|
|
30
|
+
'var s=document.createElement("script");s.id="dsh-speedometer-script";s.src="' + WIDGET_ROUTE + '";' +
|
|
31
|
+
's.onerror=function(){};d.appendChild(s)}catch(e){}})()'
|
|
32
|
+
|
|
33
|
+
function clamp(value, min, max) {
|
|
34
|
+
return Math.min(max, Math.max(min, value))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function finite(value) {
|
|
38
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function count(value) {
|
|
42
|
+
return finite(value) && value >= 0 ? Math.floor(value) : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function textOfContent(content) {
|
|
46
|
+
try {
|
|
47
|
+
if (typeof content === 'string') return content
|
|
48
|
+
if (!Array.isArray(content)) return ''
|
|
49
|
+
const parts = []
|
|
50
|
+
for (const block of content) {
|
|
51
|
+
if (!block || typeof block !== 'object') continue
|
|
52
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
53
|
+
else if (block.type === 'reasoning' && typeof block.text === 'string') parts.push(block.text)
|
|
54
|
+
}
|
|
55
|
+
return parts.join(' ').trim()
|
|
56
|
+
} catch {
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function shortLabel(text, max = 34) {
|
|
62
|
+
const normalized = String(text || '').replace(/\s+/g, ' ').trim()
|
|
63
|
+
if (!normalized) return ''
|
|
64
|
+
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeId(value) {
|
|
68
|
+
return typeof value === 'string' && value.length > 0 ? value : ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isTokenChunk(chunk) {
|
|
72
|
+
if (!chunk || typeof chunk !== 'object') return false
|
|
73
|
+
switch (chunk.type) {
|
|
74
|
+
case 'text-delta':
|
|
75
|
+
case 'reasoning-delta':
|
|
76
|
+
return typeof chunk.text === 'string' && chunk.text.length > 0
|
|
77
|
+
case 'tool-call-delta':
|
|
78
|
+
return (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta.length > 0)
|
|
79
|
+
|| typeof chunk.name === 'string'
|
|
80
|
+
default:
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Expand DSH's compact timed stream records without importing host internals. */
|
|
86
|
+
function* expandedRecords(stream) {
|
|
87
|
+
if (!Array.isArray(stream)) return
|
|
88
|
+
for (const record of stream) {
|
|
89
|
+
if (!record || typeof record !== 'object') continue
|
|
90
|
+
if (record.type === 'chunk' && record.chunk && typeof record.chunk === 'object') {
|
|
91
|
+
yield { time: finite(record.time) ? record.time : 0, chunk: record.chunk }
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const members = record.type === 'tool-call-chunks'
|
|
95
|
+
? record.args
|
|
96
|
+
: record.type === 'text-chunks' || record.type === 'reasoning-chunks'
|
|
97
|
+
? record.texts
|
|
98
|
+
: undefined
|
|
99
|
+
if (!Array.isArray(members)) continue
|
|
100
|
+
let time = finite(record.time0) ? record.time0 : 0
|
|
101
|
+
const dt = Array.isArray(record.dt) ? record.dt : []
|
|
102
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
103
|
+
if (index > 0 && finite(dt[index - 1])) time += dt[index - 1]
|
|
104
|
+
yield { time, chunk: null, member: members[index], recordType: record.type }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Derive one durable attempt from an assistant settlement's compact stream.
|
|
111
|
+
* Historical TTFT is intentionally null: the stream begins after the request
|
|
112
|
+
* is already in flight, so claiming a first-byte latency would be misleading.
|
|
113
|
+
*/
|
|
114
|
+
function deriveSamples(tokenTimes, startTime, lastTime, maxPoints = 48) {
|
|
115
|
+
if (!Array.isArray(tokenTimes) || tokenTimes.length === 0 || !startTime) return []
|
|
116
|
+
const endTime = Math.max(lastTime, startTime + 320)
|
|
117
|
+
const span = endTime - startTime
|
|
118
|
+
const step = Math.max(280, Math.ceil(span / maxPoints))
|
|
119
|
+
const points = []
|
|
120
|
+
let cursor = 0
|
|
121
|
+
let windowStart = 0
|
|
122
|
+
for (let t = startTime; t <= endTime + step; t += step) {
|
|
123
|
+
while (cursor < tokenTimes.length && tokenTimes[cursor] <= t) cursor += 1
|
|
124
|
+
while (windowStart < cursor && tokenTimes[windowStart] < t - 1200) windowStart += 1
|
|
125
|
+
const windowTokens = cursor - windowStart
|
|
126
|
+
const elapsed = Math.max(320, Math.min(1200, t - startTime))
|
|
127
|
+
points.push({
|
|
128
|
+
t: Math.max(0, t - startTime),
|
|
129
|
+
v: windowTokens > 0 ? windowTokens / (elapsed / 1000) : 0,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
return points.length > 0 ? points : [{ t: 0, v: 0 }]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function deriveFromStream(stream, eventTime) {
|
|
136
|
+
let firstTime = 0
|
|
137
|
+
let lastTime = 0
|
|
138
|
+
let firstTokenTime = 0
|
|
139
|
+
let lastTokenTime = 0
|
|
140
|
+
let tokens = 0
|
|
141
|
+
let usage
|
|
142
|
+
let finishKind = ''
|
|
143
|
+
const tokenTimes = []
|
|
144
|
+
for (const entry of expandedRecords(stream)) {
|
|
145
|
+
if (!firstTime) firstTime = entry.time
|
|
146
|
+
lastTime = Math.max(lastTime, entry.time)
|
|
147
|
+
if (entry.chunk) {
|
|
148
|
+
if ((entry.chunk.type === 'text-delta' || entry.chunk.type === 'reasoning-delta')
|
|
149
|
+
&& typeof entry.chunk.text === 'string' && entry.chunk.text.length > 0) {
|
|
150
|
+
tokens += 1
|
|
151
|
+
if (!firstTokenTime) firstTokenTime = entry.time
|
|
152
|
+
lastTokenTime = Math.max(lastTokenTime, entry.time)
|
|
153
|
+
tokenTimes.push(entry.time)
|
|
154
|
+
} else if (entry.chunk.type === 'tool-call-delta'
|
|
155
|
+
&& ((typeof entry.chunk.argumentsDelta === 'string' && entry.chunk.argumentsDelta.length > 0)
|
|
156
|
+
|| typeof entry.chunk.name === 'string')) {
|
|
157
|
+
tokens += 1
|
|
158
|
+
if (!firstTokenTime) firstTokenTime = entry.time
|
|
159
|
+
lastTokenTime = Math.max(lastTokenTime, entry.time)
|
|
160
|
+
tokenTimes.push(entry.time)
|
|
161
|
+
} else if (entry.chunk.type === 'usage') {
|
|
162
|
+
usage = entry.chunk.usage
|
|
163
|
+
} else if (entry.chunk.type === 'finish') {
|
|
164
|
+
finishKind = entry.chunk.reason && typeof entry.chunk.reason.kind === 'string'
|
|
165
|
+
? entry.chunk.reason.kind
|
|
166
|
+
: ''
|
|
167
|
+
}
|
|
168
|
+
} else if (typeof entry.member === 'string' && entry.member.length > 0) {
|
|
169
|
+
tokens += 1
|
|
170
|
+
if (!firstTokenTime) firstTokenTime = entry.time
|
|
171
|
+
lastTokenTime = Math.max(lastTokenTime, entry.time)
|
|
172
|
+
tokenTimes.push(entry.time)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const outputTokens = usage && count(usage.outputTokens)
|
|
176
|
+
const exactTokens = outputTokens !== undefined && outputTokens > 0 ? outputTokens : tokens
|
|
177
|
+
const durationMs = firstTime && lastTime > firstTime ? lastTime - firstTime : 0
|
|
178
|
+
const pointTimes = [...new Set(tokenTimes)].sort((a, b) => a - b)
|
|
179
|
+
const lastToken = pointTimes.length > 0 ? pointTimes[pointTimes.length - 1] : lastTime
|
|
180
|
+
const spanMs = lastToken - firstTime
|
|
181
|
+
const tps = spanMs > 40 && exactTokens > 0 ? exactTokens / (spanMs / 1000) : null
|
|
182
|
+
return {
|
|
183
|
+
tokens: exactTokens,
|
|
184
|
+
inputTokens: usage && count(usage.inputTokens),
|
|
185
|
+
finishKind,
|
|
186
|
+
firstTime,
|
|
187
|
+
lastTime,
|
|
188
|
+
generationMs: spanMs || durationMs,
|
|
189
|
+
tps,
|
|
190
|
+
samples: deriveSamples(pointTimes, firstTime || eventTime, lastTime || eventTime),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function uniqueId(prefix) {
|
|
195
|
+
return `${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default {
|
|
199
|
+
name: 'dsh-speedometer',
|
|
200
|
+
|
|
201
|
+
apply(root) {
|
|
202
|
+
const rowDisposers = []
|
|
203
|
+
try {
|
|
204
|
+
rowDisposers.push(root.on('webserver/index-inject', (table) => {
|
|
205
|
+
try {
|
|
206
|
+
if (!Array.isArray(table)) return
|
|
207
|
+
for (const row of table) {
|
|
208
|
+
if (!row) continue
|
|
209
|
+
if (row.kind === 'script-src' && row.src === WIDGET_ROUTE) return
|
|
210
|
+
if (row.kind === 'script' && typeof row.text === 'string'
|
|
211
|
+
&& row.text.indexOf(WIDGET_ROUTE) !== -1) return
|
|
212
|
+
}
|
|
213
|
+
table.push({ kind: 'script', placement: 'body', text: DESKTOP_WIDGET_ROW_TEXT })
|
|
214
|
+
} catch {}
|
|
215
|
+
}))
|
|
216
|
+
} catch {}
|
|
217
|
+
|
|
218
|
+
root.effect(() => () => {
|
|
219
|
+
for (const dispose of rowDisposers) {
|
|
220
|
+
try { dispose() } catch {}
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
root.inject(['webServer', 'sessions', 'llm', 'connection'], (ctx) => {
|
|
225
|
+
const disposers = []
|
|
226
|
+
const clients = new Set()
|
|
227
|
+
const sessions = new Map()
|
|
228
|
+
let activeSessionId = null
|
|
229
|
+
let saveTimer = null
|
|
230
|
+
let lastBroadcastError = 0
|
|
231
|
+
|
|
232
|
+
function ensureSession(sessionId, patch = {}) {
|
|
233
|
+
const id = safeId(sessionId) || 'unknown'
|
|
234
|
+
let session = sessions.get(id)
|
|
235
|
+
if (!session) {
|
|
236
|
+
session = {
|
|
237
|
+
id,
|
|
238
|
+
label: '',
|
|
239
|
+
model: '',
|
|
240
|
+
provider: '',
|
|
241
|
+
createdAt: Date.now(),
|
|
242
|
+
updatedAt: Date.now(),
|
|
243
|
+
attempts: [],
|
|
244
|
+
live: null,
|
|
245
|
+
currentTurn: 0,
|
|
246
|
+
}
|
|
247
|
+
sessions.set(id, session)
|
|
248
|
+
}
|
|
249
|
+
if (patch.label) session.label = patch.label
|
|
250
|
+
if (patch.model) session.model = patch.model
|
|
251
|
+
if (patch.provider) session.provider = patch.provider
|
|
252
|
+
session.updatedAt = Date.now()
|
|
253
|
+
return session
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function trimSessions() {
|
|
257
|
+
if (sessions.size <= MAX_SESSIONS) return
|
|
258
|
+
const ordered = [...sessions.values()].sort((a, b) => a.updatedAt - b.updatedAt)
|
|
259
|
+
while (ordered.length > MAX_SESSIONS) {
|
|
260
|
+
const victim = ordered.shift()
|
|
261
|
+
if (!victim || victim.id === activeSessionId) continue
|
|
262
|
+
sessions.delete(victim.id)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function attemptSignature(attempt) {
|
|
267
|
+
return [
|
|
268
|
+
attempt.sessionId,
|
|
269
|
+
attempt.turn ?? '',
|
|
270
|
+
attempt.step ?? '',
|
|
271
|
+
attempt.tokens,
|
|
272
|
+
Math.round((attempt.endedAt || attempt.startedAt || 0) / 2000),
|
|
273
|
+
].join('|')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function addAttempt(attempt, { broadcast = true } = {}) {
|
|
277
|
+
const session = ensureSession(attempt.sessionId, {
|
|
278
|
+
model: attempt.model,
|
|
279
|
+
provider: attempt.provider,
|
|
280
|
+
})
|
|
281
|
+
const signature = attemptSignature(attempt)
|
|
282
|
+
const existing = session.attempts.find((candidate) => attemptSignature(candidate) === signature)
|
|
283
|
+
if (existing) return existing
|
|
284
|
+
session.attempts.push(attempt)
|
|
285
|
+
if (session.attempts.length > MAX_ATTEMPTS) {
|
|
286
|
+
session.attempts.splice(0, session.attempts.length - MAX_ATTEMPTS)
|
|
287
|
+
}
|
|
288
|
+
session.updatedAt = Date.now()
|
|
289
|
+
if (broadcast) emit('attempt', { attempt: publicAttempt(attempt, true), session: publicSessionMeta(session) })
|
|
290
|
+
scheduleSave()
|
|
291
|
+
return attempt
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function publicSample(sample) {
|
|
295
|
+
return { t: Math.round(sample.t), v: Math.round(sample.v * 10) / 10 }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function publicAttempt(attempt, includeSamples = false) {
|
|
299
|
+
if (!attempt) return null
|
|
300
|
+
const out = {
|
|
301
|
+
id: attempt.id,
|
|
302
|
+
provider: attempt.provider || '',
|
|
303
|
+
model: attempt.model || '',
|
|
304
|
+
startedAt: attempt.startedAt,
|
|
305
|
+
endedAt: attempt.endedAt,
|
|
306
|
+
ttftMs: finite(attempt.ttftMs) ? Math.round(attempt.ttftMs) : null,
|
|
307
|
+
durationMs: finite(attempt.durationMs) ? Math.round(attempt.durationMs) : null,
|
|
308
|
+
tokens: attempt.tokens || 0,
|
|
309
|
+
inputTokens: attempt.inputTokens,
|
|
310
|
+
tps: finite(attempt.tps) ? Math.round(attempt.tps * 10) / 10 : null,
|
|
311
|
+
peakTps: finite(attempt.peakTps) ? Math.round(attempt.peakTps * 10) / 10 : null,
|
|
312
|
+
status: attempt.status || 'done',
|
|
313
|
+
turn: attempt.turn,
|
|
314
|
+
step: attempt.step,
|
|
315
|
+
historical: attempt.historical === true,
|
|
316
|
+
}
|
|
317
|
+
if (includeSamples && Array.isArray(attempt.samples) && attempt.samples.length > 0) {
|
|
318
|
+
out.samples = attempt.samples.map(publicSample)
|
|
319
|
+
}
|
|
320
|
+
return out
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function publicSessionMeta(session) {
|
|
324
|
+
return {
|
|
325
|
+
id: session.id,
|
|
326
|
+
label: session.label || '',
|
|
327
|
+
model: session.model || '',
|
|
328
|
+
provider: session.provider || '',
|
|
329
|
+
updatedAt: session.updatedAt,
|
|
330
|
+
count: session.attempts.length,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function publicLive(live) {
|
|
335
|
+
if (!live) return null
|
|
336
|
+
return {
|
|
337
|
+
id: live.id,
|
|
338
|
+
sessionId: live.sessionId,
|
|
339
|
+
provider: live.provider || '',
|
|
340
|
+
model: live.model || '',
|
|
341
|
+
startedAt: live.startedAt,
|
|
342
|
+
tokens: live.tokens || 0,
|
|
343
|
+
tps: finite(live.tps) ? Math.round(live.tps * 10) / 10 : 0,
|
|
344
|
+
avgTps: finite(live.avgTps) ? Math.round(live.avgTps * 10) / 10 : null,
|
|
345
|
+
peakTps: finite(live.peakTps) ? Math.round(live.peakTps * 10) / 10 : null,
|
|
346
|
+
ttftMs: finite(live.ttftMs) ? Math.round(live.ttftMs) : null,
|
|
347
|
+
elapsedMs: Math.max(0, Date.now() - live.startedAt),
|
|
348
|
+
turn: live.turn,
|
|
349
|
+
samples: Array.isArray(live.samples) ? live.samples.map(publicSample) : [],
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function publicState() {
|
|
354
|
+
const list = [...sessions.values()]
|
|
355
|
+
.filter((session) => session.attempts.length > 0 || session.live)
|
|
356
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
357
|
+
.slice(0, 24)
|
|
358
|
+
.map((session) => {
|
|
359
|
+
const attempts = session.attempts.slice(-MAX_ATTEMPTS)
|
|
360
|
+
const sampleFrom = attempts.length - 120
|
|
361
|
+
const mapped = attempts.map((attempt, index) => publicAttempt(attempt, index >= sampleFrom))
|
|
362
|
+
return {
|
|
363
|
+
...publicSessionMeta(session),
|
|
364
|
+
attempts: mapped,
|
|
365
|
+
live: publicLive(session.live),
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
return {
|
|
369
|
+
type: 'state',
|
|
370
|
+
version: VERSION,
|
|
371
|
+
activeSessionId,
|
|
372
|
+
sessions: list,
|
|
373
|
+
serverTime: Date.now(),
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function writeSse(res, event, payload) {
|
|
378
|
+
try {
|
|
379
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
380
|
+
} catch (error) {
|
|
381
|
+
clients.delete(res)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function emit(event, payload) {
|
|
386
|
+
for (const res of [...clients]) {
|
|
387
|
+
try {
|
|
388
|
+
writeSse(res, event, payload)
|
|
389
|
+
} catch (error) {
|
|
390
|
+
clients.delete(res)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (clients.size === 0) return
|
|
394
|
+
if (Date.now() - lastBroadcastError < 0) lastBroadcastError = 0
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function scheduleSave() {
|
|
398
|
+
if (saveTimer) clearTimeout(saveTimer)
|
|
399
|
+
saveTimer = setTimeout(saveHistory, 900)
|
|
400
|
+
if (typeof saveTimer.unref === 'function') saveTimer.unref()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function serializableAttempt(attempt) {
|
|
404
|
+
const out = publicAttempt(attempt, true)
|
|
405
|
+
return out
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function saveHistory() {
|
|
409
|
+
saveTimer = null
|
|
410
|
+
try {
|
|
411
|
+
const payload = {
|
|
412
|
+
version: 1,
|
|
413
|
+
savedAt: Date.now(),
|
|
414
|
+
activeSessionId,
|
|
415
|
+
sessions: [...sessions.values()]
|
|
416
|
+
.filter((session) => session.attempts.length > 0)
|
|
417
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
418
|
+
.slice(-MAX_SESSIONS)
|
|
419
|
+
.map((session) => ({
|
|
420
|
+
...publicSessionMeta(session),
|
|
421
|
+
attempts: session.attempts.slice(-MAX_ATTEMPTS).map(serializableAttempt),
|
|
422
|
+
})),
|
|
423
|
+
}
|
|
424
|
+
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true })
|
|
425
|
+
const temporary = `${HISTORY_FILE}.tmp`
|
|
426
|
+
fs.writeFileSync(temporary, JSON.stringify(payload), 'utf8')
|
|
427
|
+
fs.renameSync(temporary, HISTORY_FILE)
|
|
428
|
+
} catch (error) {
|
|
429
|
+
try { ctx.logger?.warn?.(`[dsh-speedometer] history save failed: ${error.message}`) } catch {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function loadHistory() {
|
|
434
|
+
try {
|
|
435
|
+
const raw = fs.readFileSync(HISTORY_FILE, 'utf8')
|
|
436
|
+
const payload = JSON.parse(raw)
|
|
437
|
+
if (!payload || !Array.isArray(payload.sessions)) return
|
|
438
|
+
for (const item of payload.sessions) {
|
|
439
|
+
if (!item || typeof item.id !== 'string' || !Array.isArray(item.attempts)) continue
|
|
440
|
+
const session = ensureSession(item.id, {
|
|
441
|
+
label: typeof item.label === 'string' ? item.label : '',
|
|
442
|
+
model: typeof item.model === 'string' ? item.model : '',
|
|
443
|
+
provider: typeof item.provider === 'string' ? item.provider : '',
|
|
444
|
+
})
|
|
445
|
+
session.createdAt = finite(item.createdAt) ? item.createdAt : session.createdAt
|
|
446
|
+
session.updatedAt = finite(item.updatedAt) ? item.updatedAt : session.updatedAt
|
|
447
|
+
for (const candidate of item.attempts.slice(-MAX_ATTEMPTS)) {
|
|
448
|
+
if (!candidate || !finite(candidate.startedAt)) continue
|
|
449
|
+
const attempt = {
|
|
450
|
+
id: typeof candidate.id === 'string' ? candidate.id : uniqueId('a'),
|
|
451
|
+
sessionId: item.id,
|
|
452
|
+
provider: typeof candidate.provider === 'string' ? candidate.provider : '',
|
|
453
|
+
model: typeof candidate.model === 'string' ? candidate.model : '',
|
|
454
|
+
startedAt: candidate.startedAt,
|
|
455
|
+
endedAt: finite(candidate.endedAt) ? candidate.endedAt : candidate.startedAt,
|
|
456
|
+
ttftMs: finite(candidate.ttftMs) ? candidate.ttftMs : null,
|
|
457
|
+
durationMs: finite(candidate.durationMs) ? candidate.durationMs : null,
|
|
458
|
+
tokens: count(candidate.tokens) || 0,
|
|
459
|
+
inputTokens: count(candidate.inputTokens),
|
|
460
|
+
tps: finite(candidate.tps) ? candidate.tps : null,
|
|
461
|
+
peakTps: finite(candidate.peakTps) ? candidate.peakTps : null,
|
|
462
|
+
status: typeof candidate.status === 'string' ? candidate.status : 'done',
|
|
463
|
+
turn: finite(candidate.turn) ? candidate.turn : undefined,
|
|
464
|
+
step: finite(candidate.step) ? candidate.step : undefined,
|
|
465
|
+
historical: candidate.historical === true,
|
|
466
|
+
samples: Array.isArray(candidate.samples)
|
|
467
|
+
? candidate.samples.filter((s) => finite(s?.t) && finite(s?.v)).slice(-MAX_SAMPLES)
|
|
468
|
+
: [],
|
|
469
|
+
}
|
|
470
|
+
addAttempt(attempt, { broadcast: false })
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
activeSessionId = safeId(payload.activeSessionId) || activeSessionId
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error && error.code !== 'ENOENT') {
|
|
476
|
+
try { ctx.logger?.warn?.(`[dsh-speedometer] history load failed: ${error.message}`) } catch {}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function pushSample(live, tps, now) {
|
|
482
|
+
const t = Math.max(0, now - live.startedAt)
|
|
483
|
+
const last = live.samples[live.samples.length - 1]
|
|
484
|
+
if (last && t - last.t < 220) {
|
|
485
|
+
last.v = Math.max(last.v, tps)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
live.samples.push({ t, v: tps })
|
|
489
|
+
if (live.samples.length > MAX_SAMPLES) {
|
|
490
|
+
const compacted = []
|
|
491
|
+
for (let index = 0; index < live.samples.length; index += 2) {
|
|
492
|
+
const a = live.samples[index]
|
|
493
|
+
const b = live.samples[index + 1] || a
|
|
494
|
+
compacted.push({ t: b.t, v: Math.max(a.v, b.v) })
|
|
495
|
+
}
|
|
496
|
+
live.samples = compacted
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function pruneMarks(live, now) {
|
|
501
|
+
const cutoff = now - WINDOW_MS
|
|
502
|
+
while (live.marks.length > 0 && live.marks[0].t < cutoff) live.marks.shift()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function computeWindowTps(live, now) {
|
|
506
|
+
pruneMarks(live, now)
|
|
507
|
+
if (!live.firstTokenAt) return 0
|
|
508
|
+
let tokens = 0
|
|
509
|
+
for (const mark of live.marks) tokens += mark.n
|
|
510
|
+
const windowStart = Math.max(live.firstTokenAt, now - WINDOW_MS)
|
|
511
|
+
const span = Math.max(320, now - windowStart)
|
|
512
|
+
return tokens > 0 ? tokens / (span / 1000) : 0
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function tickLive(live) {
|
|
516
|
+
if (!live || live.finished) return
|
|
517
|
+
const now = Date.now()
|
|
518
|
+
live.tps = computeWindowTps(live, now)
|
|
519
|
+
if (finite(live.tps)) live.peakTps = Math.max(live.peakTps || 0, live.tps)
|
|
520
|
+
if (live.firstTokenAt && live.lastTokenAt > live.firstTokenAt && live.tokens > 0) {
|
|
521
|
+
const generation = (live.lastTokenAt - live.firstTokenAt) / 1000
|
|
522
|
+
live.avgTps = generation > 0.08 ? live.tokens / generation : null
|
|
523
|
+
}
|
|
524
|
+
live.ttftMs = live.firstTokenAt ? live.firstTokenAt - live.startedAt : null
|
|
525
|
+
pushSample(live, live.tps || 0, now)
|
|
526
|
+
emit('tick', {
|
|
527
|
+
type: 'tick',
|
|
528
|
+
id: live.id,
|
|
529
|
+
sessionId: live.sessionId,
|
|
530
|
+
tokens: live.tokens,
|
|
531
|
+
tps: Math.round((live.tps || 0) * 10) / 10,
|
|
532
|
+
avgTps: finite(live.avgTps) ? Math.round(live.avgTps * 10) / 10 : null,
|
|
533
|
+
peakTps: finite(live.peakTps) ? Math.round(live.peakTps * 10) / 10 : null,
|
|
534
|
+
ttftMs: finite(live.ttftMs) ? Math.round(live.ttftMs) : null,
|
|
535
|
+
elapsedMs: Math.max(0, now - live.startedAt),
|
|
536
|
+
sample: live.samples.length > 0 ? publicSample(live.samples[live.samples.length - 1]) : null,
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function finishLive(live, status) {
|
|
541
|
+
if (!live || live.finished) return
|
|
542
|
+
live.finished = true
|
|
543
|
+
if (live.timer) {
|
|
544
|
+
clearInterval(live.timer)
|
|
545
|
+
live.timer = null
|
|
546
|
+
}
|
|
547
|
+
const endedAt = Date.now()
|
|
548
|
+
const outputTokens = count(live.usage?.outputTokens)
|
|
549
|
+
const tokens = outputTokens !== undefined && outputTokens > 0 ? outputTokens : live.tokens
|
|
550
|
+
const generationMs = live.firstTokenAt && live.lastTokenAt > live.firstTokenAt
|
|
551
|
+
? live.lastTokenAt - live.firstTokenAt
|
|
552
|
+
: 0
|
|
553
|
+
const durationMs = Math.max(0, endedAt - live.startedAt)
|
|
554
|
+
const tps = generationMs > 40 && tokens > 0 ? tokens / (generationMs / 1000) : null
|
|
555
|
+
// Keep at least a small visible segment for very short generations.
|
|
556
|
+
const currentTps = live.tps || live.avgTps || tps || 0
|
|
557
|
+
const endT = Math.max(0, endedAt - live.startedAt)
|
|
558
|
+
if (live.samples.length === 0) {
|
|
559
|
+
live.samples.push({ t: 0, v: currentTps })
|
|
560
|
+
live.samples.push({ t: endT, v: currentTps })
|
|
561
|
+
} else {
|
|
562
|
+
const last = live.samples[live.samples.length - 1]
|
|
563
|
+
if (endT - last.t > 40) live.samples.push({ t: endT, v: currentTps })
|
|
564
|
+
else last.v = Math.max(last.v, currentTps)
|
|
565
|
+
if (live.samples.length === 1) live.samples.unshift({ t: 0, v: live.samples[0].v })
|
|
566
|
+
}
|
|
567
|
+
const attempt = {
|
|
568
|
+
id: live.id,
|
|
569
|
+
sessionId: live.sessionId,
|
|
570
|
+
provider: live.provider || '',
|
|
571
|
+
model: live.model || '',
|
|
572
|
+
startedAt: live.startedAt,
|
|
573
|
+
endedAt,
|
|
574
|
+
ttftMs: live.firstTokenAt ? live.firstTokenAt - live.startedAt : null,
|
|
575
|
+
durationMs,
|
|
576
|
+
generationMs: generationMs || durationMs,
|
|
577
|
+
tokens,
|
|
578
|
+
inputTokens: count(live.usage?.inputTokens),
|
|
579
|
+
tps,
|
|
580
|
+
avgTps: tps,
|
|
581
|
+
peakTps: live.peakTps || null,
|
|
582
|
+
status: status || 'done',
|
|
583
|
+
turn: live.turn,
|
|
584
|
+
step: live.step,
|
|
585
|
+
samples: live.samples.slice(-MAX_SAMPLES),
|
|
586
|
+
}
|
|
587
|
+
const session = ensureSession(live.sessionId)
|
|
588
|
+
if (session.live === live) session.live = null
|
|
589
|
+
addAttempt(attempt)
|
|
590
|
+
emit('end', {
|
|
591
|
+
type: 'end',
|
|
592
|
+
id: live.id,
|
|
593
|
+
sessionId: live.sessionId,
|
|
594
|
+
attempt: publicAttempt(attempt, true),
|
|
595
|
+
session: publicSessionMeta(session),
|
|
596
|
+
})
|
|
597
|
+
scheduleSave()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function wrapStream(options, next) {
|
|
601
|
+
let iterator
|
|
602
|
+
const sessionId = safeId(options?.sessionId)
|
|
603
|
+
if (!sessionId || options?.purpose) {
|
|
604
|
+
iterator = next()
|
|
605
|
+
return iterator
|
|
606
|
+
}
|
|
607
|
+
return (async function* wrapped() {
|
|
608
|
+
const live = {
|
|
609
|
+
id: uniqueId('s'),
|
|
610
|
+
sessionId,
|
|
611
|
+
provider: typeof options.provider === 'string' ? options.provider : '',
|
|
612
|
+
model: typeof options.model === 'string' ? options.model : '',
|
|
613
|
+
startedAt: Date.now(),
|
|
614
|
+
firstTokenAt: 0,
|
|
615
|
+
lastTokenAt: 0,
|
|
616
|
+
tokens: 0,
|
|
617
|
+
tps: 0,
|
|
618
|
+
avgTps: null,
|
|
619
|
+
peakTps: 0,
|
|
620
|
+
ttftMs: null,
|
|
621
|
+
marks: [],
|
|
622
|
+
samples: [],
|
|
623
|
+
usage: undefined,
|
|
624
|
+
finished: false,
|
|
625
|
+
timer: null,
|
|
626
|
+
}
|
|
627
|
+
const session = ensureSession(sessionId, {
|
|
628
|
+
model: live.model,
|
|
629
|
+
provider: live.provider,
|
|
630
|
+
})
|
|
631
|
+
live.turn = finite(session.currentTurn) ? session.currentTurn : undefined
|
|
632
|
+
if (session.live?.timer) finishLive(session.live, 'replaced')
|
|
633
|
+
session.live = live
|
|
634
|
+
activeSessionId = sessionId
|
|
635
|
+
emit('start', {
|
|
636
|
+
type: 'start',
|
|
637
|
+
sessionId,
|
|
638
|
+
session: publicSessionMeta(session),
|
|
639
|
+
live: publicLive(live),
|
|
640
|
+
activeSessionId,
|
|
641
|
+
})
|
|
642
|
+
live.timer = setInterval(() => tickLive(live), TICK_MS)
|
|
643
|
+
if (typeof live.timer.unref === 'function') live.timer.unref()
|
|
644
|
+
let status = 'done'
|
|
645
|
+
let completed = false
|
|
646
|
+
let source
|
|
647
|
+
try {
|
|
648
|
+
source = next()
|
|
649
|
+
for await (const chunk of source) {
|
|
650
|
+
const now = Date.now()
|
|
651
|
+
if (chunk && isTokenChunk(chunk)) {
|
|
652
|
+
live.tokens += 1
|
|
653
|
+
if (!live.firstTokenAt) live.firstTokenAt = now
|
|
654
|
+
live.lastTokenAt = now
|
|
655
|
+
live.marks.push({ t: now, n: 1 })
|
|
656
|
+
pruneMarks(live, now)
|
|
657
|
+
live.tps = computeWindowTps(live, now)
|
|
658
|
+
if (finite(live.tps)) live.peakTps = Math.max(live.peakTps, live.tps)
|
|
659
|
+
if (live.firstTokenAt && live.lastTokenAt > live.firstTokenAt) {
|
|
660
|
+
const generation = (live.lastTokenAt - live.firstTokenAt) / 1000
|
|
661
|
+
live.avgTps = generation > 0.08 ? live.tokens / generation : null
|
|
662
|
+
}
|
|
663
|
+
} else if (chunk && chunk.type === 'usage') {
|
|
664
|
+
live.usage = chunk.usage
|
|
665
|
+
} else if (chunk && chunk.type === 'finish') {
|
|
666
|
+
completed = true
|
|
667
|
+
status = chunk.reason && typeof chunk.reason.kind === 'string'
|
|
668
|
+
? chunk.reason.kind
|
|
669
|
+
: 'done'
|
|
670
|
+
}
|
|
671
|
+
yield chunk
|
|
672
|
+
}
|
|
673
|
+
} catch (error) {
|
|
674
|
+
status = 'error'
|
|
675
|
+
throw error
|
|
676
|
+
} finally {
|
|
677
|
+
if (!completed && status === 'done') status = 'aborted'
|
|
678
|
+
finishLive(live, status)
|
|
679
|
+
}
|
|
680
|
+
})()
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---- hydration & live session bookkeeping -----------------------------
|
|
684
|
+
function hydrateSession(session) {
|
|
685
|
+
const sessionId = safeId(session?.id)
|
|
686
|
+
if (!sessionId) return
|
|
687
|
+
let events
|
|
688
|
+
try {
|
|
689
|
+
events = typeof session.snapshotEvents === 'function'
|
|
690
|
+
? session.snapshotEvents()
|
|
691
|
+
: typeof session.ownEvents === 'function'
|
|
692
|
+
? session.ownEvents()
|
|
693
|
+
: []
|
|
694
|
+
} catch {
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
if (!Array.isArray(events) || events.length === 0) return
|
|
698
|
+
let header = null
|
|
699
|
+
for (const event of events) {
|
|
700
|
+
if (!event || typeof event !== 'object') continue
|
|
701
|
+
const type = event.type
|
|
702
|
+
if (type === 'turn/start') {
|
|
703
|
+
ensureSession(sessionId).currentTurn = finite(event.data?.turn) ? event.data.turn : 0
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
if (type === 'turn/end') {
|
|
707
|
+
ensureSession(sessionId).currentTurn = 0
|
|
708
|
+
continue
|
|
709
|
+
}
|
|
710
|
+
if (type === 'request/header') {
|
|
711
|
+
const config = event.data?.header?.config
|
|
712
|
+
if (config) header = { provider: config.provider, model: config.model }
|
|
713
|
+
continue
|
|
714
|
+
}
|
|
715
|
+
if (type === 'session/title') {
|
|
716
|
+
const title = event.data?.title
|
|
717
|
+
if (typeof title === 'string' && title.trim()) {
|
|
718
|
+
const target = ensureSession(sessionId)
|
|
719
|
+
target.label = shortLabel(title, 42)
|
|
720
|
+
}
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
if (type === 'user/message') {
|
|
724
|
+
const target = ensureSession(sessionId)
|
|
725
|
+
if (!target.label) {
|
|
726
|
+
const text = textOfContent(event.data?.message?.content ?? event.data?.content)
|
|
727
|
+
if (text) target.label = shortLabel(text)
|
|
728
|
+
}
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
if (type !== 'assistant/message' && type !== 'assistant/attempt') continue
|
|
732
|
+
const stream = event.data?.stream
|
|
733
|
+
if (!Array.isArray(stream) || stream.length === 0) continue
|
|
734
|
+
const derived = deriveFromStream(stream, event.time)
|
|
735
|
+
if (!derived || derived.tokens <= 0) continue
|
|
736
|
+
const usage = event.data?.usage
|
|
737
|
+
const outputTokens = usage?.outputTokens
|
|
738
|
+
const tokens = count(outputTokens) > 0 ? outputTokens : derived.tokens
|
|
739
|
+
const exactGeneration = derived.generationMs || 1
|
|
740
|
+
const attempt = {
|
|
741
|
+
id: uniqueId('h'),
|
|
742
|
+
sessionId,
|
|
743
|
+
provider: header?.provider || '',
|
|
744
|
+
model: header?.model || '',
|
|
745
|
+
startedAt: derived.firstTime,
|
|
746
|
+
endedAt: derived.lastTime,
|
|
747
|
+
ttftMs: null,
|
|
748
|
+
durationMs: derived.durationMs,
|
|
749
|
+
generationMs: derived.generationMs,
|
|
750
|
+
tokens,
|
|
751
|
+
inputTokens: count(usage?.inputTokens) ?? derived.inputTokens,
|
|
752
|
+
tps: derived.tps,
|
|
753
|
+
peakTps: null,
|
|
754
|
+
status: type === 'assistant/attempt'
|
|
755
|
+
? (derived.finishKind || 'failed')
|
|
756
|
+
: (derived.finishKind || 'done'),
|
|
757
|
+
turn: finite(event.data?.turn) ? event.data.turn : undefined,
|
|
758
|
+
step: finite(event.data?.step) ? event.data.step : undefined,
|
|
759
|
+
historical: true,
|
|
760
|
+
samples: derived.samples || [],
|
|
761
|
+
}
|
|
762
|
+
if (attempt.tps === null && tokens > 0 && exactGeneration > 40) {
|
|
763
|
+
attempt.tps = tokens / (exactGeneration / 1000)
|
|
764
|
+
}
|
|
765
|
+
addAttempt(attempt, { broadcast: false })
|
|
766
|
+
}
|
|
767
|
+
trimSessions()
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function hydrateExistingSessions() {
|
|
771
|
+
try {
|
|
772
|
+
const list = ctx.sessions?.list?.()
|
|
773
|
+
if (!Array.isArray(list)) return
|
|
774
|
+
for (const session of list) {
|
|
775
|
+
try { hydrateSession(session) } catch {}
|
|
776
|
+
}
|
|
777
|
+
if (!activeSessionId) {
|
|
778
|
+
let newest = null
|
|
779
|
+
for (const session of sessions.values()) {
|
|
780
|
+
if (!newest || session.updatedAt > newest.updatedAt) newest = session
|
|
781
|
+
}
|
|
782
|
+
if (newest) activeSessionId = newest.id
|
|
783
|
+
}
|
|
784
|
+
} catch {}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---- web routes -------------------------------------------------------
|
|
788
|
+
function parseHost(req) {
|
|
789
|
+
const host = String(req.headers?.host || '').toLowerCase()
|
|
790
|
+
const hostname = host.startsWith('[')
|
|
791
|
+
? host.slice(0, host.indexOf(']') + 1)
|
|
792
|
+
: host.split(':')[0]
|
|
793
|
+
return { host, hostname }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function deny(res, status = 403) {
|
|
797
|
+
try {
|
|
798
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
799
|
+
res.end('forbidden')
|
|
800
|
+
} catch {}
|
|
801
|
+
return true
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function rejected(req, res) {
|
|
805
|
+
try {
|
|
806
|
+
const { host, hostname } = parseHost(req)
|
|
807
|
+
if (!host) return deny(res)
|
|
808
|
+
if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(hostname)) return deny(res)
|
|
809
|
+
const origin = req.headers?.origin
|
|
810
|
+
if (origin) {
|
|
811
|
+
const parsed = new URL(String(origin))
|
|
812
|
+
if (parsed.host.toLowerCase() !== host) return deny(res)
|
|
813
|
+
}
|
|
814
|
+
if (String(req.headers?.['sec-fetch-site'] || '') === 'cross-site') return deny(res)
|
|
815
|
+
const connection = ctx.get?.('connection')
|
|
816
|
+
if (connection && typeof connection.requestRejection === 'function') {
|
|
817
|
+
const code = connection.requestRejection(req)
|
|
818
|
+
if (code !== undefined && code !== null && code !== false) {
|
|
819
|
+
return deny(res, typeof code === 'number' ? code : 403)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
} catch {
|
|
823
|
+
return deny(res)
|
|
824
|
+
}
|
|
825
|
+
return false
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function register(route) {
|
|
829
|
+
const inner = route.handler
|
|
830
|
+
const wrapped = {
|
|
831
|
+
...route,
|
|
832
|
+
handler: async (req, res) => {
|
|
833
|
+
if (rejected(req, res)) return
|
|
834
|
+
try {
|
|
835
|
+
return await inner(req, res)
|
|
836
|
+
} catch (error) {
|
|
837
|
+
try { ctx.logger?.warn?.(`[dsh-speedometer] route ${route.path} failed: ${error.message}`) } catch {}
|
|
838
|
+
if (!res.headersSent) {
|
|
839
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
840
|
+
res.end(JSON.stringify({ error: 'internal' }))
|
|
841
|
+
} else {
|
|
842
|
+
try { res.end() } catch {}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
}
|
|
847
|
+
return ctx.webServer.register(wrapped)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function json(res, status, payload) {
|
|
851
|
+
const body = JSON.stringify(payload)
|
|
852
|
+
res.writeHead(status, {
|
|
853
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
854
|
+
'Cache-Control': 'no-store',
|
|
855
|
+
'Content-Length': Buffer.byteLength(body),
|
|
856
|
+
})
|
|
857
|
+
res.end(body)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function widgetJs() {
|
|
861
|
+
try {
|
|
862
|
+
return fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'widget.js'), 'utf8')
|
|
863
|
+
} catch {
|
|
864
|
+
return '/* dsh-speedometer widget.js not found */'
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function serveWidget(req, res) {
|
|
869
|
+
const body = widgetJs()
|
|
870
|
+
res.writeHead(200, {
|
|
871
|
+
'Content-Type': 'application/javascript; charset=utf-8',
|
|
872
|
+
'Cache-Control': 'no-store',
|
|
873
|
+
})
|
|
874
|
+
res.end(body)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function serveState(req, res) {
|
|
878
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method' })
|
|
879
|
+
json(res, 200, publicState())
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function serveEvents(req, res) {
|
|
883
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method' })
|
|
884
|
+
res.writeHead(200, {
|
|
885
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
886
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
887
|
+
Connection: 'keep-alive',
|
|
888
|
+
'X-Accel-Buffering': 'no',
|
|
889
|
+
})
|
|
890
|
+
try { res.flushHeaders?.() } catch {}
|
|
891
|
+
res.write('retry: 1800\n\n')
|
|
892
|
+
clients.add(res)
|
|
893
|
+
writeSse(res, 'state', publicState())
|
|
894
|
+
const heartbeat = setInterval(() => {
|
|
895
|
+
try { res.write(': ping\n\n') } catch { clients.delete(res) }
|
|
896
|
+
}, 15000)
|
|
897
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref()
|
|
898
|
+
const cleanup = () => {
|
|
899
|
+
clearInterval(heartbeat)
|
|
900
|
+
clients.delete(res)
|
|
901
|
+
}
|
|
902
|
+
req.on?.('close', cleanup)
|
|
903
|
+
req.on?.('aborted', cleanup)
|
|
904
|
+
res.on?.('close', cleanup)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---- wire it up -------------------------------------------------------
|
|
908
|
+
loadHistory()
|
|
909
|
+
hydrateExistingSessions()
|
|
910
|
+
|
|
911
|
+
disposers.push(register({ kind: 'exact', path: WIDGET_ROUTE, handler: serveWidget }))
|
|
912
|
+
disposers.push(register({ kind: 'exact', path: STATE_ROUTE, handler: serveState }))
|
|
913
|
+
disposers.push(register({ kind: 'exact', path: EVENTS_ROUTE, handler: serveEvents }))
|
|
914
|
+
disposers.push(register({
|
|
915
|
+
kind: 'exact',
|
|
916
|
+
path: `${ROUTE}/health`,
|
|
917
|
+
handler: (req, res) => json(res, 200, { ok: true, version: VERSION }),
|
|
918
|
+
}))
|
|
919
|
+
|
|
920
|
+
// Browser form: the index HTML is rendered by the host, so append the
|
|
921
|
+
// script tag here. The desktop shell uses the index-inject row above.
|
|
922
|
+
disposers.push(ctx.webServer.tapIndex((html) => {
|
|
923
|
+
try {
|
|
924
|
+
if (typeof html !== 'string' || html.indexOf(WIDGET_ROUTE) !== -1) return html
|
|
925
|
+
const tag = `<script defer src="${WIDGET_ROUTE}"></script>`
|
|
926
|
+
return html.indexOf('</body>') !== -1
|
|
927
|
+
? html.replace('</body>', `${tag}</body>`)
|
|
928
|
+
: html + tag
|
|
929
|
+
} catch {
|
|
930
|
+
return html
|
|
931
|
+
}
|
|
932
|
+
}))
|
|
933
|
+
|
|
934
|
+
disposers.push(ctx.on('llm/stream', (options, next) => wrapStream(options, next)))
|
|
935
|
+
|
|
936
|
+
disposers.push(ctx.on('session/event', (session, event) => {
|
|
937
|
+
try {
|
|
938
|
+
const sessionId = safeId(session?.id)
|
|
939
|
+
if (!sessionId) return
|
|
940
|
+
if (event?.type === 'session/title') {
|
|
941
|
+
const title = event.data?.title
|
|
942
|
+
if (typeof title === 'string' && title.trim()) {
|
|
943
|
+
const target = ensureSession(sessionId)
|
|
944
|
+
target.label = shortLabel(title, 42)
|
|
945
|
+
target.updatedAt = Date.now()
|
|
946
|
+
emit('session', { type: 'session', session: publicSessionMeta(target), activeSessionId })
|
|
947
|
+
}
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
if (event?.type === 'user/message') {
|
|
951
|
+
const target = ensureSession(sessionId)
|
|
952
|
+
if (!target.label) {
|
|
953
|
+
const text = textOfContent(event.data?.message?.content ?? event.data?.content)
|
|
954
|
+
if (text) target.label = shortLabel(text)
|
|
955
|
+
}
|
|
956
|
+
if (!target.attempts.length && !target.live && sessionId !== activeSessionId) {
|
|
957
|
+
activeSessionId = sessionId
|
|
958
|
+
emit('active', { type: 'active', activeSessionId })
|
|
959
|
+
}
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
if (event?.type === 'turn/start') {
|
|
963
|
+
const target = ensureSession(sessionId)
|
|
964
|
+
target.currentTurn = finite(event.data?.turn) ? event.data.turn : 0
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
if (event?.type === 'turn/end') {
|
|
968
|
+
const target = ensureSession(sessionId)
|
|
969
|
+
target.currentTurn = 0
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
if (event?.type === 'session/disposed') {
|
|
973
|
+
try { sessions.delete(sessionId) } catch {}
|
|
974
|
+
}
|
|
975
|
+
} catch {}
|
|
976
|
+
}))
|
|
977
|
+
|
|
978
|
+
// The profile can add sessions after this plugin starts; hydrate an empty
|
|
979
|
+
// session once so titles and transcript labels are available.
|
|
980
|
+
disposers.push(ctx.on('session/created', (session) => {
|
|
981
|
+
try { hydrateSession(session) } catch {}
|
|
982
|
+
}))
|
|
983
|
+
|
|
984
|
+
disposers.push(ctx.effect(() => () => {
|
|
985
|
+
for (const dispose of disposers) {
|
|
986
|
+
try { dispose() } catch {}
|
|
987
|
+
}
|
|
988
|
+
for (const session of sessions.values()) {
|
|
989
|
+
if (session.live?.timer) clearInterval(session.live.timer)
|
|
990
|
+
}
|
|
991
|
+
for (const res of clients) {
|
|
992
|
+
try { res.end() } catch {}
|
|
993
|
+
}
|
|
994
|
+
clients.clear()
|
|
995
|
+
if (saveTimer) clearTimeout(saveTimer)
|
|
996
|
+
saveHistory()
|
|
997
|
+
}))
|
|
998
|
+
|
|
999
|
+
// Expose a small diagnostic surface for the host log.
|
|
1000
|
+
try {
|
|
1001
|
+
ctx.logger?.info?.(`[dsh-speedometer] v${VERSION} ready (${sessions.size} session(s) hydrated)`)
|
|
1002
|
+
} catch {}
|
|
1003
|
+
})
|
|
1004
|
+
},
|
|
1005
|
+
}
|