
এই সিরিজে যা যা বানানো হলো তা জোড়া দিয়ে একটা Complete Mini-Agent প্রজেক্ট রিক্যাপ
আমাদের আগের পর্বে আমরা যখন Agent Architecture-এ Client Component আর Server Component কোনটা কোথায় বসবে তা নিয়ে কাজ করেছিলাম, তখন React-এর Slot Pattern ব্যবহার করে আমাদের সার্ভার-রেন্ডারড SessionSidebar এবং ক্লায়েন্ট-সাইড স্ট্রিমিং চ্যাট বক্সের মধ্যে একটি নিখুঁত সীমানা তৈরি করেছিলাম। ১৩তম পর্ব থেকে ২৫তম পর্ব পর্যন্ত আমরা আমাদের DevPulse (Level 2 Semi-Autonomous Issue Triage & Docs Agent) প্রজেক্টের প্রতিটি অঙ্গপ্রত্যঙ্গ আলাদা আলাদাভাবে তৈরি করেছি। কিন্তু একটি রিপোজিটরিতে ১৪-১৫টি আলাদা ফাইল থাকা আর সবগুলো ফাইল মিলে একটি পূর্ণাঙ্গ প্রোডাকশন অ্যাপ হিসেবে কাজ করার মধ্যে সূক্ষ্ম কিছু গ্যাপ থেকে যায়—যেমন ফ্রন্টএন্ডের 'Approve & Save Ticket' বাটনে ক্লিক করলে তা সত্যিই ডাটাবেসে সেভ হচ্ছে কি না, কিংবা নতুন চ্যাট শেষ হওয়ার পর বাঁপাশের সার্ভার কম্পোনেন্ট সাইডবারটি আপডেট হচ্ছে কি না!
আমাদের তৈরি করা সবগুলো মডিউল জোড়া দিয়ে একটি সম্পূর্ণ, প্রোডাকশন-রেডি Mini-Agent প্রজেক্ট দাঁড় করানোর মূল কৌশল হলো ৪টি লেয়ারকে এক সুতোয় গাঁথা: ১) নেটওয়ার্কের শুরুতে Edge Middleware Rate Limiter, ২) রুটে প্যারালাল ডেটাবেস হাইড্রেশনসহ Server Component Workspace, ৩) /api/chat রাউটে ৪টি টুল, স্লাইডিং উইন্ডো ও onFinish Supabase JSONB পারসিস্টেন্সসহ Streaming Engine, এবং ৪) ব্রাউজারে টাইপ-সেফ useChat লিফ কম্পোনেন্টের সাথে Level 2 Ticket Approval Server Action যুক্ত করা। এই সবগুলো অংশ একসাথে যুক্ত করলেই আমাদের এজেন্ট ডকুমেন্টেশন সার্চ করা, লাইভ গিটহাব ইস্যু চেক করা এবং ইউজারের কনফার্মেশন নিয়ে ডাটাবেসে বাগ টিকেট সেভ করার মতো সম্পূর্ণ এন্ড-টু-এন্ড কাজ সম্পন্ন করতে পারে।
আজকের এই ২৬তম পর্বের মাধ্যমেই আমাদের ৬২ পর্বের সিরিজের Section B (Next.js/React দিয়ে বাস্তব Implementation, পর্ব ১৩–২৬) সমাপ্ত হতে যাচ্ছে। আগামী পর্ব (পর্ব ২৭) থেকে আমরা প্রবেশ করব Section C (Python, FastAPI, Vector Search, Docker ও DevOps)-এ।
এক নজরে আমাদের সম্পূর্ণ প্রজেক্ট আর্কিটেকচার (পর্ব ১৩ থেকে ২৫-এর ম্যাপ)
চলুন প্রথমে দেখে নিই গত ১৪টি পর্বে আমরা যে মডিউলগুলো তৈরি করেছি, সেগুলো একটি প্রোডাকশন Next.js App Router রিপোজিটরিতে কীভাবে একে অপরের সাথে যুক্ত হয়ে কাজ করে:
src/middleware.ts(পর্ব ২২): নেটওয়ার্কের শুরুতেই প্রতিটিPOST /api/chatরিকোয়েস্ট পরীক্ষা করে এবং Upstash Redis দিয়ে মিনিটে সর্বোচ্চ ৫টি (Burst) ও দিনে ৪০টি প্রশ্নের স্লাইডিং-উইন্ডো লিমিট নিশ্চিত করে।src/app/page.tsxওSessionSidebar.tsx(পর্ব ২৫): সার্ভারে বসেইPromise.allদিয়ে প্যারালাল কুয়েরি চালিয়ে সেশন লিস্ট ও আগের মেসেজ হাইড্রেট করে এবং ব্রাউজারে শূন্য বাইট সাইডবার জাভাস্ক্রিপ্ট পাঠায়।src/lib/agent/blueprint.tsওtype-contracts.ts(পর্ব ১২ ও ২১): পুরো এজেন্টের সব Zod স্কিমা, Discriminated Union এনভেলপ (ok: trueবনামok: false), এবং React টাইপ গার্ড (isToolInvocationFor) নিয়ন্ত্রণ করে।src/lib/agent/custom-github-tool.tsওvercel-tools.ts(পর্ব ১৭ ও ১৯): ডকুমেন্টেশন সার্চ, ইস্যু ট্র্যাকার, ৫ সেকেন্ডের টাইমআউট ও ১৮০-টোকেন DTO শেপারসহ লাইভ গিটহাব API টুল এবং বাগ টিকেট ড্রাফটারকে স্ট্রিমিং ইঞ্জিনের সাথে যুক্ত করে।src/app/api/chat/route.tsওhistory-store.ts(পর্ব ১৫, ২০ ও ২৪): ~৩০০ মিলিসেকেন্ডে টোকেন স্ট্রিম শুরু করে এবং উত্তর শেষ হওয়ার পরonFinishহুকে পুরো টার্ন ওtool_invocationsJSONB ডাটাবেসে সেভ করে।src/components/DevPulseStreamChat.tsxওAgentThinkingTimeline.tsx(পর্ব ১৬ ও ২০): ফ্রন্টএন্ডে লাইভ চ্যাট স্ট্রিম, স্মার্ট অটো-স্ক্রলিং (useSmartAutoScroll), এবং CLS-মুক্ত থিংকিং টাইমলাইন পরিচালনা করে।

