Agent যখন 'ভাবছে' তখন UI-তে Thinking/Loading State দেখানোর সঠিক উপায়

Agent যখন 'ভাবছে' তখন UI-তে Thinking/Loading State দেখানোর সঠিক উপায়

Z
জাহিদ হাসান তন্ময়
২৩ অক্টোবর, ২০২৬
14 মিনিটের পড়া
🇬🇧 Read in English
|
0 views
অডিও ওভারভিউ ও পডকাস্ট
~১ মিনিট কুইক ওভারভিউ
0%

Agent যখন 'ভাবছে' তখন UI-তে Thinking/Loading State দেখানোর সঠিক উপায়
Agent যখন 'ভাবছে' তখন UI-তে Thinking/Loading State দেখানোর সঠিক উপায়

আমাদের আগের পর্বে আমরা যখন নিজের একটি প্রোডাকশন Custom Tool লিখে AI Agent-এর সাথে যুক্ত করেছিলাম, তখন inspectLiveGitHubIssue টুলটি তৈরি করে আমাদের DevPulse এজেন্টকে সরাসরি লাইভ গিটহাব API থেকে বাগ রিপোর্ট পড়ে আসার ক্ষমতা দিয়েছিলাম। এজেন্টের হাতে লাইভ টুল দেওয়ার ফলে সে এখন অনেক বাস্তব সমস্যার সমাধান করতে পারে ঠিকই, কিন্তু এর ফলে ফ্রন্টএন্ডে একটি নতুন ইউজার এক্সপেরিয়েন্স (UX) চ্যালেঞ্জ তৈরি হয়: এজেন্ট যখন পরপর ডকুমেন্টেশন সার্চ করে, গিটহাব API কল করে এবং সেই তথ্য মিলিয়ে উত্তর তৈরি করে, তখন প্রথম শব্দটা স্ক্রিনে আসার আগে ৪ থেকে ৮ সেকেন্ড সময় লেগে যায়।

এজেন্ট যখন ব্যাকএন্ডে এভাবে একাধিক ধাপে 'ভাবছে' (Reasoning ও Tool Execution করছে), তখন React UI-তে সঠিক Thinking ও Loading স্টেট দেখানোর জন্য সাধারণ স্পিনার বাদ দিয়ে Vercel AI SDK-এর লাইফসাইকেল (isLoading/status), toolInvocation-এর তিনটি ধাপ ('partial-call', 'call', 'result'), এবং সার্ভারের dataStream.writeData() ইভেন্টকে মিলিয়ে একটি Collapsible Thinking Timeline তৈরি করতে হয়। এর ফলে ইউজার প্রতিটি মুহূর্তে স্পষ্ট দেখতে পান এজেন্ট এখন প্রশ্ন বিশ্লেষণ করছে, নাকি নির্দিষ্ট কোনো টুল চালাচ্ছে, নাকি প্রাপ্ত ডেটা থেকে উত্তর সাজাচ্ছে—এবং এতে স্ক্রিনে কোনো ধরনের লেআউট জাম্প (CLS) ঘটে না।

একজন ডেভেলপার বা ইউজার তখনই ৫ সেকেন্ড অপেক্ষা করতে বিরক্ত হন না, যখন তিনি স্ক্রিনে চোখের সামনে দেখতে পান যে এজেন্ট এখন ডকুমেন্টেশন ফাইল খুঁজছে এবং গিটহাব ইস্যু #402 চেক করছে। কিন্তু এই ৫ সেকেন্ড যদি তিনি শুধু একটি ঘুরতে থাকা গোল চাকা বা স্থির 'Loading...' লেখা দেখেন, তবে তিনি ধরেই নেবেন অ্যাপটি হ্যাং করেছে।

মাল্টি-স্টেপ AI Agent-এর ক্ষেত্রে সাধারণ Loading Spinner কেন ব্যর্থ হয়?

সাধারণ ওয়েব অ্যাপ্লিকেশনে লোডিং স্টেট খুবই সরল—ডেটাবেজে রিকোয়েস্ট গেলে isLoading = true হয়, আর ডেটা চলে এলে isLoading = false হয়ে যায়। কিন্তু আমাদের DevPulse-এর মতো মাল্টি-স্টেপ এজেন্টে ইউজার Send বাটনে ক্লিক করার পর থেকে চূড়ান্ত টেক্সট স্ট্রিম শুরু হওয়ার মাঝখানে ৪টি সম্পূর্ণ আলাদা ল্যাটেন্সি ধাপ (Latency Phases) কাজ করে:

