From afdfa3021bb1ce6e735fb48456b7ae268be3e51f Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 20 Oct 2025 08:30:08 +0100 Subject: [PATCH] Refactor conversation content handling and improve error messages - Split content and functionCall into separate fields in ConversationContent - Extract content parsing logic into dedicated methods for Claude and Gemini - Add API error 429 handling with provider-specific user information - Improve error messages in naming services to include response text - Increase max_tokens for main requests and naming services - Optimize ChatService to reduce unnecessary saves and array recreations - Remove message key binding in ChatArea to improve performance - Adjust chat window max-width to fixed pixel value --- AIClasses/Claude/Claude.ts | 121 +++++--- .../Claude/ClaudeConversationNamingService.ts | 4 +- AIClasses/Gemini/Gemini.ts | 74 +++-- .../Gemini/GeminiConversationNamingService.ts | 2 +- AIClasses/IAIClass.ts | 1 + AIClasses/OpenAI/OpenAI.ts | 258 ++++++++++++++++++ .../OpenAI/OpenAIConversationNamingService.ts | 4 +- Components/ChatArea.svelte | 2 +- Components/ChatWindow.svelte | 10 +- Conversations/Conversation.ts | 21 +- Conversations/ConversationContent.ts | 44 +-- Enums/ApiProvider.ts | 10 +- Enums/Role.ts | 9 +- Services/ChatService.ts | 105 +++---- Services/ConversationFileSystemService.ts | 3 +- Services/ServiceRegistration.ts | 4 + Services/StreamingService.ts | 10 +- 17 files changed, 513 insertions(+), 169 deletions(-) create mode 100644 AIClasses/OpenAI/OpenAI.ts diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index ce85707..f6b47f5 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -9,8 +9,12 @@ import { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; import type AIAgentPlugin from "main"; import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; +import { isValidJson } from "Helpers/Helpers"; +import type { ConversationContent } from "Conversations/ConversationContent"; export class Claude implements IAIClass { + public readonly apiError429UserInfo = "Claude implements rate limits based on API Tier. Your tier increases as you spend more on the API - E.g. $40 total spend moves you to tier 2 (see: https://anthropic.mintlify.app/en/api/rate-limits#spend-limits)"; + private readonly STOP_REASON_TOOL_USE: string = "tool_use"; private readonly apiKey: string; @@ -39,48 +43,7 @@ export class Claude implements IAIClass { await this.aiPrompt.userInstruction() ].filter(s => s).join("\n\n"); - const messages = conversation.contents - .filter(content => content.content.trim() !== "") - .map(content => { - if (content.isFunctionCall) { - const parsedContent = JSON.parse(content.content); - return { - role: content.role, - content: [ - { - type: "tool_use", - id: parsedContent.functionCall.id, - name: parsedContent.functionCall.name, - input: parsedContent.functionCall.args - } - ] - }; - } - - if (content.isFunctionCallResponse) { - const parsedContent = JSON.parse(content.content); - return { - role: content.role, - content: [ - { - type: "tool_result", - tool_use_id: parsedContent.id, - content: JSON.stringify(parsedContent.functionResponse.response) - } - ] - }; - } - - return { - role: content.role, - content: [ - { - type: "text", - text: content.content - } - ] - }; - }); + const messages = this.extractContents(conversation.contents); const tools = this.mapFunctionDefinitions( this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) @@ -88,7 +51,7 @@ export class Claude implements IAIClass { const requestBody = { model: AIProviderModel.Claude, - max_tokens: 8192, + max_tokens: 16384, system: systemPrompt, messages: messages, tools: tools, @@ -103,6 +66,7 @@ export class Claude implements IAIClass { }; yield* this.streamingService.streamRequest( + this, AIProviderURL.Claude, requestBody, this.parseStreamChunk.bind(this), @@ -187,6 +151,77 @@ export class Claude implements IAIClass { } } + private extractContents(conversationContent: ConversationContent[]) { + return conversationContent.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") + .map(content => { + const contentBlocks: any[] = []; + + if (content.content.trim() !== "" && !content.isFunctionCallResponse) { + contentBlocks.push({ + type: "text", + text: content.content + }); + } + + // Add function call if present + if (content.isFunctionCall && content.functionCall.trim() !== "") { + if (isValidJson(content.functionCall)) { + try { + const parsedContent = JSON.parse(content.functionCall); + contentBlocks.push({ + type: "tool_use", + id: parsedContent.functionCall.id, + name: parsedContent.functionCall.name, + input: parsedContent.functionCall.args + }); + } catch (error) { + console.error("Failed to parse function call:", error); + // Fall back to treating as text + if (content.content.trim() === "") { + contentBlocks.push({ + type: "text", + text: "Error parsing function call" + }); + } + } + } else { + console.error("Invalid JSON in functionCall field"); + } + } + + // Add function response if present + if (content.isFunctionCallResponse && content.content.trim() !== "") { + if (isValidJson(content.content)) { + try { + const parsedContent = JSON.parse(content.content); + contentBlocks.push({ + type: "tool_result", + tool_use_id: parsedContent.id, + content: JSON.stringify(parsedContent.functionResponse.response) + }); + } catch (error) { + console.error("Failed to parse function response:", error); + contentBlocks.push({ + type: "text", + text: content.content + }); + } + } else { + console.error("Invalid JSON in function response content"); + contentBlocks.push({ + type: "text", + text: content.content + }); + } + } + + return { + role: content.role, + content: contentBlocks + }; + }); + } + private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { return aiFunctionDefinitions.map((functionDefinition) => ({ name: functionDefinition.name, diff --git a/AIClasses/Claude/ClaudeConversationNamingService.ts b/AIClasses/Claude/ClaudeConversationNamingService.ts index 21fdbd9..5bc5505 100644 --- a/AIClasses/Claude/ClaudeConversationNamingService.ts +++ b/AIClasses/Claude/ClaudeConversationNamingService.ts @@ -18,7 +18,7 @@ export class ClaudeConversationNamingService implements IConversationNamingServi const requestBody = { model: AIProviderModel.ClaudeNamer, - max_tokens: 50, + max_tokens: 100, system: NamePrompt, messages: [{ role: Role.User, @@ -39,7 +39,7 @@ export class ClaudeConversationNamingService implements IConversationNamingServi }); if (!response.ok) { - throw new Error(`Claude API error: ${response.status} ${response.statusText}`); + throw new Error(`Claude API error: ${response.status} ${response.statusText} - ${await response.text()}`); } const data = await response.json(); diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 882ddd6..7f64581 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -10,8 +10,12 @@ import { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; import type AIAgentPlugin from "main"; import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; +import { isValidJson } from "Helpers/Helpers"; +import type { ConversationContent } from "Conversations/ConversationContent"; export class Gemini implements IAIClass { + public readonly apiError429UserInfo = ""; + private readonly REQUEST_WEB_SEARCH: string = "request_web_search"; private readonly STOP_REASON_STOP: string = "STOP"; @@ -33,30 +37,23 @@ export class Gemini implements IAIClass { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) const requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; - // Reset function call accumulation state for new request this.accumulatedFunctionName = null; this.accumulatedFunctionArgs = {}; - const contents = conversation.contents - .filter(content => content.content.trim() !== "") - .map(content => ({ - role: content.role === Role.User ? "user" : "model", - parts: (content.isFunctionCall || content.isFunctionCallResponse) - ? [JSON.parse(content.content)] : [{ text: content.content }] - })); + const contents = this.extractContents(conversation.contents); const tools = requestWebSearch ? { google_search: {} } : - { - functionDeclarations: [ - { - name: "request_web_search", - description: `Use this function when you need to search the web for current + { + functionDeclarations: [ + { + name: "request_web_search", + description: `Use this function when you need to search the web for current information, recent events, news, or facts that may have changed. After calling this, you will be able to perform web searches.`, - }, - ...this.mapFunctionDefinitions(this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions)), - ] - } + }, + ...this.mapFunctionDefinitions(this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions)), + ] + } const requestBody = { system_instruction: { @@ -89,6 +86,7 @@ export class Gemini implements IAIClass { }; yield* this.streamingService.streamRequest( + this, AIProviderURL.Gemini.replace("API_KEY", this.apiKey), requestBody, this.parseStreamChunk.bind(this), @@ -159,6 +157,48 @@ export class Gemini implements IAIClass { } } + private extractContents(conversationContent: ConversationContent[]): { role: Role, parts: any[] }[] { + return conversationContent.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") + .map(content => { + const parts: any[] = []; + + if (content.content.trim() !== "") { + if (content.isFunctionCallResponse) { + if (isValidJson(content.content)) { + try { + parts.push(JSON.parse(content.content)); + } catch (error) { + console.error("Failed to parse function response:", error); + parts.push({ text: content.content }); + } + } else { + console.error("Invalid JSON in function response content"); + parts.push({ text: content.content }); + } + } else { + parts.push({ text: content.content }); + } + } + + if (content.isFunctionCall && content.functionCall.trim() !== "") { + if (isValidJson(content.functionCall)) { + try { + parts.push(JSON.parse(content.functionCall)); + } catch (error) { + console.error("Failed to parse function call:", error); + } + } else { + console.error("Invalid JSON in functionCall field"); + } + } + + return { + role: content.role === Role.User ? Role.User : Role.Model, + parts: parts.length > 0 ? parts : [{ text: "" }] + }; + }); + } + private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { return aiFunctionDefinitions.map((functionDefinition) => ({ name: functionDefinition.name, diff --git a/AIClasses/Gemini/GeminiConversationNamingService.ts b/AIClasses/Gemini/GeminiConversationNamingService.ts index 55cf5a0..9047c18 100644 --- a/AIClasses/Gemini/GeminiConversationNamingService.ts +++ b/AIClasses/Gemini/GeminiConversationNamingService.ts @@ -36,7 +36,7 @@ export class GeminiConversationNamingService implements IConversationNamingServi }); if (!response.ok) { - throw new Error(`Gemini API error: ${response.status} ${response.statusText}`); + throw new Error(`Gemini API error: ${response.status} ${response.statusText} - ${await response.text()}`); } const data = await response.json(); diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index c57e460..8a263c6 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -2,5 +2,6 @@ import type { StreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; export interface IAIClass { + readonly apiError429UserInfo: string; streamRequest(conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal): AsyncGenerator; } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts new file mode 100644 index 0000000..77105a9 --- /dev/null +++ b/AIClasses/OpenAI/OpenAI.ts @@ -0,0 +1,258 @@ +import { Resolve } from "Services/DependencyService"; +import { Services } from "Services/Services"; +import type { IAIClass } from "AIClasses/IAIClass"; +import type { IPrompt } from "AIClasses/IPrompt"; +import { StreamingService, type StreamChunk } from "Services/StreamingService"; +import type { Conversation } from "Conversations/Conversation"; +import { AIProviderURL, AIProviderModel } from "Enums/ApiProvider"; +import { AIFunctionCall } from "AIClasses/AIFunctionCall"; +import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; +import type AIAgentPlugin from "main"; +import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; +import { Role } from "Enums/Role"; +import { isValidJson } from "Helpers/Helpers"; + +interface ToolCallAccumulator { + id: string | null; + name: string | null; + arguments: string; +} + +export class OpenAI implements IAIClass { + public readonly apiError429UserInfo = "OpenAI implements rate limits based on usage tier. Higher tiers provide increased rate limits. For details and tier requirements, see: https://platform.openai.com/docs/guides/rate-limits"; + + private readonly STOP_REASON_TOOL_CALLS: string = "tool_calls"; + + private readonly apiKey: string; + private readonly aiPrompt: IPrompt = Resolve(Services.IPrompt); + private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin); + private readonly streamingService: StreamingService = Resolve(Services.StreamingService); + private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); + + // OpenAI can have multiple tool calls, so we track them by index + private accumulatedToolCalls: Map = new Map(); + + public constructor() { + this.apiKey = this.plugin.settings.apiKey; + } + + public async* streamRequest( + conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal + ): AsyncGenerator { + // Reset tool call accumulation state for new request + this.accumulatedToolCalls.clear(); + + const systemPrompt = [ + this.aiPrompt.systemInstruction(), + await this.aiPrompt.userInstruction() + ].filter(s => s).join("\n\n"); + + const messages = [ + { + role: Role.System, + content: systemPrompt + }, + ...conversation.contents + .filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") + .map(content => { + // Handle function call + if (content.isFunctionCall && content.functionCall.trim() !== "") { + if (isValidJson(content.functionCall)) { + try { + const parsedContent = JSON.parse(content.functionCall); + return { + role: content.role, + content: content.content.trim() !== "" ? content.content : null, + tool_calls: [ + { + id: parsedContent.functionCall.id, + type: "function", + function: { + name: parsedContent.functionCall.name, + arguments: JSON.stringify(parsedContent.functionCall.args) + } + } + ] + }; + } catch (error) { + console.error("Failed to parse function call:", error); + // Fall back to regular message + return { + role: content.role, + content: content.content || "Error parsing function call" + }; + } + } else { + console.error("Invalid JSON in functionCall field"); + return { + role: content.role, + content: content.content || "Invalid function call" + }; + } + } + + // Handle function response + if (content.isFunctionCallResponse && content.content.trim() !== "") { + if (isValidJson(content.content)) { + try { + const parsedContent = JSON.parse(content.content); + return { + role: "tool", + tool_call_id: parsedContent.id, + content: JSON.stringify(parsedContent.functionResponse.response) + }; + } catch (error) { + console.error("Failed to parse function response:", error); + // Fall back to regular message + return { + role: content.role, + content: content.content + }; + } + } else { + console.error("Invalid JSON in function response content"); + return { + role: content.role, + content: content.content + }; + } + } + + // Regular text message + return { + role: content.role, + content: content.content + }; + }) + ]; + + const tools = this.mapFunctionDefinitions( + this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) + ); + + const requestBody = { + model: AIProviderModel.OpenAI, + messages: messages, + tools: tools, + stream: true + }; + + const headers = { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json" + }; + + yield* this.streamingService.streamRequest( + this, + AIProviderURL.OpenAI, + requestBody, + this.parseStreamChunk.bind(this), + abortSignal, + headers + ); + } + + private parseStreamChunk(chunk: string): StreamChunk { + try { + // OpenAI sends "[DONE]" as the final message, which is not valid JSON + if (chunk.trim() === "[DONE]") { + return { content: "", isComplete: true }; + } + + const data = JSON.parse(chunk); + + let text = ""; + let functionCall: AIFunctionCall | undefined = undefined; + let isComplete = false; + let shouldContinue = false; + + const choice = data.choices?.[0]; + if (!choice) { + return { content: "", isComplete: false }; + } + + const delta = choice.delta; + + // Handle text content + if (delta?.content) { + text = delta.content; + } + + // Handle tool calls - OpenAI streams them incrementally with an index + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + const index = toolCall.index; + + // Get or create accumulator for this tool call index + if (!this.accumulatedToolCalls.has(index)) { + this.accumulatedToolCalls.set(index, { + id: null, + name: null, + arguments: "" + }); + } + + const accumulator = this.accumulatedToolCalls.get(index)!; + + // Accumulate tool call data + if (toolCall.id) { + accumulator.id = toolCall.id; + } + if (toolCall.function?.name) { + accumulator.name = toolCall.function.name; + } + if (toolCall.function?.arguments) { + accumulator.arguments += toolCall.function.arguments; + } + } + } + + // Check for completion + if (choice.finish_reason) { + isComplete = true; + shouldContinue = choice.finish_reason === this.STOP_REASON_TOOL_CALLS; + + // If we're finishing with a tool call, create the function call object + // For now, we'll handle the first tool call (OpenAI can have multiple) + if (shouldContinue && this.accumulatedToolCalls.size > 0) { + // Get the first accumulated tool call + const firstToolCall = this.accumulatedToolCalls.get(0); + if (firstToolCall && firstToolCall.name && firstToolCall.arguments) { + try { + const args = JSON.parse(firstToolCall.arguments); + functionCall = new AIFunctionCall( + firstToolCall.name, + args, + firstToolCall.id || undefined + ); + } catch (error) { + console.error("Failed to parse accumulated tool call arguments:", error); + } + } + } + } + + return { + content: text, + isComplete: isComplete, + functionCall: functionCall, + shouldContinue: shouldContinue, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown parsing error"; + console.error("Failed to parse stream chunk:", message, "Chunk:", chunk); + return { content: "", isComplete: false, error: `Failed to parse chunk: ${message}` }; + } + } + + private mapFunctionDefinitions(aiFunctionDefinitions: IAIFunctionDefinition[]): object[] { + return aiFunctionDefinitions.map((functionDefinition) => ({ + type: "function", + function: { + name: functionDefinition.name, + description: functionDefinition.description, + parameters: functionDefinition.parameters + } + })); + } +} diff --git a/AIClasses/OpenAI/OpenAIConversationNamingService.ts b/AIClasses/OpenAI/OpenAIConversationNamingService.ts index 2cbb9f6..aa09225 100644 --- a/AIClasses/OpenAI/OpenAIConversationNamingService.ts +++ b/AIClasses/OpenAI/OpenAIConversationNamingService.ts @@ -18,7 +18,7 @@ export class OpenAIConversationNamingService implements IConversationNamingServi const requestBody = { model: AIProviderModel.OpenAINamer, - max_tokens: 50, + max_tokens: 100, messages: [ { role: Role.System, @@ -42,7 +42,7 @@ export class OpenAIConversationNamingService implements IConversationNamingServi }); if (!response.ok) { - throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`); + throw new Error(`OpenAI API error: ${response.status} ${response.statusText} - ${await response.text()}`); } const data = await response.json(); diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 95583f1..3f48c70 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -310,7 +310,7 @@
- {#each messages as message (message.timestamp.getTime())} + {#each messages as message} {#if !message.isFunctionCall && !message.isFunctionCallResponse && message.content}
diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 5a97ba4..a98f961 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -107,9 +107,9 @@ autoResize(); scrollToBottom(); - conversation = await chatService.submit(conversation, editModeActive, currentRequest, { - onStreamingUpdate: (updatedConversation, streamingId, streaming) => { - conversation = updatedConversation; + await chatService.submit(conversation, editModeActive, currentRequest, { + onStreamingUpdate: (streamingId) => { + conversation = conversation; currentStreamingMessageId = streamingId; }, onThoughtUpdate: (thought) => { @@ -177,8 +177,8 @@ } $: if ($conversationStore.shouldDeactivateEditMode) { - editModeActive = false; conversationStore.clearEditModeFlag(); + editModeActive = false; } @@ -234,7 +234,7 @@ #chat-container { height: 100%; width: 100%; - max-width: 40vw; + max-width: 1000px; justify-self: center; user-select: text; grid-row: 1; diff --git a/Conversations/Conversation.ts b/Conversations/Conversation.ts index 0200b8c..3d96729 100644 --- a/Conversations/Conversation.ts +++ b/Conversations/Conversation.ts @@ -9,6 +9,11 @@ export class Conversation { contents: ConversationContent[] = []; + constructor() { + this.created = new Date(); + this.title = `${dateToString(this.created)}`; + } + public static isConversationData(data: unknown): data is { title: string; created: string; contents: ConversationContent[] } { return ( typeof data === 'object' && @@ -23,8 +28,18 @@ export class Conversation { ); } - constructor() { - this.created = new Date(); - this.title = `${dateToString(this.created)}`; + public setMostRecentContent(content: string) { + const conversationContent: ConversationContent | undefined = this.contents.last(); + if (conversationContent) { + conversationContent.content = content; + } + } + + public setMostRecentFunctionCall(functionCall: string) { + const conversationContent: ConversationContent | undefined = this.contents.last(); + if (conversationContent) { + conversationContent.functionCall = functionCall; + conversationContent.isFunctionCall = true; + } } } \ No newline at end of file diff --git a/Conversations/ConversationContent.ts b/Conversations/ConversationContent.ts index 44f8b4e..d660199 100644 --- a/Conversations/ConversationContent.ts +++ b/Conversations/ConversationContent.ts @@ -1,36 +1,40 @@ export class ConversationContent { role: string; content: string + functionCall: string; timestamp: Date; isFunctionCall: boolean; isFunctionCallResponse: boolean; toolId?: string; - public static isConversationContentData(data: unknown): data is { - role: string; content: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string - } { - return ( - typeof data === "object" && - data !== null && - "role" in data && - "content" in data && - "timestamp" in data && - "isFunctionCall" in data && - "isFunctionCallResponse" in data && - typeof data.role === "string" && - typeof data.content === "string" && - typeof data.timestamp === "string" && - typeof data.isFunctionCall == "boolean" && - typeof data.isFunctionCallResponse == "boolean" - ); - } - - constructor(role: string, content: string, timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string) { + constructor(role: string, content: string = "", functionCall: string = "", timestamp: Date = new Date(), isFunctionCall = false, isFunctionCallResponse = false, toolId?: string) { this.role = role; this.content = content; + this.functionCall = functionCall; this.timestamp = timestamp; this.isFunctionCall = isFunctionCall; this.isFunctionCallResponse = isFunctionCallResponse; this.toolId = toolId; } + + public static isConversationContentData(data: unknown): data is { + role: string; content: string; functionCall: string; timestamp: string, isFunctionCall: boolean, isFunctionCallResponse: boolean, toolId?: string + } { + return ( + data !== null && + typeof data === "object" && + "role" in data && + "content" in data && + "functionCall" in data && + "timestamp" in data && + "isFunctionCall" in data && + "isFunctionCallResponse" in data && + typeof data.role === "string" && + typeof data.content === "string" && + typeof data.functionCall === "string" && + typeof data.timestamp === "string" && + typeof data.isFunctionCall === "boolean" && + typeof data.isFunctionCallResponse === "boolean" + ); + } } \ No newline at end of file diff --git a/Enums/ApiProvider.ts b/Enums/ApiProvider.ts index 493cd00..7cf1e52 100644 --- a/Enums/ApiProvider.ts +++ b/Enums/ApiProvider.ts @@ -6,13 +6,12 @@ export enum AIProvider { export enum AIProviderModel { Claude = "claude-sonnet-4-5-20250929", - ClaudeNamer = "claude-haiku-4-5-20251001", - Gemini = "gemini-2.5-flash", - GeminiNamer = "gemini-2.5-flash", + OpenAI = "gpt-4o", - OpenAI = "gpt-5", - OpenAINamer = "gpt-5-nano" + ClaudeNamer = "claude-haiku-4-5-20251001", + GeminiNamer = "gemini-2.5-flash", + OpenAINamer = "gpt-4o-mini", } export enum AIProviderURL { @@ -22,5 +21,6 @@ export enum AIProviderURL { Gemini = `https://generativelanguage.googleapis.com/v1beta/models/${AIProviderModel.GeminiNamer}:streamGenerateContent?key=API_KEY&alt=sse`, GeminiNamer = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=API_KEY", + OpenAI = "https://api.openai.com/v1/chat/completions", OpenAINamer = "https://api.openai.com/v1/chat/completions" } \ No newline at end of file diff --git a/Enums/Role.ts b/Enums/Role.ts index 994ad47..291e519 100644 --- a/Enums/Role.ts +++ b/Enums/Role.ts @@ -1,7 +1,10 @@ export enum Role { - Assistant = "assistant", User = "user", - // used by OpenAI - System = "system" + // Claude + Assistant = "assistant", + // OpenAI + System = "system", + // Gemini + Model = "model" } \ No newline at end of file diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 4cff60c..1b6f20d 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -14,7 +14,7 @@ import { Role } from "Enums/Role"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; export interface ChatServiceCallbacks { - onStreamingUpdate: (conversation: Conversation, streamingMessageId: string | null, isStreaming: boolean) => void; + onStreamingUpdate: (streamingMessageId: string | null) => void; onThoughtUpdate: (thought: string | null) => void; onComplete: () => void; } @@ -48,23 +48,23 @@ export class ChatService { this.tokenService = Resolve(Services.ITokenService); } - public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise { + public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise { if (!await this.semaphore.wait()) { - return conversation; + return; } this.semaphoreHeld = true; try { if (userRequest.trim() === "") { - return conversation; + return; } this.abortController = new AbortController(); - // Add user message to conversation - conversation.contents = [...conversation.contents, new ConversationContent(Role.User, userRequest)]; - await this.conversationService.saveConversation(conversation); + conversation.contents.push(new ConversationContent(Role.User, userRequest)); + this.conversationService.saveConversation(conversation); + callbacks.onStreamingUpdate(null); if (conversation.contents.length === 1) { this.onNameChanged?.(conversation.title); // on change for initial conversation name @@ -76,31 +76,27 @@ export class ChatService { while (response.functionCall || response.shouldContinue) { if (response.functionCall) { - if ('user_message' in response.functionCall.arguments) { + if (response.functionCall.arguments.user_message) { callbacks.onThoughtUpdate(response.functionCall.arguments.user_message); } - conversation.contents = [...conversation.contents, new ConversationContent( - Role.Assistant, response.functionCall.toConversationString(), new Date(), true, false, response.functionCall.toolId)]; - await this.conversationService.saveConversation(conversation); - const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); - conversation.contents = [...conversation.contents, new ConversationContent( - Role.User, functionResponse.toConversationString(), new Date(), false, true, functionResponse.toolId)]; - await this.conversationService.saveConversation(conversation); + + conversation.contents.push(new ConversationContent( + Role.User, functionResponse.toConversationString(), "", new Date(), false, true, functionResponse.toolId + )); } response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); } - - return conversation; } finally { - callbacks.onThoughtUpdate(null); + this.conversationService.saveConversation(conversation); this.abortController = null; if (this.semaphoreHeld) { this.semaphoreHeld = false; this.semaphore.release(); } + callbacks.onThoughtUpdate(null); callbacks.onComplete(); } } @@ -122,13 +118,13 @@ export class ChatService { const userInstruction = await this.prompt.userInstruction(); const inputMessages = conversation.contents - .filter(msg => msg.role === Role.User && !msg.isFunctionCallResponse) - .map(msg => msg.content) + .filter(message => message.role === Role.User && !message.isFunctionCallResponse) + .map(message => message.content) .join("\n"); const outputMessages = conversation.contents - .filter(msg => msg.role === Role.Assistant && !msg.isFunctionCall) - .map(msg => msg.content) + .filter(message => message.role === Role.Assistant && !message.isFunctionCall) + .map(message => message.content) .join("\n"); const inputText = systemInstruction + "\n" + userInstruction + "\n" + inputMessages; @@ -150,13 +146,9 @@ export class ChatService { return { functionCall: null, shouldContinue: false };; } - // Create AI message with stable timestamp for identification - const aiMessage = new ConversationContent(Role.Assistant, ""); - conversation.contents = [...conversation.contents, aiMessage]; - - // Notify that streaming has started - use timestamp as unique identifier - const messageId = aiMessage.timestamp.getTime().toString(); - callbacks.onStreamingUpdate(conversation, messageId, true); + const aiMessage = new ConversationContent(Role.Assistant); + conversation.contents.push(aiMessage); + callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); let accumulatedContent = ""; let capturedFunctionCall: AIFunctionCall | null = null; @@ -165,27 +157,11 @@ export class ChatService { for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions, this.abortController?.signal)) { if (chunk.error) { console.error("Streaming error:", chunk.error); - conversation.contents = conversation.contents.map((msg) => - msg.timestamp.getTime() === aiMessage.timestamp.getTime() - ? { ...msg, content: "Error: " + chunk.error } - : msg - ); - callbacks.onStreamingUpdate(conversation, null, false); - await this.conversationService.saveConversation(conversation); + conversation.setMostRecentContent(`Error: ${chunk.error}`); + callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); break; } - if (chunk.content) { - callbacks.onThoughtUpdate(null); - accumulatedContent += chunk.content; - conversation.contents = conversation.contents.map((msg) => - msg.timestamp.getTime() === aiMessage.timestamp.getTime() - ? { ...msg, content: accumulatedContent } - : msg - ); - callbacks.onStreamingUpdate(conversation, messageId, true); - } - if (chunk.functionCall) { capturedFunctionCall = chunk.functionCall; } @@ -194,25 +170,24 @@ export class ChatService { capturedShouldContinue = true; } - if (chunk.isComplete) { - callbacks.onStreamingUpdate(conversation, null, false); - - if (accumulatedContent.trim() !== "") { - // We have content - always keep the message - conversation.contents = conversation.contents.map((msg) => - msg.timestamp.getTime() === aiMessage.timestamp.getTime() - ? { ...msg, content: accumulatedContent } - : msg - ); - } else if (capturedFunctionCall) { - // No content but there's a function call - remove the empty placeholder - conversation.contents = conversation.contents.filter((msg) => msg.timestamp.getTime() !== aiMessage.timestamp.getTime()); - } else { - // No content and no function call - remove empty message - conversation.contents = conversation.contents.filter((msg) => msg.timestamp.getTime() !== aiMessage.timestamp.getTime()); - } - await this.conversationService.saveConversation(conversation); + if (chunk.content) { + accumulatedContent += chunk.content; + conversation.setMostRecentContent(accumulatedContent); + callbacks.onThoughtUpdate(null); } + + if (chunk.isComplete) { + // No content and no function call - remove empty message + if (accumulatedContent.trim() == "" && !capturedFunctionCall) { + conversation.contents.pop(); + } + + conversation.setMostRecentContent(accumulatedContent); + if (capturedFunctionCall) { + conversation.setMostRecentFunctionCall(capturedFunctionCall?.toConversationString()); + } + } + callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString()); } return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue }; diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index 1bedc63..f8a84bd 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -32,6 +32,7 @@ export class ConversationFileSystemService { .map(content => ({ role: content.role, content: content.content, + functionCall: content.functionCall, timestamp: content.timestamp.toISOString(), isFunctionCall: content.isFunctionCall, isFunctionCallResponse: content.isFunctionCallResponse, @@ -81,7 +82,7 @@ export class ConversationFileSystemService { conversation.created = new Date(data.created); conversation.contents = data.contents.map(content => { return new ConversationContent( - content.role, content.content, new Date(content.timestamp), content.isFunctionCall, content.isFunctionCallResponse, content.toolId); + content.role, content.content, content.functionCall, new Date(content.timestamp), content.isFunctionCall, content.isFunctionCallResponse, content.toolId); }); conversations.push(conversation); } diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 918fee8..aff06b5 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -26,6 +26,8 @@ import { ClaudeTokenService } from "AIClasses/Claude/ClaudeTokenService"; import { OpenAITokenService } from "AIClasses/OpenAI/OpenAITokenService"; import { ClaudeConversationNamingService } from "AIClasses/Claude/ClaudeConversationNamingService"; import { Claude } from "AIClasses/Claude/Claude"; +import { OpenAIConversationNamingService } from "AIClasses/OpenAI/OpenAIConversationNamingService"; +import { OpenAI } from "AIClasses/OpenAI/OpenAI"; export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.AIAgentPlugin, plugin); @@ -61,7 +63,9 @@ export function RegisterAiProvider(plugin: AIAgentPlugin) { RegisterSingleton(Services.IConversationNamingService, new GeminiConversationNamingService()); } else if (plugin.settings.apiProvider == AIProvider.OpenAI) { + RegisterSingleton(Services.IAIClass, new OpenAI()); RegisterSingleton(Services.ITokenService, new OpenAITokenService()); + RegisterSingleton(Services.IConversationNamingService, new OpenAIConversationNamingService()); } else { // should be impossible to land here throw new Error("Invalid Provider Selection!"); diff --git a/Services/StreamingService.ts b/Services/StreamingService.ts index 499ecbf..f9327c5 100644 --- a/Services/StreamingService.ts +++ b/Services/StreamingService.ts @@ -1,4 +1,5 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; +import type { IAIClass } from "AIClasses/IAIClass"; import { Selector } from "Enums/Selector"; export interface StreamChunk { @@ -11,6 +12,7 @@ export interface StreamChunk { export class StreamingService { public async* streamRequest( + aiInstance: IAIClass, url: string, requestBody: unknown, parseStreamChunk: (chunk: string) => StreamChunk, @@ -33,7 +35,13 @@ export class StreamingService { if (!response.ok) { const errorText = await response.text(); - throw new Error(`API request failed: ${response.status} - ${errorText}`); + let errorMessage = `API request failed: ${response.status} - ${errorText}`; + + if (response.status === 429 && aiInstance.apiError429UserInfo) { + errorMessage += `\n\n${aiInstance.apiError429UserInfo}`; + } + + throw new Error(errorMessage); } const reader = response.body?.getReader();