Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,6 @@ provide('sha', sha)
const historyOpen = useVersionHistory()

const { assistant } = useAppConfig()
const assistantOpen = useAssistant()

// Mounting pulls the AI SDK + shiki chunks, so the panel only mounts after the first open.
const assistantMounted = ref(false)
watch(assistantOpen, (isOpen) => {
if (isOpen) assistantMounted.value = true
})

defineShortcuts({
// Disabled `d` for now as it prevents the playground editor to work with the `d` letter
Expand All @@ -87,26 +80,33 @@ defineShortcuts({
color="var(--ui-text-highlighted)"
/>

<AppHeader />

<UMain>
<Suspense>
<LayoutsPage v-if="navigationLayout === 'page'">
<NuxtPage />
</LayoutsPage>
<LayoutsDocs v-else-if="navigationLayout === 'docs'">
<NuxtPage />
</LayoutsDocs>
<NuxtPage v-else />
</Suspense>
</UMain>

<AppFooter />
<div class="flex">
<div class="flex-1 min-w-0">
<AppHeader />

<UMain>
<Suspense>
<LayoutsPage v-if="navigationLayout === 'page'">
<NuxtPage />
</LayoutsPage>
<LayoutsDocs v-else-if="navigationLayout === 'docs'">
<NuxtPage />
</LayoutsDocs>
<NuxtPage v-else />
</Suspense>
</UMain>

<AppFooter />
</div>

<ClientOnly>
<LazyAssistantChat v-if="assistant?.enabled" />
Comment thread
larbish marked this conversation as resolved.
</ClientOnly>
</div>

<ClientOnly>
<AppSearch :navigation="navTree" />
<LazyVersionHistory />
<LazyAssistantChat v-if="assistant?.enabled && assistantMounted" />
</ClientOnly>
</UApp>
</template>
2 changes: 1 addition & 1 deletion app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const navigation = useMainNavigation()
label="Ask AI"
color="neutral"
variant="outline"
@click="assistantOpen = true"
@click="assistantOpen = !assistantOpen"
/>

<UButton
Expand Down
292 changes: 155 additions & 137 deletions app/components/AssistantChat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ const plugins = [rangi({ theme: geistTheme })]
// Suggestions grouped by category, shown before the first message.
const questions = computed(() => assistant?.faqQuestions ?? [])

// Compact prose sizes for the narrow panel
const proseUi = {
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' },
},
}

// The panel mounts on every page (for the push-header layout), so autofocus would steal
// focus on load. Focus the prompt only once the assistant actually opens.
const promptRef = ref<{ textareaRef?: HTMLTextAreaElement } | null>(null)
watch(open, (isOpen) => {
if (!isOpen) return
nextTick(() => promptRef.value?.textareaRef?.focus({ preventScroll: true }))
Comment thread
larbish marked this conversation as resolved.
})

watch(input, (value) => {
if (value.length > MAX_INPUT) input.value = value.slice(0, MAX_INPUT)
})
Expand Down Expand Up @@ -99,160 +125,152 @@ function sourcesLabel(message: UIMessage) {
</script>

<template>
<USlideover
<USidebar
Comment thread
larbish marked this conversation as resolved.
v-model:open="open"
:ui="{ content: 'sm:max-w-md', body: 'flex flex-col' }"
side="right"
title="Chat"
close
close-icon="i-lucide-panel-right-close"
:style="{ '--sidebar-width': '24rem' }"
:ui="{ actions: 'gap-0.5' }"
>
<template #header>
<div class="flex items-center justify-between w-full">
<h2 class="font-bold text-highlighted">Chat</h2>
<div class="flex items-center gap-1">
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
color="neutral"
variant="ghost"
:disabled="!messages.length"
:ui="{ leadingIcon: 'size-4' }"
aria-label="Copy conversation"
@click="copyConversation"
/>
<UButton
icon="i-lucide-trash-2"
color="neutral"
variant="ghost"
:disabled="!messages.length"
:ui="{ leadingIcon: 'size-4' }"
aria-label="Clear conversation"
@click="clearChat"
/>
<UButton
icon="i-lucide-chevron-right"
color="neutral"
variant="ghost"
:ui="{ leadingIcon: 'size-4' }"
aria-label="Close chat"
@click="open = false"
/>
</div>
</div>
<template #actions>
<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
color="neutral"
variant="ghost"
:disabled="!messages.length"
:ui="{ leadingIcon: 'size-4' }"
aria-label="Copy conversation"
@click="copyConversation"
/>
<UButton
icon="i-lucide-trash-2"
color="neutral"
variant="ghost"
:disabled="!messages.length"
:ui="{ leadingIcon: 'size-4' }"
aria-label="Clear conversation"
@click="clearChat"
/>
</template>

<template #body>
<UChatPalette>
<UChatMessages
v-if="messages.length"
:messages="messages"
:status="status"
:user="{ side: 'right', variant: 'soft' }"
:assistant="{ side: 'left', variant: 'naked' }"
>
<template #indicator>
<AssistantIndicator />
</template>
<UTheme :ui="proseUi">
<UChatMessages
v-if="messages.length"
should-auto-scroll
:messages="messages"
:status="status"
compact
class="px-0"
:user="{ side: 'right', variant: 'soft' }"
:assistant="{ side: 'left', variant: 'naked' }"
>
<template #indicator>
<AssistantIndicator />
</template>

<template #content="{ message }">
<UChatTool
v-if="message.role === 'assistant' && !isMessageStreaming(message) && messageToolCount(message)"
icon="i-lucide-bookmark"
:text="sourcesLabel(message)"
<template #content="{ message }">
<UChatTool
v-if="message.role === 'assistant' && !isMessageStreaming(message) && messageToolCount(message)"
icon="i-lucide-bookmark"
:text="sourcesLabel(message)"
>
<div
v-if="messageSources(message).length"
class="flex flex-col items-start gap-1 pt-1"
>
<div
v-if="messageSources(message).length"
class="flex flex-col items-start gap-1 pt-1"
<ULink
v-for="path in messageSources(message)"
:key="path"
:to="path"
class="text-sm text-muted hover:text-highlighted"
>
<ULink
v-for="path in messageSources(message)"
:key="path"
:to="path"
class="text-sm text-muted hover:text-highlighted"
>
{{ path }}
</ULink>
</div>
</UChatTool>
{{ path }}
</ULink>
</div>
</UChatTool>

<template
v-for="(part, index) in message.parts"
:key="`${message.id}-${part.type}-${index}`"
<template
v-for="(part, index) in message.parts"
:key="`${message.id}-${part.type}-${index}`"
>
<UChatReasoning
v-if="isReasoningUIPart(part)"
icon="i-lucide-brain"
:text="part.text"
:streaming="isPartStreaming(part)"
>
<UChatReasoning
v-if="isReasoningUIPart(part)"
icon="i-lucide-brain"
:text="part.text"
<Markdown
:value="part.text"
:streaming="isPartStreaming(part)"
>
<Markdown
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="plugins"
class="text-sm text-muted *:first:mt-0 *:last:mb-0"
/>
</UChatReasoning>

<UChatTool
v-else-if="isToolUIPart(part) && isMessageStreaming(message)"
v-bind="toolMeta(part)"
:streaming="isToolStreaming(part)"
:plugins="plugins"
class="text-sm text-muted *:first:mt-0 *:last:mb-0"
/>
</UChatReasoning>

<template v-else-if="isTextUIPart(part)">
<Markdown
v-if="message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="plugins"
class="*:first:mt-0 *:last:mb-0"
/>
<p
v-else
class="whitespace-pre-wrap"
>
{{ part.text }}
</p>
</template>
<UChatTool
v-else-if="isToolUIPart(part) && isMessageStreaming(message)"
v-bind="toolMeta(part)"
:streaming="isToolStreaming(part)"
/>

<template v-else-if="isTextUIPart(part)">
<Markdown
v-if="message.role === 'assistant'"
:value="part.text"
:streaming="isPartStreaming(part)"
:plugins="plugins"
class="*:first:mt-0 *:last:mb-0"
/>
<p
v-else
class="whitespace-pre-wrap text-sm/6"
>
{{ part.text }}
</p>
</template>
</template>
</UChatMessages>
</template>
</UChatMessages>

<div
v-else
class="flex flex-col gap-6"
>
<UPageLinks
v-for="category in questions"
:key="category.category"
:title="category.category"
:links="category.items.map((item: string) => ({ label: item, onClick: () => ask(item) }))"
/>
</div>
</UTheme>

<div
v-else
class="flex-1 flex flex-col justify-end gap-6 py-4 overflow-y-auto"
>
<div class="flex flex-col gap-6">
<UPageLinks
v-for="category in questions"
:key="category.category"
:title="category.category"
:links="category.items.map((item: string) => ({ label: item, onClick: () => ask(item) }))"
<template #footer>
<UChatPrompt
ref="promptRef"
v-model="input"
:error="error"
:rows="2"
:autofocus="false"
:ui="{ root: 'rounded-lg! px-2.5' }"
placeholder="What would you like to know?"
@submit="onSubmit"
>
<template #footer>
<div class="flex items-center justify-between w-full px-2.5">
<span class="text-xs text-dimmed tabular-nums">{{ input.length }} / {{ MAX_INPUT }}</span>
<UChatPromptSubmit
:status="status"
icon="i-lucide-corner-down-left"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</div>
</div>

<template #prompt>
<UChatPrompt
v-model="input"
:error="error"
:rows="2"
:ui="{ root: 'rounded-lg! px-2.5' }"
placeholder="What would you like to know?"
autofocus
@submit="onSubmit"
>
<template #footer>
<div class="flex items-center justify-between w-full px-2.5">
<span class="text-xs text-dimmed tabular-nums">{{ input.length }} / {{ MAX_INPUT }}</span>
<UChatPromptSubmit
:status="status"
icon="i-lucide-corner-down-left"
color="neutral"
@stop="stop()"
@reload="regenerate()"
/>
</div>
</template>
</UChatPrompt>
</template>
</UChatPalette>
</UChatPrompt>
</template>
</USlideover>
</USidebar>
</template>
Loading
Loading