১. ধাপ ১ — প্রম্পট রাউটিং (status === 'submitted'): ইউজার প্রশ্ন পাঠিয়েছেন, কিন্তু সার্ভার থেকে এখনো কোনো টেক্সট বা টুল কল ব্রাউজারে আসেনি। মডেল এই মুহূর্তে সিস্টেম প্রম্পট ও চ্যাট হিস্ট্রি পড়ে সিদ্ধান্ত নিচ্ছে কোন টুলটি কল করা উচিত (~৩০০–৭০০ মিলিসেকেন্ড)। ২. ধাপ ২ — টুল আর্গুমেন্ট তৈরি (toolInvocation.state === 'partial-call'): মডেল ঠিক করে ফেলেছে সে কোন টুলটি কল করবে (যেমন inspectLiveGitHubIssue) এবং এখন সে ওই টুলের JSON প্যারামিটারগুলো (repository, issueNumber) তৈরি করছে। ৩. ধাপ ৩ — সার্ভারে টুল এক্সিকিউশন (toolInvocation.state === 'call'): প্যারামিটারগুলো Zod দিয়ে ভ্যালিডেট হয়ে গেছে এবং আমাদের Next.js সার্ভার এখন লাইভ GitHub API বা Supabase ভেক্টর ডেটাবেজের উত্তরের জন্য অপেক্ষা করছে (৫০০ মিলিসেকেন্ড থেকে ৫ সেকেন্ড)। ৪. ধাপ ৪ — পোস্ট-টুল সিন্থেসিস গ্যাপ (toolInvocation.state === 'result' কিন্তু m.content === ''): এটি সবচেয়ে সূক্ষ্ম ধাপ! টুলের কাজ শেষ হয়ে JSON রেজাল্ট চলে এসেছে, কিন্তু সেই রেজাল্ট পড়ে ২ নম্বর ধাপে LLM এখনো বাংলা বা ইংরেজি বাক্য লেখা শুরু করেনি। এই মাঝখানের ১ থেকে ১.৫ সেকেন্ড সময় সব টুলের স্টেট 'result' দেখায়, অথচ মেসেজ বক্স তখনো ফাঁকা থাকে!

আপনি যদি এই ৪টি ধাপ আলাদা না করে শুধু isLoading দেখে একটা স্পিনার ঘোরান, কিংবা টুল 'result' হওয়ার সাথে সাথেই লোডিং বক্স লুকিয়ে ফেলেন, তবে ইউজার প্রতি ধাপের মাঝখানে একটা ফাঁকা স্ক্রিন ও ঝাঁকুনি দেখবেন।

এআই এজেন্টের চারটি ল্যাটেন্সি ধাপ—রাউটিং, আর্গুমেন্ট তৈরি, টুল এক্সিকিউশন এবং সিন্থেসিসের থ্রিডি গ্লাসমরফিজম টাইমলাইন
এআই এজেন্টের চারটি ল্যাটেন্সি ধাপ—রাউটিং, আর্গুমেন্ট তৈরি, টুল এক্সিকিউশন এবং সিন্থেসিসের থ্রিডি গ্লাসমরফিজম টাইমলাইন

এজেন্টের Thinking UI স্টেট মেশিন কীভাবে কাজ করে?

ফ্রন্টএন্ডে যেন এক সেকেন্ডের জন্যও ইউজার অন্ধকারে না থাকেন, সেজন্য আমরা মেসেজ ও টুল ইনভোকেশনগুলোকে একটি সুশৃঙ্খল স্টেট মেশিনের (State Machine) ভেতর দিয়ে পরিচালনা করব।

নিচের ডায়াগ্রামে দেখুন ইউজারের প্রশ্ন সাবমিট করা থেকে শুরু করে প্রতিটি ধাপে আমাদের UI কীভাবে রূপ পরিবর্তন করে:

