This guide shows how to build chat interfaces by combining Nuxt UI Chat components with AI Search primitives.
| Component | Purpose |
|---|---|
UChatPrompt | Text input with submit button |
UChatPromptSubmit | Submit/stop/reload button |
UChatMessages | Message list with auto-scroll |
UChatMessage | Individual message display |
| Component | Purpose |
|---|---|
AiSearchPreStream | Streaming code highlighting |
AiSearchToolCall | Tool invocation indicator |
AiSearchReasoning | Collapsible thinking section |
AiSearchSources | Search result citations |
AskAiInput | Rich ProseKit editor |
AiSearchToolbar | Floating search bar |
<script setup>
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport } from 'ai'
const input = ref('')
const chat = new Chat({
transport: new DefaultChatTransport({ api: '/api/chat' })
})
function handleSubmit(e: Event) {
e.preventDefault()
if (!input.value.trim()) return
chat.sendMessage({ text: input.value })
input.value = ''
}
</script>
<template>
<div class="flex flex-col h-screen">
<UChatMessages
:messages="chat.messages"
:status="chat.status"
should-auto-scroll
class="flex-1 p-4"
/>
<UChatPrompt
v-model="input"
:error="chat.error"
class="sticky bottom-0"
@submit="handleSubmit"
>
<UChatPromptSubmit
:status="chat.status"
@stop="chat.stop()"
@reload="chat.regenerate()"
/>
</UChatPrompt>
</div>
</template>
AI SDK v5 uses a parts-based message format. Render each part type:
<script setup>
import AiSearchPreStream from '#components'
const components = {
pre: AiSearchPreStream
}
function getToolLabel(part: any) {
const labels: Record<string, string> = {
'search': 'Searching...',
'get-page': `Read ${part.args?.path || ''}`,
}
return labels[part.toolName] || part.toolName
}
</script>
<template>
<UChatMessages :messages="chat.messages" :status="chat.status">
<template #content="{ message }">
<template v-for="(part, index) in message.parts" :key="index">
<!-- Reasoning/thinking -->
<AiSearchReasoning
v-if="part.type === 'reasoning'"
:text="part.text"
:is-streaming="part.state !== 'done'"
/>
<!-- Text content with streaming code -->
<MDCCached
v-else-if="part.type === 'text'"
:value="part.text"
:cache-key="`${message.id}-${index}`"
:components="components"
:parser-options="{ highlight: false }"
/>
<!-- Tool calls -->
<AiSearchToolCall
v-else-if="part.type === 'tool-invocation'"
:text="getToolLabel(part)"
:is-loading="part.state !== 'output-available'"
/>
</template>
</template>
</UChatMessages>
</template>
Track sources from search tool calls:
<script setup>
const sources = ref<Map<string, Source[]>>(new Map())
const chat = new Chat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
onData(data) {
// Capture sources from tool results
if (data.type === 'tool-result' && data.toolName === 'search') {
sources.value.set(chat.lastMessage?.id, data.result)
}
}
})
</script>
<template>
<UChatMessages :messages="chat.messages" :status="chat.status">
<template #content="{ message }">
<!-- Message parts... -->
<AiSearchSources
v-if="sources.get(message.id)"
:sources="sources.get(message.id)"
/>
</template>
</UChatMessages>
</template>
Compose with Nuxt UI's USlideover:
<script setup>
const open = ref(false)
</script>
<template>
<UButton @click="open = true">Ask AI</UButton>
<USlideover v-model:open="open" side="right">
<template #header>
<span>Ask AI</span>
</template>
<template #body>
<UChatMessages
:messages="chat.messages"
:status="chat.status"
class="flex-1"
>
<!-- Content template... -->
</UChatMessages>
</template>
<template #footer>
<UChatPrompt v-model="input" @submit="handleSubmit">
<UChatPromptSubmit :status="chat.status" />
</UChatPrompt>
</template>
</USlideover>
</template>
Add suggested questions when chat is empty:
<script setup>
const faq = [
{ category: 'Getting Started', items: ['How do I install?', 'What are the requirements?'] },
{ category: 'Configuration', items: ['How do I configure embeddings?', 'What providers are supported?'] }
]
function askQuestion(question: string) {
chat.sendMessage({ text: question })
}
</script>
<template>
<div v-if="chat.messages.length === 0" class="p-4">
<div v-for="category in faq" :key="category.category" class="mb-4">
<h4 class="text-xs font-medium text-muted uppercase mb-2">
{{ category.category }}
</h4>
<button
v-for="question in category.items"
:key="question"
class="block text-sm text-muted hover:text-highlighted py-1"
@click="askQuestion(question)"
>
{{ question }}
</button>
</div>
</div>
<UChatMessages v-else :messages="chat.messages" :status="chat.status">
<!-- Content template... -->
</UChatMessages>
</template>
Use AskAiInput for rich text editing:
<template>
<form @submit.prevent="handleSubmit" class="border rounded-lg p-2">
<AskAiInput placeholder="Ask anything..." />
<div class="flex justify-end mt-2">
<UButton type="submit" :loading="chat.status === 'streaming'">
Send
</UButton>
</div>
</form>
</template>
Add a global search entry point:
<template>
<div>
<!-- Page content -->
<AiSearchToolbar />
</div>
</template>
The toolbar navigates to /chat?q={query} on submit.
See the chat template for a complete implementation with: