feat(web): add scenarios UI - explorer, detail, and homepage section
- Add global scenarios explorer page at /scenarios - Add scenario detail page with run history at /scenarios/[id] - Add collection-scoped scenario detail page - Add featured scenarios section to homepage - Add useScenarios hook for data fetching - Regenerate CLI manifest
This commit is contained in:
parent
dc684d3b0e
commit
3c5c218207
6 changed files with 3323 additions and 42 deletions
|
|
@ -0,0 +1,467 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { notFound, useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface ScenarioRun {
|
||||
id: string;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
evaluator: {
|
||||
model: string | null;
|
||||
verdict: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
assertions: { passed: string[]; failed: string[] } | null;
|
||||
usage: {
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
executionTimeMs: number | null;
|
||||
estimatedCost: number | null;
|
||||
};
|
||||
timestamps: {
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
output?: string;
|
||||
errorLog?: string;
|
||||
conversation?: unknown[];
|
||||
}
|
||||
|
||||
interface ScenarioDetail {
|
||||
id: string;
|
||||
collectionId: string | null;
|
||||
prompt: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
qualityScore: number;
|
||||
consecutivePasses: number;
|
||||
consecutiveFails: number;
|
||||
totalRuns: number;
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isOwner: boolean;
|
||||
collection: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
username: string;
|
||||
} | null;
|
||||
recentRuns: ScenarioRun[];
|
||||
runCount: number;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) {
|
||||
return <Badge variant="secondary">Not run</Badge>;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return (
|
||||
<Badge className="bg-success/10 text-success border-success/20">
|
||||
<Icon icon="check" className="w-3.5 h-3.5 mr-1" />
|
||||
Pass
|
||||
</Badge>
|
||||
);
|
||||
case 'fail':
|
||||
return (
|
||||
<Badge className="bg-error/10 text-error border-error/20">
|
||||
<Icon icon="x" className="w-3.5 h-3.5 mr-1" />
|
||||
Fail
|
||||
</Badge>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Badge className="bg-warning/10 text-warning border-warning/20">
|
||||
<Icon icon="alertTriangle" className="w-3.5 h-3.5 mr-1" />
|
||||
Error
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20">
|
||||
<Icon icon="loader" className="w-3.5 h-3.5 mr-1 animate-spin" />
|
||||
Running
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Icon icon="clock" className="w-3.5 h-3.5 mr-1" />
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function QualityIndicator({ score, showLabel = true }: { score: number; showLabel?: boolean }) {
|
||||
const percentage = Math.round(score * 100);
|
||||
const color =
|
||||
percentage >= 70
|
||||
? 'text-success'
|
||||
: percentage >= 40
|
||||
? 'text-warning'
|
||||
: 'text-foreground-tertiary';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={`Quality score: ${percentage}%`}>
|
||||
<Icon icon="star" className={`w-4 h-4 ${color}`} />
|
||||
<span className={`font-medium ${color}`}>{percentage}%</span>
|
||||
{showLabel && <span className="text-foreground-tertiary">quality</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (!ms) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function CollectionScenarioDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const rawUsername = params.username as string;
|
||||
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||
const slug = params.slug as string;
|
||||
const scenarioId = params.id as string;
|
||||
|
||||
const [scenario, setScenario] = useState<ScenarioDetail | null>(null);
|
||||
const [runs, setRuns] = useState<ScenarioRun[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
||||
|
||||
const fetchScenario = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/scenarios/${scenarioId}?runsLimit=50`);
|
||||
if (response.status === 404) {
|
||||
setError('not_found');
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setScenario(data.data);
|
||||
setRuns(data.data.recentRuns || []);
|
||||
} else {
|
||||
setError(data.error?.message || 'Failed to load scenario');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load scenario');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scenarioId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchScenario();
|
||||
}, [fetchScenario]);
|
||||
|
||||
const handleRunScenario = async () => {
|
||||
setIsRunning(true);
|
||||
setRunError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scenarios/${scenarioId}/run`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const data = await response.json();
|
||||
setRunError(`Rate limited. Try again in ${Math.ceil(data.retryAfter / 60)} minute(s).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error?.message || 'Failed to run scenario');
|
||||
}
|
||||
|
||||
// Refresh to show updated status
|
||||
await fetchScenario();
|
||||
} catch (err) {
|
||||
setRunError(err instanceof Error ? err.message : 'Failed to run scenario');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error === 'not_found') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-error">{error}</p>
|
||||
</div>
|
||||
) : scenario ? (
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<Link href={`/@${username}`} className="hover:text-foreground transition-colors">
|
||||
@{username}
|
||||
</Link>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<Link
|
||||
href={`/@${username}/collections/${slug}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{scenario.collection?.name || slug}
|
||||
</Link>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<span className="text-foreground">{scenario.name || 'Scenario'}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{scenario.name || 'Unnamed Scenario'}
|
||||
</h1>
|
||||
{scenario.description && (
|
||||
<p className="text-foreground-secondary mt-2">{scenario.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{scenario.isOwner && (
|
||||
<Button onClick={handleRunScenario} disabled={isRunning}>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Icon icon="loader" className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="arrowRight" className="w-4 h-4 mr-1.5" />
|
||||
Run Scenario
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runError && (
|
||||
<div className="p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error">
|
||||
{runError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<h2 className="text-sm font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Prompt
|
||||
</h2>
|
||||
<p className="text-foreground whitespace-pre-wrap">{scenario.prompt}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Quality</div>
|
||||
<QualityIndicator score={scenario.qualityScore} showLabel={false} />
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Total Runs</div>
|
||||
<div className="text-xl font-semibold text-foreground">{scenario.totalRuns}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Pass Streak</div>
|
||||
<div className="text-xl font-semibold text-success flex items-center gap-1">
|
||||
<Icon icon="check" className="w-5 h-5" />
|
||||
{scenario.consecutivePasses}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Fail Streak</div>
|
||||
<div className="text-xl font-semibold text-error flex items-center gap-1">
|
||||
<Icon icon="x" className="w-5 h-5" />
|
||||
{scenario.consecutiveFails}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Last Status</div>
|
||||
<StatusBadge status={scenario.lastRunStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{scenario.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scenario.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run History */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Run History</h2>
|
||||
|
||||
{runs.length === 0 ? (
|
||||
<div className="p-8 bg-surface border border-border rounded-xl text-center">
|
||||
<Icon icon="clock" className="w-8 h-8 mx-auto text-foreground-tertiary mb-3" />
|
||||
<p className="text-foreground-secondary">No runs yet</p>
|
||||
{scenario.isOwner && (
|
||||
<Button className="mt-4" onClick={handleRunScenario} disabled={isRunning}>
|
||||
Run Scenario
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="bg-surface border border-border rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Run Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedRunId(expandedRunId === run.id ? null : run.id)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="text-sm text-foreground-secondary">
|
||||
{formatDate(run.timestamps.createdAt)}
|
||||
</span>
|
||||
{run.usage.executionTimeMs && (
|
||||
<span className="text-sm text-foreground-tertiary">
|
||||
{formatDuration(run.usage.executionTimeMs)}
|
||||
</span>
|
||||
)}
|
||||
{run.usage.totalTokens && (
|
||||
<span className="text-sm text-foreground-tertiary">
|
||||
{run.usage.totalTokens.toLocaleString()} tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon={expandedRunId === run.id ? 'chevronDown' : 'chevronRight'}
|
||||
className="w-5 h-5 text-foreground-tertiary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Run Details (Expanded) */}
|
||||
{expandedRunId === run.id && (
|
||||
<div className="px-4 pb-4 border-t border-border/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{/* Evaluator */}
|
||||
{run.evaluator.verdict && (
|
||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
LLM Evaluation
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<StatusBadge status={run.evaluator.verdict} />
|
||||
{run.evaluator.model && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{run.evaluator.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{run.evaluator.reason && (
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
{run.evaluator.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Stats */}
|
||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Usage
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-foreground-tertiary">Duration:</span>{' '}
|
||||
<span className="text-foreground">
|
||||
{formatDuration(run.usage.executionTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-foreground-tertiary">Tokens:</span>{' '}
|
||||
<span className="text-foreground">
|
||||
{run.usage.totalTokens?.toLocaleString() || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-foreground-tertiary">Retries:</span>{' '}
|
||||
<span className="text-foreground">{run.retryCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output (if owner) */}
|
||||
{run.output && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Output
|
||||
</h4>
|
||||
<pre className="p-3 bg-surface-secondary rounded-lg text-sm text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||
{run.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Log (if owner and error) */}
|
||||
{run.errorLog && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-error uppercase tracking-wide mb-2">
|
||||
Error Log
|
||||
</h4>
|
||||
<pre className="p-3 bg-error/5 border border-error/20 rounded-lg text-sm text-error overflow-x-auto whitespace-pre-wrap">
|
||||
{run.errorLog}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db';
|
|||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
// import { ArchitectureDiagramWrapper } from '../components/home/ArchitectureDiagramWrapper';
|
||||
|
|
@ -13,41 +14,89 @@ export const dynamic = 'force-dynamic';
|
|||
async function getHomePageData() {
|
||||
try {
|
||||
// Fetch stats in parallel
|
||||
const [packageCount, toolCount, featuredTools, categoryStats] = await Promise.all([
|
||||
// Total package count
|
||||
prisma.package.count(),
|
||||
const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] =
|
||||
await Promise.all([
|
||||
// Total package count
|
||||
prisma.package.count(),
|
||||
|
||||
// Total tool count
|
||||
prisma.tool.count(),
|
||||
// Total tool count
|
||||
prisma.tool.count(),
|
||||
|
||||
// Top 6 featured tools by quality score
|
||||
prisma.tool.findMany({
|
||||
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
|
||||
take: 6,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
qualityScore: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
isOfficial: true,
|
||||
// Top 6 featured tools by quality score
|
||||
prisma.tool.findMany({
|
||||
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
|
||||
take: 6,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
qualityScore: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
isOfficial: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
// Category distribution for stats (group by package category)
|
||||
prisma.package.groupBy({
|
||||
by: ['category'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// Category distribution for stats (group by package category)
|
||||
prisma.package.groupBy({
|
||||
by: ['category'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Featured scenarios - mix of high quality, diverse, and fresh
|
||||
(async () => {
|
||||
// Get high quality scenarios
|
||||
const highQuality = await prisma.scenario.findMany({
|
||||
where: {
|
||||
collection: { isPublic: true },
|
||||
qualityScore: { gte: 0.3 },
|
||||
totalRuns: { gte: 1 },
|
||||
},
|
||||
orderBy: { qualityScore: 'desc' },
|
||||
take: 3,
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get fresh scenarios (excluding already selected)
|
||||
const seenIds = new Set(highQuality.map((s) => s.id));
|
||||
const fresh = await prisma.scenario.findMany({
|
||||
where: {
|
||||
collection: { isPublic: true },
|
||||
id: { notIn: Array.from(seenIds) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return [...highQuality, ...fresh].slice(0, 6);
|
||||
})(),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats: {
|
||||
|
|
@ -60,6 +109,7 @@ async function getHomePageData() {
|
|||
name: c.category,
|
||||
count: c._count._all,
|
||||
})),
|
||||
featuredScenarios,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch homepage data:', error);
|
||||
|
|
@ -71,6 +121,7 @@ async function getHomePageData() {
|
|||
},
|
||||
featuredTools: [],
|
||||
categories: [],
|
||||
featuredScenarios: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -192,6 +243,117 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Featured Scenarios Section */}
|
||||
{data.featuredScenarios.length > 0 && (
|
||||
<section className="py-16 bg-surface border-t border-border">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-foreground">
|
||||
Test Scenarios
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto mb-8">
|
||||
See how tool collections are tested with AI-generated scenarios. Real execution,
|
||||
real results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 mb-12">
|
||||
{data.featuredScenarios.map((scenario) => {
|
||||
const qualityPercent = Math.round(scenario.qualityScore * 100);
|
||||
const qualityColor =
|
||||
qualityPercent >= 70
|
||||
? 'text-success'
|
||||
: qualityPercent >= 40
|
||||
? 'text-warning'
|
||||
: 'text-foreground-tertiary';
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={scenario.id}
|
||||
href={
|
||||
scenario.collection
|
||||
? `/@${scenario.collection.user.username}/collections/${scenario.collection.slug}/scenarios/${scenario.id}`
|
||||
: `/scenarios/${scenario.id}`
|
||||
}
|
||||
className="group"
|
||||
>
|
||||
<div className="p-6 border border-border rounded-lg bg-background hover:border-foreground transition-colors h-full flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<h3 className="text-lg font-semibold text-foreground group-hover:text-brutalist-accent transition-colors min-w-0 break-words line-clamp-1">
|
||||
{scenario.name ||
|
||||
(scenario.prompt.length > 50
|
||||
? `${scenario.prompt.slice(0, 50)}...`
|
||||
: scenario.prompt)}
|
||||
</h3>
|
||||
{scenario.lastRunStatus === 'pass' && (
|
||||
<Badge
|
||||
size="sm"
|
||||
className="flex-shrink-0 bg-success/10 text-success border-success/20"
|
||||
>
|
||||
<Icon icon="check" className="w-3 h-3 mr-1" />
|
||||
Pass
|
||||
</Badge>
|
||||
)}
|
||||
{scenario.lastRunStatus === 'fail' && (
|
||||
<Badge
|
||||
size="sm"
|
||||
className="flex-shrink-0 bg-error/10 text-error border-error/20"
|
||||
>
|
||||
<Icon icon="x" className="w-3 h-3 mr-1" />
|
||||
Fail
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground-secondary mb-4 flex-1 line-clamp-2">
|
||||
{scenario.prompt}
|
||||
</p>
|
||||
|
||||
{scenario.collection && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Badge variant="outline" size="sm">
|
||||
{scenario.collection.name}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
by @{scenario.collection.user.username}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenario.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{scenario.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" size="sm" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
|
||||
<span className={`flex items-center gap-1 ${qualityColor}`}>
|
||||
<Icon icon="star" className="w-3.5 h-3.5" />
|
||||
{qualityPercent}% quality
|
||||
</span>
|
||||
<span>{scenario.totalRuns} runs</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Link href="/scenarios">
|
||||
<Button size="lg" variant="outline">
|
||||
Browse All Scenarios
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Integration Section */}
|
||||
<section className="py-20 bg-surface border-y border-border">
|
||||
<Container size="xl" padding="lg">
|
||||
|
|
@ -243,9 +405,7 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
<div className="font-mono text-lg font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
Windsurf
|
||||
</div>
|
||||
<p className="font-mono text-xs text-foreground-tertiary mt-1">
|
||||
agentic ide
|
||||
</p>
|
||||
<p className="font-mono text-xs text-foreground-tertiary mt-1">agentic ide</p>
|
||||
</div>
|
||||
<div className="group p-6 border border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all">
|
||||
<div className="w-10 h-10 bg-primary/10 flex items-center justify-center mb-4">
|
||||
|
|
@ -273,21 +433,35 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
<div className="bg-background border border-border p-4 font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-foreground">
|
||||
<span className="text-foreground-tertiary">{'{'}</span>
|
||||
{'\n '}<span className="text-primary">"mcpServers"</span>: <span className="text-foreground-tertiary">{'{'}</span>
|
||||
{'\n '}<span className="text-primary">"tpmjs"</span>: <span className="text-foreground-tertiary">{'{'}</span>
|
||||
{'\n '}<span className="text-primary">"command"</span>: <span className="text-success">"npx"</span>,
|
||||
{'\n '}<span className="text-primary">"args"</span>: [<span className="text-success">"-y"</span>, <span className="text-success">"@anthropic/mcp-remote"</span>,
|
||||
{'\n '}<span className="text-success">"https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"</span>]
|
||||
{'\n '}<span className="text-foreground-tertiary">{'}'}</span>
|
||||
{'\n '}<span className="text-foreground-tertiary">{'}'}</span>
|
||||
{'\n'}<span className="text-foreground-tertiary">{'}'}</span>
|
||||
{'\n '}
|
||||
<span className="text-primary">"mcpServers"</span>:{' '}
|
||||
<span className="text-foreground-tertiary">{'{'}</span>
|
||||
{'\n '}
|
||||
<span className="text-primary">"tpmjs"</span>:{' '}
|
||||
<span className="text-foreground-tertiary">{'{'}</span>
|
||||
{'\n '}
|
||||
<span className="text-primary">"command"</span>:{' '}
|
||||
<span className="text-success">"npx"</span>,{'\n '}
|
||||
<span className="text-primary">"args"</span>: [
|
||||
<span className="text-success">"-y"</span>,{' '}
|
||||
<span className="text-success">"@anthropic/mcp-remote"</span>,{'\n '}
|
||||
<span className="text-success">
|
||||
"https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"
|
||||
</span>
|
||||
]{'\n '}
|
||||
<span className="text-foreground-tertiary">{'}'}</span>
|
||||
{'\n '}
|
||||
<span className="text-foreground-tertiary">{'}'}</span>
|
||||
{'\n'}
|
||||
<span className="text-foreground-tertiary">{'}'}</span>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-dashed border-border">
|
||||
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
|
||||
<p className="font-mono text-xs text-foreground-secondary">
|
||||
add to config → instant access to <span className="text-primary font-medium">170+ tools</span>
|
||||
add to config → instant access to{' '}
|
||||
<span className="text-primary font-medium">170+ tools</span>
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
468
apps/web/src/app/scenarios/[id]/page.tsx
Normal file
468
apps/web/src/app/scenarios/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { notFound, useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface ScenarioRun {
|
||||
id: string;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
evaluator: {
|
||||
model: string | null;
|
||||
verdict: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
assertions: { passed: string[]; failed: string[] } | null;
|
||||
usage: {
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
executionTimeMs: number | null;
|
||||
estimatedCost: number | null;
|
||||
};
|
||||
timestamps: {
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
output?: string;
|
||||
errorLog?: string;
|
||||
conversation?: unknown[];
|
||||
}
|
||||
|
||||
interface ScenarioDetail {
|
||||
id: string;
|
||||
collectionId: string | null;
|
||||
prompt: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
qualityScore: number;
|
||||
consecutivePasses: number;
|
||||
consecutiveFails: number;
|
||||
totalRuns: number;
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isOwner: boolean;
|
||||
collection: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
username: string;
|
||||
} | null;
|
||||
recentRuns: ScenarioRun[];
|
||||
runCount: number;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) {
|
||||
return <Badge variant="secondary">Not run</Badge>;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return (
|
||||
<Badge className="bg-success/10 text-success border-success/20">
|
||||
<Icon icon="check" className="w-3.5 h-3.5 mr-1" />
|
||||
Pass
|
||||
</Badge>
|
||||
);
|
||||
case 'fail':
|
||||
return (
|
||||
<Badge className="bg-error/10 text-error border-error/20">
|
||||
<Icon icon="x" className="w-3.5 h-3.5 mr-1" />
|
||||
Fail
|
||||
</Badge>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Badge className="bg-warning/10 text-warning border-warning/20">
|
||||
<Icon icon="alertTriangle" className="w-3.5 h-3.5 mr-1" />
|
||||
Error
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20">
|
||||
<Icon icon="loader" className="w-3.5 h-3.5 mr-1 animate-spin" />
|
||||
Running
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Icon icon="clock" className="w-3.5 h-3.5 mr-1" />
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function QualityIndicator({ score, showLabel = true }: { score: number; showLabel?: boolean }) {
|
||||
const percentage = Math.round(score * 100);
|
||||
const color =
|
||||
percentage >= 70
|
||||
? 'text-success'
|
||||
: percentage >= 40
|
||||
? 'text-warning'
|
||||
: 'text-foreground-tertiary';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={`Quality score: ${percentage}%`}>
|
||||
<Icon icon="star" className={`w-4 h-4 ${color}`} />
|
||||
<span className={`font-medium ${color}`}>{percentage}%</span>
|
||||
{showLabel && <span className="text-foreground-tertiary">quality</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (!ms) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ScenarioDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const scenarioId = params.id as string;
|
||||
|
||||
const [scenario, setScenario] = useState<ScenarioDetail | null>(null);
|
||||
const [runs, setRuns] = useState<ScenarioRun[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
||||
|
||||
const fetchScenario = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/scenarios/${scenarioId}?runsLimit=50`);
|
||||
if (response.status === 404) {
|
||||
setError('not_found');
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setScenario(data.data);
|
||||
setRuns(data.data.recentRuns || []);
|
||||
} else {
|
||||
setError(data.error?.message || 'Failed to load scenario');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load scenario');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scenarioId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchScenario();
|
||||
}, [fetchScenario]);
|
||||
|
||||
const handleRunScenario = async () => {
|
||||
setIsRunning(true);
|
||||
setRunError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scenarios/${scenarioId}/run`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const data = await response.json();
|
||||
setRunError(`Rate limited. Try again in ${Math.ceil(data.retryAfter / 60)} minute(s).`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error?.message || 'Failed to run scenario');
|
||||
}
|
||||
|
||||
// Refresh to show updated status
|
||||
await fetchScenario();
|
||||
} catch (err) {
|
||||
setRunError(err instanceof Error ? err.message : 'Failed to run scenario');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error === 'not_found') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-error">{error}</p>
|
||||
</div>
|
||||
) : scenario ? (
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-foreground-secondary">
|
||||
<Link href="/scenarios" className="hover:text-foreground transition-colors">
|
||||
Scenarios
|
||||
</Link>
|
||||
{scenario.collection && (
|
||||
<>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<Link
|
||||
href={`/@${scenario.collection.username}/collections/${scenario.collection.slug}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{scenario.collection.name}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<span className="text-foreground">{scenario.name || 'Scenario'}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{scenario.name || 'Unnamed Scenario'}
|
||||
</h1>
|
||||
{scenario.description && (
|
||||
<p className="text-foreground-secondary mt-2">{scenario.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{scenario.isOwner && (
|
||||
<Button onClick={handleRunScenario} disabled={isRunning}>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Icon icon="loader" className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="arrowRight" className="w-4 h-4 mr-1.5" />
|
||||
Run Scenario
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runError && (
|
||||
<div className="p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error">
|
||||
{runError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<h2 className="text-sm font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Prompt
|
||||
</h2>
|
||||
<p className="text-foreground whitespace-pre-wrap">{scenario.prompt}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Quality</div>
|
||||
<QualityIndicator score={scenario.qualityScore} showLabel={false} />
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Total Runs</div>
|
||||
<div className="text-xl font-semibold text-foreground">{scenario.totalRuns}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Pass Streak</div>
|
||||
<div className="text-xl font-semibold text-success flex items-center gap-1">
|
||||
<Icon icon="check" className="w-5 h-5" />
|
||||
{scenario.consecutivePasses}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Fail Streak</div>
|
||||
<div className="text-xl font-semibold text-error flex items-center gap-1">
|
||||
<Icon icon="x" className="w-5 h-5" />
|
||||
{scenario.consecutiveFails}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="text-sm text-foreground-secondary mb-1">Last Status</div>
|
||||
<StatusBadge status={scenario.lastRunStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{scenario.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scenario.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run History */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Run History</h2>
|
||||
|
||||
{runs.length === 0 ? (
|
||||
<div className="p-8 bg-surface border border-border rounded-xl text-center">
|
||||
<Icon icon="clock" className="w-8 h-8 mx-auto text-foreground-tertiary mb-3" />
|
||||
<p className="text-foreground-secondary">No runs yet</p>
|
||||
{scenario.isOwner && (
|
||||
<Button className="mt-4" onClick={handleRunScenario} disabled={isRunning}>
|
||||
Run Scenario
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="bg-surface border border-border rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Run Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedRunId(expandedRunId === run.id ? null : run.id)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="text-sm text-foreground-secondary">
|
||||
{formatDate(run.timestamps.createdAt)}
|
||||
</span>
|
||||
{run.usage.executionTimeMs && (
|
||||
<span className="text-sm text-foreground-tertiary">
|
||||
{formatDuration(run.usage.executionTimeMs)}
|
||||
</span>
|
||||
)}
|
||||
{run.usage.totalTokens && (
|
||||
<span className="text-sm text-foreground-tertiary">
|
||||
{run.usage.totalTokens.toLocaleString()} tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon={expandedRunId === run.id ? 'chevronDown' : 'chevronRight'}
|
||||
className="w-5 h-5 text-foreground-tertiary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Run Details (Expanded) */}
|
||||
{expandedRunId === run.id && (
|
||||
<div className="px-4 pb-4 border-t border-border/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{/* Evaluator */}
|
||||
{run.evaluator.verdict && (
|
||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
LLM Evaluation
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<StatusBadge status={run.evaluator.verdict} />
|
||||
{run.evaluator.model && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{run.evaluator.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{run.evaluator.reason && (
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
{run.evaluator.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Stats */}
|
||||
<div className="p-3 bg-surface-secondary rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Usage
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-foreground-tertiary">Duration:</span>{' '}
|
||||
<span className="text-foreground">
|
||||
{formatDuration(run.usage.executionTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-foreground-tertiary">Tokens:</span>{' '}
|
||||
<span className="text-foreground">
|
||||
{run.usage.totalTokens?.toLocaleString() || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-foreground-tertiary">Retries:</span>{' '}
|
||||
<span className="text-foreground">{run.retryCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output (if owner) */}
|
||||
{run.output && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Output
|
||||
</h4>
|
||||
<pre className="p-3 bg-surface-secondary rounded-lg text-sm text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||
{run.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Log (if owner and error) */}
|
||||
{run.errorLog && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-error uppercase tracking-wide mb-2">
|
||||
Error Log
|
||||
</h4>
|
||||
<pre className="p-3 bg-error/5 border border-error/20 rounded-lg text-sm text-error overflow-x-auto whitespace-pre-wrap">
|
||||
{run.errorLog}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
apps/web/src/app/scenarios/page.tsx
Normal file
297
apps/web/src/app/scenarios/page.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
|
||||
import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { type PublicScenario, useScenarios } from '~/hooks/useScenarios';
|
||||
|
||||
type SortOption = 'qualityScore' | 'totalRuns' | 'createdAt' | 'lastRunAt';
|
||||
|
||||
function sortScenarios(scenarios: PublicScenario[], sortBy: SortOption): PublicScenario[] {
|
||||
return [...scenarios].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'qualityScore':
|
||||
return b.qualityScore - a.qualityScore;
|
||||
case 'totalRuns':
|
||||
return b.totalRuns - a.totalRuns;
|
||||
case 'createdAt':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
case 'lastRunAt': {
|
||||
const aTime = a.lastRunAt ? new Date(a.lastRunAt).getTime() : 0;
|
||||
const bTime = b.lastRunAt ? new Date(b.lastRunAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
Not run
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return (
|
||||
<Badge size="sm" className="bg-success/10 text-success border-success/20">
|
||||
<Icon icon="check" size="xs" className="mr-1" />
|
||||
Pass
|
||||
</Badge>
|
||||
);
|
||||
case 'fail':
|
||||
return (
|
||||
<Badge size="sm" className="bg-error/10 text-error border-error/20">
|
||||
<Icon icon="x" size="xs" className="mr-1" />
|
||||
Fail
|
||||
</Badge>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Badge size="sm" className="bg-warning/10 text-warning border-warning/20">
|
||||
<Icon icon="alertTriangle" size="xs" className="mr-1" />
|
||||
Error
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function QualityScore({ score }: { score: number }) {
|
||||
const percentage = Math.round(score * 100);
|
||||
const color =
|
||||
percentage >= 70
|
||||
? 'text-success'
|
||||
: percentage >= 40
|
||||
? 'text-warning'
|
||||
: 'text-foreground-tertiary';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1" title={`Quality score: ${percentage}%`}>
|
||||
<Icon icon="star" size="xs" className={color} />
|
||||
<span className={`text-sm ${color}`}>{percentage}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScenariosExplorerPage(): React.ReactElement {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortOption>('qualityScore');
|
||||
|
||||
// Fetch scenarios using SWR
|
||||
const { data, isLoading, error: swrError, mutate } = useScenarios({ sortBy: sort });
|
||||
|
||||
const scenarios = data?.scenarios ?? [];
|
||||
const hasMore = data?.pagination.hasMore ?? false;
|
||||
const error = swrError?.message ?? null;
|
||||
|
||||
// Filter and sort scenarios (client-side search)
|
||||
const filteredScenarios = useMemo(() => {
|
||||
let result = scenarios;
|
||||
|
||||
if (search) {
|
||||
const query = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(s: PublicScenario) =>
|
||||
s.prompt.toLowerCase().includes(query) ||
|
||||
s.name?.toLowerCase().includes(query) ||
|
||||
s.tags.some((tag) => tag.toLowerCase().includes(query)) ||
|
||||
s.collection?.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return sortScenarios(result, sort);
|
||||
}, [scenarios, search, sort]);
|
||||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[300px]">Scenario</th>
|
||||
<th className="px-4 py-3 w-[200px]">Collection</th>
|
||||
<th className="px-4 py-3 w-[100px] text-center">Quality</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Runs</th>
|
||||
<th className="px-4 py-3 w-[100px] text-center">Status</th>
|
||||
<th className="px-4 py-3 w-[150px]">Tags</th>
|
||||
</tr>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const TableRow = useCallback((_index: number, scenario: PublicScenario) => {
|
||||
const detailUrl = scenario.collection
|
||||
? `/@${scenario.collection.username}/collections/${scenario.collection.slug}/scenarios/${scenario.id}`
|
||||
: `/scenarios/${scenario.id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={detailUrl} className="block group">
|
||||
<div className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{scenario.name || truncateText(scenario.prompt, 50)}
|
||||
</div>
|
||||
{scenario.name && (
|
||||
<div className="text-sm text-foreground-secondary mt-0.5 line-clamp-1">
|
||||
{truncateText(scenario.prompt, 80)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{scenario.collection ? (
|
||||
<Link
|
||||
href={`/@${scenario.collection.username}/collections/${scenario.collection.slug}`}
|
||||
className="text-sm text-foreground-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
{scenario.collection.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-foreground-tertiary">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<QualityScore score={scenario.qualityScore} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{scenario.totalRuns}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<StatusBadge status={scenario.lastRunStatus} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{scenario.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" size="sm" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{scenario.tags.length > 2 && (
|
||||
<Badge variant="secondary" size="sm" className="text-xs">
|
||||
+{scenario.tags.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<PageHeader
|
||||
title="Test Scenarios"
|
||||
description="Explore AI-generated test scenarios that validate tool collections"
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search scenarios..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground-secondary">Sort:</span>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
||||
options={[
|
||||
{ value: 'qualityScore', label: 'Highest Quality' },
|
||||
{ value: 'totalRuns', label: 'Most Runs' },
|
||||
{ value: 'createdAt', label: 'Most Recent' },
|
||||
{ value: 'lastRunAt', label: 'Recently Run' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<ErrorState message={error} onRetry={() => mutate()} />
|
||||
) : isLoading ? (
|
||||
<LoadingState message="Loading scenarios..." size="lg" />
|
||||
) : filteredScenarios.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="terminal"
|
||||
title="No scenarios found"
|
||||
description={
|
||||
search
|
||||
? 'Try adjusting your search terms'
|
||||
: 'No test scenarios have been created yet. Create a collection and generate scenarios!'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<TableVirtuoso
|
||||
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
|
||||
data={filteredScenarios}
|
||||
overscan={30}
|
||||
fixedHeaderContent={TableHeader}
|
||||
itemContent={TableRow}
|
||||
components={{
|
||||
Table: (props) => (
|
||||
<table
|
||||
{...props}
|
||||
className="w-full border-collapse text-sm"
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
/>
|
||||
),
|
||||
TableHead: (props) => (
|
||||
<thead {...props} className="bg-surface-secondary sticky top-0 z-10" />
|
||||
),
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border bg-surface hover:bg-surface-secondary transition-all duration-150 group"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-foreground-tertiary">
|
||||
Showing {filteredScenarios.length} scenario
|
||||
{filteredScenarios.length !== 1 ? 's' : ''}
|
||||
{search && ` matching "${search}"`}
|
||||
{hasMore && ' (scroll for more)'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/hooks/useScenarios.ts
Normal file
86
apps/web/src/hooks/useScenarios.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
export interface PublicScenario {
|
||||
id: string;
|
||||
collectionId: string | null;
|
||||
prompt: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
qualityScore: number;
|
||||
totalRuns: number;
|
||||
lastRunAt: string | null;
|
||||
lastRunStatus: string | null;
|
||||
createdAt: string;
|
||||
collection: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
username: string;
|
||||
} | null;
|
||||
runCount: number;
|
||||
}
|
||||
|
||||
export interface UseScenariosParams {
|
||||
sortBy?: 'qualityScore' | 'totalRuns' | 'createdAt' | 'lastRunAt';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface ScenariosResponse {
|
||||
scenarios: PublicScenario[];
|
||||
pagination: {
|
||||
hasMore: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public scenarios
|
||||
*/
|
||||
export function useScenarios(params: UseScenariosParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('limit', String(params.limit ?? 100));
|
||||
searchParams.set('offset', String(params.offset ?? 0));
|
||||
if (params.sortBy) {
|
||||
searchParams.set('sortBy', params.sortBy);
|
||||
}
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
searchParams.set('tags', params.tags.join(','));
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return useSWR<ScenariosResponse>(`/api/scenarios?${queryString}`, async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message || 'Failed to fetch scenarios');
|
||||
}
|
||||
|
||||
return {
|
||||
scenarios: json.data,
|
||||
pagination: json.pagination,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured scenarios for homepage
|
||||
*/
|
||||
export function useFeaturedScenarios(limit = 6) {
|
||||
return useSWR<PublicScenario[]>(`/api/scenarios/featured?limit=${limit}`, async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message || 'Failed to fetch featured scenarios');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
});
|
||||
}
|
||||
1789
packages/cli/oclif.manifest.json
Normal file
1789
packages/cli/oclif.manifest.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue