ماندگاری پیام در چت‌بات هوش مصنوعی


توانایی ذخیره‌سازی و بارگذاری پیام‌های چت برای اکثر چت‌بات‌های هوش مصنوعی یک قابلیت ضروری محسوب می‌شود. در این راهنما، نشان خواهیم داد که چگونه می‌توان پایداری پیام (message persistence) را با استفاده از useChat و streamText پیاده‌سازی کرد.

این راهنما شامل مباحث احراز هویت (Authorization)، مدیریت خطا (Error Handling) یا سایر ملاحظات دنیای واقعی نیست. هدف اصلی آن ارائه یک مثال ساده از نحوه پیاده‌سازی قابلیت ماندگاری پیام است.


شروع یک گفت‌وگوی جدید

هنگامی که کاربر بدون ارائه chat ID وارد صفحه چت می‌شود، لازم است که ما یک گفت‌وگوی جدید ایجاد کنیم و سپس کاربر را به همان صفحه چت با chat ID جدید هدایت کنیم. می‌توانید در مسیر app/chat/page.tsx قطعه کد زیر را قرار دهید:

کپی
import { redirect } from 'next/navigation';
import { createChat } from '@/tools/chat-store';

export default async function Page() {
  const id = await createChat(); // create a new chat
  redirect(`/chat/${id}`); // redirect to chat page, see below
}

در پیاده‌سازی فوق، پیام‌های چت در فایل‌ها ذخیره می‌شوند. اما در یک برنامه واقعی، معمولاً بهتر است از یک دیتابیس یا یک سرویس ذخیره‌سازی ابری، مانند فضای ذخیره‌سازی ابری لیارا استفاده کنید و chat ID را از پایگاه داده دریافت کنید. با این حال، function interfaceها به گونه‌ای طراحی شده‌اند که بتوان آن‌ها را به‌راحتی با پیاده‌سازی‌های دیگر جایگزین کرد. در مسیر tools/chat-store.ts، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add ai@^4

import { generateId } from 'ai';
import { existsSync, mkdirSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';

export async function createChat(): Promise<string> {
  const id = generateId(); // generate a unique chat ID
  await writeFile(getChatFile(id), '[]'); // create an empty chat file
  return id;
}

function getChatFile(id: string): string {
  const chatDir = path.join(process.cwd(), '.chats');
  if (!existsSync(chatDir)) mkdirSync(chatDir, { recursive: true });
  return path.join(chatDir, `${id}.json`);
}

بارگذاری یک گفت‌وگوی موجود

زمانی که کاربر با یک chat ID وارد یک صفحه چت می‌شود، لازم است پیام‌های آن چت، بارگذاری شده و نمایش داده شوند. در مسیر app/chat/[id]/page.tsx، می‌توانید قطعه کد زیر را قرار دهید:

کپی
import { loadChat } from '@/tools/chat-store';
import Chat from '@/ui/chat';

export default async function Page(props: { params: Promise<{ id: string }> }) {
  const { id } = await props.params; // get the chat ID from the URL
  const messages = await loadChat(id); // load the chat messages
  return <Chat id={id} initialMessages={messages} />; // display the chat
}

تابع loadChat را در مسیر tools/chat-store.ts می‌توانید به صورت زیر پیاده‌سازی کنید:

کپی
// npm add ai@^4

import { generateId } from 'ai';
import { existsSync, mkdirSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';
import { Message } from 'ai';
import { readFile } from 'fs/promises';

export async function loadChat(id: string): Promise<Message[]> {
  return JSON.parse(await readFile(getChatFile(id), 'utf8'));
}

export async function createChat(): Promise<string> {
  const id = generateId(); // generate a unique chat ID
  await writeFile(getChatFile(id), '[]'); // create an empty chat file
  return id;
}

function getChatFile(id: string): string {
  const chatDir = path.join(process.cwd(), '.chats');
  if (!existsSync(chatDir)) mkdirSync(chatDir, { recursive: true });
  return path.join(chatDir, `${id}.json`);
}

Display Component یک کامپوننت ساده‌ی چت است که از هوک useChat برای ارسال و دریافت پیام‌ها استفاده می‌کند. در مسیر ui/chat.tsx، می‌توانید قطعه کد زیر را قرار دهید:

کپی
'use client';

import { Message, useChat } from '@ai-sdk/react';

export default function Chat({
  id,
  initialMessages,
}: { id?: string | undefined; initialMessages?: Message[] } = {}) {
  const { input, handleInputChange, handleSubmit, messages } = useChat({
    id, // use the provided chat ID
    initialMessages, // initial messages if provided
    sendExtraMessageFields: true, // send id and createdAt for each message
  });

  // simplified rendering code, extend as needed:
  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role === 'user' ? 'User: ' : 'AI: '}
          {m.content}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
      </form>
    </div>
  );
}

ذخیره‌سازی پیام‌ها

هوک useChat مقدار chat ID و پیام‌ها را به سمت بک‌اند ارسال می‌کند. ما گزینه‌ی sendExtraMessageFields را فعال کردیم تا فیلدهای id و createdAt نیز ارسال شوند؛ در نظر داشته باشید که پیام‌ها، در قالب پیام‌های useChat ذخیره خواهند شد.

قالب پیام‌های useChat با قالب پیام‌های CoreMessage متفاوت است. قالب پیام‌های useChat برای نمایش در فرانت‌اند طراحی شده و شامل فیلدهای اضافی نظیر id و createdAt است. پیشنهاد ما این است که پیام‌ها را در قالب پیام‌های useChat ذخیره کنید.

فرآیند ذخیره‌سازی پیام‌ها در callback مربوط به onFinish در تابع streamText انجام می‌شود. onFinish پیام‌های پاسخ هوش مصنوعی را به صورت یک آرایه‌ی []CoreMessage دریافت می‌کند و ما با استفاده از یک helper به نام appendResponseMessages، پیام‌های پاسخ را به مجموعه‌ی پیام‌های چت اضافه می‌کنیم. در مسیر app/api/chat/route.ts، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add @ai-sdk/openai@^1 ai@^4

import { createOpenAI } from '@ai-sdk/openai';
import { appendResponseMessages, streamText } from 'ai';
import { saveChat } from '@/tools/chat-store';

const my_model = createOpenAI({
  baseURL: process.env.BASE_URL!,
  apiKey: process.env.LIARA_API_KEY!,
});

export async function POST(req: Request) {
  const { messages, id } = await req.json();

  const result = streamText({
    model: my_model('openai/gpt-4o-mini'),
    messages,
    async onFinish({ response }) {
      await saveChat({
        id,
        messages: appendResponseMessages({
          messages,
          responseMessages: response.messages,
        }),
      });
    },
  });

  return result.toDataStreamResponse();
}

ذخیره‌سازی واقعی پیام‌ها در تابع saveChat انجام می‌شود. در مسیر tools/chat-store.ts، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add ai@^4

import { generateId } from 'ai';
import { existsSync, mkdirSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';
import { Message } from 'ai';
import { readFile } from 'fs/promises';

export async function saveChat({
  id,
  messages,
}: {
  id: string;
  messages: Message[];
}): Promise<void> {
  const content = JSON.stringify(messages, null, 2);
  await writeFile(getChatFile(id), content);
}

export async function loadChat(id: string): Promise<Message[]> {
  return JSON.parse(await readFile(getChatFile(id), 'utf8'));
}

export async function createChat(): Promise<string> {
  const id = generateId(); // generate a unique chat ID
  await writeFile(getChatFile(id), '[]'); // create an empty chat file
  return id;
}

function getChatFile(id: string): string {
  const chatDir = path.join(process.cwd(), '.chats');
  if (!existsSync(chatDir)) mkdirSync(chatDir, { recursive: true });
  return path.join(chatDir, `${id}.json`);
}

IDهای پیام

علاوه بر chat ID، هر پیام دارای یک ID نیز است. از این ID می‌توان برای کارهایی مانند مدیریت هر پیام، استفاده کرد. IDهای مربوط به پیام‌های کاربر توسط هوک useChat در سمت کلاینت تولید می‌شوند، در حالی که IDهای پیام‌های پاسخ هوش مصنوعی توسط streamText ساخته می‌شوند. شما می‌توانید قالب IDها را با ارائه‌ی ID generatorها کنترل کنید. می‌توانید در مسیر ui/chat.tsx، قطعه کد زیر را قرار دهید:

کپی
// npm add ai@^4

'use client';

import { Message, useChat } from '@ai-sdk/react';
import { createIdGenerator } from 'ai';

export default function Chat({
  id,
  initialMessages,
}: { id?: string | undefined; initialMessages?: Message[] } = {}) {
  const { input, handleInputChange, handleSubmit, messages } = useChat({
    id, 
    initialMessages, 
    sendExtraMessageFields: true, 

    // id format for client-side messages:
    generateId: createIdGenerator({
    prefix: 'msgc',
    size: 16,
    }),
  });

  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role === 'user' ? 'User: ' : 'AI: '}
          {m.content}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
      </form>
    </div>
  );
}

در مسیر app/api/chat/route.ts، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add @ai-sdk/openai@^1 ai@^4

import { createOpenAI } from '@ai-sdk/openai';
import { appendResponseMessages, streamText, createIdGenerator } from 'ai';
import { saveChat } from '@/tools/chat-store';

const my_model = createOpenAI({
  baseURL: process.env.BASE_URL!,
  apiKey: process.env.LIARA_API_KEY!,
});

export async function POST(req: Request) {
  const { messages, id } = await req.json();

  const result = streamText({
    model: my_model('openai/gpt-4o-mini'),
    messages,
    async onFinish({ response }) {
      await saveChat({
        id,
        messages: appendResponseMessages({
          messages,
          responseMessages: response.messages,
        }),
      });
    },
    experimental_generateMessageId: createIdGenerator({
      prefix: 'msgs',
      size: 16,
    }),

  });

  return result.toDataStreamResponse();
}

ارسال فقط آخرین پیام

