From 05dc676d361bdfaaf11df6a7adf784212b140c69 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:52:29 +1000 Subject: [PATCH] feat(studio): incident-aware AI assistant (#41603) * handling and mock data support * admonition * only show in empty state * rabbit * mock without local overrides * remove admonition * make incident banner more prominent * remove mock data * move to tool * prettier * fix(studio): get_active_incidents tool --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> --- .../layouts/AppLayout/IncidentBanner.tsx | 2 +- .../ui/AIAssistantPanel/Message.Parts.tsx | 3 +- .../lib/ai/generate-assistant-response.ts | 16 +-- apps/studio/lib/ai/tool-filter.ts | 1 + apps/studio/lib/ai/tools/incident-tools.ts | 63 ++++++++++ apps/studio/lib/ai/tools/index.ts | 4 + apps/studio/lib/api/incident-status.ts | 111 ++++++++++++++++++ apps/studio/pages/api/ai/sql/generate-v4.ts | 2 + apps/studio/pages/api/incident-status.ts | 93 +-------------- 9 files changed, 194 insertions(+), 101 deletions(-) create mode 100644 apps/studio/lib/ai/tools/incident-tools.ts diff --git a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx index 4890f795e0c..5f5830ef2e1 100644 --- a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx @@ -7,7 +7,7 @@ import { InlineLink } from 'components/ui/InlineLink' export const IncidentBanner = () => { return ( diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx index a3b54c37c9d..c61c7b24cd9 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.Parts.tsx @@ -235,7 +235,8 @@ export function MessagePartSwitcher({ return } case 'tool-list_policies': - case 'tool-search_docs': { + case 'tool-search_docs': + case 'tool-get_active_incidents': { return } case 'reasoning': diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index 1f35a8223d1..66ef0922aec 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -16,11 +16,11 @@ import { CHAT_PROMPT, EDGE_FUNCTION_PROMPT, GENERAL_PROMPT, - PG_BEST_PRACTICES, - RLS_PROMPT, - REALTIME_PROMPT, - SECURITY_PROMPT, LIMITATIONS_PROMPT, + PG_BEST_PRACTICES, + REALTIME_PROMPT, + RLS_PROMPT, + SECURITY_PROMPT, } from 'lib/ai/prompts' import { sanitizeMessagePart } from 'lib/ai/tools/tool-sanitizer' @@ -94,10 +94,12 @@ export async function generateAssistantResponse({ // Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions` // https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88 - const assistantContent = + const hasProjectContext = projectRef || chatName || schemasString !== "You don't have access to any schemas." - ? `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}` - : undefined + + const assistantContent = hasProjectContext + ? `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.` + : undefined const coreMessages: ModelMessage[] = [ { diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index d9e0c66d27b..53f5a905fcf 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -70,6 +70,7 @@ export const TOOL_CATEGORY_MAP: Record = { deploy_edge_function: TOOL_CATEGORIES.UI, rename_chat: TOOL_CATEGORIES.UI, search_docs: TOOL_CATEGORIES.UI, + get_active_incidents: TOOL_CATEGORIES.UI, // Schema tools - MCP list_tables: TOOL_CATEGORIES.SCHEMA, diff --git a/apps/studio/lib/ai/tools/incident-tools.ts b/apps/studio/lib/ai/tools/incident-tools.ts new file mode 100644 index 00000000000..19ab94ab77c --- /dev/null +++ b/apps/studio/lib/ai/tools/incident-tools.ts @@ -0,0 +1,63 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import { IS_PLATFORM } from 'common' +import type { IncidentInfo } from 'lib/api/incident-status' + +/** + * Creates incident-related tools for the AI assistant. + * + * @param baseUrl - The base URL for API requests (e.g., https://supabase.com/dashboard) + * This should be the public URL to leverage CDN caching. + */ +export const getIncidentTools = ({ baseUrl }: { baseUrl: string }) => ({ + get_active_incidents: tool({ + description: + 'Check for active incidents. Use this tool when the user reports issues with any Supabase service, including the database, authentication, realtime, storage, and functions. Possible problems include, but are not limited to, connection issues, timeouts, service unavailability, authentication failures, or unexpected errors.', + inputSchema: z.object({}), + execute: async () => { + if (!IS_PLATFORM) { + return { + incidents: [], + message: 'Incident checking is only available on Supabase platform.', + } + } + + try { + const response = await fetch(`${baseUrl}/api/incident-status`, { + signal: AbortSignal.timeout(5_000), + }) + + if (!response.ok) { + console.warn('Failed to fetch incident status:', response.status) + return { incidents: [], error: 'Unable to check incident status at this time.' } + } + + const incidents: IncidentInfo[] = await response.json() + + if (incidents.length === 0) { + return { + incidents: [], + message: + 'No active incidents. The issue the user is experiencing is likely not related to a Supabase infrastructure problem.', + } + } + + const incidentSummaries = incidents.map((incident) => ({ + name: incident.name, + status: incident.status, + impact: incident.impact, + active_since: incident.active_since, + })) + + return { + incidents: incidentSummaries, + message: `There ${incidents.length === 1 ? 'is' : 'are'} ${incidents.length} active incident${incidents.length === 1 ? '' : 's'} on Supabase infrastructure. If the user's issue appears related, inform them about the ongoing incident(s) and direct them to https://status.supabase.com for real-time updates.`, + } + } catch (error) { + console.warn('Failed to fetch incident status:', error) + return { incidents: [], error: 'Unable to check incident status at this time.' } + } + }, + }), +}) diff --git a/apps/studio/lib/ai/tools/index.ts b/apps/studio/lib/ai/tools/index.ts index 9082cfdb81f..00290964526 100644 --- a/apps/studio/lib/ai/tools/index.ts +++ b/apps/studio/lib/ai/tools/index.ts @@ -3,6 +3,7 @@ import { filterToolsByOptInLevel } from '../tool-filter' import { getFallbackTools } from './fallback-tools' import { ToolSet } from 'ai' import { IS_PLATFORM } from 'common' +import { getIncidentTools } from './incident-tools' import { getMcpTools } from './mcp-tools' import { getSchemaTools } from './schema-tools' import { getRenderingTools } from './rendering-tools' @@ -13,12 +14,14 @@ export const getTools = async ({ authorization, aiOptInLevel, accessToken, + baseUrl, }: { projectRef: string connectionString: string authorization?: string aiOptInLevel: AiOptInLevel accessToken?: string + baseUrl?: string }) => { // Always include rendering tools let tools: ToolSet = getRenderingTools() @@ -50,6 +53,7 @@ export const getTools = async ({ connectionString, authorization, }), + ...(baseUrl ? getIncidentTools({ baseUrl }) : {}), } } diff --git a/apps/studio/lib/api/incident-status.ts b/apps/studio/lib/api/incident-status.ts index 9f526e4540f..b552e27ead4 100644 --- a/apps/studio/lib/api/incident-status.ts +++ b/apps/studio/lib/api/incident-status.ts @@ -1,3 +1,8 @@ +import z from 'zod' + +import { IS_PLATFORM } from 'common' +import { InternalServerError } from 'lib/api/apiHelpers' + export type IncidentInfo = { id: string name: string @@ -5,3 +10,109 @@ export type IncidentInfo = { impact: string active_since: string } + +const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1' +const STATUSPAGE_PAGE_ID = process.env.STATUSPAGE_PAGE_ID +const STATUSPAGE_API_KEY = process.env.STATUSPAGE_API_KEY + +function getIncidentsEndpoint(): string { + return `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/incidents/unresolved` +} + +const StatusPageIncidentsSchema = z.array( + z.object({ + id: z.string(), + name: z.string(), + status: z.string(), + created_at: z.string(), + scheduled_for: z.string().nullable(), + impact: z.string(), + }) +) + +/** + * Fetches active incidents from the StatusPage API. + * This function is used both by the API route and the AI assistant. + * + * @returns Array of active incidents + * @throws InternalServerError if StatusPage is not configured or returns an error + */ +export async function getActiveIncidents(): Promise { + if (!IS_PLATFORM) { + return [] + } + + if (!STATUSPAGE_PAGE_ID) { + throw new InternalServerError('StatusPage page ID is not configured') + } + + if (!STATUSPAGE_API_KEY) { + throw new InternalServerError('StatusPage API key is not configured') + } + + const response = await fetch(getIncidentsEndpoint(), { + headers: { + Authorization: `OAuth ${STATUSPAGE_API_KEY}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + cache: 'no-store', + signal: AbortSignal.timeout(30_000), + }) + const responseText = await response.text() + + if (!response.ok) { + throw new InternalServerError(`StatusPage API responded with ${response.status}`, { + status: response.status, + body: responseText, + }) + } + + let incidentsJson: unknown + try { + incidentsJson = JSON.parse(responseText) + } catch (error) { + throw new InternalServerError('StatusPage API response could not be parsed as JSON', { + error: error instanceof Error ? error.message : error, + body: responseText, + }) + } + + const result = StatusPageIncidentsSchema.safeParse(incidentsJson) + + if (!result.success) { + throw new InternalServerError('StatusPage API response did not match expected schema', { + issues: result.error.issues, + }) + } + + const now = Date.now() + const activeIncidents = result.data.filter((incident) => { + const hasNoScheduledTime = !incident.scheduled_for + if (hasNoScheduledTime) { + return true + } + + const scheduledTime = Date.parse(incident.scheduled_for!) + const isScheduledTimeInvalid = Number.isNaN(scheduledTime) + if (isScheduledTimeInvalid) { + // Keep the record but note it locally for debugging + console.warn('Encountered incident with invalid scheduled_for date', { + incidentId: incident.id, + scheduled_for: incident.scheduled_for, + }) + return true + } + + const hasScheduledTimePassed = scheduledTime <= now + return hasScheduledTimePassed + }) + + return activeIncidents.map((incident) => ({ + id: incident.id, + name: incident.name, + status: incident.status, + impact: incident.impact, + active_since: incident.scheduled_for ?? incident.created_at, + })) +} diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 93aa261b14c..92de85ef6d6 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -10,6 +10,7 @@ import { getModel } from 'lib/ai/model' import { getOrgAIDetails } from 'lib/ai/org-ai-details' import { generateAssistantResponse } from 'lib/ai/generate-assistant-response' import { getTools } from 'lib/ai/tools' +import { getURL } from 'lib/helpers' import apiWrapper from 'lib/api/apiWrapper' import { executeQuery } from 'lib/api/self-hosted/query' @@ -138,6 +139,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { authorization, aiOptInLevel, accessToken, + baseUrl: getURL(), }) // Get a list of all schemas to add to context diff --git a/apps/studio/pages/api/incident-status.ts b/apps/studio/pages/api/incident-status.ts index d18e9a9394e..e925a92e6a3 100644 --- a/apps/studio/pages/api/incident-status.ts +++ b/apps/studio/pages/api/incident-status.ts @@ -1,15 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next' -import z from 'zod' import { IS_PLATFORM } from 'common' import { InternalServerError } from 'lib/api/apiHelpers' -import type { IncidentInfo } from 'lib/api/incident-status' - -const STATUSPAGE_API_URL = 'https://api.statuspage.io/v1' -const STATUSPAGE_PAGE_ID = process.env.STATUSPAGE_PAGE_ID -const STATUSPAGE_API_KEY = process.env.STATUSPAGE_API_KEY - -const INCIDENTS_ENDPOINT = `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/incidents/unresolved` +import { getActiveIncidents, type IncidentInfo } from 'lib/api/incident-status' /** * Cache on browser for 5 minutes @@ -18,90 +11,6 @@ const INCIDENTS_ENDPOINT = `${STATUSPAGE_API_URL}/pages/${STATUSPAGE_PAGE_ID}/in */ const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' -const StatusPageIncidentsSchema = z.array( - z.object({ - id: z.string(), - name: z.string(), - status: z.string(), - created_at: z.string(), - scheduled_for: z.string().nullable(), - impact: z.string(), - }) -) - -const getActiveIncidents = async (): Promise => { - if (!STATUSPAGE_PAGE_ID) { - throw new InternalServerError('StatusPage page ID is not configured') - } - - if (!STATUSPAGE_API_KEY) { - throw new InternalServerError('StatusPage API key is not configured') - } - - const response = await fetch(INCIDENTS_ENDPOINT, { - headers: { - Authorization: `OAuth ${STATUSPAGE_API_KEY}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - cache: 'no-store', - signal: AbortSignal.timeout(30_000), - }) - const responseText = await response.text() - - if (!response.ok) { - throw new InternalServerError(`StatusPage API responded with ${response.status}`, { - status: response.status, - body: responseText, - }) - } - - let incidentsJson: unknown - try { - incidentsJson = JSON.parse(responseText) - } catch (error) { - throw new InternalServerError('StatusPage API response could not be parsed as JSON', { - error: error instanceof Error ? error.message : error, - body: responseText, - }) - } - - const result = StatusPageIncidentsSchema.safeParse(incidentsJson) - - if (!result.success) { - throw new InternalServerError('StatusPage API response did not match expected schema', { - issues: result.error.issues, - }) - } - - const now = Date.now() - const activeIncidents = result.data.filter((incident) => { - if (!incident.scheduled_for) { - return true - } - - const scheduledTime = Date.parse(incident.scheduled_for) - if (Number.isNaN(scheduledTime)) { - // Keep the record but note it locally for debugging - console.warn('Encountered incident with invalid scheduled_for date', { - incidentId: incident.id, - scheduled_for: incident.scheduled_for, - }) - return true - } - - return scheduledTime <= now - }) - - return activeIncidents.map((incident) => ({ - id: incident.id, - name: incident.name, - status: incident.status, - impact: incident.impact, - active_since: incident.scheduled_for ?? incident.created_at, - })) -} - // Default export needed by Next.js convention // eslint-disable-next-line no-restricted-exports export default async function handler(