챗봇에 날씨 조회 툴을 붙였더니, 답변이 "서울은 현재 23도이고 맑습니다" 같은 문장으로만 나왔다. 데이터는 이미 구조화돼서 손에 들어왔는데 그걸 다시 문장으로 풀어 보여주는 게 아깝더라. 카드 UI로 온도랑 아이콘을 딱 띄우면 훨씬 나을 텐데.
이게 소위 '생성형 UI(Generative UI)'다. LLM이 어떤 툴을 부를지 정하면, 그 툴의 결과를 텍스트가 아니라 내가 만든 컴포넌트로 렌더링하는 것. AI SDK v5의 useChat은 메시지를 parts 배열로 주는데, 여기에 툴 호출 상태가 그대로 담겨 있어서 생각보다 붙이기 쉽다.
서버: 툴 정의
먼저 라우트에서 툴을 하나 정의한다. inputSchema는 Zod로 잡고, execute에서 실제 데이터를 돌려주면 된다.
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, tool, convertToModelMessages } from 'ai';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages: convertToModelMessages(messages),
tools: {
weather: tool({
description: '도시의 현재 날씨를 조회한다',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 23, condition: 'sunny' }),
}),
},
});
return result.toUIMessageStreamResponse();
}
클라이언트: part 타입으로 분기
핵심은 클라이언트다. message.parts를 순회하면서 type이 tool-weather인 part를 만나면 텍스트 대신 카드를 그린다. part에는 state가 있어서 실행 중(input-available)엔 스켈레톤, 완료(output-available)엔 결과를 보여줄 수 있다.
'use client';
import { useChat } from '@ai-sdk/react';
export default function Chat() {
const { messages } = useChat();
return messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.text}</p>;
if (part.type === 'tool-weather') {
if (part.state === 'output-available') {
const { city, tempC, condition } = part.output;
return <WeatherCard key={i} city={city} temp={tempC} icon={condition} />;
}
return <WeatherCard key={i} loading />;
}
})}
</div>
));
}
tool- 접두사 뒤에 오는 이름이 서버에서 정의한 툴 키(weather)와 정확히 일치해야 한다. 이걸 헷갈리면 part가 조용히 무시된다.
실전 팁
로딩 상태를 꼭 챙기자. 툴 execute가 외부 API를 부르면 몇 초씩 걸리는데, state 분기로 스켈레톤을 먼저 그려두면 화면이 훨씬 자연스럽다. 결국 생성형 UI의 8할은 "언제 무엇을 그릴지"를 state로 나누는 일이더라. 챗봇 답변을 문장에서 UI로 한 단계만 올려도 완성도가 확 달라진다.