From ba8db87ffdeed8ded8b4fa892e3050417aed0117 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 31 Oct 2025 18:32:40 +0000 Subject: [PATCH] refactor: use promptContent for user messages across AI providers Standardize message extraction logic to use promptContent field for user roles and content field for assistant roles. Update all AI provider implementations (Claude, OpenAI, Gemini) and corresponding tests. Remove trailing space insertion in ChatInput and clean up minor formatting issues. --- AIClasses/Claude/Claude.ts | 20 +++++++++++--------- AIClasses/Gemini/Gemini.ts | 15 ++++++++------- AIClasses/OpenAI/OpenAI.ts | 23 ++++++++++++----------- Components/ChatInput.svelte | 4 +--- Services/ChatService.ts | 5 ++--- __tests__/AIClasses/Claude.test.ts | 23 +++++++++++++---------- __tests__/AIClasses/Gemini.test.ts | 23 +++++++++++++---------- __tests__/AIClasses/OpenAI.test.ts | 22 ++++++++++++---------- 8 files changed, 72 insertions(+), 63 deletions(-) diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index baa3647..f104755 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -11,6 +11,7 @@ import type AIAgentPlugin from "main"; import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; import { isValidJson } from "Helpers/Helpers"; import type { ConversationContent } from "Conversations/ConversationContent"; +import { Role } from "Enums/Role"; export class Claude implements IAIClass { @@ -153,11 +154,12 @@ export class Claude implements IAIClass { return conversationContent.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") .map(content => { const contentBlocks: any[] = []; + const contentToExtract = content.role == Role.User ? content.promptContent : content.content; - if (content.content.trim() !== "" && !content.isFunctionCallResponse) { + if (contentToExtract.trim() !== "" && !content.isFunctionCallResponse) { contentBlocks.push({ type: "text", - text: content.content + text: contentToExtract }); } @@ -175,7 +177,7 @@ export class Claude implements IAIClass { } catch (error) { console.error("Failed to parse function call:", error); // Fall back to treating as text - if (content.content.trim() === "") { + if (contentToExtract.trim() === "") { contentBlocks.push({ type: "text", text: "Error parsing function call" @@ -185,7 +187,7 @@ export class Claude implements IAIClass { } else { console.error("Invalid JSON in functionCall field"); // Fall back to treating as text - if (content.content.trim() === "") { + if (contentToExtract.trim() === "") { contentBlocks.push({ type: "text", text: "Error parsing function call" @@ -195,10 +197,10 @@ export class Claude implements IAIClass { } // Add function response if present - if (content.isFunctionCallResponse && content.content.trim() !== "") { - if (isValidJson(content.content)) { + if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { + if (isValidJson(contentToExtract)) { try { - const parsedContent = JSON.parse(content.content); + const parsedContent = JSON.parse(contentToExtract); contentBlocks.push({ type: "tool_result", tool_use_id: parsedContent.id, @@ -208,14 +210,14 @@ export class Claude implements IAIClass { console.error("Failed to parse function response:", error); contentBlocks.push({ type: "text", - text: content.content + text: contentToExtract }); } } else { console.error("Invalid JSON in function response content"); contentBlocks.push({ type: "text", - text: content.content + text: contentToExtract }); } } diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 7f92f66..ec81c39 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -159,29 +159,30 @@ export class Gemini implements IAIClass { return conversationContent.filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") .map(content => { const parts: any[] = []; + const contentToExtract = content.role == Role.User ? content.promptContent : content.content; - if (content.content.trim() !== "") { + if (contentToExtract.trim() !== "") { if (content.isFunctionCallResponse) { - if (isValidJson(content.content)) { + if (isValidJson(contentToExtract)) { try { - const parsedContent = JSON.parse(content.content); + const parsedContent = JSON.parse(contentToExtract); if (parsedContent.functionResponse) { parts.push({ functionResponse: parsedContent.functionResponse }); } else { - parts.push({ text: content.content }); + parts.push({ text: contentToExtract }); } } catch (error) { console.error("Failed to parse function response:", error); - parts.push({ text: content.content }); + parts.push({ text: contentToExtract }); } } else { console.error("Invalid JSON in function response content"); - parts.push({ text: content.content }); + parts.push({ text: contentToExtract }); } } else { - parts.push({ text: content.content }); + parts.push({ text: contentToExtract }); } } diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 79e3bf0..ed6cd3d 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -52,8 +52,9 @@ export class OpenAI implements IAIClass { content: systemPrompt }, ...conversation.contents - .filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") - .map(content => { + .filter(content => content.content.trim() !== "" || content.functionCall.trim() !== "") + .map(content => { + const contentToExtract = content.role == Role.User ? content.promptContent : content.content; // Handle function call if (content.isFunctionCall && content.functionCall.trim() !== "") { if (isValidJson(content.functionCall)) { @@ -61,7 +62,7 @@ export class OpenAI implements IAIClass { const parsedContent = JSON.parse(content.functionCall); return { role: content.role, - content: content.content.trim() !== "" ? content.content : null, + content: contentToExtract.trim() !== "" ? contentToExtract : null, tool_calls: [ { id: parsedContent.functionCall.id, @@ -78,23 +79,23 @@ export class OpenAI implements IAIClass { // Fall back to regular message return { role: content.role, - content: content.content || "Error parsing function call" + content: contentToExtract || "Error parsing function call" }; } } else { console.error("Invalid JSON in functionCall field"); return { role: content.role, - content: content.content || "Error parsing function call" + content: contentToExtract || "Error parsing function call" }; } } // Handle function response - if (content.isFunctionCallResponse && content.content.trim() !== "") { - if (isValidJson(content.content)) { + if (content.isFunctionCallResponse && contentToExtract.trim() !== "") { + if (isValidJson(contentToExtract)) { try { - const parsedContent = JSON.parse(content.content); + const parsedContent = JSON.parse(contentToExtract); return { role: "tool", tool_call_id: parsedContent.id, @@ -105,14 +106,14 @@ export class OpenAI implements IAIClass { // Fall back to regular message return { role: content.role, - content: content.content + content: contentToExtract }; } } else { console.error("Invalid JSON in function response content"); return { role: content.role, - content: content.content + content: contentToExtract }; } } @@ -120,7 +121,7 @@ export class OpenAI implements IAIClass { // Regular text message return { role: content.role, - content: content.content + content: contentToExtract }; }) ]; diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index f02a7bc..713a0a5 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -9,7 +9,6 @@ import ChatSearchResults from "./ChatSearchResults.svelte"; import type { Writable } from "svelte/store"; import type { InputService } from "Services/InputService"; - import { textAreaNode } from "happy-dom/lib/PropertySymbol"; export let hasNoApiKey: boolean; export let isSubmitting: boolean; @@ -88,7 +87,7 @@ } inputService.deleteTextRange(position - 1, position, textareaElement); - + return; } @@ -131,7 +130,6 @@ inputService.deleteTextRange($searchState.position, inputService.getCursorPosition(textareaElement), textareaElement); inputService.insertElementAtCursor(node, textareaElement); - inputService.insertTextAtCursor(" ", textareaElement); } searchStateStore.resetSearch(); return; diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 085a51e..58d3637 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -121,7 +121,7 @@ export class ChatService { const inputMessages = conversation.contents .filter(message => message.role === Role.User && !message.isFunctionCallResponse) - .map(message => message.content) + .map(message => message.promptContent) .join("\n"); const outputMessages = conversation.contents @@ -143,8 +143,7 @@ export class ChatService { private async streamRequestResponse( conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks ): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> { - // this should never happen - if (!this.ai) { + if (!this.ai) { // this should never happen return { functionCall: null, shouldContinue: false };; } diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index d0793d3..d803cd7 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -255,7 +255,7 @@ describe('Claude', () => { describe('extractContents', () => { it('should convert simple text content to Claude message format', () => { const contents = [ - new ConversationContent(Role.User, 'Hello'), + new ConversationContent(Role.User, 'Hello', 'Hello'), // content, promptContent new ConversationContent(Role.Assistant, 'Hi there') ]; @@ -301,14 +301,16 @@ describe('Claude', () => { }); it('should convert function response to tool_result format', () => { + const responseContent = JSON.stringify({ + id: 'call_123', + functionResponse: { + response: ['file1.txt', 'file2.txt'] + } + }); const functionResponseContent = new ConversationContent( Role.User, - JSON.stringify({ - id: 'call_123', - functionResponse: { - response: ['file1.txt', 'file2.txt'] - } - }) + responseContent, + responseContent // promptContent should also be set for User role ); functionResponseContent.isFunctionCallResponse = true; @@ -351,7 +353,8 @@ describe('Claude', () => { const invalidContent = new ConversationContent( Role.User, - 'invalid json {' + 'invalid json {', + 'invalid json {' // promptContent for User role ); invalidContent.isFunctionCallResponse = true; @@ -369,9 +372,9 @@ describe('Claude', () => { it('should filter out empty content', () => { const contents = [ - new ConversationContent(Role.User, 'Hello'), + new ConversationContent(Role.User, 'Hello', 'Hello'), new ConversationContent(Role.Assistant, ''), // Empty - new ConversationContent(Role.User, 'World') + new ConversationContent(Role.User, 'World', 'World') ]; const result = (claude as any).extractContents(contents); diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index 15db36d..556d7b1 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -309,7 +309,7 @@ describe('Gemini', () => { it('should format system instruction as parts array', async () => { const conversation = new Conversation(); - conversation.contents.push(new ConversationContent(Role.User, 'Test')); + conversation.contents.push(new ConversationContent(Role.User, 'Test', 'Test')); mockStreamingService.streamRequest.mockImplementation(async function* () { yield { content: 'done', isComplete: true }; @@ -357,14 +357,16 @@ describe('Gemini', () => { }); it('should convert function response to Gemini format', () => { + const responseContent = JSON.stringify({ + functionResponse: { + name: 'search_files', + response: ['file1.txt', 'file2.txt'] + } + }); const functionResponseContent = new ConversationContent( Role.User, - JSON.stringify({ - functionResponse: { - name: 'search_files', - response: ['file1.txt', 'file2.txt'] - } - }) + responseContent, + responseContent // promptContent for User role ); functionResponseContent.isFunctionCallResponse = true; @@ -407,7 +409,8 @@ describe('Gemini', () => { const invalidContent = new ConversationContent( Role.User, - 'invalid json {' + 'invalid json {', + 'invalid json {' // promptContent for User role ); invalidContent.isFunctionCallResponse = true; @@ -424,9 +427,9 @@ describe('Gemini', () => { it('should filter out empty content', () => { const contents = [ - new ConversationContent(Role.User, 'Hello'), + new ConversationContent(Role.User, 'Hello', 'Hello'), new ConversationContent(Role.Assistant, ''), // Empty - new ConversationContent(Role.User, 'World') + new ConversationContent(Role.User, 'World', 'World') ]; const result = (gemini as any).extractContents(contents); diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 1f70ea3..e1588b9 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -358,14 +358,16 @@ describe('OpenAI', () => { it('should convert function response to role:tool format', async () => { const conversation = new Conversation(); + const responseContent = JSON.stringify({ + id: 'call_123', + functionResponse: { + response: ['file1.txt', 'file2.txt'] + } + }); const functionResponseContent = new ConversationContent( Role.User, - JSON.stringify({ - id: 'call_123', - functionResponse: { - response: ['file1.txt', 'file2.txt'] - } - }) + responseContent, + responseContent // promptContent for User role ); functionResponseContent.isFunctionCallResponse = true; conversation.contents.push(functionResponseContent); @@ -425,7 +427,7 @@ describe('OpenAI', () => { const invalidContent = new ConversationContent( Role.User, 'invalid json {', - false, + 'invalid json {', // promptContent for User role '' ); invalidContent.isFunctionCallResponse = true; @@ -451,9 +453,9 @@ describe('OpenAI', () => { it('should filter out empty content', async () => { const conversation = new Conversation(); - conversation.contents.push(new ConversationContent(Role.User, 'Hello')); - conversation.contents.push(new ConversationContent(Role.Assistant, '', false, '')); - conversation.contents.push(new ConversationContent(Role.User, 'World')); + conversation.contents.push(new ConversationContent(Role.User, 'Hello', 'Hello')); + conversation.contents.push(new ConversationContent(Role.Assistant, '', '', '')); + conversation.contents.push(new ConversationContent(Role.User, 'World', 'World')); mockStreamingService.streamRequest.mockImplementation(async function* () { yield { content: 'done', isComplete: true };