יום 9 - בואו ננסה את זה בטייפסקריפט

08/10/2025

ספריית OpenAI Agents SDK זמינה גם בגירסת טייפסקריפט ואפשר להשתמש בה כשכותבים יישומי next.js. נכון, גם ל vercel יש ספריית סוכנים וגם אותה אפשר לחבר בקלות ל next.js, אבל תמיד טוב שיש כמה אפשרויות.

בפוסט זה נראה איך ליצור יישום next.js לשיחה עם סוכן באמצעות ספריית OpenAI Agents SDK תוך שימוש בדברים שלמדנו והתאמתם לטייפסקריפט.

1. איך עובד Streaming ב Next.JS

האתגר הראשון בפיתוח ממשק ווב לסוכנים הוא ההמתנה הארוכה בין הודעה שאני שולח לבין תשובת הסוכן. בזמן שממתינים אנחנו אוהבים להציג למשתמשים את הטקסט שהסוכן מייצר ב Streaming כדי שלפחות לא יהיה להם משעמם.

ב Next.JS הזרמת מידע עובדת באופן הבא:

  1. אנחנו מגדירים API Route ביישום שמחזיר ReadableStream.

  2. אנחנו כותבים בצורה אסינכרונית ל Stream.

  3. כשלקוח פונה עם fetch לנתיב זה הוא יקבל תשובה שמכילה את הכותרת:

transfer-encoding: chunked
  1. המידע עצמו יגיע בחלקים כשהוא מוכן. בצד לקוח נוכל ליצור Reader ולקרוא לפונקציה reader.read() בלולאה. פונקציה זו מחזירה את ה chunk-ים של ההודעה מהשרת בהזרמה כשהם מוכנים.

קוד? בטח. אני יוצר אפליקציית next.js חדשה ופותח קובץ src/app/chat/route.ts עם התוכן הבא:

export async function POST() {
  const encoder = new TextEncoder();
  const customReadable = new ReadableStream({
    async start(controller) {
      controller.enqueue(encoder.encode('Processing...'));
      await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate delay
      controller.enqueue(encoder.encode('Verifying hash...'));
      await new Promise(resolve => setTimeout(resolve, 1500));
      controller.enqueue(encoder.encode('Scanning file...'));
      await new Promise(resolve => setTimeout(resolve, 2000));
      controller.enqueue(encoder.encode('Generating link...'));
      controller.close(); // Signal end of stream
    },
  });

  return new Response(customReadable, {
    headers: {
      'Content-Type': 'text/plain',
      'Transfer-Encoding': 'chunked',
    },
  });
}

כל קריאה ל:

controller.enqueue(encoder.encode('Processing...'));

כותבת הודעה חדשה ל Stream והחזרת ה Stream בסוף הפונקציה מבטיחה שהדפדפן יוכל לקרוא את המידע ממנו כשהמידע נוצר.

בצד הלקוח אני מעדכן את הקובץ page.tsx לתוכן הבא:

'use client';
import { FormEvent, useState } from "react";

export default function Home() {
  const [streamedText, setStreamedText] = useState('');

  async function startStreaming(ev: FormEvent) {
    ev.preventDefault();

    try {
      const response = await fetch('/chat', {
        method: "POST"
      });
      const reader = response.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        setStreamedText(prev => prev + decoder.decode(value));
      }
    } catch (error) {
      console.error('Streaming error:', error);
    }
    setStreamedText(prev => prev + 'Done!');
  }

  return (
    <div >
      <main >
        <p>{streamedText}</p>
        <form onSubmit={startStreaming}>
          <input type="text" />
          <input type="submit" value="Send" />
        </form>
      </main>
    </div>
  );
}

כשהחלק שעוסק בהזרמת התשובה הוא:

const response = await fetch('/chat', {
  method: "POST"
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  setStreamedText(prev => prev + decoder.decode(value));
}

הפעלתי fetch, בניתי Reader מהתשובה ואני קורא בלולאה ל reader.read עד שהשרת מסיים את ההזרמה. כל פעם שמוזרם מידע נוסף אני מעדכן משתנה state שיגרום למידע החדש להופיע על המסך.

2. הזרמת מידע מסוכן

נוסיף ליישום את ספריית הסוכנים של openai ואז zod עם:

npm install @openai/agents 'zod@<=3.25.67'

שימו לב שנכון לכתיבת הפוסט ספריית openai-agents תומכת רק בגירסה ישנה יותר של zod ולכן יש לציין גירסה מקסימלית בהתקנה. אני מקווה שבקרוב הם יתקנו את זה.

אחרי התקנת הספריות והגדרת משתנה הסביבה OPENAI_API_KEY נוכל להתקדם לקובץ route.ts ולעדכן אותו לתוכן הבא:

import { Agent, run } from '@openai/agents';

const agent = new Agent({
  name: 'Assistant',
  instructions: 'You are a helpful assistant',
});

export async function POST() {
  const result = await run(agent, 'Tell me a story about a cat.', {
    stream: true,
  });

  // Convert the result to a standard ReadableStream
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      try {
        for await (const event of result) {
          if (event.type === 'raw_model_stream_event') {
            if (event.data.type === "output_text_delta") {
              controller.enqueue(encoder.encode(event.data.delta));
            }                        
          }          
        }
      } catch (error) {
        controller.error(error);
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Transfer-Encoding': 'chunked',
    },
  });
}

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

  const result = await run(agent, 'Tell me a story about a cat.', {
    stream: true,
  });

בפייתון השתמשתי בפונקציה אחרת כדי לרוץ במצב הזרמה וזה הבדל ראשון בין הספריות.

ההזרמה עצמה דורשת לולאת for async שנראית כך:

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      try {
        for await (const event of result) {
          if (event.type === 'raw_model_stream_event') {
            if (event.data.type === "output_text_delta") {
              controller.enqueue(encoder.encode(event.data.delta));
            }
          }
        }
      } catch (error) {
        controller.error(error);
      } finally {
        controller.close();
      }
    },
  });

קריאת אירועים מסוכן אסינכרוני מבוצעת עם לולאת for async וכל פעם שיש אירוע נוסף נכנסים לגוף הלולאה. עבור כל אירוע אני בודק את הסוג שלו, ואלה אותם סוגים שהכרנו אתמול כשראינו איך להזרים מידע מתוכנית פייתון, רק שהפעם הכתיבה לתשובת ה HTTP לא נעשית עם yield כמו בפייתון אלא עם controller.enqueue.

3. שיחה עם סוכן - ניהול היסטוריה

האתגר האחרון שלנו בממשק הוא ליצור שיחה - לקחת הודעה מהמשתמש, לשלוח אותה לסוכן, לשמור את התשובה של הסוכן ובהודעה הבאה לשלוח את כל ההיסטוריה בצירוף ההודעה.

בפייתון יכולנו לשמור את היסטוריית השיחות בצד שרת באמצעות session. ב TypeScript ספריית הסוכנים לא תומכת ב Session ולכן נצטרך לבנות את המנגנון בעצמנו. אנחנו יכולים לבחור אם לשמור הודעות ישנות בבסיס נתונים, בזיכרון בצד שרת או בצד לקוח. בדוגמה היום אני בוחר לשמור את כל ההודעות בצד לקוח פשוט כי זה קל למימוש וממילא זה לא משנה לספריית הסוכנים.

זה קוד צד הלקוח המעודכן:

'use client';
import { FormEvent, useState } from "react";

type Message = {role: string, content: string };

export default function Home() {
  const [messages, setMessages] = useState<Array<Message>>([]);

  async function sendMessage(ev: FormEvent) {
    ev.preventDefault();
    const form = ev.target as HTMLFormElement;
    const fd = new FormData(form);

    const userMessage = { role: 'user', content: fd.get('message') as string };
    setMessages(prev => [...prev,
      userMessage,
      { role: 'assistant', content: '' }
    ]);

    form.reset();

    try {
      const response = await fetch('/chat', {
        method: "POST",
        body: JSON.stringify([...messages, userMessage]),
      });
      const reader = response.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        setMessages(prev => prev.with(-1, {
          content: prev.at(-1)!.content + decoder.decode(value),
          role: prev.at(-1)!.role,
        }));
      }
    } catch (error) {
      console.error('Streaming error:', error);
    }
  }

  return (
    <div >
      <main >
        <ul>
          {messages.map(msg => (
            <li>{msg.role}: {msg.content}</li>
          ))}
        </ul>
        <form onSubmit={sendMessage}>
          <input type="text" name="message" />
          <input type="submit" value="Send" />
        </form>
      </main>
    </div>
  );
}