নিচের মাস্টার আর্কিটেকচার ডায়াগ্রামে দেখুন ইউজারের একটি রিকোয়েস্ট কীভাবে এই সবগুলো ফাইলের ভেতর দিয়ে প্রবাহিত হয়ে একটি পূর্ণাঙ্গ এজেন্ট সিস্টেম তৈরি করে:
mermaid flowchart TD User([ব্রাউজারে ডেভেলপার]) -->|১. পেজ লোড| PageRSC[page.tsx Server Component] PageRSC -->|Parallel Promise.all| SupaRead[(Supabase PostgreSQL)] PageRSC -->|স্লট ও initialMessages হাইড্রেশন| UI[DevPulseStreamChat Client Leaf] User -->|২. ট্রায়াজ প্রশ্ন সাবমিট| Edge[middleware.ts Edge Rate Limiter] Edge -->|লিমিট পার হলে| Banner429[RateLimitCooldownBanner 429 UI] Edge -->|বৈধ রিকোয়েস্ট| ChatAPI[POST /api/chat Route Handler] ChatAPI -->|১০-টার্ন হিস্ট্রি স্যানিটাইজ| StreamEngine[stream-client.ts LLM Loop] StreamEngine -->|ধাপ ১..N টুল কল| Tools[vercel-tools.ts + GitHub API] Tools -->|১৮০-টোকেন DTO ফলাফল| StreamEngine StreamEngine -->|লাইভ টোকেন + ফেজ ইভেন্ট স্ট্রিম| UI StreamEngine -->|onFinish ব্যাচ ইনসার্ট| SupaRead User -->|৩. Approve Ticket ক্লিক| ApproveSA[approve-ticket.ts Server Action] ApproveSA -->|আইডেমপোটেন্ট আপসার্ট| SupaRead
আর্কিটেকচার ফ্লো সামারি: ব্রাউজার রিকোয়েস্ট → সার্ভার কম্পোনেন্ট প্যারালাল হাইড্রেশন → এজ মিডলওয়্যার রেট লিমিটার → মাল্টি-স্টেপ টুল স্ট্রিমিং রাউট → সুপাবেস JSONB পারসিস্টেন্স ও লেভেল ২ টিকেট অ্যাপ্রুভাল সার্ভার অ্যাকশন।
ধাপ ১: Level 2 Human-in-the-Loop অ্যাপ্রুভাল সম্পূর্ণ করা (approve-ticket.ts)
১৭তম পর্বে আমরা যখন draftIssueTicket টুলটি বানিয়েছিলাম, তখন ফ্রন্টএন্ডে 'Approve & Save Ticket' বাটনে ক্লিক করলে শুধু একটি লোকাল React স্টেট পরিবর্তন হতো—কিন্তু আসল ডাটাবেসে কোনো টিকেট সেভ হতো না। একটি পূর্ণাঙ্গ প্রজেক্টে এই লুপটি সম্পূর্ণ করা জরুরি।
এজন্য প্রথমে Supabase-এ agent_tickets নামে একটি টেবিল তৈরি করে নিন যেখানে tool_call_id ইউনিক কলাম হিসেবে থাকবে। এরপর তৈরি করুন src/app/actions/approve-ticket.ts। লক্ষ্য করুন, এখানে আমরা ১২তম পর্বের draftTicketSchema এবং ২২তম পর্বের assertServerActionRateLimit() পুনরায় ব্যবহার করছি:
`typescript // src/app/actions/approve-ticket.ts 'use server';
import { z } from 'zod'; import { createClient } from '@supabase/supabase-js'; import { draftTicketSchema } from '@/lib/agent/blueprint'; import { assertServerActionRateLimit } from '@/lib/agent/server-action-guard';
const approveActionSchema = z.object({ sessionKey: z.string().min(8), toolCallId: z.string().min(4), ticket: draftTicketSchema, });
export interface ApproveTicketResponse { ok: boolean; ticketId?: string; errorMessage?: string; }
export async function approveAndPersistTicket(
rawInput: unknown
): Promise {
const rateCheck = await assertServerActionRateLimit();
if (!rateCheck.allowed) {
return {
ok: false,
errorMessage: Rate limit active. Please wait ${rateCheck.retryAfterSeconds}s.,
};
}
const parsed = approveActionSchema.safeParse(rawInput); if (!parsed.success) { return { ok: false, errorMessage: parsed.error.issues.map((i) => i.message).join('; '), }; }
const url = process.env.SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) { return { ok: false, errorMessage: 'Server database credentials missing.' }; }
const supabase = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false }, });
const { sessionKey, toolCallId, ticket } = parsed.data;
const { data, error } = await supabase .from('agent_tickets') .upsert( { session_key: sessionKey, tool_call_id: toolCallId, title: ticket.title, severity: ticket.severity, component: ticket.component, reproduction_steps: ticket.reproductionSteps, }, { onConflict: 'tool_call_id' } ) .select('id') .single();
if (error || !data) { return { ok: false, errorMessage: error?.message ?? 'Failed to save approved ticket.', }; }
return { ok: true, ticketId: data.id, }; } `
এখানে upsert(..., { onConflict: 'tool_call_id' }) ব্যবহারের ফলে আপনার অ্যাকশনটি শতভাগ Idempotent হয়ে গেল—অর্থাৎ ইউজার ভুল করে দুবার বাটনে ক্লিক করলেও একই টুল কলের জন্য ডাটাবেসে কখনোই ডুপ্লিকেট বাগ টিকেট তৈরি হবে না।
ধাপ ২: চূড়ান্ত সমন্বিত /api/chat/route.ts কন্ট্রোলার
চলুন আমাদের মূল স্ট্রিমিং রাউট src/app/api/chat/route.ts-এর সম্পূর্ণ ফাইনাল রূপটি দেখে নিই, যেখানে Zod ভ্যালিডেশন, agent-phase ইভেন্ট স্ট্রিমিং, মাল্টি-স্টেপ টুল কলিং এবং onFinish Supabase পারসিস্টেন্স একসাথে কাজ করছে:
`typescript // src/app/api/chat/route.ts 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 chatRouteSchema = 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 interface MessageTelemetryAnnotation { modelUsed: string; promptTokens: number; completionTokens: number; latencyMs: number; stepCount: number; toolsCalled: string[]; }
export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => null); const parsed = chatRouteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid chat request payload.' },
{ status: 400 }
);
}
const { sessionId, modelId, messages } = parsed.data;
const lastUserTurn = [...messages].reverse().find((m) => m.role === 'user');
const cleanMessages: 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) => {
dataStream.writeData({
type: 'agent-phase',
phase: 'reasoning',
detail: `Routing query with ${modelId}...`,
timestamp: Date.now(),
});
const result = createAgentTextStream(
cleanMessages,
{
...defaultDevPulseConfig,
modelId,
},
async (telemetry, completionDetails) => {
dataStream.writeMessageAnnotation({
modelUsed: telemetry.modelUsed,
promptTokens: telemetry.promptTokens,
completionTokens: telemetry.completionTokens,
latencyMs: telemetry.latencyMs,
stepCount: telemetry.stepCount,
toolsCalled: telemetry.toolsCalled,
});
if (lastUserTurn) {
await persistCompletedAgentTurn({
externalSessionKey: sessionId,
modelId,
userPrompt: lastUserTurn.content,
assistantReply: completionDetails.text,
toolInvocations: completionDetails.toolInvocations as ToolInvocation[],
telemetry,
});
}
}
);
result.mergeIntoDataStream(dataStream);
},
onError: (err) =>
err instanceof Error ? err.message : 'Agent stream failed on server.',
});
} catch (err) { const message = err instanceof Error ? err.message : 'Unexpected server error'; return NextResponse.json({ error: message }, { status: 500 }); } } `
ধাপ ৩: সব ফিচারের সমন্বয়ে চূড়ান্ত DevPulseStreamChat.tsx কম্পোনেন্ট
এবার আমাদের ক্লায়েন্ট লিফ কম্পোনেন্ট src/components/DevPulseStreamChat.tsx-এ সবগুলো টুকরো জোড়া দেওয়ার পালা। খেয়াল করুন এখানে কীভাবে প্রতিটি পর্বের কম্পোনেন্ট একে অপরের পরিপূরক হিসেবে বসেছে:
১. useChat + onFinish: () => router.refresh(): স্ট্রিম শেষ হয়ে ডাটাবেসে নতুন সেশন সেভ হওয়ার সাথে সাথে বাঁপাশের সার্ভার কম্পোনেন্ট সাইডবারটি (SessionSidebar) অটোমেটিক আপডেট করে দেয়।
২. AgentThinkingTimeline: এজেন্ট যখন টুল চালাচ্ছে তখন জিরো-CLS কলাপ্সিবল টাইমলাইন দেখায়।
৩. TypeSafeToolRenderer ও approveAndPersistTicket: গিটহাব ও ডকস কার্ড রেন্ডার করে এবং বাগ টিকেট ড্রাফট হলে ক্লিক করামাত্র তা Supabase-এর agent_tickets টেবিলে সেভ করে।
৪. RateLimitCooldownBanner: রেট লিমিট অতিক্রম করলে লাইভ কাউন্টডাউন টাইমার দেখায়।
`tsx // src/components/DevPulseStreamChat.tsx 'use client';
import { useState, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { useChat, type Message } from 'ai/react'; import { useSmartAutoScroll } from '@/hooks/useSmartAutoScroll'; import { deriveAgentThinkingState } from '@/lib/agent/ui-state'; import { isToolInvocationFor } from '@/lib/agent/type-contracts'; import { approveAndPersistTicket } from '@/app/actions/approve-ticket'; import { type MessageTelemetryAnnotation } from '@/app/api/chat/route'; import AgentThinkingTimeline from './AgentThinkingTimeline'; import TypeSafeToolRenderer from './TypeSafeToolRenderer'; import RateLimitCooldownBanner from './RateLimitCooldownBanner';
interface DevPulseStreamChatProps { sessionKey: string; initialMessages: Message[]; }
export default function DevPulseStreamChat({ sessionKey, initialMessages, }: DevPulseStreamChatProps) { const router = useRouter(); const [modelId, setModelId] = useState<'claude-3-5-sonnet-latest' | 'gpt-4o'>( 'claude-3-5-sonnet-latest' ); const [approvedTickets, setApprovedTickets] = useState<Record<string, string>>({}); const [isApproving, startApprovalTransition] = useTransition();
const { messages, input, handleInputChange, handleSubmit, isLoading, stop, reload, error, } = useChat({ api: '/api/chat', id: sessionKey, initialMessages, onFinish: () => { router.refresh(); }, });
const { containerRef, handleScroll, shouldAutoScroll, scrollToBottom } = useSmartAutoScroll(messages);
const thinkingState = deriveAgentThinkingState(messages, isLoading);
function onFormSubmit(e: React.FormEvent) { e.preventDefault(); if (!input.trim() || isLoading) return;
handleSubmit(e, {
body: {
sessionId: sessionKey,
modelId,
},
});
}
function handleTicketApproval( toolCallId: string, ticketPreview: unknown ) { startApprovalTransition(async () => { const res = await approveAndPersistTicket({ sessionKey, toolCallId, ticket: ticketPreview, }); if (res.ok && res.ticketId) { setApprovedTickets((prev) => ({ ...prev, [toolCallId]: res.ticketId! })); } }); }
return ( DevPulse Issue Triage & Docs Agent Session: {sessionKey} • Max Steps: 5
<select
value={modelId}
onChange={(e) =>
setModelId(e.target.value as 'claude-3-5-sonnet-latest' | 'gpt-4o')
}
disabled={isLoading}
className='bg-slate-950 border border-slate-700 rounded-lg px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50'
>
<option value='claude-3-5-sonnet-latest'>Claude 3.5 Sonnet</option>
<option value='gpt-4o'>OpenAI GPT-4o</option>
</select>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
className='relative flex-1 overflow-y-auto space-y-4 p-4 bg-slate-950 rounded-lg border border-slate-800/80'
>
{messages.map((m, idx) => {
const isLastMessage = idx === messages.length - 1;
const telemetry = (m.annotations?.[0] as unknown) as
| MessageTelemetryAnnotation
| undefined;
return (
<div
key={m.id}
className={`p-4 rounded-xl text-sm space-y-3 ${
m.role === 'user'
? 'bg-cyan-950/40 border border-cyan-800/50 ml-10'
: 'bg-slate-900 border border-slate-800 mr-10'
}`}
>
<div className='flex items-center justify-between text-[10px] uppercase tracking-wider text-slate-400'>
<span>{m.role === 'user' ? 'Developer' : 'DevPulse Agent'}</span>
{telemetry && (
<span className='text-cyan-400 font-mono lowercase'>
{telemetry.modelUsed} • {telemetry.stepCount} step(s) •{' '}
{telemetry.promptTokens} in / {telemetry.completionTokens} out •{' '}
{telemetry.latencyMs}ms
</span>
)}
</div>
{m.role === 'assistant' && isLastMessage && (
<AgentThinkingTimeline
thinkingState={thinkingState}
toolInvocations={m.toolInvocations}
/>
)}
{m.toolInvocations && m.toolInvocations.length > 0 && (
<div className='space-y-2'>
{m.toolInvocations.map((inv) => {
if (isToolInvocationFor(inv, 'draftIssueTicket') && inv.state === 'result' && inv.result.ok) {
const preview = inv.result.data.ticketPreview;
const savedId = approvedTickets[inv.toolCallId];
return (
<div
key={inv.toolCallId}
className='p-3 rounded-lg bg-slate-950 border border-amber-800/70 text-xs space-y-2'
>
<div className='flex justify-between font-mono text-amber-300'>
<span>Ticket Draft: {preview.title}</span>
<span className='uppercase'>{preview.severity}</span>
</div>
{savedId ? (
<div className='text-emerald-400 font-mono'>
Persisted to Supabase (Ticket UUID: {savedId.slice(0, 8)})
</div>
) : (
<button
type='button'
disabled={isApproving}
onClick={() => handleTicketApproval(inv.toolCallId, preview)}
className='px-3 py-1 rounded bg-emerald-600 hover:bg-emerald-500 text-white font-medium'
>
{isApproving ? 'Saving to DB...' : 'Approve & Save Ticket'}
</button>
)}
</div>
);
}
return (
<TypeSafeToolRenderer
key={inv.toolCallId}
invocation={inv}
/>
);
})}
</div>
)}
{m.content && (
<div className='whitespace-pre-wrap leading-relaxed'>
{m.content}
</div>
)}
</div>
);
})}
{!shouldAutoScroll && isLoading && (
<button
type='button'
onClick={scrollToBottom}
className='sticky bottom-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-cyan-700 text-xs font-medium shadow-lg'
>
Resume Auto-Scroll
</button>
)}
</div>
<RateLimitCooldownBanner
error={error}
onRetry={() => reload({ body: { sessionId: sessionKey, modelId } })}
/>
<form onSubmit={onFormSubmit} className='flex gap-2'>
<input
value={input}
onChange={handleInputChange}
placeholder='Ask DevPulse to check docs, inspect a GitHub issue, or draft a bug ticket...'
className='flex-1 px-3.5 py-2.5 rounded-lg bg-slate-950 border border-slate-700 text-sm focus:outline-none focus:border-cyan-500'
/>
{isLoading ? (
<button
type='button'
onClick={stop}
className='px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-sm font-semibold'
>
Stop
</button>
) : (
<button
type='submit'
disabled={!input.trim()}
className='px-4 py-2.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-sm font-semibold'
>
Send
</button>
)}
</form>
</div>
); } `
পুরো প্রজেক্টের End-to-End Smoke Test: ১টি প্রম্পটে সবগুলো ফিচার যাচাই করা
সব ফাইল তো জোড়া লাগানো হলো, এবার লোকাল ব্রাউজারে অ্যাপটি রান করে নিচের এই একটি যৌগিক প্রম্পট দিয়ে পরীক্ষা করে দেখুন ১৩ থেকে ২৫তম পর্বের প্রতিটি অংশ একসাথে কাজ করছে কি না:
'Check our internal rate-limit docs for 429 burst rules, inspect live GitHub issue #402 in vercel/ai, and draft a high-severity ticket for api-gateway if token refresh bursts are still causing timeouts.'
সেন্ড বাটনে চাপ দেওয়ার সাথে সাথে নিচের ঘটনাগুলো পরপর ঘটবে:
১. Edge Middleware (middleware.ts): মাত্র ১২ মিলিসেকেন্ডে আপনার IP চেক করে X-RateLimit-Remaining: 4 হেডার যুক্ত করবে।
২. Thinking Timeline (AgentThinkingTimeline.tsx): সাথে সাথে 'Routing' স্টেটে চালু হবে এবং ধাপে ধাপে searchKnowledgeBase, inspectLiveGitHubIssue, এবং draftIssueTicket টুল তিনটির প্রগ্রেস দেখাবে।
৩. Type-Safe Tool Cards (TypeSafeToolRenderer.tsx): ডকুমেন্টেশনের চাঙ্ক, লাইভ গিটহাব ইস্যু কার্ড (vercel/ai #402), এবং অ্যাম্বার রঙের বাগ টিকেট ড্রাফট কার্ড রেন্ডার করবে।
৪. Synthesis ও Supabase সেভ (onFinish): চূড়ান্ত উত্তরটি স্ট্রিম হয়ে আসবে, থিংকিং টাইমলাইনটি নিজে থেকেই কলাপ্স হয়ে যাবে, পুরো টার্নটি Supabase-এ সেভ হবে এবং router.refresh() কল হয়ে বাঁপাশের সাইডবারে নতুন সেশনটি চলে আসবে!
জাহিদের বাস্তব অভিজ্ঞতা: সাইডবারে সেশন পাল্টানোর পরও যখন চ্যাট বক্সে আগের মেসেজ আটকে থাকত!
আমি যখন আমার আগের প্রজেক্টগুলো (যেমন GitHub Issues Tracker এবং Movie Explorer) তৈরি করেছিলাম, তখন সব স্টেট ক্লায়েন্টে থাকার কারণে কোনো বাটনে ক্লিক করলেই সাথে সাথে UI পরিবর্তন হতো। কিন্তু DevPulse প্রজেক্টে যখন আমি ২৫তম পর্বের Server Component সাইডবার এবং ক্লায়েন্ট কম্পোনেন্ট DevPulseStreamChat একসাথে জোড়া লাগালাম, তখন খুব অদ্ভুত একটা ইন্টিগ্রেশন বাগ ধরা পড়ল।
আমি সাইডবার থেকে প্রথমে একটি পুরনো সেশনে ক্লিক করলাম—মেসেজগুলো সুন্দর লোড হলো। এরপর সাইডবারের + New বাটনে ক্লিক করে নতুন সেশন URL-এ (/?session=devpulse-1762790000) গেলাম। সার্ভারে page.tsx ঠিকই নতুন খালি অ্যারে (initialMessages) নিয়ে রেন্ডার করল এবং ক্লায়েন্ট কম্পোনেন্টে প্রপস হিসেবে পাঠাল। কিন্তু ব্রাউজারে তাকিয়ে দেখি চ্যাট বক্সে তখনো আগের সেশনের মেসেজ আর অ্যাপ্রুভ করা টিকেটের স্টেট (approvedTickets) আটকে আছে!
কারণটা ছিল React-এর কম্পোনেন্ট রিকনসিলিয়েশন (Reconciliation) নিয়ম: রাউট বা কুয়েরি প্যারামিটার পরিবর্তন হলেও যেহেতু DevPulseStreamChat কম্পোনেন্টটি DOM ট্রির ঠিক একই জায়গায় বসে ছিল, তাই React সেটিকে আনমাউন্ট না করে পুরনো কম্পোনেন্ট ইনস্ট্যান্সটিকেই রেখে দিচ্ছিল! সমাধান ছিল অত্যন্ত সহজ কিন্তু কার্যকর: src/app/page.tsx ফাইলে DevPulseStreamChat কল করার সময় সরাসরি key={activeSessionKey} প্রপটি যোগ করা। এটি দেওয়ার সাথে সাথেই সাইডবারে সেশন পরিবর্তন করামাত্র React পুরনো সেশনের সমস্ত ক্লায়েন্ট স্টেট পরিষ্কার করে একদম ফ্রেশ চ্যাট ইনস্ট্যান্স মাউন্ট করা শুরু করল।
পুরো প্রজেক্ট জোড়া দেওয়ার সময় যে ৪টি ভুল এড়িয়ে চলবেন
আমাদের Next.js ইমপ্লিমেন্টেশন মডিউল শেষ করার সময় নিশ্চিত হয়ে নিন আপনার কোডবেসে যেন এই ৪টি ইন্টিগ্রেশন ভুল না থাকে:
১. page.tsx-এ চ্যাট কম্পোনেন্টে key={activeSessionKey} না দেওয়া (Missing Component Key): যেমনটি একটু আগেই বললাম, key প্রপ না দিলে সাইডবার থেকে এক সেশন থেকে অন্য সেশনে যাওয়ার সময় লোকাল স্টেট (approvedTickets, স্ক্রল পজিশন বা ইনপুট ড্রাফট) আগের সেশনের সাথে মিশে যাবে। তাই সবসময় ইউনিক সেশন কী-কে React key হিসেবে পাস করবেন।
২. Level 2 অ্যাপ্রুভাল বাটনকে শুধু লোকাল useState-এ সীমাবদ্ধ রাখা (Ephemeral Approval State): হিউম্যান-ইন-দ্য-লুপ কনফার্মেশন বাটন যদি ব্যাকএন্ডের কোনো Server Action (approveAndPersistTicket) কল না করে শুধু ব্রাউজারের স্টেট পরিবর্তন করে, তবে পেজ রিফ্রেশ দিলেই সেই অনুমোদন হারিয়ে যাবে। সবসময় এটিকে ডাটাবেস মিউটেশনের সাথে যুক্ত রাখবেন।
৩. useChat-এর onFinish-এ router.refresh() কল করতে ভুলে যাওয়া (Stale Server Sidebar): ইউজার যখন নতুন সেশনে প্রথম মেসেজ পাঠান, তখন স্ট্রিম শেষ হওয়ার পর ব্যাকএন্ডে সেশনটি ডাটাবেসে তৈরি হয়। কিন্তু ফ্রন্টএন্ডে onFinish-এর ভেতরে router.refresh() কল না করলে বাঁপাশের Server Component সাইডবারটি (SessionSidebar) জানতেই পারবে না যে ডাটাবেসে নতুন সেশন যোগ হয়েছে!
৪. আলাদা আলাদা ফাইলে ভিন্ন ভিন্ন মডেল নাম বা maxSteps হার্ডকোড করা (Configuration Drift): প্রজেক্টে ফাইল সংখ্যা বাড়লে অনেকেই এক ফাইলে maxSteps: 3 আর অন্য ফাইলে maxSteps: 5 লিখে ফেলেন। এই অসামঞ্জস্য এড়াতে সবসময় ১২তম পর্বের blueprint.ts-এর defaultDevPulseConfig থেকেই সব জায়গায় কনফিগারেশন ইমপোর্ট করবেন।
প্রায়শই জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
আমাদের এই DevPulse মিনি-এজেন্ট প্রজেক্টটি কি এখনই লাইভ প্রোডাকশনে ডিপ্লয় করার উপযোগী?
হ্যাঁ, শতভাগ উপযোগী। কারণ এতে শুধুমাত্র চ্যাটবট UI নয়—বরং Edge Middleware রেট লিমিটিং, সার্ভার-অনলি API Key সুরক্ষা, এক্সটার্নাল টুলে ৫ সেকেন্ডের টাইমআউট সার্কিট ব্রেকার, এন্ড-টু-এন্ড TypeScript টাইপ গার্ড এবং Supabase JSONB পারসিস্টেন্স—সবগুলো প্রোডাকশন গার্ডরেইল যুক্ত করা হয়েছে। ঠিক পরের সেকশনেই (পর্ব ৩০ ও ৩১) আমরা এই প্রজেক্টটিকে Vercel এবং নিজস্ব Docker VPS-এ ডিপ্লয় করব।
ভবিষ্যতে যদি আমি এই এজেন্টে ৫ম বা ৬ষ্ঠ আরেকটি নতুন টুল যোগ করতে চাই, তবে কোন কোন ফাইলে হাত দিতে হবে?
আমাদের মডুলার আর্কিটেকচারের কারণে নতুন যেকোনো টুল যোগ করতে মাত্র ৩টি ছোট কাজ করতে হবে: ১) type-contracts.ts-এ নতুন টুলের Zod স্কিমা ও আউটপুট DTO যোগ করা, ২) vercel-tools.ts-এ সার্ভার এক্সিকিউটর ফাংশনটি বাইন্ড করা, এবং ৩) TypeSafeToolRenderer.tsx-এ নতুন টুলের কার্ড রেন্ডার করার জন্য একটি if (isToolInvocationFor(...)) ব্লক যোগ করা।
Next.js ইমপ্লিমেন্টেশন মডিউল (পর্ব ১৩–২৬) তো শেষ হলো, এরপর আমরা কী শিখব?
এতদিন আমরা Next.js এবং TypeScript দিয়ে ফুল-স্ট্যাক এজেন্ট তৈরি করা শিখেছি। আগামী পর্ব (পর্ব ২৭) থেকে শুরু হচ্ছে আমাদের Section C (Backend, Python, Vector Search & DevOps)—যেখানে আমরা দেখব কীভাবে Python ও FastAPI দিয়ে এজেন্ট ব্যাকএন্ড বানাতে হয়, Supabase pgvector দিয়ে আসল Semantic Search যোগ করতে হয় এবং Docker, Webhook, Cron Job ও Redis দিয়ে স্বয়ংক্রিয় ব্যাকগ্রাউন্ড এজেন্ট চালাতে হয়।
কাস্টম ওয়েব অ্যাপ বা এআই অটোমেশন বানাতে চান?
আমি স্টার্টআপ ও আধুনিক ব্যবসার জন্য স্কেলেবল ফুল-স্ট্যাক ওয়েব অ্যাপ্লিকেশন (Next.js, React, Node, PostgreSQL) এবং ইন্টেলিজেন্ট এআই এজেন্ট সলিউশন তৈরি করি। আপনার প্রজেক্টের আইডিয়া নিয়ে কথা বলা যাক!
আর্টিকেলটি কি আপনার ভালো লেগেছে?
তন্ময়ের কাজকে সাপোর্ট করতে তালি (Clap) দিয়ে উৎসাহিত করুন!
জাহিদ হাসান তন্ময়
সফটওয়্যার ডেভেলপার
MERN ফুল-স্ট্যাক ডেভেলপার এবং AI এজেন্ট ডেভেলপার, ঢাকা, বাংলাদেশ। ওয়েব ডেভেলপমেন্ট, রিঅ্যাক্ট, লারাভেল এবং আমার লার্নিং জার্নি নিয়ে লিখছি।