diff --git a/src/LLMProviders/chainRunner.ts b/src/LLMProviders/chainRunner.ts index 579b88c7..33c2c532 100644 --- a/src/LLMProviders/chainRunner.ts +++ b/src/LLMProviders/chainRunner.ts @@ -776,8 +776,8 @@ class ProjectChainRunner extends CopilotPlusChainRunner { return super.getSystemPrompt(); } - // Get cached context synchronously - const context = ProjectManager.instance.getProjectContext(projectConfig.id); + // Get context asynchronously + const context = await ProjectManager.instance.getProjectContext(projectConfig.id); let finalPrompt = projectConfig.systemPrompt; if (context) { diff --git a/src/LLMProviders/projectManager.ts b/src/LLMProviders/projectManager.ts index 2d0b95a9..f7032749 100644 --- a/src/LLMProviders/projectManager.ts +++ b/src/LLMProviders/projectManager.ts @@ -7,7 +7,7 @@ import { subscribeToModelKeyChange, subscribeToProjectChange, } from "@/aiParams"; -import { ProjectContextCache } from "@/cache/projectContextCache"; +import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache"; import { ChainType } from "@/chainFactory"; import { updateChatMemory } from "@/chatUtils"; import CopilotView from "@/components/CopilotView"; @@ -82,8 +82,8 @@ export default class ProjectManager { if (prevProject) { // Check if project configuration has changed if (JSON.stringify(prevProject) !== JSON.stringify(nextProject)) { - // Clear context cache for this project - await this.projectContextCache.clearForProject(nextProject); + // Compare project configuration changes and selectively update cache + await this.compareAndUpdateCache(prevProject, nextProject); // If this is the current project, reload its context and recreate chain if (this.currentProjectId === nextProject.id) { @@ -184,54 +184,103 @@ export default class ProjectManager { } // TODO(logan): This should be reused as a generic context loading function - private async loadProjectContext(project: ProjectConfig): Promise { + private async loadProjectContext(project: ProjectConfig): Promise { try { - if (project.contextSource) { - // Try to get context from cache first - const cachedContext = await this.projectContextCache.get(project); - if (cachedContext) { - return; - } + if (!project.contextSource) { + return null; + } - const [markdownContext, webContext, youtubeContext] = await Promise.all([ - this.processMarkdownContext( - project.contextSource.inclusions, - project.contextSource.exclusions - ), - this.processWebUrlsContext(project.contextSource.webUrls), - this.processYoutubeUrlsContext(project.contextSource.youtubeUrls), - ]); + const contextCache = (await this.projectContextCache.get(project)) || { + markdownContext: "", + webContexts: {}, + youtubeContexts: {}, + timestamp: Date.now(), + markdownNeedsReload: false, + }; + + const [updatedContextCache] = await Promise.all([ + this.processMarkdownFiles(project, contextCache), + this.processWebUrls(project, contextCache), + this.processYoutubeUrls(project, contextCache), + ]); - // Build context sections only for non-null sources - const contextParts = []; + updatedContextCache.timestamp = Date.now(); + await this.projectContextCache.set(project, updatedContextCache); + return updatedContextCache; + } catch (error) { + logError(`Failed to load project context: ${error}`); + throw error; + } + } - if (project.contextSource.inclusions || project.contextSource.exclusions) { - contextParts.push(`## Markdown Files\n${markdownContext}`); - } + private async compareAndUpdateCache(prevProject: ProjectConfig, nextProject: ProjectConfig) { + try { + const cache = await this.projectContextCache.get(prevProject); - if (project.contextSource.webUrls?.trim()) { - contextParts.push(`## Web Content\n${webContext}`); - } + // If no cache exists, return true to create a new cache later + if (!cache) { + return true; + } + + // Check if Markdown configuration has changed + const prevInclusions = prevProject.contextSource?.inclusions || ""; + const nextInclusions = nextProject.contextSource?.inclusions || ""; + const prevExclusions = prevProject.contextSource?.exclusions || ""; + const nextExclusions = nextProject.contextSource?.exclusions || ""; + + if (prevInclusions !== nextInclusions || prevExclusions !== nextExclusions) { + // Markdown config changed, clear markdown context and mark for reload + cache.markdownContext = ""; + cache.markdownNeedsReload = true; + logInfo( + `Markdown configuration changed for project ${nextProject.name}, marking for reload` + ); + } + + // Check if Web URLs configuration has changed + const prevWebUrls = prevProject.contextSource?.webUrls || ""; + const nextWebUrls = nextProject.contextSource?.webUrls || ""; - if (project.contextSource.youtubeUrls?.trim()) { - contextParts.push(`## YouTube Content\n${youtubeContext}`); + if (prevWebUrls !== nextWebUrls) { + // Find removed URLs + const prevUrls = prevWebUrls.split("\n").filter((url) => url.trim()); + const nextUrls = nextWebUrls.split("\n").filter((url) => url.trim()); + + // Remove context for URLs that no longer exist + for (const url of prevUrls) { + if (!nextUrls.includes(url) && cache.webContexts[url]) { + delete cache.webContexts[url]; + logInfo(`Removed web context for URL ${url} in project ${nextProject.name}`); + } } + } - const contextText = ` -# Project Context -The following information is the relevant context for this project. Use this information to inform your responses when appropriate: + // Check if YouTube URLs configuration has changed + const prevYoutubeUrls = prevProject.contextSource?.youtubeUrls || ""; + const nextYoutubeUrls = nextProject.contextSource?.youtubeUrls || ""; - -${contextParts.join("\n\n")} - -`; + if (prevYoutubeUrls !== nextYoutubeUrls) { + // Find removed URLs + const prevUrls = prevYoutubeUrls.split("\n").filter((url) => url.trim()); + const nextUrls = nextYoutubeUrls.split("\n").filter((url) => url.trim()); + + // Remove context for URLs that no longer exist + for (const url of prevUrls) { + if (!nextUrls.includes(url) && cache.youtubeContexts[url]) { + delete cache.youtubeContexts[url]; + logInfo(`Removed YouTube context for URL ${url} in project ${nextProject.name}`); + } + } + } - // Cache the generated context - await this.projectContextCache.set(project, contextText); + // Update cache if needed + if (cache.markdownNeedsReload) { + // Save updated cache with the new project ID + await this.projectContextCache.set(nextProject, cache); + logInfo(`Updated cache for project ${nextProject.name}`); } } catch (error) { - logError(`Failed to load project context: ${error}`); - throw error; + logError(`Error comparing project configurations: ${error}`); } } @@ -243,20 +292,125 @@ ${contextParts.join("\n\n")} } } - public getProjectContext(projectId: string): string | null { + public async getProjectContext(projectId: string): Promise { const project = getSettings().projectList.find((p) => p.id === projectId); if (!project) { return null; } - return this.projectContextCache.getSync(project); + + const contextCache = this.projectContextCache.getSync(project); + if (!contextCache) { + return null; + } + + if (contextCache.markdownNeedsReload) { + const updatedCache = await this.loadProjectContext(project); + if (!updatedCache) { + return null; + } + return this.formatProjectContext(updatedCache); + } + + return this.formatProjectContext(contextCache); } - public async clearContextCache(projectId: string): Promise { - const project = getSettings().projectList.find((p) => p.id === projectId); - if (project) { - await this.projectContextCache.clearForProject(project); - logInfo(`Context cache cleared for project: ${projectId}`); + private formatProjectContext(contextCache: ContextCache): string { + const contextParts = []; + + if (contextCache.markdownContext) { + contextParts.push(`## Markdown Files\n${contextCache.markdownContext}`); } + + if (Object.keys(contextCache.webContexts).length > 0) { + contextParts.push(`## Web Content\n${Object.values(contextCache.webContexts).join("\n\n")}`); + } + + if (Object.keys(contextCache.youtubeContexts).length > 0) { + contextParts.push( + `## YouTube Content\n${Object.values(contextCache.youtubeContexts).join("\n\n")}` + ); + } + + return ` +# Project Context +The following information is the relevant context for this project. Use this information to inform your responses when appropriate: + + +${contextParts.join("\n\n")} + +`; + } + + private async processMarkdownFiles( + project: ProjectConfig, + contextCache: ContextCache + ): Promise { + if (project.contextSource?.inclusions || project.contextSource?.exclusions) { + // Only process if needsReload is true or there is no existing content + if (contextCache.markdownNeedsReload || !contextCache.markdownContext.trim()) { + const markdownContext = await this.processMarkdownContext( + project.contextSource.inclusions, + project.contextSource.exclusions + ); + contextCache.markdownContext = markdownContext; + contextCache.markdownNeedsReload = false; // reset flag + } + } + return contextCache; + } + + private async processWebUrls( + project: ProjectConfig, + contextCache: ContextCache + ): Promise { + if (!project.contextSource?.webUrls?.trim()) { + return contextCache; + } + + const urls = project.contextSource.webUrls.split("\n").filter((url) => url.trim()); + const webContextPromises = urls.map(async (url) => { + if (!contextCache.webContexts[url]) { + const webContext = await this.processWebUrlsContext(url); + return { url, context: webContext }; + } + return null; + }); + + const results = await Promise.all(webContextPromises); + results.forEach((result) => { + if (result) { + contextCache.webContexts[result.url] = result.context; + } + }); + + return contextCache; + } + + private async processYoutubeUrls( + project: ProjectConfig, + contextCache: ContextCache + ): Promise { + if (!project.contextSource?.youtubeUrls?.trim()) { + return contextCache; + } + + const urls = project.contextSource.youtubeUrls.split("\n").filter((url) => url.trim()); + const youtubeContextPromises = urls.map(async (url) => { + if (!contextCache.youtubeContexts[url]) { + const youtubeContext = await this.processYoutubeUrlsContext(url); + return { url, context: youtubeContext }; + } + return null; + }); + + const results = await Promise.all(youtubeContextPromises); + results.forEach((result) => { + if (result) { + contextCache.youtubeContexts[result.url] = result.context; + } + }); + + return contextCache; } private async processMarkdownContext(inclusions?: string, exclusions?: string): Promise { @@ -336,4 +490,8 @@ ${content}`; const results = await Promise.all(processPromises); return results.join(""); } + + public onunload(): void { + this.projectContextCache.cleanup(); + } } diff --git a/src/cache/projectContextCache.ts b/src/cache/projectContextCache.ts index 071c2d2d..2b90c79c 100644 --- a/src/cache/projectContextCache.ts +++ b/src/cache/projectContextCache.ts @@ -1,13 +1,31 @@ import { ProjectConfig } from "@/aiParams"; import { logError, logInfo } from "@/logger"; +import { TAbstractFile, TFile, Vault } from "obsidian"; +import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils"; +import { getSettings } from "@/settings/model"; import { MD5 } from "crypto-js"; +const DEBOUNCE_DELAY = 5000; // 5 seconds + +export interface ContextCache { + markdownContext: string; + webContexts: Record; // URL -> context + youtubeContexts: Record; // URL -> context + timestamp: number; + markdownNeedsReload: boolean; +} + export class ProjectContextCache { private static instance: ProjectContextCache; private cacheDir: string = ".copilot/project-context-cache"; - private memoryCache: Map = new Map(); + private memoryCache: Map = new Map(); + private vault: Vault; + private debounceTimer: number | null = null; - private constructor() {} + private constructor() { + this.vault = app.vault; + this.initializeEventListeners(); + } static getInstance(): ProjectContextCache { if (!ProjectContextCache.instance) { @@ -16,23 +34,74 @@ export class ProjectContextCache { return ProjectContextCache.instance; } + private handleFileEvent = (file: TAbstractFile) => { + if (file instanceof TFile) { + this.debouncedHandleFileChange(file); + } + }; + + private initializeEventListeners() { + // Monitor file events + this.vault.on("create", this.handleFileEvent); + this.vault.on("modify", this.handleFileEvent); + this.vault.on("delete", this.handleFileEvent); + this.vault.on("rename", this.handleFileEvent); + } + + private debouncedHandleFileChange = (file: TFile) => { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + + this.debounceTimer = window.setTimeout(() => { + this.handleFileChange(file); + this.debounceTimer = null; + }, DEBOUNCE_DELAY); + }; + + private async handleFileChange(file: TFile) { + try { + // enable markdown file + if (file.extension !== "md") { + return; + } + + const settings = getSettings(); + const projects = settings.projectList || []; + + // check if the project needs to clear cache + await Promise.all([ + projects.map(async (project) => { + const { inclusions, exclusions } = getMatchingPatterns({ + inclusions: project.contextSource.inclusions, + exclusions: project.contextSource.exclusions, + isProject: true, + }); + + if (shouldIndexFile(file, inclusions, exclusions)) { + // Only clear markdown context, keep web and youtube contexts + await this.clearMarkdownContext(project); + logInfo( + `Cleared markdown context cache for project ${project.name} due to file change: ${file.path}` + ); + } + }), + ]); + } catch (error) { + logError("Error handling file change for project context cache:", error); + } + } + private async ensureCacheDir() { - if (!(await app.vault.adapter.exists(this.cacheDir))) { + if (!(await this.vault.adapter.exists(this.cacheDir))) { logInfo("Creating project context cache directory:", this.cacheDir); - await app.vault.adapter.mkdir(this.cacheDir); + await this.vault.adapter.mkdir(this.cacheDir); } } private getCacheKey(project: ProjectConfig): string { - // Use project ID, system prompt, and context sources for a unique cache key - logInfo("Generating cache key for project context:", project.contextSource); - const metadata = JSON.stringify({ - id: project.id, - contextSource: project.contextSource, - systemPrompt: project.systemPrompt, - }); - const key = MD5(metadata).toString(); - logInfo("Generated cache key for project:", { name: project.name, key }); + // Use project ID as cache key + const key = MD5(project.id).toString(); return key; } @@ -40,7 +109,7 @@ export class ProjectContextCache { return `${this.cacheDir}/${cacheKey}.json`; } - async get(project: ProjectConfig): Promise { + async get(project: ProjectConfig): Promise { try { const cacheKey = this.getCacheKey(project); @@ -52,13 +121,13 @@ export class ProjectContextCache { } const cachePath = this.getCachePath(cacheKey); - if (await app.vault.adapter.exists(cachePath)) { + if (await this.vault.adapter.exists(cachePath)) { logInfo("File cache hit for project:", project.name); - const cacheContent = await app.vault.adapter.read(cachePath); - const context = JSON.parse(cacheContent).context; + const cacheContent = await this.vault.adapter.read(cachePath); + const contextCache = JSON.parse(cacheContent); // Store in memory cache - this.memoryCache.set(cacheKey, context); - return context; + this.memoryCache.set(cacheKey, contextCache); + return contextCache; } logInfo("Cache miss for project:", project.name); return null; @@ -68,7 +137,7 @@ export class ProjectContextCache { } } - getSync(project: ProjectConfig): string | null { + getSync(project: ProjectConfig): ContextCache | null { try { const cacheKey = this.getCacheKey(project); const memoryResult = this.memoryCache.get(cacheKey); @@ -84,38 +153,78 @@ export class ProjectContextCache { } } - async set(project: ProjectConfig, context: string): Promise { + async set(project: ProjectConfig, contextCache: ContextCache): Promise { try { await this.ensureCacheDir(); const cacheKey = this.getCacheKey(project); const cachePath = this.getCachePath(cacheKey); logInfo("Caching context for project:", project.name); // Store in memory cache - this.memoryCache.set(cacheKey, context); + this.memoryCache.set(cacheKey, contextCache); // Store in file cache - await app.vault.adapter.write( - cachePath, - JSON.stringify({ - context, - timestamp: Date.now(), - }) - ); + await this.vault.adapter.write(cachePath, JSON.stringify(contextCache)); } catch (error) { logError("Error writing to project context cache:", error); } } - async clear(): Promise { + async updateWebContext(project: ProjectConfig, url: string, context: string): Promise { + const cache = (await this.get(project)) || { + markdownContext: "", + webContexts: {}, + youtubeContexts: {}, + timestamp: Date.now(), + markdownNeedsReload: false, + }; + + cache.webContexts[url] = context; + await this.set(project, cache); + } + + async updateYoutubeContext(project: ProjectConfig, url: string, context: string): Promise { + const cache = (await this.get(project)) || { + markdownContext: "", + webContexts: {}, + youtubeContexts: {}, + timestamp: Date.now(), + markdownNeedsReload: false, + }; + + cache.youtubeContexts[url] = context; + await this.set(project, cache); + } + + async updateMarkdownContext(project: ProjectConfig, context: string): Promise { + const cache = (await this.get(project)) || { + markdownContext: "", + webContexts: {}, + youtubeContexts: {}, + timestamp: Date.now(), + markdownNeedsReload: false, + }; + + cache.markdownContext = context; + await this.set(project, cache); + } + + async clearMarkdownContext(project: ProjectConfig): Promise { + const cache = await this.get(project); + if (cache) { + cache.markdownContext = ""; + cache.markdownNeedsReload = true; + await this.set(project, cache); + } + } + + async clearAllCache(): Promise { try { // Clear memory cache this.memoryCache.clear(); // Clear file cache - if (await app.vault.adapter.exists(this.cacheDir)) { - const files = await app.vault.adapter.list(this.cacheDir); + if (await this.vault.adapter.exists(this.cacheDir)) { + const files = await this.vault.adapter.list(this.cacheDir); logInfo("Clearing project context cache, removing files:", files.files.length); - for (const file of files.files) { - await app.vault.adapter.remove(file); - } + await Promise.all([files.files.map((file) => this.vault.adapter.remove(file))]); } } catch (error) { logError("Error clearing project context cache:", error); @@ -129,12 +238,23 @@ export class ProjectContextCache { this.memoryCache.delete(cacheKey); // Clear from file cache const cachePath = this.getCachePath(cacheKey); - if (await app.vault.adapter.exists(cachePath)) { + if (await this.vault.adapter.exists(cachePath)) { logInfo("Clearing cache for project:", project.name); - await app.vault.adapter.remove(cachePath); + await this.vault.adapter.remove(cachePath); } } catch (error) { logError("Error clearing cache for project:", error); } } + + public cleanup() { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + + this.vault.off("create", this.handleFileEvent); + this.vault.off("modify", this.handleFileEvent); + this.vault.off("delete", this.handleFileEvent); + this.vault.off("rename", this.handleFileEvent); + } } diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 6fa93c84..3442a21c 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -28,8 +28,6 @@ import { Buffer } from "buffer"; import { Notice, TFile } from "obsidian"; import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; -type ChatMode = "default" | "project"; - interface ChatProps { sharedState: SharedState; chainManager: ChainManager; @@ -37,7 +35,6 @@ interface ChatProps { updateUserMessageHistory: (newMessage: string) => void; fileParserManager: FileParserManager; plugin: CopilotPlugin; - mode?: ChatMode; } const Chat: React.FC = ({ diff --git a/src/main.ts b/src/main.ts index aa8d7456..deae001a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -126,6 +126,10 @@ export default class CopilotPlugin extends Plugin { this.vectorStoreManager.onunload(); } + if (this.projectManager) { + this.projectManager.onunload(); + } + this.settingsUnsubscriber?.(); this.autocompleteService?.destroy();