mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
minor issues fixed
This commit is contained in:
parent
68a5d8dc91
commit
87c18e445e
@ -17,7 +17,7 @@ export const POST = createSmartRouteHandler({
|
||||
},
|
||||
request: yupObject({
|
||||
params: yupObject({
|
||||
mode: yupString().defined(),
|
||||
mode: yupString().oneOf(["stream", "generate"]).defined(),
|
||||
}),
|
||||
body: requestBodySchema,
|
||||
}),
|
||||
@ -25,10 +25,6 @@ export const POST = createSmartRouteHandler({
|
||||
async handler({ params, body }, fullReq) {
|
||||
const { mode } = params;
|
||||
|
||||
if (mode !== "stream" && mode !== "generate") {
|
||||
throw new StatusError(StatusError.BadRequest, `Invalid mode: ${mode}. Must be "stream" or "generate".`);
|
||||
}
|
||||
|
||||
if (!validateToolNames(body.tools)) {
|
||||
throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`);
|
||||
}
|
||||
@ -43,22 +39,26 @@ export const POST = createSmartRouteHandler({
|
||||
}
|
||||
|
||||
if (apiKey === "FORWARD_TO_PRODUCTION") {
|
||||
const prodResponse = await forwardToProduction(fullReq.headers, mode, body);
|
||||
return {
|
||||
statusCode: 200,
|
||||
statusCode: prodResponse.status,
|
||||
bodyType: "response" as const,
|
||||
body: await forwardToProduction(fullReq.headers, mode, body),
|
||||
body: prodResponse,
|
||||
};
|
||||
}
|
||||
|
||||
const isAuthenticated = fullReq.auth != null;
|
||||
const quality = body.quality as ModelQuality;
|
||||
const speed = body.speed as ModelSpeed;
|
||||
const systemPromptId = body.systemPrompt as SystemPromptId;
|
||||
const toolNames = body.tools as ToolName[];
|
||||
|
||||
const model = selectModel(body.quality as ModelQuality, body.speed as ModelSpeed, isAuthenticated);
|
||||
const systemPrompt = getFullSystemPrompt(body.systemPrompt as SystemPromptId);
|
||||
const tools = await getTools(body.tools as ToolName[], { auth: fullReq.auth });
|
||||
const model = selectModel(quality, speed, isAuthenticated);
|
||||
const systemPrompt = getFullSystemPrompt(systemPromptId);
|
||||
const tools = await getTools(toolNames, { auth: fullReq.auth });
|
||||
const toolsArg = Object.keys(tools).length > 0 ? tools : undefined;
|
||||
const messages = body.messages as ModelMessage[];
|
||||
const promptId = body.systemPrompt as SystemPromptId;
|
||||
const isDocsOrSearch = promptId === "docs-ask-ai" || promptId === "command-center-ask-ai";
|
||||
const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai";
|
||||
const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5;
|
||||
|
||||
if (mode === "stream") {
|
||||
@ -103,14 +103,19 @@ export const POST = createSmartRouteHandler({
|
||||
});
|
||||
}
|
||||
|
||||
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: (toolCall as any).result ?? null,
|
||||
result: (toolResult?.output ?? null) as Json,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +1,25 @@
|
||||
import { forwardToProduction } from "@/lib/ai/forward";
|
||||
import { selectModel } from "@/lib/ai/models";
|
||||
import { getFullSystemPrompt } from "@/lib/ai/prompts";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { adaptSchema, yupArray, yupMixed, yupNumber, 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 { generateText } from "ai";
|
||||
|
||||
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.
|
||||
const AI_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
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 <br />, sometimes you should create a new <p>, 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.
|
||||
`;
|
||||
function stripCodeFences(code: string): string {
|
||||
if (!code.startsWith("```")) {
|
||||
return code;
|
||||
}
|
||||
const lines = code.split("\n");
|
||||
lines.shift();
|
||||
if (lines[lines.length - 1]?.trim() === "```") {
|
||||
lines.pop();
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const editMetadataSchema = yupObject({
|
||||
id: yupString().defined(),
|
||||
@ -109,42 +108,13 @@ export const POST = createSmartRouteHandler({
|
||||
|
||||
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
|
||||
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 },
|
||||
};
|
||||
if (apiKey === "") {
|
||||
throw new StatusError(
|
||||
StatusError.InternalServerError,
|
||||
"OpenRouter API key is not configured. Please set STACK_OPENROUTER_API_KEY environment variable."
|
||||
);
|
||||
}
|
||||
|
||||
// Build the prompt for the AI
|
||||
const userPrompt = `
|
||||
## Source Code to Edit
|
||||
\`\`\`tsx
|
||||
@ -185,30 +155,45 @@ ${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.
|
||||
`;
|
||||
|
||||
// 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 (apiKey === "FORWARD_TO_PRODUCTION") {
|
||||
const prodResponse = await forwardToProduction(fullReq.headers, "generate", {
|
||||
quality: "smart",
|
||||
speed: "fast",
|
||||
systemPrompt: "wysiwyg-edit",
|
||||
tools: [],
|
||||
messages: [{ role: "user", content: userPrompt }],
|
||||
});
|
||||
|
||||
if (!prodResponse.ok) {
|
||||
throw new StatusError(prodResponse.status, `Production AI request failed: ${prodResponse.status}`);
|
||||
}
|
||||
|
||||
const prodResult = await prodResponse.json() as { content?: Array<{ type: string, text?: string }> };
|
||||
const textBlock = Array.isArray(prodResult.content)
|
||||
? prodResult.content.find((b) => b.type === "text" && b.text)
|
||||
: undefined;
|
||||
const updatedSource = stripCodeFences(textBlock?.text?.trim() ?? source_code);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { updated_source: updatedSource },
|
||||
};
|
||||
}
|
||||
|
||||
const model = selectModel("smart", "fast", true);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: WYSIWYG_SYSTEM_PROMPT,
|
||||
system: getFullSystemPrompt("wysiwyg-edit"),
|
||||
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");
|
||||
}
|
||||
const updatedSource = stripCodeFences(result.text.trim());
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
|
||||
@ -34,7 +34,9 @@ For personalized support, complex issues, or help beyond documentation:
|
||||
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"
|
||||
@ -194,6 +196,25 @@ This is not optional - retrieve relevant documentation for every question.
|
||||
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 <br />, sometimes you should create a new <p>, 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.
|
||||
@ -225,6 +246,40 @@ RULES:
|
||||
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-template": `
|
||||
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.
|
||||
|
||||
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 <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.
|
||||
- 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 \`<Hr />\`, import \`Hr\`. If you use \`<Img />\`, 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 \`<Container>\`, not \`<Container>\`.
|
||||
- NEVER use bare & in JSX text content — it is invalid JSX and causes a build error. Use \`&\` or \`{"&"}\` instead.
|
||||
`,
|
||||
|
||||
"email-assistant-theme": `
|
||||
|
||||
@ -8,10 +8,13 @@ export const requestBodySchema = yupObject({
|
||||
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",
|
||||
"edit-dashboard",
|
||||
"run-query",
|
||||
]).defined(),
|
||||
messages: yupArray(
|
||||
|
||||
@ -14,55 +14,30 @@ export function createEmailThemeTool(auth: SmartRequestAuth | null) {
|
||||
return tool({
|
||||
description: `
|
||||
Create a new email theme.
|
||||
|
||||
The email theme is a React component that wraps all emails with a consistent layout.
|
||||
|
||||
EXACT PROP SIGNATURE (do not change or add props):
|
||||
\`\`\`tsx
|
||||
type EmailThemeProps = {
|
||||
children: React.ReactNode, // required — the email body
|
||||
unsubscribeLink?: string, // optional URL string — use as href={unsubscribeLink}, NOT as a function call
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
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.
|
||||
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 { Body, Container, Head, Hr, Html, Link, Section, Text, Tailwind } from '@react-email/components'
|
||||
import { Container, Head, Html, Tailwind } from '@react-email/components'
|
||||
|
||||
export function EmailTheme({ children, unsubscribeLink }: { children: React.ReactNode, unsubscribeLink?: string }) {
|
||||
export function EmailTheme({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<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>
|
||||
<Container>{children}</Container>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
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"),
|
||||
|
||||
@ -28,14 +28,8 @@ export async function getTools(
|
||||
for (const toolName of toolNames) {
|
||||
switch (toolName) {
|
||||
case "docs": {
|
||||
// Docs tools come from MCP server - returns multiple tools
|
||||
try {
|
||||
const docsTools = await createDocsTools();
|
||||
Object.assign(tools, docsTools);
|
||||
} catch (error) {
|
||||
console.error("Failed to load docs tools:", error);
|
||||
// Continue without docs tools rather than failing completely
|
||||
}
|
||||
const docsTools = await createDocsTools();
|
||||
Object.assign(tools, docsTools);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -44,7 +38,6 @@ export async function getTools(
|
||||
if (sqlTool != null) {
|
||||
tools["queryAnalytics"] = sqlTool;
|
||||
}
|
||||
// If null (no auth), skip this tool silently
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@ -10,78 +10,40 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null) {
|
||||
}
|
||||
|
||||
return tool({
|
||||
description: `Run a ClickHouse SQL query against the project's analytics database.
|
||||
|
||||
**CRITICAL**: Only SELECT queries are allowed. Project filtering is automatic - do not add WHERE project_id = ... clauses.
|
||||
|
||||
**Available Tables:**
|
||||
|
||||
**events** - User activity events
|
||||
- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed
|
||||
- event_at: DateTime64(3, 'UTC') - When the event occurred
|
||||
- data: JSON - Additional event data
|
||||
- user_id: Nullable(String) - Associated user ID
|
||||
- team_id: Nullable(String) - Associated team ID
|
||||
- created_at: DateTime64(3, 'UTC') - When the record was created
|
||||
|
||||
**users** - User profiles
|
||||
- id: UUID - User ID
|
||||
- display_name: Nullable(String) - User's display name
|
||||
- primary_email: Nullable(String) - User's primary email
|
||||
- primary_email_verified: UInt8 - Whether email is verified (0/1)
|
||||
- signed_up_at: DateTime64(3, 'UTC') - When user signed up
|
||||
- client_metadata: JSON - Client-side metadata
|
||||
- client_read_only_metadata: JSON - Read-only client metadata
|
||||
- server_metadata: JSON - Server-side metadata
|
||||
- is_anonymous: UInt8 - Whether user is anonymous (0/1)
|
||||
|
||||
**Query Guidelines:**
|
||||
- Always include LIMIT clause (default to LIMIT 100)
|
||||
- Use appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc.
|
||||
- For counting: COUNT(*) or COUNT(DISTINCT column)
|
||||
- Only SELECT queries allowed - no DDL/DML
|
||||
|
||||
**Example Queries:**
|
||||
- Count users: SELECT COUNT(*) FROM users
|
||||
- Recent signups: SELECT * FROM users ORDER BY signed_up_at DESC LIMIT 10
|
||||
- Events today: SELECT COUNT(*) FROM events WHERE toDate(event_at) = today()
|
||||
- Event types: SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10`,
|
||||
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 }) => {
|
||||
try {
|
||||
const client = getClickhouseExternalClient();
|
||||
const resultSet = await client.query({
|
||||
query,
|
||||
clickhouse_settings: {
|
||||
SQL_project_id: auth.tenancy.project.id,
|
||||
SQL_branch_id: auth.tenancy.branchId,
|
||||
max_execution_time: 5, // 5 seconds timeout
|
||||
readonly: "1",
|
||||
allow_ddl: 0,
|
||||
max_result_rows: "10000",
|
||||
max_result_bytes: (10 * 1024 * 1024).toString(), // 10MB
|
||||
result_overflow_mode: "throw",
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const rows = await resultSet.json<Record<string, unknown>[]>();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
rowCount: rows.length,
|
||||
result: rows,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
const client = getClickhouseExternalClient();
|
||||
return await client.query({
|
||||
query,
|
||||
clickhouse_settings: {
|
||||
SQL_project_id: auth.tenancy.project.id,
|
||||
SQL_branch_id: auth.tenancy.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<Record<string, unknown>[]>();
|
||||
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",
|
||||
};
|
||||
}
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ export async function POST(req: Request) {
|
||||
const user = await stackServerApp.getUser({ or: "redirect" });
|
||||
const accessToken = await user.getAccessToken();
|
||||
|
||||
// Check if the user has admin access to the requested project
|
||||
let hasProjectAccess = false;
|
||||
if (projectId) {
|
||||
const projects = await user.listOwnedProjects();
|
||||
@ -29,7 +28,6 @@ export async function POST(req: Request) {
|
||||
// so the backend can scope Clickhouse queries to the right project via auth context
|
||||
const tools = hasProjectAccess ? ["docs", "sql-query"] : ["docs"];
|
||||
|
||||
// Convert UIMessage[] (sent by useChat) to ModelMessage[] (expected by the backend)
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
|
||||
const backendBaseUrl =
|
||||
@ -41,9 +39,7 @@ export async function POST(req: Request) {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
// Pass project admin auth so the backend's sql-query tool can scope queries to this project.
|
||||
// The dashboard user's access token acts as the admin access token for their owned projects
|
||||
// (same mechanism used by StackAdminApp.projectOwnerSession internally).
|
||||
|
||||
if (projectId && hasProjectAccess && accessToken) {
|
||||
requestHeaders["x-stack-access-type"] = "admin";
|
||||
requestHeaders["x-stack-project-id"] = projectId;
|
||||
@ -65,9 +61,14 @@ export async function POST(req: Request) {
|
||||
}
|
||||
);
|
||||
|
||||
// Stream the response directly back to the client.
|
||||
// Only forward safe headers — avoid leaking internal Next.js routing headers
|
||||
// (x-middleware-rewrite etc.) which would cause a NextResponse.rewrite() error.
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(backendResponse.body, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
|
||||
@ -180,7 +180,7 @@ const ToolInvocationCard = memo(function ToolInvocationCard({
|
||||
|
||||
// Format the tool name for display
|
||||
const getToolDisplay = () => {
|
||||
if (toolName === "sql-query" || toolName === "queryAnalytics") {
|
||||
if (toolName === "queryAnalytics") {
|
||||
return { label: "Analytics Query", icon: DatabaseIcon };
|
||||
}
|
||||
return { label: toolName, icon: DatabaseIcon };
|
||||
|
||||
@ -14,13 +14,12 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => {
|
||||
|
||||
const CONTEXT_MAP = {
|
||||
"email-theme": { systemPrompt: "email-assistant-theme", tools: ["create-email-theme"] },
|
||||
"email-template": { systemPrompt: "email-wysiwyg-editor", tools: ["create-email-template"] },
|
||||
"email-template": { systemPrompt: "email-assistant-template", tools: ["create-email-template"] },
|
||||
"email-draft": { systemPrompt: "email-assistant-draft", tools: ["create-email-draft"] },
|
||||
} as const;
|
||||
|
||||
export function createChatAdapter(
|
||||
projectId: string,
|
||||
threadId: string,
|
||||
contextType: "email-theme" | "email-template" | "email-draft",
|
||||
onToolCall: (toolCall: ToolCallContent) => void,
|
||||
getCurrentSource?: () => string,
|
||||
@ -59,15 +58,15 @@ export function createChatAdapter(
|
||||
throw new Error(`AI request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result: { content: ChatContent } = await response.json();
|
||||
const result = await response.json() as { content?: ChatContent };
|
||||
const content: ChatContent = Array.isArray(result.content) ? result.content : [];
|
||||
|
||||
if (result.content.some(isToolCall)) {
|
||||
const toolCall = result.content.find(isToolCall);
|
||||
if (toolCall) {
|
||||
onToolCall(toolCall);
|
||||
}
|
||||
const toolCall = content.find(isToolCall);
|
||||
if (toolCall) {
|
||||
onToolCall(toolCall);
|
||||
}
|
||||
return { content: result.content };
|
||||
|
||||
return { content };
|
||||
} catch (error) {
|
||||
if (abortSignal.aborted) {
|
||||
return {};
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../helpers";
|
||||
import { niceBackendFetch, Project } from "../../../backend-helpers";
|
||||
// Note: Since tests run with FORWARD_TO_PRODUCTION, actual AI responses won't be tested.
|
||||
// These tests focus on request validation, structure, and error handling.
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
|
||||
const hasRealAiKey = (() => {
|
||||
const key = getEnvVariable("STACK_OPENROUTER_API_KEY", "");
|
||||
return key !== "" && key !== "FORWARD_TO_PRODUCTION";
|
||||
})();
|
||||
|
||||
const describeWithAi = hasRealAiKey ? describe : describe.skip;
|
||||
|
||||
describe("AI Query Endpoint - Validation", () => {
|
||||
it("rejects invalid mode in URL", async ({ expect }) => {
|
||||
@ -206,7 +212,7 @@ describe("AI Query Endpoint - Validation", () => {
|
||||
}, 10000); // 60 seconds for AI API call
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Authentication", () => {
|
||||
describeWithAi("AI Query Endpoint - Authentication", () => {
|
||||
it("accepts authenticated requests with admin access", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
|
||||
@ -246,7 +252,7 @@ describe("AI Query Endpoint - Authentication", () => {
|
||||
}, 10000); // 60 seconds for AI API call
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - System Prompts", () => {
|
||||
describeWithAi("AI Query Endpoint - System Prompts", () => {
|
||||
const systemPrompts = [
|
||||
"command-center-ask-ai",
|
||||
"docs-ask-ai",
|
||||
@ -277,7 +283,7 @@ describe("AI Query Endpoint - System Prompts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Tools", () => {
|
||||
describeWithAi("AI Query Endpoint - Tools", () => {
|
||||
const validTools = [
|
||||
"docs",
|
||||
"sql-query",
|
||||
@ -324,7 +330,7 @@ describe("AI Query Endpoint - Tools", () => {
|
||||
}, 10000); // 60 seconds for AI API call
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Mode Handling", () => {
|
||||
describeWithAi("AI Query Endpoint - Mode Handling", () => {
|
||||
it("stream mode returns response (forwarded to production)", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/ai/query/stream", {
|
||||
method: "POST",
|
||||
@ -362,7 +368,7 @@ describe("AI Query Endpoint - Mode Handling", () => {
|
||||
}, 10000); // 60 seconds for AI API call
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Quality and Speed Combinations", () => {
|
||||
describeWithAi("AI Query Endpoint - Quality and Speed Combinations", () => {
|
||||
const qualities = ["dumb", "smart", "smartest"];
|
||||
const speeds = ["slow", "fast"];
|
||||
|
||||
@ -388,7 +394,7 @@ describe("AI Query Endpoint - Quality and Speed Combinations", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Response Structure", () => {
|
||||
describeWithAi("AI Query Endpoint - Response Structure", () => {
|
||||
it("generate mode returns body with content array", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/ai/query/generate", {
|
||||
method: "POST",
|
||||
@ -424,7 +430,7 @@ describe("AI Query Endpoint - Response Structure", () => {
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Message Formats", () => {
|
||||
describeWithAi("AI Query Endpoint - Message Formats", () => {
|
||||
it("accepts multi-turn conversation (user → assistant → user)", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/ai/query/generate", {
|
||||
method: "POST",
|
||||
@ -504,7 +510,7 @@ describe("AI Query Endpoint - Invalid Message Structure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Tool Behavior", () => {
|
||||
describeWithAi("AI Query Endpoint - Tool Behavior", () => {
|
||||
it("sql-query tool is gracefully omitted when unauthenticated (no error)", async ({ expect }) => {
|
||||
// Without auth, createSqlQueryTool returns null and the tool is silently skipped
|
||||
const response = await niceBackendFetch("/api/v1/ai/query/generate", {
|
||||
@ -551,7 +557,7 @@ describe("AI Query Endpoint - Tool Behavior", () => {
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe("AI Query Endpoint - Auth Edge Cases", () => {
|
||||
describeWithAi("AI Query Endpoint - Auth Edge Cases", () => {
|
||||
it("smartest quality without auth falls back to cheaper model and succeeds", async ({ expect }) => {
|
||||
// Unauthenticated + smartest → falls back to x-ai/grok-4.1-fast per model matrix
|
||||
const response = await niceBackendFetch("/api/v1/ai/query/generate", {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { stackServerApp } from "@/stack";
|
||||
import { convertToModelMessages, UIMessage } from "ai";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -10,24 +9,28 @@ export async function POST(request: Request) {
|
||||
"https://api.stack-auth.com";
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const projectId = process.env.NEXT_PUBLIC_STACK_PROJECT_ID;
|
||||
const publishableClientKey = process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY;
|
||||
|
||||
const user = await stackServerApp.getUser();
|
||||
if (user != null && projectId != null && publishableClientKey != null) {
|
||||
const accessToken = await user.getAccessToken();
|
||||
if (accessToken != null) {
|
||||
requestHeaders["x-stack-access-type"] = "client";
|
||||
requestHeaders["x-stack-project-id"] = projectId;
|
||||
requestHeaders["x-stack-publishable-client-key"] = publishableClientKey;
|
||||
requestHeaders["x-stack-access-token"] = accessToken;
|
||||
}
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
if (projectId != null && publishableClientKey != null) {
|
||||
requestHeaders["x-stack-access-type"] = "client";
|
||||
requestHeaders["x-stack-project-id"] = projectId;
|
||||
requestHeaders["x-stack-publishable-client-key"] = publishableClientKey;
|
||||
}
|
||||
|
||||
const errorResponse = new Response(
|
||||
JSON.stringify({
|
||||
error: "Documentation service temporarily unavailable",
|
||||
details: "Our documentation service is currently unreachable. Please try again in a moment, or visit https://docs.stack-auth.com directly for help.",
|
||||
}),
|
||||
{ status: 503, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const backendResponse = await fetch(
|
||||
`${backendBaseUrl}/api/latest/ai/query/stream`,
|
||||
{
|
||||
@ -41,7 +44,11 @@ export async function POST(request: Request) {
|
||||
messages: modelMessages,
|
||||
}),
|
||||
}
|
||||
);
|
||||
).catch(() => errorResponse);
|
||||
|
||||
if (!backendResponse.ok) {
|
||||
return errorResponse;
|
||||
}
|
||||
|
||||
return new Response(backendResponse.body, {
|
||||
status: backendResponse.status,
|
||||
|
||||
@ -1,22 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useChat, type UIMessage } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
|
||||
import { DefaultChatTransport, type DynamicToolUIPart } from 'ai';
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Maximize2, Minimize2, Send, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSidebar } from '../layouts/sidebar-context';
|
||||
import { MessageFormatter } from './message-formatter';
|
||||
|
||||
type ToolInvocationPart = {
|
||||
type: `tool-${string}`,
|
||||
toolCallId: string,
|
||||
state: "input-streaming" | "input-available" | "output-available" | "output-error" | "approval-requested" | "approval-responded" | "output-denied",
|
||||
input: unknown,
|
||||
output?: unknown,
|
||||
errorText?: string,
|
||||
};
|
||||
|
||||
function getMessageContent(message: UIMessage): string {
|
||||
return message.parts
|
||||
.filter((part): part is { type: "text", text: string } => part.type === "text")
|
||||
@ -24,10 +15,10 @@ function getMessageContent(message: UIMessage): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getToolInvocations(message: UIMessage): ToolInvocationPart[] {
|
||||
return message.parts
|
||||
.filter((part) => part.type.startsWith("tool-") || part.type === "dynamic-tool")
|
||||
.map((part) => part as unknown as ToolInvocationPart);
|
||||
function getToolInvocations(message: UIMessage): DynamicToolUIPart[] {
|
||||
return message.parts.filter(
|
||||
(part): part is DynamicToolUIPart => part.type === "dynamic-tool"
|
||||
);
|
||||
}
|
||||
|
||||
// Stack Auth Icon Component (just the icon, not full logo)
|
||||
@ -377,17 +368,6 @@ export function AIChatDrawer() {
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming';
|
||||
|
||||
// Debug: log messages, status, and errors
|
||||
useEffect(() => {
|
||||
console.log('[docs-chat] status:', status);
|
||||
console.log('[docs-chat] error:', error);
|
||||
console.log('[docs-chat] messages:', JSON.stringify(messages.map(m => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
parts: m.parts.map(p => ({ type: p.type, ...(p.type === 'text' ? { text: (p as { text: string }).text.slice(0, 100) } : {}) })),
|
||||
})), null, 2));
|
||||
}, [messages, status, error]);
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
@ -626,7 +606,7 @@ export function AIChatDrawer() {
|
||||
<ToolCallDisplay
|
||||
key={index}
|
||||
toolCall={{
|
||||
toolName: part.type === "dynamic-tool" ? (part as unknown as { toolName: string }).toolName : part.type.replace(/^tool-/, ""),
|
||||
toolName: part.toolName,
|
||||
args: part.input as { id?: string, search_query?: string },
|
||||
result: part.output as { content?: { text: string }[], text?: string } | undefined,
|
||||
}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user