mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 11:30:17 +08: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 -->
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { useParams } from 'common'
|
|
import { useRouter } from 'next/router'
|
|
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
|
|
import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
|
|
import type { SqlSnippetSource } from './querySource'
|
|
import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types'
|
|
import {
|
|
assembleCompletionDiff,
|
|
buildCompletionRequestBody,
|
|
buildDebugChatArgs,
|
|
buildDebugPromptText,
|
|
createSqlSnippetSkeletonV2,
|
|
extractDebugContext,
|
|
planDiffRequestApplication,
|
|
sqlSourceToDialect,
|
|
} from './SQLEditor.utils'
|
|
import { useSQLEditorContext } from './SQLEditorContext'
|
|
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
|
|
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
|
import { constructHeaders } from '@/data/fetchers'
|
|
import { stripSqlCodeFences } from '@/data/logs/logs-sql-rewrite'
|
|
import { isError } from '@/data/utils/error-check'
|
|
import { useLogsAttributeKeys } from '@/hooks/analytics/useLogsAttributeKeys'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { BASE_PATH } from '@/lib/constants'
|
|
import { formatSql } from '@/lib/formatSql'
|
|
import { useProfile } from '@/lib/profile'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
|
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
|
import { useSqlEditorDiffRequestSnapshot } from '@/state/sql-editor/sql-editor-diff-request'
|
|
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
|
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
|
|
|
type UseSqlEditorAiArgs = {
|
|
id: string
|
|
/** Bumped on every editor mount; drives one-shot draining of a pending diff request. */
|
|
editorMountCount: number
|
|
diff: ReturnType<typeof useSqlEditorDiff>
|
|
prompt: ReturnType<typeof useSqlEditorPrompt>
|
|
/**
|
|
* Where the snippet runs. Selects the dialect the AI writes in — logs snippets
|
|
* get ClickHouse SQL for the `logs` table, database snippets get Postgres.
|
|
*/
|
|
sqlSource: SqlSnippetSource
|
|
}
|
|
|
|
/**
|
|
* Owns the Assistant / diff cluster: SQL completion, the ask-AI prompt flow, the
|
|
* accept/discard diff handlers, the debug-prompt helpers, and the fragile diff
|
|
* lifecycle effects (one-shot diff-request drain, diff-editor value sync, and the
|
|
* ask-AI widget visibility).
|
|
*/
|
|
export function useSqlEditorAi({
|
|
id,
|
|
editorMountCount,
|
|
diff,
|
|
prompt,
|
|
sqlSource,
|
|
}: UseSqlEditorAiArgs) {
|
|
const {
|
|
sourceSqlDiff,
|
|
setSourceSqlDiff,
|
|
selectedDiffType,
|
|
setSelectedDiffType,
|
|
setIsAcceptDiffLoading,
|
|
isDiffOpen,
|
|
defaultSqlDiff,
|
|
closeDiff,
|
|
} = diff
|
|
const { promptState, setPromptState, resetPrompt } = prompt
|
|
|
|
const { editor, diff: diffController, refocusEditor } = useSQLEditorContext()
|
|
|
|
const router = useRouter()
|
|
const { ref } = useParams()
|
|
const { profile } = useProfile()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const { data: org } = useSelectedOrganizationQuery()
|
|
const track = useTrack()
|
|
const snapV2 = useSqlEditorV2StateSnapshot()
|
|
const sessionSnap = useSqlEditorSessionSnapshot()
|
|
const aiSnap = useAiAssistantStateSnapshot()
|
|
const { openSidebar } = useSidebarManagerSnapshot()
|
|
const diffRequest = useSqlEditorDiffRequestSnapshot()
|
|
const { generateSqlTitle } = useSnippetTitleGenerator()
|
|
|
|
const [isCompletionLoading, setIsCompletionLoading] = useState<boolean>(false)
|
|
const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
|
|
const [showWidget, setShowWidget] = useState(false)
|
|
|
|
const dialect = sqlSourceToDialect(sqlSource)
|
|
const isClickhouse = dialect === 'clickhouse'
|
|
|
|
// Grounds ClickHouse edits in the source's real log_attributes keys, the same way
|
|
// the whole-query rewrite does — otherwise inline edits invent dotted paths.
|
|
// Looked up when the user submits, not while they type.
|
|
const { fetchAttributeKeys } = useLogsAttributeKeys()
|
|
|
|
const handleNewQuery = useCallback(
|
|
async (sql: string, name: string) => {
|
|
if (!ref) return console.error('Project ref is required')
|
|
if (!profile) return console.error('Profile is required')
|
|
if (!project) return console.error('Project is required')
|
|
|
|
try {
|
|
const snippet = createSqlSnippetSkeletonV2({
|
|
name,
|
|
sql,
|
|
owner_id: profile.id,
|
|
project_id: project.id,
|
|
})
|
|
snapV2.addSnippet({ projectRef: ref, snippet })
|
|
snapV2.addNeedsSaving(snippet.id!)
|
|
router.push(`/project/${ref}/sql/${snippet.id}`)
|
|
} catch (error: any) {
|
|
toast.error(`Failed to create new query: ${error.message}`)
|
|
}
|
|
},
|
|
[profile, project, ref, router, snapV2]
|
|
)
|
|
|
|
const buildDebugPrompt = useCallback(() => {
|
|
const snippet = snapV2.snippets[id]
|
|
const result = sessionSnap.results[id]?.[0]
|
|
const { sql, errorMessage } = extractDebugContext(snippet, result)
|
|
|
|
return buildDebugPromptText(sql, errorMessage, sqlSource)
|
|
}, [id, sessionSnap.results, snapV2.snippets, sqlSource])
|
|
|
|
const onDebug = useCallback(async () => {
|
|
try {
|
|
const snippet = snapV2.snippets[id]
|
|
const result = sessionSnap.results[id]?.[0]
|
|
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
|
|
aiSnap.newChat(buildDebugChatArgs(snippet, result, sqlSource))
|
|
} catch (error: unknown) {
|
|
// [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message
|
|
// that's not relevant for the user - so we prettify it here by avoiding to return the
|
|
// entire error body from the assistant
|
|
if (isError(error)) {
|
|
toast.error(
|
|
`Sorry, the assistant failed to debug your query! Please try again with a different one.`
|
|
)
|
|
}
|
|
}
|
|
}, [id, sessionSnap.results, snapV2.snippets, aiSnap, openSidebar, sqlSource])
|
|
|
|
const acceptAiHandler = useCallback(async () => {
|
|
try {
|
|
setIsAcceptDiffLoading(true)
|
|
|
|
// TODO: show error if undefined
|
|
if (!sourceSqlDiff || !editor.isReady() || !diffController.isMounted()) return
|
|
|
|
const sql = diffController.getModifiedValue()
|
|
if (sql === undefined) return
|
|
|
|
if (selectedDiffType === DiffType.NewSnippet) {
|
|
const { title } = await generateSqlTitle({ sql })
|
|
await handleNewQuery(sql, title)
|
|
} else {
|
|
editor.replaceAll(sql, 'apply-ai-edit')
|
|
}
|
|
|
|
track('assistant_sql_diff_handler_evaluated', { handlerAccepted: true })
|
|
|
|
setSelectedDiffType(DiffType.Modification)
|
|
resetPrompt()
|
|
closeDiff()
|
|
refocusEditor()
|
|
} finally {
|
|
setIsAcceptDiffLoading(false)
|
|
}
|
|
}, [
|
|
editor,
|
|
diffController,
|
|
sourceSqlDiff,
|
|
selectedDiffType,
|
|
generateSqlTitle,
|
|
handleNewQuery,
|
|
track,
|
|
setIsAcceptDiffLoading,
|
|
setSelectedDiffType,
|
|
resetPrompt,
|
|
closeDiff,
|
|
refocusEditor,
|
|
])
|
|
|
|
const discardAiHandler = useCallback(() => {
|
|
track('assistant_sql_diff_handler_evaluated', { handlerAccepted: false })
|
|
resetPrompt()
|
|
closeDiff()
|
|
refocusEditor()
|
|
}, [closeDiff, resetPrompt, track, refocusEditor])
|
|
|
|
const complete = useCallback(
|
|
async (
|
|
_prompt: string,
|
|
options?: {
|
|
headers?: Record<string, string>
|
|
body?: { completionMetadata?: any }
|
|
}
|
|
) => {
|
|
try {
|
|
setIsCompletionLoading(true)
|
|
|
|
const response = await fetch(`${BASE_PATH}/api/ai/code/complete`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(options?.headers ?? {}),
|
|
},
|
|
body: JSON.stringify(
|
|
buildCompletionRequestBody({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
orgSlug: org?.slug,
|
|
dialect,
|
|
options: options?.body,
|
|
})
|
|
),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
throw new Error(errorText || 'Failed to generate completion')
|
|
}
|
|
|
|
// API returns a JSON-encoded string
|
|
const text: string = await response.json()
|
|
|
|
const meta = options?.body?.completionMetadata ?? {}
|
|
// The clickhouse system prompt forbids fences, but strip them defensively
|
|
// so a chatty model can't leak backticks into the snippet.
|
|
const { original, modified } = assembleCompletionDiff(
|
|
meta,
|
|
isClickhouse ? stripSqlCodeFences(text) : text
|
|
)
|
|
|
|
// sql-formatter is Postgres-only — it mangles ClickHouse backticks and
|
|
// map lookups — so ClickHouse output goes into the diff unformatted.
|
|
const formattedModified = isClickhouse ? modified : formatSql(modified)
|
|
setSourceSqlDiff({ original, modified: formattedModified })
|
|
setSelectedDiffType(DiffType.Modification)
|
|
setPromptState((prev) => ({ ...prev, isLoading: false }))
|
|
setIsCompletionLoading(false)
|
|
} catch (error: any) {
|
|
toast.error(`Failed to generate SQL: ${error?.message ?? 'Unknown error'}`)
|
|
setIsCompletionLoading(false)
|
|
throw error
|
|
}
|
|
},
|
|
[
|
|
dialect,
|
|
isClickhouse,
|
|
org?.slug,
|
|
project?.connectionString,
|
|
project?.ref,
|
|
setPromptState,
|
|
setSelectedDiffType,
|
|
setSourceSqlDiff,
|
|
]
|
|
)
|
|
|
|
const handlePrompt = useCallback(
|
|
async (
|
|
prompt: string,
|
|
context: {
|
|
beforeSelection: string
|
|
selection: string
|
|
afterSelection: string
|
|
}
|
|
) => {
|
|
try {
|
|
setPromptState((prev) => ({
|
|
...prev,
|
|
selection: context.selection,
|
|
beforeSelection: context.beforeSelection,
|
|
afterSelection: context.afterSelection,
|
|
}))
|
|
// ClickHouse only: there's no server-side schema to fetch for the logs
|
|
// table, so the real log_attributes keys travel with the request. Detected
|
|
// from the whole document, which is what the three context fields spell.
|
|
const [headerData, availableKeys] = await Promise.all([
|
|
constructHeaders(),
|
|
isClickhouse
|
|
? fetchAttributeKeys(
|
|
context.beforeSelection + context.selection + context.afterSelection
|
|
)
|
|
: undefined,
|
|
])
|
|
|
|
const authorizationHeader = headerData.get('Authorization')
|
|
|
|
// The instruction goes over as-is for both dialects — the route assembles
|
|
// the schema section and the cursor context around it, so there's exactly
|
|
// one place that knows how a completion prompt is built.
|
|
await complete(prompt, {
|
|
...(authorizationHeader
|
|
? { headers: { Authorization: authorizationHeader } }
|
|
: undefined),
|
|
body: {
|
|
completionMetadata: {
|
|
textBeforeCursor: context.beforeSelection,
|
|
textAfterCursor: context.afterSelection,
|
|
language: 'pgsql',
|
|
prompt,
|
|
selection: context.selection,
|
|
...(availableKeys ? { availableKeys } : {}),
|
|
},
|
|
},
|
|
})
|
|
} catch (error) {
|
|
setPromptState((prev) => ({ ...prev, isLoading: false }))
|
|
}
|
|
},
|
|
[complete, fetchAttributeKeys, isClickhouse, setPromptState]
|
|
)
|
|
|
|
const handleDiffEditorMount = useCallback(
|
|
(mountedDiffEditor: IStandaloneDiffEditor) => {
|
|
diffController.attach(mountedDiffEditor)
|
|
setIsDiffEditorMounted(true)
|
|
},
|
|
[diffController]
|
|
)
|
|
|
|
const resetDiff = useEffectEvent(() => {
|
|
if (id) {
|
|
closeDiff()
|
|
setPromptState((prev) => ({ ...prev, isOpen: false }))
|
|
}
|
|
})
|
|
useEffect(() => {
|
|
resetDiff()
|
|
}, [id])
|
|
|
|
const syncDiffEditor = useEffectEvent(() => {
|
|
if (isDiffOpen) {
|
|
diffController.setDiff(defaultSqlDiff, promptState.startLineNumber)
|
|
}
|
|
})
|
|
useEffect(() => {
|
|
syncDiffEditor()
|
|
}, [selectedDiffType, sourceSqlDiff])
|
|
|
|
const drainDiffRequest = useEffectEvent(() => {
|
|
const request = diffRequest.pending
|
|
if (request === undefined) return
|
|
|
|
// Editor isn't ready yet; leave the request pending. editorMountCount bumps
|
|
// on mount and re-runs this effect, so the request applies once mounted.
|
|
if (!editor.isReady()) return
|
|
|
|
const existingValue = editor.getValue() ?? ''
|
|
const plan = planDiffRequestApplication({ existingValue, request })
|
|
if (plan.kind === 'replace') {
|
|
// if the editor is empty, just copy over the code
|
|
editor.replaceAll(plan.text, 'apply-ai-message')
|
|
} else {
|
|
setSourceSqlDiff(plan.diff)
|
|
setSelectedDiffType(plan.diffType)
|
|
}
|
|
|
|
// One-shot: drain the request so it can't re-apply to a later editor or session.
|
|
diffRequest.consumeDiffRequest()
|
|
})
|
|
useEffect(() => {
|
|
drainDiffRequest()
|
|
}, [diffRequest.pending, editorMountCount])
|
|
|
|
// We want to check if the diff editor is mounted and if it is, we want to show the widget
|
|
// We also want to cleanup the widget when the diff editor is closed
|
|
useEffect(() => {
|
|
if (!isDiffOpen) {
|
|
setIsDiffEditorMounted(false)
|
|
setShowWidget(false)
|
|
} else if (diffController.isMounted() && isDiffEditorMounted) {
|
|
setShowWidget(true)
|
|
return () => setShowWidget(false)
|
|
}
|
|
}, [diffController, isDiffOpen, isDiffEditorMounted])
|
|
|
|
return useMemo(
|
|
() => ({
|
|
handlePrompt,
|
|
acceptAiHandler,
|
|
discardAiHandler,
|
|
onDebug,
|
|
buildDebugPrompt,
|
|
handleDiffEditorMount,
|
|
isCompletionLoading,
|
|
showWidget,
|
|
}),
|
|
[
|
|
handlePrompt,
|
|
acceptAiHandler,
|
|
discardAiHandler,
|
|
onDebug,
|
|
buildDebugPrompt,
|
|
handleDiffEditorMount,
|
|
isCompletionLoading,
|
|
showWidget,
|
|
]
|
|
)
|
|
}
|