mermaid stateDiagram-v2 [*] --> Idle: status = ready Idle --> Routing: ইউজার প্রশ্ন সাবমিট করল (status = submitted) Routing --> DraftingArgs: টুল সিলেক্ট ও আর্গুমেন্ট স্ট্রিম (state = partial-call) DraftingArgs --> ExecutingTool: সার্ভারে টুল রান হচ্ছে (state = call) ExecutingTool --> Synthesizing: টুলের JSON রেজাল্ট তৈরি (state = result, content empty) Synthesizing --> DraftingArgs: ২য় ধাপে আরেকটি টুল কল হলে Synthesizing --> StreamingText: চূড়ান্ত উত্তরের প্রথম শব্দ এলে Routing --> StreamingText: কোনো টুল না লাগলে সরাসরি টেক্সট স্ট্রিম StreamingText --> Idle: উত্তর শেষ ও টাইমলাইন কলাপ্স (onFinish)

আর্কিটেকচার ফ্লো সামারি (Architecture Flow Summary): ইউজার প্রশ্ন পাঠানোর সাথে সাথে UI Idle থেকে Routing স্টেটে যায়। এজেন্ট কোনো টুল কল করলে তা ক্রমান্বয়ে DraftingArgs (partial-call), ExecutingTool (call), এবং টুল শেষ হওয়ার পর টেক্সট আসার আগ পর্যন্ত Synthesizing (result কিন্তু content খালি) স্টেটে থাকে। একাধিক টুল কল হলে এই চক্রটি পুনরায় ঘোরে এবং চূড়ান্ত উত্তরের প্রথম অক্ষর আসা মাত্র টাইমলাইনটি সংকুচিত (Collapse) হয়ে StreamingText শুরু করে।

ধাপ ১: /api/chat থেকে সার্ভার-সাইড ফেজ ইভেন্ট স্ট্রিম করা

টুল কল শুরু হওয়ার পর m.toolInvocations থেকে আমরা সহজেই আপডেট পাই। কিন্তু টুল কল হওয়ার আগের মুহূর্তে বা এক ধাপ শেষ হয়ে পরের ধাপ শুরু হওয়ার সময় সার্ভার আসলে কী করছে তা ব্রাউজারকে জানানোর জন্য আমরা createDataStreamResponse-এর ভেতরে dataStream.writeData() ব্যবহার করতে পারি।

চলুন আমাদের src/app/api/chat/route.ts ফাইলটিতে কাস্টম agent-phase ইভেন্ট যোগ করি:

`typescript // src/app/api/chat/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createDataStreamResponse } 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';

export const maxDuration = 60;

const routePayloadSchema = z.object({ sessionId: z.string().default('devpulse-default-session'), 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), }) ) .min(1), });

export interface AgentPhaseEvent { type: 'agent-phase'; phase: 'sanitizing' | 'reasoning' | 'step-completed' | 'synthesizing'; detail: string; timestamp: number; }

export async function POST(req: NextRequest) { try { const body = await req.json(); const parsed = routePayloadSchema.safeParse(body);

if (!parsed.success) {
  return NextResponse.json(
    { error: 'Invalid chat request payload.' },
    { status: 400 }
  );
}

const { messages, modelId } = parsed.data;
const normalizedMessages: ChatMessage[] = messages.filter(
  (m): m is { role: 'user' | 'assistant'; content: string } =>
    m.role === 'user' || m.role === 'assistant'
);

return createDataStreamResponse({
  execute: (dataStream) => {
    dataStream.writeData({
      type: 'agent-phase',
      phase: 'reasoning',
      detail: `Routing query with ${modelId}...`,
      timestamp: Date.now(),
    });

    const result = createAgentTextStream(
      normalizedMessages,
      {
        ...defaultDevPulseConfig,
        modelId,
      },
      (telemetry) => {
        dataStream.writeMessageAnnotation({
          modelUsed: telemetry.modelUsed,
          promptTokens: telemetry.promptTokens,
          completionTokens: telemetry.completionTokens,
          latencyMs: telemetry.latencyMs,
          stepCount: telemetry.stepCount,
          toolsCalled: telemetry.toolsCalled,
        });
      }
    );

    result.mergeIntoDataStream(dataStream);
  },
  onError: (error) =>
    error instanceof Error ? error.message : 'Stream failed on server.',
});

} catch (err) { const message = err instanceof Error ? err.message : 'Unexpected server error'; return NextResponse.json({ error: message }, { status: 500 }); } } `

ধাপ ২: মানুষের পড়ার উপযোগী Thinking State তৈরি করা (src/lib/agent/ui-state.ts)

টুলের টেকনিক্যাল নাম (searchKnowledgeBase বা inspectLiveGitHubIssue) কিংবা আধা-ভাঙা JSON স্ট্রিং সরাসরি ইউজারের মুখের ওপর ছুড়ে দিলে ইউআই দেখতে ভীষণ অপেশাদার লাগে। তাই আমরা src/lib/agent/ui-state.ts নামে একটি আলাদা হেল্পার ফাইল তৈরি করব, যা মেসেজ এবং টুল ইনভোকেশন বিশ্লেষণ করে প্রতিটি ধাপের জন্য অর্থবহ শিরোনাম (headline) ও সাবটেক্সট তৈরি করবে:

`typescript // src/lib/agent/ui-state.ts import { type Message } from 'ai';

export type ThinkingStage = | 'idle' | 'routing' | 'preparing-tool' | 'executing-tool' | 'synthesizing' | 'streaming-answer';

export interface DerivedThinkingState { isThinking: boolean; stage: ThinkingStage; headline: string; subtext: string; completedToolsCount: number; totalToolsCount: number; }

const TOOL_FRIENDLY_LABELS: Record< string, { preparing: string; executing: (args: Record<string, unknown>) => string }

= { searchKnowledgeBase: { preparing: 'Formulating documentation search query...', executing: (args) => Searching internal docs for '${String(args?.query ?? 'technical specs').slice(0, 45)}'..., }, checkIssueTracker: { preparing: 'Preparing issue tracker filter...', executing: (args) => Checking ${String(args?.status ?? 'open')} issues matching '${String(args?.keyword ?? '')}'..., }, inspectLiveGitHubIssue: { preparing: 'Targeting upstream GitHub repository...', executing: (args) => Fetching live GitHub issue ${String(args?.repository ?? '')}#${String(args?.issueNumber ?? '')}..., }, draftIssueTicket: { preparing: 'Structuring bug report fields and reproduction steps...', executing: (args) => Drafting ${String(args?.severity ?? 'medium')} severity ticket for ${String(args?.component ?? 'system')}..., }, };

export function deriveAgentThinkingState( messages: Message[], isLoading: boolean ): DerivedThinkingState { if (!isLoading) { return { isThinking: false, stage: 'idle', headline: 'Ready', subtext: '', completedToolsCount: 0, totalToolsCount: 0, }; }

const lastMessage = messages[messages.length - 1];

if (!lastMessage || lastMessage.role === 'user') { return { isThinking: true, stage: 'routing', headline: 'Analyzing request & selecting tools...', subtext: 'Evaluating system prompt rules and conversation context', completedToolsCount: 0, totalToolsCount: 0, }; }

const invocations = lastMessage.toolInvocations ?? []; const completedCount = invocations.filter((t) => t.state === 'result').length; const activeTool = invocations.find((t) => t.state !== 'result');

if (activeTool) { const labelConfig = TOOL_FRIENDLY_LABELS[activeTool.toolName]; const isPartial = activeTool.state === 'partial-call'; const safeArgs = activeTool.args && typeof activeTool.args === 'object' ? (activeTool.args as Record<string, unknown>) : {};

return {
  isThinking: true,
  stage: isPartial ? 'preparing-tool' : 'executing-tool',
  headline: labelConfig
    ? isPartial
      ? labelConfig.preparing
      : labelConfig.executing(safeArgs)
    : `Running ${activeTool.toolName}...`,
  subtext: `Step ${completedCount + 1}: Executing server-side tool boundary`,
  completedToolsCount: completedCount,
  totalToolsCount: invocations.length,
};

}

if (invocations.length > 0 && (!lastMessage.content || lastMessage.content.trim().length === 0)) { return { isThinking: true, stage: 'synthesizing', headline: 'Synthesizing tool observations into final response...', subtext: Processed ${completedCount} tool output(s) • Verifying technical accuracy, completedToolsCount: completedCount, totalToolsCount: invocations.length, }; }

return { isThinking: false, stage: 'streaming-answer', headline: 'Streaming response...', subtext: '', completedToolsCount: completedCount, totalToolsCount: invocations.length, }; } `

এই কোডের চার নম্বর কন্ডিশনটি (if (invocations.length > 0 && !lastMessage.content)) বিশেষভাবে খেয়াল করুন। যখন সব টুলের কাজ শেষ হয়ে state === 'result' হয়ে যায়, কিন্তু মডেল তখনো চূড়ান্ত টেক্সট উত্তর লেখা শুরু করেনি—ঠিক সেই মুহূর্তটিতে এই লজিকটি 'Synthesizing tool observations into final response...' মেসেজটি ধরে রাখে, ফলে মাঝখানের ১.৫ সেকেন্ড সময় কখনোই স্ক্রিন ফাঁকা হয়ে যায় না।

ধাপ ৩: Collapsible AgentThinkingTimeline কম্পোনেন্ট তৈরি করা

এবার চলুন তৈরি করি src/components/AgentThinkingTimeline.tsx। এই কম্পোনেন্টটিতে একটি লাইভ টাইমার (০.১ সেকেন্ডের নিখুঁত হিসাবে) চলবে, কোন কোন টুল কোন ধাপে চলছে তার একটি টাইমলাইন দেখাবে এবং যেই মুহূর্তে এজেন্টের মূল টেক্সট উত্তর আসা শুরু হবে, এটি নিজে থেকেই সংকুচিত (Collapse) হয়ে 'Thought for 3.2s across 2 tool step(s)' আকারে মেসেজের মাথার ওপর বসে থাকবে—যাতে স্ক্রিনে কোনো লেআউট শিফট (CLS) না হয়:

`tsx // src/components/AgentThinkingTimeline.tsx 'use client';

import { useEffect, useState } from 'react'; import { type ToolInvocation } from 'ai'; import { type DerivedThinkingState } from '@/lib/agent/ui-state';

interface AgentThinkingTimelineProps { thinkingState: DerivedThinkingState; toolInvocations?: ToolInvocation[]; }

export default function AgentThinkingTimeline({ thinkingState, toolInvocations = [], }: AgentThinkingTimelineProps) { const [elapsedTenths, setElapsedTenths] = useState(0); const [isExpanded, setIsExpanded] = useState(true);

useEffect(() => { if (!thinkingState.isThinking) { if (toolInvocations.length > 0) { setIsExpanded(false); } return; }

setIsExpanded(true);
const interval = setInterval(() => {
  setElapsedTenths((prev) => prev + 1);
}, 100);

return () => clearInterval(interval);

}, [thinkingState.isThinking, toolInvocations.length]);

if (!thinkingState.isThinking && toolInvocations.length === 0) { return null; }

const formattedSeconds = (elapsedTenths / 10).toFixed(1);

return ( <button type='button' onClick={() => setIsExpanded((prev) => !prev)} className='w-full px-3.5 py-2.5 flex items-center justify-between text-left hover:bg-slate-900/60' > {thinkingState.isThinking ? ( ) : ( )}

      <span className='text-xs font-medium text-cyan-300 truncate'>
        {thinkingState.isThinking
          ? thinkingState.headline
          : `Thought for ${formattedSeconds}s across ${toolInvocations.length} tool step(s)`}
      </span>
    </div>

    <div className='flex items-center gap-2 shrink-0 ml-3'>
      <span className='text-[11px] font-mono text-slate-400 bg-slate-900 px-2 py-0.5 rounded border border-slate-800'>
        {formattedSeconds}s
      </span>
      <span className='text-xs text-slate-500'>
        {isExpanded ? '▴' : '▾'}
      </span>
    </div>
  </button>

  {isExpanded && (
    <div className='px-3.5 pb-3 pt-1 border-t border-slate-900 space-y-2 text-xs'>
      {thinkingState.subtext && (
        <p className='text-slate-400 text-[11px]'>{thinkingState.subtext}</p>
      )}

      {toolInvocations.length > 0 && (
        <div className='space-y-1.5 pt-1'>
          {toolInvocations.map((inv, index) => {
            const isDone = inv.state === 'result';
            const isPartial = inv.state === 'partial-call';

            return (
              <div
                key={inv.toolCallId}
                className='flex items-center justify-between py-1 px-2.5 rounded bg-slate-900/80 border border-slate-800/80 font-mono text-[11px]'
              >
                <div className='flex items-center gap-2 truncate'>
                  <span
                    className={
                      isDone
                        ? 'text-emerald-400'
                        : isPartial
                        ? 'text-amber-400'
                        : 'text-cyan-400 animate-pulse'
                    }
                  >
                    {isDone ? '✓' : isPartial ? '⋯' : '⚡'}
                  </span>
                  <span className='text-slate-200'>
                    Step {index + 1}: {inv.toolName}
                  </span>
                </div>

                <span className='text-[10px] text-slate-400 ml-2 shrink-0'>
                  {isDone
                    ? 'Observation ready'
                    : isPartial
                    ? 'Streaming args...'
                    : 'Running on server...'}
                </span>
              </div>
            );
          })}
        </div>
      )}
    </div>
  )}
</div>

); } `

এখন আমাদের মূল চ্যাট কম্পোনেন্ট (DevPulseStreamChat.tsx)-এ শুধু const thinkingState = deriveAgentThinkingState(messages, isLoading); কল করে মেসেজ বাবলের শুরুতে AgentThinkingTimeline কম্পোনেন্টটি বসিয়ে দিলেই পুরো Thinking ইউআই জীবন্ত হয়ে উঠবে।

বাম পাশে প্রসারিত থিংকিং টাইমলাইন এবং ডান পাশে উত্তর শুরু হওয়ার পর সংকুচিত সামারি বারের পাশাপাশি থ্রিডি ভিজ্যুয়াল রূপ
বাম পাশে প্রসারিত থিংকিং টাইমলাইন এবং ডান পাশে উত্তর শুরু হওয়ার পর সংকুচিত সামারি বারের পাশাপাশি থ্রিডি ভিজ্যুয়াল রূপ

আমার নিজের প্রজেক্টের অভিজ্ঞতা: টুল শেষ হওয়া এবং লেখা শুরু হওয়ার মাঝখানের সেই ১.৮ সেকেন্ডের 'Dead Air' বাগ!

আমি যখন আমার Movie Explorer এবং GitHub Issues Tracker প্রজেক্টে সার্চ ও মডাল লোডিং স্টেট নিয়ে কাজ করেছিলাম, তখন হিসাবটা ছিল একদম সোজা—API কল চলাকালীন স্কেলিটন লোডার দেখাও, আর ডেটা চলে এলেই লোডার সরিয়ে কার্ড রেন্ডার করো। সেই একই অভ্যাসে আমি যখন DevPulse এজেন্টের প্রথম টুল-কলিং ইউআই বানাই, তখন একটি মারাত্মক ইউআই ফ্লিকার (UI Flicker) তৈরি হয়েছিল।

আমি কোডে লিখেছিলাম: যতক্ষণ toolInvocation.state === 'call' থাকবে, ততক্ষণ 'Running tool...' লোডার দেখাবে। কিন্তু বাস্তবে কী ঘটল? ১ নম্বর ধাপে inspectLiveGitHubIssue টুলটি গিটহাব থেকে ডেটা নিয়ে আসা মাত্র তার স্টেট 'result' হয়ে গেল এবং আমার লোডারটি সাথে সাথে স্ক্রিন থেকে গায়েব হয়ে গেল! কিন্তু Vercel AI SDK যে ওই গিটহাবের JSON ডেটা আবার ২ নম্বর ধাপে Claude 3.5 Sonnet-এর কাছে পাঠিয়ে বাংলা বা ইংরেজি উত্তর তৈরি করে প্রথম শব্দটা স্ট্রিম করবে—তার জন্য মাঝখানে আরও ১.৫ থেকে ১.৮ সেকেন্ড সময় লাগে।

