|
| 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