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/lib/widget.js ADDED
@@ -0,0 +1,1447 @@
1
+ /* DSH Speedometer — floating live-speed widget and conversation speed chart.
2
+ * Runs inside a closed shadow root so the host UI cannot leak styles in or out.
3
+ * All data comes from the dsh-speedometer host plugin; no network access beyond
4
+ * the same-origin routes it exposes.
5
+ */
6
+ (function () {
7
+ 'use strict'
8
+ if (window.__dshSpeedometerMounted) return
9
+ window.__dshSpeedometerMounted = true
10
+
11
+ const STATE_ROUTE = '/dsh-speed/state'
12
+ const EVENTS_ROUTE = '/dsh-speed/events'
13
+ const LS_KEY = 'dsh-speedometer.ui.v1'
14
+ const NS = 'http://www.w3.org/2000/svg'
15
+ const ALL_WINDOW = 100
16
+ const RECENT_WINDOW_MS = 60000
17
+ const RECENT_GAP_MS = 4000
18
+
19
+ const state = {
20
+ sessions: [],
21
+ activeSessionId: null,
22
+ selectedId: null,
23
+ manualSelection: false,
24
+ expanded: false,
25
+ chartMode: 'all',
26
+ chartOffset: 0,
27
+ connected: false,
28
+ mounted: false,
29
+ }
30
+ let root = null
31
+ let els = {}
32
+ let eventSource = null
33
+ let pollTimer = null
34
+ let renderQueued = false
35
+ let sessionSignature = ''
36
+ let chartHits = []
37
+ let chartGeometry = null
38
+ let chartRange = null
39
+ let hoverX = null
40
+ let drag = null
41
+ let chartDrag = null
42
+ let pillDrag = null
43
+ let suppressPillClick = false
44
+ let lastEventAt = 0
45
+
46
+ function loadPrefs() {
47
+ try {
48
+ const saved = JSON.parse(localStorage.getItem(LS_KEY) || '{}')
49
+ if (saved && typeof saved === 'object') {
50
+ if (saved.expanded === true) state.expanded = true
51
+ if (saved.chartMode === 'recent' || saved.chartMode === 'all') state.chartMode = saved.chartMode
52
+ if (Number.isFinite(saved.left) && Number.isFinite(saved.top)) {
53
+ drag = { left: saved.left, top: saved.top }
54
+ }
55
+ if (typeof saved.selectedId === 'string') state.selectedId = saved.selectedId
56
+ if (typeof saved.activeSessionId === 'string') state.activeSessionId = saved.activeSessionId
57
+ if (saved.manualSelection === true) state.manualSelection = true
58
+ }
59
+ } catch {}
60
+ }
61
+
62
+ function savePrefs() {
63
+ try {
64
+ const rect = els.wrap ? els.wrap.getBoundingClientRect() : null
65
+ localStorage.setItem(LS_KEY, JSON.stringify({
66
+ expanded: state.expanded,
67
+ chartMode: state.chartMode,
68
+ selectedId: state.selectedId || '',
69
+ activeSessionId: state.activeSessionId || '',
70
+ manualSelection: state.manualSelection === true,
71
+ left: rect && rect.width ? rect.left : undefined,
72
+ top: rect && rect.height ? rect.top : undefined,
73
+ }))
74
+ } catch {}
75
+ }
76
+
77
+ function fmt(value, digits = 1) {
78
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
79
+ if (Math.abs(value) >= 10000) return `${(value / 1000).toFixed(1)}k`
80
+ if (Math.abs(value) >= 100) return value.toFixed(0)
81
+ return value.toFixed(digits)
82
+ }
83
+
84
+ function fmtMs(ms) {
85
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '—'
86
+ if (ms < 1000) return `${Math.round(ms)} ms`
87
+ return `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`
88
+ }
89
+
90
+ function clock(ms) {
91
+ if (!Number.isFinite(ms) || ms < 0) return '0:00'
92
+ const total = Math.round(ms / 1000)
93
+ const h = Math.floor(total / 3600)
94
+ const m = Math.floor((total % 3600) / 60)
95
+ const s = total % 60
96
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
97
+ return `${m}:${String(s).padStart(2, '0')}`
98
+ }
99
+
100
+ function esc(value) {
101
+ return String(value == null ? '' : value)
102
+ .replace(/&/g, '&amp;')
103
+ .replace(/</g, '&lt;')
104
+ .replace(/>/g, '&gt;')
105
+ .replace(/"/g, '&quot;')
106
+ }
107
+
108
+ function sessionLabel(session) {
109
+ if (!session) return '未选择会话'
110
+ const title = String(session.label || '').trim()
111
+ if (title) return title
112
+ return `会话 ${String(session.id || '').slice(-6) || '—'}`
113
+ }
114
+
115
+ function modelLabel(session) {
116
+ if (!session) return ''
117
+ const model = String(session.model || '').trim()
118
+ const provider = String(session.provider || '').trim()
119
+ return model || provider || '未知模型'
120
+ }
121
+
122
+ function ensureSession(id, meta) {
123
+ if (!id) return null
124
+ let session = state.sessions.find((item) => item.id === id)
125
+ if (!session) {
126
+ session = {
127
+ id,
128
+ label: '',
129
+ model: '',
130
+ provider: '',
131
+ attempts: [],
132
+ count: 0,
133
+ updatedAt: Date.now(),
134
+ live: null,
135
+ }
136
+ state.sessions.unshift(session)
137
+ }
138
+ if (meta) {
139
+ if (typeof meta.label === 'string' && meta.label) session.label = meta.label
140
+ if (typeof meta.model === 'string' && meta.model) session.model = meta.model
141
+ if (typeof meta.provider === 'string' && meta.provider) session.provider = meta.provider
142
+ if (Number.isFinite(meta.updatedAt)) session.updatedAt = meta.updatedAt
143
+ if (Number.isFinite(meta.count)) session.count = meta.count
144
+ }
145
+ return session
146
+ }
147
+
148
+ function selectedSession() {
149
+ if (state.selectedId) {
150
+ const chosen = state.sessions.find((item) => item.id === state.selectedId)
151
+ if (chosen) return chosen
152
+ }
153
+ if (state.activeSessionId) {
154
+ const active = state.sessions.find((item) => item.id === state.activeSessionId)
155
+ if (active) return active
156
+ }
157
+ return state.sessions[0] || null
158
+ }
159
+
160
+ function sortedSessions() {
161
+ return [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
162
+ }
163
+
164
+ function chooseDefaultSession() {
165
+ if (state.manualSelection && state.selectedId
166
+ && state.sessions.some((session) => session.id === state.selectedId)) return
167
+ if (state.activeSessionId && state.sessions.some((session) => session.id === state.activeSessionId)) {
168
+ state.selectedId = state.activeSessionId
169
+ return
170
+ }
171
+ const sorted = sortedSessions()
172
+ if (!state.selectedId || !state.sessions.some((session) => session.id === state.selectedId)) {
173
+ state.selectedId = sorted[0] ? sorted[0].id : null
174
+ }
175
+ }
176
+
177
+ function CSS() {
178
+ return `
179
+ :host { all: initial; }
180
+ * { box-sizing: border-box; }
181
+ button, select { font: inherit; }
182
+ .wrap {
183
+ --sp-bg: var(--orca-surface-strong, var(--dsw-alias-bg-overlay, rgba(19, 27, 44, .975)));
184
+ --sp-text: var(--dsw-alias-label-primary, #eef4ff);
185
+ --sp-text-2: var(--dsw-alias-label-secondary, #c8d2e2);
186
+ --sp-muted: var(--dsw-alias-label-tertiary, #8998af);
187
+ --sp-border: var(--dsw-alias-border-l2, rgba(126, 169, 255, .16));
188
+ --sp-border-strong: var(--dsw-alias-border-l3, rgba(126, 169, 255, .26));
189
+ --sp-accent: var(--dsw-alias-brand-primary, #7ea9ff);
190
+ --sp-accent-invert: var(--dsw-alias-brand-primary-invert, #fff);
191
+ --sp-live: var(--orca-cyan, #5eead4);
192
+ --sp-radius: var(--dsw-radius-sm, 7px);
193
+ --sp-panel-radius: var(--dsw-radius-panel, 18px);
194
+ --sp-pill-radius: var(--dsw-radius-panel, 999px);
195
+ --sp-shadow: var(--orca-shadow, 0 24px 64px rgba(0, 0, 0, .5));
196
+ position: fixed; right: 20px; bottom: 148px; z-index: 2147483000;
197
+ pointer-events: auto;
198
+ font-family: "Inter", "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
199
+ color: var(--sp-text); font-size: 13px; line-height: 1.35;
200
+ -webkit-font-smoothing: antialiased;
201
+ }
202
+ .wrap.panel-open .pill { visibility: hidden; pointer-events: none; }
203
+ .pill {
204
+ display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 13px;
205
+ border: 1px solid var(--sp-border-strong); border-radius: var(--sp-pill-radius);
206
+ background: var(--sp-bg);
207
+ box-shadow: 0 8px 24px rgba(27, 39, 58, .14), 0 1px 2px rgba(27, 39, 58, .08);
208
+ color: var(--sp-text); cursor: pointer; backdrop-filter: blur(18px) saturate(1.15);
209
+ transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
210
+ touch-action: none; user-select: none; -webkit-user-select: none;
211
+ }
212
+ .pill.dragging { cursor: grabbing; transform: none; }
213
+ .pill:hover { transform: translateY(-1px); border-color: var(--sp-accent); box-shadow: 0 12px 30px rgba(27, 39, 58, .2); }
214
+ .pill:active { transform: translateY(0); }
215
+ .pill .dot {
216
+ width: 6px; height: 6px; border-radius: 0; background: var(--sp-muted); flex: none;
217
+ }
218
+ .wrap.live .pill .dot {
219
+ background: var(--sp-live);
220
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--sp-live) 16%, transparent);
221
+ animation: pulse 1.5s ease-in-out infinite;
222
+ }
223
+ @keyframes pulse {
224
+ 0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sp-live) 16%, transparent); }
225
+ 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--sp-live) 2%, transparent); }
226
+ }
227
+ .pill-speed { font-variant-numeric: tabular-nums; font-weight: 680; letter-spacing: -.02em; font-size: 14px; min-width: 38px; text-align: right; }
228
+ .pill-unit { color: var(--sp-muted); font-size: 11px; margin-left: -3px; }
229
+ .pill-spark { width: 58px; height: 20px; overflow: visible; }
230
+ .pill-spark path { fill: none; stroke: var(--sp-accent); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
231
+ .wrap.live .pill-spark path { stroke: var(--sp-live); }
232
+ .pill-hint { color: var(--sp-muted); font-size: 11px; white-space: nowrap; }
233
+ .panel {
234
+ position: absolute; left: 0; bottom: calc(100% + 10px);
235
+ width: 432px; max-width: calc(100vw - 24px); overflow: hidden;
236
+ border: 1px solid var(--sp-border-strong); border-radius: var(--sp-panel-radius);
237
+ background:
238
+ linear-gradient(180deg, color-mix(in srgb, var(--sp-text) 2.5%, transparent), transparent 42%),
239
+ var(--sp-bg);
240
+ box-shadow: var(--sp-shadow);
241
+ backdrop-filter: blur(22px) saturate(1.1);
242
+ display: flex; flex-direction: column;
243
+ animation: panel-in .18s ease-out;
244
+ }
245
+ @keyframes panel-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
246
+ .header {
247
+ display: flex; align-items: center; gap: 9px; padding: 11px 12px 10px;
248
+ border-bottom: 1px solid var(--sp-border);
249
+ cursor: grab; user-select: none; touch-action: none;
250
+ background: color-mix(in srgb, var(--sp-text) 2%, transparent);
251
+ }
252
+ .header:active { cursor: grabbing; }
253
+ .logo {
254
+ width: 26px; height: 26px; border-radius: var(--sp-radius); flex: none;
255
+ display: grid; place-items: center;
256
+ background: var(--sp-accent); border: 1px solid var(--sp-accent);
257
+ color: var(--sp-accent-invert);
258
+ }
259
+ .logo svg { width: 15px; height: 15px; }
260
+ .titles { min-width: 0; flex: 1; }
261
+ .title { font-weight: 680; letter-spacing: .015em; font-size: 13px; display: flex; align-items: center; gap: 7px; }
262
+ .close {
263
+ width: 27px; height: 27px; flex: none; border-radius: var(--sp-radius); border: 1px solid transparent;
264
+ background: transparent; color: var(--sp-muted); cursor: pointer; display: grid; place-items: center;
265
+ }
266
+ .close:hover {
267
+ background: color-mix(in srgb, var(--sp-text) 7%, transparent);
268
+ color: var(--sp-text); border-color: var(--sp-border);
269
+ }
270
+ .body { padding: 12px 12px 11px; }
271
+ .sessionbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
272
+ .session-select {
273
+ flex: 1; min-width: 0; height: 29px; padding: 0 25px 0 9px; border-radius: var(--sp-radius);
274
+ color: var(--sp-text); background-color: color-mix(in srgb, var(--sp-text) 3%, transparent);
275
+ border: 1px solid var(--sp-border); outline: none;
276
+ appearance: none; cursor: pointer; font-size: 11.5px;
277
+ background-image: linear-gradient(45deg, transparent 50%, var(--sp-muted) 50%), linear-gradient(135deg, var(--sp-muted) 50%, transparent 50%);
278
+ background-position: calc(100% - 13px) 12px, calc(100% - 9px) 12px;
279
+ background-size: 4px 4px, 4px 4px; background-repeat: no-repeat;
280
+ }
281
+ .session-select:focus { border-color: var(--sp-accent); }
282
+ .session-select option { background: var(--sp-bg); color: var(--sp-text); }
283
+ .model-badge {
284
+ max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
285
+ font-size: 10.5px; color: var(--sp-text-2); border: 1px solid var(--sp-border);
286
+ border-radius: var(--sp-radius); padding: 4px 7px;
287
+ background: color-mix(in srgb, var(--sp-text) 2%, transparent);
288
+ }
289
+ .hero {
290
+ display: flex; align-items: flex-end; justify-content: space-between; gap: 12px;
291
+ padding: 6px 2px 11px;
292
+ }
293
+ .hero-main { min-width: 0; }
294
+ .hero-kicker { color: var(--sp-muted); font-size: 10.5px; letter-spacing: .04em; margin-bottom: 1px; }
295
+ .hero-number {
296
+ display: flex; align-items: baseline; gap: 6px;
297
+ font-variant-numeric: tabular-nums; font-weight: 720; letter-spacing: -.045em;
298
+ font-size: 42px; line-height: 1.03; color: var(--sp-text);
299
+ }
300
+ .hero-number.idle { color: var(--sp-muted); }
301
+ .hero-unit { font-size: 11px; color: var(--sp-muted); letter-spacing: 0; font-weight: 500; }
302
+ .hero-grid { display: grid; grid-template-columns: repeat(3, auto); gap: 12px; text-align: right; }
303
+ .metric .k { color: var(--sp-muted); font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; }
304
+ .metric .v { color: var(--sp-text-2); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; margin-top: 1px; }
305
+ .chart-head { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin: 0 0 5px; }
306
+ .modes {
307
+ display: flex; gap: 2px; padding: 2px; border-radius: var(--sp-radius);
308
+ background: color-mix(in srgb, var(--sp-text) 4%, transparent);
309
+ border: 1px solid var(--sp-border);
310
+ }
311
+ .modes button {
312
+ border: 0; background: transparent; color: var(--sp-muted); font-size: 10px; padding: 3px 8px;
313
+ border-radius: var(--sp-radius); cursor: pointer;
314
+ }
315
+ .modes button.on { background: var(--sp-accent); color: var(--sp-accent-invert); font-weight: 650; }
316
+ .chart-wrap {
317
+ position: relative; height: 190px; margin: 0 -2px;
318
+ border-radius: var(--sp-radius); overflow: hidden;
319
+ background: rgba(148, 163, 184, .035);
320
+ background:
321
+ repeating-linear-gradient(90deg, color-mix(in srgb, var(--sp-text) 3.5%, transparent) 0 1px, transparent 1px 62px),
322
+ color-mix(in srgb, var(--sp-text) 2.5%, transparent);
323
+ box-shadow: inset 0 0 0 1px var(--sp-border);
324
+ }
325
+ .chart { width: 100%; height: 190px; display: block; overflow: visible; touch-action: none; }
326
+ .chart.pannable { cursor: grab; }
327
+ .chart.panning { cursor: grabbing; }
328
+ .chart .grid { stroke: color-mix(in srgb, var(--sp-text) 10%, transparent); stroke-width: 1; shape-rendering: crispEdges; }
329
+ .chart .axis { fill: var(--sp-muted); font-size: 9px; font-variant-numeric: tabular-nums; }
330
+ .chart .attempt-line { fill: none; stroke: var(--sp-accent); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; opacity: .92; }
331
+ .chart .live-line {
332
+ fill: none; stroke: var(--sp-live); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round;
333
+ filter: drop-shadow(0 0 3px rgba(32, 199, 232, .35));
334
+ }
335
+ .chart .area { fill: url(#speedFill); opacity: .24; }
336
+ .chart .area-live { fill: url(#speedFillLive); opacity: .3; }
337
+ .chart .stop-accent { stop-color: var(--sp-accent); }
338
+ .chart .stop-live { stop-color: var(--sp-live); }
339
+ .chart .attempt-dot { fill: var(--sp-accent); stroke: var(--sp-bg); stroke-width: 1; opacity: .75; }
340
+ .chart .live-dot { fill: var(--sp-live); stroke: var(--sp-bg); stroke-width: 1.2; filter: drop-shadow(0 0 4px rgba(32, 199, 232, .55)); }
341
+ .chart .guide { stroke: color-mix(in srgb, var(--sp-text) 35%, transparent); stroke-width: 1; stroke-dasharray: 3 3; }
342
+ .chart .hover-dot { fill: var(--sp-text); stroke: var(--sp-live); stroke-width: 1.4; }
343
+ .chart-empty {
344
+ position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
345
+ color: var(--sp-muted); font-size: 11px; padding: 16px; pointer-events: none;
346
+ }
347
+ .chart-tip {
348
+ position: absolute; pointer-events: none; padding: 5px 7px; border-radius: var(--sp-radius);
349
+ background: var(--sp-bg); border: 1px solid var(--sp-border-strong);
350
+ color: var(--sp-text); font-size: 10px; line-height: 1.4; white-space: nowrap;
351
+ box-shadow: 0 6px 18px rgba(27, 39, 58, .16); display: none; z-index: 2;
352
+ font-variant-numeric: tabular-nums;
353
+ }
354
+ .turns { display: flex; gap: 6px; margin-top: 9px; overflow: hidden; }
355
+ .turn-chip {
356
+ flex: 1; min-width: 0; border: 1px solid var(--sp-border); border-radius: var(--sp-radius);
357
+ background: color-mix(in srgb, var(--sp-text) 2.5%, transparent); padding: 6px 7px;
358
+ }
359
+ .turn-chip .n { color: var(--sp-muted); font-size: 9px; display: flex; justify-content: space-between; gap: 4px; }
360
+ .turn-chip .t {
361
+ color: var(--sp-text-2); font-size: 12.5px; font-weight: 650; font-variant-numeric: tabular-nums;
362
+ margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
363
+ }
364
+ .hidden { display: none !important; }
365
+ @media (max-width: 540px) {
366
+ .wrap { right: 10px; bottom: 120px; }
367
+ .panel { width: calc(100vw - 20px); }
368
+ .hero-number { font-size: 36px; }
369
+ .hero-grid { gap: 8px; }
370
+ .model-badge { max-width: 92px; }
371
+ .turns { display: none; }
372
+ }
373
+ `
374
+ }
375
+
376
+ function markup() {
377
+ return `
378
+ <style>${CSS()}</style>
379
+ <div class="wrap" id="wrap">
380
+ <button class="pill" id="pill" type="button" title="打开 DSH 速度计">
381
+ <span class="dot"></span>
382
+ <span class="pill-speed" id="pillSpeed">—</span>
383
+ <span class="pill-unit">tok/s</span>
384
+ <svg class="pill-spark" id="pillSpark" viewBox="0 0 58 20" aria-hidden="true"><path d="M1 18 L57 18"></path></svg>
385
+ <span class="pill-hint" id="pillHint">速度计</span>
386
+ </button>
387
+ <section class="panel hidden" id="panel" aria-label="DSH 速度计">
388
+ <header class="header" id="header">
389
+ <span class="logo" aria-hidden="true">
390
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
391
+ <path d="M4.6 17.2a8 8 0 1 1 14.8 0"></path>
392
+ <path d="M12 14.2 16.3 9"></path>
393
+ <circle cx="12" cy="15" r="1.5" fill="currentColor" stroke="none"></circle>
394
+ </svg>
395
+ </span>
396
+ <div class="titles">
397
+ <div class="title">DSH 速度计</div>
398
+ </div>
399
+ <button class="close" id="close" type="button" title="收起 (Esc)" aria-label="收起">
400
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5 6 8.5 10 4.5"></path></svg>
401
+ </button>
402
+ </header>
403
+ <div class="body">
404
+ <div class="sessionbar">
405
+ <select class="session-select" id="sessionSelect" aria-label="选择会话"></select>
406
+ <span class="model-badge" id="modelBadge">—</span>
407
+ </div>
408
+ <div class="hero">
409
+ <div class="hero-main">
410
+ <div class="hero-kicker" id="heroKicker">实时吞吐</div>
411
+ <div class="hero-number idle" id="heroNumber"><span id="heroValue">—</span><span class="hero-unit">tok/s</span></div>
412
+ </div>
413
+ <div class="hero-grid">
414
+ <div class="metric"><div class="k">均值</div><div class="v" id="mAvg">—</div></div>
415
+ <div class="metric"><div class="k">首字</div><div class="v" id="mTtft">—</div></div>
416
+ <div class="metric"><div class="k">输出</div><div class="v" id="mTokens">—</div></div>
417
+ </div>
418
+ </div>
419
+ <div class="chart-head">
420
+ <div class="modes" role="group" aria-label="图表范围">
421
+ <button type="button" data-mode="all">全程</button>
422
+ <button type="button" data-mode="recent">最近</button>
423
+ </div>
424
+ </div>
425
+ <div class="chart-wrap" id="chartWrap">
426
+ <svg class="chart" id="chart" role="img" aria-label="对话速度折线图"></svg>
427
+ <div class="chart-empty" id="chartEmpty">发送一条消息后,这里会绘制每次生成的速度曲线</div>
428
+ <div class="chart-tip" id="chartTip"></div>
429
+ </div>
430
+ <div class="turns" id="turns"></div>
431
+ </div>
432
+ </section>
433
+ </div>
434
+ `
435
+ }
436
+
437
+ function mount() {
438
+ if (!document.documentElement) {
439
+ setTimeout(mount, 40)
440
+ return
441
+ }
442
+ if (document.getElementById('dsh-speedometer-host')) return
443
+ const host = document.createElement('div')
444
+ host.id = 'dsh-speedometer-host'
445
+ host.style.cssText = 'all:initial;position:fixed;inset:0;width:100vw;height:100vh;overflow:visible;pointer-events:none;z-index:2147483000;'
446
+ const shadow = host.attachShadow({ mode: 'closed' })
447
+ shadow.innerHTML = markup()
448
+ ;(document.body || document.documentElement).appendChild(host)
449
+ root = shadow
450
+ els = {
451
+ wrap: shadow.getElementById('wrap'),
452
+ pill: shadow.getElementById('pill'),
453
+ pillSpeed: shadow.getElementById('pillSpeed'),
454
+ pillSpark: shadow.getElementById('pillSpark').querySelector('path'),
455
+ pillHint: shadow.getElementById('pillHint'),
456
+ panel: shadow.getElementById('panel'),
457
+ header: shadow.getElementById('header'),
458
+ close: shadow.getElementById('close'),
459
+ sessionSelect: shadow.getElementById('sessionSelect'),
460
+ modelBadge: shadow.getElementById('modelBadge'),
461
+ heroKicker: shadow.getElementById('heroKicker'),
462
+ heroNumber: shadow.getElementById('heroNumber'),
463
+ heroValue: shadow.getElementById('heroValue'),
464
+ mAvg: shadow.getElementById('mAvg'),
465
+ mTtft: shadow.getElementById('mTtft'),
466
+ mTokens: shadow.getElementById('mTokens'),
467
+ chartWrap: shadow.getElementById('chartWrap'),
468
+ chart: shadow.getElementById('chart'),
469
+ chartEmpty: shadow.getElementById('chartEmpty'),
470
+ chartTip: shadow.getElementById('chartTip'),
471
+ turns: shadow.getElementById('turns'),
472
+ }
473
+ state.mounted = true
474
+ bindUI()
475
+ restorePosition()
476
+ render()
477
+ loadState()
478
+ connect()
479
+ if (state.expanded) requestAnimationFrame(positionPanel)
480
+ window.addEventListener('resize', () => {
481
+ clampPosition()
482
+ if (state.expanded) positionPanel()
483
+ scheduleRender()
484
+ })
485
+ if (window.ResizeObserver && els.chartWrap) {
486
+ new ResizeObserver(() => scheduleRender()).observe(els.chartWrap)
487
+ }
488
+ }
489
+
490
+ function restorePosition() {
491
+ if (!drag || !els.wrap) return
492
+ if (!Number.isFinite(drag.left) || !Number.isFinite(drag.top)) return
493
+ els.wrap.style.right = 'auto'
494
+ els.wrap.style.bottom = 'auto'
495
+ els.wrap.style.left = `${Math.max(8, drag.left)}px`
496
+ els.wrap.style.top = `${Math.max(8, drag.top)}px`
497
+ clampPosition()
498
+ }
499
+
500
+ function clampPosition() {
501
+ if (!els.wrap) return
502
+ const rect = els.wrap.getBoundingClientRect()
503
+ if (!rect.width || !rect.height) return
504
+ if (els.wrap.style.left === 'auto' || !els.wrap.style.left) return
505
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - rect.width - 8))
506
+ const top = Math.min(Math.max(8, rect.top), Math.max(8, window.innerHeight - rect.height - 8))
507
+ els.wrap.style.left = `${left}px`
508
+ els.wrap.style.top = `${top}px`
509
+ drag = { left, top }
510
+ }
511
+
512
+ function positionPanel() {
513
+ if (!state.expanded || !els.wrap || !els.pill || !els.panel) return
514
+ const wrapRect = els.wrap.getBoundingClientRect()
515
+ const pillRect = els.pill.getBoundingClientRect()
516
+ const panelRect = els.panel.getBoundingClientRect()
517
+ if (!pillRect.width || !pillRect.height || !panelRect.width || !panelRect.height) return
518
+
519
+ const margin = 8
520
+ const gap = 10
521
+ const maxLeft = Math.max(margin, window.innerWidth - panelRect.width - margin)
522
+ const left = Math.min(Math.max(margin, pillRect.left), maxLeft)
523
+ els.panel.style.left = `${left - wrapRect.left}px`
524
+
525
+ const above = pillRect.top - gap - panelRect.height
526
+ const below = pillRect.bottom + gap
527
+ let top
528
+ if (above >= margin) {
529
+ top = above
530
+ } else if (below + panelRect.height <= window.innerHeight - margin) {
531
+ top = below
532
+ } else {
533
+ const maxTop = Math.max(margin, window.innerHeight - panelRect.height - margin)
534
+ top = Math.min(Math.max(margin, above), maxTop)
535
+ }
536
+ els.panel.style.top = `${top - wrapRect.top}px`
537
+ els.panel.style.bottom = 'auto'
538
+ }
539
+
540
+ function bindUI() {
541
+ els.pill.addEventListener('click', () => {
542
+ if (suppressPillClick) {
543
+ suppressPillClick = false
544
+ return
545
+ }
546
+ setExpanded(true)
547
+ })
548
+ els.pill.addEventListener('keydown', (event) => {
549
+ if (event.key === 'Enter' || event.key === ' ') {
550
+ event.preventDefault()
551
+ setExpanded(true)
552
+ }
553
+ })
554
+ els.pill.addEventListener('pointerdown', (event) => {
555
+ if (event.button !== 0) return
556
+ const rect = els.wrap.getBoundingClientRect()
557
+ pillDrag = {
558
+ pointerId: event.pointerId,
559
+ startX: event.clientX,
560
+ startY: event.clientY,
561
+ left: rect.left,
562
+ top: rect.top,
563
+ moved: false,
564
+ }
565
+ els.pill.setPointerCapture?.(event.pointerId)
566
+ })
567
+ els.pill.addEventListener('pointermove', (event) => {
568
+ if (!pillDrag || pillDrag.pointerId !== event.pointerId) return
569
+ const dx = event.clientX - pillDrag.startX
570
+ const dy = event.clientY - pillDrag.startY
571
+ if (!pillDrag.moved && Math.hypot(dx, dy) < 4) return
572
+ if (!pillDrag.moved) {
573
+ pillDrag.moved = true
574
+ els.pill.classList.add('dragging')
575
+ }
576
+ event.preventDefault()
577
+ const rect = els.wrap.getBoundingClientRect()
578
+ const left = Math.min(Math.max(8, pillDrag.left + dx), Math.max(8, window.innerWidth - rect.width - 8))
579
+ const top = Math.min(Math.max(8, pillDrag.top + dy), Math.max(8, window.innerHeight - rect.height - 8))
580
+ els.wrap.style.right = 'auto'
581
+ els.wrap.style.bottom = 'auto'
582
+ els.wrap.style.left = `${left}px`
583
+ els.wrap.style.top = `${top}px`
584
+ })
585
+ els.pill.addEventListener('pointerup', (event) => {
586
+ if (!pillDrag || pillDrag.pointerId !== event.pointerId) return
587
+ const moved = pillDrag.moved
588
+ pillDrag = null
589
+ els.pill.classList.remove('dragging')
590
+ els.pill.releasePointerCapture?.(event.pointerId)
591
+ if (moved) {
592
+ suppressPillClick = true
593
+ clampPosition()
594
+ savePrefs()
595
+ setTimeout(() => { suppressPillClick = false }, 250)
596
+ }
597
+ })
598
+ els.pill.addEventListener('pointercancel', () => {
599
+ pillDrag = null
600
+ els.pill.classList.remove('dragging')
601
+ })
602
+
603
+ els.close.addEventListener('click', () => setExpanded(false))
604
+ document.addEventListener('keydown', (event) => {
605
+ if (event.key === 'Escape' && state.expanded) setExpanded(false)
606
+ }, true)
607
+
608
+ els.sessionSelect.addEventListener('change', () => {
609
+ state.selectedId = els.sessionSelect.value
610
+ state.manualSelection = true
611
+ state.chartOffset = 0
612
+ savePrefs()
613
+ scheduleRender()
614
+ })
615
+
616
+ root.querySelectorAll('.modes button').forEach((button) => {
617
+ button.addEventListener('click', () => {
618
+ state.chartMode = button.dataset.mode === 'recent' ? 'recent' : 'all'
619
+ state.chartOffset = 0
620
+ hoverX = null
621
+ savePrefs()
622
+ render()
623
+ })
624
+ })
625
+
626
+ els.chart.addEventListener('mousemove', onChartMove, { passive: true })
627
+ els.chart.addEventListener('mouseleave', clearHover)
628
+ els.chart.addEventListener('pointerdown', (event) => {
629
+ if (event.button !== 0 || state.chartMode !== 'all') return
630
+ if (!chartRange || chartRange.maxOffset <= 0) return
631
+ chartDrag = {
632
+ pointerId: event.pointerId,
633
+ startX: event.clientX,
634
+ offset: state.chartOffset,
635
+ }
636
+ els.chart.setPointerCapture?.(event.pointerId)
637
+ els.chart.classList.add('panning')
638
+ clearHover()
639
+ event.preventDefault()
640
+ })
641
+ els.chart.addEventListener('pointermove', (event) => {
642
+ if (!chartDrag || chartDrag.pointerId !== event.pointerId) return
643
+ if (!chartGeometry || !chartRange) return
644
+ event.preventDefault()
645
+ const dx = event.clientX - chartDrag.startX
646
+ const perSlot = chartGeometry.innerW / Math.max(1, chartRange.window)
647
+ const next = Math.min(Math.max(0, chartDrag.offset + dx / perSlot), chartRange.maxOffset)
648
+ if (Math.abs(next - state.chartOffset) > 0.001) {
649
+ state.chartOffset = next
650
+ renderChart(selectedSession())
651
+ }
652
+ })
653
+ const endChartDrag = (event) => {
654
+ if (!chartDrag || chartDrag.pointerId !== event.pointerId) return
655
+ chartDrag = null
656
+ els.chart.classList.remove('panning')
657
+ els.chart.releasePointerCapture?.(event.pointerId)
658
+ hoverX = null
659
+ }
660
+ els.chart.addEventListener('pointerup', endChartDrag)
661
+ els.chart.addEventListener('pointercancel', endChartDrag)
662
+
663
+ els.header.addEventListener('pointerdown', (event) => {
664
+ if (event.button !== 0) return
665
+ if (event.target.closest('button,select')) return
666
+ const rect = els.wrap.getBoundingClientRect()
667
+ drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, left: rect.left, top: rect.top }
668
+ els.header.setPointerCapture?.(event.pointerId)
669
+ event.preventDefault()
670
+ })
671
+ els.header.addEventListener('pointermove', (event) => {
672
+ if (!drag || drag.pointerId !== event.pointerId) return
673
+ const left = Math.min(Math.max(8, drag.left + event.clientX - drag.startX), window.innerWidth - 80)
674
+ const top = Math.min(Math.max(8, drag.top + event.clientY - drag.startY), window.innerHeight - 60)
675
+ els.wrap.style.right = 'auto'
676
+ els.wrap.style.bottom = 'auto'
677
+ els.wrap.style.left = `${left}px`
678
+ els.wrap.style.top = `${top}px`
679
+ })
680
+ els.header.addEventListener('pointerup', (event) => {
681
+ if (!drag || drag.pointerId !== event.pointerId) return
682
+ drag = null
683
+ clampPosition()
684
+ savePrefs()
685
+ })
686
+ els.header.addEventListener('pointercancel', () => { drag = null })
687
+ }
688
+
689
+ function setExpanded(expanded) {
690
+ state.expanded = expanded === true
691
+ els.wrap.classList.toggle('panel-open', state.expanded)
692
+ els.panel.classList.toggle('hidden', !state.expanded)
693
+ if (state.expanded) {
694
+ clampPosition()
695
+ requestAnimationFrame(() => { positionPanel(); scheduleRender() })
696
+ }
697
+ savePrefs()
698
+ }
699
+
700
+ function parseData(payload) {
701
+ try { return JSON.parse(payload) } catch { return null }
702
+ }
703
+
704
+ function applyState(payload) {
705
+ if (!payload || !Array.isArray(payload.sessions)) return
706
+ state.sessions = payload.sessions
707
+ if (typeof payload.activeSessionId === 'string' && payload.activeSessionId) {
708
+ state.activeSessionId = payload.activeSessionId
709
+ }
710
+ for (const session of state.sessions) {
711
+ if (!Array.isArray(session.attempts)) session.attempts = []
712
+ if (!Number.isFinite(session.count)) session.count = session.attempts.length
713
+ if (!session.live) delete session.live
714
+ }
715
+ chooseDefaultSession()
716
+ refreshSessionOptions()
717
+ if (state.expanded) requestAnimationFrame(positionPanel)
718
+ }
719
+
720
+ function findSession(id) {
721
+ return state.sessions.find((session) => session.id === id) || null
722
+ }
723
+
724
+ function applyEvent(type, data) {
725
+ if (!data) return
726
+ lastEventAt = Date.now()
727
+ if (type === 'state') {
728
+ applyState(data)
729
+ scheduleRender()
730
+ return
731
+ }
732
+ if (type === 'start') {
733
+ state.activeSessionId = data.sessionId || state.activeSessionId
734
+ const session = ensureSession(data.sessionId, data.session)
735
+ if (session) {
736
+ session.live = data.live || null
737
+ if (data.live && data.live.startedAt) session.updatedAt = data.live.startedAt
738
+ }
739
+ if (session && !state.manualSelection) state.selectedId = session.id
740
+ refreshSessionOptions()
741
+ scheduleRender()
742
+ return
743
+ }
744
+ if (type === 'tick') {
745
+ let session = findSession(data.sessionId)
746
+ if (!session) {
747
+ session = ensureSession(data.sessionId)
748
+ }
749
+ if (session) {
750
+ if (!session.live) {
751
+ session.live = {
752
+ id: data.id,
753
+ sessionId: data.sessionId,
754
+ provider: session.provider || '',
755
+ model: session.model || '',
756
+ startedAt: Date.now() - (data.elapsedMs || 0),
757
+ tokens: data.tokens || 0,
758
+ tps: data.tps || 0,
759
+ avgTps: data.avgTps,
760
+ peakTps: data.peakTps,
761
+ ttftMs: data.ttftMs,
762
+ elapsedMs: data.elapsedMs || 0,
763
+ samples: [],
764
+ }
765
+ }
766
+ const live = session.live
767
+ live.tokens = data.tokens || 0
768
+ live.tps = data.tps || 0
769
+ live.avgTps = data.avgTps
770
+ live.peakTps = data.peakTps
771
+ live.ttftMs = data.ttftMs
772
+ live.elapsedMs = data.elapsedMs || live.elapsedMs || 0
773
+ if (data.sample && Number.isFinite(data.sample.t) && Number.isFinite(data.sample.v)) {
774
+ const last = live.samples[live.samples.length - 1]
775
+ if (last && Math.abs(last.t - data.sample.t) < 1) last.v = Math.max(last.v, data.sample.v)
776
+ else if (!last || data.sample.t > last.t) live.samples.push({ t: data.sample.t, v: data.sample.v })
777
+ if (live.samples.length > 260) live.samples.splice(0, live.samples.length - 260)
778
+ }
779
+ }
780
+ scheduleRender()
781
+ return
782
+ }
783
+ if (type === 'end') {
784
+ const session = findSession(data.sessionId)
785
+ if (session) {
786
+ if (data.attempt) {
787
+ const index = session.attempts.findIndex((attempt) => attempt.id === data.attempt.id)
788
+ if (index === -1) session.attempts.push(data.attempt)
789
+ else session.attempts[index] = data.attempt
790
+ }
791
+ if (data.session) {
792
+ if (Number.isFinite(data.session.count)) session.count = data.session.count
793
+ if (data.session.label) session.label = data.session.label
794
+ }
795
+ session.live = null
796
+ session.updatedAt = Date.now()
797
+ }
798
+ refreshSessionOptions()
799
+ scheduleRender()
800
+ return
801
+ }
802
+ if (type === 'attempt') {
803
+ const session = ensureSession(data.sessionId || data.attempt?.sessionId, data.session)
804
+ if (session && data.attempt) {
805
+ const index = session.attempts.findIndex((attempt) => attempt.id === data.attempt.id)
806
+ if (index === -1) session.attempts.push(data.attempt)
807
+ else session.attempts[index] = data.attempt
808
+ }
809
+ refreshSessionOptions()
810
+ scheduleRender()
811
+ return
812
+ }
813
+ if (type === 'session') {
814
+ const session = ensureSession(data.session?.id, data.session)
815
+ if (session) session.updatedAt = Date.now()
816
+ refreshSessionOptions()
817
+ scheduleRender()
818
+ return
819
+ }
820
+ if (type === 'active') {
821
+ state.activeSessionId = data.activeSessionId || state.activeSessionId
822
+ chooseDefaultSession()
823
+ refreshSessionOptions()
824
+ scheduleRender()
825
+ }
826
+ }
827
+
828
+ function refreshSessionOptions() {
829
+ if (!els.sessionSelect) return
830
+ const sorted = sortedSessions()
831
+ const signature = sorted.map((session) => [
832
+ session.id,
833
+ session.label,
834
+ session.count,
835
+ session.model,
836
+ session.live ? '1' : '0',
837
+ ].join(':')).join('|')
838
+ if (signature === sessionSignature) return
839
+ sessionSignature = signature
840
+ const current = state.selectedId
841
+ const options = sorted.map((session) => {
842
+ const title = sessionLabel(session)
843
+ const suffix = session.live ? ' · 生成中' : session.count ? ` · ${session.count} 次` : ''
844
+ return `<option value="${esc(session.id)}">${esc(title + suffix)}</option>`
845
+ })
846
+ els.sessionSelect.innerHTML = options.join('') || '<option value="">暂无会话</option>'
847
+ if (current && sorted.some((session) => session.id === current)) els.sessionSelect.value = current
848
+ else if (sorted[0]) els.sessionSelect.value = sorted[0].id
849
+ }
850
+
851
+ function sessionStats(session) {
852
+ const attempts = Array.isArray(session?.attempts) ? session.attempts : []
853
+ const values = attempts.map((attempt) => attempt.tps).filter((value) => Number.isFinite(value) && value >= 0)
854
+ const avg = values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null
855
+ const peaks = attempts.map((attempt) => attempt.peakTps ?? attempt.tps).filter((value) => Number.isFinite(value))
856
+ const peak = peaks.length ? Math.max(...peaks) : null
857
+ const totalTokens = attempts.reduce((sum, attempt) => sum + (Number.isFinite(attempt.tokens) ? attempt.tokens : 0), 0)
858
+ return { attempts, avg, peak, totalTokens }
859
+ }
860
+
861
+ function render() {
862
+ renderQueued = false
863
+ if (!state.mounted) return
864
+ const session = selectedSession()
865
+ const live = session && session.live ? session.live : null
866
+ const attempts = session && Array.isArray(session.attempts) ? session.attempts : []
867
+ const last = attempts.length ? attempts[attempts.length - 1] : null
868
+ const stats = sessionStats(session)
869
+ const isLiveForSelected = !!live
870
+
871
+ els.wrap.classList.toggle('live', isLiveForSelected)
872
+ els.wrap.classList.toggle('panel-open', state.expanded)
873
+ els.panel.classList.toggle('hidden', !state.expanded)
874
+
875
+ const heroValue = live ? live.tps : (last && Number.isFinite(last.tps) ? last.tps : null)
876
+ const avg = live ? live.avgTps : last ? last.tps : null
877
+ const ttft = live ? live.ttftMs : last ? last.ttftMs : null
878
+ const tokens = live ? live.tokens : last ? last.tokens : null
879
+ els.modelBadge.textContent = session ? modelLabel(session) : '—'
880
+ els.modelBadge.title = session ? modelLabel(session) : ''
881
+ els.heroKicker.textContent = live ? '实时吞吐' : last ? '上次平均' : '等待生成'
882
+ els.heroValue.textContent = heroValue == null ? '—' : fmt(heroValue)
883
+ els.heroNumber.classList.toggle('idle', !live)
884
+ els.mAvg.textContent = avg == null ? '—' : fmt(avg)
885
+ els.mTtft.textContent = fmtMs(ttft)
886
+ els.mTokens.textContent = tokens == null ? '—' : fmt(tokens, 0)
887
+ els.pillSpeed.textContent = heroValue == null ? '—' : fmt(heroValue)
888
+ els.pillHint.textContent = live ? '生成中' : last ? '上次' : '速度计'
889
+
890
+ const count = attempts.length + (live ? 1 : 0)
891
+ const avgText = stats.avg == null ? '—' : fmt(stats.avg)
892
+ els.chartEmpty.classList.toggle('hidden', count > 0)
893
+ els.chart.setAttribute('aria-label', count
894
+ ? `对话速度折线图,共 ${count} 次生成,平均 ${avgText} tok/s`
895
+ : '对话速度折线图,暂无数据')
896
+
897
+ renderSpark(live, last)
898
+ renderTurns(attempts, live)
899
+ if (state.expanded) renderChart(session)
900
+ refreshSessionOptions()
901
+ }
902
+
903
+ function sparkValues(live, last) {
904
+ const samples = (live && Array.isArray(live.samples) ? live.samples : null)
905
+ || (last && Array.isArray(last.samples) ? last.samples : null)
906
+ || []
907
+ return samples.filter((sample) => Number.isFinite(sample.t) && Number.isFinite(sample.v)).slice(-48)
908
+ }
909
+
910
+ function renderSpark(live, last) {
911
+ if (!els.pillSpark) return
912
+ const samples = sparkValues(live, last)
913
+ if (samples.length < 2) {
914
+ els.pillSpark.setAttribute('d', 'M1 18 L57 18')
915
+ return
916
+ }
917
+ const max = Math.max(1, ...samples.map((sample) => sample.v))
918
+ const t0 = samples[0].t
919
+ const t1 = Math.max(samples[samples.length - 1].t, t0 + 1)
920
+ const points = samples.map((sample) => {
921
+ const x = 1 + ((sample.t - t0) / (t1 - t0)) * 56
922
+ const y = 18 - (Math.min(sample.v, max) / max) * 15
923
+ return [x, y]
924
+ })
925
+ els.pillSpark.setAttribute('d', points.map(([x, y], index) => `${index ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' '))
926
+ }
927
+
928
+ function recentRoundGroups(attempts) {
929
+ let maxTurn = 0
930
+ for (const attempt of attempts) {
931
+ if (Number.isFinite(attempt.turn)) maxTurn = Math.max(maxTurn, attempt.turn)
932
+ }
933
+ const groups = []
934
+ let synthetic = 0
935
+ for (const attempt of attempts) {
936
+ const turn = Number.isFinite(attempt.turn) ? attempt.turn : null
937
+ let group = groups[groups.length - 1]
938
+ const turnChanged = turn !== null && group && group.turn !== null && group.turn !== turn
939
+ if (!group || turnChanged || (group.complete && turn === null)) {
940
+ if (turn === null) synthetic += 1
941
+ group = {
942
+ turn,
943
+ labelTurn: turn !== null ? turn : maxTurn + synthetic,
944
+ tokens: 0,
945
+ attempts: 0,
946
+ lastStatus: null,
947
+ complete: false,
948
+ }
949
+ groups.push(group)
950
+ } else if (group.turn === null && turn !== null) {
951
+ group.turn = turn
952
+ group.labelTurn = turn
953
+ }
954
+ group.tokens += Number.isFinite(attempt.tokens) ? attempt.tokens : 0
955
+ group.attempts += 1
956
+ group.lastStatus = attempt.status
957
+ group.complete = attempt.status !== 'tool-calls'
958
+ }
959
+ return groups.filter((group) => group.complete).slice(-5)
960
+ }
961
+
962
+ function renderTurns(attempts, live) {
963
+ if (!els.turns) return
964
+ const items = recentRoundGroups(attempts).map((group) => ({
965
+ label: `第${group.labelTurn}轮`,
966
+ tokens: group.tokens,
967
+ count: group.attempts,
968
+ status: group.lastStatus,
969
+ }))
970
+ els.turns.innerHTML = items.map((item) => `
971
+ <div class="turn-chip" title="${esc(item.label)} · ${item.count} 次生成 · ${esc(item.status || 'unknown')} · 输出 ${fmt(item.tokens, 0)} tokens">
972
+ <div class="n"><span>${esc(item.label)}</span></div>
973
+ <div class="t">${fmt(item.tokens, 0)}</div>
974
+ </div>
975
+ `).join('')
976
+ }
977
+
978
+ function sampleAverage(samples) {
979
+ if (!samples.length) return 0
980
+ return samples.reduce((sum, sample) => sum + sample.v, 0) / samples.length
981
+ }
982
+
983
+ function timeAgoLabel(ms) {
984
+ if (!Number.isFinite(ms)) return ''
985
+ const seconds = Math.max(0, ms) / 1000
986
+ return seconds < 10 ? `-${seconds.toFixed(1)}s` : `-${Math.round(seconds)}s`
987
+ }
988
+
989
+ function pushRecentGroups(groups, points, attempt, index, live) {
990
+ if (points.length === 0) return
991
+ points.sort((a, b) => a.abs - b.abs)
992
+ let segment = []
993
+ const flush = () => {
994
+ if (segment.length === 0) return
995
+ if (segment.length === 1) {
996
+ const only = segment[0]
997
+ segment = [
998
+ { ...only, x: Math.max(0, only.x - 0.001) },
999
+ { ...only, x: Math.min(1, only.x + 0.001) },
1000
+ ]
1001
+ }
1002
+ groups.push({
1003
+ points: segment,
1004
+ live,
1005
+ average: false,
1006
+ attempt: live ? null : attempt,
1007
+ slot: 0,
1008
+ attemptIndex: live ? null : index,
1009
+ label: live ? '正在生成' : `第 ${index + 1} 次生成`,
1010
+ })
1011
+ segment = []
1012
+ }
1013
+ for (const point of points) {
1014
+ if (segment.length > 0 && point.abs - segment[segment.length - 1].abs > RECENT_GAP_MS) flush()
1015
+ segment.push(point)
1016
+ }
1017
+ flush()
1018
+ }
1019
+
1020
+ function recentChartGroups(attempts, live) {
1021
+ const attemptEnd = (attempt) => {
1022
+ if (Number.isFinite(attempt.endedAt) && attempt.endedAt > 0) return attempt.endedAt
1023
+ const base = Number.isFinite(attempt.startedAt) ? attempt.startedAt : 0
1024
+ const samples = Array.isArray(attempt.samples) ? attempt.samples : []
1025
+ const last = samples.length ? samples[samples.length - 1] : null
1026
+ return last && Number.isFinite(last.t) ? base + last.t : base
1027
+ }
1028
+ let anchor = 0
1029
+ for (const attempt of attempts) anchor = Math.max(anchor, attemptEnd(attempt))
1030
+ if (live && Number.isFinite(live.startedAt)) {
1031
+ const elapsed = Number.isFinite(live.elapsedMs) ? live.elapsedMs : Date.now() - live.startedAt
1032
+ anchor = Math.max(anchor, live.startedAt + Math.max(0, elapsed))
1033
+ }
1034
+ if (!(anchor > 0)) return []
1035
+ const from = anchor - RECENT_WINDOW_MS
1036
+ const groups = []
1037
+ attempts.forEach((attempt, index) => {
1038
+ const base = Number.isFinite(attempt.startedAt) ? attempt.startedAt : null
1039
+ if (base == null) return
1040
+ const samples = (Array.isArray(attempt.samples) ? attempt.samples : [])
1041
+ .filter((sample) => Number.isFinite(sample.t) && Number.isFinite(sample.v) && sample.t >= 0)
1042
+ let points = samples
1043
+ .map((sample) => ({ abs: base + sample.t, y: sample.v }))
1044
+ .filter((point) => point.abs >= from && point.abs <= anchor)
1045
+ if (points.length === 0 && Number.isFinite(attempt.tps) && attempt.tps > 0) {
1046
+ const first = Math.max(from, base)
1047
+ const last = Math.min(anchor, Number.isFinite(attempt.endedAt) ? attempt.endedAt : base)
1048
+ if (last >= first) points = [{ abs: first, y: attempt.tps }, { abs: last, y: attempt.tps }]
1049
+ }
1050
+ points = points.map((point) => ({
1051
+ ...point,
1052
+ x: (point.abs - from) / RECENT_WINDOW_MS,
1053
+ relMs: point.abs - anchor,
1054
+ timeLabel: timeAgoLabel(anchor - point.abs),
1055
+ }))
1056
+ pushRecentGroups(groups, points, attempt, index, false)
1057
+ })
1058
+ if (live && Number.isFinite(live.startedAt) && live.startedAt + 1000 >= from) {
1059
+ const samples = (Array.isArray(live.samples) ? live.samples : [])
1060
+ .filter((sample) => Number.isFinite(sample.t) && Number.isFinite(sample.v) && sample.t >= 0)
1061
+ let points = samples
1062
+ .map((sample) => ({ abs: live.startedAt + sample.t, y: sample.v }))
1063
+ .filter((point) => point.abs >= from && point.abs <= anchor)
1064
+ if (points.length === 0 && Number.isFinite(live.tps)) {
1065
+ points = [{ abs: Math.max(from, live.startedAt), y: live.tps }, { abs: anchor, y: live.tps }]
1066
+ } else if (points.length > 0) {
1067
+ const last = points[points.length - 1]
1068
+ if (anchor - last.abs > 250 && Number.isFinite(live.tps)) points.push({ abs: anchor, y: live.tps })
1069
+ }
1070
+ points = points.map((point) => ({
1071
+ ...point,
1072
+ x: (point.abs - from) / RECENT_WINDOW_MS,
1073
+ relMs: point.abs - anchor,
1074
+ timeLabel: timeAgoLabel(anchor - point.abs),
1075
+ }))
1076
+ pushRecentGroups(groups, points, null, 0, true)
1077
+ }
1078
+ chartRange = { recent: true, total: attempts.length, anchor, window: 1, maxOffset: 0, offset: 0 }
1079
+ return groups
1080
+ }
1081
+
1082
+ function chartGroups(session) {
1083
+ if (!session) return []
1084
+ const attempts = Array.isArray(session.attempts) ? session.attempts : []
1085
+ const live = session.live
1086
+ if (state.chartMode === 'recent') return recentChartGroups(attempts, live)
1087
+
1088
+ const total = attempts.length
1089
+ const liveAvg = live
1090
+ ? (Number.isFinite(live.avgTps) && live.avgTps > 0
1091
+ ? live.avgTps
1092
+ : Number.isFinite(live.tps) && live.tps > 0 ? live.tps : 0)
1093
+ : 0
1094
+ const includeLive = liveAvg > 0
1095
+ const maxOffset = Math.max(0, total - ALL_WINDOW)
1096
+ state.chartOffset = Math.min(Math.max(0, Number(state.chartOffset) || 0), maxOffset)
1097
+ const offset = state.chartOffset
1098
+ const showLive = includeLive && offset === 0
1099
+ const window = Math.min(ALL_WINDOW, total) + (showLive ? 1 : 0)
1100
+ const start = total > ALL_WINDOW ? total - ALL_WINDOW - offset : 0
1101
+ const groups = []
1102
+ const first = Math.max(0, Math.floor(start))
1103
+ const last = Math.min(total - 1, Math.ceil(start + window))
1104
+ for (let index = first; index <= last; index += 1) {
1105
+ const attempt = attempts[index]
1106
+ const x = index + 0.5 - start
1107
+ if (x < 0 || x > window) continue
1108
+ const samples = (Array.isArray(attempt.samples) ? attempt.samples : [])
1109
+ .filter((sample) => Number.isFinite(sample.t) && Number.isFinite(sample.v) && sample.t >= 0)
1110
+ const avg = Number.isFinite(attempt.tps) && attempt.tps > 0 ? attempt.tps : sampleAverage(samples)
1111
+ if (!(avg > 0)) continue
1112
+ groups.push({
1113
+ points: [{ x, y: avg, relMs: null }],
1114
+ live: false,
1115
+ average: true,
1116
+ attempt,
1117
+ slot: index - start,
1118
+ attemptIndex: index,
1119
+ label: `第 ${index + 1} 次生成`,
1120
+ })
1121
+ }
1122
+ if (showLive) {
1123
+ groups.push({
1124
+ points: [{ x: window - 0.5, y: liveAvg, relMs: null }],
1125
+ live: true,
1126
+ average: true,
1127
+ attempt: null,
1128
+ slot: window - 0.5,
1129
+ attemptIndex: null,
1130
+ label: '正在生成',
1131
+ })
1132
+ }
1133
+ chartRange = { recent: false, total, start, window, maxOffset, offset }
1134
+ return groups
1135
+ }
1136
+
1137
+ function niceMax(value) {
1138
+ if (!Number.isFinite(value) || value <= 0) return 10
1139
+ const steps = [5, 10, 20, 30, 40, 50, 75, 100, 150, 200, 300, 500, 750, 1000, 2000, 5000]
1140
+ for (const step of steps) if (value <= step) return step
1141
+ return Math.ceil(value / 1000) * 1000
1142
+ }
1143
+
1144
+ function renderChart(session) {
1145
+ if (!els.chart) return
1146
+ const groups = chartGroups(session)
1147
+ const width = Math.max(240, Math.round(els.chartWrap.clientWidth || 410))
1148
+ const height = 190
1149
+ els.chart.setAttribute('viewBox', `0 0 ${width} ${height}`)
1150
+ els.chart.setAttribute('width', String(width))
1151
+ els.chart.setAttribute('height', String(height))
1152
+ if (groups.length === 0) {
1153
+ els.chart.innerHTML = ''
1154
+ els.chart.classList.remove('pannable')
1155
+ els.chart.removeAttribute('title')
1156
+ chartHits = []
1157
+ chartGeometry = null
1158
+ chartRange = null
1159
+ return
1160
+ }
1161
+ const plot = { left: 31, right: 9, top: 10, bottom: 21 }
1162
+ const innerW = Math.max(20, width - plot.left - plot.right)
1163
+ const innerH = Math.max(20, height - plot.top - plot.bottom)
1164
+ const recentMode = !!(chartRange && chartRange.recent)
1165
+ const xMin = 0
1166
+ const xMax = recentMode ? 1 : Math.max(1, chartRange?.window || groups.length)
1167
+ let yMaxRaw = 0
1168
+ for (const group of groups) {
1169
+ for (const point of group.points) {
1170
+ if (point.y > yMaxRaw) yMaxRaw = point.y
1171
+ }
1172
+ }
1173
+ const yMax = niceMax(yMaxRaw * 1.16)
1174
+ const sx = (x) => plot.left + ((x - xMin) / (xMax - xMin)) * innerW
1175
+ const sy = (y) => plot.top + innerH - (Math.min(y, yMax) / yMax) * innerH
1176
+ let svg = `
1177
+ <defs>
1178
+ <linearGradient id="speedFill" x1="0" y1="0" x2="0" y2="1">
1179
+ <stop offset="0%" class="stop-accent" stop-opacity=".8"></stop>
1180
+ <stop offset="100%" class="stop-accent" stop-opacity="0"></stop>
1181
+ </linearGradient>
1182
+ <linearGradient id="speedFillLive" x1="0" y1="0" x2="0" y2="1">
1183
+ <stop offset="0%" class="stop-live" stop-opacity=".95"></stop>
1184
+ <stop offset="100%" class="stop-live" stop-opacity="0"></stop>
1185
+ </linearGradient>
1186
+ </defs>
1187
+ `
1188
+ svg += `<rect x="${plot.left}" y="${plot.top}" width="${innerW}" height="${innerH}" fill="transparent" pointer-events="all"></rect>`
1189
+ for (let index = 0; index <= 3; index += 1) {
1190
+ const y = plot.top + (innerH / 3) * index
1191
+ const value = yMax * (1 - index / 3)
1192
+ svg += `<line class="grid" x1="${plot.left}" y1="${y.toFixed(1)}" x2="${width - plot.right}" y2="${y.toFixed(1)}"></line>`
1193
+ svg += `<text class="axis" x="${plot.left - 5}" y="${(y + 3).toFixed(1)}" text-anchor="end">${fmt(value, value >= 100 ? 0 : 1)}</text>`
1194
+ }
1195
+ if (recentMode) {
1196
+ for (const seconds of [60, 45, 30, 15, 0]) {
1197
+ const x = sx(1 - seconds / 60)
1198
+ const anchor = seconds === 60 ? 'start' : seconds === 0 ? 'end' : 'middle'
1199
+ const tx = seconds === 60 ? plot.left : seconds === 0 ? width - plot.right - 8 : x
1200
+ svg += `<text class="axis" x="${tx.toFixed(1)}" y="${height - 5}" text-anchor="${anchor}">${seconds === 0 ? '现在' : `-${seconds}s`}</text>`
1201
+ }
1202
+ } else {
1203
+ const liveGroup = groups.find((group) => group.live)
1204
+ const history = groups.filter((group) => !group.live)
1205
+ const ticks = []
1206
+ if (!liveGroup) {
1207
+ const count = Math.min(5, history.length)
1208
+ if (history.length <= count) ticks.push(...history)
1209
+ else {
1210
+ for (let tick = 0; tick < count; tick += 1) {
1211
+ ticks.push(history[Math.round((tick * (history.length - 1)) / (count - 1))])
1212
+ }
1213
+ }
1214
+ } else {
1215
+ const count = Math.min(3, history.length)
1216
+ if (history.length <= count) ticks.push(...history)
1217
+ else {
1218
+ for (let tick = 0; tick < count; tick += 1) {
1219
+ ticks.push(history[Math.round((tick * (history.length - 1)) / count)])
1220
+ }
1221
+ }
1222
+ ticks.push(liveGroup)
1223
+ }
1224
+ for (let index = 0; index < ticks.length; index += 1) {
1225
+ const group = ticks[index]
1226
+ const last = index === ticks.length - 1
1227
+ const x = last ? width - plot.right : sx(group.points[0].x)
1228
+ const label = group.live ? '现在' : `#${group.attemptIndex + 1}`
1229
+ svg += `<text class="axis" x="${x.toFixed(1)}" y="${height - 5}" text-anchor="${last ? 'end' : 'middle'}">${label}</text>`
1230
+ }
1231
+ }
1232
+ svg += `<text class="axis" x="${width - plot.right}" y="${plot.top + 8}" text-anchor="end" opacity=".72">tok/s</text>`
1233
+ chartHits = []
1234
+ const averageMode = groups.every((group) => group.average === true)
1235
+ if (averageMode) {
1236
+ const history = groups.filter((group) => !group.live)
1237
+ if (history.length >= 2) {
1238
+ const points = history.map((group) => group.points[0])
1239
+ const path = points.map((point, index) => `${index ? 'L' : 'M'}${sx(point.x).toFixed(1)} ${sy(point.y).toFixed(1)}`).join(' ')
1240
+ const area = `${path} L${sx(points[points.length - 1].x).toFixed(1)} ${plot.top + innerH} L${sx(points[0].x).toFixed(1)} ${plot.top + innerH} Z`
1241
+ svg += `<path class="area" d="${area}"></path>`
1242
+ svg += `<path class="attempt-line" d="${path}"></path>`
1243
+ }
1244
+ const liveIndex = groups.findIndex((group) => group.live)
1245
+ if (liveIndex > 0) {
1246
+ const previous = groups[liveIndex - 1].points[0]
1247
+ const current = groups[liveIndex].points[0]
1248
+ svg += `<path class="live-line" d="M${sx(previous.x).toFixed(1)} ${sy(previous.y).toFixed(1)} L${sx(current.x).toFixed(1)} ${sy(current.y).toFixed(1)}"></path>`
1249
+ }
1250
+ const lastHistory = history[history.length - 1]
1251
+ for (let index = 0; index < groups.length; index += 1) {
1252
+ const group = groups[index]
1253
+ const point = group.points[0]
1254
+ const showDot = group.live || groups.length <= 30 || group === lastHistory
1255
+ if (showDot) {
1256
+ svg += `<circle class="${group.live ? 'live-dot' : 'attempt-dot'}" cx="${sx(point.x).toFixed(1)}" cy="${sy(point.y).toFixed(1)}" r="${group.live ? 3 : 1.8}"></circle>`
1257
+ }
1258
+ chartHits.push({
1259
+ x: sx(point.x),
1260
+ y: sy(point.y),
1261
+ rawX: point.x,
1262
+ relMs: point.relMs,
1263
+ timeLabel: point.timeLabel,
1264
+ value: point.y,
1265
+ live: group.live,
1266
+ average: true,
1267
+ attempt: group.attempt,
1268
+ label: group.label,
1269
+ })
1270
+ }
1271
+ } else {
1272
+ for (const group of groups) {
1273
+ const points = group.points
1274
+ if (points.length === 0) continue
1275
+ const path = points.map((point, index) => `${index ? 'L' : 'M'}${sx(point.x).toFixed(1)} ${sy(point.y).toFixed(1)}`).join(' ')
1276
+ if (points.length >= 2) {
1277
+ const area = `${path} L${sx(points[points.length - 1].x).toFixed(1)} ${plot.top + innerH} L${sx(points[0].x).toFixed(1)} ${plot.top + innerH} Z`
1278
+ svg += `<path class="${group.live ? 'area-live' : 'area'}" d="${area}"></path>`
1279
+ svg += `<path class="${group.live ? 'live-line' : 'attempt-line'}" d="${path}"></path>`
1280
+ }
1281
+ const endpoint = points[points.length - 1]
1282
+ svg += `<circle class="${group.live ? 'live-dot' : 'attempt-dot'}" cx="${sx(endpoint.x).toFixed(1)}" cy="${sy(endpoint.y).toFixed(1)}" r="${group.live ? 2.6 : 1.6}"></circle>`
1283
+ for (const point of points) {
1284
+ chartHits.push({
1285
+ x: sx(point.x),
1286
+ y: sy(point.y),
1287
+ rawX: point.x,
1288
+ relMs: point.relMs,
1289
+ timeLabel: point.timeLabel,
1290
+ value: point.y,
1291
+ live: group.live,
1292
+ average: false,
1293
+ attempt: group.attempt,
1294
+ label: group.label,
1295
+ })
1296
+ }
1297
+ }
1298
+ }
1299
+ const liveGroup = groups.find((group) => group.live)
1300
+ if (liveGroup) {
1301
+ const lastX = liveGroup.points[liveGroup.points.length - 1].x
1302
+ svg += `<line x1="${sx(lastX).toFixed(1)}" y1="${plot.top}" x2="${sx(lastX).toFixed(1)}" y2="${plot.top + innerH}" stroke="var(--sp-live)" stroke-opacity=".2" stroke-width="1"></line>`
1303
+ }
1304
+ svg += `<line class="guide hidden" id="guide" x1="0" y1="${plot.top}" x2="0" y2="${plot.top + innerH}"></line>`
1305
+ svg += `<circle class="hover-dot hidden" id="hoverDot" cx="0" cy="0" r="3"></circle>`
1306
+ els.chart.innerHTML = svg
1307
+ const pannable = !recentMode && chartRange.maxOffset > 0
1308
+ els.chart.classList.toggle('pannable', pannable)
1309
+ els.chart.setAttribute('title', pannable ? '横向拖动查看更早的生成' : '')
1310
+ chartGeometry = { width, height, plot, innerW, innerH, xMin, xMax, yMax, sx, sy }
1311
+ if (hoverX != null) updateHover(hoverX)
1312
+ }
1313
+
1314
+ function onChartMove(event) {
1315
+ if (!chartGeometry || chartDrag) return
1316
+ const rect = els.chart.getBoundingClientRect()
1317
+ if (!rect.width) return
1318
+ const x = ((event.clientX - rect.left) / rect.width) * chartGeometry.width
1319
+ hoverX = x
1320
+ updateHover(x)
1321
+ }
1322
+
1323
+ function updateHover(x) {
1324
+ if (!chartGeometry || !chartHits.length || !els.chart) return
1325
+ let best = null
1326
+ let bestDistance = Infinity
1327
+ for (const hit of chartHits) {
1328
+ const distance = Math.abs(hit.x - x)
1329
+ if (distance < bestDistance) {
1330
+ bestDistance = distance
1331
+ best = hit
1332
+ }
1333
+ }
1334
+ if (!best || bestDistance > 40) {
1335
+ clearHover()
1336
+ return
1337
+ }
1338
+ const guide = els.chart.querySelector('#guide')
1339
+ const dot = els.chart.querySelector('#hoverDot')
1340
+ if (guide) {
1341
+ guide.classList.remove('hidden')
1342
+ guide.setAttribute('x1', best.x.toFixed(1))
1343
+ guide.setAttribute('x2', best.x.toFixed(1))
1344
+ }
1345
+ if (dot) {
1346
+ dot.classList.remove('hidden')
1347
+ dot.setAttribute('cx', best.x.toFixed(1))
1348
+ dot.setAttribute('cy', best.y.toFixed(1))
1349
+ }
1350
+ const rel = Math.max(0, Number.isFinite(best.relMs) ? best.relMs : 0)
1351
+ const title = best.label || (best.live ? '正在生成' : best.attempt ? `第 ${Math.max(1, (session?.attempts || []).indexOf(best.attempt) + 1)} 次生成` : '生成')
1352
+ const timePart = best.average || best.live ? '' : best.timeLabel ? ` · ${best.timeLabel}` : ` · +${clock(rel)}`
1353
+ els.chartTip.innerHTML = `<b>${fmt(best.value)} tok/s</b><br>${esc(title)}${timePart}`
1354
+ els.chartTip.style.display = 'block'
1355
+ const px = (best.x / chartGeometry.width) * els.chart.getBoundingClientRect().width
1356
+ const left = Math.min(Math.max(2, px + 8), Math.max(2, els.chart.getBoundingClientRect().width - 118))
1357
+ els.chartTip.style.left = `${left}px`
1358
+ els.chartTip.style.top = `${Math.max(4, Math.min(120, best.y - 6))}px`
1359
+ }
1360
+
1361
+ function clearHover() {
1362
+ hoverX = null
1363
+ if (!els.chart) return
1364
+ const guide = els.chart.querySelector('#guide')
1365
+ const dot = els.chart.querySelector('#hoverDot')
1366
+ if (guide) guide.classList.add('hidden')
1367
+ if (dot) dot.classList.add('hidden')
1368
+ if (els.chartTip) els.chartTip.style.display = 'none'
1369
+ }
1370
+
1371
+ function scheduleRender() {
1372
+ if (renderQueued) return
1373
+ renderQueued = true
1374
+ requestAnimationFrame(render)
1375
+ }
1376
+
1377
+ async function loadState() {
1378
+ try {
1379
+ const response = await fetch(STATE_ROUTE, { cache: 'no-store', credentials: 'same-origin' })
1380
+ if (!response.ok) throw new Error(String(response.status))
1381
+ const payload = await response.json()
1382
+ applyState(payload)
1383
+ lastEventAt = Date.now()
1384
+ state.connected = true
1385
+ scheduleRender()
1386
+ } catch {
1387
+ state.connected = false
1388
+ scheduleRender()
1389
+ }
1390
+ }
1391
+
1392
+ function connect() {
1393
+ if (typeof window.EventSource !== 'function') {
1394
+ startPolling()
1395
+ return
1396
+ }
1397
+ if (eventSource) {
1398
+ try { eventSource.close() } catch {}
1399
+ }
1400
+ try {
1401
+ eventSource = new EventSource(EVENTS_ROUTE)
1402
+ } catch {
1403
+ eventSource = null
1404
+ startPolling()
1405
+ return
1406
+ }
1407
+ const types = ['state', 'start', 'tick', 'end', 'attempt', 'session', 'active']
1408
+ for (const type of types) {
1409
+ eventSource.addEventListener(type, (event) => {
1410
+ const payload = parseData(event.data)
1411
+ if (payload) applyEvent(type, payload)
1412
+ })
1413
+ }
1414
+ eventSource.onopen = () => {
1415
+ state.connected = true
1416
+ lastEventAt = Date.now()
1417
+ stopPolling()
1418
+ scheduleRender()
1419
+ }
1420
+ eventSource.onerror = () => {
1421
+ state.connected = false
1422
+ scheduleRender()
1423
+ startPolling()
1424
+ }
1425
+ }
1426
+
1427
+ function startPolling() {
1428
+ if (pollTimer) return
1429
+ pollTimer = setInterval(loadState, 2500)
1430
+ }
1431
+
1432
+ function stopPolling() {
1433
+ if (!pollTimer) return
1434
+ clearInterval(pollTimer)
1435
+ pollTimer = null
1436
+ }
1437
+
1438
+ document.addEventListener('visibilitychange', () => {
1439
+ if (document.visibilityState !== 'visible') return
1440
+ scheduleRender()
1441
+ loadState()
1442
+ })
1443
+
1444
+ loadPrefs()
1445
+ if (document.body) mount()
1446
+ else document.addEventListener('DOMContentLoaded', mount, { once: true })
1447
+ })()