Skip to content
Draft
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
28 changes: 28 additions & 0 deletions app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
export default defineAppConfig({
agent: {
faqQuestions: [
{
category: 'Getting Started',
items: [
'Show me available starter templates',
'What\'s new in Nuxt 4?',
'How do I add authentication to my Nuxt app?'
]
},
{
category: 'Features',
items: [
'useFetch vs useAsyncData?',
'How does file-based routing work?',
'How do I connect a database to my Nuxt app?'
]
},
{
category: 'Deploy & Explore',
items: [
'How do I deploy my Nuxt app?',
'What are the available rendering modes?',
'How do I add SEO meta tags in Nuxt?'
]
}
]
},
ui: {
colors: {
primary: 'green',
Expand Down
21 changes: 17 additions & 4 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
const colorMode = useColorMode()
const route = useRoute()
const { version } = useDocsVersion()
const { searchGroups, searchLinks, searchTerm } = useNavigation()
const { fetchList: fetchModules } = useModules()
Expand Down Expand Up @@ -72,12 +73,24 @@ onMounted(() => {
</script>

<template>
<UApp>
<UApp :tooltip="{ delayDuration: 500 }">
<NuxtLoadingIndicator color="var(--ui-primary)" />

<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<div class="flex">
<div class="flex-1 min-w-0">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>

<ClientOnly>
<LazyAgentFloatingInput v-if="route.path !== '/chat'" />
</ClientOnly>
</div>

<ClientOnly>
<LazyAgentPanel v-if="route.path !== '/chat'" />
</ClientOnly>
</div>

<ClientOnly>
<LazyUContentSearch
Expand Down
8 changes: 8 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@
--ui-bg-elevated: var(--ui-color-neutral-900);
--ui-bg-accented: var(--ui-color-neutral-800);
}

html.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
2 changes: 1 addition & 1 deletion app/assets/icons/ai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions app/components/agent/AgentChatButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
const { toggle, isOpen } = useNuxtAgent()
const { track } = useAnalytics()

function handleToggle() {
track('Nuxt Agent Toggled', { source: 'header', open: !isOpen.value })
toggle()
}
</script>

<template>
<UTooltip text="Agent">
<UButton
icon="i-custom-ai"
color="neutral"
variant="ghost"
@click="handleToggle"
/>
</UTooltip>
</template>
13 changes: 13 additions & 0 deletions app/components/agent/AgentComark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import highlight from '@comark/nuxt/plugins/highlight'
import SourceLink from '../tools/SourceLink.vue'

export default defineComarkComponent({
name: 'AgentComark',
plugins: [
highlight()
],
components: {
'source-link': SourceLink
},
class: '*:first:mt-0 *:last:mb-0'
})
94 changes: 94 additions & 0 deletions app/components/agent/AgentFloatingInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { AnimatePresence, motion } from 'motion-v'

const route = useRoute()
const { open, isOpen } = useNuxtAgent()
const { track } = useAnalytics()
const input = ref('')
const isVisible = ref(true)
const inputRef = ref<{ inputRef: HTMLInputElement } | null>(null)

const isDocsRoute = computed(() => route.path.startsWith('/docs') || route.path.startsWith('/blog'))

function handleSubmit() {
if (!input.value.trim()) return

track('Nuxt Agent Message Sent', { query: input.value, source: 'floating-input', page: route.path })
const message = input.value
isVisible.value = false

setTimeout(() => {
open(message, true)
input.value = ''
isVisible.value = true
}, 200)
}

defineShortcuts({
meta_i: {
usingInput: true,
handler: () => {
inputRef.value?.inputRef?.focus()
}
},
escape: {
usingInput: true,
handler: () => {
inputRef.value?.inputRef?.blur()
}
}
})
</script>

<template>
<AnimatePresence>
<motion.div
v-if="isDocsRoute && isVisible && !isOpen"
key="floating-input"
:initial="{ y: 20, opacity: 0 }"
:animate="{ y: 0, opacity: 1 }"
:exit="{ y: 100, opacity: 0 }"
:transition="{ duration: 0.2, ease: 'easeOut' }"
class="pointer-events-none fixed inset-x-0 z-10 bottom-[max(1.5rem,env(safe-area-inset-bottom))] px-4 sm:px-80"
style="will-change: transform"
>
<form
class="pointer-events-none flex w-full justify-center"
@submit.prevent="handleSubmit"
>
<div class="pointer-events-auto w-full max-w-96">
<UInput
ref="inputRef"
v-model="input"
placeholder="Ask anything…"
size="lg"
maxlength="1000"
:ui="{
root: 'group w-full! min-w-0 sm:max-w-96 transition-all duration-300 ease-out [@media(hover:hover)]:hover:scale-105 [@media(hover:hover)]:focus-within:scale-105',
base: 'bg-default shadow-lg rounded-xl text-base',
trailing: 'pe-2'
}"
@keydown.enter.exact.prevent="handleSubmit"
>
<template #trailing>
<div class="flex items-center gap-2">
<div class="hidden sm:flex group-focus-within:hidden items-center gap-1">
<UKbd value="meta" />
<UKbd value="I" />
</div>

<UButton
type="submit"
icon="i-lucide-arrow-up"
color="primary"
size="xs"
:disabled="!input.trim()"
/>
</div>
</template>
</UInput>
</div>
</form>
</motion.div>
</AnimatePresence>
</template>
113 changes: 113 additions & 0 deletions app/components/agent/AgentIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script setup lang="ts">
const size = 4
const dotSize = 2
const gap = 2
const totalDots = size * size

const patterns = [
[[0], [1], [2], [3], [7], [11], [15], [14], [13], [12], [8], [4], [5], [6], [10], [9]],
[[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]],
[[5, 6, 9, 10], [1, 4, 7, 8, 11, 14], [0, 3, 12, 15], [1, 4, 7, 8, 11, 14], [5, 6, 9, 10]],
[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]],
[[0], [3], [15], [12]],
[[5, 6, 9, 10], [1, 2, 4, 7, 8, 11, 13, 14], [0, 3, 12, 15]],
[[0], [1], [2], [3], [7], [6], [5], [4], [8], [9], [10], [11], [15], [14], [13], [12]],
[[0], [1, 4], [2, 5, 8], [3, 6, 9, 12], [7, 10, 13], [11, 14], [15]]
]

const activeDots = ref<Set<number>>(new Set())
let patternIndex = 0
let stepIndex = 0

function nextStep() {
const pattern = patterns[patternIndex]
if (!pattern) return

activeDots.value = new Set(pattern[stepIndex])
stepIndex++

if (stepIndex >= pattern.length) {
stepIndex = 0
patternIndex = (patternIndex + 1) % patterns.length
}
}

const statusMessages = ['Thinking...', 'Searching...', 'Reading...', 'Analyzing...']
const currentIndex = ref(0)
const displayedText = ref(statusMessages[0]!)
const chars = 'abcdefghijklmnopqrstuvwxyz'

function scramble(from: string, to: string) {
const maxLength = Math.max(from.length, to.length)
let frame = 0
const totalFrames = 15

const step = () => {
frame++
let result = ''
const progress = (frame / totalFrames) * maxLength

for (let i = 0; i < maxLength; i++) {
if (i < progress - 2) {
result += to[i] || ''
} else if (i < progress) {
result += chars[Math.floor(Math.random() * chars.length)]
} else {
result += from[i] || ''
}
}

displayedText.value = result

if (frame < totalFrames) {
requestAnimationFrame(step)
} else {
displayedText.value = to
}
}

requestAnimationFrame(step)
}

let matrixInterval: ReturnType<typeof setInterval> | undefined
let textInterval: ReturnType<typeof setInterval> | undefined

onMounted(() => {
nextStep()
matrixInterval = setInterval(nextStep, 120)
textInterval = setInterval(() => {
const prev = displayedText.value
currentIndex.value = (currentIndex.value + 1) % statusMessages.length
scramble(prev, statusMessages[currentIndex.value]!)
}, 3500)
})

onUnmounted(() => {
clearInterval(matrixInterval)
clearInterval(textInterval)
})
</script>

<template>
<div class="flex items-center text-xs text-muted overflow-hidden">
<div
class="shrink-0 mr-2 grid"
:style="{
gridTemplateColumns: `repeat(${size}, 1fr)`,
gap: `${gap}px`,
width: `${size * dotSize + (size - 1) * gap}px`,
height: `${size * dotSize + (size - 1) * gap}px`
}"
>
<span
v-for="i in totalDots"
:key="i"
class="rounded-[0.5px] bg-current transition-opacity duration-100"
:class="activeDots.has(i - 1) ? 'opacity-100' : 'opacity-20'"
:style="{ width: `${dotSize}px`, height: `${dotSize}px` }"
/>
</div>

<UChatShimmer :text="displayedText" class="font-mono tracking-tight" />
</div>
</template>
Loading