Backend: - ConversationTracker: Protocol + PostgresConversationTracker for lifecycle tracking - Error handler: ErrorCategory enum, classify_error(), with_retry() exponential backoff - Wire PostgresAnalyticsRecorder + ConversationTracker into ws_handler - Rate limiting (10 msg/10s per thread), edge case hardening - Health endpoint GET /api/health, version 0.5.0 - Demo seed data script + sample OpenAPI spec Frontend (all new): - React Router with NavBar (Chat / Replay / Dashboard / Review) - ReplayListPage + ReplayPage with ReplayTimeline component - DashboardPage with MetricCard, range selector, zero-state - ReviewPage for OpenAPI classification review - ErrorBanner for WebSocket disconnect handling - API client (api.ts) with typed fetch wrappers Infrastructure: - Frontend Dockerfile (multi-stage node -> nginx) - nginx.conf with SPA routing + API/WS proxy - docker-compose.yml with frontend service + healthchecks - .env.example files (root + backend) Documentation: - README.md with quick start and architecture - Agent configuration guide - OpenAPI import guide - Deployment guide - Demo script 48 new tests, 449 total passing, 92.87% coverage
145 lines
3.6 KiB
TypeScript
145 lines
3.6 KiB
TypeScript
import { useState } from "react";
|
|
import type { ReplayStep } from "../api";
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
message: "#1976d2",
|
|
token: "#388e3c",
|
|
tool_call: "#f57c00",
|
|
tool_result: "#7b1fa2",
|
|
interrupt: "#d32f2f",
|
|
interrupt_response: "#c2185b",
|
|
error: "#c62828",
|
|
};
|
|
|
|
function TypeBadge({ type }: { type: string }) {
|
|
const color = TYPE_COLORS[type] ?? "#555";
|
|
return (
|
|
<span
|
|
style={{
|
|
background: color,
|
|
color: "#fff",
|
|
fontSize: "11px",
|
|
fontWeight: 600,
|
|
padding: "2px 7px",
|
|
borderRadius: "10px",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
}}
|
|
>
|
|
{type}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ReplayStepItem({ step }: { step: ReplayStep }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const hasDetails = step.params != null || step.result != null;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
borderLeft: "2px solid #e0e0e0",
|
|
paddingLeft: "12px",
|
|
marginBottom: "12px",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: "-5px",
|
|
top: "4px",
|
|
width: "8px",
|
|
height: "8px",
|
|
borderRadius: "50%",
|
|
background: TYPE_COLORS[step.type] ?? "#555",
|
|
}}
|
|
/>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
|
<span style={{ fontSize: "11px", color: "#888" }}>#{step.step}</span>
|
|
<TypeBadge type={step.type} />
|
|
{step.agent && (
|
|
<span style={{ fontSize: "11px", color: "#666", fontStyle: "italic" }}>
|
|
{step.agent}
|
|
</span>
|
|
)}
|
|
{step.tool && (
|
|
<span style={{ fontSize: "11px", color: "#555" }}>
|
|
tool: <strong>{step.tool}</strong>
|
|
</span>
|
|
)}
|
|
<span style={{ fontSize: "11px", color: "#aaa", marginLeft: "auto" }}>
|
|
{new Date(step.timestamp).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
{step.content && (
|
|
<div
|
|
style={{
|
|
fontSize: "13px",
|
|
color: "#333",
|
|
background: "#f9f9f9",
|
|
padding: "6px 10px",
|
|
borderRadius: "4px",
|
|
maxHeight: "80px",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{step.content}
|
|
</div>
|
|
)}
|
|
{hasDetails && (
|
|
<button
|
|
onClick={() => setExpanded((v) => !v)}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#1976d2",
|
|
cursor: "pointer",
|
|
fontSize: "12px",
|
|
padding: "2px 0",
|
|
}}
|
|
>
|
|
{expanded ? "Hide details" : "Show details"}
|
|
</button>
|
|
)}
|
|
{expanded && hasDetails && (
|
|
<pre
|
|
style={{
|
|
fontSize: "11px",
|
|
background: "#f3f3f3",
|
|
padding: "8px",
|
|
borderRadius: "4px",
|
|
overflow: "auto",
|
|
maxHeight: "200px",
|
|
}}
|
|
>
|
|
{JSON.stringify({ params: step.params, result: step.result }, null, 2)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ReplayTimelineProps {
|
|
steps: ReplayStep[];
|
|
}
|
|
|
|
export function ReplayTimeline({ steps }: ReplayTimelineProps) {
|
|
if (steps.length === 0) {
|
|
return (
|
|
<div style={{ color: "#888", fontSize: "14px", padding: "16px 0" }}>
|
|
No steps recorded.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: "8px 0" }}>
|
|
{steps.map((step) => (
|
|
<ReplayStepItem key={step.step} step={step} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|