mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
d0be254c5f
commit
38c9809a27
11 changed files with 153 additions and 7 deletions
51
AIClasses/Gemini/GeminiConversationNamingService.ts
Normal file
51
AIClasses/Gemini/GeminiConversationNamingService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
AIClasses/IConversationNamingService.ts
Normal file
3
AIClasses/IConversationNamingService.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export interface IConversationNamingService {
|
||||
generateName(userPrompt: string, abortSignal?: AbortSignal): Promise<string>;
|
||||
}
|
||||
8
AIClasses/NamePrompt.ts
Normal file
8
AIClasses/NamePrompt.ts
Normal 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`
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
53
Services/ConversationNamingService.ts
Normal file
53
Services/ConversationNamingService.ts
Normal 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(" ");
|
||||
}
|
||||
}
|
||||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue