יום 8 - ממשק ווב לסוכן
הממשק שאנחנו הכי רגילים לראות בעבודה עם סוכנים הוא הצ'אט בדפדפן ולכן חשוב להבין איך הוא בנוי. בשביל הדוגמה היום אשתמש ב fastapi בצד השרת ובהזרמת מידע HTTP Streaming. בואו נראה את הקוד.
1. קוד צד שרת
את השרת כתבתי ב fastapi. הסיבה לבחירה ב fastapi במקום למשל ב flask היא התמיכה המובנית של fastapi ב async. ספריית OpenAI Agents SDK היא אסינכרונית, כלומר לולאת ההזרמה שלה כתובה במבנה:
async for event in result.stream_events():
זה אומר שבשביל להפעיל שיחה עם סוכן ולקבל הזדמנות להזרים את הטוקנים כשהם נוצרים אני צריך Event Loop בתוכנית שלי, והדרך הכי פשוטה לקבל אחת היא להשתמש בפריימוורק ווב שכבר דואג לזה.
הפונקציה הראשונה בקוד צד שרת צריכה להיות מוכרת לכם מפוסטים קודמים בסידרה:
async def generate():
agent = Agent(
name=request.agent_name,
instructions=request.agent_instructions,
)
result = Runner.run_streamed(
agent,
input=request.message,
)
# Send initial message to indicate start
yield f"data: {json.dumps({'type': 'start', 'message': 'Starting chat...'})}\n\n"
async for event in result.stream_events():
# Handle different event types
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
# Stream individual tokens
token = event.data.delta
if token:
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
elif event.type == "agent_updated_stream_event":
yield f"data: {json.dumps({'type': 'agent_update', 'agent_name': event.new_agent.name})}\n\n"
elif event.type == "run_item_stream_event":
if event.item.type == "tool_call_item":
yield f"data: {json.dumps({'type': 'tool_call', 'message': 'Tool was called'})}\n\n"
elif event.item.type == "tool_call_output_item":
yield f"data: {json.dumps({'type': 'tool_output', 'content': str(event.item.output)})}\n\n"
elif event.item.type == "message_output_item":
message_text = ItemHelpers.text_message_output(event.item)
yield f"data: {json.dumps({'type': 'message', 'content': message_text})}\n\n"
# Send completion signal
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
יש פה פנייה לסוכן עם run_streamed ואז הפעלת yield עבור כל טקסט שהסוכן מייצר.
מי שמשתמש ב generate היא פקודת השיחה:
@app.post("/chat")
async def chat(request: ChatRequest):
async def generate():
...
return StreamingResponse(
generate(),
media_type="text/plain",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
וספריית fastapi מטפלת עבורי בצורה אוטומטית בהזרמת כל מידע שמגיע מ yield כחלק מהתשובה לדפדפן.
קוד צד השרת המלא בקובץ main.py הוא:
import json
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from agents import Agent, ItemHelpers, Runner
from openai.types.responses import ResponseTextDeltaEvent
app = FastAPI()
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
app.mount("/client", StaticFiles(directory="client"), name="client")
class ChatRequest(BaseModel):
message: str
agent_name: str = "Assistant"
agent_instructions: str = "You are a helpful assistant."
@app.get("/", response_class=HTMLResponse)
async def index():
with open("client/index.html", "r") as f:
return HTMLResponse(content=f.read())
@app.post("/chat")
async def chat(request: ChatRequest):
async def generate():
agent = Agent(
name=request.agent_name,
instructions=request.agent_instructions,
)
result = Runner.run_streamed(
agent,
input=request.message,
)
# Send initial message to indicate start
yield f"data: {json.dumps({'type': 'start', 'message': 'Starting chat...'})}\n\n"
async for event in result.stream_events():
# Handle different event types
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
# Stream individual tokens
token = event.data.delta
if token:
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
elif event.type == "agent_updated_stream_event":
yield f"data: {json.dumps({'type': 'agent_update', 'agent_name': event.new_agent.name})}\n\n"
elif event.type == "run_item_stream_event":
if event.item.type == "tool_call_item":
yield f"data: {json.dumps({'type': 'tool_call', 'message': 'Tool was called'})}\n\n"
elif event.item.type == "tool_call_output_item":
yield f"data: {json.dumps({'type': 'tool_output', 'content': str(event.item.output)})}\n\n"
elif event.item.type == "message_output_item":
message_text = ItemHelpers.text_message_output(event.item)
yield f"data: {json.dumps({'type': 'message', 'content': message_text})}\n\n"
# Send completion signal
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
return StreamingResponse(
generate(),
media_type="text/plain",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=8080)
2. קוד צד לקוח
בצד הלקוח אני מגדיר קובץ HTML עם אזור להודעות וטופס לשליחת הודעה חדשה:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Agent</title>
<link rel="stylesheet" href="client/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🤖 Chat Agent</h1>
<p>Powered by Lean Agents SDK</p>
</header>
<div class="agent-config">
<div class="config-row">
<label for="agent-name">Agent Name:</label>
<input type="text" id="agent-name" value="Assistant" placeholder="Enter agent name">
</div>
<div class="config-row">
<label for="agent-instructions">Agent Instructions:</label>
<textarea id="agent-instructions" placeholder="Enter agent instructions" rows="2">You are a helpful assistant.</textarea>
</div>
</div>
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
<div class="system-message">
Welcome! Configure your agent above and start chatting.
</div>
</div>
<div class="chat-input-container">
<div class="input-group">
<textarea
id="message-input"
placeholder="Type your message here..."
rows="3"
disabled
></textarea>
<button id="send-button" disabled>Send</button>
</div>
<div class="status" id="status">Ready to chat</div>
</div>
</div>
</div>
<script src="/client/script.js"></script>
</body>
</html>
ועיקר הקוד נמצא בקובץ ה JavaScript. קובץ זה צריך לטפל בתגובה המוזרמת ולעדכן את רשימת ההודעות כל פעם שמתקבלים טוקנים חדשים מהשרת. הקוד המרכזי שם הוא:
async processStreamingResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
this.status.textContent = 'Streaming response...';
this.status.className = 'status streaming';
// Initialize streaming message container
this.currentStreamingMessage = this.createStreamingMessage();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode the chunk
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
this.handleStreamEvent(data);
} catch (e) {
console.warn('Failed to parse JSON:', line, e);
}
}
}
}
} finally {
reader.releaseLock();
this.finalizeStreamingMessage();
}
}
handleStreamEvent(data) {
switch (data.type) {
case 'start':
console.log('Stream started:', data.message);
break;
case 'token':
if (data.content) {
this.appendToStreamingMessage(data.content);
}
break;
case 'message':
if (data.content) {
this.appendToStreamingMessage(data.content);
}
break;
case 'agent_update':
console.log('Agent updated:', data.agent_name);
break;
case 'tool_call':
this.appendToStreamingMessage('\n[Tool called]');
break;
case 'tool_output':
this.appendToStreamingMessage(`\n[Tool output: ${data.content}]`);
break;
case 'complete':
console.log('Stream completed');
this.status.textContent = 'Response complete';
break;
default:
console.log('Unknown event type:', data);
}
}
נשים לב שהקוד מתאים לקוד ההזרמה שכתבתי בפייתון. בפייתון היו לי פקודות כמו:
yield f"data: {json.dumps({'type': 'tool_call', 'message': 'Tool was called'})}\n\n"
כדי להזרים אירוע של tool call, ואז ב JavaScript אנחנו מסתכלים על ה data.type ולפי זה מחליטים איך לטפל בהודעה:
switch (data.type) {
קובץ ה JavaScript המלא לדוגמה הוא:
// file: client/script.js
class ChatAgent {
constructor() {
this.chatMessages = document.getElementById('chat-messages');
this.messageInput = document.getElementById('message-input');
this.sendButton = document.getElementById('send-button');
this.status = document.getElementById('status');
this.agentName = document.getElementById('agent-name');
this.agentInstructions = document.getElementById('agent-instructions');
this.isStreaming = false;
this.currentStreamingMessage = null;
this.initializeEventListeners();
this.enableInterface();
}
initializeEventListeners() {
// Send button click
this.sendButton.addEventListener('click', () => this.sendMessage());
// Enter key in message input (Shift+Enter for new line)
this.messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
// Enable send button when there's content
this.messageInput.addEventListener('input', () => {
const hasContent = this.messageInput.value.trim().length > 0;
this.sendButton.disabled = !hasContent || this.isStreaming;
});
// Auto-resize message input
this.messageInput.addEventListener('input', () => {
this.messageInput.style.height = 'auto';
this.messageInput.style.height = this.messageInput.scrollHeight + 'px';
});
}
enableInterface() {
this.messageInput.disabled = false;
this.sendButton.disabled = true;
this.status.textContent = 'Ready to chat';
this.status.className = 'status';
}
disableInterface() {
this.messageInput.disabled = true;
this.sendButton.disabled = true;
}
async sendMessage() {
const message = this.messageInput.value.trim();
if (!message || this.isStreaming) return;
const agentName = this.agentName.value.trim() || 'Assistant';
const agentInstructions = this.agentInstructions.value.trim() || 'You are a helpful assistant.';
// Add user message to chat
this.addMessage(message, 'user');
// Clear input and disable interface
this.messageInput.value = '';
this.messageInput.style.height = 'auto';
this.disableInterface();
// Set streaming status
this.isStreaming = true;
this.status.textContent = 'Connecting...';
this.status.className = 'status connecting';
try {
// Make the streaming request
const response = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message,
agent_name: agentName,
agent_instructions: agentInstructions
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Process the streaming response
await this.processStreamingResponse(response);
} catch (error) {
console.error('Error:', error);
this.addMessage('Sorry, there was an error processing your request.', 'system');
this.status.textContent = 'Error occurred';
this.status.className = 'status error';
} finally {
this.isStreaming = false;
this.enableInterface();
}
}
async processStreamingResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
this.status.textContent = 'Streaming response...';
this.status.className = 'status streaming';
// Initialize streaming message container
this.currentStreamingMessage = this.createStreamingMessage();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Decode the chunk
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
this.handleStreamEvent(data);
} catch (e) {
console.warn('Failed to parse JSON:', line, e);
}
}
}
}
} finally {
reader.releaseLock();
this.finalizeStreamingMessage();
}
}
handleStreamEvent(data) {
switch (data.type) {
case 'start':
console.log('Stream started:', data.message);
break;
case 'token':
if (data.content) {
this.appendToStreamingMessage(data.content);
}
break;
case 'message':
if (data.content) {
this.appendToStreamingMessage(data.content);
}
break;
case 'agent_update':
console.log('Agent updated:', data.agent_name);
break;
case 'tool_call':
this.appendToStreamingMessage('\n[Tool called]');
break;
case 'tool_output':
this.appendToStreamingMessage(`\n[Tool output: ${data.content}]`);
break;
case 'complete':
console.log('Stream completed');
this.status.textContent = 'Response complete';
break;
default:
console.log('Unknown event type:', data);
}
}
createStreamingMessage() {
const messageDiv = document.createElement('div');
messageDiv.className = 'message streaming-message';
messageDiv.innerHTML = '<span class="typing-indicator"></span>';
this.chatMessages.appendChild(messageDiv);
this.scrollToBottom();
return messageDiv;
}
appendToStreamingMessage(content) {
if (!this.currentStreamingMessage) return;
// Remove typing indicator if it exists
const typingIndicator = this.currentStreamingMessage.querySelector('.typing-indicator');
if (typingIndicator) {
typingIndicator.remove();
}
// Append the content
this.currentStreamingMessage.textContent += content;
this.scrollToBottom();
}
finalizeStreamingMessage() {
if (!this.currentStreamingMessage) return;
// Remove typing indicator
const typingIndicator = this.currentStreamingMessage.querySelector('.typing-indicator');
if (typingIndicator) {
typingIndicator.remove();
}
// Change class to final assistant message
this.currentStreamingMessage.className = 'message assistant-message';
// If the message is empty, add a default message
if (!this.currentStreamingMessage.textContent.trim()) {
this.currentStreamingMessage.textContent = 'No response received.';
}
this.currentStreamingMessage = null;
this.scrollToBottom();
}
addMessage(content, type) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}-message`;
messageDiv.textContent = content;
this.chatMessages.appendChild(messageDiv);
this.scrollToBottom();
}
scrollToBottom() {
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
}
}
// Initialize the chat when the page loads
document.addEventListener('DOMContentLoaded', () => {
new ChatAgent();
});
ואגב יש גם קובץ CSS שנקרא client/style.css עם התוכן הבא:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
text-align: center;
margin-bottom: 30px;
color: white;
}
header h1 {
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
header p {
font-size: 1.1em;
opacity: 0.9;
}
.agent-config {
background: white;
border-radius: 15px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
.config-row {
margin-bottom: 15px;
}
.config-row:last-child {
margin-bottom: 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
}
input, textarea {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s ease;
resize: vertical;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
max-height: 400px;
min-height: 300px;
}
.message {
margin-bottom: 15px;
padding: 12px 16px;
border-radius: 12px;
max-width: 80%;
word-wrap: break-word;
}
.user-message {
background: #667eea;
color: white;
margin-left: auto;
border-bottom-right-radius: 4px;
}
.assistant-message {
background: #f1f3f5;
color: #333;
border-bottom-left-radius: 4px;
}
.system-message {
background: #e3f2fd;
color: #1976d2;
text-align: center;
border-radius: 8px;
padding: 10px;
font-style: italic;
margin: 10px auto;
max-width: 90%;
}
.streaming-message {
background: #f8f9fa;
color: #333;
border-bottom-left-radius: 4px;
border-left: 3px solid #667eea;
}
.chat-input-container {
padding: 20px;
border-top: 1px solid #e1e5e9;
background: #fafafa;
}
.input-group {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
#message-input {
flex: 1;
min-height: 60px;
resize: none;
}
button {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
min-width: 80px;
}
button:hover:not(:disabled) {
background: #5a6fd8;
transform: translateY(-1px);
}
button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.status {
font-size: 12px;
color: #666;
text-align: center;
}
.status.connecting {
color: #ff9800;
}
.status.streaming {
color: #4caf50;
}
.status.error {
color: #f44336;
}
/* Typing indicator */
.typing-indicator {
display: inline-block;
padding: 8px 12px;
background: #f1f3f5;
border-radius: 12px;
margin-bottom: 15px;
}
.typing-indicator::after {
content: '●●●';
animation: typing 1.4s infinite;
color: #999;
}
@keyframes typing {
0%, 60% {
opacity: 1;
}
30% {
opacity: 0.3;
}
}
/* Scrollbar styling */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #999;
}
/* Responsive design */
@media (max-width: 600px) {
.container {
padding: 10px;
}
header h1 {
font-size: 2em;
}
.message {
max-width: 95%;
}
.input-group {
flex-direction: column;
}
button {
align-self: flex-end;
min-width: 100px;
}
}
3. עכשיו אתם
שמרו את כל הקבצים לפרויקט אצלכם בתיקייה והפעילו את התוכנית. וודאו שאתם מצליחים לדבר עם הסוכן. לאחר מכן החליפו מודל ותראו שאתם מצליחים לנהל שיחה גם עם מודלים נוספים.
לסיום פתחו את מסך כלי הפיתוח בדפדפן בטאב Network. הסתכלו על המידע שעובר ברשת בין השרת ללקוח בזמן הזרמת הטוקנים ב Chat.
חשבו: מה קורה לשיחה כשמרעננים את העמוד? נסו לחשוב על רעיונות איך לשמור על ההזרמה פעילה גם אחרי ריענון.