Pass multiple notes to prompt by setting folder as context using Copilot command (#265)

This commit is contained in:
Logan Yang 2024-01-27 22:51:54 -08:00 committed by GitHub
parent d6a28ec3ac
commit 65f62513e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 845 additions and 513 deletions

31
.github/workflows/node.js.yml vendored Normal file
View file

@ -0,0 +1,31 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: Node.js CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm test

16
__mocks__/obsidian.js Normal file
View file

@ -0,0 +1,16 @@
// __mocks__/obsidian.js
module.exports = {
Vault: jest.fn().mockImplementation(() => {
return {
getMarkdownFiles: jest.fn().mockImplementation(() => {
// Return an array of mock markdown file objects
return Promise.resolve([
{ path: 'test/test2/note1.md' },
{ path: 'test/note2.md' },
{ path: 'test2/note3.md' },
{ path: 'note4.md' },
]);
}),
};
}),
};

View file

@ -8,8 +8,10 @@ module.exports = {
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
'^obsidian$': '<rootDir>/__mocks__/obsidian.js'
},
testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testPathIgnorePatterns: ['/node_modules/'],
};
setupFiles: ['<rootDir>/jest.setup.js'],
};

1
jest.setup.js Normal file
View file

@ -0,0 +1 @@
import 'web-streams-polyfill/dist/polyfill.min.js';

