feat: complete phase 1 -- core framework with chat loop, agents, and React UI

Backend:
- FastAPI WebSocket /ws endpoint with streaming via LangGraph astream
- LangGraph Supervisor connecting 3 mock agents (order_lookup, order_actions, fallback)
- YAML Agent Registry with Pydantic validation and immutable configs
- PostgresSaver checkpoint persistence via langgraph-checkpoint-postgres
- Session TTL with 30-min sliding window and interrupt extension
- LLM provider abstraction (Anthropic/OpenAI/Google)
- Token usage + cost tracking callback handler
- Input validation: message size cap, thread_id format, content length
- Security: no hardcoded defaults, startup API key validation, no input reflection

Frontend:
- React 19 + TypeScript + Vite chat UI
- WebSocket hook with reconnect + exponential backoff
- Streaming token display with agent attribution
- Interrupt approval/reject UI for write operations
- Collapsible tool call viewer

Testing:
- 87 unit tests, 87% coverage (exceeds 80% requirement)
- Ruff lint + format clean

Infrastructure:
- Docker Compose (PostgreSQL 16 + backend)
- pyproject.toml with full dependency management
This commit is contained in:
Yaojia Wang
2026-03-30 00:54:21 +02:00
parent e4f08576a9
commit 33488fd634
51 changed files with 4701 additions and 1 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Smart Support</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1820
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "smart-support-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "~5.7.0",
"vite": "^6.2.0"
}
}

5
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { ChatPage } from "./pages/ChatPage";
export default function App() {
return <ChatPage />;
}

View File

@@ -0,0 +1,77 @@
import { useState } from "react";
import type { ToolAction } from "../types";
interface Props {
action: ToolAction;
}
export function AgentAction({ action }: Props) {
const [expanded, setExpanded] = useState(false);
return (
<div style={styles.container}>
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
<span style={styles.icon}>{expanded ? "v" : ">"}</span>
<span style={styles.agent}>{action.agent}</span>
<span style={styles.tool}>{action.tool}</span>
</div>
{expanded && (
<div style={styles.details}>
<div style={styles.section}>
<strong>Args:</strong>
<pre style={styles.code}>{JSON.stringify(action.args, null, 2)}</pre>
</div>
{action.result !== undefined && (
<div style={styles.section}>
<strong>Result:</strong>
<pre style={styles.code}>{JSON.stringify(action.result, null, 2)}</pre>
</div>
)}
</div>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
margin: "4px 16px",
padding: "6px 10px",
background: "#f5f5f5",
borderRadius: "6px",
fontSize: "12px",
color: "#666",
},
header: {
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
},
icon: {
fontFamily: "monospace",
width: "12px",
},
agent: {
fontWeight: 600,
},
tool: {
color: "#0066cc",
fontFamily: "monospace",
},
details: {
marginTop: "6px",
paddingLeft: "18px",
},
section: {
marginBottom: "4px",
},
code: {
background: "#e8e8e8",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "11px",
overflowX: "auto",
margin: "4px 0",
},
};

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
interface Props {
onSend: (content: string) => void;
disabled: boolean;
}
export function ChatInput({ onSend, disabled }: Props) {
const [value, setValue] = useState("");
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div style={styles.container}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? "Waiting for response..." : "Type a message..."}
disabled={disabled}
style={styles.input}
/>
<button onClick={handleSubmit} disabled={disabled || !value.trim()} style={styles.button}>
Send
</button>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: "flex",
gap: "8px",
padding: "12px 16px",
borderTop: "1px solid #e0e0e0",
background: "white",
},
input: {
flex: 1,
padding: "10px 14px",
border: "1px solid #ccc",
borderRadius: "8px",
fontSize: "14px",
outline: "none",
},
button: {
padding: "10px 20px",
background: "#0066cc",
color: "white",
border: "none",
borderRadius: "8px",
fontSize: "14px",
cursor: "pointer",
},
};

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from "react";
import type { ChatMessage } from "../types";
interface Props {
messages: ChatMessage[];
}
export function ChatMessages({ messages }: Props) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div style={styles.container}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
...styles.message,
...(msg.sender === "user" ? styles.userMessage : styles.agentMessage),
}}
>
<div style={styles.header}>
<span style={styles.sender}>
{msg.sender === "user" ? "You" : msg.agent || "Agent"}
</span>
</div>
<div style={styles.content}>
{msg.content}
{msg.isStreaming && <span style={styles.cursor}>|</span>}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
flex: 1,
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "12px",
},
message: {
maxWidth: "80%",
padding: "10px 14px",
borderRadius: "12px",
lineHeight: 1.5,
},
userMessage: {
alignSelf: "flex-end",
background: "#0066cc",
color: "white",
},
agentMessage: {
alignSelf: "flex-start",
background: "#f0f0f0",
color: "#333",
},
header: {
marginBottom: "4px",
},
sender: {
fontSize: "12px",
fontWeight: 600,
opacity: 0.8,
},
content: {
fontSize: "14px",
whiteSpace: "pre-wrap",
},
cursor: {
animation: "blink 1s infinite",
opacity: 0.7,
},
};

