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:
200
frontend/src/pages/ChatPage.tsx
Normal file
200
frontend/src/pages/ChatPage.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user