@mingozhou/dsh-replay 0.4.1 → 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 -148
- package/README.zh.md +225 -154
- package/assets/icon.png +0 -0
- package/lib/client.js +954 -2418
- 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 +7 -1
- 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,8 +1,13 @@
|
|
|
1
1
|
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { auditSession } from '../../core/audit.js'
|
|
2
3
|
import { compareSessions, type CompareResult } from '../../core/compare.js'
|
|
4
|
+
import { estimateCostDetailed, formatMoney } from '../../core/cost.js'
|
|
5
|
+
import { redactText } from '../../core/redact.js'
|
|
6
|
+
import { buildTimeline } from '../../core/timeline.js'
|
|
3
7
|
import type { ParsedSession } from '../../core/types.js'
|
|
4
8
|
import { formatDuration, formatTokens, shortId, type ReplayApi, type SessionEntry } from '../api.js'
|
|
5
9
|
import { useI18n } from '../i18n.js'
|
|
10
|
+
import { usePricing } from '../pricing.js'
|
|
6
11
|
|
|
7
12
|
function Row({
|
|
8
13
|
label,
|
|
@@ -27,25 +32,38 @@ function Row({
|
|
|
27
32
|
)
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
function preview(event: { type: string; data: unknown } | undefined): string {
|
|
36
|
+
if (event === undefined) return '—'
|
|
37
|
+
const data = redactText(JSON.stringify(event.data) ?? '').text
|
|
38
|
+
return `${event.type}\n${data.slice(0, 700)}${data.length > 700 ? '…' : ''}`
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export function CompareView({
|
|
31
42
|
api,
|
|
32
43
|
session,
|
|
33
44
|
entries,
|
|
34
45
|
title,
|
|
46
|
+
onOpenSession,
|
|
35
47
|
}: {
|
|
36
48
|
api: ReplayApi
|
|
37
49
|
session: ParsedSession
|
|
38
50
|
entries: SessionEntry[]
|
|
39
51
|
title?: string
|
|
52
|
+
onOpenSession?: (id: string) => void
|
|
40
53
|
}): React.ReactElement {
|
|
41
54
|
const { t } = useI18n()
|
|
55
|
+
const pricing = usePricing()
|
|
42
56
|
const others = useMemo(
|
|
43
|
-
() => entries.filter(
|
|
57
|
+
() => entries.filter(entry => entry.header.id !== session.header.id),
|
|
44
58
|
[entries, session],
|
|
45
59
|
)
|
|
60
|
+
const parentMap = useMemo(
|
|
61
|
+
() => new Map(entries.map(entry => [entry.header.id, entry.header.parentSession])),
|
|
62
|
+
[entries],
|
|
63
|
+
)
|
|
46
64
|
const defaultOther =
|
|
47
|
-
others.find(
|
|
48
|
-
others.find(
|
|
65
|
+
others.find(entry => entry.header.id === session.header.parentSession)?.header.id ??
|
|
66
|
+
others.find(entry => entry.header.parentSession === session.header.id)?.header.id ??
|
|
49
67
|
others[0]?.header.id
|
|
50
68
|
const [otherId, setOtherId] = useState(defaultOther)
|
|
51
69
|
const [other, setOther] = useState<ParsedSession>()
|
|
@@ -60,13 +78,13 @@ export function CompareView({
|
|
|
60
78
|
let cancelled = false
|
|
61
79
|
api
|
|
62
80
|
.getSession(otherId)
|
|
63
|
-
.then(
|
|
81
|
+
.then(value => {
|
|
64
82
|
if (!cancelled) {
|
|
65
|
-
setOther(
|
|
83
|
+
setOther(value)
|
|
66
84
|
setError(undefined)
|
|
67
85
|
}
|
|
68
86
|
})
|
|
69
|
-
.catch(
|
|
87
|
+
.catch(reason => !cancelled && setError(String(reason)))
|
|
70
88
|
return () => {
|
|
71
89
|
cancelled = true
|
|
72
90
|
}
|
|
@@ -74,11 +92,32 @@ export function CompareView({
|
|
|
74
92
|
|
|
75
93
|
const result: CompareResult | undefined = useMemo(() => {
|
|
76
94
|
if (other === undefined) return undefined
|
|
77
|
-
return compareSessions(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
return compareSessions(
|
|
96
|
+
session,
|
|
97
|
+
other,
|
|
98
|
+
{ a: title, b: others.find(entry => entry.header.id === other.header.id)?.title },
|
|
99
|
+
id => parentMap.get(id),
|
|
100
|
+
)
|
|
101
|
+
}, [session, other, title, others, parentMap])
|
|
102
|
+
|
|
103
|
+
const costA = useMemo(() => {
|
|
104
|
+
const model = buildTimeline(session)
|
|
105
|
+
return estimateCostDetailed(model.modelTotals, pricing)
|
|
106
|
+
}, [session, pricing])
|
|
107
|
+
const costB = useMemo(() => {
|
|
108
|
+
if (other === undefined) return undefined
|
|
109
|
+
const model = buildTimeline(other)
|
|
110
|
+
return estimateCostDetailed(model.modelTotals, pricing)
|
|
111
|
+
}, [other, pricing])
|
|
112
|
+
const riskA = useMemo(() => {
|
|
113
|
+
const report = auditSession(session)
|
|
114
|
+
return report.counts.critical + report.counts.serious
|
|
115
|
+
}, [session])
|
|
116
|
+
const riskB = useMemo(() => {
|
|
117
|
+
if (other === undefined) return 0
|
|
118
|
+
const report = auditSession(other)
|
|
119
|
+
return report.counts.critical + report.counts.serious
|
|
120
|
+
}, [other])
|
|
82
121
|
|
|
83
122
|
if (others.length === 0) {
|
|
84
123
|
return <div className="dshr-center">{t('cmp.none')}</div>
|
|
@@ -91,7 +130,7 @@ export function CompareView({
|
|
|
91
130
|
<select
|
|
92
131
|
className="dshr-select"
|
|
93
132
|
value={otherId}
|
|
94
|
-
onChange={
|
|
133
|
+
onChange={event => setOtherId(event.target.value)}
|
|
95
134
|
aria-label={t('cmp.aria')}
|
|
96
135
|
>
|
|
97
136
|
{others.map(entry => (
|
|
@@ -108,7 +147,25 @@ export function CompareView({
|
|
|
108
147
|
<>
|
|
109
148
|
{result.related && result.divergenceSeq !== undefined && (
|
|
110
149
|
<div className="dshr-divergence">
|
|
111
|
-
|
|
150
|
+
<div className="dshr-divergence-title">
|
|
151
|
+
{t('cmp.divergence', { seq: result.divergenceSeq })}
|
|
152
|
+
</div>
|
|
153
|
+
<div className="dshr-diff-grid">
|
|
154
|
+
<div className="dshr-diff-side">
|
|
155
|
+
<div className="dshr-section-label">{t('cmp.divergenceA')}</div>
|
|
156
|
+
<pre className="dshr-pre">{preview(result.divergenceA)}</pre>
|
|
157
|
+
<button className="dshr-tab" onClick={() => onOpenSession?.(result.a.id)}>
|
|
158
|
+
{t('forks.open')}
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="dshr-diff-side">
|
|
162
|
+
<div className="dshr-section-label">{t('cmp.divergenceB')}</div>
|
|
163
|
+
<pre className="dshr-pre">{preview(result.divergenceB)}</pre>
|
|
164
|
+
<button className="dshr-tab" onClick={() => onOpenSession?.(result.b.id)}>
|
|
165
|
+
{t('forks.open')}
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
112
169
|
</div>
|
|
113
170
|
)}
|
|
114
171
|
<div className="dshr-compare-grid">
|
|
@@ -122,6 +179,10 @@ export function CompareView({
|
|
|
122
179
|
</tr>
|
|
123
180
|
</thead>
|
|
124
181
|
<tbody>
|
|
182
|
+
<tr>
|
|
183
|
+
<td>{t('cmp.relation')}</td>
|
|
184
|
+
<td colSpan={3}>{t(`cmp.relation.${result.relation}`)}</td>
|
|
185
|
+
</tr>
|
|
125
186
|
<Row label={t('cmp.turns')} a={result.a.turns} b={result.b.turns} />
|
|
126
187
|
<Row label={t('cmp.steps')} a={result.a.steps} b={result.b.steps} />
|
|
127
188
|
<Row label={t('cmp.events')} a={result.a.events} b={result.b.events} />
|
|
@@ -151,6 +212,21 @@ export function CompareView({
|
|
|
151
212
|
b={result.b.durationMs}
|
|
152
213
|
format={formatDuration}
|
|
153
214
|
/>
|
|
215
|
+
<Row label={t('cmp.risk')} a={riskA} b={riskB} />
|
|
216
|
+
{costA !== undefined && costB !== undefined && (
|
|
217
|
+
<tr>
|
|
218
|
+
<td>{t('cmp.cost')}</td>
|
|
219
|
+
<td>{formatMoney(costA.usd, 'USD')}</td>
|
|
220
|
+
<td>{formatMoney(costB.usd, 'USD')}</td>
|
|
221
|
+
<td style={{ color: 'var(--dshr-ink-3)' }}>
|
|
222
|
+
{costA.usd === costB.usd
|
|
223
|
+
? '='
|
|
224
|
+
: costA.usd > costB.usd
|
|
225
|
+
? `+${formatMoney(costA.usd - costB.usd, 'USD')}`
|
|
226
|
+
: `−${formatMoney(costB.usd - costA.usd, 'USD')}`}
|
|
227
|
+
</td>
|
|
228
|
+
</tr>
|
|
229
|
+
)}
|
|
154
230
|
</tbody>
|
|
155
231
|
</table>
|
|
156
232
|
{result.toolNames.length > 0 && (
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react'
|
|
2
|
+
import { estimateCost, estimateCostDetailed, formatMoney } from '../../core/cost.js'
|
|
3
|
+
import {
|
|
4
|
+
formatDuration,
|
|
5
|
+
formatRelativeTime,
|
|
6
|
+
formatTokens,
|
|
7
|
+
shortId,
|
|
8
|
+
type SessionEntry,
|
|
9
|
+
type SessionSummaryEntry,
|
|
10
|
+
} from '../api.js'
|
|
11
|
+
import { useI18n } from '../i18n.js'
|
|
12
|
+
import { usePricing } from '../pricing.js'
|
|
13
|
+
import { MascotState } from '../mascot.js'
|
|
14
|
+
|
|
15
|
+
type Period = 'month' | 'all'
|
|
16
|
+
type Currency = 'USD' | 'CNY'
|
|
17
|
+
|
|
18
|
+
export function DashboardView({
|
|
19
|
+
entries,
|
|
20
|
+
summaries,
|
|
21
|
+
loading,
|
|
22
|
+
onPick,
|
|
23
|
+
}: {
|
|
24
|
+
entries: SessionEntry[]
|
|
25
|
+
summaries: SessionSummaryEntry[]
|
|
26
|
+
loading: boolean
|
|
27
|
+
onPick: (id: string) => void
|
|
28
|
+
}): React.ReactElement {
|
|
29
|
+
const { t, lang } = useI18n()
|
|
30
|
+
const pricing = usePricing()
|
|
31
|
+
const [query, setQuery] = useState('')
|
|
32
|
+
const [workspace, setWorkspace] = useState('')
|
|
33
|
+
const [period, setPeriod] = useState<Period>('all')
|
|
34
|
+
const [currency, setCurrency] = useState<Currency>('USD')
|
|
35
|
+
|
|
36
|
+
const summaryMap = useMemo(
|
|
37
|
+
() => new Map(summaries.map(summary => [summary.header.id, summary])),
|
|
38
|
+
[summaries],
|
|
39
|
+
)
|
|
40
|
+
const workspaces = useMemo(
|
|
41
|
+
() => [...new Set(entries.map(entry => entry.header.cwd).filter((cwd): cwd is string => typeof cwd === 'string'))].sort(),
|
|
42
|
+
[entries],
|
|
43
|
+
)
|
|
44
|
+
const periodStart = useMemo(() => {
|
|
45
|
+
const now = new Date()
|
|
46
|
+
return period === 'month' ? new Date(now.getFullYear(), now.getMonth(), 1).getTime() : 0
|
|
47
|
+
}, [period])
|
|
48
|
+
|
|
49
|
+
const rows = useMemo(() => {
|
|
50
|
+
const q = query.trim().toLowerCase()
|
|
51
|
+
return entries
|
|
52
|
+
.filter(entry => entry.header.createdAt >= periodStart)
|
|
53
|
+
.filter(entry => workspace === '' || entry.header.cwd === workspace)
|
|
54
|
+
.filter(entry => {
|
|
55
|
+
if (q === '') return true
|
|
56
|
+
const haystack = `${entry.title ?? ''} ${entry.header.id} ${entry.header.cwd ?? ''}`.toLowerCase()
|
|
57
|
+
return haystack.includes(q)
|
|
58
|
+
})
|
|
59
|
+
}, [entries, periodStart, query, workspace])
|
|
60
|
+
|
|
61
|
+
const costFor = (summary: SessionSummaryEntry | undefined): number | undefined => {
|
|
62
|
+
if (summary?.summary === undefined) return undefined
|
|
63
|
+
const detailed = estimateCostDetailed(summary.summary.modelTotals, pricing)
|
|
64
|
+
if (detailed !== undefined) return detailed.usd
|
|
65
|
+
return estimateCost(summary.summary.tokens, summary.summary.models, pricing)?.usd
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const totals = useMemo(() => {
|
|
69
|
+
let tokens = 0
|
|
70
|
+
let cost = 0
|
|
71
|
+
let risk = 0
|
|
72
|
+
let errors = 0
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
const summary = summaryMap.get(row.header.id)?.summary
|
|
75
|
+
if (summary === undefined) continue
|
|
76
|
+
tokens += summary.tokens.billedInput + summary.tokens.output
|
|
77
|
+
cost += costFor(summaryMap.get(row.header.id)) ?? 0
|
|
78
|
+
risk += summary.critical + summary.serious
|
|
79
|
+
errors += summary.toolErrors
|
|
80
|
+
}
|
|
81
|
+
return { tokens, cost, risk, errors }
|
|
82
|
+
}, [rows, summaryMap, pricing])
|
|
83
|
+
|
|
84
|
+
if (loading && entries.length === 0) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="dshr-dashboard">
|
|
87
|
+
<div className="dshr-dash-cards">
|
|
88
|
+
{[0, 1, 2, 3].map(index => (
|
|
89
|
+
<div key={index} className="dshr-dash-card dshr-skeleton" />
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="dshr-dash-list">
|
|
93
|
+
{[0, 1, 2, 3, 4].map(index => (
|
|
94
|
+
<div key={index} className="dshr-dash-row dshr-skeleton" />
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (entries.length === 0) {
|
|
102
|
+
return <MascotState mood="happy" text={t('dash.empty')} />
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="dshr-dashboard">
|
|
107
|
+
<div className="dshr-dash-head">
|
|
108
|
+
<div>
|
|
109
|
+
<div className="dshr-dash-subtitle">{t('dash.subtitle')}</div>
|
|
110
|
+
<div className="dshr-dash-scanned">{t('dash.scanned', { n: summaries.length })}</div>
|
|
111
|
+
</div>
|
|
112
|
+
<span className="dshr-spacer" />
|
|
113
|
+
<span className="dshr-segmented" role="group" aria-label={t('dash.sessions')}>
|
|
114
|
+
<button data-active={period === 'month' || undefined} onClick={() => setPeriod('month')}>
|
|
115
|
+
{t('dash.thisMonth')}
|
|
116
|
+
</button>
|
|
117
|
+
<button data-active={period === 'all' || undefined} onClick={() => setPeriod('all')}>
|
|
118
|
+
{t('dash.allTime')}
|
|
119
|
+
</button>
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="dshr-dash-cards">
|
|
124
|
+
<div className="dshr-dash-card">
|
|
125
|
+
<div className="dshr-dash-value">{rows.length}</div>
|
|
126
|
+
<div className="dshr-dash-label">{t('dash.sessions')}</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="dshr-dash-card">
|
|
129
|
+
<div className="dshr-dash-value">{formatTokens(totals.tokens)}</div>
|
|
130
|
+
<div className="dshr-dash-label">{t('dash.tokens')}</div>
|
|
131
|
+
</div>
|
|
132
|
+
<button
|
|
133
|
+
className="dshr-dash-card dshr-dash-card-action"
|
|
134
|
+
onClick={() => setCurrency(value => (value === 'USD' ? 'CNY' : 'USD'))}
|
|
135
|
+
title={t('ov.currency')}
|
|
136
|
+
>
|
|
137
|
+
<div className="dshr-dash-value">{formatMoney(totals.cost, currency)}</div>
|
|
138
|
+
<div className="dshr-dash-label">
|
|
139
|
+
{t('dash.cost')} · {currency}
|
|
140
|
+
</div>
|
|
141
|
+
</button>
|
|
142
|
+
<div className={`dshr-dash-card${totals.risk > 0 ? ' dshr-dash-risk' : ''}`}>
|
|
143
|
+
<div className="dshr-dash-value">{totals.risk}</div>
|
|
144
|
+
<div className="dshr-dash-label">
|
|
145
|
+
{t('dash.risk')} · {t('dash.errors')} {totals.errors}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div className="dshr-dash-toolbar">
|
|
151
|
+
<input
|
|
152
|
+
className="dshr-filter-input dshr-dash-search"
|
|
153
|
+
type="search"
|
|
154
|
+
placeholder={t('picker.search')}
|
|
155
|
+
value={query}
|
|
156
|
+
onChange={event => setQuery(event.target.value)}
|
|
157
|
+
aria-label={t('picker.search')}
|
|
158
|
+
/>
|
|
159
|
+
<select
|
|
160
|
+
className="dshr-select"
|
|
161
|
+
value={workspace}
|
|
162
|
+
onChange={event => setWorkspace(event.target.value)}
|
|
163
|
+
aria-label={t('picker.allWorkspaces')}
|
|
164
|
+
>
|
|
165
|
+
<option value="">{t('picker.allWorkspaces')}</option>
|
|
166
|
+
{workspaces.map(cwd => (
|
|
167
|
+
<option key={cwd} value={cwd}>
|
|
168
|
+
{cwd}
|
|
169
|
+
</option>
|
|
170
|
+
))}
|
|
171
|
+
</select>
|
|
172
|
+
<span className="dshr-spacer" />
|
|
173
|
+
<span className="dshr-dash-count">{t('picker.count', { n: rows.length })}</span>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="dshr-dash-list">
|
|
177
|
+
{rows.map(entry => {
|
|
178
|
+
const summary = summaryMap.get(entry.header.id)
|
|
179
|
+
const data = summary?.summary
|
|
180
|
+
const cost = costFor(summary)
|
|
181
|
+
const risk = data === undefined ? 0 : data.critical + data.serious
|
|
182
|
+
return (
|
|
183
|
+
<button
|
|
184
|
+
key={entry.header.id}
|
|
185
|
+
className="dshr-dash-row"
|
|
186
|
+
onClick={() => onPick(entry.header.id)}
|
|
187
|
+
>
|
|
188
|
+
<span className="dshr-dash-main">
|
|
189
|
+
<span className="dshr-dash-title">
|
|
190
|
+
{entry.title ?? shortId(entry.header.id)}
|
|
191
|
+
{entry.header.origin === 'subagent' && (
|
|
192
|
+
<span className="dshr-picker-tag">{t('picker.subagent')}</span>
|
|
193
|
+
)}
|
|
194
|
+
{entry.header.parentSession !== undefined && entry.header.origin !== 'subagent' && (
|
|
195
|
+
<span className="dshr-picker-tag">{t('picker.fork')}</span>
|
|
196
|
+
)}
|
|
197
|
+
{entry.live === true && <span className="dshr-live-pill">{t('picker.live')}</span>}
|
|
198
|
+
</span>
|
|
199
|
+
<span className="dshr-dash-sub">
|
|
200
|
+
{shortId(entry.header.id)}
|
|
201
|
+
{entry.header.cwd !== undefined ? ` · ${entry.header.cwd}` : ''}
|
|
202
|
+
{' · '}
|
|
203
|
+
{formatRelativeTime(entry.header.createdAt, Date.now(), lang)}
|
|
204
|
+
</span>
|
|
205
|
+
</span>
|
|
206
|
+
<span className="dshr-dash-metrics">
|
|
207
|
+
{data !== undefined ? (
|
|
208
|
+
<>
|
|
209
|
+
<span title={t('cmp.turns')}>{t('hdr.turns', { n: data.turns })}</span>
|
|
210
|
+
<span title={t('dash.tokens')}>
|
|
211
|
+
{formatTokens(data.tokens.billedInput + data.tokens.output)}
|
|
212
|
+
</span>
|
|
213
|
+
<span title={t('dash.cost')}>
|
|
214
|
+
{cost !== undefined ? formatMoney(cost, currency) : '—'}
|
|
215
|
+
</span>
|
|
216
|
+
<span title={t('dash.risk')} className={risk > 0 ? 'dshr-risk-badge' : 'dshr-ok-badge'}>
|
|
217
|
+
{risk > 0 ? `⚠ ${risk}` : '✓'}
|
|
218
|
+
</span>
|
|
219
|
+
<span className="dshr-dash-duration">{formatDuration(data.durationMs)}</span>
|
|
220
|
+
</>
|
|
221
|
+
) : (
|
|
222
|
+
<span className="dshr-dash-pending">—</span>
|
|
223
|
+
)}
|
|
224
|
+
</span>
|
|
225
|
+
</button>
|
|
226
|
+
)
|
|
227
|
+
})}
|
|
228
|
+
{rows.length === 0 && <MascotState mood="happy" text={t('audit.noRisk')} />}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { redactSession } from '../../core/redact.js'
|
|
3
|
+
import type { ParsedSession } from '../../core/types.js'
|
|
4
|
+
import { shortId, type ReplayApi } from '../api.js'
|
|
5
|
+
import { buildReplayHtml, downloadReplayHtml, formatBytes } from '../export.js'
|
|
6
|
+
import { useI18n } from '../i18n.js'
|
|
7
|
+
|
|
8
|
+
export function ExportDialog({
|
|
9
|
+
api,
|
|
10
|
+
session,
|
|
11
|
+
title,
|
|
12
|
+
onClose,
|
|
13
|
+
}: {
|
|
14
|
+
api: ReplayApi
|
|
15
|
+
session: ParsedSession
|
|
16
|
+
title?: string
|
|
17
|
+
onClose: () => void
|
|
18
|
+
}): React.ReactElement {
|
|
19
|
+
const { t, lang } = useI18n()
|
|
20
|
+
const [redact, setRedact] = useState(true)
|
|
21
|
+
const [viewer, setViewer] = useState<string>()
|
|
22
|
+
const [error, setError] = useState<string>()
|
|
23
|
+
const [busy, setBusy] = useState(false)
|
|
24
|
+
const [loadingViewer, setLoadingViewer] = useState(true)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
28
|
+
if (event.key === 'Escape') onClose()
|
|
29
|
+
}
|
|
30
|
+
document.addEventListener('keydown', onKey)
|
|
31
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
32
|
+
}, [onClose])
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false
|
|
36
|
+
const getViewerSource = api.getViewerSource
|
|
37
|
+
if (getViewerSource === undefined) {
|
|
38
|
+
setError('viewer bundle unavailable')
|
|
39
|
+
setLoadingViewer(false)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
setLoadingViewer(true)
|
|
43
|
+
getViewerSource
|
|
44
|
+
.call(api)
|
|
45
|
+
.then(source => {
|
|
46
|
+
if (cancelled) return
|
|
47
|
+
setViewer(source)
|
|
48
|
+
setLoadingViewer(false)
|
|
49
|
+
})
|
|
50
|
+
.catch(reason => {
|
|
51
|
+
if (cancelled) return
|
|
52
|
+
setError(String(reason))
|
|
53
|
+
setLoadingViewer(false)
|
|
54
|
+
})
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true
|
|
57
|
+
}
|
|
58
|
+
}, [api])
|
|
59
|
+
|
|
60
|
+
const redacted = useMemo(
|
|
61
|
+
() => (redact ? redactSession(session) : { session, count: 0, kinds: [] as string[] }),
|
|
62
|
+
[redact, session],
|
|
63
|
+
)
|
|
64
|
+
const html = useMemo(() => {
|
|
65
|
+
if (viewer === undefined) return ''
|
|
66
|
+
return buildReplayHtml(
|
|
67
|
+
viewer,
|
|
68
|
+
[{ header: redacted.session.header, events: redacted.session.events, title }],
|
|
69
|
+
{ lang, title },
|
|
70
|
+
)
|
|
71
|
+
}, [lang, redacted, title, viewer])
|
|
72
|
+
const size = useMemo(() => (html === '' ? 0 : new Blob([html]).size), [html])
|
|
73
|
+
|
|
74
|
+
const handleExport = (): void => {
|
|
75
|
+
if (html === '') return
|
|
76
|
+
setBusy(true)
|
|
77
|
+
try {
|
|
78
|
+
downloadReplayHtml(html, `replay-${shortId(session.header.id).replace('…', '')}.html`)
|
|
79
|
+
onClose()
|
|
80
|
+
} catch (reason) {
|
|
81
|
+
setError(String(reason))
|
|
82
|
+
setBusy(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="dshr-modal-backdrop dshr-export-backdrop" onClick={onClose}>
|
|
88
|
+
<div
|
|
89
|
+
className="dshr-modal dshr-export"
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-modal="true"
|
|
92
|
+
aria-labelledby="dshr-export-title"
|
|
93
|
+
onClick={event => event.stopPropagation()}
|
|
94
|
+
>
|
|
95
|
+
<div className="dshr-modal-chrome">
|
|
96
|
+
<span className="dshr-modal-title" id="dshr-export-title">
|
|
97
|
+
{t('exp.title')}
|
|
98
|
+
</span>
|
|
99
|
+
<span className="dshr-spacer" />
|
|
100
|
+
<button className="dshr-modal-close" onClick={onClose} aria-label={t('modal.close')}>
|
|
101
|
+
✕
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="dshr-export-body">
|
|
105
|
+
<p className="dshr-export-warning">{t('exp.warning')}</p>
|
|
106
|
+
<label className="dshr-check">
|
|
107
|
+
<input type="checkbox" checked={redact} onChange={event => setRedact(event.target.checked)} />
|
|
108
|
+
<span>{t('exp.redact')}</span>
|
|
109
|
+
</label>
|
|
110
|
+
{redact ? (
|
|
111
|
+
<p className="dshr-export-meta">{t('exp.redactions', { n: redacted.count })}</p>
|
|
112
|
+
) : (
|
|
113
|
+
<p className="dshr-export-danger">{t('exp.includeRaw')}</p>
|
|
114
|
+
)}
|
|
115
|
+
<p className="dshr-export-meta">
|
|
116
|
+
{t('exp.size', { size: loadingViewer ? '…' : formatBytes(size) })}
|
|
117
|
+
</p>
|
|
118
|
+
<p className="dshr-export-note">{t('exp.note')}</p>
|
|
119
|
+
{error !== undefined && <p className="dshr-export-danger">{error}</p>}
|
|
120
|
+
<div className="dshr-export-actions">
|
|
121
|
+
<button className="dshr-btn" onClick={onClose}>
|
|
122
|
+
{t('exp.cancel')}
|
|
123
|
+
</button>
|
|
124
|
+
<button
|
|
125
|
+
className="dshr-btn dshr-btn-primary"
|
|
126
|
+
disabled={busy || loadingViewer || html === ''}
|
|
127
|
+
onClick={handleExport}
|
|
128
|
+
>
|
|
129
|
+
{busy ? '…' : t('exp.confirm')}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
2
|
import type { TimelineItem, TimelineModel } from '../../core/timeline.js'
|
|
3
3
|
import { formatClock, formatDuration, formatTokens } from '../api.js'
|
|
4
4
|
import { useI18n } from '../i18n.js'
|
|
@@ -10,6 +10,22 @@ const KIND_COLOR: Record<string, string> = {
|
|
|
10
10
|
meta: 'var(--dshr-s-meta)',
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function CopyButton({ text }: { text: string }): React.ReactElement {
|
|
14
|
+
const { t } = useI18n()
|
|
15
|
+
const [copied, setCopied] = useState(false)
|
|
16
|
+
const copy = (): void => {
|
|
17
|
+
void navigator.clipboard?.writeText(text).then(() => {
|
|
18
|
+
setCopied(true)
|
|
19
|
+
setTimeout(() => setCopied(false), 1500)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
<button className="dshr-copy-btn" onClick={copy} type="button">
|
|
24
|
+
{copied ? t('detail.copied') : t('detail.copy')}
|
|
25
|
+
</button>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
export function ItemDetail({
|
|
14
30
|
item,
|
|
15
31
|
model,
|
|
@@ -38,9 +54,19 @@ export function ItemDetail({
|
|
|
38
54
|
{item.step !== undefined && ` · step ${item.step}`}
|
|
39
55
|
</div>
|
|
40
56
|
|
|
57
|
+
{item.reasoningText !== undefined && item.reasoningText !== '' && (
|
|
58
|
+
<details className="dshr-reasoning">
|
|
59
|
+
<summary>{t('detail.reasoning')}</summary>
|
|
60
|
+
<pre className="dshr-pre">{item.reasoningText}</pre>
|
|
61
|
+
</details>
|
|
62
|
+
)}
|
|
63
|
+
|
|
41
64
|
{item.text !== undefined && item.text !== '' && (
|
|
42
65
|
<>
|
|
43
|
-
<div className="dshr-section-label">
|
|
66
|
+
<div className="dshr-section-label">
|
|
67
|
+
{t('detail.message')}
|
|
68
|
+
<CopyButton text={item.text} />
|
|
69
|
+
</div>
|
|
44
70
|
<pre className="dshr-pre">{item.text}</pre>
|
|
45
71
|
</>
|
|
46
72
|
)}
|
|
@@ -65,11 +91,17 @@ export function ItemDetail({
|
|
|
65
91
|
</>
|
|
66
92
|
)}
|
|
67
93
|
</dl>
|
|
68
|
-
<div className="dshr-section-label">
|
|
94
|
+
<div className="dshr-section-label">
|
|
95
|
+
{t('detail.arguments')}
|
|
96
|
+
<CopyButton text={item.toolCall.argumentsPretty ?? item.toolCall.argumentsRaw} />
|
|
97
|
+
</div>
|
|
69
98
|
<pre className="dshr-pre">{item.toolCall.argumentsPretty ?? item.toolCall.argumentsRaw}</pre>
|
|
70
99
|
{item.toolCall.resultText !== undefined && item.toolCall.resultText !== '' && (
|
|
71
100
|
<>
|
|
72
|
-
<div className="dshr-section-label">
|
|
101
|
+
<div className="dshr-section-label">
|
|
102
|
+
{t('detail.result')}
|
|
103
|
+
<CopyButton text={item.toolCall.resultText} />
|
|
104
|
+
</div>
|
|
73
105
|
<pre className="dshr-pre">{item.toolCall.resultText}</pre>
|
|
74
106
|
</>
|
|
75
107
|
)}
|