mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
## Problem
- Assistant responses were capped at 120 seconds and 10 steps, which is
too short for longer reasoning or multi-step tool work.
- When the hosting platform ended a request at that limit, the
connection just dropped. The user got no explanation, and "Thinking…"
and tool rows kept spinning.
- Studio's own tools ignored the request's abort signal, so a stop,
disconnect or deadline couldn't cancel their in-flight requests.
- Aborted responses never closed their Braintrust span. Under TanStack
Start, the remote MCP client was only released on `res.on('close')`,
which the adapter never emits.
## Solution
Uses AI SDK options instead of custom stream handling:
- `maxDuration` goes to 300s and the step limit to 20. `streamText({
timeout: { totalMs } })` stops the response at 270s, leaving time to
finish the stream before the platform cutoff.
- `toUIMessageStream({ messageMetadata })` marks an aborted response
`timedOut: true`. `Chat` ignores `abort` chunks, so the client reads
this flag instead and shows a timeout alert with Retry. The flag is
saved with the message, so the alert survives a reload.
- `toUIMessageStream({ onEnd })` aborts the request whenever the stream
ends, releasing the MCP client on both runtimes. `streamText({ onAbort
})` ends the Braintrust span.
- Studio tools pass the SDK's `abortSignal` to their fetches. MCP tools
already did.
- Reasoning and server-tool rows that never finished show "Response
interrupted" instead of a spinner or "Ran X ✓".
There's no per-tool timeout. Approved SQL and migrations can
legitimately run longer, and aborting the HTTP request doesn't stop the
query in Postgres.
## Review instructions
1. Run the unit tests: `cd apps/studio && pnpm vitest run
lib/api/generate-v4.test.ts lib/ai components/ui/AIAssistantPanel`
2. To see a timeout without waiting 4.5 minutes, temporarily set
`ASSISTANT_TIMEOUT_MS` in `apps/studio/lib/ai/assistant-timeout.ts` to
`15_000` and run `pnpm dev:studio`.
3. Ask the Assistant something that needs several tool calls or long
reasoning, for example "Audit my schema for missing indexes and RLS
gaps, then write the fixes."
4. After 15 seconds, check that:
- the response stops and a "Assistant response timed out" alert appears
with Retry
- any in-progress reasoning or tool row shows "Response interrupted"
instead of spinning
- Retry starts a new response
- reloading the page still shows the alert on that chat
5. Stop a response with the Stop button before the deadline. It should
stop without the timeout alert.
6. With the default 270s, confirm that a normal response completes as
before.
## Checklist
Check all before review:
- [ ] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [ ] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which references
[WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md)
and the docs
[CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md)
guide
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Improvements**
* AI assistant responses can now run for up to five minutes, supporting
longer requests.
* When a response times out, the assistant displays a message suggesting
you retry or ask for a smaller change.
* Incomplete responses now show a “Response interrupted” notice, and
loading indicators stop when generation ends.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import { UIMessage as VercelMessage } from '@ai-sdk/react'
|
|
import {
|
|
isToolUIPart,
|
|
type DynamicToolUIPart,
|
|
type ReasoningUIPart,
|
|
type TextUIPart,
|
|
type ToolUIPart,
|
|
} from 'ai'
|
|
import { BrainIcon, CheckIcon, CircleStop, Loader2 } from 'lucide-react'
|
|
import { memo, type ReactNode } from 'react'
|
|
import { cn } from 'ui'
|
|
|
|
import { AssistantQueryCell } from './AssistantQueryCell'
|
|
import { toAssistantQueryResult } from './AssistantQueryCell.utils'
|
|
import { getManualToolApprovalHandlers } from './Confirm.utils'
|
|
import { EdgeFunctionRenderer } from './EdgeFunctionRenderer'
|
|
import { Tool } from './elements/Tool'
|
|
import { useMessageActionsContext, useMessageInfoContext } from './Message.Context'
|
|
import { areMessagePartsEqual } from './Message.Parts.utils'
|
|
import {
|
|
deployEdgeFunctionInputSchema,
|
|
deployEdgeFunctionOutputSchema,
|
|
parseExecuteSqlChartResult,
|
|
} from './Message.utils'
|
|
import { MessageMarkdown } from './MessageMarkdown'
|
|
import { MessagePartQueryLogs } from './MessagePartQueryLogs'
|
|
import { NotebookProposalRenderer, type NotebookProposalMode } from './NotebookProposalRenderer'
|
|
import { NotebookRunRenderer } from './NotebookRunRenderer'
|
|
import { parseSupportRequestMessage, SupportRequestMessage } from './SupportRequestMessage'
|
|
|
|
function MessagePartText({ textPart }: { textPart: TextUIPart }) {
|
|
const { id, isLoading, readOnly, isUserMessage, state } = useMessageInfoContext()
|
|
const supportRequest = isUserMessage ? parseSupportRequestMessage(textPart.text) : null
|
|
|
|
if (supportRequest) {
|
|
return <SupportRequestMessage request={supportRequest} />
|
|
}
|
|
|
|
return (
|
|
<MessageMarkdown
|
|
id={id}
|
|
isLoading={isLoading}
|
|
readOnly={readOnly}
|
|
className={cn(
|
|
'max-w-none space-y-4 prose prose-sm prose-li:mt-1 [&>div]:my-4 prose-h1:text-xl prose-h1:mt-6 prose-h2:text-lg prose-h2:font-medium prose-h3:no-underline prose-h3:text-base prose-h3:mb-4 prose-strong:font-medium prose-strong:text-foreground prose-ol:space-y-3 prose-ul:space-y-3 prose-li:my-0 wrap-break-word [&>p:not(:last-child)]:mb-2! [&>*>p:first-child]:mt-0! [&>*>p:last-child]:mb-0! [&>*>*>p:first-child]:mt-0! [&>*>*>p:last-child]:mb-0! [&>ol>li]:pl-4!',
|
|
isUserMessage && 'text-foreground [&>p]:font-medium',
|
|
state === 'editing' && 'animate-pulse'
|
|
)}
|
|
>
|
|
{textPart.text}
|
|
</MessageMarkdown>
|
|
)
|
|
}
|
|
|
|
function MessagePartDynamicTool({ toolPart }: { toolPart: DynamicToolUIPart }) {
|
|
return (
|
|
<Tool
|
|
icon={
|
|
toolPart.state === 'input-streaming' ? (
|
|
<Loader2 strokeWidth={1.5} size={12} className="animate-spin" />
|
|
) : (
|
|
<CheckIcon strokeWidth={1.5} size={12} className="text-foreground-muted" />
|
|
)
|
|
}
|
|
label={
|
|
<div>
|
|
{toolPart.state === 'input-streaming' ? 'Running ' : 'Ran '}
|
|
<span className="text-foreground-lighter">{`${toolPart.toolName}`}</span>
|
|
</div>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function MessagePartTool({ toolPart }: { toolPart: ToolUIPart }) {
|
|
return (
|
|
<Tool
|
|
icon={
|
|
toolPart.state === 'input-streaming' ? (
|
|
<Loader2 strokeWidth={1.5} size={12} className="animate-spin" />
|
|
) : (
|
|
<CheckIcon strokeWidth={1.5} size={12} className="text-foreground-muted" />
|
|
)
|
|
}
|
|
label={
|
|
<div>
|
|
{toolPart.state === 'input-streaming' ? 'Running ' : 'Ran '}
|
|
<span className="text-foreground-lighter">{`${toolPart.type.replace('tool-', '')}`}</span>
|
|
</div>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function MessagePartReasoning({ reasoningPart }: { reasoningPart: ReasoningUIPart }) {
|
|
return (
|
|
<Tool
|
|
icon={
|
|
reasoningPart.state === 'streaming' ? (
|
|
<Loader2 strokeWidth={1.5} size={12} className="animate-spin" />
|
|
) : (
|
|
<BrainIcon strokeWidth={1.5} size={12} className="text-foreground-muted" />
|
|
)
|
|
}
|
|
label={reasoningPart.state === 'streaming' ? 'Thinking...' : 'Reasoned'}
|
|
>
|
|
{reasoningPart.text}
|
|
</Tool>
|
|
)
|
|
}
|
|
|
|
function ToolDisplayExecuteSqlLoading({ label = 'Writing SQL...' }: { label?: string }) {
|
|
return (
|
|
<div className="my-4 rounded-lg border bg-surface-75 heading-meta h-9 px-3 text-foreground-light flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{label}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MessagePartExecuteSql({ toolPart }: { toolPart: ToolUIPart }) {
|
|
const { id } = useMessageInfoContext()
|
|
const { addToolApprovalResponse } = useMessageActionsContext()
|
|
|
|
const { toolCallId, state, input: submittedInput, output } = toolPart
|
|
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
|
|
|
if (state === 'input-streaming') {
|
|
return <ToolDisplayExecuteSqlLoading />
|
|
}
|
|
|
|
const { data: chart, success } = parseExecuteSqlChartResult(input)
|
|
if (!success) return null
|
|
|
|
if (
|
|
state === 'input-available' ||
|
|
state === 'approval-requested' ||
|
|
state === 'approval-responded' ||
|
|
state === 'output-denied' ||
|
|
state === 'output-available' ||
|
|
state === 'output-error'
|
|
) {
|
|
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
|
|
state,
|
|
approval: toolPart.approval,
|
|
addToolApprovalResponse,
|
|
})
|
|
|
|
return (
|
|
<div className="w-auto overflow-x-hidden my-4 space-y-2">
|
|
<AssistantQueryCell
|
|
id={`${id}-${toolCallId}`}
|
|
sql={chart.sql}
|
|
title={chart.label}
|
|
initialResult={
|
|
state === 'output-error'
|
|
? { rows: [], error: { message: toolPart.errorText ?? 'Failed to execute SQL' } }
|
|
: toAssistantQueryResult(output)
|
|
}
|
|
view={chart.view}
|
|
xAxis={chart.xAxis}
|
|
yAxis={chart.yAxis}
|
|
confirmState={confirmState}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT = new Set([
|
|
'input-available',
|
|
'approval-requested',
|
|
'approval-responded',
|
|
'output-denied',
|
|
'output-available',
|
|
'output-error',
|
|
])
|
|
|
|
function MessagePartDeployEdgeFunction({ toolPart }: { toolPart: ToolUIPart }) {
|
|
const { state, input: submittedInput, output } = toolPart
|
|
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
|
const { addToolApprovalResponse } = useMessageActionsContext()
|
|
|
|
if (state === 'input-streaming') {
|
|
return (
|
|
<div className="my-4 rounded-lg border bg-surface-75 heading-meta h-9 px-3 text-foreground-light flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Writing Edge Function...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!TOOL_DEPLOY_EDGE_FUNCTION_STATES_WITH_INPUT.has(state)) return null
|
|
|
|
const parsedInput = deployEdgeFunctionInputSchema.safeParse(input)
|
|
if (!parsedInput.success) return null
|
|
|
|
const parsedOutput = deployEdgeFunctionOutputSchema.safeParse(output)
|
|
const isInitiallyDeployed =
|
|
state === 'output-available' && parsedOutput.success && parsedOutput.data.success === true
|
|
|
|
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
|
|
state,
|
|
approval: toolPart.approval,
|
|
addToolApprovalResponse,
|
|
})
|
|
|
|
return (
|
|
<EdgeFunctionRenderer
|
|
label={parsedInput.data.label}
|
|
code={parsedInput.data.code}
|
|
functionName={parsedInput.data.functionName}
|
|
confirmState={confirmState}
|
|
isDeploying={confirmState === 'approval-responded'}
|
|
initialIsDeployed={isInitiallyDeployed}
|
|
errorText={state === 'output-error' ? toolPart.errorText : undefined}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const NOTEBOOK_DRAFTING_LABEL: Record<NotebookProposalMode, string> = {
|
|
create: 'Drafting notebook...',
|
|
update: 'Drafting notebook update...',
|
|
delete: 'Preparing to delete notebook...',
|
|
}
|
|
|
|
function MessagePartNotebookProposal({
|
|
toolPart,
|
|
mode,
|
|
}: {
|
|
toolPart: ToolUIPart
|
|
mode: NotebookProposalMode
|
|
}) {
|
|
const { state, input: submittedInput, output } = toolPart
|
|
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
|
const { addToolApprovalResponse } = useMessageActionsContext()
|
|
|
|
if (state === 'input-streaming') {
|
|
return <ToolDisplayExecuteSqlLoading label={NOTEBOOK_DRAFTING_LABEL[mode]} />
|
|
}
|
|
|
|
const { confirmState, onApprove, onDeny, denyWithReason } = getManualToolApprovalHandlers({
|
|
state,
|
|
approval: toolPart.approval,
|
|
addToolApprovalResponse,
|
|
})
|
|
|
|
return (
|
|
<NotebookProposalRenderer
|
|
mode={mode}
|
|
state={state}
|
|
input={input}
|
|
output={output}
|
|
confirmState={confirmState}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
denyWithReason={denyWithReason}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function MessagePartNotebookRun({ toolPart }: { toolPart: ToolUIPart }) {
|
|
const { state, input: submittedInput, output } = toolPart
|
|
const input = state === 'output-error' ? (submittedInput ?? toolPart.rawInput) : submittedInput
|
|
const { addToolApprovalResponse } = useMessageActionsContext()
|
|
|
|
if (state === 'input-streaming')
|
|
return <ToolDisplayExecuteSqlLoading label="Preparing notebook..." />
|
|
|
|
const { confirmState, onApprove, onDeny } = getManualToolApprovalHandlers({
|
|
state,
|
|
approval: toolPart.approval,
|
|
addToolApprovalResponse,
|
|
})
|
|
|
|
return (
|
|
<NotebookRunRenderer
|
|
state={state}
|
|
input={input}
|
|
output={output}
|
|
confirmState={confirmState}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const MessagePart = {
|
|
Text: MessagePartText,
|
|
Dynamic: MessagePartDynamicTool,
|
|
Tool: MessagePartTool,
|
|
Reasoning: MessagePartReasoning,
|
|
ExecuteSql: MessagePartExecuteSql,
|
|
QueryLogs: MessagePartQueryLogs,
|
|
DeployEdgeFunction: MessagePartDeployEdgeFunction,
|
|
NotebookProposal: MessagePartNotebookProposal,
|
|
NotebookRun: MessagePartNotebookRun,
|
|
} as const
|
|
|
|
// Wide parts share the default width for now; the split stays so a part can diverge again.
|
|
const MESSAGE_PART_WIDTH = 'max-w-3xl'
|
|
const WIDE_MESSAGE_PART_WIDTH = 'max-w-3xl'
|
|
|
|
function MessagePartContainer({
|
|
children,
|
|
isWide = false,
|
|
}: {
|
|
children: ReactNode
|
|
isWide?: boolean
|
|
}) {
|
|
return (
|
|
<div className={cn('w-full mx-auto', isWide ? WIDE_MESSAGE_PART_WIDTH : MESSAGE_PART_WIDTH)}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isWideMessagePart = (part: NonNullable<VercelMessage['parts']>[number]) =>
|
|
part.type === 'tool-execute_sql' ||
|
|
part.type === 'tool-query_logs' ||
|
|
part.type === 'tool-create_notebook' ||
|
|
part.type === 'tool-update_notebook' ||
|
|
part.type === 'tool-delete_notebook' ||
|
|
part.type === 'tool-run_notebook' ||
|
|
(part.type === 'dynamic-tool' && part.toolName === 'query_logs') ||
|
|
// Unlabelled code fences resolve to SQL in MessageMarkdown, too.
|
|
(part.type === 'text' && /```(?:sql)?(?:\s|$)/i.test(part.text))
|
|
|
|
const isCompactToolPart = (part: NonNullable<VercelMessage['parts']>[number]) =>
|
|
part.type === 'reasoning' ||
|
|
(part.type === 'dynamic-tool' && part.toolName !== 'query_logs') ||
|
|
part.type === 'tool-list_policies' ||
|
|
part.type === 'tool-search_docs' ||
|
|
part.type === 'tool-get_active_incidents' ||
|
|
part.type === 'tool-load_knowledge'
|
|
|
|
export const MessagePartSwitcher = memo(
|
|
function MessagePartSwitcher({ part }: { part: NonNullable<VercelMessage['parts']>[number] }) {
|
|
const { isLoading, isLastMessage } = useMessageInfoContext()
|
|
const isActiveMessage = isLoading && isLastMessage
|
|
// Compact rows and query_logs run on the server, so `input-available` means the tool never
|
|
// returned. Other tools wait in that state for the user to act.
|
|
const isServerToolAwaitingOutput =
|
|
isToolUIPart(part) &&
|
|
part.state === 'input-available' &&
|
|
(isCompactToolPart(part) ||
|
|
part.type === 'tool-query_logs' ||
|
|
(part.type === 'dynamic-tool' && part.toolName === 'query_logs'))
|
|
const isIncompletePart =
|
|
(part.type === 'reasoning' && part.state === 'streaming') ||
|
|
(isToolUIPart(part) && part.state === 'input-streaming') ||
|
|
isServerToolAwaitingOutput
|
|
|
|
if (!isActiveMessage && isIncompletePart) {
|
|
return (
|
|
<Tool
|
|
icon={<CircleStop strokeWidth={1.5} size={12} className="text-foreground-muted" />}
|
|
label="Response interrupted"
|
|
>
|
|
{part.type === 'reasoning' ? part.text : undefined}
|
|
</Tool>
|
|
)
|
|
}
|
|
|
|
const content = (() => {
|
|
switch (part.type) {
|
|
case 'dynamic-tool': {
|
|
if (part.toolName === 'query_logs') {
|
|
return <MessagePart.QueryLogs toolPart={part} />
|
|
}
|
|
return <MessagePart.Dynamic toolPart={part} />
|
|
}
|
|
case 'tool-list_policies':
|
|
case 'tool-search_docs':
|
|
case 'tool-get_active_incidents':
|
|
case 'tool-load_knowledge': {
|
|
return <MessagePart.Tool toolPart={part} />
|
|
}
|
|
case 'reasoning':
|
|
return <MessagePart.Reasoning reasoningPart={part} />
|
|
case 'text':
|
|
return <MessagePart.Text textPart={part} />
|
|
|
|
case 'tool-execute_sql': {
|
|
return <MessagePart.ExecuteSql toolPart={part} />
|
|
}
|
|
case 'tool-query_logs': {
|
|
return <MessagePart.QueryLogs toolPart={part} />
|
|
}
|
|
case 'tool-deploy_edge_function': {
|
|
return <MessagePart.DeployEdgeFunction toolPart={part} />
|
|
}
|
|
case 'tool-create_notebook': {
|
|
return <MessagePart.NotebookProposal toolPart={part} mode="create" />
|
|
}
|
|
case 'tool-update_notebook': {
|
|
return <MessagePart.NotebookProposal toolPart={part} mode="update" />
|
|
}
|
|
case 'tool-delete_notebook': {
|
|
return <MessagePart.NotebookProposal toolPart={part} mode="delete" />
|
|
}
|
|
case 'tool-run_notebook': {
|
|
return <MessagePart.NotebookRun toolPart={part} />
|
|
}
|
|
|
|
case 'source-url':
|
|
case 'source-document':
|
|
case 'file':
|
|
default:
|
|
return null
|
|
}
|
|
})()
|
|
|
|
if (content === null) return null
|
|
// Tool rows depend on being direct siblings to share their compact spacing and dividers.
|
|
if (isCompactToolPart(part)) return content
|
|
|
|
return <MessagePartContainer isWide={isWideMessagePart(part)}>{content}</MessagePartContainer>
|
|
},
|
|
(previous, next) => areMessagePartsEqual(previous.part, next.part)
|
|
)
|