<script setup lang="ts">import type { ToolUIPart, DynamicToolUIPart } from 'ai'import { DefaultChatTransport, isToolUIPart, isReasoningUIPart, isTextUIPart } from 'ai'import { useChat } from '@ai-sdk/vue'import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'import { useDocusI18n } from '../../../../app/composables/useDocusI18n'import { getToolText, getToolSuffix, getToolIcon } from '../../../../app/utils/assistantTools'import AssistantComark from './AssistantComark'import AssistantIndicator from './AssistantIndicator.vue' const { isOpen, isStudioExpanded, messages, faqQuestions } = useAssistant()const config = useRuntimeConfig()const toast = useToast()const { t } = useDocusI18n()const input = ref('') const open = computed({ get: () => isOpen.value && !isStudioExpanded.value, set: (value) => { if (!isStudioExpanded.value) { isOpen.value = value } },}) const promptRef = ref<{ textareaRef?: HTMLTextAreaElement } | null>(null)watch(open, (value) => { if (!value) return nextTick(() => promptRef.value?.textareaRef?.focus({ preventScroll: true }))}) const displayTitle = computed(() => t('assistant.title'))const displayPlaceholder = computed(() => t('assistant.placeholder')) let _skipSync = false const { messages: chatMessages, status, error, sendMessage, regenerate, stop,} = useChat({ messages: messages.value, transport: new DefaultChatTransport({ api: (config.app?.baseURL.replace(/\/$/, '') || '') + config.public.assistant.apiPath, }), onError: (error: Error) => { let message = error.message if (typeof message === 'string' && message[0] === '{') { try { message = JSON.parse(message).message || message } catch { // keep original on malformed JSON } } toast.add({ description: message, icon: 'i-lucide-alert-circle', color: 'error', duration: 0, }) }, onFinish: () => { _skipSync = true messages.value = [...chatMessages.value] nextTick(() => { _skipSync = false }) },}) watch(messages, (newMessages) => { if (_skipSync) return chatMessages.value = newMessages const lastMessage = chatMessages.value[chatMessages.value.length - 1] if (lastMessage?.role === 'user' && status.value !== 'streaming') { regenerate() }}) const canClear = computed(() => messages.value.length > 0) type ToolPart = ToolUIPart | DynamicToolUIPart function getToolOutput(part: ToolPart): string | undefined { if (part.state !== 'output-available' || !part.output) return undefined const output = part.output as Record<string, unknown> const content = (output.content ?? output) as Array<{ text?: string }> | string if (typeof content === 'string') { return content || undefined } if (Array.isArray(content)) { return content.map(c => c.text).filter(Boolean).join('\n') || undefined } return JSON.stringify(output, null, 2)} function onSubmit() { if (!input.value.trim()) return sendMessage({ text: input.value }) input.value = ''} function askQuestion(question: string) { input.value = question onSubmit()} function clearMessages() { if (status.value === 'streaming') { stop() } messages.value = [] chatMessages.value = []} defineShortcuts({ meta_i: { handler: () => { open.value = !open.value }, usingInput: true, },})</script> <template> <USidebar v-model:open="open" side="right" :title="displayTitle" rail :style="{ '--sidebar-width': '24rem' }" :ui="{ footer: 'p-0', actions: 'gap-0.5', container: '!left-auto' }" > <template #actions> <UTooltip v-if="canClear" :text="t('assistant.clearChat')" > <UButton icon="i-lucide-list-x" color="neutral" variant="ghost" @click="clearMessages" /> </UTooltip> </template> <template #close> <UTooltip :text="t('assistant.close')" :kbds="['meta', 'i']" > <UButton icon="i-lucide-panel-right-close" color="neutral" variant="ghost" aria-label="Close" @click="() => { open = false }" /> </UTooltip> </template> <UTheme :ui="{ prose: { p: { base: 'my-2 text-sm/6' }, li: { base: 'my-0.5 text-sm/6' }, ul: { base: 'my-2' }, ol: { base: 'my-2' }, h1: { base: 'text-xl mb-4' }, h2: { base: 'text-lg mt-6 mb-3' }, h3: { base: 'text-base mt-4 mb-2' }, h4: { base: 'text-sm mt-3 mb-1.5' }, code: { base: 'text-xs' }, pre: { root: 'my-2', base: 'text-xs/5' }, table: { root: 'my-2' }, hr: { base: 'my-4' }, }, }" > <UChatMessages v-if="chatMessages.length" should-auto-scroll :messages="chatMessages" :status="status" compact class="px-0 gap-2" :user="{ ui: { container: 'max-w-full pb-3' } }" > <template #indicator> <AssistantIndicator /> </template> <template #content="{ message }"> <template v-for="(part, index) in message.parts" :key="`${message.id}-${part.type}-${index}`" > <UChatReasoning v-if="isReasoningUIPart(part)" :text="part.text" :streaming="isPartStreaming(part)" icon="i-lucide-brain" chevron="leading" > <AssistantComark :markdown="part.text" :streaming="isPartStreaming(part)" /> </UChatReasoning> <template v-else-if="isTextUIPart(part) && part.text.length > 0"> <AssistantComark v-if="message.role === 'assistant'" :markdown="part.text" :streaming="isPartStreaming(part)" /> <p v-else-if="message.role === 'user'" class="whitespace-pre-wrap text-sm/6" > {{ part.text }} </p> </template> <UChatTool v-else-if="isToolUIPart(part)" :text="getToolText(part, t)" :suffix="getToolSuffix(part)" :icon="getToolIcon(part)" :streaming="isToolStreaming(part)" chevron="leading" > <pre v-if="getToolOutput(part)" class="text-xs text-muted whitespace-pre-wrap break-all rounded-md border border-muted bg-muted p-2 max-h-64 overflow-y-auto" v-text="getToolOutput(part)" /> </UChatTool> </template> </template> </UChatMessages> <div v-else> <div v-if="!faqQuestions?.length" class="flex h-full flex-col items-center justify-center py-12 text-center" > <div class="mb-4 flex size-12 items-center justify-center rounded-full bg-primary/10"> <UIcon name="i-lucide-message-circle-question" class="size-6 text-primary" /> </div> <h3 class="mb-2 text-base font-medium text-highlighted"> {{ t('assistant.askMeAnything') }} </h3> <p class="max-w-xs text-sm text-muted"> {{ t('assistant.askMeAnythingDescription') }} </p> </div> <template v-else> <div class="flex flex-col gap-6"> <UPageLinks v-for="category in faqQuestions" :key="category.category" :title="category.category" :links="category.items.map(item => ({ label: item, onClick: () => askQuestion(item) }))" /> </div> </template> </div> </UTheme> <template #footer> <UChatPrompt ref="promptRef" v-model="input" :error="error" :placeholder="displayPlaceholder" variant="naked" size="sm" autofocus :ui="{ base: 'px-0' }" class="px-4" @submit="onSubmit" > <template #footer> <div class="flex items-center gap-1.5 text-xs text-dimmed"> <span>{{ t('assistant.lineBreak') }}</span> <UKbd size="sm" value="shift" /> <UKbd size="sm" value="enter" /> </div> <UChatPromptSubmit size="sm" :status="status" :disabled="status === 'ready' && !input.trim()" @stop="stop()" @reload="regenerate()" /> </template> </UChatPrompt> </template> </USidebar></template>
<script setup lang="ts">
import type { ToolUIPart, DynamicToolUIPart } from 'ai'
import { DefaultChatTransport, isToolUIPart, isReasoningUIPart, isTextUIPart } from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai'
import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
import { getToolText, getToolSuffix, getToolIcon } from '../../../../app/utils/assistantTools'
import AssistantComark from './AssistantComark'
import AssistantIndicator from './AssistantIndicator.vue'
const { isOpen, isStudioExpanded, messages, faqQuestions } = useAssistant()
const config = useRuntimeConfig()
const toast = useToast()
const { t } = useDocusI18n()
const input = ref('')
const open = computed({
get: () => isOpen.value && !isStudioExpanded.value,
set: (value) => {
if (!isStudioExpanded.value) {
isOpen.value = value
}
},
})
const promptRef = ref<{ textareaRef?: HTMLTextAreaElement } | null>(null)
watch(open, (value) => {
if (!value) return
nextTick(() => promptRef.value?.textareaRef?.focus({ preventScroll: true }))
})
const displayTitle = computed(() => t('assistant.title'))
const displayPlaceholder = computed(() => t('assistant.placeholder'))
let _skipSync = false
const {
messages: chatMessages,
status,
error,
sendMessage,
regenerate,
stop,
} = useChat({
messages: messages.value,
transport: new DefaultChatTransport({
api: (config.app?.baseURL.replace(/\/$/, '') || '') + config.public.assistant.apiPath,
}),
onError: (error: Error) => {
let message = error.message
if (typeof message === 'string' && message[0] === '{') {
try {
message = JSON.parse(message).message || message
}
catch {
// keep original on malformed JSON
}
}
toast.add({
description: message,
icon: 'i-lucide-alert-circle',
color: 'error',
duration: 0,
})
},
onFinish: () => {
_skipSync = true
messages.value = [...chatMessages.value]
nextTick(() => {
_skipSync = false
})
},
})
watch(messages, (newMessages) => {
if (_skipSync) return
chatMessages.value = newMessages
const lastMessage = chatMessages.value[chatMessages.value.length - 1]
if (lastMessage?.role === 'user' && status.value !== 'streaming') {
regenerate()
}
})
const canClear = computed(() => messages.value.length > 0)
type ToolPart = ToolUIPart | DynamicToolUIPart
function getToolOutput(part: ToolPart): string | undefined {
if (part.state !== 'output-available' || !part.output) return undefined
const output = part.output as Record<string, unknown>
const content = (output.content ?? output) as Array<{ text?: string }> | string
if (typeof content === 'string') {
return content || undefined
}
if (Array.isArray(content)) {
return content.map(c => c.text).filter(Boolean).join('\n') || undefined
}
return JSON.stringify(output, null, 2)
}
function onSubmit() {
if (!input.value.trim()) return
sendMessage({ text: input.value })
input.value = ''
}
function askQuestion(question: string) {
input.value = question
onSubmit()
}
function clearMessages() {
if (status.value === 'streaming') {
stop()
}
messages.value = []
chatMessages.value = []
}
defineShortcuts({
meta_i: {
handler: () => {
open.value = !open.value
},
usingInput: true,
},
})
</script>
<template>
<USidebar
v-model:open="open"
side="right"
:title="displayTitle"
rail
:style="{ '--sidebar-width': '24rem' }"
:ui="{ footer: 'p-0', actions: 'gap-0.5', container: '!left-auto' }"
>
<template #actions>
<UTooltip
v-if="canClear"
:text="t('assistant.clearChat')"
>
<UButton
icon="i-lucide-list-x"
color="neutral"
variant="ghost"
@click="clearMessages"
/>
</UTooltip>
</template>
<template #close>
<UTooltip
:text="t('assistant.close')"
:kbds="['meta', 'i']"
>
<UButton
icon="i-lucide-panel-right-close"
color="neutral"
variant="ghost"
aria-label="Close"
@click="() => { open = false }"
/>
</UTooltip>
</template>
<UTheme
:ui="{
prose: {
p: { base: 'my-2 text-sm/6' },
li: { base: 'my-0.5 text-sm/6' },
ul: { base: 'my-2' },
ol: { base: 'my-2' },
h1: { base: 'text-xl mb-4' },
h2: { base: 'text-lg mt-6 mb-3' },
h3: { base: 'text-base mt-4 mb-2' },
h4: { base: 'text-sm mt-3 mb-1.5' },
code: { base: 'text-xs' },
pre: { root: 'my-2', base: 'text-xs/5' },
table: { root: 'my-2' },
hr: { base: 'my-4' },
},
}"
>
<UChatMessages
v-if="chatMessages.length"
should-auto-scroll
:messages="chatMessages"
:status="status"
compact
class="px-0 gap-2"
:user="{ ui: { container: 'max-w-full pb-3' } }"
>
<template #indicator>
<AssistantIndicator />
</template>
<template #content="{ message }">
<template
v-for="(part, index) in message.parts"
:key="`${message.id}-${part.type}-${index}`"
>
<UChatReasoning
v-if="isReasoningUIPart(part)"
:text="part.text"
:streaming="isPartStreaming(part)"
icon="i-lucide-brain"
chevron="leading"
>
<AssistantComark
:markdown="part.text"
:streaming="isPartStreaming(part)"
/>
</UChatReasoning>
<template v-else-if="isTextUIPart(part) && part.text.length > 0">
<AssistantComark
v-if="message.role === 'assistant'"
:markdown="part.text"
:streaming="isPartStreaming(part)"
/>
<p
v-else-if="message.role === 'user'"
class="whitespace-pre-wrap text-sm/6"
>
{{ part.text }}
</p>
</template>
<UChatTool
v-else-if="isToolUIPart(part)"
:text="getToolText(part, t)"
:suffix="getToolSuffix(part)"
:icon="getToolIcon(part)"
:streaming="isToolStreaming(part)"
chevron="leading"
>
<pre
v-if="getToolOutput(part)"
class="text-xs text-muted whitespace-pre-wrap break-all rounded-md border border-muted bg-muted p-2 max-h-64 overflow-y-auto"
v-text="getToolOutput(part)"
/>
</UChatTool>
</template>
</template>
</UChatMessages>
<div v-else>
<div
v-if="!faqQuestions?.length"
class="flex h-full flex-col items-center justify-center py-12 text-center"
>
<div class="mb-4 flex size-12 items-center justify-center rounded-full bg-primary/10">
<UIcon
name="i-lucide-message-circle-question"
class="size-6 text-primary"
/>
</div>
<h3 class="mb-2 text-base font-medium text-highlighted">
{{ t('assistant.askMeAnything') }}
</h3>
<p class="max-w-xs text-sm text-muted">
{{ t('assistant.askMeAnythingDescription') }}
</p>
</div>
<template v-else>
<div class="flex flex-col gap-6">
<UPageLinks
v-for="category in faqQuestions"
:key="category.category"
:title="category.category"
:links="category.items.map(item => ({ label: item, onClick: () => askQuestion(item) }))"
/>
</div>
</template>
</div>
</UTheme>
<template #footer>
<UChatPrompt
ref="promptRef"
v-model="input"
:error="error"
:placeholder="displayPlaceholder"
variant="naked"
size="sm"
autofocus
:ui="{ base: 'px-0' }"
class="px-4"
@submit="onSubmit"
>
<template #footer>
<div class="flex items-center gap-1.5 text-xs text-dimmed">
<span>{{ t('assistant.lineBreak') }}</span>
<UKbd
size="sm"
value="shift"
/>
<UKbd
size="sm"
value="enter"
/>
</div>
<UChatPromptSubmit
size="sm"
:status="status"
:disabled="status === 'ready' && !input.trim()"
@stop="stop()"
@reload="regenerate()"
/>
</template>
</UChatPrompt>
</template>
</USidebar>
</template>