mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
c7964ae327
commit
e0af6d9fbf
46 changed files with 3505 additions and 470 deletions
|
|
@ -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
33
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
149
src/LLMProviders/brevilabsClient.ts
Normal file
149
src/LLMProviders/brevilabsClient.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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[]) {
|
||||
|
|
|
|||
502
src/LLMProviders/chainRunner.ts
Normal file
502
src/LLMProviders/chainRunner.ts
Normal 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 };
|
||||
161
src/LLMProviders/intentAnalyzer.ts
Normal file
161
src/LLMProviders/intentAnalyzer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
33
src/components/AddContextNoteModal.tsx
Normal file
33
src/components/AddContextNoteModal.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/components/AddImageModal.tsx
Normal file
29
src/components/AddImageModal.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
51
src/components/BaseNoteModal.tsx
Normal file
51
src/components/BaseNoteModal.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
80
src/components/ChatComponents/ChatContextMenu.tsx
Normal file
80
src/components/ChatComponents/ChatContextMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
93
src/components/OramaSearchModal.tsx
Normal file
93
src/components/OramaSearchModal.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
61
src/components/SourcesModal.tsx
Normal file
61
src/components/SourcesModal.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
131
src/contextProcessor.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
src/main.ts
68
src/main.ts
|
|
@ -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
94
src/mentions/Mention.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
31
src/settings/components/CopilotPlusSettings.tsx
Normal file
31
src/settings/components/CopilotPlusSettings.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
76
src/tools/FileParserManager.ts
Normal file
76
src/tools/FileParserManager.ts
Normal 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
161
src/tools/SearchTools.ts
Normal 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
281
src/tools/TimeTools.ts
Normal 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
46
src/tools/YoutubeTools.ts
Normal 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
10
src/tools/toolManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/utils.ts
18
src/utils.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
173
styles.css
173
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue