mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
<img width="1510" height="862" alt="image" src="https://github.com/user-attachments/assets/f7157bad-9b23-4d73-a9aa-2a7a7c179318" /> ## 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 and bug fix. ## What is the current behavior? `query_logs` can return rows to the assistant, but the chat UI does not hydrate those rows into the query result by default. The query only becomes visible after clicking **Run query**, even though the same SQL and time range work when rerun manually. ## What is the new behavior? - Renders `query_logs` tool output through a dedicated logs message part using the shared assistant query cell. - Parses the exact MCP untrusted-data envelope into the initial query result, without changing what the assistant model receives. - Preserves the logs source and time range for manual reruns. - Infers a useful table or chart presentation from the returned rows while retaining explicit display settings. - Adds focused tests for MCP result parsing, timestamps, errors, query source handling, and visualization inference. ## How to test 1. Check out this PR and run Studio against a project that has recent logs. Generate some project activity first, such as an API request, if needed. 2. Open the AI Assistant and ask: `Show log counts by minute for the last 15 minutes and summarize any spikes.` 3. Wait for `query_logs` to finish. Verify the query cell appears with results already populated; do not click **Run query** first. 4. Verify the aggregate result opens as a chart, then switch to the table view and confirm the underlying rows are present. 5. Click **Run query** and verify the query runs successfully again using the same logs source and 15-minute time range. 6. Ask: `Show the 20 most recent log entries from the last 15 minutes.` Verify this non-aggregate result opens as a table with rows already populated. 7. Confirm the assistant's written summary agrees with the displayed rows and does not report zero rows when results are visible. ## Additional context This is the top PR in stack #49294 and depends on the back-end knowledge change in #49292. Verified with 59 focused tests across assistant context, Studio/MCP tools, query display, and logs result parsing. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added AI Assistant support for querying and displaying application logs. * Added automatic visualization selection, including charts for time-based and categorical data. * Added source-aware query handling with dedicated titles, time ranges, and result displays. * Added clearer loading, parsing, and error states for log queries. * **Bug Fixes** * Improved handling of streamed results, source changes, and query display updates. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
import dayjs from 'dayjs'
|
|
import { z, type SafeParseReturnType } from 'zod'
|
|
|
|
import { DEFAULT_ASSISTANT_LOGS_QUERY_TITLE } from './AssistantQueryCell.utils'
|
|
import { type QueryResult } from '@/components/interfaces/Explorer/types'
|
|
import { type TimeRange } from '@/data/content/notebooks/notebook-schema'
|
|
import { isoDateTimeString } from '@/lib/iso-datetime'
|
|
|
|
const UNTRUSTED_DATA_CLOSE_RE = /<\/untrusted-data-([^>]+)>/g
|
|
const unknownRecordSchema = z.record(z.string(), z.unknown())
|
|
|
|
/** Matches the MCP `query_logs` window when the model omits timestamps. */
|
|
export const DEFAULT_ASSISTANT_LOGS_TIME_RANGE: TimeRange = {
|
|
_tag: 'relative_time_range',
|
|
unit: 'day',
|
|
amount: 1,
|
|
}
|
|
|
|
const queryLogsInputSchema = z.object({
|
|
sql: z.string().min(1),
|
|
iso_timestamp_start: z.string().optional(),
|
|
iso_timestamp_end: z.string().optional(),
|
|
})
|
|
|
|
export function parseQueryLogsInput(
|
|
input: unknown
|
|
): SafeParseReturnType<unknown, z.infer<typeof queryLogsInputSchema>> {
|
|
return queryLogsInputSchema.safeParse(input)
|
|
}
|
|
|
|
export function getAssistantLogsQueryTitle(sql: string): string {
|
|
const title = sql
|
|
.trim()
|
|
.match(/^--[ \t]*([^\r\n]+)/)?.[1]
|
|
?.trim()
|
|
return title || DEFAULT_ASSISTANT_LOGS_QUERY_TITLE
|
|
}
|
|
|
|
export function getAssistantLogsTimeRange(start?: string, end?: string): TimeRange {
|
|
const parsedStart = start ? isoDateTimeString(start) : null
|
|
const parsedEnd = end ? isoDateTimeString(end) : null
|
|
if (parsedStart && parsedEnd && dayjs(parsedEnd).isAfter(parsedStart)) {
|
|
return { _tag: 'absolute_time_range', start: parsedStart, end: parsedEnd }
|
|
}
|
|
|
|
return DEFAULT_ASSISTANT_LOGS_TIME_RANGE
|
|
}
|
|
|
|
export function toQueryLogsResult(output: unknown): QueryResult | undefined {
|
|
return parseQueryResult(output)
|
|
}
|
|
|
|
function parseQueryResult(output: unknown, depth = 0): QueryResult | undefined {
|
|
if (depth > 6 || output == null) return undefined
|
|
|
|
if (Array.isArray(output)) return toRowResult(output)
|
|
|
|
if (typeof output === 'string') {
|
|
const extracted = extractUntrustedDataJson(output) ?? tryParseJson(output)
|
|
return extracted !== undefined ? parseQueryResult(extracted, depth + 1) : undefined
|
|
}
|
|
|
|
const parsedRecord = unknownRecordSchema.safeParse(output)
|
|
if (!parsedRecord.success) return undefined
|
|
|
|
const record = parsedRecord.data
|
|
const mcpError = readMcpToolError(record)
|
|
if (mcpError) return { rows: [], error: { message: mcpError } }
|
|
|
|
const error = readErrorMessage(record.error)
|
|
const rows = Array.isArray(record.rows)
|
|
? toRowResult(record.rows)
|
|
: Array.isArray(record.result)
|
|
? toRowResult(record.result)
|
|
: undefined
|
|
if (rows) return error ? { ...rows, error } : rows
|
|
|
|
if ('result' in record) {
|
|
const result = parseQueryResult(record.result, depth + 1)
|
|
const mergedResult = mergeParentError(result, error)
|
|
if (mergedResult) return mergedResult
|
|
}
|
|
|
|
if (record.structuredContent != null) {
|
|
const result = parseQueryResult(record.structuredContent, depth + 1)
|
|
const mergedResult = mergeParentError(result, error)
|
|
if (mergedResult) return mergedResult
|
|
}
|
|
|
|
if (Array.isArray(record.content)) {
|
|
const result = parseQueryResult(textFromMcpContent(record.content), depth + 1)
|
|
return mergeParentError(result, error)
|
|
}
|
|
|
|
return error ? { rows: [], error } : undefined
|
|
}
|
|
|
|
function mergeParentError(
|
|
result: QueryResult | undefined,
|
|
error: QueryResult['error']
|
|
): QueryResult | undefined {
|
|
return error ? { ...(result ?? { rows: [] }), error } : result
|
|
}
|
|
|
|
function toRowResult(rows: unknown[]): QueryResult {
|
|
return {
|
|
rows: rows.filter(
|
|
(row): row is Record<string, unknown> =>
|
|
row !== null && typeof row === 'object' && !Array.isArray(row)
|
|
),
|
|
}
|
|
}
|
|
|
|
function textFromMcpContent(content: unknown[]): string | undefined {
|
|
const texts = content.flatMap((part) => {
|
|
if (typeof part === 'string' && part.length > 0) return [part]
|
|
const parsedPart = unknownRecordSchema.safeParse(part)
|
|
if (!parsedPart.success) return []
|
|
if (typeof parsedPart.data.text === 'string') return [parsedPart.data.text]
|
|
if (typeof parsedPart.data.value === 'string') return [parsedPart.data.value]
|
|
return []
|
|
})
|
|
return texts.length > 0 ? texts.join('\n') : undefined
|
|
}
|
|
|
|
function readMcpToolError(record: Record<string, unknown>): string | undefined {
|
|
if (record.isError !== true) return undefined
|
|
|
|
const text = Array.isArray(record.content) ? textFromMcpContent(record.content) : undefined
|
|
return text?.trim() || 'Failed to query logs'
|
|
}
|
|
|
|
function readErrorMessage(error: unknown): { message: string } | undefined {
|
|
if (typeof error === 'string' && error.length > 0) return { message: error }
|
|
const parsedError = unknownRecordSchema.safeParse(error)
|
|
const message = parsedError.success ? parsedError.data.message : undefined
|
|
if (typeof message === 'string' && message.length > 0) return { message }
|
|
return undefined
|
|
}
|
|
|
|
function extractUntrustedDataJson(value: string): unknown {
|
|
for (const match of value.matchAll(UNTRUSTED_DATA_CLOSE_RE)) {
|
|
const boundaryId = match[1]
|
|
const closingIndex = match.index
|
|
if (!boundaryId || closingIndex === undefined) continue
|
|
|
|
const openingTag = `<untrusted-data-${boundaryId}>`
|
|
// The MCP wrapper mentions the tag in its explanatory prose before opening
|
|
// the real JSON boundary, so select the final opening tag before the close.
|
|
const openingIndex = value.lastIndexOf(openingTag, closingIndex)
|
|
if (openingIndex === -1) continue
|
|
|
|
const parsed = tryParseJson(value.slice(openingIndex + openingTag.length, closingIndex).trim())
|
|
if (parsed !== undefined) return parsed
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function tryParseJson(value: string): unknown {
|
|
try {
|
|
return JSON.parse(value)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|