feat: Support project-based feature.

This commit is contained in:
wyh 2025-02-19 14:45:22 +08:00
parent e165288a59
commit 1694357823
34 changed files with 1915 additions and 175 deletions

150
package-lock.json generated
View file

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

View file

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

View file

@ -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<void> {
let newModelKey = getModelKey();
async createChainWithNewModel(
options: SetChainOptions = {},
neededReInitChatMode: boolean = true
): Promise<void> {
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);
}
}

View file

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

View file

@ -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<void> {
const modelKey = getModelKeyFromModel(model);
setModelKey(modelKey);
// todo 为啥这里需要重新设置?
// setModelKey(modelKey);
try {
const modelInstance = await this.createModelInstance(model);
ChatModelManager.chatModel = modelInstance;

View file

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

View file

@ -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<string, string>;
private chatMessageCache: Map<string, ChatMessage[]>;
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<void> {
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<void> {
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 = `
<ProjectContext>
# Project Context
The following information is relevant context for this project. Use this information to inform your responses when appropriate:
${contextParts.join("\n")}
<ProjectContext>
`;
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<string> {
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<string> {
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<string> {
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("");
}
}

View file

@ -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),

View file

@ -34,6 +34,29 @@ const chainTypeAtom = atom(
}
);
const currentProjectAtom = atom<ProjectConfig | null>(null);
const projectLoadingAtom = atom<boolean>(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;
}

View file

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

View file

@ -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([
{

View file

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

View file

@ -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<ChatProps> = ({
@ -51,6 +55,10 @@ const Chat: React.FC<ChatProps> = ({
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [previousMode, setPreviousMode] = useState<ChainType | null>(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 (
<div className="chat-container">
<ChatMessages
chatHistory={chatHistory}
currentAiMessage={currentAiMessage}
loading={loading}
loadingMessage={loadingMessage}
app={app}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onInsertToChat={handleInsertToChat}
onReplaceChat={setInputMessage}
/>
<div className="bottom-container">
<ChatControls onNewChat={handleNewChat} onSaveAsNote={() => handleSaveAsNote(true)} />
const renderChatComponents = () => (
<>
<div className="flex-1 overflow-y-auto">
<ChatMessages
chatHistory={chatHistory}
currentAiMessage={currentAiMessage}
loading={loading}
loadingMessage={loadingMessage}
app={app}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
onInsertToChat={handleInsertToChat}
onReplaceChat={setInputMessage}
showHelperComponents={mode !== "project"}
/>
</div>
<div className="flex flex-col items-center justify-end w-full flex-none box-border sticky bottom-0 border-t border-border">
<ChatControls
onNewChat={handleNewChat}
onSaveAsNote={() => handleSaveAsNote(true)}
onModeChange={(newMode) => {
setPreviousMode(mode);
}}
/>
<ChatInput
ref={inputRef}
inputMessage={inputMessage}
@ -589,8 +640,44 @@ ${chatContent}`;
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}
disableModelSwitch={mode === "project"}
/>
</div>
</>
);
return (
<div className="chat-container">
<div className="h-full">
<div className="flex flex-col h-full relative">
{mode === ChainType.PROJECT_CHAIN && (
<div className={`${mode === ChainType.PROJECT_CHAIN ? "z-modal" : ""}`}>
<ProjectList
projects={settings.projectList || []}
defaultOpen={true}
app={app}
hasMessages={false}
onProjectAdded={handleAddProject}
onEditProject={handleEditProject}
inputRef={inputRef}
onClose={() => {
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)}
/>
</div>
)}
{(mode !== "project" || (mode === "project" && showChatUI)) && renderChatComponents()}
</div>
</div>
</div>
);
};

View file

@ -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<void>) | 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<void> {
const root = createRoot(this.containerEl.children[1]);
this.root = createRoot(this.containerEl.children[1]);
const handleSaveAsNote = (saveFunction: () => Promise<void>) => {
this.handleSaveAsNote = saveFunction;
};
const updateUserMessageHistory = (newMessage: string) => {
this.plugin.updateUserMessageHistory(newMessage);
};
root.render(
this.renderView(handleSaveAsNote, updateUserMessageHistory);
}
private renderView(
handleSaveAsNote: (saveFunction: () => Promise<void>) => void,
updateUserMessageHistory: (newMessage: string) => void
): void {
if (!this.root) return;
this.root.render(
<AppContext.Provider value={this.app}>
<EventTargetContext.Provider value={this.eventTarget}>
<React.StrictMode>
@ -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<void>) => {
this.handleSaveAsNote = saveFunction;
};
const updateUserMessageHistory = (newMessage: string) => {
this.plugin.updateUserMessageHistory(newMessage);
};
this.renderView(handleSaveAsNote, updateUserMessageHistory);
}
async onClose(): Promise<void> {
if (this.root) {
this.root.unmount();
this.root = null;
}
}
updateView(): void {
// Force a re-render of the React component
this.onOpen();
}
}

View file

@ -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 (
<div className="w-full py-1 flex justify-between items-center px-1">
<div className="flex-1">
@ -56,18 +73,31 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
copilot plus (beta)
</div>
)}
{selectedChain === ChainType.PROJECT_CHAIN && "plus projects (alpha)"}
<ChevronDown className="size-5 mt-0.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.LLM_CHAIN)}>
<DropdownMenuItem
onSelect={() => {
handleModeChange(ChainType.LLM_CHAIN);
}}
>
chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.VAULT_QA_CHAIN)}>
<DropdownMenuItem
onSelect={() => {
handleModeChange(ChainType.VAULT_QA_CHAIN);
}}
>
vault QA
</DropdownMenuItem>
{isPlusUser ? (
<DropdownMenuItem onSelect={() => setSelectedChain(ChainType.COPILOT_PLUS_CHAIN)}>
<DropdownMenuItem
onSelect={() => {
handleModeChange(ChainType.COPILOT_PLUS_CHAIN);
}}
>
<div className="flex items-center gap-1">
<Sparkles className="size-4" />
copilot plus (beta)
@ -77,6 +107,29 @@ export function ChatControls({ onNewChat, onSaveAsNote }: ChatControlsProps) {
<DropdownMenuItem
onSelect={() => {
navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT);
onCloseProject?.();
}}
>
copilot plus (beta)
<SquareArrowOutUpRight className="size-3" />
</DropdownMenuItem>
)}
{isPlusUser ? (
<DropdownMenuItem
className="flex items-center gap-1"
onSelect={() => {
handleModeChange(ChainType.PROJECT_CHAIN);
}}
>
<LibraryBig className="size-4" />
plus projects (alpha)
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => {
navigateToPlusPage(PLUS_UTM_MEDIUMS.CHAT_MODE_SELECT);
onCloseProject?.();
}}
>
copilot plus (beta)

View file

@ -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<React.SetStateAction<File[]>>;
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<string | null>(null);
const [currentChain] = useChainType();
const [isProjectLoading] = useProjectLoading();
const [currentActiveNote, setCurrentActiveNote] = useState<TFile | null>(
app.workspace.getActiveFile()
);
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(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>(
)}
<div className="relative" {...(isCopilotPlus ? getRootProps() : {})}>
{isProjectLoading && (
<div className="absolute inset-0 z-modal bg-background/80 backdrop-blur-sm flex items-center justify-center">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm">Loading the project context...</span>
</div>
</div>
)}
<textarea
ref={textAreaRef}
className="w-full bg-transparent focus-visible:ring-0 border-none min-h-[60px] max-h-40 overflow-y-auto resize-none px-2 rounded-md text-sm text-normal"
@ -469,6 +516,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={isProjectLoading}
/>
{isCopilotPlus && (
<>
@ -492,17 +540,18 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
) : (
<DropdownMenu open={isModelDropdownOpen} onOpenChange={setIsModelDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost2" size="fit">
<Button variant="ghost2" size="fit" disabled={disableModelSwitch}>
{modelError ? (
<span className="text-error">Model Load Failed</span>
) : settings.activeModels.find(
(model) => model.enabled && getModelKeyFromModel(model) === currentModelKey
(model) =>
model.enabled && getModelKeyFromModel(model) === getDisplayModelKey()
) ? (
<ModelDisplay
model={
settings.activeModels.find(
(model) =>
model.enabled && getModelKeyFromModel(model) === currentModelKey
model.enabled && getModelKeyFromModel(model) === getDisplayModelKey()
)!
}
iconSize={8}
@ -510,47 +559,48 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
) : (
"Select Model"
)}
<ChevronDown className="size-5 mt-0.5" />
{!disableModelSwitch && <ChevronDown className="size-5 mt-0.5" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{settings.activeModels
.filter((model) => model.enabled)
.map((model) => {
const { hasApiKey, errorNotice } = checkModelApiKey(model, settings);
return (
<DropdownMenuItem
key={getModelKeyFromModel(model)}
onSelect={async (event) => {
if (!hasApiKey && errorNotice) {
event.preventDefault();
new Notice(errorNotice);
return;
}
try {
setModelError(null);
setCurrentModelKey(getModelKeyFromModel(model));
} catch (error) {
const msg = `Model switch failed: ` + err2String(error);
setModelError(msg);
new Notice(msg);
// Restore to the last valid model
const lastValidModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === currentModelKey
);
if (lastValidModel) {
setCurrentModelKey(getModelKeyFromModel(lastValidModel));
{!disableModelSwitch &&
settings.activeModels
.filter((model) => model.enabled)
.map((model) => {
const { hasApiKey, errorNotice } = checkModelApiKey(model, settings);
return (
<DropdownMenuItem
key={getModelKeyFromModel(model)}
onSelect={async (event) => {
if (!hasApiKey && errorNotice) {
event.preventDefault();
new Notice(errorNotice);
return;
}
}
}}
className={!hasApiKey ? "opacity-50 cursor-not-allowed" : ""}
>
<ModelDisplay model={model} iconSize={12} />
</DropdownMenuItem>
);
})}
try {
setModelError(null);
setCurrentModelKey(getModelKeyFromModel(model));
} catch (error) {
const msg = `Model switch failed: ` + err2String(error);
setModelError(msg);
new Notice(msg);
// Restore to the last valid model
const lastValidModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === currentModelKey
);
if (lastValidModel) {
setCurrentModelKey(getModelKeyFromModel(lastValidModel));
}
}
}}
className={!hasApiKey ? "opacity-50 cursor-not-allowed" : ""}
>
<ModelDisplay model={model} iconSize={12} />
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}

View file

@ -17,6 +17,7 @@ interface ChatMessagesProps {
onDelete: (messageIndex: number) => void;
onInsertToChat: (prompt: string) => void;
onReplaceChat: (prompt: string) => void;
showHelperComponents: boolean;
}
const ChatMessages = memo(
@ -31,6 +32,7 @@ const ChatMessages = memo(
onDelete,
onInsertToChat,
onReplaceChat,
showHelperComponents = true,
}: ChatMessagesProps) => {
const [loadingDots, setLoadingDots] = useState("");
@ -63,14 +65,16 @@ const ChatMessages = memo(
if (!chatHistory.filter((message) => message.isVisible).length && !currentAiMessage) {
return (
<div className="flex flex-col gap-2 overflow-y-auto w-full h-full">
{settings.showRelevantNotes && (
{showHelperComponents && settings.showRelevantNotes && (
<RelevantNotes
onInsertToChat={onInsertToChat}
defaultOpen={true}
key="relevant-notes-before-chat"
/>
)}
{settings.showSuggestedPrompts && <SuggestedPrompts onClick={onReplaceChat} />}
{showHelperComponents && settings.showSuggestedPrompts && (
<SuggestedPrompts onClick={onReplaceChat} />
)}
</div>
);
}
@ -81,7 +85,7 @@ const ChatMessages = memo(
return (
<div className="flex flex-col flex-1 h-full overflow-hidden">
{settings.showRelevantNotes && (
{showHelperComponents && settings.showRelevantNotes && (
<RelevantNotes
className="mb-4"
onInsertToChat={onInsertToChat}

View file

@ -0,0 +1,369 @@
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ProjectConfig, setCurrentProject } from "@/aiParams";
import {
BookOpen,
ChevronDown,
ChevronUp,
Edit2,
Folder,
Info,
MessageSquare,
Plus,
X,
} from "lucide-react";
import React, { memo, useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AddProjectModal } from "@/components/modals/AddProjectModal";
import { App } from "obsidian";
import { logError } from "@/logger";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function ProjectItem({
project,
loadContext,
onEdit,
}: {
project: ProjectConfig;
loadContext: (project: ProjectConfig) => void;
onEdit: (project: ProjectConfig) => void;
}) {
return (
<div
className="flex gap-2 p-3 justify-between items-center rounded-lg bg-secondary/40 border border-border border-solid group transition-all duration-200 hover:border-interactive-accent/30 hover:bg-interactive-accent/5 hover:shadow-[0_2px_12px_rgba(0,0,0,0.1)] active:scale-[0.98] cursor-pointer"
onDoubleClick={() => loadContext(project)}
>
<div className="flex items-center gap-2 flex-1 overflow-hidden">
<div className="text-blue-400">
<Folder className="size-4" />
</div>
<div className="flex flex-col gap-1.5 flex-1 overflow-hidden">
<span className="text-[13px] font-medium text-normal text-ellipsis overflow-hidden whitespace-nowrap w-full">
{project.name}
</span>
{project.description && (
<span className="text-[12px] text-muted/80 text-ellipsis overflow-hidden whitespace-nowrap w-full">
{project.description}
</span>
)}
</div>
</div>
<div className="flex flex-row gap-1 opacity-100 transition-opacity duration-200">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
className="h-6 w-6 hover:bg-accent/10 hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit(project);
}}
>
<Edit2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit Project</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
className="h-6 w-6 hover:bg-accent/10 hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation();
loadContext(project);
}}
>
<MessageSquare className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Start Chat</TooltipContent>
</Tooltip>
</div>
</div>
);
}
export const ProjectList = memo(
({
className,
projects,
defaultOpen = false,
app,
onProjectAdded,
onEditProject,
hasMessages = false,
showChatUI,
onClose,
inputRef,
}: {
className?: string;
projects: ProjectConfig[];
defaultOpen?: boolean;
app: App;
onProjectAdded: (project: ProjectConfig) => void;
onEditProject: (originP: ProjectConfig, updateP: ProjectConfig) => void;
hasMessages?: boolean;
showChatUI: (v: boolean) => void;
onClose: () => void;
inputRef: React.RefObject<HTMLTextAreaElement>;
}): React.ReactElement => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showChatInput, setShowChatInput] = useState(false);
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
// Auto collapse when messages appear
useEffect(() => {
if (hasMessages) {
setIsOpen(false);
}
}, [hasMessages]);
const handleAddProject = () => {
const modal = new AddProjectModal(app, async (project: ProjectConfig) => {
onProjectAdded(project);
});
modal.open();
};
const handleEditProject = (originP: ProjectConfig) => {
const modal = new AddProjectModal(
app,
async (updatedProject: ProjectConfig) => {
onEditProject(originP, updatedProject);
if (selectedProject && selectedProject.name === originP.name) {
setSelectedProject(updatedProject);
}
},
originP
);
modal.open();
};
const enableOrDisableProject = (enable: boolean, project?: ProjectConfig) => {
if (!enable) {
setSelectedProject(null);
setShowChatInput(false);
setIsOpen(true);
showChatUI(false);
setCurrentProject(null);
return;
} else {
if (!project) {
logError("Must be exist one project.");
return;
}
setSelectedProject(project);
setShowChatInput(true);
setIsOpen(false);
}
};
const handleLoadContext = (p: ProjectConfig) => {
setSelectedProject(p);
setShowChatInput(true);
setIsOpen(false);
showChatUI(true);
setCurrentProject(p);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
};
return (
<div className={cn("flex flex-col", className)}>
<div className="overflow-y-auto">
<div className="flex flex-col">
{showChatInput && selectedProject ? (
<div className="flex justify-between items-center px-2 py-3">
<div className="flex gap-2 items-center">
<span className="font-semibold text-normal">Projects</span>
<Select
value={selectedProject.name}
onValueChange={(value) => {
const project = projects.find((p) => p.name === value);
if (project) {
handleLoadContext(project);
}
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue>
<div className="flex items-center gap-2">
<Folder className="size-4 text-accent/70" />
<span>{selectedProject.name}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.name} value={project.name}>
<div className="flex items-center gap-2">
<Folder className="size-4" />
<span>{project.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="fit"
variant="ghost2"
onClick={() => handleEditProject(selectedProject)}
className="hover:bg-accent/50 hover:text-on-accent"
>
<Edit2 className="size-4 mr-1" />
Edit
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit Current Project</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
onClick={() => {
enableOrDisableProject(false);
}}
aria-label="Close Current Project"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close Current Project</TooltipContent>
</Tooltip>
</div>
</div>
) : (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="transition-all duration-200 ease-in-out"
>
<div className="flex justify-between items-center px-4 py-3">
<div className="flex gap-2 items-center flex-1">
<span className="font-semibold text-normal">Projects</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-muted" />
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64">
Manage your projects with different contexts and configurations.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button size="fit" onClick={handleAddProject}>
Create
<Plus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Add New Project</TooltipContent>
</Tooltip>
{projects.length > 0 && (
<CollapsibleTrigger asChild>
<Button variant="ghost2" size="icon">
{isOpen ? (
<ChevronUp className="size-5" />
) : (
<ChevronDown className="size-5" />
)}
</Button>
</CollapsibleTrigger>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
onClick={() => onClose()}
aria-label="close project mode"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close Project Mode</TooltipContent>
</Tooltip>
</div>
</div>
{projects.length === 0 && (
<div className="px-4 py-2 text-xs text-muted bg-secondary/30">
No projects available
</div>
)}
<CollapsibleContent className="transition-all duration-200 ease-in-out">
<div className="relative bg-secondary/30">
<div className="px-4 pt-3 pb-6 max-h-[calc(3*5.7rem)] overflow-y-auto">
<div className="flex flex-col gap-2 @2xl:grid @2xl:grid-cols-2 @4xl:grid-cols-3">
{projects.map((project) => (
<ProjectItem
key={project.name}
project={project}
loadContext={handleLoadContext}
onEdit={handleEditProject}
/>
))}
</div>
</div>
{projects.length > 0 && (
<div
className="absolute bottom-0 left-0 right-0 h-6"
style={{
background: "linear-gradient(transparent, var(--background-primary) 75%)",
}}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
{!showChatInput && (
<div className="flex flex-col gap-4 items-center justify-center p-8 text-muted bg-secondary/30">
<div className="max-w-[600px] space-y-4">
<p className="text-base text-center">
Create and explore personalized AI assistants with custom instructions, knowledge
bases, and skill sets for each project.
</p>
<div className="flex flex-col gap-3 text-sm">
<div className="flex items-center gap-2">
<MessageSquare className="size-4" />
<span>Double-click a project card to start chatting</span>
</div>
<div className="flex items-center gap-2">
<BookOpen className="size-4" />
<span>
The more you use a project, the deeper the AI&#39;s understanding becomes
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
);
ProjectList.displayName = "ProjectList";

View file

@ -65,6 +65,7 @@ const PROMPT_KEYS: Record<ChainType, Array<keyof typeof SUGGESTED_PROMPTS>> = {
[ChainType.LLM_CHAIN]: ["activeNote", "quoteNote", "fun"],
[ChainType.VAULT_QA_CHAIN]: ["qaVault", "qaVault", "quoteNote"],
[ChainType.COPILOT_PLUS_CHAIN]: ["copilotPlus", "copilotPlus", "copilotPlus"],
[ChainType.PROJECT_CHAIN]: ["copilotPlus", "copilotPlus", "copilotPlus"],
};
function getRandomPrompt(chainType: ChainType = ChainType.LLM_CHAIN) {

View file

@ -0,0 +1,393 @@
import { App, Modal, Notice } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { err2String, checkModelApiKey, randomUUID } from "@/utils";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { ProjectConfig } from "@/aiParams";
import { useSettingsValue, getModelKeyFromModel } from "@/settings/model";
import { getModelDisplayWithIcons } from "@/components/ui/model-display";
import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select";
import { SettingSlider } from "@/components/ui/setting-slider";
import { PatternMatchingModal } from "@/components/modals/PatternMatchingModal";
interface AddProjectModalContentProps {
initialProject?: ProjectConfig;
onSave: (project: ProjectConfig) => Promise<void>;
onCancel: () => void;
}
function AddProjectModalContent({ initialProject, onSave, onCancel }: AddProjectModalContentProps) {
const settings = useSettingsValue();
const [isSubmitting, setIsSubmitting] = useState(false);
const [touched, setTouched] = useState({
name: false,
systemPrompt: false,
projectModelKey: false,
inclusions: false,
});
const [formData, setFormData] = useState<Partial<ProjectConfig>>(
initialProject || {
id: randomUUID(),
name: "",
description: "",
systemPrompt: "",
projectModelKey: "",
modelConfigs: {
temperature: 1.0,
maxTokens: 1000,
},
contextSource: {
inclusions: "",
exclusions: "",
webUrls: "",
youtubeUrls: "",
},
created: Date.now(),
UsageTimestamps: Date.now(),
}
);
const isFormValid = () => {
return (
formData.name &&
formData.systemPrompt &&
formData.projectModelKey &&
formData.contextSource?.inclusions?.trim()
);
};
const handleInputChange = (
field: string,
value: string | number | string[] | Record<string, any>
) => {
setFormData((prev) => {
// Handle text input
if (typeof value === "string") {
value = value.trim();
}
// Handle string arrays
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
value = value.map((item) => item.trim()).filter(Boolean);
}
if (field.includes(".")) {
const [parent, child] = field.split(".");
const parentKey = parent as keyof typeof prev;
const parentValue = prev[parentKey];
if (typeof parentValue === "object" && parentValue !== null) {
return {
...prev,
[parent]: {
...parentValue,
[child]: value,
},
};
}
}
return {
...prev,
[field]: value,
};
});
};
const handleSave = async () => {
const requiredFields = ["name", "systemPrompt", "projectModelKey"];
const missingFields = requiredFields.filter((field) => !formData[field as keyof ProjectConfig]);
if (missingFields.length > 0) {
setTouched((prev) => ({
...prev,
...Object.fromEntries(missingFields.map((field) => [field, true])),
}));
new Notice("Please fill in all required fields");
return;
}
try {
setIsSubmitting(true);
await onSave(formData as ProjectConfig);
} catch (e) {
new Notice(err2String(e));
setTouched((prev) => ({
...prev,
name: true,
}));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-2 p-4">
<div className="text-xl font-bold text-normal mb-2">Add New Project</div>
<div className="flex flex-col gap-2">
<FormField
label="Project Name"
required
error={touched.name && !formData.name}
errorMessage="Project name is required"
>
<Input
type="text"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, name: true }))}
className="w-full"
/>
</FormField>
<FormField
label="Description"
description="Briefly describe the purpose and goals of the project"
>
<Input
type="text"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
className="w-full"
/>
</FormField>
<FormField
label="Prompt"
required
error={touched.systemPrompt && !formData.systemPrompt}
errorMessage="System prompt is required"
>
<Textarea
value={formData.systemPrompt}
onChange={(e) => handleInputChange("systemPrompt", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, systemPrompt: true }))}
className="min-h-[8rem]"
/>
</FormField>
<FormField
label="Default Model"
required
error={touched.projectModelKey && !formData.projectModelKey}
errorMessage="Default model is required"
>
<ObsidianNativeSelect
value={formData.projectModelKey}
onChange={(e) => {
const value = e.target.value;
const selectedModel = settings.activeModels.find(
(m) => m.enabled && getModelKeyFromModel(m) === value
);
if (!selectedModel) return;
const { hasApiKey, errorNotice } = checkModelApiKey(selectedModel, settings);
if (!hasApiKey && errorNotice) {
new Notice(errorNotice);
return;
}
handleInputChange("projectModelKey", value);
}}
onBlur={() => setTouched((prev) => ({ ...prev, projectModelKey: true }))}
placeholder="Select a model"
options={settings.activeModels
.filter((m) => m.enabled)
.map((model) => ({
label: getModelDisplayWithIcons(model),
value: getModelKeyFromModel(model),
}))}
/>
</FormField>
<div className="space-y-4">
<div className="text-base font-medium">Model Configuration</div>
<div className="grid grid-cols-1 gap-4">
<FormField label="Temperature">
<SettingSlider
value={formData.modelConfigs?.temperature ?? 1}
onChange={(value) => handleInputChange("modelConfigs.temperature", value)}
min={0}
max={2}
step={0.01}
className="w-full"
/>
</FormField>
<FormField label="Token Limit">
<SettingSlider
value={formData.modelConfigs?.maxTokens ?? 1000}
onChange={(value) => handleInputChange("modelConfigs.maxTokens", value)}
min={1}
max={16000}
step={1}
className="w-full"
/>
</FormField>
</div>
</div>
<div className="space-y-4">
<div className="text-base font-medium">Context Sources</div>
<FormField
label="Inclusions"
required
error={touched.inclusions && !formData.contextSource?.inclusions?.trim()}
errorMessage="At least one inclusion pattern is required"
>
<div className="flex items-center gap-2">
<div className="flex-1 text-xs text-muted">
{formData.contextSource?.inclusions?.trim()
? "Patterns configured"
: "No patterns configured"}
</div>
<Button
variant="secondary"
onClick={() =>
new PatternMatchingModal(
app,
(value: string) => {
handleInputChange("contextSource.inclusions", value);
setTouched((prev) => ({ ...prev, inclusions: true }));
},
formData.contextSource?.inclusions || "",
"Manage Inclusions"
).open()
}
>
Manage Patterns
</Button>
</div>
</FormField>
<FormField
label="Exclusions"
description="Exclude specific files or patterns from the included folders above"
>
<div className="flex items-center gap-2">
<div className="flex-1 text-xs text-muted">
{formData.contextSource?.exclusions?.trim()
? "Patterns configured"
: "No patterns configured"}
</div>
<Button
variant="secondary"
onClick={() =>
new PatternMatchingModal(
app,
(value: string) => {
handleInputChange("contextSource.exclusions", value);
},
formData.contextSource?.exclusions || "",
"Manage Exclusions"
).open()
}
>
Manage Patterns
</Button>
</div>
</FormField>
<FormField label="Web URLs">
<Textarea
value={formData.contextSource?.webUrls}
onChange={(e) => {
const urls = e.target.value
.split("\n")
.map((url) => url.trim())
.filter((url) => {
if (!url) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
})
.join("\n");
handleInputChange("contextSource.webUrls", urls);
}}
placeholder="Enter web URLs, one per line"
className="min-h-[80px] w-full"
/>
</FormField>
<FormField label="YouTube URLs">
<Textarea
value={formData.contextSource?.youtubeUrls}
onChange={(e) => {
const urls = e.target.value
.split("\n")
.map((url) => url.trim())
.filter((url) => {
if (!url) return false;
try {
const urlObj = new URL(url);
return (
urlObj.hostname.includes("youtube.com") ||
urlObj.hostname.includes("youtu.be")
);
} catch {
return false;
}
})
.join("\n");
handleInputChange("contextSource.youtubeUrls", urls);
}}
placeholder="Enter YouTube URLs, one per line"
className="min-h-[80px] w-full"
/>
</FormField>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-4">
<Button variant="ghost" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSubmitting || !isFormValid()}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
);
}
export class AddProjectModal extends Modal {
private root: Root;
constructor(
app: App,
private onSave: (project: ProjectConfig) => Promise<void>,
private initialProject?: ProjectConfig
) {
super(app);
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleSave = async (project: ProjectConfig) => {
await this.onSave(project);
this.close();
};
const handleCancel = () => {
this.close();
};
this.root.render(
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
/>
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -1,7 +1,6 @@
import { useModelKey } from "@/aiParams";
import { processCommandPrompt } from "@/commands/inlineEditCommandUtils";
import { getModelDisplayText } from "@/components/ui/model-display";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { InlineEditCommandSettings, useSettingsValue } from "@/settings/model";
import { ChatMessage } from "@/sharedState";
import { findCustomModel, insertIntoEditor } from "@/utils";
@ -9,6 +8,7 @@ import { Bot, Copy, PenLine } from "lucide-react";
import { App, Modal, Notice } from "obsidian";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { createRoot, Root } from "react-dom/client";
import ProjectManager from "@/LLMProviders/projectManager";
interface InlineEditModalContentProps {
originalText: string;
@ -44,7 +44,9 @@ function InlineEditModalContent({
async function stream() {
const prompt = processCommandPrompt(command.prompt, originalText);
let fullAIResponse = "";
const chatModel = await ChatModelManager.getInstance().createModelInstance(selectedModel);
const chatModel = await ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.createModelInstance(selectedModel);
const chatStream = await chatModel.stream(prompt);
for await (const chunk of chatStream) {
if (abortController?.signal.aborted) break;

View file

@ -0,0 +1,59 @@
import React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
export interface SelectOption {
label: string;
value: string;
}
interface ObsidianNativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[];
placeholder?: string;
containerClassName?: string;
}
export function ObsidianNativeSelect({
options,
placeholder,
containerClassName,
className,
...props
}: ObsidianNativeSelectProps) {
return (
<div className={cn("relative w-full group", containerClassName)}>
<select
className={cn(
"w-full appearance-none",
"flex h-9 rounded-md border border-solid border-border bg-dropdown px-3 py-1 pr-8",
"text-sm !shadow transition-colors",
"focus:outline-none focus:ring-1 focus:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"hover:bg-interactive-hover hover:text-normal",
className
)}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2",
"transition-colors group-hover:[&>svg]:text-normal",
props.disabled && "opacity-50"
)}
>
<ChevronDown className="h-4 w-4" />
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"!shadow-none",
"data-[state=active]:!bg-interactive-accent data-[state=active]:!text-on-accent data-[state=active]:!shadow-md",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-ring transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -496,6 +496,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
promptUsageTimestamps: {},
defaultConversationNoteName: "{$topic}@{$date}_{$time}",
inlineEditCommands: DEFAULT_INLINE_EDIT_COMMANDS,
projectList: [],
};
export const EVENT_NAMES = {

View file

@ -1,5 +1,5 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ChainManager from "@/LLMProviders/chainManager";
import ProjectManager from "@/LLMProviders/projectManager";
import { CustomModel } from "@/aiParams";
import { parseChatContent, updateChatMemory } from "@/chatUtils";
import { registerCommands } from "@/commands";
@ -32,12 +32,13 @@ import {
WorkspaceLeaf,
} from "obsidian";
import { IntentAnalyzer } from "./LLMProviders/intentAnalyzer";
import { logInfo } from "@/logger";
export default class CopilotPlugin extends Plugin {
// A chat history that stores the messages sent and received
// Only reset when the user explicitly clicks "New Chat"
sharedState: SharedState;
chainManager: ChainManager;
projectManager: ProjectManager;
brevilabsClient: BrevilabsClient;
userMessageHistory: string[] = [];
vectorStoreManager: VectorStoreManager;
@ -55,8 +56,9 @@ export default class CopilotPlugin extends Plugin {
registerCommands(this, prev, next);
});
this.addSettingTab(new CopilotSettingTab(this.app, this));
// Always have one instance of sharedState and chainManager in the plugin
this.sharedState = new SharedState();
// Always have one instance of sharedState in the plugin
this.sharedState = new SharedState(this);
this.vectorStoreManager = VectorStoreManager.getInstance();
@ -65,7 +67,8 @@ export default class CopilotPlugin extends Plugin {
this.brevilabsClient.setPluginVersion(this.manifest.version);
checkIsPlusUser();
this.chainManager = new ChainManager(this.app, this.vectorStoreManager);
// Initialize ProjectManager
this.projectManager = ProjectManager.getInstance(this.app, this.vectorStoreManager, this);
// Initialize FileParserManager early with other core services
this.fileParserManager = new FileParserManager(this.brevilabsClient);
@ -115,9 +118,10 @@ export default class CopilotPlugin extends Plugin {
if (this.vectorStoreManager) {
this.vectorStoreManager.onunload();
}
this.settingsUnsubscriber?.();
console.log("Copilot plugin unloaded");
logInfo("Copilot plugin unloaded");
}
updateUserMessageHistory(newMessage: string) {
@ -300,7 +304,7 @@ export default class CopilotPlugin extends Plugin {
messages.forEach((message) => this.sharedState.addMessage(message));
// Update the chain's memory with the loaded messages
await updateChatMemory(messages, this.chainManager.memoryManager);
await updateChatMemory(messages, this.projectManager.getCurrentChainManager().memoryManager);
// Check if the Copilot view is already active
const existingView = this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0];

View file

@ -1,5 +1,4 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import EmbeddingManager from "@/LLMProviders/embeddingManager";
import { logInfo } from "@/logger";
import VectorStoreManager from "@/search/vectorStoreManager";
@ -13,6 +12,7 @@ import { BaseRetriever } from "@langchain/core/retrievers";
import { search } from "@orama/orama";
import { TFile } from "obsidian";
import { DBOperations } from "./dbOperations";
import ProjectManager from "@/LLMProviders/projectManager";
export class HybridRetriever extends BaseRetriever {
public lc_namespace = ["hybrid_retriever"];
@ -130,8 +130,9 @@ export class HybridRetriever extends BaseRetriever {
private async rewriteQuery(query: string): Promise<string> {
try {
const promptResult = await this.queryRewritePrompt.format({ question: query });
const chatModel = ChatModelManager.getInstance()
.getChatModel()
const chatModel = ProjectManager.instance
.getCurrentChainManager()
.chatModelManager.getChatModel()
.bind({ temperature: 0 } as BaseChatModelCallOptions);
const rewrittenQueryObject = await chatModel.invoke(promptResult);

View file

@ -94,18 +94,24 @@ function getInclusionPatterns(): string[] {
}
/**
* Get the inclusion and exclusion patterns from the settings.
* Get the inclusion and exclusion patterns from the settings or provided values.
* @param options - Optional parameters for inclusions and exclusions.
* @returns An object containing the inclusions and exclusions patterns strings.
*/
export function getMatchingPatterns(): {
export function getMatchingPatterns(options?: { inclusions?: string; exclusions?: string }): {
inclusions: PatternCategory | null;
exclusions: PatternCategory | null;
} {
const inclusions = getInclusionPatterns();
const exclusions = getExclusionPatterns();
const inclusionPatterns = options?.inclusions
? getDecodedPatterns(options.inclusions)
: getInclusionPatterns();
const exclusionPatterns = options?.exclusions
? getDecodedPatterns(options.exclusions)
: getExclusionPatterns();
return {
inclusions: inclusions.length > 0 ? categorizePatterns(inclusions) : null,
exclusions: exclusions.length > 0 ? categorizePatterns(exclusions) : null,
inclusions: inclusionPatterns.length > 0 ? categorizePatterns(inclusionPatterns) : null,
exclusions: exclusionPatterns.length > 0 ? categorizePatterns(exclusionPatterns) : null,
};
}

View file

@ -1,4 +1,4 @@
import { CustomModel } from "@/aiParams";
import { CustomModel, ProjectConfig } from "@/aiParams";
import { atom, createStore, useAtomValue } from "jotai";
import { type ChainType } from "@/chainFactory";
@ -89,6 +89,7 @@ export interface CopilotSettings {
// undefined means never checked
isPlusUser: boolean | undefined;
inlineEditCommands: InlineEditCommandSettings[] | undefined;
projectList: Array<ProjectConfig>;
}
export const settingsStore = createStore();

View file

@ -1,4 +1,3 @@
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { CustomModel } from "@/aiParams";
import { Button } from "@/components/ui/button";
import {
@ -15,6 +14,7 @@ import { err2String, getNeedSetKeyProvider, getProviderInfo, getProviderLabel }
import { Loader2 } from "lucide-react";
import { Notice } from "obsidian";
import React, { useState } from "react";
import ProjectManager from "@/LLMProviders/projectManager";
interface ApiKeyDialogProps {
open: boolean;
@ -96,7 +96,7 @@ const ApiKeyDialog: React.FC<ApiKeyDialogProps> = ({
apiKey,
enabled: true,
};
await ChatModelManager.getInstance().ping(customModel);
await ProjectManager.instance.getCurrentChainManager().chatModelManager.ping(customModel);
new Notice("API key verified successfully!");
setUnverifiedKeys((prev) => {

View file

@ -21,6 +21,7 @@ const ChainType2Label: Record<ChainType, string> = {
[ChainType.LLM_CHAIN]: "Chat",
[ChainType.VAULT_QA_CHAIN]: "Vault QA (Basic)",
[ChainType.COPILOT_PLUS_CHAIN]: "Copilot Plus (beta)",
[ChainType.PROJECT_CHAIN]: "Project",
};
export const BasicSettings: React.FC = () => {

View file

@ -2,11 +2,11 @@ import React, { useState } from "react";
import { SettingItem } from "@/components/ui/setting-item";
import { setSettings, updateSetting, useSettingsValue } from "@/settings/model";
import { CustomModel } from "@/aiParams";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import EmbeddingManager from "@/LLMProviders/embeddingManager";
import { ModelAddDialog } from "@/settings/v2/components/ModelAddDialog";
import { ModelTable } from "@/settings/v2/components/ModelTable";
import { ModelEditDialog } from "@/settings/v2/components/ModelEditDialog";
import ProjectManager from "@/LLMProviders/projectManager";
export const ModelSettings: React.FC = () => {
const settings = useSettingsValue();
@ -94,7 +94,9 @@ export const ModelSettings: React.FC = () => {
const updatedModels = [...settings.activeModels, model];
updateSetting("activeModels", updatedModels);
}}
ping={(model) => ChatModelManager.getInstance().ping(model)}
ping={(model) =>
ProjectManager.instance.getCurrentChainManager().chatModelManager.ping(model)
}
/>
<div className="space-y-4">

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { FormattedDateTime } from "./utils";
import { TFile } from "obsidian";
import CopilotPlugin from "@/main";
export interface ChatMessage {
message: string;
@ -18,10 +19,14 @@ export interface ChatMessage {
}
class SharedState {
constructor(private plugin: CopilotPlugin) {
this.plugin = plugin;
}
chatHistory: ChatMessage[] = [];
addMessage(message: ChatMessage): void {
this.chatHistory.push(message);
this.plugin.projectManager.getCurrentChainManager().addChatMessage(message);
}
getMessages(): ChatMessage[] {
@ -30,6 +35,11 @@ class SharedState {
clearChatHistory(): void {
this.chatHistory = [];
this.plugin.projectManager.getCurrentChainManager().clearHistory();
}
replaceMessages(messages: ChatMessage[]): void {
this.chatHistory = [...messages];
}
}
@ -48,8 +58,8 @@ export function useSharedState(
// When there are no dependencies, the effect will only run once,
// *right after the initial render* (similar to componentDidMount in class components).
useEffect(() => {
setChatHistory(sharedState.getMessages());
}, [sharedState]);
setChatHistory([...sharedState.getMessages()]);
}, [sharedState, sharedState.chatHistory]);
const addMessage = useCallback(
(message: ChatMessage) => {

View file

@ -90,16 +90,6 @@ If your plugin does not need CSS, delete this file.
overflow: hidden;
}
.bottom-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
width: 100%;
flex: 0 0 auto;
box-sizing: border-box;
}
.chat-context-menu {
display: flex;
align-items: center;

View file

@ -925,3 +925,7 @@ export function checkModelApiKey(
export function removeThinkTags(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
export function randomUUID() {
return crypto.randomUUID();
}