
আমাদের আগের পর্বে আমরা যখন Server-Sent Events (SSE) দিয়ে আরও Reliable Streaming বানানো নিয়ে কাজ করেছিলাম, তখন ৩-সেকেন্ডের হার্টবিট পালস এবং অ্যান্টি-বাফারিং হেডার বসিয়ে নিশ্চিত করেছিলাম যেন দুর্বল নেটওয়ার্ক বা রিভার্স প্রক্সির কারণে এজেন্টের স্ট্রিম মাঝপথে কেটে না যায়। আমাদের DevPulse এজেন্ট এখন কথা বলে দ্রুত, টুল চালায় নিখুঁতভাবে এবং নেটওয়ার্কের ঝড়ও সামলাতে পারে। কিন্তু এখনো আমাদের অ্যাপে একটি বড় সীমাবদ্ধতা রয়ে গেছে: ইউজার ভুল করে ব্রাউজারের ট্যাবটি একবার রিফ্রেশ (F5) দিলেই পুরো চ্যাট হিস্ট্রি, গিটহাব টুল কার্ড এবং টোকেন টেলিমেট্রি এক নিমেষে হাওয়া হয়ে যায়!
Next.js প্রজেক্টে স্ট্রিমিংয়ের গতি এক বিন্দুও না কমিয়ে AI Agent-এর পুরো Conversation History Supabase (PostgreSQL)-এ সেভ করে রাখার সবচেয়ে কার্যকর পদ্ধতি হলো দুটি রিলেশনাল টেবিল (agent_sessions এবং agent_messages) তৈরি করা—যেখানে সাধারণ টেক্সটের পাশাপাশি এজেন্টের টুল কল ও ফলাফলের জন্য থাকবে JSONB কলাম (tool_invocations ও telemetry)। পেজ লোড হওয়ার সময় Server Component থেকে পুরনো মেসেজগুলো হাইড্রেট (initialMessages) করে এবং নতুন মেসেজ সেভ করার কাজটি স্ট্রিমিং শেষ হওয়ার পর onFinish কলব্যাকের ভেতরে রাখলে ইউজার আগের মতোই ~৩০০ মিলিসেকেন্ডে প্রথম শব্দের স্ট্রিম পান, অথচ পেজ রিফ্রেশ দিলেও প্রতিটি টুল কার্ড ও মেসেজ হুবহু আগের মতোই স্ক্রিনে টিকে থাকে।
আজকের এই ২৪তম পর্বের মাধ্যমে আমাদের ইমপ্লিমেন্টেশন রোডম্যাপের তৃতীয় ধাপ (Phase 3: Rate Limiting, SSE & Supabase Persistence, পর্ব ২২–২৪) সফলভাবে শেষ হতে যাচ্ছে।
সাধারণ চ্যাট অ্যাপের চেয়ে AI Agent-এর হিস্ট্রি সেভ করা কেন আলাদা?
সাধারণ মেসেজিং অ্যাপ বানানোর সময় ডেভেলপাররা ডেটাবেসে সাধারণত তিনটি কলাম রাখেন: id, role, এবং content। কিন্তু আমাদের মাল্টি-স্টেপ AI Agent-এর জন্য শুধু এই তিনটি কলাম রাখলে পেজ রিফ্রেশ দেওয়ার সাথে সাথে তিনটি বড় সমস্যা তৈরি হবে:
১. টুল কার্ড ও কনফার্মেশন হারিয়ে যাওয়া (tool_invocations): ১৭ থেকে ২১তম পর্বে আমরা যে সুন্দর GitHubToolCard এবং AgentThinkingTimeline বানিয়েছিলাম, সেগুলো রেন্ডার হয় m.toolInvocations অ্যারে থেকে। আপনি যদি ডেটাবেসে শুধু চূড়ান্ত টেক্সটটুকু (m.content) সেভ করেন, তবে পেজ রিফ্রেশ দিলেই সব টুল কার্ড উধাও হয়ে যাবে!
২. পরবর্তী প্রশ্নে এজেন্টের কনটেক্সট নষ্ট হওয়া: ইউজার যদি আগের টুলের ফলাফল দেখে পরের প্রশ্নে বলেন—'আগের ইস্যুটির দ্বিতীয় লেবেলটা নিয়ে একটু বলো'—তখন ডাটাবেসে টুলের JSON আউটপুট সেভ না থাকলে এজেন্ট বুঝতেই পারবে না কোন ইস্যুর কথা বলা হচ্ছে।
৩. স্ট্রিমিং শুরুর আগে ডাটাবেস ল্যাটেন্সি যোগ হওয়া: আপনি যদি LLM স্ট্রিম শুরু করার আগে ডেটাবেসে রাইট (await supabase.insert()) করার জন্য অপেক্ষা করেন কিংবা প্রতি টোকেন ডেল্টায় ডেটাবেস আপডেট করতে যান, তবে আপনার স্ট্রিমিং ৩০০-৪০০ মিলিসেকেন্ড স্লো হয়ে যাবে।
তাই প্রোডাকশন এজেন্টের জন্য আমাদের দরকার এমন একটি স্কিমা যা পুরো টার্নের সবটুকু তথ্য (content + tool_invocations JSONB + telemetry JSONB) স্ট্রিম শেষ হওয়ার পর এক ধাক্কায় (Atomic Batch Insert) সেভ করে ফেলবে।

