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