mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat: Add project new context loading ui. (#1695)
This commit is contained in:
parent
eca41a929b
commit
fe26c8943b
20 changed files with 2064 additions and 322 deletions
63
package-lock.json
generated
63
package-lock.json
generated
|
|
@ -32,6 +32,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|
@ -6329,6 +6330,68 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
|
|
||||||
279
src/LLMProviders/projectLoadTracker.ts
Normal file
279
src/LLMProviders/projectLoadTracker.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import {
|
||||||
|
FailedItem,
|
||||||
|
ProjectConfig,
|
||||||
|
projectContextLoadAtom,
|
||||||
|
setProjectContextLoadState,
|
||||||
|
updateProjectContextLoadState,
|
||||||
|
} from "@/aiParams";
|
||||||
|
import { ContextCache } from "@/cache/projectContextCache";
|
||||||
|
import { logInfo } from "@/logger";
|
||||||
|
import { settingsStore } from "@/settings/model";
|
||||||
|
import { err2String } from "@/utils";
|
||||||
|
import { App, TFile } from "obsidian";
|
||||||
|
import { isRateLimitError } from "@/utils/rateLimitUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProjectLoadTracker is responsible for managing the progress tracking of project file processing
|
||||||
|
*/
|
||||||
|
export class ProjectLoadTracker {
|
||||||
|
private static instance: ProjectLoadTracker;
|
||||||
|
private app: App;
|
||||||
|
|
||||||
|
private constructor(app: App) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(app: App): ProjectLoadTracker {
|
||||||
|
if (!ProjectLoadTracker.instance) {
|
||||||
|
ProjectLoadTracker.instance = new ProjectLoadTracker(app);
|
||||||
|
}
|
||||||
|
return ProjectLoadTracker.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all project context loading states
|
||||||
|
*/
|
||||||
|
public clearAllLoadStates(): void {
|
||||||
|
setProjectContextLoadState({
|
||||||
|
success: [],
|
||||||
|
failed: [],
|
||||||
|
processingFiles: [],
|
||||||
|
total: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an operation and track its execution status
|
||||||
|
*/
|
||||||
|
public async executeWithProcessTracking<T>(
|
||||||
|
key: string,
|
||||||
|
type: FailedItem["type"],
|
||||||
|
operation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
this.setFileOrUrlStartProcess(key);
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
this.setFileOrUrlProcessSuccessful(key);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = isRateLimitError(error)
|
||||||
|
? "Rate limit exceeded. (Rate limit: 50 files or 100MB per 3 hours, whichever is reached first)"
|
||||||
|
: err2String(error);
|
||||||
|
|
||||||
|
this.setFileOrUrlProcessFailed(key, type, errorMessage);
|
||||||
|
throw error; // throw error to outer layer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a file or URL as processing started
|
||||||
|
*/
|
||||||
|
private setFileOrUrlStartProcess(key: string): void {
|
||||||
|
settingsStore.set(projectContextLoadAtom, (prev) => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
|
||||||
|
// note: we remove the failed file from the failed list when it starts processing
|
||||||
|
if (newState.failed.find((item) => item.path === key)) {
|
||||||
|
newState.failed = newState.failed.filter((file) => file.path !== key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: we remove the success file from the success list when it starts processing
|
||||||
|
// For the case where the file cacheKey still exists, but the actual cached content is missing
|
||||||
|
if (newState.success.includes(key)) {
|
||||||
|
newState.success = newState.success.filter((file) => file !== key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to processing files list
|
||||||
|
if (!newState.processingFiles.includes(key)) {
|
||||||
|
newState.processingFiles = [...newState.processingFiles, key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure file is in the total list
|
||||||
|
if (!newState.total.includes(key)) {
|
||||||
|
newState.total = [...newState.total, key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a process as successful
|
||||||
|
*/
|
||||||
|
private setFileOrUrlProcessSuccessful(key: string): void {
|
||||||
|
updateProjectContextLoadState("processingFiles", (prev) => prev.filter((file) => file !== key));
|
||||||
|
updateProjectContextLoadState("success", (prev) => {
|
||||||
|
if (!prev.includes(key)) {
|
||||||
|
return [...prev, key];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a process as failed
|
||||||
|
*/
|
||||||
|
private setFileOrUrlProcessFailed(key: string, type: FailedItem["type"], error?: string): void {
|
||||||
|
updateProjectContextLoadState("processingFiles", (prev) => prev.filter((file) => file !== key));
|
||||||
|
|
||||||
|
updateProjectContextLoadState("failed", (prev) => {
|
||||||
|
const existingFailed = prev.find((item) => item.path === key);
|
||||||
|
if (!existingFailed) {
|
||||||
|
const failedItem: FailedItem = {
|
||||||
|
path: key,
|
||||||
|
type,
|
||||||
|
error,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
return [...prev, failedItem];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-compute all items that need to be processed in the project
|
||||||
|
*/
|
||||||
|
public preComputeAllItems(project: ProjectConfig, projectAllFiles: TFile[]): void {
|
||||||
|
logInfo(`[preComputeAllItems] Starting pre-computation for project: ${project.name}`);
|
||||||
|
|
||||||
|
const allItems: string[] = [];
|
||||||
|
|
||||||
|
// 1. Count all matching files (markdown and non-markdown)
|
||||||
|
// Add all matching file paths to the list
|
||||||
|
allItems.push(...projectAllFiles.map((file: TFile) => file.path));
|
||||||
|
|
||||||
|
// 2. Count all Web URLs
|
||||||
|
const configuredWebUrls = project.contextSource?.webUrls?.trim() || "";
|
||||||
|
if (configuredWebUrls) {
|
||||||
|
const webUrls = configuredWebUrls.split("\n").filter((url) => url.trim());
|
||||||
|
allItems.push(...webUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Count all YouTube URLs
|
||||||
|
const configuredYoutubeUrls = project.contextSource?.youtubeUrls?.trim() || "";
|
||||||
|
if (configuredYoutubeUrls) {
|
||||||
|
const youtubeUrls = configuredYoutubeUrls.split("\n").filter((url) => url.trim());
|
||||||
|
allItems.push(...youtubeUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all items to the total list
|
||||||
|
if (allItems.length > 0) {
|
||||||
|
const uniqueItems = [...new Set([...allItems])];
|
||||||
|
updateProjectContextLoadState("total", (_) => uniqueItems);
|
||||||
|
logInfo(
|
||||||
|
`[preComputeAllItems] Project ${project.name}: Added ${allItems.length} items to tracking (${uniqueItems.length} total unique items)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all cached items(besides Non-markdown files) as successful
|
||||||
|
*/
|
||||||
|
public markAllCachedItemsAsSuccess(
|
||||||
|
project: ProjectConfig,
|
||||||
|
contextCache: ContextCache,
|
||||||
|
projectAllFiles: TFile[]
|
||||||
|
): void {
|
||||||
|
logInfo(`[markAllCachedItemsAsSuccess] Starting for project: ${project.name || "default"}`);
|
||||||
|
|
||||||
|
// 1. Mark cached Web URLs
|
||||||
|
const configuredWebUrls = project.contextSource?.webUrls?.trim() || "";
|
||||||
|
if (configuredWebUrls) {
|
||||||
|
const urlsInConfig = configuredWebUrls.split("\n").filter((url) => url.trim());
|
||||||
|
const cachedUrls = urlsInConfig.filter((url) => contextCache.webContexts[url]);
|
||||||
|
cachedUrls.forEach((url) => {
|
||||||
|
this.markCachedItemAsSuccess(url);
|
||||||
|
});
|
||||||
|
if (cachedUrls.length > 0) {
|
||||||
|
logInfo(
|
||||||
|
`[markAllCachedItemsAsSuccess] Project ${project.name}: Marked ${cachedUrls.length} cached Web URLs as successful`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mark cached YouTube URLs
|
||||||
|
const configuredYoutubeUrls = project.contextSource?.youtubeUrls?.trim() || "";
|
||||||
|
if (configuredYoutubeUrls) {
|
||||||
|
const urlsInConfig = configuredYoutubeUrls.split("\n").filter((url) => url.trim());
|
||||||
|
const cachedUrls = urlsInConfig.filter((url) => contextCache.youtubeContexts[url]);
|
||||||
|
cachedUrls.forEach((url) => {
|
||||||
|
this.markCachedItemAsSuccess(url);
|
||||||
|
});
|
||||||
|
if (cachedUrls.length > 0) {
|
||||||
|
logInfo(
|
||||||
|
`[markAllCachedItemsAsSuccess] Project ${project.name}: Marked ${cachedUrls.length} cached YouTube URLs as successful`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Only mark markdown files present in fileContexts as successful, does not include Non-markdown files.
|
||||||
|
// because track Non-markdown in the processNonMarkdownFiles method
|
||||||
|
if (contextCache.fileContexts) {
|
||||||
|
// only for markdown files
|
||||||
|
const matchingFilesSet = new Set(
|
||||||
|
projectAllFiles.filter((file) => file.extension === "md").map((file: TFile) => file.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
const cachedFilesToMark = Object.keys(contextCache.fileContexts).filter((filePath) =>
|
||||||
|
matchingFilesSet.has(filePath)
|
||||||
|
);
|
||||||
|
|
||||||
|
cachedFilesToMark.forEach((filePath) => {
|
||||||
|
this.markCachedItemAsSuccess(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedFilesToMark.length > 0) {
|
||||||
|
logInfo(
|
||||||
|
`[markAllCachedItemsAsSuccess] Project ${project.name}: Marked ${
|
||||||
|
cachedFilesToMark.length
|
||||||
|
} cached files that match current project patterns as successful.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a cached item as successful
|
||||||
|
*/
|
||||||
|
public markCachedItemAsSuccess(key: string): void {
|
||||||
|
updateProjectContextLoadState("total", (prev) => {
|
||||||
|
if (!prev.includes(key)) {
|
||||||
|
return [...prev, key];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as successful directly
|
||||||
|
updateProjectContextLoadState("success", (prev) => {
|
||||||
|
if (!prev.includes(key)) {
|
||||||
|
return [...prev, key];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public makeItemFailed(key: string, type: FailedItem["type"], error?: string): void {
|
||||||
|
updateProjectContextLoadState("total", (prev) => {
|
||||||
|
if (!prev.includes(key)) {
|
||||||
|
return [...prev, key];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if this item is already in the failed list
|
||||||
|
updateProjectContextLoadState("failed", (prev) => {
|
||||||
|
const existingFailed = prev.find((item) => item.path === key);
|
||||||
|
if (!existingFailed) {
|
||||||
|
const failedItem: FailedItem = {
|
||||||
|
path: key,
|
||||||
|
type,
|
||||||
|
error,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
return [...prev, failedItem];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
FailedItem,
|
||||||
getChainType,
|
getChainType,
|
||||||
isProjectMode,
|
isProjectMode,
|
||||||
ProjectConfig,
|
ProjectConfig,
|
||||||
|
|
@ -23,6 +24,7 @@ import { App, Notice, TFile } from "obsidian";
|
||||||
import VectorStoreManager from "../search/vectorStoreManager";
|
import VectorStoreManager from "../search/vectorStoreManager";
|
||||||
import { BrevilabsClient } from "./brevilabsClient";
|
import { BrevilabsClient } from "./brevilabsClient";
|
||||||
import ChainManager from "./chainManager";
|
import ChainManager from "./chainManager";
|
||||||
|
import { ProjectLoadTracker } from "./projectLoadTracker";
|
||||||
|
|
||||||
export default class ProjectManager {
|
export default class ProjectManager {
|
||||||
public static instance: ProjectManager;
|
public static instance: ProjectManager;
|
||||||
|
|
@ -32,6 +34,7 @@ export default class ProjectManager {
|
||||||
private readonly chainMangerInstance: ChainManager;
|
private readonly chainMangerInstance: ChainManager;
|
||||||
private readonly projectContextCache: ProjectContextCache;
|
private readonly projectContextCache: ProjectContextCache;
|
||||||
private fileParserManager: FileParserManager;
|
private fileParserManager: FileParserManager;
|
||||||
|
private loadTracker: ProjectLoadTracker;
|
||||||
|
|
||||||
private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) {
|
private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
|
@ -45,6 +48,7 @@ export default class ProjectManager {
|
||||||
true,
|
true,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
this.loadTracker = ProjectLoadTracker.getInstance(this.app);
|
||||||
|
|
||||||
// Set up subscriptions
|
// Set up subscriptions
|
||||||
subscribeToModelKeyChange(async () => {
|
subscribeToModelKeyChange(async () => {
|
||||||
|
|
@ -124,6 +128,8 @@ export default class ProjectManager {
|
||||||
|
|
||||||
public async switchProject(project: ProjectConfig | null): Promise<void> {
|
public async switchProject(project: ProjectConfig | null): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Clear all project context loading states
|
||||||
|
this.loadTracker.clearAllLoadStates();
|
||||||
setProjectLoading(true);
|
setProjectLoading(true);
|
||||||
logInfo("Project loading started...");
|
logInfo("Project loading started...");
|
||||||
|
|
||||||
|
|
@ -187,6 +193,10 @@ export default class ProjectManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadProjectContext(project: ProjectConfig): Promise<ContextCache | null> {
|
private async loadProjectContext(project: ProjectConfig): Promise<ContextCache | null> {
|
||||||
|
// for update context condition
|
||||||
|
this.loadTracker.clearAllLoadStates();
|
||||||
|
setProjectLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!project.contextSource) {
|
if (!project.contextSource) {
|
||||||
logWarn(`[loadProjectContext] Project ${project.name}: No contextSource. Aborting.`);
|
logWarn(`[loadProjectContext] Project ${project.name}: No contextSource. Aborting.`);
|
||||||
|
|
@ -194,86 +204,31 @@ export default class ProjectManager {
|
||||||
}
|
}
|
||||||
logInfo(`[loadProjectContext] Starting for project: ${project.name}`);
|
logInfo(`[loadProjectContext] Starting for project: ${project.name}`);
|
||||||
|
|
||||||
const initialProjectCache = await this.projectContextCache.get(project);
|
logInfo(
|
||||||
const contextCache = initialProjectCache || {
|
`[loadProjectContext] Project ${project.name}: Cleared all project context load states`
|
||||||
markdownContext: "",
|
);
|
||||||
webContexts: {},
|
|
||||||
youtubeContexts: {},
|
const contextCache = await this.projectContextCache.getOrInitializeCache(project);
|
||||||
fileContexts: {},
|
|
||||||
timestamp: Date.now(),
|
const projectAllFiles = this.getProjectAllFiles(project);
|
||||||
markdownNeedsReload: true,
|
|
||||||
};
|
// Pre-count all items that need to be processed
|
||||||
if (!initialProjectCache) {
|
this.loadTracker.preComputeAllItems(project, projectAllFiles);
|
||||||
logInfo(
|
this.loadTracker.markAllCachedItemsAsSuccess(project, contextCache, projectAllFiles);
|
||||||
`[loadProjectContext] Project ${project.name}: No existing cache found, building fresh context.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logInfo(
|
|
||||||
`[loadProjectContext] Project ${project.name}: Existing cache found. MarkdownNeedsReload: ${contextCache.markdownNeedsReload}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedContextCacheAfterSources] = await Promise.all([
|
const [updatedContextCacheAfterSources] = await Promise.all([
|
||||||
this.processMarkdownFiles(project, contextCache),
|
this.processMarkdownFiles(project, contextCache, projectAllFiles),
|
||||||
this.processWebUrls(project, contextCache),
|
this.processWebUrls(project, contextCache),
|
||||||
this.processYoutubeUrls(project, contextCache),
|
this.processYoutubeUrls(project, contextCache),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// After other contexts are processed, ensure all referenced non-markdown files are parsed and cached
|
|
||||||
if (updatedContextCacheAfterSources.fileContexts) {
|
|
||||||
const fileContextCount = Object.keys(updatedContextCacheAfterSources.fileContexts).length;
|
|
||||||
logInfo(
|
|
||||||
`[loadProjectContext] Project ${project.name}: Checking ${fileContextCount} fileContexts for non-markdown processing.`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fileContextCount > 0) {
|
|
||||||
this.fileParserManager = new FileParserManager(
|
|
||||||
BrevilabsClient.getInstance(),
|
|
||||||
this.app.vault,
|
|
||||||
true,
|
|
||||||
project
|
|
||||||
);
|
|
||||||
let processedNonMdCount = 0;
|
|
||||||
for (const filePath in updatedContextCacheAfterSources.fileContexts) {
|
|
||||||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
|
||||||
if (file instanceof TFile && file.extension !== "md") {
|
|
||||||
if (this.fileParserManager.supportsExtension(file.extension)) {
|
|
||||||
try {
|
|
||||||
const existingContent = await this.projectContextCache.getFileContext(
|
|
||||||
project,
|
|
||||||
filePath
|
|
||||||
);
|
|
||||||
if (!existingContent) {
|
|
||||||
logInfo(
|
|
||||||
`[loadProjectContext] Project ${project.name}: Parsing/caching new/updated file: ${filePath}`
|
|
||||||
);
|
|
||||||
await this.fileParserManager.parseFile(file, this.app.vault);
|
|
||||||
processedNonMdCount++;
|
|
||||||
} // else { logInfo for skipped can be too verbose }
|
|
||||||
} catch (error) {
|
|
||||||
logError(
|
|
||||||
`[loadProjectContext] Project ${project.name}: Error parsing file ${filePath}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if this is a rate limit error and re-throw it to fail the entire operation
|
|
||||||
if (isRateLimitError(error)) {
|
|
||||||
throw error; // Re-throw to fail the entire operation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (processedNonMdCount > 0) {
|
|
||||||
logInfo(
|
|
||||||
`[loadProjectContext] Project ${project.name}: Processed and cached ${processedNonMdCount} non-markdown files.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedContextCacheAfterSources.timestamp = Date.now();
|
updatedContextCacheAfterSources.timestamp = Date.now();
|
||||||
await this.projectContextCache.set(project, updatedContextCacheAfterSources);
|
// Note: Since non-markdown files cannot pass cache parameters , so we need to save the context cache first
|
||||||
|
await this.projectContextCache.setCacheSafely(project, updatedContextCacheAfterSources);
|
||||||
|
|
||||||
|
// After other contexts are processed, ensure all referenced non-markdown files are parsed and cached
|
||||||
|
await this.processNonMarkdownFiles(project, projectAllFiles);
|
||||||
|
|
||||||
logInfo(`[loadProjectContext] Completed for project: ${project.name}.`);
|
logInfo(`[loadProjectContext] Completed for project: ${project.name}.`);
|
||||||
return updatedContextCacheAfterSources;
|
return updatedContextCacheAfterSources;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -315,11 +270,10 @@ export default class ProjectManager {
|
||||||
const nextUrls = nextWebUrls.split("\n").filter((url) => url.trim());
|
const nextUrls = nextWebUrls.split("\n").filter((url) => url.trim());
|
||||||
|
|
||||||
// Remove context for URLs that no longer exist
|
// Remove context for URLs that no longer exist
|
||||||
for (const url of prevUrls) {
|
await this.projectContextCache.removeWebUrls(
|
||||||
if (!nextUrls.includes(url)) {
|
nextProject,
|
||||||
await this.projectContextCache.removeWebUrl(nextProject, url);
|
prevUrls.filter((url) => !nextUrls.includes(url))
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if YouTube URLs configuration has changed
|
// Check if YouTube URLs configuration has changed
|
||||||
|
|
@ -332,11 +286,10 @@ export default class ProjectManager {
|
||||||
const nextUrls = nextYoutubeUrls.split("\n").filter((url) => url.trim());
|
const nextUrls = nextYoutubeUrls.split("\n").filter((url) => url.trim());
|
||||||
|
|
||||||
// Remove context for URLs that no longer exist
|
// Remove context for URLs that no longer exist
|
||||||
for (const url of prevUrls) {
|
await this.projectContextCache.removeYoutubeUrls(
|
||||||
if (!nextUrls.includes(url)) {
|
nextProject,
|
||||||
await this.projectContextCache.removeYoutubeUrl(nextProject, url);
|
prevUrls.filter((url) => !nextUrls.includes(url))
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Error comparing project configurations: ${error}`);
|
logError(`Error comparing project configurations: ${error}`);
|
||||||
|
|
@ -424,7 +377,7 @@ export default class ProjectManager {
|
||||||
|
|
||||||
// Retrieve file content from FileCache
|
// Retrieve file content from FileCache
|
||||||
const content =
|
const content =
|
||||||
(await this.projectContextCache.getFileContext(project, filePath)) ||
|
(await this.projectContextCache.getOrReuseFileContext(project, filePath)) ||
|
||||||
"[Content not available]"; // This is expected for files not processed into FileCache
|
"[Content not available]"; // This is expected for files not processed into FileCache
|
||||||
|
|
||||||
return `[[${fileName}]]\npath: ${filePath}\ntype: ${fileType}\nmodified: ${new Date(fileContext.timestamp).toISOString()}\n\n${content}`;
|
return `[[${fileName}]]\npath: ${filePath}\ntype: ${fileType}\nmodified: ${new Date(fileContext.timestamp).toISOString()}\n\n${content}`;
|
||||||
|
|
@ -497,82 +450,50 @@ ${contextParts.join("\n\n")}
|
||||||
|
|
||||||
private async processMarkdownFiles(
|
private async processMarkdownFiles(
|
||||||
project: ProjectConfig,
|
project: ProjectConfig,
|
||||||
contextCache: ContextCache
|
contextCache: ContextCache,
|
||||||
|
projectAllFiles: TFile[]
|
||||||
): Promise<ContextCache> {
|
): Promise<ContextCache> {
|
||||||
logInfo(`[processMarkdownFiles] Starting for project: ${project.name}`);
|
logInfo(`[processMarkdownFiles] Starting for project: ${project.name}`);
|
||||||
const initialFileContextsCount = Object.keys(contextCache.fileContexts || {}).length;
|
|
||||||
|
|
||||||
if (project.contextSource?.inclusions || project.contextSource?.exclusions) {
|
if (
|
||||||
contextCache = await this.projectContextCache.updateProjectFilesFromPatterns(
|
contextCache.markdownNeedsReload ||
|
||||||
|
!contextCache.markdownContext ||
|
||||||
|
!contextCache.markdownContext.trim()
|
||||||
|
) {
|
||||||
|
logInfo(`[processMarkdownFiles] Project ${project.name}: Processing markdown content.`);
|
||||||
|
const markdownContent = await this.processMarkdownFileContext(projectAllFiles);
|
||||||
|
|
||||||
|
// add context reference to markdown file
|
||||||
|
this.projectContextCache.updateProjectMarkdownFilesFromPatterns(
|
||||||
project,
|
project,
|
||||||
contextCache
|
contextCache,
|
||||||
|
projectAllFiles
|
||||||
);
|
);
|
||||||
const newFileContextsCount = Object.keys(contextCache.fileContexts || {}).length;
|
|
||||||
if (newFileContextsCount > initialFileContextsCount) {
|
|
||||||
logInfo(
|
|
||||||
`[processMarkdownFiles] Project ${project.name}: Added ${newFileContextsCount - initialFileContextsCount} new file references via updateProjectFilesFromPatterns.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
contextCache.markdownContext = markdownContent;
|
||||||
contextCache.markdownNeedsReload ||
|
contextCache.markdownNeedsReload = false;
|
||||||
!contextCache.markdownContext ||
|
|
||||||
!contextCache.markdownContext.trim()
|
logInfo(`[processMarkdownFiles] Project ${project.name}: Markdown content updated.`);
|
||||||
) {
|
} else {
|
||||||
logInfo(`[processMarkdownFiles] Project ${project.name}: Processing markdown content.`);
|
logInfo(
|
||||||
const markdownContent = await this.processFileContext(
|
`[processMarkdownFiles] Project ${project.name}: Markdown content already up-to-date.`
|
||||||
project.contextSource.inclusions,
|
);
|
||||||
project.contextSource.exclusions,
|
|
||||||
project
|
|
||||||
);
|
|
||||||
contextCache.markdownContext = markdownContent;
|
|
||||||
contextCache.markdownNeedsReload = false;
|
|
||||||
logInfo(`[processMarkdownFiles] Project ${project.name}: Markdown content updated.`);
|
|
||||||
} else {
|
|
||||||
logInfo(
|
|
||||||
`[processMarkdownFiles] Project ${project.name}: Markdown content already up-to-date.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo(
|
logInfo(
|
||||||
`[processMarkdownFiles] Completed for project: ${project.name}. Total fileContexts: ${Object.keys(contextCache.fileContexts || {}).length}`
|
`[processMarkdownFiles] Completed for project: ${project.name}. Total fileContexts: ${Object.keys(contextCache.fileContexts || {}).length}`
|
||||||
);
|
);
|
||||||
return contextCache;
|
return contextCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processFileContext(
|
private async processMarkdownFileContext(projectAllFiles: TFile[]): Promise<string> {
|
||||||
inclusions?: string,
|
|
||||||
exclusions?: string,
|
|
||||||
project?: ProjectConfig
|
|
||||||
): Promise<string> {
|
|
||||||
if (!inclusions && !exclusions) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Must not fallback to GLOBAL inclusions and exclusions in Copilot settings in Projects!
|
|
||||||
// This is to avoid project inclusions in the project that conflict with the global ones
|
|
||||||
// Project UI should be the ONLY source of truth for project inclusions and exclusions
|
|
||||||
const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = getMatchingPatterns({
|
|
||||||
inclusions,
|
|
||||||
exclusions,
|
|
||||||
isProject: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// FileParserManager will be used to process these files when they're accessed,
|
// FileParserManager will be used to process these files when they're accessed,
|
||||||
// either immediately or on-demand when the context is formatted
|
// either immediately or on-demand when the context is formatted
|
||||||
|
|
||||||
// Get all markdown files that match the inclusion/exclusion patterns
|
// Get all markdown files that match the inclusion/exclusion patterns
|
||||||
// Note: We're only processing markdown files here, other file types
|
// Note: We're only processing markdown files here, other file types
|
||||||
// are handled by FileParserManager and stored in the file cache
|
// are handled by FileParserManager and stored in the file cache
|
||||||
const files = this.app.vault.getFiles().filter((file) => {
|
const files = projectAllFiles.filter((file) => file.extension === "md");
|
||||||
return (
|
|
||||||
file.extension === "md" && shouldIndexFile(file, inclusionPatterns, exclusionPatterns, true)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
logInfo(`Found ${files.length} markdown files to process for project context`);
|
logInfo(`Found ${files.length} markdown files to process for project context`);
|
||||||
|
|
||||||
|
|
@ -583,15 +504,25 @@ ${contextParts.join("\n\n")}
|
||||||
let metadata = "";
|
let metadata = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await this.app.vault.adapter.stat(file.path);
|
// Only process markdown files here
|
||||||
|
const [stat, fileContent] = await this.loadTracker.executeWithProcessTracking(
|
||||||
|
file.path,
|
||||||
|
"md",
|
||||||
|
async () => {
|
||||||
|
return Promise.all([
|
||||||
|
this.app.vault.adapter.stat(file.path),
|
||||||
|
this.app.vault.read(file),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
metadata = `[[${file.basename}]]
|
metadata = `[[${file.basename}]]
|
||||||
path: ${file.path}
|
path: ${file.path}
|
||||||
type: ${file.extension}
|
type: ${file.extension}
|
||||||
created: ${stat ? new Date(stat.ctime).toISOString() : "unknown"}
|
created: ${stat ? new Date(stat.ctime).toISOString() : "unknown"}
|
||||||
modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
||||||
|
|
||||||
// Only process markdown files here
|
content = fileContent;
|
||||||
content = await this.app.vault.read(file);
|
|
||||||
logInfo(`Completed processing markdown file: ${file.path}`);
|
logInfo(`Completed processing markdown file: ${file.path}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Error processing file ${file.path}: ${error}`);
|
logError(`Error processing file ${file.path}: ${error}`);
|
||||||
|
|
@ -650,8 +581,8 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webContextPromises = urlsToFetch.map(async (url) => {
|
const webContextPromises = urlsToFetch.map(async (url) => {
|
||||||
// processWebUrlsContext itself should log errors if a specific URL fetch fails.
|
// processWebUrlContext itself should log errors if a specific URL fetch fails.
|
||||||
const webContext = await this.processWebUrlsContext(url);
|
const webContext = await this.processWebUrlContext(url);
|
||||||
if (webContext) {
|
if (webContext) {
|
||||||
logInfo(
|
logInfo(
|
||||||
`[processWebUrls] Project ${project.name}: Successfully fetched content for URL: ${url.substring(0, 50)}...`
|
`[processWebUrls] Project ${project.name}: Successfully fetched content for URL: ${url.substring(0, 50)}...`
|
||||||
|
|
@ -717,7 +648,7 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const youtubeContextPromises = urlsToFetch.map(async (url) => {
|
const youtubeContextPromises = urlsToFetch.map(async (url) => {
|
||||||
const youtubeContext = await this.processYoutubeUrlsContext(url);
|
const youtubeContext = await this.processYoutubeUrlContext(url);
|
||||||
if (youtubeContext) {
|
if (youtubeContext) {
|
||||||
logInfo(
|
logInfo(
|
||||||
`[processYoutubeUrls] Project ${project.name}: Successfully fetched transcript for YouTube URL: ${url.substring(0, 50)}...`
|
`[processYoutubeUrls] Project ${project.name}: Successfully fetched transcript for YouTube URL: ${url.substring(0, 50)}...`
|
||||||
|
|
@ -742,44 +673,252 @@ modified: ${stat ? new Date(stat.mtime).toISOString() : "unknown"}`;
|
||||||
return contextCache;
|
return contextCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processWebUrlsContext(webUrls?: string): Promise<string> {
|
private async processWebUrlContext(webUrl?: string): Promise<string> {
|
||||||
if (!webUrls?.trim()) {
|
if (!webUrl?.trim()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mention = Mention.getInstance();
|
const mention = Mention.getInstance();
|
||||||
const { urlContext } = await mention.processUrls(webUrls);
|
const { urlContext } = await this.loadTracker.executeWithProcessTracking(
|
||||||
|
webUrl,
|
||||||
|
"web",
|
||||||
|
async () => {
|
||||||
|
const result = await mention.processUrls(webUrl);
|
||||||
|
|
||||||
|
if (result.processedErrorUrls[webUrl]) {
|
||||||
|
throw new Error(result.processedErrorUrls[webUrl]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
return urlContext || "";
|
return urlContext || "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Failed to process web URLs: ${error}`);
|
logError(`Failed to process web URL: ${error}`);
|
||||||
new Notice(`Failed to process web URLs: ${err2String(error)}`);
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processYoutubeUrlsContext(youtubeUrls?: string): Promise<string> {
|
private async processYoutubeUrlContext(youtubeUrl?: string): Promise<string> {
|
||||||
if (!youtubeUrls?.trim()) {
|
if (!youtubeUrl?.trim()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = youtubeUrls.split("\n").filter((url) => url.trim());
|
try {
|
||||||
const processPromises = urls.map(async (url) => {
|
const response = await this.loadTracker.executeWithProcessTracking(
|
||||||
try {
|
youtubeUrl,
|
||||||
const response = await BrevilabsClient.getInstance().youtube4llm(url);
|
"youtube",
|
||||||
if (response.response.transcript) {
|
async () => {
|
||||||
return `\n\nYouTube transcript from ${url}:\n${response.response.transcript}`;
|
return BrevilabsClient.getInstance().youtube4llm(youtubeUrl);
|
||||||
}
|
}
|
||||||
return "";
|
);
|
||||||
} catch (error) {
|
if (response.response.transcript) {
|
||||||
logError(`Failed to process YouTube URL ${url}: ${error}`);
|
return `\n\nYouTube transcript from ${youtubeUrl}:\n${response.response.transcript}`;
|
||||||
new Notice(`Failed to process YouTube URL ${url}: ${err2String(error)}`);
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Failed to process YouTube URL ${youtubeUrl}: ${error}`);
|
||||||
|
new Notice(`Failed to process YouTube URL ${youtubeUrl}: ${err2String(error)}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processNonMarkdownFiles(
|
||||||
|
project: ProjectConfig,
|
||||||
|
projectAllFiles: TFile[]
|
||||||
|
): Promise<void> {
|
||||||
|
const nonMarkdownFiles = projectAllFiles.filter((file) => file.extension !== "md");
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
`[loadProjectContext] Project ${project.name}: Checking for non-markdown processing: ${nonMarkdownFiles.length} files .`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nonMarkdownFiles.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fileParserManager = new FileParserManager(
|
||||||
|
BrevilabsClient.getInstance(),
|
||||||
|
this.app.vault,
|
||||||
|
true,
|
||||||
|
project
|
||||||
|
);
|
||||||
|
|
||||||
|
let processedNonMdCount = 0;
|
||||||
|
|
||||||
|
for (const file of nonMarkdownFiles) {
|
||||||
|
const filePath = file.path;
|
||||||
|
if (this.fileParserManager.supportsExtension(file.extension)) {
|
||||||
|
try {
|
||||||
|
await this.loadTracker.executeWithProcessTracking(filePath, "nonMd", async () => {
|
||||||
|
const existingContent = await this.projectContextCache.getOrReuseFileContext(
|
||||||
|
project,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
if (existingContent) {
|
||||||
|
processedNonMdCount++;
|
||||||
|
} else {
|
||||||
|
logInfo(
|
||||||
|
`[loadProjectContext] Project ${project.name}: Parsing/caching new/updated file: ${filePath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.fileParserManager.parseFile(file, this.app.vault);
|
||||||
|
processedNonMdCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(
|
||||||
|
`[loadProjectContext] Project ${project.name}: Error parsing file ${filePath}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this is a rate limit error and re-throw it to fail the entire operation
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
throw error; // Re-throw to fail the entire operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedNonMdCount > 0) {
|
||||||
|
logInfo(
|
||||||
|
`[loadProjectContext] Project ${project.name}: Processed and cached ${processedNonMdCount} non-markdown files.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry failed item
|
||||||
|
* @param failedItem Failed item information
|
||||||
|
*/
|
||||||
|
public async retryFailedItem(failedItem: FailedItem): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.currentProjectId) {
|
||||||
|
logWarn("[retryFailedItem] No current project, aborting retry");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = getSettings().projectList.find((p) => p.id === this.currentProjectId);
|
||||||
|
if (!project) {
|
||||||
|
logError(`[retryFailedItem] Current project not found: ${this.currentProjectId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(`[retryFailedItem] Starting retry for ${failedItem.type} item: ${failedItem.path}`);
|
||||||
|
|
||||||
|
// Handle different retry types
|
||||||
|
switch (failedItem.type) {
|
||||||
|
case "web":
|
||||||
|
await this.retryWebUrl(project, failedItem.path);
|
||||||
|
break;
|
||||||
|
case "youtube":
|
||||||
|
await this.retryYoutubeUrl(project, failedItem.path);
|
||||||
|
break;
|
||||||
|
case "md":
|
||||||
|
await this.retryMarkdownFile(project, failedItem.path);
|
||||||
|
break;
|
||||||
|
case "nonMd":
|
||||||
|
await this.retryNonMarkdownFile(project, failedItem.path);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logWarn(`[retryFailedItem] Unknown item type: ${failedItem.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(`[retryFailedItem] Successfully retried ${failedItem.type} item: ${failedItem.path}`);
|
||||||
|
new Notice(`Retry successful: ${failedItem.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
logError(
|
||||||
|
`[retryFailedItem] Failed to retry ${failedItem.type} item ${failedItem.path}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
new Notice(`Retry failed: ${err2String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryWebUrl(project: ProjectConfig, url: string): Promise<void> {
|
||||||
|
const webContext = await this.processWebUrlContext(url);
|
||||||
|
if (!webContext) {
|
||||||
|
logWarn(`[retryWebUrl] Project ${project.name}: Fetched empty content for Web URL: ${url}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
`[retryWebUrl] Project ${project.name}: Successfully fetched content for URL: ${url.substring(0, 50)}...`
|
||||||
|
);
|
||||||
|
await this.projectContextCache.updateWebUrl(project, url, webContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryYoutubeUrl(project: ProjectConfig, url: string): Promise<void> {
|
||||||
|
const youtubeContext = await this.processYoutubeUrlContext(url);
|
||||||
|
if (!youtubeContext) {
|
||||||
|
logWarn(
|
||||||
|
`[retryYoutubeUrl] Project ${project.name}: Fetched empty transcript for YouTube URL: ${url}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
`[retryYoutubeUrl] Project ${project.name}: Successfully fetched transcript for YouTube URL: ${url.substring(0, 50)}...`
|
||||||
|
);
|
||||||
|
await this.projectContextCache.updateYoutubeUrl(project, url, youtubeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryMarkdownFile(project: ProjectConfig, filePath: string): Promise<void> {
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||||
|
if (!(file instanceof TFile) || file.extension !== "md") {
|
||||||
|
throw new Error(`File not found or not a markdown file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// add flag to track reprocessing of Markdown
|
||||||
|
await this.loadTracker.executeWithProcessTracking(file.path, "md", async () => {});
|
||||||
|
|
||||||
|
logInfo(`[retryMarkdownFile] Successfully reprocessed markdown file: ${filePath}`);
|
||||||
|
|
||||||
|
// flag the markdown context as needing a reload
|
||||||
|
await this.projectContextCache.invalidateMarkdownContext(project);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`[retryMarkdownFile] Error processing file ${filePath}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryNonMarkdownFile(project: ProjectConfig, filePath: string): Promise<void> {
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||||
|
if (!(file instanceof TFile) || file.extension === "md") {
|
||||||
|
throw new Error(`File not found or is a markdown file: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.fileParserManager.supportsExtension(file.extension)) {
|
||||||
|
throw new Error(`Unsupported file extension: ${file.extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.loadTracker.executeWithProcessTracking(filePath, "nonMd", async () => {
|
||||||
|
return this.fileParserManager.parseFile(file, this.app.vault);
|
||||||
|
});
|
||||||
|
|
||||||
|
logInfo(`[retryNonMarkdownFile] Successfully reprocessed non-markdown file: ${filePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`[retryNonMarkdownFile] Error processing file ${filePath}: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProjectAllFiles(project: ProjectConfig) {
|
||||||
|
// NOTE: Must not fallback to GLOBAL inclusions and exclusions in Copilot settings in Projects!
|
||||||
|
// This is to avoid project inclusions in the project that conflict with the global ones
|
||||||
|
// Project UI should be the ONLY source of truth for project inclusions and exclusions
|
||||||
|
const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = getMatchingPatterns({
|
||||||
|
inclusions: project.contextSource.inclusions,
|
||||||
|
exclusions: project.contextSource.exclusions,
|
||||||
|
isProject: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(processPromises);
|
return this.app.vault.getFiles().filter((file: TFile) => {
|
||||||
return results.join("");
|
return shouldIndexFile(file, inclusionPatterns, exclusionPatterns, true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onunload(): void {
|
public onunload(): void {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,27 @@ const chainTypeAtom = atom(
|
||||||
const currentProjectAtom = atom<ProjectConfig | null>(null);
|
const currentProjectAtom = atom<ProjectConfig | null>(null);
|
||||||
const projectLoadingAtom = atom<boolean>(false);
|
const projectLoadingAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
export interface FailedItem {
|
||||||
|
path: string;
|
||||||
|
type: "md" | "web" | "youtube" | "nonMd";
|
||||||
|
error?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectContextLoadState {
|
||||||
|
success: Array<string>;
|
||||||
|
failed: Array<FailedItem>;
|
||||||
|
processingFiles: Array<string>;
|
||||||
|
total: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectContextLoadAtom = atom<ProjectContextLoadState>({
|
||||||
|
success: [],
|
||||||
|
failed: [],
|
||||||
|
processingFiles: [],
|
||||||
|
total: [],
|
||||||
|
});
|
||||||
|
|
||||||
const selectedTextContextsAtom = atom<SelectedTextContext[]>([]);
|
const selectedTextContextsAtom = atom<SelectedTextContext[]>([]);
|
||||||
|
|
||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
|
|
@ -51,7 +72,7 @@ export interface ProjectConfig {
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
};
|
};
|
||||||
contextSource: {
|
contextSource: {
|
||||||
inclusions: string;
|
inclusions?: string;
|
||||||
exclusions?: string;
|
exclusions?: string;
|
||||||
webUrls?: string;
|
webUrls?: string;
|
||||||
youtubeUrls?: string;
|
youtubeUrls?: string;
|
||||||
|
|
@ -239,3 +260,50 @@ export function useSelectedTextContexts() {
|
||||||
store: settingsStore,
|
store: settingsStore,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the project context load state from the atom.
|
||||||
|
*/
|
||||||
|
export function getProjectContextLoadState(): Readonly<ProjectContextLoadState> {
|
||||||
|
return settingsStore.get(projectContextLoadAtom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the project context load state in the atom.
|
||||||
|
*/
|
||||||
|
export function setProjectContextLoadState(state: ProjectContextLoadState) {
|
||||||
|
settingsStore.set(projectContextLoadAtom, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a specific field in the project context load state.
|
||||||
|
*/
|
||||||
|
export function updateProjectContextLoadState<K extends keyof ProjectContextLoadState>(
|
||||||
|
key: K,
|
||||||
|
valueFn: (prev: ProjectContextLoadState[K]) => ProjectContextLoadState[K]
|
||||||
|
) {
|
||||||
|
settingsStore.set(projectContextLoadAtom, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: valueFn(prev[key]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to changes in the project context load state.
|
||||||
|
*/
|
||||||
|
export function subscribeToProjectContextLoadChange(
|
||||||
|
callback: (state: ProjectContextLoadState) => void
|
||||||
|
): () => void {
|
||||||
|
return settingsStore.sub(projectContextLoadAtom, () => {
|
||||||
|
callback(settingsStore.get(projectContextLoadAtom));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the project context load state from the atom.
|
||||||
|
*/
|
||||||
|
export function useProjectContextLoad() {
|
||||||
|
return useAtom(projectContextLoadAtom, {
|
||||||
|
store: settingsStore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
518
src/cache/projectContextCache.ts
vendored
518
src/cache/projectContextCache.ts
vendored
|
|
@ -6,6 +6,7 @@ import { getSettings } from "@/settings/model";
|
||||||
import { MD5 } from "crypto-js";
|
import { MD5 } from "crypto-js";
|
||||||
import { TAbstractFile, TFile, Vault } from "obsidian";
|
import { TAbstractFile, TFile, Vault } from "obsidian";
|
||||||
import debounce from "lodash.debounce";
|
import debounce from "lodash.debounce";
|
||||||
|
import { Mutex } from "async-mutex";
|
||||||
|
|
||||||
export interface ContextCache {
|
export interface ContextCache {
|
||||||
// Markdown context
|
// Markdown context
|
||||||
|
|
@ -38,6 +39,8 @@ export class ProjectContextCache {
|
||||||
private vault: Vault;
|
private vault: Vault;
|
||||||
private fileCache: FileCache<string>;
|
private fileCache: FileCache<string>;
|
||||||
private static readonly DEBOUNCE_DELAY = 5000; // 5 seconds
|
private static readonly DEBOUNCE_DELAY = 5000; // 5 seconds
|
||||||
|
private projectMutexMap: Map<string, Mutex> = new Map();
|
||||||
|
private mutexCreationMutex: Mutex = new Mutex(); // Global lock to protect project mutex creation
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.vault = app.vault;
|
this.vault = app.vault;
|
||||||
|
|
@ -65,6 +68,9 @@ export class ProjectContextCache {
|
||||||
this.vault.off("modify", this.handleFileEvent);
|
this.vault.off("modify", this.handleFileEvent);
|
||||||
this.vault.off("delete", this.handleFileEvent);
|
this.vault.off("delete", this.handleFileEvent);
|
||||||
this.vault.off("rename", this.handleFileEvent);
|
this.vault.off("rename", this.handleFileEvent);
|
||||||
|
|
||||||
|
// Clean up project mutexes
|
||||||
|
this.projectMutexMap.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeEventListeners() {
|
private initializeEventListeners() {
|
||||||
|
|
@ -143,6 +149,32 @@ export class ProjectContextCache {
|
||||||
return `${this.cacheDir}/${cacheKey}.json`;
|
return `${this.cacheDir}/${cacheKey}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOrCreateProjectMutex(project: ProjectConfig): Promise<Mutex> {
|
||||||
|
const projectId = project.id;
|
||||||
|
|
||||||
|
// Quick check without lock for performance
|
||||||
|
const existingMutex = this.projectMutexMap.get(projectId);
|
||||||
|
if (existingMutex) {
|
||||||
|
return existingMutex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global lock to ensure atomic creation
|
||||||
|
return await this.mutexCreationMutex.runExclusive(async () => {
|
||||||
|
// Double-check inside the lock
|
||||||
|
const mutex = this.projectMutexMap.get(projectId);
|
||||||
|
if (mutex) {
|
||||||
|
return mutex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new mutex safely
|
||||||
|
const newMutex = new Mutex();
|
||||||
|
this.projectMutexMap.set(projectId, newMutex);
|
||||||
|
logInfo(`Created new mutex for project: ${project.name} (ID: ${projectId})`);
|
||||||
|
|
||||||
|
return newMutex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async get(project: ProjectConfig): Promise<ContextCache | null> {
|
async get(project: ProjectConfig): Promise<ContextCache | null> {
|
||||||
try {
|
try {
|
||||||
const cacheKey = this.getCacheKey(project);
|
const cacheKey = this.getCacheKey(project);
|
||||||
|
|
@ -171,6 +203,25 @@ export class ProjectContextCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrInitializeCache(project: ProjectConfig): Promise<ContextCache> {
|
||||||
|
const initialProjectCache = await this.get(project);
|
||||||
|
|
||||||
|
if (initialProjectCache) {
|
||||||
|
logInfo(
|
||||||
|
`[getOrInitializeCache] Project ${project.name}: Existing cache found. MarkdownNeedsReload: ${initialProjectCache.markdownNeedsReload}`
|
||||||
|
);
|
||||||
|
return initialProjectCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
`[getOrInitializeCache] Project ${project.name}: No existing cache found, building fresh context.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const newCache = this.createEmptyCache();
|
||||||
|
await this.setWithoutMutex(project, newCache);
|
||||||
|
return newCache;
|
||||||
|
}
|
||||||
|
|
||||||
getSync(project: ProjectConfig): ContextCache | null {
|
getSync(project: ProjectConfig): ContextCache | null {
|
||||||
try {
|
try {
|
||||||
const cacheKey = this.getCacheKey(project);
|
const cacheKey = this.getCacheKey(project);
|
||||||
|
|
@ -187,18 +238,37 @@ export class ProjectContextCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
|
private async set(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
|
||||||
|
const mutex = await this.getOrCreateProjectMutex(project);
|
||||||
|
|
||||||
|
if (mutex.isLocked()) {
|
||||||
|
logInfo(`Waiting for project cache lock for project: ${project.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await mutex.runExclusive(async () => {
|
||||||
|
logInfo(`Acquired cache lock for project: ${project.name}`);
|
||||||
|
return await this.setWithoutMutex(project, contextCache);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setWithoutMutex(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.ensureCacheDir();
|
await this.ensureCacheDir();
|
||||||
const cacheKey = this.getCacheKey(project);
|
const cacheKey = this.getCacheKey(project);
|
||||||
const cachePath = this.getCachePath(cacheKey);
|
const cachePath = this.getCachePath(cacheKey);
|
||||||
logInfo("Caching context for project:", project.name);
|
logInfo("Caching context for project:", project.name);
|
||||||
|
|
||||||
|
// Create a deep copy to avoid reference issues
|
||||||
|
const contextCacheCopy = JSON.parse(JSON.stringify(contextCache));
|
||||||
|
|
||||||
// Store in memory cache
|
// Store in memory cache
|
||||||
this.memoryCache.set(cacheKey, contextCache);
|
this.memoryCache.set(cacheKey, contextCacheCopy);
|
||||||
|
|
||||||
// Store in file cache
|
// Store in file cache
|
||||||
await this.vault.adapter.write(cachePath, JSON.stringify(contextCache));
|
await this.vault.adapter.write(cachePath, JSON.stringify(contextCacheCopy));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError("Error writing to project context cache:", error);
|
logError("Error writing to project context cache:", error);
|
||||||
|
throw error; // Re-throw to maintain error propagation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +279,7 @@ export class ProjectContextCache {
|
||||||
youtubeContexts: {},
|
youtubeContexts: {},
|
||||||
fileContexts: {},
|
fileContexts: {},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
markdownNeedsReload: false,
|
markdownNeedsReload: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,6 +384,10 @@ export class ProjectContextCache {
|
||||||
`[clearForProject] Project ${project.name}: Main project cache file not found (already deleted or never existed): ${cachePath}`
|
`[clearForProject] Project ${project.name}: Main project cache file not found (already deleted or never existed): ${cachePath}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Clean up the mutex for this project to prevent memory leaks
|
||||||
|
this.projectMutexMap.delete(project.id);
|
||||||
|
logInfo(`[clearForProject] Cleaned up mutex for project: ${project.name}`);
|
||||||
|
|
||||||
logInfo(`[clearForProject] Completed for project: ${project.name}`);
|
logInfo(`[clearForProject] Completed for project: ${project.name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`[clearForProject] Error for project ${project.name} (ID: ${project.id}):`, error);
|
logError(`[clearForProject] Error for project ${project.name} (ID: ${project.id}):`, error);
|
||||||
|
|
@ -332,47 +406,49 @@ export class ProjectContextCache {
|
||||||
project: ProjectConfig,
|
project: ProjectConfig,
|
||||||
forceReloadAllRemotes: boolean = false
|
forceReloadAllRemotes: boolean = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cache = await this.get(project);
|
await this.updateCacheSafely(
|
||||||
if (cache) {
|
project,
|
||||||
cache.markdownContext = "";
|
(cache) => {
|
||||||
cache.markdownNeedsReload = true;
|
cache.markdownContext = "";
|
||||||
|
cache.markdownNeedsReload = true;
|
||||||
|
|
||||||
if (forceReloadAllRemotes) {
|
if (forceReloadAllRemotes) {
|
||||||
cache.webContexts = {};
|
cache.webContexts = {};
|
||||||
cache.youtubeContexts = {};
|
cache.youtubeContexts = {};
|
||||||
logInfo(`Flagged Web/YouTube contexts for full reload for project ${project.name}`);
|
logInfo(`Flagged Web/YouTube contexts for full reload for project ${project.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.set(project, cache);
|
// Also clean up any file references that no longer match the project's patterns
|
||||||
|
const cleanedCache = this.cleanupFileReferencesInCache(project, cache);
|
||||||
|
|
||||||
// Also clean up any file references that no longer match the project's patterns
|
logInfo(`Invalidated markdown context for project ${project.name}`);
|
||||||
await this.cleanupProjectFileReferences(project);
|
return cleanedCache;
|
||||||
|
},
|
||||||
logInfo(`Invalidated markdown context for project ${project.name}`);
|
true
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the markdown context for a project
|
* Update the markdown context for a project
|
||||||
*/
|
*/
|
||||||
async updateMarkdownContext(project: ProjectConfig, content: string): Promise<void> {
|
async updateMarkdownContext(project: ProjectConfig, content: string): Promise<void> {
|
||||||
const cache = (await this.get(project)) || this.createEmptyCache();
|
return await this.updateCacheSafely(project, (cache) => {
|
||||||
cache.markdownContext = content;
|
cache.markdownContext = content;
|
||||||
cache.markdownNeedsReload = false;
|
cache.markdownNeedsReload = false;
|
||||||
await this.set(project, cache);
|
logInfo(`Updated markdown context for project ${project.name}`);
|
||||||
logInfo(`Updated markdown context for project ${project.name}`);
|
return cache;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear only the markdown context for a project
|
* Clear only the markdown context for a project
|
||||||
*/
|
*/
|
||||||
async clearMarkdownContext(project: ProjectConfig): Promise<void> {
|
async clearMarkdownContext(project: ProjectConfig): Promise<void> {
|
||||||
const cache = await this.get(project);
|
await this.updateCacheSafely(project, (cache) => {
|
||||||
if (cache) {
|
|
||||||
cache.markdownContext = "";
|
cache.markdownContext = "";
|
||||||
cache.markdownNeedsReload = true;
|
cache.markdownNeedsReload = true;
|
||||||
await this.set(project, cache);
|
return cache;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//===========================================================================
|
//===========================================================================
|
||||||
|
|
@ -380,10 +456,41 @@ export class ProjectContextCache {
|
||||||
// Non-markdown files cached in fileCache after processing by FileParserManager
|
// Non-markdown files cached in fileCache after processing by FileParserManager
|
||||||
//===========================================================================
|
//===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file content from project cache or reuse from universal cache if available
|
||||||
|
* This method efficiently searches across all available caches to find file content
|
||||||
|
*/
|
||||||
|
async getOrReuseFileContext(project: ProjectConfig, filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 1. Try to get from project cache first
|
||||||
|
const projectContent = await this.getFileContext(project, filePath);
|
||||||
|
if (projectContent) {
|
||||||
|
return projectContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Search other projects as fallback
|
||||||
|
const result = await this.searchOtherProjectsForFile(filePath);
|
||||||
|
if (result) {
|
||||||
|
// Associate with current project
|
||||||
|
await this.associateCacheWithProject(project, filePath, result.cacheKey);
|
||||||
|
logInfo(
|
||||||
|
`Reused cached content from other project for: ${filePath} in project ${project.name}`
|
||||||
|
);
|
||||||
|
return result.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No content found in any cache
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Error in getOrReuseFileContext for ${filePath} in project ${project.name}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get content for a specific file in a project
|
* Get content for a specific file in a project
|
||||||
*/
|
*/
|
||||||
async getFileContext(project: ProjectConfig, filePath: string): Promise<string | null> {
|
protected async getFileContext(project: ProjectConfig, filePath: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Ensure filePath is valid before proceeding
|
// Ensure filePath is valid before proceeding
|
||||||
if (!filePath || typeof filePath !== "string") {
|
if (!filePath || typeof filePath !== "string") {
|
||||||
|
|
@ -445,8 +552,7 @@ export class ProjectContextCache {
|
||||||
* Add or update a file in a project's context
|
* Add or update a file in a project's context
|
||||||
*/
|
*/
|
||||||
async setFileContext(project: ProjectConfig, filePath: string, content: string): Promise<void> {
|
async setFileContext(project: ProjectConfig, filePath: string, content: string): Promise<void> {
|
||||||
try {
|
return await this.updateCacheSafelyAsync(project, async (cache) => {
|
||||||
const cache = (await this.get(project)) || this.createEmptyCache();
|
|
||||||
if (!cache.fileContexts) {
|
if (!cache.fileContexts) {
|
||||||
cache.fileContexts = {};
|
cache.fileContexts = {};
|
||||||
}
|
}
|
||||||
|
|
@ -469,60 +575,122 @@ export class ProjectContextCache {
|
||||||
cacheKey,
|
cacheKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.set(project, cache);
|
|
||||||
logInfo(`Added/updated file context for ${filePath} in project ${project.name}`);
|
logInfo(`Added/updated file context for ${filePath} in project ${project.name}`);
|
||||||
} catch (error) {
|
return cache;
|
||||||
logError(`Error setting file context for ${filePath}:`, error);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a file from a project's context
|
* Remove a file from a project's context
|
||||||
*/
|
*/
|
||||||
async removeFileContext(project: ProjectConfig, filePath: string): Promise<void> {
|
async removeFileContext(project: ProjectConfig, filePath: string): Promise<void> {
|
||||||
try {
|
return await this.updateCacheSafelyAsync(project, async (cache) => {
|
||||||
const cache = await this.get(project);
|
if (cache.fileContexts && cache.fileContexts[filePath]) {
|
||||||
if (cache && cache.fileContexts[filePath]) {
|
|
||||||
// Get the cache key before removing from project cache
|
// Get the cache key before removing from project cache
|
||||||
const { cacheKey } = cache.fileContexts[filePath];
|
const { cacheKey } = cache.fileContexts[filePath];
|
||||||
|
|
||||||
// Remove from project cache
|
// Remove from project cache
|
||||||
delete cache.fileContexts[filePath];
|
delete cache.fileContexts[filePath];
|
||||||
await this.set(project, cache);
|
|
||||||
|
|
||||||
// Remove from file cache
|
// Remove from file cache
|
||||||
await this.fileCache.remove(cacheKey);
|
await this.fileCache.remove(cacheKey);
|
||||||
|
|
||||||
logInfo(`Removed file context for ${filePath} in project ${project.name}`);
|
logInfo(`Removed file context for ${filePath} in project ${project.name}`);
|
||||||
}
|
}
|
||||||
|
return cache;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search all existing projects for cached content of a file
|
||||||
|
* If found, migrate that content to universal cache and return it
|
||||||
|
*/
|
||||||
|
private async searchOtherProjectsForFile(
|
||||||
|
filePath: string
|
||||||
|
): Promise<{ cacheKey: string; content: string } | null> {
|
||||||
|
try {
|
||||||
|
const settings = getSettings();
|
||||||
|
const projects = settings.projectList || [];
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(`Searching other projects for file: ${filePath}`);
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
// Skip projects without cache
|
||||||
|
const cache = await this.get(project);
|
||||||
|
if (!cache || !cache.fileContexts) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this project has the file cached
|
||||||
|
if (cache.fileContexts[filePath]) {
|
||||||
|
const { cacheKey } = cache.fileContexts[filePath];
|
||||||
|
if (!cacheKey) continue;
|
||||||
|
|
||||||
|
// Try to get content from this project's cache
|
||||||
|
const content = await this.fileCache.get(cacheKey);
|
||||||
|
if (content) {
|
||||||
|
logInfo(`Found content for file ${filePath} in project ${project.name}`);
|
||||||
|
return { content, cacheKey };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(`No content found in any project for file: ${filePath}`);
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Error removing file context for ${filePath}:`, error);
|
logError(`Error searching other projects for file ${filePath}:`, error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update project file references based on inclusion/exclusion patterns.
|
* Associate an existing cache with a specific project, creates the project reference to that cache
|
||||||
* Removes references to files that no longer match patterns, but keeps their content cached.
|
|
||||||
*/
|
*/
|
||||||
async cleanupProjectFileReferences(project: ProjectConfig): Promise<void> {
|
async associateCacheWithProject(
|
||||||
try {
|
project: ProjectConfig,
|
||||||
const cache = await this.get(project);
|
filePath: string,
|
||||||
if (!cache || !cache.fileContexts) {
|
cacheKey: string
|
||||||
return;
|
): Promise<void> {
|
||||||
|
return await this.updateCacheSafelyAsync(project, async (cache) => {
|
||||||
|
if (!cache.fileContexts) {
|
||||||
|
cache.fileContexts = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { inclusions, exclusions } = getMatchingPatterns({
|
// Update project context to reference the other cache key directly
|
||||||
inclusions: project.contextSource.inclusions,
|
cache.fileContexts[filePath] = {
|
||||||
exclusions: project.contextSource.exclusions,
|
timestamp: Date.now(),
|
||||||
isProject: true,
|
cacheKey: cacheKey,
|
||||||
});
|
};
|
||||||
|
|
||||||
let removedCount = 0;
|
logInfo(`Associated cache with project ${project.name} for file: ${filePath}`);
|
||||||
const updatedFileContexts: typeof cache.fileContexts = {};
|
return cache;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check each file against the patterns
|
/**
|
||||||
for (const filePath in cache.fileContexts) {
|
* Helper method to perform file reference cleanup logic on a cache object
|
||||||
const file = this.vault.getAbstractFileByPath(filePath);
|
*/
|
||||||
|
private cleanupFileReferencesInCache(project: ProjectConfig, cache: ContextCache): ContextCache {
|
||||||
|
if (!cache.fileContexts) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inclusions, exclusions } = getMatchingPatterns({
|
||||||
|
inclusions: project.contextSource.inclusions,
|
||||||
|
exclusions: project.contextSource.exclusions,
|
||||||
|
isProject: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let removedCount = 0;
|
||||||
|
const updatedFileContexts: typeof cache.fileContexts = {};
|
||||||
|
|
||||||
|
// Check each file against the patterns
|
||||||
|
for (const filePath in cache.fileContexts) {
|
||||||
|
const file = this.vault.getAbstractFileByPath(filePath);
|
||||||
|
|
||||||
// If file no longer exists or doesn't match patterns, remove its reference
|
// If file no longer exists or doesn't match patterns, remove its reference
|
||||||
if (!(file instanceof TFile) || !shouldIndexFile(file, inclusions, exclusions, true)) {
|
if (!(file instanceof TFile) || !shouldIndexFile(file, inclusions, exclusions, true)) {
|
||||||
|
|
@ -534,14 +702,29 @@ export class ProjectContextCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if we actually removed something
|
// Only update if we actually removed something
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
cache.fileContexts = updatedFileContexts;
|
cache.fileContexts = updatedFileContexts;
|
||||||
await this.set(project, cache);
|
logInfo(
|
||||||
logInfo(
|
`Removed ${removedCount} file references from project ${project.name} that no longer match inclusion patterns`
|
||||||
`Removed ${removedCount} file references from project ${project.name} that no longer match inclusion patterns`
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project file references based on inclusion/exclusion patterns.
|
||||||
|
* Removes references to files that no longer match patterns, but keeps their content cached.
|
||||||
|
*/
|
||||||
|
async cleanupProjectFileReferences(project: ProjectConfig): Promise<void> {
|
||||||
|
logInfo(`[cleanupProjectFileReferences] Starting for project: ${project.name}`);
|
||||||
|
try {
|
||||||
|
await this.updateCacheSafely(
|
||||||
|
project,
|
||||||
|
(cache) => this.cleanupFileReferencesInCache(project, cache),
|
||||||
|
true
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(`Error cleaning up project file references for ${project.name}:`, error);
|
logError(`Error cleaning up project file references for ${project.name}:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -598,33 +781,82 @@ export class ProjectContextCache {
|
||||||
return contextCacheToUpdate;
|
return contextCacheToUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateProjectMarkdownFilesFromPatterns(
|
||||||
|
project: ProjectConfig,
|
||||||
|
contextCacheToUpdate: ContextCache,
|
||||||
|
projectAllFiles: TFile[]
|
||||||
|
): ContextCache {
|
||||||
|
try {
|
||||||
|
if (!contextCacheToUpdate.fileContexts) {
|
||||||
|
contextCacheToUpdate.fileContexts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = projectAllFiles.filter((file) => file.extension === "md");
|
||||||
|
let addedCount = 0;
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
if (contextCacheToUpdate.fileContexts[file.path]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cacheKey = this.fileCache.getCacheKey(file, project.id);
|
||||||
|
contextCacheToUpdate.fileContexts[file.path] = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
cacheKey,
|
||||||
|
};
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
logInfo(
|
||||||
|
`[updateProjectFilesFromPatterns] Project ${project.name}: Added ${addedCount} new file references to context (in memory).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
`[updateProjectFilesFromPatterns] Completed for project: ${project.name}. Total markdown fileContexts in memory: ${Object.keys(contextCacheToUpdate.fileContexts).length}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`[updateProjectFilesFromPatterns] Error for project ${project.name}:`, error);
|
||||||
|
}
|
||||||
|
return contextCacheToUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
//===========================================================================
|
//===========================================================================
|
||||||
// WEB CONTEXT OPERATIONS
|
// WEB CONTEXT OPERATIONS
|
||||||
//===========================================================================
|
//===========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a web URL from a project's context
|
* Remove a web URLs from a project's context
|
||||||
*/
|
*/
|
||||||
async removeWebUrl(project: ProjectConfig, url: string): Promise<void> {
|
|
||||||
const cache = await this.get(project);
|
async removeWebUrls(project: ProjectConfig, urls: string[]): Promise<void> {
|
||||||
if (cache?.webContexts?.[url]) {
|
if (!urls.length) return;
|
||||||
delete cache.webContexts[url];
|
|
||||||
await this.set(project, cache);
|
await this.updateCacheSafely(project, (cache) => {
|
||||||
logInfo(`Removed web context for URL ${url} in project ${project.name}`);
|
if (cache.webContexts) {
|
||||||
}
|
for (const url of urls) {
|
||||||
|
if (cache.webContexts[url]) {
|
||||||
|
delete cache.webContexts[url];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo(`Removed web contexts for URLs ${urls.join(", ")} in project ${project.name}`);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update a web URL in a project's context
|
* Add or update a web URL in a project's context
|
||||||
*/
|
*/
|
||||||
async updateWebUrl(project: ProjectConfig, url: string, content: string): Promise<void> {
|
async updateWebUrl(project: ProjectConfig, url: string, content: string): Promise<void> {
|
||||||
const cache = (await this.get(project)) || this.createEmptyCache();
|
return await this.updateCacheSafely(project, (cache) => {
|
||||||
if (!cache.webContexts) {
|
if (!cache.webContexts) {
|
||||||
cache.webContexts = {};
|
cache.webContexts = {};
|
||||||
}
|
}
|
||||||
cache.webContexts[url] = content;
|
cache.webContexts[url] = content;
|
||||||
await this.set(project, cache);
|
logInfo(`Updated web context for URL ${url} in project ${project.name}`);
|
||||||
logInfo(`Updated web context for URL ${url} in project ${project.name}`);
|
return cache;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//===========================================================================
|
//===========================================================================
|
||||||
|
|
@ -632,27 +864,123 @@ export class ProjectContextCache {
|
||||||
//===========================================================================
|
//===========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a YouTube URL from a project's context
|
* Remove a YouTube URLs from a project's context
|
||||||
*/
|
*/
|
||||||
async removeYoutubeUrl(project: ProjectConfig, url: string): Promise<void> {
|
|
||||||
const cache = await this.get(project);
|
async removeYoutubeUrls(project: ProjectConfig, urls: string[]): Promise<void> {
|
||||||
if (cache?.youtubeContexts?.[url]) {
|
if (!urls.length) return;
|
||||||
delete cache.youtubeContexts[url];
|
|
||||||
await this.set(project, cache);
|
await this.updateCacheSafely(project, (cache) => {
|
||||||
logInfo(`Removed YouTube context for URL ${url} in project ${project.name}`);
|
if (cache.youtubeContexts) {
|
||||||
}
|
for (const url of urls) {
|
||||||
|
if (cache.youtubeContexts[url]) {
|
||||||
|
delete cache.youtubeContexts[url];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo(
|
||||||
|
`removeYoutubeUrls: Removed YouTube contexts for URLs ${urls.join(", ")} in project ${project.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update a YouTube URL in a project's context
|
* Add or update a YouTube URL in a project's context
|
||||||
*/
|
*/
|
||||||
async updateYoutubeUrl(project: ProjectConfig, url: string, content: string): Promise<void> {
|
async updateYoutubeUrl(project: ProjectConfig, url: string, content: string): Promise<void> {
|
||||||
const cache = (await this.get(project)) || this.createEmptyCache();
|
return await this.updateCacheSafely(project, (cache) => {
|
||||||
if (!cache.youtubeContexts) {
|
if (!cache.youtubeContexts) {
|
||||||
cache.youtubeContexts = {};
|
cache.youtubeContexts = {};
|
||||||
}
|
}
|
||||||
cache.youtubeContexts[url] = content;
|
cache.youtubeContexts[url] = content;
|
||||||
await this.set(project, cache);
|
logInfo(`Updated YouTube context for URL ${url} in project ${project.name}`);
|
||||||
logInfo(`Updated YouTube context for URL ${url} in project ${project.name}`);
|
return cache;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//===========================================================================
|
||||||
|
// EXTERNAL SAFE OPERATIONS
|
||||||
|
//===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe external method for bulk cache updates
|
||||||
|
* This is for external modules that need to perform complex updates safely
|
||||||
|
* @param project
|
||||||
|
* @param updateFn
|
||||||
|
* @param skipIfEmpty - If true, skip the update when cache is empty instead of throwing an error
|
||||||
|
*/
|
||||||
|
async updateCacheSafely(
|
||||||
|
project: ProjectConfig,
|
||||||
|
updateFn: (cache: ContextCache) => ContextCache,
|
||||||
|
skipIfEmpty: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
const mutex = await this.getOrCreateProjectMutex(project);
|
||||||
|
|
||||||
|
return await mutex.runExclusive(async () => {
|
||||||
|
try {
|
||||||
|
const cache = await this.get(project);
|
||||||
|
if (!cache) {
|
||||||
|
if (skipIfEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Project: ${project.name} context cache not found, please invoke getOrInitializeCache method before invoke update context cache`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedCache = updateFn(cache);
|
||||||
|
await this.setWithoutMutex(project, updatedCache);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Error updating cache for project ${project.name}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe external method for async cache updates
|
||||||
|
* This is for external modules that need to perform async updates safely
|
||||||
|
* @param project
|
||||||
|
* @param updateFn
|
||||||
|
* @param skipIfEmpty - If true, skip the update when cache is empty instead of throwing an error
|
||||||
|
*/
|
||||||
|
async updateCacheSafelyAsync(
|
||||||
|
project: ProjectConfig,
|
||||||
|
updateFn: (cache: ContextCache) => Promise<ContextCache>,
|
||||||
|
skipIfEmpty: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
const mutex = await this.getOrCreateProjectMutex(project);
|
||||||
|
|
||||||
|
return await mutex.runExclusive(async () => {
|
||||||
|
try {
|
||||||
|
const cache = await this.get(project);
|
||||||
|
if (!cache) {
|
||||||
|
if (skipIfEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Project: ${project.name} context cache not found, please invoke getOrInitializeCache method before invoke update context cache`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updatedCache = await updateFn(cache);
|
||||||
|
await this.setWithoutMutex(project, updatedCache);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Error updating cache for project ${project.name}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe external method for setting complete cache
|
||||||
|
* Use this instead of direct set() calls from external modules
|
||||||
|
*/
|
||||||
|
async setCacheSafely(project: ProjectConfig, contextCache: ContextCache): Promise<void> {
|
||||||
|
const mutex = await this.getOrCreateProjectMutex(project);
|
||||||
|
|
||||||
|
return await mutex.runExclusive(async () => {
|
||||||
|
logInfo(`External safe set for project: ${project.name}`);
|
||||||
|
return await this.setWithoutMutex(project, contextCache);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
useModelKey,
|
useModelKey,
|
||||||
useSelectedTextContexts,
|
useSelectedTextContexts,
|
||||||
} from "@/aiParams";
|
} from "@/aiParams";
|
||||||
|
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
|
||||||
import { ChainType } from "@/chainFactory";
|
import { ChainType } from "@/chainFactory";
|
||||||
|
|
||||||
import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls";
|
import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls";
|
||||||
|
|
@ -30,6 +31,7 @@ import { err2String } from "@/utils";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import { Notice, TFile } from "obsidian";
|
import { Notice, TFile } from "obsidian";
|
||||||
import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import ProgressCard from "@/components/project/progress-card";
|
||||||
|
|
||||||
type ChatMode = "default" | "project";
|
type ChatMode = "default" | "project";
|
||||||
|
|
||||||
|
|
@ -66,7 +68,29 @@ const Chat: React.FC<ChatProps> = ({
|
||||||
const [includeActiveNote, setIncludeActiveNote] = useState(false);
|
const [includeActiveNote, setIncludeActiveNote] = useState(false);
|
||||||
const [selectedImages, setSelectedImages] = useState<File[]>([]);
|
const [selectedImages, setSelectedImages] = useState<File[]>([]);
|
||||||
const [showChatUI, setShowChatUI] = useState(false);
|
const [showChatUI, setShowChatUI] = useState(false);
|
||||||
|
// null: keep default behavior; true: show; false: hide
|
||||||
|
const [progressCardVisible, setProgressCardVisible] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const [selectedTextContexts] = useSelectedTextContexts();
|
const [selectedTextContexts] = useSelectedTextContexts();
|
||||||
|
const projectContextStatus = useProjectContextStatus();
|
||||||
|
|
||||||
|
// Calculate whether to show ProgressCard based on status and user preference
|
||||||
|
const shouldShowProgressCard = () => {
|
||||||
|
if (selectedChain !== ChainType.PROJECT_CHAIN) return false;
|
||||||
|
|
||||||
|
// If user has explicitly set visibility, respect that choice
|
||||||
|
if (progressCardVisible !== null) {
|
||||||
|
return progressCardVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior: show for loading/error, hide for success
|
||||||
|
return projectContextStatus === "loading" || projectContextStatus === "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset user preference when status changes to allow default behavior
|
||||||
|
useEffect(() => {
|
||||||
|
setProgressCardVisible(null);
|
||||||
|
}, [projectContextStatus]);
|
||||||
|
|
||||||
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
|
const [previousMode, setPreviousMode] = useState<ChainType | null>(null);
|
||||||
const [selectedChain, setSelectedChain] = useChainType();
|
const [selectedChain, setSelectedChain] = useChainType();
|
||||||
|
|
@ -535,38 +559,54 @@ const Chat: React.FC<ChatProps> = ({
|
||||||
onReplaceChat={setInputMessage}
|
onReplaceChat={setInputMessage}
|
||||||
showHelperComponents={selectedChain !== ChainType.PROJECT_CHAIN}
|
showHelperComponents={selectedChain !== ChainType.PROJECT_CHAIN}
|
||||||
/>
|
/>
|
||||||
<ChatControls
|
{shouldShowProgressCard() ? (
|
||||||
onNewChat={handleNewChat}
|
<div className="tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-rounded-xl">
|
||||||
onSaveAsNote={() => handleSaveAsNote()}
|
<ProgressCard
|
||||||
onLoadHistory={handleLoadHistory}
|
plugin={plugin}
|
||||||
onModeChange={(newMode) => {
|
setHiddenCard={() => {
|
||||||
setPreviousMode(selectedChain);
|
setProgressCardVisible(false);
|
||||||
// Hide chat UI when switching to project mode
|
}}
|
||||||
if (newMode === ChainType.PROJECT_CHAIN) {
|
/>
|
||||||
setShowChatUI(false);
|
</div>
|
||||||
}
|
) : (
|
||||||
}}
|
<>
|
||||||
/>
|
<ChatControls
|
||||||
<ChatInput
|
onNewChat={handleNewChat}
|
||||||
ref={inputRef}
|
onSaveAsNote={() => handleSaveAsNote()}
|
||||||
inputMessage={inputMessage}
|
onLoadHistory={handleLoadHistory}
|
||||||
setInputMessage={setInputMessage}
|
onModeChange={(newMode) => {
|
||||||
handleSendMessage={handleSendMessage}
|
setPreviousMode(selectedChain);
|
||||||
isGenerating={loading}
|
// Hide chat UI when switching to project mode
|
||||||
onStopGenerating={() => handleStopGenerating(ABORT_REASON.USER_STOPPED)}
|
if (newMode === ChainType.PROJECT_CHAIN) {
|
||||||
app={app}
|
setShowChatUI(false);
|
||||||
contextNotes={contextNotes}
|
}
|
||||||
setContextNotes={setContextNotes}
|
}}
|
||||||
includeActiveNote={includeActiveNote}
|
/>
|
||||||
setIncludeActiveNote={setIncludeActiveNote}
|
<ChatInput
|
||||||
mention={mention}
|
ref={inputRef}
|
||||||
selectedImages={selectedImages}
|
inputMessage={inputMessage}
|
||||||
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
|
setInputMessage={setInputMessage}
|
||||||
setSelectedImages={setSelectedImages}
|
handleSendMessage={handleSendMessage}
|
||||||
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
|
isGenerating={loading}
|
||||||
selectedTextContexts={selectedTextContexts}
|
onStopGenerating={() => handleStopGenerating(ABORT_REASON.USER_STOPPED)}
|
||||||
onRemoveSelectedText={handleRemoveSelectedText}
|
app={app}
|
||||||
/>
|
contextNotes={contextNotes}
|
||||||
|
setContextNotes={setContextNotes}
|
||||||
|
includeActiveNote={includeActiveNote}
|
||||||
|
setIncludeActiveNote={setIncludeActiveNote}
|
||||||
|
mention={mention}
|
||||||
|
selectedImages={selectedImages}
|
||||||
|
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
|
||||||
|
setSelectedImages={setSelectedImages}
|
||||||
|
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
|
||||||
|
selectedTextContexts={selectedTextContexts}
|
||||||
|
onRemoveSelectedText={handleRemoveSelectedText}
|
||||||
|
showProgressCard={() => {
|
||||||
|
setProgressCardVisible(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -597,6 +637,9 @@ const Chat: React.FC<ChatProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
showChatUI={(v) => setShowChatUI(v)}
|
showChatUI={(v) => setShowChatUI(v)}
|
||||||
|
onProjectClose={() => {
|
||||||
|
setProgressCardVisible(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { Plus, X } from "lucide-react";
|
import { AlertCircle, CheckCircle, CircleDashed, Loader2, Plus, X } from "lucide-react";
|
||||||
import { TFile } from "obsidian";
|
import { TFile } from "obsidian";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { SelectedTextContext } from "@/types/message";
|
import { SelectedTextContext } from "@/types/message";
|
||||||
|
import { ChainType } from "@/chainFactory";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useChainType } from "@/aiParams";
|
||||||
|
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
|
||||||
|
|
||||||
interface ChatContextMenuProps {
|
interface ChatContextMenuProps {
|
||||||
activeNote: TFile | null;
|
activeNote: TFile | null;
|
||||||
|
|
@ -14,6 +18,7 @@ interface ChatContextMenuProps {
|
||||||
onRemoveContext: (path: string) => void;
|
onRemoveContext: (path: string) => void;
|
||||||
onRemoveUrl: (url: string) => void;
|
onRemoveUrl: (url: string) => void;
|
||||||
onRemoveSelectedText?: (id: string) => void;
|
onRemoveSelectedText?: (id: string) => void;
|
||||||
|
showProgressCard: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContextNote({
|
function ContextNote({
|
||||||
|
|
@ -102,7 +107,11 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
||||||
onRemoveContext,
|
onRemoveContext,
|
||||||
onRemoveUrl,
|
onRemoveUrl,
|
||||||
onRemoveSelectedText,
|
onRemoveSelectedText,
|
||||||
|
showProgressCard,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [currentChain] = useChainType();
|
||||||
|
const contextStatus = useProjectContextStatus();
|
||||||
|
|
||||||
const uniqueNotes = React.useMemo(() => {
|
const uniqueNotes = React.useMemo(() => {
|
||||||
const notesMap = new Map(contextNotes.map((note) => [note.path, note]));
|
const notesMap = new Map(contextNotes.map((note) => [note.path, note]));
|
||||||
|
|
||||||
|
|
@ -125,6 +134,20 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
||||||
selectedTextContexts.length > 0 ||
|
selectedTextContexts.length > 0 ||
|
||||||
!!activeNote;
|
!!activeNote;
|
||||||
|
|
||||||
|
// Get contextStatus from the shared hook
|
||||||
|
const getContextStatusIcon = () => {
|
||||||
|
switch (contextStatus) {
|
||||||
|
case "success":
|
||||||
|
return <CheckCircle className="tw-size-4 tw-text-success" />;
|
||||||
|
case "loading":
|
||||||
|
return <Loader2 className="tw-size-4 tw-animate-spin tw-text-loading" />;
|
||||||
|
case "error":
|
||||||
|
return <AlertCircle className="tw-size-4 tw-text-error" />;
|
||||||
|
case "initial":
|
||||||
|
return <CircleDashed className="tw-size-4 tw-text-faint" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tw-flex tw-w-full tw-items-center tw-gap-1">
|
<div className="tw-flex tw-w-full tw-items-center tw-gap-1">
|
||||||
<div className="tw-flex tw-h-full tw-items-start">
|
<div className="tw-flex tw-h-full tw-items-start">
|
||||||
|
|
@ -166,6 +189,22 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentChain === ChainType.PROJECT_CHAIN && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<div className="">
|
||||||
|
<Button
|
||||||
|
variant="ghost2"
|
||||||
|
size="fit"
|
||||||
|
className="tw-text-muted"
|
||||||
|
onClick={() => showProgressCard()}
|
||||||
|
>
|
||||||
|
{getContextStatusIcon()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
ProjectConfig,
|
|
||||||
getCurrentProject,
|
getCurrentProject,
|
||||||
|
ProjectConfig,
|
||||||
subscribeToProjectChange,
|
subscribeToProjectChange,
|
||||||
useChainType,
|
useChainType,
|
||||||
useModelKey,
|
useModelKey,
|
||||||
|
|
@ -71,6 +71,7 @@ interface ChatInputProps {
|
||||||
disableModelSwitch?: boolean;
|
disableModelSwitch?: boolean;
|
||||||
selectedTextContexts?: SelectedTextContext[];
|
selectedTextContexts?: SelectedTextContext[];
|
||||||
onRemoveSelectedText?: (id: string) => void;
|
onRemoveSelectedText?: (id: string) => void;
|
||||||
|
showProgressCard: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
|
const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
|
||||||
|
|
@ -93,6 +94,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
|
||||||
disableModelSwitch,
|
disableModelSwitch,
|
||||||
selectedTextContexts,
|
selectedTextContexts,
|
||||||
onRemoveSelectedText,
|
onRemoveSelectedText,
|
||||||
|
showProgressCard,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -509,6 +511,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
|
||||||
onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))}
|
onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))}
|
||||||
selectedTextContexts={selectedTextContexts}
|
selectedTextContexts={selectedTextContexts}
|
||||||
onRemoveSelectedText={onRemoveSelectedText}
|
onRemoveSelectedText={onRemoveSelectedText}
|
||||||
|
showProgressCard={showProgressCard}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedImages.length > 0 && (
|
{selectedImages.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface ChatControlsProps {
|
||||||
onRemoveUrl: (url: string) => void;
|
onRemoveUrl: (url: string) => void;
|
||||||
selectedTextContexts?: SelectedTextContext[];
|
selectedTextContexts?: SelectedTextContext[];
|
||||||
onRemoveSelectedText?: (id: string) => void;
|
onRemoveSelectedText?: (id: string) => void;
|
||||||
|
showProgressCard: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContextControl: React.FC<ChatControlsProps> = ({
|
const ContextControl: React.FC<ChatControlsProps> = ({
|
||||||
|
|
@ -34,6 +35,7 @@ const ContextControl: React.FC<ChatControlsProps> = ({
|
||||||
onRemoveUrl,
|
onRemoveUrl,
|
||||||
selectedTextContexts,
|
selectedTextContexts,
|
||||||
onRemoveSelectedText,
|
onRemoveSelectedText,
|
||||||
|
showProgressCard,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedChain] = useChainType();
|
const [selectedChain] = useChainType();
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ const ContextControl: React.FC<ChatControlsProps> = ({
|
||||||
onRemoveUrl={onRemoveUrl}
|
onRemoveUrl={onRemoveUrl}
|
||||||
selectedTextContexts={selectedTextContexts}
|
selectedTextContexts={selectedTextContexts}
|
||||||
onRemoveSelectedText={onRemoveSelectedText}
|
onRemoveSelectedText={onRemoveSelectedText}
|
||||||
|
showProgressCard={showProgressCard}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export const ProjectList = memo(
|
||||||
showChatUI,
|
showChatUI,
|
||||||
onClose,
|
onClose,
|
||||||
inputRef,
|
inputRef,
|
||||||
|
onProjectClose,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
projects: ProjectConfig[];
|
projects: ProjectConfig[];
|
||||||
|
|
@ -142,6 +143,7 @@ export const ProjectList = memo(
|
||||||
showChatUI: (v: boolean) => void;
|
showChatUI: (v: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
onProjectClose: () => void;
|
||||||
}): React.ReactElement => {
|
}): React.ReactElement => {
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
const [showChatInput, setShowChatInput] = useState(false);
|
const [showChatInput, setShowChatInput] = useState(false);
|
||||||
|
|
@ -285,6 +287,7 @@ export const ProjectList = memo(
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
enableOrDisableProject(false);
|
enableOrDisableProject(false);
|
||||||
|
onProjectClose();
|
||||||
}}
|
}}
|
||||||
aria-label="Close Current Project"
|
aria-label="Close Current Project"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
218
src/components/project/progress-card.tsx
Normal file
218
src/components/project/progress-card.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { FailedItem, useProjectContextLoad } from "@/aiParams";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { TruncatedText } from "@/components/TruncatedText";
|
||||||
|
import CopilotPlugin from "@/main";
|
||||||
|
import { logError } from "@/logger";
|
||||||
|
|
||||||
|
interface ProgressCardProps {
|
||||||
|
plugin?: CopilotPlugin;
|
||||||
|
setHiddenCard: (hidden: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProgressCard({ plugin, setHiddenCard }: ProgressCardProps) {
|
||||||
|
const [contextLoadState] = useProjectContextLoad();
|
||||||
|
const totalFiles = contextLoadState.total;
|
||||||
|
const successFiles = contextLoadState.success;
|
||||||
|
const failedFiles = contextLoadState.failed;
|
||||||
|
const processingFiles = contextLoadState.processingFiles;
|
||||||
|
|
||||||
|
// Control file list expand/collapse state
|
||||||
|
const [isProcessingExpanded, setIsProcessingExpanded] = useState(false);
|
||||||
|
const [isFailedExpanded, setIsFailedExpanded] = useState(false);
|
||||||
|
|
||||||
|
const processedFilesLen = successFiles.length + failedFiles.length;
|
||||||
|
const progressPercentage =
|
||||||
|
totalFiles.length > 0 ? Math.round((processedFilesLen / totalFiles.length) * 100) : 0;
|
||||||
|
|
||||||
|
const getFailedItemDisplayName = (item: FailedItem): string => {
|
||||||
|
return item.path;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO(emt-lin): maybe use it in the future
|
||||||
|
/*const handleRetryAllFailed = () => {
|
||||||
|
console.log("Retrying all failed items");
|
||||||
|
};*/
|
||||||
|
|
||||||
|
const handleRetryFailedItem = async (item: FailedItem) => {
|
||||||
|
if (!plugin?.projectManager) {
|
||||||
|
logError("ProjectManager not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugin.projectManager.retryFailedItem(item);
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Error retrying failed item: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="tw-w-full tw-border tw-border-solid tw-border-border tw-bg-transparent tw-shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="tw-flex tw-items-center tw-justify-between tw-gap-2">
|
||||||
|
<div className="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<FileText className="tw-size-5" />
|
||||||
|
Context Loading
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost2"
|
||||||
|
className="tw-size-6 tw-p-0 tw-text-muted"
|
||||||
|
title="Close Progress Bar"
|
||||||
|
onClick={() => setHiddenCard(true)}
|
||||||
|
>
|
||||||
|
<X className="tw-size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="tw-space-y-6">
|
||||||
|
{/* Total progress display */}
|
||||||
|
<div className="tw-space-y-2">
|
||||||
|
<div className="tw-flex tw-items-center tw-justify-between tw-text-sm">
|
||||||
|
<div className="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<span className="tw-text-muted">Total progress</span>
|
||||||
|
<span className="tw-text-xs tw-text-muted">
|
||||||
|
(Success:{" "}
|
||||||
|
<span className="tw-font-medium tw-text-success">{successFiles.length}</span>,
|
||||||
|
Failed: <span className="tw-font-medium tw-text-error">{failedFiles.length}</span>)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="tw-font-medium">
|
||||||
|
{processedFilesLen}/{totalFiles.length} ({progressPercentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercentage} className="tw-h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Currently processing file */}
|
||||||
|
{processingFiles.length > 0 && (
|
||||||
|
<div className="tw-space-y-3">
|
||||||
|
<div
|
||||||
|
className="tw--m-1 tw-flex tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-p-1 tw-transition-colors hover:tw-bg-muted/10"
|
||||||
|
onClick={() => setIsProcessingExpanded(!isProcessingExpanded)}
|
||||||
|
>
|
||||||
|
<Loader2 className="tw-size-4 tw-animate-spin tw-text-accent" />
|
||||||
|
<span className="tw-text-sm tw-font-medium">Processing</span>
|
||||||
|
{isProcessingExpanded ? (
|
||||||
|
<ChevronDown className="tw-ml-auto tw-size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="tw-ml-auto tw-size-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isProcessingExpanded && (
|
||||||
|
<div className="tw-max-h-32 tw-space-y-2 tw-overflow-y-auto">
|
||||||
|
{processingFiles.map((fileName, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-p-2 tw-text-sm tw-bg-faint/10"
|
||||||
|
>
|
||||||
|
<div className="tw-size-2 tw-animate-pulse tw-rounded-full tw-bg-interactive-accent" />
|
||||||
|
<TruncatedText className="tw-flex-1" title={fileName}>
|
||||||
|
{fileName}
|
||||||
|
</TruncatedText>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failed to process the file */}
|
||||||
|
{failedFiles.length > 0 && (
|
||||||
|
<div className="tw-space-y-3">
|
||||||
|
<div className="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<div
|
||||||
|
className="-tw-m-1 tw-flex tw-flex-1 tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-p-1 tw-transition-colors hover:tw-bg-muted/10"
|
||||||
|
onClick={() => setIsFailedExpanded(!isFailedExpanded)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="tw-size-4 tw-text-error" />
|
||||||
|
<span className="tw-text-sm tw-font-medium">Failed</span>
|
||||||
|
<Badge variant="destructive" className="tw-text-xs">
|
||||||
|
{failedFiles.length} files
|
||||||
|
</Badge>
|
||||||
|
{isFailedExpanded ? (
|
||||||
|
<ChevronDown className="tw-ml-auto tw-size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="tw-ml-auto tw-size-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/*todo(emt-lin): in the future, we can add all failed files to retry*/}
|
||||||
|
{/*<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="tw-size-6 tw-bg-transparent tw-p-0"
|
||||||
|
title="Retry failed files"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Handle retry logic
|
||||||
|
handleRetryAllFailed()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="tw-size-3" />
|
||||||
|
</Button>*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFailedExpanded && (
|
||||||
|
<div className="tw-max-h-32 tw-space-y-2 tw-overflow-y-auto">
|
||||||
|
{failedFiles.map((failedItem: FailedItem, index: number) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-p-2 tw-text-sm tw-bg-faint/10"
|
||||||
|
>
|
||||||
|
<div className="tw-flex tw-min-w-0 tw-flex-1 tw-flex-col tw-gap-1">
|
||||||
|
<div className="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<div className="tw-size-2 tw-rounded-full tw-bg-error/80" />
|
||||||
|
<TruncatedText className="tw-flex-1 tw-font-bold" title={failedItem.path}>
|
||||||
|
{getFailedItemDisplayName(failedItem)}
|
||||||
|
</TruncatedText>
|
||||||
|
</div>
|
||||||
|
<div className="tw-flex tw-items-center tw-gap-2">
|
||||||
|
<div className="tw-size-2 tw-rounded-full" />
|
||||||
|
{failedItem.error && (
|
||||||
|
<TruncatedText
|
||||||
|
className="tw-flex-1 tw-text-xs tw-text-error/80"
|
||||||
|
title={failedItem.error}
|
||||||
|
>
|
||||||
|
<span className="tw-text-sm tw-text-error">Loading Error: </span>
|
||||||
|
{failedItem.error}
|
||||||
|
</TruncatedText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="tw-size-5 tw-p-0"
|
||||||
|
title={`Retry ${failedItem.type} item`}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await handleRetryFailedItem(failedItem);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="tw-size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"tw-relative tw-h-2 tw-w-full tw-overflow-hidden tw-rounded-full tw-bg-interactive-accent/20 tw-border-interactive-accent/30",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="tw-size-full tw-flex-1 tw-bg-interactive-accent tw-transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - Math.min(Math.max(value || 0, 0), 100)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
50
src/hooks/useProjectContextStatus.ts
Normal file
50
src/hooks/useProjectContextStatus.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { ChainType } from "@/chainFactory";
|
||||||
|
import { useChainType, useProjectLoading, useProjectContextLoad } from "@/aiParams";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to calculate the project context status based on project loading state and context load state.
|
||||||
|
* Returns one of: 'initial', 'loading', 'success', 'error'
|
||||||
|
*
|
||||||
|
* Status meanings:
|
||||||
|
* - 'initial': Project context loading has not started yet or not in project mode
|
||||||
|
* - 'loading': Project context is currently being loaded or files are being processed
|
||||||
|
* - 'success': All files have been processed successfully without any errors
|
||||||
|
* - 'error': One or more files have failed to process during the loading operation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProjectContextStatus = "initial" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export function useProjectContextStatus(): ProjectContextStatus {
|
||||||
|
const [currentChain] = useChainType();
|
||||||
|
const [isProjectLoading] = useProjectLoading();
|
||||||
|
const [contextLoadState] = useProjectContextLoad();
|
||||||
|
|
||||||
|
const contextStatus = (() => {
|
||||||
|
// Only calculate status for project mode
|
||||||
|
if (currentChain !== ChainType.PROJECT_CHAIN) {
|
||||||
|
return "initial";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { total, success, failed, processingFiles } = contextLoadState;
|
||||||
|
|
||||||
|
// Loading state: when project is loading or files are being processed
|
||||||
|
if (isProjectLoading || processingFiles.length > 0) {
|
||||||
|
return "loading";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state: when there are failed files
|
||||||
|
if (failed.length > 0) {
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state: when all files have been processed successfully
|
||||||
|
if (total.length > 0 && success.length === total.length) {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state: when no context loading has started yet
|
||||||
|
return "initial";
|
||||||
|
})();
|
||||||
|
|
||||||
|
return contextStatus;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { ImageProcessor } from "@/imageProcessing/imageProcessor";
|
import { ImageProcessor } from "@/imageProcessing/imageProcessor";
|
||||||
import { BrevilabsClient, Url4llmResponse } from "@/LLMProviders/brevilabsClient";
|
import { BrevilabsClient, Url4llmResponse } from "@/LLMProviders/brevilabsClient";
|
||||||
import { isYoutubeUrl } from "@/utils";
|
import { err2String, isYoutubeUrl } from "@/utils";
|
||||||
|
import { logError } from "@/logger";
|
||||||
|
|
||||||
export interface MentionData {
|
export interface MentionData {
|
||||||
type: string;
|
type: string;
|
||||||
original: string;
|
original: string;
|
||||||
processed?: string;
|
processed?: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Mention {
|
export class Mention {
|
||||||
|
|
@ -41,24 +43,30 @@ export class Mention {
|
||||||
.filter((url) => !isYoutubeUrl(url));
|
.filter((url) => !isYoutubeUrl(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
async processUrl(url: string): Promise<Url4llmResponse> {
|
async processUrl(url: string): Promise<Url4llmResponse & { error?: string }> {
|
||||||
try {
|
try {
|
||||||
return await this.brevilabsClient.url4llm(url);
|
return await this.brevilabsClient.url4llm(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing URL ${url}:`, error);
|
const msg = err2String(error);
|
||||||
return { response: url, elapsed_time_ms: 0 };
|
logError(`Error processing URL ${url}: ${msg}`);
|
||||||
|
return { response: url, elapsed_time_ms: 0, error: msg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-youtube URLs
|
// For non-youtube URLs
|
||||||
async processUrls(text: string): Promise<{ urlContext: string; imageUrls: string[] }> {
|
async processUrls(text: string): Promise<{
|
||||||
|
urlContext: string;
|
||||||
|
imageUrls: string[];
|
||||||
|
processedErrorUrls: Record<string, string>;
|
||||||
|
}> {
|
||||||
const urls = this.extractUrls(text);
|
const urls = this.extractUrls(text);
|
||||||
let urlContext = "";
|
let urlContext = "";
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
const processedErrorUrls: Record<string, string> = {};
|
||||||
|
|
||||||
// Return empty string if no URLs to process
|
// Return empty string if no URLs to process
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return { urlContext: "", imageUrls: [] };
|
return { urlContext, imageUrls, processedErrorUrls };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all URLs concurrently
|
// Process all URLs concurrently
|
||||||
|
|
@ -75,6 +83,7 @@ export class Mention {
|
||||||
type: "url",
|
type: "url",
|
||||||
original: url,
|
original: url,
|
||||||
processed: processed.response,
|
processed: processed.response,
|
||||||
|
error: processed.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.mentions.get(url);
|
return this.mentions.get(url);
|
||||||
|
|
@ -87,9 +96,13 @@ export class Mention {
|
||||||
if (urlData?.processed) {
|
if (urlData?.processed) {
|
||||||
urlContext += `\n\n<url_content>\n<url>${urlData.original}</url>\n<content>\n${urlData.processed}\n</content>\n</url_content>`;
|
urlContext += `\n\n<url_content>\n<url>${urlData.original}</url>\n<content>\n${urlData.processed}\n</content>\n</url_content>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlData?.error) {
|
||||||
|
processedErrorUrls[urlData.original] = urlData.error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { urlContext, imageUrls };
|
return { urlContext, imageUrls, processedErrorUrls };
|
||||||
}
|
}
|
||||||
|
|
||||||
getMentions(): Map<string, MentionData> {
|
getMentions(): Map<string, MentionData> {
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export function shouldIndexFile(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project:Only the included files need to be processed.
|
// Project: Only the included files need to be processed.
|
||||||
if (isProject && !inclusions) {
|
if (isProject && !inclusions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
/*
|
||||||
|
Copilot Plugin Css
|
||||||
|
This file is generated by Copilot plugin.
|
||||||
|
*/
|
||||||
@import "tailwindcss/base";
|
@import "tailwindcss/base";
|
||||||
@import "tailwindcss/components";
|
@import "tailwindcss/components";
|
||||||
@import "tailwindcss/utilities";
|
@import "tailwindcss/utilities";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,25 @@
|
||||||
import { ProjectConfig } from "@/aiParams";
|
import { ProjectConfig } from "@/aiParams";
|
||||||
import { ProjectContextCache } from "@/cache/projectContextCache";
|
import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
jest.mock("obsidian");
|
jest.mock("obsidian", () => ({
|
||||||
|
TFile: class MockTFile {
|
||||||
|
path: string;
|
||||||
|
extension: string;
|
||||||
|
basename: string;
|
||||||
|
stat: any;
|
||||||
|
|
||||||
|
constructor(path: string, extension: string, basename: string, stat: any) {
|
||||||
|
this.path = path;
|
||||||
|
this.extension = extension;
|
||||||
|
this.basename = basename;
|
||||||
|
this.stat = stat;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TAbstractFile: class MockTAbstractFile {},
|
||||||
|
Vault: class MockVault {},
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("@/logger", () => ({
|
jest.mock("@/logger", () => ({
|
||||||
logInfo: jest.fn(),
|
logInfo: jest.fn(),
|
||||||
logError: jest.fn(),
|
logError: jest.fn(),
|
||||||
|
|
@ -94,19 +111,16 @@ describe("ProjectContextCache", () => {
|
||||||
let mockProject: ProjectConfig;
|
let mockProject: ProjectConfig;
|
||||||
|
|
||||||
// Mock files
|
// Mock files
|
||||||
const mockMarkdownFile = {
|
const { TFile: MockedTFile } = jest.requireMock("obsidian");
|
||||||
path: "test/file.md",
|
const mockMarkdownFile = new MockedTFile("test/file.md", "md", "file", {
|
||||||
extension: "md",
|
mtime: Date.now(),
|
||||||
basename: "file",
|
size: 100,
|
||||||
stat: { mtime: Date.now(), size: 100 },
|
});
|
||||||
} as any;
|
|
||||||
|
|
||||||
const mockPdfFile = {
|
const mockPdfFile = new MockedTFile("test/document.pdf", "pdf", "document", {
|
||||||
path: "test/document.pdf",
|
mtime: Date.now(),
|
||||||
extension: "pdf",
|
size: 200,
|
||||||
basename: "document",
|
});
|
||||||
stat: { mtime: Date.now(), size: 200 },
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset mocks
|
// Reset mocks
|
||||||
|
|
@ -123,7 +137,12 @@ describe("ProjectContextCache", () => {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get actual instance
|
// Reset vault adapter mocks to default behavior
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(false);
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue("{}");
|
||||||
|
mockApp.vault.adapter.write.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Get actual instance and clear any existing cache
|
||||||
projectContextCache = ProjectContextCache.getInstance();
|
projectContextCache = ProjectContextCache.getInstance();
|
||||||
|
|
||||||
// Mock project with minimal properties for testing
|
// Mock project with minimal properties for testing
|
||||||
|
|
@ -141,11 +160,14 @@ describe("ProjectContextCache", () => {
|
||||||
const filePath = "test/document.pdf";
|
const filePath = "test/document.pdf";
|
||||||
const content = "PDF content";
|
const content = "PDF content";
|
||||||
|
|
||||||
|
// Initialize a default cache for testing
|
||||||
|
await projectContextCache.getOrInitializeCache(mockProject);
|
||||||
|
|
||||||
// Store content
|
// Store content
|
||||||
await projectContextCache.setFileContext(mockProject, filePath, content);
|
await projectContextCache.setFileContext(mockProject, filePath, content);
|
||||||
|
|
||||||
// Get content
|
// Get content
|
||||||
const retrievedContent = await projectContextCache.getFileContext(mockProject, filePath);
|
const retrievedContent = await projectContextCache.getOrReuseFileContext(mockProject, filePath);
|
||||||
|
|
||||||
// Verify the content was retrieved (note: implementation may return an object instead of string)
|
// Verify the content was retrieved (note: implementation may return an object instead of string)
|
||||||
expect(retrievedContent).toBeDefined();
|
expect(retrievedContent).toBeDefined();
|
||||||
|
|
@ -176,6 +198,9 @@ describe("ProjectContextCache", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should clean up project file references", async () => {
|
test("should clean up project file references", async () => {
|
||||||
|
// Initialize a default cache for testing
|
||||||
|
await projectContextCache.getOrInitializeCache(mockProject);
|
||||||
|
|
||||||
// First add some context
|
// First add some context
|
||||||
await projectContextCache.setFileContext(mockProject, mockPdfFile.path, "PDF content");
|
await projectContextCache.setFileContext(mockProject, mockPdfFile.path, "PDF content");
|
||||||
|
|
||||||
|
|
@ -187,4 +212,440 @@ describe("ProjectContextCache", () => {
|
||||||
const projectCache = await projectContextCache.get(mockProject);
|
const projectCache = await projectContextCache.get(mockProject);
|
||||||
expect(projectCache).toBeDefined();
|
expect(projectCache).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should update project markdown files from patterns", () => {
|
||||||
|
// Create an empty context cache to update
|
||||||
|
const contextCache = {
|
||||||
|
markdownContext: "",
|
||||||
|
markdownNeedsReload: true,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a test file list containing Markdown and non-Markdown files
|
||||||
|
const testFiles = [
|
||||||
|
{
|
||||||
|
path: "test/file1.md",
|
||||||
|
extension: "md",
|
||||||
|
basename: "file1",
|
||||||
|
stat: { mtime: Date.now(), size: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/file2.md",
|
||||||
|
extension: "md",
|
||||||
|
basename: "file2",
|
||||||
|
stat: { mtime: Date.now(), size: 200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "test/document.pdf",
|
||||||
|
extension: "pdf",
|
||||||
|
basename: "document",
|
||||||
|
stat: { mtime: Date.now(), size: 300 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Call the method, passing only Markdown files
|
||||||
|
const updatedCache = projectContextCache.updateProjectMarkdownFilesFromPatterns(
|
||||||
|
mockProject,
|
||||||
|
contextCache,
|
||||||
|
testFiles as any
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that only Markdown files were added to the cache
|
||||||
|
expect(Object.keys(updatedCache.fileContexts).length).toBe(2);
|
||||||
|
expect(updatedCache.fileContexts["test/file1.md"]).toBeDefined();
|
||||||
|
expect(updatedCache.fileContexts["test/file2.md"]).toBeDefined();
|
||||||
|
expect(updatedCache.fileContexts["test/document.pdf"]).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify that each file entry contains the necessary properties
|
||||||
|
Object.values(updatedCache.fileContexts).forEach((entry) => {
|
||||||
|
expect(entry).toHaveProperty("timestamp");
|
||||||
|
expect(entry).toHaveProperty("cacheKey");
|
||||||
|
expect(typeof entry.timestamp).toBe("number");
|
||||||
|
expect(typeof entry.cacheKey).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update and remove web URLs", async () => {
|
||||||
|
// Create initial context cache
|
||||||
|
const initialCache = {
|
||||||
|
markdownContext: "",
|
||||||
|
markdownNeedsReload: true,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock vault to return our initial cache
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(true);
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
|
||||||
|
|
||||||
|
// Update Web URL
|
||||||
|
const testUrl = "https://example.com/test";
|
||||||
|
const testContent = "Example web content";
|
||||||
|
await projectContextCache.updateWebUrl(mockProject, testUrl, testContent);
|
||||||
|
|
||||||
|
// Verify write was called
|
||||||
|
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Reset mock to return the updated cache
|
||||||
|
const updatedCache = {
|
||||||
|
...initialCache,
|
||||||
|
webContexts: { [testUrl]: testContent },
|
||||||
|
};
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(updatedCache));
|
||||||
|
|
||||||
|
// Add another URL
|
||||||
|
const testUrl2 = "https://example.com/test2";
|
||||||
|
const testContent2 = "Another example";
|
||||||
|
await projectContextCache.updateWebUrl(mockProject, testUrl2, testContent2);
|
||||||
|
|
||||||
|
// Remove URL
|
||||||
|
const urlsToRemove = [testUrl];
|
||||||
|
await projectContextCache.removeWebUrls(mockProject, urlsToRemove);
|
||||||
|
|
||||||
|
// Final cache should only contain the second URL
|
||||||
|
const finalCache = {
|
||||||
|
...initialCache,
|
||||||
|
webContexts: { [testUrl2]: testContent2 },
|
||||||
|
};
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(finalCache));
|
||||||
|
|
||||||
|
// Get cache check results
|
||||||
|
const resultCache = await projectContextCache.get(mockProject);
|
||||||
|
expect(resultCache).toBeDefined();
|
||||||
|
expect(resultCache?.webContexts[testUrl]).toBeUndefined();
|
||||||
|
expect(resultCache?.webContexts[testUrl2]).toBe(testContent2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update and remove YouTube URLs", async () => {
|
||||||
|
// Create initial context cache
|
||||||
|
const initialCache = {
|
||||||
|
markdownContext: "",
|
||||||
|
markdownNeedsReload: true,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock vault to return our initial cache
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(true);
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
|
||||||
|
|
||||||
|
// Update YouTube URL
|
||||||
|
const testYoutubeUrl = "https://youtube.com/watch?v=test123";
|
||||||
|
const testYoutubeContent = "Test YouTube transcript";
|
||||||
|
await projectContextCache.updateYoutubeUrl(mockProject, testYoutubeUrl, testYoutubeContent);
|
||||||
|
|
||||||
|
// Verify write was called
|
||||||
|
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Reset mock to return the updated cache
|
||||||
|
const updatedCache = {
|
||||||
|
...initialCache,
|
||||||
|
youtubeContexts: { [testYoutubeUrl]: testYoutubeContent },
|
||||||
|
};
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(updatedCache));
|
||||||
|
|
||||||
|
// Add another URL
|
||||||
|
const testYoutubeUrl2 = "https://youtube.com/watch?v=test456";
|
||||||
|
const testYoutubeContent2 = "Another YouTube transcript";
|
||||||
|
await projectContextCache.updateYoutubeUrl(mockProject, testYoutubeUrl2, testYoutubeContent2);
|
||||||
|
|
||||||
|
// Remove URL
|
||||||
|
const urlsToRemove = [testYoutubeUrl];
|
||||||
|
await projectContextCache.removeYoutubeUrls(mockProject, urlsToRemove);
|
||||||
|
|
||||||
|
// Final cache should only contain the second URL
|
||||||
|
const finalCache = {
|
||||||
|
...initialCache,
|
||||||
|
youtubeContexts: { [testYoutubeUrl2]: testYoutubeContent2 },
|
||||||
|
};
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(finalCache));
|
||||||
|
|
||||||
|
// Get cache check results
|
||||||
|
const resultCache = await projectContextCache.get(mockProject);
|
||||||
|
expect(resultCache).toBeDefined();
|
||||||
|
expect(resultCache?.youtubeContexts[testYoutubeUrl]).toBeUndefined();
|
||||||
|
expect(resultCache?.youtubeContexts[testYoutubeUrl2]).toBe(testYoutubeContent2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should safely update cache with updateCacheSafely", async () => {
|
||||||
|
// Create initial context cache
|
||||||
|
const initialCache = {
|
||||||
|
markdownContext: "Initial markdown content",
|
||||||
|
markdownNeedsReload: false,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock cache existence
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(true);
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
|
||||||
|
|
||||||
|
// Define update function
|
||||||
|
const updateFn = (cache: any) => {
|
||||||
|
cache.markdownContext = "Updated markdown content";
|
||||||
|
cache.markdownNeedsReload = true;
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute safe update
|
||||||
|
await projectContextCache.updateCacheSafely(mockProject, updateFn);
|
||||||
|
|
||||||
|
// Verify write was called
|
||||||
|
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Check that written content contains updated values
|
||||||
|
const writeCall = mockApp.vault.adapter.write.mock.calls[0];
|
||||||
|
const writtenContent = JSON.parse(writeCall[1]);
|
||||||
|
expect(writtenContent.markdownContext).toBe("Updated markdown content");
|
||||||
|
expect(writtenContent.markdownNeedsReload).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should safely update cache with updateCacheSafelyAsync", async () => {
|
||||||
|
// Create initial context cache
|
||||||
|
const initialCache = {
|
||||||
|
markdownContext: "Initial markdown content",
|
||||||
|
markdownNeedsReload: false,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock cache existence
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(true);
|
||||||
|
mockApp.vault.adapter.read.mockResolvedValue(JSON.stringify(initialCache));
|
||||||
|
|
||||||
|
// Define async update function
|
||||||
|
const asyncUpdateFn = async (cache: any) => {
|
||||||
|
// Simulate async operation
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
cache.markdownContext = "Async updated content";
|
||||||
|
cache.webContexts = { "https://example.com": "Async web content" };
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute async safe update
|
||||||
|
await projectContextCache.updateCacheSafelyAsync(mockProject, asyncUpdateFn);
|
||||||
|
|
||||||
|
// Verify write was called
|
||||||
|
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Check that written content contains updated values
|
||||||
|
const writeCall = mockApp.vault.adapter.write.mock.calls[0];
|
||||||
|
const writtenContent = JSON.parse(writeCall[1]);
|
||||||
|
expect(writtenContent.markdownContext).toBe("Async updated content");
|
||||||
|
expect(writtenContent.webContexts["https://example.com"]).toBe("Async web content");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle skipIfEmpty parameter correctly in updateCacheSafely", async () => {
|
||||||
|
// Create a new project instance to ensure cache isolation
|
||||||
|
const isolatedProject = {
|
||||||
|
id: "isolated-test-project-id",
|
||||||
|
name: "Isolated Test Project",
|
||||||
|
contextSource: {
|
||||||
|
inclusions: "**/*.md, **/*.pdf",
|
||||||
|
exclusions: "",
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock cache non-existence for this isolated project
|
||||||
|
mockApp.vault.adapter.exists.mockImplementation((path) => {
|
||||||
|
if (path.includes("isolated-test-project-id")) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define update function
|
||||||
|
const updateFn = jest.fn((cache: any) => {
|
||||||
|
cache.markdownContext = "Should not be called";
|
||||||
|
return cache;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute safe update with skipIfEmpty=true
|
||||||
|
await projectContextCache.updateCacheSafely(isolatedProject, updateFn, true);
|
||||||
|
|
||||||
|
// Verify updateFn was not called
|
||||||
|
expect(updateFn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should throw error when skipIfEmpty=false
|
||||||
|
await expect(
|
||||||
|
projectContextCache.updateCacheSafely(isolatedProject, updateFn, false)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle concurrent updates with updateCacheSafely", async () => {
|
||||||
|
// Create initial context cache with proper types
|
||||||
|
const initialCache: ContextCache = {
|
||||||
|
markdownContext: "Initial markdown content",
|
||||||
|
markdownNeedsReload: false,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock cache storage to simulate real persistence
|
||||||
|
let currentCache = initialCache;
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(true);
|
||||||
|
mockApp.vault.adapter.read.mockImplementation(() => {
|
||||||
|
return Promise.resolve(JSON.stringify(currentCache));
|
||||||
|
});
|
||||||
|
mockApp.vault.adapter.write.mockImplementation((path, content) => {
|
||||||
|
currentCache = JSON.parse(content);
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track execution order and update timing
|
||||||
|
const executionOrder: string[] = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Create update functions to modify different parts of the cache
|
||||||
|
const updateMarkdown = (cache: any) => {
|
||||||
|
executionOrder.push(`markdown-${Date.now() - startTime}`);
|
||||||
|
cache.markdownContext = "Updated markdown content";
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWeb = (cache: any) => {
|
||||||
|
executionOrder.push(`web-${Date.now() - startTime}`);
|
||||||
|
cache.webContexts = { "https://example.com": "Web content" };
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateYoutube = (cache: any) => {
|
||||||
|
executionOrder.push(`youtube-${Date.now() - startTime}`);
|
||||||
|
cache.youtubeContexts = { "https://youtube.com/test": "YouTube content" };
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute concurrent updates
|
||||||
|
await Promise.all([
|
||||||
|
projectContextCache.updateCacheSafely(mockProject, updateMarkdown),
|
||||||
|
projectContextCache.updateCacheSafely(mockProject, updateWeb),
|
||||||
|
projectContextCache.updateCacheSafely(mockProject, updateYoutube),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify write was called three times
|
||||||
|
expect(mockApp.vault.adapter.write).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// Verify that all update functions were executed
|
||||||
|
expect(executionOrder.length).toBe(3);
|
||||||
|
|
||||||
|
// Verify all updates were applied to the final cache
|
||||||
|
expect(currentCache.markdownContext).toBe("Updated markdown content");
|
||||||
|
expect(currentCache.webContexts && currentCache.webContexts["https://example.com"]).toBe(
|
||||||
|
"Web content"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
currentCache.youtubeContexts && currentCache.youtubeContexts["https://youtube.com/test"]
|
||||||
|
).toBe("YouTube content");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle concurrent updates with updateCacheSafelyAsync", async () => {
|
||||||
|
// Create initial context cache with proper types
|
||||||
|
const initialCache: ContextCache = {
|
||||||
|
markdownContext: "Initial markdown content",
|
||||||
|
markdownNeedsReload: false,
|
||||||
|
webContexts: {},
|
||||||
|
youtubeContexts: {},
|
||||||
|
fileContexts: {},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock cache storage to simulate real persistence
|
||||||
|
let currentCache = initialCache;
|
||||||
|
mockApp.vault.adapter.exists.mockResolvedValue(true);
|
||||||
|
mockApp.vault.adapter.read.mockImplementation(() => {
|
||||||
|
return Promise.resolve(JSON.stringify(currentCache));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track execution order, write calls and timing
|
||||||
|
const executionOrder: string[] = [];
|
||||||
|
const writeOrder: string[] = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
mockApp.vault.adapter.write.mockImplementation((path, content) => {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
currentCache = parsed;
|
||||||
|
|
||||||
|
// Track what was written and when
|
||||||
|
if (parsed.markdownContext === "Async updated markdown") {
|
||||||
|
writeOrder.push(`markdown-${Date.now() - startTime}`);
|
||||||
|
} else if (
|
||||||
|
parsed.webContexts &&
|
||||||
|
parsed.webContexts["https://example.com"] === "Async web content"
|
||||||
|
) {
|
||||||
|
writeOrder.push(`web-${Date.now() - startTime}`);
|
||||||
|
} else if (
|
||||||
|
parsed.youtubeContexts &&
|
||||||
|
parsed.youtubeContexts["https://youtube.com/test"] === "Async YouTube content"
|
||||||
|
) {
|
||||||
|
writeOrder.push(`youtube-${Date.now() - startTime}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create async update functions with different delays
|
||||||
|
const updateMarkdownAsync = async (cache: any) => {
|
||||||
|
const startMark = Date.now() - startTime;
|
||||||
|
executionOrder.push(`markdown-start-${startMark}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
cache.markdownContext = "Async updated markdown";
|
||||||
|
executionOrder.push(`markdown-end-${Date.now() - startTime}`);
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWebAsync = async (cache: any) => {
|
||||||
|
const startMark = Date.now() - startTime;
|
||||||
|
executionOrder.push(`web-start-${startMark}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
cache.webContexts = { "https://example.com": "Async web content" };
|
||||||
|
executionOrder.push(`web-end-${Date.now() - startTime}`);
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateYoutubeAsync = async (cache: any) => {
|
||||||
|
const startMark = Date.now() - startTime;
|
||||||
|
executionOrder.push(`youtube-start-${startMark}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
cache.youtubeContexts = { "https://youtube.com/test": "Async YouTube content" };
|
||||||
|
executionOrder.push(`youtube-end-${Date.now() - startTime}`);
|
||||||
|
return cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute concurrent async updates
|
||||||
|
await Promise.all([
|
||||||
|
projectContextCache.updateCacheSafelyAsync(mockProject, updateMarkdownAsync),
|
||||||
|
projectContextCache.updateCacheSafelyAsync(mockProject, updateWebAsync),
|
||||||
|
projectContextCache.updateCacheSafelyAsync(mockProject, updateYoutubeAsync),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify all writes occurred
|
||||||
|
expect(mockApp.vault.adapter.write).toHaveBeenCalledTimes(3);
|
||||||
|
expect(writeOrder.length).toBe(3);
|
||||||
|
|
||||||
|
// Verify all updates were applied to the final cache
|
||||||
|
expect(currentCache.markdownContext).toBe("Async updated markdown");
|
||||||
|
expect(currentCache.webContexts && currentCache.webContexts["https://example.com"]).toBe(
|
||||||
|
"Async web content"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
currentCache.youtubeContexts && currentCache.youtubeContexts["https://youtube.com/test"]
|
||||||
|
).toBe("Async YouTube content");
|
||||||
|
|
||||||
|
// Log order details for debugging
|
||||||
|
// console.log("Execution order:", executionOrder);
|
||||||
|
// console.log("Write order:", writeOrder);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ export class Docs4LLMParser implements FileParser {
|
||||||
throw new Error("No project context provided for file parsing");
|
throw new Error("No project context provided for file parsing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedContent = await this.projectContextCache.getFileContext(
|
const cachedContent = await this.projectContextCache.getOrReuseFileContext(
|
||||||
this.currentProject,
|
this.currentProject,
|
||||||
file.path
|
file.path
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ module.exports = {
|
||||||
"on-accent-inverted": "var(--text-on-accent-inverted)",
|
"on-accent-inverted": "var(--text-on-accent-inverted)",
|
||||||
success: "var(--text-success)",
|
success: "var(--text-success)",
|
||||||
warning: "var(--text-warning)",
|
warning: "var(--text-warning)",
|
||||||
|
loading: "var(--color-blue)",
|
||||||
error: "var(--text-error)",
|
error: "var(--text-error)",
|
||||||
accent: "var(--text-accent)",
|
accent: "var(--text-accent)",
|
||||||
"accent-hover": "var(--text-accent-hover)",
|
"accent-hover": "var(--text-accent-hover)",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue