mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 12:25:05 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature — final PR (9/9) of the SQL editor logs-source stack. **Base branch:** `charislam/sql-editor-inline-ai-clickhouse-dialect` (PR 8). Nothing here is user-visible: entry points stay behind `sqlEditorLogsSource` + `otelLegacyLogs`, and flag rollout happens after the whole stack merges. ## What is the current behavior? - The Assistant has no idea a SQL editor snippet targets the logs backend. Ask it about a logs snippet and it answers in Postgres, because the attached query is fenced as ` ```sql ` and nothing tells the model otherwise. - Because the `sql` fence is what `MessageMarkdown` treats as runnable Postgres, an attached ClickHouse query is rendered with a Run-against-Postgres affordance and branded with `untrustedSql`. - "Debug with Assistant" on a failed logs query produces a dialect-less prompt, so both the in-app assistant and the copyable version get debugged as Postgres. - A report referencing a `log_sql` snippet runs its ClickHouse SQL against the user's Postgres database and surfaces the resulting error. ## What is the new behavior? **Assistant panel.** The "Current Query" chip records which backend the attached query targets. That reaches the model two ways: each attachment is fenced with its own dialect (` ```clickhouse ` vs ` ```sql `), and a `containsLogsSnippets` flag rides on the user message as AI SDK `metadata`. The server reads the flag off the conversation and prepends the ClickHouse dialect rules plus the logs schema reference as a non-cached context message. Two design points worth calling out in review: - The flag lives on the **message**, not the request body, so Retry and the tool-approval continuation reproduce the context a message was originally asked in — neither of those passes a per-call body. - It's derived from **what's actually attached**, so detaching the chip drops the claim rather than leaving the two able to disagree. The `clickhouse` fence also keeps a logs query out of `MessageMarkdown`'s `sql` branch, so it's no longer offered as runnable Postgres or branded with `untrustedSql` — a boundary this stack's distinct brands exist to prevent crossing. **Debug flow.** `buildDebugChatArgs` attaches its query with a source for the same reason, and names the dialect in the prompt text so the copyable version stands on its own outside the app. **Reports.** A report only stores a snippet id, so whether it queries the logs backend is only knowable once the content loads. `ReportBlock` guards on the fetched type and renders a `LogsSnippetReportBlock` placeholder instead of executing. Double-guarded: no `sql` for a logs snippet (so it's out of the query key and `queryFn` short-circuits even on an explicit `refetch`) and `enabled` excludes it. **Incidental cleanups.** `buildAssistantContextMessages` extracted out of `generate-assistant-response`; a schema-access sentinel that was duplicated as a string literal across two files (and compared against) replaced with one exported constant; `SqlSnippet` deduplicated to a single declaration; `resolveSnippetSource` / `isLogsSource` shared instead of re-implemented per surface. **Tests.** 4 new/extended suites. Notable cases pinned: a message with no metadata must validate (`safeValidateUIMessages` applies `metadataSchema` to *every* message, so a required schema would 400 every existing conversation); only *user* messages count, so a model reply can't talk the server into a different dialect; a mixed-attachment message is flagged without overclaiming a single source; and `ReportBlock` registers no pg-meta mock for the logs cases, so an unhandled request failing the test *is* the assertion that logs SQL never reaches Postgres. Verified: `pnpm typecheck`, `lint:ratchet` (no regression), Prettier, and the full Studio suite (459 files / 4969 tests). ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for recognizing log snippets in reports, with clear guidance to open them in the SQL editor or remove them. - AI Assistant now understands log snippets and provides ClickHouse-specific context, formatting, and troubleshooting guidance. - Snippets retain their source information when shared with the AI Assistant. - **Bug Fixes** - Prevented unsupported log snippets from being executed as regular database queries. - Improved source detection when opening snippets directly from links. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
273 lines
7.9 KiB
TypeScript
273 lines
7.9 KiB
TypeScript
import pgMeta from '@supabase/pg-meta'
|
|
import type { JwtPayload } from '@supabase/supabase-js'
|
|
import { safeValidateUIMessages } from 'ai'
|
|
import { IS_PLATFORM } from 'common'
|
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
import z from 'zod'
|
|
|
|
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
|
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
|
|
import { getOrgAIDetails, getProjectAIDetails } from '@/lib/ai/ai-details'
|
|
import { NO_SCHEMA_ACCESS_MESSAGE } from '@/lib/ai/assistant-context'
|
|
import {
|
|
assistantMessageMetadataSchema,
|
|
messagesIncludeLogsSnippets,
|
|
} from '@/lib/ai/assistant-message-metadata'
|
|
import { isTracingAllowed } from '@/lib/ai/braintrust-logger'
|
|
import { generateAssistantResponse } from '@/lib/ai/generate-assistant-response'
|
|
import { getModel } from '@/lib/ai/model'
|
|
import {
|
|
DEFAULT_ASSISTANT_ADVANCE_MODEL_ID,
|
|
DEFAULT_ASSISTANT_BASE_MODEL_ID,
|
|
getAssistantModelEntry,
|
|
isAssistantBaseModelId,
|
|
isKnownAssistantModelId,
|
|
type AssistantModelId,
|
|
} from '@/lib/ai/model.utils'
|
|
import { getTools } from '@/lib/ai/tools'
|
|
import { apiWrapper } from '@/lib/api/apiWrapper'
|
|
import { executeQuery } from '@/lib/api/self-hosted/query'
|
|
import { getURL } from '@/lib/helpers'
|
|
|
|
export const maxDuration = 120
|
|
|
|
export const config = {
|
|
api: {
|
|
bodyParser: {
|
|
sizeLimit: '5mb',
|
|
},
|
|
},
|
|
}
|
|
|
|
async function handler(req: NextApiRequest, res: NextApiResponse, claims?: JwtPayload) {
|
|
const { method } = req
|
|
|
|
switch (method) {
|
|
case 'POST':
|
|
return handlePost(req, res, claims)
|
|
default:
|
|
res.setHeader('Allow', ['POST'])
|
|
res.status(405).json({
|
|
data: null,
|
|
error: { message: `Method ${method} Not Allowed` },
|
|
})
|
|
}
|
|
}
|
|
|
|
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
|
|
apiWrapper(req, res, handler, { withAuth: true })
|
|
|
|
export default wrapper
|
|
|
|
const requestBodySchema = z.object({
|
|
messages: z.array(z.any()),
|
|
projectRef: z.string(),
|
|
connectionString: z.string(),
|
|
schema: z.string().optional(),
|
|
table: z.string().optional(),
|
|
chatId: z.string().optional(),
|
|
chatName: z.string().optional(),
|
|
supportMode: z.boolean().optional(),
|
|
orgSlug: z.string().optional(),
|
|
model: z.string().optional(),
|
|
})
|
|
|
|
async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: JwtPayload) {
|
|
const authorization = req.headers.authorization
|
|
const accessToken = authorization?.replace('Bearer ', '')
|
|
|
|
if (IS_PLATFORM && !accessToken) {
|
|
return res.status(401).json({ error: 'Authorization token is required' })
|
|
}
|
|
|
|
const userId = claims?.sub
|
|
|
|
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
|
const { data, error: parseError } = requestBodySchema.safeParse(body)
|
|
|
|
if (parseError) {
|
|
return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues })
|
|
}
|
|
|
|
const {
|
|
messages: rawMessages,
|
|
projectRef,
|
|
connectionString,
|
|
orgSlug,
|
|
chatId,
|
|
chatName,
|
|
model: rawRequestedModel,
|
|
supportMode,
|
|
} = data
|
|
|
|
const requestedModel: AssistantModelId | undefined =
|
|
rawRequestedModel && isKnownAssistantModelId(rawRequestedModel) ? rawRequestedModel : undefined
|
|
|
|
const messagesValidation = await safeValidateUIMessages({
|
|
messages: rawMessages,
|
|
metadataSchema: assistantMessageMetadataSchema,
|
|
})
|
|
if (!messagesValidation.success) {
|
|
return res.status(400).json({
|
|
error: 'Invalid request body',
|
|
message: messagesValidation.error.message,
|
|
})
|
|
}
|
|
const messages = messagesValidation.data
|
|
|
|
const includesLogsSnippets = messagesIncludeLogsSnippets(messages)
|
|
|
|
let aiOptInLevel: AiOptInLevel = 'disabled'
|
|
let hasAccessToAdvanceModel = false
|
|
let orgHasHipaaAddon: boolean | undefined
|
|
let projectIsSensitive: boolean | undefined
|
|
let projectRegion: string | undefined
|
|
let orgId: number | undefined
|
|
let planId: string | undefined
|
|
|
|
if (!IS_PLATFORM) {
|
|
aiOptInLevel = 'schema'
|
|
hasAccessToAdvanceModel = true
|
|
}
|
|
|
|
if (IS_PLATFORM && orgSlug && authorization && projectRef) {
|
|
try {
|
|
const [orgDetails, projectDetails] = await Promise.all([
|
|
getOrgAIDetails({ orgSlug, authorization }),
|
|
getProjectAIDetails({ projectRef, authorization }),
|
|
])
|
|
|
|
aiOptInLevel = orgDetails.aiOptInLevel
|
|
hasAccessToAdvanceModel = orgDetails.hasAccessToAdvanceModel
|
|
orgHasHipaaAddon = orgDetails.hasHipaaAddon
|
|
orgId = orgDetails.orgId
|
|
planId = orgDetails.planId
|
|
projectIsSensitive = projectDetails.isSensitive
|
|
projectRegion = projectDetails.region
|
|
} catch (error) {
|
|
return res.status(400).json({
|
|
error: 'There was an error fetching your organization details',
|
|
})
|
|
}
|
|
}
|
|
|
|
const envThrottled = process.env.IS_THROTTLED !== 'false'
|
|
|
|
let effectiveModel: AssistantModelId = requestedModel ?? DEFAULT_ASSISTANT_ADVANCE_MODEL_ID
|
|
if (!hasAccessToAdvanceModel || (envThrottled && !isAssistantBaseModelId(effectiveModel))) {
|
|
effectiveModel = DEFAULT_ASSISTANT_BASE_MODEL_ID
|
|
}
|
|
|
|
const {
|
|
modelParams,
|
|
error: modelError,
|
|
systemProviderOptions,
|
|
} = await getModel({
|
|
provider: 'openai',
|
|
modelEntry: getAssistantModelEntry(effectiveModel),
|
|
})
|
|
|
|
if (modelError) {
|
|
return res.status(500).json({ error: modelError.message })
|
|
}
|
|
|
|
try {
|
|
const abortController = new AbortController()
|
|
req.on('close', () => abortController.abort())
|
|
req.on('aborted', () => abortController.abort())
|
|
// Fires when the response finishes streaming or the connection drops, which
|
|
// is what tears down the remote MCP connection opened in getTools.
|
|
res.on('close', () => abortController.abort())
|
|
|
|
const tools = await getTools({
|
|
projectRef,
|
|
connectionString,
|
|
authorization,
|
|
aiOptInLevel,
|
|
accessToken,
|
|
baseUrl: getURL(),
|
|
supportMode,
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
// Get a list of all schemas to add to context
|
|
const getSchemas = async (): Promise<string> => {
|
|
const pgMetaSchemasList = pgMeta.schemas.list()
|
|
type Schemas = z.infer<(typeof pgMetaSchemasList)['zod']>
|
|
|
|
const { result: schemas } = await executeSql<Schemas>(
|
|
{
|
|
projectRef,
|
|
connectionString,
|
|
sql: pgMetaSchemasList.sql,
|
|
},
|
|
undefined,
|
|
{
|
|
'Content-Type': 'application/json',
|
|
...(authorization && { Authorization: authorization }),
|
|
},
|
|
IS_PLATFORM ? undefined : executeQuery
|
|
)
|
|
|
|
return schemas?.length > 0
|
|
? `The available database schema names are: ${JSON.stringify(schemas)}`
|
|
: NO_SCHEMA_ACCESS_MESSAGE
|
|
}
|
|
|
|
const result = await generateAssistantResponse({
|
|
messages,
|
|
...modelParams,
|
|
tools,
|
|
aiOptInLevel,
|
|
getSchemas: aiOptInLevel !== 'disabled' ? getSchemas : undefined,
|
|
projectRef,
|
|
chatId,
|
|
chatName,
|
|
allowTracing: isTracingAllowed({
|
|
orgHasHipaaAddon,
|
|
projectIsSensitive,
|
|
projectRegion,
|
|
}),
|
|
supportMode,
|
|
userId,
|
|
orgId,
|
|
planId,
|
|
includesLogsSnippets,
|
|
requestedModel,
|
|
systemProviderOptions,
|
|
abortSignal: abortController.signal,
|
|
onSpanCreated: (spanId) => {
|
|
res.setHeader('x-braintrust-span-id', spanId)
|
|
},
|
|
})
|
|
|
|
result.pipeUIMessageStreamToResponse(res, {
|
|
sendReasoning: true,
|
|
headers: { 'Content-Encoding': 'none' },
|
|
onError: (error) => {
|
|
console.error('Assistant stream error:', error)
|
|
|
|
if (error == null) {
|
|
return 'unknown error'
|
|
}
|
|
|
|
if (typeof error === 'string') {
|
|
return error
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return error.message
|
|
}
|
|
|
|
return JSON.stringify(error)
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('Error in handlePost:', error)
|
|
if (error instanceof Error) {
|
|
return res.status(500).json({ message: error.message })
|
|
}
|
|
return res.status(500).json({ message: 'An unexpected error occurred.' })
|
|
}
|
|
}
|