Skip to content

Commit 89ee233

Browse files
authored
feat(ai): integrate assistant (#6)
1 parent 53d69e4 commit 89ee233

18 files changed

Lines changed: 736 additions & 194 deletions

app/app.config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ export default defineAppConfig({
6868
},
6969
pageHero: {
7070
slots: {
71-
title: 'font-semibold text-5xl sm:text-6xl lg:text-6xl tracking-tighter',
71+
title: 'font-medium text-5xl sm:text-6xl lg:text-12xl tracking-tighter',
7272
description: 'text-lg sm:text-xl mt-6',
73-
container: 'max-w-5xl',
73+
container: 'max-w-6xl',
7474
},
7575
},
7676
page: {
@@ -185,6 +185,12 @@ export default defineAppConfig({
185185
toc: {
186186
title: 'On this page',
187187
},
188+
assistant: {
189+
// Shows the "Ask AI" (requires AI_GATEWAY_API_KEY).
190+
enabled: false,
191+
// Suggested questions shown before the first message, grouped by category.
192+
faqQuestions: [] as { category: string; items: string[] }[],
193+
},
188194
docs: {
189195
rss: {
190196
// Empty = `${seo.siteName} Documentation`.

app/app.vue

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,15 @@ provide('navigation', navTree)
5656
const colorMode = useColorMode()
5757
const historyOpen = useVersionHistory()
5858
59-
// `g-h` (a chained sequence — `-` separates keys in order, `_` would mean a
60-
// modifier) rather than `meta_h`: ⌘H is Hide Window at the macOS level, so the page
61-
// never receives it. `defineShortcuts` already ignores keypresses in inputs, which
62-
// is what makes the bare `d` safe. Both are documented in the README.
59+
const { assistant } = useAppConfig()
60+
const assistantOpen = useAssistant()
61+
62+
// Mounting pulls the AI SDK + shiki chunks, so the panel only mounts after the first open.
63+
const assistantMounted = ref(false)
64+
watch(assistantOpen, (isOpen) => {
65+
if (isOpen) assistantMounted.value = true
66+
})
67+
6368
defineShortcuts({
6469
'd': () => (colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'),
6570
'g-h': () => (historyOpen.value = !historyOpen.value),
@@ -92,6 +97,7 @@ defineShortcuts({
9297
:placeholder="status !== 'success' ? 'Loading...' : undefined"
9398
/>
9499
<LazyVersionHistory />
100+
<LazyAssistantChat v-if="assistant?.enabled && assistantMounted" />
95101
</ClientOnly>
96102
</UApp>
97103
</template>

app/components/AppHeader.vue

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script setup lang="ts">
2-
const { header, footer } = useAppConfig()
2+
const { header, footer, assistant } = useAppConfig()
33
const cms = useCMS()
44
const historyOpen = useVersionHistory()
5+
const assistantOpen = useAssistant()
56
const navigation = useMainNavigation()
67
</script>
78

@@ -21,6 +22,26 @@ const navigation = useMainNavigation()
2122
class="text-muted font-normal hidden lg:inline-flex min-w-[150px]"
2223
/>
2324

25+
<UButton
26+
v-if="assistant?.enabled"
27+
label="Ask AI"
28+
color="neutral"
29+
variant="outline"
30+
class="hidden lg:inline-flex"
31+
@click="assistantOpen = true"
32+
/>
33+
34+
<UButton
35+
v-if="assistant?.enabled"
36+
icon="i-lucide-sparkles"
37+
aria-label="Ask AI"
38+
color="neutral"
39+
variant="outline"
40+
:ui="{ leadingIcon: 'size-4' }"
41+
class="p-2 lg:hidden"
42+
@click="assistantOpen = true"
43+
/>
44+
2445
<UButton
2546
icon="i-lucide-history"
2647
:color="cms.mode === 'prod' ? 'neutral' : 'warning'"

app/components/AssistantChat.vue

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
<script setup lang="ts">
2+
import { DefaultChatTransport, isReasoningUIPart, isTextUIPart, isToolUIPart, getToolName, type ToolUIPart, type DynamicToolUIPart, type UIMessage } from 'ai'
3+
import { useChat } from '@ai-sdk/vue'
4+
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
5+
import highlight from '@comark/nuxt/plugins/highlight'
6+
7+
const MAX_INPUT = 1000
8+
9+
const open = useAssistant()
10+
const { assistant } = useAppConfig()
11+
12+
const input = ref('')
13+
const { messages, status, error, sendMessage, regenerate, stop } = useChat({
14+
transport: new DefaultChatTransport({ api: '/api/assistant' }),
15+
})
16+
17+
const plugins = [highlight()]
18+
19+
// Suggestions grouped by category, shown before the first message.
20+
const questions = computed(() => assistant?.faqQuestions ?? [])
21+
22+
watch(input, (value) => {
23+
if (value.length > MAX_INPUT) input.value = value.slice(0, MAX_INPUT)
24+
})
25+
26+
function onSubmit() {
27+
const text = input.value.trim()
28+
if (!text) return
29+
sendMessage({ text })
30+
input.value = ''
31+
}
32+
33+
function ask(question: string) {
34+
sendMessage({ text: question })
35+
}
36+
37+
const copied = ref(false)
38+
async function copyConversation() {
39+
const text = messages.value
40+
.map((message) => {
41+
const content = message.parts.filter(isTextUIPart).map((part) => part.text).join('\n')
42+
return `${message.role === 'user' ? 'User' : 'Assistant'}:\n${content}`
43+
})
44+
.join('\n\n')
45+
await navigator.clipboard.writeText(text)
46+
copied.value = true
47+
setTimeout(() => (copied.value = false), 1500)
48+
}
49+
50+
function clearChat() {
51+
stop()
52+
messages.value = []
53+
}
54+
55+
function toolMeta(part: ToolUIPart | DynamicToolUIPart) {
56+
const name = getToolName(part)
57+
const streaming = isToolStreaming(part)
58+
const input = part.input as Record<string, string> | undefined
59+
if (name === 'search_docs') {
60+
return { icon: 'i-lucide-text-search', text: streaming ? 'Searching the docs' : 'Searched the docs', suffix: input?.query }
61+
}
62+
if (name === 'get_page') {
63+
return { icon: 'i-lucide-book-open', text: streaming ? 'Reading a page' : 'Read a page', suffix: input?.path }
64+
}
65+
return { icon: 'i-lucide-wrench', text: name.replace(/_/g, ' ') }
66+
}
67+
68+
/** Live tool rows show while the answer streams; once done they collapse into one "Used N sources" row. */
69+
function isMessageStreaming(message: UIMessage) {
70+
const last = messages.value[messages.value.length - 1]
71+
return (
72+
message.role === 'assistant' &&
73+
message.id === last?.id &&
74+
(status.value === 'streaming' || status.value === 'submitted')
75+
)
76+
}
77+
78+
/** Unique doc pages the assistant read for this message, in call order. */
79+
function messageSources(message: UIMessage) {
80+
const paths = new Set<string>()
81+
for (const part of message.parts) {
82+
if (isToolUIPart(part) && getToolName(part) === 'get_page') {
83+
const path = (part.input as { path?: string } | undefined)?.path
84+
if (path) paths.add(path.startsWith('/') ? path : `/${path}`)
85+
}
86+
}
87+
return [...paths]
88+
}
89+
90+
function messageToolCount(message: UIMessage) {
91+
return message.parts.filter((part) => isToolUIPart(part)).length
92+
}
93+
94+
function sourcesLabel(message: UIMessage) {
95+
const count = messageSources(message).length || messageToolCount(message)
96+
return `Used ${count} source${count === 1 ? '' : 's'}`
97+
}
98+
</script>
99+
100+
<template>
101+
<USlideover
102+
v-model:open="open"
103+
:ui="{ content: 'sm:max-w-md', body: 'flex flex-col' }"
104+
>
105+
<template #header>
106+
<div class="flex items-center justify-between w-full">
107+
<h2 class="font-bold text-highlighted">Chat</h2>
108+
<div class="flex items-center gap-1">
109+
<UButton
110+
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
111+
color="neutral"
112+
variant="ghost"
113+
:disabled="!messages.length"
114+
:ui="{ leadingIcon: 'size-4' }"
115+
aria-label="Copy conversation"
116+
@click="copyConversation"
117+
/>
118+
<UButton
119+
icon="i-lucide-trash-2"
120+
color="neutral"
121+
variant="ghost"
122+
:disabled="!messages.length"
123+
:ui="{ leadingIcon: 'size-4' }"
124+
aria-label="Clear conversation"
125+
@click="clearChat"
126+
/>
127+
<UButton
128+
icon="i-lucide-chevron-right"
129+
color="neutral"
130+
variant="ghost"
131+
:ui="{ leadingIcon: 'size-4' }"
132+
aria-label="Close chat"
133+
@click="open = false"
134+
/>
135+
</div>
136+
</div>
137+
</template>
138+
139+
<template #body>
140+
<UChatPalette>
141+
<UChatMessages
142+
v-if="messages.length"
143+
:messages="messages"
144+
:status="status"
145+
:user="{ side: 'right', variant: 'soft' }"
146+
:assistant="{ side: 'left', variant: 'naked' }"
147+
>
148+
<template #indicator>
149+
<AssistantIndicator />
150+
</template>
151+
152+
<template #content="{ message }">
153+
<UChatTool
154+
v-if="message.role === 'assistant' && !isMessageStreaming(message) && messageToolCount(message)"
155+
icon="i-lucide-bookmark"
156+
:text="sourcesLabel(message)"
157+
>
158+
<div
159+
v-if="messageSources(message).length"
160+
class="flex flex-col items-start gap-1 pt-1"
161+
>
162+
<ULink
163+
v-for="path in messageSources(message)"
164+
:key="path"
165+
:to="path"
166+
class="text-sm text-muted hover:text-highlighted"
167+
>
168+
{{ path }}
169+
</ULink>
170+
</div>
171+
</UChatTool>
172+
173+
<template
174+
v-for="(part, index) in message.parts"
175+
:key="`${message.id}-${part.type}-${index}`"
176+
>
177+
<UChatReasoning
178+
v-if="isReasoningUIPart(part)"
179+
icon="i-lucide-brain"
180+
:text="part.text"
181+
:streaming="isPartStreaming(part)"
182+
>
183+
<Comark
184+
:markdown="part.text"
185+
:streaming="isPartStreaming(part)"
186+
:plugins="plugins"
187+
class="text-sm text-muted *:first:mt-0 *:last:mb-0"
188+
/>
189+
</UChatReasoning>
190+
191+
<UChatTool
192+
v-else-if="isToolUIPart(part) && isMessageStreaming(message)"
193+
v-bind="toolMeta(part)"
194+
:streaming="isToolStreaming(part)"
195+
/>
196+
197+
<template v-else-if="isTextUIPart(part)">
198+
<Comark
199+
v-if="message.role === 'assistant'"
200+
:markdown="part.text"
201+
:streaming="isPartStreaming(part)"
202+
:plugins="plugins"
203+
class="*:first:mt-0 *:last:mb-0"
204+
/>
205+
<p
206+
v-else
207+
class="whitespace-pre-wrap"
208+
>
209+
{{ part.text }}
210+
</p>
211+
</template>
212+
</template>
213+
</template>
214+
</UChatMessages>
215+
216+
<div
217+
v-else
218+
class="flex-1 flex flex-col justify-end gap-6 py-4 overflow-y-auto"
219+
>
220+
<div class="flex flex-col gap-6">
221+
<UPageLinks
222+
v-for="category in questions"
223+
:key="category.category"
224+
:title="category.category"
225+
:links="category.items.map((item: string) => ({ label: item, onClick: () => ask(item) }))"
226+
/>
227+
</div>
228+
</div>
229+
230+
<template #prompt>
231+
<UChatPrompt
232+
v-model="input"
233+
:error="error"
234+
:rows="2"
235+
:ui="{ root: 'rounded-lg! px-2.5' }"
236+
placeholder="What would you like to know?"
237+
autofocus
238+
@submit="onSubmit"
239+
>
240+
<template #footer>
241+
<div class="flex items-center justify-between w-full px-2.5">
242+
<span class="text-xs text-dimmed tabular-nums">{{ input.length }} / {{ MAX_INPUT }}</span>
243+
<UChatPromptSubmit
244+
:status="status"
245+
icon="i-lucide-corner-down-left"
246+
color="neutral"
247+
@stop="stop()"
248+
@reload="regenerate()"
249+
/>
250+
</div>
251+
</template>
252+
</UChatPrompt>
253+
</template>
254+
</UChatPalette>
255+
</template>
256+
</USlideover>
257+
</template>

0 commit comments

Comments
 (0)