ফলে ওই ১.৮ সেকেন্ডের জন্য লোডারও নেই, আবার কোনো টেক্সটও নেই—পুরো মেসেজ বক্সটি হঠাৎ ফাঁকা হয়ে যেত! টেস্ট করার সময় আমি নিজেই ভেবেছিলাম উত্তর বোধহয় ক্র্যাশ করেছে এবং আবার বাটনে ক্লিক করতে যাচ্ছিলাম, ঠিক তখনই হঠাৎ করে লেখা স্ট্রিম হওয়া শুরু হলো। এই সমস্যা দেখার পরই আমি deriveAgentThinkingState()-এ 'synthesizing' ধাপটি যোগ করি—যা টুল 'result' হওয়ার পরও যতক্ষণ lastMessage.content খালি থাকে, ততক্ষণ 'Synthesizing tool observations into final response...' দেখাতে থাকে। আর লেখা শুরু হলে বক্সটি ডিলিট না করে অটোমেটিক কলাপ্স (setIsExpanded(false)) করে দেওয়ায় পুরো ইউআইতে আর কোনো ঝাঁকুনিই রইল না।

এজেন্টের Thinking/Loading State বানানোর সময় যে ৪টি ভুল এড়িয়ে চলবেন

পরের পর্বে (পর্ব ২১) পুরো এজেন্ট সিস্টেমকে TypeScript দিয়ে Type-safe করার গাইডে যাওয়ার আগে নিশ্চিত হয়ে নিন আপনার ফ্রন্টএন্ডে যেন এই ৪টি ভুল না থাকে:

১. উত্তর আসা মাত্র Thinking বক্সটি পুরোপুরি মুছে ফেলা (return null)

এজেন্ট যখন ২-৩টি টুল চালায়, তখন Thinking বক্সটি প্রায় ১২০-১৫০ পিক্সেল জায়গা নেয়। এখন প্রথম অক্ষর স্ট্রিম হওয়ার সাথে সাথেই যদি আপনি পুরো কম্পোনেন্টটি DOM থেকে মুছে ফেলেন (return null), তবে পুরো চ্যাট মেসেজটি ধপ করে ১৫০ পিক্সেল উপরে লাফ দেবে (যাকে Cumulative Layout Shift - CLS বলে)। তাই কখনোই এটি পুরোপুরি হাইড করবেন না; বরং আমাদের কোডের মতো একটি এক লাইনের সারাংশ বারে (Thought for 3.4s...) সংকুচিত করে রাখুন।

২. partial-call চলাকালীন কাঁচা (Raw) JSON আর্গুমেন্ট স্ক্রিনে দেখানো

যখন inv.state === 'partial-call' থাকে, তখন মডেল কেবল JSON আর্গুমেন্টের অক্ষরগুলো স্ট্রিম করছে। এই অবস্থায় আপনি যদি মেইন ব্যানারে JSON.stringify(inv.args) দেখান, তবে ইউজার স্ক্রিনে অর্ধেক ভাঙা ব্র্যাকেট লাফাতে দেখবেন। তাই সবসময় আমাদের TOOL_FRIENDLY_LABELS-এর মতো পরিষ্কার টেক্সট দেখাবেন।

৩. stop() বা এরর হওয়ার পরও ব্যাকগ্রাউন্ডে টাইমার চালু রাখা

অনেকেই লোডিং শুরু হলে setInterval দিয়ে সেকেন্ড গণনা শুরু করেন, কিন্তু ইউজার যদি মাঝপথে Stop বাটনে চাপ দেন বা নেটওয়ার্ক এরর হয়, তখনও টাইমার চলতেই থাকে। তাই আমাদের useEffect-এর মতো সবসময় thinkingState.isThinking এবং ক্লিনআপ ফাংশন (clearInterval) ব্যবহার করবেন।

৪. প্রতিটি টুলের জন্য আলাদা আলাদা স্পিনার দেখিয়ে স্ক্রিন ভরে ফেলা

এজেন্ট যদি এক টার্নে ৩টি টুল কল করে আর আপনি ৩টি আলাদা কার্ডে ৩টি বড় ঘুরতে থাকা স্পিনার বসান, তবে চ্যাট ইন্টারফেসটি দেখতে অত্যন্ত হিজিবিজি লাগবে। সব টুলকে একটি সিঙ্গেল কলাপ্সিবল টাইমলাইনের (AgentThinkingTimeline) ভেতরে রাখলে ইউআই সবসময় পরিচ্ছন্ন ও আধুনিক থাকে।

