Skip to content

Commit f808561

Browse files
antfubotantfu
andauthored
docs: let the Getting Started wizard compose a prompt (Ask AI / Copy prompt) (#306)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 63bd57d commit f808561

6 files changed

Lines changed: 398 additions & 5 deletions

File tree

.gitignore

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

0 commit comments

Comments
 (0)