From 1694357823e66e1584828495f6af24f69926655e Mon Sep 17 00:00:00 2001 From: wyh Date: Wed, 19 Feb 2025 14:45:22 +0800 Subject: [PATCH] feat: Support project-based feature. --- package-lock.json | 150 +++++++ package.json | 1 + src/LLMProviders/chainManager.ts | 141 +++++-- src/LLMProviders/chainRunner.ts | 35 +- src/LLMProviders/chatModelManager.ts | 5 +- src/LLMProviders/intentAnalyzer.ts | 4 +- src/LLMProviders/projectManager.ts | 298 +++++++++++++ src/LLMProviders/promptManager.ts | 15 +- src/aiParams.ts | 70 ++++ src/chainFactory.ts | 1 + src/chainUtils.ts | 7 +- src/commands/index.ts | 8 +- src/components/Chat.tsx | 123 +++++- src/components/CopilotView.tsx | 44 +- .../chat-components/ChatControls.tsx | 75 +++- src/components/chat-components/ChatInput.tsx | 134 ++++-- .../chat-components/ChatMessages.tsx | 10 +- .../chat-components/ProjectList.tsx | 369 ++++++++++++++++ .../chat-components/SuggestedPrompts.tsx | 1 + src/components/modals/AddProjectModal.tsx | 393 ++++++++++++++++++ src/components/modals/InlineEditModal.tsx | 6 +- src/components/ui/obsidian-native-select.tsx | 59 +++ src/components/ui/tabs.tsx | 55 +++ src/constants.ts | 1 + src/main.ts | 18 +- src/search/hybridRetriever.ts | 7 +- src/search/searchUtils.ts | 18 +- src/settings/model.ts | 3 +- src/settings/v2/components/ApiKeyDialog.tsx | 4 +- src/settings/v2/components/BasicSettings.tsx | 1 + src/settings/v2/components/ModelSettings.tsx | 6 +- src/sharedState.ts | 14 +- src/styles/tailwind.css | 10 - src/utils.ts | 4 + 34 files changed, 1915 insertions(+), 175 deletions(-) create mode 100644 src/LLMProviders/projectManager.ts create mode 100644 src/components/chat-components/ProjectList.tsx create mode 100644 src/components/modals/AddProjectModal.tsx create mode 100644 src/components/ui/obsidian-native-select.tsx create mode 100644 src/components/ui/tabs.tsx diff --git a/package-lock.json b/package-lock.json index 7a0cc291..7506e00f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.6", "@tabler/icons-react": "^2.14.0", "async-mutex": "^0.5.0", @@ -6518,6 +6519,155 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "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-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "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-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "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-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "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-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "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-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "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-tooltip": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", diff --git a/package.json b/package.json index e62b3619..4a2e384a 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.6", "@tabler/icons-react": "^2.14.0", "async-mutex": "^0.5.0", diff --git a/src/LLMProviders/chainManager.ts b/src/LLMProviders/chainManager.ts index d7155b76..beccf0f6 100644 --- a/src/LLMProviders/chainManager.ts +++ b/src/LLMProviders/chainManager.ts @@ -1,17 +1,17 @@ import { getChainType, + getCurrentProject, getModelKey, SetChainOptions, setChainType, - subscribeToChainTypeChange, - subscribeToModelKeyChange, } from "@/aiParams"; import ChainFactory, { ChainType, Document } from "@/chainFactory"; -import { BUILTIN_CHAT_MODELS, USER_SENDER, VAULT_VECTOR_STORE_STRATEGY } from "@/constants"; +import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants"; import { ChainRunner, CopilotPlusChainRunner, LLMChainRunner, + ProjectChainRunner, VaultQAChainRunner, } from "@/LLMProviders/chainRunner"; import { logError, logInfo } from "@/logger"; @@ -32,17 +32,27 @@ import MemoryManager from "./memoryManager"; import PromptManager from "./promptManager"; export default class ChainManager { - private static chain: RunnableSequence; - private static retrievalChain: RunnableSequence; + private chain: RunnableSequence; + private retrievalChain: RunnableSequence; + private retrievedDocuments: Document[] = []; + + public getRetrievedDocuments(): Document[] { + return this.retrievedDocuments; + } public app: App; public vectorStoreManager: VectorStoreManager; public chatModelManager: ChatModelManager; public memoryManager: MemoryManager; public promptManager: PromptManager; - public static retrievedDocuments: Document[] = []; + + // A chat history that stores the messages sent and received + // Only reset when the user explicitly clicks "New Chat" + private chatMessages: ChatMessage[] = []; constructor(app: App, vectorStoreManager: VectorStoreManager) { + this.chatMessages = []; + // Instantiate singletons this.app = app; this.vectorStoreManager = vectorStoreManager; @@ -53,29 +63,21 @@ export default class ChainManager { // Initialize async operations this.initialize(); - // Set up subscriptions - subscribeToModelKeyChange(async () => await this.createChainWithNewModel()); - subscribeToChainTypeChange(() => - this.setChain(getChainType(), { - refreshIndex: - getSettings().indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH && - (getChainType() === ChainType.VAULT_QA_CHAIN || - getChainType() === ChainType.COPILOT_PLUS_CHAIN), - }) - ); - subscribeToSettingsChange(async () => await this.createChainWithNewModel()); + subscribeToSettingsChange(async () => { + await this.createChainWithNewModel(); + }); } private async initialize() { await this.createChainWithNewModel(); } - static getChain(): RunnableSequence { - return ChainManager.chain; + public getChain(): RunnableSequence { + return this.chain; } - static getRetrievalChain(): RunnableSequence { - return ChainManager.retrievalChain; + public getRetrievalChain(): RunnableSequence { + return this.retrievalChain; } private validateChainType(chainType: ChainType): void { @@ -92,35 +94,60 @@ export default class ChainManager { } private validateChainInitialization() { - if (!ChainManager.chain || !isSupportedChain(ChainManager.chain)) { + if (!this.chain || !isSupportedChain(this.chain)) { console.error("Chain is not initialized properly, re-initializing chain: ", getChainType()); - this.setChain(getChainType()); + this.createChainWithNewModel({}, false); + // this.setChain(getChainType()); } } - static storeRetrieverDocuments(documents: Document[]) { - ChainManager.retrievedDocuments = documents; + public storeRetrieverDocuments(documents: Document[]) { + this.retrievedDocuments = documents; } /** * Update the active model and create a new chain with the specified model * name. */ - async createChainWithNewModel(): Promise { - let newModelKey = getModelKey(); + async createChainWithNewModel( + options: SetChainOptions = {}, + neededReInitChatMode: boolean = true + ): Promise { + const chainType = getChainType(); + const currentProject = getCurrentProject(); + + if (chainType === ChainType.PROJECT_CHAIN && !currentProject) { + return; + } + + let newModelKey = + chainType === ChainType.PROJECT_CHAIN ? currentProject?.projectModelKey : getModelKey(); + + if (!newModelKey) { + new Notice("No model key found"); + throw new Error("No model key found"); + } + try { - let customModel = findCustomModel(newModelKey, getSettings().activeModels); - if (!customModel) { - // Reset default model if no model is found - console.error("Resetting default model. No model configuration found for: ", newModelKey); - customModel = BUILTIN_CHAT_MODELS[0]; - newModelKey = customModel.name + "|" + customModel.provider; + if (neededReInitChatMode) { + let customModel = findCustomModel(newModelKey, getSettings().activeModels); + if (!customModel) { + // Reset default model if no model is found + console.error("Resetting default model. No model configuration found for: ", newModelKey); + customModel = BUILTIN_CHAT_MODELS[0]; + newModelKey = customModel.name + "|" + customModel.provider; + } + const mergedModel = { + ...customModel, + ...currentProject?.modelConfigs, + }; + await this.chatModelManager.setChatModel(mergedModel); } - await this.chatModelManager.setChatModel(customModel); + // Must update the chatModel for chain because ChainFactory always // retrieves the old chain without the chatModel change if it exists! // Create a new chain with the new chatModel - this.setChain(getChainType()); + this.setChain(chainType, options); logInfo(`Setting model to ${newModelKey}`); } catch (error) { logError(`createChainWithNewModel failed: ${error}`); @@ -143,7 +170,7 @@ export default class ChainManager { switch (chainType) { case ChainType.LLM_CHAIN: { - ChainManager.chain = ChainFactory.createNewLLMChain({ + this.chain = ChainFactory.createNewLLMChain({ llm: chatModel, memory: memory, prompt: options.prompt || chatPrompt, @@ -164,13 +191,13 @@ export default class ChainManager { }); // Create new conversational retrieval chain - ChainManager.retrievalChain = ChainFactory.createConversationalRetrievalChain( + this.retrievalChain = ChainFactory.createConversationalRetrievalChain( { llm: chatModel, retriever: retriever, systemMessage: getSystemPrompt(), }, - ChainManager.storeRetrieverDocuments.bind(ChainManager), + this.storeRetrieverDocuments.bind(this), getSettings().debug ); @@ -185,7 +212,7 @@ export default class ChainManager { case ChainType.COPILOT_PLUS_CHAIN: { // For initial load of the plugin await this.initializeQAChain(options); - ChainManager.chain = ChainFactory.createNewLLMChain({ + this.chain = ChainFactory.createNewLLMChain({ llm: chatModel, memory: memory, prompt: options.prompt || chatPrompt, @@ -196,6 +223,19 @@ export default class ChainManager { break; } + case ChainType.PROJECT_CHAIN: { + // For initial load of the plugin + await this.initializeQAChain(options); + this.chain = ChainFactory.createNewLLMChain({ + llm: chatModel, + memory: memory, + prompt: options.prompt || chatPrompt, + abortController: options.abortController, + }) as RunnableSequence; + setChainType(ChainType.PROJECT_CHAIN); + break; + } + default: this.validateChainType(chainType); break; @@ -211,6 +251,8 @@ export default class ChainManager { return new VaultQAChainRunner(this); case ChainType.COPILOT_PLUS_CHAIN: return new CopilotPlusChainRunner(this); + case ChainType.PROJECT_CHAIN: + return new ProjectChainRunner(this); default: throw new Error(`Unsupported chain type: ${chainType}`); } @@ -259,9 +301,10 @@ export default class ChainManager { ]); } - this.setChain(getChainType(), { + this.createChainWithNewModel({ prompt: effectivePrompt }, false); + /*this.setChain(getChainType(), { prompt: effectivePrompt, - }); + });*/ } const chainRunner = this.getChainRunner(); @@ -286,4 +329,20 @@ export default class ChainManager { } } } + + public clearHistory() { + this.chatMessages = []; + } + + public getChatMessages(): ChatMessage[] { + return this.chatMessages; + } + + public setChatMessages(messages: ChatMessage[]) { + this.chatMessages = [...messages]; + } + + public addChatMessage(message: ChatMessage) { + this.chatMessages.push(message); + } } diff --git a/src/LLMProviders/chainRunner.ts b/src/LLMProviders/chainRunner.ts index 145bb13b..987e3d52 100644 --- a/src/LLMProviders/chainRunner.ts +++ b/src/LLMProviders/chainRunner.ts @@ -27,6 +27,8 @@ import { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { Notice } from "obsidian"; import ChainManager from "./chainManager"; import { COPILOT_TOOL_NAMES, IntentAnalyzer } from "./intentAnalyzer"; +import { getCurrentProject } from "@/aiParams"; +import ProjectManager from "./projectManager"; export interface ChainRunner { run( @@ -164,7 +166,7 @@ class LLMChainRunner extends BaseChainRunner { let fullAIResponse = ""; try { - const chain = ChainManager.getChain(); + const chain = this.chainManager.getChain(); const chatStream = await chain.stream({ input: userMessage.message, } as any); @@ -221,7 +223,7 @@ class VaultQAChainRunner extends BaseChainRunner { const memory = this.chainManager.memoryManager.getMemory(); const memoryVariables = await memory.loadMemoryVariables({}); const chatHistory = extractChatHistory(memoryVariables); - const qaStream = await ChainManager.getRetrievalChain().stream({ + const qaStream = await this.chainManager.getRetrievalChain().stream({ question: userMessage.message, chat_history: chatHistory, } as any); @@ -248,7 +250,7 @@ class VaultQAChainRunner extends BaseChainRunner { } private addSourcestoResponse(response: string): string { - const docTitles = extractUniqueTitlesFromDocs(ChainManager.retrievedDocuments); + const docTitles = extractUniqueTitlesFromDocs(this.chainManager.getRetrievedDocuments()); if (docTitles.length > 0) { const links = docTitles.map((title) => `- [[${title}]]`).join("\n"); response += "\n\n#### Sources:\n\n" + links; @@ -347,6 +349,10 @@ class CopilotPlusChainRunner extends BaseChainRunner { return this.hasCapability(model, ModelCapability.VISION); } + protected getSystemPrompt(): string { + return getSystemPrompt(); + } + private async streamMultimodalResponse( textContent: string, userMessage: ChatMessage, @@ -363,7 +369,7 @@ class CopilotPlusChainRunner extends BaseChainRunner { const messages: any[] = []; // Add system message if available - let fullSystemMessage = getSystemPrompt(); + let fullSystemMessage = this.getSystemPrompt(); // Add chat history context to system message if exists if (chatHistory.length > 0) { @@ -693,4 +699,23 @@ class CopilotPlusChainRunner extends BaseChainRunner { } } -export { CopilotPlusChainRunner, LLMChainRunner, VaultQAChainRunner }; +class ProjectChainRunner extends CopilotPlusChainRunner { + protected getSystemPrompt(): string { + // get current project + const projectConfig = getCurrentProject(); + + if (!projectConfig) { + return super.getSystemPrompt(); + } + + const context = ProjectManager.instance.getProjectContext(projectConfig.id); + let finalPrompt = projectConfig.systemPrompt; + + if (context) { + finalPrompt += context; + } + return finalPrompt; + } +} + +export { CopilotPlusChainRunner, LLMChainRunner, VaultQAChainRunner, ProjectChainRunner }; diff --git a/src/LLMProviders/chatModelManager.ts b/src/LLMProviders/chatModelManager.ts index faa409dc..e7804384 100644 --- a/src/LLMProviders/chatModelManager.ts +++ b/src/LLMProviders/chatModelManager.ts @@ -1,4 +1,4 @@ -import { CustomModel, getModelKey, ModelConfig, setModelKey } from "@/aiParams"; +import { CustomModel, getModelKey, ModelConfig } from "@/aiParams"; import { BREVILABS_API_BASE_URL, BUILTIN_CHAT_MODELS, ChatModelProviders } from "@/constants"; import { getDecryptedKey } from "@/encryptionService"; import { logError } from "@/logger"; @@ -290,7 +290,8 @@ export default class ChatModelManager { async setChatModel(model: CustomModel): Promise { const modelKey = getModelKeyFromModel(model); - setModelKey(modelKey); + // todo 为啥这里需要重新设置? + // setModelKey(modelKey); try { const modelInstance = await this.createModelInstance(model); ChatModelManager.chatModel = modelInstance; diff --git a/src/LLMProviders/intentAnalyzer.ts b/src/LLMProviders/intentAnalyzer.ts index 7aad5764..61165b49 100644 --- a/src/LLMProviders/intentAnalyzer.ts +++ b/src/LLMProviders/intentAnalyzer.ts @@ -11,8 +11,8 @@ import { simpleYoutubeTranscriptionTool } from "@/tools/YoutubeTools"; import { ToolManager } from "@/tools/toolManager"; import { extractChatHistory, extractYoutubeUrl } from "@/utils"; import { BrevilabsClient } from "./brevilabsClient"; -import MemoryManager from "./memoryManager"; import { Vault } from "obsidian"; +import ProjectManager from "@/LLMProviders/projectManager"; // TODO: Add @index with explicit pdf files in chat context menu export const COPILOT_TOOL_NAMES = ["@vault", "@web", "@youtube", "@pomodoro"]; @@ -112,7 +112,7 @@ export class IntentAnalyzer { // Handle @web command if (message.includes("@web")) { const cleanQuery = this.removeAtCommands(originalMessage); - const memory = MemoryManager.getInstance().getMemory(); + const memory = ProjectManager.instance.getCurrentChainManager().memoryManager.getMemory(); const memoryVariables = await memory.loadMemoryVariables({}); const chatHistory = extractChatHistory(memoryVariables); diff --git a/src/LLMProviders/projectManager.ts b/src/LLMProviders/projectManager.ts new file mode 100644 index 00000000..1bfea453 --- /dev/null +++ b/src/LLMProviders/projectManager.ts @@ -0,0 +1,298 @@ +import { App, Notice } from "obsidian"; +import ChainManager from "./chainManager"; +import VectorStoreManager from "../search/vectorStoreManager"; +import { + getChainType, + isProjectMode, + ProjectConfig, + setProjectLoading, + subscribeToChainTypeChange, + subscribeToModelKeyChange, + subscribeToProjectChange, +} from "@/aiParams"; +import { logError, logInfo } from "@/logger"; +import { ChainType } from "@/chainFactory"; +import { getSettings, subscribeToSettingsChange } from "@/settings/model"; +import { err2String } from "@/utils"; +import { Mention } from "@/mentions/Mention"; +import { BrevilabsClient } from "./brevilabsClient"; +import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils"; +import CopilotPlugin from "@/main"; +import { CHAT_VIEWTYPE, VAULT_VECTOR_STORE_STRATEGY } from "@/constants"; +import CopilotView from "@/components/CopilotView"; +import { ChatMessage } from "@/sharedState"; +import { updateChatMemory } from "@/chatUtils"; + +export default class ProjectManager { + public static instance: ProjectManager; + private currentProjectId: string | null; + private app: App; + private plugin: CopilotPlugin; + private readonly chainMangerInstance: ChainManager; + + private projectContextCache: Map; + private chatMessageCache: Map; + + private defaultProjectKey: string = "defaultProjectKey"; + + private constructor(app: App, vectorStoreManager: VectorStoreManager, plugin: CopilotPlugin) { + this.app = app; + this.plugin = plugin; + this.currentProjectId = null; + + this.chainMangerInstance = new ChainManager(app, vectorStoreManager); + this.projectContextCache = new Map(); + this.chatMessageCache = new Map(); + + // Set up subscriptions + subscribeToModelKeyChange(async () => { + await this.getCurrentChainManager().createChainWithNewModel(); + }); + + subscribeToChainTypeChange(async () => { + // When switching from other modes to project mode, no need to update the chain. + if (isProjectMode()) { + return; + } + await this.getCurrentChainManager().createChainWithNewModel({ + refreshIndex: + getSettings().indexVaultToVectorStore === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH && + (getChainType() === ChainType.VAULT_QA_CHAIN || + getChainType() === ChainType.COPILOT_PLUS_CHAIN), + }); + }); + + // Subscribe to Project changes + subscribeToProjectChange(async (project) => { + await this.switchProject(project); + }); + + // Subscribe to settings changes to monitor projectList changes + this.setupProjectListChangeMonitor(); + } + + private setupProjectListChangeMonitor() { + subscribeToSettingsChange(async (prev, next) => { + if (!prev || !next) return; + + const prevProjects = prev.projectList || []; + const nextProjects = next.projectList || []; + + // Find modified projects + for (const nextProject of nextProjects) { + const prevProject = prevProjects.find((p) => p.id === nextProject.id); + if (prevProject) { + // Check if project configuration has changed + if (JSON.stringify(prevProject) !== JSON.stringify(nextProject)) { + // Clear context cache for this project + this.clearContextCache(nextProject.id); + + // If this is the current project, reload its context + if (this.currentProjectId === nextProject.id) { + console.log("reload its context"); + await this.loadProjectContext(nextProject); + } + } + } + } + }); + } + + public static getInstance( + app: App, + vectorStoreManager: VectorStoreManager, + plugin: CopilotPlugin + ): ProjectManager { + if (!ProjectManager.instance) { + ProjectManager.instance = new ProjectManager(app, vectorStoreManager, plugin); + } + return ProjectManager.instance; + } + + public getCurrentChainManager(): ChainManager { + return this.chainMangerInstance; + } + + public async switchProject(project: ProjectConfig | null): Promise { + try { + setProjectLoading(true); + + // 1. save current project message. 2. load next project message + + // switch default project + if (!project) { + await this.saveCurrentProjectMessage(); + this.currentProjectId = null; // ensure set currentProjectId + + await this.loadNextProjectMessage(); + this.refreshChatView(); + return; + } + + // else + const projectId = project.id; + if (this.currentProjectId === projectId) { + return; + } + + await this.saveCurrentProjectMessage(); + this.currentProjectId = projectId; // ensure set currentProjectId + + await Promise.all([ + // load memory + this.loadNextProjectMessage(), + // update chat model + this.getCurrentChainManager().createChainWithNewModel(), + // load context + this.loadProjectContext(project), + ]); + + // fresh chat view + this.refreshChatView(); + + logInfo(`Switched to project: ${project.name}`); + } catch (error) { + logError(`Failed to switch project: ${error}`); + throw error; + } finally { + setProjectLoading(false); + } + } + + private async saveCurrentProjectMessage() { + // save show message + this.chatMessageCache.set( + this.currentProjectId ? this.currentProjectId : this.defaultProjectKey, + this.getCurrentChainManager().getChatMessages() + ); + + // todo 需不需要? + await this.plugin.autosaveCurrentChat(); + } + + private async loadNextProjectMessage() { + const chainManager = this.getCurrentChainManager(); + + const messages = + this.chatMessageCache.get( + this.currentProjectId ? this.currentProjectId : this.defaultProjectKey + ) ?? []; + + chainManager.setChatMessages(messages); + await updateChatMemory(messages, chainManager.memoryManager); + } + + private async loadProjectContext(project: ProjectConfig): Promise { + try { + if (project.contextSource) { + if (this.projectContextCache.has(project.id)) { + logInfo(`Using cached context for project: ${project.name}`); + return; + } + + const contextParts = await Promise.all([ + this.processMarkdownContext( + project.contextSource.inclusions, + project.contextSource.exclusions + ), + this.processWebUrlsContext(project.contextSource.webUrls), + this.processYoutubeUrlsContext(project.contextSource.youtubeUrls), + ]).then((res) => res.filter((it) => !!it)); + + const contextText = ` + + +# Project Context +The following information is relevant context for this project. Use this information to inform your responses when appropriate: + +${contextParts.join("\n")} + +`; + + this.projectContextCache.set(project.id, contextText); + } + } catch (error) { + logError(`Failed to load project context: ${error}`); + throw error; + } + } + + private refreshChatView() { + // get chat view + const chatView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0]?.view as CopilotView; + if (chatView) { + chatView.updateView(); + } + } + + public getProjectContext(projectId: string): string | null { + return this.projectContextCache.get(projectId) || null; + } + + public clearContextCache(projectId: string): void { + this.projectContextCache.delete(projectId); + logInfo(`Context cache cleared for project: ${projectId}`); + } + + private async processMarkdownContext(inclusions?: string, exclusions?: string): Promise { + if (!inclusions && !exclusions) { + return ""; + } + + let allContent = ""; + const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = getMatchingPatterns({ + inclusions, + exclusions, + }); + + const files = this.app.vault.getMarkdownFiles().filter((file) => { + return shouldIndexFile(file, inclusionPatterns, exclusionPatterns); + }); + + await Promise.all(files.map((file) => this.app.vault.cachedRead(file))).then((contents) => + contents.map((c) => (allContent += c + " ")) + ); + + return allContent; + } + + private async processWebUrlsContext(webUrls?: string): Promise { + if (!webUrls?.trim()) { + return ""; + } + + try { + const mention = Mention.getInstance(); + const { urlContext } = await mention.processUrls(webUrls); + return urlContext || ""; + } catch (error) { + logError(`Failed to process web URLs: ${error}`); + new Notice(`Failed to process web URLs: ${err2String(error)}`); + return ""; + } + } + + private async processYoutubeUrlsContext(youtubeUrls?: string): Promise { + if (!youtubeUrls?.trim()) { + return ""; + } + + const urls = youtubeUrls.split("\n").filter((url) => url.trim()); + const processPromises = urls.map(async (url) => { + try { + const response = await BrevilabsClient.getInstance().youtube4llm(url); + if (response.response.transcript) { + return `\n\nYouTube transcript from ${url}:\n${response.response.transcript}`; + } + return ""; + } catch (error) { + logError(`Failed to process YouTube URL ${url}: ${error}`); + new Notice(`Failed to process YouTube URL ${url}: ${err2String(error)}`); + return ""; + } + }); + + const results = await Promise.all(processPromises); + return results.join(""); + } +} diff --git a/src/LLMProviders/promptManager.ts b/src/LLMProviders/promptManager.ts index a2a0edca..4bd8fc0d 100644 --- a/src/LLMProviders/promptManager.ts +++ b/src/LLMProviders/promptManager.ts @@ -5,6 +5,7 @@ import { MessagesPlaceholder, SystemMessagePromptTemplate, } from "@langchain/core/prompts"; +import { getCurrentProject, isProjectMode, subscribeToProjectChange } from "@/aiParams"; export default class PromptManager { private static instance: PromptManager; @@ -19,6 +20,11 @@ export default class PromptManager { this.initChatPrompt(); this.initQAPrompt(); }); + + subscribeToProjectChange(() => { + this.initChatPrompt(); + this.initQAPrompt(); + }); } static getInstance(): PromptManager { @@ -29,8 +35,15 @@ export default class PromptManager { } private initChatPrompt(): void { + let systemPrompt = getSystemPrompt(); + + const currentProject = getCurrentProject(); + if (currentProject && isProjectMode()) { + systemPrompt = currentProject.systemPrompt; + } + // Escape curly braces in the system message - const escapedSystemMessage = this.escapeTemplateString(getSystemPrompt()); + const escapedSystemMessage = this.escapeTemplateString(systemPrompt); this.chatPrompt = ChatPromptTemplate.fromMessages([ SystemMessagePromptTemplate.fromTemplate(escapedSystemMessage), diff --git a/src/aiParams.ts b/src/aiParams.ts index 38b45135..77331e8e 100644 --- a/src/aiParams.ts +++ b/src/aiParams.ts @@ -34,6 +34,29 @@ const chainTypeAtom = atom( } ); +const currentProjectAtom = atom(null); +const projectLoadingAtom = atom(false); + +export interface ProjectConfig { + id: string; + name: string; + description?: string; + systemPrompt: string; + projectModelKey: string; + modelConfigs: { + temperature?: number; + maxTokens?: number; + }; + contextSource: { + inclusions: string; + exclusions?: string; + webUrls?: string; + youtubeUrls?: string; + }; + created: number; + UsageTimestamps: number; +} + export interface ModelConfig { modelName: string; temperature: number; @@ -79,6 +102,7 @@ export interface CustomModel { stream?: boolean; temperature?: number; maxTokens?: number; + context?: number; plusExclusive?: boolean; believerExclusive?: boolean; @@ -132,3 +156,49 @@ export function useChainType() { store: settingsStore, }); } + +export function setCurrentProject(project: ProjectConfig | null) { + settingsStore.set(currentProjectAtom, project); +} + +export function getCurrentProject(): ProjectConfig | null { + return settingsStore.get(currentProjectAtom); +} + +export function subscribeToProjectChange( + callback: (project: ProjectConfig | null) => void +): () => void { + return settingsStore.sub(currentProjectAtom, () => { + callback(settingsStore.get(currentProjectAtom)); + }); +} + +export function useCurrentProject() { + return useAtom(currentProjectAtom, { + store: settingsStore, + }); +} + +export function setProjectLoading(loading: boolean) { + settingsStore.set(projectLoadingAtom, loading); +} + +export function isProjectLoading(): boolean { + return settingsStore.get(projectLoadingAtom); +} + +export function subscribeToProjectLoadingChange(callback: (loading: boolean) => void): () => void { + return settingsStore.sub(projectLoadingAtom, () => { + callback(settingsStore.get(projectLoadingAtom)); + }); +} + +export function useProjectLoading() { + return useAtom(projectLoadingAtom, { + store: settingsStore, + }); +} + +export function isProjectMode() { + return getChainType() === ChainType.PROJECT_CHAIN; +} diff --git a/src/chainFactory.ts b/src/chainFactory.ts index cb853af1..81e556a2 100644 --- a/src/chainFactory.ts +++ b/src/chainFactory.ts @@ -54,6 +54,7 @@ export enum ChainType { LLM_CHAIN = "llm_chain", VAULT_QA_CHAIN = "vault_qa", COPILOT_PLUS_CHAIN = "copilot_plus", + PROJECT_CHAIN = "project", } class ChainFactory { diff --git a/src/chainUtils.ts b/src/chainUtils.ts index 1351c18d..1ff7d096 100644 --- a/src/chainUtils.ts +++ b/src/chainUtils.ts @@ -1,6 +1,6 @@ -import ChatModelManager from "@/LLMProviders/chatModelManager"; import { removeThinkTags } from "@/utils"; import { BaseChatModelCallOptions } from "@langchain/core/language_models/chat_models"; +import ProjectManager from "@/LLMProviders/projectManager"; export async function getStandaloneQuestion( question: string, @@ -22,8 +22,9 @@ export async function getStandaloneQuestion( .map(([human, ai]) => `Human: ${human}\nAssistant: ${ai}`) .join("\n"); - const chatModel = ChatModelManager.getInstance() - .getChatModel() + const chatModel = ProjectManager.instance + .getCurrentChainManager() + .chatModelManager.getChatModel() .bind({ temperature: 0 } as BaseChatModelCallOptions); const response = await chatModel.invoke([ { diff --git a/src/commands/index.ts b/src/commands/index.ts index b933015c..b1447b74 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -124,14 +124,18 @@ export function registerCommands( addEditorCommand(plugin, COMMAND_IDS.COUNT_WORD_AND_TOKENS_SELECTION, async (editor: Editor) => { const selectedText = await editor.getSelection(); const wordCount = selectedText.split(" ").length; - const tokenCount = await plugin.chainManager.chatModelManager.countTokens(selectedText); + const tokenCount = await plugin.projectManager + .getCurrentChainManager() + .chatModelManager.countTokens(selectedText); new Notice(`Selected text contains ${wordCount} words and ${tokenCount} tokens.`); }); addCommand(plugin, COMMAND_IDS.COUNT_TOTAL_VAULT_TOKENS, async () => { try { const allContent = await getAllQAMarkdownContent(plugin.app); - const totalTokens = await plugin.chainManager.chatModelManager.countTokens(allContent); + const totalTokens = await plugin.projectManager + .getCurrentChainManager() + .chatModelManager.countTokens(allContent); new Notice(`Total tokens in your vault: ${totalTokens}`); } catch (error) { console.error("Error counting tokens: ", error); diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 1ed9640b..fcb858db 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -1,9 +1,10 @@ -import { useChainType, useModelKey } from "@/aiParams"; +import { ProjectConfig, useChainType, useModelKey } from "@/aiParams"; import { ChainType } from "@/chainFactory"; import { updateChatMemory } from "@/chatUtils"; import { ChatControls } from "@/components/chat-components/ChatControls"; import ChatInput from "@/components/chat-components/ChatInput"; import ChatMessages from "@/components/chat-components/ChatMessages"; +import { ProjectList } from "@/components/chat-components/ProjectList"; import { ABORT_REASON, COMMAND_IDS, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants"; import { AppContext, EventTargetContext } from "@/context"; import { ContextProcessor } from "@/contextProcessor"; @@ -12,7 +13,7 @@ import { getAIResponse } from "@/langchainStream"; import ChainManager from "@/LLMProviders/chainManager"; import CopilotPlugin from "@/main"; import { Mention } from "@/mentions/Mention"; -import { getSettings, useSettingsValue } from "@/settings/model"; +import { getSettings, updateSetting, useSettingsValue } from "@/settings/model"; import SharedState, { ChatMessage, useSharedState } from "@/sharedState"; import { FileParserManager } from "@/tools/FileParserManager"; import { err2String, formatDateTime } from "@/utils"; @@ -20,6 +21,8 @@ import { Notice, TFile } from "obsidian"; import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; import { Buffer } from "buffer"; +type ChatMode = "default" | "project"; + interface ChatProps { sharedState: SharedState; chainManager: ChainManager; @@ -27,6 +30,7 @@ interface ChatProps { updateUserMessageHistory: (newMessage: string) => void; fileParserManager: FileParserManager; plugin: CopilotPlugin; + mode?: ChatMode; } const Chat: React.FC = ({ @@ -51,6 +55,10 @@ const Chat: React.FC = ({ const [contextNotes, setContextNotes] = useState([]); const [includeActiveNote, setIncludeActiveNote] = useState(false); const [selectedImages, setSelectedImages] = useState([]); + const [showChatUI, setShowChatUI] = useState(false); + + const [previousMode, setPreviousMode] = useState(null); + const [mode, setSelectedChain] = useChainType(); const mention = Mention.getInstance(); @@ -533,6 +541,40 @@ ${chatContent}`; [addMessage, chainManager.memoryManager, chatHistory, clearMessages] ); + const handleAddProject = useCallback( + (project: ProjectConfig) => { + const currentProjects = settings.projectList || []; + const existingIndex = currentProjects.findIndex((p) => p.name === project.name); + + if (existingIndex >= 0) { + throw new Error(`Project "${project.name}" already exists, please use a different name`); + } + + const newProjectList = [...currentProjects, project]; + updateSetting("projectList", newProjectList); + new Notice(`${project.name} added successfully`); + return true; + }, + [settings.projectList] + ); + + const handleEditProject = useCallback( + (originP: ProjectConfig, updateP: ProjectConfig) => { + const currentProjects = settings.projectList || []; + const existingProject = currentProjects.find((p) => p.name === originP.name); + + if (!existingProject) { + throw new Error(`Project "${originP.name}" does not exist`); + } + + const newProjectList = currentProjects.map((p) => (p.name === originP.name ? updateP : p)); + updateSetting("projectList", newProjectList); + new Notice(`${originP.name} updated successfully`); + return true; + }, + [settings.projectList] + ); + const handleInsertToChat = useCallback((prompt: string) => { setInputMessage((prev) => `${prev} ${prompt} `); }, []); @@ -556,22 +598,31 @@ ${chatContent}`; handleSaveAsNote, ]); - return ( -
- -
- handleSaveAsNote(true)} /> + const renderChatComponents = () => ( + <> +
+ +
+
+ handleSaveAsNote(true)} + onModeChange={(newMode) => { + setPreviousMode(mode); + }} + /> setSelectedImages((prev) => [...prev, ...files])} setSelectedImages={setSelectedImages} + disableModelSwitch={mode === "project"} />
+ + ); + + return ( +
+
+
+ {mode === ChainType.PROJECT_CHAIN && ( +
+ { + if (previousMode) { + setSelectedChain(previousMode); + setPreviousMode(null); + } else { + // default back to chat or plus mode + setSelectedChain( + settings.isPlusUser ? ChainType.COPILOT_PLUS_CHAIN : ChainType.LLM_CHAIN + ); + } + }} + showChatUI={(v) => setShowChatUI(v)} + /> +
+ )} + {(mode !== "project" || (mode === "project" && showChatUI)) && renderChatComponents()} +
+
); }; diff --git a/src/components/CopilotView.tsx b/src/components/CopilotView.tsx index e1f7d8b7..337dcd5c 100644 --- a/src/components/CopilotView.tsx +++ b/src/components/CopilotView.tsx @@ -8,10 +8,13 @@ import { FileParserManager } from "@/tools/FileParserManager"; import * as Tooltip from "@radix-ui/react-tooltip"; import { ItemView, WorkspaceLeaf } from "obsidian"; import * as React from "react"; -import { Root, createRoot } from "react-dom/client"; +import { createRoot, Root } from "react-dom/client"; export default class CopilotView extends ItemView { - private chainManager: ChainManager; + private get chainManager(): ChainManager { + return this.plugin.projectManager.getCurrentChainManager(); + } + private fileParserManager: FileParserManager; private root: Root | null = null; private handleSaveAsNote: (() => Promise) | null = null; @@ -25,7 +28,6 @@ export default class CopilotView extends ItemView { super(leaf); this.sharedState = plugin.sharedState; this.app = plugin.app; - this.chainManager = plugin.chainManager; this.fileParserManager = plugin.fileParserManager; this.eventTarget = new EventTarget(); this.plugin = plugin; @@ -50,14 +52,24 @@ export default class CopilotView extends ItemView { } async onOpen(): Promise { - const root = createRoot(this.containerEl.children[1]); + this.root = createRoot(this.containerEl.children[1]); const handleSaveAsNote = (saveFunction: () => Promise) => { this.handleSaveAsNote = saveFunction; }; const updateUserMessageHistory = (newMessage: string) => { this.plugin.updateUserMessageHistory(newMessage); }; - root.render( + + this.renderView(handleSaveAsNote, updateUserMessageHistory); + } + + private renderView( + handleSaveAsNote: (saveFunction: () => Promise) => void, + updateUserMessageHistory: (newMessage: string) => void + ): void { + if (!this.root) return; + + this.root.render( @@ -83,14 +95,26 @@ export default class CopilotView extends ItemView { } } + updateView(): void { + // load currentChainManager chatMessages + this.sharedState.replaceMessages( + this.plugin.projectManager.getCurrentChainManager().getChatMessages() + ); + + const handleSaveAsNote = (saveFunction: () => Promise) => { + this.handleSaveAsNote = saveFunction; + }; + const updateUserMessageHistory = (newMessage: string) => { + this.plugin.updateUserMessageHistory(newMessage); + }; + + this.renderView(handleSaveAsNote, updateUserMessageHistory); + } + async onClose(): Promise { if (this.root) { this.root.unmount(); + this.root = null; } } - - updateView(): void { - // Force a re-render of the React component - this.onOpen(); - } } diff --git a/src/components/chat-components/ChatControls.tsx b/src/components/chat-components/ChatControls.tsx index 25e896be..66ebfc41 100644 --- a/src/components/chat-components/ChatControls.tsx +++ b/src/components/chat-components/ChatControls.tsx @@ -1,12 +1,13 @@ import React from "react"; import { - Download, - MoreHorizontal, - Sparkles, - FileText, - RefreshCw, - MessageCirclePlus, ChevronDown, + Download, + FileText, + LibraryBig, + MessageCirclePlus, + MoreHorizontal, + RefreshCw, + Sparkles, SquareArrowOutUpRight, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -15,7 +16,7 @@ import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown- import { SettingSwitch } from "@/components/ui/setting-switch"; import { updateSetting, useSettingsValue } from "@/settings/model"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useChainType } from "@/aiParams"; +import { setCurrentProject, useChainType } from "@/aiParams"; import { ChainType } from "@/chainFactory"; import { Notice } from "obsidian"; import VectorStoreManager from "@/search/vectorStoreManager"; @@ -35,13 +36,29 @@ export async function refreshVaultIndex() { interface ChatControlsProps { onNewChat: () => void; onSaveAsNote: () => void; + onModeChange: (mode: ChainType) => void; + onCloseProject?: () => void; } -export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) { +export function ChatControls({ + onNewChat, + onSaveAsNote, + onModeChange, + onCloseProject, +}: ChatControlsProps) { const settings = useSettingsValue(); const [selectedChain, setSelectedChain] = useChainType(); const isPlusUser = useIsPlusUser(); + const handleModeChange = (chainType: ChainType) => { + setSelectedChain(chainType); + onModeChange(chainType); + if (chainType !== ChainType.PROJECT_CHAIN) { + setCurrentProject(null); + onCloseProject?.(); + } + }; + return (
@@ -56,18 +73,31 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) { copilot plus (beta)
)} + {selectedChain === ChainType.PROJECT_CHAIN && "plus projects (alpha)"} - setSelectedChain(ChainType.LLM_CHAIN)}> + { + handleModeChange(ChainType.LLM_CHAIN); + }} + > chat - setSelectedChain(ChainType.VAULT_QA_CHAIN)}> + { + handleModeChange(ChainType.VAULT_QA_CHAIN); + }} + > vault QA {isPlusUser ? ( - setSelectedChain(ChainType.COPILOT_PLUS_CHAIN)}> + { + handleModeChange(ChainType.COPILOT_PLUS_CHAIN); + }} + >
copilot plus (beta) @@ -77,6 +107,29 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) { { navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT); + onCloseProject?.(); + }} + > + copilot plus (beta) + + + )} + + {isPlusUser ? ( + { + handleModeChange(ChainType.PROJECT_CHAIN); + }} + > + + plus projects (alpha) + + ) : ( + { + navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT); + onCloseProject?.(); }} > copilot plus (beta) diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx index f77d5313..f8370f52 100644 --- a/src/components/chat-components/ChatInput.tsx +++ b/src/components/chat-components/ChatInput.tsx @@ -1,4 +1,11 @@ -import { useChainType, useModelKey } from "@/aiParams"; +import { + useChainType, + useModelKey, + useProjectLoading, + ProjectConfig, + getCurrentProject, + subscribeToProjectChange, +} from "@/aiParams"; import { ChainType } from "@/chainFactory"; import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal"; import { AddImageModal } from "@/components/modals/AddImageModal"; @@ -24,9 +31,9 @@ import { Command, CornerDownLeft, Image, + Loader2, StopCircle, X, - Loader2, } from "lucide-react"; import { App, Notice, Platform, TFile } from "obsidian"; import React, { @@ -61,6 +68,7 @@ interface ChatInputProps { selectedImages: File[]; onAddImage: (files: File[]) => void; setSelectedImages: React.Dispatch>; + disableModelSwitch?: boolean; } const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( @@ -81,6 +89,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( selectedImages, onAddImage, setSelectedImages, + disableModelSwitch, }, ref ) => { @@ -93,11 +102,14 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( const [currentModelKey, setCurrentModelKey] = useModelKey(); const [modelError, setModelError] = useState(null); const [currentChain] = useChainType(); + const [isProjectLoading] = useProjectLoading(); const [currentActiveNote, setCurrentActiveNote] = useState( app.workspace.getActiveFile() ); + const [selectedProject, setSelectedProject] = useState(null); const settings = useSettingsValue(); - const isCopilotPlus = currentChain === ChainType.COPILOT_PLUS_CHAIN; + const isCopilotPlus = + currentChain === ChainType.COPILOT_PLUS_CHAIN || currentChain === ChainType.PROJECT_CHAIN; useImperativeHandle(ref, () => ({ focus: () => { @@ -105,6 +117,33 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( }, })); + useEffect(() => { + if (currentChain === ChainType.PROJECT_CHAIN) { + setSelectedProject(getCurrentProject()); + + const unsubscribe = subscribeToProjectChange((project) => { + setSelectedProject(project); + }); + + return () => { + unsubscribe(); + }; + } else { + setSelectedProject(null); + } + }, [currentChain]); + + const getDisplayModelKey = (): string => { + if ( + selectedProject && + currentChain === ChainType.PROJECT_CHAIN && + selectedProject.projectModelKey + ) { + return selectedProject.projectModelKey; + } + return currentModelKey; + }; + const onSendMessage = (includeVault: boolean) => { if (!isCopilotPlus) { handleSendMessage(); @@ -458,6 +497,14 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>( )}
+ {isProjectLoading && ( +
+
+ + Loading the project context... +
+
+ )}