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.
This commit is contained in:
Andrew Beal 2025-10-17 16:18:23 +01:00
parent d0be254c5f
commit 38c9809a27
11 changed files with 153 additions and 7 deletions

View file

@ -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<AIAgentPlugin>(Services.AIAgentPlugin);
public constructor() {
this.apiKey = this.plugin.settings.apiKey;
}
public async generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string> {
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;
}
}

View file

@ -0,0 +1,3 @@
export interface IConversationNamingService {
generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string>;
}

8
AIClasses/NamePrompt.ts Normal file
View file

@ -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`

View file

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

View file

@ -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<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
this.namingService = Resolve<ConversationNamingService>(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) {

View file

@ -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<Conversation[]> {
@ -90,4 +90,18 @@ export class ConversationFileSystemService {
return conversations;
}
public async updateConversationTitle(oldPath: string, newTitle: string): Promise<void> {
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;
}
}
}

View file

@ -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<ConversationFileSystemService>(Services.ConversationFileSystemService);
}
public resolveNamingProvider() {
this.namingProvider = Resolve<IConversationNamingService>(Services.IConversationNamingService);
}
public async requestName(conversation: Conversation, userPrompt: string, abortController: AbortController): Promise<void> {
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(" ");
}
}

View file

@ -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<TFile[]> {

View file

@ -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<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
RegisterSingleton<IPrompt>(Services.IPrompt, new AIPrompt());
RegisterSingleton<AIFunctionDefinitions>(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<IAIClass>(Services.IAIClass, new Gemini());
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new GeminiConversationNamingService());
}
Resolve<ChatService>(Services.ChatService).resolveAIProvider();
Resolve<ConversationNamingService>(Services.ConversationNamingService).resolveNamingProvider();
}
function RegisterModals(app: App) {

View file

@ -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");

View file

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