mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
Introduce a new AbortService to centralize cancellation logic across all async operations, replacing scattered AbortSignal parameters with a unified singleton service. This improves maintainability and provides consistent cancellation behavior throughout the application. Key changes: - Add AbortService for centralized abort signal management with automatic cleanup - Refactor all AI providers (Claude, Gemini, OpenAI) to use AbortService instead of passing AbortSignal parameters - Update streaming operations to use centralized abort handling - Add CancellationIndicator component to show visual feedback during operation cancellation - Rename ChatAreaThought to ThoughtIndicator for better semantic clarity - Add Environment enum for consistent environment detection - Enhance ChatService lifecycle with proper cancellation state management - Remove scattered abort-related UI selectors and error messages in favor of dedicated indicator - Add safeContinue() factory method to ConversationContent for internal continuations - Update all tests to reflect new abort handling architecture This change simplifies the API surface by removing AbortSignal parameters from method signatures while improving the user experience with clearer cancellation feedback.
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
import { Semaphore } from "Helpers/Semaphore";
|
|
import { Resolve } from "./DependencyService";
|
|
import { Services } from "./Services";
|
|
import type { IAIClass } from "AIClasses/IAIClass";
|
|
import type { ConversationFileSystemService } from "./ConversationFileSystemService";
|
|
import type { AIFunctionService } from "./AIFunctionService";
|
|
import type { ConversationNamingService } from "./ConversationNamingService";
|
|
import type { ITokenService } from "AIClasses/ITokenService";
|
|
import type { IPrompt } from "AIClasses/IPrompt";
|
|
import type { StatusBarService } from "./StatusBarService";
|
|
import { Conversation } from "Conversations/Conversation";
|
|
import { ConversationContent } from "Conversations/ConversationContent";
|
|
import { Role } from "Enums/Role";
|
|
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
|
|
import { Notice } from "obsidian";
|
|
import type { EventService } from "./EventService";
|
|
import { Event } from "Enums/Event";
|
|
import { AbortService } from "./AbortService";
|
|
|
|
export interface IChatServiceCallbacks {
|
|
onSubmit: () => void;
|
|
onStreamingUpdate: (streamingMessageId: string | null) => void;
|
|
onThoughtUpdate: (thought: string | null) => void;
|
|
onComplete: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export class ChatService {
|
|
private ai: IAIClass | undefined;
|
|
private conversationService: ConversationFileSystemService;
|
|
private aiFunctionService: AIFunctionService;
|
|
private namingService: ConversationNamingService;
|
|
private tokenService: ITokenService | undefined;
|
|
private prompt: IPrompt;
|
|
private statusBarService: StatusBarService;
|
|
private eventService: EventService;
|
|
private abortService: AbortService;
|
|
|
|
private semaphore: Semaphore;
|
|
private semaphoreHeld: boolean = false;
|
|
|
|
constructor() {
|
|
this.conversationService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
|
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
|
|
this.namingService = Resolve<ConversationNamingService>(Services.ConversationNamingService);
|
|
this.prompt = Resolve<IPrompt>(Services.IPrompt);
|
|
this.statusBarService = Resolve<StatusBarService>(Services.StatusBarService);
|
|
this.eventService = Resolve<EventService>(Services.EventService);
|
|
this.abortService = Resolve<AbortService>(Services.AbortService);
|
|
this.semaphore = new Semaphore(1, false);
|
|
}
|
|
|
|
public onNameChanged: ((name: string) => void) | undefined = undefined;
|
|
|
|
public resolveAIProvider() {
|
|
this.ai = Resolve<IAIClass>(Services.IAIClass);
|
|
this.tokenService = Resolve<ITokenService>(Services.ITokenService);
|
|
}
|
|
|
|
public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks) {
|
|
if (!await this.semaphore.wait()) {
|
|
return;
|
|
}
|
|
|
|
this.semaphoreHeld = true;
|
|
|
|
try {
|
|
if (userRequest.trim() === "") {
|
|
return;
|
|
}
|
|
|
|
this.abortService.initialiseAbortController();
|
|
|
|
await this.abortService.abortableOperation(async () => {
|
|
conversation.contents.push(new ConversationContent(Role.User, userRequest, formattedRequest));
|
|
await this.saveConversation(conversation);
|
|
|
|
callbacks.onSubmit();
|
|
callbacks.onStreamingUpdate(null);
|
|
|
|
if (conversation.contents.length === 1) {
|
|
this.onNameChanged?.(conversation.title); // on change for initial conversation name
|
|
await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged);
|
|
}
|
|
|
|
// Process AI responses and function calls
|
|
let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks);
|
|
while (response.functionCall || response.shouldContinue) {
|
|
if (response.functionCall) {
|
|
const userMessage = response.functionCall.arguments.user_message;
|
|
if (userMessage && typeof userMessage === "string") {
|
|
callbacks.onThoughtUpdate(userMessage);
|
|
}
|
|
|
|
const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall);
|
|
|
|
const functionResponseString = functionResponse.toConversationString();
|
|
conversation.contents.push(new ConversationContent(
|
|
Role.User, functionResponseString, functionResponseString, "", new Date(), false, true, functionResponse.toolId
|
|
));
|
|
}
|
|
|
|
this.ensureCorrectConversationStructure(conversation);
|
|
response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (AbortService.isAbortError(error)) {
|
|
callbacks.onCancel();
|
|
}
|
|
} finally {
|
|
// reset "Cancelling..." flag this needs to get to window (window may actually handle this itself)
|
|
this.eventService.trigger(Event.DiffClosed);
|
|
await this.saveConversation(conversation);
|
|
if (this.semaphoreHeld) {
|
|
this.semaphoreHeld = false;
|
|
this.semaphore.release();
|
|
}
|
|
callbacks.onThoughtUpdate(null);
|
|
callbacks.onComplete();
|
|
}
|
|
}
|
|
|
|
public stop() {
|
|
this.abortService.abort("User requested cancellation");
|
|
this.eventService.trigger(Event.DiffClosed);
|
|
}
|
|
|
|
public async updateTokenDisplay(conversation: Conversation) {
|
|
if (this.tokenService === undefined) {
|
|
return;
|
|
}
|
|
|
|
const systemInstruction = this.prompt.systemInstruction();
|
|
const userInstruction = await this.prompt.userInstruction();
|
|
|
|
const inputMessages = conversation.contents
|
|
.filter(message => message.role === Role.User && !message.isFunctionCallResponse)
|
|
.map(message => message.promptContent)
|
|
.join("\n");
|
|
|
|
const outputMessages = conversation.contents
|
|
.filter(message => message.role === Role.Assistant && !message.isFunctionCall)
|
|
.map(message => message.content)
|
|
.join("\n");
|
|
|
|
const inputText = systemInstruction + "\n" + userInstruction + "\n" + inputMessages;
|
|
const inputTokens = await this.tokenService.countTokens(inputText);
|
|
const outputTokens = await this.tokenService.countTokens(outputMessages);
|
|
|
|
this.setStatusBarTokens(inputTokens, outputTokens);
|
|
}
|
|
|
|
public setStatusBarTokens(inputTokens: number, outputTokens: number) {
|
|
this.statusBarService.animateTokens(inputTokens, outputTokens);
|
|
}
|
|
|
|
private async saveConversation(conversation: Conversation) {
|
|
const result = await this.conversationService.saveConversation(conversation);
|
|
if (result instanceof Error) {
|
|
new Notice(`Failed to save conversation data for '${conversation.title}'`);
|
|
}
|
|
}
|
|
|
|
private ensureCorrectConversationStructure(conversation: Conversation) {
|
|
// Check if the last message is from the assistant to prevent assistant-to-assistant structure
|
|
// This can happen when the assistant's last message had no function call and the user sends a new request
|
|
if (conversation.contents.length > 0) {
|
|
const lastMessage = conversation.contents[conversation.contents.length - 1];
|
|
if (lastMessage.role === Role.Assistant) {
|
|
// Insert a hidden "Continue" message to maintain proper conversation structure
|
|
conversation.contents.push(ConversationContent.safeContinue());
|
|
}
|
|
}
|
|
}
|
|
|
|
private async streamRequestResponse(
|
|
conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks
|
|
): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> {
|
|
if (!this.ai) { // this should never happen
|
|
return { functionCall: null, shouldContinue: false };;
|
|
}
|
|
|
|
const aiMessage = new ConversationContent(Role.Assistant);
|
|
conversation.contents.push(aiMessage);
|
|
callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString());
|
|
|
|
let accumulatedContent = "";
|
|
let capturedFunctionCall: AIFunctionCall | null = null;
|
|
let capturedShouldContinue = false;
|
|
|
|
for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions)) {
|
|
if (chunk.error && chunk.errorType) {
|
|
conversation.setMostRecentError(chunk.error, chunk.errorType);
|
|
callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString());
|
|
break;
|
|
}
|
|
|
|
if (chunk.functionCall) {
|
|
capturedFunctionCall = chunk.functionCall;
|
|
}
|
|
|
|
if (chunk.shouldContinue) {
|
|
capturedShouldContinue = true;
|
|
}
|
|
|
|
if (chunk.content) {
|
|
accumulatedContent += chunk.content;
|
|
|
|
conversation.setMostRecentContent(accumulatedContent);
|
|
if (accumulatedContent.trim() !== "") {
|
|
callbacks.onThoughtUpdate(null);
|
|
}
|
|
}
|
|
|
|
if (chunk.isComplete) {
|
|
const sanitizedContent = this.sanitizeFunctionCallContent(accumulatedContent, capturedFunctionCall);
|
|
|
|
if (sanitizedContent.trim() === "" && !capturedFunctionCall) {
|
|
conversation.contents.pop();
|
|
} else {
|
|
conversation.setMostRecentContent(sanitizedContent);
|
|
if (capturedFunctionCall) {
|
|
conversation.setMostRecentFunctionCall(capturedFunctionCall?.toConversationString());
|
|
}
|
|
}
|
|
}
|
|
callbacks.onStreamingUpdate(aiMessage.timestamp.getTime().toString());
|
|
}
|
|
|
|
callbacks.onStreamingUpdate(null);
|
|
|
|
return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue };
|
|
}
|
|
|
|
// handle the rare event where a function call is also included in content (gemini sometimes does this)
|
|
private sanitizeFunctionCallContent(content: string, functionCall: AIFunctionCall | null): string {
|
|
// Early returns for simple cases
|
|
if (!functionCall || !content.trim()) {
|
|
return content;
|
|
}
|
|
|
|
// If content has no JSON-like characters, return as-is
|
|
if (!content.includes('{') || !content.includes('}')) {
|
|
return content;
|
|
}
|
|
|
|
const functionCallString = functionCall.toConversationString();
|
|
let sanitized = content;
|
|
|
|
// Step 1: Remove markdown code blocks that might contain the function call
|
|
// Pattern matches ```json\n...\n``` or ```\n...\n```
|
|
sanitized = sanitized.replace(/```(?:json)?\s*\n?([\s\S]*?)\n?```/g, (match: string, codeContent: string) => {
|
|
// If the code block contains our function call, remove it entirely
|
|
if (codeContent.trim() === functionCallString.trim()) {
|
|
return '';
|
|
}
|
|
// Otherwise keep the code block
|
|
return match;
|
|
});
|
|
|
|
// Step 2: Remove exact JSON match (handles compact JSON)
|
|
sanitized = sanitized.replace(functionCallString, '').trim();
|
|
|
|
// Step 3: Handle pretty-printed variations by normalizing both strings
|
|
try {
|
|
const functionCallObj: unknown = JSON.parse(functionCallString);
|
|
const normalizedTarget = JSON.stringify(functionCallObj);
|
|
|
|
// Find and remove any JSON that matches when normalized
|
|
// This regex finds JSON objects/arrays in the text
|
|
const jsonPattern = /\{(?:[^{}]|(?:\{(?:[^{}]|(?:\{[^{}]*\}))*\}))*\}|\[(?:[^[\]]|(?:\[(?:[^[\]]|(?:\[[^[\]]*\]))*\]))*\]/g;
|
|
|
|
sanitized = sanitized.replace(jsonPattern, (match) => {
|
|
try {
|
|
const parsedMatch: unknown = JSON.parse(match);
|
|
const normalizedMatch = JSON.stringify(parsedMatch);
|
|
// Remove if it matches our function call when normalized
|
|
return normalizedMatch === normalizedTarget ? '' : match;
|
|
} catch {
|
|
// If it's not valid JSON, keep it
|
|
return match;
|
|
}
|
|
});
|
|
} catch {
|
|
// If function call string isn't valid JSON, we've done what we can
|
|
}
|
|
|
|
// Step 4: Clean up multiple consecutive whitespace/newlines left by removals
|
|
sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim();
|
|
|
|
return sanitized;
|
|
}
|
|
}
|