From 39a9a7d24b60edfc1a9a8aa82c61ad73501a34e4 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Fri, 10 Apr 2026 16:48:22 -0700 Subject: [PATCH 1/4] initial commit --- apps/backend/src/lib/ai/prompts.ts | 38 +++++++++++++++++++ apps/backend/src/lib/ai/tools/sql-query.ts | 15 ++++++++ .../src/components/commands/ask-ai.tsx | 28 +++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index 1ce70b5af1..e8057ad1c9 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -102,6 +102,44 @@ SQL QUERY GUIDELINES: - 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 + +TOOL RESULT BUDGET (HARD LIMIT): +- The queryAnalytics tool returns { success: false } if the result JSON exceeds 50,000 characters. + NO ROWS reach you in that case — you get { success: false, error, rowCount, characters, columnsReturned } + and you MUST re-query with a more specific SQL statement. +- The events.data JSON blob typically triples per-row cost. Never SELECT * on events unless you have + a very small LIMIT and truly need every column. + +PREFER AGGREGATION OVER RAW ROWS: +For "how many", "top N", "distribution", "unique count", "average", "over time" questions, +push the math into SQL using ClickHouse functions. Examples: + + Count: SELECT COUNT(*) FROM events WHERE event_type='$token-refresh' AND event_at >= today() + Distinct count: SELECT uniqExact(user_id) FROM events WHERE event_at >= today() - INTERVAL 7 DAY + Top N: SELECT user_id, COUNT(*) AS c FROM events GROUP BY user_id ORDER BY c DESC LIMIT 10 + Quantiles: SELECT quantile(0.5)(amount), quantile(0.95)(amount) FROM events + Time bucketing: SELECT toStartOfHour(event_at) AS bucket, COUNT(*) AS c FROM events + WHERE event_at >= now() - INTERVAL 1 DAY GROUP BY bucket ORDER BY bucket + JSON key discovery: SELECT arrayJoin(JSONExtractKeys(data)) AS k, COUNT(*) AS c FROM events + GROUP BY k ORDER BY c DESC LIMIT 20 + Multi-metric: SELECT COUNT(*), uniqExact(user_id), min(event_at), max(event_at) + FROM events WHERE event_type='$token-refresh' + +WHEN INDIVIDUAL ROWS MATTER (user explicitly asked to see records): +- ALWAYS use LIMIT <= 50. +- ALWAYS specify the exact columns you need — never SELECT * on events. +- Drop the 'data' column unless the user specifically asked about event payloads. + +GROUP BY REQUIRES ORDER BY + LIMIT unless you expect <= 50 groups, otherwise the result may +exceed the 50,000-character budget and fail. + +HANDLING { success: false } ERRORS: +When the tool returns success:false with "Result too large": +1. Read rowCount — if it's large (>100), switch to aggregation (COUNT, uniqExact, GROUP BY...). +2. Read columnsReturned — if it includes 'data', re-query without it. +3. Re-query with a narrower WHERE clause or a smaller LIMIT. +4. Do NOT present the error to the user — fix the query and try again. +5. Do NOT claim you saw rows that you didn't — the error response contains no row data. `, "docs-ask-ai": ` # Stack Auth AI Assistant System Prompt diff --git a/apps/backend/src/lib/ai/tools/sql-query.ts b/apps/backend/src/lib/ai/tools/sql-query.ts index fa6c5f7800..a024798db1 100644 --- a/apps/backend/src/lib/ai/tools/sql-query.ts +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -37,6 +37,21 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI }) .then(async (resultSet) => { const rows = await resultSet.json[]>(); + const serialized = JSON.stringify(rows); + if (serialized.length > 50_000) { + return { + success: false as const, + error: + `Result too large: ${rows.length} rows, ${serialized.length} characters (limit 50000). ` + + `To fix: ` + + `(1) Use aggregation (COUNT, uniqExact, GROUP BY, topK, quantile) instead of fetching rows. ` + + `(2) If you need rows, add a WHERE clause or reduce LIMIT. ` + + `(3) Select only the columns you need — avoid the 'data' column on events unless essential.`, + rowCount: rows.length, + characters: serialized.length, + columnsReturned: rows.length > 0 ? Object.keys(rows[0]) : [], + }; + } return { success: true as const, rowCount: rows.length, diff --git a/apps/dashboard/src/components/commands/ask-ai.tsx b/apps/dashboard/src/components/commands/ask-ai.tsx index 131d773f0c..c3ac5497fe 100644 --- a/apps/dashboard/src/components/commands/ask-ai.tsx +++ b/apps/dashboard/src/components/commands/ask-ai.tsx @@ -5,7 +5,7 @@ import { getPublicEnvVar } from "@/lib/env"; import { useChat, type UIMessage } from "@ai-sdk/react"; import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react"; import { useUser } from "@stackframe/stack"; -import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { convertToModelMessages, DefaultChatTransport } from "ai"; import { usePathname } from "next/navigation"; @@ -480,6 +480,23 @@ function getToolInvocations(message: UIMessage): ToolInvocationPart[] { .map((part) => part as unknown as ToolInvocationPart); } +// Classifies raw AI provider errors into user-friendly messages. +// The raw error is captured to Sentry separately via captureError — never shown to the user. +function getFriendlyAiErrorMessage(error: Error): string { + const causeMessage = (error as { cause?: { message?: string } }).cause?.message ?? ""; + const blob = `${error.message} ${causeMessage}`; + if (/maximum context length|context_length_exceeded|too many tokens|context length/i.test(blob)) { + return "The conversation got too long. Try starting a new chat or asking a more focused question."; + } + if (/rate limit|429|quota|too many requests/i.test(blob)) { + return "Service is busy. Please try again in a moment."; + } + if (/timeout|ECONNRESET|fetch failed|network/i.test(blob)) { + return "Request timed out. Please try again."; + } + return "Something went wrong. Please try again."; +} + // Word streaming hook - handles the progressive word reveal animation function useWordStreaming(content: string) { const [displayedWordCount, setDisplayedWordCount] = useState(0); @@ -575,6 +592,13 @@ const AIChatPreviewInner = memo(function AIChatPreview({ const aiLoading = status === "submitted" || status === "streaming"; + // Log the raw AI error once per error (Sentry captures the original message) + useEffect(() => { + if (aiError) { + captureError("ask-ai", aiError); + } + }, [aiError]); + // Send initial query on mount (once) with debounce useDebouncedAction({ action: async () => { @@ -732,7 +756,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({ {aiError && (
- {aiError.message || "Failed to get response. Please try again."} + {getFriendlyAiErrorMessage(aiError)}
)} From 443e26c06fe1f25fd94d9a6814a91ad02cf11bae Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 12 Apr 2026 19:08:30 -0700 Subject: [PATCH 2/4] pr comments --- apps/backend/src/lib/ai/prompts.ts | 8 +++++--- apps/backend/src/lib/ai/tools/sql-query.ts | 15 +++++++-------- apps/dashboard/src/components/commands/ask-ai.tsx | 12 +++++------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index e8057ad1c9..6ee1b2a0a9 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -1,3 +1,5 @@ +import { SQL_QUERY_RESULT_MAX_CHARS } from "@/lib/ai/tools/sql-query"; + /** * Base prompt for all Stack Auth AI interactions. * Contains global guidelines and core knowledge about Stack Auth. @@ -104,7 +106,7 @@ SQL QUERY GUIDELINES: - Event types: SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10 TOOL RESULT BUDGET (HARD LIMIT): -- The queryAnalytics tool returns { success: false } if the result JSON exceeds 50,000 characters. +- The queryAnalytics tool returns { success: false } if the result JSON exceeds ${SQL_QUERY_RESULT_MAX_CHARS.toLocaleString()} characters. NO ROWS reach you in that case — you get { success: false, error, rowCount, characters, columnsReturned } and you MUST re-query with a more specific SQL statement. - The events.data JSON blob typically triples per-row cost. Never SELECT * on events unless you have @@ -117,7 +119,7 @@ push the math into SQL using ClickHouse functions. Examples: Count: SELECT COUNT(*) FROM events WHERE event_type='$token-refresh' AND event_at >= today() Distinct count: SELECT uniqExact(user_id) FROM events WHERE event_at >= today() - INTERVAL 7 DAY Top N: SELECT user_id, COUNT(*) AS c FROM events GROUP BY user_id ORDER BY c DESC LIMIT 10 - Quantiles: SELECT quantile(0.5)(amount), quantile(0.95)(amount) FROM events + Quantiles: SELECT quantile(0.5)(c), quantile(0.95)(c) FROM (SELECT user_id, COUNT(*) AS c FROM events GROUP BY user_id) Time bucketing: SELECT toStartOfHour(event_at) AS bucket, COUNT(*) AS c FROM events WHERE event_at >= now() - INTERVAL 1 DAY GROUP BY bucket ORDER BY bucket JSON key discovery: SELECT arrayJoin(JSONExtractKeys(data)) AS k, COUNT(*) AS c FROM events @@ -131,7 +133,7 @@ WHEN INDIVIDUAL ROWS MATTER (user explicitly asked to see records): - Drop the 'data' column unless the user specifically asked about event payloads. GROUP BY REQUIRES ORDER BY + LIMIT unless you expect <= 50 groups, otherwise the result may -exceed the 50,000-character budget and fail. +exceed the ${SQL_QUERY_RESULT_MAX_CHARS.toLocaleString()}-character budget and fail. HANDLING { success: false } ERRORS: When the tool returns success:false with "Result too large": diff --git a/apps/backend/src/lib/ai/tools/sql-query.ts b/apps/backend/src/lib/ai/tools/sql-query.ts index a024798db1..beab2d90fe 100644 --- a/apps/backend/src/lib/ai/tools/sql-query.ts +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -3,6 +3,8 @@ import { SmartRequestAuth } from "@/route-handlers/smart-request"; import { tool } from "ai"; import { z } from "zod"; +export const SQL_QUERY_RESULT_MAX_CHARS = 50_000; + export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectId?: string | null) { if (auth == null) { // Return null or throw - analytics queries require authentication @@ -37,12 +39,13 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI }) .then(async (resultSet) => { const rows = await resultSet.json[]>(); - const serialized = JSON.stringify(rows); - if (serialized.length > 50_000) { + const response = { success: true as const, rowCount: rows.length, result: rows }; + const serialized = JSON.stringify(response); + if (serialized.length > SQL_QUERY_RESULT_MAX_CHARS) { return { success: false as const, error: - `Result too large: ${rows.length} rows, ${serialized.length} characters (limit 50000). ` + + `Result too large: ${rows.length} rows, ${serialized.length} characters (limit ${SQL_QUERY_RESULT_MAX_CHARS}). ` + `To fix: ` + `(1) Use aggregation (COUNT, uniqExact, GROUP BY, topK, quantile) instead of fetching rows. ` + `(2) If you need rows, add a WHERE clause or reduce LIMIT. ` + @@ -52,11 +55,7 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI columnsReturned: rows.length > 0 ? Object.keys(rows[0]) : [], }; } - return { - success: true as const, - rowCount: rows.length, - result: rows, - }; + return response; }) .catch((error: unknown) => ({ success: false as const, diff --git a/apps/dashboard/src/components/commands/ask-ai.tsx b/apps/dashboard/src/components/commands/ask-ai.tsx index c3ac5497fe..1107858bdd 100644 --- a/apps/dashboard/src/components/commands/ask-ai.tsx +++ b/apps/dashboard/src/components/commands/ask-ai.tsx @@ -494,6 +494,11 @@ function getFriendlyAiErrorMessage(error: Error): string { if (/timeout|ECONNRESET|fetch failed|network/i.test(blob)) { return "Request timed out. Please try again."; } + if (/result too large|limit \d+/i.test(blob)) { + return "The query returned too much data. Try narrowing your question or requesting fewer rows."; + } + // Unclassified — this is unexpected, report it + captureError("ask-ai", error); return "Something went wrong. Please try again."; } @@ -592,13 +597,6 @@ const AIChatPreviewInner = memo(function AIChatPreview({ const aiLoading = status === "submitted" || status === "streaming"; - // Log the raw AI error once per error (Sentry captures the original message) - useEffect(() => { - if (aiError) { - captureError("ask-ai", aiError); - } - }, [aiError]); - // Send initial query on mount (once) with debounce useDebouncedAction({ action: async () => { From 4d030b4da2405b1990f088f5a2f0d314b1c990f8 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Mon, 13 Apr 2026 12:54:25 -0700 Subject: [PATCH 3/4] pr comment --- .../latest/internal/analytics/query/route.ts | 44 +--------- apps/backend/src/lib/ai/tools/sql-query.ts | 80 ++++++++++--------- apps/backend/src/lib/clickhouse-errors.ts | 43 ++++++++++ 3 files changed, 88 insertions(+), 79 deletions(-) create mode 100644 apps/backend/src/lib/clickhouse-errors.ts diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts index e62c9286d4..e204bd0e2b 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts @@ -1,9 +1,9 @@ import { getClickhouseExternalClient } from "@/lib/clickhouse"; +import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; -import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { randomUUID } from "crypto"; @@ -72,45 +72,5 @@ export const POST = createSmartRouteHandler({ }, }); -const SAFE_CLICKHOUSE_ERROR_CODES = [ - 62, // SYNTAX_ERROR - 159, // TIMEOUT_EXCEEDED - 164, // READONLY - 158, // TOO_MANY_ROWS - 396, // TOO_MANY_ROWS_OR_BYTES - 636, // CANNOT_EXTRACT_TABLE_STRUCTURE -]; - -const UNSAFE_CLICKHOUSE_ERROR_CODES = [ - 36, // BAD_ARGUMENTS - 43, // ILLEGAL_TYPE_OF_ARGUMENT - 47, // UNKNOWN_IDENTIFIER - 60, // UNKNOWN_TABLE - 497, // ACCESS_DENIED -]; - -const DEFAULT_CLICKHOUSE_ERROR_MESSAGE = "Error during execution of this query."; const MAX_RESULT_ROWS = 10_000; const MAX_RESULT_BYTES = 10 * 1024 * 1024; - -function getSafeClickhouseErrorMessage(error: unknown, query: string) { - if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string" || isNaN(Number(error.code)) || !("message" in error) || typeof error.message !== "string") { - captureError("unknown-clickhouse-error-for-query-not-clickhouse-error", new StackAssertionError("Unknown error from Clickhouse is not a Clickhouse error", { cause: error, query: query })); - return DEFAULT_CLICKHOUSE_ERROR_MESSAGE; - } - - const errorCode = Number(error.code); - const message = error.message; - if (SAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode)) { - return message; - } - const isKnown = UNSAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode); - if (!isKnown) { - captureError("unknown-clickhouse-error-for-query", new StackAssertionError(`Unknown Clickhouse error: code ${errorCode} not in safe or unsafe codes`, { cause: error, query: query })); - } - - if (getNodeEnvironment() === "development" || getNodeEnvironment() === "test") { - return `${DEFAULT_CLICKHOUSE_ERROR_MESSAGE}${!isKnown ? "\n\nThis error is not known and you should probably add it to the safe or unsafe codes in analytics/query/route.ts." : ""}\n\nAs you are in development mode, you can see the full error: ${errorCode} ${message}`; - } - return DEFAULT_CLICKHOUSE_ERROR_MESSAGE; -} diff --git a/apps/backend/src/lib/ai/tools/sql-query.ts b/apps/backend/src/lib/ai/tools/sql-query.ts index beab2d90fe..3025e03e90 100644 --- a/apps/backend/src/lib/ai/tools/sql-query.ts +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -1,5 +1,7 @@ import { getClickhouseExternalClient } from "@/lib/clickhouse"; +import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors"; import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { ClickHouseError } from "@clickhouse/client"; import { tool } from "ai"; import { z } from "zod"; @@ -23,44 +25,48 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI }), execute: async ({ query }: { query: string }) => { const client = getClickhouseExternalClient(); - return await client.query({ - query, - clickhouse_settings: { - SQL_project_id: projectId, - SQL_branch_id: branchId, - max_execution_time: 5, - readonly: "1", - allow_ddl: 0, - max_result_rows: "10000", - max_result_bytes: (10 * 1024 * 1024).toString(), - result_overflow_mode: "throw", - }, - format: "JSONEachRow", - }) - .then(async (resultSet) => { - const rows = await resultSet.json[]>(); - const response = { success: true as const, rowCount: rows.length, result: rows }; - const serialized = JSON.stringify(response); - if (serialized.length > SQL_QUERY_RESULT_MAX_CHARS) { - return { - success: false as const, - error: - `Result too large: ${rows.length} rows, ${serialized.length} characters (limit ${SQL_QUERY_RESULT_MAX_CHARS}). ` + - `To fix: ` + - `(1) Use aggregation (COUNT, uniqExact, GROUP BY, topK, quantile) instead of fetching rows. ` + - `(2) If you need rows, add a WHERE clause or reduce LIMIT. ` + - `(3) Select only the columns you need — avoid the 'data' column on events unless essential.`, - rowCount: rows.length, - characters: serialized.length, - columnsReturned: rows.length > 0 ? Object.keys(rows[0]) : [], - }; - } - return response; - }) - .catch((error: unknown) => ({ + try { + const resultSet = await client.query({ + query, + clickhouse_settings: { + SQL_project_id: projectId, + SQL_branch_id: branchId, + max_execution_time: 5, + readonly: "1", + allow_ddl: 0, + max_result_rows: "10000", + max_result_bytes: (10 * 1024 * 1024).toString(), + result_overflow_mode: "throw", + }, + format: "JSONEachRow", + }); + const rows = await resultSet.json[]>(); + const response = { success: true as const, rowCount: rows.length, result: rows }; + const serialized = JSON.stringify(response); + if (serialized.length > SQL_QUERY_RESULT_MAX_CHARS) { + return { + success: false as const, + error: + `Result too large: ${rows.length} rows, ${serialized.length} characters (limit ${SQL_QUERY_RESULT_MAX_CHARS}). ` + + `To fix: ` + + `(1) Use aggregation (COUNT, uniqExact, GROUP BY, topK, quantile) instead of fetching rows. ` + + `(2) If you need rows, add a WHERE clause or reduce LIMIT. ` + + `(3) Select only the columns you need — avoid the 'data' column on events unless essential.`, + rowCount: rows.length, + characters: serialized.length, + columnsReturned: rows.length > 0 ? Object.keys(rows[0]) : [], + }; + } + return response; + } catch (error) { + if (!(error instanceof ClickHouseError)) { + throw error; + } + return { success: false as const, - error: error instanceof Error ? error.message : "Query failed", - })); + error: getSafeClickhouseErrorMessage(error, query), + }; + } }, }); } diff --git a/apps/backend/src/lib/clickhouse-errors.ts b/apps/backend/src/lib/clickhouse-errors.ts new file mode 100644 index 0000000000..60e15398ba --- /dev/null +++ b/apps/backend/src/lib/clickhouse-errors.ts @@ -0,0 +1,43 @@ +import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +const SAFE_CLICKHOUSE_ERROR_CODES = [ + 62, // SYNTAX_ERROR + 159, // TIMEOUT_EXCEEDED + 164, // READONLY + 158, // TOO_MANY_ROWS + 396, // TOO_MANY_ROWS_OR_BYTES + 636, // CANNOT_EXTRACT_TABLE_STRUCTURE +]; + +const UNSAFE_CLICKHOUSE_ERROR_CODES = [ + 36, // BAD_ARGUMENTS + 43, // ILLEGAL_TYPE_OF_ARGUMENT + 47, // UNKNOWN_IDENTIFIER + 60, // UNKNOWN_TABLE + 497, // ACCESS_DENIED +]; + +const DEFAULT_CLICKHOUSE_ERROR_MESSAGE = "Error during execution of this query."; + +export function getSafeClickhouseErrorMessage(error: unknown, query: string) { + if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string" || isNaN(Number(error.code)) || !("message" in error) || typeof error.message !== "string") { + captureError("unknown-clickhouse-error-for-query-not-clickhouse-error", new StackAssertionError("Unknown error from Clickhouse is not a Clickhouse error", { cause: error, query: query })); + return DEFAULT_CLICKHOUSE_ERROR_MESSAGE; + } + + const errorCode = Number(error.code); + const message = error.message; + if (SAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode)) { + return message; + } + const isKnown = UNSAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode); + if (!isKnown) { + captureError("unknown-clickhouse-error-for-query", new StackAssertionError(`Unknown Clickhouse error: code ${errorCode} not in safe or unsafe codes`, { cause: error, query: query })); + } + + if (getNodeEnvironment() === "development" || getNodeEnvironment() === "test") { + return `${DEFAULT_CLICKHOUSE_ERROR_MESSAGE}${!isKnown ? "\n\nThis error is not known and you should probably add it to the safe or unsafe codes in clickhouse-errors.ts." : ""}\n\nAs you are in development mode, you can see the full error: ${errorCode} ${message}`; + } + return DEFAULT_CLICKHOUSE_ERROR_MESSAGE; +} From 40e15fc773683650f90cf9fbcd4c3d530fd60078 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Mon, 13 Apr 2026 13:14:20 -0700 Subject: [PATCH 4/4] merge --- .../components/commands/ai-chat-shared.tsx | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 apps/dashboard/src/components/commands/ai-chat-shared.tsx diff --git a/apps/dashboard/src/components/commands/ai-chat-shared.tsx b/apps/dashboard/src/components/commands/ai-chat-shared.tsx new file mode 100644 index 0000000000..ef955001ea --- /dev/null +++ b/apps/dashboard/src/components/commands/ai-chat-shared.tsx @@ -0,0 +1,558 @@ +import { cn } from "@/components/ui"; +import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers"; +import { getPublicEnvVar } from "@/lib/env"; +import type { UIMessage } from "@ai-sdk/react"; +import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react"; +import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { convertToModelMessages, DefaultChatTransport } from "ai"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + + +export function createAskAiTransport({ + currentUser, + projectId, +}: { + currentUser: CurrentUser | null, + projectId: string | undefined, +}): DefaultChatTransport { + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); + return new DefaultChatTransport({ + api: `${backendBaseUrl}/api/latest/ai/query/stream`, + headers: () => buildStackAuthHeaders(currentUser), + prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { + const modelMessages = await convertToModelMessages(uiMessages); + return { + body: { + systemPrompt: "command-center-ask-ai", + tools: ["docs", "sql-query"], + quality: "smart", + speed: "slow", + projectId, + messages: modelMessages.map(m => ({ + role: m.role, + content: m.content, + })), + }, + headers, + }; + }, + }); +} + +// Memoized copy button for performance +export const CopyButton = memo(function CopyButton({ text, className, size = "sm" }: { + text: string, + className?: string, + size?: "sm" | "xs", +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [text]); + + const iconSize = size === "xs" ? "h-2.5 w-2.5" : "h-3 w-3"; + + return ( + + ); +}); + +// Truncate URL for display while keeping full URL for copy +export function truncateUrl(url: string, maxLength = 50): string { + if (url.length <= maxLength) return url; + const match = url.match(/^https?:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/); + if (match) { + const host = match[1]; + const path = (match[2] || "") + (match[3] || ""); + if (path.length > 30) { + return host + path.slice(0, 20) + "..." + path.slice(-10); + } + } + return url.slice(0, maxLength - 3) + "..."; +} + +// Inline code with smart copy button +export const InlineCode = memo(function InlineCode({ children }: { children?: React.ReactNode }) { + const text = String(children || ""); + const isUrl = /^https?:\/\//.test(text); + const isCommand = /^(npm|npx|pnpm|yarn|curl|git|docker|cd|mkdir|ls|brew|apt|pip)/.test(text); + const isPath = /^[./~]/.test(text) && text.includes("/"); + const showCopy = isUrl || isCommand || isPath || text.length > 15; + + // For very long URLs, show truncated version + const displayText = isUrl && text.length > 60 ? truncateUrl(text, 55) : text; + + return ( + + + {displayText} + + {showCopy && } + + ); +}); + +// Code block with language label and copy +export const CodeBlock = memo(function CodeBlock({ children, className }: { + children?: React.ReactNode, + className?: string, +}) { + const text = String(children || "").replace(/\n$/, ""); + const language = className?.replace("language-", "").toUpperCase() ?? ""; + + return ( +
+ {/* Header with language and copy */} +
+ + {language || "CODE"} + + +
+ {/* Code content */} +
+
+          {children}
+        
+
+
+ ); +}); + +// Smart link component with copy and external icon +export const SmartLink = memo(function SmartLink({ href, children }: { + href?: string, + children?: React.ReactNode, +}) { + const displayText = String(children || href || ""); + const isFullUrl = href?.startsWith("http"); + const isDocsLink = href?.includes("docs.stack-auth.com"); + + // Truncate long URLs for display + const truncatedDisplay = displayText.length > 55 ? truncateUrl(displayText, 50) : displayText; + + return ( + + + {truncatedDisplay} + {isFullUrl && !isDocsLink && ( + + )} + + {isFullUrl && href && } + + ); +}); + +export type ToolInvocationPart = Extract; + + +// Expandable tool invocation card +export const ToolInvocationCard = memo(function ToolInvocationCard({ + invocation, +}: { + invocation: ToolInvocationPart, +}) { + const [isExpanded, setIsExpanded] = useState(false); + const isLoading = invocation.state === "input-streaming" || invocation.state === "input-available"; + const hasResult = invocation.state === "output-available"; + const hasError = invocation.state === "output-error"; + + // Extract tool name from type (e.g., "tool-queryAnalytics" → "queryAnalytics") + const toolName = invocation.type.replace(/^tool-/, ""); + + // Format the tool name for display + const getToolDisplay = () => { + if (toolName === "queryAnalytics") { + return { label: "Analytics Query", icon: DatabaseIcon }; + } + return { label: toolName, icon: DatabaseIcon }; + }; + + const { label, icon: Icon } = getToolDisplay(); + + const input = invocation.input as { query?: string }; + const queryArg = input.query; + const result = invocation.output as { success?: boolean, result?: unknown[], error?: string, rowCount?: number }; + + return ( +
+ {/* Header - always visible */} + + + {/* Expandable content */} +
+
+
+ {/* Query */} + {queryArg && ( +
+
+ + Query + + +
+
+                  {queryArg}
+                
+
+ )} + + {/* Result */} + {hasResult && ( +
+
+ + {result.success ? "Result" : "Error"} + + {result.success && result.result && ( + + )} +
+ {result.success ? ( +
+                    {JSON.stringify(result.result, null, 2)}
+                  
+ ) : ( +
+ {result.error || "Query failed"} +
+ )} +
+ )} + + {/* Error state from SDK */} + {hasError && invocation.errorText && ( +
+
+ + Error + +
+
+ {invocation.errorText} +
+
+ )} + + {/* Loading state */} + {isLoading && ( +
+ + Running query... +
+ )} +
+
+
+
+ ); +}); + +// Memoized markdown components for consistent rendering +export const markdownComponents = { + p: ({ children }: { children?: React.ReactNode }) => ( +

