From 38c9809a279bdcb08e9dd6352c63bfc181e78205 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Fri, 17 Oct 2025 16:18:23 +0100 Subject: [PATCH] feat: add AI-powered conversation naming with Gemini integration Implement automatic conversation title generation using Gemini API. Add IConversationNamingService interface with GeminiConversationNamingService implementation. Integrate naming service into ChatService to generate titles on first user message. Update ConversationFileSystemService to support title updates via file renaming. --- .../Gemini/GeminiConversationNamingService.ts | 51 ++++++++++++++++++ AIClasses/IConversationNamingService.ts | 3 ++ AIClasses/NamePrompt.ts | 8 +++ Enums/ApiProvider.ts | 3 +- Services/ChatService.ts | 10 +++- Services/ConversationFileSystemService.ts | 18 ++++++- Services/ConversationNamingService.ts | 53 +++++++++++++++++++ Services/FileSystemService.ts | 4 +- Services/ServiceRegistration.ts | 6 +++ Services/Services.ts | 2 + Services/VaultService.ts | 2 +- 11 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 AIClasses/Gemini/GeminiConversationNamingService.ts create mode 100644 AIClasses/IConversationNamingService.ts create mode 100644 AIClasses/NamePrompt.ts create mode 100644 Services/ConversationNamingService.ts diff --git a/AIClasses/Gemini/GeminiConversationNamingService.ts b/AIClasses/Gemini/GeminiConversationNamingService.ts new file mode 100644 index 0000000..b574c96 --- /dev/null +++ b/AIClasses/Gemini/GeminiConversationNamingService.ts @@ -0,0 +1,51 @@ +import { Resolve } from "Services/DependencyService"; +import { Services } from "Services/Services"; +import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; +import type AIAgentPlugin from "main"; +import { AIProviderURL } from "Enums/ApiProvider"; +import { Role } from "Enums/Role"; +import { NamePrompt } from "AIClasses/NamePrompt"; + +export class GeminiConversationNamingService implements IConversationNamingService { + private readonly apiKey: string; + private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin); + + public constructor() { + this.apiKey = this.plugin.settings.apiKey; + } + + public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise { + + const requestBody = { + system_instruction: { + parts: [{ text: NamePrompt }] + }, + contents: [{ + role: Role.User, + parts: [{ text: userPrompt }] + }] + }; + + const response = await fetch(AIProviderURL.GeminiNamer.replace("API_KEY", this.apiKey), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: abortSignal + }); + + if (!response.ok) { + throw new Error(`Gemini API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const generatedName = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!generatedName) { + throw new Error("Failed to generate conversation name"); + } + + return generatedName; + } +} diff --git a/AIClasses/IConversationNamingService.ts b/AIClasses/IConversationNamingService.ts new file mode 100644 index 0000000..f10ea63 --- /dev/null +++ b/AIClasses/IConversationNamingService.ts @@ -0,0 +1,3 @@ +export interface IConversationNamingService { + generateName(userPrompt: string, abortSignal?: AbortSignal): Promise; +} diff --git a/AIClasses/NamePrompt.ts b/AIClasses/NamePrompt.ts new file mode 100644 index 0000000..e071e02 --- /dev/null +++ b/AIClasses/NamePrompt.ts @@ -0,0 +1,8 @@ +export const NamePrompt: string = `You are a conversation title generator. Given a user's first message, generate a concise, descriptive title (maximum 6 words) that captures the essence of their request. + + Rules: + - Maximum 6 words + - No quotes or special formatting + - Capitalize appropriately + - Be specific and descriptive + - Return ONLY the title, nothing else` \ No newline at end of file diff --git a/Enums/ApiProvider.ts b/Enums/ApiProvider.ts index 8590a59..418df29 100644 --- a/Enums/ApiProvider.ts +++ b/Enums/ApiProvider.ts @@ -5,5 +5,6 @@ export enum AIProvider { }; export enum AIProviderURL { - Gemini = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=API_KEY&alt=sse" + Gemini = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=API_KEY&alt=sse", + GeminiNamer = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=API_KEY&alt=sse" } \ No newline at end of file diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 1cde7bc..d6746b3 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -4,6 +4,7 @@ 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"; @@ -19,7 +20,8 @@ export class ChatService { private ai: IAIClass | undefined; private conversationService: ConversationFileSystemService; private aiFunctionService: AIFunctionService; - + private namingService: ConversationNamingService; + private abortController: AbortController | null = null; private isAborting: boolean = false; @@ -29,6 +31,7 @@ export class ChatService { constructor() { this.conversationService = Resolve(Services.ConversationFileSystemService); this.aiFunctionService = Resolve(Services.AIFunctionService); + this.namingService = Resolve(Services.ConversationNamingService); this.semaphore = new Semaphore(1, false); } @@ -54,6 +57,11 @@ export class ChatService { conversation.contents = [...conversation.contents, new ConversationContent(Role.User, userRequest)]; await this.conversationService.saveConversation(conversation); + // Request conversation name on first user message (fire-and-forget) + if (conversation.contents.length === 1) { + this.namingService.requestName(conversation, userRequest, this.abortController); + } + // Process AI responses and function calls let response = await this.streamRequestResponse(conversation, allowDestructiveActions, callbacks); while (response.functionCall || response.shouldContinue) { diff --git a/Services/ConversationFileSystemService.ts b/Services/ConversationFileSystemService.ts index cf3a1c6..ac3341f 100644 --- a/Services/ConversationFileSystemService.ts +++ b/Services/ConversationFileSystemService.ts @@ -62,11 +62,11 @@ export class ConversationFileSystemService { const deleted = await this.fileSystemService.deleteFile(this.currentConversationPath, true); - if (deleted) { + if (deleted.success) { this.resetCurrentConversation(); } - return deleted; + return deleted.success; } public async getAllConversations(): Promise { @@ -90,4 +90,18 @@ export class ConversationFileSystemService { return conversations; } + public async updateConversationTitle(oldPath: string, newTitle: string): Promise { + const newPath = `${Path.Conversations}/${newTitle}.json`; + + const result = await this.fileSystemService.moveFile(oldPath, newPath, true); + + if (!result.success) { + throw new Error(`Failed to update conversation title: ${result.error}`); + } + + if (this.currentConversationPath === oldPath) { + this.currentConversationPath = newPath; + } + } + } diff --git a/Services/ConversationNamingService.ts b/Services/ConversationNamingService.ts new file mode 100644 index 0000000..b78ba81 --- /dev/null +++ b/Services/ConversationNamingService.ts @@ -0,0 +1,53 @@ +import { Resolve } from "./DependencyService"; +import { Services } from "./Services"; +import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; +import type { ConversationFileSystemService } from "./ConversationFileSystemService"; +import type { Conversation } from "Conversations/Conversation"; + +export class ConversationNamingService { + private namingProvider: IConversationNamingService | undefined; + private conversationService: ConversationFileSystemService; + + constructor() { + this.conversationService = Resolve(Services.ConversationFileSystemService); + } + + public resolveNamingProvider() { + this.namingProvider = Resolve(Services.IConversationNamingService); + } + + public async requestName(conversation: Conversation, userPrompt: string, abortController: AbortController): Promise { + if (!this.namingProvider) { + return; + } + + const conversationPath = this.conversationService.getCurrentConversationPath(); + + if (!conversationPath) { + return; + } + + try { + const generatedName: string = await this.namingProvider.generateName(userPrompt, abortController.signal); + const validatedName: string = this.validateName(generatedName); + + const stillExists = this.conversationService.getCurrentConversationPath() === conversationPath; + if (!stillExists) { + return; + } + + await this.conversationService.updateConversationTitle(conversationPath, validatedName); + conversation.title = validatedName; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + console.error("Failed to generate name:", error); + } + } + + private validateName(generatedName: string): string { + const cleanedTitle = generatedName.trim().replace(/^["']|["']$/g, ""); + return cleanedTitle.split(/\s+/).slice(0, 6).join(" "); + } +} diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index aad1e6f..c060616 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -56,8 +56,8 @@ export class FileSystemService { return await this.vaultService.delete(file, undefined, allowAccessToPluginRoot); } - public async moveFile(sourcePath: string, destinationPath: string): Promise<{ success: true } | { success: false, error: string }> { - return await this.vaultService.move(sourcePath, destinationPath); + public async moveFile(sourcePath: string, destinationPath: string, allowAccessToPluginRoot: boolean = false): Promise<{ success: true } | { success: false, error: string }> { + return await this.vaultService.move(sourcePath, destinationPath, allowAccessToPluginRoot); } public async listFilesInDirectory(dirPath: string, recursive: boolean = true, allowAccessToPluginRoot: boolean = false): Promise { diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 9e271cf..0403ca6 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -4,7 +4,9 @@ import { RegisterSingleton, RegisterTransient, Resolve } from "./DependencyServi import { Services } from "./Services"; import { AIPrompt, type IPrompt } from "AIClasses/IPrompt"; import type { IAIClass } from "AIClasses/IAIClass"; +import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; import { Gemini } from "AIClasses/Gemini/Gemini"; +import { GeminiConversationNamingService } from "AIClasses/Gemini/GeminiConversationNamingService"; import { StreamingMarkdownService } from "./StreamingMarkdownService"; import { MessageService } from "./MessageService"; import { FileSystemService } from "./FileSystemService"; @@ -16,6 +18,7 @@ import { StreamingService } from "./StreamingService"; import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; import { WorkSpaceService } from "./WorkSpaceService"; import { ChatService } from "./ChatService"; +import { ConversationNamingService } from "./ConversationNamingService"; import { VaultService } from "./VaultService"; export function RegisterDependencies(plugin: AIAgentPlugin) { @@ -27,6 +30,7 @@ export function RegisterDependencies(plugin: AIAgentPlugin) { RegisterSingleton(Services.WorkSpaceService, new WorkSpaceService()); RegisterSingleton(Services.FileSystemService, new FileSystemService()); RegisterSingleton(Services.ConversationFileSystemService, new ConversationFileSystemService()); + RegisterSingleton(Services.ConversationNamingService, new ConversationNamingService()); RegisterSingleton(Services.IPrompt, new AIPrompt()); RegisterSingleton(Services.AIFunctionDefinitions, new AIFunctionDefinitions()); @@ -43,8 +47,10 @@ export function RegisterDependencies(plugin: AIAgentPlugin) { export function RegisterAiProvider(plugin: AIAgentPlugin) { if (plugin.settings.apiProvider == AIProvider.Gemini) { RegisterSingleton(Services.IAIClass, new Gemini()); + RegisterSingleton(Services.IConversationNamingService, new GeminiConversationNamingService()); } Resolve(Services.ChatService).resolveAIProvider(); + Resolve(Services.ConversationNamingService).resolveNamingProvider(); } function RegisterModals(app: App) { diff --git a/Services/Services.ts b/Services/Services.ts index 7b833d4..9b61875 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -6,6 +6,7 @@ export class Services { static WorkSpaceService = Symbol("WorkSpaceService"); static FileSystemService = Symbol("FileSystemService"); static ConversationFileSystemService = Symbol("ConversationFileSystemService"); + static ConversationNamingService = Symbol("ConversationNamingService"); static StreamingService = Symbol("StreamingService"); static MarkdownService = Symbol("MarkdownService"); static StreamingMarkdownService = Symbol("StreamingMarkdownService"); @@ -16,6 +17,7 @@ export class Services { // interfaces static IAIClass = Symbol("IAIClass"); static IPrompt = Symbol("IPrompt"); + static IConversationNamingService = Symbol("IConversationNamingService"); // modals static ConversationHistoryModal = Symbol("ConversationHistoryModal"); diff --git a/Services/VaultService.ts b/Services/VaultService.ts index 8ec9ade..0c6f4b6 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -78,7 +78,7 @@ export class VaultService { return { success: false, error: "Source file is in exclusion list" }; } - const file: TAbstractFile | null = this.getAbstractFileByPath(sourcePath); + const file: TAbstractFile | null = this.getAbstractFileByPath(sourcePath, allowAccessToPluginRoot); if (file === null) { return { success: false, error: "Source file not found" }; }