975
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@
"@testing-library/react": "^14.0.0",
"@types/crypto-js": "^4.1.1",
"@types/events": "^3.0.0",
"@types/jest": "^29.5.0",
"@types/jest": "^29.5.11",
"@types/koa": "^2.13.7",
"@types/koa__cors": "^4.0.0",
"@types/node": "^16.11.6",
@ -28,12 +28,13 @@
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"jest": "^29.5.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
"obsidian": "latest",
"ts-jest": "^29.1.0",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "4.7.4",
"web-streams-polyfill": "^3.3.2"
},
"dependencies": {
"@huggingface/inference": "^2.6.4",

View file

@ -13,21 +13,21 @@ export class AddPromptModal extends Modal {
this.contentEl.createEl('h2', { text: 'User Custom Prompt' });
const formContainer = this.contentEl.createEl('div', { cls: 'custom-prompt-modal' });
const formContainer = this.contentEl.createEl('div', { cls: 'copilot-command-modal' });
const titleContainer = formContainer.createEl(
'div',
{ cls: 'custom-prompt-input-container' }
{ cls: 'copilot-command-input-container' }
);
titleContainer.createEl(
'h3', { text: 'Title', cls: 'custom-prompt-header' }
'h3', { text: 'Title', cls: 'copilot-command-header' }
);
titleContainer.createEl(
'p',
{
text: 'The title of the prompt, must be unique.',
cls: 'custom-prompt-description',
cls: 'copilot-command-input-description',
}
);
@ -41,17 +41,17 @@ export class AddPromptModal extends Modal {
const promptContainer = formContainer.createEl(
'div',
{ cls: 'custom-prompt-input-container' }
{ cls: 'copilot-command-input-container' }
);
promptContainer.createEl(
'h3', { text: 'Prompt', cls: 'custom-prompt-header' }
'h3', { text: 'Prompt', cls: 'copilot-command-header' }
);
promptContainer.createEl(
'p',
{
text: 'The content of the prompt. Use "{}" to represent the selected text. For example, "Improve the readability of the following text: {}"',
cls: 'custom-prompt-description',
cls: 'copilot-command-input-description',
}
);
const promptField = promptContainer.createEl('textarea');
@ -75,18 +75,18 @@ export class AddPromptModal extends Modal {
});
const descContainer = promptContainer.createEl('p', {
cls: 'custom-prompt-description',
cls: 'copilot-command-input-description',
});
descContainer.appendChild(descFragment);
const saveButtonContainer = formContainer.createEl(
'div',
{ cls: 'custom-prompt-save-btn-container' }
{ cls: 'copilot-command-save-btn-container' }
);
const saveButton = saveButtonContainer.createEl(
'button',
{ text: 'Save', cls: 'custom-prompt-save-btn' }
{ text: 'Save', cls: 'copilot-command-save-btn' }
);
saveButton.addEventListener('click', () => {
if (titleField.value && promptField.value) {

View file

@ -7,6 +7,7 @@ import ChatMessages from '@/components/ChatComponents/ChatMessages';
import { AI_SENDER, USER_SENDER } from '@/constants';
import { AppContext } from '@/context';
import { getAIResponse } from '@/langchainStream';
import { CopilotSettings } from '@/settings/SettingsPage';
import SharedState, {
ChatMessage, useSharedState,
} from '@/sharedState';
@ -20,6 +21,8 @@ import {
formatDateTime,
getFileContent,
getFileName,
getNotesFromPath,
getSendChatContextNotesPrompt,
glossaryPrompt,
removeUrlsFromSelectionPrompt,
rewriteLongerSelectionPrompt,
@ -27,14 +30,14 @@ import {
rewriteShorterSelectionPrompt,
rewriteTweetSelectionPrompt,
rewriteTweetThreadSelectionPrompt,
sendNoteContentPrompt,
sendNotesContentPrompt,
simplifyPrompt,
summarizePrompt,
tocPrompt
tocPrompt,
} from '@/utils';
import VectorDBManager from '@/vectorDBManager';
import { EventEmitter } from 'events';
import { Notice, TFile } from 'obsidian';
import { Notice, TFile, Vault } from 'obsidian';
import React, {
useContext,
useEffect,
@ -49,15 +52,24 @@ interface CreateEffectOptions {
interface ChatProps {
sharedState: SharedState;
settings: CopilotSettings;
chainManager: ChainManager;
emitter: EventEmitter;
getChatVisibility: () => Promise<boolean>;
defaultSaveFolder: string;
vault: Vault;
debug: boolean;
}
const Chat: React.FC<ChatProps> = ({
sharedState, chainManager, emitter, getChatVisibility, defaultSaveFolder, debug
sharedState,
settings,
chainManager,
emitter,
getChatVisibility,
defaultSaveFolder,
vault,
debug
}) => {
const [
chatHistory, addMessage, clearMessages,
@ -138,30 +150,45 @@ const Chat: React.FC<ChatProps> = ({
return;
}
const file = app.workspace.getActiveFile();
if (!file) {
new Notice('No active note found.');
console.error('No active note found.');
return;
let noteFiles: TFile[] = [];
if (debug) {
console.log('Chat note context path:', settings.chatNoteContextPath);
}
if (settings.chatNoteContextPath) {
// Recursively get all note TFiles in the path
noteFiles = await getNotesFromPath(vault, settings.chatNoteContextPath);
}
const noteContent = await getFileContent(file);
const noteName = getFileName(file);
if (!noteContent) {
new Notice('No note content found.');
console.error('No note content found.');
return;
const file = app.workspace.getActiveFile();
// If no note context provided, default to the active note
if (noteFiles.length === 0) {
if (!file) {
new Notice('No active note found.');
console.error('No active note found.');
return;
}
new Notice('No valid Chat context provided. Defaulting to the active note.');
noteFiles = [file];
}
const notes = [];
for (const file of noteFiles) {
const content = await getFileContent(file);
if (content) {
notes.push({ name: getFileName(file), content });
}
}
// Send the content of the note to AI
const promptMessageHidden: ChatMessage = {
message: sendNoteContentPrompt(noteName, noteContent),
message: sendNotesContentPrompt(notes),
sender: USER_SENDER,
isVisible: false,
};
// Visible user message that is not sent to AI
const sendNoteContentUserMessage = `Please read this note [[${noteName}]] and be ready to answer questions about it.`;
// const sendNoteContentUserMessage = `Please read the following notes [[${activeNoteContent}]] and be ready to answer questions about it.`;
const sendNoteContentUserMessage = getSendChatContextNotesPrompt(notes);
const promptMessageVisible: ChatMessage = {
message: sendNoteContentUserMessage,
sender: USER_SENDER,

View file

@ -156,7 +156,7 @@ const ChatIcons: React.FC<ChatIconsProps> = ({
{selectedChain === 'llm_chain' && (
<button className='chat-icon-button' onClick={onSendActiveNoteToPrompt}>
<SendActiveNoteToPromptIcon className='icon-scaler' />
<span className="tooltip-text">Send Active Note to Prompt<br/>(use with long context models)</span>
<span className="tooltip-text">Send Note(s) to Prompt<br/>(Set with Copilot command.<br/>Default is active note)</span>
</button>
)}
{selectedChain === 'retrieval_qa' && (

View file

@ -0,0 +1,57 @@
import { CopilotSettings } from "@/settings/SettingsPage";
import { App, Modal } from "obsidian";
export class ChatNoteContextModal extends Modal {
private settings: CopilotSettings;
private onSubmit: (path: string) => void;
constructor(app: App, settings: CopilotSettings, onSubmit: (path: string) => void) {
super(app);
this.settings = settings;
this.onSubmit = onSubmit;
}
onOpen() {
const formContainer = this.contentEl.createEl('div', { cls: 'copilot-command-modal' });
const pathContainer = formContainer.createEl('div', { cls: 'copilot-command-input-container' });
pathContainer.createEl('h3', { text: 'Folder Path', cls: 'copilot-command-header' });
const descFragment = createFragment((frag) => {
frag.appendText('All notes under the path will be sent to the prompt when the ');
frag.createEl(
'strong',
{ text: 'Send Note(s) to Prompt' }
);
frag.appendText(' button is clicked in Chat mode. ');
frag.appendText('If none provided, ');
frag.createEl(
'strong',
{ text: 'default context is the active note' }
);
});
pathContainer.appendChild(descFragment);
const pathField = pathContainer.createEl(
'input',
{
type: 'text',
cls: 'copilot-command-input',
value: this.settings.chatNoteContextPath,
}
);
pathField.setAttribute('name', 'folderPath');
const submitButtonContainer = formContainer.createEl('div', { cls: 'copilot-command-save-btn-container' });
const submitButton = submitButtonContainer.createEl('button', { text: 'Submit', cls: 'copilot-command-save-btn' });
submitButton.addEventListener('click', () => {
// Remove the leading slash if it exists
let pathValue = pathField.value;
if (pathValue.startsWith('/') && pathValue.length > 1) {
pathValue = pathValue.slice(1);
}
this.onSubmit(pathValue);
this.close();
});
}
}

View file

@ -3,9 +3,10 @@ import Chat from '@/components/Chat';
import { CHAT_VIEWTYPE } from '@/constants';
import { AppContext } from '@/context';
import CopilotPlugin from '@/main';
import { CopilotSettings } from '@/settings/SettingsPage';
import SharedState from '@/sharedState';
import { EventEmitter } from 'events';
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { ItemView, Vault, WorkspaceLeaf } from 'obsidian';
import * as React from 'react';
import { Root, createRoot } from 'react-dom/client';
@ -14,6 +15,8 @@ export default class CopilotView extends ItemView {
private sharedState: SharedState;
private chainManager: ChainManager;
private root: Root | null = null;
private vault: Vault;
private settings: CopilotSettings;
private defaultSaveFolder: string;
private debug = false;
emitter: EventEmitter;
@ -22,12 +25,14 @@ export default class CopilotView extends ItemView {
constructor(leaf: WorkspaceLeaf, private plugin: CopilotPlugin) {
super(leaf);
this.sharedState = plugin.sharedState;
this.settings = plugin.settings;
this.app = plugin.app;
this.chainManager = plugin.chainManager;
this.debug = plugin.settings.debug;
this.emitter = new EventEmitter();
this.getChatVisibility = this.getChatVisibility.bind(this);
this.userSystemPrompt = plugin.settings.userSystemPrompt;
this.vault = plugin.app.vault;
this.defaultSaveFolder = plugin.settings.defaultSaveFolder;
}
@ -63,10 +68,12 @@ export default class CopilotView extends ItemView {
<React.StrictMode>
<Chat
sharedState={this.sharedState}
settings={this.settings}
chainManager={this.chainManager}
emitter={this.emitter}
getChatVisibility={this.getChatVisibility}
defaultSaveFolder={this.defaultSaveFolder}
vault={this.vault}
debug={this.debug}
/>
</React.StrictMode>

View file

@ -149,5 +149,6 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
stream: true,
embeddingProvider: ModelProviders.OPENAI,
defaultSaveFolder: 'copilot-conversations',
chatNoteContextPath: '',
debug: false,
};

View file

@ -2,6 +2,7 @@ import ChainManager from '@/LLMProviders/chainManager';
import { LangChainParams, SetChainOptions } from '@/aiParams';
import { ChainType } from '@/chainFactory';
import { AddPromptModal } from "@/components/AddPromptModal";
import { ChatNoteContextModal } from "@/components/ChatNoteContextModal";
import CopilotView from '@/components/CopilotView';
import { LanguageModal } from "@/components/LanguageModal";
import { ListPromptModal } from "@/components/ListPromptModal";
@ -368,6 +369,18 @@ export default class CopilotPlugin extends Plugin {
}
}
});
this.addCommand({
id: 'set-chat-note-context',
name: 'Set note context for Chat mode',
callback: async () => {
new ChatNoteContextModal(this.app, this.settings, async (path: string) => {
// Store the path in the plugin's settings, default to empty string
this.settings.chatNoteContextPath = path;
await this.saveSettings();
}).open();
},
});
}
processSelection(editor: Editor, eventType: string, eventSubtype?: string) {

View file

@ -32,6 +32,7 @@ export interface CopilotSettings {
stream: boolean;
embeddingProvider: string;
defaultSaveFolder: string;
chatNoteContextPath: string;
debug: boolean;
}

View file

@ -13,7 +13,30 @@ import {
RetrievalQAChain
} from "langchain/chains";
import moment from 'moment';
import { TFile } from 'obsidian';
import { TFile, Vault } from 'obsidian';
export const isFolderMatch = (fileFullpath: string, inputPath: string): boolean => {
const fileSegments = fileFullpath.split('/').map(segment => segment.toLowerCase());
return fileSegments.includes(inputPath.toLowerCase());
}
export const getNotesFromPath = async (vault: Vault, path: string): Promise<TFile[]> => {
const files = await vault.getMarkdownFiles();
// Special handling for the root path '/'
if (path === '/') {
return files;
}
// Split the path to get the last folder name
const pathSegments = path.split('/');
const lastSegment = pathSegments[pathSegments.length - 1].toLowerCase();
return files.filter(file => {
// Split the file path and get the last directory name
return isFolderMatch(file.path, lastSegment) || file.basename === lastSegment;
});
}
export const stringToChainType = (chain: string): ChainType => {
switch(chain) {
@ -73,7 +96,7 @@ export const formatDateTime = (now: Date, timezone: 'local' | 'utc' = 'local') =
export async function getFileContent(file: TFile): Promise<string | null> {
if (file.extension != "md") return null;
return await this.app.vault.read(file);
return await this.app.vault.cachedRead(file);
}
export function getFileName(file: TFile): string {
@ -117,17 +140,19 @@ export function sendNoteContentPrompt(
+ `Feel free to ask related questions, such as 'give me a summary of this note in bullet points', 'what key questions does it answer', etc. "\n`
}
export function useNoteAsContextPrompt(
noteName: string,
noteContent: string | null,
): string {
return `Please read the note below and be ready to answer questions about it. `
export function sendNotesContentPrompt(notes: { name: string; content: string }[]): string {
return `Please read the notes below and be ready to answer questions about them. `
+ `If there's no information about a certain topic, just say the note `
+ `does not mention it. `
+ `The content of the note is between "/***/":\n\n/***/\n\n${noteContent}\n\n/***/\n\n`
+ `The content of the note is between "/***/":\n\n/***/\n\n${JSON.stringify(notes)}\n\n/***/\n\n`
+ `Please reply with the following word for word:`
+ `"OK I've read this note titled [[ ${noteName} ]]. `
+ `Feel free to ask **specific** questions about it. For generic questions like 'give me a summary', 'brainstorm based on the content', Chat mode with *context sent in the prompt* is a better choice."\n`
+ `"OK I've read these notes. `
+ `Feel free to ask related questions, such as 'give me a summary of these notes in bullet points', 'what key questions does these notes answer', etc. "\n`
}
export function getSendChatContextNotesPrompt(notes: { name: string; content: string }[]): string {
const noteTitles = notes.map(note => `[[${note.name}]]`).join('\n\n');
return `Please read the notes below and be ready to answer questions about them. \n\n${noteTitles}`;
}
export function fixGrammarSpellingSelectionPrompt(selectedText: string): string {

View file

@ -269,38 +269,37 @@ If your plugin does not need CSS, delete this file.
width: 95%;
}
/* Apply some style to AddPromptModal */
.custom-prompt-modal {
.copilot-command-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.custom-prompt-input-container {
.copilot-command-input-container {
width: 90%;
margin: auto;
}
.custom-prompt-input-container input,
.custom-prompt-input-container textarea {
.copilot-command-input-container input,
.copilot-command-input-container textarea {
display: block;
width: 100%;
margin-top: 5px;
}
.custom-prompt-input-container textarea {
.copilot-command-input-container textarea {
height: 150px;
resize: vertical;
}
.custom-prompt-save-btn-container {
.copilot-command-save-btn-container {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.custom-prompt-save-btn {
.copilot-command-save-btn {
margin-top: 15px;
}

97
tests/utils.test.ts Normal file
View file

@ -0,0 +1,97 @@
import * as Obsidian from 'obsidian';
import { getNotesFromPath, isFolderMatch } from '../src/utils';
describe('isFolderMatch', () => {
it('should return file from the folder name 1', async () => {
const match = isFolderMatch('test2/note3.md', 'test2');
expect(match).toEqual(true);
});
it('should return file from the folder name 2', async () => {
const match = isFolderMatch('test/test2/note1.md', 'test2');
expect(match).toEqual(true);
});
it('should return file from the folder name 3', async () => {
const match = isFolderMatch('test/test2/note1.md', 'test');
expect(match).toEqual(true);
});
it('should not return file from the folder name 1', async () => {
const match = isFolderMatch('test/test2/note1.md', 'tes');
expect(match).toEqual(false);
});
it('should return file from file name 1', async () => {
const match = isFolderMatch('test/test2/note1.md', 'note1.md');
expect(match).toEqual(true);
});
});
describe('Vault', () => {
it('should return all markdown files', async () => {
const vault = new Obsidian.Vault();
const files = await vault.getMarkdownFiles();
expect(files).toEqual([
{ path: 'test/test2/note1.md' },
{ path: 'test/note2.md' },
{ path: 'test2/note3.md' },
{ path: 'note4.md' },
]);
});
});
describe('getNotesFromPath', () => {
it('should return all markdown files', async () => {
const vault = new Obsidian.Vault();
const files = await getNotesFromPath(vault, '/');
expect(files).toEqual([
{ path: 'test/test2/note1.md' },
{ path: 'test/note2.md' },
{ path: 'test2/note3.md' },
{ path: 'note4.md' },
]);
});
it('should return filtered markdown files 1', async () => {
const vault = new Obsidian.Vault();
const files = await getNotesFromPath(vault, 'test2');
expect(files).toEqual([
{ path: 'test/test2/note1.md' },
{ path: 'test2/note3.md' },
]);
});
it('should return filtered markdown files 2', async () => {
const vault = new Obsidian.Vault();
const files = await getNotesFromPath(vault, 'test');
expect(files).toEqual([
{ path: 'test/test2/note1.md' },
{ path: 'test/note2.md' },
]);
});
it('should return filtered markdown files 3', async () => {
const vault = new Obsidian.Vault();
const files = await getNotesFromPath(vault, 'note4.md');
expect(files).toEqual([
{ path: 'note4.md' },
]);
});
it('should return filtered markdown files 4', async () => {
const vault = new Obsidian.Vault();
const files = await getNotesFromPath(vault, '/test');
expect(files).toEqual([
{ path: 'test/test2/note1.md' },
{ path: 'test/note2.md' },
]);
});
it('should not return markdown files', async () => {
const vault = new Obsidian.Vault();
const files = await getNotesFromPath(vault, '');
expect(files).toEqual([]);
});
});

View file

@ -16,7 +16,8 @@
"jsx": "react",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strictNullChecks": true,
"strictNullChecks": true,
"types": ["jest"],
"lib": [
"DOM",
"ES5",