+ {children} +

+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
+ ), + ol: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
+ ), + li: ({ children }: { children?: React.ReactNode }) => ( +
  • {children}
  • + ), + code: ({ children, className }: { children?: React.ReactNode, className?: string }) => { + if (className) { + return {children}; + } + return {children}; + }, + pre: ({ children }: { children?: React.ReactNode }) => <>{children}, + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + a: SmartLink, + table: ({ children }: { children?: React.ReactNode }) => ( +
    + {children}
    +
    + ), + thead: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + tbody: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + tr: ({ children }: { children?: React.ReactNode }) => {children}, + th: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + td: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + h1: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h2: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + h3: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + blockquote: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
    + ), + hr: () =>
    , +}; + +// Helper to count words in a string +export function countWords(text: string): number { + return text.split(/\s+/).filter(Boolean).length; +} + +// Helper to get first N words from text +export function getFirstNWords(text: string, n: number): string { + const words = text.split(/(\s+)/); // Split but keep whitespace + let wordCount = 0; + let result = ""; + for (const part of words) { + if (part.trim()) { + wordCount++; + if (wordCount > n) break; + } + result += part; + } + return result; +} + +// Helper to extract text content from UIMessage parts +export function getMessageContent(message: UIMessage): string { + return message.parts + .filter((part): part is { type: "text", text: string } => part.type === "text") + .map(part => part.text) + .join(""); +} + +// Helper to extract tool invocations from UIMessage parts +export function getToolInvocations(message: UIMessage): ToolInvocationPart[] { + return message.parts.filter( + (part): part is ToolInvocationPart => part.type.startsWith("tool-") + ); +} + +// Memoized user message component +export const UserMessage = memo(function UserMessage({ content }: { content: string }) { + return ( +
    +
    +

    {content}

    +
    +
    + +
    +
    + ); +}); + +// Memoized assistant message component +export const AssistantMessage = memo(function AssistantMessage({ + content, + toolInvocations, +}: { + content: string, + toolInvocations?: ToolInvocationPart[], +}) { + const hasToolInvocations = toolInvocations && toolInvocations.length > 0; + const hasContent = content.trim().length > 0; + + return ( +
    +
    + +
    +
    + {/* Tool invocations */} + {hasToolInvocations && ( +
    + {toolInvocations.map((invocation) => ( + + ))} +
    + )} + + {/* Text content */} + {hasContent && ( +
    +
    + + {content} + +
    +
    + )} +
    +
    + ); +}); + +// Word streaming hook - handles the progressive word reveal animation +export function useWordStreaming(content: string) { + const [displayedWordCount, setDisplayedWordCount] = useState(0); + const targetWordCount = content ? countWords(content) : 0; + const previousContentRef = useRef(""); + + useEffect(() => { + if (!content) { + setDisplayedWordCount(0); + previousContentRef.current = ""; + return; + } + if (!content.startsWith(previousContentRef.current)) { + setDisplayedWordCount(0); + } + previousContentRef.current = content; + }, [content]); + + useEffect(() => { + if (targetWordCount === 0 || displayedWordCount >= targetWordCount) { + return; + } + const timeoutId = setTimeout(() => { + setDisplayedWordCount(prev => Math.min(prev + 1, targetWordCount)); + }, 15); + return () => clearTimeout(timeoutId); + }, [displayedWordCount, targetWordCount]); + + return { + displayedWordCount, + targetWordCount, + getDisplayContent: (text: string) => getFirstNWords(text, displayedWordCount), + isRevealing: displayedWordCount < targetWordCount, + }; +} + + +// Classifies raw AI provider errors into user-friendly messages. +// The raw error is captured to Sentry separately via captureError — never shown to the user. +export function getFriendlyAiErrorMessage(error: Error): string { + const causeMessage = (error as { cause?: { message?: string } }).cause?.message ?? ""; + const blob = `${error.message} ${causeMessage}`; + if (/maximum context length|context_length_exceeded|too many tokens|context length/i.test(blob)) { + return "The conversation got too long. Try starting a new chat or asking a more focused question."; + } + if (/rate limit|429|quota|too many requests/i.test(blob)) { + return "Service is busy. Please try again in a moment."; + } + if (/timeout|ECONNRESET|fetch failed|network/i.test(blob)) { + return "Request timed out. Please try again."; + } + if (/result too large|limit \d+/i.test(blob)) { + return "The query returned too much data. Try narrowing your question or requesting fewer rows."; + } + // Unclassified — this is unexpected, report it + captureError("ask-ai", error); + return "Something went wrong. Please try again."; +}