mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
eac7ac13fb
commit
afdfa3021b
17 changed files with 513 additions and 169 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
258
AIClasses/OpenAI/OpenAI.ts
Normal 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
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue