diff --git a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte index 99e1251a26..c4214640d4 100644 --- a/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolContentDisplay.svelte @@ -1,5 +1,6 @@ -{#if showWhileLoading || (!loading && hasContent)} +{#if showWhileLoading || (!loading && hasContent) || streaming}
- + {title}: - {#if showCopy && hasContent} + {#if showCopy && hasContent && !streaming}
- - {:else} + + {:else if !message.isStreamingArguments} { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null - let tempToolId: string | undefined = undefined - // When we receive a JSON input, we need to show a temporary tool call in loading state - completion.on('inputJson', (_: string) => { - if (!tempToolId) { - callbacks.onMessageEnd() - tempToolId = `temp-${generateRandomString(12)}` - callbacks.setToolStatus(tempToolId, { isLoading: true, content: 'Calling tool...' }) + let currentStreamingTool: + | { tempId: string; shouldStream: boolean; toolName: string } + | undefined = undefined + let accumulatedJson = '' + + completion.on('streamEvent', (event: RawMessageStreamEvent) => { + if (event.type === 'content_block_start') { + const block = event.content_block + if (block.type === 'tool_use') { + const toolName = block.name + const toolId = block.id as string + + const tool = tools.find((t) => t.def.function.name === toolName) + const shouldStream = tool?.streamArguments ?? false + + callbacks.onMessageEnd() + + // Reset accumulated JSON for new tool + accumulatedJson = '' + currentStreamingTool = { tempId: toolId, shouldStream, toolName } + + callbacks.setToolStatus(toolId, { + isLoading: true, + content: `Calling ${toolName}...`, + toolName, + isStreamingArguments: shouldStream, + showFade: tool?.showFade, + showDetails: tool?.showDetails + }) + } + } + }) + + completion.on('inputJson', (partialJson: string) => { + if (currentStreamingTool?.shouldStream && currentStreamingTool.tempId) { + // Accumulate the partial JSON + accumulatedJson += partialJson + + // Try to parse and display + try { + const parsed = JSON.parse(accumulatedJson) + callbacks.setToolStatus(currentStreamingTool.tempId, { + parameters: parsed, + isStreamingArguments: true, + isLoading: true + }) + } catch { + // JSON incomplete, display as raw string + callbacks.setToolStatus(currentStreamingTool.tempId, { + parameters: accumulatedJson, + isStreamingArguments: true, + isLoading: true + }) + } } }) @@ -86,11 +133,6 @@ export async function parseAnthropicCompletion( addedMessages.push(assistantMessage) callbacks.onMessageEnd() } else if (block.type === 'tool_use') { - // Remove temp display if it exists - if (tempToolId) { - callbacks.removeToolStatus(tempToolId) - } - // Convert Anthropic tool calls to OpenAI format for compatibility toolCallsToProcess.push({ id: block.id, @@ -109,15 +151,11 @@ export async function parseAnthropicCompletion( } // Clear temp tracking after processing - tempToolId = undefined + currentStreamingTool = undefined }) // Handle errors completion.on('error', (e: any) => { - if (tempToolId) { - callbacks.removeToolStatus(tempToolId) - tempToolId = undefined - } console.error('Anthropic stream error:', e) error = e }) diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index 4229af0d16..d7e5ad5c96 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -211,6 +211,12 @@ export async function parseOpenAIResponsesCompletion( let textContent = '' let toolCallsMap: Record = {} + // Streaming state tracking + let currentStreamingTool: + | { itemId: string; shouldStream: boolean; toolName: string } + | undefined = undefined + let accumulatedJson = '' + // Handle text streaming runner.on('response.output_text.delta', (event) => { callbacks.onNewToken(event.delta) @@ -221,22 +227,62 @@ export async function parseOpenAIResponsesCompletion( runner.on('response.output_item.added', (event) => { const item = event.item if (item.type === 'function_call' && item.id) { + const tool = tools.find((t) => t.def.function.name === item.name) + const shouldStream = tool?.streamArguments ?? false + toolCallsMap[item.id] = { name: item.name, call_id: item.call_id } + // Reset streaming state for new tool + accumulatedJson = '' + currentStreamingTool = { itemId: item.id, shouldStream, toolName: item.name } + // Show temporary loading state for the tool call callbacks.onMessageEnd() callbacks.setToolStatus(`${item.id}`, { isLoading: true, - content: `Calling ${item.name} tool...` + content: `Calling ${item.name}...`, + toolName: item.name, + isStreamingArguments: shouldStream, + showFade: tool?.showFade, + showDetails: tool?.showDetails }) } }) + // Stream function call arguments incrementally + runner.on('response.function_call_arguments.delta', (event) => { + if (currentStreamingTool?.shouldStream && currentStreamingTool.itemId === event.item_id) { + accumulatedJson += event.delta + + try { + const parsed = JSON.parse(accumulatedJson) + callbacks.setToolStatus(`${event.item_id}`, { + parameters: parsed, + isStreamingArguments: true, + isLoading: true + }) + } catch { + // JSON incomplete, display as raw string + callbacks.setToolStatus(`${event.item_id}`, { + parameters: accumulatedJson, + isStreamingArguments: true, + isLoading: true + }) + } + } + }) + // Handle function call arguments done runner.on('response.function_call_arguments.done', (event) => { + // Clear streaming state + currentStreamingTool = undefined + callbacks.setToolStatus(`${event.item_id}`, { + isStreamingArguments: false + }) + // Retrieve tool call metadata from map const metadata = toolCallsMap[event.item_id] if (!metadata) { @@ -257,6 +303,7 @@ export async function parseOpenAIResponsesCompletion( // Handle errors runner.on('error', (err: OpenAIError | ResponseErrorEvent) => { + currentStreamingTool = undefined console.error('OpenAI Responses stream error:', err) error = err }) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 123198c347..d74c85eb72 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -897,6 +897,9 @@ const TEST_RUN_SCRIPT_TOOL: ChatCompletionFunctionTool = { export const editCodeToolWithDiff: Tool = { def: EDIT_CODE_TOOL_WITH_DIFF, + streamArguments: true, + showDetails: true, + showFade: true, fn: async function ({ args, helpers, toolCallbacks, toolId }) { const scriptOptions = helpers.getScriptOptions() @@ -947,7 +950,8 @@ export const editCodeToolWithDiff: Tool = { await helpers.applyCode(oldCode, { mode: 'revert' }) toolCallbacks.setToolStatus(toolId, { - content: `Code changes applied` + content: `Code changes applied`, + result: 'Success' }) return `Applied changes to the script editor.` } catch (error) { @@ -963,6 +967,9 @@ export const editCodeToolWithDiff: Tool = { export const editCodeTool: Tool = { def: EDIT_CODE_TOOL, + streamArguments: true, + showDetails: true, + showFade: true, fn: async function ({ args, helpers, toolCallbacks, toolId }) { const scriptOptions = helpers.getScriptOptions() @@ -984,8 +991,6 @@ export const editCodeTool: Tool = { throw new Error('Code parameter is required and must be a string') } - toolCallbacks.setToolStatus(toolId, { content: 'Applying code changes...' }) - try { // Save old code const oldCode = scriptOptions.code @@ -996,7 +1001,10 @@ export const editCodeTool: Tool = { // Show revert mode await helpers.applyCode(oldCode, { mode: 'revert' }) - toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied' }) + toolCallbacks.setToolStatus(toolId, { + content: 'Code changes applied', + result: 'Success' + }) return 'Code has been applied to the script editor.' } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 655f44ddc0..0277794632 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -15,6 +15,42 @@ import { scriptLangToEditorLang } from '$lib/scripts' import YAML from 'yaml' import { getCurrentModel } from '$lib/aiStore' +// Prettify function for code arguments - extracts and formats code from JSON +function prettifyCodeArguments(content: string): string { + let codeContent = content + + // If it's a JSON string, try to extract the code property + if (typeof content === 'string' && content.trim().startsWith('{')) { + try { + const parsed = JSON.parse(content) + if (parsed.code) { + codeContent = parsed.code + } + } catch { + // If JSON is incomplete during streaming, try to extract manually + // Remove leading { "code": " or {"code":" + codeContent = content.replace(/^\{\s*"code"\s*:\s*"/, '') + // Remove trailing } if it exists + codeContent = codeContent.replace(/"\s*}\s*$/, '') + } + } + + // Convert escaped newlines to actual newlines + codeContent = codeContent.replace(/\\n/g, '\n') + + // Convert other common escape sequences + codeContent = codeContent.replace(/\\t/g, '\t') + codeContent = codeContent.replace(/\\"/g, '"') + codeContent = codeContent.replace(/\\\\/g, '\\') + + return codeContent +} + +// Map of tool names to their prettify functions +export const TOOL_PRETTIFY_MAP: Record string> = { + edit_code: prettifyCodeArguments +} + export interface ContextStringResult { dbContext: string diffContext: string @@ -217,6 +253,9 @@ export type ToolDisplayMessage = { error?: string needsConfirmation?: boolean showDetails?: boolean + isStreamingArguments?: boolean + toolName?: string + showFade?: boolean } export type AssistantDisplayMessage = BaseDisplayMessage & { @@ -358,6 +397,8 @@ export interface Tool { requiresConfirmation?: boolean confirmationMessage?: string showDetails?: boolean + streamArguments?: boolean + showFade?: boolean } export interface ToolCallbacks { diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 1fa2a0d07c..5345517ecf 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -852,6 +852,7 @@ export async function parseOpenAICompletion( helpers: any ): Promise { const finalToolCalls: Record = {} + const streamingTools: Record = {} // Track which tools should stream let answer = '' for await (const chunk of completion) { @@ -909,14 +910,36 @@ export async function parseOpenAICompletion( } = finalToolCall if (funcName && toolCallId) { const tool = tools.find((t) => t.def.function.name === funcName) + + // Track if this tool should stream (only set once per tool) + if (streamingTools[index] === undefined) { + streamingTools[index] = tool?.streamArguments ?? false + } + if (tool && tool.preAction) { tool.preAction({ toolCallbacks: callbacks, toolId: toolCallId }) } - // Display tool call immediately in loading state + const shouldStream = streamingTools[index] + const accumulatedArgs = finalToolCall.function.arguments + let parameters: any = undefined + if (accumulatedArgs) { + try { + parameters = JSON.parse(accumulatedArgs) + } catch { + parameters = accumulatedArgs + } + } + + // Display tool call with streaming parameters if enabled callbacks.setToolStatus(toolCallId, { isLoading: true, - content: `Calling ${funcName} tool...` + content: `Calling ${funcName}...`, + toolName: funcName, + isStreamingArguments: shouldStream, + showFade: tool?.showFade, + showDetails: tool?.showDetails, + parameters: parameters }) } } @@ -931,6 +954,13 @@ export async function parseOpenAICompletion( callbacks.onMessageEnd() + // Clear streaming state for all tool calls + for (const toolCall of Object.values(finalToolCalls)) { + if (toolCall.id) { + callbacks.setToolStatus(toolCall.id, { isStreamingArguments: false }) + } + } + const toolCalls = Object.values(finalToolCalls).filter( (toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined ) as ChatCompletionMessageFunctionToolCall[]