View File

@@ -0,0 +1,81 @@
import type { InterruptMessage } from "../types";
interface Props {
interrupt: InterruptMessage;
onRespond: (approved: boolean) => void;
}
export function InterruptPrompt({ interrupt, onRespond }: Props) {
return (
<div style={styles.container}>
<div style={styles.header}>Action Requires Approval</div>
<div style={styles.action}>
<strong>Action:</strong> {interrupt.action}
</div>
{"message" in interrupt.params && interrupt.params.message != null && (
<div style={styles.detail}>{String(interrupt.params.message)}</div>
)}
{"order_id" in interrupt.params && interrupt.params.order_id != null && (
<div style={styles.detail}>
<strong>Order:</strong> {String(interrupt.params.order_id)}
</div>
)}
<div style={styles.buttons}>
<button onClick={() => onRespond(true)} style={styles.approveBtn}>
Approve
</button>
<button onClick={() => onRespond(false)} style={styles.rejectBtn}>
Reject
</button>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
margin: "12px 16px",
padding: "16px",
border: "2px solid #ff9800",
borderRadius: "12px",
background: "#fff8e1",
},
header: {
fontWeight: 700,
fontSize: "14px",
color: "#e65100",
marginBottom: "8px",
},
action: {
fontSize: "14px",
marginBottom: "4px",
},
detail: {
fontSize: "13px",
color: "#555",
marginBottom: "4px",
},
buttons: {
display: "flex",
gap: "8px",
marginTop: "12px",
},
approveBtn: {
padding: "8px 20px",
background: "#4caf50",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontWeight: 600,
},
rejectBtn: {
padding: "8px 20px",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontWeight: 600,
},
};

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
ClientMessage,
ConnectionStatus,
InterruptResponse,
SendMessage,
ServerMessage,
} from "../types";
const WS_URL = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
function getOrCreateThreadId(): string {
const key = "smart_support_thread_id";
let id = sessionStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem(key, id);
}
return id;
}
export function useWebSocket(onMessage: (msg: ServerMessage) => void) {
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [threadId] = useState(getOrCreateThreadId);
const wsRef = useRef<WebSocket | null>(null);
const retriesRef = useRef(0);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
setStatus("connecting");
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
setStatus("connected");
retriesRef.current = 0;
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as ServerMessage;
onMessageRef.current(data);
} catch {
// ignore non-JSON messages
}
};
ws.onclose = () => {
setStatus("disconnected");
wsRef.current = null;
if (retriesRef.current < MAX_RETRIES) {
const delay = BASE_DELAY_MS * Math.pow(2, retriesRef.current);
retriesRef.current += 1;
setTimeout(connect, delay);
}
};
ws.onerror = () => {
ws.close();
};
wsRef.current = ws;
}, []);
useEffect(() => {
connect();
return () => {
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((msg: ClientMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
}
}, []);
const sendMessage = useCallback(
(content: string) => {
const msg: SendMessage = { type: "message", thread_id: threadId, content };
send(msg);
},
[send, threadId]
);
const sendInterruptResponse = useCallback(
(approved: boolean) => {
const msg: InterruptResponse = {
type: "interrupt_response",
thread_id: threadId,
approved,
};
send(msg);
},
[send, threadId]
);
return { status, threadId, sendMessage, sendInterruptResponse };
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,200 @@
import { useCallback, useState } from "react";
import { AgentAction } from "../components/AgentAction";
import { ChatInput } from "../components/ChatInput";
import { ChatMessages } from "../components/ChatMessages";
import { InterruptPrompt } from "../components/InterruptPrompt";
import { useWebSocket } from "../hooks/useWebSocket";
import type {
ChatMessage,
ConnectionStatus,
InterruptMessage,
ServerMessage,
ToolAction,
} from "../types";
let msgCounter = 0;
function nextId(): string {
msgCounter += 1;
return `msg-${msgCounter}`;
}
export function ChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [toolActions, setToolActions] = useState<ToolAction[]>([]);
const [currentInterrupt, setCurrentInterrupt] = useState<InterruptMessage | null>(null);
const [isWaiting, setIsWaiting] = useState(false);
const handleServerMessage = useCallback((msg: ServerMessage) => {
switch (msg.type) {
case "token": {
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last && last.sender === "agent" && last.isStreaming) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + msg.content },
];
}
return [
...prev,
{
id: nextId(),
sender: "agent",
agent: msg.agent,
content: msg.content,
timestamp: Date.now(),
isStreaming: true,
},
];
});
break;
}
case "tool_call": {
setToolActions((prev) => [
...prev,
{
id: nextId(),
agent: msg.agent,
tool: msg.tool,
args: msg.args,
timestamp: Date.now(),
},
]);
break;
}
case "interrupt": {
setCurrentInterrupt(msg);
setIsWaiting(false);
break;
}
case "message_complete": {
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last && last.isStreaming) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return prev;
});
setIsWaiting(false);
break;
}
case "error": {
setMessages((prev) => [
...prev,
{
id: nextId(),
sender: "agent",
agent: "System",
content: `Error: ${msg.message}`,
timestamp: Date.now(),
},
]);
setIsWaiting(false);
break;
}
}
}, []);
const { status, sendMessage, sendInterruptResponse } =
useWebSocket(handleServerMessage);
const handleSend = useCallback(
(content: string) => {
setMessages((prev) => [
...prev,
{
id: nextId(),
sender: "user",
content,
timestamp: Date.now(),
},
]);
setIsWaiting(true);
sendMessage(content);
},
[sendMessage]
);
const handleInterruptResponse = useCallback(
(approved: boolean) => {
sendInterruptResponse(approved);
setCurrentInterrupt(null);
setIsWaiting(true);
},
[sendInterruptResponse]
);
return (
<div style={styles.page}>
<div style={styles.header}>
<h1 style={styles.title}>Smart Support</h1>
<StatusIndicator status={status} />
</div>
<ChatMessages messages={messages} />
{toolActions.length > 0 && (
<div style={styles.actionsBar}>
{toolActions.slice(-3).map((action) => (
<AgentAction key={action.id} action={action} />
))}
</div>
)}
{currentInterrupt && (
<InterruptPrompt
interrupt={currentInterrupt}
onRespond={handleInterruptResponse}
/>
)}
<ChatInput onSend={handleSend} disabled={isWaiting || status !== "connected"} />
</div>
);
}
function StatusIndicator({ status }: { status: ConnectionStatus }) {
const colors: Record<ConnectionStatus, string> = {
connected: "#4caf50",
connecting: "#ff9800",
disconnected: "#f44336",
};
return (
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
background: colors[status],
}}
/>
<span style={{ fontSize: "12px", color: "#666" }}>{status}</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
height: "100vh",
display: "flex",
flexDirection: "column",
background: "white",
maxWidth: "800px",
margin: "0 auto",
boxShadow: "0 0 20px rgba(0,0,0,0.1)",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid #e0e0e0",
},
title: {
fontSize: "18px",
fontWeight: 700,
margin: 0,
color: "#333",
},
actionsBar: {
borderTop: "1px solid #eee",
paddingTop: "4px",
},
};

