diff --git a/apps/backend/.env.development b/apps/backend/.env.development index c6f1072b9a..aa7c0e9586 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -58,9 +58,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 813d93aa63..2c3cf85c26 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..aded240d53 --- /dev/null +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -0,0 +1,136 @@ +import { forwardToProduction } from "@/lib/ai/forward"; +import { selectModel } from "@/lib/ai/models"; +import { getFullSystemPrompt } from "@/lib/ai/prompts"; +import { requestBodySchema } from "@/lib/ai/schema"; +import { getTools, validateToolNames } from "@/lib/ai/tools"; +import { listManagedProjectIds } from "@/lib/projects"; +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 { generateText, ModelMessage, stepCountIs, streamText } from "ai"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + params: yupObject({ + mode: yupString().oneOf(["stream", "generate"]).defined(), + }), + body: requestBodySchema, + }), + response: yupMixed().defined(), + async handler({ params, body }, fullReq) { + const { mode } = params; + + if (!validateToolNames(body.tools)) { + throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`); + } + + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); + + + if (apiKey === "FORWARD_TO_PRODUCTION") { + const prodResponse = await forwardToProduction(mode, body); + return { + statusCode: prodResponse.status, + bodyType: "response" as const, + body: prodResponse, + }; + } + + const isAuthenticated = fullReq.auth != null; + const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages, projectId } = body; + + // Verify user has access to the target project + if (projectId != null) { + const user = fullReq.auth?.user; + if (user == null) { + throw new StatusError(StatusError.Forbidden, "You do not have access to this project"); + } + const managedProjectIds = await listManagedProjectIds(user); + if (!managedProjectIds.includes(projectId)) { + throw new StatusError(StatusError.Forbidden, "You do not have access to this project"); + } + } + + const model = selectModel(quality, speed, isAuthenticated); + const systemPrompt = getFullSystemPrompt(systemPromptId); + const tools = await getTools(toolNames, { auth: fullReq.auth, targetProjectId: projectId }); + const toolsArg = Object.keys(tools).length > 0 ? tools : undefined; + const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai"; + const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5; + + if (mode === "stream") { + const result = streamText({ + model, + system: systemPrompt, + messages: messages as ModelMessage[], + tools: toolsArg, + stopWhen: stepCountIs(stepLimit), + }); + return { + statusCode: 200, + bodyType: "response" as const, + body: result.toUIMessageStreamResponse(), + }; + } else { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 120_000); + const result = await generateText({ + model, + system: systemPrompt, + messages: messages as ModelMessage[], + tools: toolsArg, + abortSignal: controller.signal, + stopWhen: stepCountIs(stepLimit), + }).finally(() => clearTimeout(timeoutId)); + + 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, + }); + } + + const toolResultsByCallId = new Map( + step.toolResults.map((r) => [r.toolCallId, r]) + ); + + step.toolCalls.forEach((toolCall) => { + const toolResult = toolResultsByCallId.get(toolCall.toolCallId); + contentBlocks.push({ + type: "tool-call", + toolName: toolCall.toolName, + toolCallId: toolCall.toolCallId, + args: toolCall.input, + argsText: JSON.stringify(toolCall.input), + result: (toolResult?.output ?? null) as Json, + }); + }); + }); + + 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..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,144 +1,6 @@ -import { getChatAdapter } from "@/lib/ai-chat/adapter-registry"; 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 { 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(), -}); - -// 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; - -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 } }) { - // 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 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"); - } - - // Validate messages structure before passing to AI - 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 - - // 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), - 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.args, - argsText: JSON.stringify(toolCall.args), - 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 deleted file mode 100644 index adb4a40172..0000000000 --- a/apps/backend/src/app/api/latest/internal/wysiwyg-edit/route.tsx +++ /dev/null @@ -1,245 +0,0 @@ -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: -1. You will be given the original source code and details about a text edit the user wants to make. -2. Find the text at the specified location and replace it with the new text. -3. If there are multiple occurrences of the same text, use the provided location info (line, column, occurrence index) to identify the correct one. -4. The text you're given is given as plaintext, so you should escape it properly. Be smart about what the user's intent may have been; if it contains eg. an added newline character, that's because the user added a newline character, so depending on the context sometimes you should replace it with
, sometimes you should create a new

, and sometimes you should do something else. Change it in a good-faith interpretation of what the user may have wanted to do, not in perfect spec-compliance. -5. If the text is part of a template literal or JSX expression, only change the static text portion. -6. Return ONLY the complete updated source code, nothing else. -7. Do NOT add any explanation, markdown formatting, or code fences - just the raw source code. -8. Context: The user is editing the text in a WYSIWYG editor. They expect that the change they made will be reflected as-is, without massively the rest of the source code. However, in most cases, the user don't actually care about the rest of the source code, so in the rare cases where things are complex and you would have to change a bit more than just the text node, you should make the changes that sound reasonable from a UX perspective. -9. If the user added whitespace padding at the very end or the very beginning of the text node, that was probably an accident and you can ignore it. - -IMPORTANT: -- The location info includes: line number, column, source context (lines before/after), JSX path, parent element. -- Use all available information to find the exact text to replace. -`; - -const editMetadataSchema = yupObject({ - id: yupString().defined(), - loc: yupObject({ - start: yupNumber().defined(), - end: yupNumber().defined(), - line: yupNumber().defined(), - column: yupNumber().defined(), - }).defined(), - originalText: yupString().defined(), - textHash: yupString().defined(), - jsxPath: yupArray(yupString().defined()).defined(), - parentElement: yupObject({ - tagName: yupString().defined(), - props: yupMixed().defined(), - }).defined(), - sourceContext: yupObject({ - before: yupString().defined(), - after: yupString().defined(), - }).defined(), - siblingIndex: yupNumber().defined(), - occurrenceCount: yupNumber().defined(), - occurrenceIndex: yupNumber().defined(), - sourceFile: yupString().oneOf(["template", "theme"]).defined(), -}); - -const domPathItemSchema = yupObject({ - tag_name: yupString().defined(), - index: yupNumber().defined(), -}); - -export const POST = createSmartRouteHandler({ - metadata: { - summary: "Apply WYSIWYG text edit", - description: "Uses AI to update source code based on a WYSIWYG text edit", - tags: ["Internal", "AI"], - hidden: true, - }, - request: yupObject({ - auth: yupObject({ - type: yupString().oneOf(["admin"]).defined(), - tenancy: adaptSchema.defined(), - }).defined(), - body: yupObject({ - /** The type of source being edited */ - source_type: yupString().oneOf(["template", "theme", "draft"]).defined(), - /** The current source code to edit */ - source_code: yupString().defined(), - /** The original text that was in the editable region */ - old_text: yupString().defined(), - /** The new text the user wants */ - new_text: yupString().defined(), - /** Metadata from the editable region for locating the text */ - metadata: editMetadataSchema.defined(), - /** DOM path from the iframe for additional context */ - dom_path: yupArray(domPathItemSchema.defined()).defined(), - /** HTML context from the rendered output */ - html_context: yupString().defined(), - }).defined(), - }), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["json"]).defined(), - body: yupObject({ - updated_source: yupString().defined(), - }).defined(), - }), - async handler({ body }) { - const { - source_code, - old_text, - new_text, - metadata, - dom_path, - html_context, - } = body; - - // If no change, return original - if (old_text === new_text) { - return { - statusCode: 200, - bodyType: "json", - body: { updated_source: source_code }, - }; - } - - // Mock mode: perform string replacement at the correct occurrence index without calling AI - if (isMockMode) { - let replacedSource: string; - - // Handle edge case: empty old_text can't be meaningfully replaced - if (old_text === "") { - // Just return original source with the note - replacedSource = source_code; - } else { - // Use occurrence index from metadata to replace the correct occurrence - const occurrenceIndex = metadata.occurrenceIndex; - const parts = source_code.split(old_text); - - // Validate that the occurrence index is valid (1-based index from metadata) - // parts.length - 1 equals the number of occurrences of old_text in source_code - if (occurrenceIndex < 1 || occurrenceIndex > parts.length - 1) { - // Fallback to first occurrence if index is invalid - replacedSource = source_code.replace(old_text, new_text); - } else { - // Replace only the occurrence at the specified index (convert 1-based to 0-based) - const zeroBasedIndex = occurrenceIndex - 1; - replacedSource = parts.slice(0, zeroBasedIndex + 1).join(old_text) + - new_text + - parts.slice(zeroBasedIndex + 1).join(old_text); - } - } - - const updatedSource = `// NOTE: You haven't specified a STACK_OPENROUTER_API_KEY, so we're using a mock mode where we just replace the old text with the new text instead of calling AI.\n\n${replacedSource}`; - return { - statusCode: 200, - bodyType: "json", - body: { updated_source: updatedSource }, - }; - } - - // Build the prompt for the AI - const userPrompt = ` -## Source Code to Edit -\`\`\`tsx -${source_code} -\`\`\` - -## Edit Request -- **Old text:** "${old_text}" -- **New text:** "${new_text}" - -## Location Information -- **Line:** ${metadata.loc.line} -- **Column:** ${metadata.loc.column} -- **JSX Path:** ${metadata.jsxPath.join(" > ")} -- **Parent Element:** <${metadata.parentElement.tagName}> -- **Sibling Index:** ${metadata.siblingIndex} -- **Occurrence:** ${metadata.occurrenceIndex} of ${metadata.occurrenceCount} - -## Source Context (lines around the text) -Before: -\`\`\` -${metadata.sourceContext.before} -\`\`\` - -After: -\`\`\` -${metadata.sourceContext.after} -\`\`\` - -## Runtime DOM Path (for disambiguation) -${dom_path.map((p, i) => `${i + 1}. <${p.tag_name}> (index: ${p.index})`).join("\n")} - -## Rendered HTML Context -\`\`\`html -${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"); - } - - // 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); - } - - // Extract the updated source code from the response - let updatedSource = result.text.trim(); - - // Remove any markdown code fences if the AI added them despite instructions - if (updatedSource.startsWith("```")) { - const lines = updatedSource.split("\n"); - // Remove first line (```tsx or similar) - lines.shift(); - // Remove last line if it's ``` - if (lines[lines.length - 1]?.trim() === "```") { - lines.pop(); - } - updatedSource = lines.join("\n"); - } - - return { - statusCode: 200, - bodyType: "json", - body: { updated_source: updatedSource }, - }; - }, -}); 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 289dadb1d0..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(), - parameters: 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 fc141a7354..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), - parameters: 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 a20b6ded05..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), - parameters: 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/forward.ts b/apps/backend/src/lib/ai/forward.ts new file mode 100644 index 0000000000..5acf449fff --- /dev/null +++ b/apps/backend/src/lib/ai/forward.ts @@ -0,0 +1,17 @@ +import { type RequestBody } from "@/lib/ai/schema"; + +export async function forwardToProduction( + mode: "stream" | "generate", + body: RequestBody, +): Promise { + const productionUrl = `https://api.stack-auth.com/api/latest/ai/query/${mode}`; + const forwardHeaders = new Headers(); + forwardHeaders.set("content-type", "application/json"); + forwardHeaders.set("accept-encoding", "identity"); + + 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..d4a54623d5 --- /dev/null +++ b/apps/backend/src/lib/ai/models.ts @@ -0,0 +1,67 @@ +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" }, + authenticated: { modelId: "anthropic/claude-haiku-4-5" }, + unauthenticated: { modelId: "anthropic/claude-haiku-4-5" }, + }, + 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..1ce70b5af1 --- /dev/null +++ b/apps/backend/src/lib/ai/prompts.ts @@ -0,0 +1,819 @@ +/** + * 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" + | "wysiwyg-edit" + | "email-wysiwyg-editor" + | "email-assistant-template" + | "email-assistant-theme" + | "email-assistant-draft" + | "create-dashboard" + | "run-query" + | "rewrite-template-source"; + +/** + * 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 + + +Run a ClickHouse SQL query against the project's analytics database. Only SELECT queries are allowed. Project filtering is automatic - you don't need WHERE project_id = ... + +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) +- 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 +`, + "docs-ask-ai": ` + # 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. + `, + + "wysiwyg-edit": ` +You are an expert at editing React/JSX code. Your task is to update a specific text string in the source code. + +RULES: +1. You will be given the original source code and details about a text edit the user wants to make. +2. Find the text at the specified location and replace it with the new text. +3. If there are multiple occurrences of the same text, use the provided location info (line, column, occurrence index) to identify the correct one. +4. The text you're given is given as plaintext, so you should escape it properly. Be smart about what the user's intent may have been; if it contains eg. an added newline character, that's because the user added a newline character, so depending on the context sometimes you should replace it with
, sometimes you should create a new

, and sometimes you should do something else. Change it in a good-faith interpretation of what the user may have wanted to do, not in perfect spec-compliance. +5. If the text is part of a template literal or JSX expression, only change the static text portion. +6. Return ONLY the complete updated source code, nothing else. +7. Do NOT add any explanation, markdown formatting, or code fences - just the raw source code. +8. Context: The user is editing the text in a WYSIWYG editor. They expect that the change they made will be reflected as-is, without massively the rest of the source code. However, in most cases, the user don't actually care about the rest of the source code, so in the rare cases where things are complex and you would have to change a bit more than just the text node, you should make the changes that sound reasonable from a UX perspective. +9. If the user added whitespace padding at the very end or the very beginning of the text node, that was probably an accident and you can ignore it. + +IMPORTANT: +- The location info includes: line number, column, source context (lines before/after), JSX path, parent element. +- Use all available information to find the exact text to replace. +`, + + "email-wysiwyg-editor": ` +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. + +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-template": ` +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. + +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. + +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. +- Import email components only from \`@react-email/components\`, schema types from \`arktype\`, and Stack Auth helpers from \`@stackframe/emails\` (Subject, NotificationCategory, Props). +- 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. +- YOU MUST call the \`createEmailTemplate\` tool with the complete code. NEVER output code directly in the chat. +- Output raw TSX source code — NEVER HTML-encode angle brackets. Write \`\`, not \`<Container>\`. +- NEVER use bare & in JSX text content — it is invalid JSX and causes a build error. Use \`&\` or \`{"&"}\` instead. +`, + + "email-assistant-theme": ` +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. + +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": ` +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. Do NOT use Tailwind classes that require style injection (e.g., hover:, focus:, active:, dark:, group-hover:, media queries). Only use inlineable Tailwind utilities. +- Export 'EmailTemplate' component. +- Import email components only from \`@react-email/components\` and Stack Auth helpers from \`@stackframe/emails\` (Subject, NotificationCategory, Props). +- EVERY component you use in JSX must be explicitly imported. If you use \`
\`, import \`Hr\`. Never use a component without importing it. +- YOU MUST call the \`createEmailTemplate\` tool with the complete code. NEVER output code directly in the chat. +- Output raw TSX source code — NEVER HTML-encode angle brackets. Write \`\`, not \`<Container>\`. +- NEVER use bare & in JSX text content — it is invalid JSX and causes a build error. Use \`&\` or \`{"&"}\` instead. + +The current source code will be provided in the conversation messages. +`, + + "create-dashboard": ` +[IDENTITY] +You are an analytics dashboard builder and editor for Stack Auth. +You create new dashboards and modify existing ones by producing complete React/JSX source code. + +Your output is used to render a real UI. Therefore: prioritize clarity, relevance, and visual explanation over text. + +──────────────────────────────────────── +CRITICAL: API ACCESS METHOD (HARD RULE) +──────────────────────────────────────── +You MUST use the global stackServerApp instance (already initialized). +Authentication is handled automatically - the SDK fetches access tokens from the parent window as needed. + +You MUST NOT create a new StackServerApp or StackAdminApp instance. +You MUST NOT use fetch() directly. + +IMPORTANT: All Stack API calls are async and may fail. ALWAYS: +1. Wrap API calls in try-catch blocks +2. Set error state when calls fail +3. Show user-friendly error messages (not technical details) +4. Log errors to console for debugging: console.error('[Dashboard]', error) + +Example: +try { + const users = await stackServerApp.listUsers({ includeAnonymous: true }); + setData(users); +} catch (error) { + console.error('[Dashboard] Failed to load users:', error); + setError('Failed to load user data'); +} + +await stackServerApp.getProject() // Admin API +await stackServerApp.listInternalApiKeys() // Admin API + +Violating this is a failure condition. + +──────────────────────────────────────── +RUNTIME CONTRACT (HARD RULES) +──────────────────────────────────────── +- Define a React functional component named "Dashboard" (no props) +- Use hooks via the React global object: React.useState, React.useEffect, React.useCallback +- DashboardUI components are available via the global DashboardUI object (e.g. DashboardUI.DesignMetricCard) +- Recharts is available via the global Recharts object (e.g. Recharts.BarChart) +- Use stackServerApp for all Stack API calls +- Both light and dark mode are supported automatically — do NOT hardcode colors + +No import/export/require statements. No external networking calls. + +──────────────────────────────────────── +EDITING BEHAVIOR (when existing code is provided) +──────────────────────────────────────── +- When the user provides existing dashboard source code, modify it according to their request. +- Always preserve parts of the dashboard the user didn't ask to change. +- If the user asks to add something, add it without removing existing content. +- If the user asks to change styling, colors, or layout, make those changes while preserving functionality. +- Always call the updateDashboard tool with the COMPLETE updated source code — no partial code or diffs. + +──────────────────────────────────────── +CORE DATA FETCHING RULES (STACK) +──────────────────────────────────────── +Users: +- stackServerApp.listUsers(options?) + - ALWAYS set includeAnonymous: true + - Prefer limit: 500 (or higher only if clearly necessary) + - Avoid pagination/cursor unless the UI explicitly needs it + - Result is an array that may contain .nextCursor; treat it as an array for normal usage + +Teams: +- stackServerApp.listTeams(options?) → Promise + +Project: +- stackServerApp.getProject() → Promise + +Important: +- Use camelCase options (includeAnonymous) +- The SDK handles auth/retries/errors; still show graceful UI states + +──────────────────────────────────────── +CHART RULES (RECHARTS REQUIRED) +──────────────────────────────────────── +- Every dashboard MUST include at least one chart. +- Choose chart types that match the question: + - Trends over time → LineChart / AreaChart + - Comparisons/top-N → BarChart + - Distributions → PieChart (or BarChart if many categories) +- Always wrap charts in ResponsiveContainer. +- Use XAxis/YAxis + Tooltip; include CartesianGrid when useful. +- If the query is time-series, ALWAYS show a time-series chart. + +Do not overwhelm: 1–2 charts maximum. + +──────────────────────────────────────── +LAYOUT & DESIGN RULES (PRACTICAL) +──────────────────────────────────────── +Use this container baseline: +
+ + +Header: +- Clear title that matches the question +- Optional Refresh button using DashboardUI.DesignButton (disabled while loading) + +Metrics: +- 2–4 DashboardUI.DesignMetricCard in a CSS grid: +
+- Use the trend prop to show up/down/neutral direction +- Keep titles short + +Charts: +- Wrap Recharts in DashboardUI.DesignChartCard + DashboardUI.DesignChartContainer +- Always use DashboardUI.DesignChartTooltipContent and DashboardUI.DesignChartLegendContent +- Use DashboardUI.getDesignChartColor(index) for consistent colors + +Tables (optional): +- Use DashboardUI.DesignTable with sub-components +- Only include if it helps answer the question + +Loading & Errors: +- Always show DashboardUI.DesignSkeleton while loading +- Disable interactions during loading +- If an error happens, show a small, user-friendly message in the UI (non-technical) +- Use DashboardUI.DesignEmptyState when there is no data to display + +DASHBOARD UI COMPONENTS: See the DashboardUI type definitions provided in context. +All accessed as DashboardUI.. Light/dark mode is automatic. +AVAILABLE DASHBOARD UI COMPONENTS (via DashboardUI.*) +──────────────────────────────────────── +All components are accessed as DashboardUI.. No imports needed. +Light and dark mode are handled automatically via CSS variables. + +METRIC CARDS (use for KPI / big numbers): + + +GENERAL-PURPOSE CARD (for text-only content like titles, descriptions, headings): + + Any content goes here + + Text-only cards (bodyOnly variant) are automatically transparent in dark mode. + Do NOT add padding (p-6, p-5, etc.) to DesignCard className — the component already has built-in padding. + +CHART COMPONENTS (wrapping Recharts): + + + + + + + } /> + } /> + + + + + + chartConfig format: { [dataKey]: { label: "Human Name", color: DashboardUI.getDesignChartColor(index) } } + + TABLE: + + + + Name + Email + + + + {rows.map(row => ( + + {row.name} + {row.email} + + ))} + + + +OTHER COMPONENTS: + + + + + + + +──────────────────────────────────────── +LAYOUT +──────────────────────────────────────── +You have FULL FREEDOM over the page layout. Use standard JSX with CSS classes (flexbox, CSS grid, spacing, typography) to design the overall page however looks best. +Example: + + function Dashboard() { + const [loading, setLoading] = React.useState(true); + const [users, setUsers] = React.useState(null); + const [error, setError] = React.useState(null); + const [showControls] = React.useState(!!window.__showControls); + const [chatOpen, setChatOpen] = React.useState(!!window.__chatOpen); + React.useEffect(() => { + const handler = () => { setChatOpen(!!window.__chatOpen); }; + window.addEventListener('chat-state-change', handler); + return () => window.removeEventListener('chat-state-change', handler); + }, []); + React.useEffect(() => { + stackServerApp.listUsers({ includeAnonymous: true }) + .then(result => { setUsers(result); setLoading(false); }) + .catch(err => { setError(String(err)); setLoading(false); }); + }, []); + if (loading) return
Loading...
; + if (error) return
{error}
; + + const totalUsers = users.length; + const verifiedUsers = users.filter(u => u.primaryEmailVerified).length; + + return ( +
+ {showControls && ( +
+ {!chatOpen && window.dashboardBack()} className="bg-background/70 dark:bg-background/50 backdrop-blur-xl shadow-lg ring-1 ring-foreground/[0.08] text-foreground/80 hover:text-foreground hover:bg-background/90 dark:hover:bg-background/70">← Back} + window.dashboardEdit()} className="ml-auto bg-background/70 dark:bg-background/50 backdrop-blur-xl shadow-lg ring-1 ring-foreground/[0.08] text-foreground/80 hover:text-foreground hover:bg-background/90 dark:hover:bg-background/70">{chatOpen ? "Done" : "Edit"} +
+ )} + +

User Analytics

+

Overview of your user base

+
+
+ window.dashboardNavigate('/users')} className="cursor-pointer hover:bg-foreground/[0.02] transition-colors hover:transition-none" /> + window.dashboardNavigate('/users')} className="cursor-pointer hover:bg-foreground/[0.02] transition-colors hover:transition-none" /> +
+ + + {/* Recharts chart here */} + + +
+ ); + } + +──────────────────────────────────────── +RECHARTS (via Recharts.*) +──────────────────────────────────────── +Use via Recharts.* — always wrap in DashboardUI.DesignChartContainer: +- Recharts.LineChart, Recharts.BarChart, Recharts.AreaChart, Recharts.PieChart +- Recharts.XAxis, Recharts.YAxis, Recharts.CartesianGrid +- Recharts.Line, Recharts.Bar, Recharts.Area, Recharts.Cell +- Recharts.ResponsiveContainer (used internally by DesignChartContainer — do NOT wrap again) + +Use DashboardUI.DesignChartTooltipContent for Recharts.Tooltip content prop. +Use DashboardUI.DesignChartLegendContent for Recharts.Legend content prop. +Use DashboardUI.getDesignChartColor(index) for consistent chart colors. +Use "hsl(var(--border))" for CartesianGrid stroke and "hsl(var(--muted-foreground))" for axis tick fill. + +TYPE DEFINITIONS +The type definitions for the Stack SDK and dashboard UI components will be provided in the user messages. +Use them to determine available fields, methods, prop types, and variants. + +CLICKHOUSE (queryAnalytics only) +Available tables: + +events: +- event_type: LowCardinality(String) ($token-refresh only) +- event_at: DateTime64(3, 'UTC') +- data: JSON +- user_id: Nullable(String) +- team_id: Nullable(String) +- created_at: DateTime64(3, 'UTC') + +users (limited fields): +- id: UUID +- display_name: Nullable(String) +- primary_email: Nullable(String) +- primary_email_verified: UInt8 (0/1) +- signed_up_at: DateTime64(3, 'UTC') +- client_metadata: JSON +- client_read_only_metadata: JSON +- server_metadata: JSON +- is_anonymous: UInt8 (0/1) + +──────────────────────────────────────── +NAVIGATION API (postMessage-based) +──────────────────────────────────────── +These global functions are pre-defined in the iframe runtime. Call them directly: +- window.dashboardNavigate(path) — navigate the parent dashboard to a relative path + IMPORTANT: Only use paths from the AVAILABLE DASHBOARD ROUTES list provided in the context. + The user's project may not have all apps installed, so only link to routes that are listed. +- window.dashboardBack() — go back to the dashboards list +- window.dashboardEdit() — open/close the edit chat panel + +──────────────────────────────────────── +CLICKABLE CARDS & NAVIGATION +──────────────────────────────────────── +- When a card represents a navigable entity (users, teams, etc.), make it clickable + and call window.dashboardNavigate(path) on click, using ONLY paths from the + AVAILABLE DASHBOARD ROUTES list provided in the context. Do NOT invent paths. +- Use cursor-pointer class and a hover tint on clickable cards: + className="cursor-pointer hover:bg-foreground/[0.02] transition-colors hover:transition-none" + +──────────────────────────────────────── +BACK & EDIT CONTROLS (conditional) +──────────────────────────────────────── +- The host sets window.__showControls and window.__chatOpen at runtime. +- Only render Back/Edit when __showControls is true (false in cmd+K preview). +- Listen for 'chat-state-change' events to track __chatOpen. Hide Back when chat is open. +- The Edit/Done button calls window.dashboardEdit() to toggle edit mode. Show "Done" when chatOpen is true, "Edit" when false. +- Use ml-auto on the Edit/Done button so it stays on the right side even when the Back button is hidden. +- See the example above for the exact implementation pattern. + +──────────────────────────────────────── +PRIMARY OBJECTIVE +──────────────────────────────────────── +Build a dashboard that directly answers THE USER'S SPECIFIC QUESTION. + +A "generic analytics dashboard" is wrong. +Every card, chart, and table must exist only because it helps answer the query. + +──────────────────────────────────────── +DASHBOARD REQUIREMENTS (HARD RULES) +──────────────────────────────────────── +1) Read the user's query carefully. Build ONLY what answers it. +2) The dashboard MUST include at least one Recharts chart that visualizes the answer. + - Text-only dashboards are not allowed. +3) Keep it concise: + - 2–4 metric cards + - 1–2 charts + - Optional: a small table ONLY if it adds decision-useful detail +4) Never show technical details in the UI: + - No API names, method names, SDK details, types, or implementation notes. +5) Use professional, clean design: + - Clear hierarchy, good spacing, good contrast, readable labels. +6) Format numbers cleanly: + - Round percentages and decimal values to at most 2 decimal places (e.g. 12.34%, not 12.3456%). + - Use whole numbers when the decimal adds no value (e.g. "1,234 users", not "1234.0 users"). + - Use toLocaleString() or Intl.NumberFormat for thousand separators on large numbers. +7) EVERY card that represents a navigable entity (user, team, etc.) MUST be clickable. + - Use window.dashboardNavigate(path) with ONLY paths from the AVAILABLE DASHBOARD ROUTES list. + - Add cursor-pointer and hover tint: className="cursor-pointer hover:bg-foreground/[0.02] transition-colors hover:transition-none" + - This is non-negotiable — cards without links to their relevant page are a failure condition. + +──────────────────────────────────────── +DEFAULT-TO-ACTION BEHAVIOR +──────────────────────────────────────── +By default, implement the dashboard (data fetch + transformation + UI) rather than suggesting ideas. +If the user's intent is slightly ambiguous, infer the most useful dashboard and proceed. + +──────────────────────────────────────── +EXAMPLES (MENTAL MODEL, NOT UI TEXT) +──────────────────────────────────────── +Query: "how many users do I have?" +→ Total users card, verified card, anonymous card, signup trend chart + +Query: "what users came from oauth providers?" +→ OAuth vs email cards, provider distribution chart (Google/GitHub/etc.) + +Query: "show me user growth over time" +→ Total users card, net-new in period card, growth rate card, line chart + +Query: "which teams have the most users?" +→ Total teams card, avg users per team card, bar chart of top teams + +You MUST call the updateDashboard tool with the complete source code. NEVER output code directly in the chat. +`, + + "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 +`, + + "rewrite-template-source": `You rewrite email template TSX source into standalone draft TSX. + +Requirements: +1) Keep exactly one exported EmailTemplate component. +2) Remove variables schema declarations and preview variable assignments. + - Remove exports like variablesSchema regardless of symbol name. For example, you may see export const profileSchema = ... which should be removed too. + - Remove EmailTemplate.PreviewVariables assignment. +3) Adjust EmailTemplate props: + - It must not rely on a variables prop from outside. user and project are fine as props + - Define "const variables = { ... }" inside EmailTemplate with sensible placeholder values based on the schema/types present in source. + - It should be the only exported function in the file. +4) Preserve subject/notification/category and existing JSX structure as much as possible. +5) Fix imports after removal. +6) Return only raw TSX source, without markdown code fences. +`, +}; + +/** + * 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..e27a5abe27 --- /dev/null +++ b/apps/backend/src/lib/ai/schema.ts @@ -0,0 +1,29 @@ +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", + "wysiwyg-edit", + "email-wysiwyg-editor", + "email-assistant-template", + "email-assistant-theme", + "email-assistant-draft", + "create-dashboard", + "run-query", + "rewrite-template-source" + ]).defined(), + messages: yupArray( + yupObject({ + role: yupString().oneOf(["user", "assistant", "tool"]).defined(), + content: yupMixed().defined(), + }).defined() + ).defined().min(1), + projectId: yupString().optional().nullable(), +}); + +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..380a8eb27b --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-dashboard.ts @@ -0,0 +1,18 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { tool } from "ai"; +import { z } from "zod"; + +/** + * Tool for updating/creating dashboard source code. + * + * This tool does NOT execute server-side - it returns the tool call to the caller, + * who is responsible for rendering the dashboard code in a sandbox. + */ +export function updateDashboardTool(auth: SmartRequestAuth | null) { + return tool({ + description: "Update the dashboard with new source code. The source code must define a React functional component named 'Dashboard' (no props). It runs inside a sandboxed iframe with React, Recharts, DashboardUI, and stackServerApp available as globals. No imports, exports, or require statements.", + inputSchema: z.object({ + content: z.string().describe("The complete updated JSX source code for the Dashboard component"), + }), + }); +} 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..12d9a66e6e --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-email-draft.ts @@ -0,0 +1,49 @@ +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. +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}!
+
+
+ ); +} +\`\`\` + +The user's current email draft can be found in the conversation messages. +`, + inputSchema: z.object({ + 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 new file mode 100644 index 0000000000..a35d0ceaea --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-email-template.ts @@ -0,0 +1,63 @@ +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. +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 } 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 +\`\`\` + +The user's current email template can be found in the conversation messages. +`, + inputSchema: z.object({ + 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 new file mode 100644 index 0000000000..2af8307c25 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/create-email-theme.ts @@ -0,0 +1,47 @@ +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. +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} + + + ) +} +\`\`\` + +The user's current email theme can be found in the conversation messages. +`, + inputSchema: z.object({ + 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/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..8430b0ec04 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/index.ts @@ -0,0 +1,95 @@ +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { ToolSet } from "ai"; +import { updateDashboardTool } 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" + | "update-dashboard"; + +export type ToolContext = { + auth: SmartRequestAuth | null, + targetProjectId?: string | null, +}; + +export async function getTools( + toolNames: ToolName[], + context: ToolContext +): Promise { + const tools: ToolSet = {}; + + for (const toolName of toolNames) { + switch (toolName) { + case "docs": { + const docsTools = await createDocsTools(); + Object.assign(tools, docsTools); + break; + } + + case "sql-query": { + const sqlTool = createSqlQueryTool(context.auth, context.targetProjectId); + if (sqlTool != null) { + tools["queryAnalytics"] = sqlTool; + } + 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 "update-dashboard": { + tools["updateDashboard"] = updateDashboardTool(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", + "update-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..fa6c5f7800 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -0,0 +1,52 @@ +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, targetProjectId?: string | null) { + if (auth == null) { + // Return null or throw - analytics queries require authentication + return null; + } + + const projectId = targetProjectId ?? auth.tenancy.project.id; + const branchId = targetProjectId ? "main" : auth.tenancy.branchId; + + return 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 }: { query: string }) => { + const client = getClickhouseExternalClient(); + return await client.query({ + query, + clickhouse_settings: { + SQL_project_id: projectId, + SQL_branch_id: branchId, + max_execution_time: 5, + readonly: "1", + allow_ddl: 0, + max_result_rows: "10000", + max_result_bytes: (10 * 1024 * 1024).toString(), + result_overflow_mode: "throw", + }, + format: "JSONEachRow", + }) + .then(async (resultSet) => { + const rows = await resultSet.json[]>(); + return { + success: true as const, + rowCount: rows.length, + result: rows, + }; + }) + .catch((error: unknown) => ({ + success: false as const, + error: error instanceof Error ? error.message : "Query failed", + })); + }, + }); +} diff --git a/apps/backend/src/lib/email-template-rewrite.ts b/apps/backend/src/lib/email-template-rewrite.ts index 7b22a47a1c..3960dc4b36 100644 --- a/apps/backend/src/lib/email-template-rewrite.ts +++ b/apps/backend/src/lib/email-template-rewrite.ts @@ -1,57 +1,42 @@ import { renderEmailWithTemplate } from "@/lib/email-rendering"; -import { createOpenAI } from "@ai-sdk/openai"; import { emptyEmailTheme } from "@stackframe/stack-shared/dist/helpers/emails"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; -import { generateText } from "ai"; const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key"; const AI_REQUEST_TIMEOUT_MS = 120_000; const MAX_REWRITE_ATTEMPTS = 3; -const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL); -const isMockMode = apiKey === MOCK_API_KEY_SENTINEL; -const openai = isMockMode ? null : createOpenAI({ - apiKey, - baseURL: "https://openrouter.ai/api/v1", -}); - -const TEMPLATE_REWRITE_SYSTEM_PROMPT = deindent` - You rewrite email template TSX source into standalone draft TSX. - - Requirements: - 1) Keep exactly one exported EmailTemplate component. - 2) Remove variables schema declarations and preview variable assignments. - - Remove exports like variablesSchema regardless of symbol name. For example, you may see export const profileSchema = ... which should be removed too. - - Remove EmailTemplate.PreviewVariables assignment. - 3) Make EmailTemplate standalone: - - It must not rely on a variables prop from outside. - - Define "const variables = { ... }" inside EmailTemplate with sensible placeholder values based on the schema/types present in source. - - It should be the only exported function in the file. - 4) Preserve subject/notification/category and existing JSX structure as much as possible. - 5) Fix imports after removal. - 6) Return only raw TSX source, without markdown code fences. -`; +function isMockMode() { + const key = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL); + return key === MOCK_API_KEY_SENTINEL || key === "FORWARD_TO_PRODUCTION"; +} async function rewriteTemplateSourceWithCurrentAIPlumbing(templateTsxSource: string): Promise> { - if (!openai) { - return Result.error("OpenAI client not initialized - STACK_OPENROUTER_API_KEY may be missing"); - } - - // Keep consistent with other AI routes. - const modelName = getEnvVariable("STACK_AI_MODEL"); + const backendUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); try { - const response = await generateText({ - model: openai(modelName), - system: TEMPLATE_REWRITE_SYSTEM_PROMPT, - messages: [{ role: "user", content: templateTsxSource }], - abortSignal: controller.signal, + const response = await fetch(`${backendUrl}/api/latest/ai/query/generate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + quality: "smart", + speed: "slow", + tools: [], + systemPrompt: "rewrite-template-source", + messages: [{ role: "user", content: templateTsxSource }], + projectId: null, + }), + signal: controller.signal, }); - return Result.ok(stripCodeFences(response.text)); + if (!response.ok) { + return Result.error(`AI endpoint returned ${response.status}: ${await response.text()}`); + } + const json = await response.json() as { content: Array<{ type: string, text?: string }> }; + const text = json.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join(""); + return Result.ok(stripCodeFences(text)); } catch (error) { return Result.error(error instanceof Error ? error.message : String(error)); } finally { @@ -131,7 +116,7 @@ function stripCodeFences(text: string): string { } export async function rewriteTemplateSourceWithAI(templateTsxSource: string): Promise> { - if (isMockMode) { + if (isMockMode()) { const mockRewrittenSource = rewriteTemplateSourceInMockMode(templateTsxSource); const mockRenderResult = await renderEmailWithTemplate(mockRewrittenSource, emptyEmailTheme, { previewMode: true, @@ -145,7 +130,6 @@ export async function rewriteTemplateSourceWithAI(templateTsxSource: string): Pr let lastError = "Unknown rewrite failure"; for (let attempt = 0; attempt < MAX_REWRITE_ATTEMPTS; attempt++) { - // TODO: Switch this adapter to unified AI endpoint once PR #1240 is merged. const rewriteResult = await rewriteTemplateSourceWithCurrentAIPlumbing(templateTsxSource); if (rewriteResult.status === "error") { lastError = rewriteResult.error; @@ -165,7 +149,7 @@ export async function rewriteTemplateSourceWithAI(templateTsxSource: string): Pr captureError("email-template-rewrite-failed-after-retries", new StackAssertionError( "Template rewrite failed after all retries", { - isMockMode, + isMockMode: isMockMode(), maxRewriteAttempts: MAX_REWRITE_ATTEMPTS, lastError, }, 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/dashboard/.env.development b/apps/dashboard/.env.development index 4b14a42eaf..c431a5ee70 100644 --- a/apps/dashboard/.env.development +++ b/apps/dashboard/.env.development @@ -12,5 +12,3 @@ STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50 NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=false STACK_FEATUREBASE_JWT_SECRET=secret-value - -STACK_OPENAI_API_KEY=mock_openai_api_key diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore index fd3dbb571a..30989b0cea 100644 --- a/apps/dashboard/.gitignore +++ b/apps/dashboard/.gitignore @@ -34,3 +34,10 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# local dev IIFE bundles (built locally, not committed) +/public/dashboard-ui-components.iife.js +/public/dashboard-ui-components.iife.js.map + +# auto-generated files (rebuilt at build time and in dev watch) +/src/generated/bundled-type-definitions.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c23c1667c3..80ad5a7611 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -8,16 +8,17 @@ "typecheck": "tsc --noEmit", "with-env": "dotenv -c development --", "with-env:prod": "dotenv -c --", - "dev": "next dev --turbopack --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01", - "build": "next build", - "docker-build": "next build --experimental-build-mode compile", + "dev": "concurrently -n \"dev,bundle-type-defs\" -k \"next dev --turbopack --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01\" \"pnpm run bundle-type-definitions:watch\"", + "bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts", + "bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts", + "build": "pnpm run bundle-type-definitions && next build", + "docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile", "analyze-bundle": "next experimental-analyze", "start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01", "psql": "pnpm run with-env bash -c 'psql $STACK_DATABASE_CONNECTION_STRING'", "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", @@ -59,6 +60,7 @@ "@radix-ui/react-tooltip": "^1.1.3", "@react-hook/resize-observer": "^2.0.2", "@sentry/nextjs": "^10.11.0", + "@stackframe/dashboard-ui-components": "workspace:*", "@stackframe/stack": "workspace:*", "@stackframe/stack-shared": "workspace:*", "@stripe/connect-js": "^3.3.27", @@ -83,6 +85,7 @@ "jose": "^6.1.3", "lodash": "^4.17.21", "next": "16.1.5", + "next-themes": "^0.2.1", "posthog-js": "^1.336.1", "react": "19.2.3", "react-day-picker": "^9.6.7", @@ -114,6 +117,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/three": "^0.169.0", "autoprefixer": "^10.4.17", + "concurrently": "^8.2.2", "glob": "^10.4.1", "import-in-the-middle": "1.14.2", "postcss": "^8.4.38", diff --git a/apps/dashboard/scripts/bundle-type-definitions.ts b/apps/dashboard/scripts/bundle-type-definitions.ts new file mode 100644 index 0000000000..3165bcff05 --- /dev/null +++ b/apps/dashboard/scripts/bundle-type-definitions.ts @@ -0,0 +1,134 @@ +import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs'; +import { glob } from 'glob'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +type TypeDefinitionFile = { + path: string, + content: string, +}; + +async function main() { + console.log('[Bundle Type Definitions] Finding Stack SDK type definition files...'); + + const rootPath = path.resolve(process.cwd(), '../..'); + const stackAppPath = path.join(rootPath, 'packages/template/src/lib/stack-app'); + const outputPath = path.join(rootPath, 'apps/dashboard/src/generated/bundled-type-definitions.ts'); + + const files = await glob(`${stackAppPath}/**/*.ts`, { + ignore: [ + `${stackAppPath}/**/implementations/**`, + `${stackAppPath}/**/utils/**`, + `${stackAppPath}/**/*.d.ts`, + `${stackAppPath}/**/global.css`, + ], + }); + files.sort(); + + console.log(`[Bundle Type Definitions] Found ${files.length} type definition files`); + + const bundledFiles: TypeDefinitionFile[] = []; + + for (const filePath of files) { + const relativePath = path.relative(stackAppPath, filePath); + const content = await fs.readFile(filePath, 'utf8'); + + bundledFiles.push({ + path: relativePath, + content, + }); + } + + // Bundle dashboard-ui-components type declarations (auto-generated by tsdown build) + // The build produces per-file .d.ts files, so we concatenate all of them to give + // the AI the full type definitions (not just the re-export barrel). + let dashboardUITypes = ''; + try { + const dashboardUIDistPath = path.join(rootPath, 'packages/dashboard-ui-components/dist'); + const dtsFiles = await glob(`${dashboardUIDistPath}/**/*.d.ts`, { + ignore: [`${dashboardUIDistPath}/**/*.d.ts.map`], + }); + dtsFiles.sort(); + + const parts: string[] = []; + for (const dtsFile of dtsFiles) { + const content = await fs.readFile(dtsFile, 'utf8'); + parts.push(content); + } + let raw = parts.join('\n'); + + // Deduplicate #region blocks + const seenRegions = new Set(); + const lines = raw.split('\n'); + const dedupedLines: string[] = []; + let skipUntilEndRegion = false; + let currentRegionKey = ''; + for (const line of lines) { + const regionMatch = line.match(/^\/\/\s*#region\s+(.+)/); + if (regionMatch) { + currentRegionKey = regionMatch[1].trim(); + if (seenRegions.has(currentRegionKey)) { + skipUntilEndRegion = true; + continue; + } + seenRegions.add(currentRegionKey); + } + if (skipUntilEndRegion) { + if (/^\/\/\s*#endregion/.test(line)) { + skipUntilEndRegion = false; + } + continue; + } + dedupedLines.push(line); + } + raw = dedupedLines.join('\n'); + + // Strip imports, exports, sourcemap comments + raw = raw + .replace(/^import\s+\*\s+as\s+.*$/gm, '') + .replace(/^import\s+\{[^}]*\}\s+from\s+["'][^"']*["']\s*;?\s*$/gm, '') + .replace(/^export\s*\{[^}]*\}\s*;?\s*$/gm, '') + .replace(/^\/\/#\s*sourceMappingURL=.*$/gm, ''); + + // Remove internal-only component type files + const internalComponents = ['cursor-blast-effect.d.ts', 'edit-mode.d.ts', 'resize-handle.d.ts']; + for (const internal of internalComponents) { + const regex = new RegExp(`\\/\\/\\s*#region\\s+${internal.replace('.', '\\.')}[\\s\\S]*?\\/\\/\\s*#endregion[^\\n]*`, 'g'); + raw = raw.replace(regex, ''); + } + + // Collapse multiple blank lines + raw = raw.replace(/\n{3,}/g, '\n\n').trim(); + + dashboardUITypes = raw; + console.log(`[Bundle Type Definitions] Loaded dashboard-ui-components types from ${dtsFiles.length} .d.ts files (${(dashboardUITypes.length / 1024).toFixed(2)} KB)`); + } catch { + console.warn('[Bundle Type Definitions] Warning: dashboard-ui-components dist not found. Run the package build first.'); + } + + console.log('[Bundle Type Definitions] Generating bundled-type-definitions.ts...'); + + const output = `// This file is auto-generated by scripts/bundle-type-definitions.ts +// Do not edit manually - changes will be overwritten + +export type TypeDefinitionFile = { + path: string, + content: string, +}; + +export const BUNDLED_TYPE_DEFINITIONS: TypeDefinitionFile[] = ${JSON.stringify(bundledFiles, null, 2)}; + +export const BUNDLED_DASHBOARD_UI_TYPES: string = ${JSON.stringify(dashboardUITypes)}; +`; + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + writeFileSyncIfChanged(outputPath, output); + + console.log(`[Bundle Type Definitions] Generated ${outputPath}`); + console.log(`[Bundle Type Definitions] Total size: ${(output.length / 1024).toFixed(2)} KB, ${bundledFiles.length} files bundled`); +} + +main().catch((...args) => { + console.error('[Bundle Type Definitions] ERROR! Failed to bundle type definitions:', ...args); + process.exit(1); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index df3416ad75..9c58911db4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -10,14 +10,16 @@ import { DesignButton, DesignCard, DesignCategoryTabs, + DesignInput, + DesignPillToggle, +} from "@stackframe/dashboard-ui-components"; +import { DesignDataTable, DesignEditableGrid, type DesignEditableGridItem, type DesignEditableGridSize, - DesignInput, DesignListItemRow, DesignMenu, - DesignPillToggle, DesignSelectorDropdown, DesignUserList, } from "@/components/design-components"; diff --git a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx index 84e0cfbb67..c82dcf7c90 100644 --- a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx @@ -1,7 +1,7 @@ "use client"; import Loading from "@/app/loading"; -import { CursorBlastEffect } from "@/components/design-components/cursor-blast-effect"; +import { CursorBlastEffect } from "@stackframe/dashboard-ui-components"; import { ConfigUpdateDialogProvider } from "@/lib/config-update"; import { getPublicEnvVar } from '@/lib/env'; import { useStackApp, useUser } from "@stackframe/stack"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx new file mode 100644 index 0000000000..76f620ee50 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx @@ -0,0 +1,482 @@ + +"use client"; + +import { DashboardSandboxHost } from "@/components/commands/create-dashboard/dashboard-sandbox-host"; +import { useRouter, useRouterConfirm } from "@/components/router"; +import { ActionDialog, Button, Typography } from "@/components/ui"; +import { Input } from "@/components/ui/input"; +import { + AssistantChat, + createDashboardChatAdapter, + createHistoryAdapter, + DashboardToolUI, +} from "@/components/vibe-coding"; +import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { useUpdateConfig } from "@/lib/config-update"; +import { cn } from "@/lib/utils"; +import { + FloppyDiskIcon, + PencilSimpleIcon, + TrashIcon, + XIcon, +} from "@phosphor-icons/react"; +import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import type { AppId } from "@/lib/apps-frontend"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { getPublicEnvVar } from "@/lib/env"; +import { useUser } from "@stackframe/stack"; +import { usePathname } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp, useProjectId } from "../../use-admin-app"; + +function useDashboardId(): string { + const pathname = usePathname(); + const parts = pathname.split("/"); + const dashboardsIdx = parts.indexOf("dashboards"); + return parts[dashboardsIdx + 1] ?? throwErr("Dashboard ID not found in path"); +} + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const projectId = useProjectId(); + const currentUser = useUser({ or: "redirect" }); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + const router = useRouter(); + const dashboardId = useDashboardId(); + const [hasEverExisted, setHasEverExisted] = useState(false); + + const enabledAppIds = useMemo(() => + typedEntries(config.apps.installed) + .filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS) + .map(([appId]) => appId as AppId), + [config.apps.installed] + ); + + const dashboard = config.customDashboards[dashboardId] as + | typeof config.customDashboards[string] + | undefined; + + useEffect(() => { + if (dashboard) { + setHasEverExisted(true); + } + }, [dashboard]); + + useEffect(() => { + if (hasEverExisted && !dashboard) { + router.replace(`/projects/${projectId}/dashboards`); + } + }, [hasEverExisted, dashboard, router, projectId]); + + if (!dashboard) { + return null; + } + + return ( + + ); +} + +function DashboardDetailContent({ + dashboardId, + displayName, + tsxSource, + projectId, + adminApp, + updateConfig, + router, + currentUser, + backendBaseUrl, + enabledAppIds, +}: { + dashboardId: string, + displayName: string, + tsxSource: string, + projectId: string, + adminApp: ReturnType, + updateConfig: ReturnType, + router: ReturnType, + currentUser: NonNullable>, + backendBaseUrl: string, + enabledAppIds: AppId[], +}) { + const composerPlaceholder = useTypingPlaceholder( + "Create a dashboard about ", + DASHBOARD_PLACEHOLDER_SUFFIXES, + ); + + const hasSource = tsxSource.length > 0; + const [isChatOpen, setIsChatOpen] = useState(!hasSource); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [currentTsxSource, setCurrentTsxSource] = useState(tsxSource); + const [savedTsxSource, setSavedTsxSource] = useState(tsxSource); + const hasUnsavedChanges = currentTsxSource !== savedTsxSource; + const { setNeedConfirm } = useRouterConfirm(); + useEffect(() => { + if (!hasUnsavedChanges) return; + setNeedConfirm(true); + return () => setNeedConfirm(false); + }, [setNeedConfirm, hasUnsavedChanges]); + + const [isEditingName, setIsEditingName] = useState(false); + const [editedName, setEditedName] = useState(displayName); + + const artifact = useMemo(() => ({ + prompt: displayName, + projectId, + runtimeCodegen: { + title: displayName, + description: "", + uiRuntimeSourceCode: currentTsxSource, + }, + }), [displayName, projectId, currentTsxSource]); + + const handleBack = useCallback(() => { + router.push(`/projects/${projectId}/dashboards`); + }, [router, projectId]); + + const currentHasSource = currentTsxSource.length > 0; + + const handleEditToggle = useCallback(() => { + if (!currentHasSource) return; + setIsChatOpen(prev => !prev); + }, [currentHasSource]); + + const handleNavigate = useCallback((path: string) => { + router.push(`/projects/${projectId}${path}`); + }, [router, projectId]); + + const handleCodeUpdate = useCallback((toolCall: ToolCallContent) => { + setCurrentTsxSource(toolCall.args.content); + }, []); + + const handleSaveDashboard = useCallback(async () => { + await updateConfig({ + adminApp, + configUpdate: { + [`customDashboards.${dashboardId}.tsxSource`]: currentTsxSource, + }, + pushable: false, + }); + setSavedTsxSource(currentTsxSource); + }, [updateConfig, adminApp, dashboardId, currentTsxSource]); + + const handleSaveName = async () => { + const trimmed = editedName.trim(); + if (!trimmed || trimmed === displayName) { + setEditedName(displayName); + setIsEditingName(false); + return; + } + await updateConfig({ + adminApp, + configUpdate: { + [`customDashboards.${dashboardId}.displayName`]: trimmed, + }, + pushable: false, + }); + setIsEditingName(false); + }; + + const handleDelete = async () => { + await updateConfig({ + adminApp, + configUpdate: { + [`customDashboards.${dashboardId}`]: null, + }, + pushable: false, + }); + router.replace(`/projects/${projectId}/dashboards`); + }; + + const dashboardPreview = currentHasSource ? ( + + ) : ( +
+
+
+ + + +
+ No dashboard yet + + Describe what you'd like to see in the chat to generate your dashboard. + +
+
+ ); + + return ( + + {/* Both panels are always in the DOM so the iframe never unmounts/reloads. + The chat panel animates its width; the dashboard panel adjusts via flex-1. */} +
+ {/* Dashboard iframe panel */} +
+
+ {dashboardPreview} +
+
+ + {/* Chat panel — slides in from the right. min-w on the inner card prevents content + squishing during the width animation (overflow-hidden clips the excess). */} +
+
+
+ { + setEditedName(displayName); + setIsEditingName(true); + }} + onEditedNameChange={setEditedName} + onSaveName={handleSaveName} + onCancelEditName={() => { + setEditedName(displayName); + setIsEditingName(false); + }} + onDelete={() => setDeleteDialogOpen(true)} + onClose={currentHasSource ? () => setIsChatOpen(false) : undefined} + hasUnsavedChanges={hasUnsavedChanges} + onSaveDashboard={handleSaveDashboard} + /> +
+ } + useOffWhiteLightMode + composerPlaceholder={currentHasSource ? undefined : composerPlaceholder} + /> +
+
+
+
+
+ + setDeleteDialogOpen(false)} + title="Delete Dashboard" + okButton={{ + label: "Delete", + onClick: handleDelete, + props: { variant: "destructive" }, + }} + cancelButton={{ label: "Cancel" }} + > + + Are you sure you want to delete "{displayName}"? This action cannot be undone. + + +
+ ); +} + +const DASHBOARD_PLACEHOLDER_SUFFIXES = [ + "user signups and retention", + "team activity across projects", + "API latency and error rates", + "email open rates and clicks", + "authentication trends", + "revenue and subscription growth", +]; + +function useTypingPlaceholder( + prefix: string, + suffixes: readonly string[], + { typeSpeed = 70, deleteSpeed = 40, pauseAfterType = 2000, pauseAfterDelete = 400 } = {}, +): string { + const [suffixText, setSuffixText] = useState(""); + const state = useRef({ + suffixIndex: 0, + charIndex: 0, + phase: "typing" as "typing" | "pausing" | "deleting" | "waiting", + }); + + useEffect(() => { + let timeoutId: ReturnType; + + function tick() { + const s = state.current; + const target = suffixes[s.suffixIndex % suffixes.length]; + + switch (s.phase) { + case "typing": { + if (s.charIndex < target.length) { + s.charIndex++; + setSuffixText(target.slice(0, s.charIndex)); + timeoutId = setTimeout(tick, typeSpeed); + } else { + s.phase = "pausing"; + timeoutId = setTimeout(tick, pauseAfterType); + } + break; + } + case "pausing": { + s.phase = "deleting"; + timeoutId = setTimeout(tick, deleteSpeed); + break; + } + case "deleting": { + if (s.charIndex > 0) { + s.charIndex--; + setSuffixText(target.slice(0, s.charIndex)); + timeoutId = setTimeout(tick, deleteSpeed); + } else { + s.phase = "waiting"; + timeoutId = setTimeout(tick, pauseAfterDelete); + } + break; + } + case "waiting": { + s.suffixIndex = (s.suffixIndex + 1) % suffixes.length; + s.charIndex = 0; + s.phase = "typing"; + timeoutId = setTimeout(tick, typeSpeed); + break; + } + } + } + + timeoutId = setTimeout(tick, 500); + return () => clearTimeout(timeoutId); + }, [suffixes, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete]); + + return prefix + suffixText; +} + +function ChatPanelHeader({ + displayName, + isEditingName, + editedName, + onStartEditName, + onEditedNameChange, + onSaveName, + onCancelEditName, + onDelete, + onClose, + hasUnsavedChanges, + onSaveDashboard, +}: { + displayName: string, + isEditingName: boolean, + editedName: string, + onStartEditName: () => void, + onEditedNameChange: (name: string) => void, + onSaveName: () => Promise, + onCancelEditName: () => void, + onDelete: () => void, + onClose?: () => void, + hasUnsavedChanges: boolean, + onSaveDashboard: () => Promise, +}) { + return ( +
+
+
+ {isEditingName ? ( + onEditedNameChange(e.target.value)} + className="h-7 text-sm flex-1" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + runAsynchronouslyWithAlert(onSaveName); + } + if (e.key === "Escape") { + onCancelEditName(); + } + }} + onBlur={() => runAsynchronouslyWithAlert(onSaveName)} + /> + ) : ( + + )} +
+ +
+ {hasUnsavedChanges && ( + + )} + + {onClose && ( + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page.tsx new file mode 100644 index 0000000000..57acc3e46f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Dashboard", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx new file mode 100644 index 0000000000..11bc249889 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { DesignListItemRow } from "@/components/design-components"; +import { FormDialog } from "@/components/form-dialog"; +import { InputField } from "@/components/form-fields"; +import { useRouter } from "@/components/router"; +import { ActionDialog, Button, Typography } from "@/components/ui"; +import { useUpdateConfig } from "@/lib/config-update"; +import { + ChartBarIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { DesignCard } from "@stackframe/dashboard-ui-components"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { useMemo, useState } from "react"; +import * as yup from "yup"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +type DashboardEntry = { + id: string, + displayName: string, +}; + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + const router = useRouter(); + const [deleteDialogId, setDeleteDialogId] = useState(null); + + const dashboards = useMemo((): DashboardEntry[] => { + return Object.entries(config.customDashboards).map(([id, dashboard]) => ({ + id, + displayName: (dashboard as { displayName: string }).displayName, + })); + }, [config.customDashboards]); + + const dashboardToDelete = deleteDialogId + ? dashboards.find(d => d.id === deleteDialogId) + : null; + + const handleDelete = async (id: string) => { + await updateConfig({ + adminApp, + configUpdate: { + [`customDashboards.${id}`]: null, + }, + pushable: false, + }); + setDeleteDialogId(null); + }; + + return ( + + } + > + {dashboards.length === 0 ? ( + +
+
+ +
+
+ No dashboards yet + + Create a dashboard from the command palette (Cmd+K) or click "New Dashboard" above. + +
+
+
+ ) : ( +
+ {dashboards.map((dashboard) => ( + router.push(`/projects/${project.id}/dashboards/${dashboard.id}`)} + buttons={[ + { + id: "delete", + label: "Delete", + icon: , + display: "icon", + onClick: [ + { + id: "delete-action", + label: "Delete Dashboard", + onClick: () => setDeleteDialogId(dashboard.id), + itemVariant: "destructive" as const, + }, + ], + }, + ]} + /> + ))} +
+ )} + + setDeleteDialogId(null)} + title="Delete Dashboard" + okButton={{ + label: "Delete", + onClick: async () => { + if (deleteDialogId) { + await handleDelete(deleteDialogId); + } + }, + props: { variant: "destructive" }, + }} + cancelButton={{ label: "Cancel" }} + > + + Are you sure you want to delete "{dashboardToDelete?.displayName ?? "this dashboard"}"? This action cannot be undone. + + +
+ ); +} + +function NewDashboardButton({ + adminApp, + updateConfig, + router, +}: { + adminApp: ReturnType, + updateConfig: ReturnType, + router: ReturnType, +}) { + const handleCreate = async (values: { name: string }) => { + const id = generateUuid(); + await updateConfig({ + adminApp, + configUpdate: { + [`customDashboards.${id}`]: { + displayName: values.name, + tsxSource: "", + }, + }, + pushable: false, + }); + router.push(`/projects/${adminApp.projectId}/dashboards/${id}`); + }; + + return ( + + + New Dashboard + + } + onSubmit={handleCreate} + formSchema={yup.object({ + name: yup.string().defined().min(1, "Name is required"), + })} + render={(form) => ( + + )} + /> + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page.tsx new file mode 100644 index 0000000000..d3cd9e87d8 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Custom Dashboards", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx index 6ed389b46e..315540701e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx @@ -8,15 +8,17 @@ import { DesignCard, DesignCardTint, DesignCategoryTabs, + DesignInput, + DesignPillToggle, +} from "@stackframe/dashboard-ui-components"; +import { DesignDataTable, DesignEditableGrid, type DesignEditableGridItem, - DesignInput, DesignListItemRow, DesignMenu, - DesignPillToggle, DesignSelectorDropdown, - DesignUserList + DesignUserList, } from "@/components/design-components"; import { Link } from "@/components/link"; import { 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 7a275e99d3..543e03168b 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 @@ -1,20 +1,21 @@ "use client"; import { TeamMemberSearchTable } from "@/components/data-table/team-member-search-table"; -import { DesignBadge, DesignBadgeColor } from "@/components/design-components/badge"; -import { DesignButton } from "@/components/design-components/button"; -import { DesignCard } from "@/components/design-components/card"; -import { DesignDataTable } from "@/components/design-components/table"; +import { DesignButton } from "@/components/design-components"; +import { DesignCard } from "@/components/design-components"; import EmailPreview, { type OnWysiwygEditCommit } from "@/components/email-preview"; import { EmailThemeSelector } from "@/components/email-theme-selector"; import { useRouter, useRouterConfirm } from "@/components/router"; import { ActionDialog, Alert, AlertDescription, AlertTitle, Badge, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, Spinner, Typography } from "@/components/ui"; import { AssistantChat, CodeEditor, VibeCodeLayout, type ViewportMode, type WysiwygDebugInfo } from "@/components/vibe-coding"; -import { ToolCallContent, createChatAdapter, createHistoryAdapter } from "@/components/vibe-coding/chat-adapters"; +import { ToolCallContent, applyWysiwygEdit, createChatAdapter, createHistoryAdapter } from "@/components/vibe-coding/chat-adapters"; import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components"; +import { useUser } from "@stackframe/stack"; +import { getPublicEnvVar } from "@/lib/env"; import { PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react"; import { AdminEmailOutbox, AdminEmailOutboxStatus } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { ColumnDef } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -34,6 +35,8 @@ function isValidStage(stage: string | null): stage is DraftStage { export default function PageClient({ draftId }: { draftId: string }) { const stackAdminApp = useAdminApp(); + const currentUser = useUser({ or: "redirect" }); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"); const router = useRouter(); const searchParams = useSearchParams(); const { setNeedConfirm } = useRouterConfirm(); @@ -151,7 +154,8 @@ export default function PageClient({ draftId }: { draftId: string }) { // Handle WYSIWYG edit commits - calls the AI endpoint to update source code const handleWysiwygEditCommit: OnWysiwygEditCommit = useCallback(async (data) => { - const result = await stackAdminApp.applyWysiwygEdit({ + const result = await applyWysiwygEdit(backendBaseUrl, { + currentUser, sourceType: 'draft', sourceCode: currentCode, oldText: data.oldText, @@ -162,7 +166,7 @@ export default function PageClient({ draftId }: { draftId: string }) { }); setCurrentCode(result.updatedSource); return result.updatedSource; - }, [stackAdminApp, currentCode]); + }, [backendBaseUrl, currentCode, currentUser]); return ( @@ -229,7 +233,7 @@ export default function PageClient({ draftId }: { draftId: string }) { chatComponent={ currentCode, currentUser)} toolComponents={} useOffWhiteLightMode /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx index 2e247a9155..42671d8f7c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignCard } from "@/components/design-components"; import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/domain-reputation-card.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/domain-reputation-card.tsx index 8fd5745a1a..08fd771a52 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/domain-reputation-card.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/domain-reputation-card.tsx @@ -1,7 +1,7 @@ "use client"; -import { DesignButton } from "@/components/design-components/button"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignButton } from "@/components/design-components"; +import { DesignCard } from "@/components/design-components"; import { SimpleTooltip, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; import { Gauge } from "@phosphor-icons/react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-status-utils.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-status-utils.tsx index 17a0c0f645..70e8f8d5ea 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-status-utils.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/email-status-utils.tsx @@ -1,4 +1,4 @@ -import { DesignBadgeColor } from "@/components/design-components/badge"; +import { DesignBadgeColor } from "@/components/design-components"; import { AdminEmailOutboxStatus } from "@stackframe/stack"; import { StatsBarData } from "./stats-bar"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/grouped-email-table.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/grouped-email-table.tsx index a0001e70ce..b6fc5a2a67 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/grouped-email-table.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/grouped-email-table.tsx @@ -1,6 +1,6 @@ "use client"; -import { DesignDataTable } from "@/components/design-components/table"; +import { DesignDataTable } from "@/components/design-components"; import { useRouter } from "@/components/router"; import { Spinner, Typography } from "@/components/ui"; import { AdminEmailOutbox, AdminEmailOutboxStatus } from "@stackframe/stack"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx index ce9c6ad331..17021cc3f0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/page-client.tsx @@ -1,9 +1,9 @@ "use client"; -import { DesignBadge } from "@/components/design-components/badge"; -import { DesignCard } from "@/components/design-components/card"; -import { DesignPillToggle } from "@/components/design-components/pill-toggle"; -import { DesignDataTable } from "@/components/design-components/table"; +import { DesignBadge } from "@/components/design-components"; +import { DesignCard } from "@/components/design-components"; +import { DesignPillToggle } from "@/components/design-components"; +import { DesignDataTable } from "@/components/design-components"; import { useRouter } from "@/components/router"; import { Spinner, Typography } from "@/components/ui"; import { Envelope } from "@phosphor-icons/react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx index 704ca8059b..367cede8f5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-sent/sent-emails-view.tsx @@ -1,8 +1,8 @@ "use client"; -import { DesignBadge } from "@/components/design-components/badge"; -import { DesignCard } from "@/components/design-components/card"; -import { DesignDataTable } from "@/components/design-components/table"; +import { DesignBadge } from "@/components/design-components"; +import { DesignCard } from "@/components/design-components"; +import { DesignDataTable } from "@/components/design-components"; import { useRouter } from "@/components/router"; import { Spinner, Typography } from "@/components/ui"; import { Envelope } from "@phosphor-icons/react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx index f58d62f155..8b8772e18d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx @@ -2,7 +2,7 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignCard } from "@/components/design-components"; import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; @@ -12,10 +12,10 @@ import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { ArrowsClockwise, Envelope, GearSix, GlobeSimple, PaperPlaneTilt } from "@phosphor-icons/react"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { DesignAlert } from "@/components/design-components/alert"; -import { DesignButton } from "@/components/design-components/button"; -import { DesignInput } from "@/components/design-components/input"; -import { DesignSelectorDropdown } from "@/components/design-components/select"; +import { DesignAlert } from "@/components/design-components"; +import { DesignButton } from "@/components/design-components"; +import { DesignInput } from "@/components/design-components"; +import { DesignSelectorDropdown } from "@/components/design-components"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Label, Typography, useToast } from "@/components/ui"; import { SimpleTooltip } from "@/components/ui/simple-tooltip"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/theme-settings.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/theme-settings.tsx index 0a26bd4432..2f1bd54ef8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/theme-settings.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/theme-settings.tsx @@ -1,7 +1,7 @@ "use client"; -import { DesignButton } from "@/components/design-components/button"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignButton } from "@/components/design-components"; +import { DesignCard } from "@/components/design-components"; import EmailPreview from "@/components/email-preview"; import { useRouter } from "@/components/router"; import { Typography } from "@/components/ui"; 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 8d547b8506..75d6b6f4a7 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 @@ -14,16 +14,22 @@ import { type ViewportMode, type WysiwygDebugInfo, } from "@/components/vibe-coding"; -import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { applyWysiwygEdit, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { getPublicEnvVar } from "@/lib/env"; +import { useUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { useCallback, useEffect, useRef, useState } from "react"; + 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 currentUser = useUser({ or: "redirect" }); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"); const templates = stackAdminApp.useEmailTemplates(); const { setNeedConfirm } = useRouterConfirm(); const templateFromHook = templates.find((t) => t.id === props.templateId); @@ -145,7 +151,8 @@ export default function PageClient(props: { templateId: string }) { // Handle WYSIWYG edit commits - calls the AI endpoint to update source code const handleWysiwygEditCommit: OnWysiwygEditCommit = useCallback(async (data) => { - const result = await stackAdminApp.applyWysiwygEdit({ + const result = await applyWysiwygEdit(backendBaseUrl, { + currentUser, sourceType: 'template', sourceCode: currentCode, oldText: data.oldText, @@ -156,7 +163,7 @@ export default function PageClient(props: { templateId: string }) { }); setCurrentCode(result.updatedSource); return result.updatedSource; - }, [stackAdminApp, currentCode]); + }, [backendBaseUrl, currentCode, currentUser]); if (!template) { // Show loading state while waiting for the template (either from hook or direct fetch) @@ -270,7 +277,7 @@ export default function PageClient(props: { templateId: string }) { } chatComponent={ currentCode, currentUser)} historyAdapter={createHistoryAdapter(stackAdminApp, template.id)} toolComponents={} useOffWhiteLightMode diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx index 97b9491822..a6ad5364e2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignCard } from "@/components/design-components"; import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; 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..4f0b778e5c 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 @@ -5,12 +5,16 @@ import { useRouterConfirm } from "@/components/router"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui"; import { AssistantChat, CodeEditor, EmailThemeUI, VibeCodeLayout, type ViewportMode, type WysiwygDebugInfo } from "@/components/vibe-coding"; import { + applyWysiwygEdit, createChatAdapter, createHistoryAdapter, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { getPublicEnvVar } from "@/lib/env"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { useUser } from "@stackframe/stack"; import { useCallback, useEffect, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { useAdminApp } from "../../use-admin-app"; @@ -18,6 +22,8 @@ import { useAdminApp } from "../../use-admin-app"; export default function PageClient({ themeId }: { themeId: string }) { const stackAdminApp = useAdminApp(); + const currentUser = useUser({ or: "redirect" }); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"); const theme = stackAdminApp.useEmailTheme(themeId); const { setNeedConfirm } = useRouterConfirm(); const [currentCode, setCurrentCode] = useState(theme.tsxSource); @@ -31,7 +37,8 @@ export default function PageClient({ themeId }: { themeId: string }) { // Handle WYSIWYG edit commits - calls the AI endpoint to update source code const handleWysiwygEditCommit: OnWysiwygEditCommit = useCallback(async (data) => { - const result = await stackAdminApp.applyWysiwygEdit({ + const result = await applyWysiwygEdit(backendBaseUrl, { + currentUser, sourceType: 'theme', sourceCode: currentCode, oldText: data.oldText, @@ -42,7 +49,7 @@ export default function PageClient({ themeId }: { themeId: string }) { }); setCurrentCode(result.updatedSource); return result.updatedSource; - }, [stackAdminApp, currentCode]); + }, [backendBaseUrl, currentCode, currentUser]); useEffect(() => { if (theme.tsxSource === currentCode) return; @@ -124,7 +131,7 @@ export default function PageClient({ themeId }: { themeId: string }) { } chatComponent={ currentCode, currentUser)} historyAdapter={createHistoryAdapter(stackAdminApp, themeId)} toolComponents={} useOffWhiteLightMode diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx index d7d8443d70..3c146c2e05 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignCard } from "@/components/design-components"; import EmailPreview, { DEVICE_VIEWPORTS, DeviceViewport } from "@/components/email-preview"; import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-viewer/[emailId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-viewer/[emailId]/page-client.tsx index 1479101960..fa6b800822 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-viewer/[emailId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-viewer/[emailId]/page-client.tsx @@ -2,7 +2,7 @@ import EmailPreview from "@/components/email-preview"; import { EmailThemeSelector } from "@/components/email-theme-selector"; -import { DesignButton } from "@/components/design-components/button"; +import { DesignButton } from "@/components/design-components"; import { ActionDialog, Alert, AlertDescription, AlertTitle, Badge, Button, Input, Label, Spinner, Typography, useToast } from "@/components/ui"; import { CodeEditor, VibeCodeLayout, type ViewportMode } from "@/components/vibe-coding"; import { cn } from "@/lib/utils"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx index e48292974d..a97c02bc10 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx @@ -1,8 +1,8 @@ "use client"; import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; -import { DesignBadge } from "@/components/design-components/badge"; -import { DesignCard } from "@/components/design-components/card"; +import { DesignBadge } from "@/components/design-components"; +import { DesignCard } from "@/components/design-components"; import { ActionDialog, Spinner, Switch } from "@/components/ui"; import { useUpdateConfig } from "@/lib/config-update"; import { ShieldCheck } from "@phosphor-icons/react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx index abfa4deb83..f834f927dd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx @@ -17,9 +17,10 @@ import { toast, Typography, } from "@/components/ui"; +import { SubpageHeader } from "@/components/design-components/subpage-header"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; -import { ArrowLeftIcon, ClockIcon, HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react"; +import { ClockIcon, HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { useState } from "react"; @@ -270,37 +271,28 @@ function EditProductForm({ productId, existingProduct }: { productId: string, ex return (
- {/* Header */} -
-
- - Edit Product -
-
- - - - -
-
+ + + + + } + /> {/* Main content - form on left, preview on right */}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx index f6896da345..064691797d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx @@ -25,9 +25,10 @@ import { toast, Typography, } from "@/components/ui"; +import { SubpageHeader } from "@/components/design-components/subpage-header"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; -import { ArrowLeftIcon, ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, ClockIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react"; +import { ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, ClockIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@stackframe/stack-shared/dist/schema-fields"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -115,18 +116,7 @@ function CustomerTypeSelection({ return (
-
- - Create Product -
+
@@ -620,95 +610,86 @@ ${Object.entries(prices).map(([id, price]) => { return (
- {/* Header */} -
-
- - Create Product -
-
- - {isInlineProduct ? ( - - ) : ( - -
- - + ) : ( + +
+ - {isSaving ? "Creating..." : "Create Product"} - - -
- - + +
+ + + +
+ + { + const code = generateInlineProductCode(); + runAsynchronouslyWithAlert(async () => { + await navigator.clipboard.writeText(code); + toast({ title: "Code copied to clipboard" }); + }); + }} + className="flex items-center gap-2" > - - -
-
- - { - const code = generateInlineProductCode(); - runAsynchronouslyWithAlert(async () => { - await navigator.clipboard.writeText(code); - toast({ title: "Code copied to clipboard" }); - }); - }} - className="flex items-center gap-2" - > - - Copy inline product code - - { - const prompt = generateInlineProductPrompt(); - runAsynchronouslyWithAlert(async () => { - await navigator.clipboard.writeText(prompt); - toast({ title: "Prompt copied to clipboard" }); - }); - }} - className="flex items-center gap-2" - > - - Copy prompt for inline product - - - - )} -
-
+ + Copy inline product code + + { + const prompt = generateInlineProductPrompt(); + runAsynchronouslyWithAlert(async () => { + await navigator.clipboard.writeText(prompt); + toast({ title: "Prompt copied to clipboard" }); + }); + }} + className="flex items-center gap-2" + > + + Copy prompt for inline product + + +
+ )} + + } + /> {/* Main content - form on left, preview on right */}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 0cc477d5d6..c3ce4bf149 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -24,6 +24,7 @@ import { cn } from "@/lib/utils"; import { CaretDownIcon, CaretRightIcon, + ChartBarIcon, CubeIcon, GearIcon, GlobeIcon, @@ -99,6 +100,14 @@ const overviewItem: Item = { type: 'item' }; +const dashboardsItem: Item = { + name: "Dashboards", + href: "/dashboards", + regex: /^\/projects\/[^\/]+\/dashboards(\/.*)?$/, + icon: ChartBarIcon, + type: 'item', +}; + function NavItem({ item, href, @@ -448,6 +457,12 @@ function SidebarContent({ href={`/projects/${projectId}${overviewItem.href}`} isCollapsed={isCollapsed} /> +
@@ -558,6 +573,9 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) { const [isCollapsed, setIsCollapsed] = useState(false); const projectId = useProjectId(); + const pathname = usePathname(); + // Custom dashboard detail pages have a transparent iframe background; the companion should match. + const isCustomDashboardPage = /\/dashboards\/[^/]+/.test(pathname); const toggleCollapsed = useCallback(() => { setIsCollapsed(prev => !prev); @@ -667,7 +685,7 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) { {/* Stack Companion - overlay with reserved content gutter */}
- +
diff --git a/apps/dashboard/src/app/api/ai-search/route.ts b/apps/dashboard/src/app/api/ai-search/route.ts deleted file mode 100644 index b803d0a8b3..0000000000 --- a/apps/dashboard/src/app/api/ai-search/route.ts +++ /dev/null @@ -1,139 +0,0 @@ -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`; - -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; - - if (messages.length === 0) { - return new Response(JSON.stringify({ error: "Messages are required" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - // Get authenticated user - const user = await stackServerApp.getUser({ or: "redirect" }); - - // Check if we have a projectId and user owns the project - let adminApp: Awaited>[number]["app"] | null = null; - if (projectId) { - const projects = await user.listOwnedProjects(); - const project = projects.find(p => p.id === projectId); - if (project) { - adminApp = project.app; - } - } - - // 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; - - const result = streamText({ - model: openai("gpt-5.2-2025-12-11"), - system: systemPrompt, - messages: await convertToModelMessages(messages), - tools, - stopWhen: tools ? stepCountIs(5) : undefined, - }); - - return result.toUIMessageStreamResponse(); -} diff --git a/apps/dashboard/src/components/assistant-ui/markdown-text.tsx b/apps/dashboard/src/components/assistant-ui/markdown-text.tsx index cc489c7fdc..2c105b64b8 100644 --- a/apps/dashboard/src/components/assistant-ui/markdown-text.tsx +++ b/apps/dashboard/src/components/assistant-ui/markdown-text.tsx @@ -2,7 +2,6 @@ import "@assistant-ui/react-markdown/styles/dot.css"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; import { CodeHeaderProps, @@ -10,8 +9,7 @@ import { unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock, } from "@assistant-ui/react-markdown"; -import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; -import { FC, memo, useState } from "react"; +import { FC, memo } from "react"; import remarkGfm from "remark-gfm"; const MarkdownTextImpl = () => { @@ -26,45 +24,15 @@ const MarkdownTextImpl = () => { export const MarkdownText = memo(MarkdownTextImpl); -const CodeHeader: FC = ({ language, code }) => { - const { isCopied, copyToClipboard } = useCopyToClipboard(); - const onCopy = () => { - if (!code || isCopied) return; - copyToClipboard(code); - }; - +const CodeHeader: FC = ({ language }) => { + if (!language) return null; return ( -
+
{language} - - {!isCopied && } - {isCopied && } -
); }; -const useCopyToClipboard = ({ - copiedDuration = 3000, -}: { - copiedDuration?: number, -} = {}) => { - const [isCopied, setIsCopied] = useState(false); - - const copyToClipboard = (value: string) => { - if (!value) return; - - navigator.clipboard.writeText(value).then(() => { - setIsCopied(true); - setTimeout(() => setIsCopied(false), copiedDuration); - }).catch(() => { - setIsCopied(false); - }); - }; - - return { isCopied, copyToClipboard }; -}; - const defaultComponents = memoizeMarkdownComponents({ h1: ({ className, ...props }) => (

diff --git a/apps/dashboard/src/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index d2e5477389..73392e502d 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -7,54 +7,58 @@ import { ThreadPrimitive, } from "@assistant-ui/react"; import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle } from "@phosphor-icons/react"; -import type { FC } from "react"; +import { createContext, useContext, type FC } from "react"; + +const HideMessageActionsContext = createContext(false); import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui"; -export const Thread: FC<{ useOffWhiteLightMode?: boolean }> = ({ useOffWhiteLightMode = false }) => { +export const Thread: FC<{ useOffWhiteLightMode?: boolean, composerPlaceholder?: string, hideMessageActions?: boolean }> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false }) => { return ( - + - + - + > + - + - -
- + +
+ -
- - -
- - + + +
+ + + ); }; @@ -119,13 +123,13 @@ const ThreadWelcomeSuggestions: FC = () => { ); }; -const Composer: FC = () => { +const Composer: FC<{ placeholder?: string }> = ({ placeholder }) => { return (
@@ -180,6 +184,8 @@ const UserMessage: FC = () => { }; const UserActionBar: FC = () => { + const hidden = useContext(HideMessageActionsContext); + if (hidden) return null; return ( { }; const AssistantActionBar: FC = () => { + const hidden = useContext(HideMessageActionsContext); + if (hidden) { + return null; + } + return ( { className="text-muted-foreground flex gap-0.5 -ml-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150 data-[floating]:bg-background/95 data-[floating]:backdrop-blur-sm data-[floating]:absolute data-[floating]:rounded-lg data-[floating]:ring-1 data-[floating]:ring-foreground/[0.06] data-[floating]:p-1 data-[floating]:shadow-md" > - + @@ -286,7 +297,7 @@ const AssistantActionBar: FC = () => { - + diff --git a/apps/dashboard/src/components/cmdk-commands.tsx b/apps/dashboard/src/components/cmdk-commands.tsx index 789473d55a..febaa2baf1 100644 --- a/apps/dashboard/src/components/cmdk-commands.tsx +++ b/apps/dashboard/src/components/cmdk-commands.tsx @@ -12,6 +12,7 @@ import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/ import Image from "next/image"; import React, { memo, useEffect, useMemo } from "react"; import { AIChatPreview } from "./commands/ask-ai"; +import { CreateDashboardPreview } from "./commands/create-dashboard/create-dashboard-preview"; import { RunQueryPreview } from "./commands/run-query"; export type CmdKPreviewProps = { @@ -25,44 +26,14 @@ export type CmdKPreviewProps = { registerNestedCommands: (commands: CmdKCommand[]) => void, /** Navigate into the nested column (call after registering commands) */ navigateToNested: () => void, + /** Close the command center dialog */ + onClose: () => void, /** Current nesting depth (0 = first preview) */ depth: number, /** Current pathname for checking active state */ pathname: string, }; -// Create Dashboard Preview Component - shows a TODO message for now -const CreateDashboardPreview = memo(function CreateDashboardPreview({ - query, -}: CmdKPreviewProps) { - return ( -
-
-
- -
-
-

Create Dashboard

-

- Generate custom dashboards for your users. -

-
-
-

Your query:

-

“{query}”

-
-
-

- 🚧 Coming Soon — This feature is under development. - Soon you'll be able to create custom dashboards like “analytics overview”, - “user management panel”, or “team activity feed”. -

-
-
-
- ); -}); - // Available App Preview Component - shows app store page in preview panel const AvailableAppPreview = memo(function AvailableAppPreview({ appId, diff --git a/apps/dashboard/src/components/cmdk-search.tsx b/apps/dashboard/src/components/cmdk-search.tsx index 35449a37bf..d6e7201470 100644 --- a/apps/dashboard/src/components/cmdk-search.tsx +++ b/apps/dashboard/src/components/cmdk-search.tsx @@ -600,6 +600,8 @@ export function CmdKSearch({ setSelectedIndex(index); }, []); + const handleClose = useCallback(() => setOpen(false), []); + // Track pending focus action to handle click-then-focus timing const pendingFocusRef = useRef(false); @@ -825,6 +827,7 @@ export function CmdKSearch({ onBlur: handleBackFromPreview, registerNestedCommands: registerNestedCommandsDepth0, navigateToNested: navigateToNestedDepth1, + onClose: handleClose, depth: 0, pathname, })} @@ -913,6 +916,7 @@ export function CmdKSearch({ onBlur: handlePreviewBlur, registerNestedCommands: registerNestedCommandsDepth0, navigateToNested: navigateToNestedDepth1, + onClose: handleClose, depth: 0, pathname, })} @@ -932,6 +936,7 @@ export function CmdKSearch({ onBlur: handlePreviewBlur, registerNestedCommands: registerNestedCommandsDepth0, navigateToNested: navigateToNestedDepth1, + onClose: handleClose, depth: 0, pathname, })} diff --git a/apps/dashboard/src/components/commands/ask-ai.tsx b/apps/dashboard/src/components/commands/ask-ai.tsx index ec8638fe50..131d773f0c 100644 --- a/apps/dashboard/src/components/commands/ask-ai.tsx +++ b/apps/dashboard/src/components/commands/ask-ai.tsx @@ -1,9 +1,13 @@ import { cn } from "@/components/ui"; import { useDebouncedAction } from "@/hooks/use-debounced-action"; +import { buildStackAuthHeaders } from "@/lib/api-headers"; +import { getPublicEnvVar } from "@/lib/env"; +import { useChat, type UIMessage } from "@ai-sdk/react"; import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react"; +import { useUser } from "@stackframe/stack"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; -import { useChat, type UIMessage } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; +import { convertToModelMessages, DefaultChatTransport } from "ai"; import { usePathname } from "next/navigation"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; @@ -533,12 +537,12 @@ const AIChatPreviewInner = memo(function AIChatPreview({ const followUpInputRef = useRef(null); const lastMessageCountRef = useRef(0); const isNearBottomRef = useRef(true); - - // Extract projectId from URL path (e.g., /projects/abc123/...) + const currentUser = useUser(); const pathname = usePathname(); - const projectId = pathname.startsWith("/projects/") ? pathname.split("/")[2] : null; + const projectId = pathname.startsWith("/projects/") ? pathname.split("/")[2] : undefined; const trimmedQuery = query.trim(); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set"); const { messages, @@ -547,8 +551,25 @@ const AIChatPreviewInner = memo(function AIChatPreview({ error: aiError, } = useChat({ transport: new DefaultChatTransport({ - api: "/api/ai-search", - body: { projectId }, + api: `${backendBaseUrl}/api/latest/ai/query/stream`, + headers: () => buildStackAuthHeaders(currentUser), + prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { + const modelMessages = await convertToModelMessages(uiMessages); + return { + body: { + systemPrompt: "command-center-ask-ai", + tools: ["docs", "sql-query"], + quality: "smart", + speed: "slow", + projectId, + messages: modelMessages.map(m => ({ + role: m.role, + content: m.content, + })), + }, + headers, + }; + }, }), }); diff --git a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx new file mode 100644 index 0000000000..a74139887f --- /dev/null +++ b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { useRouter } from "@/components/router"; +import { Button } from "@/components/ui"; +import { generateDashboardCode } from "@/components/vibe-coding/chat-adapters"; +import { useDebouncedAction } from "@/hooks/use-debounced-action"; +import type { AppId } from "@/lib/apps-frontend"; +import { useUpdateConfig } from "@/lib/config-update"; +import { getPublicEnvVar } from "@/lib/env"; +import { cn } from "@/lib/utils"; +import { FloppyDiskIcon } from "@phosphor-icons/react"; +import { useUser } from "@stackframe/stack"; +import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { CmdKPreviewProps } from "../../cmdk-commands"; +import { DashboardSandboxHost } from "./dashboard-sandbox-host"; + +type GenerationState = "idle" | "generating" | "ready" | "error"; + +type DashboardArtifact = { + prompt: string, + projectId: string, + runtimeCodegen: { + title: string, + description: string, + uiRuntimeSourceCode: string, + }, +}; + +export function CreateDashboardPreview({ query, ...rest }: CmdKPreviewProps) { + return ; +} + +const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ + query, + onClose, +}: CmdKPreviewProps) { + const projectId = useProjectId(); + const adminApp = useAdminApp(projectId); + const project = adminApp.useProject(); + const config = project.useConfig(); + const currentUser = useUser({ or: "redirect" }); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set");; + const updateConfig = useUpdateConfig(); + const router = useRouter(); + const prompt = query.trim(); + + const enabledAppIds = useMemo(() => + typedEntries(config.apps.installed) + .filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS) + .map(([appId]) => appId as AppId), + [config.apps.installed] + ); + + const [state, setState] = useState("idle"); + const [errorText, setErrorText] = useState(null); + const [artifact, setArtifact] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const abortControllerRef = useRef(null); + + const generateDashboard = useCallback(async () => { + if (!projectId || !prompt) { + return; + } + + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setState("generating"); + setErrorText(null); + setArtifact(null); + + try { + const userMessages: Array<{ role: string, content: string }> = [{ role: "user", content: prompt }]; + const { toolCall } = await generateDashboardCode(backendBaseUrl, currentUser, userMessages, { enabledAppIds, abortSignal: controller.signal }); + + if (controller.signal.aborted) return; + + if (!toolCall?.args?.content) { + setState("error"); + setErrorText("AI did not return dashboard code"); + return; + } + + setArtifact({ + prompt, + projectId, + runtimeCodegen: { + title: prompt.slice(0, 120), + description: "", + uiRuntimeSourceCode: toolCall.args.content, + }, + }); + setState("ready"); + } catch (error) { + if (controller.signal.aborted) return; + captureError("create-dashboard-preview", error); + setState("error"); + setErrorText("Failed to generate dashboard. Please try again."); + } + }, [projectId, prompt, currentUser, backendBaseUrl, enabledAppIds]); + + const handleSave = useCallback(async () => { + if (!artifact) return; + setIsSaving(true); + try { + const id = generateUuid(); + await updateConfig({ + adminApp, + configUpdate: { + [`customDashboards.${id}`]: { + displayName: artifact.runtimeCodegen.title, + tsxSource: artifact.runtimeCodegen.uiRuntimeSourceCode, + }, + }, + pushable: false, + }); + onClose(); + router.push(`/projects/${projectId}/dashboards/${id}`); + } finally { + setIsSaving(false); + } + }, [artifact, adminApp, updateConfig, router, projectId, onClose]); + + useDebouncedAction({ + action: generateDashboard, + delayMs: 500, + skip: !projectId || !prompt, + }); + + if (!prompt) { + return ( +
+

Create Dashboard

+

Describe the dashboard you want and we will generate it in a sandbox.

+
+ ); + } + + return ( +
+
+
+
+
Create Dashboard
+
{prompt}
+
+
+ {state === "ready" && artifact && ( + + )} + +
+
+ {state === "error" && errorText && ( +
+ {errorText} +
+ )} +
+ +
+ {state === "generating" && ( +
Generating dashboard...
+ )} + {state !== "generating" && artifact && ( + + )} + {state !== "generating" && !artifact && state !== "error" && ( +
Waiting for generation...
+ )} +
+
+ ); +}); diff --git a/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx b/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx new file mode 100644 index 0000000000..36e3fa6c12 --- /dev/null +++ b/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { DashboardRuntimeCodegen } from "@/lib/ai-dashboard/contracts"; +import { getPublicEnvVar } from "@/lib/env"; +import { useTheme } from "@/lib/theme"; +import { useUser } from "@stackframe/stack"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { memo, useEffect, useMemo, useRef } from "react"; +import packageJson from "../../../../package.json"; + +type DashboardArtifact = { + prompt: string, + projectId: string, + runtimeCodegen: DashboardRuntimeCodegen, +}; + +function html(strings: TemplateStringsArray, ...values: unknown[]): string { + return strings.reduce((result, str, i) => result + str + (values[i] ?? ''), ''); +} + +const isDev = process.env.NODE_ENV === "development"; + +function getDependencyScripts(esmVersion: string, esmFallbackVersion: string, dashboardUrl: string): string { + if (isDev) { + return html` + `; + } + + return html` + `; +} + +function escapeScriptContent(code: string): string { + return code + .replace(/<\/script/gi, "<\\/script") + .replace(//g, "--\\>"); +} + +function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashboardUrl: string, initialTheme: "light" | "dark", showControls: boolean, initialChatOpen: boolean): string { + const sourceCode = escapeScriptContent(artifact.runtimeCodegen.uiRuntimeSourceCode); + const darkClass = initialTheme === "dark" ? "dark" : ""; + const esmVersion = packageJson.version; + const esmFallbackVersion = "2.8.71"; + const devScriptSrc = isDev ? ` ${dashboardUrl}` : ''; + const devConnectSrc = isDev ? ` ${dashboardUrl}` : ''; + + return html` + + + + + + + + + + + + + +
+ + + + + ${getDependencyScripts(esmVersion, esmFallbackVersion, dashboardUrl)} + + + +`; +} + +export const DashboardSandboxHost = memo(function DashboardSandboxHost({ + artifact, + onBack, + onEditToggle, + onNavigate, + isChatOpen, +}: { + artifact: DashboardArtifact, + onBack?: () => void, + onEditToggle?: () => void, + onNavigate?: (path: string) => void, + isChatOpen?: boolean, +}) { + const iframeRef = useRef(null); + const onBackRef = useRef(onBack); + onBackRef.current = onBack; + const onEditToggleRef = useRef(onEditToggle); + onEditToggleRef.current = onEditToggle; + const onNavigateRef = useRef(onNavigate); + onNavigateRef.current = onNavigate; + const user = useUser({ or: "redirect" }); + const { resolvedTheme } = useTheme(); + + const baseUrl = useMemo(() => { + const url = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL"); + if (!url) throw new Error("NEXT_PUBLIC_STACK_API_URL is not set"); + return url; + }, []); + + const dashboardUrl = useMemo(() => { + if (typeof window === "undefined") return ""; + return window.location.origin; + }, []); + + const initialThemeRef = useRef<"light" | "dark">(resolvedTheme === "dark" ? "dark" : "light"); + const initialChatOpenRef = useRef(!!isChatOpen); + const showControls = onBack != null || onEditToggle != null; + const srcDoc = useMemo(() => getSandboxDocument(artifact, baseUrl, dashboardUrl, initialThemeRef.current, showControls, initialChatOpenRef.current), [artifact, baseUrl, dashboardUrl, showControls]); + + // Send theme changes to iframe dynamically (without full reload) + useEffect(() => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage({ + type: 'stack-theme-change', + theme: resolvedTheme, + }, '*'); + } + }, [resolvedTheme]); + + useEffect(() => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage({ + type: 'dashboard-controls-update', + chatOpen: !!isChatOpen, + }, '*'); + } + }, [isChatOpen]); + + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (typeof event.data !== "object" || event.data === null) { + return; + } + if (event.origin !== "null") { + return; + } + if (!iframeRef.current?.contentWindow || event.source !== iframeRef.current.contentWindow) { + return; + } + const type = event.data.type; + + if (type === "stack-access-token-request") { + const requestId = event.data.requestId; + runAsynchronously(async () => { + const accessToken = await user.getAccessToken(); + if (!accessToken) { + const err = new Error('[DashboardSandboxHost] Failed to get access token: access token is null'); + captureError('dashboard-sandbox-host', err); + event.source?.postMessage({ + type: 'stack-access-token-response', + requestId, + accessToken: null, + error: err.message, + }, { targetOrigin: '*' }); + return; + } + + event.source?.postMessage({ + type: 'stack-access-token-response', + requestId, + accessToken, + }, { targetOrigin: '*' }); + }); + return; + } + + if (type === "dashboard-navigate") { + onNavigateRef.current?.(event.data.path); + return; + } + + if (type === "dashboard-back") { + onBackRef.current?.(); + return; + } + + if (type === "dashboard-edit") { + onEditToggleRef.current?.(); + return; + } + + if (type === "dashboard-error-boundary") { + const err = new Error(event.data.message ?? 'Unknown dashboard error'); + if (event.data.stack) err.stack = event.data.stack; + captureError('dashboard-sandbox-error-boundary', err); + return; + } + + if (type === "stack-ai-dashboard-ready" || type === "stack-ai-dashboard-error") { + return; + } + }; + + window.addEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + }; + }, [user]); + + return ( +