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
This commit is contained in:
Andrew Beal 2025-10-20 08:30:08 +01:00
parent eac7ac13fb
commit afdfa3021b
17 changed files with 513 additions and 169 deletions

View file

@ -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,

View file

@ -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();

View file

@ -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,

View file

@ -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();

View file

@ -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<StreamChunk, void, unknown>;
}

258
AIClasses/OpenAI/OpenAI.ts Normal file
View file

@ -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<IPrompt>(Services.IPrompt);
private readonly plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
private readonly streamingService: StreamingService = Resolve<StreamingService>(Services.StreamingService);
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
// OpenAI can have multiple tool calls, so we track them by index
private accumulatedToolCalls: Map<number, ToolCallAccumulator> = new Map();
public constructor() {
this.apiKey = this.plugin.settings.apiKey;
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean, abortSignal?: AbortSignal
): AsyncGenerator<StreamChunk, void, unknown> {
// 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
}
}));
}
}

View file

@ -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();

View file

@ -310,7 +310,7 @@
</script>
<div class="chat-area" bind:this={chatContainer}>
{#each messages as message (message.timestamp.getTime())}
{#each messages as message}
{#if !message.isFunctionCall && !message.isFunctionCallResponse && message.content}
<div class="message-container {message.role === Role.User ? Role.User : Role.Assistant}" use:messageContainerAction>
<div class="message-bubble {message.role === Role.User ? Role.User : Role.Assistant}">

View file

@ -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;
}
</script>
@ -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;

View file

@ -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;
}
}
}

View file

@ -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"
);
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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<ITokenService>(Services.ITokenService);
}
public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise<Conversation> {
public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise<void> {
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 };

View file

@ -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);
}

View file

@ -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<AIAgentPlugin>(Services.AIAgentPlugin, plugin);
@ -61,7 +63,9 @@ export function RegisterAiProvider(plugin: AIAgentPlugin) {
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new GeminiConversationNamingService());
}
else if (plugin.settings.apiProvider == AIProvider.OpenAI) {
RegisterSingleton<IAIClass>(Services.IAIClass, new OpenAI());
RegisterSingleton<ITokenService>(Services.ITokenService, new OpenAITokenService());
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new OpenAIConversationNamingService());
}
else { // should be impossible to land here
throw new Error("Invalid Provider Selection!");

View file

@ -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();