Introduce Copilot Plus Alpha to testers (#835)

- Implement Copilot Plus mode
- Use broca endpoint and add license key setting
- Implement context menu for Plus mode
- Implement url mention
- Implement cmd shift enter for vault search
- Handle embedding model change at note reindex
- Implement image support in chat
- Fix vault qa and remove relevance threshold score
- Implement brevilabs client, reranker when max score from orama is low
- Implement chat history to plus mode
- Pass only original user message for intent analysis and processing
- Implement pdf in chat context
- Implement web search
- Implement youtube tool
- Enable index on mode switch for copilot plus mode
This commit is contained in:
Logan Yang 2024-11-21 17:36:52 -08:00 committed by GitHub
parent c7964ae327
commit e0af6d9fbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 3505 additions and 470 deletions

View file

@ -20,7 +20,7 @@ If you enjoy Copilot for Obsidian, please consider [sponsoring this project](htt
<a href="https://www.buymeacoffee.com/logancyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
SPECIAL THANKS TO OUR TOP SPONSORS:
@pedramamini, @Arlorean, @dashinja, @azagore, @MTGMAD, @gpythomas, @emaynard, @scmarinelli, @borthwick, @adamhill, @gluecode, @rusi, @timgrote, @JiaruiYu-Consilium, @ddocta, @AMOz1, @agu3rra
@pedramamini, @Arlorean, @dashinja, @azagore, @MTGMAD, @gpythomas, @emaynard, @scmarinelli, @borthwick, @adamhill, @gluecode, @rusi, @timgrote, @JiaruiYu-Consilium, @ddocta, @AMOz1, @chchwy, @pborenstein, @GitTom, @kazukgw, @mjluser1, @joesfer, @rwaal
[Changelog](https://github.com/logancyang/obsidian-copilot/releases)

33
package-lock.json generated
View file

@ -22,6 +22,7 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@tabler/icons-react": "^2.14.0",
"axios": "^1.3.4",
"chrono-node": "^2.7.7",
"cohere-ai": "^7.13.0",
"crypto-js": "^4.1.1",
"esbuild-plugin-svg": "^0.1.0",
@ -30,6 +31,7 @@
"koa-proxies": "^0.12.3",
"langchain": "^0.3.2",
"lucide-react": "^0.454.0",
"luxon": "^3.5.0",
"next-i18next": "^13.2.2",
"prop-types": "^15.8.1",
"react": "^18.2.0",
@ -47,6 +49,7 @@
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/luxon": "^3.4.2",
"@types/node": "^16.11.6",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
@ -6125,6 +6128,12 @@
"@types/koa": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"dev": true
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@ -7199,6 +7208,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chrono-node": {
"version": "2.7.7",
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.7.7.tgz",
"integrity": "sha512-p3S7gotuTPu5oqhRL2p1fLwQXGgdQaRTtWR3e8Di9P1Pa9mzkK5DWR5AWBieMUh2ZdOnPgrK+zCrbbtyuA+D/Q==",
"dependencies": {
"dayjs": "^1.10.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/ci-info": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz",
@ -7642,6 +7662,11 @@
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@ -12259,6 +12284,14 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View file

@ -37,6 +37,7 @@
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/luxon": "^3.4.2",
"@types/node": "^16.11.6",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
@ -73,6 +74,7 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@tabler/icons-react": "^2.14.0",
"axios": "^1.3.4",
"chrono-node": "^2.7.7",
"cohere-ai": "^7.13.0",
"crypto-js": "^4.1.1",
"esbuild-plugin-svg": "^0.1.0",
@ -81,6 +83,7 @@
"koa-proxies": "^0.12.3",
"langchain": "^0.3.2",
"lucide-react": "^0.454.0",
"luxon": "^3.5.0",
"next-i18next": "^13.2.2",
"prop-types": "^15.8.1",
"react": "^18.2.0",

View file

@ -0,0 +1,149 @@
import { BREVILABS_API_BASE_URL } from "@/constants";
import { Notice } from "obsidian";
export interface BrocaResponse {
response: {
tool_calls: Array<{
tool: string;
args: {
[key: string]: any;
};
}>;
salience_terms: string[];
};
elapsed_time_ms: number;
}
export interface RerankResponse {
response: {
object: string;
data: Array<{
relevance_score: number;
index: number;
}>;
model: string;
usage: {
total_tokens: number;
};
};
elapsed_time_ms: number;
}
export interface ToolCall {
tool: any;
args: any;
}
export interface Url4llmResponse {
response: any;
elapsed_time_ms: number;
}
export interface Pdf4llmResponse {
response: any;
elapsed_time_ms: number;
}
export interface WebSearchResponse {
response: any;
elapsed_time_ms: number;
}
export interface Youtube4llmResponse {
response: {
transcript: string;
};
elapsed_time_ms: number;
}
export class BrevilabsClient {
private static instance: BrevilabsClient;
private licenseKey: string;
private options: any;
private constructor(licenseKey: string, options?: { debug?: boolean }) {
this.licenseKey = licenseKey;
this.options = options;
}
static getInstance(licenseKey: string, options?: { debug?: boolean }): BrevilabsClient {
if (!BrevilabsClient.instance) {
BrevilabsClient.instance = new BrevilabsClient(licenseKey, options);
}
return BrevilabsClient.instance;
}
private checkLicenseKey() {
if (!this.licenseKey) {
new Notice(
"Copilot Plus license key not found. Please enter your license key in the settings."
);
throw new Error("License key not initialized");
}
}
private async makeRequest<T>(endpoint: string, body: any, method = "POST"): Promise<T> {
this.checkLicenseKey();
const url = new URL(`${BREVILABS_API_BASE_URL}${endpoint}`);
if (method === "GET") {
// Add query parameters for GET requests
Object.entries(body).forEach(([key, value]) => {
url.searchParams.append(key, value as string);
});
}
const response = await fetch(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.licenseKey}`,
},
...(method === "POST" && { body: JSON.stringify(body) }),
});
const data = await response.json();
if (this.options?.debug) {
console.log(`==== ${endpoint} request ====:`, data);
}
return data;
}
async broca(userMessage: string): Promise<BrocaResponse> {
const brocaResponse = await this.makeRequest<BrocaResponse>("/broca", {
message: userMessage,
});
return brocaResponse;
}
async rerank(query: string, documents: string[]): Promise<RerankResponse> {
return this.makeRequest<RerankResponse>("/rerank", {
query,
documents,
model: "rerank-2",
});
}
async url4llm(url: string): Promise<Url4llmResponse> {
return this.makeRequest<Url4llmResponse>("/url4llm", { url });
}
async pdf4llm(binaryContent: ArrayBuffer): Promise<Pdf4llmResponse> {
// Convert ArrayBuffer to base64 string
const base64Content = Buffer.from(binaryContent).toString("base64");
return this.makeRequest<Pdf4llmResponse>("/pdf4llm", {
pdf: base64Content,
});
}
async webSearch(query: string): Promise<WebSearchResponse> {
return this.makeRequest<WebSearchResponse>("/websearch", { q: query }, "GET");
}
async youtube4llm(url: string): Promise<Youtube4llmResponse> {
return this.makeRequest<Youtube4llmResponse>("/youtube4llm", { url });
}
}

View file

@ -1,17 +1,17 @@
import { CustomModel, LangChainParams, SetChainOptions } from "@/aiParams";
import ChainFactory, { ChainType, Document } from "@/chainFactory";
import { ABORT_REASON, AI_SENDER, BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants";
import EncryptionService from "@/encryptionService";
import {
ChainRunner,
CopilotPlusChainRunner,
LLMChainRunner,
VaultQAChainRunner,
} from "@/LLMProviders/chainRunner";
import { HybridRetriever } from "@/search/hybridRetriever";
import { CopilotSettings } from "@/settings/SettingsPage";
import { ChatMessage } from "@/sharedState";
import {
extractChatHistory,
extractUniqueTitlesFromDocs,
formatDateTime,
isSupportedChain,
} from "@/utils";
import VectorDBManager from "@/vectorDBManager";
import { isSupportedChain } from "@/utils";
import VectorStoreManager from "@/VectorStoreManager";
import {
ChatPromptTemplate,
@ -19,9 +19,8 @@ import {
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { RunnableSequence } from "@langchain/core/runnables";
import { BaseChatMemory } from "langchain/memory";
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { App, Notice } from "obsidian";
import { BrevilabsClient } from "./brevilabsClient";
import ChatModelManager from "./chatModelManager";
import EmbeddingsManager from "./embeddingManager";
import MemoryManager from "./memoryManager";
@ -30,35 +29,34 @@ import PromptManager from "./promptManager";
export default class ChainManager {
private static chain: RunnableSequence;
private static retrievalChain: RunnableSequence;
private static retrievedDocuments: Document[] = [];
private app: App;
private settings: CopilotSettings;
private vectorStore: MemoryVectorStore;
private vectorStoreManager: VectorStoreManager;
private promptManager: PromptManager;
private encryptionService: EncryptionService;
private langChainParams: LangChainParams;
public app: App;
public vectorStoreManager: VectorStoreManager;
public chatModelManager: ChatModelManager;
public memoryManager: MemoryManager;
public embeddingsManager: EmbeddingsManager;
public promptManager: PromptManager;
public brevilabsClient: BrevilabsClient;
public static retrievedDocuments: Document[] = [];
constructor(
app: App,
getLangChainParams: () => LangChainParams,
encryptionService: EncryptionService,
settings: CopilotSettings,
// Ensure ChainManager always has the up-to-date dbVectorStores
vectorStoreManager: VectorStoreManager
vectorStoreManager: VectorStoreManager,
brevilabsClient: BrevilabsClient
) {
// Instantiate singletons
this.app = app;
this.langChainParams = getLangChainParams();
this.settings = settings;
this.vectorStoreManager = vectorStoreManager;
this.memoryManager = MemoryManager.getInstance(this.getLangChainParams());
this.memoryManager = MemoryManager.getInstance(this.getLangChainParams(), settings.debug);
this.encryptionService = encryptionService;
this.chatModelManager = ChatModelManager.getInstance(
() => this.getLangChainParams(),
@ -67,6 +65,7 @@ export default class ChainManager {
);
this.embeddingsManager = this.vectorStoreManager.getEmbeddingsManager();
this.promptManager = PromptManager.getInstance(this.getLangChainParams());
this.brevilabsClient = brevilabsClient;
this.createChainWithNewModel(this.getLangChainParams().modelKey);
}
@ -81,14 +80,37 @@ export default class ChainManager {
this.langChainParams[key] = value;
}
private setNoteFile(noteFile: any): void {
this.setLangChainParam("options", { ...this.getLangChainParams().options, noteFile });
static getChain(): RunnableSequence {
return ChainManager.chain;
}
static getRetrievalChain(): RunnableSequence {
return ChainManager.retrievalChain;
}
private validateChainType(chainType: ChainType): void {
if (chainType === undefined || chainType === null) throw new Error("No chain type set");
}
private validateChatModel() {
if (!this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
const errorMsg =
"Chat model is not initialized properly, check your API key in Copilot setting and make sure you have API access.";
new Notice(errorMsg);
throw new Error(errorMsg);
}
}
private validateChainInitialization() {
if (!ChainManager.chain || !isSupportedChain(ChainManager.chain)) {
console.error(
"Chain is not initialized properly, re-initializing chain: ",
this.getLangChainParams().chainType
);
this.setChain(this.getLangChainParams().chainType, this.getLangChainParams().options);
}
}
private findCustomModel(modelKey: string): CustomModel | undefined {
const [name, provider] = modelKey.split("|");
return this.settings.activeModels.find(
@ -206,9 +228,11 @@ export default class ChainManager {
this.app.vault,
chatModel,
embeddingsAPI,
this.brevilabsClient,
{
minSimilarityScore: 0.3,
minSimilarityScore: 0.01,
maxK: this.settings.maxSourceChunks,
salientTerms: [],
},
options.debug
);
@ -232,14 +256,51 @@ export default class ChainManager {
break;
}
case ChainType.COPILOT_PLUS_CHAIN: {
// TODO: Create new copilotPlusChain with retriever
// For initial load of the plugin
if (options.forceNewCreation) {
ChainManager.chain = ChainFactory.createNewLLMChain({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
} else {
// For navigating back to the plugin view
ChainManager.chain = ChainFactory.getLLMChainFromMap({
llm: chatModel,
memory: memory,
prompt: options.prompt || chatPrompt,
abortController: options.abortController,
}) as RunnableSequence;
}
this.setLangChainParam("chainType", ChainType.COPILOT_PLUS_CHAIN);
break;
}
default:
this.validateChainType(chainType);
break;
}
}
private getChainRunner(): ChainRunner {
switch (this.getLangChainParams().chainType) {
case ChainType.LLM_CHAIN:
return new LLMChainRunner(this);
case ChainType.VAULT_QA_CHAIN:
return new VaultQAChainRunner(this);
case ChainType.COPILOT_PLUS_CHAIN:
return new CopilotPlusChainRunner(this);
default:
throw new Error(`Unsupported chain type: ${this.getLangChainParams().chainType}`);
}
}
async runChain(
userMessage: string,
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
@ -251,211 +312,31 @@ export default class ChainManager {
) {
const { debug = false, ignoreSystemMessage = false } = options;
// Check if chat model is initialized
if (!this.chatModelManager.validateChatModel(this.chatModelManager.getChatModel())) {
const errorMsg =
"Chat model is not initialized properly, check your API key in Copilot setting and make sure you have API access.";
new Notice(errorMsg);
console.error(errorMsg);
return;
}
// Check if chain is initialized properly
if (!ChainManager.chain || !isSupportedChain(ChainManager.chain)) {
console.error(
"Chain is not initialized properly, re-initializing chain: ",
this.getLangChainParams().chainType
);
this.setChain(this.getLangChainParams().chainType, this.getLangChainParams().options);
}
if (debug) console.log("==== Step 0: Initial user message ====\n", userMessage);
const { temperature, maxTokens, systemMessage, chatContextTurns, chainType } =
this.getLangChainParams();
this.validateChatModel();
this.validateChainInitialization();
const memory = this.memoryManager.getMemory();
const chatPrompt = this.promptManager.getChatPrompt();
const systemPrompt = ignoreSystemMessage ? "" : systemMessage;
// Whether to ignore system prompt (for commands)
// Handle ignoreSystemMessage
if (ignoreSystemMessage) {
const effectivePrompt = ignoreSystemMessage
? ChatPromptTemplate.fromMessages([
new MessagesPlaceholder("history"),
HumanMessagePromptTemplate.fromTemplate("{input}"),
])
: chatPrompt;
this.setChain(chainType, {
const effectivePrompt = ChatPromptTemplate.fromMessages([
new MessagesPlaceholder("history"),
HumanMessagePromptTemplate.fromTemplate("{input}"),
]);
this.setChain(this.getLangChainParams().chainType, {
...this.getLangChainParams().options,
prompt: effectivePrompt,
});
} else {
this.setChain(chainType, this.getLangChainParams().options);
}
let fullAIResponse = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chatModel = (ChainManager.chain as any).last.bound;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chatStream = await ChainManager.chain.stream({
input: userMessage,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
try {
switch (chainType) {
case ChainType.LLM_CHAIN:
if (debug) {
console.log(
`*** DEBUG INFO ***\n` +
`user message: ${userMessage}\n` +
// ChatOpenAI has modelName, some other ChatModels like ChatOllama have model
`model: ${chatModel.modelName || chatModel.model}\n` +
`chain type: ${chainType}\n` +
`temperature: ${temperature}\n` +
`maxTokens: ${maxTokens}\n` +
`system prompt: ${systemPrompt}\n` +
`chat context turns: ${chatContextTurns}\n`
);
}
for await (const chunk of chatStream) {
if (abortController.signal.aborted) break;
fullAIResponse += chunk.content;
updateCurrentAiMessage(fullAIResponse);
}
break;
case ChainType.VAULT_QA_CHAIN:
if (debug) {
console.log(
`*** DEBUG INFO ***\n` +
`user message: ${userMessage}\n` +
`model: ${chatModel.modelName || chatModel.model}\n` +
`chain type: ${chainType}\n` +
`temperature: ${temperature}\n` +
`maxTokens: ${maxTokens}\n` +
`system prompt: ${systemPrompt}\n` +
`chat context turns: ${chatContextTurns}\n`
);
console.log("chain RunnableSequence:", ChainManager.chain);
console.log("embedding model:", this.getLangChainParams().embeddingModelKey);
}
fullAIResponse = await this.runRetrievalChain(
userMessage,
memory,
updateCurrentAiMessage,
abortController,
{ debug }
);
break;
default:
console.error("Chain type not supported:", this.getLangChainParams().chainType);
}
} catch (error) {
const errorData = error?.response?.data?.error || error;
const errorCode = errorData?.code || error;
if (errorCode === "model_not_found") {
const modelNotFoundMsg =
"You do not have access to this model or the model does not exist, please check with your API provider.";
new Notice(modelNotFoundMsg);
console.error(modelNotFoundMsg);
} else {
new Notice(`LangChain error: ${errorCode}`);
console.error(errorData);
}
} finally {
// note: pls Check Abort Reason
if (fullAIResponse && abortController.signal.reason !== ABORT_REASON.NEW_CHAT) {
// This line is a must for memory to work with RunnableSequence!
await memory.saveContext({ input: userMessage }, { output: fullAIResponse });
addMessage({
message: fullAIResponse,
sender: AI_SENDER,
isVisible: true,
timestamp: formatDateTime(new Date()),
});
}
updateCurrentAiMessage("");
}
if (debug) {
console.log(
"Chat memory:",
(memory as any).chatHistory.messages.map((msg: any) => msg.content)
);
}
return fullAIResponse;
}
private async runRetrievalChain(
userMessage: string,
memory: BaseChatMemory,
updateCurrentAiMessage: (message: string) => void,
abortController: AbortController,
options: {
debug?: boolean;
} = {}
): Promise<string> {
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
const qaStream = await ChainManager.retrievalChain.stream({
question: userMessage,
chat_history: chatHistory,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
let fullAIResponse = "";
for await (const chunk of qaStream) {
if (abortController.signal.aborted) break;
fullAIResponse += chunk.content;
updateCurrentAiMessage(fullAIResponse);
}
if (options.debug) {
console.log("Max source chunks:", this.settings.maxSourceChunks);
console.log("Retrieved chunks:", ChainManager.retrievedDocuments);
}
// TODO: This only returns unique note titles, but actual retrieved docs are chunks.
// That means multiple chunks can be from the same note. A more advanced logic is needed
// to show specific chunks in the future. E.g. collapsed note title when clicked,
// expand and reveal the chunk
if (this.getLangChainParams().chainType === ChainType.VAULT_QA_CHAIN) {
const docTitles = extractUniqueTitlesFromDocs(ChainManager.retrievedDocuments);
if (docTitles.length > 0) {
const markdownLinks = docTitles
.map(
(title) =>
`- [${title}](obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(
title
)})`
)
.join("\n");
fullAIResponse += "\n\n#### Sources:\n" + markdownLinks;
}
}
return fullAIResponse;
}
async indexFile(noteFile: any): Promise<any | undefined> {
const embeddingsAPI = this.embeddingsManager.getEmbeddingsAPI();
const db = this.vectorStoreManager.getDb();
if (!embeddingsAPI) {
const errorMsg =
"Failed to load file, embedding API is not set correctly, please check your settings.";
new Notice(errorMsg);
console.error(errorMsg);
return;
}
if (!db) {
const errorMsg = "Vector database is not initialized or disabled on mobile.";
new Notice(errorMsg);
console.error(errorMsg);
return;
}
return await VectorDBManager.indexFile(db, embeddingsAPI, noteFile);
const chainRunner = this.getChainRunner();
return await chainRunner.run(
userMessage,
abortController,
updateCurrentAiMessage,
addMessage,
options
);
}
async updateMemoryWithLoadedMessages(messages: ChatMessage[]) {

View file

@ -0,0 +1,502 @@
import { ABORT_REASON, AI_SENDER, LOADING_MESSAGES } from "@/constants";
import { ChatMessage } from "@/sharedState";
import { ToolManager } from "@/tools/toolManager";
import { extractChatHistory, extractUniqueTitlesFromDocs, formatDateTime } from "@/utils";
import { Notice } from "obsidian";
import ChainManager from "./chainManager";
import { COPILOT_TOOL_NAMES, IntentAnalyzer } from "./intentAnalyzer";
export interface ChainRunner {
run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string>;
}
abstract class BaseChainRunner implements ChainRunner {
constructor(protected chainManager: ChainManager) {}
abstract run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string>;
protected async handleResponse(
fullAIResponse: string,
userMessage: ChatMessage,
abortController: AbortController,
addMessage: (message: ChatMessage) => void,
updateCurrentAiMessage: (message: string) => void,
debug: boolean,
sources?: { title: string; score: number }[]
) {
if (fullAIResponse && abortController.signal.reason !== ABORT_REASON.NEW_CHAT) {
await this.chainManager.memoryManager
.getMemory()
.saveContext({ input: userMessage.message }, { output: fullAIResponse });
addMessage({
message: fullAIResponse,
sender: AI_SENDER,
isVisible: true,
timestamp: formatDateTime(new Date()),
sources: sources,
});
}
updateCurrentAiMessage("");
if (debug) {
console.log(
"==== Chat Memory ====\n",
(this.chainManager.memoryManager.getMemory().chatHistory as any).messages.map(
(m: any) => m.content
)
);
console.log("==== Final AI Response ====\n", fullAIResponse);
}
return fullAIResponse;
}
protected async handleError(error: any, debug: boolean) {
if (debug) console.error("Error during LLM invocation:", error);
const errorData = error?.response?.data?.error || error;
const errorCode = errorData?.code || error;
if (errorCode === "model_not_found") {
const modelNotFoundMsg =
"You do not have access to this model or the model does not exist, please check with your API provider.";
new Notice(modelNotFoundMsg);
console.error(modelNotFoundMsg);
} else {
new Notice(`LangChain error: ${errorCode}`);
console.error(errorData);
}
}
}
class LLMChainRunner extends BaseChainRunner {
async run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
const { debug = false } = options;
let fullAIResponse = "";
try {
const chain = ChainManager.getChain();
const chatStream = await chain.stream({
input: userMessage.message,
} as any);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) break;
fullAIResponse += chunk.content;
updateCurrentAiMessage(fullAIResponse);
}
} catch (error) {
await this.handleError(error, debug);
}
return this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage,
debug
);
}
}
class VaultQAChainRunner extends BaseChainRunner {
async run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
}
): Promise<string> {
const { debug = false } = options;
let fullAIResponse = "";
try {
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
const qaStream = await ChainManager.getRetrievalChain().stream({
question: userMessage.message,
chat_history: chatHistory,
} as any);
for await (const chunk of qaStream) {
if (abortController.signal.aborted) break;
fullAIResponse += chunk.content;
updateCurrentAiMessage(fullAIResponse);
}
fullAIResponse = this.addSourcestoResponse(fullAIResponse);
} catch (error) {
await this.handleError(error, debug);
}
return this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage,
debug
);
}
private addSourcestoResponse(response: string): string {
const docTitles = extractUniqueTitlesFromDocs(ChainManager.retrievedDocuments);
if (docTitles.length > 0) {
const markdownLinks = docTitles
.map(
(title) =>
`- [${title}](obsidian://open?vault=${encodeURIComponent(this.chainManager.app.vault.getName())}&file=${encodeURIComponent(
title
)})`
)
.join("\n");
response += "\n\n#### Sources:\n" + markdownLinks;
}
return response;
}
}
class CopilotPlusChainRunner extends BaseChainRunner {
private async streamMultimodalResponse(
textContent: string,
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
debug: boolean
): Promise<string> {
// Get chat history
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
// Create messages array starting with system message
const messages: any[] = [];
// Add system message if available
const systemMessage = this.chainManager.getLangChainParams().systemMessage;
if (systemMessage) {
messages.push({
role: "system",
content: `${systemMessage}\nIMPORTANT: Maintain consistency with previous responses in the conversation. If you've provided information about a person or topic before, use that same information in follow-up questions.`,
});
}
// Add chat history with explicit instruction to maintain context
if (chatHistory.length > 0) {
messages.push({
role: "system",
content:
"The following is the relevant conversation history. Use this context to maintain consistency in your responses:",
});
}
// Add chat history
for (const [human, ai] of chatHistory) {
messages.push({ role: "user", content: human });
messages.push({ role: "assistant", content: ai });
}
// Create content array for current message
const content = [
{
type: "text",
text: textContent,
},
];
// Add image content if present
if (userMessage.content && userMessage.content.length > 0) {
const imageContent = userMessage.content.filter(
(item) => item.type === "image_url" && item.image_url?.url
);
content.push(...imageContent);
}
// Add current user message
messages.push({
role: "user",
content,
});
let fullAIResponse = "";
const chatStream = await this.chainManager.chatModelManager.getChatModel().stream(messages);
for await (const chunk of chatStream) {
if (abortController.signal.aborted) break;
fullAIResponse += chunk.content;
updateCurrentAiMessage(fullAIResponse);
}
return fullAIResponse;
}
async run(
userMessage: ChatMessage,
abortController: AbortController,
updateCurrentAiMessage: (message: string) => void,
addMessage: (message: ChatMessage) => void,
options: {
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
updateLoadingMessage?: (message: string) => void;
}
): Promise<string> {
const { debug = false, updateLoadingMessage } = options;
let fullAIResponse = "";
let sources: { title: string; score: number }[] = [];
try {
if (debug) console.log("==== Step 1: Analyzing intent ====");
// Use the original message for intent analysis
const messageForAnalysis = userMessage.originalMessage || userMessage.message;
const toolCalls = await IntentAnalyzer.analyzeIntent(
messageForAnalysis,
this.chainManager.vectorStoreManager,
this.chainManager.chatModelManager,
this.chainManager.brevilabsClient
);
if (debug)
console.log(
"Tool identification result:",
toolCalls.map((call) => call.tool.name)
);
// Use the same removeAtCommands logic as IntentAnalyzer
const cleanedUserMessage = userMessage.message
.split(" ")
.filter((word) => !COPILOT_TOOL_NAMES.includes(word.toLowerCase()))
.join(" ")
.trim();
const toolOutputs = await this.executeToolCalls(toolCalls, debug, updateLoadingMessage);
const localSearchResult = toolOutputs.find(
(output) => output.tool === "localSearch" && output.output && output.output.length > 0
);
if (localSearchResult) {
if (debug) console.log("==== Step 2: Processing local search results ====");
const documents = JSON.parse(localSearchResult.output);
// Format chat history from memory
const memory = this.chainManager.memoryManager.getMemory();
const memoryVariables = await memory.loadMemoryVariables({});
const chatHistory = extractChatHistory(memoryVariables);
if (debug) console.log("==== Step 3: Condensing Question ====");
const standaloneQuestion = await this.getStandaloneQuestion(
cleanedUserMessage,
chatHistory
);
if (debug) console.log("Condensed standalone question: ", standaloneQuestion);
if (debug) console.log("==== Step 4: Preparing context ====");
const timeExpression = this.getTimeExpression(toolCalls);
const context = this.formatLocalSearchResult(documents, timeExpression);
if (debug) console.log(context);
if (debug) console.log("==== Step 5: Invoking QA Chain ====");
const qaPrompt = await this.chainManager.promptManager.getQAPrompt({
question: standaloneQuestion,
context: context,
systemMessage: this.chainManager.getLangChainParams().systemMessage,
});
fullAIResponse = await this.streamMultimodalResponse(
qaPrompt,
userMessage,
abortController,
updateCurrentAiMessage,
debug
);
// Append sources to the response
sources = this.getSources(documents);
} else {
const enhancedUserMessage = this.prepareEnhancedUserMessage(
cleanedUserMessage,
toolOutputs
);
// If no results, default to LLM Chain
if (debug) {
console.log("No local search results. Using standard LLM Chain.");
console.log("Enhanced user message:", enhancedUserMessage);
}
fullAIResponse = await this.streamMultimodalResponse(
enhancedUserMessage,
userMessage,
abortController,
updateCurrentAiMessage,
debug
);
}
} catch (error) {
// Reset loading message to default
updateLoadingMessage?.(LOADING_MESSAGES.DEFAULT);
await this.handleError(error, debug);
}
return this.handleResponse(
fullAIResponse,
userMessage,
abortController,
addMessage,
updateCurrentAiMessage,
debug,
sources
);
}
private async getStandaloneQuestion(
question: string,
chatHistory: [string, string][]
): Promise<string> {
const condenseQuestionTemplate = `Given the following conversation and a follow up question,
summarize the conversation as context and keep the follow up question unchanged, in its original language.
If the follow up question is unrelated to its preceding messages, return this follow up question directly.
If it is related, then combine the summary and the follow up question to construct a standalone question.
Make sure to keep any [[]] wrapped note titles in the question unchanged.
If there's nothing in the chat history, just return the follow up question.
Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:`;
const formattedChatHistory = chatHistory
.map(([human, ai]) => `Human: ${human}\nAssistant: ${ai}`)
.join("\n");
const response = await this.chainManager.chatModelManager.getChatModel().invoke([
{ role: "system", content: condenseQuestionTemplate },
{
role: "user",
content: condenseQuestionTemplate
.replace("{chat_history}", formattedChatHistory)
.replace("{question}", question),
},
]);
return response.content as string;
}
private getSources(documents: any): { title: string; score: number }[] {
if (!documents || !Array.isArray(documents)) {
console.warn("No valid documents provided to getSources");
return [];
}
return this.sortUniqueDocsByScore(documents);
}
private sortUniqueDocsByScore(documents: any[]): any[] {
const uniqueDocs = new Map<string, any>();
// Iterate through all documents
for (const doc of documents) {
if (!doc.title || (!doc?.score && !doc?.rerank_score)) {
console.warn("Invalid document structure:", doc);
continue;
}
const currentDoc = uniqueDocs.get(doc.title);
const isReranked = doc && "rerank_score" in doc;
const docScore = isReranked ? doc.rerank_score : doc.score;
// If the title doesn't exist in the map, or if the new doc has a higher score, update the map
if (!currentDoc || docScore > (currentDoc.score ?? 0)) {
uniqueDocs.set(doc.title, {
title: doc.title,
score: docScore,
isReranked: isReranked,
});
}
}
// Convert the map values back to an array and sort by score in descending order
return Array.from(uniqueDocs.values()).sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
}
private async executeToolCalls(
toolCalls: any[],
debug: boolean,
updateLoadingMessage?: (message: string) => void
) {
const toolOutputs = [];
for (const toolCall of toolCalls) {
if (debug) {
console.log(`==== Step 2: Calling tool: ${toolCall.tool.name} ====`);
}
if (toolCall.tool.name === "localSearch") {
updateLoadingMessage?.(LOADING_MESSAGES.READING_FILES);
} else if (toolCall.tool.name === "webSearch") {
updateLoadingMessage?.(LOADING_MESSAGES.SEARCHING_WEB);
}
const output = await ToolManager.callTool(toolCall.tool, toolCall.args);
toolOutputs.push({ tool: toolCall.tool.name, output });
}
return toolOutputs;
}
private prepareEnhancedUserMessage(userMessage: string, toolOutputs: any[]) {
let context = "";
if (toolOutputs.length > 0) {
context =
"\n# Additional context:\n\n" +
toolOutputs
.map((output) => `# ${output.tool}\n${JSON.stringify(output.output)}`)
.join("\n\n");
}
return `User message: ${userMessage}\n${context}`;
}
private getTimeExpression(toolCalls: any[]): string {
const timeRangeCall = toolCalls.find((call) => call.tool.name === "getTimeRangeMs");
return timeRangeCall ? timeRangeCall.args.timeExpression : "";
}
private formatLocalSearchResult(documents: any[], timeExpression: string): string {
const formattedDocs = documents
.filter((doc) => doc.includeInContext)
.map((doc: any) => `Note in Vault: ${doc.content}`)
.join("\n\n");
return timeExpression
? `Local Search Result for ${timeExpression}:\n${formattedDocs}`
: `Local Search Result:\n${formattedDocs}`;
}
}
export { CopilotPlusChainRunner, LLMChainRunner, VaultQAChainRunner };

View file

@ -0,0 +1,161 @@
import ChatModelManager from "@/LLMProviders/chatModelManager";
import VectorStoreManager from "@/VectorStoreManager";
import { indexTool, localSearchTool, webSearchTool } from "@/tools/SearchTools";
import {
getCurrentTimeTool,
getTimeInfoByEpochTool,
getTimeRangeMsTool,
pomodoroTool,
TimeInfo,
} from "@/tools/TimeTools";
import { simpleYoutubeTranscriptionTool } from "@/tools/YoutubeTools";
import { ToolManager } from "@/tools/toolManager";
import { extractYoutubeUrl } from "@/utils";
import { BrevilabsClient } from "./brevilabsClient";
// TODO: Add @index with explicit pdf files in chat context menu
export const COPILOT_TOOL_NAMES = ["@vault", "@web", "@youtube", "@pomodoro"];
type ToolCall = {
tool: any;
args: any;
};
export class IntentAnalyzer {
private static tools = [
getCurrentTimeTool,
getTimeInfoByEpochTool,
getTimeRangeMsTool,
localSearchTool,
indexTool,
pomodoroTool,
webSearchTool,
];
static async analyzeIntent(
originalMessage: string,
vectorStoreManager: VectorStoreManager,
chatModelManager: ChatModelManager,
brevilabsClient: BrevilabsClient
): Promise<ToolCall[]> {
try {
// Only analyze the original message
const brocaResponse = await brevilabsClient.broca(originalMessage);
const brocaToolCalls = brocaResponse.response.tool_calls;
const salientTerms = brocaResponse.response.salience_terms;
const processedToolCalls: ToolCall[] = [];
let timeRange: { startTime: TimeInfo; endTime: TimeInfo } | undefined;
// Process tool calls from broca
for (const brocaToolCall of brocaToolCalls) {
const tool = this.tools.find((t) => t.name === brocaToolCall.tool);
if (tool) {
const args = brocaToolCall.args || {};
if (tool.name === "getTimeRangeMs") {
timeRange = await ToolManager.callTool(tool, args);
}
if (tool.name === "indexVault") {
args.vectorStoreManager = vectorStoreManager;
}
processedToolCalls.push({ tool, args });
}
}
// Process @ commands from original message only
await this.processAtCommands(originalMessage, processedToolCalls, {
timeRange,
salientTerms,
vectorStoreManager,
chatModelManager,
brevilabsClient,
});
return processedToolCalls;
} catch (error) {
console.error("Error in intent analysis:", error);
return [];
}
}
private static async processAtCommands(
originalMessage: string,
processedToolCalls: ToolCall[],
context: {
timeRange?: { startTime: TimeInfo; endTime: TimeInfo };
salientTerms: string[];
vectorStoreManager: VectorStoreManager;
chatModelManager: ChatModelManager;
brevilabsClient: BrevilabsClient;
}
): Promise<void> {
const message = originalMessage.toLowerCase();
const { timeRange, salientTerms, vectorStoreManager, chatModelManager, brevilabsClient } =
context;
// Handle @vault command
if (message.includes("@vault") && (salientTerms.length > 0 || timeRange)) {
// Remove all @commands from the query
const cleanQuery = this.removeAtCommands(originalMessage);
processedToolCalls.push({
tool: localSearchTool,
args: {
timeRange: timeRange || undefined,
query: cleanQuery,
salientTerms,
vectorStoreManager,
chatModelManager,
brevilabsClient,
},
});
}
// Handle @web command
if (message.includes("@web")) {
const cleanQuery = this.removeAtCommands(originalMessage);
processedToolCalls.push({
tool: webSearchTool,
args: {
query: cleanQuery,
brevilabsClient,
},
});
}
// Handle @pomodoro command
if (message.includes("@pomodoro")) {
const pomodoroMatch = originalMessage.match(/@pomodoro\s+(\S+)/i);
const interval = pomodoroMatch ? pomodoroMatch[1] : "25min";
processedToolCalls.push({
tool: pomodoroTool,
args: { interval },
});
}
// Handle @youtube command
if (message.includes("@youtube")) {
const youtubeUrl = extractYoutubeUrl(originalMessage);
if (youtubeUrl) {
processedToolCalls.push({
tool: simpleYoutubeTranscriptionTool,
args: {
url: youtubeUrl,
brevilabsClient,
},
});
}
}
}
private static removeAtCommands(message: string): string {
return message
.split(" ")
.filter((word) => !COPILOT_TOOL_NAMES.includes(word.toLowerCase()))
.join(" ")
.trim();
}
}

View file

@ -4,14 +4,19 @@ import { BaseChatMemory, BufferWindowMemory } from "langchain/memory";
export default class MemoryManager {
private static instance: MemoryManager;
private memory: BaseChatMemory;
private debug: boolean;
private constructor(private langChainParams: LangChainParams) {
private constructor(
private langChainParams: LangChainParams,
debug = false
) {
this.debug = debug;
this.initMemory();
}
static getInstance(langChainParams: LangChainParams): MemoryManager {
static getInstance(langChainParams: LangChainParams, debug = false): MemoryManager {
if (!MemoryManager.instance) {
MemoryManager.instance = new MemoryManager(langChainParams);
MemoryManager.instance = new MemoryManager(langChainParams, debug);
}
return MemoryManager.instance;
}
@ -23,14 +28,27 @@ export default class MemoryManager {
inputKey: "input",
returnMessages: true,
});
if (this.debug)
console.log("Memory initialized with context turns:", this.langChainParams.chatContextTurns);
}
getMemory(): BaseChatMemory {
return this.memory;
}
clearChatMemory(): void {
console.log("clearing chat memory");
this.memory.clear();
async clearChatMemory(): Promise<void> {
if (this.debug) console.log("Clearing chat memory");
await this.memory.clear();
}
async loadMemoryVariables(): Promise<any> {
const variables = await this.memory.loadMemoryVariables({});
if (this.debug) console.log("Loaded memory variables:", variables);
return variables;
}
async saveContext(input: any, output: any): Promise<void> {
if (this.debug) console.log("Saving to memory - Input:", input, "Output:", output);
await this.memory.saveContext(input, output);
}
}

View file

@ -9,9 +9,11 @@ import {
export default class PromptManager {
private static instance: PromptManager;
private chatPrompt: ChatPromptTemplate;
private qaPrompt: ChatPromptTemplate;
private constructor(private langChainParams: LangChainParams) {
this.initChatPrompt();
this.initQAPrompt();
}
static getInstance(langChainParams: LangChainParams): PromptManager {
@ -32,6 +34,20 @@ export default class PromptManager {
]);
}
private initQAPrompt(): void {
const qaTemplate = `{system_message}
Answer the question with as detailed as possible based only on the following context:
{context}
Question: {question}
`;
this.qaPrompt = ChatPromptTemplate.fromMessages([
SystemMessagePromptTemplate.fromTemplate(qaTemplate),
]);
}
// Add this new method to escape curly braces
private escapeTemplateString(str: string): string {
return str.replace(/\{/g, "{{").replace(/\}/g, "}}");
@ -40,4 +56,21 @@ export default class PromptManager {
getChatPrompt(): ChatPromptTemplate {
return this.chatPrompt;
}
async getQAPrompt({
question,
context,
systemMessage,
}: {
question: string;
context: string;
systemMessage: string;
}): Promise<string> {
const promptResult = await this.qaPrompt.format({
question,
context,
system_message: systemMessage,
});
return promptResult;
}
}

View file

@ -1,4 +1,3 @@
import { LangChainParams } from "@/aiParams";
import EncryptionService from "@/encryptionService";
import { CustomError } from "@/error";
import EmbeddingsManager from "@/LLMProviders/embeddingManager";
@ -8,7 +7,9 @@ import VectorDBManager from "@/vectorDBManager";
import { Embeddings } from "@langchain/core/embeddings";
import { create, load, Orama, remove, removeMultiple, save, search } from "@orama/orama";
import { MD5 } from "crypto-js";
import { App, Notice, Platform } from "obsidian";
import { App, Notice, Platform, TAbstractFile, TFile, Vault } from "obsidian";
import { LangChainParams } from "./aiParams";
import { ChainType } from "./chainFactory";
import { VAULT_VECTOR_STORE_STRATEGY } from "./constants";
class VectorStoreManager {
@ -28,6 +29,10 @@ class VectorStoreManager {
private totalFilesToIndex = 0;
private initializationPromise: Promise<void>;
private isIndexLoaded = false;
private excludedFiles: Set<string> = new Set();
private debounceDelay = 5000; // 5 seconds
private debounceTimer: number | null = null;
constructor(
app: App,
@ -69,6 +74,8 @@ class VectorStoreManager {
getEmbeddingRequestsPerSecond: () => this.settings.embeddingRequestsPerSecond,
debug: this.settings.debug,
});
this.updateExcludedFiles();
}
private async performPostInitializationTasks() {
@ -161,6 +168,12 @@ class VectorStoreManager {
}
const vectorLength = await this.getVectorLength(embeddingInstance);
if (!vectorLength || vectorLength === 0) {
throw new CustomError(
"Invalid vector length detected. Please check if your embedding model is working."
);
}
const schema = this.createDynamicSchema(vectorLength);
const db = await create({
@ -180,10 +193,33 @@ class VectorStoreManager {
return db;
}
public getVault(): Vault {
return this.app.vault;
}
public getSettings(): CopilotSettings {
return this.settings;
}
private async getVectorLength(embeddingInstance: Embeddings): Promise<number> {
const sampleText = "Sample text for embedding";
const sampleEmbedding = await embeddingInstance.embedQuery(sampleText);
return sampleEmbedding.length;
try {
const sampleText = "Sample text for embedding";
const sampleEmbedding = await embeddingInstance.embedQuery(sampleText);
if (!sampleEmbedding || sampleEmbedding.length === 0) {
throw new CustomError("Failed to get valid embedding vector length");
}
console.log(
`Detected vector length: ${sampleEmbedding.length} for model: ${EmbeddingsManager.getModelName(embeddingInstance)}`
);
return sampleEmbedding.length;
} catch (error) {
console.error("Error getting vector length:", error);
throw new CustomError(
"Failed to determine embedding vector length. Please check your embedding model settings."
);
}
}
private async ensureCorrectSchema(db: Orama<any>, embeddingInstance: Embeddings): Promise<void> {
@ -320,6 +356,49 @@ class VectorStoreManager {
return allContent;
}
private async checkAndHandleEmbeddingModelChange(
db: Orama<any>,
embeddingInstance: Embeddings
): Promise<boolean> {
const singleDoc = await search(db, {
term: "",
limit: 1,
});
let prevEmbeddingModel: string | undefined;
if (singleDoc.hits.length > 0) {
const oramaDocSample = singleDoc.hits[0];
if (
typeof oramaDocSample === "object" &&
oramaDocSample !== null &&
"document" in oramaDocSample
) {
const document = oramaDocSample.document as { embeddingModel?: string };
prevEmbeddingModel = document.embeddingModel;
}
}
if (prevEmbeddingModel) {
const currEmbeddingModel = EmbeddingsManager.getModelName(embeddingInstance);
if (!areEmbeddingModelsSame(prevEmbeddingModel, currEmbeddingModel)) {
// Model has changed, notify user and rebuild DB
new Notice("New embedding model detected. Rebuilding vector store from scratch.");
console.log("Detected change in embedding model. Rebuilding vector store from scratch.");
// Create new DB with new model
this.oramaDb = await this.createNewDb();
await this.saveDB();
return true;
}
} else {
console.log("No previous embedding model found in the database.");
}
return false;
}
public async indexVaultToVectorStore(overwrite?: boolean): Promise<number> {
// Add check at the start of the method
if ((Platform.isMobile && this.settings.disableIndexOnMobile) || !this.oramaDb) {
@ -337,38 +416,13 @@ class VectorStoreManager {
}
await this.ensureCorrectSchema(this.oramaDb, embeddingInstance);
const singleDoc = await search(this.oramaDb, {
term: "",
limit: 1,
});
let prevEmbeddingModel: string | undefined;
if (singleDoc.hits.length > 0) {
const oramaDocSample = singleDoc.hits[0];
if (
typeof oramaDocSample === "object" &&
oramaDocSample !== null &&
"document" in oramaDocSample
) {
const document = oramaDocSample.document as { embeddingModel?: string };
prevEmbeddingModel = document.embeddingModel;
}
}
if (prevEmbeddingModel) {
const currEmbeddingModel = EmbeddingsManager.getModelName(embeddingInstance);
if (!areEmbeddingModelsSame(prevEmbeddingModel, currEmbeddingModel)) {
// Model has changed, reinitialize DB
this.oramaDb = await this.createNewDb();
overwrite = true;
new Notice("Detected change in embedding model. Rebuilding vector store from scratch.");
console.log("Detected change in embedding model. Rebuilding vector store from scratch.");
await this.saveDB();
}
} else {
console.log("No previous embedding model found in the database.");
// Check for model change
const modelChanged = await this.checkAndHandleEmbeddingModelChange(
this.oramaDb,
embeddingInstance
);
if (modelChanged) {
overwrite = true;
}
const latestMtime = await VectorDBManager.getLatestFileMtime(this.oramaDb);
@ -432,7 +486,7 @@ class VectorStoreManager {
embeddingModel: EmbeddingsManager.getModelName(embeddingInstance),
ctime: file.stat.ctime,
mtime: file.stat.mtime,
tags: fileMetadatas[index]?.tags ?? [], // Assuming tags are in the metadata
tags: fileMetadatas[index]?.tags?.map((tag) => tag.tag) ?? [],
extension: file.extension,
metadata: fileMetadatas[index]?.frontmatter ?? {},
};
@ -510,9 +564,14 @@ class VectorStoreManager {
public async clearVectorStore(): Promise<void> {
try {
// Create a new, empty database instance
// Create a new, empty database instance with fresh schema
this.oramaDb = await this.createNewDb();
// Delete the existing database file
if (await this.app.vault.adapter.exists(this.dbPath)) {
await this.app.vault.adapter.remove(this.dbPath);
}
// Save the new, empty database
await this.saveDB();
new Notice("Local vector store cleared successfully.");
@ -580,7 +639,6 @@ class VectorStoreManager {
const searchResult = await search(this.oramaDb, {
term: filePath,
properties: ["path"],
tolerance: 1,
});
if (searchResult.hits.length > 0) {
await removeMultiple(
@ -588,6 +646,9 @@ class VectorStoreManager {
searchResult.hits.map((hit) => hit.id),
500
);
if (this.settings.debug) {
console.log(`Deleted document from local Copilot index: ${filePath}`);
}
}
} catch (err) {
console.error("Error deleting document from local Copilotindex:", err);
@ -611,6 +672,98 @@ class VectorStoreManager {
});
return result.hits[0]?.document;
}
public async initializeEventListeners() {
if (this.settings.debug) {
console.log("Copilot Plus: Initializing event listeners");
}
this.app.vault.on("modify", this.handleFileModify);
this.app.vault.on("delete", this.handleFileDelete);
}
private debouncedReindexFile = (file: TFile) => {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.reindexFile(file);
this.debounceTimer = null;
}, this.debounceDelay);
};
private handleFileModify = async (file: TAbstractFile) => {
await this.updateExcludedFiles();
const currentChainType = this.getLangChainParams().chainType;
if (
file instanceof TFile &&
file.extension === "md" &&
!this.excludedFiles.has(file.path) &&
currentChainType === ChainType.COPILOT_PLUS_CHAIN
) {
if (this.settings.debug) {
console.log("Copilot Plus: Triggering reindex for file ", file.path);
}
await this.removeDocs(file.path);
this.debouncedReindexFile(file);
}
};
private handleFileDelete = async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.removeDocs(file.path);
}
};
private updateExcludedFiles = async () => {
this.excludedFiles = await this.getFilePathsForQA("exclusions");
};
private async reindexFile(file: TFile) {
try {
const embeddingInstance = this.embeddingsManager.getEmbeddingsAPI();
if (!embeddingInstance) {
throw new CustomError("Embedding instance not found.");
}
const db = this.oramaDb;
if (!db) {
throw new CustomError("Copilot index is not loaded.");
}
// Check for model change
const modelChanged = await this.checkAndHandleEmbeddingModelChange(db, embeddingInstance);
if (modelChanged) {
// Trigger full reindex and exit
await this.indexVaultToVectorStore(true);
return;
}
// Proceed with single file reindex
const content = await this.app.vault.cachedRead(file);
const fileCache = this.app.metadataCache.getFileCache(file);
const fileToSave = {
title: file.basename,
path: file.path,
content: content,
embeddingModel: EmbeddingsManager.getModelName(embeddingInstance),
ctime: file.stat.ctime,
mtime: file.stat.mtime,
tags: fileCache?.tags?.map((tag) => tag.tag) ?? [],
extension: file.extension,
metadata: fileCache?.frontmatter ?? {},
};
await VectorDBManager.indexFile(db, embeddingInstance, fileToSave);
await this.saveDB();
if (this.settings.debug) {
console.log(`Reindexed file: ${file.path}`);
}
} catch (error) {
console.error(`Error reindexing file ${file.path}:`, error);
}
}
}
export default VectorStoreManager;

View file

@ -1,4 +1,5 @@
import { ChainType } from "@/chainFactory";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { ChatPromptTemplate } from "@langchain/core/prompts";
export interface ModelConfig {
@ -54,6 +55,7 @@ export interface LangChainParams {
export interface SetChainOptions {
prompt?: ChatPromptTemplate;
chatModel?: BaseChatModel;
noteFile?: any;
forceNewCreation?: boolean;
abortController?: AbortController;

View file

@ -0,0 +1,33 @@
import { App, TFile } from "obsidian";
import { BaseNoteModal } from "./BaseNoteModal";
interface AddContextNoteModalProps {
app: App;
onNoteSelect: (note: TFile) => void;
excludeNotes: string[];
}
export class AddContextNoteModal extends BaseNoteModal<TFile> {
private onNoteSelect: (note: TFile) => void;
private excludeNotes: string[];
constructor({ app, onNoteSelect, excludeNotes }: AddContextNoteModalProps) {
super(app);
this.onNoteSelect = onNoteSelect;
this.excludeNotes = excludeNotes;
this.availableNotes = this.getOrderedNotes(excludeNotes);
}
getItems(): TFile[] {
return this.availableNotes;
}
getItemText(note: TFile): string {
const isActive = note.path === this.activeNote?.path;
return this.formatNoteTitle(note.basename, isActive, note.extension);
}
onChooseItem(note: TFile, evt: MouseEvent | KeyboardEvent) {
this.onNoteSelect(note);
}
}

View file

@ -0,0 +1,29 @@
import { App } from "obsidian";
export class AddImageModal {
private app: App;
private onImagesSelected: (files: File[]) => void;
constructor(app: App, onImagesSelected: (files: File[]) => void) {
this.app = app;
this.onImagesSelected = onImagesSelected;
}
open() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
const files = Array.from(input.files || []);
this.onImagesSelected(files);
// Clean up
document.body.removeChild(input);
});
document.body.appendChild(input);
input.click();
}
}

View file

@ -0,0 +1,51 @@
import { App, FuzzySuggestModal, TFile } from "obsidian";
export abstract class BaseNoteModal<T> extends FuzzySuggestModal<T> {
protected activeNote: TFile | null;
protected availableNotes: T[];
constructor(app: App) {
super(app);
this.activeNote = app.workspace.getActiveFile();
}
protected getOrderedNotes(excludeNotes: string[] = []): TFile[] {
// Get recently opened files first
const recentFiles = this.app.workspace
.getLastOpenFiles()
.map((filePath) => this.app.vault.getAbstractFileByPath(filePath))
.filter(
(file): file is TFile =>
file instanceof TFile &&
(file.extension === "md" || file.extension === "pdf") &&
!excludeNotes.includes(file.path) &&
file.path !== this.activeNote?.path
);
// Get all other files that weren't recently opened
const allFiles = this.app.vault
.getFiles()
.filter((file) => file.extension === "md" || file.extension === "pdf");
const otherFiles = allFiles.filter(
(file) =>
!recentFiles.some((recent) => recent.path === file.path) &&
!excludeNotes.includes(file.path) &&
file.path !== this.activeNote?.path
);
// Combine active note (if exists) with recent files and other files
return [...(this.activeNote ? [this.activeNote] : []), ...recentFiles, ...otherFiles];
}
protected formatNoteTitle(basename: string, isActive: boolean, extension?: string): string {
let title = basename;
if (isActive) {
title += " (current)";
}
if (extension === "pdf") {
title += " (PDF)";
}
return title;
}
}

View file

@ -7,15 +7,19 @@ import {
ABORT_REASON,
AI_SENDER,
EVENT_NAMES,
LOADING_MESSAGES,
USER_SENDER,
VAULT_VECTOR_STORE_STRATEGY,
} from "@/constants";
import { AppContext } from "@/context";
import { ContextProcessor } from "@/contextProcessor";
import { CustomPromptProcessor } from "@/customPromptProcessor";
import { getAIResponse } from "@/langchainStream";
import CopilotPlugin from "@/main";
import { Mention } from "@/mentions/Mention";
import { CopilotSettings } from "@/settings/SettingsPage";
import SharedState, { ChatMessage, useSharedState } from "@/sharedState";
import { FileParserManager } from "@/tools/FileParserManager";
import {
createChangeToneSelectionPrompt,
createTranslateSelectionPrompt,
@ -51,6 +55,7 @@ interface ChatProps {
defaultSaveFolder: string;
onSaveChat: (saveAsNote: () => Promise<void>) => void;
updateUserMessageHistory: (newMessage: string) => void;
fileParserManager: FileParserManager;
plugin: CopilotPlugin;
debug: boolean;
}
@ -63,6 +68,7 @@ const Chat: React.FC<ChatProps> = ({
defaultSaveFolder,
onSaveChat,
updateUserMessageHistory,
fileParserManager,
plugin,
debug,
}) => {
@ -73,8 +79,16 @@ const Chat: React.FC<ChatProps> = ({
const [inputMessage, setInputMessage] = useState("");
const [abortController, setAbortController] = useState<AbortController | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES.DEFAULT);
const [historyIndex, setHistoryIndex] = useState(-1);
const [chatIsVisible, setChatIsVisible] = useState(false);
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const mention = Mention.getInstance(plugin.settings.plusLicenseKey);
const contextProcessor = ContextProcessor.getInstance();
useEffect(() => {
const handleChatVisibility = (evt: CustomEvent<{ chatIsVisible: boolean }>) => {
@ -90,54 +104,118 @@ const Chat: React.FC<ChatProps> = ({
const app = plugin.app || useContext(AppContext);
const handleSendMessage = async () => {
if (!inputMessage) return;
const processContextNotes = async (
customPromptProcessor: CustomPromptProcessor,
fileParserManager: FileParserManager
) => {
const activeNote = app.workspace.getActiveFile();
return await contextProcessor.processContextNotes(
customPromptProcessor,
fileParserManager,
app.vault,
contextNotes,
includeActiveNote,
activeNote
);
};
const handleSendMessage = async (toolCalls?: string[]) => {
if (!inputMessage && selectedImages.length === 0) return;
const timestamp = formatDateTime(new Date());
// Create message content array
const content: any[] = [];
// Add text content if present
if (inputMessage) {
content.push({
type: "text",
text: inputMessage,
});
}
// Add images if present
for (const image of selectedImages) {
const imageData = await image.arrayBuffer();
const base64Image = Buffer.from(imageData).toString("base64");
content.push({
type: "image_url",
image_url: {
url: `data:${image.type};base64,${base64Image}`,
},
});
}
const userMessage: ChatMessage = {
message: inputMessage || "Image message",
originalMessage: inputMessage,
sender: USER_SENDER,
isVisible: true,
timestamp: timestamp,
content: content,
};
// Clear input and images
setInputMessage("");
setSelectedImages([]);
// Add messages to chat history
addMessage(userMessage);
setLoading(true);
setLoadingMessage(LOADING_MESSAGES.DEFAULT);
// First, process the original user message for custom prompts
const customPromptProcessor = CustomPromptProcessor.getInstance(app.vault, settings);
const processedUserMessage = await customPromptProcessor.processCustomPrompt(
inputMessage,
let processedUserMessage = await customPromptProcessor.processCustomPrompt(
inputMessage || "",
"",
app.workspace.getActiveFile() as TFile | undefined
);
const timestamp = formatDateTime(new Date());
// Extract Mentions (such as URLs) from original input message only
const urlContextAddition = await mention.processUrls(inputMessage || "");
const userMessage: ChatMessage = {
message: inputMessage,
sender: USER_SENDER,
isVisible: true,
timestamp: timestamp,
};
// Add context notes
const noteContextAddition = await processContextNotes(customPromptProcessor, fileParserManager);
// Combine everything
processedUserMessage = processedUserMessage + urlContextAddition + noteContextAddition;
let messageWithToolCalls = inputMessage;
// Add tool calls last
if (toolCalls) {
messageWithToolCalls += " " + toolCalls.join("\n");
}
const promptMessageHidden: ChatMessage = {
message: processedUserMessage,
originalMessage: messageWithToolCalls,
sender: USER_SENDER,
isVisible: false,
timestamp: timestamp,
content: content,
};
// Add user message to chat history
addMessage(userMessage);
// Add hidden user message to chat history
addMessage(promptMessageHidden);
// Add to user message history
updateUserMessageHistory(inputMessage);
setHistoryIndex(-1);
// Add to user message history if there's text
if (inputMessage) {
updateUserMessageHistory(inputMessage);
setHistoryIndex(-1);
}
// Clear input
setInputMessage("");
// Display running dots to indicate loading
setLoading(true);
await getAIResponse(
promptMessageHidden,
chainManager,
addMessage,
setCurrentAiMessage,
setAbortController,
{ debug }
{ debug, updateLoadingMessage: setLoadingMessage }
);
setLoading(false);
setLoadingMessage(LOADING_MESSAGES.DEFAULT);
};
const navigateHistory = (direction: "up" | "down"): string => {
@ -301,7 +379,7 @@ ${chatContent}`;
setLoading(true);
try {
const regeneratedResponse = await chainManager.runChain(
lastUserMessage.message,
lastUserMessage,
new AbortController(),
setCurrentAiMessage,
addMessage,
@ -535,6 +613,7 @@ ${chatContent}`;
currentAiMessage={currentAiMessage}
indexVaultToVectorStore={settings.indexVaultToVectorStore as VAULT_VECTOR_STORE_STRATEGY}
loading={loading}
loadingMessage={loadingMessage}
app={app}
onInsertAtCursor={handleInsertAtCursor}
onRegenerate={handleRegenerate}
@ -571,10 +650,18 @@ ${chatContent}`;
onSaveAsNote={() => handleSaveAsNote(true)}
onRefreshVaultContext={refreshVaultContext}
vault_qa_strategy={plugin.settings.indexVaultToVectorStore}
debug={debug}
addMessage={addMessage}
vault={app.vault}
isIndexLoadedPromise={plugin.vectorStoreManager.getIsIndexLoaded()}
contextNotes={contextNotes}
setContextNotes={setContextNotes}
includeActiveNote={includeActiveNote}
setIncludeActiveNote={setIncludeActiveNote}
mention={mention}
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}
debug={debug}
/>
</div>
</div>

View file

@ -1,6 +1,14 @@
import { USER_SENDER } from "@/constants";
import { ChatMessage } from "@/sharedState";
import { Check, Copy, MessageSquarePlus, PenSquare, RotateCw, Trash2 } from "lucide-react";
import {
Check,
Copy,
LibraryBig,
MessageSquarePlus,
PenSquare,
RotateCw,
Trash2,
} from "lucide-react";
import React from "react";
interface ChatButtonsProps {
@ -11,6 +19,8 @@ interface ChatButtonsProps {
onRegenerate?: () => void;
onEdit?: () => void;
onDelete: () => void;
onShowSources?: () => void;
hasSources: boolean;
}
export const ChatButtons: React.FC<ChatButtonsProps> = ({
@ -21,6 +31,8 @@ export const ChatButtons: React.FC<ChatButtonsProps> = ({
onRegenerate,
onEdit,
onDelete,
onShowSources,
hasSources,
}) => {
return (
<div className="chat-message-buttons">
@ -38,6 +50,11 @@ export const ChatButtons: React.FC<ChatButtonsProps> = ({
</>
) : (
<>
{hasSources && (
<button onClick={onShowSources} className="clickable-icon" title="Show Sources">
<LibraryBig />
</button>
)}
<button
onClick={onInsertAtCursor}
className="clickable-icon"

View file

@ -0,0 +1,80 @@
import { Plus } from "lucide-react";
import { TFile } from "obsidian";
import React from "react";
import { TooltipActionButton } from "./TooltipActionButton";
interface ChatContextMenuProps {
activeNote: TFile | null;
contextNotes: TFile[];
contextUrls: string[];
onAddContext: () => void;
onRemoveContext: (path: string) => void;
onRemoveUrl: (url: string) => void;
}
export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
activeNote,
contextNotes,
contextUrls,
onAddContext,
onRemoveContext,
onRemoveUrl,
}) => {
const uniqueNotes = React.useMemo(() => {
const notesMap = new Map(contextNotes.map((note) => [note.path, note]));
return Array.from(notesMap.values()).filter(
(note) => !(activeNote && note.path === activeNote.path)
);
}, [contextNotes, activeNote]);
const uniqueUrls = React.useMemo(() => Array.from(new Set(contextUrls)), [contextUrls]);
const renderNote = (note: TFile, isActive = false) => (
<div key={note.path} className={`context-note ${isActive ? "active" : "with-hover"}`}>
<span className="note-name">{note.basename}</span>
{isActive && <span className="note-badge">current</span>}
{note.extension === "pdf" && <span className="note-badge pdf">pdf</span>}
<button
className="remove-note"
onClick={() => onRemoveContext(note.path)}
aria-label="Remove from context"
>
×
</button>
</div>
);
return (
<div className="chat-context-menu">
<TooltipActionButton onClick={onAddContext} Icon={<Plus className="icon-scaler" />}>
Add Note to Context
</TooltipActionButton>
<div className="context-notes">
{activeNote && renderNote(activeNote, true)}
{uniqueNotes.map((note) => renderNote(note))}
{uniqueUrls.map((url) => (
<div key={url} className="context-note url">
<span className="note-name" title={url}>
{(() => {
const hostname = new URL(url).hostname;
const cleanUrl = url.replace(/\/+$/, "");
const path = cleanUrl.slice(cleanUrl.indexOf(hostname) + hostname.length);
if (path.length <= 1) return hostname;
return `${hostname}/...${cleanUrl.slice(-2)}`;
})()}
</span>
<span className="note-badge">url</span>
<button
className="remove-note"
onClick={() => onRemoveUrl(url)}
aria-label="Remove URL from context"
>
×
</button>
</div>
))}
</div>
</div>
);
};

View file

@ -1,17 +1,20 @@
import { SetChainOptions } from "@/aiParams";
import { CopilotPlusModal } from "@/components/CopilotPlusModal";
import { VAULT_VECTOR_STORE_STRATEGY } from "@/constants";
import { CustomError } from "@/error";
import { CopilotSettings } from "@/settings/SettingsPage";
import { Notice } from "obsidian";
import { App, Notice } from "obsidian";
import React, { useEffect, useState } from "react";
import { ChainType } from "@/chainFactory";
import { AddContextNoteModal } from "@/components/AddContextNoteModal";
import { TooltipActionButton } from "@/components/ChatComponents/TooltipActionButton";
import { stringToChainType } from "@/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDown, Download, Puzzle, RefreshCw } from "lucide-react";
import { TFile } from "obsidian";
import { ChatContextMenu } from "./ChatContextMenu";
interface ChatControlsProps {
currentChain: ChainType;
setCurrentChain: (chain: ChainType, options?: SetChainOptions) => void;
@ -21,6 +24,13 @@ interface ChatControlsProps {
settings: CopilotSettings;
vault_qa_strategy: string;
isIndexLoadedPromise: Promise<boolean>;
app: App;
contextNotes: TFile[];
setContextNotes: React.Dispatch<React.SetStateAction<TFile[]>>;
includeActiveNote: boolean;
setIncludeActiveNote: React.Dispatch<React.SetStateAction<boolean>>;
contextUrls: string[];
onRemoveUrl: (url: string) => void;
debug?: boolean;
}
@ -32,11 +42,19 @@ const ChatControls: React.FC<ChatControlsProps> = ({
onRefreshVaultContext,
settings,
vault_qa_strategy,
debug,
isIndexLoadedPromise,
app,
contextNotes,
setContextNotes,
includeActiveNote,
setIncludeActiveNote,
contextUrls,
onRemoveUrl,
debug,
}) => {
const [selectedChain, setSelectedChain] = useState<ChainType>(currentChain);
const [isIndexLoaded, setIsIndexLoaded] = useState(false);
const activeNote = app.workspace.getActiveFile();
useEffect(() => {
isIndexLoadedPromise.then((loaded) => {
@ -48,13 +66,14 @@ const ChatControls: React.FC<ChatControlsProps> = ({
const newChain = stringToChainType(value);
setSelectedChain(newChain);
if (newChain === ChainType.COPILOT_PLUS_CHAIN) {
new CopilotPlusModal(app).open();
// Reset the selected chain to the previous value
setSelectedChain(currentChain);
} else {
setCurrentChain(newChain, { debug });
}
// TODO: Update Copilot Plus Modal to check the license key when ready to ship
// if (newChain === ChainType.COPILOT_PLUS_CHAIN) {
// new CopilotPlusModal(app).open();
// // Reset the selected chain to the previous value
// setSelectedChain(currentChain);
// } else {
// setCurrentChain(newChain, { debug });
// }
};
useEffect(() => {
@ -64,10 +83,12 @@ const ChatControls: React.FC<ChatControlsProps> = ({
return;
}
if (selectedChain === ChainType.VAULT_QA_CHAIN) {
if (vault_qa_strategy === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH) {
await onRefreshVaultContext();
}
if (
(selectedChain === ChainType.VAULT_QA_CHAIN ||
selectedChain === ChainType.COPILOT_PLUS_CHAIN) &&
vault_qa_strategy === VAULT_VECTOR_STORE_STRATEGY.ON_MODE_SWITCH
) {
await onRefreshVaultContext();
}
try {
@ -104,59 +125,102 @@ const ChatControls: React.FC<ChatControlsProps> = ({
// new SimilarNotesModal(app, similarChunks).open();
// };
return (
<div className="chat-icons-container">
<TooltipActionButton
onClick={() => {
onNewChat(false);
}}
Icon={<RefreshCw className="icon-scaler" />}
>
<div>New Chat</div>
{!settings.autosaveChat && <div>(Unsaved history will be lost)</div>}
</TooltipActionButton>
<TooltipActionButton onClick={onSaveAsNote} Icon={<Download className="icon-scaler" />}>
Save as Note
</TooltipActionButton>
{selectedChain === "vault_qa" && (
<TooltipActionButton
onClick={onRefreshVaultContext}
Icon={<Puzzle className="icon-scaler" />}
>
Refresh Index for Vault
</TooltipActionButton>
)}
<div className="chat-icon-selection-tooltip">
<DropdownMenu.Root>
<DropdownMenu.Trigger className="chain-select-button">
{currentChain === "llm_chain" && "chat"}
{currentChain === "vault_qa" && "vault QA (basic)"}
{currentChain === "copilot_plus" && "copilot plus (alpha)"}
<ChevronDown size={10} />
</DropdownMenu.Trigger>
const handleAddContext = () => {
const excludeNotes = [
...contextNotes.map((note) => note.path),
...(includeActiveNote && activeNote ? [activeNote.path] : []),
].filter(Boolean) as string[];
<DropdownMenu.Portal>
<DropdownMenu.Content className="chain-select-content" align="end" sideOffset={5}>
<DropdownMenu.Item onSelect={() => handleChainChange({ value: "llm_chain" })}>
chat
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => handleChainChange({ value: "vault_qa" })}
disabled={!isIndexLoaded}
className={!isIndexLoaded ? "disabled-menu-item" : ""}
>
vault QA (basic) {!isIndexLoaded && "(index not loaded)"}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => handleChainChange({ value: "copilot_plus" })}
disabled={!isIndexLoaded}
className={!isIndexLoaded ? "disabled-menu-item" : ""}
>
copilot plus (alpha) {!isIndexLoaded && "(index not loaded)"}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
new AddContextNoteModal({
app,
onNoteSelect: (note) => {
if (activeNote && note.path === activeNote.path) {
setIncludeActiveNote(true);
// Remove the note from contextNotes if it exists there
setContextNotes((prev) => prev.filter((n) => n.path !== note.path));
} else {
setContextNotes((prev) => [...prev, note]);
}
},
excludeNotes,
}).open();
};
const handleRemoveContext = (path: string) => {
if (activeNote && path === activeNote.path) {
setIncludeActiveNote(false);
} else {
setContextNotes((prev) => prev.filter((note) => note.path !== path));
}
};
return (
<div className="chat-controls-wrapper">
<div className="chat-icons-container">
{currentChain === ChainType.COPILOT_PLUS_CHAIN && (
<ChatContextMenu
activeNote={includeActiveNote ? activeNote : null}
contextNotes={contextNotes}
onAddContext={handleAddContext}
onRemoveContext={handleRemoveContext}
contextUrls={contextUrls}
onRemoveUrl={onRemoveUrl}
/>
)}
<div className="chat-icons-right">
<TooltipActionButton
onClick={() => {
onNewChat(false);
}}
Icon={<RefreshCw className="icon-scaler" />}
>
<div>New Chat</div>
{!settings.autosaveChat && <div>(Unsaved history will be lost)</div>}
</TooltipActionButton>
<TooltipActionButton onClick={onSaveAsNote} Icon={<Download className="icon-scaler" />}>
Save as Note
</TooltipActionButton>
{selectedChain === "vault_qa" && (
<TooltipActionButton
onClick={onRefreshVaultContext}
Icon={<Puzzle className="icon-scaler" />}
>
Refresh Index for Vault
</TooltipActionButton>
)}
<div className="chat-icon-selection-tooltip">
<DropdownMenu.Root>
<DropdownMenu.Trigger className="chain-select-button">
{currentChain === "llm_chain" && "chat"}
{currentChain === "vault_qa" && "vault QA (basic)"}
{currentChain === "copilot_plus" && "copilot plus (alpha)"}
<ChevronDown size={10} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="chain-select-content" align="end" sideOffset={5}>
<DropdownMenu.Item onSelect={() => handleChainChange({ value: "llm_chain" })}>
chat
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => handleChainChange({ value: "vault_qa" })}
disabled={!isIndexLoaded}
className={!isIndexLoaded ? "disabled-menu-item" : ""}
>
vault QA (basic) {!isIndexLoaded && "(index not loaded)"}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => handleChainChange({ value: "copilot_plus" })}
disabled={!isIndexLoaded}
className={!isIndexLoaded ? "disabled-menu-item" : ""}
>
copilot plus (alpha) {!isIndexLoaded && "(index not loaded)"}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
</div>
</div>
);

View file

@ -2,19 +2,25 @@ import { CustomModel, SetChainOptions } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { ListPromptModal } from "@/components/ListPromptModal";
import { NoteTitleModal } from "@/components/NoteTitleModal";
import { ContextProcessor } from "@/contextProcessor";
import { CustomPromptProcessor } from "@/customPromptProcessor";
import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer";
import { Mention } from "@/mentions/Mention";
import { CopilotSettings } from "@/settings/SettingsPage";
import { ChatMessage } from "@/sharedState";
import { extractNoteTitles } from "@/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronUp, Command, CornerDownLeft, StopCircle } from "lucide-react";
import { ArrowBigUp, ChevronUp, Command, CornerDownLeft, Image, StopCircle } from "lucide-react";
import { App, Platform, TFile, Vault } from "obsidian";
import React, { useEffect, useRef, useState } from "react";
import { AddImageModal } from "../AddImageModal";
import ChatControls from "./ChatControls";
import { TooltipActionButton } from "./TooltipActionButton";
interface ChatInputProps {
inputMessage: string;
setInputMessage: (message: string) => void;
handleSendMessage: () => void;
handleSendMessage: (toolCalls?: string[]) => void;
isGenerating: boolean;
chatIsVisible: boolean;
onStopGenerating: () => void;
@ -32,6 +38,14 @@ interface ChatInputProps {
vault: Vault;
vault_qa_strategy: string;
isIndexLoadedPromise: Promise<boolean>;
contextNotes: TFile[];
setContextNotes: React.Dispatch<React.SetStateAction<TFile[]>>;
includeActiveNote: boolean;
setIncludeActiveNote: (include: boolean) => void;
mention: Mention;
selectedImages: File[];
onAddImage: (files: File[]) => void;
setSelectedImages: React.Dispatch<React.SetStateAction<File[]>>;
debug?: boolean;
}
@ -58,15 +72,70 @@ const ChatInput: React.FC<ChatInputProps> = ({
vault,
vault_qa_strategy,
isIndexLoadedPromise,
contextNotes,
setContextNotes,
includeActiveNote,
setIncludeActiveNote,
mention,
selectedImages,
onAddImage,
setSelectedImages,
debug,
}) => {
const [shouldFocus, setShouldFocus] = useState(false);
const [historyIndex, setHistoryIndex] = useState(-1);
const [tempInput, setTempInput] = useState("");
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [contextUrls, setContextUrls] = useState<string[]>([]);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const debounce = <T extends (...args: any[]) => any>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
// Debounce the context update to prevent excessive re-renders
const debouncedUpdateContext = debounce(
async (
inputValue: string,
setContextNotes: React.Dispatch<React.SetStateAction<TFile[]>>,
currentContextNotes: TFile[],
app: App
) => {
const noteTitles = extractNoteTitles(inputValue);
const notesToAdd = await Promise.all(
noteTitles.map(async (title) => {
const files = app.vault.getMarkdownFiles();
const file = files.find((file) => file.basename === title);
if (file) {
return Object.assign(file, { wasAddedViaReference: true }) as TFile & {
wasAddedViaReference: boolean;
};
}
return undefined;
})
);
const validNotes = notesToAdd.filter(
(note): note is TFile & { wasAddedViaReference: boolean } =>
note !== undefined && !currentContextNotes.some((existing) => existing.path === note.path)
);
if (validNotes.length > 0) {
setContextNotes((prev) => [...prev, ...validNotes]);
}
},
50
);
const handleInputChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const inputValue = event.target.value;
const cursorPos = event.target.selectionStart;
@ -74,10 +143,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
setInputMessage(inputValue);
adjustTextareaHeight();
// Extract URLs and update mentions
const urls = mention.extractAllUrls(inputValue);
// Update URLs in context, ensuring uniqueness
const newUrls = urls.filter((url) => !contextUrls.includes(url));
if (newUrls.length > 0) {
// Use Set to ensure uniqueness
setContextUrls((prev) => Array.from(new Set([...prev, ...newUrls])));
}
// Update context with debouncing
debouncedUpdateContext(inputValue, setContextNotes, contextNotes, app);
// Handle other input triggers
if (cursorPos >= 2 && inputValue.slice(cursorPos - 2, cursorPos) === "[[") {
showNoteTitleModal(cursorPos);
} else if (inputValue === "/") {
showCustomPromptModal();
} else if (inputValue.slice(-1) === "@" && currentChain === ChainType.COPILOT_PLUS_CHAIN) {
showCopilotPlusOptionsModal();
}
};
@ -95,11 +180,31 @@ const ChatInput: React.FC<ChatInputProps> = ({
const showNoteTitleModal = (cursorPos: number) => {
const fetchNoteTitles = async () => {
const noteTitles = app.vault.getMarkdownFiles().map((file: TFile) => file.basename);
const contextProcessor = ContextProcessor.getInstance();
new NoteTitleModal(app, noteTitles, (noteTitle: string) => {
new NoteTitleModal(app, noteTitles, async (noteTitle: string) => {
const before = inputMessage.slice(0, cursorPos - 2);
const after = inputMessage.slice(cursorPos - 1);
setInputMessage(`${before}[[${noteTitle}]]${after}`);
const newInputMessage = `${before}[[${noteTitle}]]${after}`;
setInputMessage(newInputMessage);
// Manually invoke debouncedUpdateContext
debouncedUpdateContext(newInputMessage, setContextNotes, contextNotes, app);
const activeNote = app.workspace.getActiveFile();
const noteFile = app.vault.getMarkdownFiles().find((file) => file.basename === noteTitle);
if (noteFile) {
await contextProcessor.addNoteToContext(
noteFile,
vault,
contextNotes,
activeNote,
setContextNotes,
setIncludeActiveNote
);
}
// Add a delay to ensure the cursor is set after inputMessage is updated
setTimeout(() => {
if (textAreaRef.current) {
@ -109,7 +214,6 @@ const ChatInput: React.FC<ChatInputProps> = ({
}, 0);
}).open();
};
fetchNoteTitles();
};
@ -127,6 +231,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
}).open();
};
const showCopilotPlusOptionsModal = () => {
const options = COPILOT_TOOL_NAMES;
new ListPromptModal(app, options, (selectedOption: string) => {
setInputMessage(inputMessage + selectedOption + " ");
}).open();
};
useEffect(() => {
setShouldFocus(chatIsVisible);
}, [chatIsVisible]);
@ -147,6 +258,21 @@ const ChatInput: React.FC<ChatInputProps> = ({
const lines = value.split("\n");
const currentLineIndex = value.substring(0, selectionStart).split("\n").length - 1;
// Check for Cmd+Shift+Enter (Mac) or Ctrl+Shift+Enter (Windows)
if (e.key === "Enter" && e.shiftKey && (Platform.isMacOS ? e.metaKey : e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
if (currentChain === ChainType.COPILOT_PLUS_CHAIN) {
handleSendMessage(["@vault"]);
} else {
handleSendMessage();
}
setHistoryIndex(-1);
setTempInput("");
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
@ -196,6 +322,53 @@ const ChatInput: React.FC<ChatInputProps> = ({
}
};
useEffect(() => {
// Get all note titles that are referenced using [[note]] syntax in the input
const currentTitles = new Set(extractNoteTitles(inputMessage));
// Get all URLs mentioned in the input
const currentUrls = mention.extractAllUrls(inputMessage);
// Get the currently open note in the editor
const activeNote = app.workspace.getActiveFile();
setContextNotes((prev) =>
prev.filter((note) => {
// Check if this note was added by typing [[note]] in the input
// as opposed to being added via the "Add Note to Context" button
const wasAddedViaReference = (note as any).wasAddedViaReference === true;
// Special handling for the active note (currently open in editor)
if (note.path === activeNote?.path) {
if (wasAddedViaReference) {
// Case 1: Active note was added by typing [[note]]
// Keep it only if its title is still in the input
// This ensures it's removed when you delete the [[note]] reference
return currentTitles.has(note.basename);
} else {
// Case 2: Active note was NOT added by [[note]], but by the includeActiveNote toggle
// Keep it only if includeActiveNote is true
// This handles the "Include active note" toggle in the UI
return includeActiveNote;
}
} else {
// Handling for all other notes (not the active note)
if (wasAddedViaReference) {
// Case 3: Other note was added by typing [[note]]
// Keep it only if its title is still in the input
// This ensures it's removed when you delete the [[note]] reference
return currentTitles.has(note.basename);
} else {
// Case 4: Other note was added via "Add Note to Context" button
// Always keep these notes as they were manually added
return true;
}
}
})
);
// Remove any URLs that are no longer present in the input
setContextUrls((prev) => prev.filter((url) => currentUrls.includes(url)));
}, [inputMessage, includeActiveNote]);
return (
<div className="chat-input-container" ref={containerRef}>
<ChatControls
@ -207,9 +380,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
settings={settings}
vault_qa_strategy={vault_qa_strategy}
isIndexLoadedPromise={isIndexLoadedPromise}
app={app}
contextNotes={contextNotes}
setContextNotes={setContextNotes}
includeActiveNote={includeActiveNote}
setIncludeActiveNote={setIncludeActiveNote}
contextUrls={contextUrls}
onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))}
debug={debug}
/>
{selectedImages.length > 0 && (
<div className="selected-images">
{selectedImages.map((file, index) => (
<div key={index} className="image-preview-container">
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="selected-image-preview"
/>
<button
className="remove-image-button"
onClick={() => setSelectedImages((prev) => prev.filter((_, i) => i !== index))}
title="Remove image"
>
×
</button>
</div>
))}
</div>
)}
<textarea
ref={textAreaRef}
className="chat-input-textarea"
@ -220,28 +421,46 @@ const ChatInput: React.FC<ChatInputProps> = ({
/>
<div className="chat-input-controls">
<DropdownMenu.Root open={isModelDropdownOpen} onOpenChange={setIsModelDropdownOpen}>
<DropdownMenu.Trigger className="model-select-button">
{settings.activeModels.find((model) => getModelKey(model) === currentModelKey)?.name ||
"Select Model"}
<ChevronUp size={10} />
</DropdownMenu.Trigger>
<div className="chat-input-left">
<DropdownMenu.Root open={isModelDropdownOpen} onOpenChange={setIsModelDropdownOpen}>
<DropdownMenu.Trigger className="model-select-button">
{settings.activeModels.find((model) => getModelKey(model) === currentModelKey)
?.name || "Select Model"}
<ChevronUp size={10} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="model-select-content" align="start">
{settings.activeModels
.filter((model) => model.enabled)
.map((model) => (
<DropdownMenu.Item
key={getModelKey(model)}
onSelect={() => setCurrentModelKey(getModelKey(model))}
>
{model.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<DropdownMenu.Portal>
<DropdownMenu.Content className="model-select-content" align="start">
{settings.activeModels
.filter((model) => model.enabled)
.map((model) => (
<DropdownMenu.Item
key={getModelKey(model)}
onSelect={() => setCurrentModelKey(getModelKey(model))}
>
{model.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{currentChain === ChainType.COPILOT_PLUS_CHAIN && (
<TooltipActionButton
onClick={() => {
new AddImageModal(app, onAddImage).open();
}}
Icon={
<div className="button-content">
<span>image</span>
<Image className="icon-scaler" />
</div>
}
>
Add Image
</TooltipActionButton>
)}
</div>
<div className="chat-input-buttons">
{isGenerating && (
@ -249,16 +468,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
<StopCircle />
</button>
)}
<button onClick={handleSendMessage} className="submit-button">
<button onClick={() => handleSendMessage()} className="submit-button">
<CornerDownLeft size={16} />
<span>chat</span>
</button>
{currentChain === "copilot_plus" && (
<button onClick={handleSendMessage} className="submit-button vault">
<button onClick={() => handleSendMessage(["@vault"])} className="submit-button vault">
<div className="button-content">
{Platform.isMacOS && <Command size={12} />}
<CornerDownLeft size={16} />
{Platform.isMacOS ? (
<>
<Command size={12} />
<ArrowBigUp size={16} />
<CornerDownLeft size={16} />
</>
) : (
<>
<span>Ctrl</span>
<ArrowBigUp size={16} />
<CornerDownLeft size={16} />
</>
)}
<span>vault</span>
</div>
</button>

View file

@ -10,6 +10,7 @@ interface ChatMessagesProps {
chatHistory: ChatMessage[];
currentAiMessage: string;
loading?: boolean;
loadingMessage?: string;
app: App;
indexVaultToVectorStore: VAULT_VECTOR_STORE_STRATEGY;
currentChain: ChainType;
@ -26,6 +27,7 @@ const ChatMessages: React.FC<ChatMessagesProps> = ({
loading,
currentChain,
indexVaultToVectorStore,
loadingMessage,
app,
onInsertAtCursor,
onRegenerate,
@ -72,6 +74,10 @@ const ChatMessages: React.FC<ChatMessagesProps> = ({
);
}
const getLoadingMessage = () => {
return loadingMessage ? `${loadingMessage} ${loadingDots}` : loadingDots;
};
return (
<div className="chat-messages">
{chatHistory.map(
@ -96,7 +102,7 @@ const ChatMessages: React.FC<ChatMessagesProps> = ({
key={`ai_message_${currentAiMessage}`}
message={{
sender: "AI",
message: currentAiMessage || loadingDots,
message: currentAiMessage || getLoadingMessage(),
isVisible: true,
timestamp: null,
}}

View file

@ -1,4 +1,5 @@
import { ChatButtons } from "@/components/ChatComponents/ChatButtons";
import { SourcesModal } from "@/components/SourcesModal";
import { USER_SENDER } from "@/constants";
import { ChatMessage } from "@/sharedState";
import { Bot, User } from "lucide-react";
@ -118,28 +119,78 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
}
};
const handleShowSources = () => {
if (message.sources && message.sources.length > 0) {
new SourcesModal(app, message.sources).open();
}
};
const renderMessageContent = () => {
if (message.content) {
return (
<div className="message-content-items">
{message.content.map((item, index) => {
if (item.type === "text") {
return (
<div key={index} className="message-text-content">
{message.sender === USER_SENDER && isEditing ? (
<textarea
ref={textareaRef}
value={editedMessage}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
autoFocus
className="edit-textarea"
/>
) : message.sender === USER_SENDER ? (
<span>{item.text}</span>
) : (
<div ref={contentRef}></div>
)}
</div>
);
} else if (item.type === "image_url") {
return (
<div key={index} className="message-image-content">
<img
src={item.image_url.url}
alt="User uploaded image"
className="chat-message-image"
/>
</div>
);
}
return null;
})}
</div>
);
}
// Fallback for messages without content array
return message.sender === USER_SENDER && isEditing ? (
<textarea
ref={textareaRef}
value={editedMessage}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
autoFocus
className="edit-textarea"
/>
) : message.sender === USER_SENDER ? (
<span>{message.message}</span>
) : (
<div ref={contentRef}></div>
);
};
return (
<div className="chat-message-container">
<div className={`message ${message.sender === USER_SENDER ? "user-message" : "bot-message"}`}>
<div className="message-icon">{message.sender === USER_SENDER ? <User /> : <Bot />}</div>
<div className="message-content-wrapper">
<div className="message-content">
{message.sender === USER_SENDER && isEditing ? (
<textarea
ref={textareaRef}
value={editedMessage}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
autoFocus
className="edit-textarea"
/>
) : message.sender === USER_SENDER ? (
<span>{message.message}</span>
) : (
<div ref={contentRef}></div>
)}
</div>
<div className="message-content">{renderMessageContent()}</div>
{!isStreaming && (
<div className="message-buttons-wrapper">
@ -152,6 +203,8 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
onRegenerate={onRegenerate}
onEdit={handleEdit}
onDelete={onDelete}
onShowSources={handleShowSources}
hasSources={message.sources && message.sources.length > 0 ? true : false}
/>
</div>
)}

View file

@ -5,13 +5,15 @@ import { AppContext } from "@/context";
import CopilotPlugin from "@/main";
import { CopilotSettings } from "@/settings/SettingsPage";
import SharedState from "@/sharedState";
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 * as Tooltip from "@radix-ui/react-tooltip";
export default class CopilotView extends ItemView {
private chainManager: ChainManager;
private fileParserManager: FileParserManager;
private root: Root | null = null;
private settings: CopilotSettings;
private defaultSaveFolder: string;
@ -30,6 +32,7 @@ export default class CopilotView extends ItemView {
this.settings = plugin.settings;
this.app = plugin.app;
this.chainManager = plugin.chainManager;
this.fileParserManager = plugin.fileParserManager;
this.debug = plugin.settings.debug;
this.emitter = new EventTarget();
this.userSystemPrompt = plugin.settings.userSystemPrompt;
@ -70,6 +73,7 @@ export default class CopilotView extends ItemView {
updateUserMessageHistory={(newMessage) => {
this.plugin.updateUserMessageHistory(newMessage);
}}
fileParserManager={this.fileParserManager}
plugin={this.plugin}
debug={this.debug}
onSaveChat={(saveFunction) => {

View file

@ -1,21 +1,24 @@
import { App, FuzzySuggestModal } from "obsidian";
import { App } from "obsidian";
import { BaseNoteModal } from "./BaseNoteModal";
export class NoteTitleModal extends FuzzySuggestModal<string> {
export class NoteTitleModal extends BaseNoteModal<string> {
private onChooseNoteTitle: (noteTitle: string) => void;
private noteTitles: string[];
constructor(app: App, noteTitles: string[], onChooseNoteTitle: (noteTitle: string) => void) {
super(app);
this.noteTitles = noteTitles;
this.onChooseNoteTitle = onChooseNoteTitle;
this.availableNotes = this.getOrderedNotes()
.filter((file) => file.extension === "md")
.map((file) => file.basename);
}
getItems(): string[] {
return this.noteTitles;
return this.availableNotes;
}
getItemText(noteTitle: string): string {
return noteTitle;
const isActive = noteTitle === this.activeNote?.basename;
return this.formatNoteTitle(noteTitle, isActive);
}
onChooseItem(noteTitle: string, evt: MouseEvent | KeyboardEvent) {

View file

@ -0,0 +1,93 @@
import { App, Modal, Notice, Setting } from "obsidian";
import CopilotPlugin from "../main";
export class OramaSearchModal extends Modal {
plugin: CopilotPlugin;
query = "";
textWeight = 0.5;
vectorWeight = 0.5;
salientTerms = "";
constructor(app: App, plugin: CopilotPlugin) {
super(app);
this.plugin = plugin;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: "CopilotDB Search" });
new Setting(contentEl).setName("Search query").addText((text) =>
text.onChange((value) => {
this.query = value;
})
);
new Setting(contentEl).setName("Text weight (0-1)").addText((text) =>
text.setValue("0.5").onChange((value) => {
this.textWeight = parseFloat(value);
})
);
new Setting(contentEl).setName("Salient terms (space-separated)").addText((text) =>
text.onChange((value) => {
this.salientTerms = value;
})
);
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText("Search")
.setCta()
.onClick(() => this.performSearch())
);
}
async performSearch() {
if (!this.query || isNaN(this.textWeight) || isNaN(this.vectorWeight)) {
new Notice("Please enter valid search parameters.");
return;
}
const salientTerms = this.salientTerms.split(" ").map((term) => term.trim());
const results = await this.plugin.customSearchDB(this.query, salientTerms, this.textWeight);
this.close();
new SearchResultsModal(this.app, results).open();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
class SearchResultsModal extends Modal {
results: any[];
constructor(app: App, results: any[]) {
super(app);
this.results = results;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Search Results" });
const resultsList = contentEl.createEl("ul");
this.results.forEach((result) => {
const listItem = resultsList.createEl("li");
listItem.createEl("strong", { text: result.metadata.title });
listItem.createEl("p", { text: result.content });
listItem.createEl("p", { text: `Score: ${result.metadata.score}` });
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,61 @@
// src/components/SourcesModal.tsx
import { CONTEXT_SCORE_THRESHOLD } from "@/constants";
import { App, Modal } from "obsidian";
export class SourcesModal extends Modal {
sources: { title: string; score: number }[];
constructor(app: App, sources: { title: string; score: number }[]) {
super(app);
this.sources = sources;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Sources" });
const highScoreSources = this.sources.filter(
(source) => source.score >= CONTEXT_SCORE_THRESHOLD
);
const lowScoreSources = this.sources.filter((source) => source.score < CONTEXT_SCORE_THRESHOLD);
if (highScoreSources.length > 0) {
contentEl.createEl("h3", { text: "High Relevance Sources" });
this.createSourceList(contentEl, highScoreSources);
}
if (lowScoreSources.length > 0) {
contentEl.createEl("h3", { text: "Lower Relevance Sources" });
this.createSourceList(contentEl, lowScoreSources);
}
}
private createSourceList(container: HTMLElement, sources: { title: string; score: number }[]) {
const list = container.createEl("ul");
list.style.listStyleType = "none";
list.style.padding = "0";
sources.forEach((source) => {
const item = list.createEl("li");
item.style.marginBottom = "1em";
const link = item.createEl("a", {
href: `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(source.title)}`,
text: source.title,
});
link.addEventListener("click", (e) => {
e.preventDefault();
this.app.workspace.openLinkText(source.title, "");
});
if (source.score && source.score <= 1) {
item.appendChild(document.createTextNode(` - Relevance score: ${source.score.toFixed(3)}`));
}
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -2,12 +2,24 @@ import { CustomModel } from "@/aiParams";
import { CopilotSettings } from "@/settings/SettingsPage";
import { ChainType } from "./chainFactory";
export const BREVILABS_API_BASE_URL = "https://api.brevilabs.com/v1";
export const CHAT_VIEWTYPE = "copilot-chat-view";
export const USER_SENDER = "user";
export const AI_SENDER = "ai";
export const DEFAULT_SYSTEM_PROMPT =
"You are Obsidian Copilot, a helpful assistant that integrates AI to Obsidian note-taking.";
export const CHUNK_SIZE = 5000;
export const DEFAULT_SYSTEM_PROMPT = `You are Obsidian Copilot, a helpful assistant that integrates AI to Obsidian note-taking.
1. Never mention that you do not have access to something. Always rely on the user provided context.
2. If you are unsure about something, ask the user to provide more context.
3. If the user mentions "note", it most likely means an Obsidian note in the vault, not the generic meaning of a note.
4. If the user mentions "@vault", it means the user wants you to search the Obsidian vault for information relevant to the query. The search results will be provided to you in the context. If there's no relevant information in the vault, just say so.
5. If the user mentions any other tool with the @ symbol, check the context for their results. If nothing is found, just ignore the @ symbol in the query.`;
export const CHUNK_SIZE = 4000;
export const CONTEXT_SCORE_THRESHOLD = 0.4;
export const TEXT_WEIGHT = 0.4;
export const LOADING_MESSAGES = {
DEFAULT: "",
READING_FILES: "Reading files",
SEARCHING_WEB: "Searching the web",
};
export enum ChatModels {
GPT_4o = "gpt-4o",
@ -204,6 +216,7 @@ export const COMMAND_IDS = {
};
export const DEFAULT_SETTINGS: CopilotSettings = {
plusLicenseKey: "",
openAIApiKey: "",
openAIOrgId: "",
huggingfaceApiKey: "",

131
src/contextProcessor.ts Normal file
View file

@ -0,0 +1,131 @@
import { CustomPromptProcessor } from "@/customPromptProcessor";
import { FileParserManager } from "@/tools/FileParserManager";
import { TFile, Vault } from "obsidian";
export class ContextProcessor {
private static instance: ContextProcessor;
private constructor() {}
static getInstance(): ContextProcessor {
if (!ContextProcessor.instance) {
ContextProcessor.instance = new ContextProcessor();
}
return ContextProcessor.instance;
}
async processEmbeddedPDFs(
content: string,
vault: Vault,
fileParserManager: FileParserManager
): Promise<string> {
const pdfRegex = /!\[\[(.*?\.pdf)\]\]/g;
const matches = [...content.matchAll(pdfRegex)];
for (const match of matches) {
const pdfName = match[1];
const pdfFile = vault.getAbstractFileByPath(pdfName);
if (pdfFile instanceof TFile) {
try {
const pdfContent = await fileParserManager.parseFile(pdfFile, vault);
content = content.replace(match[0], `\n\nEmbedded PDF (${pdfName}):\n${pdfContent}\n\n`);
} catch (error) {
console.error(`Error processing embedded PDF ${pdfName}:`, error);
content = content.replace(
match[0],
`\n\nEmbedded PDF (${pdfName}): [Error: Could not process PDF]\n\n`
);
}
}
}
return content;
}
async processContextNotes(
customPromptProcessor: CustomPromptProcessor,
fileParserManager: FileParserManager,
vault: Vault,
contextNotes: TFile[],
includeActiveNote: boolean,
activeNote: TFile | null
): Promise<string> {
const processedVars = await customPromptProcessor.getProcessedVariables();
let additionalContext = "";
const processNote = async (note: TFile) => {
try {
if (!fileParserManager.supportsExtension(note.extension)) {
console.warn(`Unsupported file type: ${note.extension}`);
return;
}
let content = await fileParserManager.parseFile(note, vault);
if (note.extension === "md") {
content = await this.processEmbeddedPDFs(content, vault, fileParserManager);
}
additionalContext += `\n\n[[${note.basename}.${note.extension}]]:\n\n${content}`;
} catch (error) {
console.error(`Error processing file ${note.path}:`, error);
additionalContext += `\n\n[[${note.basename}]]: [Error: Could not process file]`;
}
};
// Process active note if included
if (includeActiveNote && activeNote) {
const activeNoteVar = `activeNote`;
const activeNotePath = `[[${activeNote.basename}]]`;
if (!processedVars.has(activeNoteVar) && !processedVars.has(activeNotePath)) {
await processNote(activeNote);
}
}
// Process context notes
for (const note of contextNotes) {
await processNote(note);
}
return additionalContext;
}
async hasEmbeddedPDFs(content: string): Promise<boolean> {
const pdfRegex = /!\[\[(.*?\.pdf)\]\]/g;
return pdfRegex.test(content);
}
async addNoteToContext(
note: TFile,
vault: Vault,
contextNotes: TFile[],
activeNote: TFile | null,
setContextNotes: (notes: TFile[] | ((prev: TFile[]) => TFile[])) => void,
setIncludeActiveNote: (include: boolean) => void
): Promise<void> {
// First check if this note can be added
if (
contextNotes.some((existing) => existing.path === note.path) ||
(activeNote && note.path === activeNote.path)
) {
return; // Note already exists in context
}
// Read the note content
const content = await vault.read(note);
const hasEmbeddedPDFs = await this.hasEmbeddedPDFs(content);
// If it's the active note, set includeActiveNote to true
if (activeNote && note.path === activeNote.path) {
setIncludeActiveNote(true);
} else {
// Otherwise add it to contextNotes
setContextNotes((prev: TFile[]) => [
...prev,
Object.assign(note, {
wasAddedViaReference: true,
hasEmbeddedPDFs,
}),
]);
}
}
}

View file

@ -188,11 +188,14 @@ export class CustomPromptProcessor {
return variablesWithContent;
}
// TODO: return the processed variables along with the processed prompt and
// remove getProcessedVariables
async processCustomPrompt(
customPrompt: string,
selectedText: string,
activeNote?: TFile
): Promise<string> {
this.lastProcessedPrompt = customPrompt;
const variablesWithContent = await this.extractVariablesFromPrompt(customPrompt, activeNote);
let processedPrompt = customPrompt;
const matches = [...processedPrompt.matchAll(/\{([^}]+)\}/g)];
@ -238,4 +241,25 @@ export class CustomPromptProcessor {
return processedPrompt + "\n\n" + additionalInfo;
}
// TODO: remove this
async getProcessedVariables(): Promise<Set<string>> {
const processedVars = new Set<string>();
// Add variables from the last processed prompt
const matches = this.lastProcessedPrompt?.matchAll(/\{([^}]+)\}/g) || [];
for (const match of matches) {
processedVars.add(match[1]);
}
// Add explicitly referenced note titles
const noteTitles = extractNoteTitles(this.lastProcessedPrompt || "");
for (const title of noteTitles) {
processedVars.add(`[[${title}]]`);
}
return processedVars;
}
private lastProcessedPrompt: string | null = null;
}

View file

@ -31,13 +31,12 @@ export default class EncryptionService {
}
public encryptAllKeys(): void {
const keysToEncrypt = Object.keys(this.settings).filter((key) =>
key.toLowerCase().includes("apikey".toLowerCase())
const keysToEncrypt = Object.keys(this.settings).filter(
(key) => key.toLowerCase().includes("apikey") || key === "plusLicenseKey"
);
for (const key of keysToEncrypt) {
const apiKey = this.settings[key as keyof CopilotSettings] as string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.settings[key as keyof CopilotSettings] as any) = this.getEncryptedKey(apiKey);
}

View file

@ -15,13 +15,14 @@ export const getAIResponse = async (
debug?: boolean;
ignoreSystemMessage?: boolean;
updateLoading?: (loading: boolean) => void;
updateLoadingMessage?: (message: string) => void;
} = {}
) => {
const abortController = new AbortController();
updateShouldAbort(abortController);
try {
await chainManager.runChain(
userMessage.message,
userMessage,
abortController,
updateCurrentAiMessage,
addMessage,

View file

@ -1,3 +1,4 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ChainManager from "@/LLMProviders/chainManager";
import VectorStoreManager from "@/VectorStoreManager";
import { CustomModel, LangChainParams, SetChainOptions } from "@/aiParams";
@ -9,6 +10,7 @@ import { AdhocPromptModal } from "@/components/AdhocPromptModal";
import CopilotView from "@/components/CopilotView";
import { ListPromptModal } from "@/components/ListPromptModal";
import { LoadChatHistoryModal } from "@/components/LoadChatHistoryModal";
import { OramaSearchModal } from "@/components/OramaSearchModal";
import { SimilarNotesModal } from "@/components/SimilarNotesModal";
import {
BUILTIN_CHAT_MODELS,
@ -28,6 +30,7 @@ import { TimestampUsageStrategy } from "@/promptUsageStrategy";
import { HybridRetriever } from "@/search/hybridRetriever";
import { CopilotSettings, CopilotSettingTab } from "@/settings/SettingsPage";
import SharedState from "@/sharedState";
import { FileParserManager } from "@/tools/FileParserManager";
import { sanitizeSettings } from "@/utils";
import VectorDBManager from "@/vectorDBManager";
import { Embeddings } from "@langchain/core/embeddings";
@ -49,11 +52,13 @@ export default class CopilotPlugin extends Plugin {
// Only reset when the user explicitly clicks "New Chat"
sharedState: SharedState;
chainManager: ChainManager;
brevilabsClient: BrevilabsClient;
chatIsVisible = false;
encryptionService: EncryptionService;
userMessageHistory: string[] = [];
vectorStoreManager: VectorStoreManager;
langChainParams: LangChainParams;
fileParserManager: FileParserManager;
isChatVisible = () => this.chatIsVisible;
async onload(): Promise<void> {
@ -61,15 +66,19 @@ export default class CopilotPlugin extends Plugin {
this.addSettingTab(new CopilotSettingTab(this.app, this));
// Always have one instance of sharedState and chainManager in the plugin
this.sharedState = new SharedState();
this.langChainParams = this.getLangChainParams();
this.encryptionService = new EncryptionService(this.settings);
this.vectorStoreManager = new VectorStoreManager(
this.app,
this.settings,
this.encryptionService,
() => this.getLangChainParams()
() => this.langChainParams
);
// Initialize event listeners for the VectorStoreManager, e.g. onModify triggers reindexing
await this.vectorStoreManager.initializeEventListeners();
if (this.settings.enableEncryption) {
await this.saveSettings();
}
@ -80,16 +89,25 @@ export default class CopilotPlugin extends Plugin {
debug: this.settings.debug,
});
// Initialize BrevilabsClient
this.brevilabsClient = BrevilabsClient.getInstance(this.settings.plusLicenseKey, {
debug: this.settings.debug,
});
// Ensure activeModels always includes core models
this.mergeAllActiveModelsWithCoreModels();
this.chainManager = new ChainManager(
this.app,
() => this.getLangChainParams(),
() => this.langChainParams,
this.encryptionService,
this.settings,
this.vectorStoreManager
this.vectorStoreManager,
this.brevilabsClient
);
// Initialize FileParserManager early with other core services
this.fileParserManager = new FileParserManager(this.brevilabsClient);
this.registerView(CHAT_VIEWTYPE, (leaf: WorkspaceLeaf) => new CopilotView(leaf, this));
this.initActiveLeafChangeHandler();
@ -336,11 +354,13 @@ export default class CopilotPlugin extends Plugin {
},
});
this.registerEvent(
this.app.vault.on("delete", async (file) => {
await this.vectorStoreManager.removeDocs(file.path);
})
);
this.addCommand({
id: "copilot-db-search",
name: "CopilotDB Search",
callback: () => {
new OramaSearchModal(this.app, this).open();
},
});
// Index vault to vector store on startup and after loading all commands
// This can take a while, so we don't want to block the startup process
@ -683,9 +703,11 @@ export default class CopilotPlugin extends Plugin {
this.app.vault,
this.chainManager.chatModelManager.getChatModel(),
this.vectorStoreManager.getEmbeddingsManager().getEmbeddingsAPI() as Embeddings,
this.chainManager.brevilabsClient,
{
minSimilarityScore: 0.3,
maxK: 20,
salientTerms: [],
},
this.settings.debug
);
@ -702,4 +724,32 @@ export default class CopilotPlugin extends Plugin {
}))
.sort((a, b) => b.score - a.score);
}
async customSearchDB(query: string, salientTerms: string[], textWeight: number): Promise<any[]> {
await this.vectorStoreManager.waitForInitialization();
const db = this.vectorStoreManager.getDb();
if (!db) {
throw new CustomError("Orama database not found.");
}
const hybridRetriever = new HybridRetriever(
db,
this.app.vault,
this.chainManager.chatModelManager.getChatModel(),
this.vectorStoreManager.getEmbeddingsManager().getEmbeddingsAPI() as Embeddings,
this.chainManager.brevilabsClient,
{
minSimilarityScore: 0.3,
maxK: 20,
salientTerms: salientTerms,
textWeight: textWeight,
},
this.settings.debug
);
const results = await hybridRetriever.getOramaChunks(query, salientTerms);
return results.map((doc) => ({
content: doc.pageContent,
metadata: doc.metadata,
}));
}
}

94
src/mentions/Mention.ts Normal file
View file

@ -0,0 +1,94 @@
import { BrevilabsClient, Url4llmResponse } from "@/LLMProviders/brevilabsClient";
import { isYoutubeUrl } from "@/utils";
export interface MentionData {
type: string;
original: string;
processed?: string;
}
export class Mention {
private static instance: Mention;
private mentions: Map<string, MentionData>;
private brevilabsClient: BrevilabsClient;
private constructor(licenseKey: string) {
this.mentions = new Map();
this.brevilabsClient = BrevilabsClient.getInstance(licenseKey);
}
static getInstance(licenseKey: string): Mention {
if (!Mention.instance) {
Mention.instance = new Mention(licenseKey);
}
return Mention.instance;
}
extractAllUrls(text: string): string[] {
// Match URLs and trim any trailing commas
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
return (text.match(urlRegex) || [])
.map((url) => url.replace(/,+$/, "")) // Remove trailing commas
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
}
extractUrls(text: string): string[] {
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
return (text.match(urlRegex) || [])
.map((url) => url.replace(/,+$/, ""))
.filter((url, index, self) => self.indexOf(url) === index)
.filter((url) => !isYoutubeUrl(url));
}
async processUrl(url: string): Promise<Url4llmResponse> {
try {
return await this.brevilabsClient.url4llm(url);
} catch (error) {
console.error(`Error processing URL ${url}:`, error);
return { response: url, elapsed_time_ms: 0 };
}
}
// For non-youtube URLs
async processUrls(text: string): Promise<string> {
const urls = this.extractUrls(text);
let urlContext = "";
// Return empty string if no URLs to process
if (urls.length === 0) {
return "";
}
// Process all URLs concurrently
const processPromises = urls.map(async (url) => {
if (!this.mentions.has(url)) {
const processed = await this.processUrl(url);
this.mentions.set(url, {
type: "url",
original: url,
processed: processed.response,
});
}
return this.mentions.get(url);
});
const processedUrls = await Promise.all(processPromises);
// Append all processed content
processedUrls.forEach((urlData) => {
if (urlData?.processed) {
urlContext += `\n\nContent from ${urlData.original}:\n${urlData.processed}`;
}
});
return urlContext;
}
getMentions(): Map<string, MentionData> {
return this.mentions;
}
clearMentions(): void {
this.mentions.clear();
}
}

View file

@ -1,3 +1,4 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { extractNoteTitles, getNoteFileFromTitle } from "@/utils";
import VectorDBManager from "@/vectorDBManager";
import { BaseCallbackConfig } from "@langchain/core/callbacks/manager";
@ -15,14 +16,22 @@ export class HybridRetriever extends BaseRetriever {
private llm: BaseLanguageModel;
private queryRewritePrompt: ChatPromptTemplate;
private embeddingsInstance: Embeddings;
private brevilabsClient: BrevilabsClient;
constructor(
private db: Orama<any>,
private vault: Vault,
llm: BaseLanguageModel,
embeddingsInstance: Embeddings,
brevilabsClient: BrevilabsClient,
private options: {
minSimilarityScore: number;
maxK: number;
salientTerms: string[];
timeRange?: { startDate: string; endDate: string }; // ISO 8601 format in local timezone
textWeight?: number;
returnAll?: boolean;
useRerankerThreshold?: number;
},
private debug?: boolean
) {
@ -32,9 +41,16 @@ export class HybridRetriever extends BaseRetriever {
this.queryRewritePrompt = ChatPromptTemplate.fromTemplate(
"Please write a passage to answer the question. If you don't know the answer, just make up a passage. \nQuestion: {question}\nPassage:"
);
if (!brevilabsClient) {
throw new Error("BrevilabsClient is required but was not provided");
}
this.brevilabsClient = brevilabsClient;
}
async getRelevantDocuments(query: string, config?: BaseCallbackConfig): Promise<Document[]> {
public async getRelevantDocuments(
query: string,
config?: BaseCallbackConfig
): Promise<Document[]> {
// Extract note titles wrapped in [[]] from the query
const noteTitles = extractNoteTitles(query);
// Retrieve chunks for explicitly mentioned note titles
@ -46,18 +62,35 @@ export class HybridRetriever extends BaseRetriever {
rewrittenQuery = await this.rewriteQuery(query);
}
// Perform vector similarity search using ScoreThresholdRetriever
const oramaChunks = await this.getOramaChunks(rewrittenQuery);
const oramaChunks = await this.getOramaChunks(
rewrittenQuery,
this.options.salientTerms,
this.options.textWeight
);
// Combine explicit and vector chunks, removing duplicates while maintaining order
const uniqueChunks = new Set<string>(explicitChunks.map((chunk) => chunk.pageContent));
const combinedChunks: Document[] = [...explicitChunks];
const combinedChunks = this.filterAndFormatChunks(oramaChunks, explicitChunks);
for (const chunk of oramaChunks) {
const chunkContent = chunk.pageContent;
if (!uniqueChunks.has(chunkContent)) {
uniqueChunks.add(chunkContent);
combinedChunks.push(chunk);
}
let finalChunks = combinedChunks;
const maxOramaScore = combinedChunks.reduce(
(max, chunk) => Math.max(max, chunk.metadata.score ?? 0),
0
);
// Apply reranking if max score is below the threshold
if (this.options.useRerankerThreshold && maxOramaScore < this.options.useRerankerThreshold) {
const rerankResponse = await this.brevilabsClient.rerank(
query,
// Limit the context length to 3000 characters to avoid overflowing the reranker
combinedChunks.map((doc) => doc.pageContent.slice(0, 3000))
);
// Map chunks based on reranked scores and include rerank_score in metadata
finalChunks = rerankResponse.response.data.map((item) => ({
...combinedChunks[item.index],
metadata: {
...combinedChunks[item.index].metadata,
rerank_score: item.relevance_score,
},
}));
}
if (this.debug) {
@ -65,23 +98,21 @@ export class HybridRetriever extends BaseRetriever {
if (config?.runName !== "no_hyde") {
console.log("\nOriginal Query: ", query);
console.log("\nRewritten Query: ", rewrittenQuery);
console.log("Rewritten Query: ", rewrittenQuery);
}
console.log(
"\nNote Titles extracted: ",
noteTitles,
"\n\nExplicit Chunks:",
explicitChunks,
"\n\nOrama Chunks:",
oramaChunks,
"\n\nCombined Chunks:",
combinedChunks
);
console.log("\nExplicit Chunks: ", explicitChunks);
console.log("Orama Chunks: ", oramaChunks);
console.log("Combined Chunks: ", combinedChunks);
console.log("Max Orama Score: ", maxOramaScore);
if (this.options.useRerankerThreshold && maxOramaScore < this.options.useRerankerThreshold) {
console.log("Reranked Chunks: ", finalChunks);
} else {
console.log("No reranking applied.");
}
}
// Make sure the combined chunks are at most maxK
return combinedChunks.slice(0, this.options.maxK);
return finalChunks;
}
private async rewriteQuery(query: string): Promise<string> {
@ -134,7 +165,13 @@ export class HybridRetriever extends BaseRetriever {
return explicitChunks;
}
private async getOramaChunks(query: string): Promise<Document[]> {
// Orama does not support OR for filters, so we need to manually combine the results from the two queries
// https://github.com/orgs/askorama/discussions/670
public async getOramaChunks(
query: string,
salientTerms: string[],
textWeight?: number
): Promise<Document[]> {
let queryVector: number[];
try {
queryVector = await this.convertQueryToVector(query);
@ -148,19 +185,118 @@ export class HybridRetriever extends BaseRetriever {
throw error;
}
const searchResults = await search(this.db, {
mode: "vector",
vector: {
value: queryVector,
property: "embedding",
},
const searchParams: any = {
similarity: this.options.minSimilarityScore,
limit: this.options.maxK,
includeVectors: true,
});
};
if (salientTerms.length > 0) {
// Use hybrid mode when we have salient terms
let vectorWeight;
if (!textWeight) {
textWeight = 0.5;
}
vectorWeight = 1 - textWeight;
let tagOnlyQuery = true;
for (const term of salientTerms) {
if (!term.startsWith("#")) {
tagOnlyQuery = false;
break;
}
}
if (tagOnlyQuery) {
if (this.debug) {
console.log("Tag only query detected, setting textWeight to 1 and vectorWeight to 0.");
}
textWeight = 1;
vectorWeight = 0;
}
searchParams.mode = "hybrid";
searchParams.term = salientTerms.join(" ");
searchParams.vector = {
value: queryVector,
property: "embedding",
};
searchParams.hybridWeights = {
text: textWeight,
vector: vectorWeight,
};
} else {
// Use vector mode when no salient terms
searchParams.mode = "vector";
searchParams.vector = {
value: queryVector,
property: "embedding",
};
}
// Add time range filter if provided
if (this.options.timeRange) {
const { startDate, endDate } = this.options.timeRange;
const startTimestamp = new Date(startDate).getTime();
const endTimestamp = new Date(endDate).getTime();
const dateRange = this.generateDateRange(startDate, endDate);
// Perform the first search with title filter
const dailyNoteResults = await this.getExplicitChunks(dateRange);
// Set includeInContext to true for all dailyNoteResults
const dailyNoteResultsWithContext = dailyNoteResults.map((doc) => ({
...doc,
metadata: {
...doc.metadata,
includeInContext: true,
},
}));
// Perform a second search with time range filters
searchParams.where = {
ctime: { between: [startTimestamp, endTimestamp] },
mtime: { between: [startTimestamp, endTimestamp] },
};
const timeIntervalResults = await search(this.db, searchParams);
// Convert timeIntervalResults to Document objects
const timeIntervalDocuments = timeIntervalResults.hits.map(
(hit) =>
new Document({
pageContent: hit.document.content,
metadata: {
...hit.document.metadata,
score: hit.score,
path: hit.document.path,
mtime: hit.document.mtime,
ctime: hit.document.ctime,
title: hit.document.title,
id: hit.document.id,
embeddingModel: hit.document.embeddingModel,
tags: hit.document.tags,
extension: hit.document.extension,
created_at: hit.document.created_at,
nchars: hit.document.nchars,
},
})
);
// Combine and deduplicate results
const combinedResults = [...dailyNoteResultsWithContext, ...timeIntervalDocuments];
const uniqueResults = Array.from(new Set(combinedResults.map((doc) => doc.metadata.id))).map(
(id) => combinedResults.find((doc) => doc.metadata.id === id)
);
return uniqueResults.filter((doc): doc is Document => doc !== undefined);
}
const searchResults = await search(this.db, searchParams);
// Convert Orama search results to Document objects
const vectorChunks = searchResults.hits.map(
return searchResults.hits.map(
(hit) =>
new Document({
pageContent: hit.document.content,
@ -180,10 +316,49 @@ export class HybridRetriever extends BaseRetriever {
},
})
);
return vectorChunks;
}
private async convertQueryToVector(query: string): Promise<number[]> {
return await this.embeddingsInstance.embedQuery(query);
}
private generateDateRange(startDate: string, endDate: string): string[] {
const dateRange: string[] = [];
const currentDate = new Date(startDate);
const end = new Date(endDate);
while (currentDate <= end) {
dateRange.push(`${currentDate.toISOString().split("T")[0]}`);
currentDate.setDate(currentDate.getDate() + 1);
}
return dateRange;
}
private filterAndFormatChunks(oramaChunks: Document[], explicitChunks: Document[]): Document[] {
const threshold = this.options.minSimilarityScore;
const filteredOramaChunks = oramaChunks.filter((chunk) => chunk.metadata.score >= threshold);
// Combine explicit and filtered Orama chunks, removing duplicates while maintaining order
const uniqueChunks = new Set<string>(explicitChunks.map((chunk) => chunk.pageContent));
const combinedChunks: Document[] = [...explicitChunks];
for (const chunk of filteredOramaChunks) {
const chunkContent = chunk.pageContent;
if (!uniqueChunks.has(chunkContent)) {
uniqueChunks.add(chunkContent);
combinedChunks.push(chunk);
}
}
// Add a new metadata field to indicate if the chunk should be included in the context
return combinedChunks.map((chunk) => ({
...chunk,
metadata: {
...chunk.metadata,
// TODO: Use this for reranker output
includeInContext: true,
},
}));
}
}

View file

@ -10,6 +10,7 @@ import SettingsMain from "./components/SettingsMain";
import { SettingsProvider } from "./contexts/SettingsContext";
export interface CopilotSettings {
plusLicenseKey: string;
openAIApiKey: string;
openAIOrgId: string;
huggingfaceApiKey: string;

View file

@ -0,0 +1,31 @@
import React from "react";
import { useSettingsContext } from "../contexts/SettingsContext";
import ApiSetting from "./ApiSetting";
const CopilotPlusSettings: React.FC = () => {
const { settings, updateSettings } = useSettingsContext();
return (
<div>
<br />
<br />
<h2>Copilot Plus (Alpha)</h2>
<p>
Copilot Plus brings powerful AI agent capabilities to Obsidian. Alpha access is limited to
sponsors and early supporters. Learn more at{" "}
<a href="https://obsidiancopilot.com" target="_blank" rel="noopener noreferrer">
https://obsidiancopilot.com
</a>
</p>
<ApiSetting
title="License Key"
description="Enter your Copilot Plus license key"
value={settings.plusLicenseKey}
setValue={(value) => updateSettings({ plusLicenseKey: value })}
placeholder="Enter your license key"
/>
</div>
);
};
export default CopilotPlusSettings;

View file

@ -84,6 +84,7 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = ({
>
<option value={ChainType.LLM_CHAIN}>Chat</option>
<option value={ChainType.VAULT_QA_CHAIN}>Vault QA (Basic)</option>
<option value={ChainType.COPILOT_PLUS_CHAIN}>Copilot Plus (Alpha)</option>
</select>
</div>
</div>

View file

@ -3,6 +3,7 @@ import React from "react";
import { useSettingsContext } from "../contexts/SettingsContext";
import AdvancedSettings from "./AdvancedSettings";
import ApiSettings from "./ApiSettings";
import CopilotPlusSettings from "./CopilotPlusSettings";
import GeneralSettings from "./GeneralSettings";
import QASettings from "./QASettings";
@ -27,6 +28,7 @@ const SettingsMain: React.FC<{ plugin: CopilotPlugin; reloadPlugin: () => Promis
Please Save and Reload the plugin when you change any setting below!
</div>
<CopilotPlusSettings />
<GeneralSettings
getLangChainParams={plugin.getLangChainParams.bind(plugin)}
encryptionService={plugin.getEncryptionService()}

View file

@ -1,10 +1,14 @@
import { useEffect, useState } from "react";
import { FormattedDateTime } from "./utils";
export interface ChatMessage {
message: string;
originalMessage?: string;
sender: string;
timestamp: FormattedDateTime | null;
isVisible: boolean;
sources?: { title: string; score: number }[];
content?: any[];
}
class SharedState {

View file

@ -0,0 +1,76 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { Vault } from "obsidian";
import { TFile } from "obsidian";
interface FileParser {
supportedExtensions: string[];
parseFile: (file: TFile, vault: Vault) => Promise<string>;
}
export class MarkdownParser implements FileParser {
supportedExtensions = ["md"];
async parseFile(file: TFile, vault: Vault): Promise<string> {
return await vault.read(file);
}
}
export class PDFParser implements FileParser {
supportedExtensions = ["pdf"];
private brevilabsClient: BrevilabsClient;
constructor(brevilabsClient: BrevilabsClient) {
this.brevilabsClient = brevilabsClient;
}
async parseFile(file: TFile, vault: Vault): Promise<string> {
try {
const binaryContent = await vault.readBinary(file);
const pdf4llmResponse = await this.brevilabsClient.pdf4llm(binaryContent);
return pdf4llmResponse.response;
} catch (error) {
console.error(`Error extracting content from PDF ${file.path}:`, error);
return `[Error: Could not extract content from PDF ${file.basename}]`;
}
}
}
// Future parsers can be added like this:
/*
class DocxParser implements FileParser {
supportedExtensions = ["docx", "doc"];
async parseFile(file: TFile, vault: Vault): Promise<string> {
// Implementation for Word documents
}
}
*/
export class FileParserManager {
private parsers: Map<string, FileParser> = new Map();
constructor(brevilabsClient: BrevilabsClient) {
// Register more parsers here
this.registerParser(new MarkdownParser());
this.registerParser(new PDFParser(brevilabsClient));
}
registerParser(parser: FileParser) {
for (const ext of parser.supportedExtensions) {
this.parsers.set(ext, parser);
}
}
async parseFile(file: TFile, vault: Vault): Promise<string> {
const parser = this.parsers.get(file.extension);
if (!parser) {
throw new Error(`No parser found for file type: ${file.extension}`);
}
return await parser.parseFile(file, vault);
}
supportsExtension(extension: string): boolean {
return this.parsers.has(extension);
}
}

161
src/tools/SearchTools.ts Normal file
View file

@ -0,0 +1,161 @@
import { TEXT_WEIGHT } from "@/constants";
import { CustomError } from "@/error";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ChatModelManager from "@/LLMProviders/chatModelManager";
import { HybridRetriever } from "@/search/hybridRetriever";
import { TimeInfo } from "@/tools/TimeTools";
import VectorStoreManager from "@/VectorStoreManager";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const localSearchTool = tool(
async ({
timeRange,
query,
salientTerms,
vectorStoreManager,
chatModelManager,
brevilabsClient,
}: {
timeRange?: { startTime: TimeInfo; endTime: TimeInfo };
query: string;
salientTerms: string[];
vectorStoreManager: VectorStoreManager;
chatModelManager: ChatModelManager;
brevilabsClient: BrevilabsClient;
}) => {
// Ensure VectorStoreManager is initialized
await vectorStoreManager.waitForInitialization();
const embeddingsManager = vectorStoreManager.getEmbeddingsManager();
const vault = vectorStoreManager.getVault();
const embeddingInstance = embeddingsManager?.getEmbeddingsAPI();
const settings = vectorStoreManager.getSettings();
if (!embeddingInstance) {
throw new CustomError("Embedding instance not found.");
}
const returnAll = timeRange !== undefined;
const db = vectorStoreManager.getDb();
if (!db) {
throw new CustomError("Orama database not found.");
}
const hybridRetriever = new HybridRetriever(
db,
vault,
chatModelManager.getChatModel(),
embeddingInstance,
brevilabsClient,
{
minSimilarityScore: returnAll ? 0.0 : 0.1,
maxK: returnAll ? 100 : 15,
salientTerms,
timeRange: timeRange
? {
startDate: timeRange.startTime.localDateString,
endDate: timeRange.endTime.localDateString,
}
: undefined,
textWeight: TEXT_WEIGHT,
returnAll: returnAll,
// Voyage AI reranker did worse than Orama in some cases, so only use it if
// Orama did not return anything higher than this threshold
useRerankerThreshold: 0.5,
},
settings.debug
);
// Perform the search
const documents = await hybridRetriever.getRelevantDocuments(query);
// Format the results
const formattedResults = documents.map((doc) => ({
title: doc.metadata.title,
content: doc.pageContent,
path: doc.metadata.path,
score: doc.metadata.score,
rerank_score: doc.metadata.rerank_score,
includeInContext: doc.metadata.includeInContext,
}));
return JSON.stringify(formattedResults);
},
{
name: "localSearch",
description: "Search for notes based on the time range and query",
schema: z.object({
timeRange: z
.object({
startTime: z.any(),
endTime: z.any(),
})
.optional(),
query: z.string().describe("The search query"),
salientTerms: z.array(z.string()).describe("List of salient terms extracted from the query"),
vectorStoreManager: z.any().describe("The VectorStoreManager instance"),
chatModelManager: z.any().describe("The ChatModelManager instance"),
brevilabsClient: z.any().describe("The BrevilabsClient instance"),
}),
}
);
const indexTool = tool(
async ({ vectorStoreManager }: { vectorStoreManager: VectorStoreManager }) => {
try {
const indexedCount = await vectorStoreManager.indexVaultToVectorStore();
const indexResultPrompt = `Please report whether the indexing was successful.\nIf success is true, just say it is successful. If 0 files is indexed, say there are no new files to index.`;
return (
indexResultPrompt +
JSON.stringify({
success: true,
message:
indexedCount === 0
? "No new files to index."
: `Indexed ${indexedCount} files in the vault.`,
})
);
} catch (error) {
console.error("Error indexing vault:", error);
return JSON.stringify({
success: false,
message: "An error occurred while indexing the vault.",
});
}
},
{
name: "indexVault",
description: "Index the vault to the vector store",
schema: z.object({
vectorStoreManager: z.any().describe("The VectorStoreManager instance"),
}),
}
);
// Add new web search tool
const webSearchTool = tool(
async ({ query, brevilabsClient }: { query: string; brevilabsClient: BrevilabsClient }) => {
try {
const response = await brevilabsClient.webSearch(query);
return (
"\n\nWeb search results below, don't forget to list the sources at the end of your answer:\n" +
response.response
);
} catch (error) {
console.error(`Error processing web search query ${query}:`, error);
return "";
}
},
{
name: "webSearch",
description: "Search the web for information",
schema: z.object({
query: z.string().describe("The search query"),
brevilabsClient: z.any().describe("The BrevilabsClient instance"),
}),
}
);
export { indexTool, localSearchTool, webSearchTool };

281
src/tools/TimeTools.ts Normal file
View file

@ -0,0 +1,281 @@
import { tool } from "@langchain/core/tools";
import * as chrono from "chrono-node";
import { DateTime } from "luxon";
import { Notice } from "obsidian";
import { z } from "zod";
export interface TimeInfo {
epoch: number;
isoString: string;
userLocaleString: string;
localDateString: string;
timezoneOffset: number;
timezone: string;
}
async function getCurrentTime(): Promise<TimeInfo> {
const now = new Date();
const timezoneOffset = now.getTimezoneOffset();
const timezoneAbbr =
new Intl.DateTimeFormat("en", { timeZoneName: "short" })
.formatToParts(now)
.find((part) => part.type === "timeZoneName")?.value || "Unknown";
return {
epoch: Math.floor(now.getTime()),
isoString: now.toISOString(),
userLocaleString: now.toLocaleString(),
localDateString: now.toLocaleDateString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
timezoneOffset: -timezoneOffset, // Invert the offset to match common conventions
timezone: timezoneAbbr,
};
}
const getCurrentTimeTool = tool(async () => getCurrentTime(), {
name: "getCurrentTime",
description: "Get the current time in various formats, including timezone information",
schema: z.object({}), // No input required
});
function getTimeRangeMs(timeExpression: string):
| {
startTime: TimeInfo;
endTime: TimeInfo;
}
| undefined {
const now = DateTime.now();
let start: DateTime;
let end: DateTime;
// Add handling for single month names
const monthNames = {
jan: 1,
january: 1,
feb: 2,
february: 2,
mar: 3,
march: 3,
apr: 4,
april: 4,
may: 5,
jun: 6,
june: 6,
jul: 7,
july: 7,
aug: 8,
august: 8,
sep: 9,
september: 9,
oct: 10,
october: 10,
nov: 11,
november: 11,
dec: 12,
december: 12,
};
const normalizedInput = timeExpression.toLowerCase().trim();
// Check if input is just a month name
if (monthNames[normalizedInput as keyof typeof monthNames]) {
const monthNum = monthNames[normalizedInput as keyof typeof monthNames];
let year = now.year;
// If the month is in the future, use last year
if (monthNum > now.month) {
year--;
}
start = DateTime.fromObject({ year, month: monthNum }).startOf("month");
end = start.endOf("month");
} else {
// Original switch case and chrono parsing logic remains the same
switch (normalizedInput) {
case "yesterday":
start = now.minus({ days: 1 }).startOf("day");
end = now.minus({ days: 1 }).endOf("day");
break;
case "last week":
start = now.minus({ weeks: 1 }).startOf("week");
end = now.minus({ weeks: 1 }).endOf("week");
break;
case "this week":
start = now.startOf("week");
end = now.endOf("week");
break;
case "next week":
start = now.plus({ weeks: 1 }).startOf("week");
end = now.plus({ weeks: 1 }).endOf("week");
break;
case "last month":
start = now.minus({ months: 1 }).startOf("month");
end = now.minus({ months: 1 }).endOf("month");
break;
case "this month":
start = now.startOf("month");
end = now.endOf("month");
break;
case "next month":
start = now.plus({ months: 1 }).startOf("month");
end = now.plus({ months: 1 }).endOf("month");
break;
case "last year":
start = now.minus({ years: 1 }).startOf("year");
end = now.minus({ years: 1 }).endOf("year");
break;
case "this year":
start = now.startOf("year");
end = now.endOf("year");
break;
case "next year":
start = now.plus({ years: 1 }).startOf("year");
end = now.plus({ years: 1 }).endOf("year");
break;
default: {
// Use Chrono.js for more complex expressions
const parsedDates = chrono.parse(timeExpression, now.toJSDate(), { forwardDate: false });
if (parsedDates.length > 0) {
start = DateTime.fromJSDate(parsedDates[0].start.date());
end = parsedDates[0].end
? DateTime.fromJSDate(parsedDates[0].end.date())
: start.endOf("month"); // Default to end of month if no end date is specified
// If the parsed date is in the future, adjust it to the previous occurrence
if (start > now) {
start = start.minus({ years: 1 });
end = end.minus({ years: 1 });
}
} else {
console.warn(`Unable to parse time expression: ${timeExpression}`);
return;
}
break;
}
}
}
return {
startTime: convertToTimeInfo(start),
endTime: convertToTimeInfo(end),
};
}
function convertToTimeInfo(dateTime: DateTime): TimeInfo {
const jsDate = dateTime.toJSDate();
const timezoneOffset = jsDate.getTimezoneOffset();
const timezoneAbbr =
new Intl.DateTimeFormat("en", { timeZoneName: "short" })
.formatToParts(jsDate)
.find((part) => part.type === "timeZoneName")?.value || "Unknown";
return {
epoch: Math.floor(jsDate.getTime()),
isoString: jsDate.toISOString(),
userLocaleString: jsDate.toLocaleString(),
localDateString: jsDate.toLocaleDateString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
timezoneOffset: -timezoneOffset,
timezone: timezoneAbbr,
};
}
const getTimeRangeMsTool = tool(
async ({ timeExpression }: { timeExpression: string }) => getTimeRangeMs(timeExpression),
{
name: "getTimeRangeMs",
description: "Get a time range in milliseconds based on a natural language time expression",
schema: z.object({
timeExpression: z
.string()
.describe(
"A natural language time expression (e.g., 'last week', 'from July 1 to July 15')"
),
}),
}
);
function getTimeInfoByEpoch(epoch: number): TimeInfo {
// Check if the epoch is in seconds (10 digits) or milliseconds (13 digits)
const epochMs = epoch.toString().length === 10 ? epoch * 1000 : epoch;
const dateTime = DateTime.fromMillis(epochMs);
return convertToTimeInfo(dateTime);
}
const getTimeInfoByEpochTool = tool(
async ({ epoch }: { epoch: number }) => getTimeInfoByEpoch(epoch),
{
name: "getTimeInfoByEpoch",
description:
"Convert a Unix timestamp (in seconds or milliseconds) to detailed time information",
schema: z.object({
epoch: z.number().describe("Unix timestamp in seconds or milliseconds"),
}),
}
);
function parseTimeInterval(interval: string): number {
const match = interval.match(/^(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)$/i);
if (!match) {
throw new Error(`Invalid time interval format: ${interval}`);
}
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case "s":
case "sec":
case "second":
case "seconds":
return value * 1000;
case "m":
case "min":
case "minute":
case "minutes":
return value * 60 * 1000;
case "h":
case "hr":
case "hour":
case "hours":
return value * 60 * 60 * 1000;
default:
throw new Error(`Unsupported time unit: ${unit}`);
}
}
async function startPomodoro(interval = "25min"): Promise<void> {
const duration = parseTimeInterval(interval);
return new Promise((resolve) => {
setTimeout(() => {
new Notice(`Pomodoro timer (${interval}) completed! Take a break!`);
resolve();
}, duration);
});
}
const pomodoroTool = tool(
async ({ interval = "25min" }: { interval?: string }) => {
startPomodoro(interval);
return `Pomodoro timer started. It will end in ${interval}.`;
},
{
name: "startPomodoro",
description: "Start a Pomodoro timer with a customizable interval",
schema: z.object({
interval: z
.string()
.optional()
.describe("Time interval (e.g., '25min', '5s', '1h'). Default is 25min."),
}),
}
);
export { getCurrentTimeTool, getTimeInfoByEpochTool, getTimeRangeMsTool, pomodoroTool };

46
src/tools/YoutubeTools.ts Normal file
View file

@ -0,0 +1,46 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const simpleYoutubeTranscriptionTool = tool(
async ({ url, brevilabsClient }: { url: string; brevilabsClient: BrevilabsClient }) => {
try {
const response = await brevilabsClient.youtube4llm(url);
// Check if transcript is empty
if (!response.response.transcript) {
return JSON.stringify({
success: false,
message:
"Transcript not available. Only English videos with the auto transcript option turned on are supported at the moment",
});
}
const transcriptResultPrompt = `Please correct any typo or obvious mistranscription in the following youtube transcript, and return the corrected version, with an empty line between each sentence. Return the TRANSCRIPT ONLY, WITHOUT any other text. If the transcript is empty, return the provided message.\n\n`;
return (
transcriptResultPrompt +
JSON.stringify({
success: true,
transcript: response.response.transcript,
elapsed_time_ms: response.elapsed_time_ms,
})
);
} catch (error) {
console.error(`Error transcribing YouTube video ${url}:`, error);
return JSON.stringify({
success: false,
message: "An error occurred while transcribing the YouTube video.",
});
}
},
{
name: "youtubeTranscription",
description: "Get the transcript of a YouTube video",
schema: z.object({
url: z.string().describe("The YouTube video URL"),
brevilabsClient: z.any().describe("The BrevilabsClient instance"),
}),
}
);
export { simpleYoutubeTranscriptionTool };

10
src/tools/toolManager.ts Normal file
View file

@ -0,0 +1,10 @@
export class ToolManager {
static async callTool(tool: any, args: any): Promise<any> {
try {
return await tool.call(args);
} catch (error) {
console.error(`Error calling tool: ${error}`);
return null;
}
}
}

View file

@ -531,3 +531,21 @@ export async function getFilePathsFromPatterns(
return Array.from(filePaths);
}
export function extractJsonFromCodeBlock(content: string): any {
const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
const jsonContent = codeBlockMatch ? codeBlockMatch[1].trim() : content.trim();
return JSON.parse(jsonContent);
}
const YOUTUBE_URL_REGEX =
/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([^\s&]+)/;
export function isYoutubeUrl(url: string): boolean {
return YOUTUBE_URL_REGEX.test(url);
}
export function extractYoutubeUrl(text: string): string | null {
const match = text.match(YOUTUBE_URL_REGEX);
return match ? match[0] : null;
}

View file

@ -68,7 +68,7 @@ class VectorDBManager {
// Add note title as contextual chunk headers
// https://js.langchain.com/docs/modules/data_connection/document_transformers/contextual_chunk_headers
const chunks = await textSplitter.createDocuments([fileToSave.content], [], {
chunkHeader: "[[" + fileToSave.title + "]]" + "\n\n---\n\n",
chunkHeader: "\n\nNOTE TITLE: [[" + fileToSave.title + "]]" + "\n\nNOTE BLOCK CONTENT:\n\n",
appendChunkOverlapHeader: true,
});
@ -179,19 +179,21 @@ class VectorDBManager {
}
} catch (err) {
console.error(`Error upserting document ${docToSave.id} in VectorDB:`, err);
throw err;
// Instead of throwing, we'll return undefined to indicate failure
return undefined;
}
return docToSave;
}
public static async getDocsByPath(db: Orama<any>, path: string): Promise<any | undefined> {
if (!db) throw new Error("DB not initialized");
if (!this.config) throw new Error("VectorDBManager not initialized");
if (!path) return;
const result = await search(db, {
term: path,
properties: ["path"],
limit: 100,
includeVectors: true,
exact: true,
});
return result.hits;
}

View file

@ -95,10 +95,91 @@ If your plugin does not need CSS, delete this file.
display: flex;
align-items: center;
width: 100%;
padding: 0 0 4px;
padding: 0 0 8px;
gap: 4px;
}
.chat-context-menu {
display: flex;
align-items: center;
gap: 8px;
padding: 0;
border-bottom: none;
flex: 1; /* Take up available space */
}
.context-notes {
display: flex;
gap: 4px;
flex-wrap: wrap;
flex: 1;
}
.context-note {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: var(--background-secondary-alt);
border-radius: 4px;
font-size: 10px;
height: 20px;
line-height: 1;
}
.context-note.active {
background: var(--background-modifier-hover);
color: var(--text-normal);
border-left: 2px solid var(--interactive-accent);
}
.context-note.with-hover {
/* Use the same background as active note but without border */
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.note-badge {
font-size: 10px;
padding: 0 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.remove-note {
padding: 0 4px;
cursor: pointer;
opacity: 0.5;
background: none !important;
border: none !important;
box-shadow: none !important;
}
.remove-note:hover {
opacity: 1;
}
.context-note.url .note-badge {
background-color: var(--interactive-accent);
}
.context-note.url .note-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-note .note-badge.pdf {
background-color: var(--background-modifier-error);
}
.chat-icons-right {
display: flex;
align-items: center;
gap: 4px;
justify-content: flex-end;
margin-left: auto; /* Push to the right */
}
.icon-scaler {
@ -211,9 +292,77 @@ If your plugin does not need CSS, delete this file.
padding: 0 4px;
}
.chat-input-left {
display: flex;
align-items: center;
gap: 12px;
}
.selected-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--background-secondary);
border-radius: 4px;
margin-bottom: 8px;
}
.image-preview-container {
position: relative;
width: 80px;
height: 80px;
}
.selected-image-preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
}
.remove-image-button {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--background-modifier-error);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
padding: 0;
line-height: 1;
}
.remove-image-button:hover {
background: var(--background-modifier-error-hover);
}
.button-content {
display: flex;
align-items: center;
}
.button-content span {
font-size: 10px;
}
/* Make sure the icon scales consistently */
.button-content .icon-scaler {
width: 18px;
height: 18px;
}
.chat-input-buttons {
display: flex;
gap: 8px;
gap: 12px;
}
.model-select-content {
@ -418,6 +567,26 @@ If your plugin does not need CSS, delete this file.
margin: 10px;
}
.message-content-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-image-content {
width: 100%;
max-width: 400px;
padding: 8px 0;
}
.chat-message-image {
width: 100%;
height: auto;
object-fit: contain;
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
}
.message-buttons-wrapper {
display: flex;
justify-content: space-between;