+
+
+ Individual Feedback
+
-
-
- Individual Feedback
-
-
-
-
-
+
+
+
-
-
- Showing {{ (currentPage - 1) * itemsPerPage + 1 }}-{{ Math.min(currentPage * itemsPerPage, selectedPage.total) }} of {{ selectedPage.total }} feedbacks
-
-
+
+
+ Showing {{ (currentPage - 1) * itemsPerPage + 1 }}-{{ Math.min(currentPage * itemsPerPage, selectedPage.total) }} of {{ selectedPage.total }} feedbacks
+
-
-
-
-
+
+
+
+
diff --git a/app/components/agent/AgentChatButton.vue b/app/components/agent/AgentChatButton.vue
deleted file mode 100644
index b0a57cb46..000000000
--- a/app/components/agent/AgentChatButton.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/components/agent/AgentPanel.vue b/app/components/agent/AgentPanel.vue
deleted file mode 100644
index 073c755bf..000000000
--- a/app/components/agent/AgentPanel.vue
+++ /dev/null
@@ -1,199 +0,0 @@
-
-
-
-
-
-
- Agent
-
- Beta
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Agent
-
- Beta
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/components/agent/AgentPanelMain.vue b/app/components/agent/AgentPanelMain.vue
deleted file mode 100644
index 4f1806345..000000000
--- a/app/components/agent/AgentPanelMain.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- $emit('vote', msg, isUpvoted)"
- />
-
-
-
-
-
-
diff --git a/app/components/chat/ChatMessageActions.vue b/app/components/chat/ChatMessageActions.vue
deleted file mode 100644
index 3ad316507..000000000
--- a/app/components/chat/ChatMessageActions.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/components/chat/ChatPanel.vue b/app/components/chat/ChatPanel.vue
deleted file mode 100644
index d1742f33a..000000000
--- a/app/components/chat/ChatPanel.vue
+++ /dev/null
@@ -1,215 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- What can I help you with?
-
-
- Ask anything or explore docs, modules, deployment, and more.
-
-
-
-
-
-
-
- Daily limit reached. Try again tomorrow.
-
-
-
-
-
- {{ usage.remaining }}/{{ usage.limit }}
-
-
-
-
-
-
-
-
-
-
-
- {{ suggestion.title }}
-
-
- {{ suggestion.description }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Daily limit reached. Try again tomorrow.
-
-
-
-
-
- {{ usage.remaining }}/{{ usage.limit }}
-
-
-
-
-
-
-
-
-
diff --git a/app/components/content-toc/ContentTocBottom.vue b/app/components/content-toc/ContentTocBottom.vue
index 5374af9e5..a98264d9a 100644
--- a/app/components/content-toc/ContentTocBottom.vue
+++ b/app/components/content-toc/ContentTocBottom.vue
@@ -19,8 +19,8 @@ const { open } = useNuxtAgent()
const { track } = useAnalytics()
function explainWithAI() {
- track('Nuxt Agent Explain Page', { page: route.path })
- open('Explain this page', true)
+ track('Nuxi Explain Page', { page: route.path })
+ open('Explain this page')
}
diff --git a/app/components/content/NuxiMoodGallery.vue b/app/components/content/NuxiMoodGallery.vue
new file mode 100644
index 000000000..e8684f1e0
--- /dev/null
+++ b/app/components/content/NuxiMoodGallery.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
diff --git a/app/components/content/TryNuxi.vue b/app/components/content/TryNuxi.vue
new file mode 100644
index 000000000..75465dc44
--- /dev/null
+++ b/app/components/content/TryNuxi.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ {{ prompt }}
+
+
+ {{ label ?? 'Try in Nuxi' }}
+
+
+
+
diff --git a/app/components/feedback/FeedbackDatePicker.vue b/app/components/feedback/FeedbackDatePicker.vue
index da9d6600a..7db3c0fd2 100644
--- a/app/components/feedback/FeedbackDatePicker.vue
+++ b/app/components/feedback/FeedbackDatePicker.vue
@@ -77,52 +77,50 @@ const isRangeSelected = (preset: 'week' | 'month' | '3months' | '6months' | 'yea
-
-
-
-
-
- {{ formattedDateRange }}
-
-
- Pick a date range
-
-
-
-
-
+
+
+
+
+ {{ formattedDateRange }}
+
+
+ Pick a date range
-
+
-
-
+
+
diff --git a/app/components/header/Header.vue b/app/components/header/Header.vue
index b75f0a890..14ad7685d 100644
--- a/app/components/header/Header.vue
+++ b/app/components/header/Header.vue
@@ -3,6 +3,7 @@ const route = useRoute()
const logo = useTemplateRef('logo')
const stats = useStats()
+const { loggedIn } = useUserSession()
const { copy } = useClipboard()
const { headerLinks } = useHeaderLinks()
const { track } = useAnalytics()
@@ -71,24 +72,30 @@ function trackGitHubClick() {
-
+
+
+
+
-
-
-
+
+
+
+
+
+
diff --git a/app/components/header/HeaderUserMenu.vue b/app/components/header/HeaderUserMenu.vue
new file mode 100644
index 000000000..f4ea4c3a1
--- /dev/null
+++ b/app/components/header/HeaderUserMenu.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
diff --git a/app/components/tools/FeedbackCard.vue b/app/components/tools/FeedbackCard.vue
index 535f8a872..67c3c8066 100644
--- a/app/components/tools/FeedbackCard.vue
+++ b/app/components/tools/FeedbackCard.vue
@@ -4,7 +4,7 @@ const props = defineProps<{
summary: string
}>()
-const { chatId } = useNuxtAgent()
+const chatId = inject[>('chat-id')
const feedback = ref('')
const state = ref<'idle' | 'submitting' | 'success' | 'error'>('idle')
@@ -18,7 +18,7 @@ async function submit() {
const result = await $fetch<{ url: string }>('/api/agent/feedback', {
method: 'POST',
body: {
- chatId: chatId.value,
+ chatId: chatId?.value,
title: props.title,
summary: props.summary,
userFeedback: feedback.value.trim() || undefined
diff --git a/app/components/tools/ToolSources.vue b/app/components/tools/ToolSources.vue
index f5ab5de51..742181ee5 100644
--- a/app/components/tools/ToolSources.vue
+++ b/app/components/tools/ToolSources.vue
@@ -1,5 +1,5 @@
+
+]
+
+
diff --git a/app/middleware/admin.ts b/app/middleware/admin.ts
new file mode 100644
index 000000000..27969e9a1
--- /dev/null
+++ b/app/middleware/admin.ts
@@ -0,0 +1,14 @@
+export default defineNuxtRouteMiddleware((to) => {
+ const { loggedIn, user } = useUserSession()
+
+ if (!loggedIn.value) {
+ return navigateTo({
+ path: '/login',
+ query: { redirect: to.fullPath }
+ })
+ }
+
+ if (!user.value || user.value.role !== 'admin') {
+ return navigateTo('/')
+ }
+})
diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts
index 61b8afefd..26497c452 100644
--- a/app/middleware/auth.ts
+++ b/app/middleware/auth.ts
@@ -1,7 +1,10 @@
-export default defineNuxtRouteMiddleware(() => {
+export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
- return navigateTo('/admin/login')
+ return navigateTo({
+ path: '/login',
+ query: { redirect: to.fullPath }
+ })
}
})
diff --git a/app/middleware/guest.ts b/app/middleware/guest.ts
index 3ae76f359..ffef76ae0 100644
--- a/app/middleware/guest.ts
+++ b/app/middleware/guest.ts
@@ -2,6 +2,6 @@ export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useUserSession()
if (loggedIn.value) {
- return navigateTo('/admin')
+ return navigateTo('/')
}
})
diff --git a/app/pages/admin/analytics.vue b/app/pages/admin/analytics.vue
new file mode 100644
index 000000000..e1c27ea92
--- /dev/null
+++ b/app/pages/admin/analytics.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue
index a9ea2f3e6..5e4441171 100644
--- a/app/pages/admin/index.vue
+++ b/app/pages/admin/index.vue
@@ -1,17 +1,14 @@
-
+
diff --git a/app/pages/admin/login.vue b/app/pages/admin/login.vue
deleted file mode 100644
index 5ee516add..000000000
--- a/app/pages/admin/login.vue
+++ /dev/null
@@ -1,150 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Analytics Dashboard
-
-
- Sign in to access Nuxt.com feedback analytics
-
-
-
-
-
-
-
-
-
-
-
-
-
- Welcome back
-
-
- Continue with your GitHub account to access the dashboard
-
-
-
-
-
-
-
-
-
- Restricted access β’ Core team members only
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/pages/chat.vue b/app/pages/chat.vue
deleted file mode 100644
index 6e6c48e1b..000000000
--- a/app/pages/chat.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/pages/chat/[id].vue b/app/pages/chat/[id].vue
new file mode 100644
index 000000000..d616b806a
--- /dev/null
+++ b/app/pages/chat/[id].vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/app/pages/chat/index.vue b/app/pages/chat/index.vue
new file mode 100644
index 000000000..523a8222d
--- /dev/null
+++ b/app/pages/chat/index.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/pages/login.vue b/app/pages/login.vue
new file mode 100644
index 000000000..031cd2f7f
--- /dev/null
+++ b/app/pages/login.vue
@@ -0,0 +1,288 @@
+
+
+
+
+
diff --git a/content/blog/45.meet-nuxi.md b/content/blog/45.meet-nuxi.md
new file mode 100644
index 000000000..fc5d2a31e
--- /dev/null
+++ b/content/blog/45.meet-nuxi.md
@@ -0,0 +1,147 @@
+---
+title: Meet Nuxi
+description: The Nuxt Agent grew up. It now has a name, a face, a personality, and it remembers you. Meet Nuxi.
+image: /assets/blog/meet-nuxi.png
+authors:
+ - name: Hugo Richard
+ avatar:
+ src: https://github.com/hugorcd.png
+ to: https://x.com/hugorcd
+ - name: SΓ©bastien Chopin
+ avatar:
+ src: https://github.com/Atinux.png
+ to: https://x.com/Atinux
+date: 2026-05-14T10:00:00.000Z
+category: Article
+---
+
+::video{poster="https://res.cloudinary.com/nuxt/video/upload/so_0/v1779442968/nuxt/introducing-nuxi_vvvoyh.jpg" controls class="rounded-lg"}
+ :source{src="https://res.cloudinary.com/nuxt/video/upload/v1779442968/nuxt/introducing-nuxi_vvvoyh.mp4" type="video/mp4"}
+::
+
+Nuxi lives on nuxt.com. Open the side panel with :kbd{value="meta"} :kbd{value="I"}, jump into the full-screen experience at [/dashboard/chat](/dashboard/chat), or look for the small icon that appears in the corner of any docs page when you pause. That's Nuxi.
+
+:agent-nuxi-icon{class="block mx-auto my-6 size-20 text-primary"}
+
+The name was an easy call. Nuxi is already how you invoke the CLI (`nuxi dev`, `nuxi build`, `nuxi init`), so it already lives in every Nuxt project. Giving the agent the same name felt right: not a new brand, an extension of something familiar. The face came naturally too. Once you look at the Nuxt logo closely, it's already there.
+
+::callout{icon="i-lucide-info"}
+A note for longtime Nuxters: the CLI isn't going anywhere. It stays exactly what it has always been. The agent borrows the name, not the job.
+::
+
+## Why personality matters
+
+We thought about what kind of AI assistant we'd actually want to use. Most of them answer well enough. But there's a difference between a tool you tolerate and one you come back to, and that difference mostly comes down to who it feels like you're talking to.
+
+Nuxt has always had a specific feel: a DX you don't fight, docs you can actually read, a community that ships in public. An assistant living inside that should carry the same attitude. Helpful without being verbose. Honest about what it doesn't know. With enough character that you don't feel like you're filing a support ticket.
+
+Nuxi's personality isn't a separate design decision. It follows from the framework's.
+
+::nuxi-mood-gallery
+::
+
+## What Nuxi is like
+
+**Playful.** Nuxi isn't just a text interface. It reacts to where you are, responds to what you do, and has a few things built in that we'd rather leave for you to find. Start with typing `hi`.
+
+::try-nuxi{prompt="hi" label="Say hi"}
+::
+
+::tip{icon="i-lucide-mouse-pointer-2"}
+Hover the Nuxi icon in the header and move your mouse around.
+::
+
+**Context-aware.** Nuxi reads the page you're on. Ask "how do I use this in my app?" while reading a doc and it already knows what *this* refers to. Open it on a module page and it has the module. You don't have to re-paste links every time you ask a follow-up.
+
+::try-nuxi{prompt="How do I use this in my app?"}
+::
+
+**Grounded.** Nuxi sticks to the official Nuxt docs, the modules catalog, the templates, the deployment providers, the changelog, and our GitHub issues (the same data the [Nuxt MCP server](/mcp) exposes to Cursor, Claude Desktop, and ChatGPT). When it doesn't know something, it says so. When you paste an error, it searches issues across `nuxt`, `nuxt-modules` and `nuxt-content` before reaching for opinions.
+
+::try-nuxi{prompt="How do I fetch data on the server in Nuxt 4?"}
+::
+
+**Built to grow.** Nuxi isn't a one-shot Q&A widget. It renders modules, templates, hosting providers and blog posts as cards you can click. It can spin up a [StackBlitz](https://stackblitz.com) playground straight from the conversation. It opens a feedback form when something feels off. And from today, it remembers you.
+
+::try-nuxi{prompt="Show me official starter templates"}
+::
+
+## What Nuxi can now do for you
+
+This is the biggest batch of changes since the Beta in April. nuxt.com is starting to feel less like a static site and more like somewhere you actually log into.
+
+### Nuxi remembers you
+
+You can now **sign in to nuxt.com** with GitHub. Once you do, Nuxi knows it's you across devices, your conversations come with you, and rate limits count per-account rather than per-IP.
+
+::callout{icon="i-lucide-log-in" to="/login"}
+Sign in with GitHub at [/login](/login).
+::
+
+### Pick up where you left off
+
+Every conversation is **saved automatically**. Your past chats live in [/dashboard/chat](/dashboard/chat), titled and dated, ready to pick up exactly where you left them. Open a chat from your phone, finish it on your laptop. The "I had a great conversation with Nuxi yesterday and I just lost it" problem is over.
+
+### Branch your thinking
+
+Sometimes one question opens three. From any of Nuxi's answers, hit **Branch in new chat** and the conversation forks: everything up to that point is copied into a fresh thread, the original stays untouched. Explore a tangent, try a different approach, compare two directions side by side.
+
+::video{poster="https://res.cloudinary.com/nuxt/video/upload/so_0/v1779446023/nuxt/nuxi-branch_jidlol.jpg" controls class="rounded-lg"}
+ :source{src="https://res.cloudinary.com/nuxt/video/upload/v1779446023/nuxt/nuxi-branch_jidlol.mp4" type="video/mp4"}
+::
+
+### Share a great answer
+
+Conversations are **private by default**. When one of them is worth sharing (a clean explanation of `useAsyncData`, a step-by-step debugging session, a starter template walkthrough), flip the visibility to public and send the link. Anyone can read it; nobody else can post in it. Switch it back to private whenever you want.
+
+::video{poster="https://res.cloudinary.com/nuxt/video/upload/so_0/v1779446028/nuxt/nuxi-sharing_jwgsxg.jpg" controls class="rounded-lg"}
+ :source{src="https://res.cloudinary.com/nuxt/video/upload/v1779446028/nuxt/nuxi-sharing_jwgsxg.mp4" type="video/mp4"}
+::
+
+::note
+Everything from the first release is still here: docs grounding, module and template cards, deployment provider cards, blog cards, GitHub issue search, playground links, page context, the **Report issue** flow that opens a Linear ticket on our side. The [previous post](/blog/introducing-nuxt-agent) covers the full list.
+::
+
+## Where this is going
+
+Nuxt is fun to write. We want nuxt.com to be fun to be on.
+
+What shipped today is the foundation. Over the next months, nuxt.com is going to feel less like a site and more like an application β a real place worth coming back to, that helps you build, helps you learn, helps you connect with the rest of the community. There's a lot more coming on top of these foundations. More on that as it lands.
+
+## FAQ
+
+::accordion{type="single"}
+ ::accordion-item{label="Is my chat history private?"}
+ Yes. Every conversation is private by default and only visible to you. You can flip an individual chat to public from the visibility menu in the chat header, and switch it back whenever you want.
+ ::
+ ::accordion-item{label="Do I need to sign in to use Nuxi?"}
+ No. Nuxi works without an account β you'll just lose the cross-device history and your rate limit is counted per IP instead of per account.
+ ::
+ ::accordion-item{label="How many messages can I send per day?"}
+ Currently 20 per day, counted per account when you're signed in and per IP otherwise. We're tuning these limits as usage settles.
+ ::
+ ::accordion-item{label="What model does Nuxi run on?"}
+ Anthropic's Claude Sonnet 4.6 through the [Vercel AI Gateway](https://vercel.com/ai-gateway), with the [Nuxt MCP server](/mcp) as its grounding for docs, modules, templates, deployment guides and GitHub issues.
+ ::
+ ::accordion-item{label="Can I delete a conversation?"}
+ Yes. Open [/dashboard/chat](/dashboard/chat), hover any chat in the sidebar, and use the menu to delete it. It's removed from our database.
+ ::
+ ::accordion-item{label="What does Nuxi know about me when I'm signed in?"}
+ Your GitHub username and avatar, plus your conversations on nuxt.com. Nothing about your repos, your code, or your other Nuxt projects.
+ ::
+::
+
+## Try Nuxi
+
+Nuxi is live now on nuxt.com.
+
+::callout{icon="i-lucide-message-square"}
+Sign in at [/login](/login), open Nuxi anywhere with :kbd{value="meta"} :kbd{value="I"}, or go straight to [/dashboard/chat](/dashboard/chat).
+::
+
+::try-nuxi{prompt="What can you do?" label="Start a chat"}
+::
+
+If something feels off, hit **Report issue** inside the chat. It creates a ticket with the full conversation attached, and we read every one.
+
+Go say hi.
diff --git a/layers/nuxi/app/components/agent/AgentChatButton.vue b/layers/nuxi/app/components/agent/AgentChatButton.vue
new file mode 100644
index 000000000..6ebf30963
--- /dev/null
+++ b/layers/nuxi/app/components/agent/AgentChatButton.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/agent/AgentChatSwitcher.vue b/layers/nuxi/app/components/agent/AgentChatSwitcher.vue
new file mode 100644
index 000000000..d136dc0c9
--- /dev/null
+++ b/layers/nuxi/app/components/agent/AgentChatSwitcher.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+ Nuxi
+
+ Beta
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ chat.title || 'Untitled' }}
+
+
+
+
+
+
+
+
+
+ +{{ matchingChats.length - PAGE_SIZE }} more β use search to filter
+
+
+
+
+ No chats found
+
+
+
+
diff --git a/app/components/agent/AgentComark.ts b/layers/nuxi/app/components/agent/AgentComark.ts
similarity index 100%
rename from app/components/agent/AgentComark.ts
rename to layers/nuxi/app/components/agent/AgentComark.ts
diff --git a/app/components/agent/AgentFloatingInput.vue b/layers/nuxi/app/components/agent/AgentFloatingInput.vue
similarity index 97%
rename from app/components/agent/AgentFloatingInput.vue
rename to layers/nuxi/app/components/agent/AgentFloatingInput.vue
index 8bd529d0c..ae51cf949 100644
--- a/app/components/agent/AgentFloatingInput.vue
+++ b/layers/nuxi/app/components/agent/AgentFloatingInput.vue
@@ -15,7 +15,7 @@ function handleSubmit() {
if (!input.value.trim()) return
const message = input.value
- track('Nuxt Agent Message Sent', {
+ track('Nuxi Message Sent', {
source: 'floating-input',
page: route.path,
queryLength: message.length
@@ -25,7 +25,7 @@ function handleSubmit() {
if (submitTimer) clearTimeout(submitTimer)
submitTimer = setTimeout(() => {
submitTimer = null
- open(message, true)
+ open(message)
input.value = ''
isVisible.value = true
}, 200)
diff --git a/app/components/agent/AgentIndicator.vue b/layers/nuxi/app/components/agent/AgentIndicator.vue
similarity index 100%
rename from app/components/agent/AgentIndicator.vue
rename to layers/nuxi/app/components/agent/AgentIndicator.vue
diff --git a/layers/nuxi/app/components/agent/AgentLoginHint.vue b/layers/nuxi/app/components/agent/AgentLoginHint.vue
new file mode 100644
index 000000000..d7a1551cc
--- /dev/null
+++ b/layers/nuxi/app/components/agent/AgentLoginHint.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Save your chats and keep your history across devices.
+
+
+
+
diff --git a/layers/nuxi/app/components/agent/AgentNuxiIcon.vue b/layers/nuxi/app/components/agent/AgentNuxiIcon.vue
new file mode 100644
index 000000000..3bc2e7e89
--- /dev/null
+++ b/layers/nuxi/app/components/agent/AgentNuxiIcon.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ z
+ z
+ z
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/agent/AgentPanel.vue b/layers/nuxi/app/components/agent/AgentPanel.vue
new file mode 100644
index 000000000..c45945879
--- /dev/null
+++ b/layers/nuxi/app/components/agent/AgentPanel.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+
+
+
+
+ Nuxi
+
+ Beta
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nuxi
+
+ Beta
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/agent/AgentPanelChat.vue b/layers/nuxi/app/components/agent/AgentPanelChat.vue
new file mode 100644
index 000000000..24d897717
--- /dev/null
+++ b/layers/nuxi/app/components/agent/AgentPanelChat.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
diff --git a/app/components/agent/AgentPanelFooter.vue b/layers/nuxi/app/components/agent/AgentPanelFooter.vue
similarity index 99%
rename from app/components/agent/AgentPanelFooter.vue
rename to layers/nuxi/app/components/agent/AgentPanelFooter.vue
index 0a0e4c3fe..aa081a0eb 100644
--- a/app/components/agent/AgentPanelFooter.vue
+++ b/layers/nuxi/app/components/agent/AgentPanelFooter.vue
@@ -23,6 +23,8 @@ const contextPathLabel = computed(() => props.currentPage?.replace(/^\//, '') ??
+
+
+import type { UIMessage } from 'ai'
+import type { Chat } from '@ai-sdk/vue'
+
+defineProps<{
+ chat: Chat
+ faqQuestions: FaqCategory[]
+}>()
+
+const chatId = inject[>('chat-id')
+
+const emit = defineEmits<{
+ askQuestion: [question: string]
+ vote: [message: UIMessage, isUpvoted: boolean]
+}>()
+
+const votes = defineModel]>('votes', { required: true })
+
+const AGENT_CHAT_THEME = {
+ 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' }
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $emit('vote', msg, isUpvoted)"
+ @regenerate="chat.regenerate()"
+ />
+
+
+
+
+
+
+
diff --git a/app/components/agent/AgentShader.vue b/layers/nuxi/app/components/agent/AgentShader.vue
similarity index 98%
rename from app/components/agent/AgentShader.vue
rename to layers/nuxi/app/components/agent/AgentShader.vue
index 85eeff64f..43d1e9153 100644
--- a/app/components/agent/AgentShader.vue
+++ b/layers/nuxi/app/components/agent/AgentShader.vue
@@ -1,5 +1,4 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/chat/ChatTitle.vue b/layers/nuxi/app/components/chat/ChatTitle.vue
new file mode 100644
index 000000000..cbf2aff8d
--- /dev/null
+++ b/layers/nuxi/app/components/chat/ChatTitle.vue
@@ -0,0 +1,52 @@
+
+
+
+
+ {{ displayTitle }}
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/chat/ChatVisibility.vue b/layers/nuxi/app/components/chat/ChatVisibility.vue
new file mode 100644
index 000000000..07682e054
--- /dev/null
+++ b/layers/nuxi/app/components/chat/ChatVisibility.vue
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
+
+
+ Share this chat
+
+
+ Choose who can see this conversation.
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+ More options
+
+
+ Sharing
+
+
+
+
+
+
+
+
+ Share with Nuxt admins
+
+
+ For debugging β admins can open this chat from their dashboard
+
+
+
+
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/chat/ModalConfirm.vue b/layers/nuxi/app/components/chat/ModalConfirm.vue
new file mode 100644
index 000000000..4f6d5aab0
--- /dev/null
+++ b/layers/nuxi/app/components/chat/ModalConfirm.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/layers/nuxi/app/components/chat/ModalRename.vue b/layers/nuxi/app/components/chat/ModalRename.vue
new file mode 100644
index 000000000..f676399b0
--- /dev/null
+++ b/layers/nuxi/app/components/chat/ModalRename.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/tools/SourceLink.vue b/layers/nuxi/app/components/tools/SourceLink.vue
similarity index 100%
rename from app/components/tools/SourceLink.vue
rename to layers/nuxi/app/components/tools/SourceLink.vue
diff --git a/layers/nuxi/app/composables/useAgentChat.ts b/layers/nuxi/app/composables/useAgentChat.ts
new file mode 100644
index 000000000..01ebed71a
--- /dev/null
+++ b/layers/nuxi/app/composables/useAgentChat.ts
@@ -0,0 +1,168 @@
+import { Chat } from '@ai-sdk/vue'
+import { DefaultChatTransport } from 'ai'
+import type { UIMessage } from 'ai'
+
+interface UseAgentChatOptions {
+ // Remount the consumer with `:key` when switching chats β the Chat
+ // instance carries state that bleeds across conversations otherwise.
+ chatId: string
+ initialMessages?: UIMessage[]
+ initialVotes?: Map
+ source: string
+ withPageContext?: 'always' | 'when-enabled'
+ onFinish?: () => void
+}
+
+export const AGENT_CHAT_THEME = {
+ 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' }
+ }
+} as const
+
+export function useAgentChat(options: UseAgentChatOptions) {
+ const agent = useNuxtAgent()
+ const chats = useChatsData()
+ const { loggedIn } = useUserSession()
+ const { track } = useAnalytics()
+ const toast = useToast()
+
+ const input = ref('')
+ const votes = ref>(new Map(options.initialVotes))
+
+ const useContext = computed(() =>
+ options.withPageContext === 'always'
+ ? Boolean(agent.currentPage.value)
+ : agent.pageContextEnabled.value && Boolean(agent.currentPage.value)
+ )
+
+ const chat = new Chat({
+ id: options.chatId,
+ messages: options.initialMessages,
+ transport: new DefaultChatTransport({
+ api: `/api/chats/${options.chatId}`,
+ headers: () => {
+ if (useContext.value && agent.currentPage.value) {
+ return { 'x-page-path': agent.currentPage.value }
+ }
+ return {}
+ }
+ }),
+ onFinish: () => {
+ const now = new Date().toISOString()
+ chat.messages = chat.messages.map((msg) => {
+ if (!(msg.metadata as Record | undefined)?.createdAt) {
+ return { ...msg, metadata: { ...(msg.metadata as object ?? {}), createdAt: now } }
+ }
+ return msg
+ }) as UIMessage[]
+ const chatCache = useNuxtData(`chat-${options.chatId}`)
+ if (chatCache.data.value) {
+ chatCache.data.value = {
+ ...chatCache.data.value,
+ messages: chat.messages.map(m => ({
+ id: m.id,
+ role: m.role,
+ parts: m.parts as unknown[],
+ createdAt: (m.metadata as { createdAt?: string } | undefined)?.createdAt ?? now
+ })) as ChatDetail['messages']
+ }
+ }
+ options.onFinish?.()
+ },
+ onData: async (part) => {
+ if (part.type === 'data-chat-title') {
+ await chats.refresh()
+ const updated = chats.chatList.value?.find(c => c.id === options.chatId)
+ if (updated?.title) chats.patchTitle(options.chatId, updated.title)
+ }
+ }
+ })
+
+ function vote(message: UIMessage, isUpvoted: boolean) {
+ const current = votes.value.get(message.id)
+ const next = current === isUpvoted ? undefined : isUpvoted
+
+ if (next === undefined) votes.value.delete(message.id)
+ else votes.value.set(message.id, next)
+ votes.value = new Map(votes.value)
+
+ $fetch(`/api/chats/${options.chatId}/votes`, {
+ method: 'POST',
+ body: next === undefined
+ ? { messageId: message.id }
+ : { messageId: message.id, isUpvoted: next }
+ }).catch(() => {
+ if (current !== undefined) votes.value.set(message.id, current)
+ else votes.value.delete(message.id)
+ votes.value = new Map(votes.value)
+ toast.add({ description: 'Failed to save vote', icon: 'i-lucide-alert-circle', color: 'error' })
+ })
+ }
+
+ async function send(text: string) {
+ if (!text.trim() || agent.rateLimitReached.value) return
+ track('Nuxi Message Sent', {
+ source: options.source,
+ page: agent.currentPage.value,
+ withContext: useContext.value,
+ queryLength: text.length
+ })
+ const metadata = {
+ createdAt: new Date().toISOString(),
+ ...(useContext.value && agent.currentPage.value ? { pagePath: agent.currentPage.value } : {})
+ }
+ if (chat.messages.length === 0 && loggedIn.value) {
+ const userMessage: UIMessage = {
+ id: crypto.randomUUID(),
+ role: 'user',
+ parts: [{ type: 'text', text }],
+ metadata
+ }
+ await $fetch('/api/chats', {
+ method: 'POST',
+ body: { id: options.chatId, message: userMessage }
+ })
+ chat.messages = [userMessage]
+ await chat.regenerate()
+ } else {
+ await chat.sendMessage({ text, metadata })
+ }
+ agent.onMessageSent()
+ }
+
+ async function onSubmit() {
+ const raw = input.value
+ input.value = ''
+ await send(raw)
+ }
+
+ function askQuestion(question: string) {
+ track('Nuxi FAQ Clicked', { question, source: options.source })
+ send(question)
+ }
+
+ const canClear = computed(() => chat.messages.length > 0)
+
+ return {
+ chat,
+ input,
+ votes,
+ vote,
+ send,
+ canClear,
+ onSubmit,
+ askQuestion,
+ chatTheme: AGENT_CHAT_THEME
+ }
+}
diff --git a/layers/nuxi/app/composables/useChatActions.ts b/layers/nuxi/app/composables/useChatActions.ts
new file mode 100644
index 000000000..a8462d0f9
--- /dev/null
+++ b/layers/nuxi/app/composables/useChatActions.ts
@@ -0,0 +1,70 @@
+import { LazyChatModalConfirm, LazyChatModalRename } from '#components'
+
+export function useChatActions() {
+ const route = useRoute()
+ const toast = useToast()
+ const overlay = useOverlay()
+ const chats = useChatsData()
+
+ const renameModal = overlay.create(LazyChatModalRename)
+ const deleteModal = overlay.create(LazyChatModalConfirm, {
+ props: {
+ title: 'Delete chat',
+ description: 'Are you sure you want to delete this chat? This cannot be undone.',
+ color: 'error'
+ }
+ })
+
+ async function renameChat(id: string, currentTitle?: string | null): Promise {
+ const instance = renameModal.open({ title: currentTitle ?? '' })
+ const result = (await instance.result) as string | false | undefined
+
+ if (!result || result === currentTitle) return null
+
+ try {
+ await $fetch(`/api/chats/${id}/title`, {
+ method: 'PATCH',
+ body: { title: result }
+ })
+ chats.patchTitle(id, result)
+ return result
+ } catch {
+ toast.add({
+ description: 'Failed to rename chat',
+ icon: 'i-lucide-alert-circle',
+ color: 'error'
+ })
+ return null
+ }
+ }
+
+ async function deleteChat(id: string): Promise {
+ const instance = deleteModal.open()
+ const result = (await instance.result) as boolean
+
+ if (!result) return false
+
+ try {
+ await $fetch(`/api/chats/${id}`, { method: 'DELETE' })
+ toast.add({
+ title: 'Chat deleted',
+ description: 'Your chat has been deleted',
+ icon: 'i-lucide-trash'
+ })
+ chats.removeChat(id)
+ if (route.params.id === id) {
+ navigateTo('/dashboard/chat')
+ }
+ return true
+ } catch {
+ toast.add({
+ description: 'Failed to delete chat',
+ icon: 'i-lucide-alert-circle',
+ color: 'error'
+ })
+ return false
+ }
+ }
+
+ return { renameChat, deleteChat }
+}
diff --git a/app/composables/useChatTools.ts b/layers/nuxi/app/composables/useChatTools.ts
similarity index 87%
rename from app/composables/useChatTools.ts
rename to layers/nuxi/app/composables/useChatTools.ts
index 58031cd7b..0a8afb65b 100644
--- a/app/composables/useChatTools.ts
+++ b/layers/nuxi/app/composables/useChatTools.ts
@@ -4,18 +4,6 @@ import { getToolName } from 'ai'
export type ToolPart = ToolUIPart | DynamicToolUIPart
type ToolState = ToolPart['state']
-export interface ModuleCardData {
- name: string
- npm?: string
- description?: string
- icon?: string
- category?: string
- repo?: string
- website?: string
- downloads?: number
- stars?: number
-}
-
export function isValidModuleCardData(output: unknown): output is ModuleCardData {
if (!output || typeof output !== 'object') return false
const o = output as Record
@@ -30,45 +18,6 @@ export function moduleCardProps(data: ModuleCardData): Omit
-}
-
-export interface HostingCardData {
- title: string
- description?: string
- path: string
- logoSrc?: string
- logoIcon?: string
- category?: string
- nitroPreset?: string
- website?: string
-}
-
-export interface PlaygroundCardData {
- url: string
- repo: string
- title?: string
- file?: string
- dir?: string
-}
-
function getToolMessage(state: ToolState, toolName: string, toolInput: Record) {
const searchVerb = state === 'output-available' ? 'Searched' : 'Searching'
const readVerb = state === 'output-available' ? 'Read' : 'Reading'
diff --git a/layers/nuxi/app/composables/useChats.ts b/layers/nuxi/app/composables/useChats.ts
new file mode 100644
index 000000000..0f523d874
--- /dev/null
+++ b/layers/nuxi/app/composables/useChats.ts
@@ -0,0 +1,55 @@
+import { isToday, isYesterday, subWeeks, subMonths } from 'date-fns'
+
+export interface UIChat {
+ id: string
+ label: string
+ icon?: string
+ to?: string
+ createdAt: string | Date
+ updatedAt?: string | Date | null
+}
+
+export function useChats(chats: Ref) {
+ const groups = computed(() => {
+ const today: UIChat[] = []
+ const yesterday: UIChat[] = []
+ const lastWeek: UIChat[] = []
+ const lastMonth: UIChat[] = []
+ const older: Record = {}
+
+ const oneWeekAgo = subWeeks(new Date(), 1)
+ const oneMonthAgo = subMonths(new Date(), 1)
+
+ chats.value?.forEach((chat) => {
+ const chatDate = chat.updatedAt ? new Date(chat.updatedAt) : new Date(chat.createdAt)
+
+ if (isToday(chatDate)) today.push(chat)
+ else if (isYesterday(chatDate)) yesterday.push(chat)
+ else if (chatDate >= oneWeekAgo) lastWeek.push(chat)
+ else if (chatDate >= oneMonthAgo) lastMonth.push(chat)
+ else {
+ const monthYear = chatDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
+ if (!older[monthYear]) older[monthYear] = []
+ older[monthYear].push(chat)
+ }
+ })
+
+ const sortedMonthYears = Object.keys(older).sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
+
+ const formattedGroups: Array<{ id: string, label: string, items: UIChat[] }> = []
+ if (today.length) formattedGroups.push({ id: 'today', label: 'Today', items: today })
+ if (yesterday.length) formattedGroups.push({ id: 'yesterday', label: 'Yesterday', items: yesterday })
+ if (lastWeek.length) formattedGroups.push({ id: 'last-week', label: 'Last week', items: lastWeek })
+ if (lastMonth.length) formattedGroups.push({ id: 'last-month', label: 'Last month', items: lastMonth })
+
+ sortedMonthYears.forEach((monthYear) => {
+ if (older[monthYear]?.length) {
+ formattedGroups.push({ id: monthYear, label: monthYear, items: older[monthYear] })
+ }
+ })
+
+ return formattedGroups
+ })
+
+ return { groups }
+}
diff --git a/layers/nuxi/app/composables/useChatsData.ts b/layers/nuxi/app/composables/useChatsData.ts
new file mode 100644
index 000000000..9b9cec78d
--- /dev/null
+++ b/layers/nuxi/app/composables/useChatsData.ts
@@ -0,0 +1,31 @@
+export function useChatsData() {
+ const { data: chatList } = useFetch('/api/chats', {
+ key: 'chats',
+ server: false,
+ lazy: true,
+ immediate: false,
+ default: () => []
+ })
+
+ function patchTitle(id: string, title: string) {
+ if (chatList.value) {
+ chatList.value = chatList.value.map(c => c.id === id ? { ...c, title } : c)
+ }
+ const { data: chatCache } = useNuxtData(`chat-${id}`)
+ if (chatCache.value) {
+ chatCache.value = { ...chatCache.value, title }
+ }
+ }
+
+ function removeChat(id: string) {
+ if (chatList.value) {
+ chatList.value = chatList.value.filter(c => c.id !== id)
+ }
+ }
+
+ async function refresh() {
+ await refreshNuxtData('chats')
+ }
+
+ return { chatList, patchTitle, removeChat, refresh }
+}
diff --git a/layers/nuxi/app/composables/useNuxiIcon.ts b/layers/nuxi/app/composables/useNuxiIcon.ts
new file mode 100644
index 000000000..4fb8683b6
--- /dev/null
+++ b/layers/nuxi/app/composables/useNuxiIcon.ts
@@ -0,0 +1,466 @@
+import { useAnimate } from 'motion-v'
+
+type LookDir = 'center' | 'left' | 'right' | 'up'
+
+type MouthShape = 'default' | 'O'
+
+interface MoodVisual {
+ showSmile: boolean
+ mouthShape: MouthShape
+ eyeLeftScaleY: number
+ eyeLeftScaleX: number
+ eyeRightScaleY: number
+ eyeRightScaleX: number
+ eyeTranslateY: number
+ mouthScale: number
+ mouthTY: number
+ mouthOpacity: number
+ blinkEnabled: boolean
+}
+
+const BASE_Y = -6
+const PROXIMITY_RADIUS = 400
+const LERP_FACTOR = 0.10
+
+const LOOK_OFFSETS: Record = {
+ center: { x: 0, y: 0 },
+ left: { x: -8, y: 2 },
+ right: { x: 8, y: 2 },
+ up: { x: 0, y: -6 }
+}
+
+const EYE_LEFT_PATH = 'M76.425 109.429C87.5373 92.1556 113.182 93.389 122.585 111.649C131.988 129.91 118.098 151.501 97.5822 150.515C77.0667 149.528 65.3128 126.703 76.425 109.429Z'
+const EYE_RIGHT_PATH = 'M204.632 156.542C185.601 155.627 174.698 134.454 185.006 118.43C195.314 102.407 219.102 103.551 227.825 120.49C236.547 137.429 223.662 157.458 204.632 156.542Z'
+
+// Smile arcs `^_^` β rendered as separate stroked layers, opacity-controlled per mood.
+// Drawn as single quadratic curves with stroke-linecap=round β smooth ends, no cusps.
+const SMILE_LEFT_PATH = 'M77 125 Q 99 109 121 125'
+const SMILE_RIGHT_PATH = 'M184 133 Q 206 117 228 133'
+
+const MOUTH_PATHS: Record = {
+ default: 'M129.032 174.492C137.341 190.478 160.159 191.682 170.105 176.66L172.148 173.574C173.856 170.994 172.113 167.535 169.023 167.372L131.086 165.369C127.996 165.206 125.899 168.463 127.326 171.209L129.032 174.492Z',
+ O: 'M132 178 a 17 13 0 1 0 34 0 a 17 13 0 1 0 -34 0 Z'
+}
+
+/* eslint-disable @stylistic/key-spacing, @stylistic/no-multi-spaces -- table layout for legibility */
+const MOOD_VISUALS: Record = {
+ idle: { showSmile: false, eyeLeftScaleY: 1, eyeLeftScaleX: 1, eyeRightScaleY: 1, eyeRightScaleX: 1, eyeTranslateY: 0, mouthShape: 'default', mouthScale: 1, mouthTY: 0, mouthOpacity: 1, blinkEnabled: true },
+ happy: { showSmile: true, eyeLeftScaleY: 0.02, eyeLeftScaleX: 0.5, eyeRightScaleY: 0.02, eyeRightScaleX: 0.5, eyeTranslateY: -5, mouthShape: 'default', mouthScale: 1.25, mouthTY: 3, mouthOpacity: 1, blinkEnabled: false },
+ excited: { showSmile: false, eyeLeftScaleY: 1.05, eyeLeftScaleX: 1.02, eyeRightScaleY: 1.05, eyeRightScaleX: 1.02, eyeTranslateY: 0, mouthShape: 'default', mouthScale: 1.55, mouthTY: 6, mouthOpacity: 1, blinkEnabled: false },
+ thinking: { showSmile: false, eyeLeftScaleY: 0.55, eyeLeftScaleX: 0.9, eyeRightScaleY: 0.55, eyeRightScaleX: 0.9, eyeTranslateY: 0, mouthShape: 'default', mouthScale: 0.7, mouthTY: 6, mouthOpacity: 1, blinkEnabled: false },
+ sleeping: { showSmile: false, eyeLeftScaleY: 0.15, eyeLeftScaleX: 1, eyeRightScaleY: 0.15, eyeRightScaleX: 1, eyeTranslateY: 0, mouthShape: 'default', mouthScale: 0, mouthTY: 0, mouthOpacity: 1, blinkEnabled: false },
+ surprised: { showSmile: false, eyeLeftScaleY: 1.08, eyeLeftScaleX: 1.03, eyeRightScaleY: 1.08, eyeRightScaleX: 1.03, eyeTranslateY: 0, mouthShape: 'O', mouthScale: 1, mouthTY: 0, mouthOpacity: 1, blinkEnabled: false }
+}
+/* eslint-enable @stylistic/key-spacing, @stylistic/no-multi-spaces */
+
+interface NuxiIconProps {
+ mood?: NuxiMood
+ interactive?: boolean
+}
+
+type EmitFn = (event: 'moodChange', mood: NuxiMood) => void
+
+export function useNuxiIcon(props: NuxiIconProps, emit?: EmitFn) {
+ const maskId = useId()
+ const [svgEl, animate] = useAnimate()
+
+ const internalMood = ref('idle')
+ const isHovered = ref(false)
+ const isInProximity = ref(false)
+ const lookDir = ref('center')
+ const isBlinking = ref(false)
+ const isWinking = ref(false)
+ const isEasterEggPlaying = ref(false)
+
+ const effectiveMood = computed(() => {
+ if (isHovered.value) return 'excited'
+ if (props.mood) return props.mood
+ return internalMood.value
+ })
+
+ function setInternalMood(mood: NuxiMood, duration?: number) {
+ internalMood.value = mood
+ emit?.('moodChange', mood)
+ if (duration) {
+ setTimeout(() => {
+ if (internalMood.value === mood) {
+ internalMood.value = 'idle'
+ emit?.('moodChange', 'idle')
+ }
+ }, duration)
+ }
+ }
+
+ const rawOffset = reactive({ x: 0, y: 0 })
+ const lerpedOffset = reactive({ x: 0, y: 0 })
+
+ const targetOffset = computed(() => {
+ const mood = effectiveMood.value
+ if (mood === 'sleeping') return { x: 0, y: 2 }
+ if (mood === 'thinking') return { x: -6, y: -4 }
+ if (mood === 'surprised') return { x: 0, y: -8 }
+ if (mood === 'excited') return { x: 0, y: -11 }
+ const baseY = mood === 'happy' ? -4 : 0
+ if (isInProximity.value) return { x: rawOffset.x, y: baseY + rawOffset.y }
+ const look = LOOK_OFFSETS[lookDir.value]
+ return { x: look.x, y: baseY + look.y }
+ })
+
+ const { resume: startLerp } = useRafFn(() => {
+ const t = targetOffset.value
+ lerpedOffset.x += (t.x - lerpedOffset.x) * LERP_FACTOR
+ lerpedOffset.y += (t.y - lerpedOffset.y) * LERP_FACTOR
+ }, { immediate: false })
+
+ const faceTransform = computed(() =>
+ `translate(${lerpedOffset.x}px, ${BASE_Y + lerpedOffset.y}px)`
+ )
+
+ const visual = computed(() => MOOD_VISUALS[effectiveMood.value])
+
+ const smileOpacity = computed(() => visual.value.showSmile ? 1 : 0)
+
+ const mouthD = computed(() => MOUTH_PATHS[visual.value.mouthShape])
+ const mouthOpacity = computed(() => visual.value.mouthOpacity)
+
+ const eyeLeftScaleY = computed(() => isBlinking.value ? 0.05 : visual.value.eyeLeftScaleY)
+ const eyeRightScaleY = computed(() => isBlinking.value ? 0.05 : visual.value.eyeRightScaleY)
+
+ const eyeLeftTransform = computed(() =>
+ isWinking.value
+ ? `translateY(${visual.value.eyeTranslateY}px) scale(${visual.value.eyeLeftScaleX}, 0.05)`
+ : `translateY(${visual.value.eyeTranslateY}px) scale(${visual.value.eyeLeftScaleX}, ${eyeLeftScaleY.value})`
+ )
+
+ const eyeRightTransform = computed(() =>
+ `translateY(${visual.value.eyeTranslateY}px) scale(${visual.value.eyeRightScaleX}, ${eyeRightScaleY.value})`
+ )
+
+ const eyeLeftTransition = computed(() =>
+ (isBlinking.value || isWinking.value)
+ ? 'transform 0.06s ease'
+ : 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'
+ )
+
+ const eyeRightTransition = computed(() =>
+ isBlinking.value
+ ? 'transform 0.06s ease 0.04s'
+ : 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.04s'
+ )
+
+ const mouthTransform = computed(() => {
+ const v = visual.value
+ let scale = v.mouthScale
+ let ty = v.mouthTY
+ let rotate = 0
+
+ if (isInProximity.value && effectiveMood.value !== 'sleeping' && effectiveMood.value !== 'thinking') {
+ const proximity = Math.sqrt(lerpedOffset.x ** 2 + lerpedOffset.y ** 2) / 9
+ scale += proximity * 0.1
+ ty += proximity * 1.2
+ rotate = lerpedOffset.x * 0.6
+ }
+
+ return `scale(${scale}) translateY(${ty}px) rotate(${rotate}deg)`
+ })
+
+ const bodyClass = computed(() =>
+ isEasterEggPlaying.value ? '' : `nuxi-body--${effectiveMood.value}`
+ )
+
+ let lookTimer: ReturnType | undefined
+ let blinkTimer: ReturnType | undefined
+ let sleepTimer: ReturnType | undefined
+ let attentionTimer: ReturnType | undefined
+ let winkTimer: ReturnType | undefined
+ let winkOffTimer: ReturnType | undefined
+ let mounted = true
+
+ function scheduleLook() {
+ lookTimer = setTimeout(() => {
+ if ((effectiveMood.value === 'idle' || effectiveMood.value === 'happy') && !isInProximity.value) {
+ const dirs: LookDir[] = ['center', 'center', 'left', 'right', 'up']
+ lookDir.value = dirs[Math.floor(Math.random() * dirs.length)] as LookDir
+ setTimeout(() => {
+ if (effectiveMood.value === 'idle' || effectiveMood.value === 'happy') lookDir.value = 'center'
+ }, 600 + Math.random() * 500)
+ }
+ scheduleLook()
+ }, 2000 + Math.random() * 3000)
+ }
+
+ function scheduleBlink() {
+ blinkTimer = setTimeout(() => {
+ if (visual.value.blinkEnabled && !isWinking.value) {
+ isBlinking.value = true
+ setTimeout(() => (isBlinking.value = false), 110)
+ if (Math.random() < 0.1) {
+ setTimeout(() => {
+ isBlinking.value = true
+ setTimeout(() => (isBlinking.value = false), 80)
+ }, 200)
+ }
+ }
+ scheduleBlink()
+ }, 2200 + Math.random() * 3800)
+ }
+
+ function scheduleWink() {
+ if (!mounted) return
+ winkTimer = setTimeout(() => {
+ if (effectiveMood.value === 'idle' || effectiveMood.value === 'happy') {
+ isWinking.value = true
+ winkOffTimer = setTimeout(() => (isWinking.value = false), 250)
+ }
+ scheduleWink()
+ }, 15000 + Math.random() * 10000)
+ }
+
+ function scheduleAttention() {
+ clearTimeout(attentionTimer)
+ attentionTimer = setTimeout(async () => {
+ if (effectiveMood.value === 'idle' && !isEasterEggPlaying.value && !props.mood) {
+ await doNudge()
+ }
+ scheduleAttention()
+ }, 12000 + Math.random() * 8000)
+ }
+
+ function resetSleepTimer() {
+ clearTimeout(sleepTimer)
+ if (isInProximity.value && !isHovered.value && internalMood.value === 'idle' && !props.mood) {
+ sleepTimer = setTimeout(() => setInternalMood('sleeping'), 8000)
+ }
+ }
+
+ let lastMouseX = 0
+ let lastMouseY = 0
+ let mouseSpeed = 0
+
+ function handleMouseMove(e: MouseEvent) {
+ const mdx = e.clientX - lastMouseX
+ const mdy = e.clientY - lastMouseY
+ mouseSpeed = Math.sqrt(mdx * mdx + mdy * mdy)
+ lastMouseX = e.clientX
+ lastMouseY = e.clientY
+
+ if (!svgEl.value || isHovered.value) return
+ const rect = (svgEl.value as unknown as Element).getBoundingClientRect()
+ const cx = rect.left + rect.width / 2
+ const cy = rect.top + rect.height / 2
+ const dx = e.clientX - cx
+ const dy = e.clientY - cy
+ const dist = Math.sqrt(dx * dx + dy * dy)
+
+ if (dist < PROXIMITY_RADIUS && dist > 1) {
+ const strength = ((PROXIMITY_RADIUS - dist) / PROXIMITY_RADIUS) * 9
+ rawOffset.x = (dx / dist) * strength
+ rawOffset.y = (dy / dist) * strength
+ isInProximity.value = true
+
+ if (internalMood.value === 'sleeping' && !props.mood) {
+ setInternalMood('surprised', 1200)
+ }
+
+ resetSleepTimer()
+ scheduleAttention()
+ } else if (isInProximity.value) {
+ isInProximity.value = false
+ rawOffset.x = 0
+ rawOffset.y = 0
+ clearTimeout(sleepTimer)
+ }
+ }
+
+ async function doFlip() {
+ if (isEasterEggPlaying.value) return
+ isEasterEggPlaying.value = true
+
+ await animate('.nuxi-body', { scaleX: 1.12, scaleY: 0.82, y: 6 }, { duration: 0.12, ease: [0.4, 0, 1, 1] })
+ await animate('.nuxi-body', { scaleX: 0.88, scaleY: 1.18, y: -35 }, { duration: 0.14, ease: [0, 0, 0.2, 1] })
+ await animate('.nuxi-body', {
+ rotate: 360,
+ y: [null, -48, -50, -48, -30],
+ scaleX: [null, 0.94, 1.04, 0.94, 0.97],
+ scaleY: [null, 1.06, 0.96, 1.06, 1.03]
+ }, { duration: 0.6, ease: [0.15, 0.6, 0.4, 1] })
+ await animate('.nuxi-body', { scaleX: 1.15, scaleY: 0.78, y: 4 }, { duration: 0.08, ease: [0.4, 0, 1, 1] })
+ await animate('.nuxi-body', { scaleX: 0.96, scaleY: 1.05, y: -5 }, { duration: 0.1, ease: [0, 0, 0.2, 1] })
+ await animate('.nuxi-body', { scaleX: 1, scaleY: 1, y: 0, rotate: 360 }, { duration: 0.14, ease: [0.34, 1.56, 0.64, 1] })
+ await animate('.nuxi-body', { rotate: 0 }, { duration: 0 })
+
+ isEasterEggPlaying.value = false
+ }
+
+ async function doSpin() {
+ if (isEasterEggPlaying.value) return
+ isEasterEggPlaying.value = true
+
+ await animate('.nuxi-body', { rotate: 360, scale: [1, 1.12, 1.06, 1] }, { duration: 0.5, ease: [0.15, 0.6, 0.4, 1] })
+ await animate('.nuxi-body', { rotate: 0 }, { duration: 0 })
+
+ isEasterEggPlaying.value = false
+ }
+
+ async function doDance() {
+ if (isEasterEggPlaying.value) return
+ isEasterEggPlaying.value = true
+
+ for (let i = 0; i < 2; i++) {
+ await animate('.nuxi-body', { scaleX: 1.06, scaleY: 0.88, y: 4 }, { duration: 0.08, ease: 'easeIn' })
+ await animate('.nuxi-body', { scaleX: 0.9, scaleY: 1.14, y: -18, rotate: i === 0 ? 8 : -8 }, { duration: 0.15, ease: [0, 0, 0.2, 1] })
+ await animate('.nuxi-body', { scaleX: 1.08, scaleY: 0.9, y: 3, rotate: 0 }, { duration: 0.1, ease: [0.4, 0, 1, 1] })
+ }
+ await animate('.nuxi-body', { scaleX: 1, scaleY: 1, y: 0, rotate: 0 }, { duration: 0.15, ease: [0.34, 1.56, 0.64, 1] })
+
+ isEasterEggPlaying.value = false
+ }
+
+ async function doNudge() {
+ if (isEasterEggPlaying.value) return
+ isEasterEggPlaying.value = true
+
+ await animate('.nuxi-body', { scaleX: 1.04, scaleY: 0.94, y: 2 }, { duration: 0.1, ease: 'easeIn' })
+ await animate('.nuxi-body', { scaleX: 0.96, scaleY: 1.06, y: -8, rotate: 3 }, { duration: 0.15, ease: [0, 0, 0.2, 1] })
+ await animate('.nuxi-body', { scaleX: 1.02, scaleY: 0.98, y: 1, rotate: -2 }, { duration: 0.12, ease: [0.4, 0, 1, 1] })
+ await animate('.nuxi-body', { scaleX: 1, scaleY: 1, y: 0, rotate: 0 }, { duration: 0.2, ease: [0.34, 1.56, 0.64, 1] })
+
+ isEasterEggPlaying.value = false
+ }
+
+ async function doWobble() {
+ if (isEasterEggPlaying.value) return
+ isEasterEggPlaying.value = true
+
+ for (let i = 0; i < 3; i++) {
+ const angle = (3 - i) * 6
+ await animate('.nuxi-body', { rotate: angle, y: -2 }, { duration: 0.06, ease: 'easeIn' })
+ await animate('.nuxi-body', { rotate: -angle, y: -2 }, { duration: 0.06, ease: 'easeIn' })
+ }
+ await animate('.nuxi-body', { rotate: 0, y: 0 }, { duration: 0.15, ease: [0.34, 1.56, 0.64, 1] })
+
+ isEasterEggPlaying.value = false
+ }
+
+ async function doDizzy() {
+ if (isEasterEggPlaying.value) return
+ isEasterEggPlaying.value = true
+
+ await animate('.nuxi-body', {
+ rotate: [0, -8, 12, -6, 8, -3, 0],
+ y: [0, -3, -5, -2, -4, -1, 0],
+ scaleX: [1, 1.04, 0.97, 1.03, 0.98, 1.01, 1],
+ scaleY: [1, 0.97, 1.03, 0.98, 1.02, 0.99, 1]
+ }, { duration: 0.8, ease: [0.22, 1, 0.36, 1] })
+
+ isEasterEggPlaying.value = false
+ }
+
+ const keyBuffer: string[] = []
+ let clickCount = 0
+ let clickResetTimer: ReturnType | undefined
+ let hoverCount = 0
+ let hoverResetTimer: ReturnType | undefined
+
+ function handleKeyDown(e: KeyboardEvent) {
+ const target = e.target as HTMLElement
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
+ keyBuffer.push(e.key.toLowerCase())
+ if (keyBuffer.length > 5) keyBuffer.shift()
+ const buf = keyBuffer.join('')
+ function trigger(fn: () => void) {
+ fn()
+ keyBuffer.length = 0
+ }
+ if (buf.endsWith('flip')) trigger(doFlip)
+ else if (buf.endsWith('nuxi')) trigger(doDance)
+ else if (buf.endsWith('spin')) trigger(doSpin)
+ else if (buf.endsWith('hi')) {
+ setInternalMood('happy', 2000)
+ trigger(doNudge)
+ }
+ }
+
+ function handleSvgClick(e: MouseEvent) {
+ clickCount++
+ clearTimeout(clickResetTimer)
+ clickResetTimer = setTimeout(() => {
+ clickCount = 0
+ }, 500)
+ if (clickCount === 2) {
+ isWinking.value = true
+ setTimeout(() => (isWinking.value = false), 300)
+ } else if (clickCount >= 4) {
+ e.stopPropagation()
+ e.preventDefault()
+ doWobble()
+ clickCount = 0
+ }
+ }
+
+ function handleMouseEnter() {
+ isHovered.value = true
+ hoverCount++
+ clearTimeout(hoverResetTimer)
+ hoverResetTimer = setTimeout(() => {
+ hoverCount = 0
+ }, 2000)
+ if (hoverCount >= 4) {
+ doSpin()
+ hoverCount = 0
+ }
+ scheduleAttention()
+ }
+
+ function handleMouseLeave() {
+ isHovered.value = false
+ if (mouseSpeed > 60 && Math.random() < 0.4) doDizzy()
+ }
+
+ onMounted(() => {
+ if (props.interactive !== false) {
+ useEventListener(window, 'mousemove', handleMouseMove, { passive: true })
+ useEventListener(window, 'keydown', handleKeyDown)
+ }
+ scheduleLook()
+ scheduleBlink()
+ scheduleWink()
+ scheduleAttention()
+ startLerp()
+ })
+
+ onUnmounted(() => {
+ mounted = false
+ clearTimeout(lookTimer)
+ clearTimeout(blinkTimer)
+ clearTimeout(sleepTimer)
+ clearTimeout(clickResetTimer)
+ clearTimeout(hoverResetTimer)
+ clearTimeout(attentionTimer)
+ clearTimeout(winkTimer)
+ clearTimeout(winkOffTimer)
+ })
+
+ return {
+ maskId,
+ svgEl,
+ effectiveMood,
+ faceTransform,
+ eyeLeftPath: EYE_LEFT_PATH,
+ eyeRightPath: EYE_RIGHT_PATH,
+ smileLeftPath: SMILE_LEFT_PATH,
+ smileRightPath: SMILE_RIGHT_PATH,
+ smileOpacity,
+ mouthD,
+ mouthOpacity,
+ eyeLeftTransform,
+ eyeRightTransform,
+ eyeLeftTransition,
+ eyeRightTransition,
+ mouthTransform,
+ bodyClass,
+ handleMouseEnter,
+ handleMouseLeave,
+ handleSvgClick
+ }
+}
diff --git a/app/composables/useNuxtAgent.ts b/layers/nuxi/app/composables/useNuxtAgent.ts
similarity index 64%
rename from app/composables/useNuxtAgent.ts
rename to layers/nuxi/app/composables/useNuxtAgent.ts
index 7947505a1..a5ec119d5 100644
--- a/app/composables/useNuxtAgent.ts
+++ b/layers/nuxi/app/composables/useNuxtAgent.ts
@@ -1,7 +1,3 @@
-import type { UIMessage } from 'ai'
-import { createSharedComposable, useLocalStorage, useMediaQuery, useSessionStorage } from '@vueuse/core'
-import type { FaqCategory, FaqQuestions } from '~/types/agent'
-
interface AgentUsage {
used: number
remaining: number
@@ -9,17 +5,10 @@ interface AgentUsage {
}
function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
- if (!questions || (Array.isArray(questions) && questions.length === 0)) {
- return []
- }
-
+ if (!questions || (Array.isArray(questions) && questions.length === 0)) return []
if (typeof questions[0] === 'string') {
- return [{
- category: 'Questions',
- items: questions as string[]
- }]
+ return [{ category: 'Questions', items: questions as string[] }]
}
-
return questions as FaqCategory[]
}
@@ -30,11 +19,9 @@ export const useNuxtAgent = createSharedComposable(() => {
const appConfig = useAppConfig()
const agentConfig = appConfig.agent as { faqQuestions?: FaqQuestions } | undefined
const route = useRoute()
+ const { loggedIn } = useUserSession()
const storageOpen = useLocalStorage('assistant-open', false)
- const messages = useSessionStorage('assistant-messages', [])
- const chatId = useSessionStorage('assistant-chat-id', () => crypto.randomUUID())
-
const isOpen = ref(false)
const currentPage = computed(() => {
@@ -55,7 +42,6 @@ export const useNuxtAgent = createSharedComposable(() => {
isOpen.value = storageOpen.value
})
})
-
watch(isOpen, (value) => {
storageOpen.value = value
})
@@ -66,44 +52,29 @@ export const useNuxtAgent = createSharedComposable(() => {
return normalizeFaqQuestions(faqConfig)
})
- function resetChatId() {
- chatId.value = crypto.randomUUID()
+ const pendingPrompt = ref(null)
+ function consumePendingPrompt(): string | null {
+ const value = pendingPrompt.value
+ pendingPrompt.value = null
+ return value
}
- function open(initialMessage?: string, clearPrevious = false) {
- if (clearPrevious) {
- messages.value = []
- resetChatId()
- }
-
- if (initialMessage) {
- messages.value = [...messages.value, {
- id: String(Date.now()),
- role: 'user' as const,
- parts: [{ type: 'text' as const, text: initialMessage }],
- ...(currentPage.value ? { metadata: { pagePath: currentPage.value } } : {})
- }]
- onMessageSent()
- }
+ function open(initialMessage?: string) {
+ if (initialMessage) pendingPrompt.value = initialMessage
isOpen.value = true
}
-
function toggle() {
isOpen.value = !isOpen.value
}
-
function expandToFullScreen() {
isOpen.value = false
- navigateTo('/chat')
+ navigateTo('/dashboard/chat')
}
-
function collapseToSidebar() {
isOpen.value = true
}
- /** Matches Tailwind `xl` β docked USidebar only at this width and above */
const isAgentDockedBreakpoint = useMediaQuery('(min-width: 1280px)')
-
const isAgentDocked = computed(() => isOpen.value && isAgentDockedBreakpoint.value)
const { data: usage } = useFetch('/api/agent/usage', {
@@ -111,29 +82,37 @@ export const useNuxtAgent = createSharedComposable(() => {
lazy: true,
default: () => ({ used: 0, remaining: 20, limit: 20 })
})
-
const rateLimitReached = computed(() => (usage.value?.remaining ?? Infinity) <= 0)
-
function onMessageSent() {
- if (usage.value) {
- usage.value = {
- ...usage.value,
- used: usage.value.used + 1,
- remaining: Math.max(0, usage.value.remaining - 1)
- }
+ if (!usage.value) return
+ usage.value = {
+ ...usage.value,
+ used: usage.value.used + 1,
+ remaining: Math.max(0, usage.value.remaining - 1)
}
}
+ const { chatList, refresh: refreshChats } = useChatsData()
+
+ watch(loggedIn, (next) => {
+ if (next) refreshChats()
+ else if (chatList.value) chatList.value = []
+ })
+
+ watch(isOpen, (next) => {
+ if (next && loggedIn.value && !chatList.value?.length) refreshChats()
+ })
+
+ const nuxiMood = ref('idle')
+
return {
isOpen,
- messages,
- chatId,
- resetChatId,
- faqQuestions,
open,
toggle,
expandToFullScreen,
collapseToSidebar,
+ consumePendingPrompt,
+ pendingPrompt,
isAgentDockedBreakpoint,
isAgentDocked,
usage,
@@ -141,6 +120,9 @@ export const useNuxtAgent = createSharedComposable(() => {
onMessageSent,
currentPage,
pageContextDismissed,
- pageContextEnabled
+ pageContextEnabled,
+ faqQuestions,
+ chatList,
+ nuxiMood
}
})
diff --git a/layers/nuxi/app/pages/dashboard/chat/[id].vue b/layers/nuxi/app/pages/dashboard/chat/[id].vue
new file mode 100644
index 000000000..de6adfdbb
--- /dev/null
+++ b/layers/nuxi/app/pages/dashboard/chat/[id].vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ vote(_message, isUpvoted)"
+ @regenerate="chat.regenerate()"
+ />
+
+ {{ messageTime(message) }}
+
+
+
+
+
+
+ Daily limit reached. Try again tomorrow.
+
+
+
+
+
+
+
+
+ {{ usage.remaining }}/{{ usage.limit }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/layers/nuxi/app/pages/dashboard/chat/index.vue b/layers/nuxi/app/pages/dashboard/chat/index.vue
new file mode 100644
index 000000000..d2b2697c3
--- /dev/null
+++ b/layers/nuxi/app/pages/dashboard/chat/index.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Daily limit reached. Try again tomorrow.
+
+
+
+
+
+
+ {{ usage.remaining }}/{{ usage.limit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/utils/ai.ts b/layers/nuxi/app/utils/ai.ts
similarity index 93%
rename from app/utils/ai.ts
rename to layers/nuxi/app/utils/ai.ts
index b69be4ee9..f9922c30a 100644
--- a/app/utils/ai.ts
+++ b/layers/nuxi/app/utils/ai.ts
@@ -1,6 +1,6 @@
import type { UIMessage } from 'ai'
import { isTextUIPart } from 'ai'
-import { sourceToInlineMdc } from '~/utils/tool'
+import { sourceToInlineMdc } from './tool'
export function getMergedParts(parts: UIMessage['parts']): UIMessage['parts'] {
const result: UIMessage['parts'] = []
diff --git a/app/utils/tool.ts b/layers/nuxi/app/utils/tool.ts
similarity index 100%
rename from app/utils/tool.ts
rename to layers/nuxi/app/utils/tool.ts
diff --git a/layers/nuxi/nuxt.config.ts b/layers/nuxi/nuxt.config.ts
new file mode 100644
index 000000000..6f3c41fac
--- /dev/null
+++ b/layers/nuxi/nuxt.config.ts
@@ -0,0 +1,12 @@
+// https://nuxt.com/docs/api/configuration/nuxt-config
+export default defineNuxtConfig({
+ modules: [
+ '@comark/nuxt'
+ ],
+
+ vite: {
+ optimizeDeps: {
+ include: ['ai', '@ai-sdk/vue']
+ }
+ }
+})
diff --git a/server/api/agent/feedback.post.ts b/layers/nuxi/server/api/agent/feedback.post.ts
similarity index 67%
rename from server/api/agent/feedback.post.ts
rename to layers/nuxi/server/api/agent/feedback.post.ts
index e61a2398b..55001c3aa 100644
--- a/server/api/agent/feedback.post.ts
+++ b/layers/nuxi/server/api/agent/feedback.post.ts
@@ -1,6 +1,6 @@
+import { createError } from 'evlog'
import { z } from 'zod'
-import { eq } from 'drizzle-orm'
-import { getAgentFingerprint } from '../../utils/agent-fingerprint'
+import { asc, eq } from 'drizzle-orm'
const LINEAR_API = 'https://api.linear.app/graphql'
const MAX_TRANSCRIPT_CHARS = 3000
@@ -66,29 +66,44 @@ function buildIssueBody(params: {
export default defineEventHandler(async (event) => {
const { chatId, title, summary, userFeedback } = await readValidatedBody(event, bodySchema.parse)
- const fingerprint = await getAgentFingerprint(event)
+ const session = await getUserSession(event)
+ const log = useLogger(event)
const [chat] = await db
.select({
- id: schema.agentChats.id,
- fingerprint: schema.agentChats.fingerprint,
- messages: schema.agentChats.messages,
- createdAt: schema.agentChats.createdAt
+ id: schema.chats.id,
+ userId: schema.chats.userId,
+ createdAt: schema.chats.createdAt
})
- .from(schema.agentChats)
- .where(eq(schema.agentChats.id, chatId))
+ .from(schema.chats)
+ .where(eq(schema.chats.id, chatId))
.limit(1)
- if (!chat || chat.fingerprint !== fingerprint) {
- throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
+ if (!chat || chat.userId !== (session.user?.id || session.id)) {
+ throw createError({ message: 'Forbidden', status: 403, why: 'You do not own this chat.' })
}
+ log.set({
+ user: { id: session.user?.id || session.id, authenticated: !!session.user },
+ feedback: { chatId, title, hasUserFeedback: !!userFeedback }
+ })
+
const { apiKey, teamId, projectId } = useRuntimeConfig(event).linear
if (!apiKey || !teamId || !projectId) {
- throw createError({ statusCode: 503, statusMessage: 'Linear integration not configured' })
+ throw createError({ message: 'Linear integration not configured', status: 503, why: 'The NUXT_LINEAR_API_KEY, NUXT_LINEAR_TEAM_ID, or NUXT_LINEAR_PROJECT_ID environment variables are missing.' })
}
- const transcript = buildTranscript(chat.messages as StoredMessage[])
+ const storedMessages = await db
+ .select({
+ id: schema.messages.id,
+ role: schema.messages.role,
+ parts: schema.messages.parts
+ })
+ .from(schema.messages)
+ .where(eq(schema.messages.chatId, chat.id))
+ .orderBy(asc(schema.messages.createdAt))
+
+ const transcript = buildTranscript(storedMessages as StoredMessage[])
const description = buildIssueBody({ summary, userFeedback, transcript, chatId, createdAt: chat.createdAt })
const mutation = `
@@ -123,8 +138,10 @@ export default defineEventHandler(async (event) => {
const issue = response.data?.issueCreate?.issue
if (!issue?.url) {
- throw createError({ statusCode: 500, statusMessage: 'Failed to create Linear issue' })
+ throw createError({ message: 'Failed to create Linear issue', status: 500, why: 'The Linear API did not return an issue URL.' })
}
+ log.set({ linear: { issueUrl: issue.url } })
+
return { url: issue.url }
})
diff --git a/server/api/agent/usage.get.ts b/layers/nuxi/server/api/agent/usage.get.ts
similarity index 100%
rename from server/api/agent/usage.get.ts
rename to layers/nuxi/server/api/agent/usage.get.ts
diff --git a/layers/nuxi/server/api/chats/[id].delete.ts b/layers/nuxi/server/api/chats/[id].delete.ts
new file mode 100644
index 000000000..1cfd6f498
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id].delete.ts
@@ -0,0 +1,31 @@
+import { createError } from 'evlog'
+import { and, eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ chat: { id }
+ })
+
+ const deleted = await db.delete(schema.chats)
+ .where(and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, ownerId)
+ ))
+ .returning()
+
+ if (!deleted.length) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ return deleted
+})
diff --git a/layers/nuxi/server/api/chats/[id].get.ts b/layers/nuxi/server/api/chats/[id].get.ts
new file mode 100644
index 000000000..2267781b7
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id].get.ts
@@ -0,0 +1,54 @@
+import { createError } from 'evlog'
+import { asc, eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const viewerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: viewerId, authenticated: !!session.user },
+ chat: { id }
+ })
+
+ const chat = await db.query.chats.findFirst({
+ where: () => eq(schema.chats.id, id),
+ with: {
+ messages: {
+ orderBy: () => [asc(schema.messages.createdAt), asc(schema.messages.id)]
+ }
+ }
+ })
+
+ if (!chat) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ const isOwner = chat.userId === viewerId
+ const isAdmin = session.user?.role === 'admin'
+
+ const canView = isOwner
+ || chat.visibility === 'public'
+ || (chat.visibility === 'admin' && isAdmin)
+
+ log.set({
+ chat: {
+ id,
+ visibility: chat.visibility,
+ isOwner,
+ access: isOwner ? 'owner' : (chat.visibility === 'public' ? 'public' : (isAdmin ? 'admin' : 'denied'))
+ }
+ })
+
+ if (!canView) {
+ throw createError({ message: 'Chat not found', status: 404, why: 'Viewer is not the owner and the chat is not public.' })
+ }
+
+ const { userId: _, ...rest } = chat
+ return { ...rest, isOwner }
+})
diff --git a/layers/nuxi/server/api/chats/[id].post.ts b/layers/nuxi/server/api/chats/[id].post.ts
new file mode 100644
index 000000000..a37451b9a
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id].post.ts
@@ -0,0 +1,283 @@
+import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, safeValidateUIMessages, generateText } from 'ai'
+import type { ToolSet, UIMessage } from 'ai'
+import { createMCPClient } from '@ai-sdk/mcp'
+import { anthropic } from '@ai-sdk/anthropic'
+import { createAILogger, createEvlogIntegration } from 'evlog/ai'
+import { createError } from 'evlog'
+import { and, eq, sql } from 'drizzle-orm'
+import { z } from 'zod'
+
+const MCP_PATH = '/mcp'
+const MODEL = 'anthropic/claude-sonnet-4.6'
+const TITLE_MODEL = 'openai/gpt-4.1-nano'
+const MAX_STEPS = 10
+
+function stopWhenResponseComplete({ steps }: { steps: { text?: string, toolCalls?: unknown[] }[] }): boolean {
+ const lastStep = steps.at(-1)
+ if (!lastStep) return false
+ const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0)
+ const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0
+ if (hasText && hasNoToolCalls) return true
+ return steps.length >= MAX_STEPS
+}
+
+const baseSystemPrompt = `You are **Nuxi**, Nuxt's companion on nuxt.com. You help developers navigate the official documentation, blog, modules catalog, templates, and the wider Nuxt ecosystem.
+
+**Identity:** You are Nuxi β a companion, not a generic chatbot. Your name comes from the CLI (\`nuxi dev\`, \`nuxi build\`, \`nuxi init\`); your face is the one already in the Nuxt logo. The attitude follows the framework: helpful without being verbose, honest when you don't know, with enough character that talking to you doesn't feel like filing a support ticket. If you don't know something, say so and go look. When you do know, be brief. A light touch is fine when it fits β don't force it.
+
+**Opinions:** You're on nuxt.com β be a fan. When someone asks whether Nuxt is the best framework, or how it stacks up against Next, Remix, SvelteKit, etc., take Nuxt's side playfully instead of reaching for the "well, it depends on your team, your stack, what you like" LLM hedge. A short, confident, slightly cheeky answer beats a balanced essay β own the bias, wink at it, move on. Real trade-offs are fine when the user clearly wants depth, but lead with personality, not disclaimers. Never trash other frameworks β the joke is that you're rooting for the home team, not that the others are bad.
+
+**Current page context:** When the request includes a "Current page" line at the top of this prompt, that's the page the user has open in the browser. Treat it as a strong hint about what they're asking about, especially for vague questions like "explain this", "summarize", "tldr", "what does this do?". Map the path to the right tool:
+- \`/docs/β¦\` β \`get-documentation-page\` with that exact path
+- \`/blog/β¦\` β \`get-blog-post\` with that exact path
+- \`/deploy/β¦\` β \`get-deploy-provider\` with that exact path
+- \`/modules/\` β \`show_module\` with that slug
+- \`/changelog/β¦\` β use the GitHub changelog tools
+Do NOT call \`list-*\` first when the page is given β call the get tool directly. If the question is unrelated to the current page, ignore it and answer normally.
+
+**Modules:** Never invent npm package names. Use \`show_module\` to display modules (it includes all needed info β do NOT also call \`get-module\` for the same module). NuxtHub's module is \`@nuxthub/core\`, not \`@nuxt/hub\`.
+
+**Efficiency:**
+- For \`get-documentation-page\`: pass the \`sections\` parameter with the relevant h2 titles when you only need part of a long page. Omit it when the user wants an overview/tldr/summary of the whole page.
+- For \`get-blog-post\` and \`get-deploy-provider\`: do NOT use sections β these pages are short, fetch them once in full.
+- **Never call the same tool twice with the same path** in a single turn. If the first call returned content, work with it β do not refetch.
+- If you already know the doc path, call \`get-documentation-page\` directly β skip \`list-documentation-pages\`.
+- Prefer \`show_module\` over \`get-module\` (smaller response, richer UI).
+
+**Debugging / error questions:**
+- When the user shares an error message or stack trace, use \`search_github_issues\` first β it searches across nuxt, nuxt-modules, and nuxt-content orgs.
+- If a matching closed issue exists, link to it and summarize the fix/workaround.
+- If open, link to the issue and mention any workarounds from the body.
+- Only fall back to \`web_search\` if no relevant GitHub Issue is found.
+
+**Tools:**
+- \`list-documentation-pages\` β discover pages by topic (use before \`get-documentation-page\` if path unknown)
+- \`get-documentation-page\` β read a doc page. Pass \`sections\` with the relevant h2 titles for partial reads; omit for full-page overviews.
+- \`get-blog-post\` β read a blog post (full content, no sections).
+- \`get-deploy-provider\` β read a deploy provider page (full content, no sections).
+- \`search_github_issues\` β search GitHub Issues across the Nuxt ecosystem. Use for errors, bugs, and debugging questions.
+- \`show_module\` β display a module card (preferred for module questions)
+- \`show_template\` β display template cards (accepts array of slugs). For vague requests, show official templates first: nuxt-ui-dashboard, nuxt-ui-saas, nuxt-ui-landing, nuxt-ui-chat, nuxt-ui-docs, nuxt-ui-portfolio
+- \`show_blog_post\` β display a blog post card
+- \`show_hosting\` β display a hosting provider card
+- \`open_playground\` β generate a StackBlitz link
+- \`report_issue\` β call when you cannot resolve the user's question after exhausting all available tools, or when the user expresses frustration. Provide a short title and 1-3 sentence summary of what was tried and why it failed
+- ALWAYS respond with text after tool calls β never end with just tool calls
+
+**Web search:** Only use when the user **explicitly** asks about recent events or real-time data beyond the Nuxt docs, or if \`search_github_issues\` returned no results. Never search proactively.
+
+**Web search queries:** Match the user's wording. **Do not** tack on calendar years (e.g. "2024", "2025") unless they asked for a specific year or time range β that often **hurts** relevance and looks wrong when the current year has moved on. The search already returns current pages. For stable facts (team pages, about pages), use neutral queries without a year.
+
+**Formatting:**
+- NEVER use markdown headings (#, ##, ###)
+- Use **bold** for emphasis, bullet points for lists
+- Prefer **root-relative** markdown links for nuxt.com pages (\`/docs/...\`, \`/blog/...\`, \`/modules/...\`) so navigation works on localhost and preview deployments. Full \`https://nuxt.com/...\` URLs from tool results are acceptable if shorter to reuse as-is. Use full URLs for external sites (GitHub, Stack Overflow, etc.).
+- Stay concise. Actionable over exhaustive.`
+
+const PAGE_PATH_PATTERN = /^\/[\w./-]*$/
+
+function buildSystemPrompt(pagePath: string | null): string {
+ const today = new Date()
+ const dateLine = `**Today's date:** ${today.toLocaleDateString('en-US', { timeZone: 'UTC' })} (UTC). Use it for recency β do not assume an older year when formulating web searches or answers.`
+ const withDate = `${dateLine}\n\n${baseSystemPrompt}`
+ if (!pagePath) return withDate
+ return `Current page: ${pagePath}\n\n${withDate}`
+}
+
+defineRouteMeta({
+ openAPI: {
+ description: 'Chat with Nuxi.',
+ tags: ['ai']
+ }
+})
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const isLoggedIn = !!session.user
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const raw = await readBody(event) as { messages?: unknown } | null
+ if (!raw || !Array.isArray(raw.messages)) {
+ throw createError({ message: 'Invalid request body', status: 400, why: 'Expected a JSON body with a `messages` array.' })
+ }
+ const validated = await safeValidateUIMessages({ messages: raw.messages })
+ if (validated.success === false) {
+ throw createError({ message: validated.error.message || 'Invalid messages', status: 400 })
+ }
+ const messages = validated.data
+
+ await consumeAgentRateLimit(event)
+
+ const isFirstMessage = messages.length === 1 && messages[0]?.role === 'user'
+ const statsUserId = session.user?.id ?? null
+
+ const chat = isLoggedIn
+ ? await db.query.chats.findFirst({
+ where: () => and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, session.user!.id)
+ )
+ })
+ : null
+
+ if (isLoggedIn && !chat) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ const rawPagePath = getHeader(event, 'x-page-path')?.trim() ?? null
+ const pagePath = rawPagePath && PAGE_PATH_PATTERN.test(rawPagePath) && rawPagePath.length <= 256
+ ? rawPagePath
+ : null
+
+ if (isLoggedIn && chat) {
+ const lastMessage = messages[messages.length - 1]
+ if (lastMessage?.role === 'user' && messages.length > 1) {
+ await db.insert(schema.messages).values({
+ id: lastMessage.id,
+ chatId: id,
+ role: 'user',
+ parts: lastMessage.parts
+ }).onConflictDoUpdate({ target: schema.messages.id, set: { parts: lastMessage.parts } })
+ }
+
+ if (!chat.title) {
+ try {
+ const { text: title } = await generateText({
+ model: TITLE_MODEL,
+ maxOutputTokens: 30,
+ system: `You generate short titles (2-5 words, max 40 characters) for conversations between a developer and Nuxi, the assistant on nuxt.com. Output ONLY the title β no greeting, no sentence, no quotes, no punctuation, no markdown. Do NOT respond to the message.`,
+ prompt: JSON.stringify(messages[0])
+ })
+ const cleaned = title.trim().replace(/^["'`]+|["'`]+$/g, '').slice(0, 80)
+ if (cleaned) {
+ await db.update(schema.chats).set({ title: cleaned }).where(eq(schema.chats.id, id))
+ }
+ } catch {
+ // ignore title generation failures
+ }
+ }
+ }
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: session.user?.id || session.id, authenticated: isLoggedIn },
+ chat: { id, hasTitle: !!chat?.title, persisted: isLoggedIn },
+ ...(pagePath ? { page: { path: pagePath } } : {})
+ })
+ const ai = createAILogger(log, {
+ toolInputs: true,
+ cost: { 'claude-sonnet-4-6': { input: 3, output: 15 } }
+ })
+
+ const abortController = new AbortController()
+ event.node.req.on('close', () => abortController.abort())
+
+ const httpClient = await createMCPClient({
+ transport: { type: 'http', url: `${getRequestURL(event).origin}${MCP_PATH}` }
+ })
+ const closeMcp = () => event.waitUntil(httpClient.close())
+
+ let mcpTools: ToolSet
+ try {
+ mcpTools = await httpClient.tools() as ToolSet
+ } catch (err) {
+ closeMcp()
+ throw err
+ }
+
+ let didError = false
+
+ const stream = createUIMessageStream({
+ execute: async ({ writer }) => {
+ // Guard the sync setup path β otherwise a throw would leak the MCP client.
+ try {
+ if (chat && !chat.title) {
+ writer.write({
+ type: 'data-chat-title',
+ data: { message: 'Generating title...' },
+ transient: true
+ })
+ }
+
+ const result = streamText({
+ model: ai.wrap(MODEL),
+ maxOutputTokens: 4000,
+ maxRetries: 2,
+ abortSignal: abortController.signal,
+ stopWhen: stopWhenResponseComplete,
+ system: buildSystemPrompt(pagePath),
+ messages: await convertToModelMessages(messages),
+ tools: {
+ ...mcpTools,
+ web_search: anthropic.tools.webSearch_20250305(),
+ search_github_issues: createSearchGitHubIssuesTool(event),
+ show_module: showModuleTool,
+ show_template: createShowTemplateTool(event),
+ show_blog_post: createShowBlogPostTool(event),
+ show_hosting: createShowHostingTool(event),
+ open_playground: openPlaygroundTool,
+ report_issue: reportIssueTool
+ },
+ experimental_telemetry: {
+ isEnabled: true,
+ integrations: [createEvlogIntegration(ai)]
+ },
+ onFinish: closeMcp,
+ onAbort: closeMcp,
+ onError: () => {
+ didError = true
+ closeMcp()
+ }
+ })
+
+ writer.merge(result.toUIMessageStream({
+ sendSources: true,
+ originalMessages: messages
+ }))
+ } catch (err) {
+ didError = true
+ closeMcp()
+ throw err
+ }
+ },
+ onFinish: async ({ messages }) => {
+ const metadata = ai.getMetadata()
+ await bumpAgentStats({
+ userId: statsUserId,
+ isFirstMessage,
+ provider: metadata.provider,
+ model: metadata.model,
+ inputTokens: metadata.inputTokens,
+ outputTokens: metadata.outputTokens,
+ estimatedCost: metadata.estimatedCost,
+ durationMs: metadata.totalDurationMs,
+ isError: didError
+ })
+
+ if (!isLoggedIn) return
+
+ await db.insert(schema.messages).values(messages.map(m => ({
+ id: m.id,
+ chatId: id,
+ role: m.role as 'user' | 'assistant',
+ parts: m.parts as UIMessage['parts']
+ }))).onConflictDoNothing()
+
+ await db.update(schema.chats).set({
+ updatedAt: new Date(),
+ model: metadata.model ?? null,
+ provider: metadata.provider ?? null,
+ inputTokens: sql`${schema.chats.inputTokens} + ${metadata.inputTokens ?? 0}`,
+ outputTokens: sql`${schema.chats.outputTokens} + ${metadata.outputTokens ?? 0}`,
+ estimatedCost: sql`${schema.chats.estimatedCost} + ${metadata.estimatedCost ?? 0}`,
+ durationMs: sql`${schema.chats.durationMs} + ${metadata.totalDurationMs ?? 0}`,
+ requestCount: sql`${schema.chats.requestCount} + 1`
+ }).where(eq(schema.chats.id, id))
+ }
+ })
+
+ return createUIMessageStreamResponse({ stream })
+})
diff --git a/layers/nuxi/server/api/chats/[id]/branch.post.ts b/layers/nuxi/server/api/chats/[id]/branch.post.ts
new file mode 100644
index 000000000..c07b0b904
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id]/branch.post.ts
@@ -0,0 +1,77 @@
+import { createError } from 'evlog'
+import { eq, asc } from 'drizzle-orm'
+import type { ExtractTablesWithRelations, InferSelectModel } from 'drizzle-orm'
+import type { LibSQLTransaction } from 'drizzle-orm/libsql'
+import { z } from 'zod'
+
+type Tx = LibSQLTransaction>
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const { messageId } = await readValidatedBody(event, z.object({
+ messageId: z.string().min(1)
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ branch: { sourceChatId: id, messageId }
+ })
+
+ const chat = await db.query.chats.findFirst({
+ where: () => eq(schema.chats.id, id),
+ with: {
+ messages: {
+ orderBy: () => asc(schema.messages.createdAt)
+ }
+ }
+ })
+
+ if (!chat) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+ if (chat.userId !== ownerId) {
+ throw createError({ message: 'Chat not found', status: 404, why: 'Branching is only allowed on chats you own.' })
+ }
+
+ type MessageRow = InferSelectModel
+
+ const cutIndex = chat.messages.findIndex((m: MessageRow) => m.id === messageId)
+ if (cutIndex === -1) {
+ throw createError({ message: 'Message not found in chat', status: 404, why: 'messageId does not exist in this chat.' })
+ }
+ const messagesToCopy = chat.messages.slice(0, cutIndex + 1)
+
+ const newChatId = crypto.randomUUID()
+
+ await db.transaction(async (tx: Tx) => {
+ await tx.insert(schema.chats).values({
+ id: newChatId,
+ userId: chat.userId,
+ title: chat.title ? `Branch of ${chat.title}` : null,
+ visibility: 'private'
+ })
+
+ if (messagesToCopy.length) {
+ await tx.insert(schema.messages).values(
+ messagesToCopy.map((m: MessageRow) => ({
+ id: crypto.randomUUID(),
+ chatId: newChatId,
+ role: m.role,
+ parts: m.parts as unknown[],
+ createdAt: m.createdAt
+ }))
+ )
+ }
+ })
+
+ log.set({ branch: { newChatId, copiedMessages: messagesToCopy.length } })
+
+ return { id: newChatId }
+})
diff --git a/layers/nuxi/server/api/chats/[id]/messages.delete.ts b/layers/nuxi/server/api/chats/[id]/messages.delete.ts
new file mode 100644
index 000000000..e6477f296
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id]/messages.delete.ts
@@ -0,0 +1,64 @@
+import { createError } from 'evlog'
+import { and, asc, eq, inArray } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const { messageId, type } = await readValidatedBody(event, z.object({
+ messageId: z.string(),
+ type: z.enum(['edit', 'regenerate'])
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ chat: { id },
+ truncate: { messageId, type }
+ })
+
+ const chat = await db.query.chats.findFirst({
+ where: () => and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, ownerId)
+ )
+ })
+
+ if (!chat) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ const allMessages = await db.select({ id: schema.messages.id, role: schema.messages.role })
+ .from(schema.messages)
+ .where(eq(schema.messages.chatId, id))
+ .orderBy(asc(schema.messages.createdAt), asc(schema.messages.id))
+
+ const targetIndex = allMessages.findIndex((m: { id: string }) => m.id === messageId)
+ if (targetIndex === -1) {
+ throw createError({ message: 'Message not found', status: 404 })
+ }
+
+ const targetRole = allMessages[targetIndex]!.role
+ if (type === 'edit' && targetRole !== 'user') {
+ throw createError({ message: 'Can only edit user messages', status: 400, why: `Target message role is "${targetRole}".` })
+ }
+ if (type === 'regenerate' && targetRole !== 'assistant') {
+ throw createError({ message: 'Can only regenerate assistant messages', status: 400, why: `Target message role is "${targetRole}".` })
+ }
+
+ const startIndex = type === 'edit' ? targetIndex + 1 : targetIndex
+ const idsToDelete = allMessages.slice(startIndex).map((m: { id: string }) => m.id)
+
+ log.set({ truncate: { messageId, type, deleted: idsToDelete.length } })
+
+ if (idsToDelete.length > 0) {
+ await db.delete(schema.messages).where(inArray(schema.messages.id, idsToDelete))
+ }
+
+ return { success: true }
+})
diff --git a/layers/nuxi/server/api/chats/[id]/title.patch.ts b/layers/nuxi/server/api/chats/[id]/title.patch.ts
new file mode 100644
index 000000000..fa3ce9e2e
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id]/title.patch.ts
@@ -0,0 +1,36 @@
+import { createError } from 'evlog'
+import { and, eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const { title } = await readValidatedBody(event, z.object({
+ title: z.string().trim().min(1).max(100)
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ chat: { id, titleLength: title.length }
+ })
+
+ const [updated] = await db.update(schema.chats)
+ .set({ title })
+ .where(and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, ownerId)
+ ))
+ .returning()
+
+ if (!updated) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ return updated
+})
diff --git a/layers/nuxi/server/api/chats/[id]/visibility.patch.ts b/layers/nuxi/server/api/chats/[id]/visibility.patch.ts
new file mode 100644
index 000000000..56e2a38e0
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id]/visibility.patch.ts
@@ -0,0 +1,39 @@
+import { createError } from 'evlog'
+import { and, eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ // `admin` is a user-initiated opt-in for "share with the Nuxt team for
+ // debugging" (see ChatVisibility.vue). Any owner can set it on their own
+ // chat β it does not grant the chat owner admin access anywhere else.
+ const { visibility } = await readValidatedBody(event, z.object({
+ visibility: z.enum(['public', 'private', 'admin'])
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ chat: { id, visibility }
+ })
+
+ const [updated] = await db.update(schema.chats)
+ .set({ visibility })
+ .where(and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, ownerId)
+ ))
+ .returning()
+
+ if (!updated) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ return updated
+})
diff --git a/layers/nuxi/server/api/chats/[id]/votes.get.ts b/layers/nuxi/server/api/chats/[id]/votes.get.ts
new file mode 100644
index 000000000..18a62f85b
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id]/votes.get.ts
@@ -0,0 +1,31 @@
+import { createError } from 'evlog'
+import { and, eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ chat: { id }
+ })
+
+ const chat = await db.query.chats.findFirst({
+ where: () => and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, ownerId)
+ )
+ })
+
+ if (!chat) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ return await db.select().from(schema.votes).where(eq(schema.votes.chatId, id))
+})
diff --git a/layers/nuxi/server/api/chats/[id]/votes.post.ts b/layers/nuxi/server/api/chats/[id]/votes.post.ts
new file mode 100644
index 000000000..918130b5d
--- /dev/null
+++ b/layers/nuxi/server/api/chats/[id]/votes.post.ts
@@ -0,0 +1,66 @@
+import { createError } from 'evlog'
+import { and, eq } from 'drizzle-orm'
+import { z } from 'zod'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const { id } = await getValidatedRouterParams(event, z.object({
+ id: z.uuid()
+ }).parse)
+
+ const { messageId, isUpvoted } = await readValidatedBody(event, z.object({
+ messageId: z.string().min(1),
+ isUpvoted: z.boolean().optional()
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: ownerId, authenticated: !!session.user },
+ vote: { chatId: id, messageId, isUpvoted: isUpvoted ?? null }
+ })
+
+ const chat = await db.query.chats.findFirst({
+ where: () => and(
+ eq(schema.chats.id, id),
+ eq(schema.chats.userId, ownerId)
+ )
+ })
+
+ if (!chat) {
+ throw createError({ message: 'Chat not found', status: 404 })
+ }
+
+ const message = await db.query.messages.findFirst({
+ where: () => and(
+ eq(schema.messages.id, messageId),
+ eq(schema.messages.chatId, id)
+ )
+ })
+
+ if (!message) {
+ throw createError({ message: 'Message not found', status: 404 })
+ }
+ if (message.role !== 'assistant') {
+ throw createError({ message: 'Cannot vote on this message', status: 400, why: 'Votes are only allowed on assistant messages.' })
+ }
+
+ if (isUpvoted === undefined) {
+ await db.delete(schema.votes).where(and(
+ eq(schema.votes.chatId, id),
+ eq(schema.votes.messageId, messageId)
+ ))
+ } else {
+ await db.insert(schema.votes).values({
+ chatId: id,
+ messageId,
+ isUpvoted
+ }).onConflictDoUpdate({
+ target: [schema.votes.chatId, schema.votes.messageId],
+ set: { isUpvoted }
+ })
+ }
+
+ return { chatId: id, messageId, isUpvoted }
+})
diff --git a/layers/nuxi/server/api/chats/index.get.ts b/layers/nuxi/server/api/chats/index.get.ts
new file mode 100644
index 000000000..d296292ad
--- /dev/null
+++ b/layers/nuxi/server/api/chats/index.get.ts
@@ -0,0 +1,20 @@
+import { eq, sql } from 'drizzle-orm'
+
+export default defineEventHandler(async (event) => {
+ const session = await getUserSession(event)
+ const ownerId = session.user?.id || session.id
+
+ const log = useLogger(event)
+ log.set({ user: { id: ownerId, authenticated: !!session.user } })
+
+ const chats = await db.query.chats.findMany({
+ where: () => eq(schema.chats.userId, ownerId),
+ orderBy: () => [
+ sql`coalesce(${schema.chats.updatedAt}, ${schema.chats.createdAt}) desc`
+ ]
+ })
+
+ log.set({ chats: { count: chats.length } })
+
+ return chats
+})
diff --git a/layers/nuxi/server/api/chats/index.post.ts b/layers/nuxi/server/api/chats/index.post.ts
new file mode 100644
index 000000000..3c234eb88
--- /dev/null
+++ b/layers/nuxi/server/api/chats/index.post.ts
@@ -0,0 +1,48 @@
+import { createError } from 'evlog'
+import type { ExtractTablesWithRelations } from 'drizzle-orm'
+import type { LibSQLTransaction } from 'drizzle-orm/libsql'
+import { z } from 'zod'
+
+type Tx = LibSQLTransaction>
+
+export default defineEventHandler(async (event) => {
+ const { user } = await requireUserSession(event)
+
+ const { id, message } = await readValidatedBody(event, z.object({
+ id: z.uuid(),
+ message: z.object({
+ id: z.string(),
+ role: z.literal('user'),
+ parts: z.array(z.object({ type: z.string() }).loose())
+ })
+ }).parse)
+
+ const log = useLogger(event)
+ log.set({
+ user: { id: user.id, authenticated: true },
+ chat: { id }
+ })
+
+ const chat = await db.transaction(async (tx: Tx) => {
+ const [row] = await tx.insert(schema.chats).values({
+ id,
+ title: null,
+ userId: user.id
+ }).returning()
+
+ if (!row) {
+ throw createError({ message: 'Failed to create chat', status: 500, why: 'Insert returned no row.' })
+ }
+
+ await tx.insert(schema.messages).values({
+ id: message.id,
+ chatId: row.id,
+ role: 'user',
+ parts: message.parts
+ })
+
+ return row
+ })
+
+ return chat
+})
diff --git a/layers/nuxi/server/db/schema.ts b/layers/nuxi/server/db/schema.ts
new file mode 100644
index 000000000..ca0055423
--- /dev/null
+++ b/layers/nuxi/server/db/schema.ts
@@ -0,0 +1,90 @@
+import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'
+import { sqliteTable, text, integer, real, index, primaryKey } from 'drizzle-orm/sqlite-core'
+import { relations } from 'drizzle-orm'
+import { users } from '../../../../server/db/schema'
+
+const timestamps = {
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date())
+}
+
+export const chats = sqliteTable('chats', {
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+ title: text('title'),
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ visibility: text('visibility', { enum: ['public', 'private', 'admin'] }).notNull().default('private'),
+ parentChatId: text('parent_chat_id').references((): AnySQLiteColumn => chats.id, { onDelete: 'set null' }),
+ metadata: text('metadata', { mode: 'json' }).$type>(),
+ model: text('model'),
+ provider: text('provider'),
+ inputTokens: integer('input_tokens').notNull().default(0),
+ outputTokens: integer('output_tokens').notNull().default(0),
+ estimatedCost: real('estimated_cost').notNull().default(0),
+ durationMs: integer('duration_ms').notNull().default(0),
+ requestCount: integer('request_count').notNull().default(0),
+ updatedAt: integer('updated_at', { mode: 'timestamp' }),
+ ...timestamps
+}, table => [
+ index('chats_user_id_idx').on(table.userId),
+ index('chats_parent_chat_id_idx').on(table.parentChatId)
+])
+
+export const chatsRelations = relations(chats, ({ many, one }) => ({
+ messages: many(messages),
+ votes: many(votes),
+ parent: one(chats, { fields: [chats.parentChatId], references: [chats.id], relationName: 'chat_branches' }),
+ branches: many(chats, { relationName: 'chat_branches' })
+}))
+
+export const messages = sqliteTable('messages', {
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+ chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
+ role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
+ parts: text('parts', { mode: 'json' }),
+ model: text('model'),
+ provider: text('provider'),
+ metadata: text('metadata', { mode: 'json' }).$type>(),
+ ...timestamps
+}, table => [index('messages_chat_id_idx').on(table.chatId)])
+
+export const messagesRelations = relations(messages, ({ one }) => ({
+ chat: one(chats, { fields: [messages.chatId], references: [chats.id] })
+}))
+
+export const votes = sqliteTable('votes', {
+ chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
+ messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
+ isUpvoted: integer('is_upvoted', { mode: 'boolean' }).notNull(),
+ createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())
+}, table => [primaryKey({ columns: [table.chatId, table.messageId] })])
+
+export const votesRelations = relations(votes, ({ one }) => ({
+ chat: one(chats, { fields: [votes.chatId], references: [chats.id] }),
+ message: one(messages, { fields: [votes.messageId], references: [messages.id] })
+}))
+
+export const agentDailyUsage = sqliteTable('agent_daily_usage', {
+ userId: text('user_id').notNull(),
+ dayKey: text('day_key').notNull(),
+ count: integer('count').notNull().default(0),
+ limitOverride: integer('limit_override')
+}, table => [
+ primaryKey({ columns: [table.userId, table.dayKey] }),
+ index('agent_daily_usage_day_key_idx').on(table.dayKey)
+])
+
+export const agentStats = sqliteTable('agent_stats', {
+ dayKey: text('day_key').notNull(),
+ userId: text('user_id').notNull(),
+ provider: text('provider').notNull(),
+ model: text('model').notNull(),
+ chatsStarted: integer('chats_started').notNull().default(0),
+ requestCount: integer('request_count').notNull().default(0),
+ errorCount: integer('error_count').notNull().default(0),
+ inputTokens: integer('input_tokens').notNull().default(0),
+ outputTokens: integer('output_tokens').notNull().default(0),
+ estimatedCost: real('estimated_cost').notNull().default(0),
+ durationMs: integer('duration_ms').notNull().default(0)
+}, table => [
+ primaryKey({ columns: [table.dayKey, table.userId, table.provider, table.model] }),
+ index('agent_stats_day_key_idx').on(table.dayKey)
+])
diff --git a/layers/nuxi/server/utils/rate-limit.ts b/layers/nuxi/server/utils/rate-limit.ts
new file mode 100644
index 000000000..cdf8e3a94
--- /dev/null
+++ b/layers/nuxi/server/utils/rate-limit.ts
@@ -0,0 +1,50 @@
+import type { H3Event } from 'h3'
+import { and, eq, sql } from 'drizzle-orm'
+
+type DbTransaction = Parameters[0]>[0]
+
+const DEFAULT_DAILY_LIMIT = 20
+
+function today(): string {
+ return new Date().toISOString().slice(0, 10)
+}
+
+async function resolveIdentity(event: H3Event): Promise {
+ const session = await getUserSession(event)
+ return session.user?.id || session.id || getRequestIP(event, { xForwardedFor: true }) || 'unknown'
+}
+
+export async function checkAgentRateLimit(event: H3Event): Promise<{ used: number, remaining: number, limit: number }> {
+ const userId = await resolveIdentity(event)
+ const dayKey = today()
+ const [row] = await db.select().from(schema.agentDailyUsage)
+ .where(and(eq(schema.agentDailyUsage.userId, userId), eq(schema.agentDailyUsage.dayKey, dayKey)))
+ .limit(1)
+ const limit = row?.limitOverride ?? DEFAULT_DAILY_LIMIT
+ const used = row?.count ?? 0
+ return { used, remaining: Math.max(0, limit - used), limit }
+}
+
+export async function consumeAgentRateLimit(event: H3Event): Promise<{ used: number, remaining: number, limit: number }> {
+ const userId = await resolveIdentity(event)
+ const dayKey = today()
+
+ return await db.transaction(async (tx: DbTransaction) => {
+ await tx.insert(schema.agentDailyUsage).values({ userId, dayKey, count: 1 })
+ .onConflictDoUpdate({
+ target: [schema.agentDailyUsage.userId, schema.agentDailyUsage.dayKey],
+ set: { count: sql`${schema.agentDailyUsage.count} + 1` }
+ })
+ const [row] = await tx.select().from(schema.agentDailyUsage)
+ .where(and(eq(schema.agentDailyUsage.userId, userId), eq(schema.agentDailyUsage.dayKey, dayKey)))
+ const limit = row?.limitOverride ?? DEFAULT_DAILY_LIMIT
+ const used = row!.count
+ if (used > limit) {
+ throw createError({
+ statusCode: 429,
+ message: `You've reached the daily limit of ${limit} messages. Try again tomorrow.`
+ })
+ }
+ return { used, remaining: limit - used, limit }
+ })
+}
diff --git a/layers/nuxi/server/utils/stats.ts b/layers/nuxi/server/utils/stats.ts
new file mode 100644
index 000000000..d0377dff5
--- /dev/null
+++ b/layers/nuxi/server/utils/stats.ts
@@ -0,0 +1,54 @@
+import { sql } from 'drizzle-orm'
+
+export const ANON_AGENT_STATS_USER_ID = '__anonymous__'
+
+export interface BumpStatsInput {
+ userId: string | null
+ isFirstMessage: boolean
+ provider?: string | null
+ model?: string | null
+ inputTokens?: number
+ outputTokens?: number
+ estimatedCost?: number
+ durationMs?: number
+ isError?: boolean
+}
+
+export async function bumpAgentStats(input: BumpStatsInput): Promise {
+ const dayKey = new Date().toISOString().slice(0, 10)
+ const userId = input.userId ?? ANON_AGENT_STATS_USER_ID
+ const provider = input.provider ?? 'unknown'
+ const model = input.model ?? 'unknown'
+ const chatsStartedDelta = input.isFirstMessage ? 1 : 0
+ const requestDelta = input.isError ? 0 : 1
+ const errorDelta = input.isError ? 1 : 0
+ const inputTokensDelta = input.inputTokens ?? 0
+ const outputTokensDelta = input.outputTokens ?? 0
+ const costDelta = input.estimatedCost ?? 0
+ const durationDelta = input.durationMs ?? 0
+
+ await db.insert(schema.agentStats).values({
+ dayKey,
+ userId,
+ provider,
+ model,
+ chatsStarted: chatsStartedDelta,
+ requestCount: requestDelta,
+ errorCount: errorDelta,
+ inputTokens: inputTokensDelta,
+ outputTokens: outputTokensDelta,
+ estimatedCost: costDelta,
+ durationMs: durationDelta
+ }).onConflictDoUpdate({
+ target: [schema.agentStats.dayKey, schema.agentStats.userId, schema.agentStats.provider, schema.agentStats.model],
+ set: {
+ chatsStarted: sql`${schema.agentStats.chatsStarted} + ${chatsStartedDelta}`,
+ requestCount: sql`${schema.agentStats.requestCount} + ${requestDelta}`,
+ errorCount: sql`${schema.agentStats.errorCount} + ${errorDelta}`,
+ inputTokens: sql`${schema.agentStats.inputTokens} + ${inputTokensDelta}`,
+ outputTokens: sql`${schema.agentStats.outputTokens} + ${outputTokensDelta}`,
+ estimatedCost: sql`${schema.agentStats.estimatedCost} + ${costDelta}`,
+ durationMs: sql`${schema.agentStats.durationMs} + ${durationDelta}`
+ }
+ })
+}
diff --git a/server/utils/tools/open-playground.ts b/layers/nuxi/server/utils/tools/open-playground.ts
similarity index 100%
rename from server/utils/tools/open-playground.ts
rename to layers/nuxi/server/utils/tools/open-playground.ts
diff --git a/server/utils/tools/report-issue.ts b/layers/nuxi/server/utils/tools/report-issue.ts
similarity index 100%
rename from server/utils/tools/report-issue.ts
rename to layers/nuxi/server/utils/tools/report-issue.ts
diff --git a/server/utils/tools/search-github-issues.ts b/layers/nuxi/server/utils/tools/search-github-issues.ts
similarity index 100%
rename from server/utils/tools/search-github-issues.ts
rename to layers/nuxi/server/utils/tools/search-github-issues.ts
diff --git a/server/utils/tools/show-blog-post.ts b/layers/nuxi/server/utils/tools/show-blog-post.ts
similarity index 100%
rename from server/utils/tools/show-blog-post.ts
rename to layers/nuxi/server/utils/tools/show-blog-post.ts
diff --git a/server/utils/tools/show-hosting.ts b/layers/nuxi/server/utils/tools/show-hosting.ts
similarity index 100%
rename from server/utils/tools/show-hosting.ts
rename to layers/nuxi/server/utils/tools/show-hosting.ts
diff --git a/server/utils/tools/show-module.ts b/layers/nuxi/server/utils/tools/show-module.ts
similarity index 100%
rename from server/utils/tools/show-module.ts
rename to layers/nuxi/server/utils/tools/show-module.ts
diff --git a/server/utils/tools/show-template.ts b/layers/nuxi/server/utils/tools/show-template.ts
similarity index 100%
rename from server/utils/tools/show-template.ts
rename to layers/nuxi/server/utils/tools/show-template.ts
diff --git a/app/types/agent.ts b/layers/nuxi/shared/types/agent.ts
similarity index 56%
rename from app/types/agent.ts
rename to layers/nuxi/shared/types/agent.ts
index d97cbd870..9b6d4136d 100644
--- a/app/types/agent.ts
+++ b/layers/nuxi/shared/types/agent.ts
@@ -1,3 +1,5 @@
+export type NuxiMood = 'idle' | 'happy' | 'excited' | 'thinking' | 'sleeping' | 'surprised'
+
export interface FaqCategory {
category: string
items: string[]
diff --git a/layers/nuxi/shared/types/chat.ts b/layers/nuxi/shared/types/chat.ts
new file mode 100644
index 000000000..1f5168ac3
--- /dev/null
+++ b/layers/nuxi/shared/types/chat.ts
@@ -0,0 +1,29 @@
+export interface ChatListItem {
+ id: string
+ title: string | null
+ visibility: 'public' | 'private' | 'admin'
+ createdAt: string
+ updatedAt?: string | null
+}
+
+export interface ChatVoteRow {
+ chatId: string
+ messageId: string
+ isUpvoted: boolean
+}
+
+export interface ChatMessageRow {
+ id: string
+ role: 'user' | 'assistant' | 'system'
+ parts: unknown[]
+ createdAt: string
+}
+
+export interface ChatDetail {
+ id: string
+ title: string | null
+ visibility: 'public' | 'private' | 'admin'
+ isOwner: boolean
+ createdAt: string
+ messages: ChatMessageRow[]
+}
diff --git a/layers/nuxi/shared/types/index.ts b/layers/nuxi/shared/types/index.ts
new file mode 100644
index 000000000..452d9c318
--- /dev/null
+++ b/layers/nuxi/shared/types/index.ts
@@ -0,0 +1,3 @@
+export * from './chat'
+export * from './agent'
+export * from './tools'
diff --git a/layers/nuxi/shared/types/tools.ts b/layers/nuxi/shared/types/tools.ts
new file mode 100644
index 000000000..fba8ea366
--- /dev/null
+++ b/layers/nuxi/shared/types/tools.ts
@@ -0,0 +1,50 @@
+export interface ModuleCardData {
+ name: string
+ npm?: string
+ description?: string
+ icon?: string
+ category?: string
+ repo?: string
+ website?: string
+ downloads?: number
+ stars?: number
+}
+
+export interface TemplateCardData {
+ name: string
+ slug: string
+ description?: string
+ repo?: string
+ demo?: string
+ badge?: string
+ purchase?: string
+}
+
+export interface BlogCardData {
+ title: string
+ description?: string
+ path: string
+ date?: string
+ image?: string
+ category?: string
+ authors?: Array<{ name: string, avatar?: string }>
+}
+
+export interface HostingCardData {
+ title: string
+ description?: string
+ path: string
+ logoSrc?: string
+ logoIcon?: string
+ category?: string
+ nitroPreset?: string
+ website?: string
+}
+
+export interface PlaygroundCardData {
+ url: string
+ repo: string
+ title?: string
+ file?: string
+ dir?: string
+}
diff --git a/nuxt.config.ts b/nuxt.config.ts
index c6dccca47..f487de2ae 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -5,6 +5,8 @@ const { resolve } = createResolver(import.meta.url)
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
+ extends: ['./layers/nuxi'],
+
modules: [
'@nuxt/ui',
'nuxt-content-twoslash',
@@ -26,7 +28,6 @@ export default defineNuxtConfig({
'@nuxt/hints',
'@vercel/analytics',
'@vercel/speed-insights',
- '@comark/nuxt',
'evlog/nuxt'
],
$development: {
@@ -102,7 +103,6 @@ export default defineNuxtConfig({
},
runtimeConfig: {
contactEmail: '',
- cronSecret: '',
mcpAdminToken: '',
adminGithubLogins: '',
github: {
@@ -167,6 +167,10 @@ export default defineNuxtConfig({
// Admin
'/admin': { ssr: false },
'/admin/**': { ssr: false },
+ '/admin/login': { redirect: '/login?redirect=/admin', prerender: false },
+ // Auth-protected client-side area β never SSR'd.
+ '/dashboard': { ssr: false },
+ '/dashboard/**': { ssr: false },
// Main navigation
'/api/navigation.json': { prerender: true },
'/api/search.json': { prerender: true },
@@ -457,7 +461,13 @@ export default defineNuxtConfig({
crawlLinks: true,
ignore: [
route => route === '/modules' || route.startsWith('/modules/'),
- route => route.startsWith('/admin')
+ route => route.startsWith('/admin'),
+ route => route.startsWith('/login'),
+ route => route.startsWith('/dashboard'),
+ '/mcp',
+ route => route.startsWith('/mcp/'),
+ route => route.startsWith('/api/auth/'),
+ route => route.startsWith('/api/chats')
],
autoSubfolderIndex: false
}
@@ -470,14 +480,12 @@ export default defineNuxtConfig({
vite: {
optimizeDeps: {
include: [
+ '@comark/vue',
'@vue/devtools-core',
'@vue/devtools-kit',
'valibot',
- '@comark/vue',
'zod',
- 'date-fns',
- 'ai',
- '@ai-sdk/vue'
+ 'date-fns'
],
exclude: ['vue-chrts', 'shaders']
}
@@ -519,7 +527,11 @@ export default defineNuxtConfig({
env: { service: 'nuxt-com' },
pretty: process.env.CI ? false : undefined,
sampling: {
- rates: { info: 30 }
+ rates: { info: 30 },
+ keep: [
+ { path: '/api/chats/*' },
+ { duration: 2000 }
+ ]
}
},
hints: {
diff --git a/package.json b/package.json
index ee1df56e1..f72b74a63 100644
--- a/package.json
+++ b/package.json
@@ -19,8 +19,8 @@
"test:browser:update": "docker run --rm --network host -v $(pwd):/work/ -v /tmp/playwright-node-modules:/work/node_modules -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-noble bash -c 'corepack enable && pnpm i && pnpm playwright test test/browser --update-snapshots'",
"eval": "evalite",
"eval:ui": "evalite serve",
- "db:generate": "nuxt hub database generate",
- "db:migrate": "nuxt hub database migrate"
+ "db:generate": "nuxt db generate",
+ "db:migrate": "nuxt db migrate"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.78",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 540d843ed..3348188e0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2786,8 +2786,8 @@ packages:
resolution: {integrity: sha512-da+MMyeXhBaKtxQiWPfy7+056wk3lVIhioJnXHXkJ2/OHDaZfFcyKHNl1R06sdYO8lIRXcXdoZ6LO2ARmkAREA==}
engines: {node: '>=18.16.0'}
- '@puppeteer/browsers@2.13.1':
- resolution: {integrity: sha512-zmS4RTK9fbrc++WlAJhxYbfz3IjDeOmkK/CwwbLmk7ydfS9e2CiEeRJHEPvjDVElO/bwXbidwGA37Bsm6LzCnQ==}
+ '@puppeteer/browsers@2.13.2':
+ resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==}
engines: {node: '>=18'}
hasBin: true
@@ -4610,8 +4610,8 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
- ansis@4.2.0:
- resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
+ ansis@4.3.0:
+ resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==}
engines: {node: '>=14'}
anymatch@3.1.3:
@@ -8348,12 +8348,12 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- puppeteer-core@24.43.0:
- resolution: {integrity: sha512-cCRNXsUlhyPoKDz6+TiSpfZpRS3mD6Y1YFKhkdr6ik6TMfuJb7fAtXq9ThUFc4sphxObDk3BuAvdxc1Y6YOnqQ==}
+ puppeteer-core@24.43.1:
+ resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==}
engines: {node: '>=18'}
- puppeteer@24.43.0:
- resolution: {integrity: sha512-DRnMFz+J3s4lFUQcjqKl0/7h0jzlCZuUFU9lNjtKrnMl5WI1RwCaIItpHVu9empuPyUreYueN0sUW3/pnfdqsg==}
+ puppeteer@24.43.1:
+ resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==}
engines: {node: '>=18'}
hasBin: true
@@ -10743,7 +10743,7 @@ snapshots:
'@eslint/config-inspector@1.5.0(eslint@10.4.0(jiti@2.7.0))':
dependencies:
- ansis: 4.2.0
+ ansis: 4.3.0
bundle-require: 5.1.0(esbuild@0.27.7)
cac: 7.0.0
chokidar: 5.0.0
@@ -10850,11 +10850,11 @@ snapshots:
'@ghostery/adblocker-extended-selectors@2.17.0': {}
- '@ghostery/adblocker-puppeteer@2.17.0(puppeteer@24.43.0(typescript@6.0.3))':
+ '@ghostery/adblocker-puppeteer@2.17.0(puppeteer@24.43.1(typescript@6.0.3))':
dependencies:
'@ghostery/adblocker': 2.17.0
'@ghostery/adblocker-content': 2.17.0
- puppeteer: 24.43.0(typescript@6.0.3)
+ puppeteer: 24.43.1(typescript@6.0.3)
tldts-experimental: 7.0.30
'@ghostery/adblocker@2.17.0':
@@ -11255,7 +11255,7 @@ snapshots:
fzf: 0.5.2
giget: 3.2.0
jiti: 2.7.0
- listhen: 1.10.0(srvx@0.11.15)
+ listhen: 1.10.0
nypm: 0.6.6
ofetch: 1.5.1
ohash: 2.0.11
@@ -11581,7 +11581,6 @@ snapshots:
- react-native-b4a
- rolldown
- sqlite3
- - srvx
- supports-color
- typescript
- uploadthing
@@ -11624,7 +11623,7 @@ snapshots:
std-env: 3.10.0
ufo: 1.6.4
optionalDependencies:
- ipx: 3.1.1(@vercel/functions@3.6.0)(db0@0.3.4(@libsql/client@0.17.3)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.3)(@opentelemetry/api@1.9.0)(better-sqlite3@12.10.0)))(ioredis@5.10.1)(srvx@0.11.15)
+ ipx: 3.1.1(@vercel/functions@3.6.0)(db0@0.3.4(@libsql/client@0.17.3)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.3)(@opentelemetry/api@1.9.0)(better-sqlite3@12.10.0)))(ioredis@5.10.1)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -11645,7 +11644,6 @@ snapshots:
- idb-keyval
- ioredis
- magicast
- - srvx
- uploadthing
'@nuxt/kit@3.21.5(magicast@0.5.3)':
@@ -11765,7 +11763,6 @@ snapshots:
- react-native-b4a
- rolldown
- sqlite3
- - srvx
- supports-color
- typescript
- uploadthing
@@ -11853,7 +11850,7 @@ snapshots:
fake-indexeddb: 6.2.5
get-port-please: 3.2.0
h3: 1.15.11
- h3-next: h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15))
+ h3-next: h3@2.0.1-rc.20
local-pkg: 1.1.2
magic-string: 0.30.21
node-fetch-native: 1.6.7
@@ -12668,7 +12665,7 @@ snapshots:
safe-stable-stringify: 2.5.0
secure-json-parse: 4.1.0
- '@puppeteer/browsers@2.13.1':
+ '@puppeteer/browsers@2.13.2':
dependencies:
debug: 4.4.3
extract-zip: 2.0.1
@@ -13722,7 +13719,7 @@ snapshots:
'@types/sax@1.2.7':
dependencies:
- '@types/node': 25.6.2
+ '@types/node': 24.12.3
'@types/semver@7.7.1': {}
@@ -14435,7 +14432,7 @@ snapshots:
ansi-styles@6.2.3: {}
- ansis@4.2.0: {}
+ ansis@4.3.0: {}
anymatch@3.1.3:
dependencies:
@@ -14716,9 +14713,9 @@ snapshots:
capture-website@5.1.0(typescript@6.0.3):
dependencies:
- '@ghostery/adblocker-puppeteer': 2.17.0(puppeteer@24.43.0(typescript@6.0.3))
+ '@ghostery/adblocker-puppeteer': 2.17.0(puppeteer@24.43.1(typescript@6.0.3))
file-url: 4.0.0
- puppeteer: 24.43.0(typescript@6.0.3)
+ puppeteer: 24.43.1(typescript@6.0.3)
tough-cookie: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
@@ -16384,12 +16381,10 @@ snapshots:
ufo: 1.6.4
uncrypto: 0.1.3
- h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)):
+ h3@2.0.1-rc.20:
dependencies:
rou3: 0.8.1
srvx: 0.11.15
- optionalDependencies:
- crossws: 0.4.5(srvx@0.11.15)
happy-dom@20.9.0:
dependencies:
@@ -16715,7 +16710,7 @@ snapshots:
ipaddr.js@2.4.0: {}
- ipx@3.1.1(@vercel/functions@3.6.0)(db0@0.3.4(@libsql/client@0.17.3)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.3)(@opentelemetry/api@1.9.0)(better-sqlite3@12.10.0)))(ioredis@5.10.1)(srvx@0.11.15):
+ ipx@3.1.1(@vercel/functions@3.6.0)(db0@0.3.4(@libsql/client@0.17.3)(better-sqlite3@12.10.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260511.1)(@libsql/client@0.17.3)(@opentelemetry/api@1.9.0)(better-sqlite3@12.10.0)))(ioredis@5.10.1):
dependencies:
'@fastify/accept-negotiator': 2.0.1
citty: 0.1.6
@@ -16725,7 +16720,7 @@ snapshots:
etag: 1.8.1
h3: 1.15.11
image-meta: 0.2.2
- listhen: 1.10.0(srvx@0.11.15)
+ listhen: 1.10.0
ofetch: 1.5.1
pathe: 2.0.3
sharp: 0.34.5
@@ -16752,7 +16747,6 @@ snapshots:
- db0
- idb-keyval
- ioredis
- - srvx
- uploadthing
optional: true
@@ -17119,13 +17113,13 @@ snapshots:
linkifyjs@4.3.2: {}
- listhen@1.10.0(srvx@0.11.15):
+ listhen@1.10.0:
dependencies:
'@parcel/watcher': 2.5.6
'@parcel/watcher-wasm': 2.5.6
citty: 0.2.2
consola: 3.4.2
- crossws: 0.4.5(srvx@0.11.15)
+ crossws: 0.3.5
defu: 6.1.7
get-port-please: 3.2.0
h3: 1.15.11
@@ -17139,8 +17133,6 @@ snapshots:
ufo: 1.6.4
untun: 0.1.3
uqr: 0.1.3
- transitivePeerDependencies:
- - srvx
little-date@1.2.1:
dependencies:
@@ -17733,7 +17725,7 @@ snapshots:
jiti: 2.7.0
klona: 2.0.6
knitwork: 1.3.0
- listhen: 1.10.0(srvx@0.11.15)
+ listhen: 1.10.0
magic-string: 0.30.21
magicast: 0.5.3
mime: 4.1.0
@@ -17796,7 +17788,6 @@ snapshots:
- react-native-b4a
- rolldown
- sqlite3
- - srvx
- supports-color
- uploadthing
@@ -17838,7 +17829,7 @@ snapshots:
jiti: 2.7.0
klona: 2.0.6
knitwork: 1.3.0
- listhen: 1.10.0(srvx@0.11.15)
+ listhen: 1.10.0
magic-string: 0.30.21
magicast: 0.5.3
mime: 4.1.0
@@ -17943,7 +17934,7 @@ snapshots:
jiti: 2.7.0
klona: 2.0.6
knitwork: 1.3.0
- listhen: 1.10.0(srvx@0.11.15)
+ listhen: 1.10.0
magic-string: 0.30.21
magicast: 0.5.3
mime: 4.1.0
@@ -18104,7 +18095,7 @@ snapshots:
'@nuxt/kit': 4.4.6(magicast@0.5.3)
'@nuxtjs/mdc': 0.22.0(magicast@0.5.3)
'@shikijs/vitepress-twoslash': 4.0.2(@nuxt/kit@4.4.6(magicast@0.5.3))(typescript@5.9.3)
- ansis: 4.2.0
+ ansis: 4.3.0
cac: 7.0.0
chokidar: 5.0.0
fast-glob: 3.3.3
@@ -18391,7 +18382,6 @@ snapshots:
- sass
- sass-embedded
- sqlite3
- - srvx
- stylelint
- stylus
- sugarss
@@ -19287,9 +19277,9 @@ snapshots:
punycode@2.3.1: {}
- puppeteer-core@24.43.0:
+ puppeteer-core@24.43.1:
dependencies:
- '@puppeteer/browsers': 2.13.1
+ '@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
debug: 4.4.3
devtools-protocol: 0.0.1608973
@@ -19304,13 +19294,13 @@ snapshots:
- supports-color
- utf-8-validate
- puppeteer@24.43.0(typescript@6.0.3):
+ puppeteer@24.43.1(typescript@6.0.3):
dependencies:
- '@puppeteer/browsers': 2.13.1
+ '@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
cosmiconfig: 9.0.1(typescript@6.0.3)
devtools-protocol: 0.0.1608973
- puppeteer-core: 24.43.0
+ puppeteer-core: 24.43.1
typed-query-selector: 2.12.2
transitivePeerDependencies:
- bare-abort-controller
@@ -20348,11 +20338,11 @@ snapshots:
tsdown@0.18.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(typescript@6.0.3)(vue-tsc@3.3.0(typescript@6.0.3)):
dependencies:
- ansis: 4.2.0
+ ansis: 4.3.0
cac: 6.7.14
defu: 6.1.7
empathic: 2.0.1
- hookable: 6.1.1
+ hookable: 6.0.1
import-without-cache: 0.2.5
obug: 2.1.1
picomatch: 4.0.4
@@ -20830,7 +20820,7 @@ snapshots:
vite-plugin-inspect@11.3.3(@nuxt/kit@4.4.6(magicast@0.5.3))(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
- ansis: 4.2.0
+ ansis: 4.3.0
debug: 4.4.3
error-stack-parser-es: 1.0.5
ohash: 2.0.11
diff --git a/public/assets/blog/meet-nuxi.png b/public/assets/blog/meet-nuxi.png
new file mode 100644
index 000000000..25236183e
Binary files /dev/null and b/public/assets/blog/meet-nuxi.png differ
diff --git a/server/api/admin/mcp-install.get.ts b/server/api/admin/mcp-install.get.ts
index 5c54fa7fd..2a1aec03c 100644
--- a/server/api/admin/mcp-install.get.ts
+++ b/server/api/admin/mcp-install.get.ts
@@ -1,7 +1,7 @@
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
- if (!user?.login || !(await isAuthorizedAdmin(user.login))) {
+ if (user.role !== 'admin') {
throw createError({ statusCode: 403, statusMessage: 'Admin access required' })
}
diff --git a/server/api/agent.post.ts b/server/api/agent.post.ts
deleted file mode 100644
index 77216df84..000000000
--- a/server/api/agent.post.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, safeValidateUIMessages } from 'ai'
-import type { ToolSet, UIMessage } from 'ai'
-import { createMCPClient } from '@ai-sdk/mcp'
-import { anthropic } from '@ai-sdk/anthropic'
-import { createAILogger, createEvlogIntegration } from 'evlog/ai'
-import { sql } from 'drizzle-orm'
-import { getAgentFingerprint } from '../utils/agent-fingerprint'
-import { showModuleTool } from '../utils/tools/show-module'
-import { createShowTemplateTool } from '../utils/tools/show-template'
-import { createShowBlogPostTool } from '../utils/tools/show-blog-post'
-import { createShowHostingTool } from '../utils/tools/show-hosting'
-import { openPlaygroundTool } from '../utils/tools/open-playground'
-import { createSearchGitHubIssuesTool } from '../utils/tools/search-github-issues'
-import { reportIssueTool } from '../utils/tools/report-issue'
-
-const MCP_PATH = '/mcp'
-const MODEL = 'anthropic/claude-sonnet-4.6'
-const MAX_STEPS = 10
-
-function stopWhenResponseComplete({ steps }: { steps: { text?: string, toolCalls?: unknown[] }[] }): boolean {
- const lastStep = steps.at(-1)
- if (!lastStep) return false
-
- const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0)
- const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0
-
- if (hasText && hasNoToolCalls) return true
-
- return steps.length >= MAX_STEPS
-}
-
-const baseSystemPrompt = `You are **the Nuxt Agent**, Nuxt's documentation agent on nuxt.com. You help users navigate the official documentation, blog, modules catalog, and guides.
-
-**Identity:** You are the Nuxt Agent β not a generic chatbot. Be confident, precise, and grounded in retrieved content. Avoid casual first person ("I thinkβ¦"). Attribute capabilities to Nuxt, not to yourself.
-
-**Current page context:** When the request includes a "Current page" line at the top of this prompt, that's the page the user has open in the browser. Treat it as a strong hint about what they're asking about, especially for vague questions like "explain this", "summarize", "tldr", "what does this do?". Map the path to the right tool:
-- \`/docs/β¦\` β \`get-documentation-page\` with that exact path
-- \`/blog/β¦\` β \`get-blog-post\` with that exact path
-- \`/deploy/β¦\` β \`get-deploy-provider\` with that exact path
-- \`/modules/\` β \`show_module\` with that slug
-- \`/changelog/β¦\` β use the GitHub changelog tools
-Do NOT call \`list-*\` first when the page is given β call the get tool directly. If the question is unrelated to the current page, ignore it and answer normally.
-
-**Modules:** Never invent npm package names. Use \`show_module\` to display modules (it includes all needed info β do NOT also call \`get-module\` for the same module). NuxtHub's module is \`@nuxthub/core\`, not \`@nuxt/hub\`.
-
-**TOKEN EFFICIENCY (CRITICAL β follow strictly):**
-- For \`get-documentation-page\`: pass the \`sections\` parameter with the relevant h2 titles when you only need part of a long page. Omit it when the user wants an overview/tldr/summary of the whole page.
-- For \`get-blog-post\` and \`get-deploy-provider\`: do NOT use sections β these pages are short, fetch them once in full.
-- **Never call the same tool twice with the same path** in a single turn. If the first call returned content, work with it β do not refetch.
-- If you already know the doc path, call \`get-documentation-page\` directly β skip \`list-documentation-pages\`.
-- Prefer \`show_module\` over \`get-module\` (smaller response, richer UI).
-
-**Debugging / error questions:**
-- When the user shares an error message or stack trace, use \`search_github_issues\` first β it searches across nuxt, nuxt-modules, and nuxt-content orgs.
-- If a matching closed issue exists, link to it and summarize the fix/workaround.
-- If open, link to the issue and mention any workarounds from the body.
-- Only fall back to \`web_search\` if no relevant GitHub Issue is found.
-
-**Tools:**
-- \`list-documentation-pages\` β discover pages by topic (use before \`get-documentation-page\` if path unknown)
-- \`get-documentation-page\` β read a doc page. Pass \`sections\` with the relevant h2 titles for partial reads; omit for full-page overviews.
-- \`get-blog-post\` β read a blog post (full content, no sections).
-- \`get-deploy-provider\` β read a deploy provider page (full content, no sections).
-- \`search_github_issues\` β search GitHub Issues across the Nuxt ecosystem. Use for errors, bugs, and debugging questions.
-- \`show_module\` β display a module card (preferred for module questions)
-- \`show_template\` β display template cards (accepts array of slugs). For vague requests, show official templates first: nuxt-ui-dashboard, nuxt-ui-saas, nuxt-ui-landing, nuxt-ui-chat, nuxt-ui-docs, nuxt-ui-portfolio
-- \`show_blog_post\` β display a blog post card
-- \`show_hosting\` β display a hosting provider card
-- \`open_playground\` β generate a StackBlitz link
-- \`report_issue\` β call when you cannot resolve the user's question after exhausting all available tools, or when the user expresses frustration. Provide a short title and 1-3 sentence summary of what was tried and why it failed
-- ALWAYS respond with text after tool calls β never end with just tool calls
-
-**Web search:** Only use when the user **explicitly** asks about recent events or real-time data beyond the Nuxt docs, or if \`search_github_issues\` returned no results. Never search proactively.
-
-**Web search queries:** Match the user's wording. **Do not** tack on calendar years (e.g. "2024", "2025") unless they asked for a specific year or time range β that often **hurts** relevance and looks wrong when the current year has moved on. The search already returns current pages. For stable facts (team pages, about pages), use neutral queries without a year.
-
-**Formatting:**
-- NEVER use markdown headings (#, ##, ###)
-- Use **bold** for emphasis, bullet points for lists
-- Prefer **root-relative** markdown links for nuxt.com pages (\`/docs/...\`, \`/blog/...\`, \`/modules/...\`) so navigation works on localhost and preview deployments. Full \`https://nuxt.com/...\` URLs from tool results are acceptable if shorter to reuse as-is. Use full URLs for external sites (GitHub, Stack Overflow, etc.).
-- Be concise and direct β actionable guidance, not information dumps`
-
-const PAGE_PATH_PATTERN = /^\/[\w./-]*$/
-
-function buildSystemPrompt(pagePath: string | null): string {
- const today = new Date()
- const dateLine = `**Today's date:** ${today.toLocaleDateString('en-US', { timeZone: 'UTC' })} (UTC). Use it for recency β do not assume an older year when formulating web searches or answers.`
- const withDate = `${dateLine}\n\n${baseSystemPrompt}`
- if (!pagePath) return withDate
- return `Current page: ${pagePath}\n\n${withDate}`
-}
-
-export default defineEventHandler(async (event) => {
- const raw = await readBody(event) as { messages?: unknown } | null
- if (!raw || !Array.isArray(raw.messages)) {
- throw createError({ statusCode: 400, statusMessage: 'Invalid request body' })
- }
-
- const validated = await safeValidateUIMessages({ messages: raw.messages })
- if (validated.success === false) {
- throw createError({
- statusCode: 400,
- statusMessage: validated.error.message || 'Invalid messages'
- })
- }
-
- const messages = validated.data
-
- await consumeAgentRateLimit(event)
- const chatId = getHeader(event, 'x-chat-id')
- const rawPagePath = getHeader(event, 'x-page-path')?.trim() ?? null
- const pagePath = rawPagePath && PAGE_PATH_PATTERN.test(rawPagePath) && rawPagePath.length <= 256
- ? rawPagePath
- : null
- const log = useLogger(event)
- const ai = createAILogger(log, {
- toolInputs: true,
- cost: { 'claude-sonnet-4-6': { input: 3, output: 15 } }
- })
-
- const abortController = new AbortController()
- event.node.req.on('close', () => abortController.abort())
-
- const httpClient = await createMCPClient({
- transport: { type: 'http', url: `${getRequestURL(event).origin}${MCP_PATH}` }
- })
- const mcpTools = await httpClient.tools()
-
- const closeMcp = () => event.waitUntil(httpClient.close())
-
- const saveChat = async (finalizedMessages: UIMessage[]) => {
- if (!chatId) return
- const fingerprint = await getAgentFingerprint(event)
- const now = new Date()
- const metadata = ai.getMetadata()
- const model = metadata.model ?? null
- const provider = metadata.provider ?? null
- const inputTokens = metadata.inputTokens ?? 0
- const outputTokens = metadata.outputTokens ?? 0
- const estimatedCost = metadata.estimatedCost ?? 0
- const durationMs = metadata.totalDurationMs ?? 0
-
- // Insert when chatId is new; on conflict only update when the existing row's
- // fingerprint matches β prevents anyone with a guessable chatId from
- // overwriting another user's conversation.
- await db.insert(schema.agentChats).values({
- id: chatId,
- messages: finalizedMessages,
- fingerprint,
- model,
- provider,
- inputTokens,
- outputTokens,
- estimatedCost,
- durationMs,
- requestCount: 1,
- createdAt: now,
- updatedAt: now
- }).onConflictDoUpdate({
- target: schema.agentChats.id,
- set: {
- messages: finalizedMessages,
- updatedAt: now,
- model,
- provider,
- inputTokens: sql`${schema.agentChats.inputTokens} + ${inputTokens}`,
- outputTokens: sql`${schema.agentChats.outputTokens} + ${outputTokens}`,
- estimatedCost: sql`${schema.agentChats.estimatedCost} + ${estimatedCost}`,
- durationMs: sql`${schema.agentChats.durationMs} + ${durationMs}`,
- requestCount: sql`${schema.agentChats.requestCount} + 1`
- },
- where: sql`${schema.agentChats.fingerprint} = ${fingerprint}`
- })
- }
-
- const stream = createUIMessageStream({
- execute: async ({ writer }) => {
- const result = streamText({
- model: ai.wrap(MODEL),
- maxOutputTokens: 4000,
- maxRetries: 2,
- abortSignal: abortController.signal,
- stopWhen: stopWhenResponseComplete,
- system: buildSystemPrompt(pagePath),
- messages: await convertToModelMessages(messages),
- tools: {
- ...mcpTools as ToolSet,
- web_search: anthropic.tools.webSearch_20250305(),
- search_github_issues: createSearchGitHubIssuesTool(event),
- show_module: showModuleTool,
- show_template: createShowTemplateTool(event),
- show_blog_post: createShowBlogPostTool(event),
- show_hosting: createShowHostingTool(event),
- open_playground: openPlaygroundTool,
- report_issue: reportIssueTool
- },
- experimental_telemetry: {
- isEnabled: true,
- integrations: [createEvlogIntegration(ai)]
- },
- onFinish: () => {
- closeMcp()
- },
- onAbort: closeMcp,
- onError: closeMcp
- })
-
- writer.merge(result.toUIMessageStream({
- sendSources: true,
- originalMessages: messages,
- onFinish: ({ messages: finalizedMessages }) => {
- event.waitUntil(saveChat(finalizedMessages))
- }
- }))
- }
- })
-
- return createUIMessageStreamResponse({ stream })
-})
diff --git a/server/api/agent/cleanup.get.ts b/server/api/agent/cleanup.get.ts
deleted file mode 100644
index 493430fac..000000000
--- a/server/api/agent/cleanup.get.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { lt, sql } from 'drizzle-orm'
-
-const CHATS_RETENTION_DAYS = 30
-const USAGE_RETENTION_DAYS = 7
-
-export default defineEventHandler(async (event) => {
- const secret = useRuntimeConfig(event).cronSecret
- const authHeader = getHeader(event, 'authorization')
-
- if (!secret || authHeader !== `Bearer ${secret}`) {
- throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
- }
-
- const chatsThreshold = new Date()
- chatsThreshold.setDate(chatsThreshold.getDate() - CHATS_RETENTION_DAYS)
-
- const deletedChats = await db.delete(schema.agentChats)
- .where(lt(schema.agentChats.updatedAt, chatsThreshold))
- .returning({ id: schema.agentChats.id })
-
- // `dayKey` is `rate:agent::YYYY-MM-DD` β extract the trailing 10 chars
- // to compare lexicographically (ISO format makes this safe).
- const usageThresholdDate = new Date()
- usageThresholdDate.setDate(usageThresholdDate.getDate() - USAGE_RETENTION_DAYS)
- const usageThresholdKey = usageThresholdDate.toISOString().slice(0, 10)
-
- const deletedUsage = await db.delete(schema.agentDailyUsage)
- .where(sql`substr(${schema.agentDailyUsage.dayKey}, -10) < ${usageThresholdKey}`)
- .returning({ dayKey: schema.agentDailyUsage.dayKey })
-
- return {
- chats: { deleted: deletedChats.length, threshold: chatsThreshold.toISOString() },
- usage: { deleted: deletedUsage.length, threshold: usageThresholdKey }
- }
-})
diff --git a/server/api/agent/vote.post.ts b/server/api/agent/vote.post.ts
deleted file mode 100644
index 5f918cceb..000000000
--- a/server/api/agent/vote.post.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { z } from 'zod'
-import { and, eq } from 'drizzle-orm'
-import { getAgentFingerprint } from '../../utils/agent-fingerprint'
-
-const voteSchema = z.object({
- chatId: z.string().min(1),
- messageId: z.string().min(1),
- isUpvoted: z.boolean().optional()
-})
-
-export default defineEventHandler(async (event) => {
- const { chatId, messageId, isUpvoted } = await readValidatedBody(event, voteSchema.parse)
-
- const fingerprint = await getAgentFingerprint(event)
- const [chat] = await db.select({ id: schema.agentChats.id, fingerprint: schema.agentChats.fingerprint })
- .from(schema.agentChats)
- .where(eq(schema.agentChats.id, chatId))
- .limit(1)
-
- if (!chat || chat.fingerprint !== fingerprint) {
- throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
- }
-
- if (isUpvoted === undefined) {
- await db.delete(schema.agentVotes).where(
- and(
- eq(schema.agentVotes.chatId, chatId),
- eq(schema.agentVotes.messageId, messageId)
- )
- )
- } else {
- await db.insert(schema.agentVotes).values({
- chatId,
- messageId,
- isUpvoted,
- createdAt: new Date()
- }).onConflictDoUpdate({
- target: [schema.agentVotes.chatId, schema.agentVotes.messageId],
- set: { isUpvoted }
- })
- }
-
- return { chatId, messageId, isUpvoted }
-})
diff --git a/server/api/auth/github.get.ts b/server/api/auth/github.get.ts
index ff6125fbe..36162204c 100644
--- a/server/api/auth/github.get.ts
+++ b/server/api/auth/github.get.ts
@@ -1,12 +1,70 @@
-export default defineOAuthGitHubEventHandler({
- async onSuccess(event, { user }) {
- const allowed = await isAuthorizedAdmin(user.login)
+import { and, eq } from 'drizzle-orm'
+import { getQuery, setCookie, getCookie, deleteCookie, defineEventHandler } from 'h3'
- if (!allowed) {
- return sendRedirect(event, '/admin/login?error=access-denied')
+const oauthHandler = defineOAuthGitHubEventHandler({
+ async onSuccess(event, { user: ghUser }) {
+ const redirectTo = getCookie(event, 'oauth-redirect') || '/'
+ deleteCookie(event, 'oauth-redirect')
+ const session = await getUserSession(event)
+
+ let user = await db.query.users.findFirst({
+ where: () => and(
+ eq(schema.users.provider, 'github'),
+ eq(schema.users.providerId, ghUser.id.toString())
+ )
+ })
+
+ const role: 'user' | 'admin' = (await isAuthorizedAdmin(ghUser.login)) ? 'admin' : 'user'
+
+ if (!user) {
+ // New user: reuse `session.id` as the primary key so any anonymous chats
+ // already created with `session.id` as `userId` keep working without an
+ // UPDATE pass β they're already attached to the new row.
+ [user] = await db.insert(schema.users).values({
+ id: session.id,
+ name: ghUser.name || '',
+ avatar: ghUser.avatar_url || '',
+ username: ghUser.login,
+ provider: 'github',
+ providerId: ghUser.id.toString(),
+ role
+ }).returning()
+ } else {
+ // Returning user signing in from a previously-anonymous device:
+ // re-attach anonymous chats from this device (keyed by `session.id`) to
+ // the existing user row. No-op when there were none.
+ await db.update(schema.chats).set({ userId: user!.id })
+ .where(eq(schema.chats.userId, session.id))
+
+ if (user.role !== role) {
+ [user] = await db.update(schema.users).set({ role })
+ .where(eq(schema.users.id, user.id))
+ .returning()
+ }
}
await setUserSession(event, { user })
- return sendRedirect(event, '/admin')
+
+ return sendRedirect(event, redirectTo)
+ },
+ onError(event, error) {
+ console.error('GitHub OAuth error:', error)
+ return sendRedirect(event, '/')
+ }
+})
+
+export default defineEventHandler(async (event) => {
+ const query = getQuery(event)
+ if (!query.code && query.redirect) {
+ const redirect = Array.isArray(query.redirect) ? query.redirect[0] : query.redirect as string
+ if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
+ setCookie(event, 'oauth-redirect', redirect, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV !== 'development',
+ maxAge: 300,
+ path: '/'
+ })
+ }
}
+ return oauthHandler(event)
})
diff --git a/server/db/migrations/sqlite/0003_chat_history.sql b/server/db/migrations/sqlite/0003_chat_history.sql
new file mode 100644
index 000000000..4c5a330f5
--- /dev/null
+++ b/server/db/migrations/sqlite/0003_chat_history.sql
@@ -0,0 +1,51 @@
+-- DESTRUCTIVE: the previous `agent_chats` / `agent_votes` tables stored
+-- anonymous-only conversations keyed by request fingerprint. Chat history is
+-- being re-introduced from scratch with a real `users` model, so we drop the
+-- legacy tables instead of attempting a migration.
+DROP TABLE IF EXISTS `agent_votes`;--> statement-breakpoint
+DROP TABLE IF EXISTS `agent_chats`;--> statement-breakpoint
+CREATE TABLE `users` (
+ `id` text PRIMARY KEY NOT NULL,
+ `email` text,
+ `name` text NOT NULL,
+ `avatar` text NOT NULL,
+ `username` text NOT NULL,
+ `provider` text NOT NULL,
+ `provider_id` text NOT NULL,
+ `role` text DEFAULT 'user' NOT NULL,
+ `created_at` integer NOT NULL
+);--> statement-breakpoint
+CREATE UNIQUE INDEX `users_provider_id_idx` ON `users` (`provider`,`provider_id`);--> statement-breakpoint
+CREATE TABLE `chats` (
+ `id` text PRIMARY KEY NOT NULL,
+ `title` text,
+ `user_id` text NOT NULL,
+ `visibility` text DEFAULT 'private' NOT NULL,
+ `model` text,
+ `provider` text,
+ `input_tokens` integer DEFAULT 0 NOT NULL,
+ `output_tokens` integer DEFAULT 0 NOT NULL,
+ `estimated_cost` real DEFAULT 0 NOT NULL,
+ `duration_ms` integer DEFAULT 0 NOT NULL,
+ `request_count` integer DEFAULT 0 NOT NULL,
+ `updated_at` integer,
+ `created_at` integer NOT NULL
+);--> statement-breakpoint
+CREATE INDEX `chats_user_id_idx` ON `chats` (`user_id`);--> statement-breakpoint
+CREATE TABLE `messages` (
+ `id` text PRIMARY KEY NOT NULL,
+ `chat_id` text NOT NULL,
+ `role` text NOT NULL,
+ `parts` text,
+ `created_at` integer NOT NULL,
+ FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade
+);--> statement-breakpoint
+CREATE INDEX `messages_chat_id_idx` ON `messages` (`chat_id`);--> statement-breakpoint
+CREATE TABLE `votes` (
+ `chat_id` text NOT NULL,
+ `message_id` text NOT NULL,
+ `is_upvoted` integer NOT NULL,
+ PRIMARY KEY(`chat_id`, `message_id`),
+ FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`message_id`) REFERENCES `messages`(`id`) ON UPDATE no action ON DELETE cascade
+);
diff --git a/server/db/migrations/sqlite/0004_sloppy_carlie_cooper.sql b/server/db/migrations/sqlite/0004_sloppy_carlie_cooper.sql
new file mode 100644
index 000000000..36c5d3d25
--- /dev/null
+++ b/server/db/migrations/sqlite/0004_sloppy_carlie_cooper.sql
@@ -0,0 +1,61 @@
+-- Purge anonymous chats while FK enforcement is still on (cascades to messages/votes).
+DELETE FROM `chats` WHERE `user_id` NOT IN (SELECT `id` FROM `users`);--> statement-breakpoint
+CREATE TABLE `agent_stats` (
+ `day_key` text NOT NULL,
+ `user_id` text NOT NULL,
+ `provider` text NOT NULL,
+ `model` text NOT NULL,
+ `chats_started` integer DEFAULT 0 NOT NULL,
+ `request_count` integer DEFAULT 0 NOT NULL,
+ `error_count` integer DEFAULT 0 NOT NULL,
+ `input_tokens` integer DEFAULT 0 NOT NULL,
+ `output_tokens` integer DEFAULT 0 NOT NULL,
+ `estimated_cost` real DEFAULT 0 NOT NULL,
+ `duration_ms` integer DEFAULT 0 NOT NULL,
+ PRIMARY KEY(`day_key`, `user_id`, `provider`, `model`)
+);
+--> statement-breakpoint
+CREATE INDEX `agent_stats_day_key_idx` ON `agent_stats` (`day_key`);--> statement-breakpoint
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+-- New PK shape (user_id, day_key); previous rows were IP-keyed, no value in migrating them.
+DROP TABLE `agent_daily_usage`;--> statement-breakpoint
+CREATE TABLE `agent_daily_usage` (
+ `user_id` text NOT NULL,
+ `day_key` text NOT NULL,
+ `count` integer DEFAULT 0 NOT NULL,
+ `limit_override` integer,
+ PRIMARY KEY(`user_id`, `day_key`)
+);
+--> statement-breakpoint
+CREATE INDEX `agent_daily_usage_day_key_idx` ON `agent_daily_usage` (`day_key`);--> statement-breakpoint
+CREATE TABLE `__new_chats` (
+ `id` text PRIMARY KEY NOT NULL,
+ `title` text,
+ `user_id` text NOT NULL,
+ `visibility` text DEFAULT 'private' NOT NULL,
+ `parent_chat_id` text,
+ `metadata` text,
+ `model` text,
+ `provider` text,
+ `input_tokens` integer DEFAULT 0 NOT NULL,
+ `output_tokens` integer DEFAULT 0 NOT NULL,
+ `estimated_cost` real DEFAULT 0 NOT NULL,
+ `duration_ms` integer DEFAULT 0 NOT NULL,
+ `request_count` integer DEFAULT 0 NOT NULL,
+ `updated_at` integer,
+ `created_at` integer NOT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`parent_chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+INSERT INTO `__new_chats`("id", "title", "user_id", "visibility", "model", "provider", "input_tokens", "output_tokens", "estimated_cost", "duration_ms", "request_count", "updated_at", "created_at") SELECT "id", "title", "user_id", "visibility", "model", "provider", "input_tokens", "output_tokens", "estimated_cost", "duration_ms", "request_count", "updated_at", "created_at" FROM `chats`;--> statement-breakpoint
+DROP TABLE `chats`;--> statement-breakpoint
+ALTER TABLE `__new_chats` RENAME TO `chats`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;--> statement-breakpoint
+CREATE INDEX `chats_user_id_idx` ON `chats` (`user_id`);--> statement-breakpoint
+CREATE INDEX `chats_parent_chat_id_idx` ON `chats` (`parent_chat_id`);--> statement-breakpoint
+ALTER TABLE `messages` ADD `model` text;--> statement-breakpoint
+ALTER TABLE `messages` ADD `provider` text;--> statement-breakpoint
+ALTER TABLE `messages` ADD `metadata` text;--> statement-breakpoint
+ALTER TABLE `users` ADD `metadata` text;--> statement-breakpoint
+ALTER TABLE `votes` ADD `created_at` integer;
diff --git a/server/db/migrations/sqlite/meta/0003_snapshot.json b/server/db/migrations/sqlite/meta/0003_snapshot.json
new file mode 100644
index 000000000..d34d50cb5
--- /dev/null
+++ b/server/db/migrations/sqlite/meta/0003_snapshot.json
@@ -0,0 +1,462 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "9ccf6e78-b79e-405f-8309-4542751f8ac7",
+ "prevId": "a8264235-7bd0-49b9-afc8-3b2dbbcac5ec",
+ "tables": {
+ "agent_daily_usage": {
+ "name": "agent_daily_usage",
+ "columns": {
+ "day_key": {
+ "name": "day_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "chats": {
+ "name": "chats",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'private'"
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "estimated_cost": {
+ "name": "estimated_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "request_count": {
+ "name": "request_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "chats_user_id_idx": {
+ "name": "chats_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feedback": {
+ "name": "feedback",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feedback": {
+ "name": "feedback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stem": {
+ "name": "stem",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "path_fingerprint_idx": {
+ "name": "path_fingerprint_idx",
+ "columns": [
+ "path",
+ "fingerprint"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parts": {
+ "name": "parts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "messages_chat_id_idx": {
+ "name": "messages_chat_id_idx",
+ "columns": [
+ "chat_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_chat_id_chats_id_fk": {
+ "name": "messages_chat_id_chats_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "avatar": {
+ "name": "avatar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_provider_id_idx": {
+ "name": "users_provider_id_idx",
+ "columns": [
+ "provider",
+ "provider_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "votes": {
+ "name": "votes",
+ "columns": {
+ "chat_id": {
+ "name": "chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_upvoted": {
+ "name": "is_upvoted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "votes_chat_id_chats_id_fk": {
+ "name": "votes_chat_id_chats_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "votes_message_id_messages_id_fk": {
+ "name": "votes_message_id_messages_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "messages",
+ "columnsFrom": [
+ "message_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "votes_chat_id_message_id_pk": {
+ "columns": [
+ "chat_id",
+ "message_id"
+ ],
+ "name": "votes_chat_id_message_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/server/db/migrations/sqlite/meta/0004_snapshot.json b/server/db/migrations/sqlite/meta/0004_snapshot.json
new file mode 100644
index 000000000..4e4b18df3
--- /dev/null
+++ b/server/db/migrations/sqlite/meta/0004_snapshot.json
@@ -0,0 +1,688 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "b57fe83e-5db8-4f70-8d5f-6f84c5b08518",
+ "prevId": "9ccf6e78-b79e-405f-8309-4542751f8ac7",
+ "tables": {
+ "agent_daily_usage": {
+ "name": "agent_daily_usage",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "day_key": {
+ "name": "day_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "limit_override": {
+ "name": "limit_override",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "agent_daily_usage_day_key_idx": {
+ "name": "agent_daily_usage_day_key_idx",
+ "columns": [
+ "day_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_daily_usage_user_id_day_key_pk": {
+ "columns": [
+ "user_id",
+ "day_key"
+ ],
+ "name": "agent_daily_usage_user_id_day_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "agent_stats": {
+ "name": "agent_stats",
+ "columns": {
+ "day_key": {
+ "name": "day_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chats_started": {
+ "name": "chats_started",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "request_count": {
+ "name": "request_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "error_count": {
+ "name": "error_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "estimated_cost": {
+ "name": "estimated_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "agent_stats_day_key_idx": {
+ "name": "agent_stats_day_key_idx",
+ "columns": [
+ "day_key"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "agent_stats_day_key_user_id_provider_model_pk": {
+ "columns": [
+ "day_key",
+ "user_id",
+ "provider",
+ "model"
+ ],
+ "name": "agent_stats_day_key_user_id_provider_model_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "chats": {
+ "name": "chats",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'private'"
+ },
+ "parent_chat_id": {
+ "name": "parent_chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "estimated_cost": {
+ "name": "estimated_cost",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "request_count": {
+ "name": "request_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "chats_user_id_idx": {
+ "name": "chats_user_id_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ },
+ "chats_parent_chat_id_idx": {
+ "name": "chats_parent_chat_id_idx",
+ "columns": [
+ "parent_chat_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "chats_user_id_users_id_fk": {
+ "name": "chats_user_id_users_id_fk",
+ "tableFrom": "chats",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chats_parent_chat_id_chats_id_fk": {
+ "name": "chats_parent_chat_id_chats_id_fk",
+ "tableFrom": "chats",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "parent_chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "feedback": {
+ "name": "feedback",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "feedback": {
+ "name": "feedback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stem": {
+ "name": "stem",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "path_fingerprint_idx": {
+ "name": "path_fingerprint_idx",
+ "columns": [
+ "path",
+ "fingerprint"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "messages": {
+ "name": "messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "chat_id": {
+ "name": "chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parts": {
+ "name": "parts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "messages_chat_id_idx": {
+ "name": "messages_chat_id_idx",
+ "columns": [
+ "chat_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "messages_chat_id_chats_id_fk": {
+ "name": "messages_chat_id_chats_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "avatar": {
+ "name": "avatar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_provider_id_idx": {
+ "name": "users_provider_id_idx",
+ "columns": [
+ "provider",
+ "provider_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "votes": {
+ "name": "votes",
+ "columns": {
+ "chat_id": {
+ "name": "chat_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message_id": {
+ "name": "message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_upvoted": {
+ "name": "is_upvoted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "votes_chat_id_chats_id_fk": {
+ "name": "votes_chat_id_chats_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "chats",
+ "columnsFrom": [
+ "chat_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "votes_message_id_messages_id_fk": {
+ "name": "votes_message_id_messages_id_fk",
+ "tableFrom": "votes",
+ "tableTo": "messages",
+ "columnsFrom": [
+ "message_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "votes_chat_id_message_id_pk": {
+ "columns": [
+ "chat_id",
+ "message_id"
+ ],
+ "name": "votes_chat_id_message_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/server/db/migrations/sqlite/meta/_journal.json b/server/db/migrations/sqlite/meta/_journal.json
index baa9e05e6..90c44b9f2 100644
--- a/server/db/migrations/sqlite/meta/_journal.json
+++ b/server/db/migrations/sqlite/meta/_journal.json
@@ -22,6 +22,20 @@
"when": 1776600000000,
"tag": "0002_agent_daily_usage",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1778488160047,
+ "tag": "0003_chat_history",
+ "breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "6",
+ "when": 1779463675450,
+ "tag": "0004_sloppy_carlie_cooper",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/db/schema.ts b/server/db/schema.ts
index 4bbbc9aaf..e83207bc4 100644
--- a/server/db/schema.ts
+++ b/server/db/schema.ts
@@ -1,4 +1,21 @@
-import { sqliteTable, text, integer, real, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
+import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'
+
+const timestamps = {
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date())
+}
+
+export const users = sqliteTable('users', {
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+ email: text('email'),
+ name: text('name').notNull(),
+ avatar: text('avatar').notNull(),
+ username: text('username').notNull(),
+ provider: text('provider', { enum: ['github'] }).notNull(),
+ providerId: text('provider_id').notNull(),
+ role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
+ metadata: text('metadata', { mode: 'json' }).$type>(),
+ ...timestamps
+}, table => [uniqueIndex('users_provider_id_idx').on(table.provider, table.providerId)])
export const feedback = sqliteTable('feedback', {
id: integer('id').primaryKey({ autoIncrement: true }),
@@ -12,31 +29,3 @@ export const feedback = sqliteTable('feedback', {
createdAt: integer({ mode: 'timestamp' }).notNull(),
updatedAt: integer({ mode: 'timestamp' }).notNull()
}, table => [uniqueIndex('path_fingerprint_idx').on(table.path, table.fingerprint)])
-
-export const agentChats = sqliteTable('agent_chats', {
- id: text('id').primaryKey(),
- messages: text('messages', { mode: 'json' }).notNull().$type(),
- fingerprint: text('fingerprint').notNull(),
- model: text('model'),
- provider: text('provider'),
- inputTokens: integer('input_tokens').notNull().default(0),
- outputTokens: integer('output_tokens').notNull().default(0),
- estimatedCost: real('estimated_cost').notNull().default(0),
- durationMs: integer('duration_ms').notNull().default(0),
- requestCount: integer('request_count').notNull().default(0),
- createdAt: integer({ mode: 'timestamp' }).notNull(),
- updatedAt: integer({ mode: 'timestamp' }).notNull()
-}, table => [index('agent_chats_fingerprint_idx').on(table.fingerprint)])
-
-export const agentDailyUsage = sqliteTable('agent_daily_usage', {
- dayKey: text('day_key').primaryKey(),
- count: integer('count').notNull()
-})
-
-export const agentVotes = sqliteTable('agent_votes', {
- id: integer('id').primaryKey({ autoIncrement: true }),
- chatId: text('chat_id').notNull().references(() => agentChats.id, { onDelete: 'cascade' }),
- messageId: text('message_id').notNull(),
- isUpvoted: integer('is_upvoted', { mode: 'boolean' }).notNull(),
- createdAt: integer({ mode: 'timestamp' }).notNull()
-}, table => [uniqueIndex('agent_vote_chat_msg_idx').on(table.chatId, table.messageId)])
diff --git a/server/mcp/tools/admin/agent-usage-stats.ts b/server/mcp/tools/admin/agent-usage-stats.ts
index bf1296f4d..10d96061e 100644
--- a/server/mcp/tools/admin/agent-usage-stats.ts
+++ b/server/mcp/tools/admin/agent-usage-stats.ts
@@ -1,10 +1,11 @@
import { z } from 'zod'
-import { count, desc, eq, gte, sql, sum } from 'drizzle-orm'
+import { desc, eq, gte, sql, sum } from 'drizzle-orm'
+import { ANON_AGENT_STATS_USER_ID } from '../../../../layers/nuxi/server/utils/stats'
export default defineMcpTool({
- description: `Aggregated AI agent usage stats over a time window: total chats, tokens, cost, average duration, breakdown by provider/model, and daily request counts.
+ description: `Aggregated AI agent usage stats over a time window: chats started, requests, errors, tokens, cost, breakdown by provider/model, daily volume, and the share of anonymous vs signed-in traffic. Data lives in the permanent \`agent_stats\` table, so it survives chat deletion and includes anonymous visitors.
-WHEN TO USE: Use this tool to monitor AI agent traffic, spend, and provider mix.`,
+WHEN TO USE: Use this tool to monitor AI agent traffic, spend, provider mix, and login mix.`,
inputSchema: {
sinceDays: z.number().int().min(1).max(365).default(30).describe('Window in days from now (default 30).')
},
@@ -19,83 +20,118 @@ WHEN TO USE: Use this tool to monitor AI agent traffic, spend, and provider mix.
enabled: event => isMcpAdmin(event),
async handler({ sinceDays }) {
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)
+ const sinceKey = since.toISOString().slice(0, 10)
const [globalRow] = await db
.select({
- chats: count(),
- inputTokens: sum(schema.agentChats.inputTokens),
- outputTokens: sum(schema.agentChats.outputTokens),
- estimatedCost: sum(schema.agentChats.estimatedCost),
- durationMs: sum(schema.agentChats.durationMs),
- requestCount: sum(schema.agentChats.requestCount)
+ chatsStarted: sum(schema.agentStats.chatsStarted),
+ requestCount: sum(schema.agentStats.requestCount),
+ errorCount: sum(schema.agentStats.errorCount),
+ inputTokens: sum(schema.agentStats.inputTokens),
+ outputTokens: sum(schema.agentStats.outputTokens),
+ estimatedCost: sum(schema.agentStats.estimatedCost),
+ durationMs: sum(schema.agentStats.durationMs)
})
- .from(schema.agentChats)
- .where(gte(schema.agentChats.createdAt, since))
+ .from(schema.agentStats)
+ .where(gte(schema.agentStats.dayKey, sinceKey))
- type ProviderRow = Pick & {
- chats: number
- inputTokens: string | null
- outputTokens: string | null
- estimatedCost: string | null
- }
-
- const byProvider: ProviderRow[] = await db
+ const byProvider = await db
.select({
- provider: schema.agentChats.provider,
- model: schema.agentChats.model,
- chats: count(),
- inputTokens: sum(schema.agentChats.inputTokens),
- outputTokens: sum(schema.agentChats.outputTokens),
- estimatedCost: sum(schema.agentChats.estimatedCost)
+ provider: schema.agentStats.provider,
+ model: schema.agentStats.model,
+ chatsStarted: sum(schema.agentStats.chatsStarted),
+ requestCount: sum(schema.agentStats.requestCount),
+ inputTokens: sum(schema.agentStats.inputTokens),
+ outputTokens: sum(schema.agentStats.outputTokens),
+ estimatedCost: sum(schema.agentStats.estimatedCost)
})
- .from(schema.agentChats)
- .where(gte(schema.agentChats.createdAt, since))
- .groupBy(schema.agentChats.provider, schema.agentChats.model)
- .orderBy(desc(count()))
+ .from(schema.agentStats)
+ .where(gte(schema.agentStats.dayKey, sinceKey))
+ .groupBy(schema.agentStats.provider, schema.agentStats.model)
+ .orderBy(desc(sum(schema.agentStats.requestCount)))
const dailyUsage = await db
.select({
- dayKey: schema.agentDailyUsage.dayKey,
- count: schema.agentDailyUsage.count
+ dayKey: schema.agentStats.dayKey,
+ chatsStarted: sum(schema.agentStats.chatsStarted),
+ requestCount: sum(schema.agentStats.requestCount),
+ errorCount: sum(schema.agentStats.errorCount)
+ })
+ .from(schema.agentStats)
+ .where(gte(schema.agentStats.dayKey, sinceKey))
+ .groupBy(schema.agentStats.dayKey)
+ .orderBy(desc(schema.agentStats.dayKey))
+
+ const [anonRow] = await db
+ .select({
+ chatsStarted: sum(schema.agentStats.chatsStarted),
+ requestCount: sum(schema.agentStats.requestCount)
})
- .from(schema.agentDailyUsage)
- .where(gte(schema.agentDailyUsage.dayKey, since.toISOString().slice(0, 10)))
- .orderBy(desc(schema.agentDailyUsage.dayKey))
+ .from(schema.agentStats)
+ .where(sql`${schema.agentStats.dayKey} >= ${sinceKey} AND ${schema.agentStats.userId} = ${ANON_AGENT_STATS_USER_ID}`)
+
+ const [loggedRow] = await db
+ .select({
+ chatsStarted: sum(schema.agentStats.chatsStarted),
+ requestCount: sum(schema.agentStats.requestCount),
+ uniqueUsers: sql`count(distinct ${schema.agentStats.userId})`
+ })
+ .from(schema.agentStats)
+ .where(sql`${schema.agentStats.dayKey} >= ${sinceKey} AND ${schema.agentStats.userId} != ${ANON_AGENT_STATS_USER_ID}`)
const [voteRow] = await db
.select({
- upvotes: sql`sum(case when ${schema.agentVotes.isUpvoted} = 1 then 1 else 0 end)`,
- downvotes: sql`sum(case when ${schema.agentVotes.isUpvoted} = 0 then 1 else 0 end)`
+ upvotes: sql`sum(case when ${schema.votes.isUpvoted} = 1 then 1 else 0 end)`,
+ downvotes: sql`sum(case when ${schema.votes.isUpvoted} = 0 then 1 else 0 end)`
})
- .from(schema.agentVotes)
- .innerJoin(schema.agentChats, eq(schema.agentChats.id, schema.agentVotes.chatId))
- .where(gte(schema.agentChats.createdAt, since))
+ .from(schema.votes)
+ .innerJoin(schema.chats, eq(schema.chats.id, schema.votes.chatId))
+ .where(gte(schema.chats.createdAt, since))
- const totalChats = Number(globalRow?.chats ?? 0)
+ const totalChats = Number(globalRow?.chatsStarted ?? 0)
+ const totalRequests = Number(globalRow?.requestCount ?? 0)
return {
window: { sinceDays, since: since.toISOString() },
global: {
- chats: totalChats,
+ chatsStarted: totalChats,
+ requestCount: totalRequests,
+ errorCount: Number(globalRow?.errorCount ?? 0),
inputTokens: Number(globalRow?.inputTokens ?? 0),
outputTokens: Number(globalRow?.outputTokens ?? 0),
estimatedCost: Math.round(Number(globalRow?.estimatedCost ?? 0) * 1_000_000) / 1_000_000,
- averageDurationMs: totalChats ? Math.round(Number(globalRow?.durationMs ?? 0) / totalChats) : 0,
- requestCount: Number(globalRow?.requestCount ?? 0)
+ averageDurationMs: totalRequests ? Math.round(Number(globalRow?.durationMs ?? 0) / totalRequests) : 0
+ },
+ audience: {
+ anonymous: {
+ chatsStarted: Number(anonRow?.chatsStarted ?? 0),
+ requestCount: Number(anonRow?.requestCount ?? 0)
+ },
+ signedIn: {
+ uniqueUsers: Number(loggedRow?.uniqueUsers ?? 0),
+ chatsStarted: Number(loggedRow?.chatsStarted ?? 0),
+ requestCount: Number(loggedRow?.requestCount ?? 0)
+ }
},
votes: {
upvotes: Number(voteRow?.upvotes ?? 0),
downvotes: Number(voteRow?.downvotes ?? 0)
},
- byProvider: byProvider.map((r: ProviderRow) => ({
+ byProvider: byProvider.map((r: typeof byProvider[number]) => ({
provider: r.provider,
model: r.model,
- chats: r.chats,
+ chatsStarted: Number(r.chatsStarted ?? 0),
+ requestCount: Number(r.requestCount ?? 0),
inputTokens: Number(r.inputTokens ?? 0),
outputTokens: Number(r.outputTokens ?? 0),
estimatedCost: Math.round(Number(r.estimatedCost ?? 0) * 1_000_000) / 1_000_000
})),
- dailyUsage
+ dailyUsage: dailyUsage.map((r: typeof dailyUsage[number]) => ({
+ dayKey: r.dayKey,
+ chatsStarted: Number(r.chatsStarted ?? 0),
+ requestCount: Number(r.requestCount ?? 0),
+ errorCount: Number(r.errorCount ?? 0)
+ }))
}
}
})
diff --git a/server/mcp/tools/admin/get-agent-chat.ts b/server/mcp/tools/admin/get-agent-chat.ts
index ba582033f..06350c4e0 100644
--- a/server/mcp/tools/admin/get-agent-chat.ts
+++ b/server/mcp/tools/admin/get-agent-chat.ts
@@ -1,7 +1,7 @@
import { z } from 'zod'
-import { eq } from 'drizzle-orm'
+import { asc, eq } from 'drizzle-orm'
-function extractText(parts: AgentMessagePart[]): string {
+function extractText(parts: MessagePart[]): string {
return parts
.filter(p => p.type === 'text' && p.text)
.map(p => p.text!.trim())
@@ -28,40 +28,54 @@ WHEN TO USE: After spotting an interesting chat ID via \`admin_list_agent_chats\
async handler({ chatId, includeRawParts }) {
const [chat] = await db
.select()
- .from(schema.agentChats)
- .where(eq(schema.agentChats.id, chatId))
+ .from(schema.chats)
+ .where(eq(schema.chats.id, chatId))
.limit(1)
if (!chat) {
throw createError({ statusCode: 404, message: `Agent chat not found: ${chatId}` })
}
- type VoteRow = Pick
+ type VoteRow = Pick
const votes: VoteRow[] = await db
.select({
- messageId: schema.agentVotes.messageId,
- isUpvoted: schema.agentVotes.isUpvoted,
- createdAt: schema.agentVotes.createdAt
+ messageId: schema.votes.messageId,
+ isUpvoted: schema.votes.isUpvoted
})
- .from(schema.agentVotes)
- .where(eq(schema.agentVotes.chatId, chatId))
+ .from(schema.votes)
+ .where(eq(schema.votes.chatId, chatId))
const votesByMessage = new Map(votes.map(v => [v.messageId, v]))
- const messages = chat.messages.map((msg: AgentChatMessage) => {
+ type StoredMessageRow = Pick
+ const storedMessages: StoredMessageRow[] = await db
+ .select({
+ id: schema.messages.id,
+ role: schema.messages.role,
+ parts: schema.messages.parts,
+ createdAt: schema.messages.createdAt
+ })
+ .from(schema.messages)
+ .where(eq(schema.messages.chatId, chatId))
+ .orderBy(asc(schema.messages.createdAt))
+
+ const messages = storedMessages.map((msg: StoredMessageRow) => {
const vote = votesByMessage.get(msg.id)
+ const parts = (msg.parts ?? []) as MessagePart[]
return {
id: msg.id,
role: msg.role,
- text: extractText(msg.parts),
- ...(includeRawParts ? { parts: msg.parts } : {}),
- ...(vote ? { vote: vote.isUpvoted ? 'up' : 'down', votedAt: vote.createdAt } : {})
+ text: extractText(parts),
+ ...(includeRawParts ? { parts } : {}),
+ ...(vote ? { vote: vote.isUpvoted ? 'up' : 'down' } : {})
}
})
return {
id: chat.id,
- fingerprint: chat.fingerprint,
+ userId: chat.userId,
+ title: chat.title,
+ visibility: chat.visibility,
model: chat.model,
provider: chat.provider,
stats: {
@@ -77,7 +91,6 @@ WHEN TO USE: After spotting an interesting chat ID via \`admin_list_agent_chats\
downvotes: votes.filter((v: VoteRow) => !v.isUpvoted).length
},
createdAt: chat.createdAt,
- updatedAt: chat.updatedAt,
messages
}
}
diff --git a/server/mcp/tools/admin/list-agent-chats.ts b/server/mcp/tools/admin/list-agent-chats.ts
index 35c906af0..6f64a54cc 100644
--- a/server/mcp/tools/admin/list-agent-chats.ts
+++ b/server/mcp/tools/admin/list-agent-chats.ts
@@ -28,25 +28,26 @@ WHEN TO USE: Use this tool to find expensive sessions, slow sessions, or session
const filters: SQL[] = []
if (sinceDays) {
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)
- filters.push(gte(schema.agentChats.createdAt, since))
+ filters.push(gte(schema.chats.createdAt, since))
}
- if (until) filters.push(lte(schema.agentChats.createdAt, new Date(until)))
- if (provider) filters.push(eq(schema.agentChats.provider, provider))
- if (model) filters.push(eq(schema.agentChats.model, model))
+ if (until) filters.push(lte(schema.chats.createdAt, new Date(until)))
+ if (provider) filters.push(eq(schema.chats.provider, provider))
+ if (model) filters.push(eq(schema.chats.model, model))
const sortColumn = {
- createdAt: schema.agentChats.createdAt,
- updatedAt: schema.agentChats.updatedAt,
- estimatedCost: schema.agentChats.estimatedCost,
- durationMs: schema.agentChats.durationMs,
- inputTokens: schema.agentChats.inputTokens,
- outputTokens: schema.agentChats.outputTokens
+ createdAt: schema.chats.createdAt,
+ updatedAt: schema.chats.updatedAt,
+ estimatedCost: schema.chats.estimatedCost,
+ durationMs: schema.chats.durationMs,
+ inputTokens: schema.chats.inputTokens,
+ outputTokens: schema.chats.outputTokens
}[sortBy]
- const upvotesExpr = sql`coalesce(sum(case when ${schema.agentVotes.isUpvoted} = 1 then 1 else 0 end), 0)`
- const downvotesExpr = sql`coalesce(sum(case when ${schema.agentVotes.isUpvoted} = 0 then 1 else 0 end), 0)`
+ const upvotesExpr = sql`coalesce(sum(case when ${schema.votes.isUpvoted} = 1 then 1 else 0 end), 0)`
+ const downvotesExpr = sql`coalesce(sum(case when ${schema.votes.isUpvoted} = 0 then 1 else 0 end), 0)`
+ const messageCountExpr = sql`(select count(*) from ${schema.messages} where ${schema.messages.chatId} = ${schema.chats.id})`
- type ChatRow = Omit & {
+ type ChatRow = Chat & {
messageCount: number
upvotes: number
downvotes: number
@@ -54,25 +55,27 @@ WHEN TO USE: Use this tool to find expensive sessions, slow sessions, or session
const rows: ChatRow[] = await db
.select({
- id: schema.agentChats.id,
- fingerprint: schema.agentChats.fingerprint,
- model: schema.agentChats.model,
- provider: schema.agentChats.provider,
- inputTokens: schema.agentChats.inputTokens,
- outputTokens: schema.agentChats.outputTokens,
- estimatedCost: schema.agentChats.estimatedCost,
- durationMs: schema.agentChats.durationMs,
- requestCount: schema.agentChats.requestCount,
- messageCount: sql`json_array_length(${schema.agentChats.messages})`,
+ id: schema.chats.id,
+ userId: schema.chats.userId,
+ title: schema.chats.title,
+ visibility: schema.chats.visibility,
+ model: schema.chats.model,
+ provider: schema.chats.provider,
+ inputTokens: schema.chats.inputTokens,
+ outputTokens: schema.chats.outputTokens,
+ estimatedCost: schema.chats.estimatedCost,
+ durationMs: schema.chats.durationMs,
+ requestCount: schema.chats.requestCount,
+ messageCount: messageCountExpr,
upvotes: upvotesExpr,
downvotes: downvotesExpr,
- createdAt: schema.agentChats.createdAt,
- updatedAt: schema.agentChats.updatedAt
+ createdAt: schema.chats.createdAt,
+ updatedAt: schema.chats.updatedAt
})
- .from(schema.agentChats)
- .leftJoin(schema.agentVotes, eq(schema.agentVotes.chatId, schema.agentChats.id))
+ .from(schema.chats)
+ .leftJoin(schema.votes, eq(schema.votes.chatId, schema.chats.id))
.where(filters.length ? and(...filters) : undefined)
- .groupBy(schema.agentChats.id)
+ .groupBy(schema.chats.id)
.having(hasDownvotes ? sql`${downvotesExpr} > 0` : undefined)
.orderBy(desc(sortColumn))
.limit(limit)
diff --git a/server/mcp/tools/admin/list-agent-votes.ts b/server/mcp/tools/admin/list-agent-votes.ts
index 4f51cd278..074bc0355 100644
--- a/server/mcp/tools/admin/list-agent-votes.ts
+++ b/server/mcp/tools/admin/list-agent-votes.ts
@@ -1,7 +1,7 @@
import { z } from 'zod'
import { and, desc, eq, gte, type SQL } from 'drizzle-orm'
-function extractText(parts: AgentMessagePart[]): string {
+function extractText(parts: MessagePart[]): string {
return parts
.filter(p => p.type === 'text' && p.text)
.map(p => p.text!.trim())
@@ -34,32 +34,40 @@ WHEN TO USE: Use this tool to read what users actually disliked. Default sort is
}
const filters: SQL[] = []
- if (onlyDownvotes) filters.push(eq(schema.agentVotes.isUpvoted, false))
- if (onlyUpvotes) filters.push(eq(schema.agentVotes.isUpvoted, true))
+ if (onlyDownvotes) filters.push(eq(schema.votes.isUpvoted, false))
+ if (onlyUpvotes) filters.push(eq(schema.votes.isUpvoted, true))
if (sinceDays) {
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)
- filters.push(gte(schema.agentVotes.createdAt, since))
+ filters.push(gte(schema.chats.createdAt, since))
}
- type VoteJoinRow = Pick
- & Pick
- & { votedAt: Date, chatCreatedAt: Date }
+ type VoteJoinRow = {
+ chatId: string
+ messageId: string
+ isUpvoted: boolean
+ provider: string | null
+ model: string | null
+ chatCreatedAt: Date
+ messageRole: 'user' | 'assistant' | 'system' | null
+ messageParts: MessagePart[] | null
+ }
const rows: VoteJoinRow[] = await db
.select({
- chatId: schema.agentVotes.chatId,
- messageId: schema.agentVotes.messageId,
- isUpvoted: schema.agentVotes.isUpvoted,
- votedAt: schema.agentVotes.createdAt,
- messages: schema.agentChats.messages,
- provider: schema.agentChats.provider,
- model: schema.agentChats.model,
- chatCreatedAt: schema.agentChats.createdAt
+ chatId: schema.votes.chatId,
+ messageId: schema.votes.messageId,
+ isUpvoted: schema.votes.isUpvoted,
+ provider: schema.chats.provider,
+ model: schema.chats.model,
+ chatCreatedAt: schema.chats.createdAt,
+ messageRole: schema.messages.role,
+ messageParts: schema.messages.parts as never
})
- .from(schema.agentVotes)
- .innerJoin(schema.agentChats, eq(schema.agentChats.id, schema.agentVotes.chatId))
+ .from(schema.votes)
+ .innerJoin(schema.chats, eq(schema.chats.id, schema.votes.chatId))
+ .leftJoin(schema.messages, eq(schema.messages.id, schema.votes.messageId))
.where(filters.length ? and(...filters) : undefined)
- .orderBy(desc(schema.agentVotes.createdAt))
+ .orderBy(desc(schema.chats.createdAt))
.limit(limit)
.offset(offset)
@@ -67,20 +75,16 @@ WHEN TO USE: Use this tool to read what users actually disliked. Default sort is
total: rows.length,
offset,
limit,
- rows: rows.map((r: VoteJoinRow) => {
- const message = r.messages.find((m: AgentChatMessage) => m.id === r.messageId)
- return {
- chatId: r.chatId,
- messageId: r.messageId,
- vote: r.isUpvoted ? 'up' : 'down',
- votedAt: r.votedAt,
- provider: r.provider,
- model: r.model,
- chatCreatedAt: r.chatCreatedAt,
- messageRole: message?.role,
- messageText: message ? extractText(message.parts).slice(0, 1000) : undefined
- }
- })
+ rows: rows.map((r: VoteJoinRow) => ({
+ chatId: r.chatId,
+ messageId: r.messageId,
+ vote: r.isUpvoted ? 'up' : 'down',
+ provider: r.provider,
+ model: r.model,
+ chatCreatedAt: r.chatCreatedAt,
+ messageRole: r.messageRole,
+ messageText: r.messageParts ? extractText(r.messageParts).slice(0, 1000) : undefined
+ }))
}
}
})
diff --git a/server/mcp/tools/modules/list-modules.ts b/server/mcp/tools/modules/list-modules.ts
index 88be3636e..466be7f1b 100644
--- a/server/mcp/tools/modules/list-modules.ts
+++ b/server/mcp/tools/modules/list-modules.ts
@@ -100,6 +100,7 @@ OUTPUT: Returns list of modules with name, description, category, stats. Use get
name: module.name,
description: module.description,
npm: module.npm,
+ icon: module.icon,
repo: module.repo,
github: module.github,
website: module.website,
diff --git a/server/utils/agent-fingerprint.ts b/server/utils/agent-fingerprint.ts
deleted file mode 100644
index 9b0d269d9..000000000
--- a/server/utils/agent-fingerprint.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { H3Event } from 'h3'
-
-export async function getAgentFingerprint(event: H3Event): Promise {
- const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
- const userAgent = getHeader(event, 'user-agent') || 'unknown'
- const domain = getHeader(event, 'host') || 'localhost'
- const data = `${domain}+${ip}+${userAgent}`
- const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(data))
- return [...new Uint8Array(buffer)].map(b => b.toString(16).padStart(2, '0')).join('')
-}
diff --git a/server/utils/rate-limit.ts b/server/utils/rate-limit.ts
deleted file mode 100644
index 654567b28..000000000
--- a/server/utils/rate-limit.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { H3Event } from 'h3'
-import { eq, sql } from 'drizzle-orm'
-
-type DbTransaction = Parameters[0]>[0]
-
-const DAILY_LIMIT = 20
-
-function todayKey(ip: string): string {
- const date = new Date().toISOString().slice(0, 10)
- return `rate:agent:${ip}:${date}`
-}
-
-function resolveIP(event: H3Event): string {
- return getRequestIP(event, { xForwardedFor: true }) || 'unknown'
-}
-
-export async function checkAgentRateLimit(event: H3Event): Promise<{ used: number, remaining: number, limit: number }> {
- const ip = resolveIP(event)
- const key = todayKey(ip)
- const rows = await db.select().from(schema.agentDailyUsage).where(eq(schema.agentDailyUsage.dayKey, key)).limit(1)
- const used = rows[0]?.count ?? 0
- const remaining = Math.max(0, DAILY_LIMIT - used)
-
- return { used, remaining, limit: DAILY_LIMIT }
-}
-
-export async function consumeAgentRateLimit(event: H3Event): Promise<{ used: number, remaining: number, limit: number }> {
- const ip = resolveIP(event)
- const key = todayKey(ip)
-
- return await db.transaction(async (tx: DbTransaction) => {
- await tx.insert(schema.agentDailyUsage).values({ dayKey: key, count: 1 })
- .onConflictDoUpdate({
- target: schema.agentDailyUsage.dayKey,
- set: { count: sql`${schema.agentDailyUsage.count} + 1` }
- })
- const [row] = await tx.select().from(schema.agentDailyUsage).where(eq(schema.agentDailyUsage.dayKey, key))
- const used = row!.count
- if (used > DAILY_LIMIT) {
- throw createError({
- statusCode: 429,
- message: `You've reached the daily limit of ${DAILY_LIMIT} messages. Try again tomorrow.`
- })
- }
- return { used, remaining: DAILY_LIMIT - used, limit: DAILY_LIMIT }
- })
-}
diff --git a/shared/types/auth.d.ts b/shared/types/auth.d.ts
index fbbd27f98..20dc2083c 100644
--- a/shared/types/auth.d.ts
+++ b/shared/types/auth.d.ts
@@ -1,7 +1,13 @@
declare module '#auth-utils' {
interface User {
- avatar_url: string
- login: string
+ id: string
+ name: string
+ email?: string | null
+ avatar: string
+ username: string
+ provider: 'github'
+ providerId: string
+ role: 'user' | 'admin'
}
}
diff --git a/shared/types/db.ts b/shared/types/db.ts
index 13f497168..186df7410 100644
--- a/shared/types/db.ts
+++ b/shared/types/db.ts
@@ -1,16 +1,21 @@
-import type { feedback, agentChats, agentVotes, agentDailyUsage } from '@nuxthub/db/schema'
+import type { feedback, users, chats, messages, votes, agentDailyUsage } from '@nuxthub/db/schema'
export type Feedback = typeof feedback.$inferSelect
export type NewFeedback = typeof feedback.$inferInsert
-export type AgentChat = typeof agentChats.$inferSelect
-export type NewAgentChat = typeof agentChats.$inferInsert
+export type User = typeof users.$inferSelect
+export type NewUser = typeof users.$inferInsert
-export type AgentVote = typeof agentVotes.$inferSelect
-export type NewAgentVote = typeof agentVotes.$inferInsert
+export type Chat = typeof chats.$inferSelect
+export type NewChat = typeof chats.$inferInsert
+
+export type ChatMessage = typeof messages.$inferSelect
+export type NewChatMessage = typeof messages.$inferInsert
+
+export type Vote = typeof votes.$inferSelect
+export type NewVote = typeof votes.$inferInsert
export type AgentDailyUsage = typeof agentDailyUsage.$inferSelect
export type NewAgentDailyUsage = typeof agentDailyUsage.$inferInsert
-export type AgentMessagePart = { type: string, text?: string }
-export type AgentChatMessage = { id: string, role: string, parts: AgentMessagePart[] }
+export type MessagePart = { type: string, text?: string }
diff --git a/specs/nuxi-on-ash.md b/specs/nuxi-on-ash.md
new file mode 100644
index 000000000..23d44908a
--- /dev/null
+++ b/specs/nuxi-on-ash.md
@@ -0,0 +1,328 @@
+# Spec: Rebuild Nuxi on Ash + Chat SDK
+
+## TL;DR
+
+Migrate Nuxi from a monolithic Nuxt server endpoint to a file-based agent
+defined with [Ash](https://github.com/hugorichard/ash) (running in this repo),
+exposed to the web through the [`@chat-adapter/web`](https://chat-sdk.dev/adapters/official/web)
+adapter of [Chat SDK](https://chat-sdk.dev). This decouples the agent
+definition (instructions, tools, MCP connections) from the transport layer,
+keeps the existing `useChat` front-end protocol intact, and unlocks adding
+Slack / Discord / Linear / etc. without rewriting business logic.
+
+## Goals
+
+1. Agent definition lives on disk in `agent/`, not inline in route handlers.
+2. One handler implementation, several inbound platforms (web first, then chat
+ platforms via Chat SDK adapters).
+3. Front-end stays on the AI SDK UI message stream protocol β minimal client
+ changes.
+4. Durable runs: a request that triggers a long tool loop survives function
+ restarts / cold starts.
+5. Reversible. Each phase is independently shippable and revertible.
+
+## Non-goals
+
+- Replacing the Drizzle `chats` / `messages` tables. They remain the source of
+ truth for UI state (title, history, votes, branches).
+- Removing the existing `useChat` integration on the front-end.
+- Adding new platforms before the web migration is stable.
+
+## Current Architecture
+
+Today Nuxi is implemented as a single Nuxt endpoint:
+
+- `layers/nuxi/server/api/chats/[id].post.ts` β owns the entire AI loop
+ - System prompt hard-coded as `baseSystemPrompt`
+ - `streamText` from `ai` (AI SDK)
+ - Tools defined inline (`showModuleTool`, `showTemplateTool`,
+ `showBlogPostTool`, `showHostingTool`, `openPlaygroundTool`,
+ `reportIssueTool`, `createSearchGitHubIssuesTool`)
+ - MCP tools pulled from the local `/mcp` endpoint via `createMCPClient`
+ - Anthropic `claude-sonnet-4-6` direct (via AI Gateway provider string)
+ - Title generation done inline against `openai/gpt-4.1-nano`
+ - Token / cost / duration accounting written back to `chats` row
+- `app/components/agent/AgentPanel*` + `useAgentChat.ts` consume the stream
+ with AI SDK's `useChat`.
+
+Strengths: it works, the front-end is clean, the protocol is standard.
+Limitations: not portable (each new platform = new code path), agent
+definition is buried in TypeScript, no durability across cold starts, hard to
+test in isolation.
+
+## Target Architecture
+
+```
+ βββββββββββββββββββββββββββββββ
+ [Nuxt front: useChat] β Chat SDK (Chat instance) β
+ β β β
+ β POST β onDirectMessage β
+ βΌββββββββββββββββββΆβ onNewMention β
+ /api/chat (web webhook) β onSubscribedMessage β
+ β β β
+ [Slack adapter] β βΌ β
+ [Discord adapter] βββββββββββΆβ Common handler: β
+ [Linear adapter] β invoke Ash agent β
+ β thread.post(stream) β
+ βββββββββββββ¬ββββββββββββββββββ
+ β
+ βΌ
+ βββββββββββββββββββββββββββββββ
+ β Ash agent (agent/) β
+ β β
+ β instructions.md β
+ β tools/*.ts β
+ β connections/nuxi-mcp/ β
+ β skills/*.md (later) β
+ β subagents/ (later) β
+ β schedules/ (later) β
+ βββββββββββββββββββββββββββββββ
+```
+
+**Key invariants:**
+
+- Chat SDK owns transport, threads, dedupe, subscriptions, message delivery.
+- Ash owns the agent loop, durability, tool/MCP wiring, prompt definition.
+- The bridge is one function: given a user message + auth context, run the
+ Ash agent and stream chunks back to `thread.post`.
+- Front-end keeps `useChat` β `@chat-adapter/web` speaks the AI SDK UI
+ message stream protocol.
+
+## Repository Layout
+
+```
+nuxt.com/
+βββ agent/ # Ash agent (top-level, idiomatic Ash)
+β βββ agent.ts # additive runtime config (model, buildβ¦)
+β βββ instructions.md # ex-`baseSystemPrompt`
+β βββ tools/
+β β βββ show-module.ts
+β β βββ show-template.ts
+β β βββ show-blog-post.ts
+β β βββ show-hosting.ts
+β β βββ open-playground.ts
+β β βββ report-issue.ts
+β β βββ search-github-issues.ts
+β βββ connections/
+β β βββ nuxi-mcp/ # connects to local /mcp endpoint
+β β βββ connection.ts
+β βββ channels/ # optional β Chat SDK is upstream, but
+β β # Ash native channel can also be exposed
+β βββ skills/ # phase 3+: extract procedures from prompt
+β βββ subagents/ # phase 3+: e.g. doc-finder, code-reviewer
+β βββ schedules/ # phase 3+: e.g. weekly digest
+βββ server/
+β βββ chat.ts # Chat SDK Chat instance + adapter config
+β βββ api/
+β β βββ chat.post.ts # @chat-adapter/web webhook
+β β βββ chats/ # existing CRUD stays for UI (history, etc.)
+β βββ β¦
+βββ layers/
+β βββ nuxi/ # gets slimmer over time; UI stays here
+βββ specs/
+ βββ nuxi-on-ash.md # this doc
+```
+
+`agent.ts` should pin the model and any Ash-level config:
+
+```ts
+import { defineAgent } from "experimental-ash";
+
+export default defineAgent({
+ model: "anthropic/claude-sonnet-4.6", // via Vercel AI Gateway
+ // workspace, build, compaction options as needed
+});
+```
+
+## Migration Plan
+
+Each phase is independently mergeable.
+
+### Phase 1 β Chat SDK wrap, logic untouched
+
+**Goal:** prove Chat SDK web adapter works end-to-end without changing the
+AI logic yet.
+
+1. Add deps: `chat`, `@chat-adapter/web`, `@chat-adapter/web/vue`.
+2. Create `server/chat.ts`:
+ ```ts
+ import { Chat } from "chat";
+ import { createWebAdapter } from "@chat-adapter/web";
+ import { createRedisState } from "@chat-adapter/state-redis"; // or alt
+
+ export const bot = new Chat({
+ userName: "nuxi",
+ adapters: {
+ web: createWebAdapter({
+ getUser: async (req) => {
+ const session = await getUserSession(req);
+ return { id: session.user?.id ?? session.id, name: session.user?.username };
+ }
+ })
+ },
+ state: createRedisState({ /* or in-memory for first PoC */ }),
+ });
+
+ bot.onDirectMessage(async (thread, message) => {
+ // For phase 1: inline the current streamText logic from
+ // layers/nuxi/server/api/chats/[id].post.ts
+ const stream = runCurrentNuxiLoop(message.text, thread);
+ await thread.post(stream);
+ });
+ ```
+3. Create `server/api/chat.post.ts` that wires `bot.webhooks.web` to the
+ route.
+4. Front-end: keep `useChat`, point `api` at `/api/chat`. Thread mapping is
+ `web:{user.id}:{conversationId}`.
+5. Decide what to do with existing `/api/chats/[id]` endpoints: they likely
+ stay for chat CRUD (list, title, branch, delete) β only the streaming POST
+ gets routed through Chat SDK.
+6. Validate: streaming works, `stop()` from `useChat` aborts on server, code
+ snippets render, tools (`show_module` etc.) fire.
+
+**Exit criteria:** the web experience is unchanged from the user's
+perspective; the request path now goes through Chat SDK.
+
+### Phase 2 β Extract the agent to Ash
+
+**Goal:** move prompt, tools, and MCP wiring into `agent/`.
+
+1. Add Ash dep: `experimental-ash` + CLI.
+2. Scaffold `agent/` directory with `instructions.md`, `agent.ts`, and an
+ empty `tools/` folder.
+3. Move `baseSystemPrompt` body β `agent/instructions.md`. Keep the dynamic
+ "Current page" prefix as a runtime context passed by the handler (Ash
+ supports per-step context).
+4. Convert each inline tool to `agent/tools/.ts` using
+ `defineTool` from `experimental-ash/tools`. Mirror current `inputSchema`
+ and `execute` bodies.
+5. Set up `agent/connections/nuxi-mcp/connection.ts` pointing at the local
+ `/mcp` endpoint. Decide if MCP discovery happens at build-time
+ (compile-once) or per-run.
+6. In `server/chat.ts`, replace the inline `runCurrentNuxiLoop` with an
+ invocation of the compiled Ash agent. The bridge function takes the user
+ message + auth + page context and returns an `AsyncIterable` suitable for
+ `thread.post`.
+7. Move title generation and token/cost accounting out of the AI loop:
+ - Title generation β an Ash hook or a Chat SDK side effect after the
+ first user message.
+ - Token / cost accounting β consume Ash's run metadata (it exposes
+ model/provider/usage on session finish).
+8. Update Vercel build to invoke `ash build` (or whatever the CLI is) before
+ `nuxt build`. The compiled artifacts live under `.ash/` β gitignore them
+ and rebuild on each deploy.
+
+**Exit criteria:** `layers/nuxi/server/api/chats/[id].post.ts` no longer
+contains a system prompt or tool definitions β only the bridge call. The
+agent is fully described by files under `agent/`.
+
+### Phase 3 β Hardening + extensions
+
+Once phases 1 and 2 are stable:
+
+- Extract recurring procedures from `instructions.md` into `agent/skills/`
+ (e.g. "How to answer a debugging / error question", "How to handle a
+ module request").
+- Promote heavy sub-tasks into `agent/subagents/` (e.g. a doc-finder agent
+ that owns `list-documentation-pages` + `get-documentation-page` workflows).
+- Add `agent/schedules/` if any recurring task makes sense (cron-style).
+- Add additional Chat SDK adapters as needed:
+ - Slack: `createSlackAdapter()` β onNewMention handler already exists, no
+ business logic change.
+ - Discord, Linear, Telegram, etc. β same pattern.
+
+## Storage Strategy
+
+We have three storage layers; keep them orthogonal:
+
+| Layer | Owns | Source of truth for |
+|---|---|---|
+| Drizzle `chats` / `messages` | User-visible history | Title, ordering, votes, branches, visibility, tokens/cost rollup |
+| Chat SDK state | Transport bookkeeping | Subscriptions, locks, dedupe, threadβplatform mapping |
+| Ash sessions / runs | Agent runtime | Continuation tokens, run state, durable replay |
+
+Decisions to make at implementation time:
+
+- **Mapping**: the Chat SDK thread id (`web:{user.id}:{conversationId}`)
+ should map 1:1 with `chats.id`. Persist this mapping or derive it.
+- **Chat SDK state backend**: Redis (`@chat-adapter/state-redis`) is the
+ default. We could also write a Drizzle-backed adapter to avoid a new
+ service β confirm worth the effort.
+- **Ash storage**: Ash has its own session/run persistence. Verify it works
+ on Vercel (Fluid Compute) and check whether it needs Redis / KV / Blob.
+
+## Auth
+
+- The current OAuth GitHub flow in `server/api/auth/github.get.ts` stays as-is.
+- The Chat SDK `getUser` function reads the existing session cookie and
+ returns the same user id used everywhere else.
+- The Ash agent receives the user id via the bridge call and uses it to
+ scope MCP connections / rate limits / tool access.
+
+The anonymous-vs-authenticated chat semantics from
+[`server/api/auth/github.get.ts`](../server/api/auth/github.get.ts) should be
+preserved: a logged-out user can still chat; their session.id is the user id
+until they log in.
+
+## Build & Deploy
+
+- Ash compiles to `.ash/`. Add to `.gitignore`. Run `ash build` as a pre-step
+ in `package.json` `build` script so Vercel picks it up.
+- Verify that Ash's runtime is compatible with Vercel Fluid Compute (default).
+ Both projects target Vercel, this should be fine.
+- Add `agent/` and `server/chat.ts` to the deployment ignore list for
+ prerender β they should not be crawled (similar to `/mcp` ignore added
+ for the chat-history branch).
+
+## Front-end Considerations
+
+`@chat-adapter/web/vue` exposes a reactive `Chat` instance. Decide whether to:
+
+- (A) Continue using AI SDK's `useChat` directly. The web adapter already
+ speaks the AI SDK UI message stream, so this works. Simpler migration.
+- (B) Switch to `@chat-adapter/web/vue`'s `useChat()` to gain Chat SDK
+ features (typed threads, etc.). More idiomatic to Chat SDK.
+
+Pick (A) for phase 1 to keep the diff small, evaluate (B) later.
+
+## Open Questions
+
+1. **State adapter:** Redis vs. a custom Drizzle adapter for Chat SDK state.
+ What's already provisioned on Vercel? NuxtHub KV?
+2. **MCP discovery timing:** Does Ash compile MCP tool catalogs at build
+ time, or fetch them at run time? Affects cold start.
+3. **Title generation:** Side effect outside the agent loop, or an Ash hook?
+4. **Branch & explore feature:** the current `/api/chats/[id]/branch.post.ts`
+ forks a conversation. Does that map cleanly to Chat SDK threads, or do we
+ keep it as a custom CRUD operation that creates a new thread mapping?
+5. **Admin chat view:** the dashboard listing admin chats β stays on the
+ existing `/api/chats` GET endpoint, unchanged.
+6. **Rate limiting:** currently `consumeAgentRateLimit(event)` happens
+ per-request. Move into a Chat SDK hook or keep in the route handler?
+7. **Telemetry / evlog:** the current handler wires evlog into AI SDK via
+ `createAILogger` + `createEvlogIntegration`. Ash needs the equivalent β
+ confirm Ash exposes hooks for per-step telemetry.
+
+## References
+
+- Current Nuxi entry point: `layers/nuxi/server/api/chats/[id].post.ts`
+- Tools defined in `layers/nuxi/server/utils/tools/`
+- Auth flow: `server/api/auth/github.get.ts`
+- DB schema: `layers/nuxi/server/db/schema.ts`
+- Ash: `/Users/hugorichard/Dev/ash` (local), `README.md`, `ARCHITECTURE.md`
+- Chat SDK docs: https://chat-sdk.dev
+- Chat SDK Web adapter: https://chat-sdk.dev/adapters/official/web
+- Chat SDK Vue integration: `@chat-adapter/web/vue`
+
+## Risks
+
+- **Ash maturity:** the package is `experimental-ash`. Expect API churn. Pin
+ versions and budget for upgrades.
+- **Streaming protocol drift:** if the AI SDK UI message stream format
+ changes between Chat SDK and the front-end version, debugging will be
+ painful. Lock matching versions.
+- **Vercel build time:** adding `ash build` to the build pipeline adds time.
+ Verify it fits within the build budget (the chat-history branch already
+ had build-timeout issues β addressed but a regression here would hurt).
+- **Two transport frameworks:** Chat SDK and Ash both have channel concepts.
+ Be deliberate about which owns what; do not duplicate.
diff --git a/vercel.json b/vercel.json
deleted file mode 100644
index a830cace7..000000000
--- a/vercel.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "crons": [
- {
- "path": "/api/agent/cleanup",
- "schedule": "0 3 * * *"
- }
- ]
-}