feat(aichat): stream tool arguments (#7244)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user