ใช้ UI ของ Vercel AI SDK และองค์ประกอบ AI กับ Prompt API

เผยแพร่เมื่อ: 16 กรกฎาคม 2026

ในบทความเรื่องใช้ Prompt API ในตัวกับ Vercel AI SDK คุณได้เห็น Primitive หลัก 4 รายการสำหรับการสร้าง ได้แก่ generateText(), streamText(), โค้ดแบบไฮบริด และเอาต์พุตที่มีโครงสร้างโดยใช้ Output.object() ซึ่งทั้งหมดนี้ขับเคลื่อนโดย @browser-ai/core คราวนี้คุณจะสร้างสิ่งที่โต้ตอบได้มากขึ้น นั่นคือ UI แชทแบบสตรีมมิงเต็มรูปแบบที่ทำงานในเบราว์เซอร์ทั้งหมด โดยจะเปลี่ยนไปใช้โมเดลในระบบคลาวด์โดยอัตโนมัติเมื่อ Prompt API ไม่พร้อมใช้งาน

สิ่งที่คุณจะสร้าง

อินเทอร์เฟซแชท React ที่ทำสิ่งต่อไปนี้

  • ใช้ฮุก useChat ของ Vercel AI SDK สำหรับการสนทนาไปมาแบบสตรีมมิง
  • เรียกใช้ลูปโมเดลในเบราว์เซอร์ โดยไม่จำเป็นต้องใช้เซิร์ฟเวอร์แบ็กเอนด์
  • เปลี่ยนไปใช้ Gemini 2.5 Flash โดยอัตโนมัติเมื่อ Prompt API ไม่พร้อมใช้งาน
  • แสดงการตอบกลับของ Assistant เป็น Markdown รูปแบบ GitHub โดยจัดการโทเค็นที่ไม่สมบูรณ์ระหว่างการสตรีม
  • แสดงการโหลดแบบ Shimmer ที่มีข้อความ "กำลังคิด…" ขณะรอโทเค็นแรก
  • เลื่อนไปที่ข้อความใหม่โดยอัตโนมัติ พร้อมปุ่มเลื่อนไปด้านล่างเมื่อคุณเลื่อนขึ้น

ทรัพยากร Dependency เพิ่มเติม

นอกเหนือจาก ai, @browser-ai/core และ @ai-sdk/google แล้ว UI แชทต้องใช้ React, การผูก React ของ AI SDK และแพ็กเกจ Markdown 2-3 รายการ

npm install react react-dom @ai-sdk/react
npm install react-markdown remark-gfm harden-react-markdown
npm install -D @types/react @types/react-dom

สำหรับ UI ให้เพิ่ม Tailwind CSS, ยูทิลิตีของคอมโพเนนต์บางรายการ และ Lucide สำหรับไอคอนด้วย

npm install -D tailwindcss postcss autoprefixer
npm install clsx tailwind-merge lucide-react

การกำหนดค่า Vite: บิลด์แบบหลายรายการ

โปรเจ็กต์มี index.html อยู่แล้ว เพิ่ม chat.html เป็นจุดเริ่มต้นที่ 2 และกำหนดค่า Vite ให้สร้างทั้ง 2 รายการ

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { '@': resolve(__dirname, './src') } },
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        chat: resolve(__dirname, 'chat.html'),
      },
    },
  },
});

chat.html มีขนาดเล็ก โดยมีเพียง <div id="root"> และแท็กสคริปต์

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Built-in AI Chatbot</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/chat.tsx"></script>
  </body>
</html>

การเลือกโมเดลอัตโนมัติ

ในแชทบอต แอปจะเลือกโมเดลโดยอัตโนมัติ โดยจะลองใช้โมเดลในตัวก่อน และเปลี่ยนไปใช้โมเดลในระบบคลาวด์หาก Prompt API ไม่พร้อมใช้งาน

แอปจะดำเนินการนี้ในเวลาที่ใช้ในการโหลดโมดูล ก่อนที่ React จะติดตั้ง เพื่อให้ตัวแทนพร้อมใช้งานเมื่อผู้ใช้พิมพ์ข้อความแรก

const agentPromise: Promise<ToolLoopAgent> = (async () => {
  const builtIn = browserAI();
  let model: any = builtIn;

  if (typeof builtIn.availability === 'function') {
    const availability = await builtIn.availability();
    if (availability === 'unavailable') {
      const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
      model = createGoogleGenerativeAI({ apiKey })('gemini-2.5-flash');
    } else if (availability === 'downloadable') {
      await builtIn.createSessionWithProgress(() => {});
    }
  }

  return new ToolLoopAgent({ model, instructions: 'You are a helpful assistant.' });
})();

ส่วนใหม่คือ ToolLoopAgent ซึ่งเป็นนามธรรมของ AI SDK ที่จัดการลูปการสนทนาไปมาบนโมเดลใดก็ได้ โดยจะใช้โมเดลและพรอมต์ของระบบ และจัดการการโต้ตอบภายใน

เชื่อมต่อตัวแทนเพื่อใช้ Chat

ฮุก useChat ของแพ็กเกจ @ai-sdk/react จะสื่อสารกับปลายทาง HTTP ตามปกติ สำหรับการอนุมานฝั่งเบราว์เซอร์ ให้ใช้ DirectChatTransport แทน ซึ่งจะเรียกใช้ลูป ToolLoopAgent ในเบราว์เซอร์ทั้งหมดโดยไม่เกี่ยวข้องกับเซิร์ฟเวอร์

const transport = useMemo(() => new DirectChatTransport({ agent }), [agent]);
const { messages, sendMessage, status, stop } = useChat({ transport });

useMemo มีความสำคัญเนื่องจาก DirectChatTransport เก็บสถานะการสนทนาไว้ จึงต้องเป็นข้อมูลอ้างอิงที่เสถียร การสร้างข้อมูลอ้างอิงใหม่ในการแสดงผลทุกครั้งจะรีเซ็ตการสนทนา

useChat จะให้ข้อมูลต่อไปนี้

  • Messages: การสนทนาทั้งหมดเป็น UIMessage[] โดยแต่ละรายการจะมี role และอาร์เรย์ parts
  • sendMessage({ text }): ส่งข้อความใหม่ของผู้ใช้และเริ่มสตรีมการตอบกลับ
  • Status: 'idle' | 'submitted' | 'streaming' | 'error'
  • Stop: ยกเลิกการสร้างที่กำลังดำเนินการ

แสดงข้อความ

แต่ละข้อความจะมีอาร์เรย์ parts สำหรับแชทบอตนี้ เราสนใจเฉพาะส่วน type: 'text' ข้อความของผู้ใช้จะปรากฏเป็นฟองข้อความที่จัดชิดขวา ส่วนข้อความของ Assistant จะจัดชิดซ้ายพร้อมไอคอน

const ChatMessage = ({ message, isStreaming }: { message: UIMessage; isStreaming: boolean }) => {
  const isUser = message.role === 'user';

  const textParts = message.parts.map((part, i) => {
    if (part.type !== 'text') return null;
    if (isUser) return <span key={i}>{part.text}</span>;
    return <Response key={i} parseIncompleteMarkdown={isStreaming}>{part.text}</Response>;
  });

  if (isUser) {
    return (
      <div className="flex flex-col items-end gap-2 animate-fade-up">
        <MessageContent className="w-fit max-w-[min(80%,56ch)] ...">
          {textParts}
        </MessageContent>
      </div>
    );
  }

  return (
    <div className="flex items-start gap-3">
      <AIIcon />
      <MessageContent className="text-[13px] leading-[1.65]">{textParts}</MessageContent>
    </div>
  );
};

MessageContent และ Response เป็น องค์ประกอบ AI ซึ่งเป็นคอมโพเนนต์ต้นฉบับสไตล์ shadcn ที่คุณคัดลอกลงใน โปรเจ็กต์แทนการติดตั้งจาก npm Response จะรวม react-markdown กับ remark-gfm สำหรับ Markdown รูปแบบ GitHub (ตาราง รายการสิ่งที่ต้องทำ ขีดฆ่า) และ harden-react-markdown เพื่อล้างข้อมูลลิงก์และรูปภาพในเอาต์พุต AI

พร็อพ parseIncompleteMarkdown จะเป็น true ขณะที่ข้อความยังคงสตรีมอยู่ ระหว่างการสตรีม โมเดลอาจเขียน **bold แต่ยังไม่ ปิด ** ทำให้มีโทเค็นที่ค้างอยู่ซึ่งจะแสดงเป็นเครื่องหมายดอกจัน parseIncompleteMarkdown จะปิด **, __, `, ~~ ที่เปิดอยู่ และ ตัด [ ที่ค้างอยู่ซึ่งเป็นจุดเริ่มต้นของลิงก์ เพื่อให้เอาต์พุตที่แสดงผลสะอาดในทุกๆ ชิ้นส่วนที่เพิ่มขึ้น

สถานะ "กำลังคิด…"

ระหว่างการส่งข้อความและการรับโทเค็นแรก status จะเป็น 'submitted' ในช่วงเวลานี้ แอปจะแสดงการโหลดแบบ Shimmer ที่เคลื่อนไหว

{status === 'submitted' && messages.at(-1)?.role !== 'assistant' && (
  <ThinkingMessage />
)}

เงื่อนไข messages.at(-1)?.role !== 'assistant' จะป้องกันไม่ให้การโหลดแบบ Shimmer ปรากฏขึ้นอีกครั้งเมื่อข้อความของ Assistant เริ่มสตรีม

ThinkingMessage ใช้คอมโพเนนต์ Shimmer ซึ่งเป็น <span> ที่มีการไล่ระดับสีที่เคลื่อนไหว โดยใช้ background-clip: text ซึ่งจะทำให้ข้อความ "กำลังคิด…" มีเอฟเฟกต์ไฮไลต์แบบกวาด

การเลื่อนอัตโนมัติ

เมื่อมีเนื้อหาใหม่ แอปจะเลื่อนไปด้านล่าง แต่จะเลื่อนก็ต่อเมื่อผู้ใช้อยู่ด้านล่างอยู่แล้ว การเลื่อนผู้ใช้ไปจากสิ่งที่กำลังอ่านอยู่ระหว่างการสนทนาอาจทำให้เกิดความรำคาญ

const [isAtBottom, setIsAtBottom] = useState(true);

useEffect(() => {
  if (isAtBottom) endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, status, isAtBottom]);

const handleScroll = () => {
  const el = containerRef.current;
  if (!el) return;
  setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 50);
};

ปุ่มเลื่อนไปด้านล่างแบบลอยจะปรากฏขึ้นเมื่อ isAtBottom เป็นเท็จ และจะจางหายไปเมื่อผู้ใช้อยู่ด้านล่างอีกครั้ง

พื้นที่ป้อนข้อมูล

พื้นที่ข้อความจะปรับขนาดโดยอัตโนมัติขณะที่คุณพิมพ์ โดยรีเซ็ตความสูงเป็น auto ในทุกๆ เหตุการณ์อินพุต แล้วตั้งค่าเป็น scrollHeight โดยจะส่งเมื่อกด Enter (ไม่ใช่ Shift+Enter) และขณะที่การตอบกลับกำลังสตรีม ปุ่มส่งจะถูกแทนที่ด้วยปุ่มหยุดที่เรียกใช้ stop()

<textarea
  onInput={(e) => {
    const el = e.currentTarget;
    el.style.height = 'auto';
    el.style.height = `${el.scrollHeight}px`;
  }}
  onKeyDown={(e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      if (input.trim() && !isStreaming) {
        sendMessage({ text: input });
        setInput('');
      }
    }
  }}
/>;
{
  isStreaming ? (
    <Button variant="outline" onClick={stop}>
      Stop
    </Button>
  ) : (
    <Button type="submit" disabled={!input.trim()}>
      Send
    </Button>
  );
}

ติดตั้งด้วยสถานะการโหลด

เนื่องจาก agentPromise เป็นแบบไม่พร้อมกัน ให้รอจนกว่าจะเสร็จสิ้นก่อนแสดงผล Chat ตัวห่อหุ้ม App จะแก้ปัญหา Promise และแสดงตัวหมุนในระหว่างนี้

function App() {
  const [agent, setAgent] = (useState < ToolLoopAgent) | (null > null);

  useEffect(() => {
    agentPromise.then(setAgent);
  }, []);

  if (!agent) {
    return (
      <div className="flex h-dvh items-center justify-center">
        <Loader size={20} />
      </div>
    );
  }

  return <Chat agent={agent} />;
}

เมื่อตัวแทนแก้ปัญหาได้ ไม่ว่าจะเป็นการบูตโมเดลในตัวทันทีหรือรอการดาวน์โหลดโมเดล ตัวหมุนจะหายไปและ UI แชทจะติดตั้ง

สาธิต

การสาธิตแบบสด เป็นแชทบอตที่ทำงานได้อย่างสมบูรณ์ซึ่งทำงานในเบราว์เซอร์ทั้งหมด พิมพ์ข้อความแล้วกด Enter หาก Prompt API พร้อมใช้งาน การตอบกลับจะสตรีมโดยตรงจากโมเดลในอุปกรณ์โดยไม่มีคำขอเครือข่าย หากเบราว์เซอร์ไม่รองรับ Prompt API ระบบจะเปลี่ยนไปใช้ Gemini 2.5 Flash โดยอัตโนมัติ ลองขอให้แชทบอตอธิบายบางสิ่งในรายการ เขียนข้อมูลโค้ด หรือใช้การจัดรูปแบบ Markdown การตอบกลับจะแสดงผลด้วยบล็อกโค้ด ตาราง และโค้ดแบบอินไลน์ที่จัดรูปแบบแล้ว

อินเทอร์เฟซแชทแสดงการสนทนากับผู้ช่วย AI โดยมีช่องป้อนข้อความและพื้นที่ตอบกลับแบบสตรีม

บทสรุป

ตลอดทั้ง 2 บทความนี้ คุณได้เห็นความสามารถทั้งหมดของ Vercel AI SDK ที่ใช้ Prompt API ในตัวของเบราว์เซอร์ได้ ตั้งแต่ Primitive การสร้างแบบดิบไปจนถึงอินเทอร์เฟซแชทแบบสตรีมมิงที่ขัดเกลาแล้ว

ในบทความเรื่องใช้ Prompt API ในตัวกับ Vercel AI SDK คุณได้เรียนรู้วิธี ใช้ generateText() และ streamText() สำหรับการสร้างข้อความแบบไม่สตรีมมิงและการสร้างข้อความแบบสตรีมมิง วิธีขอเอาต์พุต JSON ที่มีโครงสร้างด้วย Output.object() และ วิธีเขียนโค้ดแบบไฮบริดที่เลือกระหว่างโมเดลในตัวกับผู้ให้บริการระบบคลาวด์ ในรันไทม์โดยไม่ต้องเปลี่ยนแปลงตรรกะการสร้าง

ในเอกสารนี้ คุณได้ใช้บล็อกการสร้างเดียวกันและรวมไว้ใน UI React แบบเต็มรูปแบบ ได้แก่ ToolLoopAgent เพื่อจัดการลูปการสนทนา, useChat กับ DirectChatTransport เพื่อสตรีมการตอบกลับโดยตรงในเบราว์เซอร์ และคอมโพเนนต์องค์ประกอบ AI เพื่อแสดงผลการตอบกลับ Markdown อย่างชัดเจนเมื่อได้รับ โดยทั้งหมดนี้จะเปลี่ยนไปใช้ระบบคลาวด์โดยอัตโนมัติเมื่อ Prompt API ไม่พร้อมใช้งาน

ผลลัพธ์ที่ได้คือการสาธิต 2 รายการที่ทำงานในเบราว์เซอร์ทั้งหมดโดยไม่จำเป็นต้องใช้แบ็กเอนด์