@mingozhou/dsh-replay 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/README.zh.md +164 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +2478 -0
- package/lib/core.js +720 -0
- package/lib/index.js +111 -0
- package/lib/types/demo/main.d.ts +1 -0
- package/lib/types/demo/samples.d.ts +21 -0
- package/lib/types/src/client/api.d.ts +43 -0
- package/lib/types/src/client/components/AuditView.d.ts +8 -0
- package/lib/types/src/client/components/CompareView.d.ts +9 -0
- package/lib/types/src/client/components/ForkTreeView.d.ts +7 -0
- package/lib/types/src/client/components/ItemDetail.d.ts +6 -0
- package/lib/types/src/client/components/OverviewView.d.ts +8 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +12 -0
- package/lib/types/src/client/components/ReplayModal.d.ts +8 -0
- package/lib/types/src/client/components/SessionPicker.d.ts +6 -0
- package/lib/types/src/client/components/TimelineView.d.ts +6 -0
- package/lib/types/src/client/export.d.ts +13 -0
- package/lib/types/src/client/i18n.d.ts +24 -0
- package/lib/types/src/client/index.d.ts +12 -0
- package/lib/types/src/client/mascot-image.d.ts +1 -0
- package/lib/types/src/client/mascot.d.ts +17 -0
- package/lib/types/src/client/viewer-entry.d.ts +1 -0
- package/lib/types/src/core/audit.d.ts +39 -0
- package/lib/types/src/core/compare.d.ts +31 -0
- package/lib/types/src/core/cost.d.ts +30 -0
- package/lib/types/src/core/decode.d.ts +25 -0
- package/lib/types/src/core/forktree.d.ts +33 -0
- package/lib/types/src/core/index.d.ts +7 -0
- package/lib/types/src/core/timeline.d.ts +96 -0
- package/lib/types/src/core/types.d.ts +88 -0
- package/lib/types/src/index.d.ts +20 -0
- package/lib/viewer.js +812 -0
- package/package.json +107 -0
- package/scripts/build.mjs +131 -0
- package/src/client/api.ts +119 -0
- package/src/client/components/AuditView.tsx +57 -0
- package/src/client/components/CompareView.tsx +186 -0
- package/src/client/components/ForkTreeView.tsx +123 -0
- package/src/client/components/ItemDetail.tsx +117 -0
- package/src/client/components/OverviewView.tsx +258 -0
- package/src/client/components/ReplayApp.tsx +205 -0
- package/src/client/components/ReplayModal.tsx +53 -0
- package/src/client/components/SessionPicker.tsx +60 -0
- package/src/client/components/TimelineView.tsx +296 -0
- package/src/client/export.ts +43 -0
- package/src/client/i18n.ts +360 -0
- package/src/client/index.ts +146 -0
- package/src/client/mascot-image.ts +1 -0
- package/src/client/mascot.tsx +192 -0
- package/src/client/styles.css +719 -0
- package/src/client/viewer-entry.tsx +66 -0
- package/src/core/audit.ts +227 -0
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +61 -0
- package/src/core/decode.ts +125 -0
- package/src/core/forktree.ts +104 -0
- package/src/core/index.ts +7 -0
- package/src/core/timeline.ts +396 -0
- package/src/core/types.ts +90 -0
- package/src/index.ts +157 -0
- package/tsconfig.json +18 -0
- package/types.d.ts +15 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { auditSession } from '../../core/audit.js'
|
|
3
|
+
import { buildTimeline } from '../../core/timeline.js'
|
|
4
|
+
import type { ParsedSession } from '../../core/types.js'
|
|
5
|
+
import { formatDuration, formatTokens, shortId, type ReplayApi, type SessionEntry } from '../api.js'
|
|
6
|
+
import { buildReplayHtml, downloadReplayHtml } from '../export.js'
|
|
7
|
+
import { detectLang, I18nContext, makeT, persistLang, type Lang } from '../i18n.js'
|
|
8
|
+
import { MascotState } from '../mascot.js'
|
|
9
|
+
import { SessionPicker } from './SessionPicker.js'
|
|
10
|
+
import { AuditView } from './AuditView.js'
|
|
11
|
+
import { CompareView } from './CompareView.js'
|
|
12
|
+
import { ForkTreeView } from './ForkTreeView.js'
|
|
13
|
+
import { OverviewView } from './OverviewView.js'
|
|
14
|
+
import { TimelineView } from './TimelineView.js'
|
|
15
|
+
|
|
16
|
+
type Tab = 'overview' | 'timeline' | 'audit' | 'forks' | 'compare'
|
|
17
|
+
|
|
18
|
+
const TABS: { id: Tab; labelKey: string }[] = [
|
|
19
|
+
{ id: 'overview', labelKey: 'tab.overview' },
|
|
20
|
+
{ id: 'timeline', labelKey: 'tab.timeline' },
|
|
21
|
+
{ id: 'audit', labelKey: 'tab.audit' },
|
|
22
|
+
{ id: 'forks', labelKey: 'tab.forks' },
|
|
23
|
+
{ id: 'compare', labelKey: 'tab.compare' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export function ReplayApp({
|
|
27
|
+
api,
|
|
28
|
+
sessionId,
|
|
29
|
+
initialLang,
|
|
30
|
+
onSwitchSession,
|
|
31
|
+
}: {
|
|
32
|
+
api: ReplayApi
|
|
33
|
+
/** Omit to start on the session picker (modal / standalone modes). */
|
|
34
|
+
sessionId?: string
|
|
35
|
+
/** Override the detected language ('en' | 'zh'). */
|
|
36
|
+
initialLang?: Lang
|
|
37
|
+
/** Optional host-provided navigation; falls back to internal state. */
|
|
38
|
+
onSwitchSession?: (id: string) => void
|
|
39
|
+
}): React.ReactElement {
|
|
40
|
+
const [lang, setLangState] = useState<Lang>(initialLang ?? detectLang())
|
|
41
|
+
const [tab, setTab] = useState<Tab>('overview')
|
|
42
|
+
const [currentId, setCurrentId] = useState<string | undefined>(sessionId)
|
|
43
|
+
const [session, setSession] = useState<ParsedSession>()
|
|
44
|
+
const [entries, setEntries] = useState<SessionEntry[]>([])
|
|
45
|
+
const [error, setError] = useState<string>()
|
|
46
|
+
const [jumpToSeq, setJumpToSeq] = useState<number>()
|
|
47
|
+
|
|
48
|
+
const t = useMemo(() => makeT(lang), [lang])
|
|
49
|
+
const setLang = (next: Lang): void => {
|
|
50
|
+
setLangState(next)
|
|
51
|
+
persistLang(next)
|
|
52
|
+
}
|
|
53
|
+
const i18n = useMemo(() => ({ lang, t, setLang }), [lang, t])
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (sessionId !== undefined) setCurrentId(sessionId)
|
|
57
|
+
}, [sessionId])
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
let cancelled = false
|
|
61
|
+
setError(undefined)
|
|
62
|
+
if (currentId !== undefined) {
|
|
63
|
+
setSession(undefined)
|
|
64
|
+
api
|
|
65
|
+
.getSession(currentId)
|
|
66
|
+
.then(s => !cancelled && setSession(s))
|
|
67
|
+
.catch(e => !cancelled && setError(String(e)))
|
|
68
|
+
}
|
|
69
|
+
api
|
|
70
|
+
.listSessions()
|
|
71
|
+
.then(list => !cancelled && setEntries(list))
|
|
72
|
+
.catch(() => {})
|
|
73
|
+
return () => {
|
|
74
|
+
cancelled = true
|
|
75
|
+
}
|
|
76
|
+
}, [api, currentId])
|
|
77
|
+
|
|
78
|
+
const model = useMemo(() => (session ? buildTimeline(session) : undefined), [session])
|
|
79
|
+
const report = useMemo(() => (session ? auditSession(session) : undefined), [session])
|
|
80
|
+
const title = entries.find(e => e.header.id === currentId)?.title ?? model?.title
|
|
81
|
+
|
|
82
|
+
const switchSession = (id: string): void => {
|
|
83
|
+
setJumpToSeq(undefined)
|
|
84
|
+
if (onSwitchSession) onSwitchSession(id)
|
|
85
|
+
setCurrentId(id)
|
|
86
|
+
setTab('overview')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const jump = (seq: number): void => {
|
|
90
|
+
setTab('timeline')
|
|
91
|
+
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
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const langToggle = (
|
|
109
|
+
<button
|
|
110
|
+
className="dshr-tab"
|
|
111
|
+
onClick={() => setLang(lang === 'en' ? 'zh' : 'en')}
|
|
112
|
+
title={lang === 'en' ? '切换到中文' : 'Switch to English'}
|
|
113
|
+
aria-label="language"
|
|
114
|
+
>
|
|
115
|
+
{lang === 'en' ? '中' : 'EN'}
|
|
116
|
+
</button>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if (currentId === undefined) {
|
|
120
|
+
return (
|
|
121
|
+
<I18nContext.Provider value={i18n}>
|
|
122
|
+
<div className="dshr-root">
|
|
123
|
+
<div className="dshr-header">
|
|
124
|
+
<span className="dshr-title">Replay</span>
|
|
125
|
+
<span className="dshr-spacer" />
|
|
126
|
+
{langToggle}
|
|
127
|
+
</div>
|
|
128
|
+
<SessionPicker api={api} onPick={switchSession} />
|
|
129
|
+
</div>
|
|
130
|
+
</I18nContext.Provider>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
if (error !== undefined) {
|
|
134
|
+
return (
|
|
135
|
+
<I18nContext.Provider value={i18n}>
|
|
136
|
+
<div className="dshr-root">
|
|
137
|
+
<div className="dshr-header">
|
|
138
|
+
<span className="dshr-title">Replay</span>
|
|
139
|
+
<span className="dshr-spacer" />
|
|
140
|
+
{langToggle}
|
|
141
|
+
</div>
|
|
142
|
+
<MascotState mood="alert" text={t('app.error', { error })} />
|
|
143
|
+
</div>
|
|
144
|
+
</I18nContext.Provider>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (session === undefined || model === undefined || report === undefined) {
|
|
148
|
+
return (
|
|
149
|
+
<I18nContext.Provider value={i18n}>
|
|
150
|
+
<div className="dshr-root">
|
|
151
|
+
<MascotState mood="idle" text={t('app.loadingText')} />
|
|
152
|
+
</div>
|
|
153
|
+
</I18nContext.Provider>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<I18nContext.Provider value={i18n}>
|
|
159
|
+
<div className="dshr-root">
|
|
160
|
+
<div className="dshr-header">
|
|
161
|
+
<span className="dshr-title">Replay</span>
|
|
162
|
+
<nav className="dshr-tabs">
|
|
163
|
+
{TABS.map(item => (
|
|
164
|
+
<button
|
|
165
|
+
key={item.id}
|
|
166
|
+
className="dshr-tab"
|
|
167
|
+
data-active={tab === item.id || undefined}
|
|
168
|
+
onClick={() => setTab(item.id)}
|
|
169
|
+
>
|
|
170
|
+
{t(item.labelKey)}
|
|
171
|
+
{item.id === 'audit' && report.counts.critical + report.counts.serious > 0
|
|
172
|
+
? ` (${report.counts.critical + report.counts.serious})`
|
|
173
|
+
: ''}
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</nav>
|
|
177
|
+
<span className="dshr-spacer" />
|
|
178
|
+
<span className="dshr-header-stat">
|
|
179
|
+
{title ?? shortId(currentId)} · {t('hdr.turns', { n: model.turns.length })} ·{' '}
|
|
180
|
+
{formatTokens(model.totals.billedInput + model.totals.output)} tok ·{' '}
|
|
181
|
+
{formatDuration(model.durationMs)}
|
|
182
|
+
{model.models.length > 0 ? ` · ${model.models.join(', ')}` : ''}
|
|
183
|
+
</span>
|
|
184
|
+
{api.getViewerSource !== undefined && (
|
|
185
|
+
<button className="dshr-tab" onClick={() => void handleExport()} title={t('exp.button')}>
|
|
186
|
+
⬇ {t('exp.button')}
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
{langToggle}
|
|
190
|
+
</div>
|
|
191
|
+
{tab === 'overview' && (
|
|
192
|
+
<OverviewView model={model} report={report} onOpenAudit={() => setTab('audit')} />
|
|
193
|
+
)}
|
|
194
|
+
{tab === 'timeline' && <TimelineView model={model} jumpToSeq={jumpToSeq} />}
|
|
195
|
+
{tab === 'audit' && <AuditView report={report} model={model} onJump={jump} />}
|
|
196
|
+
{tab === 'forks' && (
|
|
197
|
+
<ForkTreeView entries={entries} currentId={currentId} onSelect={switchSession} />
|
|
198
|
+
)}
|
|
199
|
+
{tab === 'compare' && (
|
|
200
|
+
<CompareView api={api} session={session} entries={entries} title={title} />
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
</I18nContext.Provider>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import type { ReplayApi } from '../api.js'
|
|
3
|
+
import { Mascot } from '../mascot.js'
|
|
4
|
+
import { ReplayApp } from './ReplayApp.js'
|
|
5
|
+
|
|
6
|
+
/** Full-screen modal hosting the replay app, opened from the sidebar button. */
|
|
7
|
+
export function ReplayModal({
|
|
8
|
+
api,
|
|
9
|
+
onClose,
|
|
10
|
+
closeLabel,
|
|
11
|
+
}: {
|
|
12
|
+
api: ReplayApi
|
|
13
|
+
onClose: () => void
|
|
14
|
+
closeLabel: string
|
|
15
|
+
}): React.ReactElement {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
18
|
+
if (event.key === 'Escape') onClose()
|
|
19
|
+
}
|
|
20
|
+
document.addEventListener('keydown', onKey)
|
|
21
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
22
|
+
}, [onClose])
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="dshr-modal-backdrop" onClick={onClose}>
|
|
26
|
+
<div className="dshr-modal" onClick={event => event.stopPropagation()}>
|
|
27
|
+
<div className="dshr-modal-chrome">
|
|
28
|
+
<Mascot mood="idle" size={30} />
|
|
29
|
+
<span className="dshr-modal-title">dsh-replay</span>
|
|
30
|
+
<span className="dshr-spacer" />
|
|
31
|
+
<a
|
|
32
|
+
className="dshr-modal-github"
|
|
33
|
+
href="https://github.com/MingoZhou/dsh-replay"
|
|
34
|
+
target="_blank"
|
|
35
|
+
rel="noreferrer"
|
|
36
|
+
title="GitHub"
|
|
37
|
+
>
|
|
38
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
39
|
+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
|
40
|
+
</svg>
|
|
41
|
+
GitHub
|
|
42
|
+
</a>
|
|
43
|
+
<button className="dshr-modal-close" onClick={onClose} aria-label={closeLabel}>
|
|
44
|
+
✕
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="dshr-modal-body">
|
|
48
|
+
<ReplayApp api={api} />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { formatDuration, shortId, type ReplayApi, type SessionEntry } from '../api.js'
|
|
3
|
+
import { useI18n } from '../i18n.js'
|
|
4
|
+
import { MascotState } from '../mascot.js'
|
|
5
|
+
|
|
6
|
+
export function SessionPicker({
|
|
7
|
+
api,
|
|
8
|
+
onPick,
|
|
9
|
+
}: {
|
|
10
|
+
api: ReplayApi
|
|
11
|
+
onPick: (id: string) => void
|
|
12
|
+
}): React.ReactElement {
|
|
13
|
+
const { t } = useI18n()
|
|
14
|
+
const [entries, setEntries] = useState<SessionEntry[]>()
|
|
15
|
+
const [error, setError] = useState<string>()
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false
|
|
19
|
+
api
|
|
20
|
+
.listSessions()
|
|
21
|
+
.then(list => {
|
|
22
|
+
if (!cancelled) setEntries([...list].sort((a, b) => b.header.createdAt - a.header.createdAt))
|
|
23
|
+
})
|
|
24
|
+
.catch(e => !cancelled && setError(String(e)))
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true
|
|
27
|
+
}
|
|
28
|
+
}, [api])
|
|
29
|
+
|
|
30
|
+
if (error !== undefined) return <MascotState mood="alert" text={error} />
|
|
31
|
+
if (entries === undefined) return <MascotState mood="idle" text={t('app.loadingText')} />
|
|
32
|
+
if (entries.length === 0) return <MascotState mood="happy" text={t('picker.empty')} />
|
|
33
|
+
|
|
34
|
+
const now = Date.now()
|
|
35
|
+
return (
|
|
36
|
+
<div className="dshr-picker">
|
|
37
|
+
<div className="dshr-section-label">{t('picker.title')}</div>
|
|
38
|
+
{entries.map(entry => (
|
|
39
|
+
<button key={entry.header.id} className="dshr-picker-row" onClick={() => onPick(entry.header.id)}>
|
|
40
|
+
<span className="dshr-picker-title">
|
|
41
|
+
{entry.title ?? shortId(entry.header.id)}
|
|
42
|
+
{entry.header.origin === 'subagent' && <span className="dshr-picker-tag">subagent</span>}
|
|
43
|
+
{entry.header.parentSession !== undefined && entry.header.origin !== 'subagent' && (
|
|
44
|
+
<span className="dshr-picker-tag">fork</span>
|
|
45
|
+
)}
|
|
46
|
+
</span>
|
|
47
|
+
<span className="dshr-picker-sub">
|
|
48
|
+
{shortId(entry.header.id)}
|
|
49
|
+
{entry.header.cwd !== undefined ? ` · ${entry.header.cwd}` : ''}
|
|
50
|
+
</span>
|
|
51
|
+
<span className="dshr-picker-age">
|
|
52
|
+
{Number.isFinite(entry.header.createdAt) && entry.header.createdAt <= now
|
|
53
|
+
? formatDuration(now - entry.header.createdAt)
|
|
54
|
+
: ''}
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { TimelineItem, TimelineModel } from '../../core/timeline.js'
|
|
3
|
+
import { formatClock, formatDuration, formatTokens } from '../api.js'
|
|
4
|
+
import { localizeItemLabel, useI18n } from '../i18n.js'
|
|
5
|
+
import { ItemDetail } from './ItemDetail.js'
|
|
6
|
+
|
|
7
|
+
const SPEEDS = [1, 2, 4, 8, 16]
|
|
8
|
+
|
|
9
|
+
/** Compress log time into playback time: per-item delay from real dt, clamped. */
|
|
10
|
+
function delayFor(prev: TimelineItem | undefined, next: TimelineItem, speed: number): number {
|
|
11
|
+
const dt = prev === undefined ? 0 : Math.max(0, next.time - prev.time)
|
|
12
|
+
return Math.min(1400, Math.max(120, dt)) / speed
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function TokenStrip({
|
|
16
|
+
model,
|
|
17
|
+
currentSeq,
|
|
18
|
+
onJump,
|
|
19
|
+
}: {
|
|
20
|
+
model: TimelineModel
|
|
21
|
+
currentSeq: number
|
|
22
|
+
onJump: (seq: number) => void
|
|
23
|
+
}): React.ReactElement | null {
|
|
24
|
+
const { t } = useI18n()
|
|
25
|
+
const samples = model.usageSamples
|
|
26
|
+
if (samples.length === 0) return null
|
|
27
|
+
const max = Math.max(
|
|
28
|
+
...samples.map(s => s.usage.inputTokens + (s.usage.cacheReadTokens ?? 0) + (s.usage.cacheWriteTokens ?? 0) + s.usage.outputTokens),
|
|
29
|
+
1,
|
|
30
|
+
)
|
|
31
|
+
return (
|
|
32
|
+
<div className="dshr-tokenstrip">
|
|
33
|
+
<div className="dshr-tokenstrip-head">
|
|
34
|
+
<span>{t('ts.title')}</span>
|
|
35
|
+
<span className="dshr-legend">
|
|
36
|
+
<span className="dshr-legend-item">
|
|
37
|
+
<span className="dshr-legend-swatch" style={{ background: 'var(--dshr-s-assistant)' }} />
|
|
38
|
+
{t('ts.input')}
|
|
39
|
+
</span>
|
|
40
|
+
<span className="dshr-legend-item">
|
|
41
|
+
<span className="dshr-legend-swatch" style={{ background: 'var(--dshr-s-tool)' }} />
|
|
42
|
+
{t('ts.output')}
|
|
43
|
+
</span>
|
|
44
|
+
</span>
|
|
45
|
+
<span className="dshr-spacer" />
|
|
46
|
+
<span>
|
|
47
|
+
{t('ts.total', { in: formatTokens(model.totals.billedInput), out: formatTokens(model.totals.output) })}
|
|
48
|
+
{model.totals.cacheRead > 0 ? t('ts.cacheRead', { n: formatTokens(model.totals.cacheRead) }) : ''}
|
|
49
|
+
</span>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="dshr-bars" role="img" aria-label={t('ts.title')}>
|
|
52
|
+
{samples.map(sample => {
|
|
53
|
+
const input =
|
|
54
|
+
sample.usage.inputTokens + (sample.usage.cacheReadTokens ?? 0) + (sample.usage.cacheWriteTokens ?? 0)
|
|
55
|
+
const output = sample.usage.outputTokens
|
|
56
|
+
const isCurrent =
|
|
57
|
+
currentSeq >= sample.seq &&
|
|
58
|
+
(samples.find(s => s.seq > sample.seq)?.seq ?? Number.POSITIVE_INFINITY) > currentSeq
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
key={`${sample.turn}/${sample.step}`}
|
|
62
|
+
className="dshr-bar"
|
|
63
|
+
data-current={isCurrent || undefined}
|
|
64
|
+
title={t('ts.barTitle', {
|
|
65
|
+
turn: sample.turn, step: sample.step,
|
|
66
|
+
in: formatTokens(input), out: formatTokens(output),
|
|
67
|
+
})}
|
|
68
|
+
onClick={() => onJump(sample.seq)}
|
|
69
|
+
>
|
|
70
|
+
<div className="dshr-bar-out" style={{ height: `${(output / max) * 100}%` }} />
|
|
71
|
+
<div className="dshr-bar-in" style={{ height: `${(input / max) * 100}%` }} />
|
|
72
|
+
</button>
|
|
73
|
+
)
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function TimelineView({
|
|
81
|
+
model,
|
|
82
|
+
jumpToSeq,
|
|
83
|
+
}: {
|
|
84
|
+
model: TimelineModel
|
|
85
|
+
jumpToSeq?: number
|
|
86
|
+
}): React.ReactElement {
|
|
87
|
+
const { t } = useI18n()
|
|
88
|
+
const [query, setQuery] = useState('')
|
|
89
|
+
const [kinds, setKinds] = useState<Set<string>>(new Set(['user', 'assistant', 'tool', 'meta']))
|
|
90
|
+
const items = useMemo(() => {
|
|
91
|
+
const q = query.trim().toLowerCase()
|
|
92
|
+
return model.items.filter(item => {
|
|
93
|
+
if (!kinds.has(item.kind)) return false
|
|
94
|
+
if (q === '') return true
|
|
95
|
+
return (
|
|
96
|
+
item.label.toLowerCase().includes(q) ||
|
|
97
|
+
(item.text?.toLowerCase().includes(q) ?? false) ||
|
|
98
|
+
(item.toolCall !== undefined &&
|
|
99
|
+
(item.toolCall.name.toLowerCase().includes(q) ||
|
|
100
|
+
item.toolCall.argumentsRaw.toLowerCase().includes(q) ||
|
|
101
|
+
(item.toolCall.resultText?.toLowerCase().includes(q) ?? false)))
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
}, [model.items, query, kinds])
|
|
105
|
+
const [cursor, setCursor] = useState(items.length > 0 ? items.length - 1 : 0)
|
|
106
|
+
const [playing, setPlaying] = useState(false)
|
|
107
|
+
const [speed, setSpeed] = useState(4)
|
|
108
|
+
const railRef = useRef<HTMLDivElement>(null)
|
|
109
|
+
const current: TimelineItem | undefined = items[Math.min(cursor, items.length - 1)]
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
setCursor(c => Math.min(c, Math.max(0, items.length - 1)))
|
|
113
|
+
}, [items.length])
|
|
114
|
+
|
|
115
|
+
const toggleKind = (kind: string): void => {
|
|
116
|
+
setKinds(prev => {
|
|
117
|
+
const next = new Set(prev)
|
|
118
|
+
if (next.has(kind)) {
|
|
119
|
+
if (next.size > 1) next.delete(kind)
|
|
120
|
+
} else {
|
|
121
|
+
next.add(kind)
|
|
122
|
+
}
|
|
123
|
+
return next
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (jumpToSeq === undefined) return
|
|
129
|
+
const index = items.findIndex(i => i.seq >= jumpToSeq)
|
|
130
|
+
if (index >= 0) {
|
|
131
|
+
setCursor(index)
|
|
132
|
+
setPlaying(false)
|
|
133
|
+
}
|
|
134
|
+
}, [jumpToSeq, items])
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!playing) return
|
|
138
|
+
if (cursor >= items.length - 1) {
|
|
139
|
+
setPlaying(false)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
const timer = setTimeout(
|
|
143
|
+
() => setCursor(c => Math.min(c + 1, items.length - 1)),
|
|
144
|
+
delayFor(items[cursor], items[cursor + 1], speed),
|
|
145
|
+
)
|
|
146
|
+
return () => clearTimeout(timer)
|
|
147
|
+
}, [playing, cursor, speed, items])
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const el = railRef.current?.querySelector<HTMLElement>('[data-selected="true"]')
|
|
151
|
+
el?.scrollIntoView({ block: 'nearest' })
|
|
152
|
+
}, [cursor])
|
|
153
|
+
|
|
154
|
+
const grouped = useMemo(() => {
|
|
155
|
+
const rows: ({ head: { turn: number; reason?: string; durationMs?: number } } | { item: TimelineItem; index: number })[] = []
|
|
156
|
+
let lastTurn: number | undefined
|
|
157
|
+
items.forEach((item, index) => {
|
|
158
|
+
if (item.turn !== undefined && item.turn !== lastTurn) {
|
|
159
|
+
lastTurn = item.turn
|
|
160
|
+
const turnModel = model.turns.find(t => t.turn === item.turn)
|
|
161
|
+
rows.push({
|
|
162
|
+
head: {
|
|
163
|
+
turn: item.turn,
|
|
164
|
+
reason: turnModel?.endReason,
|
|
165
|
+
durationMs:
|
|
166
|
+
turnModel?.endTime !== undefined ? turnModel.endTime - turnModel.startTime : undefined,
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
rows.push({ item, index })
|
|
171
|
+
})
|
|
172
|
+
return rows
|
|
173
|
+
}, [items, model.turns])
|
|
174
|
+
|
|
175
|
+
if (model.items.length === 0) {
|
|
176
|
+
return <div className="dshr-center">{t('tl.empty')}</div>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const togglePlay = (): void => {
|
|
180
|
+
if (!playing && cursor >= items.length - 1) setCursor(0)
|
|
181
|
+
setPlaying(p => !p)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<>
|
|
186
|
+
<div className="dshr-playbar">
|
|
187
|
+
<button
|
|
188
|
+
className="dshr-play-btn"
|
|
189
|
+
data-playing={playing || undefined}
|
|
190
|
+
onClick={togglePlay}
|
|
191
|
+
aria-label={playing ? 'pause' : 'play'}
|
|
192
|
+
>
|
|
193
|
+
{playing ? '❚❚' : '▶'}
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
className="dshr-speed"
|
|
197
|
+
onClick={() => setSpeed(s => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length])}
|
|
198
|
+
title="playback speed"
|
|
199
|
+
>
|
|
200
|
+
{speed}×
|
|
201
|
+
</button>
|
|
202
|
+
<input
|
|
203
|
+
className="dshr-scrub"
|
|
204
|
+
type="range"
|
|
205
|
+
min={0}
|
|
206
|
+
max={Math.max(0, items.length - 1)}
|
|
207
|
+
value={Math.min(cursor, Math.max(0, items.length - 1))}
|
|
208
|
+
onChange={e => {
|
|
209
|
+
setCursor(Number(e.target.value))
|
|
210
|
+
setPlaying(false)
|
|
211
|
+
}}
|
|
212
|
+
aria-label={t('play.position')}
|
|
213
|
+
/>
|
|
214
|
+
<span className="dshr-clock">
|
|
215
|
+
{current ? formatClock(current.time, model.startTime) : '00:00'} /{' '}
|
|
216
|
+
{formatClock(model.endTime, model.startTime)} · {t('play.event', { i: Math.min(cursor, Math.max(0, items.length - 1)) + 1, n: items.length })}
|
|
217
|
+
</span>
|
|
218
|
+
<input
|
|
219
|
+
className="dshr-filter-input"
|
|
220
|
+
type="search"
|
|
221
|
+
placeholder={t('flt.search')}
|
|
222
|
+
value={query}
|
|
223
|
+
onChange={e => {
|
|
224
|
+
setQuery(e.target.value)
|
|
225
|
+
setPlaying(false)
|
|
226
|
+
}}
|
|
227
|
+
aria-label={t('flt.search')}
|
|
228
|
+
/>
|
|
229
|
+
<span className="dshr-kind-chips">
|
|
230
|
+
{(['user', 'assistant', 'tool', 'meta'] as const).map(kind => (
|
|
231
|
+
<button
|
|
232
|
+
key={kind}
|
|
233
|
+
className="dshr-kind-chip"
|
|
234
|
+
data-on={kinds.has(kind) || undefined}
|
|
235
|
+
data-kind={kind}
|
|
236
|
+
onClick={() => toggleKind(kind)}
|
|
237
|
+
>
|
|
238
|
+
<span className="dshr-kind-dot" />
|
|
239
|
+
{t(`kind.${kind}`)}
|
|
240
|
+
</button>
|
|
241
|
+
))}
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
<TokenStrip
|
|
245
|
+
model={model}
|
|
246
|
+
currentSeq={current?.seq ?? 0}
|
|
247
|
+
onJump={seq => {
|
|
248
|
+
const index = items.findIndex(i => i.seq >= seq)
|
|
249
|
+
if (index >= 0) {
|
|
250
|
+
setCursor(index)
|
|
251
|
+
setPlaying(false)
|
|
252
|
+
}
|
|
253
|
+
}}
|
|
254
|
+
/>
|
|
255
|
+
<div className="dshr-timeline">
|
|
256
|
+
<div className="dshr-rail" ref={railRef}>
|
|
257
|
+
{grouped.map((row, i) =>
|
|
258
|
+
'head' in row ? (
|
|
259
|
+
<div key={`h${row.head.turn}`} className="dshr-turn-head">
|
|
260
|
+
<span>{t('tl.turn', { n: row.head.turn })}</span>
|
|
261
|
+
{row.head.durationMs !== undefined && (
|
|
262
|
+
<span className="dshr-turn-reason">{formatDuration(row.head.durationMs)}</span>
|
|
263
|
+
)}
|
|
264
|
+
{row.head.reason !== undefined && row.head.reason !== 'completed' && (
|
|
265
|
+
<span className="dshr-turn-reason">· {row.head.reason}</span>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
) : (
|
|
269
|
+
<button
|
|
270
|
+
key={row.item.seq}
|
|
271
|
+
className="dshr-item"
|
|
272
|
+
data-kind={row.item.kind}
|
|
273
|
+
data-selected={row.index === cursor || undefined}
|
|
274
|
+
data-future={row.index > cursor || undefined}
|
|
275
|
+
data-shadowed={row.item.shadowed || undefined}
|
|
276
|
+
onClick={() => {
|
|
277
|
+
setCursor(row.index)
|
|
278
|
+
setPlaying(false)
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<span className="dshr-item-kind">
|
|
282
|
+
{row.item.kind === 'tool'
|
|
283
|
+
? row.item.toolCall?.name.slice(0, 10) ?? t('kind.tool')
|
|
284
|
+
: t(`kind.${row.item.kind}`)}
|
|
285
|
+
</span>
|
|
286
|
+
<span className="dshr-item-label">{localizeItemLabel(t, row.item)}</span>
|
|
287
|
+
<span className="dshr-item-time">{formatClock(row.item.time, model.startTime)}</span>
|
|
288
|
+
</button>
|
|
289
|
+
),
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
<div className="dshr-detail">{current && <ItemDetail item={current} model={model} />}</div>
|
|
293
|
+
</div>
|
|
294
|
+
</>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export a session as a single self-contained HTML file: the viewer bundle
|
|
3
|
+
* (react inlined) + the session data, playable offline and shareable in an
|
|
4
|
+
* issue, a blog post, or a chat.
|
|
5
|
+
*/
|
|
6
|
+
import type { ParsedSession } from '../core/types.js'
|
|
7
|
+
|
|
8
|
+
export interface ExportSession {
|
|
9
|
+
header: ParsedSession['header']
|
|
10
|
+
events: ParsedSession['events']
|
|
11
|
+
title?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildReplayHtml(viewerSource: string, sessions: ExportSession[]): string {
|
|
15
|
+
const data = JSON.stringify({ sessions }).replaceAll('<', '\\u003c')
|
|
16
|
+
const safeViewer = viewerSource.replaceAll('</script>', '<\\/script>')
|
|
17
|
+
const title = sessions[0]?.title ?? 'session replay'
|
|
18
|
+
return `<!doctype html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="utf-8" />
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
23
|
+
<title>dsh-replay — ${title.replaceAll('<', '<')}</title>
|
|
24
|
+
<style>html, body, #root { height: 100%; margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<div id="root"></div>
|
|
28
|
+
<script>window.__DSHR_DATA__ = ${data}</script>
|
|
29
|
+
<script>${safeViewer}</script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
32
|
+
`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function downloadReplayHtml(html: string, filename: string): void {
|
|
36
|
+
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
|
37
|
+
const url = URL.createObjectURL(blob)
|
|
38
|
+
const link = document.createElement('a')
|
|
39
|
+
link.href = url
|
|
40
|
+
link.download = filename
|
|
41
|
+
link.click()
|
|
42
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
|
43
|
+
}
|