Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b80ceab
feat: add ai chat
HugoRCD Apr 9, 2026
2c0360b
add tools to chat
HugoRCD Apr 9, 2026
7510f6d
add /chat
HugoRCD Apr 10, 2026
b650c79
improve chat
HugoRCD Apr 10, 2026
57b8661
improve agent
HugoRCD Apr 13, 2026
9db503a
add chat og image
HugoRCD Apr 13, 2026
a3dd958
add rate-limit
HugoRCD Apr 13, 2026
0c84ed4
fix auto-context
HugoRCD Apr 13, 2026
5e36122
fix prompt input resize
HugoRCD Apr 13, 2026
5a79bc1
up
HugoRCD Apr 14, 2026
5bd526b
up
HugoRCD Apr 14, 2026
ae9b73f
add evlog o11y
HugoRCD Apr 15, 2026
bf858a0
add vote system, and messages retention
HugoRCD Apr 15, 2026
ff92093
up
HugoRCD Apr 15, 2026
05f1c99
improve agent efficiency
HugoRCD Apr 16, 2026
8228510
add github issues search
HugoRCD Apr 16, 2026
1254ec6
fix typecheck
HugoRCD Apr 16, 2026
645431e
fix from code review
HugoRCD Apr 16, 2026
9c472d5
improvements
HugoRCD Apr 16, 2026
4820309
add feedback to Linear
HugoRCD Apr 16, 2026
8c350b2
add first draft for the blog post
HugoRCD Apr 16, 2026
d608654
Merge remote-tracking branch 'origin/main' into feat/ai-chat
HugoRCD Apr 20, 2026
1995aa4
up
HugoRCD Apr 20, 2026
17c5bd8
Merge remote-tracking branch 'origin/main' into feat/ai-chat
HugoRCD Apr 20, 2026
43a7a1d
fix agent preview
HugoRCD Apr 20, 2026
4d925b7
improve blog post
HugoRCD Apr 20, 2026
5bb6db2
remove kapa
HugoRCD Apr 20, 2026
0ba1966
fix lint
HugoRCD Apr 20, 2026
0789825
fix lockfile
HugoRCD Apr 20, 2026
7576b7a
use nightly of nuxt ui
HugoRCD Apr 21, 2026
97491db
add videos
HugoRCD Apr 21, 2026
755a47a
improve agent and responsiveness
HugoRCD Apr 21, 2026
30342e4
context page
HugoRCD Apr 22, 2026
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
2 changes: 1 addition & 1 deletion .nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.0"
setups.@nuxt/test-utils="4.0.2"
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
22 changes: 18 additions & 4 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
const colorMode = useColorMode()
const route = useRoute()
const isChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'))
const { version } = useDocsVersion()
const { searchGroups, searchLinks, searchTerm } = useNavigation()
const { fetchList: fetchModules } = useModules()
Expand Down Expand Up @@ -72,12 +74,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="!isChatRoute" />
</ClientOnly>
</div>

<ClientOnly>
<LazyAgentPanel v-if="!isChatRoute" />
</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 @@ -31,3 +31,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'
})
105 changes: 105 additions & 0 deletions app/components/agent/AgentFloatingInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<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)
let submitTimer: ReturnType<typeof setTimeout> | null = null

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

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

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

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

onScopeDispose(() => {
if (submitTimer) clearTimeout(submitTimer)
})

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
Loading