@mingozhou/dsh-replay 0.3.3 → 0.4.2
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/README.md +225 -164
- package/README.zh.md +225 -164
- package/assets/icon.png +0 -0
- package/lib/client.js +955 -2419
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +8 -2
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- package/src/index.ts +290 -54
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import React, { useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type { AuditReport } from '../../core/audit.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PRICING,
|
|
5
|
+
estimateCostDetailed,
|
|
6
|
+
formatMoney,
|
|
7
|
+
type PricingEntry,
|
|
8
|
+
type PricingRate,
|
|
9
|
+
} from '../../core/cost.js'
|
|
4
10
|
import type { TimelineModel } from '../../core/timeline.js'
|
|
5
11
|
import { formatClock, formatDuration, formatTokens } from '../api.js'
|
|
6
12
|
import { useI18n } from '../i18n.js'
|
|
13
|
+
import { resetPricing, setRate, usePricing } from '../pricing.js'
|
|
7
14
|
|
|
8
15
|
/** Cumulative token samples for the line chart. */
|
|
9
16
|
interface Point {
|
|
@@ -37,13 +44,13 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
|
|
|
37
44
|
const points = useMemo<Point[]>(() => {
|
|
38
45
|
let cumInput = 0
|
|
39
46
|
let cumOutput = 0
|
|
40
|
-
const seqTime = new Map(model.items.map(
|
|
41
|
-
return model.usageSamples.map(
|
|
42
|
-
cumInput +=
|
|
43
|
-
cumOutput +=
|
|
47
|
+
const seqTime = new Map(model.items.map(item => [item.seq, item.time]))
|
|
48
|
+
return model.usageSamples.map(sample => {
|
|
49
|
+
cumInput += sample.usage.inputTokens + (sample.usage.cacheReadTokens ?? 0) + (sample.usage.cacheWriteTokens ?? 0)
|
|
50
|
+
cumOutput += sample.usage.outputTokens
|
|
44
51
|
return {
|
|
45
|
-
x: seqTime.get(
|
|
46
|
-
seq:
|
|
52
|
+
x: seqTime.get(sample.seq) ?? model.startTime,
|
|
53
|
+
seq: sample.seq, turn: sample.turn, step: sample.step,
|
|
47
54
|
cumInput, cumOutput,
|
|
48
55
|
}
|
|
49
56
|
})
|
|
@@ -60,7 +67,7 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
|
|
|
60
67
|
const sy = (y: number): number => PAD.top + plotH - (y / yMax) * plotH
|
|
61
68
|
|
|
62
69
|
const path = (key: 'cumInput' | 'cumOutput'): string =>
|
|
63
|
-
points.map((
|
|
70
|
+
points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${sx(point.x).toFixed(1)} ${sy(point[key]).toFixed(1)}`).join(' ')
|
|
64
71
|
const area = (key: 'cumInput' | 'cumOutput'): string =>
|
|
65
72
|
`${path(key)} L ${sx(points.at(-1)!.x).toFixed(1)} ${sy(0)} L ${sx(points[0].x).toFixed(1)} ${sy(0)} Z`
|
|
66
73
|
|
|
@@ -71,16 +78,18 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
|
|
|
71
78
|
const rect = svgRef.current?.getBoundingClientRect()
|
|
72
79
|
if (!rect) return
|
|
73
80
|
const mx = ((event.clientX - rect.left) / rect.width) * W
|
|
74
|
-
|
|
75
|
-
let
|
|
76
|
-
points.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Binary search the nearest point by x; mousemove should be O(log n).
|
|
82
|
+
let low = 0
|
|
83
|
+
let high = points.length - 1
|
|
84
|
+
while (low < high) {
|
|
85
|
+
const mid = (low + high) >> 1
|
|
86
|
+
if (sx(points[mid].x) < mx) low = mid + 1
|
|
87
|
+
else high = mid
|
|
88
|
+
}
|
|
89
|
+
const candidate = low > 0 && Math.abs(sx(points[low - 1].x) - mx) <= Math.abs(sx(points[low].x) - mx)
|
|
90
|
+
? low - 1
|
|
91
|
+
: low
|
|
92
|
+
setHover(candidate)
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
return (
|
|
@@ -108,11 +117,11 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
|
|
|
108
117
|
onMouseMove={onMove}
|
|
109
118
|
onMouseLeave={() => setHover(undefined)}
|
|
110
119
|
>
|
|
111
|
-
{ticks.map(
|
|
112
|
-
<g key={
|
|
113
|
-
<line className="dshr-grid" x1={PAD.left} x2={W - PAD.right} y1={sy(
|
|
114
|
-
<text className="dshr-axis-label" x={PAD.left - 8} y={sy(
|
|
115
|
-
{formatTokens(
|
|
120
|
+
{ticks.map(value => (
|
|
121
|
+
<g key={value}>
|
|
122
|
+
<line className="dshr-grid" x1={PAD.left} x2={W - PAD.right} y1={sy(value)} y2={sy(value)} />
|
|
123
|
+
<text className="dshr-axis-label" x={PAD.left - 8} y={sy(value) + 3} textAnchor="end">
|
|
124
|
+
{formatTokens(value)}
|
|
116
125
|
</text>
|
|
117
126
|
</g>
|
|
118
127
|
))}
|
|
@@ -125,7 +134,6 @@ function CumulativeTokensChart({ model }: { model: TimelineModel }): React.React
|
|
|
125
134
|
<path d={area('cumInput')} fill="var(--dshr-s-assistant)" opacity={0.1} />
|
|
126
135
|
<path d={path('cumInput')} className="dshr-line" stroke="var(--dshr-s-assistant)" />
|
|
127
136
|
<path d={path('cumOutput')} className="dshr-line" stroke="var(--dshr-s-tool)" />
|
|
128
|
-
{/* end markers + end labels */}
|
|
129
137
|
<circle cx={sx(points.at(-1)!.x)} cy={sy(points.at(-1)!.cumInput)} r={4}
|
|
130
138
|
fill="var(--dshr-s-assistant)" stroke="var(--dshr-bg)" strokeWidth={2} />
|
|
131
139
|
<circle cx={sx(points.at(-1)!.x)} cy={sy(points.at(-1)!.cumOutput)} r={4}
|
|
@@ -177,13 +185,13 @@ function ToolTimeBars({ model }: { model: TimelineModel }): React.ReactElement |
|
|
|
177
185
|
byTool.set(call.name, entry)
|
|
178
186
|
}
|
|
179
187
|
return [...byTool.entries()]
|
|
180
|
-
.map(([name,
|
|
188
|
+
.map(([name, value]) => ({ name, ...value }))
|
|
181
189
|
.sort((a, b) => b.ms - a.ms)
|
|
182
190
|
.slice(0, 8)
|
|
183
191
|
}, [model])
|
|
184
192
|
|
|
185
193
|
if (rows.length === 0) return null
|
|
186
|
-
const max = Math.max(...rows.map(
|
|
194
|
+
const max = Math.max(...rows.map(row => row.ms), 1)
|
|
187
195
|
|
|
188
196
|
return (
|
|
189
197
|
<div className="dshr-panel">
|
|
@@ -207,6 +215,64 @@ function ToolTimeBars({ model }: { model: TimelineModel }): React.ReactElement |
|
|
|
207
215
|
)
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
function PricingEditor({ pricing, onClose }: { pricing: PricingEntry[]; onClose: () => void }): React.ReactElement {
|
|
219
|
+
const { t } = useI18n()
|
|
220
|
+
const [draft, setDraft] = useState<Record<string, PricingRate>>(() =>
|
|
221
|
+
Object.fromEntries(pricing.map(entry => [entry.label, { ...entry.rate }])),
|
|
222
|
+
)
|
|
223
|
+
const update = (label: string, key: 'input' | 'cacheRead' | 'output', value: string): void => {
|
|
224
|
+
const number = Number(value)
|
|
225
|
+
if (!Number.isFinite(number) || number < 0) return
|
|
226
|
+
setDraft(current => ({ ...current, [label]: { ...current[label], [key]: number } }))
|
|
227
|
+
}
|
|
228
|
+
const save = (): void => {
|
|
229
|
+
for (const entry of DEFAULT_PRICING) setRate(entry.label, draft[entry.label])
|
|
230
|
+
onClose()
|
|
231
|
+
}
|
|
232
|
+
return (
|
|
233
|
+
<div className="dshr-price-editor">
|
|
234
|
+
<table className="dshr-table dshr-price-table">
|
|
235
|
+
<thead>
|
|
236
|
+
<tr>
|
|
237
|
+
<th>{t('ov.perModel')}</th>
|
|
238
|
+
<th>{t('ov.inputTokens')}</th>
|
|
239
|
+
<th>{t('detail.cacheRead')}</th>
|
|
240
|
+
<th>{t('ov.outputTokens')}</th>
|
|
241
|
+
</tr>
|
|
242
|
+
</thead>
|
|
243
|
+
<tbody>
|
|
244
|
+
{DEFAULT_PRICING.map(entry => (
|
|
245
|
+
<tr key={entry.label}>
|
|
246
|
+
<td>{entry.label}</td>
|
|
247
|
+
{(['input', 'cacheRead', 'output'] as const).map(key => (
|
|
248
|
+
<td key={key}>
|
|
249
|
+
<input
|
|
250
|
+
className="dshr-price-input"
|
|
251
|
+
type="number"
|
|
252
|
+
min={0}
|
|
253
|
+
step={0.01}
|
|
254
|
+
value={draft[entry.label][key]}
|
|
255
|
+
onChange={event => update(entry.label, key, event.target.value)}
|
|
256
|
+
aria-label={`${entry.label} ${key}`}
|
|
257
|
+
/>
|
|
258
|
+
</td>
|
|
259
|
+
))}
|
|
260
|
+
</tr>
|
|
261
|
+
))}
|
|
262
|
+
</tbody>
|
|
263
|
+
</table>
|
|
264
|
+
<div className="dshr-price-actions">
|
|
265
|
+
<button className="dshr-btn" onClick={() => { resetPricing(); onClose() }}>
|
|
266
|
+
{t('ov.priceReset')}
|
|
267
|
+
</button>
|
|
268
|
+
<span className="dshr-spacer" />
|
|
269
|
+
<button className="dshr-btn" onClick={onClose}>{t('exp.cancel')}</button>
|
|
270
|
+
<button className="dshr-btn dshr-btn-primary" onClick={save}>{t('exp.confirm')}</button>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
210
276
|
export function OverviewView({
|
|
211
277
|
model,
|
|
212
278
|
report,
|
|
@@ -217,9 +283,12 @@ export function OverviewView({
|
|
|
217
283
|
onOpenAudit: () => void
|
|
218
284
|
}): React.ReactElement {
|
|
219
285
|
const { t } = useI18n()
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
const
|
|
286
|
+
const pricing = usePricing()
|
|
287
|
+
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD')
|
|
288
|
+
const [editing, setEditing] = useState(false)
|
|
289
|
+
const steps = model.turns.reduce((count, turn) => count + turn.steps.length, 0)
|
|
290
|
+
const risk = report.counts.critical + report.counts.serious
|
|
291
|
+
const cost = estimateCostDetailed(model.modelTotals, pricing)
|
|
223
292
|
const tiles: { value: string; label: string; alert?: boolean; onClick?: () => void }[] = [
|
|
224
293
|
{ value: formatDuration(model.durationMs), label: t('cmp.duration') },
|
|
225
294
|
{ value: String(model.turns.length), label: t('cmp.turns') },
|
|
@@ -227,11 +296,13 @@ export function OverviewView({
|
|
|
227
296
|
{ value: String(model.toolCalls.length), label: t('cmp.toolCalls') },
|
|
228
297
|
{ value: formatTokens(model.totals.billedInput), label: t('ov.inputTokens') },
|
|
229
298
|
{ value: formatTokens(model.totals.output), label: t('ov.outputTokens') },
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
:
|
|
233
|
-
|
|
299
|
+
{
|
|
300
|
+
value: cost !== undefined ? formatMoney(cost.usd, currency) : '—',
|
|
301
|
+
label: cost !== undefined ? t('ov.cost', { label: cost.label }) : t('ov.noCost'),
|
|
302
|
+
},
|
|
303
|
+
{ value: String(risk), label: t('ov.risk'), alert: risk > 0, onClick: onOpenAudit },
|
|
234
304
|
]
|
|
305
|
+
|
|
235
306
|
return (
|
|
236
307
|
<div className="dshr-overview">
|
|
237
308
|
<div className="dshr-tiles">
|
|
@@ -245,7 +316,7 @@ export function OverviewView({
|
|
|
245
316
|
>
|
|
246
317
|
<span className="dshr-tile-value">
|
|
247
318
|
{tile.value}
|
|
248
|
-
{tile.alert ? <span className="dshr-tile-flag">⛔ {
|
|
319
|
+
{tile.alert ? <span className="dshr-tile-flag">⛔ {risk}</span> : null}
|
|
249
320
|
</span>
|
|
250
321
|
<span className="dshr-tile-label">{tile.label}</span>
|
|
251
322
|
</button>
|
|
@@ -253,6 +324,51 @@ export function OverviewView({
|
|
|
253
324
|
</div>
|
|
254
325
|
<CumulativeTokensChart model={model} />
|
|
255
326
|
<ToolTimeBars model={model} />
|
|
327
|
+
<div className="dshr-panel dshr-cost-panel">
|
|
328
|
+
<div className="dshr-panel-head">
|
|
329
|
+
<span className="dshr-panel-title">{t('ov.costPanel')}</span>
|
|
330
|
+
<span className="dshr-spacer" />
|
|
331
|
+
<span className="dshr-segmented">
|
|
332
|
+
<button data-active={currency === 'USD' || undefined} onClick={() => setCurrency('USD')}>USD</button>
|
|
333
|
+
<button data-active={currency === 'CNY' || undefined} onClick={() => setCurrency('CNY')}>CNY</button>
|
|
334
|
+
</span>
|
|
335
|
+
<button className="dshr-tab" onClick={() => setEditing(value => !value)}>
|
|
336
|
+
{t('ov.editPrices')}
|
|
337
|
+
</button>
|
|
338
|
+
</div>
|
|
339
|
+
{cost !== undefined ? (
|
|
340
|
+
<>
|
|
341
|
+
<div className="dshr-cost-total">
|
|
342
|
+
<span>{t('ov.cost', { label: cost.label })}</span>
|
|
343
|
+
<strong>{formatMoney(cost.usd, currency)}</strong>
|
|
344
|
+
</div>
|
|
345
|
+
{cost.parts.length > 1 && (
|
|
346
|
+
<table className="dshr-table dshr-cost-table">
|
|
347
|
+
<thead>
|
|
348
|
+
<tr>
|
|
349
|
+
<th>{t('ov.perModel')}</th>
|
|
350
|
+
<th>{t('dash.tokens')}</th>
|
|
351
|
+
<th>{t('dash.cost')}</th>
|
|
352
|
+
</tr>
|
|
353
|
+
</thead>
|
|
354
|
+
<tbody>
|
|
355
|
+
{cost.parts.map(part => (
|
|
356
|
+
<tr key={part.model}>
|
|
357
|
+
<td>{part.model}</td>
|
|
358
|
+
<td>{formatTokens(part.totals.billedInput + part.totals.output)}</td>
|
|
359
|
+
<td>{formatMoney(part.usd, currency)}</td>
|
|
360
|
+
</tr>
|
|
361
|
+
))}
|
|
362
|
+
</tbody>
|
|
363
|
+
</table>
|
|
364
|
+
)}
|
|
365
|
+
</>
|
|
366
|
+
) : (
|
|
367
|
+
<p className="dshr-cost-note">{t('ov.noCost')}</p>
|
|
368
|
+
)}
|
|
369
|
+
<p className="dshr-cost-note">{t('ov.costNote')}</p>
|
|
370
|
+
{editing && <PricingEditor pricing={pricing} onClose={() => setEditing(false)} />}
|
|
371
|
+
</div>
|
|
256
372
|
</div>
|
|
257
373
|
)
|
|
258
374
|
}
|
|
@@ -2,10 +2,18 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|
|
2
2
|
import { auditSession } from '../../core/audit.js'
|
|
3
3
|
import { buildTimeline } from '../../core/timeline.js'
|
|
4
4
|
import type { ParsedSession } from '../../core/types.js'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
formatDuration,
|
|
7
|
+
formatTokens,
|
|
8
|
+
shortId,
|
|
9
|
+
type ReplayApi,
|
|
10
|
+
type SessionEntry,
|
|
11
|
+
type SessionSummaryEntry,
|
|
12
|
+
} from '../api.js'
|
|
13
|
+
import { I18nContext, makeT, setLang as setGlobalLang, useLang, type Lang } from '../i18n.js'
|
|
8
14
|
import { MascotState } from '../mascot.js'
|
|
15
|
+
import { DashboardView } from './DashboardView.js'
|
|
16
|
+
import { ExportDialog } from './ExportDialog.js'
|
|
9
17
|
import { SessionPicker } from './SessionPicker.js'
|
|
10
18
|
import { AuditView } from './AuditView.js'
|
|
11
19
|
import { CompareView } from './CompareView.js'
|
|
@@ -13,9 +21,9 @@ import { ForkTreeView } from './ForkTreeView.js'
|
|
|
13
21
|
import { OverviewView } from './OverviewView.js'
|
|
14
22
|
import { TimelineView } from './TimelineView.js'
|
|
15
23
|
|
|
16
|
-
type Tab = 'overview' | 'timeline' | 'audit' | 'forks' | 'compare'
|
|
24
|
+
type Tab = 'dashboard' | 'overview' | 'timeline' | 'audit' | 'forks' | 'compare'
|
|
17
25
|
|
|
18
|
-
const TABS: { id: Tab
|
|
26
|
+
const TABS: { id: Exclude<Tab, 'dashboard'>; labelKey: string }[] = [
|
|
19
27
|
{ id: 'overview', labelKey: 'tab.overview' },
|
|
20
28
|
{ id: 'timeline', labelKey: 'tab.timeline' },
|
|
21
29
|
{ id: 'audit', labelKey: 'tab.audit' },
|
|
@@ -28,56 +36,133 @@ export function ReplayApp({
|
|
|
28
36
|
sessionId,
|
|
29
37
|
initialLang,
|
|
30
38
|
onSwitchSession,
|
|
39
|
+
standalone = false,
|
|
31
40
|
}: {
|
|
32
41
|
api: ReplayApi
|
|
33
|
-
/** Omit to start on the session
|
|
42
|
+
/** Omit to start on the session archive (modal / standalone modes). */
|
|
34
43
|
sessionId?: string
|
|
35
44
|
/** Override the detected language ('en' | 'zh'). */
|
|
36
45
|
initialLang?: Lang
|
|
37
46
|
/** Optional host-provided navigation; falls back to internal state. */
|
|
38
47
|
onSwitchSession?: (id: string) => void
|
|
48
|
+
/** Exported HTML viewer: one session, no archive/forks/compare/export. */
|
|
49
|
+
standalone?: boolean
|
|
39
50
|
}): React.ReactElement {
|
|
40
|
-
const
|
|
41
|
-
const
|
|
51
|
+
const lang = useLang()
|
|
52
|
+
const t = useMemo(() => makeT(lang), [lang])
|
|
53
|
+
const [tab, setTab] = useState<Tab>(sessionId !== undefined ? 'overview' : 'dashboard')
|
|
42
54
|
const [currentId, setCurrentId] = useState<string | undefined>(sessionId)
|
|
43
55
|
const [session, setSession] = useState<ParsedSession>()
|
|
44
56
|
const [entries, setEntries] = useState<SessionEntry[]>([])
|
|
45
|
-
const [
|
|
57
|
+
const [summaries, setSummaries] = useState<SessionSummaryEntry[]>([])
|
|
58
|
+
const [listLoading, setListLoading] = useState(true)
|
|
59
|
+
const [listError, setListError] = useState<string>()
|
|
60
|
+
const [sessionLoading, setSessionLoading] = useState(false)
|
|
61
|
+
const [sessionError, setSessionError] = useState<string>()
|
|
62
|
+
const [refreshTick, setRefreshTick] = useState(0)
|
|
63
|
+
const [updatedAt, setUpdatedAt] = useState<number>()
|
|
46
64
|
const [jumpToSeq, setJumpToSeq] = useState<number>()
|
|
65
|
+
const [jumpNonce, setJumpNonce] = useState(0)
|
|
66
|
+
const [exporting, setExporting] = useState(false)
|
|
47
67
|
|
|
48
|
-
const
|
|
49
|
-
const setLang = (next: Lang): void => {
|
|
50
|
-
setLangState(next)
|
|
51
|
-
persistLang(next)
|
|
52
|
-
}
|
|
53
|
-
const i18n = useMemo(() => ({ lang, t, setLang }), [lang, t])
|
|
68
|
+
const i18n = useMemo(() => ({ lang, t, setLang: setGlobalLang }), [lang, t])
|
|
54
69
|
|
|
55
70
|
useEffect(() => {
|
|
56
|
-
if (
|
|
71
|
+
if (initialLang !== undefined) setGlobalLang(initialLang)
|
|
72
|
+
}, [initialLang])
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (sessionId !== undefined) {
|
|
76
|
+
setCurrentId(sessionId)
|
|
77
|
+
setTab('overview')
|
|
78
|
+
}
|
|
57
79
|
}, [sessionId])
|
|
58
80
|
|
|
81
|
+
// The session list and summaries load once, not on every session switch.
|
|
59
82
|
useEffect(() => {
|
|
60
83
|
let cancelled = false
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
setListLoading(true)
|
|
85
|
+
setListError(undefined)
|
|
86
|
+
api
|
|
87
|
+
.listSessions()
|
|
88
|
+
.then(list => {
|
|
89
|
+
if (cancelled) return
|
|
90
|
+
setEntries([...list].sort((a, b) => b.header.createdAt - a.header.createdAt))
|
|
91
|
+
setListLoading(false)
|
|
92
|
+
setUpdatedAt(Date.now())
|
|
93
|
+
})
|
|
94
|
+
.catch(error => {
|
|
95
|
+
if (cancelled) return
|
|
96
|
+
setListError(String(error))
|
|
97
|
+
setListLoading(false)
|
|
98
|
+
})
|
|
99
|
+
if (api.getSummaries !== undefined) {
|
|
64
100
|
api
|
|
65
|
-
.
|
|
66
|
-
.then(
|
|
67
|
-
.catch(
|
|
101
|
+
.getSummaries(30)
|
|
102
|
+
.then(list => !cancelled && setSummaries(list))
|
|
103
|
+
.catch(() => {
|
|
104
|
+
/* dashboard cards are optional; the session list still works */
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return () => {
|
|
108
|
+
cancelled = true
|
|
109
|
+
}
|
|
110
|
+
}, [api, refreshTick])
|
|
111
|
+
|
|
112
|
+
// Only the current session is fetched on selection.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (currentId === undefined) {
|
|
115
|
+
setSession(undefined)
|
|
116
|
+
setSessionError(undefined)
|
|
117
|
+
return
|
|
68
118
|
}
|
|
119
|
+
let cancelled = false
|
|
120
|
+
setSessionLoading(true)
|
|
121
|
+
setSessionError(undefined)
|
|
122
|
+
setSession(undefined)
|
|
69
123
|
api
|
|
70
|
-
.
|
|
71
|
-
.then(
|
|
72
|
-
|
|
124
|
+
.getSession(currentId)
|
|
125
|
+
.then(value => {
|
|
126
|
+
if (cancelled) return
|
|
127
|
+
setSession(value)
|
|
128
|
+
setSessionLoading(false)
|
|
129
|
+
setUpdatedAt(Date.now())
|
|
130
|
+
})
|
|
131
|
+
.catch(error => {
|
|
132
|
+
if (cancelled) return
|
|
133
|
+
setSessionError(String(error))
|
|
134
|
+
setSessionLoading(false)
|
|
135
|
+
})
|
|
73
136
|
return () => {
|
|
74
137
|
cancelled = true
|
|
75
138
|
}
|
|
76
|
-
}, [api, currentId])
|
|
139
|
+
}, [api, currentId, refreshTick])
|
|
77
140
|
|
|
78
141
|
const model = useMemo(() => (session ? buildTimeline(session) : undefined), [session])
|
|
79
142
|
const report = useMemo(() => (session ? auditSession(session) : undefined), [session])
|
|
80
|
-
const
|
|
143
|
+
const currentEntry = entries.find(entry => entry.header.id === currentId)
|
|
144
|
+
const title = currentEntry?.title ?? model?.title
|
|
145
|
+
|
|
146
|
+
const availableTabs = useMemo(
|
|
147
|
+
() =>
|
|
148
|
+
TABS.filter(item => {
|
|
149
|
+
if (standalone && (item.id === 'forks' || item.id === 'compare')) return false
|
|
150
|
+
return true
|
|
151
|
+
}),
|
|
152
|
+
[standalone],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (currentId === undefined) {
|
|
157
|
+
setTab('dashboard')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (tab === 'dashboard') {
|
|
161
|
+
setTab('overview')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (!availableTabs.some(item => item.id === tab)) setTab('overview')
|
|
165
|
+
}, [availableTabs, currentId, tab])
|
|
81
166
|
|
|
82
167
|
const switchSession = (id: string): void => {
|
|
83
168
|
setJumpToSeq(undefined)
|
|
@@ -86,65 +171,99 @@ export function ReplayApp({
|
|
|
86
171
|
setTab('overview')
|
|
87
172
|
}
|
|
88
173
|
|
|
174
|
+
const backToArchive = (): void => {
|
|
175
|
+
setCurrentId(undefined)
|
|
176
|
+
setSession(undefined)
|
|
177
|
+
setTab('dashboard')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const refresh = (): void => setRefreshTick(tick => tick + 1)
|
|
181
|
+
|
|
89
182
|
const jump = (seq: number): void => {
|
|
90
183
|
setTab('timeline')
|
|
91
184
|
setJumpToSeq(seq)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const handleExport = async (): Promise<void> => {
|
|
95
|
-
if (session === undefined || api.getViewerSource === undefined) return
|
|
96
|
-
try {
|
|
97
|
-
const viewer = await api.getViewerSource()
|
|
98
|
-
const html = buildReplayHtml(viewer, [
|
|
99
|
-
{ header: session.header, events: session.events, title },
|
|
100
|
-
])
|
|
101
|
-
downloadReplayHtml(html, `replay-${shortId(session.header.id).replace('…', '')}.html`)
|
|
102
|
-
} catch (e) {
|
|
103
|
-
// eslint-disable-next-line no-alert
|
|
104
|
-
alert(t('exp.fail', { error: String(e) }))
|
|
105
|
-
}
|
|
185
|
+
setJumpNonce(nonce => nonce + 1)
|
|
106
186
|
}
|
|
107
187
|
|
|
108
188
|
const langToggle = (
|
|
109
189
|
<button
|
|
110
190
|
className="dshr-tab"
|
|
111
|
-
onClick={() =>
|
|
112
|
-
title={lang
|
|
113
|
-
aria-label=
|
|
191
|
+
onClick={() => setGlobalLang(lang === 'en' ? 'zh' : 'en')}
|
|
192
|
+
title={t('lang.switch')}
|
|
193
|
+
aria-label={t('lang.switch')}
|
|
114
194
|
>
|
|
115
195
|
{lang === 'en' ? '中' : 'EN'}
|
|
116
196
|
</button>
|
|
117
197
|
)
|
|
118
198
|
|
|
199
|
+
const updated = updatedAt !== undefined
|
|
200
|
+
? t('app.updated', {
|
|
201
|
+
time: new Date(updatedAt).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en', {
|
|
202
|
+
hour: '2-digit',
|
|
203
|
+
minute: '2-digit',
|
|
204
|
+
}),
|
|
205
|
+
})
|
|
206
|
+
: undefined
|
|
207
|
+
|
|
119
208
|
if (currentId === undefined) {
|
|
120
209
|
return (
|
|
121
210
|
<I18nContext.Provider value={i18n}>
|
|
122
211
|
<div className="dshr-root">
|
|
123
212
|
<div className="dshr-header">
|
|
124
|
-
<span className="dshr-title">
|
|
213
|
+
<span className="dshr-title">{t('dash.title')}</span>
|
|
214
|
+
<span className="dshr-header-sub">{t('app.reviewSub')}</span>
|
|
125
215
|
<span className="dshr-spacer" />
|
|
216
|
+
{updated !== undefined && <span className="dshr-header-stat">{updated}</span>}
|
|
217
|
+
<button className="dshr-tab" onClick={refresh} title={t('app.refresh')}>
|
|
218
|
+
↻ {t('app.refresh')}
|
|
219
|
+
</button>
|
|
126
220
|
{langToggle}
|
|
127
221
|
</div>
|
|
128
|
-
|
|
222
|
+
{listError !== undefined ? (
|
|
223
|
+
<MascotState mood="alert" text={t('app.error', { error: listError })} />
|
|
224
|
+
) : api.getSummaries !== undefined ? (
|
|
225
|
+
<DashboardView
|
|
226
|
+
entries={entries}
|
|
227
|
+
summaries={summaries}
|
|
228
|
+
loading={listLoading}
|
|
229
|
+
onPick={switchSession}
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
<SessionPicker entries={entries} loading={listLoading} onPick={switchSession} />
|
|
233
|
+
)}
|
|
129
234
|
</div>
|
|
130
235
|
</I18nContext.Provider>
|
|
131
236
|
)
|
|
132
237
|
}
|
|
133
|
-
|
|
238
|
+
|
|
239
|
+
if (sessionError !== undefined) {
|
|
134
240
|
return (
|
|
135
241
|
<I18nContext.Provider value={i18n}>
|
|
136
242
|
<div className="dshr-root">
|
|
137
243
|
<div className="dshr-header">
|
|
138
|
-
|
|
244
|
+
{!standalone && (
|
|
245
|
+
<button className="dshr-tab" onClick={backToArchive}>
|
|
246
|
+
← {t('app.back')}
|
|
247
|
+
</button>
|
|
248
|
+
)}
|
|
249
|
+
<span className="dshr-title">{t('modal.title')}</span>
|
|
139
250
|
<span className="dshr-spacer" />
|
|
140
251
|
{langToggle}
|
|
141
252
|
</div>
|
|
142
|
-
<
|
|
253
|
+
<div className="dshr-center dshr-error-state">
|
|
254
|
+
<div>
|
|
255
|
+
<p>{t('app.error', { error: sessionError })}</p>
|
|
256
|
+
<button className="dshr-btn" onClick={refresh}>
|
|
257
|
+
{t('app.retry')}
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
143
261
|
</div>
|
|
144
262
|
</I18nContext.Provider>
|
|
145
263
|
)
|
|
146
264
|
}
|
|
147
|
-
|
|
265
|
+
|
|
266
|
+
if (sessionLoading || session === undefined || model === undefined || report === undefined) {
|
|
148
267
|
return (
|
|
149
268
|
<I18nContext.Provider value={i18n}>
|
|
150
269
|
<div className="dshr-root">
|
|
@@ -158,9 +277,14 @@ export function ReplayApp({
|
|
|
158
277
|
<I18nContext.Provider value={i18n}>
|
|
159
278
|
<div className="dshr-root">
|
|
160
279
|
<div className="dshr-header">
|
|
161
|
-
|
|
280
|
+
{!standalone && (
|
|
281
|
+
<button className="dshr-tab" onClick={backToArchive} title={t('app.back')}>
|
|
282
|
+
←
|
|
283
|
+
</button>
|
|
284
|
+
)}
|
|
285
|
+
<span className="dshr-title">{t('modal.title')}</span>
|
|
162
286
|
<nav className="dshr-tabs">
|
|
163
|
-
{
|
|
287
|
+
{availableTabs.map(item => (
|
|
164
288
|
<button
|
|
165
289
|
key={item.id}
|
|
166
290
|
className="dshr-tab"
|
|
@@ -175,14 +299,19 @@ export function ReplayApp({
|
|
|
175
299
|
))}
|
|
176
300
|
</nav>
|
|
177
301
|
<span className="dshr-spacer" />
|
|
178
|
-
<span className="dshr-header-stat">
|
|
302
|
+
<span className="dshr-header-stat dshr-header-session">
|
|
179
303
|
{title ?? shortId(currentId)} · {t('hdr.turns', { n: model.turns.length })} ·{' '}
|
|
180
304
|
{formatTokens(model.totals.billedInput + model.totals.output)} tok ·{' '}
|
|
181
305
|
{formatDuration(model.durationMs)}
|
|
182
306
|
{model.models.length > 0 ? ` · ${model.models.join(', ')}` : ''}
|
|
183
307
|
</span>
|
|
184
|
-
{
|
|
185
|
-
|
|
308
|
+
{currentEntry?.live === true && <span className="dshr-live-dot" title={t('picker.live')} />}
|
|
309
|
+
{updated !== undefined && <span className="dshr-header-stat">{updated}</span>}
|
|
310
|
+
<button className="dshr-tab" onClick={refresh} title={t('app.refresh')}>
|
|
311
|
+
↻
|
|
312
|
+
</button>
|
|
313
|
+
{!standalone && api.getViewerSource !== undefined && (
|
|
314
|
+
<button className="dshr-tab" onClick={() => setExporting(true)} title={t('exp.button')}>
|
|
186
315
|
⬇ {t('exp.button')}
|
|
187
316
|
</button>
|
|
188
317
|
)}
|
|
@@ -191,13 +320,31 @@ export function ReplayApp({
|
|
|
191
320
|
{tab === 'overview' && (
|
|
192
321
|
<OverviewView model={model} report={report} onOpenAudit={() => setTab('audit')} />
|
|
193
322
|
)}
|
|
194
|
-
{tab === 'timeline' &&
|
|
195
|
-
|
|
196
|
-
|
|
323
|
+
{tab === 'timeline' && (
|
|
324
|
+
<TimelineView model={model} jumpToSeq={jumpToSeq} jumpNonce={jumpNonce} />
|
|
325
|
+
)}
|
|
326
|
+
{tab === 'audit' && (
|
|
327
|
+
<AuditView report={report} model={model} onJump={jump} sessionId={currentId} />
|
|
328
|
+
)}
|
|
329
|
+
{tab === 'forks' && !standalone && (
|
|
197
330
|
<ForkTreeView entries={entries} currentId={currentId} onSelect={switchSession} />
|
|
198
331
|
)}
|
|
199
|
-
{tab === 'compare' && (
|
|
200
|
-
<CompareView
|
|
332
|
+
{tab === 'compare' && !standalone && (
|
|
333
|
+
<CompareView
|
|
334
|
+
api={api}
|
|
335
|
+
session={session}
|
|
336
|
+
entries={entries}
|
|
337
|
+
title={title}
|
|
338
|
+
onOpenSession={switchSession}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
{exporting && (
|
|
342
|
+
<ExportDialog
|
|
343
|
+
api={api}
|
|
344
|
+
session={session}
|
|
345
|
+
title={title}
|
|
346
|
+
onClose={() => setExporting(false)}
|
|
347
|
+
/>
|
|
201
348
|
)}
|
|
202
349
|
</div>
|
|
203
350
|
</I18nContext.Provider>
|