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>
This commit is contained in:
Danny WhiteandCharis Lam authored and GitHub committed 2026-01-21 13:52:29 +11:00
1 parent 60dfcc00ea
commit 05dc676d36
9 files changed
+194 -101

No files matched your search

@@ -7,7 +7,7 @@ import { InlineLink } from 'components/ui/InlineLink'
export const IncidentBanner = () => {
return (
<HeaderBanner
variant="note"
variant="warning"
title="We are investigating a technical issue"
description={
<>
@@ -235,7 +235,8 @@ export function MessagePartSwitcher({
return <MessagePart.Dynamic toolPart={part} />
}
case 'tool-list_policies':
case 'tool-search_docs': {
case 'tool-search_docs':
case 'tool-get_active_incidents': {
return <MessagePart.Tool toolPart={part} />
}
case 'reasoning':
@@ -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[] = [
{
+1
View File
@@ -70,6 +70,7 @@ export const TOOL_CATEGORY_MAP: Record<string, ToolCategory> = {
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,
@@ -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.' }
}
},
}),
})
+4
View File
@@ -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 }) : {}),
}
}
+111
View File
@@ -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<IncidentInfo[]> {
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,
}))
}
@@ -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
+1 -92
View File
@@ -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<IncidentInfo[]> => {
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(