پس از پیاده‌سازی قابلیت ماندگاری پیام، ممکن است بخواهید تنها آخرین پیام را به سرور ارسال کنید. این کار میزان داده‌های ارسالی به سرور در هر درخواست را کاهش داده و می‌تواند عملکرد سیستم را بهبود بخشد. در مسیر ui/chat.tsx، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add ai@^4

'use client';

import { Message, useChat } from '@ai-sdk/react';
import { createIdGenerator } from 'ai';

export default function Chat({
  id,
  initialMessages,
}: { id?: string | undefined; initialMessages?: Message[] } = {}) {
  const { input, handleInputChange, handleSubmit, messages } = useChat({
    id, 
    initialMessages, 
    sendExtraMessageFields: true, 

    generateId: createIdGenerator({
    prefix: 'msgc',
    size: 16,
    }),
    
    // only send the last message to the server:
    experimental_prepareRequestBody({ messages, id }) {
    return { message: messages[messages.length - 1], id };
    },
  });

  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.role === 'user' ? 'User: ' : 'AI: '}
          {m.content}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
      </form>
    </div>
  );
}

سپس، در سمت سرور، شما می‌توانید پیام‌های قبلی را بارگذاری کرده و پیام جدید را به پیام‌های قبلی اضافه کنید. در مسیر app/api/chat/route.ts، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add @ai-sdk/openai@^1 ai@^4

import { createOpenAI } from '@ai-sdk/openai';
import { appendResponseMessages, streamText, createIdGenerator, appendClientMessage } from 'ai';
import { saveChat, loadChat } from '@/tools/chat-store';

const my_model = createOpenAI({
  baseURL: process.env.BASE_URL!,
  apiKey: process.env.LIARA_API_KEY!,
});

export async function POST(req: Request) {
  const { message, id } = await req.json();

  const previousMessages = await loadChat(id);

  const messages = appendClientMessage({
    messages: previousMessages,
    message,
  });


  const result = streamText({
    model: my_model('openai/gpt-4o-mini'),
    messages,
    async onFinish({ response }) {
      await saveChat({
        id,
        messages: appendResponseMessages({
          messages,
          responseMessages: response.messages,
        }),
      });
    },
    experimental_generateMessageId: createIdGenerator({
      prefix: 'msgs',
      size: 16,
    }),

  });

  return result.toDataStreamResponse();
}

مدیریت قطع اتصال کلاینت

به‌طور پیش‌فرض، تابع streamText در AI SDK از مکانیزم backpressure برای ارائه‌دهنده‌ی مدل استفاده می‌کند تا از مصرف توکن‌هایی که هنوز درخواست نشده‌اند جلوگیری کند. در واقع، اگر کلاینت اتصال خود را قطع کند (مثلاً با بستن تب مرورگر یا به دلیل یک مشکل در شبکه)، استریم از LLM متوقف شده و مکالمه ممکن است در وضعیت ناقص قرار بگیرد.

با فرض اینکه یک راهکار ذخیره‌سازی در اختیار دارید، می‌توانید از متد consumeStream برای مصرف استریم در بک‌اند استفاده کرده و سپس نتیجه را مانند حالت عادی ذخیره کنید. استفاده از consumeStream در عمل، backpressure را حذف می‌کند و نتیجه حتی زمانی که کلاینت از قبل قطع اتصال کرده باشد نیز ذخیره می‌شود.

در مسیر app/api/chat/route.ts، می‌توانید قطعه کد زیر را قرار دهید:

کپی
// npm add @ai-sdk/openai@^1 ai@^4

import { createOpenAI } from '@ai-sdk/openai';
import { appendResponseMessages, streamText, createIdGenerator, appendClientMessage } from 'ai';
import { saveChat, loadChat } from '@/tools/chat-store';

const my_model = createOpenAI({
  baseURL: process.env.BASE_URL!,
  apiKey: process.env.LIARA_API_KEY!,
});

export async function POST(req: Request) {
  const { message, id } = await req.json();

  const previousMessages = await loadChat(id);

  const messages = appendClientMessage({
    messages: previousMessages,
    message,
  });

  const result = streamText({
    model: my_model('openai/gpt-4o-mini'),
    messages,
    async onFinish({ response }) {
      await saveChat({
        id,
        messages: appendResponseMessages({
          messages,
          responseMessages: response.messages,
        }),
      });
    },
    experimental_generateMessageId: createIdGenerator({
      prefix: 'msgs',
      size: 16,
    }),

  });

  // consume the stream to ensure it runs to completion & triggers onFinish
  // even when the client response is aborted:
  result.consumeStream(); // no await

  return result.toDataStreamResponse();
}

هنگامی که کاربر صفحه را بعد از قطع اتصال، مجدداً بارگذاری می‌کند، چت از راهکار ذخیره‌سازی بازیابی خواهد شد.

در برنامه‌های واقعی، بهتر است که وضعیت درخواست را در پیام‌های ذخیره‌شده‌تان نیز رهگیری کرده و در کلاینت از آن استفاده کنید تا حالتی را که در آن، کلاینت صفحه را بعد از قطع اتصال مجدداً بارگذاری می‌کند، اما استریم هنوز کامل نشده است، پوشش دهد.