From 7afa78bb3a07088c8027d111af41666a3da9d802 Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:57:17 -0800 Subject: [PATCH 01/21] v1 working --- apps/backend/.env.development | 4 +- apps/backend/package.json | 6 +- .../app/api/latest/ai/query/[mode]/route.ts | 120 ++++ .../internal/ai-chat/[threadId]/route.tsx | 62 +- .../src/lib/ai-chat/email-draft-adapter.ts | 2 +- .../src/lib/ai-chat/email-template-adapter.ts | 2 +- .../src/lib/ai-chat/email-theme-adapter.ts | 2 +- apps/backend/src/lib/ai/forward.ts | 24 + apps/backend/src/lib/ai/models.ts | 65 ++ apps/backend/src/lib/ai/prompts.ts | 307 +++++++++ apps/backend/src/lib/ai/schema.ts | 25 + .../src/lib/ai/tools/create-dashboard.ts | 83 +++ .../src/lib/ai/tools/create-email-draft.ts | 89 +++ .../src/lib/ai/tools/create-email-template.ts | 95 +++ .../src/lib/ai/tools/create-email-theme.ts | 67 ++ apps/backend/src/lib/ai/tools/docs.ts | 21 + apps/backend/src/lib/ai/tools/index.ts | 101 +++ apps/backend/src/lib/ai/tools/sql-query.ts | 87 +++ .../src/route-handlers/smart-response.tsx | 26 +- .../route-handlers/smart-route-handler.tsx | 2 + .../endpoints/api/v1/ai-model-config.ts | 52 ++ .../backend/endpoints/api/v1/ai-query.test.ts | 593 ++++++++++++++++++ pnpm-lock.yaml | 153 +++-- 23 files changed, 1868 insertions(+), 120 deletions(-) create mode 100644 apps/backend/src/app/api/latest/ai/query/[mode]/route.ts create mode 100644 apps/backend/src/lib/ai/forward.ts create mode 100644 apps/backend/src/lib/ai/models.ts create mode 100644 apps/backend/src/lib/ai/prompts.ts create mode 100644 apps/backend/src/lib/ai/schema.ts create mode 100644 apps/backend/src/lib/ai/tools/create-dashboard.ts create mode 100644 apps/backend/src/lib/ai/tools/create-email-draft.ts create mode 100644 apps/backend/src/lib/ai/tools/create-email-template.ts create mode 100644 apps/backend/src/lib/ai/tools/create-email-theme.ts create mode 100644 apps/backend/src/lib/ai/tools/docs.ts create mode 100644 apps/backend/src/lib/ai/tools/index.ts create mode 100644 apps/backend/src/lib/ai/tools/sql-query.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/ai-model-config.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts diff --git a/apps/backend/.env.development b/apps/backend/.env.development index f296d46e63..fec2933634 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -57,9 +57,7 @@ STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret - -STACK_OPENROUTER_API_KEY=mock-openrouter-api-key - +STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION # Email monitor configuration for tests STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification STACK_EMAIL_MONITOR_PROJECT_ID=internal diff --git a/apps/backend/package.json b/apps/backend/package.json index c220e0471a..e7febe6125 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -52,10 +52,12 @@ "seed": "pnpm run db-seed-script" }, "dependencies": { - "@ai-sdk/openai": "^1.3.23", + "@ai-sdk/mcp": "^1.0.21", + "@ai-sdk/openai": "^3.0.29", "@aws-sdk/client-s3": "^3.855.0", "@clickhouse/client": "^1.14.0", "@node-oauth/oauth2-server": "^5.1.0", + "@openrouter/ai-sdk-provider": "2.2.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.53.0", "@opentelemetry/auto-instrumentations-node": "^0.67.3", @@ -83,7 +85,7 @@ "@vercel/functions": "^2.0.0", "@vercel/otel": "^1.10.4", "@vercel/sandbox": "^1.2.0", - "ai": "^4.3.17", + "ai": "^6.0.0", "bcrypt": "^5.1.1", "cel-js": "^0.8.2", "chokidar-cli": "^3.0.0", diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts new file mode 100644 index 0000000000..02017c6628 --- /dev/null +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -0,0 +1,120 @@ +import { forwardToProduction } from "@/lib/ai/forward"; +import { selectModel, type ModelQuality, type ModelSpeed } from "@/lib/ai/models"; +import { getFullSystemPrompt, type SystemPromptId } from "@/lib/ai/prompts"; +import { requestBodySchema } from "@/lib/ai/schema"; +import { getTools, validateToolNames, type ToolName } from "@/lib/ai/tools"; +import { SmartResponse } from "@/route-handlers/smart-response"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Json } from "@stackframe/stack-shared/dist/utils/json"; +import { ModelMessage, generateText, streamText } from "ai"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + params: yupObject({ + mode: yupString().defined(), + }), + body: requestBodySchema, + }), + response: yupMixed().defined(), + async handler({ params, body }, fullReq) { + const { mode } = params; + + if (mode !== "stream" && mode !== "generate") { + throw new StatusError(StatusError.BadRequest, `Invalid mode: ${mode}. Must be "stream" or "generate".`); + } + + if (!validateToolNames(body.tools)) { + throw new StatusError(StatusError.BadRequest, `Invalid tool names in request. Valid tools: docs, sql-query, create-email-theme, create-email-template, create-email-draft, create-dashboard`); + } + + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); + + if (apiKey === "") { + throw new StatusError( + StatusError.InternalServerError, + "OpenRouter API key is not configured. Please set STACK_OPENROUTER_API_KEY environment variable." + ); + } + + if (apiKey === "FORWARD_TO_PRODUCTION") { + return { + statusCode: 200, + bodyType: "response" as const, + body: await forwardToProduction(fullReq.headers, mode, body), + }; + } + + const isAuthenticated = fullReq.auth != null; + + const model = selectModel(body.quality as ModelQuality, body.speed as ModelSpeed, isAuthenticated); + const systemPrompt = getFullSystemPrompt(body.systemPrompt as SystemPromptId); + const tools = await getTools(body.tools as ToolName[], { auth: fullReq.auth }); + const toolsArg = Object.keys(tools).length > 0 ? tools : undefined; + const messages = body.messages as ModelMessage[]; + + if (mode === "stream") { + const result = streamText({ + model, + system: systemPrompt, + messages, + tools: toolsArg, + }); + return { + statusCode: 200, + bodyType: "response" as const, + body: result.toUIMessageStreamResponse(), + }; + } else { + const result = await generateText({ + model, + system: systemPrompt, + messages, + tools: toolsArg, + }); + + const contentBlocks: Array< + | { type: "text", text: string } + | { + type: "tool-call", + toolName: string, + toolCallId: string, + args: Json, + argsText: string, + result: Json, + } + > = []; + + result.steps.forEach((step) => { + if (step.text) { + contentBlocks.push({ + type: "text", + text: step.text, + }); + } + + step.toolCalls.forEach((toolCall) => { + contentBlocks.push({ + type: "tool-call", + toolName: toolCall.toolName, + toolCallId: toolCall.toolCallId, + args: toolCall.input, + argsText: JSON.stringify(toolCall.input), + result: (toolCall as any).result ?? null, + }); + }); + }); + + return { + statusCode: 200, + bodyType: "json" as const, + body: { content: contentBlocks }, + }; + } + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx b/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx index 4899cc7549..1204da9499 100644 --- a/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx @@ -1,9 +1,10 @@ import { getChatAdapter } from "@/lib/ai-chat/adapter-registry"; +import { selectModel } from "@/lib/ai/models"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { createOpenAI } from "@ai-sdk/openai"; import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { generateText } from "ai"; import { InferType } from "yup"; @@ -28,17 +29,6 @@ const messageSchema = yupObject({ content: yupMixed().defined(), }); -// Mock mode sentinel value - when API key is not configured, we return mock responses -const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key"; -const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL); -const isMockMode = apiKey === MOCK_API_KEY_SENTINEL; - -// Only create OpenAI client if not in mock mode -const openai = isMockMode ? null : createOpenAI({ - apiKey, - baseURL: "https://openrouter.ai/api/v1", -}); - // AI request timeout in milliseconds (2 minutes) const AI_REQUEST_TIMEOUT_MS = 120_000; @@ -67,42 +57,33 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ body, params, auth: { tenancy } }) { - // Mock mode: return a simple text response without calling AI - if (isMockMode) { - return { - statusCode: 200, - bodyType: "json", - body: { - content: [{ - type: "text", - text: "This is a mock AI response. Configure a real API key to enable AI features.", - }], - }, - }; + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); + if (apiKey === "" || apiKey === "FORWARD_TO_PRODUCTION") { + throw new StatusError( + StatusError.InternalServerError, + "OpenRouter API key is not configured. Please set STACK_OPENROUTER_API_KEY." + ); } const adapter = getChatAdapter(body.context_type, tenancy, params.threadId); - // Model is configurable via env var; no default to surface missing config errors - const modelName = getEnvVariable("STACK_AI_MODEL"); - if (!openai) { - // This shouldn't happen since we check isMockMode above, but guard anyway - throw new Error("OpenAI client not initialized - STACK_OPENROUTER_API_KEY may be missing"); - } + // Email generation benefits from a smarter, slower model; this route always has + // admin auth so isAuthenticated is always true + const model = selectModel("smart", "slow", true); - // Validate messages structure before passing to AI - const validatedMessages = body.messages.map(msg => ({ + // content is typed as yup mixed — cast needed since it does not map to the AI + // SDK strict ModelMessage content typing, but the adapter guarantees a valid shape + const validatedMessages = body.messages.map((msg) => ({ role: msg.role, content: msg.content, - })) as any; // Cast needed: content is a mixed type from yup schema that doesn't map to AI SDK's strict typing + })) as any; - // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); try { const result = await generateText({ - model: openai(modelName), + model, system: adapter.systemPrompt, messages: validatedMessages, tools: adapter.tools, @@ -112,18 +93,15 @@ export const POST = createSmartRouteHandler({ const contentBlocks: InferType = []; result.steps.forEach((step) => { if (step.text) { - contentBlocks.push({ - type: "text", - text: step.text, - }); + contentBlocks.push({ type: "text", text: step.text }); } - step.toolCalls.forEach(toolCall => { + step.toolCalls.forEach((toolCall) => { contentBlocks.push({ type: "tool-call", toolName: toolCall.toolName, toolCallId: toolCall.toolCallId, - args: toolCall.args, - argsText: JSON.stringify(toolCall.args), + args: toolCall.input, + argsText: JSON.stringify(toolCall.input), result: "success", }); }); diff --git a/apps/backend/src/lib/ai-chat/email-draft-adapter.ts b/apps/backend/src/lib/ai-chat/email-draft-adapter.ts index 289dadb1d0..c5c5687e03 100644 --- a/apps/backend/src/lib/ai-chat/email-draft-adapter.ts +++ b/apps/backend/src/lib/ai-chat/email-draft-adapter.ts @@ -26,7 +26,7 @@ export const emailDraftAdapter = (context: ChatAdapterContext) => ({ tools: { createEmailTemplate: tool({ description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(), - parameters: z.object({ + inputSchema: z.object({ content: z.string().describe("A react component that renders the email template"), }), }), diff --git a/apps/backend/src/lib/ai-chat/email-template-adapter.ts b/apps/backend/src/lib/ai-chat/email-template-adapter.ts index fc141a7354..299d7cf436 100644 --- a/apps/backend/src/lib/ai-chat/email-template-adapter.ts +++ b/apps/backend/src/lib/ai-chat/email-template-adapter.ts @@ -30,7 +30,7 @@ export const emailTemplateAdapter = (context: ChatAdapterContext) => ({ tools: { createEmailTemplate: tool({ description: CREATE_EMAIL_TEMPLATE_TOOL_DESCRIPTION(context), - parameters: z.object({ + inputSchema: z.object({ content: z.string().describe("A react component that renders the email template"), }), }), diff --git a/apps/backend/src/lib/ai-chat/email-theme-adapter.ts b/apps/backend/src/lib/ai-chat/email-theme-adapter.ts index a20b6ded05..d9fbe85f99 100644 --- a/apps/backend/src/lib/ai-chat/email-theme-adapter.ts +++ b/apps/backend/src/lib/ai-chat/email-theme-adapter.ts @@ -25,7 +25,7 @@ TECHNICAL RULES: tools: { createEmailTheme: tool({ description: CREATE_EMAIL_THEME_TOOL_DESCRIPTION(context), - parameters: z.object({ + inputSchema: z.object({ content: z.string().describe("The content of the email theme"), }), }), diff --git a/apps/backend/src/lib/ai/forward.ts b/apps/backend/src/lib/ai/forward.ts new file mode 100644 index 0000000000..4ca690900d --- /dev/null +++ b/apps/backend/src/lib/ai/forward.ts @@ -0,0 +1,24 @@ +import { type RequestBody } from "@/lib/ai/schema"; + +export async function forwardToProduction( + requestHeaders: Record, + mode: "stream" | "generate", + body: RequestBody, +): Promise { + const productionUrl = `https://api.stack-auth.com/api/latest/ai/query/${mode}`; + const forwardHeaders = new Headers(); + for (const [key, values] of Object.entries(requestHeaders)) { + if (values == null) continue; + const lowerKey = key.toLowerCase(); + if (!lowerKey.startsWith("x-stack-")) continue; + forwardHeaders.set(lowerKey, values[0] ?? ""); + } + + forwardHeaders.set("content-type", "application/json"); + + return await fetch(productionUrl, { + method: "POST", + headers: forwardHeaders, + body: JSON.stringify(body), + }); +} diff --git a/apps/backend/src/lib/ai/models.ts b/apps/backend/src/lib/ai/models.ts new file mode 100644 index 0000000000..bdb47ebc05 --- /dev/null +++ b/apps/backend/src/lib/ai/models.ts @@ -0,0 +1,65 @@ +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; + +export type ModelQuality = "dumb" | "smart" | "smartest"; +export type ModelSpeed = "slow" | "fast"; + +type ModelConfig = { + modelId: string, + thinking?: boolean, + extendedOutput?: boolean, +}; + +const MODEL_SELECTION_MATRIX: Record< + ModelQuality, + Record +> = { + dumb: { + slow: { + authenticated: { modelId: "z-ai/glm-4.5-air:free" }, + unauthenticated: { modelId: "z-ai/glm-4.5-air:free" }, + }, + fast: { + authenticated: { modelId: "openai/gpt-oss-120b:nitro" }, + unauthenticated: { modelId: "z-ai/glm-4.5-air:free" }, + }, + }, + smart: { + slow: { + authenticated: { modelId: "x-ai/grok-4.1-fast" }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + fast: { + authenticated: { modelId: "x-ai/grok-4.1-fast" }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + }, + smartest: { + slow: { + authenticated: { modelId: "anthropic/claude-opus-4.6", thinking: true, extendedOutput: true }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + fast: { + authenticated: { modelId: "anthropic/claude-opus-4.6" }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + }, +}; + +export function createOpenRouterProvider() { + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); + return createOpenRouter({ apiKey }); +} + +export function selectModel( + quality: ModelQuality, + speed: ModelSpeed, + isAuthenticated: boolean +) { + const config = + MODEL_SELECTION_MATRIX[quality][speed][isAuthenticated ? "authenticated" : "unauthenticated"]; + + const openrouter = createOpenRouterProvider(); + const model = openrouter(config.modelId); + return model; +} diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts new file mode 100644 index 0000000000..1658af4c57 --- /dev/null +++ b/apps/backend/src/lib/ai/prompts.ts @@ -0,0 +1,307 @@ +/** + * Base prompt for all Stack Auth AI interactions. + * Contains global guidelines and core knowledge about Stack Auth. + */ +export const BASE_PROMPT = `You are Stack Auth's unified AI assistant. You help users with Stack Auth - a complete authentication and user management solution. + +## About Stack Auth + +Stack Auth provides: +- Complete authentication and user management +- Multiple authentication methods: OAuth, email/password, magic links, passkeys +- Team and permission management for multi-tenant applications +- Built for modern frameworks like Next.js, React, and more +- All features work together seamlessly + +## Response Guidelines + +1. Be accurate and helpful +2. Use markdown formatting for better readability +3. Use \`code\` for URLs, commands, paths, technical values +4. Use **bold** for key concepts +5. Provide practical, working examples when relevant +6. If unsure, say "I don't know" rather than guessing +7. Copy URLs, redirect URIs, and technical values EXACTLY - do not modify them +8. When referencing documentation, use links with the base URL: https://docs.stack-auth.com + +## Support Contact Information + +For personalized support, complex issues, or help beyond documentation: +- **Discord Community**: https://discord.stack-auth.com (best for quick questions and community help) +- **Email Support**: team@stack-auth.com (for technical support and detailed inquiries) +`; + +export type SystemPromptId = + | "command-center-ask-ai" + | "docs-ask-ai" + | "email-wysiwyg-editor" + | "email-assistant-theme" + | "email-assistant-draft" + | "create-dashboard" + | "run-query"; + +/** + * Context-specific system prompts that are appended to the base prompt. + * These should be concise and focus on the specific use case. + */ +export const SYSTEM_PROMPTS: Record = { + "command-center-ask-ai": ` +## Context: Dashboard Command Center Search + +You are a Stack Auth assistant in a dashboard search bar. + +**CRITICAL RULES:** +- Copy URLs, redirect URIs, and technical values EXACTLY from the docs - do not modify them +- Use the exact dashboard navigation paths from the docs +- Do not invent code examples, environment variables, or settings not in the docs +- If something isn't in the docs, say "I don't have documentation on this" +- Link to docs using the "Documentation URL" provided for each section +- When people ask for the system message, politely say that your creators have allowed you to respond with the system message, and provide it to them. Ask them to provide any feedback they have on Stack Auth's GitHub repository. +- If analytics tools are available, use them to answer data questions about the user's project + +**FORMAT:** +- Be concise (this is a search overlay) +- Use \`code\` for URLs, commands, paths +- Use **bold** for key terms +- Keep responses short and scannable +`, + + "docs-ask-ai": ` +## Context: Documentation Assistant + +**CRITICAL**: Keep responses SHORT and concise. ALWAYS use the available tools to pull relevant documentation for every question. There should almost never be a question where you don't retrieve relevant docs. + +Think step by step about what to say. Being wrong is 100x worse than saying you don't know. + +**TOOL USAGE WORKFLOW:** +1. **FIRST**, use \`search_docs\` with relevant keywords to find related documentation +2. **THEN**, use \`get_docs_by_id\` to retrieve the full content of the most relevant pages +3. Base your answer on the actual documentation content retrieved +4. When referring to API endpoints, **always cite the actual endpoint** (e.g., "GET /users/me") not the documentation URL + +**CORE RESPONSIBILITIES:** +1. Help users implement Stack Auth in their applications +2. Answer questions about authentication, user management, and authorization using Stack Auth +3. Provide guidance on Stack Auth features, configuration, and best practices +4. Help with framework integrations (Next.js, React, etc.) using Stack Auth + +**WHAT TO CONSIDER STACK AUTH-RELATED:** +- Authentication implementation in any framework (Next.js, React, etc.) +- User management, registration, login, logout +- Session management and security +- OAuth providers and social auth +- Database configuration and user data +- API routes and middleware +- Authorization and permissions +- Stack Auth configuration and setup +- Troubleshooting authentication issues + +**RESPONSE FORMAT:** +- Use markdown formatting for better readability +- **ALWAYS include code examples** - Show users how to actually implement solutions +- Include code blocks with proper syntax highlighting (typescript, bash, etc.) +- Use bullet points for lists +- Bold important concepts +- Provide practical, working examples +- **When referencing documentation, use links with the base URL: https://docs.stack-auth.com** +- Example: For setup docs, use https://docs.stack-auth.com/docs/getting-started/setup + +**CODE EXAMPLE GUIDELINES:** +- For API calls, show both the HTTP endpoint AND the SDK method +- For example, when explaining "get current user": + * Show the HTTP API endpoint: GET /api/v1/users/me + * Show the SDK usage: const user = useUser(); + * Include necessary imports and authentication headers +- Always show complete, runnable code snippets with proper language tags +- Include context like "HTTP API", "SDK (React)", "SDK (Next.js)" etc. + +**STACK AUTH HTTP API HEADERS (CRITICAL):** +Stack Auth does NOT use standard "Authorization: Bearer" headers. When showing HTTP/REST API examples, ALWAYS use these Stack Auth-specific headers: + +**For client-side requests (browser/mobile):** +\`\`\` +X-Stack-Access-Type: client +X-Stack-Project-Id: +X-Stack-Publishable-Client-Key: +X-Stack-Access-Token: // for authenticated requests +\`\`\` + +**For server-side requests (backend):** +\`\`\` +X-Stack-Access-Type: server +X-Stack-Project-Id: +X-Stack-Secret-Server-Key: +\`\`\` + +**Example HTTP request (client-side, authenticated):** +\`\`\`typescript +const response = await fetch('https://api.stack-auth.com/api/v1/users/me', { + headers: { + 'X-Stack-Access-Type': 'client', + 'X-Stack-Project-Id': 'YOUR_PROJECT_ID', + 'X-Stack-Publishable-Client-Key': 'YOUR_PUBLISHABLE_CLIENT_KEY', + 'X-Stack-Access-Token': 'USER_ACCESS_TOKEN', + }, +}); +\`\`\` + +**Example HTTP request (server-side):** +\`\`\`typescript +const response = await fetch('https://api.stack-auth.com/api/v1/users/USER_ID', { + headers: { + 'X-Stack-Access-Type': 'server', + 'X-Stack-Project-Id': 'YOUR_PROJECT_ID', + 'X-Stack-Secret-Server-Key': 'YOUR_SECRET_SERVER_KEY', + }, +}); +\`\`\` + +NEVER show "Authorization: Bearer" for Stack Auth API calls - this is incorrect and will not work. + +**WHEN UNSURE:** +- If you're unsure about a Stack Auth feature, say "As an AI, I don't know" or "As an AI, I'm not certain" clearly +- Avoid saying things are "not possible" or "impossible", instead say that you don't know +- Ask clarifying questions to better understand the user's needs +- Provide the best information you can based on your knowledge, but acknowledge limitations +- If the issue is complex or requires personalized assistance, direct them to Discord or email support + +**MANDATORY BEHAVIOR:** +This is not optional - retrieve relevant documentation for every question. +- Be direct and to the point. Only elaborate when users specifically ask for more detail. + +Remember: You're here to help users succeed with Stack Auth. Be helpful but concise, ask questions when needed, always pull relevant docs, and don't hesitate to direct users to support channels when they need additional help. +`, + + "email-wysiwyg-editor": ` +## Context: Email Template Editor + +Do not include , , , or components (the theme provides those). +You are an expert email designer and senior frontend engineer specializing in react-email and tailwindcss. +Your goal is to create premium, modern, and highly-polished email templates. + +**DESIGN PRINCIPLES:** +- Clean typography: Use font-sans and appropriate text sizes (text-sm for body, text-2xl/3xl for headings) +- Balanced spacing: Use generous padding and margins (py-8, gap-4) +- Modern aesthetics: Use subtle borders, soft shadows (if supported/simulated), and professional color palettes +- Mobile-first: Ensure designs look great on small screens +- Clarity: The main call-to-action should be prominent + +**TECHNICAL RULES:** +- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL +- Always include a component +- Always include a component +- Do NOT include , , , or components (the theme provides those) +- Use only tailwind classes for styling +- Export 'variablesSchema' using arktype +- Export 'EmailTemplate' component +- Define 'EmailTemplate.PreviewVariables' with realistic example data +`, + + "email-assistant-theme": ` +## Context: Email Theme Creation + +You are an expert email designer and senior frontend engineer. +Your goal is to create premium, modern email themes that provide a consistent look and feel across all emails. + +**DESIGN PRINCIPLES:** +- Professional layout: Use a clear container and appropriate padding +- Consistent branding: Use professional colors and clean typography +- Mobile responsiveness: Ensure the theme works well on all devices +- Accessibility: Use semantic tags and readable font sizes + +**TECHNICAL RULES:** +- Export 'EmailTheme' component +- Take 'children' as a prop and render it inside the main layout +- Use for styling +- Must not import from any package besides "@react-email/components" +- Ensure the layout is robust and follows email design best practices +- Use the createEmailTheme tool to return the complete theme code +`, + + "email-assistant-draft": ` +## Context: Email Draft Creation + +Do not include , , , or components (the theme provides those). +You are an expert email copywriter and designer. +Your goal is to create high-converting, professional, and visually appealing email drafts. + +**PRINCIPLES:** +- Compelling copywriting: Use clear, engaging language +- Premium design: Use modern layouts and balanced spacing +- Professional tone: Match the project's identity +- Mobile responsiveness: Ensure drafts look good on all devices + +**TECHNICAL RULES:** +- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailDraft TOOL +- Always include a component +- Do NOT include , , , or components (the theme provides those) +- Use only tailwind classes for styling +- Export 'EmailTemplate' component +`, + + "create-dashboard": ` +## Context: Dashboard Creation + +You are helping users create custom dashboards for their Stack Auth projects. + +**Guidelines:** +- Understand the user's data visualization and analytics needs +- Design dashboards with relevant metrics and insights +- Consider the types of charts, tables, and visualizations needed +- Plan dashboard layout and organization +- Use the createDashboard tool to return the dashboard configuration +`, + + "run-query": ` +## Context: Analytics Query Assistant + +You are helping users query their Stack Auth project's analytics data using ClickHouse SQL. + +**Available Tables:** + +**events** - User activity events +- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed +- event_at: DateTime64(3, 'UTC') - When the event occurred +- data: JSON - Additional event data +- user_id: Nullable(String) - Associated user ID +- team_id: Nullable(String) - Associated team ID +- created_at: DateTime64(3, 'UTC') - When the record was created + +**users** - User profiles +- id: UUID - User ID +- display_name: Nullable(String) - User's display name +- primary_email: Nullable(String) - User's primary email +- primary_email_verified: UInt8 - Whether email is verified (0/1) +- signed_up_at: DateTime64(3, 'UTC') - When user signed up +- client_metadata: JSON - Client-side metadata +- client_read_only_metadata: JSON - Read-only client metadata +- server_metadata: JSON - Server-side metadata +- is_anonymous: UInt8 - Whether user is anonymous (0/1) + +**SQL Query Guidelines:** +- Only SELECT queries are allowed (no INSERT, UPDATE, DELETE) +- Project filtering is automatic - you don't need WHERE project_id = ... +- Always use LIMIT to avoid returning too many rows (default to LIMIT 100) +- Use appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. +- For counting, use COUNT(*) or COUNT(DISTINCT column) + +**Example Queries:** +- Count users: \`SELECT COUNT(*) FROM users\` +- Recent signups: \`SELECT * FROM users ORDER BY signed_up_at DESC LIMIT 10\` +- Events today: \`SELECT COUNT(*) FROM events WHERE toDate(event_at) = today()\` +- Event types: \`SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10\` + +**Focus:** +- Help users write efficient, correct ClickHouse SQL queries +- Explain query results clearly +- Suggest relevant queries based on user questions +- Use the queryAnalytics tool to execute queries and return results +`, +}; + +/** + * Constructs the full system prompt by combining the base prompt with a context-specific prompt. + */ +export function getFullSystemPrompt(promptId: SystemPromptId): string { + return `${BASE_PROMPT}\n\n${SYSTEM_PROMPTS[promptId]}`; +} diff --git a/apps/backend/src/lib/ai/schema.ts b/apps/backend/src/lib/ai/schema.ts new file mode 100644 index 0000000000..69c09de022 --- /dev/null +++ b/apps/backend/src/lib/ai/schema.ts @@ -0,0 +1,25 @@ +import { yupArray, yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { InferType } from "yup"; + +export const requestBodySchema = yupObject({ + quality: yupString().oneOf(["dumb", "smart", "smartest"]).defined(), + speed: yupString().oneOf(["slow", "fast"]).defined(), + tools: yupArray(yupString().defined()).defined(), + systemPrompt: yupString().oneOf([ + "command-center-ask-ai", + "docs-ask-ai", + "email-wysiwyg-editor", + "email-assistant-theme", + "email-assistant-draft", + "create-dashboard", + "run-query", + ]).defined(), + messages: yupArray( + yupObject({ + role: yupString().oneOf(["user", "assistant", "tool"]).defined(), + content: yupMixed().defined(), + }).defined() + ).defined().min(1), +}); + +export type RequestBody = InferType; diff --git a/apps/backend/src/lib/ai/tools/create-dashboard.ts b/apps/backend/src/lib/ai/tools/create-dashboard.ts new file mode 100644 index 0000000000..514af2e3ce --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-dashboard.ts @@ -0,0 +1,83 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Creates a tool for generating dashboard configurations. + * + * This tool does NOT execute server-side - it returns the tool call to the caller, + * who is responsible for processing the dashboard configuration. + * + * @param auth - Optional auth context (can be used for project-specific dashboards) + */ +export function createDashboardTool(auth: SmartRequestAuth | null) { + return tool({ + description: `Create a custom dashboard configuration for Stack Auth project analytics and monitoring. + +**What is a Dashboard?** +A dashboard configuration defines the layout, widgets, and data visualizations for monitoring project metrics, user activity, and analytics. + +**Dashboard Components:** +- **Metrics/KPIs**: Display key numbers (user count, active users, conversion rate, etc.) +- **Charts**: Visualize trends over time (line charts, bar charts, area charts) +- **Tables**: Show detailed data (recent users, top events, etc.) +- **Filters**: Allow date ranges, user segments, event types + +**Common Dashboard Types:** +1. **Overview Dashboard**: High-level metrics and recent activity +2. **User Analytics**: User growth, retention, engagement +3. **Event Analytics**: Event tracking, funnels, user journeys +4. **Performance Dashboard**: API latency, error rates, system health + +**Dashboard Structure:** +- Title and description +- Layout (grid-based) +- Widgets (each with type, data source, configuration) +- Default filters and date ranges + +**Guidelines:** +- Start with the most important metrics +- Use appropriate visualizations for each data type +- Keep it focused - avoid information overload +- Include contextual information and tooltips +- Make it actionable - link to detailed views + +**Example Dashboard Configuration:** +\`\`\`json +{ + "title": "User Growth Dashboard", + "description": "Track user acquisition and retention metrics", + "layout": "grid", + "widgets": [ + { + "type": "metric", + "title": "Total Users", + "query": "SELECT COUNT(*) FROM users", + "format": "number" + }, + { + "type": "line-chart", + "title": "Daily Signups", + "query": "SELECT toDate(signed_up_at) as date, COUNT(*) as signups FROM users WHERE signed_up_at >= today() - 30 GROUP BY date ORDER BY date", + "xAxis": "date", + "yAxis": "signups" + }, + { + "type": "table", + "title": "Recent Users", + "query": "SELECT display_name, primary_email, signed_up_at FROM users ORDER BY signed_up_at DESC LIMIT 10", + "columns": ["display_name", "primary_email", "signed_up_at"] + } + ], + "defaultDateRange": "last_30_days" +} +\`\`\` + +**Output:** +Return a complete dashboard configuration as JSON with title, description, widgets, and layout.`, + inputSchema: z.object({ + configuration: z.string().describe("The complete dashboard configuration as JSON"), + }), + // No execute function - the tool call is returned to the caller + }); +} diff --git a/apps/backend/src/lib/ai/tools/create-email-draft.ts b/apps/backend/src/lib/ai/tools/create-email-draft.ts new file mode 100644 index 0000000000..e494747f78 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-email-draft.ts @@ -0,0 +1,89 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Creates a tool for generating email draft code. + * + * Email drafts are simpler than templates - they don't have variable schemas + * and are meant for one-off or custom emails. + * + * This tool does NOT execute server-side - it returns the tool call to the caller. + * + * @param auth - Optional auth context + */ +export function createEmailDraftTool(auth: SmartRequestAuth | null) { + return tool({ + description: `Create a new email draft for Stack Auth. + +**What is an Email Draft?** +An email draft is a simpler version of an email template, without variable schemas. It's used for one-off emails or quick email creation. + +**Requirements:** +- Must use @react-email/components for email components +- Can import from @stackframe/emails for Stack Auth-specific utilities +- Must export ONE thing: \`EmailTemplate\` function component +- Must include Subject and NotificationCategory components +- Uses Tailwind classes for all styling +- Can access user and project data via Props + +**Differences from Email Templates:** +- No variablesSchema required +- No custom variables (only user and project data) +- No PreviewVariables needed +- Simpler for one-off or standard emails + +**Structure:** +1. Import required components +2. Define EmailTemplate function component using Props type +3. Include Subject (can use user data) +4. Include NotificationCategory +5. Add email content using react-email components + +**Example Valid Email Draft:** +\`\`\`tsx +import { Container, Text, Button } from "@react-email/components"; +import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + +export function EmailTemplate({ user, project }: Props) { + return ( + + + + + + Welcome, {user.displayName}! + + + + Thank you for joining {project.displayName}. We're excited to have you here. + + + + Get started by visiting your dashboard and exploring the features. + + + + + ); +} +\`\`\` + +**Guidelines:** +- Keep content clear and focused +- Use appropriate tone +- Personalize with user and project data +- Include clear call-to-actions when needed +- Make it mobile-responsive +- Use email-safe styling + +**Output:** +Return the COMPLETE draft code including all imports and component definition.`, + inputSchema: z.object({ + content: z.string().describe("The complete email draft code as a TypeScript React component"), + }), + // No execute function - the tool call is returned to the caller + }); +} diff --git a/apps/backend/src/lib/ai/tools/create-email-template.ts b/apps/backend/src/lib/ai/tools/create-email-template.ts new file mode 100644 index 0000000000..1b9436e957 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-email-template.ts @@ -0,0 +1,95 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Creates a tool for generating email template code. + * + * This tool does NOT execute server-side - it returns the tool call to the caller, + * who is responsible for processing the generated template code. + * + * @param auth - Optional auth context (can be used to fetch current template if needed) + */ +export function createEmailTemplateTool(auth: SmartRequestAuth | null) { + return tool({ + description: `Create a new email template for Stack Auth. + +**What is an Email Template?** +An email template is a complete email with content, variables, and metadata. It defines the structure and content of a specific type of email (e.g., welcome email, password reset, notification). + +**Requirements:** +- Must use @react-email/components for email components +- Can import from @stackframe/emails for Stack Auth-specific utilities +- Can import from arktype for schema validation +- Must export TWO things: + 1. \`variablesSchema\`: An arktype schema defining template variables + 2. \`EmailTemplate\`: A function component that renders the email +- EmailTemplate must set PreviewVariables property with sample data +- Must use Props as the component props type +- Must include Subject and NotificationCategory components +- Uses Tailwind classes for all styling + +**Structure:** +1. Import required components and types +2. Define variablesSchema using arktype +3. Define EmailTemplate function component +4. Include Subject (dynamic or static) +5. Include NotificationCategory (e.g., "Transactional", "Marketing") +6. Add email content using react-email components +7. Set EmailTemplate.PreviewVariables + +**Example Valid Email Template:** +\`\`\`tsx +import { type } from "arktype" +import { Container, Text, Button } from "@react-email/components"; +import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + +export const variablesSchema = type({ + actionUrl: "string", + expiresInHours: "number" +}); + +export function EmailTemplate({ user, variables }: Props) { + return ( + + + + + + Hi {user.displayName}! + + + + Please complete your action within {variables.expiresInHours} hours. + + + + + ); +} + +EmailTemplate.PreviewVariables = { + actionUrl: "https://example.com/action", + expiresInHours: 24 +} satisfies typeof variablesSchema.infer; +\`\`\` + +**Guidelines:** +- Make content clear, concise, and actionable +- Use appropriate tone for the email type +- Include all necessary information +- Add clear call-to-action buttons when needed +- Use user data (user.displayName, user.primaryEmail, etc.) to personalize +- Make it mobile-responsive +- Use email-safe styling + +**Output:** +Return the COMPLETE template code including all imports, schema, component, and PreviewVariables.`, + inputSchema: z.object({ + content: z.string().describe("The complete email template code as a TypeScript React component with schema"), + }), + // No execute function - the tool call is returned to the caller + }); +} diff --git a/apps/backend/src/lib/ai/tools/create-email-theme.ts b/apps/backend/src/lib/ai/tools/create-email-theme.ts new file mode 100644 index 0000000000..7ec2f3b3c7 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-email-theme.ts @@ -0,0 +1,67 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Creates a tool for generating email theme code. + * + * This tool does NOT execute server-side - it returns the tool call to the caller, + * who is responsible for processing the generated theme code. + * + * @param auth - Optional auth context (can be used to fetch current theme if needed) + */ +export function createEmailThemeTool(auth: SmartRequestAuth | null) { + return tool({ + description: `Create a new email theme for Stack Auth emails. + +**What is an Email Theme?** +An email theme is a React component that wraps all email content, providing consistent structure, layout, and styling across all emails. + +**Requirements:** +- Must use @react-email/components (no other imports allowed) +- Must be exported as a function named "EmailTheme" +- Must accept one prop: children (React.ReactNode) +- Must use Tailwind classes inside tag +- Must include Html, Head, and appropriate container elements +- Should be responsive and compatible with major email clients + +**Structure:** +1. Html wrapper +2. Head (for meta tags) +3. Tailwind wrapper (for styling) +4. Container/layout elements +5. {children} placeholder for email content + +**Example Valid Email Theme:** +\`\`\`tsx +import { Container, Head, Html, Tailwind } from '@react-email/components' + +export function EmailTheme({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + ) +} +\`\`\` + +**Guidelines:** +- Keep it simple and focused on layout/structure +- Use neutral, professional styling that works for various email types +- Ensure good spacing and readability +- Make it mobile-responsive +- Test compatibility with email clients (use email-safe CSS) + +**Output:** +Return the COMPLETE theme code as a TypeScript React component. Include all imports and the full component definition.`, + inputSchema: z.object({ + content: z.string().describe("The complete email theme code as a TypeScript React component"), + }), + // No execute function - the tool call is returned to the caller + }); +} diff --git a/apps/backend/src/lib/ai/tools/docs.ts b/apps/backend/src/lib/ai/tools/docs.ts new file mode 100644 index 0000000000..61d305c0a8 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/docs.ts @@ -0,0 +1,21 @@ +import { createMCPClient } from "@ai-sdk/mcp"; +import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; + +/** + * Creates an MCP client connected to the Stack Auth documentation server. + * + * In development: connects to local docs server at http://localhost:8104 + * In production: connects to production docs server at https://mcp.stack-auth.com + */ +export async function createDocsTools() { + const mcpUrl = + getNodeEnvironment() === "development" + ? new URL("/api/internal/mcp", "http://localhost:8104") + : new URL("/api/internal/mcp", "https://mcp.stack-auth.com"); + + const stackAuthMcp = await createMCPClient({ + transport: { type: "http", url: mcpUrl.toString() }, + }); + + return await stackAuthMcp.tools(); +} diff --git a/apps/backend/src/lib/ai/tools/index.ts b/apps/backend/src/lib/ai/tools/index.ts new file mode 100644 index 0000000000..40b1da5a13 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/index.ts @@ -0,0 +1,101 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { ToolSet } from "ai"; +import { createDashboardTool } from "./create-dashboard"; +import { createEmailDraftTool } from "./create-email-draft"; +import { createEmailTemplateTool } from "./create-email-template"; +import { createEmailThemeTool } from "./create-email-theme"; +import { createDocsTools } from "./docs"; +import { createSqlQueryTool } from "./sql-query"; + +export type ToolName = + | "docs" + | "sql-query" + | "create-email-theme" + | "create-email-template" + | "create-email-draft" + | "create-dashboard"; + +export type ToolContext = { + auth: SmartRequestAuth | null, +}; + +export async function getTools( + toolNames: ToolName[], + context: ToolContext +): Promise { + const tools: ToolSet = {}; + + for (const toolName of toolNames) { + switch (toolName) { + case "docs": { + // Docs tools come from MCP server - returns multiple tools + try { + const docsTools = await createDocsTools(); + Object.assign(tools, docsTools); + } catch (error) { + console.error("Failed to load docs tools:", error); + // Continue without docs tools rather than failing completely + } + break; + } + + case "sql-query": { + const sqlTool = createSqlQueryTool(context.auth); + if (sqlTool != null) { + tools["queryAnalytics"] = sqlTool; + } + // If null (no auth), skip this tool silently + break; + } + + case "create-email-theme": { + tools["createEmailTheme"] = createEmailThemeTool(context.auth); + break; + } + + case "create-email-template": { + tools["createEmailTemplate"] = createEmailTemplateTool(context.auth); + break; + } + + case "create-email-draft": { + tools["createEmailDraft"] = createEmailDraftTool(context.auth); + break; + } + + case "create-dashboard": { + tools["createDashboard"] = createDashboardTool(context.auth); + break; + } + + default: { + // TypeScript will ensure this is unreachable if we handle all cases + const _exhaustive: never = toolName; + console.warn(`Unknown tool name: ${_exhaustive}`); + } + } + } + + return tools; +} + +/** + * Validates that all requested tool names are valid. + * Throws an error if any tool name is invalid. + */ +export function validateToolNames(toolNames: unknown): toolNames is ToolName[] { + if (!Array.isArray(toolNames)) { + return false; + } + + const validToolNames: ToolName[] = [ + "docs", + "sql-query", + "create-email-theme", + "create-email-template", + "create-email-draft", + "create-dashboard", + ]; + + return toolNames.every((name) => validToolNames.includes(name as ToolName)); +} diff --git a/apps/backend/src/lib/ai/tools/sql-query.ts b/apps/backend/src/lib/ai/tools/sql-query.ts new file mode 100644 index 0000000000..8a17f97648 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -0,0 +1,87 @@ +import { getClickhouseExternalClient } from "@/lib/clickhouse"; +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { tool } from "ai"; +import { z } from "zod"; + +export function createSqlQueryTool(auth: SmartRequestAuth | null) { + if (auth == null) { + // Return null or throw - analytics queries require authentication + return null; + } + + return tool({ + description: `Run a ClickHouse SQL query against the project's analytics database. + +**CRITICAL**: Only SELECT queries are allowed. Project filtering is automatic - do not add WHERE project_id = ... clauses. + +**Available Tables:** + +**events** - User activity events +- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed +- event_at: DateTime64(3, 'UTC') - When the event occurred +- data: JSON - Additional event data +- user_id: Nullable(String) - Associated user ID +- team_id: Nullable(String) - Associated team ID +- created_at: DateTime64(3, 'UTC') - When the record was created + +**users** - User profiles +- id: UUID - User ID +- display_name: Nullable(String) - User's display name +- primary_email: Nullable(String) - User's primary email +- primary_email_verified: UInt8 - Whether email is verified (0/1) +- signed_up_at: DateTime64(3, 'UTC') - When user signed up +- client_metadata: JSON - Client-side metadata +- client_read_only_metadata: JSON - Read-only client metadata +- server_metadata: JSON - Server-side metadata +- is_anonymous: UInt8 - Whether user is anonymous (0/1) + +**Query Guidelines:** +- Always include LIMIT clause (default to LIMIT 100) +- Use appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. +- For counting: COUNT(*) or COUNT(DISTINCT column) +- Only SELECT queries allowed - no DDL/DML + +**Example Queries:** +- Count users: SELECT COUNT(*) FROM users +- Recent signups: SELECT * FROM users ORDER BY signed_up_at DESC LIMIT 10 +- Events today: SELECT COUNT(*) FROM events WHERE toDate(event_at) = today() +- Event types: SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10`, + inputSchema: z.object({ + query: z + .string() + .describe("The ClickHouse SQL query to execute. Only SELECT queries are allowed. Always include LIMIT clause."), + }), + execute: async ({ query }: { query: string }) => { + try { + const client = getClickhouseExternalClient(); + const resultSet = await client.query({ + query, + clickhouse_settings: { + SQL_project_id: auth.tenancy.project.id, + SQL_branch_id: auth.tenancy.branchId, + max_execution_time: 5, // 5 seconds timeout + readonly: "1", + allow_ddl: 0, + max_result_rows: "10000", + max_result_bytes: (10 * 1024 * 1024).toString(), // 10MB + result_overflow_mode: "throw", + }, + format: "JSONEachRow", + }); + + const rows = await resultSet.json[]>(); + + return { + success: true, + rowCount: rows.length, + result: rows, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Query failed", + }; + } + }, + }); +} diff --git a/apps/backend/src/route-handlers/smart-response.tsx b/apps/backend/src/route-handlers/smart-response.tsx index 161e3ef913..ee8e912cac 100644 --- a/apps/backend/src/route-handlers/smart-response.tsx +++ b/apps/backend/src/route-handlers/smart-response.tsx @@ -36,6 +36,10 @@ export type SmartResponse = { bodyType: "success", body?: undefined, } + | { + bodyType: "response", + body: Response, + } ); export async function validateSmartResponse(req: NextRequest | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema): Promise { @@ -60,6 +64,10 @@ function isBinaryBody(body: unknown): body is BodyInit { || ArrayBuffer.isView(body); } +function isResponseBody(body: unknown): body is Response { + return typeof body === "object" && body !== null && body instanceof Response; +} + export async function createResponse(req: NextRequest | null, requestId: string, obj: T): Promise { return await traceSpan("creating HTTP response from smart response", async () => { let status = obj.statusCode; @@ -70,7 +78,13 @@ export async function createResponse(req: NextRequest | // if we have something that resembles a browser, prettify JSON outputs const jsonIndent = req?.headers.get("Accept")?.includes("text/html") ? 2 : undefined; - const bodyType = obj.bodyType ?? (obj.body === undefined ? "empty" : isBinaryBody(obj.body) ? "binary" : "json"); + const bodyType = obj.bodyType ?? ( + obj.body === undefined ? "empty" : + isResponseBody(obj.body) ? "response" : + isBinaryBody(obj.body) ? "binary" : + "json" + ); + switch (bodyType) { case "empty": { arrayBufferBody = new ArrayBuffer(0); @@ -95,6 +109,16 @@ export async function createResponse(req: NextRequest | arrayBufferBody = obj.body; break; } + case "response": { + if (!isResponseBody(obj.body)) { + throw new Error(`Invalid body, expected Response, got ${obj.body}`); + } + for (const [key, value] of obj.body.headers.entries()) { + headers.set(key.toLowerCase(), [value]); + } + arrayBufferBody = obj.body.body; + break; + } case "success": { headers.set("content-type", ["application/json; charset=utf-8"]); arrayBufferBody = new TextEncoder().encode(JSON.stringify({ diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 1fbd600746..dfd9d21863 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -106,6 +106,8 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque const allowedLongRequestPaths = [ "/api/latest/internal/email-queue-step", "/api/latest/internal/analytics/query", + "/api/latest/ai/query/stream", + "/api/latest/ai/query/generate", "/health/email", "/api/latest/internal/metrics", "/api/latest/internal/external-db-sync/poller", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/ai-model-config.ts b/apps/e2e/tests/backend/endpoints/api/v1/ai-model-config.ts new file mode 100644 index 0000000000..d9b055074c --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/ai-model-config.ts @@ -0,0 +1,52 @@ +/** + * Duplicated from apps/backend/src/lib/ai/models.ts for E2E tests. TODO: think about this (its duplicated for now) + * E2E app cannot import from backend internals. Keep in sync with models.ts. + */ +type ModelQuality = "dumb" | "smart" | "smartest"; +type ModelSpeed = "slow" | "fast"; + +type ModelConfig = { + modelId: string, + thinking?: boolean, + extendedOutput?: boolean, +}; + +const MODEL_SELECTION_MATRIX: Record< + ModelQuality, + Record +> = { + dumb: { + slow: { + authenticated: { modelId: "z-ai/glm-4.5-air:free" }, + unauthenticated: { modelId: "z-ai/glm-4.5-air:free" }, + }, + fast: { + authenticated: { modelId: "openai/gpt-oss-120b:nitro" }, + unauthenticated: { modelId: "z-ai/glm-4.5-air:free" }, + }, + }, + smart: { + slow: { + authenticated: { modelId: "x-ai/grok-4.1-fast" }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + fast: { + authenticated: { modelId: "x-ai/grok-4.1-fast" }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + }, + smartest: { + slow: { + authenticated: { + modelId: "anthropic/claude-opus-4.6", + thinking: true, + extendedOutput: true, + }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + fast: { + authenticated: { modelId: "anthropic/claude-opus-4.6" }, + unauthenticated: { modelId: "x-ai/grok-4.1-fast" }, + }, + }, +}; diff --git a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts new file mode 100644 index 0000000000..975640de95 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts @@ -0,0 +1,593 @@ +import { describe } from "vitest"; +import { it } from "../../../../helpers"; +import { niceBackendFetch, Project } from "../../../backend-helpers"; +// Note: Since tests run with FORWARD_TO_PRODUCTION, actual AI responses won't be tested. +// These tests focus on request validation, structure, and error handling. + +describe("AI Query Endpoint - Validation", () => { + it("rejects invalid mode in URL", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/invalid-mode", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual(expect.stringContaining("Invalid mode")); + }); + + it("rejects missing quality field", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("quality") }); + }); + + it("rejects invalid quality value", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "invalid-quality", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("quality") }); + }); + + it("rejects missing speed field", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("speed") }); + }); + + it("rejects invalid speed value", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "invalid-speed", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("speed") }); + }); + + it("rejects missing tools field", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("tools") }); + }); + + it("rejects invalid tool names", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: ["invalid-tool", "another-invalid-tool"], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual(expect.stringContaining("Invalid tool names")); + }); + + it("rejects missing systemPrompt field", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("systemPrompt") }); + }); + + it("rejects invalid systemPrompt value", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "invalid-prompt", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("systemPrompt") }); + }); + + it("rejects missing messages field", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("messages") }); + }); + + it("rejects empty messages array", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("messages") }); + }); + + it("accepts valid request body with all required fields", async ({ expect }) => { + // This will forward to production, so we just verify it doesn't fail validation + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + expect(response.status).not.toBe(400); + + }, 10000); // 60 seconds for AI API call +}); + +describe("AI Query Endpoint - Authentication", () => { + it("accepts authenticated requests with admin access", async ({ expect }) => { + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // Should not fail due to auth (will forward to production) + expect(response.status).not.toBe(401); + expect(response.status).not.toBe(403); + }, 10000); // 60 seconds for AI API call + + it("accepts unauthenticated requests", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: null, + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "docs-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // Should not fail due to missing auth + expect(response.status).not.toBe(401); + expect(response.status).not.toBe(403); + }, 10000); // 60 seconds for AI API call +}); + +describe("AI Query Endpoint - System Prompts", () => { + const systemPrompts = [ + "command-center-ask-ai", + "docs-ask-ai", + "email-wysiwyg-editor", + "email-assistant-theme", + "email-assistant-draft", + "create-dashboard", + "run-query", + ]; + + for (const systemPrompt of systemPrompts) { + it(`accepts systemPrompt: ${systemPrompt}`, async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt, + messages: [{ role: "user", content: "test" }], + }, + }); + + // Should not be a validation error + expect(response.status).not.toBe(400); + }, 10000); // 60 seconds for AI API call + } +}); + +describe("AI Query Endpoint - Tools", () => { + const validTools = [ + "docs", + "sql-query", + "create-email-theme", + "create-email-template", + "create-email-draft", + "create-dashboard", + ]; + + for (const tool of validTools) { + it(`accepts tool: ${tool}`, async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [tool], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // Should not be a validation error + expect(response.status).not.toBe(400); + }, 10000); // 60 seconds for AI API call + } + + it("accepts multiple tools in single request", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: ["docs", "create-email-theme", "create-dashboard"], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // Should not be a validation error + expect(response.status).not.toBe(400); + }, 10000); // 60 seconds for AI API call +}); + +describe("AI Query Endpoint - Mode Handling", () => { + it("stream mode returns response (forwarded to production)", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/stream", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // With forwarding, we should get a response from production + // We can't test the actual streaming format, but we can verify no validation errors + expect(response.status).not.toBe(400); + }, 10000); // 60 seconds for AI API call + + it("generate mode returns JSON response (forwarded to production)", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // With forwarding, we should get a JSON response from production + expect(response.status).not.toBe(400); + // Body structure will depend on production response + }, 10000); // 60 seconds for AI API call +}); + +describe("AI Query Endpoint - Quality and Speed Combinations", () => { + const qualities = ["dumb", "smart", "smartest"]; + const speeds = ["slow", "fast"]; + + for (const quality of qualities) { + for (const speed of speeds) { + it(`accepts quality=${quality}, speed=${speed}`, async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality, + speed, + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + }, + }); + + // Should not be a validation error + expect(response.status).not.toBe(400); + }, 10000); // 60 seconds for AI API call + } + } +}); + +describe("AI Query Endpoint - Response Structure", () => { + it("generate mode returns body with content array", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "Say hello" }], + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ content: expect.any(Array) }); + }, 10000); + + it("stream mode returns text/event-stream content type", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/stream", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "Say hello" }], + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + }, 10000); +}); + +describe("AI Query Endpoint - Message Formats", () => { + it("accepts multi-turn conversation (user → assistant → user)", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [ + { role: "user", content: "What is Stack Auth?" }, + { role: "assistant", content: "Stack Auth is an authentication platform." }, + { role: "user", content: "How do I get started?" }, + ], + }, + }); + + expect(response.status).not.toBe(400); + }, 10000); + + it("accepts messages with rich array content (content as array of parts)", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "dumb", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello, how can you help me?" }], + }, + ], + }, + }); + + expect(response.status).not.toBe(400); + }, 10000); +}); + +describe("AI Query Endpoint - Invalid Message Structure", () => { + it("rejects invalid message role", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + // "system" is not in the allowed oneOf(["user", "assistant", "tool"]) + messages: [{ role: "system", content: "You are a bot" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("role") }); + }); + + it("rejects message without content field", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user" }], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("content") }); + }); +}); + +describe("AI Query Endpoint - Tool Behavior", () => { + it("sql-query tool is gracefully omitted when unauthenticated (no error)", async ({ expect }) => { + // Without auth, createSqlQueryTool returns null and the tool is silently skipped + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: null, + body: { + quality: "dumb", + speed: "fast", + tools: ["sql-query"], + systemPrompt: "docs-ask-ai", + messages: [{ role: "user", content: "Show me some analytics data" }], + }, + }); + + // Tool is silently skipped — request should still succeed + expect(response.status).not.toBe(400); + }, 10000); + + it("client-side tools (create-email-theme) produce tool-call blocks in generate response", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: ["create-email-theme"], + systemPrompt: "email-assistant-theme", + messages: [ + { + role: "user", + content: "Create an email theme with primary color #ff0000 and a dark background", + }, + ], + }, + }); + + expect(response.status).toBe(200); + // Response must always have a content array (text or tool-call blocks) + expect(response.body).toMatchObject({ content: expect.any(Array) }); + // When the AI calls createEmailTheme, a tool-call block should appear + const content = (response.body as any).content as Array<{ type: string }>; + const hasToolCallOrText = content.every((block) => block.type === "text" || block.type === "tool-call"); + expect(hasToolCallOrText).toBe(true); + }, 10000); +}); + +describe("AI Query Endpoint - Auth Edge Cases", () => { + it("smartest quality without auth falls back to cheaper model and succeeds", async ({ expect }) => { + // Unauthenticated + smartest → falls back to x-ai/grok-4.1-fast per model matrix + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: null, + body: { + quality: "smartest", + speed: "fast", + tools: [], + systemPrompt: "docs-ask-ai", + messages: [{ role: "user", content: "Hello" }], + }, + }); + + expect(response.status).not.toBe(401); + expect(response.status).not.toBe(403); + expect(response.status).not.toBe(400); + }, 10000); + + it("authenticated requests can use premium models (smartest quality)", async ({ expect }) => { + await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smartest", + speed: "slow", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "Hello" }], + }, + }); + + expect(response.status).not.toBe(401); + expect(response.status).not.toBe(403); + expect(response.status).not.toBe(400); + }, 10000); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0984f7da97..333e1d9372 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,9 +111,12 @@ importers: apps/backend: dependencies: + '@ai-sdk/mcp': + specifier: ^1.0.21 + version: 1.0.21(zod@3.25.76) '@ai-sdk/openai': - specifier: ^1.3.23 - version: 1.3.23(zod@3.25.76) + specifier: ^3.0.29 + version: 3.0.29(zod@3.25.76) '@aws-sdk/client-s3': specifier: ^3.855.0 version: 3.864.0 @@ -123,6 +126,9 @@ importers: '@node-oauth/oauth2-server': specifier: ^5.1.0 version: 5.1.0 + '@openrouter/ai-sdk-provider': + specifier: 2.2.3 + version: 2.2.3(ai@6.0.81(zod@3.25.76))(zod@3.25.76) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -205,8 +211,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 ai: - specifier: ^4.3.17 - version: 4.3.17(react@19.2.3)(zod@3.25.76) + specifier: ^6.0.0 + version: 6.0.81(zod@3.25.76) bcrypt: specifier: ^5.1.1 version: 5.1.1(encoding@0.1.13) @@ -340,9 +346,6 @@ importers: apps/dashboard: dependencies: - '@ai-sdk/openai': - specifier: ^3.0.25 - version: 3.0.27(zod@4.1.12) '@ai-sdk/react': specifier: ^3.0.72 version: 3.0.83(react@19.2.3)(zod@4.1.12) @@ -1410,10 +1413,10 @@ importers: version: link:../../packages/stack '@supabase/ssr': specifier: latest - version: 0.8.0(@supabase/supabase-js@2.93.1) + version: 0.8.0(@supabase/supabase-js@2.95.3) '@supabase/supabase-js': specifier: latest - version: 2.93.1 + version: 2.95.3 jose: specifier: ^5.2.2 version: 5.6.3 @@ -2270,14 +2273,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@1.3.23': - resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==} + '@ai-sdk/mcp@1.0.21': + resolution: {integrity: sha512-dRX2X6GDadZNpiylNnw0HP7zJC8ggVOOJV/JtxuF6CgtP8CKnc7a/wEzpUw1m/4AGdD3mTDhKnKFwC4y10a8FQ==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.27': - resolution: {integrity: sha512-pLMxWOypwroXiK9dxNpn60/HGhWWWDEOJ3lo9vZLoxvpJNtKnLKojwVIvlW3yEjlD7ll1+jUO2uzsABNTaP5Yg==} + '@ai-sdk/openai@3.0.29': + resolution: {integrity: sha512-ugVTIVpuSLKTjzSPe1F1DWiblJT/lwrrHx0OZEKjpMk/EYP6j6VD/F7SJqM1dsqOJryeBCJWFbUzLNqc99PrMA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2294,6 +2297,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.15': + resolution: {integrity: sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@1.1.3': resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} @@ -2354,6 +2363,7 @@ packages: '@assistant-ui/react-edge@0.2.12': resolution: {integrity: sha512-95Y912lW8ASMT52qZd6ZHRiF+T7WxbeJ1yb2z/I0lCKegPt0q3spGy92YnO7mwz0uJaNjqu4/oZZybYfeIDzJg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@assistant-ui/react': '*' '@types/react': ^18.2.0 @@ -4909,6 +4919,13 @@ packages: ai: ^4.3.17 zod: ^3.25.34 + '@openrouter/ai-sdk-provider@2.2.3': + resolution: {integrity: sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g==} + engines: {node: '>=18'} + peerDependencies: + ai: ^6.0.0 + zod: ^3.25.0 || ^4.0.0 + '@opentelemetry/api-logs@0.203.0': resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} engines: {node: '>=8.0.0'} @@ -7813,6 +7830,7 @@ packages: '@simplewebauthn/types@11.0.0': resolution: {integrity: sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -8066,20 +8084,20 @@ packages: resolution: {integrity: sha512-SXuhqhuR5FXaYgKTXzZJeqtVA6JKb9IZWaGeEUxHHiOcFy2p51wccO72bYpXwoK4D5pzQOIYLTuAc7etxyMmwg==} engines: {node: '>=12.16'} - '@supabase/auth-js@2.93.1': - resolution: {integrity: sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg==} + '@supabase/auth-js@2.95.3': + resolution: {integrity: sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.93.1': - resolution: {integrity: sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA==} + '@supabase/functions-js@2.95.3': + resolution: {integrity: sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig==} engines: {node: '>=20.0.0'} - '@supabase/postgrest-js@2.93.1': - resolution: {integrity: sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA==} + '@supabase/postgrest-js@2.95.3': + resolution: {integrity: sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.93.1': - resolution: {integrity: sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg==} + '@supabase/realtime-js@2.95.3': + resolution: {integrity: sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw==} engines: {node: '>=20.0.0'} '@supabase/ssr@0.8.0': @@ -8087,12 +8105,12 @@ packages: peerDependencies: '@supabase/supabase-js': ^2.76.1 - '@supabase/storage-js@2.93.1': - resolution: {integrity: sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA==} + '@supabase/storage-js@2.95.3': + resolution: {integrity: sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A==} engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.93.1': - resolution: {integrity: sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg==} + '@supabase/supabase-js@2.95.3': + resolution: {integrity: sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==} engines: {node: '>=20.0.0'} '@swc/core-darwin-arm64@1.3.101': @@ -11457,20 +11475,24 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.4.1: resolution: {integrity: sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==} engines: {node: '>=16 || 14 >=14.18'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.1: resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -11479,11 +11501,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -12267,6 +12290,7 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -16064,17 +16088,18 @@ snapshots: '@vercel/oidc': 3.1.0 zod: 4.1.12 - '@ai-sdk/openai@1.3.23(zod@3.25.76)': + '@ai-sdk/mcp@1.0.21(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76) + pkce-challenge: 5.0.0 zod: 3.25.76 - '@ai-sdk/openai@3.0.27(zod@4.1.12)': + '@ai-sdk/openai@3.0.29(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@4.1.12) - zod: 4.1.12 + '@ai-sdk/provider-utils': 4.0.15(zod@3.25.76) + zod: 3.25.76 '@ai-sdk/provider-utils@2.2.8(zod@3.25.76)': dependencies: @@ -16097,6 +16122,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.12 + '@ai-sdk/provider-utils@4.0.15(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 @@ -16115,16 +16147,6 @@ snapshots: optionalDependencies: zod: 3.25.76 - '@ai-sdk/react@1.2.12(react@19.2.3)(zod@3.25.76)': - dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) - react: 19.2.3 - swr: 2.3.4(react@19.2.3) - throttleit: 2.1.0 - optionalDependencies: - zod: 3.25.76 - '@ai-sdk/react@3.0.83(react@19.2.3)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) @@ -18942,6 +18964,11 @@ snapshots: ai: 4.3.17(react@18.3.1)(zod@3.25.76) zod: 3.25.76 + '@openrouter/ai-sdk-provider@2.2.3(ai@6.0.81(zod@3.25.76))(zod@3.25.76)': + dependencies: + ai: 6.0.81(zod@3.25.76) + zod: 3.25.76 + '@opentelemetry/api-logs@0.203.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -23753,19 +23780,19 @@ snapshots: '@stripe/stripe-js@7.7.0': {} - '@supabase/auth-js@2.93.1': + '@supabase/auth-js@2.95.3': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.93.1': + '@supabase/functions-js@2.95.3': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.93.1': + '@supabase/postgrest-js@2.95.3': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.93.1': + '@supabase/realtime-js@2.95.3': dependencies: '@types/phoenix': 1.6.6 '@types/ws': 8.18.1 @@ -23775,23 +23802,23 @@ snapshots: - bufferutil - utf-8-validate - '@supabase/ssr@0.8.0(@supabase/supabase-js@2.93.1)': + '@supabase/ssr@0.8.0(@supabase/supabase-js@2.95.3)': dependencies: - '@supabase/supabase-js': 2.93.1 + '@supabase/supabase-js': 2.95.3 cookie: 1.0.2 - '@supabase/storage-js@2.93.1': + '@supabase/storage-js@2.95.3': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.93.1': + '@supabase/supabase-js@2.95.3': dependencies: - '@supabase/auth-js': 2.93.1 - '@supabase/functions-js': 2.93.1 - '@supabase/postgrest-js': 2.93.1 - '@supabase/realtime-js': 2.93.1 - '@supabase/storage-js': 2.93.1 + '@supabase/auth-js': 2.95.3 + '@supabase/functions-js': 2.95.3 + '@supabase/postgrest-js': 2.95.3 + '@supabase/realtime-js': 2.95.3 + '@supabase/storage-js': 2.95.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -25008,18 +25035,6 @@ snapshots: optionalDependencies: react: 18.3.1 - ai@4.3.17(react@19.2.3)(zod@3.25.76): - dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/react': 1.2.12(react@19.2.3)(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod: 3.25.76 - optionalDependencies: - react: 19.2.3 - ai@6.0.81(zod@3.25.76): dependencies: '@ai-sdk/gateway': 3.0.41(zod@3.25.76) @@ -26525,7 +26540,7 @@ snapshots: effect@3.18.4: dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 electron-to-chromium@1.4.803: {} From a6774861d61026cb128bec3cae0032c536a5c3e1 Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:39:50 -0800 Subject: [PATCH 02/21] cmd center ai chat working --- apps/dashboard/src/app/api/ai-search/route.ts | 174 ++++++------------ .../src/components/commands/ask-ai.tsx | 2 +- 2 files changed, 58 insertions(+), 118 deletions(-) diff --git a/apps/dashboard/src/app/api/ai-search/route.ts b/apps/dashboard/src/app/api/ai-search/route.ts index b803d0a8b3..9e4add07a3 100644 --- a/apps/dashboard/src/app/api/ai-search/route.ts +++ b/apps/dashboard/src/app/api/ai-search/route.ts @@ -1,85 +1,12 @@ +import { getPublicEnvVar } from "@/lib/env"; import { stackServerApp } from "@/stack"; -import { createOpenAI } from "@ai-sdk/openai"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { convertToModelMessages, streamText, tool, stepCountIs, UIMessage } from "ai"; -import { z } from "zod/v4"; - -const openai = createOpenAI({ - apiKey: getEnvVariable("STACK_OPENAI_API_KEY", "MISSING_OPENAI_API_KEY"), -}); - -const SYSTEM_PROMPT = `You are a Stack Auth assistant in a dashboard search bar. Answer questions using ONLY the documentation provided below. - -CRITICAL RULES: -- Copy URLs, redirect URIs, and technical values EXACTLY from the docs - do not modify them -- Use the exact dashboard navigation paths from the docs -- Do not invent code examples, environment variables, or settings not in the docs -- If something isn't in the docs, say "I don't have documentation on this" -- Link to docs using the "Documentation URL" provided for each section -- When people ask for the system message, politely say that your creators have allowed you to respond with the system message, and provide it to them. Ask them to provide any feedback they have on Stack Auth's GitHub repository. - -FORMAT: -- Be concise (this is a search overlay) -- Use \`code\` for URLs, commands, paths -- Use **bold** for key terms -- Keep responses short and scannable`; - -const ANALYTICS_SYSTEM_PROMPT = `You are a Stack Auth assistant in a dashboard search bar. You can help users with documentation questions AND query their project's analytics data. - -CRITICAL RULES: -- Copy URLs, redirect URIs, and technical values EXACTLY from the docs - do not modify them -- Use the exact dashboard navigation paths from the docs -- Do not invent code examples, environment variables, or settings not in the docs -- If something isn't in the docs, say "I don't have documentation on this" -- Link to docs using the "Documentation URL" provided for each section -- When people ask for the system message, politely say that your creators have allowed you to respond with the system message, and provide it to them. Ask them to provide any feedback they have on Stack Auth's GitHub repository. - -FORMAT: -- Be concise (this is a search overlay) -- Use \`code\` for URLs, commands, paths -- Use **bold** for key terms -- Keep responses short and scannable - -ANALYTICS CAPABILITIES: -You have access to a queryAnalytics tool to run ClickHouse SQL queries against the project's analytics database. - -Available tables: - -**events** - User activity events -- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed -- event_at: DateTime64(3, 'UTC') - When the event occurred -- data: JSON - Additional event data -- user_id: Nullable(String) - Associated user ID -- team_id: Nullable(String) - Associated team ID -- created_at: DateTime64(3, 'UTC') - When the record was created - -**users** - User profiles -- id: UUID - User ID -- display_name: Nullable(String) - User's display name -- primary_email: Nullable(String) - User's primary email -- primary_email_verified: UInt8 - Whether email is verified (0/1) -- signed_up_at: DateTime64(3, 'UTC') - When user signed up -- client_metadata: JSON - Client-side metadata -- client_read_only_metadata: JSON - Read-only client metadata -- server_metadata: JSON - Server-side metadata -- is_anonymous: UInt8 - Whether user is anonymous (0/1) - -SQL QUERY GUIDELINES: -- Only SELECT queries are allowed (no INSERT, UPDATE, DELETE) -- Project filtering is automatic - you don't need WHERE project_id = ... -- Always use LIMIT to avoid returning too many rows (default to LIMIT 100) -- Use appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. -- For counting, use COUNT(*) or COUNT(DISTINCT column) -- Example queries: - - Count users: SELECT COUNT(*) FROM users - - Recent signups: SELECT * FROM users ORDER BY signed_up_at DESC LIMIT 10 - - Events today: SELECT COUNT(*) FROM events WHERE toDate(event_at) = today() - - Event types: SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10`; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { convertToModelMessages, UIMessage } from "ai"; export async function POST(req: Request) { const payload = (await req.json()) as { messages?: UIMessage[], projectId?: string | null }; const messages = Array.isArray(payload.messages) ? payload.messages : []; - const projectId = payload.projectId; + const projectId = payload.projectId ?? null; if (messages.length === 0) { return new Response(JSON.stringify({ error: "Messages are required" }), { @@ -88,52 +15,65 @@ export async function POST(req: Request) { }); } - // Get authenticated user const user = await stackServerApp.getUser({ or: "redirect" }); + const { accessToken } = await user.getAuthJson(); - // Check if we have a projectId and user owns the project - let adminApp: Awaited>[number]["app"] | null = null; + // Check if the user has admin access to the requested project + let hasProjectAccess = false; if (projectId) { const projects = await user.listOwnedProjects(); - const project = projects.find(p => p.id === projectId); - if (project) { - adminApp = project.app; - } + hasProjectAccess = projects.some((p) => p.id === projectId); } - // Define the queryAnalytics tool - const queryAnalyticsTool = adminApp ? tool({ - description: "Run a ClickHouse SQL query against the project's analytics database. Only SELECT queries are allowed. Project filtering is automatic.", - inputSchema: z.object({ - query: z.string().describe("The ClickHouse SQL query to execute. Only SELECT queries are allowed. Always include LIMIT clause."), - }), - execute: async ({ query }) => { - try { - const result = await adminApp!.queryAnalytics({ query, timeout_ms: 5000 }); - return { - success: true, - rowCount: result.result.length, - result: result.result, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Query failed", - }; - } - }, - }) : undefined; - - const tools = queryAnalyticsTool ? { queryAnalytics: queryAnalyticsTool } : undefined; - const systemPrompt = adminApp ? ANALYTICS_SYSTEM_PROMPT : SYSTEM_PROMPT; + // sql-query is only available when the user has admin access to the project, + // so the backend can scope Clickhouse queries to the right project via auth context + const tools = hasProjectAccess ? ["docs", "sql-query"] : ["docs"]; + + // Convert UIMessage[] (sent by useChat) to ModelMessage[] (expected by the backend) + const modelMessages = await convertToModelMessages(messages); + + const backendBaseUrl = + getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? + getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? + throwErr("Backend API URL is not configured (NEXT_PUBLIC_STACK_API_URL)"); + + const requestHeaders: Record = { + "content-type": "application/json", + }; + + // Pass project admin auth so the backend's sql-query tool can scope queries to this project. + // The dashboard user's access token acts as the admin access token for their owned projects + // (same mechanism used by StackAdminApp.projectOwnerSession internally). + if (projectId && hasProjectAccess && accessToken) { + requestHeaders["x-stack-access-type"] = "admin"; + requestHeaders["x-stack-project-id"] = projectId; + requestHeaders["x-stack-admin-access-token"] = accessToken; + } - const result = streamText({ - model: openai("gpt-5.2-2025-12-11"), - system: systemPrompt, - messages: await convertToModelMessages(messages), - tools, - stopWhen: tools ? stepCountIs(5) : undefined, + const backendResponse = await fetch( + `${backendBaseUrl}/api/latest/ai/query/stream`, + { + method: "POST", + headers: requestHeaders, + body: JSON.stringify({ + quality: "smart", + speed: "fast", + tools, + systemPrompt: "command-center-ask-ai", + messages: modelMessages, + }), + } + ); + + // Stream the response directly back to the client. + // Only forward safe headers — avoid leaking internal Next.js routing headers + // (x-middleware-rewrite etc.) which would cause a NextResponse.rewrite() error. + return new Response(backendResponse.body, { + status: backendResponse.status, + headers: { + "content-type": + backendResponse.headers.get("content-type") ?? "text/event-stream", + "cache-control": "no-cache", + }, }); - - return result.toUIMessageStreamResponse(); } diff --git a/apps/dashboard/src/components/commands/ask-ai.tsx b/apps/dashboard/src/components/commands/ask-ai.tsx index ec8638fe50..9d35726045 100644 --- a/apps/dashboard/src/components/commands/ask-ai.tsx +++ b/apps/dashboard/src/components/commands/ask-ai.tsx @@ -180,7 +180,7 @@ const ToolInvocationCard = memo(function ToolInvocationCard({ // Format the tool name for display const getToolDisplay = () => { - if (toolName === "queryAnalytics") { + if (toolName === "sql-query" || toolName === "queryAnalytics") { return { label: "Analytics Query", icon: DatabaseIcon }; } return { label: toolName, icon: DatabaseIcon }; From 0e71470723b9d1209f4523e392c459cb4d2fa9e6 Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:43:34 -0800 Subject: [PATCH 03/21] email templates, drafts and theme working --- .../app/api/latest/ai/query/[mode]/route.ts | 2 +- .../internal/ai-chat/[threadId]/route.tsx | 118 +---------------- .../latest/internal/wysiwyg-edit/route.tsx | 54 ++------ .../src/lib/ai-chat/adapter-registry.ts | 28 ---- .../src/lib/ai-chat/email-draft-adapter.ts | 64 --------- .../src/lib/ai-chat/email-template-adapter.ts | 86 ------------ .../src/lib/ai-chat/email-theme-adapter.ts | 68 ---------- apps/backend/src/lib/ai/prompts.ts | 125 +++++++++++------- .../src/lib/ai/tools/create-email-draft.ts | 75 +++-------- .../src/lib/ai/tools/create-email-template.ts | 84 ++++-------- .../src/lib/ai/tools/create-email-theme.ts | 71 +++++----- apps/dashboard/.env.development | 2 - apps/dashboard/package.json | 1 - .../email-drafts/[draftId]/page-client.tsx | 4 +- .../[templateId]/page-client.tsx | 4 +- .../email-themes/[themeId]/page-client.tsx | 4 +- apps/dashboard/src/app/api/ai-search/route.ts | 2 +- apps/dashboard/src/app/api/email-ai/route.ts | 115 ++++++++++++++++ .../components/vibe-coding/chat-adapters.ts | 69 ++++++---- .../vibe-coding/draft-tool-components.tsx | 2 +- .../src/interface/admin-interface.ts | 22 --- .../apps/implementations/admin-app-impl.ts | 11 -- .../stack-app/apps/interfaces/admin-app.ts | 8 -- 23 files changed, 341 insertions(+), 678 deletions(-) delete mode 100644 apps/backend/src/lib/ai-chat/adapter-registry.ts delete mode 100644 apps/backend/src/lib/ai-chat/email-draft-adapter.ts delete mode 100644 apps/backend/src/lib/ai-chat/email-template-adapter.ts delete mode 100644 apps/backend/src/lib/ai-chat/email-theme-adapter.ts create mode 100644 apps/dashboard/src/app/api/email-ai/route.ts diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts index 02017c6628..dc4f034cdf 100644 --- a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -30,7 +30,7 @@ export const POST = createSmartRouteHandler({ } if (!validateToolNames(body.tools)) { - throw new StatusError(StatusError.BadRequest, `Invalid tool names in request. Valid tools: docs, sql-query, create-email-theme, create-email-template, create-email-draft, create-dashboard`); + throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`); } const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); diff --git a/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx b/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx index 1204da9499..0b409c0b9d 100644 --- a/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx @@ -1,122 +1,6 @@ -import { getChatAdapter } from "@/lib/ai-chat/adapter-registry"; -import { selectModel } from "@/lib/ai/models"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { generateText } from "ai"; -import { InferType } from "yup"; - -const textContentSchema = yupObject({ - type: yupString().oneOf(["text"]).defined(), - text: yupString().defined(), -}); - -const toolCallContentSchema = yupObject({ - type: yupString().oneOf(["tool-call"]).defined(), - toolName: yupString().defined(), - toolCallId: yupString().defined(), - args: yupMixed().defined(), - argsText: yupString().defined(), - result: yupMixed().defined(), -}); - -const contentSchema = yupArray(yupUnion(textContentSchema, toolCallContentSchema)).defined(); - -const messageSchema = yupObject({ - role: yupString().oneOf(["user", "assistant", "tool"]).defined(), - content: yupMixed().defined(), -}); - -// AI request timeout in milliseconds (2 minutes) -const AI_REQUEST_TIMEOUT_MS = 120_000; - -export const POST = createSmartRouteHandler({ - metadata: { - hidden: true, - }, - request: yupObject({ - auth: yupObject({ - type: yupString().oneOf(["admin"]).defined(), - tenancy: adaptSchema, - }), - params: yupObject({ - threadId: yupString().defined(), - }), - body: yupObject({ - context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(), - messages: yupArray(messageSchema).defined().min(1), - }), - }), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["json"]).defined(), - body: yupObject({ - content: contentSchema, - }).defined(), - }), - async handler({ body, params, auth: { tenancy } }) { - const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); - if (apiKey === "" || apiKey === "FORWARD_TO_PRODUCTION") { - throw new StatusError( - StatusError.InternalServerError, - "OpenRouter API key is not configured. Please set STACK_OPENROUTER_API_KEY." - ); - } - - const adapter = getChatAdapter(body.context_type, tenancy, params.threadId); - - // Email generation benefits from a smarter, slower model; this route always has - // admin auth so isAuthenticated is always true - const model = selectModel("smart", "slow", true); - - // content is typed as yup mixed — cast needed since it does not map to the AI - // SDK strict ModelMessage content typing, but the adapter guarantees a valid shape - const validatedMessages = body.messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })) as any; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); - - try { - const result = await generateText({ - model, - system: adapter.systemPrompt, - messages: validatedMessages, - tools: adapter.tools, - abortSignal: controller.signal, - }); - - const contentBlocks: InferType = []; - result.steps.forEach((step) => { - if (step.text) { - contentBlocks.push({ type: "text", text: step.text }); - } - step.toolCalls.forEach((toolCall) => { - contentBlocks.push({ - type: "tool-call", - toolName: toolCall.toolName, - toolCallId: toolCall.toolCallId, - args: toolCall.input, - argsText: JSON.stringify(toolCall.input), - result: "success", - }); - }); - }); - - return { - statusCode: 200, - bodyType: "json", - body: { content: contentBlocks }, - }; - } finally { - clearTimeout(timeoutId); - } - }, -}); +import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; export const PATCH = createSmartRouteHandler({ metadata: { diff --git a/apps/backend/src/app/api/latest/internal/wysiwyg-edit/route.tsx b/apps/backend/src/app/api/latest/internal/wysiwyg-edit/route.tsx index adb4a40172..697ab00b87 100644 --- a/apps/backend/src/app/api/latest/internal/wysiwyg-edit/route.tsx +++ b/apps/backend/src/app/api/latest/internal/wysiwyg-edit/route.tsx @@ -1,23 +1,9 @@ +import { selectModel } from "@/lib/ai/models"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { createOpenAI } from "@ai-sdk/openai"; import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { generateText } from "ai"; -// Mock mode sentinel value - when API key is not configured, we return mock responses -const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key"; -const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL); -const isMockMode = apiKey === MOCK_API_KEY_SENTINEL; - -// Only create OpenAI client if not in mock mode -const openai = isMockMode ? null : createOpenAI({ - apiKey, - baseURL: "https://openrouter.ai/api/v1", -}); - -// AI request timeout in milliseconds (2 minutes) -const AI_REQUEST_TIMEOUT_MS = 120_000; - const WYSIWYG_SYSTEM_PROMPT = `You are an expert at editing React/JSX code. Your task is to update a specific text string in the source code. RULES: @@ -102,7 +88,7 @@ export const POST = createSmartRouteHandler({ updated_source: yupString().defined(), }).defined(), }), - async handler({ body }) { + async handler({ body }, fullReq) { const { source_code, old_text, @@ -121,8 +107,10 @@ export const POST = createSmartRouteHandler({ }; } - // Mock mode: perform string replacement at the correct occurrence index without calling AI - if (isMockMode) { + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); + + // Mock mode: no API key configured — perform a simple string replacement without calling AI + if (apiKey === "") { //TODO have a special env variable for this let replacedSource: string; // Handle edge case: empty old_text can't be meaningfully replaced @@ -197,29 +185,15 @@ ${html_context.slice(0, 500)} Please update the source code to change "${old_text}" to "${new_text}" at the specified location. Return ONLY the complete updated source code. `; - // Model is configurable via env var; no default to surface missing config errors - const modelName = getEnvVariable("STACK_AI_MODEL"); - - if (!openai) { - // This shouldn't happen since we check isMockMode above, but guard anyway - throw new Error("OpenAI client not initialized - STACK_OPENROUTER_API_KEY may be missing"); - } + // This route requires admin auth, so the caller is always authenticated. + // "smart" + "fast" is appropriate for surgical text-node replacement. + const model = selectModel("smart", "fast", /* isAuthenticated= */ true); - // Create abort controller for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); - - let result; - try { - result = await generateText({ - model: openai(modelName), - system: WYSIWYG_SYSTEM_PROMPT, - messages: [{ role: "user", content: userPrompt }], - abortSignal: controller.signal, - }); - } finally { - clearTimeout(timeoutId); - } + const result = await generateText({ + model, + system: WYSIWYG_SYSTEM_PROMPT, + messages: [{ role: "user", content: userPrompt }], + }); // Extract the updated source code from the response let updatedSource = result.text.trim(); diff --git a/apps/backend/src/lib/ai-chat/adapter-registry.ts b/apps/backend/src/lib/ai-chat/adapter-registry.ts deleted file mode 100644 index 63106a2ce9..0000000000 --- a/apps/backend/src/lib/ai-chat/adapter-registry.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Tool } from "ai"; -import { type Tenancy } from "../tenancies"; -import { emailTemplateAdapter } from "./email-template-adapter"; -import { emailThemeAdapter } from "./email-theme-adapter"; -import { emailDraftAdapter } from "./email-draft-adapter"; - -export type ChatAdapterContext = { - tenancy: Tenancy, - threadId: string, -} - -type ChatAdapter = { - systemPrompt: string, - tools: Record, -} - -type ContextType = "email-theme" | "email-template" | "email-draft"; - -const CHAT_ADAPTERS: Record ChatAdapter> = { - "email-theme": emailThemeAdapter, - "email-template": emailTemplateAdapter, - "email-draft": emailDraftAdapter, -}; - -export function getChatAdapter(contextType: ContextType, tenancy: Tenancy, threadId: string): ChatAdapter { - const adapter = CHAT_ADAPTERS[contextType]; - return adapter({ tenancy, threadId }); -} diff --git a/apps/backend/src/lib/ai-chat/email-draft-adapter.ts b/apps/backend/src/lib/ai-chat/email-draft-adapter.ts deleted file mode 100644 index c5c5687e03..0000000000 --- a/apps/backend/src/lib/ai-chat/email-draft-adapter.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { tool } from "ai"; -import { z } from "zod"; -import { ChatAdapterContext } from "./adapter-registry"; - -const EMAIL_DRAFT_SYSTEM_PROMPT = ` -Do not include , , , or components (the theme provides those). -You are an expert email copywriter and designer. -Your goal is to create high-converting, professional, and visually appealing email drafts. - -PRINCIPLES: -- Compelling copywriting: Use clear, engaging language. -- Premium design: Use modern layouts and balanced spacing. -- Professional tone: Match the project's identity. -- Mobile responsiveness: Ensure drafts look good on all devices. - -TECHNICAL RULES: -- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL. -- Always include a . -- Do NOT include , , , or components (the theme provides those). -- Use only tailwind classes for styling. -- Export 'EmailTemplate' component. -`; - -export const emailDraftAdapter = (context: ChatAdapterContext) => ({ - systemPrompt: EMAIL_DRAFT_SYSTEM_PROMPT, - tools: { - createEmailTemplate: tool({ - description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(), - inputSchema: z.object({ - content: z.string().describe("A react component that renders the email template"), - }), - }), - }, -}); - - -const CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION = () => { - return ` -Create a new email draft. -The email draft is a tsx file that is used to render the email content. -It must use react-email components. -It must export one thing: -- EmailTemplate: A function that renders the email draft -It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype". -It uses tailwind classes for all styling. - -Here is an example of a valid email draft: -\`\`\`tsx -import { Container } from "@react-email/components"; -import { Subject, NotificationCategory, Props } from "@stackframe/emails"; - -export function EmailTemplate({ user, project }: Props) { - return ( - - - -
Hi {user.displayName}!
-
-
- ); -} -\`\`\` -`; -}; diff --git a/apps/backend/src/lib/ai-chat/email-template-adapter.ts b/apps/backend/src/lib/ai-chat/email-template-adapter.ts deleted file mode 100644 index 299d7cf436..0000000000 --- a/apps/backend/src/lib/ai-chat/email-template-adapter.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { tool } from "ai"; -import { z } from "zod"; -import { ChatAdapterContext } from "./adapter-registry"; - -const EMAIL_TEMPLATE_SYSTEM_PROMPT = ` -Do not include , , , or components (the theme provides those). -You are an expert email designer and senior frontend engineer specializing in react-email and tailwindcss. -Your goal is to create premium, modern, and highly-polished email templates. - -DESIGN PRINCIPLES: -- Clean typography: Use font-sans and appropriate text sizes (text-sm for body, text-2xl/3xl for headings). -- Balanced spacing: Use generous padding and margins (py-8, gap-4). -- Modern aesthetics: Use subtle borders, soft shadows (if supported/simulated), and professional color palettes. -- Mobile-first: Ensure designs look great on small screens. -- Clarity: The main call-to-action should be prominent. - -TECHNICAL RULES: -- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL. -- Always include a component. -- Always include a component. -- Do NOT include , , , or components (the theme provides those). -- Use only tailwind classes for styling. -- Export 'variablesSchema' using arktype. -- Export 'EmailTemplate' component. -- Define 'EmailTemplate.PreviewVariables' with realistic example data. -`; - -export const emailTemplateAdapter = (context: ChatAdapterContext) => ({ - systemPrompt: EMAIL_TEMPLATE_SYSTEM_PROMPT, - tools: { - createEmailTemplate: tool({ - description: CREATE_EMAIL_TEMPLATE_TOOL_DESCRIPTION(context), - inputSchema: z.object({ - content: z.string().describe("A react component that renders the email template"), - }), - }), - }, -}); - - -const CREATE_EMAIL_TEMPLATE_TOOL_DESCRIPTION = (context: ChatAdapterContext) => { - const currentEmailTemplate = context.tenancy.config.emails.templates[context.threadId]; - - return ` -Create a new email template. -The email template is a tsx file that is used to render the email content. -It must use react-email components. -It must export two things: -- variablesSchema: An arktype schema for the email template props -- EmailTemplate: A function that renders the email template. You must set the PreviewVariables property to an object that satisfies the variablesSchema by doing EmailTemplate.PreviewVariables = { ... -It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype". -It uses tailwind classes for all styling. - -Here is an example of a valid email template: -\`\`\`tsx -import { type } from "arktype" -import { Container } from "@react-email/components"; -import { Subject, NotificationCategory, Props } from "@stackframe/emails"; - -export const variablesSchema = type({ - count: "number" -}); - -export function EmailTemplate({ user, variables }: Props) { - return ( - - - -
Hi {user.displayName}!
-
- count is {variables.count} -
- ); -} - -EmailTemplate.PreviewVariables = { - count: 10 -} satisfies typeof variablesSchema.infer -\`\`\` - -Here is the user's current email template: -\`\`\`tsx -${currentEmailTemplate.tsxSource} -\`\`\` -`; -}; diff --git a/apps/backend/src/lib/ai-chat/email-theme-adapter.ts b/apps/backend/src/lib/ai-chat/email-theme-adapter.ts deleted file mode 100644 index d9fbe85f99..0000000000 --- a/apps/backend/src/lib/ai-chat/email-theme-adapter.ts +++ /dev/null @@ -1,68 +0,0 @@ - -import { tool } from "ai"; -import { z } from "zod"; -import { ChatAdapterContext } from "./adapter-registry"; - - -export const emailThemeAdapter = (context: ChatAdapterContext) => ({ - systemPrompt: ` -You are an expert email designer and senior frontend engineer. -Your goal is to create premium, modern email themes that provide a consistent look and feel across all emails. - -DESIGN PRINCIPLES: -- Professional layout: Use a clear container and appropriate padding. -- Consistent branding: Use professional colors and clean typography. -- Mobile responsiveness: Ensure the theme works well on all devices. -- Accessibility: Use semantic tags and readable font sizes. - -TECHNICAL RULES: -- Export 'EmailTheme' component. -- Take 'children' as a prop and render it inside the main layout. -- Use for styling. -- Ensure the layout is robust and follows email design best practices. -`, - - tools: { - createEmailTheme: tool({ - description: CREATE_EMAIL_THEME_TOOL_DESCRIPTION(context), - inputSchema: z.object({ - content: z.string().describe("The content of the email theme"), - }), - }), - }, -}); - -const CREATE_EMAIL_THEME_TOOL_DESCRIPTION = (context: ChatAdapterContext) => { - const currentEmailTheme = context.tenancy.config.emails.themes[context.threadId].tsxSource || ""; - - return ` -Create a new email theme. -The email theme is a React component that is used to render the email theme. -It must use react-email components. -It must be exported as a function with name "EmailTheme". -It must take one prop, children, which is a React node. -It must not import from any package besides "@react-email/components". -It uses tailwind classes inside of the tag. - -Here is an example of a valid email theme: -\`\`\`tsx -import { Container, Head, Html, Tailwind } from '@react-email/components' - -export function EmailTheme({ children }: { children: React.ReactNode }) { - return ( - - - - {children} - - - ) -} -\`\`\` - -Here is the current email theme: -\`\`\`tsx -${currentEmailTheme} -\`\`\` -`; -}; diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index 1658af4c57..c88d936773 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -173,70 +173,97 @@ Remember: You're here to help users succeed with Stack Auth. Be helpful but conc `, "email-wysiwyg-editor": ` -## Context: Email Template Editor - -Do not include , , , or components (the theme provides those). -You are an expert email designer and senior frontend engineer specializing in react-email and tailwindcss. +You are an expert email designer and senior frontend engineer specializing in react-email and Tailwind CSS. Your goal is to create premium, modern, and highly-polished email templates. -**DESIGN PRINCIPLES:** -- Clean typography: Use font-sans and appropriate text sizes (text-sm for body, text-2xl/3xl for headings) -- Balanced spacing: Use generous padding and margins (py-8, gap-4) -- Modern aesthetics: Use subtle borders, soft shadows (if supported/simulated), and professional color palettes -- Mobile-first: Ensure designs look great on small screens -- Clarity: The main call-to-action should be prominent - -**TECHNICAL RULES:** -- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL -- Always include a component -- Always include a component -- Do NOT include , , , or components (the theme provides those) -- Use only tailwind classes for styling -- Export 'variablesSchema' using arktype -- Export 'EmailTemplate' component -- Define 'EmailTemplate.PreviewVariables' with realistic example data +The current source code will be provided in the conversation messages. When modifying existing code: +- Make only the changes the user asked for; preserve everything else exactly as-is +- If the user's request is ambiguous, make the change that best matches their intent from a UX perspective +- Do NOT add explanatory comments about what you changed +- If the user added whitespace at the very start or end of a text node, that was probably accidental — ignore it + +DESIGN PRINCIPLES: +- Clean typography: Use font-sans and appropriate text sizes (text-sm for body, text-2xl/3xl for headings). +- Balanced spacing: Use generous padding and margins (py-8, gap-4). +- Modern aesthetics: Use subtle borders, soft shadows (if supported/simulated), and professional color palettes. +- Mobile-first: Ensure designs look great on small screens. +- Clarity: The main call-to-action should be prominent. + +RULES: +1. The component must NOT include , , , or — the email theme provides those wrappers. +2. Always include a component with a meaningful value. +3. Always include a component (e.g., "Transactional" or "Marketing"). +4. Export \`variablesSchema\` using arktype to define any dynamic variables the template uses. +5. Export the component as \`EmailTemplate\`. It must accept \`Props\` as its props type. +6. Set \`EmailTemplate.PreviewVariables\` with realistic sample data matching the schema. +7. Import email components only from \`@react-email/components\`, schema types from \`arktype\`, and Stack Auth helpers from \`@stackframe/emails\` (Subject, NotificationCategory, Props). +8. EVERY component you use in JSX must be explicitly imported. If you use \`
\`, import \`Hr\`. If you use \`\`, import \`Img\`. Never use a component without importing it. +9. Use only Tailwind classes for styling — no inline styles. +10. If the text is part of a template literal or JSX expression, only change the static text portion. +11. YOU MUST call the \`createEmailTemplate\` tool with the complete code. NEVER output code directly in the chat. +12. Output raw TSX source code — NEVER HTML-encode angle brackets. Write \`\`, not \`<Container>\`. +13. NEVER use bare & in JSX text content — it is invalid JSX and causes a build error. Use \`&\` or \`{"&"}\` instead. `, "email-assistant-theme": ` -## Context: Email Theme Creation - You are an expert email designer and senior frontend engineer. Your goal is to create premium, modern email themes that provide a consistent look and feel across all emails. -**DESIGN PRINCIPLES:** -- Professional layout: Use a clear container and appropriate padding -- Consistent branding: Use professional colors and clean typography -- Mobile responsiveness: Ensure the theme works well on all devices -- Accessibility: Use semantic tags and readable font sizes - -**TECHNICAL RULES:** -- Export 'EmailTheme' component -- Take 'children' as a prop and render it inside the main layout -- Use for styling -- Must not import from any package besides "@react-email/components" -- Ensure the layout is robust and follows email design best practices -- Use the createEmailTheme tool to return the complete theme code +The current source code will be provided in the conversation messages. When modifying existing code: +- Make only the changes the user asked for; preserve everything else exactly as-is +- If the user's request is ambiguous, make the change that best matches their intent from a UX perspective +- Do NOT add explanatory comments about what you changed +- If the user added whitespace at the very start or end of a text node, that was probably accidental — ignore it + +DESIGN PRINCIPLES: +- Professional layout: Use a clear container and appropriate padding. +- Consistent branding: Use professional colors and clean typography. +- Mobile responsiveness: Ensure the theme works well on all devices. +- Accessibility: Use semantic tags and readable font sizes. + +COMPONENT PROPS: +The renderer calls \`\` with exactly these props — do NOT invent additional ones: +\`\`\`tsx +type EmailThemeProps = { + children: React.ReactNode, // required — the email body content + unsubscribeLink?: string, // optional URL string — use as href={unsubscribeLink}, NEVER as a function call +} +\`\`\` + +RULES: +1. Export the component as \`EmailTheme\` with the exact props above. +2. Must include , , and a wrapper (themes are responsible for the full document structure). +3. Import ONLY from \`@react-email/components\` — no other packages are allowed. +4. EVERY component you use in JSX must be explicitly imported. If you use \`
\`, import \`Hr\`. Never use a component without importing it. +5. Use only Tailwind classes for styling — no inline styles. +6. The layout must be robust, responsive, and compatible with major email clients. +7. If the text is part of a template literal or JSX expression, only change the static text portion. +8. YOU MUST call the \`createEmailTheme\` tool with the complete code. NEVER output code directly in the chat. +9. Output raw TSX source code — NEVER HTML-encode angle brackets. Write \`\`, not \`<EmailTheme>\`. +10. NEVER use bare & in JSX text content — it is invalid JSX and causes a build error. Use \`&\` or \`{"&"}\` instead. +11. Do NOT pass a \`config\` prop to \`\`. Use only standard Tailwind utility classes in \`className\` props. +12. JavaScript object literals use COMMAS to separate properties — never semicolons. Only TypeScript types/interfaces use semicolons. Example: \`{ a: 1, b: 2 }\` NOT \`{ a: 1; b: 2 }\`. `, "email-assistant-draft": ` -## Context: Email Draft Creation - Do not include , , , or components (the theme provides those). You are an expert email copywriter and designer. Your goal is to create high-converting, professional, and visually appealing email drafts. -**PRINCIPLES:** -- Compelling copywriting: Use clear, engaging language -- Premium design: Use modern layouts and balanced spacing -- Professional tone: Match the project's identity -- Mobile responsiveness: Ensure drafts look good on all devices - -**TECHNICAL RULES:** -- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailDraft TOOL -- Always include a component -- Do NOT include , , , or components (the theme provides those) -- Use only tailwind classes for styling -- Export 'EmailTemplate' component +PRINCIPLES: +- Compelling copywriting: Use clear, engaging language. +- Premium design: Use modern layouts and balanced spacing. +- Professional tone: Match the project's identity. +- Mobile responsiveness: Ensure drafts look good on all devices. + +TECHNICAL RULES: +- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL. +- Always include a . +- Do NOT include , , , or components (the theme provides those). +- Use only tailwind classes for styling. +- Export 'EmailTemplate' component. + +The current source code will be provided in the conversation messages. `, "create-dashboard": ` diff --git a/apps/backend/src/lib/ai/tools/create-email-draft.ts b/apps/backend/src/lib/ai/tools/create-email-draft.ts index e494747f78..4af99f8f2c 100644 --- a/apps/backend/src/lib/ai/tools/create-email-draft.ts +++ b/apps/backend/src/lib/ai/tools/create-email-draft.ts @@ -14,75 +14,38 @@ import { z } from "zod"; */ export function createEmailDraftTool(auth: SmartRequestAuth | null) { return tool({ - description: `Create a new email draft for Stack Auth. - -**What is an Email Draft?** -An email draft is a simpler version of an email template, without variable schemas. It's used for one-off emails or quick email creation. - -**Requirements:** -- Must use @react-email/components for email components -- Can import from @stackframe/emails for Stack Auth-specific utilities -- Must export ONE thing: \`EmailTemplate\` function component -- Must include Subject and NotificationCategory components -- Uses Tailwind classes for all styling -- Can access user and project data via Props - -**Differences from Email Templates:** -- No variablesSchema required -- No custom variables (only user and project data) -- No PreviewVariables needed -- Simpler for one-off or standard emails - -**Structure:** -1. Import required components -2. Define EmailTemplate function component using Props type -3. Include Subject (can use user data) -4. Include NotificationCategory -5. Add email content using react-email components - -**Example Valid Email Draft:** + description: ` +Create a new email draft. +The email draft is a tsx file that is used to render the email content. +It must use react-email components. +It must export one thing: +- EmailTemplate: A function that renders the email draft +It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype". +It uses tailwind classes for all styling. +The email must include , , , , , and in the correct hierarchy. +Do not use any Tailwind classes that require style injection (e.g., hover:, focus:, active:, group-hover:, media queries, dark:, etc.). Only use inlineable Tailwind utilities. +The component must be rendered inside to support Tailwind style injection +Here is an example of a valid email draft: \`\`\`tsx -import { Container, Text, Button } from "@react-email/components"; +import { Container } from "@react-email/components"; import { Subject, NotificationCategory, Props } from "@stackframe/emails"; export function EmailTemplate({ user, project }: Props) { return ( - + - - - Welcome, {user.displayName}! - - - - Thank you for joining {project.displayName}. We're excited to have you here. - - - - Get started by visiting your dashboard and exploring the features. - - - +
Hi {user.displayName}!
+
); } \`\`\` -**Guidelines:** -- Keep content clear and focused -- Use appropriate tone -- Personalize with user and project data -- Include clear call-to-actions when needed -- Make it mobile-responsive -- Use email-safe styling - -**Output:** -Return the COMPLETE draft code including all imports and component definition.`, +The user's current email draft can be found in the conversation messages. +`, inputSchema: z.object({ - content: z.string().describe("The complete email draft code as a TypeScript React component"), + content: z.string().describe("A react component that renders the email template"), }), // No execute function - the tool call is returned to the caller }); diff --git a/apps/backend/src/lib/ai/tools/create-email-template.ts b/apps/backend/src/lib/ai/tools/create-email-template.ts index 1b9436e957..a35d0ceaea 100644 --- a/apps/backend/src/lib/ai/tools/create-email-template.ts +++ b/apps/backend/src/lib/ai/tools/create-email-template.ts @@ -12,83 +12,51 @@ import { z } from "zod"; */ export function createEmailTemplateTool(auth: SmartRequestAuth | null) { return tool({ - description: `Create a new email template for Stack Auth. - -**What is an Email Template?** -An email template is a complete email with content, variables, and metadata. It defines the structure and content of a specific type of email (e.g., welcome email, password reset, notification). - -**Requirements:** -- Must use @react-email/components for email components -- Can import from @stackframe/emails for Stack Auth-specific utilities -- Can import from arktype for schema validation -- Must export TWO things: - 1. \`variablesSchema\`: An arktype schema defining template variables - 2. \`EmailTemplate\`: A function component that renders the email -- EmailTemplate must set PreviewVariables property with sample data -- Must use Props as the component props type -- Must include Subject and NotificationCategory components -- Uses Tailwind classes for all styling - -**Structure:** -1. Import required components and types -2. Define variablesSchema using arktype -3. Define EmailTemplate function component -4. Include Subject (dynamic or static) -5. Include NotificationCategory (e.g., "Transactional", "Marketing") -6. Add email content using react-email components -7. Set EmailTemplate.PreviewVariables - -**Example Valid Email Template:** + description: ` +Create a new email template. +The email template is a tsx file that is used to render the email content. +It must use react-email components. +It must export two things: +- variablesSchema: An arktype schema for the email template props +- EmailTemplate: A function that renders the email template. You must set the PreviewVariables property to an object that satisfies the variablesSchema by doing EmailTemplate.PreviewVariables = { ... +It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype". +It uses tailwind classes for all styling. +The user's current email template will be provided in the conversation messages. +The email must include , , , , , and in the correct hierarchy. +Do not use any Tailwind classes that require style injection (e.g., hover:, focus:, active:, group-hover:, media queries, dark:, etc.). Only use inlineable Tailwind utilities. +The component must be rendered inside to support Tailwind style injection + +Here is an example of a valid email template: \`\`\`tsx import { type } from "arktype" -import { Container, Text, Button } from "@react-email/components"; +import { Container } from "@react-email/components"; import { Subject, NotificationCategory, Props } from "@stackframe/emails"; export const variablesSchema = type({ - actionUrl: "string", - expiresInHours: "number" + count: "number" }); export function EmailTemplate({ user, variables }: Props) { return ( - + - - - Hi {user.displayName}! - - - - Please complete your action within {variables.expiresInHours} hours. - - - +
Hi {user.displayName}!
+
+ count is {variables.count}
); } EmailTemplate.PreviewVariables = { - actionUrl: "https://example.com/action", - expiresInHours: 24 -} satisfies typeof variablesSchema.infer; + count: 10 +} satisfies typeof variablesSchema.infer \`\`\` -**Guidelines:** -- Make content clear, concise, and actionable -- Use appropriate tone for the email type -- Include all necessary information -- Add clear call-to-action buttons when needed -- Use user data (user.displayName, user.primaryEmail, etc.) to personalize -- Make it mobile-responsive -- Use email-safe styling - -**Output:** -Return the COMPLETE template code including all imports, schema, component, and PreviewVariables.`, +The user's current email template can be found in the conversation messages. +`, inputSchema: z.object({ - content: z.string().describe("The complete email template code as a TypeScript React component with schema"), + content: z.string().describe("A react component that renders the email template"), }), // No execute function - the tool call is returned to the caller }); diff --git a/apps/backend/src/lib/ai/tools/create-email-theme.ts b/apps/backend/src/lib/ai/tools/create-email-theme.ts index 7ec2f3b3c7..5b88b0a6fb 100644 --- a/apps/backend/src/lib/ai/tools/create-email-theme.ts +++ b/apps/backend/src/lib/ai/tools/create-email-theme.ts @@ -12,55 +12,60 @@ import { z } from "zod"; */ export function createEmailThemeTool(auth: SmartRequestAuth | null) { return tool({ - description: `Create a new email theme for Stack Auth emails. + description: ` +Create a new email theme. -**What is an Email Theme?** -An email theme is a React component that wraps all email content, providing consistent structure, layout, and styling across all emails. +The email theme is a React component that wraps all emails with a consistent layout. -**Requirements:** -- Must use @react-email/components (no other imports allowed) -- Must be exported as a function named "EmailTheme" -- Must accept one prop: children (React.ReactNode) -- Must use Tailwind classes inside tag -- Must include Html, Head, and appropriate container elements -- Should be responsive and compatible with major email clients +EXACT PROP SIGNATURE (do not change or add props): +\`\`\`tsx +type EmailThemeProps = { + children: React.ReactNode, // required — the email body + unsubscribeLink?: string, // optional URL string — use as href={unsubscribeLink}, NOT as a function call +} +\`\`\` -**Structure:** -1. Html wrapper -2. Head (for meta tags) -3. Tailwind wrapper (for styling) -4. Container/layout elements -5. {children} placeholder for email content +Other requirements: +- Must include \`\`, \`\`, and a \`\` wrapper (the theme owns the full document) +- Import ONLY from \`@react-email/components\` — no other packages +- Use standard Tailwind utility classes in \`className\` props — do NOT pass a \`config\` prop to \`\` +- EVERY component used in JSX must be explicitly imported +- JavaScript object literals use COMMAS between properties, never semicolons -**Example Valid Email Theme:** +The user's current email theme can be found in the conversation messages. + +Here is an example of a valid email theme: \`\`\`tsx -import { Container, Head, Html, Tailwind } from '@react-email/components' +import { Body, Container, Head, Hr, Html, Link, Section, Text, Tailwind } from '@react-email/components' -export function EmailTheme({ children }: { children: React.ReactNode }) { +export function EmailTheme({ children, unsubscribeLink }: { children: React.ReactNode, unsubscribeLink?: string }) { return ( - - {children} - + + +
+ {children} +
+
+
+ {unsubscribeLink && ( + + Unsubscribe + + )} +
+
+
) } \`\`\` - -**Guidelines:** -- Keep it simple and focused on layout/structure -- Use neutral, professional styling that works for various email types -- Ensure good spacing and readability -- Make it mobile-responsive -- Test compatibility with email clients (use email-safe CSS) - -**Output:** -Return the COMPLETE theme code as a TypeScript React component. Include all imports and the full component definition.`, +`, inputSchema: z.object({ - content: z.string().describe("The complete email theme code as a TypeScript React component"), + content: z.string().describe("The content of the email theme"), }), // No execute function - the tool call is returned to the caller }); diff --git a/apps/dashboard/.env.development b/apps/dashboard/.env.development index 8a4c350461..5e62be0aa1 100644 --- a/apps/dashboard/.env.development +++ b/apps/dashboard/.env.development @@ -11,5 +11,3 @@ STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50 NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=true STACK_FEATUREBASE_JWT_SECRET=secret-value - -STACK_OPENAI_API_KEY=mock_openai_api_key diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index d268f512f1..4d805f13f9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -17,7 +17,6 @@ "lint": "eslint ." }, "dependencies": { - "@ai-sdk/openai": "^3.0.25", "@ai-sdk/react": "^3.0.72", "@assistant-ui/react": "^0.10.24", "@assistant-ui/react-ai-sdk": "^0.10.14", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx index 82b144063b..c70ce90ac7 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx @@ -10,11 +10,13 @@ import { ToolCallContent, createChatAdapter, createHistoryAdapter } from "@/comp import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { useAdminApp } from "../../use-admin-app"; export default function PageClient({ draftId }: { draftId: string }) { const stackAdminApp = useAdminApp(); + const { projectId } = useParams() as { projectId: string }; const { setNeedConfirm } = useRouterConfirm(); const [saveAlert, setSaveAlert] = useState<{ variant: "destructive" | "success", @@ -162,7 +164,7 @@ export default function PageClient({ draftId }: { draftId: string }) { chatComponent={ currentCode)} toolComponents={} useOffWhiteLightMode /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx index e792ae8d37..d0a61daea9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx @@ -18,12 +18,14 @@ import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useParams } from "next/navigation"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; export default function PageClient(props: { templateId: string }) { const stackAdminApp = useAdminApp(); + const { projectId } = useParams() as { projectId: string }; const templates = stackAdminApp.useEmailTemplates(); const { setNeedConfirm } = useRouterConfirm(); const templateFromHook = templates.find((t) => t.id === props.templateId); @@ -270,7 +272,7 @@ export default function PageClient(props: { templateId: string }) { } chatComponent={ currentCode)} historyAdapter={createHistoryAdapter(stackAdminApp, template.id)} toolComponents={} useOffWhiteLightMode diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx index f7faee0c64..8770ccf378 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx @@ -12,12 +12,14 @@ import { import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { useAdminApp } from "../../use-admin-app"; export default function PageClient({ themeId }: { themeId: string }) { const stackAdminApp = useAdminApp(); + const { projectId } = useParams() as { projectId: string }; const theme = stackAdminApp.useEmailTheme(themeId); const { setNeedConfirm } = useRouterConfirm(); const [currentCode, setCurrentCode] = useState(theme.tsxSource); @@ -124,7 +126,7 @@ export default function PageClient({ themeId }: { themeId: string }) { } chatComponent={ currentCode)} historyAdapter={createHistoryAdapter(stackAdminApp, themeId)} toolComponents={} useOffWhiteLightMode diff --git a/apps/dashboard/src/app/api/ai-search/route.ts b/apps/dashboard/src/app/api/ai-search/route.ts index 9e4add07a3..1e8216504b 100644 --- a/apps/dashboard/src/app/api/ai-search/route.ts +++ b/apps/dashboard/src/app/api/ai-search/route.ts @@ -16,7 +16,7 @@ export async function POST(req: Request) { } const user = await stackServerApp.getUser({ or: "redirect" }); - const { accessToken } = await user.getAuthJson(); + const accessToken = await user.getAccessToken(); // Check if the user has admin access to the requested project let hasProjectAccess = false; diff --git a/apps/dashboard/src/app/api/email-ai/route.ts b/apps/dashboard/src/app/api/email-ai/route.ts new file mode 100644 index 0000000000..05c3a4a5ad --- /dev/null +++ b/apps/dashboard/src/app/api/email-ai/route.ts @@ -0,0 +1,115 @@ +import { getPublicEnvVar } from "@/lib/env"; +import { stackServerApp } from "@/stack"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +/** + * Sanitizes AI-generated JSX/TSX code before it is applied to the email renderer. + * + * Handles four common model output issues: + * 1. Markdown code fences (```tsx ... ```) wrapping the output despite instructions + * 2. HTML-encoded angle brackets (<Component> instead of ) + * 3. Bare & in JSX text content (invalid JSX; must be & or {"&"}) + * 4. Semicolons used as property separators in JS object literals instead of commas + * (the AI confuses TypeScript interface syntax with JS object syntax). + * TypeScript also accepts commas in interfaces/types, so replacing ; → , is always safe. + */ +function sanitizeGeneratedCode(code: string): string { + let result = code.trim(); + + // Strip markdown code fences if the model added them despite instructions. + // Handles ```tsx ... ``` and also plain ``` ... ```. + if (result.startsWith("```")) { + const lines = result.split("\n"); + lines.shift(); // remove opening ```tsx or similar + if (lines[lines.length - 1]?.trim() === "```") { + lines.pop(); // remove closing ``` + } + result = lines.join("\n").trim(); + } + + // Decode common HTML entities that models sometimes emit inside code. + // This fixes things like `&&` (should be `&&`) and `<Container>`. + // Only decodes the entities we expect in generated TSX. + result = result + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + + // Fix the common model mistake of using `;` as a property separator in object literals. + // Replace `;` with `,` only when it looks like `key: value;` followed by another `key:`. + // This avoids touching for-loops and other valid `;` usage. + result = result.replace(/;(\s*\n\s*[A-Za-z_$][\w$]*\s*:)/g, ",$1"); + + return result; +} + + +export async function POST(req: Request) { + const payload = await req.json() as { + projectId: string, + systemPrompt: string, + tools: string[], + messages: unknown[], + quality?: string, + speed?: string, + }; + + const { projectId, systemPrompt, tools, messages, quality = "smartest", speed = "fast" } = payload; + + const user = await stackServerApp.getUser({ or: "redirect" }); + const accessToken = await user.getAccessToken(); + + const projects = await user.listOwnedProjects(); + const hasProjectAccess = projects.some((p) => p.id === projectId); + + if (!hasProjectAccess || !accessToken) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 403, + headers: { "content-type": "application/json" }, + }); + } + + const backendBaseUrl = + getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? + getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? + throwErr("Backend API URL is not configured (NEXT_PUBLIC_STACK_API_URL)"); + + const backendResponse = await fetch( + `${backendBaseUrl}/api/latest/ai/query/generate`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-stack-access-type": "admin", + "x-stack-project-id": projectId, + "x-stack-admin-access-token": accessToken, //TODO not entirely sure + }, + body: JSON.stringify({ quality, speed, systemPrompt, tools, messages }), + } + ); + + if (!backendResponse.ok) { + const error = await backendResponse.json().catch(() => ({ error: "Unknown error" })); + return new Response(JSON.stringify(error), { + status: backendResponse.status, + headers: { "content-type": "application/json" }, + }); + } + + const result = await backendResponse.json() as { content: Array<{ type: string, args?: { content?: string, [key: string]: unknown }, [key: string]: unknown }> }; + const sanitized = { + content: result.content.map((item) => { + if (item.type === "tool-call" && typeof item.args?.content === "string") { + return { ...item, args: { ...item.args, content: sanitizeGeneratedCode(item.args.content) } }; + } + return item; + }), + }; + + return new Response(JSON.stringify(sanitized), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index a2480d05ce..918a2a015d 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -12,51 +12,62 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => { return content.type === "tool-call"; }; +const CONTEXT_MAP = { + "email-theme": { systemPrompt: "email-assistant-theme", tools: ["create-email-theme"] }, + "email-template": { systemPrompt: "email-wysiwyg-editor", tools: ["create-email-template"] }, + "email-draft": { systemPrompt: "email-assistant-draft", tools: ["create-email-draft"] }, +} as const; + export function createChatAdapter( - adminApp: StackAdminApp, + projectId: string, threadId: string, contextType: "email-theme" | "email-template" | "email-draft", - onToolCall: (toolCall: ToolCallContent) => void + onToolCall: (toolCall: ToolCallContent) => void, + getCurrentSource?: () => string, ): ChatModelAdapter { return { async run({ messages, abortSignal }) { try { const formattedMessages = []; for (const msg of messages) { - // Separate tool calls from other content - const toolCalls = msg.content.filter(isToolCall); - const nonToolContent = msg.content.filter(c => !isToolCall(c)); - // Only add the message if it has non-tool content - if (nonToolContent.length > 0) { - formattedMessages.push({ - role: msg.role, - content: nonToolContent - }); + const textContent = msg.content.filter(c => !isToolCall(c)); + if (textContent.length > 0) { + formattedMessages.push({ role: msg.role, content: textContent }); } - // Add tool results as separate messages - toolCalls.forEach(toolCall => { - formattedMessages.push({ - role: "tool", - content: [{ - type: "tool-result", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - result: toolCall.result, - }], - }); - }); } + const currentSource = getCurrentSource?.() ?? ""; + const contextMessages: Array<{ role: "user" | "assistant", content: string }> = currentSource ? [ + { role: "user", content: `Here is the current source:\n\`\`\`tsx\n${currentSource}\n\`\`\`` }, + { role: "assistant", content: "Got it. What would you like to change?" }, + ] : []; + + const { systemPrompt, tools } = CONTEXT_MAP[contextType]; + + const response = await fetch("/api/email-ai", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + projectId, + systemPrompt, + tools: [...tools], + messages: [...contextMessages, ...formattedMessages], + }), + signal: abortSignal, + }); + + if (!response.ok) { + throw new Error(`AI request failed: ${response.status}`); + } + + const result: { content: ChatContent } = await response.json(); - const response = await adminApp.sendChatMessage(threadId, contextType, formattedMessages, abortSignal); - if (response.content.some(isToolCall)) { - const toolCall = response.content.find(isToolCall); + if (result.content.some(isToolCall)) { + const toolCall = result.content.find(isToolCall); if (toolCall) { onToolCall(toolCall); } } - return { - content: response.content, - }; + return { content: result.content }; } catch (error) { if (abortSignal.aborted) { return {}; diff --git a/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx b/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx index 3f0a0f87c8..8a69bbbe53 100644 --- a/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx +++ b/apps/dashboard/src/components/vibe-coding/draft-tool-components.tsx @@ -11,7 +11,7 @@ export const EmailDraftUI = ({ setCurrentCode }: EmailDraftUIProps) => { { content: string }, "success" >({ - toolName: "createEmailTemplate", + toolName: "createEmailDraft", render: ({ args }) => { return ( diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 0c10351d31..9cd597ceea 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -420,28 +420,6 @@ export class StackAdminInterface extends StackServerInterface { ); } - - async sendChatMessage( - threadId: string, - contextType: "email-theme" | "email-template" | "email-draft", - messages: Array<{ role: string, content: any }>, - abortSignal?: AbortSignal, - ): Promise<{ content: ChatContent }> { - const response = await this.sendAdminRequest( - `/internal/ai-chat/${threadId}`, - { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ context_type: contextType, messages }), - signal: abortSignal, - }, - null, - ); - return await response.json(); - } - async saveChatMessage(threadId: string, message: any): Promise { await this.sendAdminRequest( `/internal/ai-chat/${threadId}`, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 389f53db74..73bb2774ac 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -25,7 +25,6 @@ import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders, import { _StackServerAppImplIncomplete } from "./server-app-impl"; import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; -import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import type { EditableMetadata } from "@stackframe/stack-shared/dist/utils/jsx-editable-transpiler"; import { branchConfigSourceSchema } from "@stackframe/stack-shared/dist/schema-fields"; import * as yup from "yup"; @@ -629,16 +628,6 @@ export class _StackAdminAppImplIncomplete, - abortSignal?: AbortSignal, - ): Promise<{ content: ChatContent }> { - return await this._interface.sendChatMessage(threadId, contextType, messages, abortSignal); - } - async saveChatMessage(threadId: string, message: any): Promise { await this._interface.saveChatMessage(threadId, message); } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 5e3c6db5f3..4864b43513 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,4 +1,3 @@ -import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; @@ -95,13 +94,6 @@ export type StackAdminApp, updateEmailTheme(id: string, tsxSource: string): Promise, deleteEmailTheme(id: string): Promise, - - sendChatMessage( - threadId: string, - contextType: "email-theme" | "email-template" | "email-draft", - messages: Array<{ role: string, content: any }>, - abortSignal?: AbortSignal, - ): Promise<{ content: ChatContent }>, saveChatMessage(threadId: string, message: any): Promise, listChatMessages(threadId: string): Promise<{ messages: Array }>, applyWysiwygEdit(options: { From 592c2c0c17ac23c9f8bbd23d0325ba4e403ab66c Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:32:35 -0800 Subject: [PATCH 04/21] docs ai working --- .../app/api/latest/ai/query/[mode]/route.ts | 7 +- apps/backend/src/lib/ai/prompts.ts | 42 ++- docs/package.json | 4 +- docs/src/app/api/chat/route.ts | 245 +++--------------- docs/src/components/chat/ai-chat.tsx | 158 +++++++---- 5 files changed, 185 insertions(+), 271 deletions(-) diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts index dc4f034cdf..c1063d4b05 100644 --- a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -9,7 +9,7 @@ import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/sc import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { Json } from "@stackframe/stack-shared/dist/utils/json"; -import { ModelMessage, generateText, streamText } from "ai"; +import { ModelMessage, generateText, stepCountIs, streamText } from "ai"; export const POST = createSmartRouteHandler({ metadata: { @@ -57,6 +57,9 @@ export const POST = createSmartRouteHandler({ const tools = await getTools(body.tools as ToolName[], { auth: fullReq.auth }); const toolsArg = Object.keys(tools).length > 0 ? tools : undefined; const messages = body.messages as ModelMessage[]; + const promptId = body.systemPrompt as SystemPromptId; + const isDocsOrSearch = promptId === "docs-ask-ai" || promptId === "command-center-ask-ai"; + const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5; if (mode === "stream") { const result = streamText({ @@ -64,6 +67,7 @@ export const POST = createSmartRouteHandler({ system: systemPrompt, messages, tools: toolsArg, + stopWhen: stepCountIs(stepLimit), }); return { statusCode: 200, @@ -76,6 +80,7 @@ export const POST = createSmartRouteHandler({ system: systemPrompt, messages, tools: toolsArg, + stopWhen: stepCountIs(stepLimit), }); const contentBlocks: Array< diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index c88d936773..45901f8d19 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -67,25 +67,27 @@ You are a Stack Auth assistant in a dashboard search bar. `, "docs-ask-ai": ` -## Context: Documentation Assistant + # Stack Auth AI Assistant System Prompt + +You are Stack Auth's AI assistant. You help users with Stack Auth - a complete authentication and user management solution. **CRITICAL**: Keep responses SHORT and concise. ALWAYS use the available tools to pull relevant documentation for every question. There should almost never be a question where you don't retrieve relevant docs. Think step by step about what to say. Being wrong is 100x worse than saying you don't know. -**TOOL USAGE WORKFLOW:** +## TOOL USAGE WORKFLOW: 1. **FIRST**, use \`search_docs\` with relevant keywords to find related documentation 2. **THEN**, use \`get_docs_by_id\` to retrieve the full content of the most relevant pages 3. Base your answer on the actual documentation content retrieved 4. When referring to API endpoints, **always cite the actual endpoint** (e.g., "GET /users/me") not the documentation URL -**CORE RESPONSIBILITIES:** +## CORE RESPONSIBILITIES: 1. Help users implement Stack Auth in their applications 2. Answer questions about authentication, user management, and authorization using Stack Auth 3. Provide guidance on Stack Auth features, configuration, and best practices 4. Help with framework integrations (Next.js, React, etc.) using Stack Auth -**WHAT TO CONSIDER STACK AUTH-RELATED:** +## WHAT TO CONSIDER STACK AUTH-RELATED: - Authentication implementation in any framework (Next.js, React, etc.) - User management, registration, login, logout - Session management and security @@ -96,17 +98,29 @@ Think step by step about what to say. Being wrong is 100x worse than saying you - Stack Auth configuration and setup - Troubleshooting authentication issues -**RESPONSE FORMAT:** +## SUPPORT CONTACT INFORMATION: +When users need personalized support, have complex issues, or ask for help beyond what you can provide from the documentation, direct them to: +- **Discord Community**: https://discord.stack-auth.com (best for quick questions and community help) +- **Email Support**: team@stack-auth.com (for technical support and detailed inquiries) + +## RESPONSE GUIDELINES: +1. Be concise and direct. Only provide detailed explanations when specifically requested +2. For every question, use the available tools to retrieve the most relevant documentation sections +3. If you're uncertain, say "I don't know" rather than making definitive negative statements +4. For complex issues or personalized help, suggest Discord or email support + +## RESPONSE FORMAT: - Use markdown formatting for better readability - **ALWAYS include code examples** - Show users how to actually implement solutions - Include code blocks with proper syntax highlighting (typescript, bash, etc.) - Use bullet points for lists - Bold important concepts - Provide practical, working examples +- Focus on giving complete, helpful answers - **When referencing documentation, use links with the base URL: https://docs.stack-auth.com** - Example: For setup docs, use https://docs.stack-auth.com/docs/getting-started/setup -**CODE EXAMPLE GUIDELINES:** +## CODE EXAMPLE GUIDELINES: - For API calls, show both the HTTP endpoint AND the SDK method - For example, when explaining "get current user": * Show the HTTP API endpoint: GET /api/v1/users/me @@ -115,7 +129,7 @@ Think step by step about what to say. Being wrong is 100x worse than saying you - Always show complete, runnable code snippets with proper language tags - Include context like "HTTP API", "SDK (React)", "SDK (Next.js)" etc. -**STACK AUTH HTTP API HEADERS (CRITICAL):** +## STACK AUTH HTTP API HEADERS (CRITICAL): Stack Auth does NOT use standard "Authorization: Bearer" headers. When showing HTTP/REST API examples, ALWAYS use these Stack Auth-specific headers: **For client-side requests (browser/mobile):** @@ -158,19 +172,27 @@ const response = await fetch('https://api.stack-auth.com/api/v1/users/USER_ID', NEVER show "Authorization: Bearer" for Stack Auth API calls - this is incorrect and will not work. -**WHEN UNSURE:** +## WHEN UNSURE: - If you're unsure about a Stack Auth feature, say "As an AI, I don't know" or "As an AI, I'm not certain" clearly - Avoid saying things are "not possible" or "impossible", instead say that you don't know - Ask clarifying questions to better understand the user's needs +- Product to help with related Stack Auth topics that might be useful - Provide the best information you can based on your knowledge, but acknowledge limitations - If the issue is complex or requires personalized assistance, direct them to Discord or email support -**MANDATORY BEHAVIOR:** +## KEY STACK AUTH CONCEPTS TO REMEMBER: +- The core philosophy is complete authentication and user management +- All features work together - authentication, user management, teams, permissions +- Built for modern frameworks like Next.js, React, and more +- Supports multiple authentication methods: OAuth, email/password, magic links +- Team and permission management for multi-tenant applications + +## MANDATORY BEHAVIOR: This is not optional - retrieve relevant documentation for every question. - Be direct and to the point. Only elaborate when users specifically ask for more detail. Remember: You're here to help users succeed with Stack Auth. Be helpful but concise, ask questions when needed, always pull relevant docs, and don't hesitate to direct users to support channels when they need additional help. -`, + `, "email-wysiwyg-editor": ` You are an expert email designer and senior frontend engineer specializing in react-email and Tailwind CSS. diff --git a/docs/package.json b/docs/package.json index 9fe8ed7ffd..9d56fc53e6 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ "clear-docs": "node scripts/clear-docs.js" }, "dependencies": { - "@ai-sdk/react": "^1.2.12", + "@ai-sdk/react": "^3.0.0", "@openrouter/ai-sdk-provider": "0.7.5", "@modelcontextprotocol/sdk": "^1.17.2", "@phosphor-icons/react": "^2.1.10", @@ -31,7 +31,7 @@ "@stackframe/stack": "workspace:^", "@stackframe/stack-shared": "workspace:^", "@vercel/mcp-adapter": "^1.0.0", - "ai": "^4.3.17", + "ai": "^6.0.0", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.3.3", "fumadocs-mdx": "11.6.4", diff --git a/docs/src/app/api/chat/route.ts b/docs/src/app/api/chat/route.ts index 788bc41950..475a96068a 100644 --- a/docs/src/app/api/chat/route.ts +++ b/docs/src/app/api/chat/route.ts @@ -1,208 +1,49 @@ -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { createOpenRouter } from '@openrouter/ai-sdk-provider'; -import { experimental_createMCPClient as createMCPClient, streamText } from 'ai'; - -// Allow streaming responses up to 30 seconds -export const maxDuration = 30; - -// Create OpenRouter AI instance -const openrouter = createOpenRouter({ - apiKey: process.env.STACK_OPENROUTER_API_KEY, -}); - -// Helper function to get error message -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} +import { stackServerApp } from "@/stack"; +import { convertToModelMessages, UIMessage } from "ai"; export async function POST(request: Request) { - const { messages } = await request.json(); - - // Create MCP client for Stack Auth documentation with error handling - let tools = {}; - try { - // Use local MCP server in development, production server in production - const mcpUrl = process.env.NODE_ENV === 'development' - ? new URL('/api/internal/mcp', 'http://localhost:8104') - : new URL('/api/internal/mcp', 'https://mcp.stack-auth.com'); - - const stackAuthMcp = await createMCPClient({ - transport: new StreamableHTTPClientTransport(mcpUrl), - }); - tools = await stackAuthMcp.tools(); - } catch (error) { - console.error('Failed to initialize MCP client or retrieve tools:', error); - return new Response( - JSON.stringify({ - error: 'Documentation service temporarily unavailable', - details: 'Our documentation service is currently unreachable. Please try again in a moment, or visit https://docs.stack-auth.com directly for help.', - }), - { - status: 503, - headers: { 'Content-Type': 'application/json' }, - } - ); + const payload = await request.json() as { messages?: UIMessage[] }; + const messages = Array.isArray(payload.messages) ? payload.messages : []; + + const backendBaseUrl = + process.env.NEXT_PUBLIC_STACK_API_URL ?? + "https://api.stack-auth.com"; + + const modelMessages = await convertToModelMessages(messages); + const requestHeaders: Record = { + "content-type": "application/json", + }; + + const user = await stackServerApp.getUser(); + if (user != null) { + const accessToken = await user.getAccessToken(); + if (accessToken != null) { + requestHeaders["x-stack-access-type"] = "client"; + requestHeaders["x-stack-access-token"] = accessToken; + } } - // Create a comprehensive system prompt that restricts AI to Stack Auth topics - const systemPrompt = ` -# Stack Auth AI Assistant System Prompt - -You are Stack Auth's AI assistant. You help users with Stack Auth - a complete authentication and user management solution. - -**CRITICAL**: Keep responses SHORT and concise. ALWAYS use the available tools to pull relevant documentation for every question. There should almost never be a question where you don't retrieve relevant docs. - -Think step by step about what to say. Being wrong is 100x worse than saying you don't know. - -## TOOL USAGE WORKFLOW: -1. **FIRST**, use \`search_docs\` with relevant keywords to find related documentation -2. **THEN**, use \`get_docs_by_id\` to retrieve the full content of the most relevant pages -3. Base your answer on the actual documentation content retrieved -4. When referring to API endpoints, **always cite the actual endpoint** (e.g., "GET /users/me") not the documentation URL - -## CORE RESPONSIBILITIES: -1. Help users implement Stack Auth in their applications -2. Answer questions about authentication, user management, and authorization using Stack Auth -3. Provide guidance on Stack Auth features, configuration, and best practices -4. Help with framework integrations (Next.js, React, etc.) using Stack Auth - -## WHAT TO CONSIDER STACK AUTH-RELATED: -- Authentication implementation in any framework (Next.js, React, etc.) -- User management, registration, login, logout -- Session management and security -- OAuth providers and social auth -- Database configuration and user data -- API routes and middleware -- Authorization and permissions -- Stack Auth configuration and setup -- Troubleshooting authentication issues - -## SUPPORT CONTACT INFORMATION: -When users need personalized support, have complex issues, or ask for help beyond what you can provide from the documentation, direct them to: -- **Discord Community**: https://discord.stack-auth.com (best for quick questions and community help) -- **Email Support**: team@stack-auth.com (for technical support and detailed inquiries) - -## RESPONSE GUIDELINES: -1. Be concise and direct. Only provide detailed explanations when specifically requested -2. For every question, use the available tools to retrieve the most relevant documentation sections -3. If you're uncertain, say "I don't know" rather than making definitive negative statements -4. For complex issues or personalized help, suggest Discord or email support - -## RESPONSE FORMAT: -- Use markdown formatting for better readability -- **ALWAYS include code examples** - Show users how to actually implement solutions -- Include code blocks with proper syntax highlighting (typescript, bash, etc.) -- Use bullet points for lists -- Bold important concepts -- Provide practical, working examples -- Focus on giving complete, helpful answers -- **When referencing documentation, use links with the base URL: https://docs.stack-auth.com** -- Example: For setup docs, use https://docs.stack-auth.com/docs/getting-started/setup - -## CODE EXAMPLE GUIDELINES: -- For API calls, show both the HTTP endpoint AND the SDK method -- For example, when explaining "get current user": - * Show the HTTP API endpoint: GET /api/v1/users/me - * Show the SDK usage: const user = useUser(); - * Include necessary imports and authentication headers -- Always show complete, runnable code snippets with proper language tags -- Include context like "HTTP API", "SDK (React)", "SDK (Next.js)" etc. - -## STACK AUTH HTTP API HEADERS (CRITICAL): -Stack Auth does NOT use standard "Authorization: Bearer" headers. When showing HTTP/REST API examples, ALWAYS use these Stack Auth-specific headers: - -**For client-side requests (browser/mobile):** -\`\`\` -X-Stack-Access-Type: client -X-Stack-Project-Id: -X-Stack-Publishable-Client-Key: -X-Stack-Access-Token: // for authenticated requests -\`\`\` - -**For server-side requests (backend):** -\`\`\` -X-Stack-Access-Type: server -X-Stack-Project-Id: -X-Stack-Secret-Server-Key: -\`\`\` - -**Example HTTP request (client-side, authenticated):** -\`\`\`typescript -const response = await fetch('https://api.stack-auth.com/api/v1/users/me', { - headers: { - 'X-Stack-Access-Type': 'client', - 'X-Stack-Project-Id': 'YOUR_PROJECT_ID', - 'X-Stack-Publishable-Client-Key': 'YOUR_PUBLISHABLE_CLIENT_KEY', - 'X-Stack-Access-Token': 'USER_ACCESS_TOKEN', - }, -}); -\`\`\` - -**Example HTTP request (server-side):** -\`\`\`typescript -const response = await fetch('https://api.stack-auth.com/api/v1/users/USER_ID', { - headers: { - 'X-Stack-Access-Type': 'server', - 'X-Stack-Project-Id': 'YOUR_PROJECT_ID', - 'X-Stack-Secret-Server-Key': 'YOUR_SECRET_SERVER_KEY', - }, -}); -\`\`\` - -NEVER show "Authorization: Bearer" for Stack Auth API calls - this is incorrect and will not work. - -## WHEN UNSURE: -- If you're unsure about a Stack Auth feature, say "As an AI, I don't know" or "As an AI, I'm not certain" clearly -- Avoid saying things are "not possible" or "impossible", instead say that you don't know -- Ask clarifying questions to better understand the user's needs -- Product to help with related Stack Auth topics that might be useful -- Provide the best information you can based on your knowledge, but acknowledge limitations -- If the issue is complex or requires personalized assistance, direct them to Discord or email support - -## KEY STACK AUTH CONCEPTS TO REMEMBER: -- The core philosophy is complete authentication and user management -- All features work together - authentication, user management, teams, permissions -- Built for modern frameworks like Next.js, React, and more -- Supports multiple authentication methods: OAuth, email/password, magic links -- Team and permission management for multi-tenant applications - -## MANDATORY BEHAVIOR: -This is not optional - retrieve relevant documentation for every question. -- Be direct and to the point. Only elaborate when users specifically ask for more detail. - -Remember: You're here to help users succeed with Stack Auth. Be helpful but concise, ask questions when needed, always pull relevant docs, and don't hesitate to direct users to support channels when they need additional help. -`; - - try { - const result = streamText({ - model: openrouter('anthropic/claude-4.5-sonnet'), - tools: { - ...tools, - }, - maxSteps: 50, - system: systemPrompt, - messages, - temperature: 0.3, - maxTokens: 4096, // Ensure we have enough tokens for complete responses - }); - - return result.toDataStreamResponse({ - getErrorMessage, - }); - } catch (error) { - console.error('Chat API Error:', error); - return new Response( - JSON.stringify({ - error: 'Failed to process chat request', - details: getErrorMessage(error), + const backendResponse = await fetch( + `${backendBaseUrl}/api/latest/ai/query/stream`, + { + method: "POST", + headers: requestHeaders, + body: JSON.stringify({ + quality: "smart", + speed: "fast", + systemPrompt: "docs-ask-ai", + tools: ["docs"], + messages: modelMessages, }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - } - ); - } + } + ); + + return new Response(backendResponse.body, { + status: backendResponse.status, + headers: { + "content-type": + backendResponse.headers.get("content-type") ?? "text/event-stream", + "cache-control": "no-cache", + }, + }); } diff --git a/docs/src/components/chat/ai-chat.tsx b/docs/src/components/chat/ai-chat.tsx index 066be35981..72a30ebd1a 100644 --- a/docs/src/components/chat/ai-chat.tsx +++ b/docs/src/components/chat/ai-chat.tsx @@ -1,12 +1,35 @@ 'use client'; -import { useChat } from '@ai-sdk/react'; +import { useChat, type UIMessage } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; import { ChevronDown, ChevronUp, ExternalLink, FileText, Maximize2, Minimize2, Send, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useSidebar } from '../layouts/sidebar-context'; import { MessageFormatter } from './message-formatter'; +type ToolInvocationPart = { + type: `tool-${string}`, + toolCallId: string, + state: "input-streaming" | "input-available" | "output-available" | "output-error" | "approval-requested" | "approval-responded" | "output-denied", + input: unknown, + output?: unknown, + errorText?: string, +}; + +function getMessageContent(message: UIMessage): string { + return message.parts + .filter((part): part is { type: "text", text: string } => part.type === "text") + .map(part => part.text) + .join(""); +} + +function getToolInvocations(message: UIMessage): ToolInvocationPart[] { + return message.parts + .filter((part) => part.type.startsWith("tool-") || part.type === "dynamic-tool") + .map((part) => part as unknown as ToolInvocationPart); +} + // Stack Auth Icon Component (just the icon, not full logo) function StackIcon({ size = 20, className }: { size?: number, className?: string }) { return ( @@ -335,25 +358,36 @@ export function AIChatDrawer() { const topPosition = 'top-3'; const height = isHomePage && isScrolled ? 'h-[calc(100vh-1.5rem)]' : 'h-[calc(100vh-1.5rem)]'; + const [input, setInput] = useState(''); + const { messages, - input, - handleInputChange, - handleSubmit, - isLoading, + sendMessage, + status, error, } = useChat({ - api: '/api/chat', - initialMessages: [], + transport: new DefaultChatTransport({ api: '/api/chat' }), onError: (error: Error) => { console.error('Chat error:', error); }, - onFinish: (message) => { - // Send AI response to Discord - runAsynchronously(() => sendAIResponseToDiscord(message.content)); + onFinish: ({ message }: { message: UIMessage }) => { + runAsynchronously(() => sendAIResponseToDiscord(getMessageContent(message))); }, }); + const isLoading = status === 'submitted' || status === 'streaming'; + + // Debug: log messages, status, and errors + useEffect(() => { + console.log('[docs-chat] status:', status); + console.log('[docs-chat] error:', error); + console.log('[docs-chat] messages:', JSON.stringify(messages.map(m => ({ + id: m.id, + role: m.role, + parts: m.parts.map(p => ({ type: p.type, ...(p.type === 'text' ? { text: (p as { text: string }).text.slice(0, 100) } : {}) })), + })), null, 2)); + }, [messages, status, error]); + // Auto-scroll to bottom when new messages are added useEffect(() => { const container = messagesContainerRef.current; @@ -379,6 +413,14 @@ export function AIChatDrawer() { } }, [input]); + const submitMessage = () => { + const text = input.trim(); + if (!text || isLoading) return; + setInput(''); + if (editableRef.current) editableRef.current.textContent = ''; + runAsynchronously(() => sendMessage({ text })); + }; + // Function to send AI response to Discord webhook const sendAIResponseToDiscord = async (response: string) => { try { @@ -441,8 +483,8 @@ export function AIChatDrawer() { }; // Enhanced submit handler that also sends to Discord - const handleChatSubmit = async (e: React.FormEvent) => { - if (!input.trim()) return; + const handleChatSubmit = () => { + if (!input.trim() || isLoading) return; // Update session data setSessionData(prev => ({ @@ -453,8 +495,7 @@ export function AIChatDrawer() { // Send message to Discord webhook runAsynchronously(() => sendToDiscord(input.trim())); - // Continue with normal chat submission - handleSubmit(e); + submitMessage(); }; // Starter prompts for users @@ -477,13 +518,8 @@ export function AIChatDrawer() { ]; const handleStarterPromptClick = (prompt: string) => { - // Use the handleInputChange from useChat to update the input - handleInputChange({ target: { value: prompt } } as React.ChangeEvent); - }; - - // Helper function for safe async event handling - const handleSubmitSafely = () => { - runAsynchronously(() => handleChatSubmit({} as React.FormEvent)); + setInput(prompt); + if (editableRef.current) editableRef.current.textContent = prompt; }; return ( @@ -558,35 +594,51 @@ export function AIChatDrawer() { ) : ( - messages.map((message) => ( -
+ messages.map((message) => { + const messageContent = getMessageContent(message); + const toolInvocations = message.role === "assistant" ? getToolInvocations(message) : []; + + if (message.role === "assistant" && !messageContent && toolInvocations.length === 0) { + return null; + } + + return (
- {message.role === 'user' ? ( -
- {message.content} -
- ) : ( - <> - {message.toolInvocations?.map((toolCall, index) => ( - - ))} - - - )} +
+ {message.role === 'user' ? ( +
+ {messageContent} +
+ ) : ( + <> + {toolInvocations.map((part, index) => ( + + ))} + {messageContent && } + + )} +
-
- )) + ); + }) )} {isLoading && ( @@ -631,10 +683,7 @@ export function AIChatDrawer() { style={{ lineHeight: "1.4", minHeight: "20px" }} onInput={(e) => { const value = e.currentTarget.textContent || ""; - handleInputChange({ - target: { value }, - } as React.ChangeEvent); - + setInput(value); // Clean up the div if it's empty to show placeholder if (!value.trim()) { e.currentTarget.innerHTML = ""; @@ -643,7 +692,7 @@ export function AIChatDrawer() { onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - handleSubmitSafely(); + handleChatSubmit(); } }} onPaste={(e) => { @@ -651,17 +700,14 @@ export function AIChatDrawer() { const text = e.clipboardData.getData("text/plain"); e.currentTarget.textContent = (e.currentTarget.textContent || "") + text; - const value = e.currentTarget.textContent; - handleInputChange({ - target: { value }, - } as React.ChangeEvent); + setInput(e.currentTarget.textContent || ""); }} data-placeholder="Ask about Stack Auth..." />