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

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",
},
};