branch:
client.tsx
14120 bytesRaw
import { useCallback, useEffect, useRef, useState } from "react";
import { useAgent } from "agents/react";
import { createRoot } from "react-dom/client";
import type { AgentInputItem, AgentState, StreamChunk } from "./server";
/** flattened messges for display */
type DisplayMessage =
| { role: "user"; content: string }
| { role: "assistant"; content: string }
| { role: "tool-call"; toolCallId: string; toolName: string; input: unknown }
| {
role: "tool-result";
toolCallId: string;
toolName: string;
output: unknown;
};
/** AgentInputItem into renderable message */
function toDisplay(item: AgentInputItem): DisplayMessage | null {
// User message
if ("role" in item && item.role === "user") {
const content =
typeof item.content === "string"
? item.content
: Array.isArray(item.content)
? item.content
.filter((c) => c.type === "input_text")
.map((c) => ("text" in c ? c.text : ""))
.join("")
: "";
return { role: "user", content };
}
// Assistant message
if ("role" in item && item.role === "assistant") {
const text = item.content
.filter((c) => c.type === "output_text")
.map((c) => ("text" in c ? c.text : ""))
.join("");
return { role: "assistant", content: text };
}
// Tool call
if ("type" in item && item.type === "function_call") {
return {
role: "tool-call",
toolCallId: item.callId,
toolName: item.name,
input: JSON.parse(item.arguments)
};
}
// tool call result
if ("type" in item && item.type === "function_call_result") {
return {
role: "tool-result",
toolCallId: item.callId,
toolName: item.name,
output: item.output
};
}
return null;
}
function parseToolOutput(output: unknown): Record<string, string> | string {
if (typeof output === "string") {
try {
return parseToolOutput(JSON.parse(output));
} catch {
return output;
}
}
if (typeof output === "object" && output !== null) {
const obj = output as Record<string, unknown>;
if (obj.type === "text" && typeof obj.text === "string") {
return parseToolOutput(obj.text);
}
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(obj)) {
result[k] = typeof v === "object" ? JSON.stringify(v) : String(v);
}
return result;
}
return String(output);
}
/** snake_case or kebab-case into Title Case */
function formatKey(key: string): string {
return key.replace(/[_-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Extract tool args as displayable key-value pairs */
function formatArgs(input: unknown): { key: string; value: string }[] {
if (typeof input === "object" && input !== null) {
return Object.entries(input as Record<string, unknown>).map(([k, v]) => ({
key: formatKey(k),
value: typeof v === "object" ? JSON.stringify(v) : String(v)
}));
}
return [];
}
function Chat() {
const [input, setInput] = useState("");
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const agent = useAgent({
agent: "my-agent",
onStateUpdate(state: AgentState) {
setMessages(
state.messages
.map(toDisplay)
.filter((m): m is DisplayMessage => m !== null)
);
}
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const send = useCallback(() => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
setIsStreaming(true);
// Optimistically add the user message and a placeholder assistant message
setMessages((prev) => [
...prev,
{ role: "user", content: text },
{ role: "assistant", content: "" }
]);
agent.call("chat", [text], {
onChunk(chunk: unknown) {
const c = chunk as StreamChunk;
if (c.type === "text-delta") {
// Append delta to the last assistant message
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last?.role === "assistant") {
updated[updated.length - 1] = {
...last,
content: last.content + c.delta
};
}
return updated;
});
}
if (c.type === "tool-call") {
// Insert tool-call before the trailing assistant placeholder
setMessages((prev) => {
const updated = [...prev];
const assistantIdx = updated.length - 1;
updated.splice(assistantIdx, 0, {
role: "tool-call",
toolCallId: c.toolCallId,
toolName: c.toolName,
input: c.input
});
return updated;
});
}
if (c.type === "tool-result") {
// Insert tool-result before the trailing assistant placeholder
setMessages((prev) => {
const updated = [...prev];
const assistantIdx = updated.length - 1;
// Find the matching tool-call to get toolName
const call = updated.find(
(m) => m.role === "tool-call" && m.toolCallId === c.toolCallId
);
updated.splice(assistantIdx, 0, {
role: "tool-result",
toolCallId: c.toolCallId,
toolName: call?.role === "tool-call" ? call.toolName : "unknown",
output: c.output
});
return updated;
});
}
},
onDone() {
setIsStreaming(false);
},
onError(error: string) {
console.error("Stream error:", error);
setIsStreaming(false);
}
});
}, [input, isStreaming, agent]);
const clearHistory = useCallback(() => {
agent.call("clearHistory", []);
setMessages([]);
}, [agent]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100vh",
fontFamily: "system-ui, -apple-system, sans-serif",
backgroundColor: "#f9fafb"
}}
>
{/* Header */}
<header
style={{
padding: "16px 20px",
borderBottom: "1px solid #e5e7eb",
backgroundColor: "white"
}}
>
<div
style={{
maxWidth: "700px",
margin: "0 auto",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
>
<div>
<h1 style={{ margin: 0, fontSize: "18px", fontWeight: 600 }}>
OpenAI Agents SDK — Streaming Chat
</h1>
<p
style={{
margin: "4px 0 0",
fontSize: "13px",
color: "#6b7280"
}}
>
@callable({ streaming: true }) + @openai/agents
</p>
</div>
<button
type="button"
onClick={clearHistory}
style={{
padding: "6px 12px",
fontSize: "13px",
backgroundColor: "#f3f4f6",
border: "1px solid #d1d5db",
borderRadius: "6px",
cursor: "pointer"
}}
>
Clear
</button>
</div>
</header>
{/* Messages */}
<div style={{ flex: 1, overflowY: "auto", padding: "20px 0" }}>
<div style={{ maxWidth: "700px", margin: "0 auto", padding: "0 20px" }}>
{messages.length === 0 && (
<div
style={{
textAlign: "center",
color: "#9ca3af",
marginTop: "60px"
}}
>
<p style={{ fontSize: "15px" }}>
Send a message to start chatting.
</p>
<p style={{ fontSize: "13px", marginTop: "4px" }}>
Try: "What's the weather in Paris?"
</p>
</div>
)}
{messages.map((message, i) => (
<MessageBubble key={i} message={message} />
))}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div
style={{
borderTop: "1px solid #e5e7eb",
backgroundColor: "white",
padding: "16px 20px"
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
style={{
maxWidth: "700px",
margin: "0 auto",
display: "flex",
gap: "8px"
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
disabled={isStreaming}
style={{
flex: 1,
padding: "10px 14px",
fontSize: "14px",
border: "1px solid #d1d5db",
borderRadius: "8px",
outline: "none"
}}
/>
<button
type="submit"
disabled={isStreaming || !input.trim()}
style={{
padding: "10px 20px",
fontSize: "14px",
backgroundColor:
isStreaming || !input.trim() ? "#9ca3af" : "#2563eb",
color: "white",
border: "none",
borderRadius: "8px",
cursor: isStreaming || !input.trim() ? "not-allowed" : "pointer"
}}
>
{isStreaming ? "Streaming..." : "Send"}
</button>
</form>
</div>
</div>
);
}
function MessageBubble({ message }: { message: DisplayMessage }) {
if (message.role === "tool-call") {
const args = formatArgs(message.input);
return (
<div style={{ marginBottom: "2px", paddingLeft: "12px" }}>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "5px 10px",
borderRadius: "6px",
backgroundColor: "#f3f4f6",
border: "1px solid #e5e7eb",
fontSize: "12px",
color: "#6b7280"
}}
>
<span style={{ fontSize: "14px" }}>⚙</span>
<span>
Calling{" "}
<strong style={{ color: "#374151" }}>{message.toolName}</strong>
</span>
{args.length > 0 && (
<span style={{ color: "#9ca3af" }}>
({args.map((a) => `${a.key}: ${a.value}`).join(", ")})
</span>
)}
</div>
</div>
);
}
if (message.role === "tool-result") {
const parsed = parseToolOutput(message.output);
const isObject = typeof parsed === "object";
return (
<div style={{ marginBottom: "12px", paddingLeft: "12px" }}>
<div
style={{
display: "inline-block",
borderRadius: "8px",
border: "1px solid #d1fae5",
overflow: "hidden",
fontSize: "13px"
}}
>
{/* Header */}
<div
style={{
padding: "5px 10px",
backgroundColor: "#ecfdf5",
borderBottom: isObject ? "1px solid #d1fae5" : "none",
color: "#059669",
fontSize: "12px",
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: "6px"
}}
>
<span>✓</span>
<span>{message.toolName}</span>
</div>
{/* Body */}
{isObject ? (
<div style={{ padding: "4px 0", backgroundColor: "#f0fdf4" }}>
{Object.entries(parsed as Record<string, string>).map(
([key, value]) => (
<div
key={key}
style={{
display: "flex",
padding: "2px 10px",
gap: "12px"
}}
>
<span
style={{
color: "#6b7280",
fontSize: "12px",
minWidth: "90px"
}}
>
{formatKey(key)}
</span>
<span
style={{
color: "#1f2937",
fontSize: "12px",
fontWeight: 500
}}
>
{value}
</span>
</div>
)
)}
</div>
) : (
<div
style={{
padding: "6px 10px",
backgroundColor: "#f0fdf4",
color: "#374151",
fontSize: "12px"
}}
>
{parsed}
</div>
)}
</div>
</div>
);
}
const isUser = message.role === "user";
if (!message.content) return null;
return (
<div
style={{
marginBottom: "16px",
display: "flex",
justifyContent: isUser ? "flex-end" : "flex-start"
}}
>
<div
style={{
maxWidth: "80%",
padding: "10px 14px",
borderRadius: isUser ? "14px 14px 4px 14px" : "14px 14px 14px 4px",
backgroundColor: isUser ? "#2563eb" : "#f3f4f6",
color: isUser ? "white" : "#1f2937",
whiteSpace: "pre-wrap",
lineHeight: "1.5",
fontSize: "14px"
}}
>
{message.content}
</div>
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<Chat />);