From 5e96efdccc12aa70e4bf604a232a388a906b7d51 Mon Sep 17 00:00:00 2001 From: centdix Date: Fri, 29 Aug 2025 11:10:06 +0000 Subject: [PATCH] draft --- .../copilot/chat/AIChatManager.svelte.ts | 107 ++-------- .../lib/components/copilot/chat/anthropic.ts | 184 +++++------------- frontend/src/lib/components/copilot/lib.ts | 144 ++++++++++---- 3 files changed, 172 insertions(+), 263 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index b1f02f00b4..e64d85d222 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -11,16 +11,13 @@ import HistoryManager from './HistoryManager.svelte' import { extractCodeFromMarkdown, getLatestAssistantMessage, - processToolCall, type DisplayMessage, type Tool, type ToolCallbacks, type ToolDisplayMessage } from './shared' import type { - ChatCompletionChunk, ChatCompletionMessageParam, - ChatCompletionMessageToolCall, ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam } from 'openai/resources/chat/completions.mjs' @@ -34,7 +31,7 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { getCompletion, getModelContextWindow } from '../lib' +import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib' import { dfs } from '$lib/components/flows/previousResults' import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' @@ -380,10 +377,8 @@ class AIChatManager { } systemMessage?: ChatCompletionSystemMessageParam }) => { - let addedMessages: ChatCompletionMessageParam[] = [] try { - let completion: any = null - + let addedMessages: ChatCompletionMessageParam[] = [] while (true) { const systemMessage = systemMessageOverride ?? this.systemMessage const helpers = this.helpers @@ -413,99 +408,23 @@ class AIChatManager { } this.pendingPrompt = '' } - completion = await getCompletion( + + const completion = await getCompletion( [systemMessage, ...messages, ...(pendingUserMessage ? [pendingUserMessage] : [])], abortController, tools.map((t) => t.def) ) if (completion) { - const finalToolCalls: Record = {} - - let answer = '' - for await (const chunk of completion) { - if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) { - continue - } - const c = chunk as ChatCompletionChunk - const delta = c.choices[0].delta.content - if (delta) { - answer += delta - callbacks.onNewToken(delta) - } - const toolCalls = c.choices[0].delta.tool_calls || [] - if (toolCalls.length > 0 && answer) { - // if tool calls are present but we have some textual content already, we need to display it to the user first - callbacks.onMessageEnd() - answer = '' - } - for (const toolCall of toolCalls) { - const { index } = toolCall - let finalToolCall = finalToolCalls[index] - if (!finalToolCall) { - finalToolCalls[index] = toolCall - } else { - if (toolCall.function?.arguments) { - if (!finalToolCall.function) { - finalToolCall.function = toolCall.function - } else { - finalToolCall.function.arguments = - (finalToolCall.function.arguments ?? '') + toolCall.function.arguments - } - } - } - finalToolCall = finalToolCalls[index] - if (finalToolCall?.function) { - const { - function: { name: funcName }, - id: toolCallId - } = finalToolCall - if (funcName && toolCallId) { - const tool = tools.find((t) => t.def.function.name === funcName) - if (tool && tool.preAction) { - tool.preAction({ toolCallbacks: callbacks, toolId: toolCallId }) - } - } - } - } - } - - if (answer) { - const toAdd = { role: 'assistant' as const, content: answer } - addedMessages.push(toAdd) - messages.push(toAdd) - } - - callbacks.onMessageEnd() - - const toolCalls = Object.values(finalToolCalls).filter( - (toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined - ) as ChatCompletionMessageToolCall[] - - if (toolCalls.length > 0) { - const toAdd = { - role: 'assistant' as const, - tool_calls: toolCalls.map((t) => ({ - ...t, - function: { - ...t.function, - arguments: t.function.arguments || '{}' - } - })) - } - messages.push(toAdd) - addedMessages.push(toAdd) - for (const toolCall of toolCalls) { - const messageToAdd = await processToolCall({ - tools, - toolCall, - helpers, - toolCallbacks: callbacks - }) - messages.push(messageToAdd) - addedMessages.push(messageToAdd) - } - } else { + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers + ) + if (!continueCompletion) { break } } diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 81bc5ff58c..ef642bab53 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -5,8 +5,57 @@ import type { TextBlockParam, ToolUnion, ToolUseBlockParam, - Tool + Tool as AnthropicTool } from '@anthropic-ai/sdk/resources' +import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' +import type { Stream } from 'openai/streaming.mjs' +import type { Tool, ToolCallbacks } from './shared' + +export async function getAnthropicCompletion( + messages: ChatCompletionMessageParam[], + abortController: AbortController, + tools?: OpenAI.Chat.Completions.ChatCompletionTool[] +) { + const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true }) + const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) + const anthropicTools = convertOpenAIToolsToAnthropic(tools) + + const anthropicClient = workspaceAIClients.getAnthropicClient() + + const anthropicParams = { + model: config.model, + max_tokens: config.max_tokens as number, + messages: anthropicMessages, + ...(system && { system }), + ...(anthropicTools && { tools: anthropicTools }), + ...(typeof config.temperature === 'number' && { temperature: config.temperature }) + } + + const stream = anthropicClient.messages.stream(anthropicParams, { + signal: abortController.signal, + headers: { + 'X-Provider': provider, + 'anthropic-version': '2023-06-01' + } + }) + + return stream +} + +export async function parseAnthropicCompletion( + completion: Stream, + callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + }, + messages: ChatCompletionMessageParam[], + addedMessages: ChatCompletionMessageParam[], + tools: Tool[], + helpers: any +): Promise { + // TODO: Implement Anthropic completion parsing + return true +} export function convertOpenAIToAnthropicMessages(messages: ChatCompletionMessageParam[]): { system: TextBlockParam[] | undefined @@ -125,7 +174,7 @@ export function convertOpenAIToolsToAnthropic( input_schema: (tool.function.parameters || { type: 'object', properties: {} - }) as Tool.InputSchema + }) as AnthropicTool.InputSchema })) // Add cache_control to the last tool to cache all tool definitions @@ -135,134 +184,3 @@ export function convertOpenAIToolsToAnthropic( return anthropicTools } - -export async function* convertAnthropicStreamToOpenAI( - model: string, - stream: Stream -): AsyncIterable { - let currentToolCall: { id: string; name: string; args: string } | null = null - const messageId = `chatcmpl-${Date.now()}` - - try { - for await (const event of stream) { - switch (event.type) { - case 'message_start': - yield { - id: messageId, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] - } - break - - case 'content_block_start': - if (event.content_block?.type === 'tool_use') { - currentToolCall = { - id: event.content_block.id, - name: event.content_block.name, - args: '' - } - } - // Text and thinking blocks start here but don't need special handling - break - - case 'content_block_delta': - switch (event.delta?.type) { - case 'text_delta': - yield { - id: messageId, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }] - } - break - - case 'input_json_delta': - if (currentToolCall) { - currentToolCall.args += event.delta.partial_json - } - break - - case 'thinking_delta': - // Skip thinking deltas - they're internal reasoning - break - - default: - // Handle unknown delta types gracefully - break - } - break - - case 'content_block_stop': - if (currentToolCall) { - // Emit completed tool call - yield { - id: messageId, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: currentToolCall.id, - type: 'function' as const, - function: { name: currentToolCall.name, arguments: currentToolCall.args } - } - ] - }, - finish_reason: null - } - ] - } - currentToolCall = null - } - break - - case 'message_delta': - const finishReason = - event.delta?.stop_reason === 'end_turn' - ? 'stop' - : event.delta?.stop_reason === 'tool_use' - ? 'tool_calls' - : event.delta?.stop_reason === 'max_tokens' - ? 'length' - : null - - yield { - id: messageId, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: {}, finish_reason: finishReason }] - } - break - - case 'message_stop': - // Stream completed successfully - break - - case 'ping': - // Ignore ping events - break - - case 'error': - // Handle error events - throw new Error(`Anthropic stream error: ${JSON.stringify(event.error)}`) - - default: - // Handle unknown event types gracefully as per Anthropic docs - console.debug('Unknown Anthropic stream event type:', event.type) - break - } - } - } catch (error) { - console.error('Error processing Anthropic stream:', error) - throw error - } -} diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index c6157a5d10..ca7958274e 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -9,10 +9,12 @@ import { import { buildClientSchema, printSchema } from 'graphql' import { OpenAI } from 'openai' import type { + ChatCompletionChunk, ChatCompletionCreateParams, ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming, - ChatCompletionMessageParam + ChatCompletionMessageParam, + ChatCompletionMessageToolCall } from 'openai/resources/index.mjs' import Anthropic from '@anthropic-ai/sdk' import { get, type Writable } from 'svelte/store' @@ -20,11 +22,8 @@ import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { formatResourceTypes } from './utils' import { z } from 'zod' -import { - convertAnthropicStreamToOpenAI, - convertOpenAIToAnthropicMessages, - convertOpenAIToolsToAnthropic -} from './chat/anthropic' +import type { Stream } from 'openai/streaming.mjs' +import { processToolCall, type Tool, type ToolCallbacks } from './chat/shared' export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts)) @@ -484,7 +483,7 @@ const PROMPTS_CONFIGS = { gen: GEN_CONFIG } -function getProviderAndCompletionConfig({ +export function getProviderAndCompletionConfig({ messages, stream, tools, @@ -649,35 +648,6 @@ export async function getCompletion( ) { const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) - // If using Anthropic, use the Anthropic SDK with format conversion - if (provider === 'anthropic') { - const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) - const anthropicTools = convertOpenAIToolsToAnthropic(tools) - - const anthropicClient = workspaceAIClients.getAnthropicClient() - - const anthropicParams = { - model: config.model, - max_tokens: config.max_tokens as number, - messages: anthropicMessages, - stream: true as const, - ...(system && { system }), - ...(anthropicTools && { tools: anthropicTools }), - ...(typeof config.temperature === 'number' && { temperature: config.temperature }) - } - - const stream = await anthropicClient.messages.create(anthropicParams, { - signal: abortController.signal, - headers: { - 'X-Provider': provider, - 'anthropic-version': '2023-06-01' - } - }) - - return convertAnthropicStreamToOpenAI(config.model, stream) - } - - // For other providers, use the existing OpenAI client const openaiClient = workspaceAIClients.getOpenaiClient() const completion = await openaiClient.chat.completions.create(config, { signal: abortController.signal, @@ -688,6 +658,108 @@ export async function getCompletion( return completion } +export async function parseOpenAICompletion( + completion: Stream, + callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + }, + messages: ChatCompletionMessageParam[], + addedMessages: ChatCompletionMessageParam[], + tools: Tool[], + helpers: any +): Promise { + const finalToolCalls: Record = {} + + let answer = '' + for await (const chunk of completion) { + if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) { + continue + } + const c = chunk as ChatCompletionChunk + const delta = c.choices[0].delta.content + if (delta) { + answer += delta + callbacks.onNewToken(delta) + } + const toolCalls = c.choices[0].delta.tool_calls || [] + if (toolCalls.length > 0 && answer) { + // if tool calls are present but we have some textual content already, we need to display it to the user first + callbacks.onMessageEnd() + answer = '' + } + for (const toolCall of toolCalls) { + const { index } = toolCall + let finalToolCall = finalToolCalls[index] + if (!finalToolCall) { + finalToolCalls[index] = toolCall + } else { + if (toolCall.function?.arguments) { + if (!finalToolCall.function) { + finalToolCall.function = toolCall.function + } else { + finalToolCall.function.arguments = + (finalToolCall.function.arguments ?? '') + toolCall.function.arguments + } + } + } + finalToolCall = finalToolCalls[index] + if (finalToolCall?.function) { + const { + function: { name: funcName }, + id: toolCallId + } = finalToolCall + if (funcName && toolCallId) { + const tool = tools.find((t) => t.def.function.name === funcName) + if (tool && tool.preAction) { + tool.preAction({ toolCallbacks: callbacks, toolId: toolCallId }) + } + } + } + } + } + + if (answer) { + const toAdd = { role: 'assistant' as const, content: answer } + addedMessages.push(toAdd) + messages.push(toAdd) + } + + callbacks.onMessageEnd() + + const toolCalls = Object.values(finalToolCalls).filter( + (toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined + ) as ChatCompletionMessageToolCall[] + + if (toolCalls.length > 0) { + const toAdd = { + role: 'assistant' as const, + tool_calls: toolCalls.map((t) => ({ + ...t, + function: { + ...t.function, + arguments: t.function.arguments || '{}' + } + })) + } + messages.push(toAdd) + addedMessages.push(toAdd) + for (const toolCall of toolCalls) { + const messageToAdd = await processToolCall({ + tools, + toolCall, + helpers, + toolCallbacks: callbacks + }) + messages.push(messageToAdd) + addedMessages.push(messageToAdd) + } + } else { + return false + } + return true +} + export function getResponseFromEvent(part: OpenAI.Chat.Completions.ChatCompletionChunk): string { return part.choices?.[0]?.delta?.content || '' }