feat(aichat): stream tool arguments (#7244)

This commit is contained in:
centdix
2025-11-29 16:27:02 +01:00
committed by GitHub
parent f277da4c88
commit ad335cc47e
7 changed files with 258 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Loader2, Copy, Check } from 'lucide-svelte'
import { TOOL_PRETTIFY_MAP } from './shared'
interface Props {
title: string
@@ -8,15 +9,41 @@
loading?: boolean
showCopy?: boolean
showWhileLoading?: boolean
streaming?: boolean
toolName?: string
showFade?: boolean
}
let { title, content, error, loading, showCopy = true, showWhileLoading = true }: Props = $props()
let {
title,
content,
error,
loading,
showCopy = true,
showWhileLoading = true,
streaming = false,
toolName,
showFade = false
}: Props = $props()
let copied = $state(false)
const hasContent = $derived(content !== undefined && content !== null)
// Look up prettify function from the map using toolName
const prettifyFn = $derived(toolName ? TOOL_PRETTIFY_MAP[toolName] : undefined)
function formatJson(obj: any): string {
try {
// Apply prettify function if available for this tool
if (prettifyFn) {
if (typeof obj === 'object') {
return prettifyFn(JSON.stringify(obj, null, 2))
} else {
return prettifyFn(obj)
}
}
// Original formatting logic as fallback
if (typeof obj === 'string') {
try {
const parsed = JSON.parse(obj)
@@ -49,13 +76,13 @@
}
</script>
{#if showWhileLoading || (!loading && hasContent)}
{#if showWhileLoading || (!loading && hasContent) || streaming}
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-secondary text-2xs font-semibold uppercase tracking-wide">
<span class="text-2xs">
{title}:
</span>
{#if showCopy && hasContent}
{#if showCopy && hasContent && !streaming}
<button
class="p-1 rounded hover:bg-surface-secondary text-primary hover:text-secondary transition-colors"
onclick={copyToClipboard}
@@ -70,7 +97,7 @@
{/if}
</div>
{#if loading}
{#if loading && !streaming && !hasContent}
<div
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 flex items-center gap-2 text-primary"
>
@@ -79,17 +106,24 @@
</div>
{:else if error}
<div
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 overflow-x-auto max-h-28 overflow-y-auto"
>
<pre class="text-2xs text-red-700 dark:text-red-300 whitespace-pre-wrap">{error}</pre>
</div>
{:else if hasContent}
<div
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded p-3 overflow-x-auto max-h-64 overflow-y-auto"
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded overflow-hidden relative"
>
<pre class="text-2xs text-primary whitespace-pre-wrap"
>{formatJson($state.snapshot(content))}</pre
>
<div class="p-3 overflow-x-auto max-h-28 overflow-y-auto">
<pre class="text-2xs text-primary whitespace-pre-wrap"
>{formatJson($state.snapshot(content))}</pre
>
</div>
{#if showFade}
<div
class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-surface-secondary via-surface-secondary/70 via-surface-secondary/40 to-transparent"
></div>
{/if}
</div>
{:else}
<div

View File

@@ -12,11 +12,15 @@
let { message }: Props = $props()
let isExpanded = $derived(message.showDetails || (message.isLoading && message.needsConfirmation))
const hasParameters = $derived(
message.parameters !== undefined && Object.keys(message.parameters).length > 0
)
let isExpanded = $derived(
message.showDetails ||
(message.isStreamingArguments && hasParameters) ||
(message.isLoading && message.needsConfirmation)
)
</script>
<div
@@ -25,14 +29,14 @@
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-3 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails}
disabled={!message.showDetails && !message.isStreamingArguments}
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails}
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
@@ -55,11 +59,19 @@
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-3 bg-surface space-y-3">
<!-- Parameters Section -->
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay title="Parameters" content={message.parameters} />
</div>
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - only show if we have parameters -->
{#if hasParameters}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
@@ -94,8 +106,8 @@
</Button>
</div>
<!-- Result Section -->
{:else}
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Logs"
content={message.logs}

View File

@@ -9,12 +9,12 @@ import type {
ToolUnion,
ToolUseBlockParam,
Tool as AnthropicTool,
Message
Message,
RawMessageStreamEvent
} from '@anthropic-ai/sdk/resources'
import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream'
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
import { generateRandomString } from '$lib/utils'
export async function getAnthropicCompletion(
messages: ChatCompletionMessageParam[],
@@ -61,14 +61,61 @@ export async function parseAnthropicCompletion(
): Promise<boolean> {
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
})

View File

@@ -211,6 +211,12 @@ export async function parseOpenAIResponsesCompletion(
let textContent = ''
let toolCallsMap: Record<string, { name: string; call_id: string }> = {}
// 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
})

View File

@@ -897,6 +897,9 @@ const TEST_RUN_SCRIPT_TOOL: ChatCompletionFunctionTool = {
export const editCodeToolWithDiff: Tool<ScriptChatHelpers> = {
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<ScriptChatHelpers> = {
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<ScriptChatHelpers> = {
export const editCodeTool: Tool<ScriptChatHelpers> = {
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<ScriptChatHelpers> = {
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<ScriptChatHelpers> = {
// 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'

View File

@@ -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, (content: string) => 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<T> {
requiresConfirmation?: boolean
confirmationMessage?: string
showDetails?: boolean
streamArguments?: boolean
showFade?: boolean
}
export interface ToolCallbacks {

View File

@@ -852,6 +852,7 @@ export async function parseOpenAICompletion(
helpers: any
): Promise<boolean> {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
const streamingTools: Record<number, boolean> = {} // 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[]