andy-stack_vaultkeeper-ai/Services/ChatService.ts
Andrew Beal 2fc9edc194 refactor: move typing-in animation to global styles and add conversation title to top bar
- Extract typing-in animation from ChatArea to global styles.css for reuse
- Add conversation title display in TopBar with fade transition
- Update ChatService to notify title changes via onNameChanged callback
- Fix ConversationNamingService to trigger callback after saving new title
- Clean up unused isAborting flag in ChatService
- Adjust grid layout in TopBar to accommodate title display
2025-10-17 19:34:47 +01:00

181 lines
6.5 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 { Conversation } from "Conversations/Conversation";
import { ConversationContent } from "Conversations/ConversationContent";
import { Role } from "Enums/Role";
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
export interface ChatServiceCallbacks {
onStreamingUpdate: (conversation: Conversation, streamingMessageId: string | null, isStreaming: boolean) => void;
onThoughtUpdate: (thought: string | null) => void;
onComplete: () => void;
}
export class ChatService {
private ai: IAIClass | undefined;
private conversationService: ConversationFileSystemService;
private aiFunctionService: AIFunctionService;
private namingService: ConversationNamingService;
private semaphore: Semaphore;
private semaphoreHeld: boolean = false;
private abortController: AbortController | null = null;
constructor() {
this.conversationService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
this.namingService = Resolve<ConversationNamingService>(Services.ConversationNamingService);
this.semaphore = new Semaphore(1, false);
}
public onNameChanged: ((name: string) => void) | undefined = undefined;
resolveAIProvider() {
this.ai = Resolve<IAIClass>(Services.IAIClass);
}
async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, callbacks: ChatServiceCallbacks): Promise<Conversation> {
if (!await this.semaphore.wait()) {
return conversation;
}
this.semaphoreHeld = true;
try {
if (userRequest.trim() === "") {
return conversation;
}
this.abortController = new AbortController();
// Add user message to conversation
conversation.contents = [...conversation.contents, new ConversationContent(Role.User, userRequest)];
await this.conversationService.saveConversation(conversation);
if (conversation.contents.length === 1) {
this.onNameChanged?.(conversation.title); // on change for initial conversation name
this.namingService.requestName(conversation, userRequest, this.onNameChanged, this.abortController);
}
// Process AI responses and function calls
let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks);
while (response.functionCall || response.shouldContinue) {
if (response.functionCall) {
if ('user_message' in response.functionCall.arguments) {
callbacks.onThoughtUpdate(response.functionCall.arguments.user_message);
}
conversation.contents = [...conversation.contents, new ConversationContent(
Role.Assistant, response.functionCall.toConversationString(), new Date(), true)];
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)];
await this.conversationService.saveConversation(conversation);
}
response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks);
}
return conversation;
} finally {
callbacks.onThoughtUpdate(null);
this.abortController = null;
if (this.semaphoreHeld) {
this.semaphoreHeld = false;
this.semaphore.release();
}
callbacks.onComplete();
}
}
stop(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.semaphore.release();
}
private async streamRequestResponse(
conversation: Conversation, allowDestructiveActions: boolean, callbacks: ChatServiceCallbacks
): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> {
// this should never happen
if (!this.ai) {
return { functionCall: null, shouldContinue: false };;
}
// Create AI message with stable ID
const aiMessage = new ConversationContent(Role.Assistant, "");
conversation.contents = [...conversation.contents, aiMessage];
// Notify that streaming has started
callbacks.onStreamingUpdate(conversation, aiMessage.id, true);
let accumulatedContent = "";
let capturedFunctionCall: AIFunctionCall | null = null;
let capturedShouldContinue = false;
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.id === aiMessage.id
? { ...msg, content: "Error: " + chunk.error }
: msg
);
callbacks.onStreamingUpdate(conversation, null, false);
await this.conversationService.saveConversation(conversation);
break;
}
if (chunk.content) {
callbacks.onThoughtUpdate(null);
accumulatedContent += chunk.content;
conversation.contents = conversation.contents.map((msg) =>
msg.id === aiMessage.id
? { ...msg, content: accumulatedContent }
: msg
);
callbacks.onStreamingUpdate(conversation, aiMessage.id, true);
}
if (chunk.functionCall) {
capturedFunctionCall = chunk.functionCall;
}
if (chunk.shouldContinue) {
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.id === aiMessage.id
? { ...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.id !== aiMessage.id);
} else {
// No content and no function call - remove empty message
conversation.contents = conversation.contents.filter((msg) => msg.id !== aiMessage.id);
}
await this.conversationService.saveConversation(conversation);
}
}
return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue };
}
}