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:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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
1820
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
5
frontend/src/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatPage } from "./pages/ChatPage";
|
||||
|
||||
export default function App() {
|
||||
return <ChatPage />;
|
||||
}
|
||||
77
frontend/src/components/AgentAction.tsx
Normal file
77
frontend/src/components/AgentAction.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
68
frontend/src/components/ChatInput.tsx
Normal file
68
frontend/src/components/ChatInput.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
82
frontend/src/components/ChatMessages.tsx
Normal file
82
frontend/src/components/ChatMessages.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
81
frontend/src/components/InterruptPrompt.tsx
Normal file
81
frontend/src/components/InterruptPrompt.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
104
frontend/src/hooks/useWebSocket.ts
Normal file
104
frontend/src/hooks/useWebSocket.ts
Normal 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
9
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
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",
|
||||
},
|
||||
};
|
||||
86
frontend/src/types.ts
Normal file
86
frontend/src/types.ts
Normal 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
21
frontend/tsconfig.json
Normal 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
15
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user