diff --git a/app/app.config.ts b/app/app.config.ts index 0a21908c0..30084f9fe 100644 --- a/app/app.config.ts +++ b/app/app.config.ts @@ -1,4 +1,32 @@ export default defineAppConfig({ + agent: { + faqQuestions: [ + { + category: 'Getting Started', + items: [ + 'Show me available starter templates', + 'What\'s new in Nuxt 4?', + 'How do I add authentication to my Nuxt app?' + ] + }, + { + category: 'Features', + items: [ + 'useFetch vs useAsyncData?', + 'How does file-based routing work?', + 'How do I connect a database to my Nuxt app?' + ] + }, + { + category: 'Deploy & Explore', + items: [ + 'How do I deploy my Nuxt app?', + 'What are the available rendering modes?', + 'How do I add SEO meta tags in Nuxt?' + ] + } + ] + }, ui: { colors: { primary: 'green', diff --git a/app/app.vue b/app/app.vue index d1c998a7a..1ef736caa 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,5 +1,6 @@ - + [0] + +export function getSearchQuery(part: ToolPart): string | undefined { + return (part.input as { query?: string } | undefined)?.query +} + +export function getSources(part: ToolPart): Source[] { + const output = part.output + if (!output) return [] + + if (Array.isArray(output)) { + return output.filter((s: Source) => s.url).map((s: Source) => ({ url: s.url, title: s.title })) + } + + const typed = output as SearchOutput + if (typed.sources) { + return typed.sources.filter(s => s.url).map(s => ({ url: s.url })) + } + + return [] +} + +export function sourceToInlineMdc(url: string): string { + const domain = getDomain(url) + const favicon = getFaviconUrl(url) + const safeUrl = url.replace(/"/g, '"') + const safeFavicon = favicon.replace(/"/g, '"') + + return ` :source-link{url="${safeUrl}" favicon="${safeFavicon}" label="${domain}"}` +} diff --git a/app/utils/url.ts b/app/utils/url.ts new file mode 100644 index 000000000..b6011d884 --- /dev/null +++ b/app/utils/url.ts @@ -0,0 +1,11 @@ +export function getDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url + } +} + +export function getFaviconUrl(url: string): string { + return `https://www.google.com/s2/favicons?sz=32&domain=${getDomain(url)}` +} diff --git a/nuxt.config.ts b/nuxt.config.ts index e60920625..c76ab09b3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -25,7 +25,9 @@ export default defineNuxtConfig({ '@nuxtjs/mcp-toolkit', '@nuxt/hints', '@vercel/analytics', - '@vercel/speed-insights' + '@vercel/speed-insights', + '@comark/nuxt', + 'evlog/nuxt' ], $development: { site: { @@ -401,6 +403,7 @@ export default defineNuxtConfig({ sourcemap: true, experimental: { extractAsyncDataHandlers: true, + viewTransition: true, defaults: { nuxtLink: { externalRelAttribute: 'noopener' @@ -462,6 +465,9 @@ export default defineNuxtConfig({ } } }, + evlog: { + env: { service: 'nuxt-com' } + }, icon: { customCollections: [{ prefix: 'custom', diff --git a/package.json b/package.json index 59f3ec444..318b2ca86 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "db:migrate": "nuxt hub database migrate" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.68", + "@ai-sdk/gateway": "^3.0.83", + "@ai-sdk/mcp": "^1.0.25", + "@ai-sdk/vue": "^3.0.141", + "@comark/nuxt": "^0.2.0", "@iconify-json/heroicons": "^1.2.3", "@iconify-json/logos": "^1.2.10", "@iconify-json/lucide": "^1.2.96", @@ -34,7 +39,7 @@ "@nuxt/hints": "1.0.0-alpha.10", "@nuxt/image": "^2.0.0", "@nuxt/scripts": "^0.13.2", - "@nuxt/ui": "^4.5.1", + "@nuxt/ui": "^4.6.1", "@nuxthub/core": "^0.10.7", "@nuxtjs/html-validator": "^2.1.0", "@nuxtjs/mcp-toolkit": "^0.7.0", @@ -47,9 +52,11 @@ "@vueuse/components": "^14.2.1", "@vueuse/core": "^14.2.1", "@vueuse/nuxt": "^14.2.1", - "better-sqlite3": "^12.6.2", + "ai": "^6.0.161", + "better-sqlite3": "^12.8.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", + "evlog": "^2.12.0", "feed": "^5.2.0", "h3": "^1.15.6", "little-date": "^1.2.1", @@ -62,13 +69,13 @@ "ofetch": "^1.5.1", "resend": "^6.9.3", "scule": "^1.3.0", + "shaders": "^2.5.93", "sitemap": "^9.0.1", "std-env": "^4.0.0", "ufo": "^1.6.3", "valibot": "^1.2.0" }, "devDependencies": { - "@ai-sdk/mcp": "^1.0.25", "@iconify-json/vscode-icons": "^1.2.44", "@nuxt/devtools": "^3.2.3", "@nuxt/eslint": "^1.15.2", @@ -80,7 +87,6 @@ "@types/semver": "^7.7.1", "@types/youtube": "^0.1.2", "@vue/test-utils": "^2.4.6", - "ai": "^6.0.116", "capture-website": "^5.1.0", "drizzle-kit": "^0.31.9", "eslint": "^10.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a0b654a3..24a2fe8d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,21 @@ importers: .: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.68 + version: 3.0.68(zod@4.3.6) + '@ai-sdk/gateway': + specifier: ^3.0.83 + version: 3.0.91(zod@4.3.6) + '@ai-sdk/mcp': + specifier: ^1.0.25 + version: 1.0.35(zod@4.3.6) + '@ai-sdk/vue': + specifier: ^3.0.141 + version: 3.0.154(vue@3.5.32(typescript@6.0.2))(zod@4.3.6) + '@comark/nuxt': + specifier: ^0.2.0 + version: 0.2.1(@types/markdown-it@14.1.2)(magicast@0.5.2)(markdown-it@14.1.1)(nuxt@4.4.2(445499489540052a4c2e3761b79c8923))(shiki@4.0.2)(vue@3.5.32(typescript@6.0.2)) '@iconify-json/heroicons': specifier: ^1.2.3 version: 1.2.3 @@ -42,7 +57,7 @@ importers: specifier: ^0.13.2 version: 0.13.2(@types/youtube@0.1.2)(@unhead/vue@2.1.13(vue@3.5.32(typescript@6.0.2)))(@vercel/functions@3.4.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)))(ioredis@5.10.1)(magicast@0.5.2)(typescript@6.0.2)(vue@3.5.32(typescript@6.0.2)) '@nuxt/ui': - specifier: ^4.5.1 + specifier: ^4.6.1 version: 4.6.1(@nuxt/content@3.12.0(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0))(magicast@0.5.2)(valibot@1.3.1(typescript@6.0.2)))(@tiptap/extensions@3.22.2(@tiptap/core@3.22.2(@tiptap/pm@3.22.2))(@tiptap/pm@3.22.2))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(@vercel/functions@3.4.3)(change-case@5.4.4)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)))(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(valibot@1.3.1(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30)(zod@4.3.6) '@nuxthub/core': specifier: ^0.10.7 @@ -80,8 +95,11 @@ importers: '@vueuse/nuxt': specifier: ^14.2.1 version: 14.2.1(magicast@0.5.2)(nuxt@4.4.2(445499489540052a4c2e3761b79c8923))(vue@3.5.32(typescript@6.0.2)) + ai: + specifier: ^6.0.161 + version: 6.0.161(zod@4.3.6) better-sqlite3: - specifier: ^12.6.2 + specifier: ^12.8.0 version: 12.8.0 date-fns: specifier: ^4.1.0 @@ -89,6 +107,9 @@ importers: drizzle-orm: specifier: ^0.45.1 version: 0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0) + evlog: + specifier: ^2.12.0 + version: 2.12.0(@nuxt/kit@4.4.2(magicast@0.5.2))(ai@6.0.161(zod@4.3.6))(express@5.2.1)(fastify@5.8.4)(h3@1.15.11)(hono@4.12.12)(nitropack@2.13.3(@libsql/client@0.17.2)(@vercel/functions@3.4.3)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0))(rolldown@1.0.0-beta.57(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(srvx@0.11.15))(ofetch@1.5.1)(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) feed: specifier: ^5.2.0 version: 5.2.0 @@ -125,6 +146,9 @@ importers: scule: specifier: ^1.3.0 version: 1.3.0 + shaders: + specifier: ^2.5.93 + version: 2.5.93(vue@3.5.32(typescript@6.0.2)) sitemap: specifier: ^9.0.1 version: 9.0.1 @@ -138,9 +162,6 @@ importers: specifier: ^1.2.0 version: 1.3.1(typescript@6.0.2) devDependencies: - '@ai-sdk/mcp': - specifier: ^1.0.25 - version: 1.0.35(zod@4.3.6) '@iconify-json/vscode-icons': specifier: ^1.2.44 version: 1.2.45 @@ -174,9 +195,6 @@ importers: '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 - ai: - specifier: ^6.0.116 - version: 6.0.149(zod@4.3.6) capture-website: specifier: ^5.1.0 version: 5.1.0(typescript@6.0.2) @@ -188,7 +206,7 @@ importers: version: 10.2.0(jiti@2.6.1) evalite: specifier: 1.0.0-beta.16 - version: 1.0.0-beta.16(ai@6.0.149(zod@4.3.6))(better-sqlite3@12.8.0) + version: 1.0.0-beta.16(ai@6.0.161(zod@4.3.6))(better-sqlite3@12.8.0) happy-dom: specifier: ^20.8.3 version: 20.8.9 @@ -231,12 +249,30 @@ packages: bcrypt: optional: true + '@ai-sdk/anthropic@3.0.68': + resolution: {integrity: sha512-BAd+fmgYoJMmGw0/uV+jRlXX60PyGxelA6Clp4cK/NI0dsyv9jOOwzQmKNaz2nwb+Jz7HqI7I70KK4XtU5EcXQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.91': resolution: {integrity: sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.94': + resolution: {integrity: sha512-uDDwLZhCkvC89crVS3S90D5L7AcVN8WriGuYVNYgVAaVcvy3Mthy3R9ICfzG75BObhz6pm2FWnhxDfNRK+t69Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.98': + resolution: {integrity: sha512-Ol+nP8PIlj8FjN8qKlxhE89N0woqAaGi9CUBGp1boe3RafpphJ7WMuq/RErSvxtwTqje03TP+zIdzP113krxRg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mcp@1.0.35': resolution: {integrity: sha512-YeFHyq3pq/tkD8rp/U0P9sJsQfDTT4F/8WdpRLLo30cCLx2kfuIYafRyTLfERnYayYlLbH5aMBpjttFunvIDCA==} engines: {node: '>=18'} @@ -253,6 +289,12 @@ packages: resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} + '@ai-sdk/vue@3.0.154': + resolution: {integrity: sha512-dPhHMhRgBD6Si8JUeJRQ+P/lQhyumB67JRl8q5kumImXG6LXXF12wy3KvNgU6fVitvi9VhwPyVitZtBzwgO8DA==} + engines: {node: '>=18'} + peerDependencies: + vue: ^3.3.4 + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -438,6 +480,32 @@ packages: '@colordx/core@5.0.3': resolution: {integrity: sha512-xBQ0MYRTNNxW3mS2sJtlQTT7C3Sasqgh1/PsHva7fyDb5uqYY+gv9V0utDdX8X80mqzbGz3u/IDJdn2d/uW09g==} + '@comark/markdown-it@0.3.3': + resolution: {integrity: sha512-qG+dbiwPKcaO3kzABw0dxtLqTxHAgqpklLwnu56jiYX3V/0dpVYCTIhXRhKgAo1JasBhcPP+OeHAENziXxGYbg==} + peerDependencies: + '@types/markdown-it': '*' + markdown-it: ^14.0.0 + + '@comark/nuxt@0.2.1': + resolution: {integrity: sha512-cluseC8YBC/+toX47LTDXCBEOpk1KAJYiK/Vyc6sZ4Q+ct0fG/q74dT2jVoEsR1es8JNmCUzEFSmnuuI8AOCfA==} + peerDependencies: + nuxt: ^3.0.0 + + '@comark/vue@0.2.1': + resolution: {integrity: sha512-MOwPsBBrTN1rKb9P5s3Vrj5SKqdHZ5PU16PgCscZLZFbL1Qt/MoRCHr0gwmA8ijrk6C5dih65Xr5BSNdTITGEA==} + peerDependencies: + beautiful-mermaid: ^1.1.3 + katex: ^0.16.33 + shiki: ^4.0.0 + vue: ^3.5.0 + peerDependenciesMeta: + beautiful-mermaid: + optional: true + katex: + optional: true + shiki: + optional: true + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -4196,8 +4264,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ai@6.0.149: - resolution: {integrity: sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw==} + ai@6.0.154: + resolution: {integrity: sha512-HfKJKCTJsDZxqrIUDSVnBQ7DpQlx5WI4ExqtLd7Bl70epLmvkpc/HYMzU1hP9W+g9VEAcvZo4fbMqc3v5D+9gQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ai@6.0.161: + resolution: {integrity: sha512-ufhmijmx2YyWTPAicGgtpLOB/xD7mG8zKs1pT1Trj+JL/3r1rS8fkMi/cHZoChSAQSGB4pgmcWVxDrVTUvK2IQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -4621,6 +4695,20 @@ packages: colortranslator@5.0.0: resolution: {integrity: sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==} + comark@0.2.1: + resolution: {integrity: sha512-DBc24O/utr7p0TPqxZwm7fsZPmA+2y3ZOrOaXF9Wk3YhETzz7lzN294epUjGEEXkPOxOs8G8RZOPbLCg3bY0Ag==} + peerDependencies: + beautiful-mermaid: ^1.1.3 + katex: ^0.16.33 + shiki: ^4.0.0 + peerDependenciesMeta: + beautiful-mermaid: + optional: true + katex: + optional: true + shiki: + optional: true + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5136,16 +5224,32 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.0.0: + resolution: {integrity: sha512-x+9D6nkC8tdXOQUS32egtZpZFLP90+HBZmWjuT920srbJvD/zPgFB9t4k3pEhlw5BQrXStQtRc1Y1zuriXk+Nw==} + engines: {node: '>=20.19.0'} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dot-prop@10.1.0: resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} engines: {node: '>=20'} @@ -5393,6 +5497,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -5643,6 +5751,59 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + evlog@2.12.0: + resolution: {integrity: sha512-uCnpBZlrjf51Evz+jTkydviq0QxiwPz4yGDmTjTKePelsIcbCiS0adPd5s26ZPf4tAf97HtWoYdlKd0JhydIkg==} + peerDependencies: + '@nestjs/common': '>=11.1.18' + '@nuxt/kit': ^4.4.2 + '@tanstack/start-client-core': ^1.167.9 + ai: '>=6.0.154' + elysia: '>=1.4.28' + express: '>=5.2.1' + fastify: '>=5.8.4' + h3: ^1.15.11 + hono: '' + next: '>=16.2.3' + nitro: ^3.0.260311-beta + nitropack: ^2.13.3 + ofetch: ^1.5.1 + react: '>=19.2.5' + react-router: '>=7.14.0' + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@nestjs/common': + optional: true + '@nuxt/kit': + optional: true + '@tanstack/start-client-core': + optional: true + ai: + optional: true + elysia: + optional: true + express: + optional: true + fastify: + optional: true + h3: + optional: true + hono: + optional: true + next: + optional: true + nitro: + optional: true + nitropack: + optional: true + ofetch: + optional: true + react: + optional: true + react-router: + optional: true + vite: + optional: true + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -6186,6 +6347,10 @@ packages: html-whitespace-sensitive-tag-names@3.0.1: resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -6811,6 +6976,9 @@ packages: maplibre-gl@2.4.0: resolution: {integrity: sha512-csNFylzntPmHWidczfgCZpvbTSmhaWvLRj9e1ezUDBEPizGgshgm3ea1T5TCNEEBq0roauu7BPuRZjA3wO4KqA==} + markdown-exit@1.0.0-beta.9: + resolution: {integrity: sha512-5tzrMKMF367amyBly131vm6eGuWRL2DjBqWaFmPzPbLyuxP0XOmyyyroOAIXuBAMF/3kZbbfqOxvW/SotqKqbQ==} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -8251,6 +8419,29 @@ packages: engines: {node: '>= 0.10'} hasBin: true + shaders@2.5.93: + resolution: {integrity: sha512-ZM41grBXeWzmG/yGCr5IKWxnvEUya/zpxcLstHmqs4kB150vkLg0getvgO0uF0pjppddyCwGJt22Y4v3MogkTg==} + peerDependencies: + pixi.js: ^8.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.8.0 + svelte: ^5 + vue: ^3.5.0 + peerDependenciesMeta: + pixi.js: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -8540,6 +8731,11 @@ packages: svix@1.88.0: resolution: {integrity: sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==} + swrv@1.2.0: + resolution: {integrity: sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==} + peerDependencies: + vue: '>=3.2.26 < 4' + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -8603,6 +8799,9 @@ packages: three@0.135.0: resolution: {integrity: sha512-kuEpuuxRzLv0MDsXai9huCxOSQPZ4vje6y0gn80SRmQvgz6/+rI0NAvCRAw56zYaWKMGMfqKWsxF9Qa2Z9xymQ==} + three@0.183.2: + resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} @@ -9521,6 +9720,12 @@ snapshots: '@phc/format': 1.0.0 '@poppinss/utils': 6.10.1 + '@ai-sdk/anthropic@3.0.68(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/gateway@3.0.91(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -9528,6 +9733,20 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.3.6 + '@ai-sdk/gateway@3.0.94(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/gateway@3.0.98(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + '@ai-sdk/mcp@1.0.35(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -9546,6 +9765,15 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/vue@3.0.154(vue@3.5.32(typescript@6.0.2))(zod@4.3.6)': + dependencies: + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + ai: 6.0.154(zod@4.3.6) + swrv: 1.2.0(vue@3.5.32(typescript@6.0.2)) + vue: 3.5.32(typescript@6.0.2) + transitivePeerDependencies: + - zod + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -9775,6 +10003,37 @@ snapshots: '@colordx/core@5.0.3': {} + '@comark/markdown-it@0.3.3(@types/markdown-it@14.1.2)(markdown-it@14.1.1)': + dependencies: + '@types/markdown-it': 14.1.2 + js-yaml: 4.1.1 + markdown-it: 14.1.1 + + '@comark/nuxt@0.2.1(@types/markdown-it@14.1.2)(magicast@0.5.2)(markdown-it@14.1.1)(nuxt@4.4.2(445499489540052a4c2e3761b79c8923))(shiki@4.0.2)(vue@3.5.32(typescript@6.0.2))': + dependencies: + '@comark/vue': 0.2.1(@types/markdown-it@14.1.2)(markdown-it@14.1.1)(shiki@4.0.2)(vue@3.5.32(typescript@6.0.2)) + '@nuxt/kit': 4.4.2(magicast@0.5.2) + comark: 0.2.1(@types/markdown-it@14.1.2)(markdown-it@14.1.1)(shiki@4.0.2) + nuxt: 4.4.2(445499489540052a4c2e3761b79c8923) + transitivePeerDependencies: + - '@types/markdown-it' + - beautiful-mermaid + - katex + - magicast + - markdown-it + - shiki + - vue + + '@comark/vue@0.2.1(@types/markdown-it@14.1.2)(markdown-it@14.1.1)(shiki@4.0.2)(vue@3.5.32(typescript@6.0.2))': + dependencies: + comark: 0.2.1(@types/markdown-it@14.1.2)(markdown-it@14.1.1)(shiki@4.0.2) + vue: 3.5.32(typescript@6.0.2) + optionalDependencies: + shiki: 4.0.2 + transitivePeerDependencies: + - '@types/markdown-it' + - markdown-it + '@drizzle-team/brocli@0.10.2': {} '@dxup/nuxt@0.4.0(magicast@0.5.2)(typescript@6.0.2)': @@ -13756,9 +14015,17 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.149(zod@4.3.6): + ai@6.0.154(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 3.0.91(zod@4.3.6) + '@ai-sdk/gateway': 3.0.94(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + + ai@6.0.161(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.98(zod@4.3.6) '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) '@opentelemetry/api': 1.9.0 @@ -14182,6 +14449,19 @@ snapshots: colortranslator@5.0.0: {} + comark@0.2.1(@types/markdown-it@14.1.2)(markdown-it@14.1.1)(shiki@4.0.2): + dependencies: + '@comark/markdown-it': 0.3.3(@types/markdown-it@14.1.2)(markdown-it@14.1.1) + entities: 8.0.0 + htmlparser2: 12.0.0 + js-yaml: 4.1.1 + markdown-exit: 1.0.0-beta.9 + optionalDependencies: + shiki: 4.0.2 + transitivePeerDependencies: + - '@types/markdown-it' + - markdown-it + comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -14696,18 +14976,36 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-serializer@3.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + domelementtype@2.3.0: {} + domelementtype@3.0.0: {} + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 + domutils@4.0.2: + dependencies: + dom-serializer: 3.0.0 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-prop@10.1.0: dependencies: type-fest: 5.5.0 @@ -14848,6 +15146,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -15184,7 +15484,7 @@ snapshots: etag@1.8.1: {} - evalite@1.0.0-beta.16(ai@6.0.149(zod@4.3.6))(better-sqlite3@12.8.0): + evalite@1.0.0-beta.16(ai@6.0.161(zod@4.3.6))(better-sqlite3@12.8.0): dependencies: '@fastify/static': 8.3.0 '@fastify/websocket': 11.2.0 @@ -15201,7 +15501,7 @@ snapshots: table: 6.9.0 tinyrainbow: 3.1.0 optionalDependencies: - ai: 6.0.149(zod@4.3.6) + ai: 6.0.161(zod@4.3.6) better-sqlite3: 12.8.0 transitivePeerDependencies: - bufferutil @@ -15223,6 +15523,18 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + evlog@2.12.0(@nuxt/kit@4.4.2(magicast@0.5.2))(ai@6.0.161(zod@4.3.6))(express@5.2.1)(fastify@5.8.4)(h3@1.15.11)(hono@4.12.12)(nitropack@2.13.3(@libsql/client@0.17.2)(@vercel/functions@3.4.3)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0))(rolldown@1.0.0-beta.57(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(srvx@0.11.15))(ofetch@1.5.1)(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + '@nuxt/kit': 4.4.2(magicast@0.5.2) + ai: 6.0.161(zod@4.3.6) + express: 5.2.1 + fastify: 5.8.4 + h3: 1.15.11 + hono: 4.12.12 + nitropack: 2.13.3(@libsql/client@0.17.2)(@vercel/functions@3.4.3)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260405.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0))(rolldown@1.0.0-beta.57(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(srvx@0.11.15) + ofetch: 1.5.1 + vite: 7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -15925,6 +16237,13 @@ snapshots: html-whitespace-sensitive-tag-names@3.0.1: {} + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -16571,6 +16890,16 @@ snapshots: tinyqueue: 2.0.3 vt-pbf: 3.1.3 + markdown-exit@1.0.0-beta.9: + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + entities: 7.0.1 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -18788,6 +19117,12 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + shaders@2.5.93(vue@3.5.32(typescript@6.0.2)): + dependencies: + three: 0.183.2 + optionalDependencies: + vue: 3.5.32(typescript@6.0.2) + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -19144,6 +19479,10 @@ snapshots: standardwebhooks: 1.0.0 uuid: 10.0.0 + swrv@1.2.0(vue@3.5.32(typescript@6.0.2)): + dependencies: + vue: 3.5.32(typescript@6.0.2) + table@6.9.0: dependencies: ajv: 8.18.0 @@ -19238,6 +19577,8 @@ snapshots: three@0.135.0: {} + three@0.183.2: {} + throttle-debounce@5.0.2: {} tiny-inflate@1.0.3: {} diff --git a/public/nuxt-chat-og.jpg b/public/nuxt-chat-og.jpg new file mode 100644 index 000000000..a43955c47 Binary files /dev/null and b/public/nuxt-chat-og.jpg differ diff --git a/server/api/agent.post.ts b/server/api/agent.post.ts new file mode 100644 index 000000000..95f379cb8 --- /dev/null +++ b/server/api/agent.post.ts @@ -0,0 +1,207 @@ +import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai' +import type { ToolSet } from 'ai' +import { createMCPClient } from '@ai-sdk/mcp' +import { anthropic } from '@ai-sdk/anthropic' +import type { H3Event } from 'h3' +import { createAILogger, createEvlogIntegration } from 'evlog/ai' +import type { AILogger } from 'evlog/ai' +import { sql } from 'drizzle-orm' +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' + +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 systemPrompt = `You are **the Nuxt Agent**, Nuxt's documentation agent on nuxt.com. You help users navigate and understand the official documentation, blog, modules catalog, and related guides. + +**Your identity:** +- Your full product name is **the Nuxt Agent** (you may also say **Nuxt Agent**). The site UI often shows **Agent** alone because context makes Nuxt obvious — in your written answers, still name yourself **the Nuxt Agent** / **Nuxt Agent** when you refer to the agent explicitly (e.g. "The Nuxt Agent can search the docs for…"). Otherwise describe what **Nuxt** provides. +- You are not a generic chatbot. +- Do not pretend to be a human. Avoid casual first person ("I think…", "my favorite…"). Prefer neutral, precise language about Nuxt and the docs. +- Be confident and grounded in retrieved content and tools. Speak as a knowledgeable agent for this site, not as the documentation text itself. + +**Browsing context prefix \`[Page: …]\` (CRITICAL):** +- User messages may start with \`[Page: /docs/…]\` (or similar). That is **only** which page the user had open in the browser — a **hint**, not a command to answer from that file. +- **Do not** call \`get-page\` (or otherwise treat that path as the primary source) when the user's actual question is **clearly unrelated** to that page's topic. Example: they are on \`/docs/…/getting-started/introduction\` but ask how to add a database — skip reading that introduction page; use \`list-pages\` / search / the right doc paths instead. +- **Do** read that page with \`get-page\` when the question is about that page's content, continues the same topic, or you need that section as a starting point. +- Never tell the user the site is "about Docus" or misidentify the product based on a single unrelated page you read by mistake — the site is **Nuxt** documentation. +- If you ignored the browsing path because it was irrelevant, answer from the right docs without apologizing at length for the mismatch. + +**Nuxt modules and package names (CRITICAL):** +- Never invent npm package names. If you are not certain, call \`list-modules\`, \`get-module\`, or \`show_module\` and use the **exact** \`name\` from the tool result in prose and install commands. +- **NuxtHub** (NuxtHub on nuxt.com / edge data): the Nuxt module is **\`@nuxthub/core\`**. There is **no** \`@nuxt/hub\` package — do not recommend or cite that name. +- Prefer tool output over memory for any \`npm install\` or module name. + +**Tool usage (CRITICAL):** +- You have tools: list-pages (discover pages), get-page (read a page), list-modules, get-module, show_module, show_template, show_blog_post, show_hosting, and open_playground +- If you already know a doc path whose **topic** clearly matches the question, read it with \`get-page\` without listing first. This is **not** the same as the \`[Page: …]\` browsing prefix — see the section above. +- ALWAYS respond with text after using tools - never end with just tool calls +- When the user asks about installing or using a specific module, use the show_module tool to display a rich module card. Do NOT also call get-module for the same module — show_module already provides all the information needed. Only use get-module if you need to read the module's documentation page content +- When the user asks about starter templates or scaffolding a project, use the show_template tool to display template cards. The tool accepts an array of template names/slugs so you can show multiple templates in one call. For vague requests (e.g. "show me templates"), show the official Nuxt UI templates first: ["nuxt-ui-dashboard", "nuxt-ui-saas", "nuxt-ui-landing", "nuxt-ui-chat", "nuxt-ui-docs", "nuxt-ui-portfolio"]. These are the official templates maintained by the Nuxt team. You can also include community templates after the official ones +- When the user asks about blog posts, releases, or announcements, use the show_blog_post tool to display a rich blog post card +- When the user asks about deploying or hosting a Nuxt app, use the show_hosting tool to display a hosting provider card with deploy guide +- When it would help the user to try code live or see a working example, use the open_playground tool to generate a StackBlitz link + +**WEB SEARCH:** +- You have access to a web search tool to find current, up-to-date information +- Only use it when the user explicitly asks about recent events, real-time data, or current facts that go beyond the Nuxt documentation +- Do NOT search proactively — rely on the documentation tools and your knowledge first +- Cite your sources when providing information from web search results + +**Guidelines:** +- If you can't find something, say "There is no documentation on that yet" or "Nuxt doesn't cover that topic yet" +- Be concise, helpful, and direct + +**Links and exploration:** +- Tool results include a \`url\` for each page — prefer markdown links \`[label](url)\` so users can open the doc in one click +- When it helps, add extra links (related pages, "read more", side topics) — make the answer easy to dig into, not a wall of text +- Stick to URLs from tool results (\`url\` / \`path\`) so links stay valid + +**FORMATTING RULES (CRITICAL):** +- NEVER use markdown headings (#, ##, ###, etc.) +- Use **bold text** for emphasis and section labels +- Start responses with content directly, never with a heading +- Use bullet points for lists +- Keep code examples focused and minimal + +**Response style:** +- Conversational but professional +- "Here's how you can do that:" instead of "The documentation shows:" +- "Nuxt supports TypeScript out of the box" — attribute capabilities to Nuxt, not to yourself as a person +- Provide actionable guidance, not just information dumps` + +function computeEstimatedCost(state: AILogger['_state']): number { + if (!state.costMap) return 0 + const model = state.models.at(-1) + if (!model) return 0 + const cost = state.costMap[model] + if (!cost) return 0 + return (state.usage.inputTokens * cost.input + state.usage.outputTokens * cost.output) / 1_000_000 +} + +async function getFingerprint(event: H3Event): Promise { + const ip = event.context.cf?.ip || '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('') +} + +export default defineEventHandler(async (event) => { + await consumeAgentRateLimit(event) + + const { messages } = await readBody(event) + const chatId = getHeader(event, 'x-chat-id') + 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 mcpUrl = import.meta.dev + ? `http://localhost:3000${MCP_PATH}` + : `${getRequestURL(event).origin}${MCP_PATH}` + + const httpClient = await createMCPClient({ + transport: { type: 'http', url: mcpUrl } + }) + const mcpTools = await httpClient.tools() + + const closeMcp = () => event.waitUntil(httpClient.close()) + + const saveChat = async () => { + if (!chatId) return + const fingerprint = await getFingerprint(event) + const now = new Date() + const state = ai._state + const model = state.models.at(-1) ?? null + const provider = state.lastProvider ?? null + const { inputTokens, outputTokens } = state.usage + const estimatedCost = computeEstimatedCost(state) + const durationMs = state.totalDurationMs ?? 0 + + await db.insert(schema.agentChats).values({ + id: chatId, + messages, + fingerprint, + model, + provider, + inputTokens, + outputTokens, + estimatedCost, + durationMs, + requestCount: 1, + createdAt: now, + updatedAt: now + }).onConflictDoUpdate({ + target: schema.agentChats.id, + set: { + messages, + 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` + } + }) + } + + const stream = createUIMessageStream({ + execute: async ({ writer }) => { + const result = streamText({ + model: ai.wrap(MODEL), + maxOutputTokens: 4000, + maxRetries: 2, + abortSignal: abortController.signal, + stopWhen: stopWhenResponseComplete, + system: systemPrompt, + messages: await convertToModelMessages(messages), + tools: { + ...mcpTools as ToolSet, + web_search: anthropic.tools.webSearch_20250305(), + show_module: showModuleTool, + show_template: createShowTemplateTool(event), + show_blog_post: createShowBlogPostTool(event), + show_hosting: createShowHostingTool(event), + open_playground: openPlaygroundTool + }, + experimental_telemetry: { + isEnabled: true, + integrations: [createEvlogIntegration(ai)] + }, + onFinish: () => { + closeMcp() + event.waitUntil(saveChat()) + }, + onAbort: closeMcp, + onError: closeMcp + }) + + writer.merge(result.toUIMessageStream({ + sendSources: true + })) + } + }) + + return createUIMessageStreamResponse({ stream }) +}) diff --git a/server/api/agent/cleanup.delete.ts b/server/api/agent/cleanup.delete.ts new file mode 100644 index 000000000..7eb3b9e4c --- /dev/null +++ b/server/api/agent/cleanup.delete.ts @@ -0,0 +1,16 @@ +import { lt } from 'drizzle-orm' + +const RETENTION_DAYS = 30 + +export default defineEventHandler(async (event) => { + await requireUserSession(event) + + const threshold = new Date() + threshold.setDate(threshold.getDate() - RETENTION_DAYS) + + const deleted = await db.delete(schema.agentChats) + .where(lt(schema.agentChats.updatedAt, threshold)) + .returning({ id: schema.agentChats.id }) + + return { deleted: deleted.length, threshold: threshold.toISOString() } +}) diff --git a/server/api/agent/usage.get.ts b/server/api/agent/usage.get.ts new file mode 100644 index 000000000..b3fff191f --- /dev/null +++ b/server/api/agent/usage.get.ts @@ -0,0 +1,3 @@ +export default defineEventHandler(async (event) => { + return checkAgentRateLimit(event) +}) diff --git a/server/api/agent/vote.post.ts b/server/api/agent/vote.post.ts new file mode 100644 index 000000000..200f44146 --- /dev/null +++ b/server/api/agent/vote.post.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' + +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) + + 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/db/migrations/sqlite/0001_great_spot.sql b/server/db/migrations/sqlite/0001_great_spot.sql new file mode 100644 index 000000000..1d88b72d3 --- /dev/null +++ b/server/db/migrations/sqlite/0001_great_spot.sql @@ -0,0 +1,24 @@ +CREATE TABLE `agent_chats` ( + `id` text PRIMARY KEY NOT NULL, + `messages` text NOT NULL, + `fingerprint` text 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, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `agent_votes` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `chat_id` text NOT NULL, + `message_id` text NOT NULL, + `is_upvoted` integer NOT NULL, + `createdAt` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `agent_vote_chat_msg_idx` ON `agent_votes` (`chat_id`,`message_id`); \ No newline at end of file diff --git a/server/db/migrations/sqlite/meta/0001_snapshot.json b/server/db/migrations/sqlite/meta/0001_snapshot.json new file mode 100644 index 000000000..4ae836d38 --- /dev/null +++ b/server/db/migrations/sqlite/meta/0001_snapshot.json @@ -0,0 +1,260 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "31933b0a-9c21-4fa2-9861-99db0e8ffcd1", + "prevId": "18f499b5-b447-4ec4-b175-6e8cce5b06ea", + "tables": { + "agent_chats": { + "name": "agent_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_votes": { + "name": "agent_votes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agent_vote_chat_msg_idx": { + "name": "agent_vote_chat_msg_idx", + "columns": [ + "chat_id", + "message_id" + ], + "isUnique": true + } + }, + "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": {} + } + }, + "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 68c4ff89b..aaca3834e 100644 --- a/server/db/migrations/sqlite/meta/_journal.json +++ b/server/db/migrations/sqlite/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1762358790381, "tag": "0000_sudden_christian_walker", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1776264773812, + "tag": "0001_great_spot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index 375dd0f7a..9dbce6462 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core' export const feedback = sqliteTable('feedback', { id: integer('id').primaryKey({ autoIncrement: true }), @@ -12,3 +12,26 @@ 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<{ id: string, role: string, parts: unknown[] }[]>(), + 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() +}) + +export const agentVotes = sqliteTable('agent_votes', { + id: integer('id').primaryKey({ autoIncrement: true }), + chatId: text('chat_id').notNull(), + 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/utils/rate-limit.ts b/server/utils/rate-limit.ts new file mode 100644 index 000000000..67d092dbc --- /dev/null +++ b/server/utils/rate-limit.ts @@ -0,0 +1,40 @@ +import type { H3Event } from 'h3' +import { kv } from 'hub:kv' + +const DAILY_LIMIT = 20 +const TTL_SECONDS = 86400 + +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 used = await kv.get(key) || 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) + const used = await kv.get(key) || 0 + + if (used >= DAILY_LIMIT) { + throw createError({ + statusCode: 429, + message: `You've reached the daily limit of ${DAILY_LIMIT} messages. Try again tomorrow.` + }) + } + + await kv.set(key, used + 1, { ttl: TTL_SECONDS }) + + return { used: used + 1, remaining: DAILY_LIMIT - used - 1, limit: DAILY_LIMIT } +} diff --git a/server/utils/tools/open-playground.ts b/server/utils/tools/open-playground.ts new file mode 100644 index 000000000..a15b58307 --- /dev/null +++ b/server/utils/tools/open-playground.ts @@ -0,0 +1,26 @@ +import { tool } from 'ai' +import { z } from 'zod' + +export const openPlaygroundTool = tool({ + description: 'Generate a StackBlitz playground link for a Nuxt example or GitHub repository. Use when the user wants to try code live, see a working example, or experiment with a Nuxt feature in the browser.', + inputSchema: z.object({ + repo: z.string().describe('GitHub repository in "owner/repo" format (e.g., "nuxt/starter", "nuxt-ui-templates/dashboard")'), + branch: z.string().default('main').describe('Branch name'), + dir: z.string().default('').describe('Subdirectory path within the repo'), + file: z.string().default('app.vue').describe('Default file to open'), + title: z.string().optional().describe('Display title for the playground') + }), + execute: async ({ repo, branch, dir, file, title }) => { + const dirPath = dir ? `/tree/${branch}/${dir}` : `/tree/${branch}` + const url = `https://stackblitz.com/github/${repo}${dirPath}?file=${file}` + + return { + url, + repo, + branch, + dir, + file, + title: title || repo.split('/').pop() || repo + } + } +}) diff --git a/server/utils/tools/show-blog-post.ts b/server/utils/tools/show-blog-post.ts new file mode 100644 index 000000000..fd93be7ea --- /dev/null +++ b/server/utils/tools/show-blog-post.ts @@ -0,0 +1,44 @@ +import { tool } from 'ai' +import { z } from 'zod' +import { queryCollection } from '@nuxt/content/server' +import type { H3Event } from 'h3' + +export function createShowBlogPostTool(event: H3Event) { + return tool({ + description: 'Display a rich blog post card with image, title, date, and author. Use when the user asks about Nuxt blog posts, release announcements, tutorials, or when referencing a specific blog article.', + inputSchema: z.object({ + title: z.string().describe('The blog post title or search keyword (e.g., "v4", "Nuxt 3.15", "TypeScript")') + }), + execute: async ({ title }) => { + const posts = await queryCollection(event, 'blog') + .where('extension', '=', 'md') + .order('date', 'DESC') + .all() + + const post = posts.find(p => + p.path !== '/blog' + && ( + p.title?.toLowerCase().includes(title.toLowerCase()) + || p.path?.toLowerCase().includes(title.toLowerCase()) + ) + ) + + if (!post) { + return { error: `Blog post matching "${title}" not found` } + } + + return { + title: post.title, + description: post.description, + path: post.path, + date: post.date, + image: post.image, + category: post.category, + authors: post.authors?.map(a => ({ + name: a.name, + avatar: a.avatar?.src + })) + } + } + }) +} diff --git a/server/utils/tools/show-hosting.ts b/server/utils/tools/show-hosting.ts new file mode 100644 index 000000000..77449f0a9 --- /dev/null +++ b/server/utils/tools/show-hosting.ts @@ -0,0 +1,39 @@ +import { tool } from 'ai' +import { z } from 'zod' +import { queryCollection } from '@nuxt/content/server' +import type { H3Event } from 'h3' + +export function createShowHostingTool(event: H3Event) { + return tool({ + description: 'Display a hosting/deployment provider card with logo, description, and deploy links. Use when the user asks about deploying a Nuxt app, hosting options, or a specific provider (Vercel, Netlify, Cloudflare, etc.).', + inputSchema: z.object({ + name: z.string().describe('The hosting provider name (e.g., "vercel", "netlify", "cloudflare")') + }), + execute: async ({ name }) => { + const providers = await queryCollection(event, 'deploy').all() + const provider = providers.find(p => + p.path !== '/deploy' + && ( + p.title?.toLowerCase() === name.toLowerCase() + || p.path?.endsWith(`/${name.toLowerCase()}`) + || p.title?.toLowerCase().includes(name.toLowerCase()) + ) + ) + + if (!provider) { + return { error: `Hosting provider "${name}" not found` } + } + + return { + title: provider.title, + description: provider.description, + path: provider.path, + logoSrc: provider.logoSrc, + logoIcon: provider.logoIcon, + category: provider.category, + nitroPreset: provider.nitroPreset, + website: provider.website + } + } + }) +} diff --git a/server/utils/tools/show-module.ts b/server/utils/tools/show-module.ts new file mode 100644 index 000000000..c21887447 --- /dev/null +++ b/server/utils/tools/show-module.ts @@ -0,0 +1,74 @@ +import { tool } from 'ai' +import { z } from 'zod' +import type { UIToolInvocation } from 'ai' + +export type ShowModuleUIToolInvocation = UIToolInvocation + +const MODULE_API = 'https://api.nuxt.com/modules' + +/** Try alternate slugs (e.g. NuxtHub → `hub`). */ +function slugCandidates(raw: string): string[] { + const t = raw.trim() + const lower = t.toLowerCase() + const hyphenated = lower.replace(/\s+/g, '-') + const set = new Set([t, lower, hyphenated]) + + if (lower === '@nuxthub/core' || (lower.endsWith('/core') && lower.includes('nuxthub'))) { + set.add('hub') + } + if ( + ['nuxthub', 'nuxt-hub', 'nuxt hub'].includes(lower) + || (lower.includes('nuxt') && lower.includes('hub') && !lower.includes('devtools')) + ) { + set.add('hub') + } + + return [...set].filter(Boolean) +} + +async function fetchModule(slug: string): Promise | null> { + try { + const data = await $fetch>(`${MODULE_API}/${encodeURIComponent(slug)}`) + return data.error === true ? null : data + } catch { + return null + } +} + +export const showModuleTool = tool({ + description: 'Display a Nuxt module card with install command. Use this tool when the user asks about installing, using, or recommending a specific Nuxt module. The card shows the module icon, description, stats, and a copy-able install command. Prefer catalog slugs when known (e.g. "hub" for NuxtHub / @nuxthub/core, "pinia" for Pinia).', + inputSchema: z.object({ + name: z.string().describe('Module slug (e.g. "pinia", "i18n", "hub" for NuxtHub)') + }), + execute: async ({ name }) => { + let data: Record | null = null + + for (const slug of slugCandidates(name)) { + data = await fetchModule(slug) + if (data) break + } + + if (!data) { + return { error: `Module "${name}" not found` } + } + + const catalogName = data.name + if (typeof catalogName !== 'string' || !catalogName.trim()) { + return { error: `Module "${name}" returned an invalid response` } + } + + const stats = data.stats as Record | undefined + + return { + name: catalogName, + npm: data.npm as string, + description: data.description as string, + icon: data.icon as string, + category: data.category as string, + repo: data.repo as string, + website: data.website as string, + downloads: stats?.downloads as number | undefined, + stars: stats?.stars as number | undefined + } + } +}) diff --git a/server/utils/tools/show-template.ts b/server/utils/tools/show-template.ts new file mode 100644 index 000000000..e5c395172 --- /dev/null +++ b/server/utils/tools/show-template.ts @@ -0,0 +1,43 @@ +import { tool } from 'ai' +import { z } from 'zod' +import { queryCollection } from '@nuxt/content/server' +import type { H3Event } from 'h3' + +export function createShowTemplateTool(event: H3Event) { + return tool({ + description: 'Display one or more Nuxt starter template cards with preview image, description, and action buttons. Use when the user asks about starter templates, project scaffolding, or wants to create a new Nuxt project. Pass multiple names to show several templates at once.', + inputSchema: z.object({ + names: z.array(z.string()).describe('Template names or slugs to display (e.g., ["ui", "content", "starter", "movies"])') + }), + execute: async ({ names }) => { + const allTemplates = await queryCollection(event, 'templates').all() + + const results = names.map((name) => { + const template = allTemplates.find(t => + t.slug === name + || t.name.toLowerCase() === name.toLowerCase() + || t.slug.includes(name.toLowerCase()) + || t.name.toLowerCase().includes(name.toLowerCase()) + ) + + if (!template) return null + + return { + name: template.name, + slug: template.slug, + description: template.description, + repo: template.repo, + demo: template.demo, + badge: template.badge, + purchase: template.purchase + } + }).filter(Boolean) + + if (!results.length) { + return { error: `No templates found matching: ${names.join(', ')}` } + } + + return { templates: results } + } + }) +}