প্রায়শই জিজ্ঞাসিত প্রশ্নাবলী (FAQ)

Vercel AI SDK-তে toolInvocation.state-এর 'partial-call' এবং 'call'-এর মধ্যে আসল পার্থক্য কী?

'partial-call' মানে হলো LLM কোন টুলটি কল করবে তা ঠিক করেছে এবং এখন সেই টুলের ইনপুট প্যারামিটারগুলো (JSON) স্ট্রিম করে পাঠাচ্ছে। আর 'call' মানে হলো প্যারামিটার পাঠানো ও Zod ভ্যালিডেশন শেষ হয়ে গেছে এবং এখন আপনার Next.js সার্ভারে আসল TypeScript ফাংশনটি (execute()) রান হচ্ছে।

Thinking স্টেট থেকে যখন মূল টেক্সট স্ট্রিমিং শুরু হয়, তখন স্ক্রিনের লাফানো (CLS) বন্ধ করব কীভাবে?

টেক্সট স্ট্রিমিং শুরু হওয়ার সময় পুরো Thinking কম্পোনেন্টটি মুছে না ফেলে সেটির ভেতরের ড্রয়ারটি কলাপ্স (setIsExpanded(false)) করে দিন এবং উপরের ছোট হেডার বারটি (Thought for 3.2s across 2 tool steps) স্থায়ীভাবে রেখে দিন। এতে মেসেজ কার্ডের অবস্থান ঠিক থাকে এবং ইউজার চাইলে পরেও ক্লিক করে দেখে নিতে পারেন এজেন্ট ভেতরে কী কী টুল চালিয়েছিল।

কোনো একটি কাস্টম টুল যদি রান হতে ৫ সেকেন্ড সময় নেয়, তবে সেই টুলের ভেতরের অগ্রগতিও কি লাইভ দেখানো সম্ভব?

হ্যাঁ, সম্ভব। আমাদের /api/chat/route.ts ফাইলে যেভাবে createDataStreamResponse থেকে dataStream.writeData() কল করা হয়েছে, সেই একই dataStream রেফারেন্স আপনি আপনার কাস্টম টুলে পাস করে দিয়ে টুলের ভেতর থেকেও ইন্টারমিডিয়েট স্ট্যাটাস মেসেজ ফ্রন্টএন্ডের useChat-এ পাঠাতে পারবেন।

🟢 ফ্রিল্যান্স ও কনট্রাক্ট প্রজেক্টের জন্য উন্মুক্ত

কাস্টম ওয়েব অ্যাপ বা এআই অটোমেশন বানাতে চান?

আমি স্টার্টআপ ও আধুনিক ব্যবসার জন্য স্কেলেবল ফুল-স্ট্যাক ওয়েব অ্যাপ্লিকেশন (Next.js, React, Node, PostgreSQL) এবং ইন্টেলিজেন্ট এআই এজেন্ট সলিউশন তৈরি করি। আপনার প্রজেক্টের আইডিয়া নিয়ে কথা বলা যাক!

ফুল-স্ট্যাক এমভিপি (MVP) ডেভেলপমেন্ট
অটোনোমাস এআই এজেন্ট ও এলএলএম ইন্টিগ্রেশন
পারফেক্ট এসইও ও সর্বোচ্চ পারফরম্যান্স
ক্লিন কোড ও মডার্ন আর্কিটেকচার

আর্টিকেলটি কি আপনার ভালো লেগেছে?

তন্ময়ের কাজকে সাপোর্ট করতে তালি (Clap) দিয়ে উৎসাহিত করুন!

0 views
Z

জাহিদ হাসান তন্ময়

সফটওয়্যার ডেভেলপার

MERN ফুল-স্ট্যাক ডেভেলপার এবং AI এজেন্ট ডেভেলপার, ঢাকা, বাংলাদেশ। ওয়েব ডেভেলপমেন্ট, রিঅ্যাক্ট, লারাভেল এবং আমার লার্নিং জার্নি নিয়ে লিখছি।

Related Articles

Stay Updated

Subscribe to get insights on full-stack architecture, AI agent engineering, and web development.