mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
email templates, drafts and theme working
This commit is contained in:
parent
a6774861d6
commit
0e71470723
@ -30,7 +30,7 @@ export const POST = createSmartRouteHandler({
|
||||
}
|
||||
|
||||
if (!validateToolNames(body.tools)) {
|
||||
throw new StatusError(StatusError.BadRequest, `Invalid tool names in request. Valid tools: docs, sql-query, create-email-theme, create-email-template, create-email-draft, create-dashboard`);
|
||||
throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`);
|
||||
}
|
||||
|
||||
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", "");
|
||||
|
||||
@ -1,122 +1,6 @@
|
||||
import { getChatAdapter } from "@/lib/ai-chat/adapter-registry";
|
||||
import { selectModel } from "@/lib/ai/models";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { generateText } from "ai";
|
||||
import { InferType } from "yup";
|
||||
|
||||
const textContentSchema = yupObject({
|
||||
type: yupString().oneOf(["text"]).defined(),
|
||||
text: yupString().defined(),
|
||||
});
|
||||
|
||||
const toolCallContentSchema = yupObject({
|
||||
type: yupString().oneOf(["tool-call"]).defined(),
|
||||
toolName: yupString().defined(),
|
||||
toolCallId: yupString().defined(),
|
||||
args: yupMixed().defined(),
|
||||
argsText: yupString().defined(),
|
||||
result: yupMixed().defined(),
|
||||
});
|
||||
|
||||
const contentSchema = yupArray(yupUnion(textContentSchema, toolCallContentSchema)).defined();
|
||||
|
||||
const messageSchema = yupObject({
|
||||
role: yupString().oneOf(["user", "assistant", "tool"]).defined(),
|
||||
content: yupMixed().defined(),
|
||||
});
|
||||
|
||||
// AI request timeout in milliseconds (2 minutes)
|
||||
const AI_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: yupString().oneOf(["admin"]).defined(),
|
||||
tenancy: adaptSchema,
|
||||
}),
|
||||
params: yupObject({
|
||||
threadId: yupString().defined(),
|
||||
}),
|
||||
body: yupObject({
|
||||
context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(),
|
||||
messages: yupArray(messageSchema).defined().min(1),
|
||||
}),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
content: contentSchema,
|
||||
}).defined(),
|
||||
}),
|
||||
async handler({ body, params, auth: { tenancy } }) {
|
||||
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", "");
|
||||
if (apiKey === "" || apiKey === "FORWARD_TO_PRODUCTION") {
|
||||
throw new StatusError(
|
||||
StatusError.InternalServerError,
|
||||
"OpenRouter API key is not configured. Please set STACK_OPENROUTER_API_KEY."
|
||||
);
|
||||
}
|
||||
|
||||
const adapter = getChatAdapter(body.context_type, tenancy, params.threadId);
|
||||
|
||||
// Email generation benefits from a smarter, slower model; this route always has
|
||||
// admin auth so isAuthenticated is always true
|
||||
const model = selectModel("smart", "slow", true);
|
||||
|
||||
// content is typed as yup mixed — cast needed since it does not map to the AI
|
||||
// SDK strict ModelMessage content typing, but the adapter guarantees a valid shape
|
||||
const validatedMessages = body.messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})) as any;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: adapter.systemPrompt,
|
||||
messages: validatedMessages,
|
||||
tools: adapter.tools,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
const contentBlocks: InferType<typeof contentSchema> = [];
|
||||
result.steps.forEach((step) => {
|
||||
if (step.text) {
|
||||
contentBlocks.push({ type: "text", text: step.text });
|
||||
}
|
||||
step.toolCalls.forEach((toolCall) => {
|
||||
contentBlocks.push({
|
||||
type: "tool-call",
|
||||
toolName: toolCall.toolName,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
args: toolCall.input,
|
||||
argsText: JSON.stringify(toolCall.input),
|
||||
result: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { content: contentBlocks },
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
},
|
||||
});
|
||||
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
|
||||
export const PATCH = createSmartRouteHandler({
|
||||
metadata: {
|
||||
|
||||
@ -1,23 +1,9 @@
|
||||
import { selectModel } from "@/lib/ai/models";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { generateText } from "ai";
|
||||
|
||||
// Mock mode sentinel value - when API key is not configured, we return mock responses
|
||||
const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key";
|
||||
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL);
|
||||
const isMockMode = apiKey === MOCK_API_KEY_SENTINEL;
|
||||
|
||||
// Only create OpenAI client if not in mock mode
|
||||
const openai = isMockMode ? null : createOpenAI({
|
||||
apiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
|
||||
// AI request timeout in milliseconds (2 minutes)
|
||||
const AI_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
const WYSIWYG_SYSTEM_PROMPT = `You are an expert at editing React/JSX code. Your task is to update a specific text string in the source code.
|
||||
|
||||
RULES:
|
||||
@ -102,7 +88,7 @@ export const POST = createSmartRouteHandler({
|
||||
updated_source: yupString().defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
async handler({ body }) {
|
||||
async handler({ body }, fullReq) {
|
||||
const {
|
||||
source_code,
|
||||
old_text,
|
||||
@ -121,8 +107,10 @@ export const POST = createSmartRouteHandler({
|
||||
};
|
||||
}
|
||||
|
||||
// Mock mode: perform string replacement at the correct occurrence index without calling AI
|
||||
if (isMockMode) {
|
||||
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", "");
|
||||
|
||||
// Mock mode: no API key configured — perform a simple string replacement without calling AI
|
||||
if (apiKey === "") { //TODO have a special env variable for this
|
||||
let replacedSource: string;
|
||||
|
||||
// Handle edge case: empty old_text can't be meaningfully replaced
|
||||
@ -197,29 +185,15 @@ ${html_context.slice(0, 500)}
|
||||
Please update the source code to change "${old_text}" to "${new_text}" at the specified location. Return ONLY the complete updated source code.
|
||||
`;
|
||||
|
||||
// Model is configurable via env var; no default to surface missing config errors
|
||||
const modelName = getEnvVariable("STACK_AI_MODEL");
|
||||
// This route requires admin auth, so the caller is always authenticated.
|
||||
// "smart" + "fast" is appropriate for surgical text-node replacement.
|
||||
const model = selectModel("smart", "fast", /* isAuthenticated= */ true);
|
||||
|
||||
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);
|
||||
}
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: WYSIWYG_SYSTEM_PROMPT,
|
||||
messages: [{ role: "user", content: userPrompt }],
|
||||
});
|
||||
|
||||
// Extract the updated source code from the response
|
||||
let updatedSource = result.text.trim();
|
||||
|
||||
@ -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<string, Tool>,
|
||||
}
|
||||
|
||||
type ContextType = "email-theme" | "email-template" | "email-draft";
|
||||
|
||||
const CHAT_ADAPTERS: Record<ContextType, (context: ChatAdapterContext) => 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 });
|
||||
}
|
||||
@ -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 <Html>, <Head>, <Body>, or <Preview> 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 <Subject />.
|
||||
- Do NOT include <Html>, <Head>, <Body>, or <Preview> components (the theme provides those).
|
||||
- Use only tailwind classes for styling.
|
||||
- Export 'EmailTemplate' component.
|
||||
`;
|
||||
|
||||
export const emailDraftAdapter = (context: ChatAdapterContext) => ({
|
||||
systemPrompt: EMAIL_DRAFT_SYSTEM_PROMPT,
|
||||
tools: {
|
||||
createEmailTemplate: tool({
|
||||
description: CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION(),
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe("A react component that renders the email template"),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const CREATE_EMAIL_DRAFT_TOOL_DESCRIPTION = () => {
|
||||
return `
|
||||
Create a new email draft.
|
||||
The email draft is a tsx file that is used to render the email content.
|
||||
It must use react-email components.
|
||||
It must export one thing:
|
||||
- EmailTemplate: A function that renders the email draft
|
||||
It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype".
|
||||
It uses tailwind classes for all styling.
|
||||
|
||||
Here is an example of a valid email draft:
|
||||
\`\`\`tsx
|
||||
import { Container } from "@react-email/components";
|
||||
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
|
||||
|
||||
export function EmailTemplate({ user, project }: Props) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={\`Hello \${user.displayName}!\`} />
|
||||
<NotificationCategory value="Transactional" />
|
||||
<div className="font-bold">Hi {user.displayName}!</div>
|
||||
<br />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
};
|
||||
@ -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 <Html>, <Head>, <Body>, or <Preview> 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 <Subject /> component.
|
||||
- Always include a <NotificationCategory /> component.
|
||||
- Do NOT include <Html>, <Head>, <Body>, or <Preview> components (the theme provides those).
|
||||
- Use only tailwind classes for styling.
|
||||
- Export 'variablesSchema' using arktype.
|
||||
- Export 'EmailTemplate' component.
|
||||
- Define 'EmailTemplate.PreviewVariables' with realistic example data.
|
||||
`;
|
||||
|
||||
export const emailTemplateAdapter = (context: ChatAdapterContext) => ({
|
||||
systemPrompt: EMAIL_TEMPLATE_SYSTEM_PROMPT,
|
||||
tools: {
|
||||
createEmailTemplate: tool({
|
||||
description: CREATE_EMAIL_TEMPLATE_TOOL_DESCRIPTION(context),
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe("A react component that renders the email template"),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const CREATE_EMAIL_TEMPLATE_TOOL_DESCRIPTION = (context: ChatAdapterContext) => {
|
||||
const currentEmailTemplate = context.tenancy.config.emails.templates[context.threadId];
|
||||
|
||||
return `
|
||||
Create a new email template.
|
||||
The email template is a tsx file that is used to render the email content.
|
||||
It must use react-email components.
|
||||
It must export two things:
|
||||
- variablesSchema: An arktype schema for the email template props
|
||||
- EmailTemplate: A function that renders the email template. You must set the PreviewVariables property to an object that satisfies the variablesSchema by doing EmailTemplate.PreviewVariables = { ...
|
||||
It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype".
|
||||
It uses tailwind classes for all styling.
|
||||
|
||||
Here is an example of a valid email template:
|
||||
\`\`\`tsx
|
||||
import { type } from "arktype"
|
||||
import { Container } from "@react-email/components";
|
||||
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
|
||||
|
||||
export const variablesSchema = type({
|
||||
count: "number"
|
||||
});
|
||||
|
||||
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={\`Hello \${user.displayName}!\`} />
|
||||
<NotificationCategory value="Transactional" />
|
||||
<div className="font-bold">Hi {user.displayName}!</div>
|
||||
<br />
|
||||
count is {variables.count}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
EmailTemplate.PreviewVariables = {
|
||||
count: 10
|
||||
} satisfies typeof variablesSchema.infer
|
||||
\`\`\`
|
||||
|
||||
Here is the user's current email template:
|
||||
\`\`\`tsx
|
||||
${currentEmailTemplate.tsxSource}
|
||||
\`\`\`
|
||||
`;
|
||||
};
|
||||
@ -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 <Tailwind> for styling.
|
||||
- Ensure the layout is robust and follows email design best practices.
|
||||
`,
|
||||
|
||||
tools: {
|
||||
createEmailTheme: tool({
|
||||
description: CREATE_EMAIL_THEME_TOOL_DESCRIPTION(context),
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe("The content of the email theme"),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const CREATE_EMAIL_THEME_TOOL_DESCRIPTION = (context: ChatAdapterContext) => {
|
||||
const currentEmailTheme = context.tenancy.config.emails.themes[context.threadId].tsxSource || "";
|
||||
|
||||
return `
|
||||
Create a new email theme.
|
||||
The email theme is a React component that is used to render the email theme.
|
||||
It must use react-email components.
|
||||
It must be exported as a function with name "EmailTheme".
|
||||
It must take one prop, children, which is a React node.
|
||||
It must not import from any package besides "@react-email/components".
|
||||
It uses tailwind classes inside of the <Tailwind> 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 (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Container>{children}</Container>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Here is the current email theme:
|
||||
\`\`\`tsx
|
||||
${currentEmailTheme}
|
||||
\`\`\`
|
||||
`;
|
||||
};
|
||||
@ -173,70 +173,97 @@ Remember: You're here to help users succeed with Stack Auth. Be helpful but conc
|
||||
`,
|
||||
|
||||
"email-wysiwyg-editor": `
|
||||
## Context: Email Template Editor
|
||||
|
||||
Do not include <Html>, <Head>, <Body>, or <Preview> components (the theme provides those).
|
||||
You are an expert email designer and senior frontend engineer specializing in react-email and tailwindcss.
|
||||
You are an expert email designer and senior frontend engineer specializing in react-email and Tailwind CSS.
|
||||
Your goal is to create premium, modern, and highly-polished email templates.
|
||||
|
||||
**DESIGN PRINCIPLES:**
|
||||
- Clean typography: Use font-sans and appropriate text sizes (text-sm for body, text-2xl/3xl for headings)
|
||||
- Balanced spacing: Use generous padding and margins (py-8, gap-4)
|
||||
- Modern aesthetics: Use subtle borders, soft shadows (if supported/simulated), and professional color palettes
|
||||
- Mobile-first: Ensure designs look great on small screens
|
||||
- Clarity: The main call-to-action should be prominent
|
||||
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
|
||||
|
||||
**TECHNICAL RULES:**
|
||||
- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL
|
||||
- Always include a <Subject /> component
|
||||
- Always include a <NotificationCategory /> component
|
||||
- Do NOT include <Html>, <Head>, <Body>, or <Preview> 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
|
||||
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 <Html>, <Head>, <Body>, or <Preview> — the email theme provides those wrappers.
|
||||
2. Always include a <Subject /> component with a meaningful value.
|
||||
3. Always include a <NotificationCategory /> 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<typeof variablesSchema.infer>\` 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 \`<Hr />\`, import \`Hr\`. If you use \`<Img />\`, 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 \`<Container>\`, not \`<Container>\`.
|
||||
13. NEVER use bare & in JSX text content — it is invalid JSX and causes a build error. Use \`&\` or \`{"&"}\` instead.
|
||||
`,
|
||||
|
||||
"email-assistant-theme": `
|
||||
## Context: Email Theme Creation
|
||||
|
||||
You are an expert email designer and senior frontend engineer.
|
||||
Your goal is to create premium, modern email themes that provide a consistent look and feel across all emails.
|
||||
|
||||
**DESIGN PRINCIPLES:**
|
||||
- Professional layout: Use a clear container and appropriate padding
|
||||
- Consistent branding: Use professional colors and clean typography
|
||||
- Mobile responsiveness: Ensure the theme works well on all devices
|
||||
- Accessibility: Use semantic tags and readable font sizes
|
||||
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
|
||||
|
||||
**TECHNICAL RULES:**
|
||||
- Export 'EmailTheme' component
|
||||
- Take 'children' as a prop and render it inside the main layout
|
||||
- Use <Tailwind> for styling
|
||||
- Must not import from any package besides "@react-email/components"
|
||||
- Ensure the layout is robust and follows email design best practices
|
||||
- Use the createEmailTheme tool to return the complete theme code
|
||||
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 \`<EmailTheme>\` 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 <Html>, <Head>, and a <Tailwind> 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 \`<Hr />\`, 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 \`<EmailTheme>\`, 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 \`<Tailwind>\`. Use only standard Tailwind utility classes in \`className\` props.
|
||||
12. JavaScript object literals use COMMAS to separate properties — never semicolons. Only TypeScript types/interfaces use semicolons. Example: \`{ a: 1, b: 2 }\` NOT \`{ a: 1; b: 2 }\`.
|
||||
`,
|
||||
|
||||
"email-assistant-draft": `
|
||||
## Context: Email Draft Creation
|
||||
|
||||
Do not include <Html>, <Head>, <Body>, or <Preview> 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
|
||||
PRINCIPLES:
|
||||
- Compelling copywriting: Use clear, engaging language.
|
||||
- Premium design: Use modern layouts and balanced spacing.
|
||||
- Professional tone: Match the project's identity.
|
||||
- Mobile responsiveness: Ensure drafts look good on all devices.
|
||||
|
||||
**TECHNICAL RULES:**
|
||||
- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailDraft TOOL
|
||||
- Always include a <Subject /> component
|
||||
- Do NOT include <Html>, <Head>, <Body>, or <Preview> components (the theme provides those)
|
||||
- Use only tailwind classes for styling
|
||||
- Export 'EmailTemplate' component
|
||||
TECHNICAL RULES:
|
||||
- YOU MUST WRITE A FULL REACT COMPONENT WHEN CALLING THE createEmailTemplate TOOL.
|
||||
- Always include a <Subject />.
|
||||
- Do NOT include <Html>, <Head>, <Body>, or <Preview> components (the theme provides those).
|
||||
- Use only tailwind classes for styling.
|
||||
- Export 'EmailTemplate' component.
|
||||
|
||||
The current source code will be provided in the conversation messages.
|
||||
`,
|
||||
|
||||
"create-dashboard": `
|
||||
|
||||
@ -14,75 +14,38 @@ import { z } from "zod";
|
||||
*/
|
||||
export function createEmailDraftTool(auth: SmartRequestAuth | null) {
|
||||
return tool({
|
||||
description: `Create a new email draft for Stack Auth.
|
||||
|
||||
**What is an Email Draft?**
|
||||
An email draft is a simpler version of an email template, without variable schemas. It's used for one-off emails or quick email creation.
|
||||
|
||||
**Requirements:**
|
||||
- Must use @react-email/components for email components
|
||||
- Can import from @stackframe/emails for Stack Auth-specific utilities
|
||||
- Must export ONE thing: \`EmailTemplate\` function component
|
||||
- Must include Subject and NotificationCategory components
|
||||
- Uses Tailwind classes for all styling
|
||||
- Can access user and project data via Props
|
||||
|
||||
**Differences from Email Templates:**
|
||||
- No variablesSchema required
|
||||
- No custom variables (only user and project data)
|
||||
- No PreviewVariables needed
|
||||
- Simpler for one-off or standard emails
|
||||
|
||||
**Structure:**
|
||||
1. Import required components
|
||||
2. Define EmailTemplate function component using Props type
|
||||
3. Include Subject (can use user data)
|
||||
4. Include NotificationCategory
|
||||
5. Add email content using react-email components
|
||||
|
||||
**Example Valid Email Draft:**
|
||||
description: `
|
||||
Create a new email draft.
|
||||
The email draft is a tsx file that is used to render the email content.
|
||||
It must use react-email components.
|
||||
It must export one thing:
|
||||
- EmailTemplate: A function that renders the email draft
|
||||
It must not import from any package besides "@react-email/components", "@stackframe/emails", and "arktype".
|
||||
It uses tailwind classes for all styling.
|
||||
The email must include <Html>, <Head />, <Preview />, <Tailwind>, <Body>, and <Container> 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 <Head /> component must be rendered inside <Tailwind> to support Tailwind style injection
|
||||
Here is an example of a valid email draft:
|
||||
\`\`\`tsx
|
||||
import { Container, Text, Button } from "@react-email/components";
|
||||
import { Container } from "@react-email/components";
|
||||
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
|
||||
|
||||
export function EmailTemplate({ user, project }: Props) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={\`Welcome to \${project.displayName}, \${user.displayName}!\`} />
|
||||
<Subject value={\`Hello \${user.displayName}!\`} />
|
||||
<NotificationCategory value="Transactional" />
|
||||
|
||||
<Text className="text-2xl font-bold">
|
||||
Welcome, {user.displayName}!
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Thank you for joining {project.displayName}. We're excited to have you here.
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Get started by visiting your dashboard and exploring the features.
|
||||
</Text>
|
||||
|
||||
<Button href="https://example.com/dashboard" className="bg-blue-600 text-white px-4 py-2 rounded">
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
<div className="font-bold">Hi {user.displayName}!</div>
|
||||
<br />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Guidelines:**
|
||||
- Keep content clear and focused
|
||||
- Use appropriate tone
|
||||
- Personalize with user and project data
|
||||
- Include clear call-to-actions when needed
|
||||
- Make it mobile-responsive
|
||||
- Use email-safe styling
|
||||
|
||||
**Output:**
|
||||
Return the COMPLETE draft code including all imports and component definition.`,
|
||||
The user's current email draft can be found in the conversation messages.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe("The complete email draft code as a TypeScript React component"),
|
||||
content: z.string().describe("A react component that renders the email template"),
|
||||
}),
|
||||
// No execute function - the tool call is returned to the caller
|
||||
});
|
||||
|
||||
@ -12,83 +12,51 @@ import { z } from "zod";
|
||||
*/
|
||||
export function createEmailTemplateTool(auth: SmartRequestAuth | null) {
|
||||
return tool({
|
||||
description: `Create a new email template for Stack Auth.
|
||||
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 <Html>, <Head />, <Preview />, <Tailwind>, <Body>, and <Container> 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 <Head /> component must be rendered inside <Tailwind> to support Tailwind style injection
|
||||
|
||||
**What is an Email Template?**
|
||||
An email template is a complete email with content, variables, and metadata. It defines the structure and content of a specific type of email (e.g., welcome email, password reset, notification).
|
||||
|
||||
**Requirements:**
|
||||
- Must use @react-email/components for email components
|
||||
- Can import from @stackframe/emails for Stack Auth-specific utilities
|
||||
- Can import from arktype for schema validation
|
||||
- Must export TWO things:
|
||||
1. \`variablesSchema\`: An arktype schema defining template variables
|
||||
2. \`EmailTemplate\`: A function component that renders the email
|
||||
- EmailTemplate must set PreviewVariables property with sample data
|
||||
- Must use Props<typeof variablesSchema.infer> as the component props type
|
||||
- Must include Subject and NotificationCategory components
|
||||
- Uses Tailwind classes for all styling
|
||||
|
||||
**Structure:**
|
||||
1. Import required components and types
|
||||
2. Define variablesSchema using arktype
|
||||
3. Define EmailTemplate function component
|
||||
4. Include Subject (dynamic or static)
|
||||
5. Include NotificationCategory (e.g., "Transactional", "Marketing")
|
||||
6. Add email content using react-email components
|
||||
7. Set EmailTemplate.PreviewVariables
|
||||
|
||||
**Example Valid Email Template:**
|
||||
Here is an example of a valid email template:
|
||||
\`\`\`tsx
|
||||
import { type } from "arktype"
|
||||
import { Container, Text, Button } from "@react-email/components";
|
||||
import { Container } from "@react-email/components";
|
||||
import { Subject, NotificationCategory, Props } from "@stackframe/emails";
|
||||
|
||||
export const variablesSchema = type({
|
||||
actionUrl: "string",
|
||||
expiresInHours: "number"
|
||||
count: "number"
|
||||
});
|
||||
|
||||
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={\`Action Required, \${user.displayName}!\`} />
|
||||
<Subject value={\`Hello \${user.displayName}!\`} />
|
||||
<NotificationCategory value="Transactional" />
|
||||
|
||||
<Text className="text-lg font-bold">
|
||||
Hi {user.displayName}!
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
Please complete your action within {variables.expiresInHours} hours.
|
||||
</Text>
|
||||
|
||||
<Button href={variables.actionUrl} className="bg-blue-600 text-white px-4 py-2 rounded">
|
||||
Take Action
|
||||
</Button>
|
||||
<div className="font-bold">Hi {user.displayName}!</div>
|
||||
<br />
|
||||
count is {variables.count}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
EmailTemplate.PreviewVariables = {
|
||||
actionUrl: "https://example.com/action",
|
||||
expiresInHours: 24
|
||||
} satisfies typeof variablesSchema.infer;
|
||||
count: 10
|
||||
} satisfies typeof variablesSchema.infer
|
||||
\`\`\`
|
||||
|
||||
**Guidelines:**
|
||||
- Make content clear, concise, and actionable
|
||||
- Use appropriate tone for the email type
|
||||
- Include all necessary information
|
||||
- Add clear call-to-action buttons when needed
|
||||
- Use user data (user.displayName, user.primaryEmail, etc.) to personalize
|
||||
- Make it mobile-responsive
|
||||
- Use email-safe styling
|
||||
|
||||
**Output:**
|
||||
Return the COMPLETE template code including all imports, schema, component, and PreviewVariables.`,
|
||||
The user's current email template can be found in the conversation messages.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe("The complete email template code as a TypeScript React component with schema"),
|
||||
content: z.string().describe("A react component that renders the email template"),
|
||||
}),
|
||||
// No execute function - the tool call is returned to the caller
|
||||
});
|
||||
|
||||
@ -12,55 +12,60 @@ import { z } from "zod";
|
||||
*/
|
||||
export function createEmailThemeTool(auth: SmartRequestAuth | null) {
|
||||
return tool({
|
||||
description: `Create a new email theme for Stack Auth emails.
|
||||
description: `
|
||||
Create a new email theme.
|
||||
|
||||
**What is an Email Theme?**
|
||||
An email theme is a React component that wraps all email content, providing consistent structure, layout, and styling across all emails.
|
||||
The email theme is a React component that wraps all emails with a consistent layout.
|
||||
|
||||
**Requirements:**
|
||||
- Must use @react-email/components (no other imports allowed)
|
||||
- Must be exported as a function named "EmailTheme"
|
||||
- Must accept one prop: children (React.ReactNode)
|
||||
- Must use Tailwind classes inside <Tailwind> tag
|
||||
- Must include Html, Head, and appropriate container elements
|
||||
- Should be responsive and compatible with major email clients
|
||||
|
||||
**Structure:**
|
||||
1. Html wrapper
|
||||
2. Head (for meta tags)
|
||||
3. Tailwind wrapper (for styling)
|
||||
4. Container/layout elements
|
||||
5. {children} placeholder for email content
|
||||
|
||||
**Example Valid Email Theme:**
|
||||
EXACT PROP SIGNATURE (do not change or add props):
|
||||
\`\`\`tsx
|
||||
import { Container, Head, Html, Tailwind } from '@react-email/components'
|
||||
type EmailThemeProps = {
|
||||
children: React.ReactNode, // required — the email body
|
||||
unsubscribeLink?: string, // optional URL string — use as href={unsubscribeLink}, NOT as a function call
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
export function EmailTheme({ children }: { children: React.ReactNode }) {
|
||||
Other requirements:
|
||||
- Must include \`<Html>\`, \`<Head>\`, and a \`<Tailwind>\` wrapper (the theme owns the full document)
|
||||
- Import ONLY from \`@react-email/components\` — no other packages
|
||||
- Use standard Tailwind utility classes in \`className\` props — do NOT pass a \`config\` prop to \`<Tailwind>\`
|
||||
- EVERY component used in JSX must be explicitly imported
|
||||
- JavaScript object literals use COMMAS between properties, never semicolons
|
||||
|
||||
The user's current email theme can be found in the conversation messages.
|
||||
|
||||
Here is an example of a valid email theme:
|
||||
\`\`\`tsx
|
||||
import { Body, Container, Head, Hr, Html, Link, Section, Text, Tailwind } from '@react-email/components'
|
||||
|
||||
export function EmailTheme({ children, unsubscribeLink }: { children: React.ReactNode, unsubscribeLink?: string }) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Container className="bg-white p-8 rounded-lg">
|
||||
{children}
|
||||
</Container>
|
||||
<Body className="bg-gray-50 font-sans">
|
||||
<Container className="mx-auto max-w-[600px] py-8 px-4">
|
||||
<Section className="bg-white rounded-lg shadow-sm p-8">
|
||||
{children}
|
||||
</Section>
|
||||
<Section className="mt-6 text-center">
|
||||
<Hr className="border-gray-200 mb-4" />
|
||||
{unsubscribeLink && (
|
||||
<Text className="text-xs text-gray-400">
|
||||
<Link href={unsubscribeLink} className="text-gray-400 underline">Unsubscribe</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Guidelines:**
|
||||
- Keep it simple and focused on layout/structure
|
||||
- Use neutral, professional styling that works for various email types
|
||||
- Ensure good spacing and readability
|
||||
- Make it mobile-responsive
|
||||
- Test compatibility with email clients (use email-safe CSS)
|
||||
|
||||
**Output:**
|
||||
Return the COMPLETE theme code as a TypeScript React component. Include all imports and the full component definition.`,
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
content: z.string().describe("The complete email theme code as a TypeScript React component"),
|
||||
content: z.string().describe("The content of the email theme"),
|
||||
}),
|
||||
// No execute function - the tool call is returned to the caller
|
||||
});
|
||||
|
||||
@ -11,5 +11,3 @@ STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50
|
||||
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=true
|
||||
|
||||
STACK_FEATUREBASE_JWT_SECRET=secret-value
|
||||
|
||||
STACK_OPENAI_API_KEY=mock_openai_api_key
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^3.0.25",
|
||||
"@ai-sdk/react": "^3.0.72",
|
||||
"@assistant-ui/react": "^0.10.24",
|
||||
"@assistant-ui/react-ai-sdk": "^0.10.14",
|
||||
|
||||
@ -10,11 +10,13 @@ import { ToolCallContent, createChatAdapter, createHistoryAdapter } from "@/comp
|
||||
import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components";
|
||||
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
|
||||
export default function PageClient({ draftId }: { draftId: string }) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const { projectId } = useParams() as { projectId: string };
|
||||
const { setNeedConfirm } = useRouterConfirm();
|
||||
const [saveAlert, setSaveAlert] = useState<{
|
||||
variant: "destructive" | "success",
|
||||
@ -162,7 +164,7 @@ export default function PageClient({ draftId }: { draftId: string }) {
|
||||
chatComponent={
|
||||
<AssistantChat
|
||||
historyAdapter={createHistoryAdapter(stackAdminApp, draftId)}
|
||||
chatAdapter={createChatAdapter(stackAdminApp, draftId, "email-draft", handleToolUpdate)}
|
||||
chatAdapter={createChatAdapter(projectId, draftId, "email-draft", handleToolUpdate, () => currentCode)}
|
||||
toolComponents={<EmailDraftUI setCurrentCode={setCurrentCode} />}
|
||||
useOffWhiteLightMode
|
||||
/>
|
||||
|
||||
@ -18,12 +18,14 @@ import { ToolCallContent } from "@/components/vibe-coding/chat-adapters";
|
||||
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
|
||||
export default function PageClient(props: { templateId: string }) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const { projectId } = useParams() as { projectId: string };
|
||||
const templates = stackAdminApp.useEmailTemplates();
|
||||
const { setNeedConfirm } = useRouterConfirm();
|
||||
const templateFromHook = templates.find((t) => t.id === props.templateId);
|
||||
@ -270,7 +272,7 @@ export default function PageClient(props: { templateId: string }) {
|
||||
}
|
||||
chatComponent={
|
||||
<AssistantChat
|
||||
chatAdapter={createChatAdapter(stackAdminApp, template.id, "email-template", handleCodeUpdate)}
|
||||
chatAdapter={createChatAdapter(projectId, template.id, "email-template", handleCodeUpdate, () => currentCode)}
|
||||
historyAdapter={createHistoryAdapter(stackAdminApp, template.id)}
|
||||
toolComponents={<EmailTemplateUI setCurrentCode={setCurrentCode} />}
|
||||
useOffWhiteLightMode
|
||||
|
||||
@ -12,12 +12,14 @@ import {
|
||||
import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails";
|
||||
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
|
||||
|
||||
export default function PageClient({ themeId }: { themeId: string }) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const { projectId } = useParams() as { projectId: string };
|
||||
const theme = stackAdminApp.useEmailTheme(themeId);
|
||||
const { setNeedConfirm } = useRouterConfirm();
|
||||
const [currentCode, setCurrentCode] = useState(theme.tsxSource);
|
||||
@ -124,7 +126,7 @@ export default function PageClient({ themeId }: { themeId: string }) {
|
||||
}
|
||||
chatComponent={
|
||||
<AssistantChat
|
||||
chatAdapter={createChatAdapter(stackAdminApp, themeId, "email-theme", handleThemeUpdate)}
|
||||
chatAdapter={createChatAdapter(projectId, themeId, "email-theme", handleThemeUpdate, () => currentCode)}
|
||||
historyAdapter={createHistoryAdapter(stackAdminApp, themeId)}
|
||||
toolComponents={<EmailThemeUI setCurrentCode={setCurrentCode} />}
|
||||
useOffWhiteLightMode
|
||||
|
||||
@ -16,7 +16,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
const { accessToken } = await user.getAuthJson();
|
||||
const accessToken = await user.getAccessToken();
|
||||
|
||||
// Check if the user has admin access to the requested project
|
||||
let hasProjectAccess = false;
|
||||
|
||||
115
apps/dashboard/src/app/api/email-ai/route.ts
Normal file
115
apps/dashboard/src/app/api/email-ai/route.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { stackServerApp } from "@/stack";
|
||||
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
|
||||
/**
|
||||
* Sanitizes AI-generated JSX/TSX code before it is applied to the email renderer.
|
||||
*
|
||||
* Handles four common model output issues:
|
||||
* 1. Markdown code fences (```tsx ... ```) wrapping the output despite instructions
|
||||
* 2. HTML-encoded angle brackets (<Component> instead of <Component>)
|
||||
* 3. Bare & in JSX text content (invalid JSX; must be & or {"&"})
|
||||
* 4. Semicolons used as property separators in JS object literals instead of commas
|
||||
* (the AI confuses TypeScript interface syntax with JS object syntax).
|
||||
* TypeScript also accepts commas in interfaces/types, so replacing ; → , is always safe.
|
||||
*/
|
||||
function sanitizeGeneratedCode(code: string): string {
|
||||
let result = code.trim();
|
||||
|
||||
// Strip markdown code fences if the model added them despite instructions.
|
||||
// Handles ```tsx ... ``` and also plain ``` ... ```.
|
||||
if (result.startsWith("```")) {
|
||||
const lines = result.split("\n");
|
||||
lines.shift(); // remove opening ```tsx or similar
|
||||
if (lines[lines.length - 1]?.trim() === "```") {
|
||||
lines.pop(); // remove closing ```
|
||||
}
|
||||
result = lines.join("\n").trim();
|
||||
}
|
||||
|
||||
// Decode common HTML entities that models sometimes emit inside code.
|
||||
// This fixes things like `&&` (should be `&&`) and `<Container>`.
|
||||
// Only decodes the entities we expect in generated TSX.
|
||||
result = result
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
|
||||
// Fix the common model mistake of using `;` as a property separator in object literals.
|
||||
// Replace `;` with `,` only when it looks like `key: value;` followed by another `key:`.
|
||||
// This avoids touching for-loops and other valid `;` usage.
|
||||
result = result.replace(/;(\s*\n\s*[A-Za-z_$][\w$]*\s*:)/g, ",$1");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const payload = await req.json() as {
|
||||
projectId: string,
|
||||
systemPrompt: string,
|
||||
tools: string[],
|
||||
messages: unknown[],
|
||||
quality?: string,
|
||||
speed?: string,
|
||||
};
|
||||
|
||||
const { projectId, systemPrompt, tools, messages, quality = "smartest", speed = "fast" } = payload;
|
||||
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
const accessToken = await user.getAccessToken();
|
||||
|
||||
const projects = await user.listOwnedProjects();
|
||||
const hasProjectAccess = projects.some((p) => p.id === projectId);
|
||||
|
||||
if (!hasProjectAccess || !accessToken) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const backendBaseUrl =
|
||||
getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ??
|
||||
getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ??
|
||||
throwErr("Backend API URL is not configured (NEXT_PUBLIC_STACK_API_URL)");
|
||||
|
||||
const backendResponse = await fetch(
|
||||
`${backendBaseUrl}/api/latest/ai/query/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-stack-access-type": "admin",
|
||||
"x-stack-project-id": projectId,
|
||||
"x-stack-admin-access-token": accessToken, //TODO not entirely sure
|
||||
},
|
||||
body: JSON.stringify({ quality, speed, systemPrompt, tools, messages }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!backendResponse.ok) {
|
||||
const error = await backendResponse.json().catch(() => ({ error: "Unknown error" }));
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: backendResponse.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await backendResponse.json() as { content: Array<{ type: string, args?: { content?: string, [key: string]: unknown }, [key: string]: unknown }> };
|
||||
const sanitized = {
|
||||
content: result.content.map((item) => {
|
||||
if (item.type === "tool-call" && typeof item.args?.content === "string") {
|
||||
return { ...item, args: { ...item.args, content: sanitizeGeneratedCode(item.args.content) } };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(sanitized), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@ -12,51 +12,62 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => {
|
||||
return content.type === "tool-call";
|
||||
};
|
||||
|
||||
const CONTEXT_MAP = {
|
||||
"email-theme": { systemPrompt: "email-assistant-theme", tools: ["create-email-theme"] },
|
||||
"email-template": { systemPrompt: "email-wysiwyg-editor", tools: ["create-email-template"] },
|
||||
"email-draft": { systemPrompt: "email-assistant-draft", tools: ["create-email-draft"] },
|
||||
} as const;
|
||||
|
||||
export function createChatAdapter(
|
||||
adminApp: StackAdminApp,
|
||||
projectId: string,
|
||||
threadId: string,
|
||||
contextType: "email-theme" | "email-template" | "email-draft",
|
||||
onToolCall: (toolCall: ToolCallContent) => void
|
||||
onToolCall: (toolCall: ToolCallContent) => void,
|
||||
getCurrentSource?: () => string,
|
||||
): ChatModelAdapter {
|
||||
return {
|
||||
async run({ messages, abortSignal }) {
|
||||
try {
|
||||
const formattedMessages = [];
|
||||
for (const msg of messages) {
|
||||
// Separate tool calls from other content
|
||||
const toolCalls = msg.content.filter(isToolCall);
|
||||
const nonToolContent = msg.content.filter(c => !isToolCall(c));
|
||||
// Only add the message if it has non-tool content
|
||||
if (nonToolContent.length > 0) {
|
||||
formattedMessages.push({
|
||||
role: msg.role,
|
||||
content: nonToolContent
|
||||
});
|
||||
const textContent = msg.content.filter(c => !isToolCall(c));
|
||||
if (textContent.length > 0) {
|
||||
formattedMessages.push({ role: msg.role, content: textContent });
|
||||
}
|
||||
// Add tool results as separate messages
|
||||
toolCalls.forEach(toolCall => {
|
||||
formattedMessages.push({
|
||||
role: "tool",
|
||||
content: [{
|
||||
type: "tool-result",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
result: toolCall.result,
|
||||
}],
|
||||
});
|
||||
});
|
||||
}
|
||||
const currentSource = getCurrentSource?.() ?? "";
|
||||
const contextMessages: Array<{ role: "user" | "assistant", content: string }> = currentSource ? [
|
||||
{ role: "user", content: `Here is the current source:\n\`\`\`tsx\n${currentSource}\n\`\`\`` },
|
||||
{ role: "assistant", content: "Got it. What would you like to change?" },
|
||||
] : [];
|
||||
|
||||
const { systemPrompt, tools } = CONTEXT_MAP[contextType];
|
||||
|
||||
const response = await fetch("/api/email-ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
systemPrompt,
|
||||
tools: [...tools],
|
||||
messages: [...contextMessages, ...formattedMessages],
|
||||
}),
|
||||
signal: abortSignal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const response = await adminApp.sendChatMessage(threadId, contextType, formattedMessages, abortSignal);
|
||||
if (response.content.some(isToolCall)) {
|
||||
const toolCall = response.content.find(isToolCall);
|
||||
const result: { content: ChatContent } = await response.json();
|
||||
|
||||
if (result.content.some(isToolCall)) {
|
||||
const toolCall = result.content.find(isToolCall);
|
||||
if (toolCall) {
|
||||
onToolCall(toolCall);
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: response.content,
|
||||
};
|
||||
return { content: result.content };
|
||||
} catch (error) {
|
||||
if (abortSignal.aborted) {
|
||||
return {};
|
||||
|
||||
@ -11,7 +11,7 @@ export const EmailDraftUI = ({ setCurrentCode }: EmailDraftUIProps) => {
|
||||
{ content: string },
|
||||
"success"
|
||||
>({
|
||||
toolName: "createEmailTemplate",
|
||||
toolName: "createEmailDraft",
|
||||
render: ({ args }) => {
|
||||
return (
|
||||
<Card className="flex items-center gap-2 p-4 justify-between">
|
||||
|
||||
@ -420,28 +420,6 @@ export class StackAdminInterface extends StackServerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
async sendChatMessage(
|
||||
threadId: string,
|
||||
contextType: "email-theme" | "email-template" | "email-draft",
|
||||
messages: Array<{ role: string, content: any }>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ content: ChatContent }> {
|
||||
const response = await this.sendAdminRequest(
|
||||
`/internal/ai-chat/${threadId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ context_type: contextType, messages }),
|
||||
signal: abortSignal,
|
||||
},
|
||||
null,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async saveChatMessage(threadId: string, message: any): Promise<void> {
|
||||
await this.sendAdminRequest(
|
||||
`/internal/ai-chat/${threadId}`,
|
||||
|
||||
@ -25,7 +25,6 @@ import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders,
|
||||
import { _StackServerAppImplIncomplete } from "./server-app-impl";
|
||||
|
||||
import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema";
|
||||
import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface";
|
||||
import type { EditableMetadata } from "@stackframe/stack-shared/dist/utils/jsx-editable-transpiler";
|
||||
import { branchConfigSourceSchema } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import * as yup from "yup";
|
||||
@ -629,16 +628,6 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
}
|
||||
await this._adminEmailDraftsCache.refresh([]);
|
||||
}
|
||||
|
||||
async sendChatMessage(
|
||||
threadId: string,
|
||||
contextType: "email-theme" | "email-template" | "email-draft",
|
||||
messages: Array<{ role: string, content: any }>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ content: ChatContent }> {
|
||||
return await this._interface.sendChatMessage(threadId, contextType, messages, abortSignal);
|
||||
}
|
||||
|
||||
async saveChatMessage(threadId: string, message: any): Promise<void> {
|
||||
await this._interface.saveChatMessage(threadId, message);
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface";
|
||||
import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics";
|
||||
import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays";
|
||||
import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions";
|
||||
@ -95,13 +94,6 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
|
||||
createEmailTheme(displayName: string): Promise<{ id: string }>,
|
||||
updateEmailTheme(id: string, tsxSource: string): Promise<void>,
|
||||
deleteEmailTheme(id: string): Promise<void>,
|
||||
|
||||
sendChatMessage(
|
||||
threadId: string,
|
||||
contextType: "email-theme" | "email-template" | "email-draft",
|
||||
messages: Array<{ role: string, content: any }>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ content: ChatContent }>,
|
||||
saveChatMessage(threadId: string, message: any): Promise<void>,
|
||||
listChatMessages(threadId: string): Promise<{ messages: Array<any> }>,
|
||||
applyWysiwygEdit(options: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user