From ea1e1d84e1f5513b79e6e54ba4edaaa35000cd55 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Mon, 24 Aug 2026 18:45:01 +0200 Subject: [PATCH 1/3] feat(assistant): allow agnostic provider --- docs/content/en/4.ai/1.assistant.md | 117 +++++++++++- docs/content/fr/4.ai/1.assistant.md | 116 +++++++++++- layer/modules/assistant/README.md | 22 ++- layer/modules/assistant/index.ts | 101 ++++++++-- .../assistant/runtime/server/api/search.ts | 132 +------------ .../runtime/server/utils/assistant.ts | 179 ++++++++++++++++++ 6 files changed, 507 insertions(+), 160 deletions(-) create mode 100644 layer/modules/assistant/runtime/server/utils/assistant.ts diff --git a/docs/content/en/4.ai/1.assistant.md b/docs/content/en/4.ai/1.assistant.md index 68c2177d9..1a65b03b2 100644 --- a/docs/content/en/4.ai/1.assistant.md +++ b/docs/content/en/4.ai/1.assistant.md @@ -27,6 +27,10 @@ By default, the assistant connects to your documentation's built-in MCP server a ## Quick Start +::note{to="#custom-ai-provider"} +This quick start uses Vercel AI Gateway. To use another provider (Mistral, OpenAI, Cloudflare AI Gateway, or anything else supported by the AI SDK), see **Custom AI provider**. +:: + ### 1. Install dependencies ::code-group @@ -47,19 +51,19 @@ yarn add ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt ### 2. Set up AI Gateway authentication -Pick **one** of this method: +Pick **one** of these methods: -**API key** — Create a key in [Vercel AI Gateway](https://vercel.com/~/ai/api-keys) and add it to your environment: +**API key**: create a key in [Vercel AI Gateway](https://vercel.com/~/ai/api-keys) and add it to your environment: ```bash [.env] AI_GATEWAY_API_KEY=your-api-key ``` -**OIDC (only on Vercel)** — `VERCEL_OIDC_TOKEN` is injected automatically. Nothing to add in the production. For local dev, run `vercel env pull` on a [linked project](https://vercel.com/docs/cli/link). +**OIDC (only on Vercel)**: `VERCEL_OIDC_TOKEN` is injected automatically, so there is nothing to add in production. For local dev, run `vercel env pull` on a [linked project](https://vercel.com/docs/cli/link). ### 3. Deploy -Deploy your site — the assistant is available as soon as authentication is configured. +Deploy your site, the assistant is available as soon as authentication is configured. ## Using the Assistant @@ -255,7 +259,19 @@ export default defineAppConfig({ ### Disable the Assistant Entirely -The assistant is disabled when no authentication is available. To explicitly disable it, remove `AI_GATEWAY_API_KEY` from your environment: +Set `enabled` to `false` to disable the assistant, even when AI Gateway credentials are available: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + docus: { + assistant: { + enabled: false + } + } +}) +``` + +The assistant is also disabled when no authentication is available, so removing `AI_GATEWAY_API_KEY` from your environment has the same effect: ```bash [.env] # AI_GATEWAY_API_KEY=your-api-key @@ -271,6 +287,9 @@ Configure advanced options in `nuxt.config.ts` under `docus.assistant`. export default defineNuxtConfig({ docus: { assistant: { + // Force enable or disable the assistant + enabled: true, + // AI model (uses AI SDK Gateway format) model: 'google/gemini-3-flash', @@ -336,6 +355,92 @@ export default defineNuxtConfig({ }) ``` +### Custom AI Provider + +The `model` option above resolves models through Vercel AI Gateway, so it requires `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN`. To use another provider (Mistral, OpenAI, Cloudflare AI Gateway, or anything else supported by the [AI SDK](https://ai-sdk.dev/)), enable the assistant explicitly and provide your own endpoint. + +#### 1. Enable the assistant and pick a path + +Set `enabled: true` so the assistant no longer depends on AI Gateway credentials, and point `apiPath` at the route you're about to create: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + docus: { + assistant: { + enabled: true, + apiPath: '/api/assistant' + } + } +}) +``` + +Your own server route always takes precedence: when you define a route at `apiPath`, Docus steps aside and doesn't register its built-in endpoint there. + +#### 2. Implement the endpoint + +The endpoint is a regular Nitro route, so you have two options: + +| Approach | Use it when | +| -------- | ----------- | +| [Reuse the built-in handler](#reuse-the-built-in-handler) | You only need to swap the model, the system prompt, or the provider options. MCP tool wiring, streaming, and abort handling stay in place. | +| [Write your own handler](#write-your-own-handler) | You need full control over tools, message handling, or the streaming pipeline. | + +#### Reuse the built-in handler + +`assistantSearchHandler` is an auto-imported server util that contains the default endpoint logic: MCP tool wiring, streaming, and abort handling. Pass a `model` to change the provider: + +```ts [server/api/assistant.ts] +import { createMistral } from '@ai-sdk/mistral' + +const mistral = createMistral() + +export default defineEventHandler(event => assistantSearchHandler(event, { + model: mistral('mistral-large-latest') +})) +``` + +That's the whole integration: everything else, including the documentation-tuned system prompt, keeps working as before. + +`assistantSearchHandler` accepts an optional config object as its second argument: + +| Property | Type | Description | +| ----------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `model` | `LanguageModel` | Any AI SDK model. Defaults to `docus.assistant.model` resolved through Vercel AI Gateway. | +| `systemPrompt` | `string \| (event, { siteName }) => string` | Replaces the built-in prompt. Use `getAssistantSystemPrompt(siteName)` to extend the default instead of replacing it. | +| `providerOptions` | `ProviderOptions` | Provider specific options passed to `streamText`. Defaults to Vercel AI Gateway caching, and is omitted when you pass a custom `model`. | + +#### Write your own handler + +Skip `assistantSearchHandler` entirely and implement the route yourself. The assistant UI talks to it through the AI SDK's `DefaultChatTransport`, so the handler only has to respect two things: + +- It receives a `POST` with a `{ messages }` body, where `messages` is an array of AI SDK `UIMessage`. +- It returns a UI message stream response, built with `createUIMessageStreamResponse`. + +```ts [server/api/assistant.ts] +import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai' +import { createMistral } from '@ai-sdk/mistral' + +const mistral = createMistral() + +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) + + const result = streamText({ + model: mistral('mistral-large-latest'), + instructions: getAssistantSystemPrompt('My Documentation'), + messages: await convertToModelMessages(messages) + }) + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ stream: result.stream }) + }) +}) +``` + +::warning +A handler written from scratch loses everything the built-in one provides: MCP tool wiring, step limits, abort handling on client disconnect, and stream smoothing. Wire in your own `tools` if you want the assistant to keep searching your documentation. +:: + ### Site Name in Responses The assistant automatically uses your site name in its responses. Configure the site name in `nuxt.config.ts`: @@ -375,7 +480,7 @@ function askQuestion() { | Property | Type | Description | | -------------------------------- | ---------------------- | --------------------------------------------------------------------------------------- | -| `isEnabled` | `ComputedRef` | Whether the assistant is enabled (`AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` at build) | +| `isEnabled` | `ComputedRef` | Whether the assistant is enabled (`docus.assistant.enabled`, or `AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN` at build) | | `isOpen` | `Ref` | Whether the slideover is open | | `open(message?, clearPrevious?)` | `Function` | Open the assistant, optionally with a message | | `close()` | `Function` | Close the assistant slideover | diff --git a/docs/content/fr/4.ai/1.assistant.md b/docs/content/fr/4.ai/1.assistant.md index d008c4f39..eff969675 100644 --- a/docs/content/fr/4.ai/1.assistant.md +++ b/docs/content/fr/4.ai/1.assistant.md @@ -27,6 +27,10 @@ Par défaut, l'assistant se connecte au serveur MCP intégré de votre documenta ## Démarrage rapide +::note{to="#fournisseur-ia-personnalisé"} +Ce démarrage rapide utilise Vercel AI Gateway. Pour utiliser un autre fournisseur (Mistral, OpenAI, Cloudflare AI Gateway, ou tout autre fournisseur supporté par l'AI SDK), consultez **Fournisseur IA personnalisé**. +:: + ### 1. Installer les dépendances ::code-group @@ -49,17 +53,17 @@ yarn add ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt Choisissez **une** de ces méthodes : -**Clé API** — Créez une clé dans [Vercel AI Gateway](https://vercel.com/~/ai/api-keys) et ajoutez-la à votre environnement : +**Clé API** : créez une clé dans [Vercel AI Gateway](https://vercel.com/~/ai/api-keys) et ajoutez-la à votre environnement : ```bash [.env] AI_GATEWAY_API_KEY=votre-cle-api ``` -**OIDC (uniquement sur Vercel)** — `VERCEL_OIDC_TOKEN` est injecté automatiquement. Rien à ajouter en production. En local, lancez `vercel env pull` sur un [projet lié](https://vercel.com/docs/cli/link). +**OIDC (uniquement sur Vercel)** : `VERCEL_OIDC_TOKEN` est injecté automatiquement, il n'y a donc rien à ajouter en production. En local, lancez `vercel env pull` sur un [projet lié](https://vercel.com/docs/cli/link). ### 3. Déployer -Déployez votre site — l'assistant est disponible dès que l'authentification est configurée. +Déployez votre site, l'assistant est disponible dès que l'authentification est configurée. ## Utiliser l'Assistant @@ -255,7 +259,19 @@ export default defineAppConfig({ ### Désactiver l'assistant entièrement -L'assistant est désactivé quand aucune authentification n'est disponible. Pour le désactiver explicitement, supprimez `AI_GATEWAY_API_KEY` de votre environnement : +Passez `enabled` à `false` pour désactiver l'assistant, même quand des identifiants AI Gateway sont disponibles : + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + docus: { + assistant: { + enabled: false + } + } +}) +``` + +L'assistant est aussi désactivé quand aucune authentification n'est disponible : supprimer `AI_GATEWAY_API_KEY` de votre environnement a donc le même effet : ```bash [.env] # AI_GATEWAY_API_KEY=votre-cle-api @@ -271,6 +287,10 @@ Configurez les options avancées dans `nuxt.config.ts` sous `docus.assistant`. export default defineNuxtConfig({ docus: { assistant: { + // Force l'activation ou la désactivation de l'assistant + // Par défaut, détection automatique via les identifiants AI Gateway + enabled: true, + // Modèle IA (utilise le format AI SDK Gateway) model: 'google/gemini-3-flash', @@ -336,6 +356,92 @@ export default defineNuxtConfig({ }) ``` +### Fournisseur IA personnalisé + +L'option `model` ci-dessus résout les modèles via Vercel AI Gateway, elle nécessite donc `AI_GATEWAY_API_KEY` ou `VERCEL_OIDC_TOKEN`. Pour utiliser un autre fournisseur (Mistral, OpenAI, Cloudflare AI Gateway, ou tout autre fournisseur supporté par l'[AI SDK](https://ai-sdk.dev/)), activez explicitement l'assistant et fournissez votre propre endpoint. + +#### 1. Activer l'assistant et choisir un chemin + +Passez `enabled` à `true` pour que l'assistant ne dépende plus des identifiants AI Gateway, et faites pointer `apiPath` vers la route que vous allez créer : + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + docus: { + assistant: { + enabled: true, + apiPath: '/api/assistant' + } + } +}) +``` + +Votre route serveur est toujours prioritaire : quand vous définissez une route sur `apiPath`, Docus s'efface et n'enregistre pas son endpoint intégré à cet emplacement. + +#### 2. Implémenter l'endpoint + +L'endpoint est une route Nitro classique, vous avez donc deux options : + +| Approche | À utiliser quand | +| -------- | ---------------- | +| [Réutiliser le handler intégré](#réutiliser-le-handler-intégré) | Vous voulez seulement changer le modèle, le prompt système ou les options du fournisseur. Le branchement des outils MCP, le streaming et la gestion de l'annulation restent en place. | +| [Écrire votre propre handler](#écrire-votre-propre-handler) | Vous avez besoin d'un contrôle total sur les outils, la gestion des messages ou le pipeline de streaming. | + +#### Réutiliser le handler intégré + +`assistantSearchHandler` est un utilitaire serveur auto-importé qui contient la logique de l'endpoint par défaut : branchement des outils MCP, streaming et gestion de l'annulation. Passez un `model` pour changer de fournisseur : + +```ts [server/api/assistant.ts] +import { createMistral } from '@ai-sdk/mistral' + +const mistral = createMistral() + +export default defineEventHandler(event => assistantSearchHandler(event, { + model: mistral('mistral-large-latest') +})) +``` + +L'intégration s'arrête là : tout le reste, dont le prompt système conçu pour la documentation, continue de fonctionner comme avant. + +`assistantSearchHandler` accepte un objet de configuration optionnel en second argument : + +| Propriété | Type | Description | +| ----------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `model` | `LanguageModel` | N'importe quel modèle AI SDK. Par défaut `docus.assistant.model` résolu via Vercel AI Gateway. | +| `systemPrompt` | `string \| (event, { siteName }) => string` | Remplace le prompt intégré. Utilisez `getAssistantSystemPrompt(siteName)` pour l'étendre plutôt que le remplacer. | +| `providerOptions` | `ProviderOptions` | Options spécifiques au fournisseur passées à `streamText`. Par défaut le cache Vercel AI Gateway, omis quand vous passez un `model` personnalisé. | + +#### Écrire votre propre handler + +Vous pouvez ignorer complètement `assistantSearchHandler` et implémenter la route vous-même. L'interface de l'assistant communique avec elle via le `DefaultChatTransport` de l'AI SDK, le handler doit donc seulement respecter deux contraintes : + +- Il reçoit un `POST` avec un body `{ messages }`, où `messages` est un tableau de `UIMessage` de l'AI SDK. +- Il retourne une réponse de type UI message stream, construite avec `createUIMessageStreamResponse`. + +```ts [server/api/assistant.ts] +import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai' +import { createMistral } from '@ai-sdk/mistral' + +const mistral = createMistral() + +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) + + const result = streamText({ + model: mistral('mistral-large-latest'), + instructions: getAssistantSystemPrompt('Ma documentation'), + messages: await convertToModelMessages(messages) + }) + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ stream: result.stream }) + }) +}) +``` + +::warning +Un handler écrit de zéro perd tout ce que fournit celui intégré : le branchement des outils MCP, la limite d'étapes, la gestion de l'annulation à la déconnexion du client et le lissage du stream. Branchez vos propres `tools` si vous voulez que l'assistant continue de chercher dans votre documentation. +:: + ### Nom du site dans les réponses L'assistant utilise automatiquement le nom de votre site dans ses réponses. Configurez le nom du site dans `nuxt.config.ts` : @@ -375,7 +481,7 @@ function askQuestion() { | Propriété | Type | Description | | -------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | -| `isEnabled` | `ComputedRef` | Si l'assistant est activé (`AI_GATEWAY_API_KEY` ou `VERCEL_OIDC_TOKEN` au build) | +| `isEnabled` | `ComputedRef` | Si l'assistant est activé (`docus.assistant.enabled`, ou `AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN` au build) | | `isOpen` | `Ref` | Si le panneau est ouvert | | `open(message?, clearPrevious?)` | `Function` | Ouvrir l'assistant, optionnellement avec un message | | `close()` | `Function` | Fermer le panneau de l'assistant | diff --git a/layer/modules/assistant/README.md b/layer/modules/assistant/README.md index 183b7eb18..37e91b9e1 100644 --- a/layer/modules/assistant/README.md +++ b/layer/modules/assistant/README.md @@ -156,6 +156,7 @@ clearMessages() | Option | Type | Default | Description | |--------|------|---------|-------------| +| `enabled` | `boolean` | auto-detected | Force enable or disable the assistant. Defaults to `true` when `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` is available at build time | | `apiPath` | `string` | `/__docus__/assistant` | API endpoint path for the chat | | `mcpServer` | `string` | `/mcp` | MCP server path or full URL (e.g., `https://docs.example.com/mcp` for external servers) | | `model` | `string` | `google/gemini-3-flash` | AI model identifier for AI SDK Gateway | @@ -208,14 +209,27 @@ Composable for syntax highlighting code blocks with Shiki. - Nuxt 4.x - Nuxt UI 3.x (for `USlideover`, `UButton`, `UTextarea`, `UChatMessages`, etc.) - An MCP server running (path configurable via `mcpServer`) -- `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` at build time +- `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` at build time, unless you set `enabled: true` and provide your own endpoint ## Customization -### System Prompt +### Custom provider or system prompt -To customize the AI's behavior, edit the system prompt in: -`runtime/server/api/search.ts` +The endpoint logic lives in `runtime/server/utils/assistant.ts` and is exposed as the auto-imported `assistantSearchHandler` server util. Set `enabled: true`, point `apiPath` at your own route, and override what you need: + +```ts +// server/api/assistant.ts +import { createMistral } from '@ai-sdk/mistral' + +const mistral = createMistral() + +export default defineEventHandler(event => assistantSearchHandler(event, { + model: mistral('mistral-large-latest'), + systemPrompt: (_event, { siteName }) => `${getAssistantSystemPrompt(siteName)}\n\nExtra instructions.`, +})) +``` + +`assistantSearchHandler` accepts `model`, `systemPrompt`, and `providerOptions`. A server route you define at `apiPath` always takes precedence over the built-in endpoint. ### Styling diff --git a/layer/modules/assistant/index.ts b/layer/modules/assistant/index.ts index beaa76709..5c1bbfdf2 100644 --- a/layer/modules/assistant/index.ts +++ b/layer/modules/assistant/index.ts @@ -1,7 +1,23 @@ -import { addComponent, addImports, addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit' +import { addComponent, addImports, addServerHandler, addServerImports, createResolver, defineNuxtModule, logger } from '@nuxt/kit' import { defu } from 'defu' export interface AssistantModuleOptions { + /** + * Enable the assistant. + * + * When left undefined, the assistant is enabled if `AI_GATEWAY_API_KEY` or + * `VERCEL_OIDC_TOKEN` is available at build time. + * + * Set it to `true` to use a custom AI SDK provider: Docus then skips + * registering its own endpoint, and you provide a route at `apiPath` built + * with `assistantSearchHandler`. + * + * Set it to `false` to disable the assistant even when AI Gateway + * credentials are available. + * + * @default undefined + */ + enabled?: boolean /** * API endpoint path for the assistant * @default '/__docus__/assistant' @@ -21,9 +37,15 @@ export interface AssistantModuleOptions { model?: string } +/** Subset of the Nitro instance used to hand the endpoint over to a user route. */ +interface NitroBuildContext { + scannedHandlers: Array<{ route?: string }> + options: { handlers: Array<{ route?: string, handler?: string }> } +} + const log = logger.withTag('docus') -const defaults: Required = { +const defaults: Required> = { apiPath: '/__docus__/assistant', mcpServer: '/mcp', model: 'google/gemini-3-flash', @@ -45,10 +67,17 @@ export default defineNuxtModule({ process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN ) + // An explicit `enabled` value always wins, so a custom AI SDK provider can + // be used without AI Gateway credentials. + const isEnabled = options.enabled ?? hasAiGatewayAuth + // Docus only owns the endpoint when it can authenticate to the AI Gateway. + // Otherwise the user brings their own route built with `assistantSearchHandler`. + const hasDefaultHandler = isEnabled && hasAiGatewayAuth + const { resolve } = createResolver(import.meta.url) nuxt.options.runtimeConfig.public.assistant = { - enabled: hasAiGatewayAuth, + enabled: isEnabled, apiPath: options.apiPath, } @@ -68,7 +97,7 @@ export default defineNuxtModule({ components.forEach(name => addComponent({ name, - filePath: hasAiGatewayAuth + filePath: isEnabled ? resolve(`./runtime/components/${name}.vue`) : resolve('./runtime/components/AssistantChatDisabled.vue'), }), @@ -79,22 +108,66 @@ export default defineNuxtModule({ filePath: resolve('./runtime/components/AssistantComark'), }) - if (!hasAiGatewayAuth) { - nuxt.hook('modules:done', () => { - log.warn('AI assistant disabled: neither `AI_GATEWAY_API_KEY` nor `VERCEL_OIDC_TOKEN` found') - }) - return - } - nuxt.options.runtimeConfig.assistant = { mcpServer: options.mcpServer, model: options.model, } + // Exposed even when disabled so overriding the endpoint stays type-safe. + addServerImports([ + { + name: 'assistantSearchHandler', + from: resolve('./runtime/server/utils/assistant'), + }, + { + name: 'getAssistantSystemPrompt', + from: resolve('./runtime/server/utils/assistant'), + }, + ]) + + if (!isEnabled) { + if (options.enabled === undefined) { + nuxt.hook('modules:done', () => { + log.warn('AI assistant disabled: neither `AI_GATEWAY_API_KEY` nor `VERCEL_OIDC_TOKEN` found') + }) + } + return + } + + if (!hasDefaultHandler) { + nuxt.hook('modules:done', () => { + log.info(`AI assistant enabled without AI Gateway credentials: provide a server route at \`${options.apiPath}\` using \`assistantSearchHandler\``) + }) + return + } + const routePath = options.apiPath!.replace(/^\//, '') - addServerHandler({ - route: `/${routePath}`, - handler: resolve('./runtime/server/api/search'), + const route = `/${routePath}` + const handler = resolve('./runtime/server/api/search') + + addServerHandler({ route, handler }) + + // A server route defined by the user at the same path would otherwise be + // silently shadowed by the handler above, so drop ours when it exists. + // `nitro:build:before` is declared by `@nuxt/nitro-server`, which Docus does + // not depend on directly, hence the local typing. + const hookNitroBuild = nuxt.hook as unknown as ( + name: 'nitro:build:before', + callback: (nitro: NitroBuildContext) => void, + ) => void + + hookNitroBuild('nitro:build:before', (nitro) => { + if (!nitro.scannedHandlers.some(scanned => scanned.route === route)) { + return + } + + const index = nitro.options.handlers.findIndex(h => h.route === route && h.handler === handler) + if (index === -1) { + return + } + + nitro.options.handlers.splice(index, 1) + log.info(`AI assistant using your \`${route}\` server route instead of the built-in endpoint`) }) }, }) diff --git a/layer/modules/assistant/runtime/server/api/search.ts b/layer/modules/assistant/runtime/server/api/search.ts index 5d4133348..e8f135316 100644 --- a/layer/modules/assistant/runtime/server/api/search.ts +++ b/layer/modules/assistant/runtime/server/api/search.ts @@ -1,131 +1 @@ -import { streamText, convertToModelMessages, isStepCount, smoothStream, toUIMessageStream, createUIMessageStreamResponse } from 'ai' -import type { ToolSet } from 'ai' -import { createMCPClient } from '@ai-sdk/mcp' -import type { H3Event } from 'h3' - -const MAX_STEPS = 10 - -function createLocalFetch(event: H3Event): typeof fetch { - const origin = getRequestURL(event).origin - - return (input, init) => { - const requestUrl = input instanceof URL - ? input - : typeof input === 'string' - ? new URL(input, origin) - : new URL(input.url) - const localPath = requestUrl.origin === origin - ? `${requestUrl.pathname}${requestUrl.search}` - : requestUrl.toString() - - return event.fetch(localPath, init) - } -} - -function getSystemPrompt(siteName: string) { - return `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation. - -**Your identity:** -- You are an assistant helping users with ${siteName} documentation -- NEVER use first person ("I", "me", "my") - always refer to the project by name: "${siteName} provides...", "${siteName} supports...", "The project offers..." -- Be confident and knowledgeable about the project -- Speak as a helpful guide, not as the documentation itself - -**Tool usage (CRITICAL):** -- You have tools: list-pages (discover pages) and get-page (read a page) -- If a page title clearly matches the question, read it directly without listing first -- ALWAYS respond with text after using tools - never end with just tool calls - -**Guidelines:** -- If you can't find something, say "There is no documentation on that yet" or "${siteName} doesn't cover that topic yet" -- Be concise, helpful, and direct -- Guide users like a friendly expert would - -**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:" -- "${siteName} supports TypeScript out of the box" instead of "I support TypeScript" -- Provide actionable guidance, not just information dumps` -} - -export default defineEventHandler(async (event) => { - const { messages } = await readBody(event) - const config = useRuntimeConfig() - const siteConfig = getSiteConfig(event) - - const siteName = siteConfig.name || 'Documentation' - - const mcpServer = config.assistant.mcpServer - const isExternalUrl = mcpServer.startsWith('http://') || mcpServer.startsWith('https://') - const baseURL = config.app?.baseURL?.replace(/\/$/, '') || '' - - const abortController = new AbortController() - event.node.req.on('close', () => abortController.abort()) - - let transport: Parameters[0]['transport'] - if (isExternalUrl) { - transport = { - type: 'http', - url: mcpServer, - } - } - else if (import.meta.dev) { - transport = { - type: 'http', - url: `${getRequestURL(event).origin}${baseURL}${mcpServer}`, - } - } - else { - transport = { - type: 'http', - url: `${getRequestURL(event).origin}${baseURL}${mcpServer}`, - fetch: createLocalFetch(event), - } - } - - const httpClient = await createMCPClient({ transport }) - const mcpTools = await httpClient.tools() - - const closeMcp = () => event.waitUntil(httpClient.close()) - - const result = streamText({ - model: config.assistant.model, - maxOutputTokens: 8000, - maxRetries: 2, - abortSignal: abortController.signal, - stopWhen: isStepCount(MAX_STEPS), - // On the last allowed step, disable tools so the model is forced to - // produce a final text answer instead of stopping mid tool-calling. - prepareStep: ({ stepNumber }) => { - return stepNumber >= MAX_STEPS - 1 ? { toolChoice: 'none' } : {} - }, - providerOptions: { - gateway: { - caching: 'auto', - }, - }, - instructions: getSystemPrompt(siteName), - messages: await convertToModelMessages(messages), - tools: mcpTools as ToolSet, - experimental_transform: smoothStream(), - onEnd: closeMcp, - onAbort: closeMcp, - onError: closeMcp, - }) - - return createUIMessageStreamResponse({ - stream: toUIMessageStream({ stream: result.stream }), - }) -}) +export default defineEventHandler(event => assistantSearchHandler(event)) diff --git a/layer/modules/assistant/runtime/server/utils/assistant.ts b/layer/modules/assistant/runtime/server/utils/assistant.ts new file mode 100644 index 000000000..7ebbce797 --- /dev/null +++ b/layer/modules/assistant/runtime/server/utils/assistant.ts @@ -0,0 +1,179 @@ +import { streamText, convertToModelMessages, isStepCount, smoothStream, toUIMessageStream, createUIMessageStreamResponse } from 'ai' +import type { LanguageModel, ToolSet } from 'ai' +import { createMCPClient } from '@ai-sdk/mcp' +import type { H3Event } from 'h3' + +const MAX_STEPS = 10 + +type StreamTextOptions = Parameters[0] +type ProviderOptions = NonNullable + +export interface AssistantSystemPromptContext { + /** + * Site name resolved from the site config, used to personalize the prompt. + */ + siteName: string +} + +export interface AssistantSearchConfig { + /** + * Model used to answer the question. + * + * Accepts any AI SDK model, which makes it possible to use a provider other + * than the Vercel AI Gateway (Cloudflare AI Gateway, Mistral, OpenAI, ...). + * + * @default runtimeConfig.assistant.model (resolved through the Vercel AI Gateway) + */ + model?: LanguageModel + /** + * System prompt sent to the model. + * + * Provide a string to fully replace the default prompt, or a function to + * build it from the request. + * + * @default the built-in Docus documentation assistant prompt + */ + systemPrompt?: string | ((event: H3Event, context: AssistantSystemPromptContext) => string) + /** + * Provider specific options forwarded to `streamText`. + * + * Defaults to Vercel AI Gateway automatic caching, and is omitted when a + * custom `model` is provided. + */ + providerOptions?: ProviderOptions +} + +function createLocalFetch(event: H3Event): typeof fetch { + const origin = getRequestURL(event).origin + + return (input, init) => { + const requestUrl = input instanceof URL + ? input + : typeof input === 'string' + ? new URL(input, origin) + : new URL(input.url) + const localPath = requestUrl.origin === origin + ? `${requestUrl.pathname}${requestUrl.search}` + : requestUrl.toString() + + return event.fetch(localPath, init) + } +} + +export function getAssistantSystemPrompt(siteName: string) { + return `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation. + +**Your identity:** +- You are an assistant helping users with ${siteName} documentation +- NEVER use first person ("I", "me", "my") - always refer to the project by name: "${siteName} provides...", "${siteName} supports...", "The project offers..." +- Be confident and knowledgeable about the project +- Speak as a helpful guide, not as the documentation itself + +**Tool usage (CRITICAL):** +- You have tools: list-pages (discover pages) and get-page (read a page) +- If a page title clearly matches the question, read it directly without listing first +- ALWAYS respond with text after using tools - never end with just tool calls + +**Guidelines:** +- If you can't find something, say "There is no documentation on that yet" or "${siteName} doesn't cover that topic yet" +- Be concise, helpful, and direct +- Guide users like a friendly expert would + +**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:" +- "${siteName} supports TypeScript out of the box" instead of "I support TypeScript" +- Provide actionable guidance, not just information dumps` +} + +/** + * Handle an assistant chat request. + * + * Can be overridden to customize the model, the system prompt or the provider options. + */ +export async function assistantSearchHandler(event: H3Event, searchConfig: AssistantSearchConfig = {}) { + const { messages } = await readBody(event) + const config = useRuntimeConfig() + const siteConfig = getSiteConfig(event) + + const siteName = siteConfig.name || 'Documentation' + + const mcpServer = config.assistant.mcpServer + const isExternalUrl = mcpServer.startsWith('http://') || mcpServer.startsWith('https://') + const baseURL = config.app?.baseURL?.replace(/\/$/, '') || '' + + const abortController = new AbortController() + event.node.req.on('close', () => abortController.abort()) + + let transport: Parameters[0]['transport'] + if (isExternalUrl) { + transport = { + type: 'http', + url: mcpServer, + } + } + else if (import.meta.dev) { + transport = { + type: 'http', + url: `${getRequestURL(event).origin}${baseURL}${mcpServer}`, + } + } + else { + transport = { + type: 'http', + url: `${getRequestURL(event).origin}${baseURL}${mcpServer}`, + fetch: createLocalFetch(event), + } + } + + const httpClient = await createMCPClient({ transport }) + const mcpTools = await httpClient.tools() + + const closeMcp = () => event.waitUntil(httpClient.close()) + + const instructions = typeof searchConfig.systemPrompt === 'function' + ? searchConfig.systemPrompt(event, { siteName }) + : searchConfig.systemPrompt ?? getAssistantSystemPrompt(siteName) + + // Gateway caching is Vercel AI Gateway specific, so it only applies to the + // default model resolved from the runtime config. + const providerOptions = searchConfig.providerOptions + ?? (searchConfig.model ? undefined : { gateway: { caching: 'auto' } }) + + const result = streamText({ + model: searchConfig.model ?? config.assistant.model, + maxOutputTokens: 8000, + maxRetries: 2, + abortSignal: abortController.signal, + stopWhen: isStepCount(MAX_STEPS), + // On the last allowed step, disable tools so the model is forced to + // produce a final text answer instead of stopping mid tool-calling. + prepareStep: ({ stepNumber }) => { + return stepNumber >= MAX_STEPS - 1 ? { toolChoice: 'none' } : {} + }, + providerOptions, + instructions, + messages: await convertToModelMessages(messages), + tools: mcpTools as ToolSet, + experimental_transform: smoothStream(), + onEnd: closeMcp, + onAbort: closeMcp, + onError: closeMcp, + }) + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ stream: result.stream }), + }) +} From 07839335daa311e13142264dacb131fa2384f628 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 25 Aug 2026 10:25:25 +0200 Subject: [PATCH 2/3] up docs --- docs/content/en/4.ai/1.assistant.md | 44 +++++++++++++++-------------- docs/content/fr/4.ai/1.assistant.md | 44 +++++++++++++++-------------- 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/docs/content/en/4.ai/1.assistant.md b/docs/content/en/4.ai/1.assistant.md index 1a65b03b2..a75811194 100644 --- a/docs/content/en/4.ai/1.assistant.md +++ b/docs/content/en/4.ai/1.assistant.md @@ -31,25 +31,7 @@ By default, the assistant connects to your documentation's built-in MCP server a This quick start uses Vercel AI Gateway. To use another provider (Mistral, OpenAI, Cloudflare AI Gateway, or anything else supported by the AI SDK), see **Custom AI provider**. :: -### 1. Install dependencies - -::code-group - -```bash [npm] -npm install ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt -``` - -```bash [pnpm] -pnpm add ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt -``` - -```bash [yarn] -yarn add ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt -``` - -:: - -### 2. Set up AI Gateway authentication +### 1. Set up AI Gateway authentication Pick **one** of these methods: @@ -61,7 +43,7 @@ AI_GATEWAY_API_KEY=your-api-key **OIDC (only on Vercel)**: `VERCEL_OIDC_TOKEN` is injected automatically, so there is nothing to add in production. For local dev, run `vercel env pull` on a [linked project](https://vercel.com/docs/cli/link). -### 3. Deploy +### 2. Deploy Deploy your site, the assistant is available as soon as authentication is configured. @@ -387,7 +369,27 @@ The endpoint is a regular Nitro route, so you have two options: #### Reuse the built-in handler -`assistantSearchHandler` is an auto-imported server util that contains the default endpoint logic: MCP tool wiring, streaming, and abort handling. Pass a `model` to change the provider: +`assistantSearchHandler` is an auto-imported server util that contains the default endpoint logic: MCP tool wiring, streaming, and abort handling. Pass a `model` to change the provider. + +Install the provider package you need, for example Mistral: + +::code-group + +```bash [npm] +npm install @ai-sdk/mistral +``` + +```bash [pnpm] +pnpm add @ai-sdk/mistral +``` + +```bash [yarn] +yarn add @ai-sdk/mistral +``` + +:: + +Then build the route: ```ts [server/api/assistant.ts] import { createMistral } from '@ai-sdk/mistral' diff --git a/docs/content/fr/4.ai/1.assistant.md b/docs/content/fr/4.ai/1.assistant.md index eff969675..3879468c4 100644 --- a/docs/content/fr/4.ai/1.assistant.md +++ b/docs/content/fr/4.ai/1.assistant.md @@ -31,25 +31,7 @@ Par défaut, l'assistant se connecte au serveur MCP intégré de votre documenta Ce démarrage rapide utilise Vercel AI Gateway. Pour utiliser un autre fournisseur (Mistral, OpenAI, Cloudflare AI Gateway, ou tout autre fournisseur supporté par l'AI SDK), consultez **Fournisseur IA personnalisé**. :: -### 1. Installer les dépendances - -::code-group - -```bash [npm] -npm install ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt -``` - -```bash [pnpm] -pnpm add ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt -``` - -```bash [yarn] -yarn add ai @ai-sdk/vue @ai-sdk/gateway @ai-sdk/mcp @comark/nuxt -``` - -:: - -### 2. Configurer l'authentification AI Gateway +### 1. Configurer l'authentification AI Gateway Choisissez **une** de ces méthodes : @@ -61,7 +43,7 @@ AI_GATEWAY_API_KEY=votre-cle-api **OIDC (uniquement sur Vercel)** : `VERCEL_OIDC_TOKEN` est injecté automatiquement, il n'y a donc rien à ajouter en production. En local, lancez `vercel env pull` sur un [projet lié](https://vercel.com/docs/cli/link). -### 3. Déployer +### 2. Déployer Déployez votre site, l'assistant est disponible dès que l'authentification est configurée. @@ -388,7 +370,27 @@ L'endpoint est une route Nitro classique, vous avez donc deux options : #### Réutiliser le handler intégré -`assistantSearchHandler` est un utilitaire serveur auto-importé qui contient la logique de l'endpoint par défaut : branchement des outils MCP, streaming et gestion de l'annulation. Passez un `model` pour changer de fournisseur : +`assistantSearchHandler` est un utilitaire serveur auto-importé qui contient la logique de l'endpoint par défaut : branchement des outils MCP, streaming et gestion de l'annulation. Passez un `model` pour changer de fournisseur. + +Installez le paquet du fournisseur dont vous avez besoin, par exemple Mistral : + +::code-group + +```bash [npm] +npm install @ai-sdk/mistral +``` + +```bash [pnpm] +pnpm add @ai-sdk/mistral +``` + +```bash [yarn] +yarn add @ai-sdk/mistral +``` + +:: + +Puis créez la route : ```ts [server/api/assistant.ts] import { createMistral } from '@ai-sdk/mistral' From 78e4141e3bf0ca5d3b22dc130c44a0e0bd89ac2f Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Wed, 26 Aug 2026 23:23:20 +0200 Subject: [PATCH 3/3] expose server utils to allow full customization --- docs/content/en/4.ai/1.assistant.md | 278 +++++++------- docs/content/fr/4.ai/1.assistant.md | 141 ++++--- layer/modules/assistant/README.md | 38 +- layer/modules/assistant/index.ts | 32 +- .../assistant/runtime/server/api/assistant.ts | 20 + .../assistant/runtime/server/api/search.ts | 1 - .../runtime/server/utils/assistant.ts | 191 +++++----- playground/nuxt.config.ts | 7 + playground/package.json | 6 +- playground/server/api/assistant.ts | 32 ++ pnpm-lock.yaml | 349 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 12 files changed, 769 insertions(+), 327 deletions(-) create mode 100644 layer/modules/assistant/runtime/server/api/assistant.ts delete mode 100644 layer/modules/assistant/runtime/server/api/search.ts create mode 100644 playground/server/api/assistant.ts diff --git a/docs/content/en/4.ai/1.assistant.md b/docs/content/en/4.ai/1.assistant.md index a75811194..80fac5fef 100644 --- a/docs/content/en/4.ai/1.assistant.md +++ b/docs/content/en/4.ai/1.assistant.md @@ -47,26 +47,6 @@ AI_GATEWAY_API_KEY=your-api-key Deploy your site, the assistant is available as soon as authentication is configured. -## Using the Assistant - -Users can interact with the assistant in multiple ways: - -### Floating Input - -On documentation pages, a floating input appears at the bottom of the screen. Users can type their questions directly and press Enter to get answers. - -::tip -Use the keyboard shortcut :kbd{value="meta"} :kbd{value="I"} to focus the floating input. -:: - -### Explain with AI - -Each documentation page includes an **Explain with AI** button in the table of contents sidebar. Clicking this button opens the assistant with the current page as context, asking it to explain the content. - -### Slideover Chat - -When a conversation starts, a slideover panel opens on the right side of the screen. This panel displays the conversation history and allows users to continue asking questions. - ## Configuration Configure the assistant through `app.config.ts`: @@ -97,7 +77,7 @@ export default defineAppConfig({ }) ``` -### FAQ Questions +### Questions Display suggested questions when the chat is empty. This helps users discover what they can ask. @@ -161,7 +141,7 @@ export default defineAppConfig({ }) ``` -## Keyboard Shortcuts +### Keyboard Shortcuts Configure the keyboard shortcut for focusing the floating input: @@ -182,7 +162,7 @@ The shortcut format uses underscores to separate keys. Common examples: - `meta_k` - Cmd+K (Mac) / Ctrl+K (Windows) - `ctrl_shift_p` - Ctrl+Shift+P -## Custom Icons +### Icons Customize the icons used by the assistant: @@ -202,20 +182,9 @@ export default defineAppConfig({ Icons use the [Iconify](https://iconify.design/) format (e.g., `i-lucide-sparkles`, `i-heroicons-sparkles`). -## Internationalization - -All UI texts are automatically translated based on the user's locale. Docus includes built-in translations for English and French. - -The following texts are translated: +### Features -- Slideover title and placeholder -- Tooltip texts -- Button labels ("Clear chat", "Close", "Explain with AI") -- Status messages ("Thinking...", "Chat is cleared on refresh") - -## Disable Features - -### Disable the Floating Input +#### Disable the Floating Input Hide the floating input at the bottom of documentation pages: @@ -227,7 +196,7 @@ export default defineAppConfig({ }) ``` -### Disable "Explain with AI" +#### Disable "Explain with AI" Hide the "Explain with AI" button in the documentation sidebar: @@ -239,7 +208,7 @@ export default defineAppConfig({ }) ``` -### Disable the Assistant Entirely +#### Disable the Assistant Entirely Set `enabled` to `false` to disable the assistant, even when AI Gateway credentials are available: @@ -253,38 +222,6 @@ export default defineNuxtConfig({ }) ``` -The assistant is also disabled when no authentication is available, so removing `AI_GATEWAY_API_KEY` from your environment has the same effect: - -```bash [.env] -# AI_GATEWAY_API_KEY=your-api-key -``` - -On Vercel with OIDC, remove the auto-injected system environment variable from your project settings. - -## Advanced Configuration - -Configure advanced options in `nuxt.config.ts` under `docus.assistant`. - -```ts [nuxt.config.ts] -export default defineNuxtConfig({ - docus: { - assistant: { - // Force enable or disable the assistant - enabled: true, - - // AI model (uses AI SDK Gateway format) - model: 'google/gemini-3-flash', - - // MCP server (path or URL) - mcpServer: '/mcp', - - // API endpoint path - apiPath: '/__docus__/assistant' - } - } -}) -``` - ### MCP Server Configuration The assistant uses an MCP server to access your documentation. You have two options: @@ -323,7 +260,7 @@ export default defineNuxtConfig({ This is useful when you want the assistant to answer questions from a different documentation source, or when connecting to a centralized knowledge base. -### Custom AI Model +### Model The assistant uses `google/gemini-3-flash` by default. You can change this to any model supported by the AI SDK Gateway: @@ -337,11 +274,26 @@ export default defineNuxtConfig({ }) ``` -### Custom AI Provider +### Site Name + +The assistant automatically uses your site name in its responses. Configure the site name in `nuxt.config.ts`: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + site: { + name: 'My Documentation' + } +}) +``` + +This makes the assistant respond as "the My Documentation assistant" and speak with authority about your specific product. + +## Custom provider The `model` option above resolves models through Vercel AI Gateway, so it requires `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN`. To use another provider (Mistral, OpenAI, Cloudflare AI Gateway, or anything else supported by the [AI SDK](https://ai-sdk.dev/)), enable the assistant explicitly and provide your own endpoint. -#### 1. Enable the assistant and pick a path +::steps +### Enable the assistant and pick a path Set `enabled: true` so the assistant no longer depends on AI Gateway credentials, and point `apiPath` at the route you're about to create: @@ -358,104 +310,134 @@ export default defineNuxtConfig({ Your own server route always takes precedence: when you define a route at `apiPath`, Docus steps aside and doesn't register its built-in endpoint there. -#### 2. Implement the endpoint +### Install a provider -The endpoint is a regular Nitro route, so you have two options: +Install the AI SDK provider package you need, for example Mistral: -| Approach | Use it when | -| -------- | ----------- | -| [Reuse the built-in handler](#reuse-the-built-in-handler) | You only need to swap the model, the system prompt, or the provider options. MCP tool wiring, streaming, and abort handling stay in place. | -| [Write your own handler](#write-your-own-handler) | You need full control over tools, message handling, or the streaming pipeline. | + :::code-group + ```bash [npm] + npm install @ai-sdk/mistral + ``` -#### Reuse the built-in handler + ```bash [pnpm] + pnpm add @ai-sdk/mistral + ``` -`assistantSearchHandler` is an auto-imported server util that contains the default endpoint logic: MCP tool wiring, streaming, and abort handling. Pass a `model` to change the provider. + ```bash [yarn] + yarn add @ai-sdk/mistral + ``` + ::: -Install the provider package you need, for example Mistral: +### Implement the endpoint -::code-group +```ts [server/api/assistant.ts] +import { streamText, convertToModelMessages } from 'ai' +import { createMistral } from '@ai-sdk/mistral' -```bash [npm] -npm install @ai-sdk/mistral -``` +const mistral = createMistral() -```bash [pnpm] -pnpm add @ai-sdk/mistral -``` +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) -```bash [yarn] -yarn add @ai-sdk/mistral + return createAssistantResponse(streamText({ + ...await getAssistantDefaultOptions(event), + model: mistral('mistral-large-latest'), + messages: await convertToModelMessages(messages) + })) +}) ``` -:: + :::tip + Because you own the `streamText` call, provider specific constraints are solved where they belong. + ::: -Then build the route: + :::warning + Spread the defaults **first**. Options you set after the spread win (before are overwritten). + ::: -```ts [server/api/assistant.ts] -import { createMistral } from '@ai-sdk/mistral' - -const mistral = createMistral() +| Util | Role | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`getAssistantDefaultOptions(event)`](#getassistantdefaultoptions) | Every `streamText` option the built-in endpoint uses: MCP tools, abort on disconnect, client cleanup, the documentation prompt, and the step and token limits. | +| [`getAssistantSystemPrompt(event)`](#getassistantsystemprompt) | The default documentation-tuned prompt on its own, for when you want to extend it. | +| [`createAssistantResponse(result)`](#createassistantresponse) | Wraps the result in the response format the assistant UI expects. | -export default defineEventHandler(event => assistantSearchHandler(event, { - model: mistral('mistral-large-latest') -})) -``` +#### `getAssistantDefaultOptions` -That's the whole integration: everything else, including the documentation-tuned system prompt, keeps working as before. +Returns real `streamText` options, so you can see and override every one of them: -`assistantSearchHandler` accepts an optional config object as its second argument: +| Option | Default | +| ------------------------ | ----------------------------------------------------------------------------------------- | +| `tools` | The MCP tools from `docus.assistant.mcpServer` | +| `abortSignal` | Aborts generation when the client disconnects | +| `onEnd` / `onAbort` | Closes the MCP client | +| `onError` | Logs the provider error server-side, then closes the MCP client | +| `instructions` | `getAssistantSystemPrompt(event)` | +| `maxOutputTokens` | `8000` | +| `maxRetries` | `2` | +| `stopWhen` | `isStepCount(10)` | +| `prepareStep` | Disables tools on the last step so the model answers instead of stopping mid tool-calling | +| `experimental_transform` | `smoothStream()` | -| Property | Type | Description | -| ----------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `model` | `LanguageModel` | Any AI SDK model. Defaults to `docus.assistant.model` resolved through Vercel AI Gateway. | -| `systemPrompt` | `string \| (event, { siteName }) => string` | Replaces the built-in prompt. Use `getAssistantSystemPrompt(siteName)` to extend the default instead of replacing it. | -| `providerOptions` | `ProviderOptions` | Provider specific options passed to `streamText`. Defaults to Vercel AI Gateway caching, and is omitted when you pass a custom `model`. | +`model` and `messages` are not included, and neither are provider specific options like `providerOptions` or `temperature`, since they don't port across providers. -#### Write your own handler +Override by setting the option after the spread: -Skip `assistantSearchHandler` entirely and implement the route yourself. The assistant UI talks to it through the AI SDK's `DefaultChatTransport`, so the handler only has to respect two things: +```ts [server/api/assistant.ts] +return createAssistantResponse(streamText({ + ...await getAssistantDefaultOptions(event), + model: mistral('mistral-large-latest'), + // Wins over the default 8000 + maxOutputTokens: 4000, + messages: await convertToModelMessages(messages) +})) +``` -- It receives a `POST` with a `{ messages }` body, where `messages` is an array of AI SDK `UIMessage`. -- It returns a UI message stream response, built with `createUIMessageStreamResponse`. +To add behaviour to a callback rather than replace it, keep a reference and call through to it, so MCP cleanup still runs: ```ts [server/api/assistant.ts] -import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai' -import { createMistral } from '@ai-sdk/mistral' +const defaults = await getAssistantDefaultOptions(event) + +return createAssistantResponse(streamText({ + ...defaults, + model: mistral('mistral-large-latest'), + messages: await convertToModelMessages(messages), + onError: (payload) => { + myErrorReporter(payload.error) + // Still closes the MCP client + defaults.onError(payload) + } +})) +``` -const mistral = createMistral() + :::warning + `onEnd`, `onAbort` and `onError` close the MCP client. Replacing one without calling through to the original leaks a connection per request. + ::: -export default defineEventHandler(async (event) => { - const { messages } = await readBody(event) +#### `getAssistantSystemPrompt` - const result = streamText({ - model: mistral('mistral-large-latest'), - instructions: getAssistantSystemPrompt('My Documentation'), - messages: await convertToModelMessages(messages) - }) +`getAssistantDefaultOptions` already sets this prompt as `instructions`, so you only need this util to extend it. It returns a plain string, so concatenate: - return createUIMessageStreamResponse({ - stream: toUIMessageStream({ stream: result.stream }) - }) -}) -``` +```ts [server/api/assistant.ts] +const defaults = await getAssistantDefaultOptions(event) -::warning -A handler written from scratch loses everything the built-in one provides: MCP tool wiring, step limits, abort handling on client disconnect, and stream smoothing. Wire in your own `tools` if you want the assistant to keep searching your documentation. -:: +return createAssistantResponse(streamText({ + ...defaults, + model: mistral('mistral-large-latest'), + messages: await convertToModelMessages(messages), + instructions: `${defaults.instructions} -### Site Name in Responses +**Extra instructions:** +- Always mention the minimum supported version +- Never speculate about the roadmap` +})) +``` -The assistant automatically uses your site name in its responses. Configure the site name in `nuxt.config.ts`: +Set `instructions` to your own string to replace the default entirely. -```ts [nuxt.config.ts] -export default defineNuxtConfig({ - site: { - name: 'My Documentation' - } -}) -``` +#### `createAssistantResponse` -This makes the assistant respond as "the My Documentation assistant" and speak with authority about your specific product. +Wraps a `streamText` result in the response format the assistant UI expects, so your route follows future stream format changes without being edited. +:: ## Programmatic Access @@ -478,13 +460,13 @@ function askQuestion() { ``` -### Composable API +## Composable API -| Property | Type | Description | -| -------------------------------- | ---------------------- | --------------------------------------------------------------------------------------- | +| Property | Type | Description | +| -------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------- | | `isEnabled` | `ComputedRef` | Whether the assistant is enabled (`docus.assistant.enabled`, or `AI_GATEWAY_API_KEY` / `VERCEL_OIDC_TOKEN` at build) | -| `isOpen` | `Ref` | Whether the slideover is open | -| `open(message?, clearPrevious?)` | `Function` | Open the assistant, optionally with a message | -| `close()` | `Function` | Close the assistant slideover | -| `toggle()` | `Function` | Toggle the assistant open/closed | -| `clearMessages()` | `Function` | Clear the conversation history | +| `isOpen` | `Ref` | Whether the slideover is open | +| `open(message?, clearPrevious?)` | `Function` | Open the assistant, optionally with a message | +| `close()` | `Function` | Close the assistant slideover | +| `toggle()` | `Function` | Toggle the assistant open/closed | +| `clearMessages()` | `Function` | Clear the conversation history | diff --git a/docs/content/fr/4.ai/1.assistant.md b/docs/content/fr/4.ai/1.assistant.md index 3879468c4..eda3c4f86 100644 --- a/docs/content/fr/4.ai/1.assistant.md +++ b/docs/content/fr/4.ai/1.assistant.md @@ -359,23 +359,11 @@ export default defineNuxtConfig({ Votre route serveur est toujours prioritaire : quand vous définissez une route sur `apiPath`, Docus s'efface et n'enregistre pas son endpoint intégré à cet emplacement. -#### 2. Implémenter l'endpoint +#### 2. Installer un fournisseur -L'endpoint est une route Nitro classique, vous avez donc deux options : - -| Approche | À utiliser quand | -| -------- | ---------------- | -| [Réutiliser le handler intégré](#réutiliser-le-handler-intégré) | Vous voulez seulement changer le modèle, le prompt système ou les options du fournisseur. Le branchement des outils MCP, le streaming et la gestion de l'annulation restent en place. | -| [Écrire votre propre handler](#écrire-votre-propre-handler) | Vous avez besoin d'un contrôle total sur les outils, la gestion des messages ou le pipeline de streaming. | - -#### Réutiliser le handler intégré - -`assistantSearchHandler` est un utilitaire serveur auto-importé qui contient la logique de l'endpoint par défaut : branchement des outils MCP, streaming et gestion de l'annulation. Passez un `model` pour changer de fournisseur. - -Installez le paquet du fournisseur dont vous avez besoin, par exemple Mistral : +Installez le paquet du fournisseur AI SDK dont vous avez besoin, par exemple Mistral : ::code-group - ```bash [npm] npm install @ai-sdk/mistral ``` @@ -387,61 +375,124 @@ pnpm add @ai-sdk/mistral ```bash [yarn] yarn add @ai-sdk/mistral ``` - :: -Puis créez la route : +#### 3. Implémenter l'endpoint ```ts [server/api/assistant.ts] +import { streamText, convertToModelMessages } from 'ai' import { createMistral } from '@ai-sdk/mistral' const mistral = createMistral() -export default defineEventHandler(event => assistantSearchHandler(event, { - model: mistral('mistral-large-latest') -})) +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) + + return createAssistantResponse(streamText({ + ...await getAssistantDefaultOptions(event), + model: mistral('mistral-large-latest'), + messages: await convertToModelMessages(messages) + })) +}) ``` -L'intégration s'arrête là : tout le reste, dont le prompt système conçu pour la documentation, continue de fonctionner comme avant. +L'intégration s'arrête là : la recherche dans la documentation, le streaming et la gestion de l'annulation continuent de fonctionner, et tous les paramètres du modèle sont à vous. + +::tip +Parce que vous possédez l'appel à `streamText`, les contraintes spécifiques aux fournisseurs se règlent là où elles doivent l'être. +:: -`assistantSearchHandler` accepte un objet de configuration optionnel en second argument : +::warning +Étalez les valeurs par défaut **en premier**. Les options définies après le spread gagnent, celles définies avant sont écrasées. +:: -| Propriété | Type | Description | -| ----------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `model` | `LanguageModel` | N'importe quel modèle AI SDK. Par défaut `docus.assistant.model` résolu via Vercel AI Gateway. | -| `systemPrompt` | `string \| (event, { siteName }) => string` | Remplace le prompt intégré. Utilisez `getAssistantSystemPrompt(siteName)` pour l'étendre plutôt que le remplacer. | -| `providerOptions` | `ProviderOptions` | Options spécifiques au fournisseur passées à `streamText`. Par défaut le cache Vercel AI Gateway, omis quand vous passez un `model` personnalisé. | +Docus n'expose délibérément pas ces éléments sous forme d'options : les paramètres de modèle ne sont pas portables, donc toute liste serait incomplète pour un fournisseur ou un autre. Ce qu'il expose, c'est la plomberie que vous ne devriez pas avoir à copier-coller, sous forme d'utilitaires serveur auto-importés : -#### Écrire votre propre handler +| Utilitaire | Rôle | +| ---------- | ---- | +| [`getAssistantDefaultOptions(event)`](#getassistantdefaultoptions) | Toutes les options `streamText` utilisées par l'endpoint intégré : outils MCP, annulation à la déconnexion, fermeture du client, prompt de documentation, limites d'étapes et de tokens. | +| [`getAssistantSystemPrompt(event)`](#getassistantsystemprompt) | Le prompt par défaut conçu pour la documentation, seul, pour quand vous voulez l'étendre. | +| [`createAssistantResponse(result)`](#createassistantresponse) | Emballe le résultat dans le format de réponse attendu par l'interface de l'assistant. | -Vous pouvez ignorer complètement `assistantSearchHandler` et implémenter la route vous-même. L'interface de l'assistant communique avec elle via le `DefaultChatTransport` de l'AI SDK, le handler doit donc seulement respecter deux contraintes : +##### getAssistantDefaultOptions -- Il reçoit un `POST` avec un body `{ messages }`, où `messages` est un tableau de `UIMessage` de l'AI SDK. -- Il retourne une réponse de type UI message stream, construite avec `createUIMessageStreamResponse`. +Retourne de vraies options `streamText` : vous voyez et pouvez surcharger chacune d'elles. + +| Option | Valeur par défaut | +| ------ | ----------------- | +| `tools` | Les outils MCP de `docus.assistant.mcpServer` | +| `abortSignal` | Annule la génération quand le client se déconnecte | +| `onEnd` / `onAbort` | Ferme le client MCP | +| `onError` | Log l'erreur du fournisseur côté serveur, puis ferme le client MCP | +| `instructions` | `getAssistantSystemPrompt(event)` | +| `maxOutputTokens` | `8000` | +| `maxRetries` | `2` | +| `stopWhen` | `isStepCount(10)` | +| `prepareStep` | Désactive les outils à la dernière étape pour que le modèle réponde au lieu de s'arrêter en plein appel d'outil | +| `experimental_transform` | `smoothStream()` | + +`model` et `messages` ne sont pas inclus, pas plus que les options spécifiques au fournisseur comme `providerOptions` ou `temperature`, puisqu'elles ne sont pas portables. + +Surchargez en définissant l'option après le spread : ```ts [server/api/assistant.ts] -import { streamText, convertToModelMessages, toUIMessageStream, createUIMessageStreamResponse } from 'ai' -import { createMistral } from '@ai-sdk/mistral' +return createAssistantResponse(streamText({ + ...await getAssistantDefaultOptions(event), + model: mistral('mistral-large-latest'), + // Gagne sur le 8000 par défaut + maxOutputTokens: 4000, + messages: await convertToModelMessages(messages) +})) +``` -const mistral = createMistral() +Pour ajouter du comportement à un callback plutôt que le remplacer, gardez une référence et rappelez-le, afin que la fermeture MCP ait toujours lieu : -export default defineEventHandler(async (event) => { - const { messages } = await readBody(event) +```ts [server/api/assistant.ts] +const defaults = await getAssistantDefaultOptions(event) + +return createAssistantResponse(streamText({ + ...defaults, + model: mistral('mistral-large-latest'), + messages: await convertToModelMessages(messages), + onError: (payload) => { + monRapporteurDErreurs(payload.error) + // Ferme quand même le client MCP + defaults.onError(payload) + } +})) +``` - const result = streamText({ - model: mistral('mistral-large-latest'), - instructions: getAssistantSystemPrompt('Ma documentation'), - messages: await convertToModelMessages(messages) - }) +::warning +`onEnd`, `onAbort` et `onError` ferment le client MCP. Remplacer l'un d'eux sans rappeler l'original fait fuiter une connexion par requête. +:: - return createUIMessageStreamResponse({ - stream: toUIMessageStream({ stream: result.stream }) - }) -}) +##### getAssistantSystemPrompt + +`getAssistantDefaultOptions` définit déjà ce prompt comme `instructions` : cet utilitaire ne sert donc qu'à l'étendre. Il retourne une simple chaîne, donc concaténez : + +```ts [server/api/assistant.ts] +const defaults = await getAssistantDefaultOptions(event) + +return createAssistantResponse(streamText({ + ...defaults, + model: mistral('mistral-large-latest'), + messages: await convertToModelMessages(messages), + instructions: `${defaults.instructions} + +**Instructions supplémentaires :** +- Toujours mentionner la version minimale supportée +- Ne jamais spéculer sur la roadmap` +})) ``` +Définissez `instructions` avec votre propre chaîne pour remplacer le prompt entièrement. + +##### createAssistantResponse + +Emballe un résultat de `streamText` dans le format de réponse attendu par l'interface de l'assistant, pour que votre route suive les futurs changements de format sans être modifiée. + ::warning -Un handler écrit de zéro perd tout ce que fournit celui intégré : le branchement des outils MCP, la limite d'étapes, la gestion de l'annulation à la déconnexion du client et le lissage du stream. Branchez vos propres `tools` si vous voulez que l'assistant continue de chercher dans votre documentation. +L'interface de l'assistant communique avec votre route via le `DefaultChatTransport` de l'AI SDK : elle doit donc accepter un `POST` avec un body `{ messages }` de `UIMessage` de l'AI SDK. Construire la réponse vous-même fonctionne, mais vous en assumez alors le format de stream. :: ### Nom du site dans les réponses diff --git a/layer/modules/assistant/README.md b/layer/modules/assistant/README.md index 37e91b9e1..8fbda17b6 100644 --- a/layer/modules/assistant/README.md +++ b/layer/modules/assistant/README.md @@ -215,21 +215,47 @@ Composable for syntax highlighting code blocks with Shiki. ### Custom provider or system prompt -The endpoint logic lives in `runtime/server/utils/assistant.ts` and is exposed as the auto-imported `assistantSearchHandler` server util. Set `enabled: true`, point `apiPath` at your own route, and override what you need: +The endpoint is not configurable by design. To use another provider or different model parameters, set `enabled: true`, point `apiPath` at your own route, and own the `streamText` call: ```ts // server/api/assistant.ts +import { streamText, convertToModelMessages } from 'ai' import { createMistral } from '@ai-sdk/mistral' const mistral = createMistral() -export default defineEventHandler(event => assistantSearchHandler(event, { - model: mistral('mistral-large-latest'), - systemPrompt: (_event, { siteName }) => `${getAssistantSystemPrompt(siteName)}\n\nExtra instructions.`, -})) +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) + + return createAssistantResponse(streamText({ + // Spread first, so your options below win + ...await getAssistantDefaultOptions(event), + model: mistral('mistral-large-latest'), + maxOutputTokens: 4000, + messages: await convertToModelMessages(messages), + })) +}) +``` + +Auto-imported server utils, all defined in `runtime/server/utils/assistant.ts`: + +| Util | Role | +|------|------| +| `getAssistantDefaultOptions(event)` | Every `streamText` option the built-in endpoint uses: MCP tools, abort signal, client cleanup, `instructions`, `maxOutputTokens`, `maxRetries`, `stopWhen`, `prepareStep`, `smoothStream`. Spread it first | +| `getAssistantSystemPrompt(event)` | The documentation prompt on its own, for extending it by concatenation | +| `createAssistantResponse(result)` | Wraps a `streamText` result in the stream format the UI expects | + +`model`, `messages` and provider specific options (`providerOptions`, `temperature`) are excluded, since they don't port across providers. + +The built-in endpoint lives in `runtime/server/api/assistant.ts` and is written with these same three utils, so it doubles as the reference implementation to copy. + +A working override on a non-Gateway provider lives in `playground/server/api/assistant.ts` (Mistral). Run it with: + +```bash +MISTRAL_API_KEY=... pnpm playground:dev ``` -`assistantSearchHandler` accepts `model`, `systemPrompt`, and `providerOptions`. A server route you define at `apiPath` always takes precedence over the built-in endpoint. +A server route you define at `apiPath` always takes precedence over the built-in endpoint. ### Styling diff --git a/layer/modules/assistant/index.ts b/layer/modules/assistant/index.ts index 5c1bbfdf2..ff43e0ad4 100644 --- a/layer/modules/assistant/index.ts +++ b/layer/modules/assistant/index.ts @@ -8,13 +8,11 @@ export interface AssistantModuleOptions { * When left undefined, the assistant is enabled if `AI_GATEWAY_API_KEY` or * `VERCEL_OIDC_TOKEN` is available at build time. * - * Set it to `true` to use a custom AI SDK provider: Docus then skips - * registering its own endpoint, and you provide a route at `apiPath` built - * with `assistantSearchHandler`. - * - * Set it to `false` to disable the assistant even when AI Gateway - * credentials are available. + * Set it to `true` to use a custom AI SDK provider: + * - Docus skips registering its own endpoint, + * - You provide a route at `apiPath` built with `createAssistantResponse`. * + * Set it to `false` to disable the assistant. * @default undefined */ enabled?: boolean @@ -71,7 +69,7 @@ export default defineNuxtModule({ // be used without AI Gateway credentials. const isEnabled = options.enabled ?? hasAiGatewayAuth // Docus only owns the endpoint when it can authenticate to the AI Gateway. - // Otherwise the user brings their own route built with `assistantSearchHandler`. + // Otherwise the user brings their own route at `apiPath`. const hasDefaultHandler = isEnabled && hasAiGatewayAuth const { resolve } = createResolver(import.meta.url) @@ -115,15 +113,13 @@ export default defineNuxtModule({ // Exposed even when disabled so overriding the endpoint stays type-safe. addServerImports([ - { - name: 'assistantSearchHandler', - from: resolve('./runtime/server/utils/assistant'), - }, - { - name: 'getAssistantSystemPrompt', - from: resolve('./runtime/server/utils/assistant'), - }, - ]) + 'getAssistantDefaultOptions', + 'getAssistantSystemPrompt', + 'createAssistantResponse', + ].map(name => ({ + name, + from: resolve('./runtime/server/utils/assistant'), + }))) if (!isEnabled) { if (options.enabled === undefined) { @@ -136,14 +132,14 @@ export default defineNuxtModule({ if (!hasDefaultHandler) { nuxt.hook('modules:done', () => { - log.info(`AI assistant enabled without AI Gateway credentials: provide a server route at \`${options.apiPath}\` using \`assistantSearchHandler\``) + log.info(`AI assistant enabled without AI Gateway credentials: provide a server route at \`${options.apiPath}\` using \`getAssistantDefaultOptions\``) }) return } const routePath = options.apiPath!.replace(/^\//, '') const route = `/${routePath}` - const handler = resolve('./runtime/server/api/search') + const handler = resolve('./runtime/server/api/assistant') addServerHandler({ route, handler }) diff --git a/layer/modules/assistant/runtime/server/api/assistant.ts b/layer/modules/assistant/runtime/server/api/assistant.ts new file mode 100644 index 000000000..8eb8bc20d --- /dev/null +++ b/layer/modules/assistant/runtime/server/api/assistant.ts @@ -0,0 +1,20 @@ +import { streamText, convertToModelMessages } from 'ai' + +/** + * Built-in assistant endpoint, resolving its model through the Vercel AI Gateway. + * + * This is also the reference implementation: to use another provider, copy it + * into your own route at `docus.assistant.apiPath` and swap the `model`. + */ +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) + const config = useRuntimeConfig() + + return createAssistantResponse(streamText({ + ...await getAssistantDefaultOptions(event), + model: config.assistant.model, + // Gateway specific, so it stays out of the shared defaults. + providerOptions: { gateway: { caching: 'auto' } }, + messages: await convertToModelMessages(messages), + })) +}) diff --git a/layer/modules/assistant/runtime/server/api/search.ts b/layer/modules/assistant/runtime/server/api/search.ts deleted file mode 100644 index e8f135316..000000000 --- a/layer/modules/assistant/runtime/server/api/search.ts +++ /dev/null @@ -1 +0,0 @@ -export default defineEventHandler(event => assistantSearchHandler(event)) diff --git a/layer/modules/assistant/runtime/server/utils/assistant.ts b/layer/modules/assistant/runtime/server/utils/assistant.ts index 7ebbce797..514c61591 100644 --- a/layer/modules/assistant/runtime/server/utils/assistant.ts +++ b/layer/modules/assistant/runtime/server/utils/assistant.ts @@ -1,46 +1,28 @@ -import { streamText, convertToModelMessages, isStepCount, smoothStream, toUIMessageStream, createUIMessageStreamResponse } from 'ai' -import type { LanguageModel, ToolSet } from 'ai' +import { toUIMessageStream, createUIMessageStreamResponse, isStepCount, smoothStream } from 'ai' +import type { streamText, ToolSet } from 'ai' import { createMCPClient } from '@ai-sdk/mcp' import type { H3Event } from 'h3' -const MAX_STEPS = 10 - type StreamTextOptions = Parameters[0] -type ProviderOptions = NonNullable -export interface AssistantSystemPromptContext { - /** - * Site name resolved from the site config, used to personalize the prompt. - */ - siteName: string -} +/** Max model/tool steps before the assistant is forced to produce a final answer. */ +const MAX_STEPS = 10 -export interface AssistantSearchConfig { - /** - * Model used to answer the question. - * - * Accepts any AI SDK model, which makes it possible to use a provider other - * than the Vercel AI Gateway (Cloudflare AI Gateway, Mistral, OpenAI, ...). - * - * @default runtimeConfig.assistant.model (resolved through the Vercel AI Gateway) - */ - model?: LanguageModel - /** - * System prompt sent to the model. - * - * Provide a string to fully replace the default prompt, or a function to - * build it from the request. - * - * @default the built-in Docus documentation assistant prompt - */ - systemPrompt?: string | ((event: H3Event, context: AssistantSystemPromptContext) => string) - /** - * Provider specific options forwarded to `streamText`. - * - * Defaults to Vercel AI Gateway automatic caching, and is omitted when a - * custom `model` is provided. - */ - providerOptions?: ProviderOptions +export interface AssistantDefaultOptions { + /** MCP tools exposed by the configured documentation server. */ + tools: ToolSet + /** Aborts generation when the client disconnects. */ + abortSignal: AbortSignal + onEnd: () => void + onAbort: () => void + onError: (payload: { error: unknown }) => void + /** The documentation-tuned prompt, built from the site name. */ + instructions: string + maxOutputTokens: number + maxRetries: number + stopWhen: StreamTextOptions['stopWhen'] + prepareStep: StreamTextOptions['prepareStep'] + experimental_transform: StreamTextOptions['experimental_transform'] } function createLocalFetch(event: H3Event): typeof fetch { @@ -60,55 +42,11 @@ function createLocalFetch(event: H3Event): typeof fetch { } } -export function getAssistantSystemPrompt(siteName: string) { - return `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation. - -**Your identity:** -- You are an assistant helping users with ${siteName} documentation -- NEVER use first person ("I", "me", "my") - always refer to the project by name: "${siteName} provides...", "${siteName} supports...", "The project offers..." -- Be confident and knowledgeable about the project -- Speak as a helpful guide, not as the documentation itself - -**Tool usage (CRITICAL):** -- You have tools: list-pages (discover pages) and get-page (read a page) -- If a page title clearly matches the question, read it directly without listing first -- ALWAYS respond with text after using tools - never end with just tool calls - -**Guidelines:** -- If you can't find something, say "There is no documentation on that yet" or "${siteName} doesn't cover that topic yet" -- Be concise, helpful, and direct -- Guide users like a friendly expert would - -**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:" -- "${siteName} supports TypeScript out of the box" instead of "I support TypeScript" -- Provide actionable guidance, not just information dumps` -} - /** - * Handle an assistant chat request. - * - * Can be overridden to customize the model, the system prompt or the provider options. + * Every `streamText` option the built-in assistant endpoint uses. */ -export async function assistantSearchHandler(event: H3Event, searchConfig: AssistantSearchConfig = {}) { - const { messages } = await readBody(event) +export async function getAssistantDefaultOptions(event: H3Event): Promise { const config = useRuntimeConfig() - const siteConfig = getSiteConfig(event) - - const siteName = siteConfig.name || 'Documentation' const mcpServer = config.assistant.mcpServer const isExternalUrl = mcpServer.startsWith('http://') || mcpServer.startsWith('https://') @@ -131,6 +69,8 @@ export async function assistantSearchHandler(event: H3Event, searchConfig: Assis } } else { + // The internal MCP route is not always reachable over the network in + // production, so route the request through the event instead. transport = { type: 'http', url: `${getRequestURL(event).origin}${baseURL}${mcpServer}`, @@ -139,40 +79,77 @@ export async function assistantSearchHandler(event: H3Event, searchConfig: Assis } const httpClient = await createMCPClient({ transport }) - const mcpTools = await httpClient.tools() - - const closeMcp = () => event.waitUntil(httpClient.close()) - - const instructions = typeof searchConfig.systemPrompt === 'function' - ? searchConfig.systemPrompt(event, { siteName }) - : searchConfig.systemPrompt ?? getAssistantSystemPrompt(siteName) + const tools = await httpClient.tools() as ToolSet - // Gateway caching is Vercel AI Gateway specific, so it only applies to the - // default model resolved from the runtime config. - const providerOptions = searchConfig.providerOptions - ?? (searchConfig.model ? undefined : { gateway: { caching: 'auto' } }) + const close = () => event.waitUntil(httpClient.close()) - const result = streamText({ - model: searchConfig.model ?? config.assistant.model, + return { + tools, + abortSignal: abortController.signal, + onEnd: close, + onAbort: close, + onError: ({ error }) => { + console.error('[docus] assistant error:', error) + close() + }, + instructions: getAssistantSystemPrompt(event), maxOutputTokens: 8000, maxRetries: 2, - abortSignal: abortController.signal, stopWhen: isStepCount(MAX_STEPS), - // On the last allowed step, disable tools so the model is forced to - // produce a final text answer instead of stopping mid tool-calling. prepareStep: ({ stepNumber }) => { return stepNumber >= MAX_STEPS - 1 ? { toolChoice: 'none' } : {} }, - providerOptions, - instructions, - messages: await convertToModelMessages(messages), - tools: mcpTools as ToolSet, experimental_transform: smoothStream(), - onEnd: closeMcp, - onAbort: closeMcp, - onError: closeMcp, - }) + } +} + +/** + * Build the default documentation assistant prompt, tuned for the current site. + */ +export function getAssistantSystemPrompt(event: H3Event): string { + const siteName = getSiteConfig(event).name || 'Documentation' + + return `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation. + +**Your identity:** +- You are an assistant helping users with ${siteName} documentation +- NEVER use first person ("I", "me", "my") - always refer to the project by name: "${siteName} provides...", "${siteName} supports...", "The project offers..." +- Be confident and knowledgeable about the project +- Speak as a helpful guide, not as the documentation itself + +**Tool usage (CRITICAL):** +- You have tools: list-pages (discover pages) and get-page (read a page) +- If a page title clearly matches the question, read it directly without listing first +- ALWAYS respond with text after using tools - never end with just tool calls + +**Guidelines:** +- If you can't find something, say "There is no documentation on that yet" or "${siteName} doesn't cover that topic yet" +- Be concise, helpful, and direct +- Guide users like a friendly expert would + +**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:" +- "${siteName} supports TypeScript out of the box" instead of "I support TypeScript" +- Provide actionable guidance, not just information dumps` +} + +/** + * Wrap a `streamText` result in the response format the assistant UI expects. + */ +export function createAssistantResponse(result: ReturnType): Response { return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), }) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 782c45510..262ea296e 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,4 +1,11 @@ export default defineNuxtConfig({ + docus: { + assistant: { + enabled: Boolean(process.env.MISTRAL_API_KEY), + apiPath: '/api/assistant', + }, + }, + // Explicitly disable i18n for playground testing (enabled by .nuxtrc) i18n: false, }) diff --git a/playground/package.json b/playground/package.json index 0f76c43e6..39530f8c1 100644 --- a/playground/package.json +++ b/playground/package.json @@ -5,8 +5,10 @@ "build": "nuxt build --extends docus" }, "dependencies": { - "docus": "latest", + "docus": "workspace:*", + "@ai-sdk/mistral": "^4.0.33", + "ai": "^7.0.77", "better-sqlite3": "^12.5.0", "nuxt": "^4.3.1" } -} \ No newline at end of file +} diff --git a/playground/server/api/assistant.ts b/playground/server/api/assistant.ts new file mode 100644 index 000000000..cbff59f7b --- /dev/null +++ b/playground/server/api/assistant.ts @@ -0,0 +1,32 @@ +import { streamText, convertToModelMessages } from 'ai' +import { createMistral } from '@ai-sdk/mistral' + +/** + * Custom assistant endpoint, rebuilt on Mistral instead of the Vercel AI Gateway. + */ + +// Reads MISTRAL_API_KEY from the environment. +const mistral = createMistral() + +export default defineEventHandler(async (event) => { + const { messages } = await readBody(event) + const defaults = await getAssistantDefaultOptions(event) + + return createAssistantResponse(streamText({ + ...defaults, + model: mistral('mistral-small-latest'), + + // Mistral rejects the 8000 the built-in endpoint uses. + maxOutputTokens: 4000, + temperature: 0.3, + + // Extend the default prompt instead of replacing it. + instructions: `${defaults.instructions} + +**Playground rules:** +- Mention that answers come from the Docus playground running on Mistral +- Keep answers under five bullet points`, + + messages: await convertToModelMessages(messages), + })) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de8f8715..e846850b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,24 @@ importers: specifier: ^3.25.2 version: 3.25.2(zod@4.4.3) + playground: + dependencies: + '@ai-sdk/mistral': + specifier: ^4.0.33 + version: 4.0.33(zod@4.4.3) + ai: + specifier: ^7.0.29 + version: 7.0.29(zod@4.4.3) + better-sqlite3: + specifier: ^12.5.0 + version: 12.11.1 + docus: + specifier: workspace:* + version: link:../layer + nuxt: + specifier: ^4.3.1 + version: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(@parcel/watcher@2.5.6)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.12.7)(supports-color@10.2.2)(terser@5.49.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0) + packages: '@ai-sdk/gateway@4.0.21': @@ -238,6 +256,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/mistral@4.0.33': + resolution: {integrity: sha512-+LmYNzmqclv1FWErfKghJqVsPY/VUbHwZzNpbx0wfxeYpcR2ioQwtukv3PWoCLa2jSiJoK8HDK9P/Aaw6GexiQ==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.10': resolution: {integrity: sha512-uPyec0+85dwxZYXtb8qe8gCjhjDfxP4LCDo/uRQS/iG+FIgYbHPRhr/ys281udG90bTaE18+5cxWraYaf8oHCw==} engines: {node: '>=22'} @@ -250,6 +274,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.30': + resolution: {integrity: sha512-9TyxUXolql77ntHIeygLqt7PO1O0HZPB73cJhLG2OsPecSBRIZhNXLxT1T7rlL6Zpm1eDoLMFrMV99kAJ28/Sg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@4.0.3': resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} engines: {node: '>=22'} @@ -258,6 +288,10 @@ packages: resolution: {integrity: sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==} engines: {node: '>=22'} + '@ai-sdk/provider@4.0.8': + resolution: {integrity: sha512-aWO7iwhFUGf347tCwNGggggfmZigaSu7TF739IZSrWWABUp7zkb4Cr3fMqvBe5EIS7ABJJu3Cadn0g/zs1G0QQ==} + engines: {node: '>=22'} + '@ai-sdk/vue@4.0.29': resolution: {integrity: sha512-oowEVPi1ESTx69qy3hP8r/ZKsXjumDhnXZGdppy0pNTBAWyG88MEH70X0QzS2nem3+g1/Wzj4XbyHLrfD0lO+Q==} engines: {node: '>=22'} @@ -9508,6 +9542,12 @@ snapshots: pkce-challenge: 5.0.1 zod: 4.4.3 + '@ai-sdk/mistral@4.0.33(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.8 + '@ai-sdk/provider-utils': 5.0.30(zod@4.4.3) + zod: 4.4.3 + '@ai-sdk/provider-utils@5.0.10(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.3 @@ -9525,6 +9565,15 @@ snapshots: undici: 7.29.0 zod: 4.4.3 + '@ai-sdk/provider-utils@5.0.30(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.8 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + undici: 7.29.0 + zod: 4.4.3 + '@ai-sdk/provider@4.0.3': dependencies: json-schema: 0.4.0 @@ -9533,6 +9582,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@4.0.8': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/vue@4.0.29(vue@3.5.41(typescript@6.0.3))(zod@4.4.3)': dependencies: '@ai-sdk/provider-utils': 5.0.10(zod@4.4.3) @@ -12048,6 +12101,91 @@ snapshots: - webpack - xml2js + '@nuxt/nitro-server@4.5.2(2a6e92daab8b39c44a647d8d6ca2b962)': + dependencies: + '@nuxt/devalue': 2.0.2 + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) + '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@vue/shared': 3.5.41 + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + devalue: 5.9.0 + errx: 0.1.2 + escape-string-regexp: 5.0.0 + exsolve: 1.1.1 + h3: 1.15.11 + impound: 1.1.7(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + klona: 2.0.6 + mocked-exports: 0.1.1 + nitropack: 2.13.4(better-sqlite3@12.11.1)(oxc-parser@0.143.0)(rolldown@1.2.4)(srvx@0.12.7)(supports-color@10.2.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + nostics: 1.2.0 + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(@parcel/watcher@2.5.6)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.12.7)(supports-color@10.2.2)(terser@5.49.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0) + nypm: 0.6.9 + ohash: 2.0.12 + pathe: 2.0.3 + rou3: 0.9.2 + std-env: 4.2.0 + ufo: 1.6.4 + unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) + unstorage: 1.17.5(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.2.2)) + vue: 3.5.41(typescript@6.0.3) + vue-bundle-renderer: 2.3.1 + vue-devtools-stub: 0.1.0 + optionalDependencies: + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@farmfe/core' + - '@libsql/client' + - '@netlify/blobs' + - '@oxc-project/types' + - '@planetscale/database' + - '@rspack/core' + - '@unhead/cli' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools-kit' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - bun-types-no-globals + - db0 + - drizzle-orm + - encoding + - esbuild + - idb-keyval + - ioredis + - lightningcss + - magic-string + - magicast + - mysql2 + - oxc-parser + - react-native-b4a + - rolldown + - rollup + - sqlite3 + - srvx + - supports-color + - typescript + - unloader + - unplugin + - uploadthing + - vite + - webpack + - xml2js + '@nuxt/nitro-server@4.5.2(4f17f64dd2fbdc1804285c06ff55d677)': dependencies: '@nuxt/devalue': 2.0.2 @@ -12555,6 +12693,75 @@ snapshots: - webpack - yaml + '@nuxt/vite-builder@4.5.2(61ace4baf0817637bede22ce20555bb9)': + dependencies: + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) + '@vitejs/plugin-vue': 6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@vitejs/plugin-vue-jsx': 5.1.6(supports-color@10.2.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + autoprefixer: 10.5.4(postcss@8.5.26) + consola: 3.4.2 + cssnano: 8.0.2(postcss@8.5.26) + defu: 6.1.7 + escape-string-regexp: 5.0.0 + exsolve: 1.1.1 + generic-names: 4.0.0 + get-port-please: 3.2.0 + jiti: 2.7.0 + js-tokens: 10.0.0 + knitwork: 1.3.0 + mlly: 1.8.2 + mocked-exports: 0.1.1 + nuxt: 4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(@parcel/watcher@2.5.6)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.12.7)(supports-color@10.2.2)(terser@5.49.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0) + nypm: 0.6.9 + pathe: 2.0.3 + pkg-types: 2.3.1 + postcss: 8.5.26 + rolldown-string: 0.3.1(rolldown@1.2.4) + seroval: 1.6.2 + std-env: 4.2.0 + ufo: 1.6.4 + unenv: 2.0.0-rc.24 + unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 6.0.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + vite-plugin-checker: 0.14.5(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(meow@13.2.0)(optionator@0.9.4)(typescript@6.0.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3)) + vue: 3.5.41(typescript@6.0.3) + vue-bundle-renderer: 2.3.1 + optionalDependencies: + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + rolldown: 1.2.4 + rollup-plugin-visualizer: 7.0.1(rolldown@1.2.4)(rollup@4.62.2) + transitivePeerDependencies: + - '@biomejs/biome' + - '@farmfe/core' + - '@rspack/core' + - '@types/node' + - '@vitejs/devtools' + - bun-types-no-globals + - esbuild + - eslint + - less + - magic-string + - magicast + - meow + - optionator + - oxc-parser + - oxlint + - rollup + - sass + - sass-embedded + - stylelint + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - unloader + - vue-tsc + - webpack + - yaml + '@nuxt/vite-builder@4.5.2(7674a6ff7fb0f48ae65476afeeeaac2b)': dependencies: '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) @@ -18738,6 +18945,148 @@ snapshots: - xml2js - yaml + nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(@parcel/watcher@2.5.6)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.12.7)(supports-color@10.2.2)(terser@5.49.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0): + dependencies: + '@dxup/nuxt': 0.5.8(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + '@nuxt/cli': 3.37.0(@nuxt/schema@4.5.2)(cac@6.7.14)(magicast@0.5.4)(supports-color@10.2.2) + '@nuxt/devtools': 3.4.1(db0@0.3.4(better-sqlite3@12.11.1))(ioredis@5.11.1(supports-color@10.2.2))(magic-string@1.2.0)(oxc-parser@0.143.0)(rolldown@1.2.4)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@nuxt/kit': 4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) + '@nuxt/nitro-server': 4.5.2(2a6e92daab8b39c44a647d8d6ca2b962) + '@nuxt/schema': 4.5.2 + '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.5.2(magic-string@1.2.0)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))) + '@nuxt/vite-builder': 4.5.2(61ace4baf0817637bede22ce20555bb9) + '@unhead/vue': 3.3.2(@oxc-project/types@0.144.0)(esbuild@0.28.1)(lightningcss@1.33.0)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@vue/shared': 3.5.41 + chokidar: 5.0.0 + compatx: 0.2.0 + consola: 3.4.2 + cookie-es: 3.1.1 + defu: 6.1.7 + devalue: 5.9.0 + errx: 0.1.2 + escape-string-regexp: 5.0.0 + exsolve: 1.1.1 + fnv1a-64: 0.1.2 + hookable: 6.1.1 + ignore: 7.0.6 + impound: 1.1.7(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + magic-string: 1.2.0 + mlly: 1.8.2 + nanotar: 0.3.0 + nostics: 1.2.0 + nypm: 0.6.9 + object-identity: 0.2.3 + ofetch: 1.5.1 + ohash: 2.0.11 + on-change: 6.0.2 + oxc-walker: 1.1.1(@oxc-project/types@0.144.0)(oxc-parser@0.143.0)(rolldown@1.2.4) + pathe: 2.0.3 + perfect-debounce: 2.1.0 + picomatch: 4.0.5 + pkg-types: 2.3.1 + rolldown: 1.2.4 + rolldown-string: 0.3.1(rolldown@1.2.4) + rou3: 0.9.2 + scule: 1.3.0 + std-env: 4.2.0 + tinyglobby: 0.2.17 + ufo: 1.6.4 + ultrahtml: 1.7.0 + uncrypto: 0.1.3 + unctx: 3.0.0(magic-string@1.2.0)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) + undici: 8.10.0 + unhead: 3.3.2(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + unimport: 6.4.0(esbuild@0.28.1)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + unrouting: 0.2.3 + untyped: 2.0.0 + verkit: 0.3.2 + vue: 3.5.41(typescript@6.0.3) + vue-component-type-helpers: 3.3.10 + vue-router: 5.2.0(@vue/compiler-sfc@3.5.41)(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + optionalDependencies: + '@parcel/watcher': 2.5.6 + '@types/node': 26.2.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@babel/plugin-proposal-decorators' + - '@babel/plugin-syntax-jsx' + - '@babel/plugin-syntax-typescript' + - '@biomejs/biome' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@farmfe/core' + - '@libsql/client' + - '@netlify/blobs' + - '@oxc-project/types' + - '@pinia/colada' + - '@planetscale/database' + - '@rollup/plugin-babel' + - '@rspack/core' + - '@unhead/cli' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - '@vitejs/devtools-kit' + - '@vue/compiler-sfc' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - bufferutil + - bun-types-no-globals + - cac + - commander + - db0 + - drizzle-orm + - encoding + - esbuild + - eslint + - idb-keyval + - ioredis + - less + - lightningcss + - magicast + - meow + - mysql2 + - optionator + - oxc-parser + - oxlint + - pinia + - react-native-b4a + - rollup + - rollup-plugin-visualizer + - sass + - sass-embedded + - sqlite3 + - srvx + - stylelint + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - unloader + - uploadthing + - utf-8-validate + - vite + - vue-tsc + - webpack + - xml2js + - yaml + nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.144.0)(@parcel/watcher@2.5.6)(@types/node@26.2.0)(@vue/compiler-sfc@3.5.41)(better-sqlite3@12.11.1)(cac@7.0.0)(db0@0.3.4(better-sqlite3@12.11.1))(esbuild@0.28.1)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.2))(rollup@4.62.2)(srvx@0.12.7)(supports-color@10.2.2)(terser@5.49.0)(tsx@4.23.12)(typescript@6.0.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.5.8(esbuild@0.28.1)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup@4.62.2)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7043b704c..028e615ef 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - cli - docs - layer + - playground overrides: h3: 1.15.11