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