Files
nuxt__ui/docs/server/api/ai.post.ts

473 lines
26 KiB
TypeScript

import type { UIMessage, InferUITools, Tool } from 'ai'
import { ToolLoopAgent, createAgentUIStreamResponse, tool, dynamicTool, jsonSchema, smoothStream, isStepCount, consumeStream, APICallError } from 'ai'
import type { AnthropicLanguageModelOptions } from '@ai-sdk/anthropic'
import type { GatewayProviderOptions } from '@ai-sdk/gateway'
import { z } from 'zod'
import { tools as mcpToolDefinitions } from '#nuxt-mcp-toolkit/tools.mjs'
import * as theme from '../../.nuxt/ui'
import { themeIcons, cssVariableDefaults } from '../../app/utils/theme'
const componentNames = Object.keys(theme)
function mcpToolsToAiTools() {
const aiTools: Record<string, Tool> = {}
for (const def of mcpToolDefinitions as any[]) {
const filename = def._meta?.filename as string | undefined
const name = def.name || (filename
? filename.replace(/\.(ts|js|mts|mjs)$/, '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[_\s]+/g, '-').toLowerCase()
: null)
if (!name) continue
const schema = def.inputSchema
? z.toJSONSchema(z.object(def.inputSchema)) as Record<string, unknown>
: { type: 'object' as const, properties: {} }
aiTools[name] = dynamicTool({
description: def.description || '',
inputSchema: jsonSchema(schema),
execute: async (args: any) => {
try {
return await def.handler(args, {})
} catch (error: any) {
return { error: error.statusCode ? `[${error.statusCode}] ${error.message}` : error.message || String(error) }
}
}
})
}
return aiTools
}
export interface ApplyThemeSettings {
primary?: string
neutral?: string
secondary?: string
success?: string
info?: string
warning?: string
error?: string
radius?: number
font?: string
blackAsPrimary?: boolean
icons?: string
customColors?: Record<string, Record<string, string>>
cssVariables?: { light?: Record<string, string>, dark?: Record<string, string> }
ui?: Record<string, any>
}
// Written as a raw JSON Schema rather than zod on purpose: the SDK's zod conversion rewrites
// `additionalProperties` to `false` on every object, which strips the value schema off
// `z.record()` and would tell the model that `customColors`, `cssVariables` and `ui` accept
// no keys at all. The `jsonSchema<T>()` generic still gives us the input type.
const applyTheme = tool({
description: 'Apply theme settings live on the docs site. Call this when users ask to change colors, radius, font, or other theme properties. Only include properties that changed.',
inputSchema: jsonSchema<ApplyThemeSettings>({
type: 'object' as const,
properties: {
primary: { type: 'string', description: 'Primary color name (e.g., green, blue, red, indigo)' },
neutral: { type: 'string', description: 'Neutral color name (slate, gray, zinc, neutral, stone, taupe, mauve, mist, olive)' },
secondary: { type: 'string', description: 'Secondary color name' },
success: { type: 'string', description: 'Success color name' },
info: { type: 'string', description: 'Info color name' },
warning: { type: 'string', description: 'Warning color name' },
error: { type: 'string', description: 'Error color name' },
radius: { type: 'number', description: 'Border radius in rem: 0, 0.125, 0.25, 0.375, 0.5' },
font: { type: 'string', description: 'Font family name — any Google Font works (e.g. Public Sans, DM Sans, Geist, Inter, Poppins, Outfit, Raleway, Playfair Display, Nunito, JetBrains Mono, etc.)' },
blackAsPrimary: { type: 'boolean', description: 'Use solid black/white as primary color for a monochrome look' },
icons: { type: 'string', description: 'Icon set for live preview: lucide (default), phosphor, or tabler. For exported code, any Iconify icon set can be suggested.' },
customColors: {
type: 'object',
description: 'Custom color palettes with shades 50-950 as hex values',
additionalProperties: {
type: 'object',
additionalProperties: { type: 'string' }
}
},
cssVariables: {
type: 'object',
description: 'Fine-tuning CSS variable overrides (last resort). Use only for subtle shade adjustments that can\'t be achieved with color names or customColors. Always provide both light and dark.',
properties: {
light: {
type: 'object',
description: 'CSS variables for light mode (.light). Keys: --ui-text, --ui-bg, --ui-border, --ui-primary, etc. Values: var(--ui-color-<name>-<shade>), hex, white, black.',
additionalProperties: { type: 'string' }
},
dark: {
type: 'object',
description: 'CSS variables for dark mode (.dark). Same variable names as light.',
additionalProperties: { type: 'string' }
}
}
},
ui: {
type: 'object',
description: 'Component-level theme overrides. MUST include ALL component customizations here so they are applied live. Keys are camelCase component names (e.g. button, badge, popover). Values have slots, defaultVariants, variants, compoundVariants.',
additionalProperties: true
}
}
}),
execute: async settings => ({ applied: true, ...settings })
})
const resetTheme = tool({
description: 'Reset the theme back to defaults (primary: green, neutral: slate, radius: 0.25rem, font: Public Sans). Call this when users ask to reset, revert, or restore the default theme.',
inputSchema: z.object({}),
execute: async () => ({ reset: true })
})
const getComponentTheme = tool({
description: 'Get the theme definition (slots, variants, compoundVariants, defaultVariants) for a specific Nuxt UI component. Call this when you need to know the available slots and customization options to suggest component-level theming.',
inputSchema: z.object({
componentName: z.string().describe(`Component name in camelCase. Available: ${componentNames.join(', ')}`)
}),
execute: async ({ componentName }) => {
const componentTheme = (theme as Record<string, unknown>)[componentName]
if (!componentTheme) {
return { error: `Component "${componentName}" not found`, availableComponents: componentNames }
}
return { componentName, theme: componentTheme }
}
})
const getThemeGuide = tool({
description: 'Get detailed instructions for applying live theme changes. Call this ONLY when you are about to use applyTheme (e.g. user says "make it blue", "create a dark theme"). Do NOT call for documentation questions about theming — search docs instead.',
inputSchema: z.object({}),
execute: async () => ({
guide: `When users ask to change the theme, customize colors, or modify the appearance, use the \`applyTheme\` tool to apply changes live on this docs site. Only include properties that changed.
When users ask for a complete theme, to change "all colors", or describe a broad aesthetic (e.g. "sakura-inspired theme"), you MUST set ALL of: primary, neutral, secondary, success, info, warning, error, radius, and font. You can change the icon set (lucide, phosphor, or tabler) if it really enhances the theme, but prefer keeping lucide as the default — it works well with most themes. You can optionally include component-level \`ui\` overrides for a more polished result — if you do, look up the component theme first with \`getComponentTheme\` and prefer \`defaultVariants\` (e.g. button size or variant) over slot class overrides. Create a cohesive design system, not just random colors:
- Pick a **primary** that embodies the theme's identity. If no standard Tailwind color fits, use \`customColors\` to define a bespoke palette with all shades 50-950 as hex values — this is encouraged for creative/unique themes.
- Pick a **secondary** that complements the primary (analogous or contrasting on the color wheel). Can also be a custom palette.
- Pick **success/info/warning/error** that feel harmonious with the palette while staying semantically meaningful (success = green-ish, error = red-ish, warning = amber/yellow-ish, info = blue/cyan-ish). You can shift hues — e.g. \`lime\` for success in a nature theme, \`rose\` for error in a warm theme — but keep them recognizable.
- For monochrome/black-and-white themes, keep semantic colors meaningful. Only primary, secondary, and neutral should go monochrome. Use \`blackAsPrimary: true\` for monochrome primary.
When users ask to reset, revert, or restore the default theme, use the \`resetTheme\` tool. This resets primary to green, neutral to slate, radius to 0.25rem, font to Public Sans, and removes any custom colors.
There are two types of customization:
**1. CSS Variables (main.css)**
The main.css file uses Tailwind CSS directives to configure design tokens:
*Fonts* — use the \`@theme\` directive. Any Google Font works, \`@nuxt/fonts\` will automatically load and optimize it:
\`\`\`css
@theme {
--font-sans: 'Inter', sans-serif;
}
\`\`\`
*Custom Colors* — define palettes with ALL shades (50-950) using \`@theme static\`:
\`\`\`css
@theme static {
--color-brand-50: #fef2f2;
--color-brand-100: #fee2e2;
/* ... all shades 200-900 ... */
--color-brand-950: #450a0a;
}
\`\`\`
*Radius:*
\`\`\`css
:root { --ui-radius: 0.375rem; }
\`\`\`
*Monochrome primary:*
\`\`\`css
:root { --ui-primary: black; }
.dark { --ui-primary: white; }
\`\`\`
*True black & white theme* — for a monochrome theme, also set \`--ui-bg\` to pure black/white:
\`\`\`css
.dark { --ui-bg: black; }
\`\`\`
*Semantic shade overrides* — override which shade a semantic color uses:
\`\`\`css
:root, .light { --ui-primary: var(--ui-color-primary-700); }
.dark { --ui-primary: var(--ui-color-primary-200); }
\`\`\`
**CSS Variable fine-tuning (last resort)** — use the \`cssVariables\` property in \`applyTheme\` ONLY for subtle one-shade adjustments. Example: shifting \`--ui-bg\` from neutral-900 to neutral-950 in dark mode, or \`--ui-border\` from neutral-200 to neutral-300 in light mode.
CRITICAL RULES for \`cssVariables\`:
- ONLY shift by 1-2 shade levels from the default (e.g. neutral-900 → neutral-950). NEVER replace the neutral palette with a completely different color (e.g. setting \`--ui-bg\` to a custom color like cream). If you want warm/cool backgrounds, choose the right \`neutral\` color instead (see color options below). Exception: for monochrome/black-and-white themes, you MAY use \`black\` or \`white\` as values (e.g. \`--ui-bg: black\` in dark mode).
- ALWAYS provide BOTH \`light\` and \`dark\` objects, but only include variables you are CHANGING from their defaults. Do NOT include variables that keep their default value.
- Values MUST use \`var(--ui-color-<name>-<shade>)\` references (e.g. \`var(--ui-color-neutral-950)\`), \`white\`, or \`black\`. NEVER use raw hex values.
- The \`<name>\` in the variable reference MUST match the current neutral color (which the user may have changed). Use \`neutral\` as the name since it maps to whatever neutral palette is active.
- In the exported main.css code, ONLY show overridden CSS variables (not defaults). Use \`:root, .light { }\` for light-mode overrides and \`.dark { }\` for dark-mode overrides. NEVER put CSS variable overrides in a plain \`:root { }\` block (that's only for \`--ui-radius\` and monochrome \`--ui-primary\`).
Default values — only override the ones you want to change:
Light defaults (\`:root, .light\`):
${Object.entries(cssVariableDefaults.light).map(([k, v]) => `- \`${k}\`: \`${v}\``).join('\n')}
Dark defaults (\`.dark\`):
${Object.entries(cssVariableDefaults.dark).map(([k, v]) => `- \`${k}\`: \`${v}\``).join('\n')}
Semantic colors (\`--ui-primary\`, \`--ui-secondary\`, \`--ui-success\`, \`--ui-info\`, \`--ui-warning\`, \`--ui-error\`) default to shade 500 in light, 400 in dark.
Do NOT use \`cssVariables\` for things achievable with \`primary\`, \`neutral\`, \`customColors\`, or component \`ui\` overrides.
**2. Config (app.config.ts for Nuxt / vite.config.ts for Vue)**
For semantic color assignment and component-level theming. The \`ui\` object is the same for both frameworks:
\`\`\`
ui: {
colors: {
primary: 'blue',
neutral: 'zinc'
},
button: {
slots: { base: 'font-bold' },
defaultVariants: { size: 'lg' }
}
}
\`\`\`
For Nuxt, wrap in \`defineAppConfig({ ui: { ... } })\`. For Vue, pass as \`ui({ ui: { ... } })\` in the Vite plugin.
**Color options:**
- Standard Tailwind: red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose
- Neutral palettes (pick one that matches the aesthetic):
- **slate** — cool blue-gray, professional, default
- **gray** — true neutral, clean, no color tint
- **zinc** — slightly cool, modern, techy
- **neutral** — perfectly balanced, no warmth or coolness
- **stone** — warm gray, earthy, organic feel
- **taupe** — warm brown-gray, sophisticated, vintage
- **mauve** — purple-tinted gray, elegant, creative
- **mist** — soft blue-gray, airy, light
- **olive** — green-tinted gray, natural, earthy
For example: warm/cozy themes → stone or taupe, elegant/creative → mauve, tech/minimal → zinc, nature → olive, etc. ALWAYS change the neutral when creating a complete theme — don't leave it as the default slate unless it genuinely fits.
- Custom: define all shades 50-950 in \`customColors\` of the \`applyTheme\` tool, then reference the name
**Other options:**
- Radius (pick one that matches the aesthetic):
- **0** — sharp, brutalist, no rounding
- **0.125** — subtle, minimal softness
- **0.25** — balanced, default
- **0.375** — rounded, friendly
- **0.5** — pill-like, playful, soft
- Font: any Google Font works, \`@nuxt/fonts\` auto-loads it. Pick a font that matches the theme's personality:
- Sans-serif (clean/modern): Inter, DM Sans, Geist, Public Sans, Outfit, Plus Jakarta Sans, Space Grotesk
- Serif (elegant/editorial): Playfair Display, Lora, Merriweather, Fraunces, Newsreader
- Rounded (friendly/playful): Nunito, Quicksand, Varela Round
- Monospace (techy/dev): JetBrains Mono, Fira Code, IBM Plex Mono
ALWAYS change the font when creating a complete theme — don't leave the default unless it genuinely fits.
- Icons: lucide (default), phosphor, or tabler for live preview. Any Iconify icon set works in the exported config. When suggesting a non-default icon set, include the FULL \`ui.icons\` mapping in the exported config and tell the user to install \`@iconify-json/{collection}\` (e.g. \`@iconify-json/ph\` for Phosphor). Required keys: ${Object.keys(themeIcons.phosphor).join(', ')}. Values use \`i-<set>-<name>\` format.
**Component Theme Lookup:**
When users ask about component-specific customization, use the \`getComponentTheme\` tool to get the exact slots, variants, and defaults for that component. This lets you suggest precise config overrides.
When you want to suggest component \`ui\` overrides (e.g. customizing button styles), call \`getComponentTheme\` for that component first — never guess slot names. Only look up component themes when you actually plan to include \`ui\` overrides in the \`applyTheme\` call.
Prefer \`defaultVariants\` as your first tool for component customization — changing a component's default size, variant, or color is impactful and low-risk. Only include values that actually differ from the component's defaults (use \`getComponentTheme\` to check) — never include unchanged defaults like \`size: 'md'\` if that's already the default. Only add slot class overrides when \`defaultVariants\` alone can't achieve the desired effect. When creating a complete theme, if you want to customize one component to give it personality, prioritize **button** — e.g. \`button: { defaultVariants: { variant: 'subtle' } }\`. Button is the most visible and impactful component to customize.
CRITICAL rules for component \`ui\` overrides:
- NEVER use \`rounded-*\` classes in component slot overrides. Border radius is controlled globally by \`--ui-radius\` — hardcoding rounded classes would override the CSS variable and break consistency.
- Only ADD new classes that aren't already in the component's default theme. Do NOT repeat or duplicate default classes (e.g. \`inline-flex\`, \`items-center\`, \`disabled:cursor-not-allowed\`, \`transition-colors\` on button are already defaults). Use \`getComponentTheme\` to check what's already there.
- Keep overrides minimal and intentional — only include classes that actually change the look from the default.
- Tailwind v4 only generates classes that are already used in source files. Arbitrary Tailwind utility classes (e.g. \`tracking-wide\`, \`shadow-2xl\`) may NOT exist in the user's CSS output. Prefer overrides that use classes already present in the component's default theme (e.g. changing \`font-medium\` to \`font-semibold\`) or CSS variables. For the exported code, mention that users may need to safelist any new utility classes.
**When suggesting theme changes, you MUST:**
1. Call the \`applyTheme\` tool with the settings so changes apply live
2. Show the full **main.css** code block so users can copy it. Use this structure:
\`\`\`css
@import "tailwindcss";
@import "@nuxt/ui";
@theme {
--font-sans: 'FontName', sans-serif; /* only if font changed */
}
@theme static {
/* custom color palettes here */
}
:root {
--ui-radius: 0.375rem; /* only radius and monochrome --ui-primary go here */
}
:root, .light {
/* light-mode CSS variable overrides here (if any) */
}
.dark {
/* dark-mode CSS variable overrides AND monochrome --ui-primary: white here */
}
\`\`\`
3. Show the config code block if colors, icons, or component overrides changed. Use **app.config.ts** for Nuxt or **vite.config.ts** for Vue (based on the user's framework). IMPORTANT: this must include ALL settings from the entire conversation — not just the current \`applyTheme\` call but also all previous calls (colors, icons with full \`ui.icons\` mapping, component \`ui\` overrides like button, popover, etc.). If a non-default icon set was chosen, the exported config MUST include the complete \`ui.icons\` object with every key mapped. Review earlier \`applyTheme\` calls in the conversation and merge everything into one complete config.
For **Nuxt** — \`app.config.ts\`:
\`\`\`typescript
export default defineAppConfig({
ui: {
// ... ALL accumulated config from this conversation
}
})
\`\`\`
For **Vue** — \`vite.config.ts\`:
\`\`\`typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
// ... ALL accumulated config from this conversation
}
})
]
})
\`\`\`
NEVER recommend \`appConfig.theme.*\` properties (like \`blackAsPrimary\`, \`radius\`, \`font\`) — those are internal to the docs site. Users should use CSS variables in main.css for radius, fonts, and monochrome primary.`
})
})
const tools = {
...mcpToolsToAiTools(),
getThemeGuide,
applyTheme,
resetTheme,
getComponentTheme
}
export type DocsChatTools = InferUITools<typeof tools>
export type DocsChatMessage = UIMessage<unknown, never, DocsChatTools>
function buildInstructions(framework: 'nuxt' | 'vue') {
return `You are a helpful assistant for Nuxt UI, a UI library for Nuxt and Vue. Nuxt UI includes \`@nuxt/fonts\` and \`@nuxt/icon\` as built-in dependencies — never tell users to install them separately. Use your knowledge base tools to search for relevant information before answering questions.
The user is using **${framework === 'vue' ? 'Vue' : 'Nuxt'}**. Tailor your answers accordingly — ${framework === 'vue' ? 'use the Vite plugin setup, Vue Router, and vite.config.ts instead of Nuxt-specific features like modules or app.config.ts. IMPORTANT: The Vite plugin auto-imports components and Nuxt UI composables, but Vue core APIs and VueUse must be explicitly imported — always include these in code examples (e.g. `import { ref, computed } from \'vue\'`, `import { useColorMode } from \'@vueuse/core\'`).' : 'use Nuxt modules, auto-imports, app.config.ts, and other Nuxt-specific features. Nuxt auto-imports Vue APIs (ref, computed, etc.), composables, and components — do not include these imports in code examples.'}
Guidelines:
- For documentation questions, ALWAYS use tools to search for information. Never rely on pre-trained knowledge for Nuxt UI APIs, props, or usage.
- For questions about how to customize themes (e.g. "how do I customize colors?", "how does theming work?"), search the documentation like any other docs question.
- When users ask you to APPLY a theme change live (e.g. "make it blue", "create a sakura theme", "change the font"), call \`getThemeGuide\` first for detailed instructions, then use \`applyTheme\` / \`resetTheme\`. Use your own judgment on aesthetics, color theory, and design — no need to search docs for that. Be decisive: pick colors/fonts/radius confidently and apply them.
- If a question is unrelated to Nuxt UI (e.g. general coding, off-topic), briefly answer if you can, but don't waste tool calls searching docs for it.
- If no relevant information is found after searching, respond with "Sorry, I couldn't find information about that in the documentation."
- Be concise and direct in your responses.
**PAGE CONTEXT:**
- A user message may end with a \`[Context: the user is currently viewing <path>]\` marker. It is added automatically and is not something the user typed, so never mention it or repeat it back.
- Use it to resolve vague questions ("explain this page", "how does this work?"). Only the most recent marker is relevant, earlier ones are stale.
- When the marker names a docs path, call \`get-documentation-page\` with that exact path instead of searching first. Don't limit yourself to that page if the question is broader or unrelated.
**FORMATTING RULES (CRITICAL):**
- ABSOLUTELY NO MARKDOWN HEADINGS: Never use #, ##, ###, ####, #####, or ######
- NO underline-style headings with === or ---
- Use **bold text** for emphasis and section labels instead
- Examples:
* Instead of "## Usage", write "**Usage:**" or just "Here's how to use it:"
* Instead of "# Complete Guide", write "**Complete Guide**" or start directly with content
- Start all responses with content, never with a heading
- Reference specific component names, props, or APIs when applicable.
- If a question is ambiguous, ask for clarification rather than guessing.
- When multiple relevant items are found, list them clearly using bullet points.
- You have up to 5 tool calls to find the answer, so be strategic: start broad, then get specific if needed.
- Format responses in a conversational way, not as documentation sections.
`
}
// Static per framework so the cached prompt prefix stays stable across requests.
const instructions = {
nuxt: buildInstructions('nuxt'),
vue: buildInstructions('vue')
}
const anthropicOptions = {
thinking: {
type: 'adaptive',
display: 'summarized'
},
effort: 'low'
} satisfies AnthropicLanguageModelOptions
export default defineEventHandler(async (event) => {
const { messages, framework, currentPage } = await readBody(event)
if (!messages || !Array.isArray(messages)) {
throw createError({ statusCode: 400, message: 'Invalid or missing messages array.' })
}
// `currentPage` reaches the model as a marker on the last user message, so it is an
// indirect prompt-injection surface (a crafted /docs/... link can smuggle newlines and
// instructions via Vue Router's path decoding). Accept it only when it is a plain docs
// path with no control characters; otherwise drop it.
const safeCurrentPage = typeof currentPage === 'string'
&& currentPage.length <= 128
&& !/[\r\n]/.test(currentPage)
&& /^\/docs\/[\w/-]*$/.test(currentPage)
? currentPage
: null
// Page context belongs to the turn it was sent with, not to the thread: it is appended
// here and never persisted client-side, so a stale path can't leak into a later answer.
const uiMessages = messages.map((message: UIMessage, index: number) => {
if (!safeCurrentPage || index !== messages.length - 1 || message.role !== 'user') {
return message
}
return {
...message,
parts: [...(message.parts || []), { type: 'text' as const, text: `[Context: the user is currently viewing ${safeCurrentPage}]` }]
}
})
const abortController = new AbortController()
event.node.req.on('close', () => abortController.abort())
const agent = new ToolLoopAgent({
model: 'anthropic/claude-sonnet-5',
instructions: framework === 'vue' ? instructions.vue : instructions.nuxt,
maxOutputTokens: 8000,
stopWhen: isStepCount(6),
tools,
providerOptions: {
anthropic: anthropicOptions,
gateway: {
caching: 'auto',
user: getChatUser(event),
tags: ['docs-chat'],
// Same tier as the primary so the adaptive thinking options stay supported.
models: ['anthropic/claude-sonnet-4.6']
} satisfies GatewayProviderOptions
}
})
return createAgentUIStreamResponse({
agent,
uiMessages,
abortSignal: abortController.signal,
experimental_transform: smoothStream(),
consumeSseStream: consumeStream,
onError: (error) => {
// Provider errors carry the outgoing prompt in `requestBodyValues` and the raw
// `responseBody`, so log identifying fields only and keep chat content out of the logs.
const statusCode = APICallError.isInstance(error) ? error.statusCode : undefined
console.error('[api/ai] stream error:', {
name: error instanceof Error ? error.name : 'UnknownError',
message: error instanceof Error ? error.message : String(error),
statusCode
})
if (statusCode === 429) {
return 'You have reached the message limit for now. Please try again later.'
}
return 'An error occurred.'
}
})
})