Supabase-এ এজেন্টের জন্য PostgreSQL টেবিল ডিজাইন
প্রথমে আপনার Supabase ড্যাশবোর্ডের SQL Editor-এ গিয়ে নিচের কুয়েরিটি রান করুন। এখানে আমরা সেশন ট্র্যাক করার জন্য agent_sessions এবং প্রতিটি মেসেজ ও টুলের ডেটা রাখার জন্য agent_messages টেবিল তৈরি করছি:
`sql -- supabase/migrations/20261106_agent_history.sql create extension if not exists "pgcrypto";
create table if not exists public.agent_sessions ( id uuid primary key default gen_random_uuid(), external_session_key text unique not null, title text not null default 'New DevPulse Triage Session', active_model text not null default 'claude-3-5-sonnet-latest', created_at timestamptz not null default now(), updated_at timestamptz not null default now() );
create table if not exists public.agent_messages ( id uuid primary key default gen_random_uuid(), session_id uuid not null references public.agent_sessions(id) on delete cascade, role text not null check (role in ('user', 'assistant')), content text not null default '', tool_invocations jsonb not null default '[]'::jsonb, telemetry jsonb default null, sequence_number bigint generated always as identity, created_at timestamptz not null default now() );
create index if not exists idx_agent_messages_session_seq on public.agent_messages (session_id, sequence_number asc);
alter table public.agent_sessions enable row level security; alter table public.agent_messages enable row level security; `
এই SQL স্কিমাটিতে তিনটি গুরুত্বপূর্ণ ইঞ্জিনিয়ারিং সিদ্ধান্ত খেয়াল করুন:
sequence_number bigint generated always as identity: আমরা যখন স্ট্রিম শেষ হওয়ার পর ইউজারের প্রশ্ন এবং এজেন্টের উত্তর একসাথে Batch Insert করব, তখন দুটি রো-এরইcreated_atটাইমস্ট্যাম্প হুবহু একই মাইক্রোসেকেন্ড হয়ে যায়! ফলে শুধুcreated_atদিয়ে সর্ট করলে মাঝে মাঝে ইউজারের প্রশ্নের আগেই এজেন্টের উত্তর উপরে চলে আসে। কিন্তুsequence_numberথাকায় মেসেজের ক্রম কখনোই উল্টোপাল্টা হবে না।tool_invocations jsonbকলাম: PostgreSQL-এরJSONBফরম্যাটে আমরা সরাসরি পুরোToolInvocation[]অ্যারে সেভ করে রাখছি, যাতে পেজ রিফ্রেশ দিলে গিটহাব কার্ড বা ডকুমেন্টেশন সার্চের ফলাফল হুবহু আগের মতোই রেন্ডার হয়।- Composite Index (
idx_agent_messages_session_seq):session_idএবংsequence_number-এর ওপর ইনডেক্স থাকায় লাখ লাখ মেসেজের ভেতর থেকেও মাত্র ২-৩ মিলিসেকেন্ডে নির্দিষ্ট সেশনের চ্যাট লোড হয়ে যাবে।
নিচের সিকোয়েন্স ডায়াগ্রামে দেখুন কীভাবে পেজ লোড হাইড্রেশন এবং নন-ব্লকিং onFinish পারসিস্টেন্স একসাথে কাজ করে:
`mermaid sequenceDiagram participant UI as React Client (useChat) participant Server as Next.js Server / Route Handler participant DB as Supabase PostgreSQL (JSONB) participant LLM as Claude 3.5 Sonnet / GPT-4o
UI->>Server: ১. পেজ লোড: আগের চ্যাট হিস্ট্রি রিকোয়েস্ট
Server->>DB: SELECT messages ORDER BY sequence_number ASC
DB-->>Server: রো রিটার্ন (content + tool_invocations + telemetry)
Server-->>UI: useChat({ initialMessages }) হাইড্রেশন
UI->>Server: ২. ইউজারের নতুন প্রশ্ন (POST /api/chat)
Server->>LLM: সাথে সাথে streamText() শুরু (~300ms TTFT)
LLM-->>UI: লাইভ টুল কার্ড ও টেক্সট স্ট্রিমিং
LLM->>Server: স্ট্রিম শেষে onFinish({ text, steps, usage }) ট্রিগার
Server->>DB: Atomic Batch INSERT (User Turn + Assistant Turn + JSONB)
`
আর্কিটেকচার ফ্লো সামারি (Architecture Flow Summary): পেজ লোড হওয়ার সময় Next.js Server Component সরাসরি Supabase থেকে sequence_number অনুযায়ী আগের মেসেজগুলো এনে useChat-এর initialMessages-এ বসিয়ে দেয়। এরপর ইউজার নতুন প্রশ্ন করলে সার্ভার ডেটাবেস রাইটের জন্য অপেক্ষা না করে সাথে সাথে স্ট্রিমিং শুরু করে (~৩০০ মিলিসেকেন্ড TTFT), এবং উত্তর সম্পূর্ণ শেষ হওয়ার পর onFinish হুকে ইউজারের প্রশ্ন, এজেন্টের উত্তর, টুল কল ও টেলিমেট্রি একসাথে একটি ব্যাচ কুয়েরিতে Supabase-এ সেভ করে।
ধাপ ১: Supabase SDK ইনস্টল এবং এনভায়রনমেন্ট কনফিগারেশন
আপনার প্রজেক্টের টার্মিনালে অফিশিয়াল Supabase ক্লায়েন্ট প্যাকেজটি ইনস্টল করে নিন:
bash npm install @supabase/supabase-js server-only
এরপর .env.local ফাইলে আপনার Supabase প্রজেক্টের URL এবং Service Role Key যুক্ত করুন। যেহেতু আমরা শুধুমাত্র সার্ভার সাইড থেকে ডেটাবেসে রিড/রাইট করব, তাই কখনোই এগুলোর আগে NEXT_PUBLIC_ বসাবেন না:
`ini
.env.local
SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... `
ধাপ ২: টাইপ-সেফ History Store তৈরি করা (src/lib/agent/history-store.ts)
চলুন এবার তৈরি করি src/lib/agent/history-store.ts। এই মডিউলটি তিনটি কাজ করবে: সেশন না থাকলে তৈরি করবে (getOrCreateSessionId), পেজ লোডের সময় Supabase থেকে মেসেজ এনে Vercel AI SDK-এর Message ফরম্যাটে সাজিয়ে দেবে (loadSessionHistory), এবং টার্ন শেষ হলে ইউজার ও অ্যাসিস্ট্যান্টের মেসেজ একসাথে সেভ করবে (persistCompletedAgentTurn):
`typescript // src/lib/agent/history-store.ts import 'server-only'; import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import { type Message, type ToolInvocation } from 'ai'; import { type MessageTelemetryAnnotation } from '@/app/api/chat/route';
interface AgentMessageRow { id: string; session_id: string; role: 'user' | 'assistant'; content: string; tool_invocations: ToolInvocation[]; telemetry: MessageTelemetryAnnotation | null; sequence_number: number; created_at: string; }
function getSupabaseAdmin(): SupabaseClient { const url = process.env.SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) { throw new Error( 'Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in server environment.' ); }
return createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false }, }); }
export async function getOrCreateSessionId( externalSessionKey: string, modelId: string ): Promise { const supabase = getSupabaseAdmin();
const { data: existing, error: selectErr } = await supabase .from('agent_sessions') .select('id') .eq('external_session_key', externalSessionKey) .maybeSingle();
if (selectErr) {
throw new Error(Supabase session lookup failed: ${selectErr.message});
}
if (existing?.id) { return existing.id; }
const { data: created, error: insertErr } = await supabase .from('agent_sessions') .insert({ external_session_key: externalSessionKey, active_model: modelId, }) .select('id') .single();
if (insertErr || !created) {
throw new Error(Failed to create agent session: ${insertErr?.message});
}
return created.id; }
export async function loadSessionHistory( externalSessionKey: string, limit = 40 ): Promise<Message[]> { const supabase = getSupabaseAdmin();
const { data: session } = await supabase .from('agent_sessions') .select('id') .eq('external_session_key', externalSessionKey) .maybeSingle();
if (!session?.id) return [];
const { data: rows, error } = await supabase .from('agent_messages') .select('*') .eq('session_id', session.id) .order('sequence_number', { ascending: true }) .limit(limit);
if (error || !rows) { console.error('Failed to load session messages:', error?.message); return []; }
return (rows as AgentMessageRow[]).map((row) => ({ id: row.id, role: row.role, content: row.content, toolInvocations: Array.isArray(row.tool_invocations) && row.tool_invocations.length > 0 ? row.tool_invocations : undefined, annotations: row.telemetry ? [row.telemetry as unknown as Record<string, string | number | string[]>] : undefined, createdAt: new Date(row.created_at), })); }
export interface PersistTurnPayload { externalSessionKey: string; modelId: string; userPrompt: string; assistantReply: string; toolInvocations: ToolInvocation[]; telemetry: MessageTelemetryAnnotation; }
export async function persistCompletedAgentTurn( payload: PersistTurnPayload ): Promise { const supabase = getSupabaseAdmin(); const sessionUuid = await getOrCreateSessionId( payload.externalSessionKey, payload.modelId );
const rowsToInsert = [ { session_id: sessionUuid, role: 'user' as const, content: payload.userPrompt, tool_invocations: [], telemetry: null, }, { session_id: sessionUuid, role: 'assistant' as const, content: payload.assistantReply, tool_invocations: payload.toolInvocations, telemetry: payload.telemetry, }, ];
const { error } = await supabase.from('agent_messages').insert(rowsToInsert);
if (error) { console.error('Failed to persist agent turn to Supabase:', error.message); return; }
await supabase .from('agent_sessions') .update({ updated_at: new Date().toISOString(), active_model: payload.modelId, }) .eq('id', sessionUuid); } `
উপরের persistCompletedAgentTurn ফাংশনটিতে দেখুন আমরা কীভাবে ইউজারের প্রশ্ন এবং অ্যাসিস্ট্যান্টের উত্তর আলাদা সময়ে ইনসার্ট না করে একটি সিঙ্গেল অ্যারেতে (rowsToInsert) একসাথে ইনসার্ট করছি। এর ফলে:
১. মাঝপথে নেটওয়ার্ক কেটে গেলে বা API এরর হলে ডেটাবেসে কখনোই উত্তরবিহীন আধখানা 'user' মেসেজ জমা হয়ে থাকবে না।
২. পুরো টার্নে ডেটাবেসে মাত্র ১ বার রাইট কল যাবে—তাও ইউজার স্ক্রিনে সম্পূর্ণ উত্তর পেয়ে যাওয়ার পর!
ধাপ ৩: /api/chat/route.ts-এর onFinish-এ ডেটাবেস পারসিস্টেন্স যুক্ত করা
এবার আমাদের src/app/api/chat/route.ts ফাইলে createAgentTextStream-এর কলব্যাক থেকে সরাসরি persistCompletedAgentTurn() কল করে দেব:
`typescript // src/app/api/chat/route.ts (স্ট্রিম শেষে অটোমেটিক Supabase সেভ) import { NextRequest, NextResponse } from 'next/server'; import { createDataStreamResponse, type ToolInvocation } from 'ai'; import { z } from 'zod'; import { defaultDevPulseConfig } from '@/lib/agent/blueprint'; import { type ChatMessage } from '@/lib/agent/llm-client'; import { createAgentTextStream } from '@/lib/agent/stream-client'; import { persistCompletedAgentTurn } from '@/lib/agent/history-store';
export const maxDuration = 60;
const routePayloadSchema = z.object({ sessionId: z.string().min(8), modelId: z .enum(['gpt-4o', 'claude-3-5-sonnet-latest']) .default('claude-3-5-sonnet-latest'), messages: z .array( z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string(), }) ) .min(1), });
export async function POST(req: NextRequest) { const body = await req.json().catch(() => null); const parsed = routePayloadSchema.safeParse(body);
if (!parsed.success) { return NextResponse.json({ error: 'Invalid chat payload' }, { status: 400 }); }
const { sessionId, modelId, messages } = parsed.data; const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
const normalizedMessages: ChatMessage[] = messages.filter( (m): m is { role: 'user' | 'assistant'; content: string } => (m.role === 'user' || m.role === 'assistant') && m.content.trim().length > 0 );
return createDataStreamResponse({ execute: (dataStream) => { const result = createAgentTextStream( normalizedMessages, { ...defaultDevPulseConfig, modelId, }, async (telemetry, completionDetails) => { dataStream.writeMessageAnnotation(telemetry);
if (lastUserMessage) {
await persistCompletedAgentTurn({
externalSessionKey: sessionId,
modelId,
userPrompt: lastUserMessage.content,
assistantReply: completionDetails.text,
toolInvocations: completionDetails.toolInvocations,
telemetry,
});
}
}
);
result.mergeIntoDataStream(dataStream);
},
}); } `
আর আমাদের src/lib/agent/stream-client.ts ফাইলের onFinish({ text, usage, steps })-এর ভেতরে প্রতিটি ধাপের toolResults থেকে completedToolInvocations অ্যারেটি তৈরি করে কলব্যাকে পাস করে দিন:
`typescript // src/lib/agent/stream-client.ts-এর onFinish ব্লকের ভেতরে const completedToolInvocations: ToolInvocation[] = [];
for (const step of steps) { for (const res of step.toolResults ?? []) { completedToolInvocations.push({ state: 'result', toolCallId: res.toolCallId, toolName: res.toolName, args: res.args, result: res.result, }); } } `
ধাপ ৪: পেজ লোড হওয়ার সময় পুরনো চ্যাট ও টুল কার্ড ফিরিয়ে আনা (page.tsx)
Next.js App Router-এ যেহেতু পেজ কম্পোনেন্ট (src/app/page.tsx) ডিফল্টভাবে একটি Server Component, তাই আমরা ব্রাউজারে পেজ রেন্ডার হওয়ার আগেই সার্ভার থেকে সরাসরি loadSessionHistory() কল করে আগের সব মেসেজ নিয়ে আসতে পারি:
`tsx // src/app/page.tsx import { loadSessionHistory } from '@/lib/agent/history-store'; import DevPulseStreamChat from '@/components/DevPulseStreamChat';
interface PageProps { searchParams: Promise<{ session?: string }>; }
export default async function DevPulsePage({ searchParams }: PageProps) { const params = await searchParams; const sessionKey = params.session ?? 'devpulse-prod-session-01'; const initialMessages = await loadSessionHistory(sessionKey, 40);
return ( ); } `
এখন আমাদের ক্লায়েন্ট কম্পোনেন্ট DevPulseStreamChat.tsx-এ শুধু useChat({ api: '/api/chat', id: sessionKey, initialMessages }) পাস করে দিলেই কাজ শেষ! আপনি এখন যতবারই ব্রাউজার রিফ্রেশ করুন না কেন, আগের প্রতিটি মেসেজ, গিটহাব ইস্যু কার্ড এবং টোকেন ব্যাজ সাথে সাথে স্ক্রিনে ভেসে উঠবে।

আমার নিজের একটি প্রজেক্ট অভিজ্ঞতা: রিফ্রেশ দেওয়ার পর যখন টুল কার্ড গায়েব হয়ে যেত এবং মেসেজের ক্রম উল্টে যেত!
আমি যখন আমার আগের ফুল-স্ট্যাক প্রজেক্টগুলো (যেমন OffenseOrbit-এ রিলেশনাল ডেটাবেস ডিজাইন এবং MealMap-এ ডেটা ট্র্যাকিং) করার অভিজ্ঞতা থেকে প্রথমবার আমাদের এজেন্টের চ্যাট হিস্ট্রি Supabase-এ সেভ করার কোড লিখি, তখন পরপর দুটি সূক্ষ্ম বাগের মুখে পড়েছিলাম।
প্রথমবার আমি খুব সাধারণ চিন্তা করে agent_messages টেবিলে শুধু role আর content কলাম রেখেছিলাম। চ্যাট করার সময় তো লাইভ স্ট্রিমে গিটহাব ইস্যু কার্ড আর Thinking Timeline খুব সুন্দর দেখা গেল। কিন্তু যেই আমি ব্রাউজারটি রিফ্রেশ (F5) দিলাম, অমনি দেখলাম টেক্সট উত্তরটা ঠিকই ফিরে এসেছে কিন্তু তার উপরের গিটহাব টুল কার্ড এবং স্টেপ কাউন্টার বেমালুম গায়েব! কারণ ডাটাবেসে tool_invocations এবং telemetry নামের কোনো JSONB কলামই আমি রাখিনি। তার চেয়েও বড় সমস্যা হলো—রিফ্রেশ দেওয়ার পর আমি যখন এজেন্টকে বললাম 'আগের ইস্যুটার লেবেলগুলো কী ছিল?', এজেন্ট আর উত্তর দিতে পারল না কারণ টুলের রেজাল্টগুলো হারিয়ে গিয়েছিল।
দ্বিতীয় বাগটি ছিল আরও মজার। ডাটাবেস কল কমানোর জন্য আমি যখন onFinish-এর ভেতরে [userMessage, assistantMessage] একসাথে ব্যাচ ইনসার্ট করা শুরু করলাম এবং লোড করার সময় ORDER BY created_at ASC লিখলাম, তখন দেখলাম প্রতি ৪-৫ বার রিফ্রেশ দিলে একবার ইউজারের প্রশ্নের আগেই এজেন্টের উত্তর উপরে চলে আসছে! ডিবাগ করে বুঝলাম, একই SQL ইনসার্ট স্টেটমেন্টে দুটি রো ঢোকার কারণে PostgreSQL দুটোকেই হুবহু একই মাইক্রোসেকেন্ডের now() টাইমস্ট্যাম্প দিচ্ছিল। ফলে সর্টিংয়ের সময় ডাটাবেস কখনো দ্বিতীয় রো-টিকে আগে পাঠিয়ে দিচ্ছিল। টেবিলে sequence_number bigint generated always as identity যোগ করে সেটির ওপর ইনডেক্স করার পর থেকে মেসেজের ক্রম আর কখনোই এলোমেলো হয়নি।
Supabase-এ এজেন্ট হিস্ট্রি সেভ করার সময় যে ৪টি ভুল এড়িয়ে চলবেন
পরের পর্বে (পর্ব ২৫: Client Component বনাম Server Component আর্কিটেকচার) যাওয়ার আগে নিশ্চিত হয়ে নিন আপনার ডেটাবেস লেয়ারে যেন এই ৪টি ভুল না থাকে:
১. স্ট্রিমিং চলাকালীন প্রতি টোকেনে ডাটাবেস আপডেট করা
কখনোই for await (const delta of textStream) লুপের ভেতরে ডাটাবেস UPDATE কুয়েরি চালাবেন না। এতে ১টি উত্তরের জন্য Supabase-এ ৫০-৮০টি রিকোয়েস্ট যাবে এবং আপনার স্ট্রিম মারাত্মক ধীরগতির হয়ে পড়বে। সবসময় স্ট্রিম শেষ হওয়ার পর onFinish হুকে মাত্র একবার ইনসার্ট করবেন।
২. ক্লায়েন্ট কম্পোনেন্টে SUPABASE_SERVICE_ROLE_KEY এক্সপোজ করা
কখনোই সার্ভিস রোল কী-এর নামের আগে NEXT_PUBLIC_ দেবেন না কিংবা ব্রাউজার থেকে সরাসরি মেসেজ টেবিলে ইনসার্ট করবেন না। ব্রাউজার থেকে সরাসরি রাইট অ্যাক্সেস থাকলে যে কেউ ভুয়া assistant মেসেজ ঢুকিয়ে এজেন্টের মেমোরি ম্যানিপুলেট করতে পারে।
৩. LLM-এ পাঠানোর সময় ডাটাবেসের সব মেসেজ পাঠিয়ে দেওয়া
ইউজার স্ক্রল করে পুরনো চ্যাট দেখার জন্য পেজ লোডে ৩০-৪০টি মেসেজ (loadSessionHistory(sessionKey, 40)) লোড করা ঠিক আছে। কিন্তু নতুন প্রশ্ন করার সময় LLM-এ যেন আমাদের ১৪তম পর্বের sanitizeConversationHistory(messages, 10) হয়ে শুধুমাত্র সর্বশেষ ১০টি মেসেজই যায়—নইলে পুরনো হিস্ট্রির কারণেই আপনার সব টোকেন শেষ হয়ে যাবে।
৪. শুধুমাত্র created_at দিয়ে মেসেজ সর্ট করা
ব্যাচ ইনসার্টের ক্ষেত্রে দুটি মেসেজের created_at একই হয়ে যায়। তাই সবসময় একটি অটো-ইনক্রিমেন্টিং sequence_number কলাম দিয়ে ORDER BY sequence_number ASC ব্যবহার করবেন।
প্রায়শই জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
টুল কলগুলো রাখার জন্য আলাদা রিলেশনাল টেবিল না বানিয়ে আমরা কেন agent_messages টেবিলে JSONB কলাম ব্যবহার করলাম?
প্রতিটি মেসেজের সাথেই তার টুল কল (tool_invocations) এবং টেলিমেট্রি ওতপ্রোতভাবে জড়িত। আলাদা টেবিল বানালে প্রতিবার চ্যাট লোড করার সময় একটি করে ভারী LEFT JOIN কুয়েরি চালাতে হতো। তার বদলে PostgreSQL-এর বাইনারি JSONB কলাম ব্যবহার করায় এক কুয়েরিতেই পুরো মেসেজ চলে আসে এবং সেটি সরাসরি Vercel AI SDK-এর Message টাইপের সাথে মিলে যায়।
ইউজার যদি উত্তর শেষ হওয়ার আগেই মাঝপথে ব্রাউজার ট্যাব বন্ধ করে দেন বা Stop চাপেন, তখন কী হবে?
যতক্ষণ স্ট্রিম সম্পূর্ণ শেষ না হয়, ততক্ষণ onFinish কল হয় না—ফলে আধখানা বা ত্রুটিপূর্ণ মেসেজ ডাটাবেসকে দূষিত করে না। তবে আপনি যদি ইউজারের Stop করা আংশিক উত্তরও সেভ রাখতে চান, তবে /api/chat রাউটের req.signal অ্যাবর্ট ইভেন্টে অ্যাকিউমুলেট হওয়া টেক্সটটুকু একটি [Aborted by user] ট্যাগসহ ইনসার্ট করে রাখতে পারেন।
চ্যাট হিস্ট্রি রাখার এই একই Supabase ডাটাবেসেই কি আমরা পরে Vector Search (RAG) করতে পারব?
হ্যাঁ, শতভাগ! এটিই Supabase বেছে নেওয়ার সবচেয়ে বড় সুবিধা। আমাদের এই একই PostgreSQL ডাটাবেসেই আমরা পরবর্তী সেকশনে (পর্ব ২৯) pgvector এক্সটেনশন চালু করব—যাতে আমাদের চ্যাট হিস্ট্রি (agent_messages) এবং ডকুমেন্টেশনের ভেক্টর এমবেডিং একই প্রজেক্টের ভেতরে একসাথে কাজ করে।
কাস্টম ওয়েব অ্যাপ বা এআই অটোমেশন বানাতে চান?
আমি স্টার্টআপ ও আধুনিক ব্যবসার জন্য স্কেলেবল ফুল-স্ট্যাক ওয়েব অ্যাপ্লিকেশন (Next.js, React, Node, PostgreSQL) এবং ইন্টেলিজেন্ট এআই এজেন্ট সলিউশন তৈরি করি। আপনার প্রজেক্টের আইডিয়া নিয়ে কথা বলা যাক!
আর্টিকেলটি কি আপনার ভালো লেগেছে?
তন্ময়ের কাজকে সাপোর্ট করতে তালি (Clap) দিয়ে উৎসাহিত করুন!
জাহিদ হাসান তন্ময়
সফটওয়্যার ডেভেলপার
MERN ফুল-স্ট্যাক ডেভেলপার এবং AI এজেন্ট ডেভেলপার, ঢাকা, বাংলাদেশ। ওয়েব ডেভেলপমেন্ট, রিঅ্যাক্ট, লারাভেল এবং আমার লার্নিং জার্নি নিয়ে লিখছি।