diff --git a/.env.example b/.env.example index 73614e6e8..64342c431 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,18 @@ NUXT_ADMIN_GITHUB_LOGINS= AI_GATEWAY_API_KEY= MCP_URL=http://localhost:3000/mcp +# Eve agent internal API (required for Nuxi on Eve — same value on web + eve Vercel services) +INTERNAL_API_SECRET= + +# Vercel Connect Slack client UID (e.g. slack/nuxi) — eve service only +SLACK_CONNECTOR= + +# Bearer token for /mcp/admin — web service (handler) + eve service (admin-mcp connection) +NUXT_MCP_ADMIN_TOKEN= + +# Comma-separated Slack team IDs allowed to use admin MCP tools via Slack (eve service) +NUXT_ADMIN_SLACK_TEAM_IDS= + # for fetching sponsors NUXT_OPEN_COLLECTIVE_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56a79dd1b..fb87abb19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,8 @@ jobs: - run: pnpm install - run: pnpm nuxt prepare - run: pnpm vitest run + env: + EVE_BASE_URL: http://127.0.0.1:1 typecheck: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 9f7c9b6b4..71b3dc931 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ dist .cache .data .eslintcache +.eve +.workflow-data # Local Netlify folder .netlify diff --git a/README.md b/README.md index 9428ece31..1ba4a6c3c 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,22 @@ To run the evals for the MCP server, follow these steps: pnpm eval:ui ``` +## Nuxi (Eve agent) + +Nuxi lives in [`layers/nuxi/`](./layers/nuxi/) — Eve runtime (`agent/`), UI, and internal APIs in one layer. For local development: + +```bash +# Required — shared secret between the Nuxt app and Eve runtime +INTERNAL_API_SECRET=$(openssl rand -base64 32) + +# Optional — canonical site URL for MCP + internal API callbacks +NUXT_PUBLIC_SITE_URL=http://localhost:3000 + +pnpm dev +``` + +On Vercel, configure **both** the `web` and `eve` services (`vercel.json`) with the same `INTERNAL_API_SECRET`, `AI_GATEWAY_API_KEY`, and database env vars. + ## License [MIT License](./LICENSE) diff --git a/app/composables/useNavigation.ts b/app/composables/useNavigation.ts index ba4d699ad..ecc346f66 100644 --- a/app/composables/useNavigation.ts +++ b/app/composables/useNavigation.ts @@ -180,8 +180,8 @@ const _useNavigation = () => { const { articles: blogArticles, fetchList: fetchBlog } = useBlog() const searchLinks = computed(() => [{ - label: 'Ask Agent', - icon: 'i-lucide-wand', + label: 'Ask Nuxi', + icon: 'i-custom-nuxi', to: 'javascript:void(0);', onSelect: () => { track('Nuxi Opened', { source: 'search-links' }) @@ -284,9 +284,8 @@ const _useNavigation = () => { ignoreFilter: true, postFilter, items: [{ - label: 'Ask Agent', - icon: 'i-lucide-wand', - to: 'javascript:void(0);', + label: 'Ask Nuxi', + icon: 'i-custom-nuxi', onSelect() { track('Nuxi Opened', { source: 'search-palette', query: searchTerm.value }) openAgent(searchTerm.value) diff --git a/layers/nuxi/README.md b/layers/nuxi/README.md new file mode 100644 index 000000000..739ac76e9 --- /dev/null +++ b/layers/nuxi/README.md @@ -0,0 +1,26 @@ +# Nuxi layer + +Everything for the Nuxi assistant on nuxt.com lives here. + +```text +layers/nuxi/ +├── agent/ # Eve runtime (channels, tools, hooks) — deployed as the `eve` Vercel service +├── app/ # Chat UI (panel, dashboard, composables) +├── server/ # Nuxt APIs + internal routes the agent calls over HTTP +└── shared/ # Types and utils shared by app + server +``` + +## Boundaries + +| Layer | Runs in | Talks to | +|-------|---------|----------| +| `agent/` | Eve (`eve dev` / Vercel `eve` service) | Nuxt via `/api/internal/*` (`INTERNAL_API_SECRET`) | +| `server/api/internal/*` | Nuxt Nitro | DB, GitHub, content — never exposed publicly | +| `server/api/chats/*`, `agent/*` | Nuxt Nitro | Browser (session auth) | +| `app/` | Browser | Nuxt APIs + Eve transport (`/_eve_internal/eve`) | + +The agent never imports Nuxt server code directly. Shared logic that both sides need either lives in `shared/` (app + server) or is exposed through an internal API route. + +## Config + +`eve.eveRoot` points Eve at this layer so `agent/` is discovered at `layers/nuxi/agent/`. The layer includes a minimal `package.json` (Eve project marker for nested layout discovery). Root `vercel.json` declares the dual `web` + `eve` services with `entrypoint: "layers/nuxi"` for Eve. diff --git a/layers/nuxi/agent/agent.ts b/layers/nuxi/agent/agent.ts new file mode 100644 index 000000000..1b5064926 --- /dev/null +++ b/layers/nuxi/agent/agent.ts @@ -0,0 +1,18 @@ +import { defineAgent } from 'eve' + +export default defineAgent({ + model: 'anthropic/claude-sonnet-4.6', + modelOptions: { + providerOptions: { + gateway: { + caching: 'auto' + }, + anthropic: { + thinking: { + type: 'enabled', + budgetTokens: 2048 + } + } + } + } +}) diff --git a/layers/nuxi/agent/channels/eve.ts b/layers/nuxi/agent/channels/eve.ts new file mode 100644 index 000000000..9c31829e9 --- /dev/null +++ b/layers/nuxi/agent/channels/eve.ts @@ -0,0 +1,96 @@ +import type { AuthFn } from 'eve/channels/auth' +import { localDev, vercelOidc } from 'eve/channels/auth' +import { defaultEveAuth, eveChannel } from 'eve/channels/eve' +import { appOrigin, internalHeaders } from '../lib/internal-api.js' + +const PAGE_PATH_PATTERN = /^\/[\w./-]*$/ + +interface SessionPrincipal { + principalId: string + principalType: 'user' | 'anonymous' + authenticated: boolean + attributes?: { + login?: string + name?: string + avatar?: string + role?: string + } +} + +function nuxtSessionAuth(): AuthFn { + return async (request) => { + const cookie = request.headers.get('cookie') ?? '' + if (!cookie) return null + + try { + const response = await fetch(`${appOrigin()}/api/internal/session`, { + headers: internalHeaders({ cookie }) + }) + + if (!response.ok) return null + + const data = await response.json() as SessionPrincipal + + return { + attributes: data.attributes ?? {}, + authenticator: data.authenticated ? 'github' : 'anonymous', + issuer: 'nuxt.com', + principalId: data.principalId, + principalType: data.principalType === 'user' ? 'user' : 'anonymous' + } + } catch { + return null + } + } +} + +function parsePagePath(request: Request): string | null { + const raw = request.headers.get('x-page-path')?.trim() ?? null + if (!raw || !PAGE_PATH_PATTERN.test(raw) || raw.length > 256) return null + return raw +} + +export default eveChannel({ + auth: [ + nuxtSessionAuth(), + localDev(), + vercelOidc() + ], + async onMessage(ctx, message) { + const context: string[] = [] + const pagePath = parsePagePath(ctx.eve.request) + const chatId = ctx.eve.request.headers.get('x-nuxi-chat-id')?.trim() + const isNewSession = !ctx.eve.sessionId + + if (pagePath) { + context.push(`Current page: ${pagePath}`) + } + + if (isNewSession && chatId) { + try { + const cookie = ctx.eve.request.headers.get('cookie') ?? '' + const response = await fetch(`${appOrigin()}/api/internal/chats/${encodeURIComponent(chatId)}/context`, { + headers: internalHeaders(cookie ? { cookie } : undefined) + }) + + if (response.ok) { + const data = await response.json() as { summary?: string } + if (data.summary) { + context.push(`Prior conversation (for context):\n${data.summary}`) + } + } + } catch { + // Non-fatal — continue without prior context + } + } + + if (typeof message === 'string' && message.trim()) { + context.push(`User message: ${message}`) + } + + return { + auth: defaultEveAuth(ctx), + context: context.length ? context : undefined + } + } +}) diff --git a/layers/nuxi/agent/channels/slack.ts b/layers/nuxi/agent/channels/slack.ts new file mode 100644 index 000000000..50b4adaf5 --- /dev/null +++ b/layers/nuxi/agent/channels/slack.ts @@ -0,0 +1,57 @@ +import { connectSlackCredentials } from '@vercel/connect/eve' +import { + defaultSlackAuth, + loadThreadContextMessages, + slackChannel, + type SlackContext, + type SlackMessage +} from 'eve/channels/slack' + +function isHookConflictFailure(event: { code?: string, message?: string }) { + const message = event.message ?? '' + return event.code === 'HookConflictError' + || message.includes('HookConflict') + || message.includes('already in use by another workflow') +} + +async function dispatchSlackMessage(ctx: SlackContext, message: SlackMessage) { + await ctx.thread.startTyping('Thinking...') + + const auth = defaultSlackAuth(message, ctx) + if (!auth) return null + + const context = ['The user is talking to Nuxi on Slack.'] + + const prior = await loadThreadContextMessages(ctx.thread, message, { + since: 'last-agent-reply' + }) + + if (prior.length > 0) { + const transcript = prior + .map(m => `${m.isMe ? 'you' : (m.user ?? 'user')}: ${m.markdown}`) + .join('\n') + context.push(`Recent thread messages since your last reply:\n\n${transcript}`) + } + + return { auth, context } +} + +export default slackChannel({ + credentials: connectSlackCredentials( + process.env.SLACK_CONNECTOR ?? 'slack/nuxi' + ), + botName: 'Nuxi', + onAppMention: dispatchSlackMessage, + onDirectMessage: dispatchSlackMessage, + events: { + async 'session.failed'(event, _channel) { + // DM + @mention (or any double dispatch on the same thread) races on one + // continuation token — the winning run already handles the user message. + if (isHookConflictFailure(event)) return + + await _channel.thread.post( + 'Something went wrong and I cannot continue in this thread. Start a new thread to try again.' + ) + } + } +}) diff --git a/layers/nuxi/agent/connections/nuxt-mcp.ts b/layers/nuxi/agent/connections/nuxt-mcp.ts new file mode 100644 index 000000000..fb3b6c1e7 --- /dev/null +++ b/layers/nuxi/agent/connections/nuxt-mcp.ts @@ -0,0 +1,7 @@ +import { defineMcpClientConnection } from 'eve/connections' +import { appOrigin } from '../lib/internal-api.js' + +export default defineMcpClientConnection({ + url: `${appOrigin()}/mcp`, + description: 'Nuxt.com documentation, blog, modules catalog, deploy providers, and changelog.' +}) diff --git a/layers/nuxi/agent/hooks/chat-title.ts b/layers/nuxi/agent/hooks/chat-title.ts new file mode 100644 index 000000000..f626b2303 --- /dev/null +++ b/layers/nuxi/agent/hooks/chat-title.ts @@ -0,0 +1,36 @@ +import { defineHook } from 'eve/hooks' +import { appOrigin, chatIdFromContinuationToken, internalHeaders } from '../lib/internal-api.js' + +export default defineHook({ + events: { + async 'message.received'(event, ctx) { + const chatId = chatIdFromContinuationToken(ctx.channel.continuationToken) + const auth = ctx.session.auth.current + if (!chatId || !auth || auth.principalType !== 'user') return + + const message = event.data.message + if (!message?.trim()) return + + try { + const response = await fetch(`${appOrigin()}/api/internal/chats/${encodeURIComponent(chatId)}/title`, { + method: 'POST', + headers: internalHeaders(), + body: JSON.stringify({ + userId: auth.principalId, + message: { + role: 'user', + parts: [{ type: 'text', text: message }] + } + }) + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + console.warn(`[chat-title] title generation failed (${response.status}): ${text}`) + } + } catch (error) { + console.warn('[chat-title] title generation request errored', error) + } + } + } +}) diff --git a/layers/nuxi/agent/hooks/rate-limit.ts b/layers/nuxi/agent/hooks/rate-limit.ts new file mode 100644 index 000000000..a4456e4e7 --- /dev/null +++ b/layers/nuxi/agent/hooks/rate-limit.ts @@ -0,0 +1,42 @@ +import { defineHook } from 'eve/hooks' +import { appOrigin, internalHeaders } from '../lib/internal-api.js' + +type TurnStartedContext = { + session: { + auth: { + current?: { + principalId?: string + } | null + } + } + eve?: { + request?: Request + } +} + +export default defineHook({ + events: { + async 'turn.started'(_event, ctx) { + const hookCtx = ctx as TurnStartedContext + const principalId = hookCtx.session.auth.current?.principalId + if (!principalId) return + + const cookie = hookCtx.eve?.request?.headers.get('cookie') ?? '' + const response = await fetch(`${appOrigin()}/api/internal/agent/rate-limit/consume`, { + method: 'POST', + headers: internalHeaders(cookie ? { cookie } : undefined), + body: JSON.stringify({ userId: principalId }) + }) + + if (response.status === 429) { + const data = await response.json().catch(() => ({})) as { message?: string } + throw new Error(data.message ?? 'Daily message limit reached.') + } + + if (!response.ok) { + const text = await response.text() + throw new Error(`Rate limit check failed: ${text}`) + } + } + } +}) diff --git a/layers/nuxi/agent/instructions.ts b/layers/nuxi/agent/instructions.ts new file mode 100644 index 000000000..956153df7 --- /dev/null +++ b/layers/nuxi/agent/instructions.ts @@ -0,0 +1,16 @@ +import { defineDynamic, defineInstructions } from 'eve/instructions' +import { ADMIN_MCP_INSTRUCTIONS, canAccessAdminMcp } from './tools/admin-mcp.js' +import { buildInstructionsWithDate } from './lib/base-instructions.js' + +export default defineDynamic({ + events: { + 'session.started': async (_event, ctx) => { + const auth = ctx.session.auth.current + const markdown = canAccessAdminMcp(auth) + ? `${buildInstructionsWithDate()}\n\n${ADMIN_MCP_INSTRUCTIONS}` + : buildInstructionsWithDate() + + return defineInstructions({ markdown }) + } + } +}) diff --git a/layers/nuxi/agent/lib/base-instructions.ts b/layers/nuxi/agent/lib/base-instructions.ts new file mode 100644 index 000000000..59ee97468 --- /dev/null +++ b/layers/nuxi/agent/lib/base-instructions.ts @@ -0,0 +1,59 @@ +export const BASE_INSTRUCTIONS = `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\` via the **nuxt-mcp** connection with that exact path +- \`/blog/…\` → \`get-blog-post\` via **nuxt-mcp** with that exact path +- \`/deploy/…\` → \`get-deploy-provider\` via **nuxt-mcp** with that exact path +- \`/modules/\` → \`show_module\` with that slug +- \`/changelog/…\` → use the GitHub changelog tools via **nuxt-mcp** +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\`. +- To discover modules, call \`list-modules\` with \`search\` (e.g. \`search: "auth"\`). Do NOT use \`category: "auth"\` — auth modules live under category **Security**. +- After \`list-modules\`, use \`show_module\` with the module **slug** from results (e.g. \`auth-utils\`, \`sidebase-auth\`), not npm package names. + +**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:** +- **nuxt-mcp connection** — documentation, blog, deploy, modules catalog, changelog (use \`connection__search\` to discover tools, then call via \`connection__nuxt_mcp__\`) +- \`search_github_issues\` — search GitHub Issues across the Nuxt ecosystem +- \`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 +- ALWAYS respond with text after tool calls — never end with just tool calls + +**Web search:** Only use \`web_search\` 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 unless they asked for a specific year or time range. + +**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/...\`) +- Stay concise. Actionable over exhaustive.` + +export function buildInstructionsWithDate(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${BASE_INSTRUCTIONS}` + if (!pagePath) return withDate + return `Current page: ${pagePath}\n\n${withDate}` +} diff --git a/layers/nuxi/agent/lib/define-nuxt-tool.ts b/layers/nuxi/agent/lib/define-nuxt-tool.ts new file mode 100644 index 000000000..e3cacb270 --- /dev/null +++ b/layers/nuxi/agent/lib/define-nuxt-tool.ts @@ -0,0 +1,24 @@ +import { defineTool } from 'eve/tools' +import type { z } from 'zod' +import { internalFetch } from './internal-api.js' + +interface DefineNuxtToolOptions { + description: string + inputSchema: T + path: string + body?: (input: z.infer) => unknown +} + +export function defineNuxtTool(options: DefineNuxtToolOptions) { + return defineTool({ + description: options.description, + inputSchema: options.inputSchema, + async execute(input) { + const body = options.body ? options.body(input) : input + return await internalFetch>(options.path, { + method: 'POST', + body: JSON.stringify(body) + }) + } + }) +} diff --git a/layers/nuxi/agent/lib/internal-api.ts b/layers/nuxi/agent/lib/internal-api.ts new file mode 100644 index 000000000..bbaa89312 --- /dev/null +++ b/layers/nuxi/agent/lib/internal-api.ts @@ -0,0 +1,57 @@ +export function appOrigin() { + const configured = process.env.NUXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, '') + || process.env.NUXT_SITE_URL?.trim().replace(/\/$/, '') + + if (configured) { + return configured + } + + const vercelUrl = process.env.VERCEL_URL?.trim() + if (vercelUrl) { + return `https://${vercelUrl}` + } + + return 'http://localhost:3000' +} + +export function internalHeaders(extra?: Record) { + const secret = process.env.INTERNAL_API_SECRET?.trim() + if (!secret) { + throw new Error('INTERNAL_API_SECRET is not configured') + } + + return { + 'Authorization': `Bearer ${secret}`, + 'content-type': 'application/json', + ...extra + } +} + +const INTERNAL_FETCH_TIMEOUT_MS = 10_000 + +export async function internalFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(`${appOrigin()}${path}`, { + ...init, + signal: init?.signal ?? AbortSignal.timeout(INTERNAL_FETCH_TIMEOUT_MS), + headers: { + ...internalHeaders(), + ...(init?.headers as Record | undefined) + } + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Internal API ${path} failed (${response.status}): ${text}`) + } + + return await response.json() as T +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Eve namespaces continuation tokens as `{channel}:{chatId}`. */ +export function chatIdFromContinuationToken(token: string | undefined): string | undefined { + if (!token?.trim()) return undefined + const raw = token.includes(':') ? token.slice(token.indexOf(':') + 1) : token + return UUID_PATTERN.test(raw) ? raw : undefined +} diff --git a/layers/nuxi/agent/tools/admin-mcp.ts b/layers/nuxi/agent/tools/admin-mcp.ts new file mode 100644 index 000000000..cab9d131a --- /dev/null +++ b/layers/nuxi/agent/tools/admin-mcp.ts @@ -0,0 +1,153 @@ +import { createMCPClient } from '@ai-sdk/mcp' +import { defineDynamic, defineTool } from 'eve/tools' +import { z } from 'zod' +import { appOrigin } from '../lib/internal-api.js' + +type AuthAttributes = Readonly> + +interface AdminAuth { + issuer?: string + attributes?: AuthAttributes +} + +function authAttr(attributes: AuthAttributes | undefined, key: string): string | undefined { + const value = attributes?.[key] + return typeof value === 'string' ? value : undefined +} + +export function canAccessAdminMcp(auth: AdminAuth | null | undefined): boolean { + if (!auth) return false + + const teamId = authAttr(auth.attributes, 'team_id') + const allowedSlackTeams = new Set( + (process.env.NUXT_ADMIN_SLACK_TEAM_IDS ?? '') + .split(',') + .map(id => id.trim()) + .filter(Boolean) + ) + + if (auth.issuer?.startsWith('slack:') || auth.issuer === 'slack' || teamId) { + return Boolean(teamId && allowedSlackTeams.has(teamId)) + } + + return authAttr(auth.attributes, 'role') === 'admin' +} + +export const ADMIN_MCP_INSTRUCTIONS = `**Admin tools (team only):** +- \`admin_mcp__feedback_stats\` — aggregated docs feedback metrics +- \`admin_mcp__list_feedback\` — individual feedback entries +- \`admin_mcp__agent_usage_stats\` — web chat counts and vote quality (NOT tokens/cost — use Vercel Observability → Agent Runs) +- \`admin_mcp__list_agent_chats\` / \`admin_mcp__get_agent_chat\` — saved web chat sessions and transcripts +- \`admin_mcp__list_agent_votes\` — message upvotes/downvotes +- For runs, tokens, cost, duration, or Slack traffic: direct the team to **Vercel Observability → Agent Runs** (nuxt project). Do not invent numbers from local DB. +- Default to recent data (last 7–30 days) unless the user asks for a longer window +- Always include direct links (path / chat id) so the team can drill down on nuxt.com` + +async function callAdminMcpTool(toolName: string, input: Record): Promise { + const token = process.env.NUXT_MCP_ADMIN_TOKEN?.trim() + if (!token) throw new Error('NUXT_MCP_ADMIN_TOKEN is not configured') + + const client = await createMCPClient({ + transport: { + type: 'http', + url: `${appOrigin()}/mcp/admin`, + headers: { Authorization: `Bearer ${token}` } + } + }) + + try { + const tools = await client.tools() + const tool = tools[toolName] + if (!tool?.execute) throw new Error(`Unknown admin MCP tool: ${toolName}`) + return await tool.execute(input, { toolCallId: crypto.randomUUID(), messages: [] }) + } finally { + await client.close() + } +} + +const feedbackRating = z.enum(['very-helpful', 'helpful', 'not-helpful', 'confusing']) + +function adminTools() { + return { + feedback_stats: defineTool({ + description: 'Admin: aggregated docs feedback metrics (ratings, worst pages, satisfaction score).', + inputSchema: z.object({ + sinceDays: z.number().int().min(1).max(365).default(30), + topPages: z.number().int().min(1).max(50).default(10), + minResponses: z.number().int().min(1).default(3) + }), + async execute(input) { + return await callAdminMcpTool('feedback-stats', input) + } + }), + list_feedback: defineTool({ + description: 'Admin: list docs feedback entries with filters for rating, path, and date range.', + inputSchema: z.object({ + ratings: z.array(feedbackRating).optional(), + pathContains: z.string().optional(), + sinceDays: z.number().int().min(1).max(365).optional(), + until: z.string().datetime({ offset: true }).optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0) + }), + async execute(input) { + return await callAdminMcpTool('list-feedback', input) + } + }), + agent_usage_stats: defineTool({ + description: 'Admin: web chat counts and vote quality. For runs/tokens/cost use Vercel Observability Agent Runs.', + inputSchema: z.object({ + sinceDays: z.number().int().min(1).max(365).default(30) + }), + async execute(input) { + return await callAdminMcpTool('agent-usage-stats', input) + } + }), + list_agent_chats: defineTool({ + description: 'Admin: list saved web chat sessions with vote counts (not Slack threads).', + inputSchema: z.object({ + sinceDays: z.number().int().min(1).max(365).optional(), + until: z.string().datetime({ offset: true }).optional(), + hasDownvotes: z.boolean().optional(), + sortBy: z.enum(['createdAt', 'updatedAt']).default('updatedAt'), + limit: z.number().int().min(1).max(100).default(25), + offset: z.number().int().min(0).default(0) + }), + async execute(input) { + return await callAdminMcpTool('list-agent-chats', input) + } + }), + get_agent_chat: defineTool({ + description: 'Admin: read a full Nuxi chat transcript and message-level votes.', + inputSchema: z.object({ + chatId: z.string().min(1), + includeRawParts: z.boolean().default(false) + }), + async execute(input) { + return await callAdminMcpTool('get-agent-chat', input) + } + }), + list_agent_votes: defineTool({ + description: 'Admin: list per-message upvotes/downvotes on Nuxi answers.', + inputSchema: z.object({ + onlyDownvotes: z.boolean().default(false), + onlyUpvotes: z.boolean().default(false), + sinceDays: z.number().int().min(1).max(365).optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0) + }), + async execute(input) { + return await callAdminMcpTool('list-agent-votes', input) + } + }) + } +} + +export default defineDynamic({ + events: { + 'session.started': async (_event, ctx) => { + if (!canAccessAdminMcp(ctx.session.auth.current)) return null + return adminTools() + } + } +}) diff --git a/layers/nuxi/server/utils/tools/open-playground.ts b/layers/nuxi/agent/tools/open_playground.ts similarity index 91% rename from layers/nuxi/server/utils/tools/open-playground.ts rename to layers/nuxi/agent/tools/open_playground.ts index 5fef6edb2..80690eb8a 100644 --- a/layers/nuxi/server/utils/tools/open-playground.ts +++ b/layers/nuxi/agent/tools/open_playground.ts @@ -1,7 +1,7 @@ -import { tool } from 'ai' +import { defineTool } from 'eve/tools' import { z } from 'zod' -export const openPlaygroundTool = tool({ +export default defineTool({ 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().regex(/^[\w.-]+\/[\w.-]+$/).describe('GitHub repository in "owner/repo" format (e.g., "nuxt/starter", "nuxt-ui-templates/dashboard")'), @@ -10,7 +10,7 @@ export const openPlaygroundTool = tool({ 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 }) => { + async execute({ repo, branch, dir, file, title }) { const [owner, name] = repo.split('/') as [string, string] const encodedBranch = encodeURIComponent(branch) const encodedDir = dir diff --git a/layers/nuxi/server/utils/tools/report-issue.ts b/layers/nuxi/agent/tools/report_issue.ts similarity index 80% rename from layers/nuxi/server/utils/tools/report-issue.ts rename to layers/nuxi/agent/tools/report_issue.ts index 83c165453..c13768c72 100644 --- a/layers/nuxi/server/utils/tools/report-issue.ts +++ b/layers/nuxi/agent/tools/report_issue.ts @@ -1,11 +1,13 @@ -import { tool } from 'ai' +import { defineTool } from 'eve/tools' import { z } from 'zod' -export const reportIssueTool = tool({ +export default defineTool({ description: 'Use this when you cannot resolve the user\'s problem after exhausting all available tools, or when the user explicitly signals frustration or a bad experience. This surfaces an inline feedback card so the user can report the issue with full conversation context attached automatically.', inputSchema: z.object({ title: z.string().max(80).describe('Short issue title describing the problem (max 80 chars)'), summary: z.string().describe('1-3 sentence summary of what was attempted and why it could not be resolved') }), - execute: async ({ title, summary }) => ({ title, summary }) + async execute({ title, summary }) { + return { title, summary } + } }) diff --git a/layers/nuxi/agent/tools/search_github_issues.ts b/layers/nuxi/agent/tools/search_github_issues.ts new file mode 100644 index 000000000..19552072f --- /dev/null +++ b/layers/nuxi/agent/tools/search_github_issues.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { defineNuxtTool } from '../lib/define-nuxt-tool.js' + +export default defineNuxtTool({ + description: 'Search GitHub Issues across the Nuxt ecosystem (nuxt, nuxt-modules, nuxt-content orgs). Use when the user shares an error message, stack trace, or debugging question. Returns matching issues with status, labels, and body excerpts. Much faster and cheaper than web search for Nuxt-specific bugs.', + inputSchema: z.object({ + query: z.string().trim().min(1).describe('Error message, keyword, or search term'), + repo: z.string().optional().describe('Scope to a specific repo (e.g. "nuxt/nuxt", "nuxt/ui"). Omit to search all Nuxt orgs.'), + state: z.enum(['open', 'closed', 'all']).optional().describe('Filter by issue state') + }), + path: '/api/internal/github/search-issues' +}) diff --git a/layers/nuxi/agent/tools/show_blog_post.ts b/layers/nuxi/agent/tools/show_blog_post.ts new file mode 100644 index 000000000..2c9069966 --- /dev/null +++ b/layers/nuxi/agent/tools/show_blog_post.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { defineNuxtTool } from '../lib/define-nuxt-tool.js' + +export default defineNuxtTool({ + 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().trim().min(1).describe('The blog post title or search keyword (e.g., "v4", "Nuxt 3.15", "TypeScript")') + }), + path: '/api/internal/content', + body: input => ({ kind: 'blog' as const, ...input }) +}) diff --git a/layers/nuxi/agent/tools/show_hosting.ts b/layers/nuxi/agent/tools/show_hosting.ts new file mode 100644 index 000000000..7c21fff30 --- /dev/null +++ b/layers/nuxi/agent/tools/show_hosting.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { defineNuxtTool } from '../lib/define-nuxt-tool.js' + +export default defineNuxtTool({ + 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().trim().min(1).describe('The hosting provider name (e.g., "vercel", "netlify", "cloudflare")') + }), + path: '/api/internal/content', + body: input => ({ kind: 'deploy' as const, ...input }) +}) diff --git a/layers/nuxi/server/utils/tools/show-module.ts b/layers/nuxi/agent/tools/show_module.ts similarity index 82% rename from layers/nuxi/server/utils/tools/show-module.ts rename to layers/nuxi/agent/tools/show_module.ts index afedc0e36..f1213095b 100644 --- a/layers/nuxi/server/utils/tools/show-module.ts +++ b/layers/nuxi/agent/tools/show_module.ts @@ -1,13 +1,8 @@ -import { tool } from 'ai' +import { defineTool } from 'eve/tools' import { z } from 'zod' -import type { UIToolInvocation } from 'ai' -import { FetchError } from 'ofetch' - -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() @@ -30,20 +25,23 @@ function slugCandidates(raw: string): string[] { async function fetchModule(slug: string): Promise | null> { const url = `${MODULE_API}/${encodeURIComponent(slug)}` try { - const data = await $fetch>(url) + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }) + if (response.status === 404) return null + if (!response.ok) throw new Error(`Module API ${response.status}`) + const data = await response.json() as Record return data.error === true ? null : data - } catch (e) { - if (e instanceof FetchError && e.statusCode === 404) return null - throw e + } catch (error) { + if (error instanceof Error && error.message.includes('404')) return null + throw error } } -export const showModuleTool = tool({ +export default defineTool({ 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 }) => { + async execute({ name }) { let data: Record | null = null for (const slug of slugCandidates(name)) { diff --git a/layers/nuxi/agent/tools/show_template.ts b/layers/nuxi/agent/tools/show_template.ts new file mode 100644 index 000000000..afee4aa74 --- /dev/null +++ b/layers/nuxi/agent/tools/show_template.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { defineNuxtTool } from '../lib/define-nuxt-tool.js' + +export default defineNuxtTool({ + 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().trim().min(1)).min(1).describe('Template names or slugs to display (e.g., ["ui", "content", "starter", "movies"])') + }), + path: '/api/internal/content', + body: input => ({ kind: 'templates' as const, ...input }) +}) diff --git a/layers/nuxi/agent/tools/web_search.ts b/layers/nuxi/agent/tools/web_search.ts new file mode 100644 index 000000000..7173b8b59 --- /dev/null +++ b/layers/nuxi/agent/tools/web_search.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' +import { defineNuxtTool } from '../lib/define-nuxt-tool.js' + +export default defineNuxtTool({ + description: 'Search the web for recent information beyond the Nuxt docs. Only use when the user explicitly asks about recent events or real-time data, or when search_github_issues returned no results.', + inputSchema: z.object({ + query: z.string().trim().min(1).describe('Search query — match the user\'s wording; do not add calendar years unless they asked for one') + }), + path: '/api/internal/agent/web-search' +}) diff --git a/layers/nuxi/app/components/agent/AgentChatMessages.vue b/layers/nuxi/app/components/agent/AgentChatMessages.vue index 086d52863..b74564206 100644 --- a/layers/nuxi/app/components/agent/AgentChatMessages.vue +++ b/layers/nuxi/app/components/agent/AgentChatMessages.vue @@ -1,9 +1,10 @@