86
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,86 @@
/** WebSocket message protocol types matching ARCHITECTURE.md Section 6.1 */
// -- Server -> Client messages --
export interface TokenMessage {
type: "token";
agent: string;
content: string;
}
export interface InterruptMessage {
type: "interrupt";
thread_id: string;
action: string;
params: Record<string, unknown>;
}
export interface ToolCallMessage {
type: "tool_call";
agent: string;
tool: string;
args: Record<string, unknown>;
}
export interface ToolResultMessage {
type: "tool_result";
agent: string;
tool: string;
result: unknown;
}
export interface MessageCompleteMessage {
type: "message_complete";
thread_id: string;
}
export interface ErrorMessage {
type: "error";
message: string;
}
export type ServerMessage =
| TokenMessage
| InterruptMessage
| ToolCallMessage
| ToolResultMessage
| MessageCompleteMessage
| ErrorMessage;
// -- Client -> Server messages --
export interface SendMessage {
type: "message";
thread_id: string;
content: string;
}
export interface InterruptResponse {
type: "interrupt_response";
thread_id: string;
approved: boolean;
}
export type ClientMessage = SendMessage | InterruptResponse;
// -- UI state --
export interface ChatMessage {
id: string;
sender: "user" | "agent";
agent?: string;
content: string;
timestamp: number;
isStreaming?: boolean;
}
export interface ToolAction {
id: string;
agent: string;
tool: string;
args: Record<string, unknown>;
result?: unknown;
timestamp: number;
}
export type ConnectionStatus = "connecting" | "connected" | "disconnected";

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/ws": {
target: "ws://localhost:8000",
ws: true,
},
},
},
});