mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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[] = [
|
||||
{
|
||||
|
||||
@@ -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.' }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -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 }) : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
Reference in new issue
Block a user