@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
package/package.json
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mingozhou/dsh-replay",
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Session replay, fork-tree, audit & compare visualization for DeepSeek Harness — time machine for your agent sessions",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"directories": {
|
|
7
|
+
"test": "tests"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "node scripts/build.mjs && tsc -p tsconfig.json",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"demo": "node demo/serve.mjs",
|
|
13
|
+
"screenshots": "node scripts/screenshot.mjs",
|
|
14
|
+
"prepare": "node scripts/build.mjs"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"dsh",
|
|
20
|
+
"agent",
|
|
21
|
+
"session-replay",
|
|
22
|
+
"observability",
|
|
23
|
+
"audit",
|
|
24
|
+
"timeline",
|
|
25
|
+
"visualization"
|
|
26
|
+
],
|
|
27
|
+
"author": "MingoZhou",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^26.2.0",
|
|
32
|
+
"@types/react": "^19.2.18",
|
|
33
|
+
"@types/react-dom": "^19.2.4",
|
|
34
|
+
"esbuild": "^0.28.2",
|
|
35
|
+
"playwright": "^1.62.1",
|
|
36
|
+
"react": "^19.2.8",
|
|
37
|
+
"react-dom": "^19.2.8",
|
|
38
|
+
"typescript": "^7.0.2",
|
|
39
|
+
"vitest": "^4.1.10"
|
|
40
|
+
},
|
|
41
|
+
"types": "lib/types/src/index.d.ts",
|
|
42
|
+
"exports": {
|
|
43
|
+
".": {
|
|
44
|
+
"types": "./lib/types/src/index.d.ts",
|
|
45
|
+
"default": "./lib/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./client": {
|
|
48
|
+
"types": "./lib/types/src/client/index.d.ts",
|
|
49
|
+
"default": "./lib/client.js"
|
|
50
|
+
},
|
|
51
|
+
"./core": {
|
|
52
|
+
"types": "./lib/types/src/core/index.d.ts",
|
|
53
|
+
"default": "./lib/core.js"
|
|
54
|
+
},
|
|
55
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
56
|
+
"./package.json": "./package.json"
|
|
57
|
+
},
|
|
58
|
+
"files": [
|
|
59
|
+
"lib",
|
|
60
|
+
"cordis.patch.yml",
|
|
61
|
+
"scripts/build.mjs",
|
|
62
|
+
"src",
|
|
63
|
+
"types.d.ts",
|
|
64
|
+
"tsconfig.json"
|
|
65
|
+
],
|
|
66
|
+
"dsh": {
|
|
67
|
+
"bundle": {
|
|
68
|
+
"patch": "./cordis.patch.yml"
|
|
69
|
+
},
|
|
70
|
+
"client": {
|
|
71
|
+
"platform": "web",
|
|
72
|
+
"inject": [
|
|
73
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"peerDependencies": {
|
|
78
|
+
"@deepseek-ai/cordis": "*",
|
|
79
|
+
"@deepseek-ai/dsh-host-webserver": "*",
|
|
80
|
+
"@deepseek-ai/dsh-session-query": "*",
|
|
81
|
+
"react": "^18.2.0"
|
|
82
|
+
},
|
|
83
|
+
"peerDependenciesMeta": {
|
|
84
|
+
"@deepseek-ai/cordis": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"@deepseek-ai/dsh-host-webserver": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"@deepseek-ai/dsh-session-query": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"react": {
|
|
94
|
+
"optional": true
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"repository": {
|
|
98
|
+
"type": "git",
|
|
99
|
+
"url": "https://github.com/MingoZhou/dsh-replay"
|
|
100
|
+
},
|
|
101
|
+
"homepage": "https://mingozhou.github.io/dsh-replay/",
|
|
102
|
+
"bugs": "https://github.com/MingoZhou/dsh-replay/issues",
|
|
103
|
+
"publishConfig": {
|
|
104
|
+
"registry": "https://registry.npmjs.org/",
|
|
105
|
+
"access": "public"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the three artifacts:
|
|
3
|
+
* lib/index.js — host half (ESM, externals: @deepseek-ai/*, node:*)
|
|
4
|
+
* lib/client.js — browser half, classic script registering a lazy CJS
|
|
5
|
+
* factory with window.__ModuleLoader__ (the dsh web
|
|
6
|
+
* loader contract); externals resolve via the injected
|
|
7
|
+
* require from the loader's module table.
|
|
8
|
+
* demo/dist/demo.js — fully self-contained demo bundle (react inlined).
|
|
9
|
+
*/
|
|
10
|
+
import { build } from 'esbuild'
|
|
11
|
+
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
|
|
12
|
+
|
|
13
|
+
const cssAsText = { loader: { '.css': 'text' } }
|
|
14
|
+
|
|
15
|
+
await mkdir('lib', { recursive: true })
|
|
16
|
+
await mkdir('demo/dist', { recursive: true })
|
|
17
|
+
|
|
18
|
+
// ---- optional mascot artwork (assets/jingxiaoshen.{png,jpg,jpeg,webp}) ----
|
|
19
|
+
// Drop your artwork there and it replaces the built-in SVG mascot everywhere
|
|
20
|
+
// in the UI (and in exported HTML). Cap 1.5 MB; square images look best.
|
|
21
|
+
{
|
|
22
|
+
const { readFile, stat } = await import('node:fs/promises')
|
|
23
|
+
const candidates = [
|
|
24
|
+
['assets/jingxiaoshen.png', 'image/png'],
|
|
25
|
+
['assets/jingxiaoshen.jpg', 'image/jpeg'],
|
|
26
|
+
['assets/jingxiaoshen.jpeg', 'image/jpeg'],
|
|
27
|
+
['assets/jingxiaoshen.webp', 'image/webp'],
|
|
28
|
+
]
|
|
29
|
+
let source = 'export const mascotImage: string | undefined = undefined\n'
|
|
30
|
+
let found = false
|
|
31
|
+
for (const [file, mime] of candidates) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await stat(file)
|
|
34
|
+
found = true
|
|
35
|
+
if (info.size > 1_500_000) {
|
|
36
|
+
console.warn(`mascot: ${file} is ${Math.round(info.size / 1024)} KB — compress it to ≤1.5 MB to embed; using built-in SVG for now`)
|
|
37
|
+
} else {
|
|
38
|
+
const b64 = (await readFile(file)).toString('base64')
|
|
39
|
+
source = `export const mascotImage: string | undefined = 'data:${mime};base64,${b64}'\n`
|
|
40
|
+
console.log(`mascot: embedded ${file} (${Math.round(info.size / 1024)} KB)`)
|
|
41
|
+
}
|
|
42
|
+
break
|
|
43
|
+
} catch {
|
|
44
|
+
/* try next candidate */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!found) console.log('mascot: no assets/jingxiaoshen.{png,jpg,webp} found — using built-in SVG fallback')
|
|
48
|
+
await writeFile('src/client/mascot-image.ts', source)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- host half ----
|
|
52
|
+
await build({
|
|
53
|
+
entryPoints: ['src/index.ts'],
|
|
54
|
+
outfile: 'lib/index.js',
|
|
55
|
+
bundle: true,
|
|
56
|
+
format: 'esm',
|
|
57
|
+
platform: 'node',
|
|
58
|
+
target: 'node22',
|
|
59
|
+
external: ['@deepseek-ai/*', 'node:*'],
|
|
60
|
+
...{},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ---- core as its own importable entry (for standalone/tooling use) ----
|
|
64
|
+
await build({
|
|
65
|
+
entryPoints: ['src/core/index.ts'],
|
|
66
|
+
outfile: 'lib/core.js',
|
|
67
|
+
bundle: true,
|
|
68
|
+
format: 'esm',
|
|
69
|
+
platform: 'neutral',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ---- browser half ----
|
|
73
|
+
const client = await build({
|
|
74
|
+
entryPoints: ['src/client/index.ts'],
|
|
75
|
+
bundle: true,
|
|
76
|
+
format: 'cjs',
|
|
77
|
+
platform: 'browser',
|
|
78
|
+
jsx: 'automatic',
|
|
79
|
+
...cssAsText,
|
|
80
|
+
external: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/*'],
|
|
81
|
+
write: false,
|
|
82
|
+
})
|
|
83
|
+
const cjs = client.outputFiles[0].text
|
|
84
|
+
const wrapped = `/* dsh-replay client bundle — registers a lazy CJS factory with the dsh web loader. */
|
|
85
|
+
;(function () {
|
|
86
|
+
var factory = function (require) {
|
|
87
|
+
var module = { exports: {} };
|
|
88
|
+
(function (require, module, exports) {
|
|
89
|
+
${cjs}
|
|
90
|
+
})(require, module, module.exports);
|
|
91
|
+
return module.exports;
|
|
92
|
+
};
|
|
93
|
+
if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
94
|
+
window.__ModuleLoader__.load({ id: 'dsh-replay', factory: factory });
|
|
95
|
+
} else if (typeof module !== 'undefined' && module.exports) {
|
|
96
|
+
module.exports = factory(typeof require === 'function' ? require : function (id) {
|
|
97
|
+
throw new Error('unresolvable external: ' + id);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
`
|
|
102
|
+
await writeFile('lib/client.js', wrapped)
|
|
103
|
+
|
|
104
|
+
// ---- standalone viewer (react inlined; embedded into exported HTML) ----
|
|
105
|
+
await build({
|
|
106
|
+
entryPoints: ['src/client/viewer-entry.tsx'],
|
|
107
|
+
outfile: 'lib/viewer.js',
|
|
108
|
+
bundle: true,
|
|
109
|
+
format: 'iife',
|
|
110
|
+
platform: 'browser',
|
|
111
|
+
jsx: 'automatic',
|
|
112
|
+
...cssAsText,
|
|
113
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
114
|
+
minify: true,
|
|
115
|
+
})
|
|
116
|
+
await copyFile('lib/viewer.js', 'demo/dist/viewer.js')
|
|
117
|
+
|
|
118
|
+
// ---- demo ----
|
|
119
|
+
await build({
|
|
120
|
+
entryPoints: ['demo/main.tsx'],
|
|
121
|
+
outfile: 'demo/dist/demo.js',
|
|
122
|
+
bundle: true,
|
|
123
|
+
format: 'iife',
|
|
124
|
+
platform: 'browser',
|
|
125
|
+
jsx: 'automatic',
|
|
126
|
+
...cssAsText,
|
|
127
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
128
|
+
minify: true,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
console.log('build ok: lib/index.js, lib/core.js, lib/client.js, lib/viewer.js, demo/dist/demo.js')
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Data access for the UI — one HTTP implementation, one in-memory demo implementation. */
|
|
2
|
+
import { fromInspection, parseSessionJsonl } from '../core/decode.js'
|
|
3
|
+
import type { ParsedSession, SessionHeader, StorageRecord } from '../core/types.js'
|
|
4
|
+
|
|
5
|
+
export interface SessionEntry {
|
|
6
|
+
header: SessionHeader
|
|
7
|
+
title?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReplayApi {
|
|
11
|
+
listSessions: () => Promise<SessionEntry[]>
|
|
12
|
+
getSession: (id: string) => Promise<ParsedSession>
|
|
13
|
+
/** Source of the standalone viewer bundle, for HTML export (optional). */
|
|
14
|
+
getViewerSource?: () => Promise<string>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HttpReplayApi implements ReplayApi {
|
|
18
|
+
constructor(private readonly base = '/replay/api') {}
|
|
19
|
+
|
|
20
|
+
private async fetchJson(path: string): Promise<unknown> {
|
|
21
|
+
const response = await fetch(`${this.base}${path}`)
|
|
22
|
+
if (!response.ok) throw new Error(`replay api ${path}: HTTP ${response.status}`)
|
|
23
|
+
return response.json()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async listSessions(): Promise<SessionEntry[]> {
|
|
27
|
+
const body = (await this.fetchJson('/sessions')) as { sessions: SessionEntry[] }
|
|
28
|
+
return body.sessions
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getSession(id: string): Promise<ParsedSession> {
|
|
32
|
+
const body = (await this.fetchJson(`/session/${encodeURIComponent(id)}`)) as {
|
|
33
|
+
header: SessionHeader
|
|
34
|
+
events: StorageRecord[]
|
|
35
|
+
}
|
|
36
|
+
return fromInspection(body.header, body.events)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getViewerSource(): Promise<string> {
|
|
40
|
+
const response = await fetch(`${this.base}/viewer.js`)
|
|
41
|
+
if (!response.ok) throw new Error(`viewer bundle: HTTP ${response.status}`)
|
|
42
|
+
return response.text()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Demo/testing implementation over raw JSONL texts keyed by session id. */
|
|
47
|
+
export class StaticReplayApi implements ReplayApi {
|
|
48
|
+
private readonly sessions = new Map<string, ParsedSession>()
|
|
49
|
+
private readonly titles = new Map<string, string>()
|
|
50
|
+
/** Optional URL of the viewer bundle for HTML export (demo mode). */
|
|
51
|
+
viewerUrl?: string
|
|
52
|
+
|
|
53
|
+
constructor(logs: { jsonl: string; title?: string }[]) {
|
|
54
|
+
for (const { jsonl, title } of logs) {
|
|
55
|
+
const parsed = parseSessionJsonl(jsonl)
|
|
56
|
+
this.sessions.set(parsed.header.id, parsed)
|
|
57
|
+
if (title !== undefined) this.titles.set(parsed.header.id, title)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build directly from already-decoded sessions (exported HTML, tooling). */
|
|
62
|
+
static fromParsed(list: { header: ParsedSession['header']; events: ParsedSession['events']; title?: string }[]): StaticReplayApi {
|
|
63
|
+
const api = new StaticReplayApi([])
|
|
64
|
+
for (const { header, events, title } of list) {
|
|
65
|
+
api.sessions.set(header.id, { header, events, malformed: [] })
|
|
66
|
+
if (title !== undefined) api.titles.set(header.id, title)
|
|
67
|
+
}
|
|
68
|
+
return api
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getViewerSource(): Promise<string> {
|
|
72
|
+
if (this.viewerUrl === undefined) throw new Error('viewer bundle unavailable in this mode')
|
|
73
|
+
const response = await fetch(this.viewerUrl)
|
|
74
|
+
if (!response.ok) throw new Error(`viewer bundle: HTTP ${response.status}`)
|
|
75
|
+
return response.text()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async listSessions(): Promise<SessionEntry[]> {
|
|
79
|
+
return [...this.sessions.values()].map(s => ({
|
|
80
|
+
header: s.header,
|
|
81
|
+
title: this.titles.get(s.header.id),
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getSession(id: string): Promise<ParsedSession> {
|
|
86
|
+
const session = this.sessions.get(id)
|
|
87
|
+
if (session === undefined) throw new Error(`unknown session ${id}`)
|
|
88
|
+
return session
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatDuration(ms: number): string {
|
|
93
|
+
if (!Number.isFinite(ms) || ms < 0) return '—'
|
|
94
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
95
|
+
const s = ms / 1000
|
|
96
|
+
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`
|
|
97
|
+
const m = Math.floor(s / 60)
|
|
98
|
+
if (m < 60) return `${m}m ${Math.round(s % 60)}s`
|
|
99
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatTokens(n: number): string {
|
|
103
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
104
|
+
if (n >= 10_000) return `${Math.round(n / 1000)}k`
|
|
105
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
106
|
+
return String(n)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatClock(time: number, startTime: number): string {
|
|
110
|
+
const ms = time - startTime
|
|
111
|
+
const s = Math.floor(ms / 1000)
|
|
112
|
+
const mm = String(Math.floor(s / 60)).padStart(2, '0')
|
|
113
|
+
const ss = String(s % 60).padStart(2, '0')
|
|
114
|
+
return `${mm}:${ss}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function shortId(id: string): string {
|
|
118
|
+
return id.length > 10 ? `${id.slice(0, 8)}…` : id
|
|
119
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { AuditReport, Severity } from '../../core/audit.js'
|
|
3
|
+
import type { TimelineModel } from '../../core/timeline.js'
|
|
4
|
+
import { formatClock } from '../api.js'
|
|
5
|
+
import { localizeFinding, useI18n } from '../i18n.js'
|
|
6
|
+
import { MascotState } from '../mascot.js'
|
|
7
|
+
|
|
8
|
+
const SEV_META: Record<Severity, { color: string; icon: string }> = {
|
|
9
|
+
critical: { color: 'var(--dshr-critical)', icon: '⛔' },
|
|
10
|
+
serious: { color: 'var(--dshr-serious)', icon: '⚠' },
|
|
11
|
+
warning: { color: 'var(--dshr-warning)', icon: '△' },
|
|
12
|
+
info: { color: 'var(--dshr-good)', icon: 'ℹ' },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function AuditView({
|
|
16
|
+
report,
|
|
17
|
+
model,
|
|
18
|
+
onJump,
|
|
19
|
+
}: {
|
|
20
|
+
report: AuditReport
|
|
21
|
+
model: TimelineModel
|
|
22
|
+
onJump: (seq: number) => void
|
|
23
|
+
}): React.ReactElement {
|
|
24
|
+
const { t } = useI18n()
|
|
25
|
+
return (
|
|
26
|
+
<div className="dshr-audit">
|
|
27
|
+
<div className="dshr-audit-summary">
|
|
28
|
+
{(Object.keys(SEV_META) as Severity[]).map(sev => (
|
|
29
|
+
<div key={sev} className="dshr-audit-tile">
|
|
30
|
+
<div className="dshr-count">{report.counts[sev]}</div>
|
|
31
|
+
<div className="dshr-cap">
|
|
32
|
+
<span className="dshr-sev-dot" style={{ background: SEV_META[sev].color }} />
|
|
33
|
+
{SEV_META[sev].icon} {t(`sev.${sev}`)}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
{report.findings.length === 0 ? (
|
|
39
|
+
<MascotState mood="happy" text={t('audit.empty')} />
|
|
40
|
+
) : (
|
|
41
|
+
report.findings.map((finding, i) => (
|
|
42
|
+
<button key={i} className="dshr-finding" onClick={() => onJump(finding.seq)}>
|
|
43
|
+
<span className="dshr-sev">
|
|
44
|
+
<span className="dshr-sev-dot" style={{ background: SEV_META[finding.severity].color }} />
|
|
45
|
+
{SEV_META[finding.severity].icon} {t(`sev.${finding.severity}`)}
|
|
46
|
+
</span>
|
|
47
|
+
<span className="dshr-finding-title">{localizeFinding(t, finding)}</span>
|
|
48
|
+
{finding.detail !== undefined && finding.detail !== '' && (
|
|
49
|
+
<span className="dshr-finding-detail">{finding.detail}</span>
|
|
50
|
+
)}
|
|
51
|
+
<span className="dshr-item-time">{formatClock(finding.time, model.startTime)}</span>
|
|
52
|
+
</button>
|
|
53
|
+
))
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { compareSessions, type CompareResult } from '../../core/compare.js'
|
|
3
|
+
import type { ParsedSession } from '../../core/types.js'
|
|
4
|
+
import { formatDuration, formatTokens, shortId, type ReplayApi, type SessionEntry } from '../api.js'
|
|
5
|
+
import { useI18n } from '../i18n.js'
|
|
6
|
+
|
|
7
|
+
function Row({
|
|
8
|
+
label,
|
|
9
|
+
a,
|
|
10
|
+
b,
|
|
11
|
+
format = String,
|
|
12
|
+
}: {
|
|
13
|
+
label: string
|
|
14
|
+
a: number
|
|
15
|
+
b: number
|
|
16
|
+
format?: (n: number) => string
|
|
17
|
+
}): React.ReactElement {
|
|
18
|
+
return (
|
|
19
|
+
<tr>
|
|
20
|
+
<td>{label}</td>
|
|
21
|
+
<td>{format(a)}</td>
|
|
22
|
+
<td>{format(b)}</td>
|
|
23
|
+
<td style={{ color: 'var(--dshr-ink-3)' }}>
|
|
24
|
+
{a === b ? '=' : a > b ? `+${format(a - b)}` : `−${format(b - a)}`}
|
|
25
|
+
</td>
|
|
26
|
+
</tr>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function CompareView({
|
|
31
|
+
api,
|
|
32
|
+
session,
|
|
33
|
+
entries,
|
|
34
|
+
title,
|
|
35
|
+
}: {
|
|
36
|
+
api: ReplayApi
|
|
37
|
+
session: ParsedSession
|
|
38
|
+
entries: SessionEntry[]
|
|
39
|
+
title?: string
|
|
40
|
+
}): React.ReactElement {
|
|
41
|
+
const { t } = useI18n()
|
|
42
|
+
const others = useMemo(
|
|
43
|
+
() => entries.filter(e => e.header.id !== session.header.id),
|
|
44
|
+
[entries, session],
|
|
45
|
+
)
|
|
46
|
+
const defaultOther =
|
|
47
|
+
others.find(e => e.header.id === session.header.parentSession)?.header.id ??
|
|
48
|
+
others.find(e => e.header.parentSession === session.header.id)?.header.id ??
|
|
49
|
+
others[0]?.header.id
|
|
50
|
+
const [otherId, setOtherId] = useState(defaultOther)
|
|
51
|
+
const [other, setOther] = useState<ParsedSession>()
|
|
52
|
+
const [error, setError] = useState<string>()
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setOtherId(defaultOther)
|
|
56
|
+
}, [defaultOther])
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (otherId === undefined) return
|
|
60
|
+
let cancelled = false
|
|
61
|
+
api
|
|
62
|
+
.getSession(otherId)
|
|
63
|
+
.then(s => {
|
|
64
|
+
if (!cancelled) {
|
|
65
|
+
setOther(s)
|
|
66
|
+
setError(undefined)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.catch(e => !cancelled && setError(String(e)))
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true
|
|
72
|
+
}
|
|
73
|
+
}, [api, otherId])
|
|
74
|
+
|
|
75
|
+
const result: CompareResult | undefined = useMemo(() => {
|
|
76
|
+
if (other === undefined) return undefined
|
|
77
|
+
return compareSessions(session, other, {
|
|
78
|
+
a: title,
|
|
79
|
+
b: others.find(e => e.header.id === other.header.id)?.title,
|
|
80
|
+
})
|
|
81
|
+
}, [session, other, title, others])
|
|
82
|
+
|
|
83
|
+
if (others.length === 0) {
|
|
84
|
+
return <div className="dshr-center">{t('cmp.none')}</div>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="dshr-compare">
|
|
89
|
+
<div className="dshr-compare-picker">
|
|
90
|
+
<span>{t('cmp.with')}</span>
|
|
91
|
+
<select
|
|
92
|
+
className="dshr-select"
|
|
93
|
+
value={otherId}
|
|
94
|
+
onChange={e => setOtherId(e.target.value)}
|
|
95
|
+
aria-label={t('cmp.aria')}
|
|
96
|
+
>
|
|
97
|
+
{others.map(entry => (
|
|
98
|
+
<option key={entry.header.id} value={entry.header.id}>
|
|
99
|
+
{entry.title ?? shortId(entry.header.id)}
|
|
100
|
+
{entry.header.id === session.header.parentSession ? ` ${t('cmp.parent')}` : ''}
|
|
101
|
+
{entry.header.parentSession === session.header.id ? ` ${t('cmp.fork')}` : ''}
|
|
102
|
+
</option>
|
|
103
|
+
))}
|
|
104
|
+
</select>
|
|
105
|
+
</div>
|
|
106
|
+
{error !== undefined && <div className="dshr-center">{error}</div>}
|
|
107
|
+
{result && (
|
|
108
|
+
<>
|
|
109
|
+
{result.related && result.divergenceSeq !== undefined && (
|
|
110
|
+
<div className="dshr-divergence">
|
|
111
|
+
{t('cmp.divergence', { seq: result.divergenceSeq })}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
<div className="dshr-compare-grid">
|
|
115
|
+
<table className="dshr-table">
|
|
116
|
+
<thead>
|
|
117
|
+
<tr>
|
|
118
|
+
<th>{t('cmp.metric')}</th>
|
|
119
|
+
<th>{result.a.title ?? shortId(result.a.id)}</th>
|
|
120
|
+
<th>{result.b.title ?? shortId(result.b.id)}</th>
|
|
121
|
+
<th>{t('cmp.delta')}</th>
|
|
122
|
+
</tr>
|
|
123
|
+
</thead>
|
|
124
|
+
<tbody>
|
|
125
|
+
<Row label={t('cmp.turns')} a={result.a.turns} b={result.b.turns} />
|
|
126
|
+
<Row label={t('cmp.steps')} a={result.a.steps} b={result.b.steps} />
|
|
127
|
+
<Row label={t('cmp.events')} a={result.a.events} b={result.b.events} />
|
|
128
|
+
<Row label={t('cmp.toolCalls')} a={result.a.toolCallCount} b={result.b.toolCallCount} />
|
|
129
|
+
<Row label={t('cmp.toolErrors')} a={result.a.toolErrors} b={result.b.toolErrors} />
|
|
130
|
+
<Row
|
|
131
|
+
label={t('cmp.inputTokens')}
|
|
132
|
+
a={result.a.billedInputTokens}
|
|
133
|
+
b={result.b.billedInputTokens}
|
|
134
|
+
format={formatTokens}
|
|
135
|
+
/>
|
|
136
|
+
<Row
|
|
137
|
+
label={t('cmp.outputTokens')}
|
|
138
|
+
a={result.a.outputTokens}
|
|
139
|
+
b={result.b.outputTokens}
|
|
140
|
+
format={formatTokens}
|
|
141
|
+
/>
|
|
142
|
+
<Row
|
|
143
|
+
label={t('cmp.cacheTokens')}
|
|
144
|
+
a={result.a.cacheReadTokens}
|
|
145
|
+
b={result.b.cacheReadTokens}
|
|
146
|
+
format={formatTokens}
|
|
147
|
+
/>
|
|
148
|
+
<Row
|
|
149
|
+
label={t('cmp.duration')}
|
|
150
|
+
a={result.a.durationMs}
|
|
151
|
+
b={result.b.durationMs}
|
|
152
|
+
format={formatDuration}
|
|
153
|
+
/>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
{result.toolNames.length > 0 && (
|
|
157
|
+
<div>
|
|
158
|
+
<div className="dshr-section-label">{t('cmp.toolMix')}</div>
|
|
159
|
+
<table className="dshr-table">
|
|
160
|
+
<thead>
|
|
161
|
+
<tr>
|
|
162
|
+
<th>{t('cmp.tool')}</th>
|
|
163
|
+
<th>{result.a.title ?? shortId(result.a.id)}</th>
|
|
164
|
+
<th>{result.b.title ?? shortId(result.b.id)}</th>
|
|
165
|
+
<th>{t('cmp.delta')}</th>
|
|
166
|
+
</tr>
|
|
167
|
+
</thead>
|
|
168
|
+
<tbody>
|
|
169
|
+
{result.toolNames.map(name => (
|
|
170
|
+
<Row
|
|
171
|
+
key={name}
|
|
172
|
+
label={name}
|
|
173
|
+
a={result.a.toolMix[name] ?? 0}
|
|
174
|
+
b={result.b.toolMix[name] ?? 0}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</tbody>
|
|
178
|
+
</table>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|