יש פה הרבה דברים חדשים אז נעבור עליהם אחד אחד:

  1. אני שומר בסטייט מערך של הודעות. כל הודעה מכילה את התוכן (content) ומי כתב אותה (role).

  2. כשמשתמש מגיש את הטופס אני מוסיף למערך ההודעות שתי הודעות, הודעה מהמשתמש עם התוכן שלקחתי מהטופס והודעה ריקה של ה AI. עוד מעט השרת יתחיל להזרים לי את תוכן ההודעה ואני אשתמש בו כדי למלא את משתנה הסטייט.

  3. כשאני מקבל הזרמה מהשרת אני מעדכן את ההודעה האחרונה (הריקה שאני יצרתי) עם התוכן:

const { done, value } = await reader.read();
if (done) break;
setMessages(prev => prev.with(-1, {
  content: prev.at(-1)!.content + decoder.decode(value),
  role: prev.at(-1)!.role,
}));

יצירת הודעת הסוכן ריקה מראש חוסכת לי טיפול מיוחד בהודעה הראשונה.

עכשיו נעבור לצד השרת - השרת מקבל הפעם מערך של הודעות ועליו להפעיל את הסוכן עם כל ההיסטוריה. ב SDK של טייפסקריפט יש שתי פונקציות של הספריה בהן עלינו להשתמש כדי "להפוך" את אובייקטי ההודעות שלנו לאוביקטי הודעות שהסוכן צריך.

אנחנו מקבלים מהלקוח אוביקט הודעה שנראה כך:

{ role: 'user', content: 'hello' }

אבל בשביל מערך ההודעות שיישלח לסוכן אנחנו צריכים להעביר הודעה שנראית כך:

{ role: 'user', content: [
    { type: 'input_text', text: 'hello' }
] }

התוכן של הודעה מורכב ממערך של "פריטים" שיכולים להגיע בסוגים שונים - כן יש טקסט אבל יכולות להיות גם תמונות וקבצים. שתי פונקציות ההמרה נקראות user ו assistant ולכן בקוד הנתיב בצד השרת עליי לכתוב:

const messages = await request.json();

const messagesForAgent = messages.map((m: any) => 
  (m.role === 'user' ? user(m.content) : assistant(m.content)))

כל השאר נשאר דומה לדוגמה הקודמת וקוד צד השרת המלא בקובץ route.ts הוא:

import { Agent, run, user, assistant } from '@openai/agents';

const agent = new Agent({
  name: 'Assistant',
  instructions: 'You are a helpful assistant',
});

export async function POST(request: Request) {
  const messages = await request.json();

  const messagesForAgent = messages.map((m: any) => 
    (m.role === 'user' ? user(m.content) : assistant(m.content)))

  console.log(messagesForAgent);

  const result = await run(agent, messagesForAgent, {
    stream: true,
  });

  // Convert the result to a standard ReadableStream
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      try {
        for await (const event of result) {
          if (event.type === 'raw_model_stream_event') {
            if (event.data.type === "output_text_delta") {
              controller.enqueue(encoder.encode(event.data.delta));
            }                        
          }          
        }
      } catch (error) {
        controller.error(error);
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Transfer-Encoding': 'chunked',
    },
  });
}

אתם יכולים למצוא את הקוד המלא של הדוגמה במאגר הגיט:

https://github.com/ynonp/next-openai-agents-demo

4. עכשיו אתם

מנגנון ה Tools קיים גם בגירסת הטייפסקריפט בדומה לגירסת הפייתון. קראו את מדריך ה Tools כאן:

https://openai.github.io/openai-agents-js/guides/tools/

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