יום 8 - ממשק ווב לסוכן

07/10/2025

הממשק שאנחנו הכי רגילים לראות בעבודה עם סוכנים הוא הצ'אט בדפדפן ולכן חשוב להבין איך הוא בנוי. בשביל הדוגמה היום אשתמש ב 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.

חשבו: מה קורה לשיחה כשמרעננים את העמוד? נסו לחשוב על רעיונות איך לשמור על ההזרמה פעילה גם אחרי ריענון.