feat: complete phase 5 -- error hardening, frontend, Docker, demo, docs

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
This commit is contained in:
Yaojia Wang
2026-03-31 21:20:06 +02:00
parent 38644594d2
commit 0e78e5b06b
44 changed files with 3397 additions and 169 deletions

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

23
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
@@ -1318,6 +1319,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1601,6 +1615,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rollup": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
@@ -1662,6 +1714,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -10,7 +10,8 @@
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.2"
},
"devDependencies": {
"@types/react": "^19.0.0",

View File

@@ -1,5 +1,23 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { ChatPage } from "./pages/ChatPage";
import { DashboardPage } from "./pages/DashboardPage";
import { ReplayListPage } from "./pages/ReplayListPage";
import { ReplayPage } from "./pages/ReplayPage";
import { ReviewPage } from "./pages/ReviewPage";
export default function App() {
return <ChatPage />;
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<ChatPage />} />
<Route path="/replay" element={<ReplayListPage />} />
<Route path="/replay/:threadId" element={<ReplayPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/review" element={<ReviewPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}

108
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,108 @@
/** Typed fetch wrappers for the Smart Support REST API. */
const API_BASE = "";
export interface ApiResponse<T> {
success: boolean;
data: T;
error: string | null;
}
export interface ConversationSummary {
thread_id: string;
started_at: string;
last_activity: string;
turn_count: number;
agents_used: string[];
total_tokens: number;
total_cost_usd: number;
resolution_type: string | null;
}
export interface ConversationsPage {
conversations: ConversationSummary[];
total: number;
page: number;
per_page: number;
}
export interface ReplayStep {
step: number;
type: string;
content: string | null;
agent: string | null;
tool: string | null;
params: Record<string, unknown> | null;
result: unknown;
timestamp: string;
}
export interface ReplayPage {
thread_id: string;
steps: ReplayStep[];
total: number;
page: number;
per_page: number;
}
export interface AgentUsage {
agent_name: string;
message_count: number;
total_tokens: number;
total_cost_usd: number;
}
export interface InterruptStats {
total: number;
approved: number;
rejected: number;
expired: number;
}
export interface AnalyticsData {
total_conversations: number;
resolved_conversations: number;
escalated_conversations: number;
resolution_rate: number;
escalation_rate: number;
total_tokens: number;
total_cost_usd: number;
avg_turns_per_conversation: number;
agent_usage: AgentUsage[];
interrupt_stats: InterruptStats;
}
async function apiFetch<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`);
if (!res.ok) {
throw new Error(`API error ${res.status}: ${res.statusText}`);
}
const json: ApiResponse<T> = await res.json();
if (!json.success) {
throw new Error(json.error ?? "Unknown API error");
}
return json.data;
}
export async function fetchConversations(
page = 1,
perPage = 20
): Promise<ConversationsPage> {
return apiFetch<ConversationsPage>(
`/api/conversations?page=${page}&per_page=${perPage}`
);
}
export async function fetchReplay(
threadId: string,
page = 1,
perPage = 20
): Promise<ReplayPage> {
return apiFetch<ReplayPage>(
`/api/replay/${encodeURIComponent(threadId)}?page=${page}&per_page=${perPage}`
);
}
export async function fetchAnalytics(range = "7d"): Promise<AnalyticsData> {
return apiFetch<AnalyticsData>(`/api/analytics?range=${range}`);
}

View File

@@ -0,0 +1,49 @@
import type { ConnectionStatus } from "../types";
interface ErrorBannerProps {
status: ConnectionStatus;
onReconnect?: () => void;
}
export function ErrorBanner({ status, onReconnect }: ErrorBannerProps) {
if (status === "connected") return null;
const isConnecting = status === "connecting";
const bannerStyle: React.CSSProperties = {
background: isConnecting ? "#fff3e0" : "#ffebee",
color: isConnecting ? "#e65100" : "#c62828",
padding: "8px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "13px",
borderBottom: `1px solid ${isConnecting ? "#ffcc02" : "#ef9a9a"}`,
};
return (
<div style={bannerStyle} role="alert">
<span>
{isConnecting
? "Connecting to server..."
: "Disconnected from server. Retrying..."}
</span>
{!isConnecting && onReconnect && (
<button
onClick={onReconnect}
style={{
background: "none",
border: "1px solid currentColor",
color: "inherit",
padding: "2px 8px",
borderRadius: "4px",
cursor: "pointer",
fontSize: "12px",
}}
>
Reconnect
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom";
import { NavBar } from "./NavBar";
export function Layout() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<NavBar />
<main style={{ flex: 1, overflow: "auto" }}>
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,32 @@
interface MetricCardProps {
label: string;
value: string | number;
unit?: string;
suffix?: string;
}
export function MetricCard({ label, value, unit, suffix }: MetricCardProps) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e0e0e0",
borderRadius: "8px",
padding: "16px 20px",
minWidth: "140px",
boxShadow: "0 1px 3px rgba(0,0,0,0.06)",
}}
>
<div
style={{ fontSize: "12px", color: "#888", marginBottom: "8px", textTransform: "uppercase", letterSpacing: "0.5px" }}
>
{label}
</div>
<div style={{ fontSize: "28px", fontWeight: 700, color: "#1a1a1a" }}>
{unit && <span style={{ fontSize: "16px", color: "#555" }}>{unit}</span>}
{value}
{suffix && <span style={{ fontSize: "16px", color: "#555", marginLeft: "2px" }}>{suffix}</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { NavLink } from "react-router-dom";
const navLinks = [
{ to: "/", label: "Chat", exact: true },
{ to: "/replay", label: "Replay" },
{ to: "/dashboard", label: "Dashboard" },
{ to: "/review", label: "API Review" },
];
const styles: Record<string, React.CSSProperties> = {
nav: {
display: "flex",
alignItems: "center",
gap: "0",
padding: "0 16px",
borderBottom: "1px solid #e0e0e0",
background: "#fff",
height: "48px",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
},
brand: {
fontWeight: 700,
fontSize: "16px",
color: "#1a1a1a",
marginRight: "24px",
textDecoration: "none",
},
link: {
padding: "0 14px",
height: "48px",
display: "flex",
alignItems: "center",
fontSize: "14px",
color: "#555",
textDecoration: "none",
borderBottom: "2px solid transparent",
transition: "color 0.15s, border-color 0.15s",
},
activeLink: {
color: "#1976d2",
borderBottom: "2px solid #1976d2",
},
};
export function NavBar() {
return (
<nav style={styles.nav}>
<span style={styles.brand}>Smart Support</span>
{navLinks.map(({ to, label }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
style={({ isActive }) => ({
...styles.link,
...(isActive ? styles.activeLink : {}),
})}
>
{label}
</NavLink>
))}
</nav>
);
}

View File

@@ -0,0 +1,144 @@
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>
);
}

View File

@@ -21,13 +21,23 @@ function getOrCreateThreadId(): string {
return id;
}
export function useWebSocket(onMessage: (msg: ServerMessage) => void) {
interface UseWebSocketOptions {
onDisconnect?: () => void;
onReconnect?: () => void;
}
export function useWebSocket(
onMessage: (msg: ServerMessage) => void,
options?: UseWebSocketOptions
) {
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [threadId] = useState(getOrCreateThreadId);
const wsRef = useRef<WebSocket | null>(null);
const retriesRef = useRef(0);
const onMessageRef = useRef(onMessage);
const optionsRef = useRef(options);
onMessageRef.current = onMessage;
optionsRef.current = options;
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
@@ -38,6 +48,7 @@ export function useWebSocket(onMessage: (msg: ServerMessage) => void) {
ws.onopen = () => {
setStatus("connected");
retriesRef.current = 0;
optionsRef.current?.onReconnect?.();
};
ws.onmessage = (event) => {
@@ -52,6 +63,7 @@ export function useWebSocket(onMessage: (msg: ServerMessage) => void) {
ws.onclose = () => {
setStatus("disconnected");
wsRef.current = null;
optionsRef.current?.onDisconnect?.();
if (retriesRef.current < MAX_RETRIES) {
const delay = BASE_DELAY_MS * Math.pow(2, retriesRef.current);
@@ -74,6 +86,12 @@ export function useWebSocket(onMessage: (msg: ServerMessage) => void) {
};
}, [connect]);
const reconnect = useCallback(() => {
retriesRef.current = 0;
wsRef.current?.close();
connect();
}, [connect]);
const send = useCallback((msg: ClientMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
@@ -100,5 +118,5 @@ export function useWebSocket(onMessage: (msg: ServerMessage) => void) {
[send, threadId]
);
return { status, threadId, sendMessage, sendInterruptResponse };
return { status, threadId, sendMessage, sendInterruptResponse, reconnect };
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
import { AgentAction } from "../components/AgentAction";
import { ChatInput } from "../components/ChatInput";
import { ChatMessages } from "../components/ChatMessages";
import { ErrorBanner } from "../components/ErrorBanner";
import { InterruptPrompt } from "../components/InterruptPrompt";
import { useWebSocket } from "../hooks/useWebSocket";
import type {
@@ -95,7 +96,7 @@ export function ChatPage() {
}
}, []);
const { status, sendMessage, sendInterruptResponse } =
const { status, sendMessage, sendInterruptResponse, reconnect } =
useWebSocket(handleServerMessage);
const handleSend = useCallback(
@@ -130,6 +131,7 @@ export function ChatPage() {
<h1 style={styles.title}>Smart Support</h1>
<StatusIndicator status={status} />
</div>
<ErrorBanner status={status} onReconnect={reconnect} />
<ChatMessages messages={messages} />
{toolActions.length > 0 && (
<div style={styles.actionsBar}>

View File

@@ -0,0 +1,184 @@
import { useEffect, useState } from "react";
import { fetchAnalytics } from "../api";
import type { AnalyticsData } from "../api";
import { MetricCard } from "../components/MetricCard";
const RANGE_OPTIONS = [
{ value: "7d", label: "7 days" },
{ value: "14d", label: "14 days" },
{ value: "30d", label: "30 days" },
];
function pct(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatCost(usd: number): string {
return usd < 0.01 ? "<$0.01" : `$${usd.toFixed(3)}`;
}
export function DashboardPage() {
const [range, setRange] = useState("7d");
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetchAnalytics(range)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [range]);
return (
<div style={styles.container}>
<div style={styles.header}>
<h2 style={styles.heading}>Dashboard</h2>
<div style={styles.rangeSelector}>
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setRange(opt.value)}
style={{
...styles.rangeBtn,
...(range === opt.value ? styles.rangeBtnActive : {}),
}}
>
{opt.label}
</button>
))}
</div>
</div>
{loading && <div style={styles.center}>Loading analytics...</div>}
{error && <div style={styles.error}>Error: {error}</div>}
{!loading && !error && data && (
<>
{data.total_conversations === 0 ? (
<div style={styles.empty}>
No conversations yet. Start a chat to see analytics here.
</div>
) : (
<>
<div style={styles.metricsGrid}>
<MetricCard
label="Total Conversations"
value={data.total_conversations}
/>
<MetricCard
label="Resolution Rate"
value={pct(data.resolution_rate)}
/>
<MetricCard
label="Escalation Rate"
value={pct(data.escalation_rate)}
/>
<MetricCard
label="Avg Turns"
value={data.avg_turns_per_conversation.toFixed(1)}
/>
<MetricCard
label="Total Tokens"
value={data.total_tokens.toLocaleString()}
/>
<MetricCard
label="Total Cost"
value={formatCost(data.total_cost_usd)}
/>
</div>
<h3 style={styles.sectionHeading}>Agent Usage</h3>
{data.agent_usage.length === 0 ? (
<div style={styles.empty}>No agent data.</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Agent</th>
<th style={styles.th}>Messages</th>
<th style={styles.th}>Tokens</th>
<th style={styles.th}>Cost</th>
</tr>
</thead>
<tbody>
{data.agent_usage.map((a) => (
<tr key={a.agent_name}>
<td style={styles.td}>{a.agent_name}</td>
<td style={styles.td}>{a.message_count}</td>
<td style={styles.td}>{a.total_tokens.toLocaleString()}</td>
<td style={styles.td}>{formatCost(a.total_cost_usd)}</td>
</tr>
))}
</tbody>
</table>
)}
<h3 style={styles.sectionHeading}>Interrupt Stats</h3>
<div style={styles.metricsGrid}>
<MetricCard label="Total Interrupts" value={data.interrupt_stats.total} />
<MetricCard label="Approved" value={data.interrupt_stats.approved} />
<MetricCard label="Rejected" value={data.interrupt_stats.rejected} />
<MetricCard label="Expired" value={data.interrupt_stats.expired} />
</div>
</>
)}
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
},
heading: { fontSize: "20px", fontWeight: 700, margin: 0 },
rangeSelector: { display: "flex", gap: "4px" },
rangeBtn: {
padding: "5px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
color: "#555",
},
rangeBtnActive: {
background: "#1976d2",
color: "#fff",
borderColor: "#1976d2",
},
metricsGrid: {
display: "flex",
flexWrap: "wrap" as const,
gap: "12px",
marginBottom: "24px",
},
sectionHeading: {
fontSize: "15px",
fontWeight: 600,
marginBottom: "12px",
color: "#333",
},
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "24px" },
th: {
textAlign: "left",
padding: "8px 12px",
borderBottom: "2px solid #e0e0e0",
color: "#555",
fontWeight: 600,
textTransform: "uppercase",
fontSize: "11px",
},
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
empty: { color: "#888", fontSize: "14px", padding: "16px 0" },
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchConversations } from "../api";
import type { ConversationSummary } from "../api";
export function ReplayListPage() {
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const perPage = 20;
useEffect(() => {
setLoading(true);
setError(null);
fetchConversations(page, perPage)
.then((data) => {
setConversations(data.conversations);
setTotal(data.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page]);
if (loading) {
return <div style={styles.center}>Loading conversations...</div>;
}
if (error) {
return <div style={styles.error}>Error: {error}</div>;
}
const totalPages = Math.ceil(total / perPage);
return (
<div style={styles.container}>
<h2 style={styles.heading}>Conversations</h2>
{conversations.length === 0 ? (
<div style={styles.empty}>No conversations yet.</div>
) : (
<>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Thread ID</th>
<th style={styles.th}>Started</th>
<th style={styles.th}>Turns</th>
<th style={styles.th}>Agents</th>
<th style={styles.th}>Resolution</th>
</tr>
</thead>
<tbody>
{conversations.map((c) => (
<tr
key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)}
style={styles.row}
>
<td style={styles.td}>
<span style={styles.threadId}>{c.thread_id}</span>
</td>
<td style={styles.td}>
{new Date(c.started_at).toLocaleString()}
</td>
<td style={styles.td}>{c.turn_count}</td>
<td style={styles.td}>{c.agents_used.join(", ") || "—"}</td>
<td style={styles.td}>{c.resolution_type ?? "open"}</td>
</tr>
))}
</tbody>
</table>
<div style={styles.pagination}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={styles.pageBtn}
>
Previous
</button>
<span style={{ fontSize: "13px", color: "#555" }}>
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={styles.pageBtn}
>
Next
</button>
</div>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
empty: { color: "#888", fontSize: "14px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px" },
th: {
textAlign: "left",
padding: "8px 12px",
borderBottom: "2px solid #e0e0e0",
color: "#555",
fontWeight: 600,
textTransform: "uppercase",
fontSize: "11px",
letterSpacing: "0.5px",
},
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
row: { cursor: "pointer", transition: "background 0.1s" },
threadId: { fontFamily: "monospace", fontSize: "12px", color: "#1976d2" },
pagination: {
display: "flex",
alignItems: "center",
gap: "12px",
marginTop: "16px",
},
pageBtn: {
padding: "6px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
},
};

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { fetchReplay } from "../api";
import type { ReplayStep } from "../api";
import { ReplayTimeline } from "../components/ReplayTimeline";
export function ReplayPage() {
const { threadId } = useParams<{ threadId: string }>();
const [steps, setSteps] = useState<ReplayStep[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const perPage = 20;
useEffect(() => {
if (!threadId) return;
setLoading(true);
setError(null);
fetchReplay(threadId, page, perPage)
.then((data) => {
setSteps(data.steps);
setTotal(data.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [threadId, page]);
if (!threadId) {
return <div style={styles.error}>No thread ID provided.</div>;
}
const totalPages = Math.ceil(total / perPage);
return (
<div style={styles.container}>
<h2 style={styles.heading}>
Replay:{" "}
<span style={styles.threadId}>{threadId}</span>
</h2>
{loading && <div style={styles.center}>Loading replay...</div>}
{error && <div style={styles.error}>Error: {error}</div>}
{!loading && !error && <ReplayTimeline steps={steps} />}
{!loading && totalPages > 1 && (
<div style={styles.pagination}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={styles.pageBtn}
>
Previous
</button>
<span style={{ fontSize: "13px", color: "#555" }}>
Page {page} of {totalPages} ({total} steps)
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={styles.pageBtn}
>
Next
</button>
</div>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "800px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "20px" },
threadId: { fontFamily: "monospace", fontSize: "16px", color: "#1976d2" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
pagination: {
display: "flex",
alignItems: "center",
gap: "12px",
marginTop: "20px",
},
pageBtn: {
padding: "6px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
},
};

View File

@@ -0,0 +1,277 @@
import { useEffect, useRef, useState } from "react";
interface ImportJob {
job_id: string;
status: "pending" | "processing" | "done" | "error";
error?: string;
}
interface EndpointClassification {
path: string;
method: string;
summary: string;
access_type: string;
agent_group: string;
}
interface JobResult {
job_id: string;
status: string;
endpoints: EndpointClassification[];
}
export function ReviewPage() {
const [url, setUrl] = useState("");
const [job, setJob] = useState<ImportJob | null>(null);
const [result, setResult] = useState<JobResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [classifications, setClassifications] = useState<EndpointClassification[]>([]);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (pollRef.current) clearTimeout(pollRef.current);
};
}, []);
function pollJob(jobId: string) {
fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}`)
.then((r) => r.json())
.then((data) => {
const j: ImportJob = data.data ?? data;
setJob(j);
if (j.status === "done") {
return fetch(`/api/openapi/jobs/${encodeURIComponent(jobId)}/result`)
.then((r) => r.json())
.then((rdata) => {
const res: JobResult = rdata.data ?? rdata;
setResult(res);
setClassifications(res.endpoints ?? []);
});
} else if (j.status === "error") {
return;
} else {
pollRef.current = setTimeout(() => pollJob(jobId), 2000);
}
})
.catch(() => {
pollRef.current = setTimeout(() => pollJob(jobId), 3000);
});
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!url.trim()) return;
setSubmitting(true);
setSubmitError(null);
setJob(null);
setResult(null);
setClassifications([]);
fetch("/api/openapi/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
})
.then((r) => r.json())
.then((data) => {
const j: ImportJob = data.data ?? data;
setJob(j);
if (j.job_id) pollJob(j.job_id);
})
.catch((err: Error) => setSubmitError(err.message))
.finally(() => setSubmitting(false));
}
function handleFieldChange(
idx: number,
field: keyof EndpointClassification,
value: string
) {
setClassifications((prev) =>
prev.map((c, i) => (i === idx ? { ...c, [field]: value } : c))
);
}
function handleApprove() {
if (!job?.job_id) return;
fetch(`/api/openapi/jobs/${encodeURIComponent(job.job_id)}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoints: classifications }),
}).then(() => {
alert("Approved and saved.");
});
}
return (
<div style={styles.container}>
<h2 style={styles.heading}>OpenAPI Import & Review</h2>
<form onSubmit={handleSubmit} style={styles.form}>
<input
type="url"
placeholder="https://example.com/openapi.yaml"
value={url}
onChange={(e) => setUrl(e.target.value)}
style={styles.input}
required
/>
<button type="submit" disabled={submitting} style={styles.submitBtn}>
{submitting ? "Importing..." : "Import"}
</button>
</form>
{submitError && <div style={styles.error}>Error: {submitError}</div>}
{job && (
<div style={styles.statusBox}>
<strong>Job:</strong> {job.job_id} &mdash; Status:{" "}
<span
style={{
color:
job.status === "done"
? "#388e3c"
: job.status === "error"
? "#c62828"
: "#f57c00",
fontWeight: 600,
}}
>
{job.status}
</span>
{job.error && (
<div style={{ color: "#c62828", marginTop: "4px" }}>{job.error}</div>
)}
</div>
)}
{result && classifications.length > 0 && (
<>
<h3 style={styles.sectionHeading}>
Endpoint Classifications ({classifications.length})
</h3>
<p style={styles.hint}>
Review and edit the access_type and agent_group before approving.
</p>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Method</th>
<th style={styles.th}>Path</th>
<th style={styles.th}>Summary</th>
<th style={styles.th}>Access Type</th>
<th style={styles.th}>Agent Group</th>
</tr>
</thead>
<tbody>
{classifications.map((c, idx) => (
<tr key={`${c.method}-${c.path}`}>
<td style={styles.td}>
<span style={{ fontWeight: 600, fontSize: "11px" }}>
{c.method.toUpperCase()}
</span>
</td>
<td style={{ ...styles.td, fontFamily: "monospace", fontSize: "12px" }}>
{c.path}
</td>
<td style={styles.td}>{c.summary}</td>
<td style={styles.td}>
<select
value={c.access_type}
onChange={(e) => handleFieldChange(idx, "access_type", e.target.value)}
style={styles.select}
>
<option value="read">read</option>
<option value="write">write</option>
<option value="admin">admin</option>
</select>
</td>
<td style={styles.td}>
<input
type="text"
value={c.agent_group}
onChange={(e) => handleFieldChange(idx, "agent_group", e.target.value)}
style={styles.textInput}
/>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={handleApprove} style={styles.approveBtn}>
Approve & Save
</button>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
form: { display: "flex", gap: "8px", marginBottom: "16px" },
input: {
flex: 1,
padding: "8px 12px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
fontSize: "14px",
},
submitBtn: {
padding: "8px 20px",
background: "#1976d2",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
},
error: { color: "#c62828", marginBottom: "12px" },
statusBox: {
background: "#f9f9f9",
border: "1px solid #e0e0e0",
padding: "10px 14px",
borderRadius: "4px",
marginBottom: "16px",
fontSize: "13px",
},
sectionHeading: { fontSize: "15px", fontWeight: 600, marginBottom: "8px" },
hint: { fontSize: "12px", color: "#888", marginBottom: "12px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "16px" },
th: {
textAlign: "left",
padding: "8px 10px",
borderBottom: "2px solid #e0e0e0",
fontSize: "11px",
textTransform: "uppercase",
color: "#555",
},
td: { padding: "8px 10px", borderBottom: "1px solid #f0f0f0" },
select: {
padding: "3px 6px",
border: "1px solid #e0e0e0",
borderRadius: "3px",
fontSize: "12px",
},
textInput: {
padding: "3px 6px",
border: "1px solid #e0e0e0",
borderRadius: "3px",
fontSize: "12px",
width: "100%",
},
approveBtn: {
padding: "8px 20px",
background: "#388e3c",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
},
};

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/agentaction.tsx","./src/components/chatinput.tsx","./src/components/chatmessages.tsx","./src/components/interruptprompt.tsx","./src/hooks/usewebsocket.ts","./src/pages/chatpage.tsx"],"version":"5.7.3"}
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/components/agentaction.tsx","./src/components/chatinput.tsx","./src/components/chatmessages.tsx","./src/components/errorbanner.tsx","./src/components/interruptprompt.tsx","./src/components/layout.tsx","./src/components/metriccard.tsx","./src/components/navbar.tsx","./src/components/replaytimeline.tsx","./src/hooks/usewebsocket.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/replaylistpage.tsx","./src/pages/replaypage.tsx","./src/pages/reviewpage.tsx"],"version":"5.7.3"}

View File

@@ -7,9 +7,12 @@ export default defineConfig({
port: 5173,
proxy: {
"/ws": {
target: "ws://localhost:8000",
target: "http://localhost:8000",
ws: true,
},
"/api": {
target: "http://localhost:8000",
},
},
},
});