Skip to content

Commit 06ffdae

Browse files
authored
feat(dashboard): render structured transcript artifacts (#1370)
* feat(dashboard): render structured transcript artifacts * fix(dashboard): guard transcript tool call shape
1 parent 964ca81 commit 06ffdae

8 files changed

Lines changed: 1041 additions & 13 deletions

File tree

apps/cli/src/commands/results/serve.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* - GET /api/runs — list available run workspaces with metadata
1111
* - GET /api/runs/:filename — load results from a specific run workspace
1212
* - GET /api/runs/:filename/log — stream the captured console.log for a run
13+
* - GET /api/runs/:filename/evals/:evalId/files/* — read artifact files as JSON,
14+
* or as raw/downloadable text with ?raw=1 / ?download=1
1315
* - GET /api/feedback — read feedback reviews
1416
* - POST /api/feedback — write feedback reviews
1517
* - GET /api/projects — list registered projects
@@ -256,6 +258,21 @@ function inferLanguage(filePath: string): string {
256258
return langMap[ext] ?? 'plaintext';
257259
}
258260

261+
function inferRawContentType(filePath: string): string {
262+
const ext = path.extname(filePath).toLowerCase();
263+
if (ext === '.json') return 'application/json; charset=utf-8';
264+
// Raw artifact links should be inspectable in a tab instead of rendered as
265+
// same-origin HTML/SVG. The explicit ?download=1 path adds
266+
// Content-Disposition for users that want a file.
267+
if (ext === '.jsonl') return 'text/plain; charset=utf-8';
268+
if (ext === '.md') return 'text/markdown; charset=utf-8';
269+
return 'text/plain; charset=utf-8';
270+
}
271+
272+
function contentDispositionFilename(filePath: string): string {
273+
return path.basename(filePath).replace(/["\\\r\n]/g, '_');
274+
}
275+
259276
function stripHeavyFields(results: readonly EvaluationResult[]) {
260277
return results.map((r) => {
261278
const { requests, trace, ...rest } = r as EvaluationResult & Record<string, unknown>;
@@ -800,6 +817,8 @@ async function handleEvalFiles(c: C, { searchDir, projectId }: DataContext) {
800817
record.input_path,
801818
record.output_path,
802819
record.response_path,
820+
record.answer_path,
821+
record.transcript_path,
803822
record.task_dir,
804823
record.eval_path,
805824
record.targets_path,
@@ -833,7 +852,13 @@ async function handleEvalFileContent(c: C, { searchDir, projectId }: DataContext
833852
// Extract the wildcard suffix without depending on decoded route params.
834853
const marker = '/files/';
835854
const markerIdx = c.req.path.indexOf(marker);
836-
const filePath = markerIdx >= 0 ? c.req.path.slice(markerIdx + marker.length) : '';
855+
const encodedFilePath = markerIdx >= 0 ? c.req.path.slice(markerIdx + marker.length) : '';
856+
let filePath = '';
857+
try {
858+
filePath = encodedFilePath ? decodeURIComponent(encodedFilePath) : '';
859+
} catch {
860+
return c.json({ error: 'Invalid file path encoding' }, 400);
861+
}
837862

838863
if (!filePath) return c.json({ error: 'No file path specified' }, 400);
839864

@@ -855,6 +880,16 @@ async function handleEvalFileContent(c: C, { searchDir, projectId }: DataContext
855880

856881
try {
857882
const fileContent = readFileSync(absolutePath, 'utf8');
883+
if (c.req.query('raw') === '1' || c.req.query('download') === '1') {
884+
c.header('Content-Type', inferRawContentType(absolutePath));
885+
if (c.req.query('download') === '1') {
886+
c.header(
887+
'Content-Disposition',
888+
`attachment; filename="${contentDispositionFilename(absolutePath)}"`,
889+
);
890+
}
891+
return c.body(fileContent);
892+
}
858893
const language = inferLanguage(absolutePath);
859894
return c.json({ content: fileContent, language });
860895
} catch {

apps/cli/test/commands/results/serve.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2330,6 +2330,47 @@ describe('serve app', () => {
23302330
const data = (await res.json()) as { content: string };
23312331
expect(data.content).toContain('Hello, Alice!');
23322332
});
2333+
2334+
it('serves transcript JSONL artifacts as browser-visible raw text and downloads', async () => {
2335+
const runsDir = path.join(tempDir, '.agentv', 'results', 'runs', 'with-transcript');
2336+
const runId = 'with-transcript::2026-03-25T10-00-00-000Z';
2337+
const timestampDir = path.join(runsDir, '2026-03-25T10-00-00-000Z');
2338+
const artifactPath = 'demo/test-greeting/outputs/transcript.jsonl';
2339+
const transcriptPath = path.join(timestampDir, artifactPath);
2340+
const transcriptJsonl = `${JSON.stringify({
2341+
test_id: 'test-greeting',
2342+
target: 'gpt-4o',
2343+
message_index: 0,
2344+
role: 'user',
2345+
content: 'Hello',
2346+
})}\n`;
2347+
2348+
mkdirSync(path.dirname(transcriptPath), { recursive: true });
2349+
writeFileSync(transcriptPath, transcriptJsonl);
2350+
writeFileSync(
2351+
path.join(timestampDir, 'index.jsonl'),
2352+
toJsonl({
2353+
...RESULT_A,
2354+
experiment: 'with-transcript',
2355+
transcript_path: artifactPath,
2356+
}),
2357+
);
2358+
2359+
const app = createApp([], tempDir, tempDir, undefined, { studioDir });
2360+
const artifactUrl = `/api/runs/${encodeURIComponent(runId)}/evals/test-greeting/files/${artifactPath}`;
2361+
2362+
const rawRes = await app.request(`${artifactUrl}?raw=1`);
2363+
expect(rawRes.status).toBe(200);
2364+
expect(rawRes.headers.get('content-type')).toContain('text/plain');
2365+
expect(await rawRes.text()).toBe(transcriptJsonl);
2366+
2367+
const downloadRes = await app.request(`${artifactUrl}?download=1`);
2368+
expect(downloadRes.status).toBe(200);
2369+
expect(downloadRes.headers.get('content-disposition')).toBe(
2370+
'attachment; filename="transcript.jsonl"',
2371+
);
2372+
expect(await downloadRes.text()).toBe(transcriptJsonl);
2373+
});
23332374
});
23342375

23352376
// ── GET /api/compare (tag filter) ───────────────────────────────────

apps/dashboard/src/components/EvalDetail.tsx

Lines changed: 194 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
* Assertions are grouped by grader name.
77
*/
88

9-
import { useState } from 'react';
9+
import { useMemo, useState } from 'react';
1010

1111
import { useQuery } from '@tanstack/react-query';
1212
import {
13+
artifactFileContentUrl,
1314
isPassing,
1415
projectEvalFileContentOptions,
1516
projectEvalFilesOptions,
@@ -31,14 +32,20 @@ import type { FileNode } from './FileTree';
3132
import { FileTree } from './FileTree';
3233
import { MonacoViewer } from './MonacoViewer';
3334
import { ScoreBar } from './ScoreBar';
35+
import {
36+
TranscriptTimeline,
37+
findAnswerPath,
38+
findTranscriptPath,
39+
parseTranscriptJsonl,
40+
} from './TranscriptTimeline';
3441

3542
interface EvalDetailProps {
3643
eval: EvalResult;
3744
runId: string;
3845
projectId?: string;
3946
}
4047

41-
type Tab = 'checks' | 'source' | 'files' | 'feedback';
48+
type Tab = 'checks' | 'transcript' | 'source' | 'files' | 'feedback';
4249

4350
/** Recursively find the first file node in the tree. */
4451
function findFirstFile(nodes: FileNode[]): string | null {
@@ -54,16 +61,23 @@ function findFirstFile(nodes: FileNode[]): string | null {
5461

5562
export function EvalDetail({ eval: result, runId, projectId }: EvalDetailProps) {
5663
const [activeTab, setActiveTab] = useState<Tab>('checks');
64+
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
5765
const { data: config } = useStudioConfig(projectId);
5866
const isReadOnly = config?.read_only === true;
5967

6068
const tabs: { id: Tab; label: string }[] = [
6169
{ id: 'checks', label: 'Checks' },
70+
{ id: 'transcript', label: 'Transcript' },
6271
{ id: 'source', label: 'Source' },
6372
{ id: 'files', label: 'Files' },
6473
...(isReadOnly ? [] : [{ id: 'feedback' as const, label: 'Feedback' }]),
6574
];
6675

76+
const openFile = (filePath: string) => {
77+
setSelectedFilePath(filePath);
78+
setActiveTab('files');
79+
};
80+
6781
return (
6882
<div className="flex min-h-full flex-col">
6983
{/* Tab navigation — at the top so Files tab editor fills maximum height */}
@@ -95,7 +109,23 @@ export function EvalDetail({ eval: result, runId, projectId }: EvalDetailProps)
95109
)}
96110
{activeTab === 'files' && (
97111
<div className="h-full p-4">
98-
<FilesTab result={result} runId={runId} projectId={projectId} />
112+
<FilesTab
113+
result={result}
114+
runId={runId}
115+
projectId={projectId}
116+
selectedPath={selectedFilePath}
117+
onSelectedPathChange={setSelectedFilePath}
118+
/>
119+
</div>
120+
)}
121+
{activeTab === 'transcript' && (
122+
<div className="overflow-auto p-4">
123+
<TranscriptTab
124+
result={result}
125+
runId={runId}
126+
projectId={projectId}
127+
onOpenFile={openFile}
128+
/>
99129
</div>
100130
)}
101131
{activeTab === 'source' && (
@@ -406,11 +436,162 @@ function ChecksTab({ result, projectId }: { result: EvalResult; projectId?: stri
406436
);
407437
}
408438

439+
function containsFilePath(nodes: FileNode[], filePath: string | null): boolean {
440+
if (!filePath) return false;
441+
for (const node of nodes) {
442+
if (node.type === 'file' && node.path === filePath) return true;
443+
if (node.children && containsFilePath(node.children, filePath)) return true;
444+
}
445+
return false;
446+
}
447+
448+
function TranscriptTab({
449+
result,
450+
runId,
451+
projectId,
452+
onOpenFile,
453+
}: {
454+
result: EvalResult;
455+
runId: string;
456+
projectId?: string;
457+
onOpenFile: (path: string) => void;
458+
}) {
459+
const evalId = result.testId;
460+
const { data: filesData, isLoading: isLoadingFiles } = projectId
461+
? useQuery(projectEvalFilesOptions(projectId, runId, evalId))
462+
: useEvalFiles(runId, evalId);
463+
const files = filesData?.files ?? [];
464+
const transcriptPath = findTranscriptPath(files);
465+
const answerPath = findAnswerPath(files);
466+
467+
const { data: transcriptContentData, isLoading: isLoadingTranscript } = projectId
468+
? useQuery(projectEvalFileContentOptions(projectId, runId, evalId, transcriptPath ?? ''))
469+
: useEvalFileContent(runId, evalId, transcriptPath ?? '');
470+
const { data: answerContentData } = projectId
471+
? useQuery(projectEvalFileContentOptions(projectId, runId, evalId, answerPath ?? ''))
472+
: useEvalFileContent(runId, evalId, answerPath ?? '');
473+
474+
const parsedTranscript = useMemo(
475+
() => parseTranscriptJsonl(transcriptContentData?.content ?? ''),
476+
[transcriptContentData?.content],
477+
);
478+
479+
if (isLoadingFiles) {
480+
return (
481+
<div className="rounded-lg border border-gray-800 bg-gray-900 p-4 text-sm text-gray-500">
482+
Loading transcript artifacts...
483+
</div>
484+
);
485+
}
486+
487+
if (!transcriptPath) {
488+
return (
489+
<div className="rounded-lg border border-gray-800 bg-gray-900 p-4">
490+
<h3 className="text-sm font-medium text-gray-300">No structured transcript</h3>
491+
<p className="mt-2 text-sm text-gray-500">
492+
This run does not include canonical <code>outputs/transcript.jsonl</code>. Dashboard does
493+
not parse <code>response.md</code> or markdown transcripts for this view.
494+
</p>
495+
</div>
496+
);
497+
}
498+
499+
if (isLoadingTranscript) {
500+
return (
501+
<div className="rounded-lg border border-gray-800 bg-gray-900 p-4 text-sm text-gray-500">
502+
Loading <code>{transcriptPath}</code>...
503+
</div>
504+
);
505+
}
506+
507+
if (parsedTranscript.error) {
508+
return (
509+
<div className="rounded-lg border border-red-900/50 bg-red-950/20 p-4">
510+
<h3 className="text-sm font-medium text-red-300">Transcript could not be parsed</h3>
511+
<p className="mt-2 text-sm text-gray-300">{parsedTranscript.error}</p>
512+
<div className="mt-3 flex flex-wrap gap-2">
513+
<button
514+
type="button"
515+
onClick={() => onOpenFile(transcriptPath)}
516+
className="rounded-md border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:border-cyan-900/60 hover:text-cyan-300"
517+
>
518+
Open raw JSONL in Files
519+
</button>
520+
<a
521+
href={artifactFileContentUrl({
522+
projectId,
523+
runId,
524+
evalId,
525+
filePath: transcriptPath,
526+
raw: true,
527+
})}
528+
target="_blank"
529+
rel="noreferrer"
530+
className="rounded-md px-3 py-1.5 text-sm text-cyan-400 transition-colors hover:text-cyan-300 hover:underline"
531+
>
532+
Open raw JSONL
533+
</a>
534+
</div>
535+
</div>
536+
);
537+
}
538+
539+
if (parsedTranscript.entries.length === 0) {
540+
return (
541+
<div className="rounded-lg border border-gray-800 bg-gray-900 p-4">
542+
<h3 className="text-sm font-medium text-gray-300">Empty transcript</h3>
543+
<p className="mt-2 text-sm text-gray-500">
544+
<code>{transcriptPath}</code> exists but contains no JSONL rows.
545+
</p>
546+
</div>
547+
);
548+
}
549+
550+
const answerHref = answerPath
551+
? artifactFileContentUrl({ projectId, runId, evalId, filePath: answerPath, raw: true })
552+
: undefined;
553+
const transcriptHref = artifactFileContentUrl({
554+
projectId,
555+
runId,
556+
evalId,
557+
filePath: transcriptPath,
558+
raw: true,
559+
});
560+
const transcriptDownloadHref = artifactFileContentUrl({
561+
projectId,
562+
runId,
563+
evalId,
564+
filePath: transcriptPath,
565+
download: true,
566+
});
567+
568+
return (
569+
<TranscriptTimeline
570+
entries={parsedTranscript.entries}
571+
finalAnswer={answerPath ? (answerContentData?.content ?? result.output) : undefined}
572+
answerPath={answerPath}
573+
transcriptPath={transcriptPath}
574+
answerHref={answerHref}
575+
transcriptHref={transcriptHref}
576+
transcriptDownloadHref={transcriptDownloadHref}
577+
onOpenFile={onOpenFile}
578+
/>
579+
);
580+
}
581+
409582
function FilesTab({
410583
result,
411584
runId,
412585
projectId,
413-
}: { result: EvalResult; runId: string; projectId?: string }) {
586+
selectedPath,
587+
onSelectedPathChange,
588+
}: {
589+
result: EvalResult;
590+
runId: string;
591+
projectId?: string;
592+
selectedPath: string | null;
593+
onSelectedPathChange: (path: string) => void;
594+
}) {
414595
const evalId = result.testId;
415596

416597
// Use project-scoped API hooks when projectId is present
@@ -419,10 +600,15 @@ function FilesTab({
419600
: useEvalFiles(runId, evalId);
420601
const files = filesData?.files ?? [];
421602

422-
const [selectedPath, setSelectedPath] = useState<string | null>(null);
603+
const [localSelectedPath, setLocalSelectedPath] = useState<string | null>(null);
423604
const [mobileShowTree, setMobileShowTree] = useState(false);
424605

425-
const effectivePath = selectedPath ?? (files.length > 0 ? findFirstFile(files) : null);
606+
const requestedPath = selectedPath ?? localSelectedPath;
607+
const effectivePath = containsFilePath(files, requestedPath)
608+
? requestedPath
609+
: files.length > 0
610+
? findFirstFile(files)
611+
: null;
426612

427613
const { data: fileContentData, isLoading: isLoadingContent } = projectId
428614
? useQuery(projectEvalFileContentOptions(projectId, runId, evalId, effectivePath ?? ''))
@@ -448,7 +634,8 @@ function FilesTab({
448634
files={files}
449635
selectedPath={effectivePath}
450636
onSelect={(path) => {
451-
setSelectedPath(path);
637+
setLocalSelectedPath(path);
638+
onSelectedPathChange(path);
452639
// On mobile, auto-switch to content viewer after selecting a file
453640
setMobileShowTree(false);
454641
}}

0 commit comments

Comments
 (0)