Merge pull request #5 from ransurf/dev

v1
This commit is contained in:
ransurf 2023-12-04 21:55:57 -08:00 committed by GitHub
commit b194cadccd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 5822 additions and 1223 deletions

View File

@ -2,9 +2,7 @@
"root": true, "root": true,
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"env": { "node": true }, "env": { "node": true },
"plugins": [ "plugins": ["@typescript-eslint"],
"@typescript-eslint"
],
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/eslint-recommended",
@ -20,4 +18,4 @@
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off" "@typescript-eslint/no-empty-function": "off"
} }
} }

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 80,
"useTabs": false,
"singleQuote": true
}

View File

@ -1,96 +1,3 @@
# Obsidian Sample Plugin # Obsidian Intelligence
This is a sample plugin for Obsidian (https://obsidian.md). AI-powered assistants trained on your notes
This project uses Typescript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does.
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
## First time developing plugins?
Quick starting guide for new plugin devs:
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
## Releasing new releases
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Adding your plugin to the community plugin list
- Check https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## How to use
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
## Manually installing the plugin
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation
See https://github.com/obsidianmd/obsidian-api

View File

@ -1,43 +1,43 @@
import esbuild from "esbuild"; import esbuild from 'esbuild';
import process from "process"; import process from 'process';
import builtins from "builtin-modules"; import builtins from 'builtin-modules';
const banner = const banner = `/*
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin if you want to view the source, please visit the github repository of this plugin
*/ */
`; `;
const prod = (process.argv[2] === "production"); const prod = process.argv[2] === 'production';
const context = await esbuild.context({ const context = await esbuild.context({
banner: { banner: {
js: banner, js: banner,
}, },
entryPoints: ["main.ts"], entryPoints: ['main.ts'],
bundle: true, bundle: true,
external: [ external: [
"obsidian", 'obsidian',
"electron", 'electron',
"@codemirror/autocomplete", '@codemirror/autocomplete',
"@codemirror/collab", '@codemirror/collab',
"@codemirror/commands", '@codemirror/commands',
"@codemirror/language", '@codemirror/language',
"@codemirror/lint", '@codemirror/lint',
"@codemirror/search", '@codemirror/search',
"@codemirror/state", '@codemirror/state',
"@codemirror/view", '@codemirror/view',
"@lezer/common", '@lezer/common',
"@lezer/highlight", '@lezer/highlight',
"@lezer/lr", '@lezer/lr',
...builtins], ...builtins,
format: "cjs", ],
target: "es2018", format: 'cjs',
logLevel: "info", target: 'es2018',
sourcemap: prod ? false : "inline", logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true, treeShaking: true,
outfile: "main.js", outfile: 'main.js',
}); });
if (prod) { if (prod) {

151
main.ts
View File

@ -1,26 +1,39 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; import { App, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { AppView, OBSIDIAN_INTELLIGENCE_VIEW_TYPE } from './src/ui/AppView';
import OpenAI from 'openai';
import { IThread } from './src/ui/types';
// Remember to rename these classes and interfaces! interface ObsidianIntelligenceSettings {
openaiKey: string;
interface MyPluginSettings { threads: IThread[];
mySetting: string; activeThread: IThread | undefined;
activeAssistant: OpenAI.Beta.Assistant | undefined;
activeAssistantFiles: OpenAI.Files.FileObject[] | undefined;
} }
const DEFAULT_SETTINGS: MyPluginSettings = { const DEFAULT_SETTINGS: ObsidianIntelligenceSettings = {
mySetting: 'default' openaiKey: '',
} threads: [],
activeThread: undefined,
activeAssistant: undefined,
activeAssistantFiles: undefined,
};
export default class MyPlugin extends Plugin { export default class ObsidianIntelligence extends Plugin {
settings: MyPluginSettings; settings: ObsidianIntelligenceSettings;
view: AppView;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
this.registerView(OBSIDIAN_INTELLIGENCE_VIEW_TYPE, (leaf) => new AppView(leaf, this));
// This creates an icon in the left ribbon. const ribbonIconEl = this.addRibbonIcon(
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => { 'bot',
// Called when the user clicks the icon. 'Open Obsidian Intelligence',
new Notice('This is a notice!'); (evt: MouseEvent) => {
}); this.activateView();
},
);
// Perform additional things with the ribbon // Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class'); ribbonIconEl.addClass('my-plugin-ribbon-class');
@ -28,107 +41,73 @@ export default class MyPlugin extends Plugin {
const statusBarItemEl = this.addStatusBarItem(); const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text'); statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({ this.addCommand({
id: 'open-sample-modal-simple', id: 'obsidian-intelligence-view-open',
name: 'Open sample modal (simple)', name: 'Open Obsidian Intelligence',
callback: () => { callback: () => {
new SampleModal(this.app).open(); this.activateView();
} },
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
}); });
// This adds a settings tab so the user can configure various aspects of the plugin // This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this)); this.addSettingTab(new OISettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
} }
onunload() { onunload() {}
}
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
} }
async saveSettings() { async saveSettings() {
await this.saveData(this.settings); await this.saveData(this.settings);
} }
}
class SampleModal extends Modal { async activateView() {
constructor(app: App) { this.app.workspace.detachLeavesOfType(OBSIDIAN_INTELLIGENCE_VIEW_TYPE);
super(app);
await this.app.workspace.getRightLeaf(false).setViewState({
type: OBSIDIAN_INTELLIGENCE_VIEW_TYPE,
active: true,
});
this.revealView();
} }
async revealView() {
onOpen() { this.app.workspace.revealLeaf(
const {contentEl} = this; this.app.workspace.getLeavesOfType(OBSIDIAN_INTELLIGENCE_VIEW_TYPE)[0],
contentEl.setText('Woah!'); );
}
onClose() {
const {contentEl} = this;
contentEl.empty();
} }
} }
class SampleSettingTab extends PluginSettingTab { class OISettingTab extends PluginSettingTab {
plugin: MyPlugin; plugin: ObsidianIntelligence;
constructor(app: App, plugin: MyPlugin) { constructor(app: App, plugin: ObsidianIntelligence) {
super(app, plugin); super(app, plugin);
this.plugin = plugin; this.plugin = plugin;
} }
display(): void { display(): void {
const {containerEl} = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
new Setting(containerEl) new Setting(containerEl)
.setName('Setting #1') .setName('OpenAI Key')
.setDesc('It\'s a secret') .setDesc('Can find it https://platform.openai.com/api-keys')
.addText(text => text .addText((text) =>
.setPlaceholder('Enter your secret') text
.setValue(this.plugin.settings.mySetting) .setPlaceholder('Enter your API Key')
.setValue(this.plugin.settings.openaiKey)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.mySetting = value; this.plugin.settings.openaiKey = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
})); }),
);
} }
} }

View File

@ -1,11 +1,11 @@
{ {
"id": "sample-plugin", "id": "obsidian-intelligence",
"name": "Sample Plugin", "name": "Obsidian Intelligence",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.", "description": "OpenAI GPT Assistants functionality inside Obsidian",
"author": "Obsidian", "author": "John Mavrick",
"authorUrl": "https://obsidian.md", "authorUrl": "https://beacons.ai/johnmavrick",
"fundingUrl": "https://obsidian.md/pricing", "fundingUrl": "https://patreon.com/johnmavrick",
"isDesktopOnly": false "isDesktopOnly": false
} }

2947
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,13 @@
{ {
"name": "obsidian-sample-plugin", "name": "obsidian-intelligence",
"version": "1.0.0", "version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "AI-powered assistants inside Obsidian",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json",
"prettier": "prettier --write ."
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -18,10 +19,21 @@
"builtin-modules": "3.3.0", "builtin-modules": "3.3.0",
"esbuild": "0.17.3", "esbuild": "0.17.3",
"obsidian": "latest", "obsidian": "latest",
"prettier": "^3.1.0",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "typescript": "4.7.4"
}, },
"dependencies": { "dependencies": {
"openai": "^4.16.1" "@popperjs/core": "^2.11.8",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"lucide-react": "^0.292.0",
"openai": "^4.16.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-spinners": "^0.13.8",
"react-tooltip": "^5.23.0",
"yup": "^1.3.2"
} }
} }

3
src/files/testFile.md Normal file
View File

@ -0,0 +1,3 @@
# This is a test file
wow

85
src/ui/AppView.tsx Normal file
View File

@ -0,0 +1,85 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import * as React from 'react';
import { Root, createRoot } from 'react-dom/client';
import PluginView from './PluginView';
import { App } from 'obsidian';
import ObsidianIntelligence from '../../main';
import OpenAI from 'openai';
export const OBSIDIAN_INTELLIGENCE_VIEW_TYPE = 'obsidian-intelligence-view';
export const AppContext = React.createContext<App | undefined>(undefined);
export const PluginContext = React.createContext<
ObsidianIntelligence | undefined
>(undefined);
export const OpenAIContext = React.createContext<OpenAI | undefined>(undefined);
// export const CommandsContext = React.createContext<ICommandPayload | undefined>(undefined);
export const useApp = (): App | undefined => {
return React.useContext(AppContext);
};
export const usePlugin = (): ObsidianIntelligence | undefined => {
return React.useContext(PluginContext);
};
export const useOpenAI = (): OpenAI | undefined => {
return React.useContext(OpenAIContext);
};
// export const useCommands = (): ICommandPayload | undefined => {
// return React.useContext(CommandsContext);
// };
export class AppView extends ItemView {
root: Root | null = null;
plugin: ObsidianIntelligence;
openAI: OpenAI;
// commands: ICommandPayload | undefined;
constructor(leaf: WorkspaceLeaf, plugin: ObsidianIntelligence) {
super(leaf);
this.plugin = plugin;
const openaiKey = plugin.settings.openaiKey;
const openAIInstance = new OpenAI({
apiKey: openaiKey,
dangerouslyAllowBrowser: true,
});
this.openAI = openAIInstance;
// this.addCommands();
}
getViewType() {
return OBSIDIAN_INTELLIGENCE_VIEW_TYPE;
}
getDisplayText() {
return 'Obsidian Intelligence';
}
getIcon(): string {
return 'bot';
}
async onOpen() {
this.root = createRoot(this.containerEl.children[1]);
this.root.render(
<AppContext.Provider value={this.app}>
<PluginContext.Provider value={this.plugin}>
<OpenAIContext.Provider value={this.openAI}>
{/* <CommandsContext.Provider value={this.commands}> */}
<PluginView />
{/* </CommandsContext.Provider> */}
</OpenAIContext.Provider>
</PluginContext.Provider>
</AppContext.Provider>,
);
}
async onClose() {
this.root?.unmount();
}
}

329
src/ui/PluginView.tsx Normal file
View File

@ -0,0 +1,329 @@
import OpenAI from 'openai';
import React, { useEffect, useState, useCallback } from 'react';
import Chatbox from './components/Chatbox';
import { useOpenAI, usePlugin } from './AppView';
import MessageInput from './components/MessageInput';
// import FilesUploadUI from './components/FilesUploadUI';
import AssistantManager from './components/AssistantManager';
import { IThread, ThreadAnnotationFile } from './types';
import { createNotice } from '@/utils/Logs';
const listQueryOptions: OpenAI.Beta.Threads.MessageListParams = {
order: 'asc',
};
const PluginView = () => {
const plugin = usePlugin();
const openaiInstance = useOpenAI();
const [messages, setMessages] = useState<
OpenAI.Beta.Threads.ThreadMessage[]
>([]);
// const [files, setFiles] = useState<string[]>([]);
const [assistants, setAssistants] = useState<OpenAI.Beta.Assistant[]>([]);
const [threads, setThreads] = useState<IThread[]>([]);
const [activeAssistant, setActiveAssistant] = useState<
OpenAI.Beta.Assistant | undefined
>(undefined);
const [activeThread, setActiveThread] = useState<IThread | undefined>(
undefined,
);
const [activeAssistantFiles, setActiveAssistantFiles] = useState<
OpenAI.Files.FileObject[] | undefined
>(undefined);
const [isResponding, setIsResponding] = useState(false);
useEffect(() => {
fetchThreads();
if (assistants.length < 1) {
fetchAssistants();
fetchActiveConfiguration();
}
}, [plugin]);
useEffect(() => {
if (!activeAssistant) {
updateActiveAssistant(assistants?.[0]);
}
}, [assistants]);
useEffect(() => {
if (!activeThread) {
updateActiveThread(threads?.[0]);
}
}, [threads]);
const fetchAssistants = async () => {
if (!openaiInstance) {
return;
}
await openaiInstance.beta.assistants.list().then((res) => {
// sort by name
const sortedAssistants = res.data.sort((a, b) => {
if (a.name && b.name && a.name < b.name) {
return -1;
}
return 1;
});
setAssistants(sortedAssistants);
});
};
const fetchThreads = () => {
if (plugin) {
setThreads(plugin.settings.threads);
}
};
const fetchActiveConfiguration = () => {
if (plugin) {
const savedActiveAssistant = plugin.settings.activeAssistant;
if (savedActiveAssistant) {
updateActiveAssistant(savedActiveAssistant);
}
const savedActiveThread = plugin.settings.activeThread;
if (savedActiveThread) {
setActiveThread(savedActiveThread);
}
}
};
const updateActiveAssistant = async (assistant: OpenAI.Beta.Assistant) => {
if (plugin) {
plugin.settings.activeAssistant = assistant;
const assistantFiles = await fetchAssistantFiles();
updateActiveAssistantFiles(assistantFiles);
plugin.saveSettings();
setActiveAssistant(assistant);
}
};
const updateActiveAssistantFiles = (files: OpenAI.Files.FileObject[]) => {
if (plugin) {
plugin.settings.activeAssistantFiles = files;
plugin.saveSettings();
setActiveAssistantFiles(files);
}
};
const updateThreads = (threads: IThread[]) => {
if (plugin) {
plugin.settings.threads = threads;
plugin.saveSettings();
setThreads(threads);
}
};
const fetchAssistantFiles = async (): Promise<
OpenAI.Files.FileObject[] | []
> => {
if (!openaiInstance || !plugin || !activeAssistant) {
return [];
}
try {
const assistantFilesResponse =
await openaiInstance.beta.assistants.files.list(
activeAssistant.id,
);
const innerFiles: OpenAI.Files.FileObject[] = [];
await Promise.all(
assistantFilesResponse.data.map(async (file) => {
try {
const fileInfoResponse =
await openaiInstance.files.retrieve(file.id);
innerFiles.push(fileInfoResponse);
} catch (error) {
console.error(error);
}
}),
);
return innerFiles;
} catch (error) {
console.error(error);
return [];
}
};
const updateActiveThread = (thread: IThread) => {
if (plugin) {
plugin.settings.activeThread = thread;
plugin.saveSettings();
setActiveThread(thread);
}
};
const fetchMessages = async () => {
if (!openaiInstance || !activeThread) {
return;
}
try {
const messages = await openaiInstance.beta.threads.messages.list(
activeThread.id,
listQueryOptions,
);
setMessages(messages.data);
} catch (e) {
createNotice(
'Thread expired or not found. Please select a new thread.',
);
//remove thread from list
const newThreads = threads.filter((t) => t.id !== activeThread.id);
setThreads(newThreads);
//update active thread
setActiveThread(newThreads?.[0]);
//save settings
if (plugin) {
plugin.settings.threads = newThreads;
plugin.settings.activeThread = newThreads?.[0];
plugin.saveSettings();
}
}
};
useEffect(() => {
fetchMessages();
}, [activeThread]);
const addAnnotatedFilesToThread = async (
messages: OpenAI.Beta.Threads.ThreadMessage[],
) => {
const files: ThreadAnnotationFile[] = [];
//for each message in messages, if it has content.type = text, then access content.annotations.file_citation.file_id and get the file name from the active files list
messages.forEach((message) => {
if (message.content) {
message.content.forEach((content) => {
if (content.type === 'text') {
content.text.annotations.forEach((annotation) => {
// @ts-ignore
if (annotation.file_citation) {
const fileId: string =
// @ts-ignore
annotation.file_citation.file_id;
const file = activeAssistantFiles?.find(
(file) => file.id === fileId,
);
if (file) {
files.push({
fileId: fileId,
fileName: file.filename,
});
}
}
});
}
});
}
});
if (files.length > 0 && activeThread) {
const newThread: IThread = {
...activeThread,
metadata: {
...activeThread.metadata,
annotationFiles: files,
},
};
const newThreads = threads.map((t) =>
t.id === activeThread.id ? newThread : t,
);
updateThreads(newThreads);
updateActiveThread(newThread);
}
};
const onMessageSend = useCallback(
async (message: string) => {
if (openaiInstance && activeThread && activeAssistant) {
const messageObject =
await openaiInstance.beta.threads.messages.create(
activeThread.id,
{
role: 'user',
content: message,
},
);
setMessages([...messages, messageObject]);
const run = await openaiInstance.beta.threads.runs.create(
activeThread.id,
{
assistant_id: activeAssistant.id,
},
);
let runStatus = await openaiInstance.beta.threads.runs.retrieve(
activeThread.id,
run.id,
);
// Initialize a counter and max attempts for the polling logic, and how long to wait each try
let attempts = 0;
const maxAttempts = 30;
const timoutWaitTimeMs = 2000;
setIsResponding(true);
const terminatedRunStatuses: string[] = [
'cancelling',
'cancelled',
'failed',
'completed',
'expired',
];
while (
!terminatedRunStatuses.includes(runStatus.status) &&
attempts < maxAttempts
) {
await new Promise((resolve) =>
setTimeout(resolve, timoutWaitTimeMs),
);
runStatus = await openaiInstance.beta.threads.runs.retrieve(
activeThread.id,
run.id,
);
attempts++;
}
// Get latest messages
await openaiInstance.beta.threads.messages
.list(activeThread.id, listQueryOptions)
.then((res) => {
setMessages(res.data);
addAnnotatedFilesToThread(res.data);
setIsResponding(false);
});
}
},
[openaiInstance, activeThread, activeAssistant, messages],
);
return (
<div className="agent-view-container">
<AssistantManager
assistants={assistants}
updateAssistants={setAssistants}
activeAssistant={activeAssistant}
updateActiveAssistant={updateActiveAssistant}
threads={threads}
updateThreads={updateThreads}
activeThread={activeThread}
updateActiveThread={updateActiveThread}
/>
<Chatbox
messages={messages}
isResponding={isResponding}
annotationFiles={activeThread?.metadata?.annotationFiles}
/>
<MessageInput onMessageSend={onMessageSend} />
{/* <FilesUploadUI files={files} onAddFile={onAddFile} onRemoveFile={onRemoveFile} /> */}
</div>
);
};
export default PluginView;

View File

@ -0,0 +1,536 @@
import React, { useEffect, useMemo } from 'react';
import OpenAI from 'openai';
import { OBSIDIAN_INTELLIGENCE_VIEW_TYPE, useApp, useOpenAI, usePlugin } from '../AppView';
import { IThread } from '../types';
import DropdownSelect from './DropdownSelect';
import { MarkdownView } from 'obsidian';
import { Bot, MessageSquare, Plus, Pencil, Trash2 } from 'lucide-react';
import {
AssistantEditModal,
IAssistantEditModalValues,
} from './modals/AssistantEditModal';
import {} from './modals/AssistantEditModal';
import {
ThreadEditModal,
IThreadEditModalValues,
} from './modals/ThreadEditModal';
import { createNotice } from '@/utils/Logs';
import { defaultAssistantInstructions } from '@/utils/templates';
interface AssistantManagerProps {
assistants: OpenAI.Beta.Assistant[];
updateAssistants: (assistants: OpenAI.Beta.Assistant[]) => void;
threads: IThread[];
updateThreads: (threads: IThread[]) => void;
activeAssistant: OpenAI.Beta.Assistant | undefined;
updateActiveAssistant: (assistant: OpenAI.Beta.Assistant) => void;
activeThread: IThread | undefined;
updateActiveThread: (thread: IThread) => void;
}
const AssistantManager = ({
assistants,
updateAssistants,
threads,
updateThreads,
activeAssistant,
activeThread,
updateActiveAssistant,
updateActiveThread,
}: AssistantManagerProps) => {
const app = useApp();
const plugin = usePlugin();
const openaiInstance = useOpenAI();
useEffect(() => {
if (!plugin || !app) {
return;
}
plugin.addCommand({
id: 'create-assistant-from-active-note',
name: 'Create Assistant from Active Note',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView =
app.workspace.getActiveViewOfType(MarkdownView);
const openFile = markdownView?.file;
if (openFile) {
if (!checking) {
// const links = app.metadataCache.getFileCache(openFile)?.links?.map(
// (link) => addFileType(truncateLink(link.link))
// ) || [];
const links = Object.keys(
app.metadataCache.resolvedLinks[openFile.path],
);
const backlinks = Object.keys(
//@ts-ignore
app.metadataCache.getBacklinksForFile(openFile)
.data,
).map((file) => file);
const currentFile = openFile.path;
const filesToUpload = new Set([
currentFile,
...links,
...backlinks,
]);
handleCreateAssistant({
name: `${openFile.path} Assistant`,
instructions: defaultAssistantInstructions,
files: Array.from(filesToUpload)
.filter((file) => file.endsWith('.md'))
.map((file) => ({
filename: file,
})),
});
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
},
});
plugin.addCommand({
id: 'create-assistant-from-active-note',
name: 'Create Thread',
callback: async () => {
const isViewOpen = app.workspace.getLeavesOfType(OBSIDIAN_INTELLIGENCE_VIEW_TYPE).some((leaf) => {
return leaf.view;
});
if (!isViewOpen) {
plugin.activateView();
}
plugin.revealView();
createThread();
}
});
}, [plugin]);
useEffect(() => {
console.log('threads update', threads);
}, [threads]);
const createThread = async () => {
if (!openaiInstance || !plugin) {
return;
}
const newThreadName = 'New Thread';
await openaiInstance.beta.threads
.create({
metadata: {
name: newThreadName,
},
})
.then((res) => {
const newThread = {
...res,
metadata: {
name: newThreadName,
},
};
updateThreads([...plugin.settings.threads, newThread]);
updateActiveThread(newThread);
});
};
const editThread = async (values: IThreadEditModalValues) => {
if (!openaiInstance || !app || !activeThread) {
return;
}
const newThread = {
...activeThread,
metadata: {
name: values.metadata.name,
},
};
await openaiInstance.beta.threads
.update(activeThread.id, {
metadata: {
name: values.metadata.name,
},
})
.then((res) => {
updateThreads(
threads.map((thread) => {
if (thread.id === activeThread.id) {
return newThread;
}
return thread;
}),
);
updateActiveThread(newThread);
});
};
const handleEditThread = async () => {
if (!openaiInstance || !app || !activeThread) {
return;
}
// Get the previous values of the thread
const previousValues = {
metadata: {
name: activeThread.metadata.name || '',
},
};
new ThreadEditModal({
app,
title: 'Edit Thread',
submitButtonText: 'Edit',
previousValues,
onSubmit: editThread,
}).open();
};
const deleteThread = async () => {
if (!openaiInstance || !plugin || !activeThread) {
return;
}
await openaiInstance.beta.threads
.del(activeThread.id)
.then((res) => {
const newThreadsList = threads.filter(
(thread) => thread.id !== activeThread.id,
);
updateThreads(newThreadsList);
updateActiveThread(newThreadsList?.[0]);
plugin.saveSettings();
})
.catch((error) => {
console.error(error);
});
};
const createAssistant = async (values: IAssistantEditModalValues) => {
if (!openaiInstance || !app) {
return;
}
// use uploadFileToOpenAI to upload files to openai
const uploadedFiles: string[] = [];
await Promise.all(
values.files.map(async (file) => {
if (file.filename) {
const uploadedFile = await uploadFileToOpenAI(
file?.filename,
);
if (uploadedFile) {
uploadedFiles.push(uploadedFile.id);
}
}
}),
);
await openaiInstance.beta.assistants
.create({
name: values.name,
description: values.description,
instructions: values.instructions,
tools: [{ type: 'code_interpreter' }, { type: 'retrieval' }],
model: 'gpt-4-1106-preview',
file_ids: uploadedFiles,
})
.then((res) => {
createNotice(`Assistant "${values.name}" created`);
updateAssistants([...assistants, res]);
updateActiveAssistant(res);
});
};
const handleCreateAssistant = async (
assistant?: IAssistantEditModalValues,
) => {
if (!openaiInstance || !app) {
return;
}
// const assistant = await openaiInstance.beta.assistants.create({
// name: "Math Tutor",
// instructions:
// "You are a personal math tutor. Write and run code to answer math questions.",
// tools: [{ type: "code_interpreter" }],
// model: "gpt-4-1106-preview",
// });
// setActiveAssistant(assistant);
new AssistantEditModal({
app,
title: 'Create New Assistant',
submitButtonText: 'Create',
previousValues: assistant,
onSubmit: createAssistant,
}).open();
};
const editAssistant = async (values: IAssistantEditModalValues) => {
if (!openaiInstance || !app || !activeAssistant) {
return;
}
// Check each values.files element to see if there is an id. If there is, then it is already uploaded to openai and we don't need to upload it again.
const filesToUpload: string[] = [];
const assistantFiles: string[] = [];
values.files.forEach((file) => {
if (file.id) {
assistantFiles.push(file.id);
} else if (file.filename) {
filesToUpload.push(file.filename);
}
});
await Promise.all(
filesToUpload.map(async (file) => {
const uploadedFile = await uploadFileToOpenAI(file);
if (uploadedFile) {
assistantFiles.push(uploadedFile.id);
}
}),
);
await openaiInstance.beta.assistants
.update(activeAssistant.id, {
name: values.name,
description: values.description,
instructions: values.instructions,
file_ids: assistantFiles,
})
.then((res) => {
updateActiveAssistant(res);
updateAssistants(
assistants.map((assistant) => {
if (assistant.id === activeAssistant.id) {
return res;
}
return assistant;
}),
);
});
};
const handleEditAssistant = async () => {
if (!openaiInstance || !app || !activeAssistant) {
return;
}
const files = await getAssistantFiles();
const previousValues = {
name: activeAssistant.name || '',
description: activeAssistant.description || '',
instructions: activeAssistant.instructions || '',
files: files || [],
};
new AssistantEditModal({
app,
title: 'Edit Assistant',
submitButtonText: 'Edit',
previousValues,
onSubmit: editAssistant,
}).open();
};
const deleteAssistant = async () => {
if (!openaiInstance || !activeAssistant) {
return;
}
await openaiInstance.beta.assistants
.del(activeAssistant.id)
.then((res) => {
const newAssistantsList = assistants.filter(
(assistant) => assistant.id !== activeAssistant.id,
);
updateAssistants(newAssistantsList);
updateActiveAssistant(newAssistantsList?.[0]);
});
};
const uploadFileToOpenAI = async (
fileName: string,
): Promise<OpenAI.Files.FileObject | undefined> => {
if (!openaiInstance || !plugin || !app) {
return undefined;
}
const file = await app.vault.adapter.read(fileName);
const blob = new File([file], fileName, { type: 'text/markdown' });
const returnedFile = await openaiInstance.files
.create({
purpose: 'assistants',
file: blob,
})
.then((res) => {
return res;
})
.catch((error) => {
console.error(error);
return undefined;
});
return returnedFile;
};
// const attachFileToAssistant = async (assistantId: string, fileId: string) => {
// //file id is file-CFBYJh1WUxRWdAtdaScVZHS7
// //assistant id is asst_0HHHYCL2dgImUlXbZKDRjac0
// if (!openaiInstance || !plugin) {
// return;
// }
// await openaiInstance.beta.assistants.files.create(
// assistantId,
// {
// file_id: fileId,
// }
// ).then((res) => {
// // Handle the response
// }).catch((error) => {
// // Handle the error
// console.error(error);
// });
// };
const getAssistantFiles = async (): Promise<
OpenAI.Files.FileObject[] | []
> => {
if (!openaiInstance || !plugin || !activeAssistant) {
return [];
}
try {
const assistantFilesResponse =
await openaiInstance.beta.assistants.files.list(
activeAssistant?.id,
);
const innerFiles: OpenAI.Files.FileObject[] = [];
await Promise.all(
assistantFilesResponse.data.map(async (file) => {
try {
const fileInfoResponse =
await openaiInstance.files.retrieve(file.id);
innerFiles.push(fileInfoResponse);
} catch (error) {
console.error(error);
}
}),
);
return innerFiles;
} catch (error) {
console.error(error);
return [];
}
};
//format assistants into ISelectOption
const assistantOptions = useMemo(() => {
return assistants.map((assistant) => {
return {
label: assistant.name || '',
value: assistant.id,
};
});
}, [assistants]);
const threadOptions = useMemo(() => {
return threads.map((thread) => {
return {
label: thread.metadata.name,
value: thread.id,
};
});
}, [threads]);
const onUpdateActiveAssistant = (assistantId: string) => {
if (!openaiInstance || !activeAssistant) {
return;
}
const assistant = assistants.find(
(assistant) => assistant.id === assistantId,
);
updateActiveAssistant(assistant ?? assistants?.[0]);
};
const onUpdateActiveThread = (threadId: string) => {
if (!openaiInstance || !activeThread) {
return;
}
const newActiveThread = threads.find(
(thread) => thread.id === threadId,
);
updateActiveThread(newActiveThread ?? activeThread);
};
return (
<div className="chat-top-section-container">
<div className="dropdowns-container">
<div className="dropdown-container">
<Bot size={16} />
<DropdownSelect
items={assistantOptions}
onChange={onUpdateActiveAssistant}
activeItem={activeAssistant?.id || ''}
/>
<div className="dropdown-buttons-container">
<button className="create" onClick={deleteAssistant}>
<Trash2 size={16} />
</button>
<button
className="create"
onClick={handleEditAssistant}
>
<Pencil size={16} />
</button>
<button
className="create"
onClick={() => handleCreateAssistant()}
>
<Plus size={16} />
</button>
</div>
</div>
<div className="dropdown-container">
<MessageSquare size={16} />
<DropdownSelect
items={threadOptions}
onChange={onUpdateActiveThread}
activeItem={activeThread?.id || ''}
/>
<div className="dropdown-buttons-container">
<button className="create" onClick={deleteThread}>
<Trash2 size={16} />
</button>
<button className="create" onClick={handleEditThread}>
<Pencil size={16} />
</button>
<button className="create" onClick={createThread}>
<Plus size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default AssistantManager;

View File

@ -0,0 +1,235 @@
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
import OpenAI from 'openai';
import { ChevronDown, ClipboardCopy } from 'lucide-react';
import BeatLoader from 'react-spinners/BeatLoader';
import { Tooltip } from 'react-tooltip';
import Markdown from 'react-markdown';
import { useApp } from '../AppView';
import { createNotice } from '@/utils/Logs';
import { TFile } from 'obsidian';
import { ThreadAnnotationFile } from '../types';
const override: CSSProperties = {
display: 'block',
margin: '0 auto',
borderColor: 'white',
};
interface ChatboxProps {
messages: OpenAI.Beta.Threads.ThreadMessage[];
isResponding: boolean;
annotationFiles?: ThreadAnnotationFile[];
}
const Chatbox = ({ annotationFiles, isResponding, messages }: ChatboxProps) => {
const app = useApp();
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
checkScrollButtonVisibility();
}
const handleScroll = () => {
checkScrollButtonVisibility();
};
if (messagesContainerRef.current) {
scrollToBottom();
messagesContainerRef.current.addEventListener(
'scroll',
handleScroll,
);
}
return () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.removeEventListener(
'scroll',
handleScroll,
);
}
};
}, [isResponding, messagesContainerRef.current]);
useEffect(() => {
// scroll to bottom when switching to different thread
if (showScrollButton) {
scrollToBottom();
setShowScrollButton(false);
}
}, [messages]);
const getGroupMessages = () =>
messages.map((message, index) => (
<div key={index} className={`chat-message ${message.role}`}>
{message.content.map((content, index) => {
if (content.type === 'text') {
const getMessageText = () => {
const annotationsTexts =
content.text.annotations.map(
(annotation: any) => annotation.text,
);
let text = content.text.value;
annotationsTexts.forEach(
(annotationText: string, index: number) => {
const annotationIndex = index;
const regex = new RegExp(
annotationText,
'g',
);
text = text.replace(
regex,
`[^${annotationIndex}]`,
);
},
);
return text;
};
const renderAnnotation = (
annotation: any,
index: number,
) => {
const { file_citation } = annotation;
const fileId = file_citation?.file_id;
const fileName = annotationFiles?.find(
(file) => file.fileId === fileId,
)?.fileName;
let quote = file_citation?.quote;
// Check if quote has list markdown syntax
if (quote && quote.includes('- ')) {
quote = quote.replace(/- /g, '\n- ');
}
const handleAnnotationClick = () => {
// open new tab and then navigate to fil
if (app && fileName) {
const file =
app.vault.getAbstractFileByPath(
fileName,
);
if (file && file instanceof TFile) {
app.workspace.getLeaf().openFile(file);
}
// add leaf to parent
}
};
return (
<div key={index}>
<a
className="annotation"
data-tooltip-id={`tooltip-${index}`}
onClick={handleAnnotationClick}
>
[^{index}]
</a>
<Tooltip id={`tooltip-${index}`}>
<div className="annotation-tooltip-container">
<strong>{fileName}</strong>
<Markdown>{quote}</Markdown>
</div>
</Tooltip>
</div>
);
};
const renderContent = () => {
const text = getMessageText();
const copyText = () => {
navigator.clipboard.writeText(text);
createNotice('Copied to clipboard!', 2000);
};
return (
<div key={index} className="message-content">
{
<Markdown className="message-text">
{text}
</Markdown>
}
<div className="message-footer">
{message.role === 'assistant' && (
<div className="copy-icon-container">
<ClipboardCopy
className="copy-icon"
color={'#ffffff'}
size={16}
onClick={copyText}
/>
</div>
)}
{content.text.annotations.map(
(annotation: any, index: number) =>
renderAnnotation(
annotation,
index,
),
)}
</div>
</div>
);
};
return (
<div key={index} className="message-content">
{renderContent()}
</div>
);
}
})}
</div>
));
const scrollToBottom = () => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
checkScrollButtonVisibility();
}
};
const checkScrollButtonVisibility = () => {
if (messagesContainerRef.current) {
const containerHeight = messagesContainerRef.current.offsetHeight;
const scrollHeight = messagesContainerRef.current.scrollHeight;
const scrollTop = messagesContainerRef.current.scrollTop;
const gap = scrollHeight - scrollTop - containerHeight;
setShowScrollButton(gap > containerHeight);
}
};
return (
<div className="chatbox-container">
<div ref={messagesContainerRef} className="messages-container">
{getGroupMessages()}
{isResponding && (
<div className="loader-container">
<BeatLoader
color="#ffffff"
loading={true}
cssOverride={override}
size={12}
/>
</div>
)}
</div>
{showScrollButton && (
<button
className="scroll-to-bottom-button"
onClick={scrollToBottom}
>
<ChevronDown size={16} />
</button>
)}
</div>
);
};
export default Chatbox;

View File

@ -0,0 +1,43 @@
import React, { useEffect, useRef } from 'react';
import { DropdownComponent } from 'obsidian';
interface ISelectOption {
label: string;
value: string;
}
interface DropdownSelectProps {
items: ISelectOption[];
onChange: (item: string) => void;
activeItem: string;
}
const DropdownSelect: React.FC<DropdownSelectProps> = ({
items,
onChange,
activeItem,
}) => {
const selectElementRef = useRef<HTMLSelectElement>(null);
useEffect(() => {
if (selectElementRef.current) {
new DropdownComponent(selectElementRef.current);
}
}, [selectElementRef.current]);
return (
<select
ref={selectElementRef}
onChange={(e) => onChange(e.target.value)}
value={activeItem}
className={'dropdown-select'}
>
{items.map((item) => (
<option className="" key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
);
};
export default DropdownSelect;

View File

@ -0,0 +1,104 @@
import React, { useState, useEffect, useRef } from "react";
import { useApp } from "../AppView";
import { FileSuggest } from "./suggesters/FileSuggester";
import { ClipboardCopy, ChevronDown, ChevronRight, Trash } from "lucide-react";
interface FilesUploadUIProps {
files: string[];
onAddFile: (file: string) => void;
onRemoveFile: (file: string) => void;
}
const FilesUploadUI = ({
files,
onAddFile,
onRemoveFile,
}: FilesUploadUIProps) => {
const [isExpanded, setIsExpanded] = useState(true);
const [newFile, setNewFile] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const app = useApp();
if (!app) {
return null;
}
const copyTitle = (file: string) => {
navigator.clipboard.writeText(`[[${file}]]`);
};
useEffect(() => {
if (inputRef.current) {
new FileSuggest(app, inputRef.current, handleAddFile);
}
}, [inputRef.current]);
const handleAddFile = (file: string) => {
onAddFile(file);
setNewFile("");
};
return (
<div className="collapsible-container">
<div
className="collapsible-header"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="arrow">
{isExpanded ? (
<ChevronRight size={16} />
) : (
<ChevronDown size={16} />
)}
</span>
<span className="label">Uploaded Files</span>
</div>
{isExpanded && (
<div className="collapsible-content">
<div className="file-input-container">
<input
ref={inputRef}
type="text"
placeholder="Add a file..."
value={newFile}
onChange={(e) => setNewFile(e.target.value)}
/>
{/* <button onClick={() => handleAddFile(newFile)}>
<Plus size={16} />
</button> */}
</div>
<div className="files-list">
{files.map(
(file, index) => (
,
(
<div key={index} className="file-row">
<span>{file}</span>
<div className="button-group">
<button
onClick={() => copyTitle(file)}
>
<ClipboardCopy size={16} />
</button>
<button
onClick={() =>
onRemoveFile(file)
}
>
<Trash size={16} />
</button>
</div>
</div>
)
)
)}
</div>
</div>
)}
</div>
);
};
export default FilesUploadUI;

View File

@ -0,0 +1,31 @@
import { SendHorizontal } from 'lucide-react';
import React, { useState } from 'react';
interface MessageInputProps {
onMessageSend: (message: string) => void;
}
const MessageInput: React.FC<MessageInputProps> = ({ onMessageSend }) => {
const [newMessage, setNewMessage] = useState('');
const handleSend = () => {
onMessageSend(newMessage);
setNewMessage('');
};
return (
<div className="message-input container">
<textarea
className="message-input input"
placeholder="Type your message here..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<button className="message-input send" onClick={handleSend}>
<SendHorizontal size={16} />
</button>
</div>
);
};
export default MessageInput;

View File

@ -0,0 +1,221 @@
import { App, ButtonComponent, Modal, Notice, Setting } from 'obsidian';
import OpenAI from 'openai';
import { FileSuggest } from '../suggesters/FileSuggester';
import * as yup from 'yup';
import { defaultAssistantInstructions } from '../../../utils/templates';
interface AssistantEditModalProps {
app: App;
title: string;
submitButtonText?: string;
previousValues?: IAssistantEditModalValues;
onSubmit: (values: IAssistantEditModalValues) => void;
}
export interface IAssistantEditModalValues {
name: string;
description?: string;
instructions: string;
files: Partial<OpenAI.Files.FileObject>[];
[key: string]: unknown;
}
export class AssistantEditModal extends Modal {
private values: IAssistantEditModalValues = {
name: '',
description: '',
instructions: defaultAssistantInstructions,
files: [],
};
private title: string;
private submitButtonText: string;
private onSubmit: (values: IAssistantEditModalValues) => void;
private fileListDiv: HTMLElement;
private fileCountText: HTMLElement;
constructor(props: AssistantEditModalProps) {
super(props.app);
this.title = props.title;
this.submitButtonText = props.submitButtonText || 'Submit';
if (props.previousValues) {
this.values = props.previousValues;
}
this.onSubmit = props.onSubmit;
this.display();
}
display() {
const { contentEl } = this;
contentEl.createEl('h1', { text: this.title });
this.addNameSetting(contentEl);
this.addDescriptionSetting(contentEl);
this.addInstructionsSetting(contentEl);
this.addFileIdsSetting(contentEl);
this.addSubmitButton(contentEl);
}
addNameSetting(contentEl: HTMLElement) {
new Setting(contentEl)
.setName('Name (required)')
.setDesc('The name of the assistant')
.addText((text) => {
text.setPlaceholder('Enter name...')
.onChange((value) => {
this.values.name = value;
})
.setValue(this.values.name);
});
}
addDescriptionSetting(contentEl: HTMLElement) {
new Setting(contentEl)
.setName('Description')
.setDesc('The description of the assistant')
.setClass('form-setting-textarea')
.addTextArea((text) => {
text.setPlaceholder('Enter description...')
.onChange((value) => {
this.values.description = value;
})
.setValue(this.values.description || '');
});
}
addInstructionsSetting(contentEl: HTMLElement) {
new Setting(contentEl)
.setName('Instructions (required)')
.setDesc('The instructions you want the assistant to follow (free by courtesy of OpenAI until 01/12/2024')
.setClass('form-setting-textarea')
.addTextArea((text) => {
text.setPlaceholder('Enter instructions...')
.onChange((value) => {
this.values.instructions = value;
})
.setValue(this.values.instructions);
});
}
addFileIdsSetting(contentEl: HTMLElement) {
// Function to add a file to the list
const addFileToList = (fileName: string) => {
// if filename already is in values, replace it. this prevents duplicates and allows for re-uploading
const fileIndex: number =
this.values.files &&
this.values.files.findIndex(
(file) => file.filename === fileName,
);
if (fileIndex !== -1) {
this.values.files[fileIndex] = {
filename: fileName,
};
return;
}
this.values.files.push({
filename: fileName,
});
updateFileCountText();
createFileListElement(fileName);
};
const updateFileCountText = () => {
this.fileCountText.setText(
`Files Uploaded (${this.values.files.length}/20)`,
);
};
const createFileListElement = (fileName: string) => {
const fileDiv = this.fileListDiv.createDiv({ cls: 'file-item' });
fileDiv.createEl('span', { text: fileName });
new ButtonComponent(fileDiv)
.setIcon('trash-2')
.setClass('remove-button')
.onClick(() => {
fileDiv.remove();
// Remove the file from the values object
const file = this.values.files.find(
(file) => file.filename === fileName,
);
if (file) {
this.values.files.remove(file);
updateFileCountText();
}
});
};
new Setting(contentEl)
.setName(`Files`)
.setDesc(
'The files uploaded to the assistant (not real-time synced, so you will need to re-upload). Max 20.',
)
.addSearch((search) => {
search.setPlaceholder('Enter file IDs separated by commas...');
// .onChange((value) => {
// this.values.file_ids.push(value);
// });
new FileSuggest(this.app, search.inputEl, addFileToList);
});
// Add a div to hold the list of selected files
this.fileCountText = contentEl.createEl('h6');
updateFileCountText();
this.fileListDiv = contentEl.createDiv({ cls: 'file-list' });
// Add the files that were already selected
this.values.files.forEach((file) => {
if (file.filename) {
createFileListElement(file.filename);
}
});
}
addSubmitButton(contentEl: HTMLElement) {
const validationSchema = yup.object().shape({
name: yup.string().required('Name is required'),
instructions: yup.string().required('Instructions are required'),
files: yup.array().max(20, 'Files cannot exceed 20'),
});
const checkRequiredFields = async (): Promise<string[]> => {
try {
await validationSchema.validate(this.values, {
abortEarly: false,
});
return [];
} catch (error) {
if (error instanceof yup.ValidationError) {
return error.inner.map((err) => err.message) as string[];
}
throw error;
}
};
const handleSubmit = async () => {
const missingFields = await checkRequiredFields();
if (missingFields.length > 0) {
new Notice(`Submit Error: \n${missingFields.join('\n')}`);
return;
}
this.onSubmit(this.values);
this.onClose();
this.close();
};
const modalFooterEl = contentEl.createDiv({ cls: 'modal-footer' });
new ButtonComponent(modalFooterEl)
.setButtonText(this.submitButtonText)
.setClass('form-submit-button')
.onClick(() => {
handleSubmit();
});
}
onClose() {}
}

View File

@ -0,0 +1,98 @@
import { App, ButtonComponent, Modal, Notice, Setting } from 'obsidian';
import * as yup from 'yup';
interface ThreadEditModalProps {
app: App;
title: string;
submitButtonText?: string;
previousValues?: IThreadEditModalValues;
onSubmit: (values: IThreadEditModalValues) => void;
}
export interface IThreadEditModalValues {
metadata: {
name: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export class ThreadEditModal extends Modal {
private values: IThreadEditModalValues = {
metadata: {
name: '',
},
};
private title: string;
private submitButtonText: string;
private onSubmit: (values: IThreadEditModalValues) => void;
constructor(props: ThreadEditModalProps) {
super(props.app);
this.title = props.title;
this.submitButtonText = props.submitButtonText || 'Submit';
if (props.previousValues) {
this.values = props.previousValues;
}
this.onSubmit = props.onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('h1', { text: this.title });
// Create name input
new Setting(contentEl)
.setName('Name')
.setDesc('The name of the thread')
.addText((text) => {
text.setPlaceholder('Enter thread name...')
.onChange((value) => {
this.values.metadata.name = value;
})
.setValue(this.values.metadata.name);
});
const validationSchema = yup.object().shape({
metadata: yup.object().shape({
name: yup.string().required('Name is required'),
}),
});
const checkRequiredFields = async (): Promise<string[]> => {
try {
await validationSchema.validate(this.values, {
abortEarly: false,
});
return [];
} catch (error) {
if (error instanceof yup.ValidationError) {
return error.inner.map((err) => err.message) as string[];
}
throw error;
}
};
const handleSubmit = async () => {
const missingFields = await checkRequiredFields();
if (missingFields.length > 0) {
new Notice(`Submit Error: \n${missingFields.join('\n')}`);
return;
}
this.onSubmit(this.values);
this.onClose();
this.close();
};
const modalFooterEl = contentEl.createDiv({ cls: 'modal-footer' });
new ButtonComponent(modalFooterEl)
.setButtonText(this.submitButtonText)
.setClass('form-submit-button')
.onClick(() => {
handleSubmit();
});
}
onClose() {}
}

View File

@ -0,0 +1,50 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { App, TAbstractFile, TFile } from 'obsidian';
import { TextInputSuggest } from './suggest';
import { get_tfiles_from_folder } from '@/utils/utils';
export class FileSuggest extends TextInputSuggest<TFile> {
private onSelect: (file: string) => void;
constructor(
app: App,
public inputEl: HTMLInputElement,
onSelect: (file: string) => void,
) {
super(app, inputEl);
this.onSelect = onSelect;
}
getSuggestions(input_str: string): TFile[] {
//TODO: allow other file types
const all_files = get_tfiles_from_folder('', 'md');
if (!all_files) {
return [];
}
const files: TFile[] = [];
const lower_input_str = input_str.toLowerCase();
all_files.forEach((file: TAbstractFile) => {
if (
file instanceof TFile &&
file.extension === 'md' &&
file.path.toLowerCase().contains(lower_input_str)
) {
files.push(file);
}
});
return files;
}
renderSuggestion(file: TFile, el: HTMLElement): void {
el.setText(file.path);
}
selectSuggestion(file: TFile): void {
this.onSelect(file.path);
this.close();
}
}

View File

@ -0,0 +1,204 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { ISuggestOwner, Scope } from 'obsidian';
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
import { App } from 'obsidian';
const wrapAround = (value: number, size: number): number => {
return ((value % size) + size) % size;
};
class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLDivElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(
owner: ISuggestOwner<T>,
containerEl: HTMLElement,
scope: Scope,
) {
this.owner = owner;
this.containerEl = containerEl;
containerEl.on(
'click',
'.suggestion-item',
this.onSuggestionClick.bind(this),
);
containerEl.on(
'mousemove',
'.suggestion-item',
this.onSuggestionMouseover.bind(this),
);
scope.register([], 'ArrowUp', (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem - 1, true);
return false;
}
});
scope.register([], 'ArrowDown', (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem + 1, true);
return false;
}
});
scope.register([], 'Enter', (event) => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
});
}
onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {
event.preventDefault();
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
this.useSelectedItem(event);
}
onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
}
setSuggestions(values: T[]) {
this.containerEl.empty();
const suggestionEls: HTMLDivElement[] = [];
values.forEach((value) => {
const suggestionEl = this.containerEl.createDiv('suggestion-item');
this.owner.renderSuggestion(value, suggestionEl);
suggestionEls.push(suggestionEl);
});
this.values = values;
this.suggestions = suggestionEls;
this.setSelectedItem(0, false);
}
useSelectedItem(event: MouseEvent | KeyboardEvent) {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
const normalizedIndex = wrapAround(
selectedIndex,
this.suggestions.length,
);
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex];
prevSelectedSuggestion?.removeClass('is-selected');
selectedSuggestion?.addClass('is-selected');
this.selectedItem = normalizedIndex;
if (scrollIntoView) {
selectedSuggestion.scrollIntoView(false);
}
}
}
export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
protected inputEl: HTMLInputElement | HTMLTextAreaElement;
private popper: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
private app: App;
constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) {
this.app = app;
this.inputEl = inputEl;
this.scope = new Scope();
this.suggestEl = createDiv('suggestion-container');
const suggestion = this.suggestEl.createDiv('suggestion');
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], 'Escape', this.close.bind(this));
this.inputEl.addEventListener('input', this.onInputChanged.bind(this));
this.inputEl.addEventListener('focus', this.onInputChanged.bind(this));
this.inputEl.addEventListener('blur', this.close.bind(this));
this.suggestEl.on(
'mousedown',
'.suggestion-container',
(event: MouseEvent) => {
event.preventDefault();
},
);
}
onInputChanged(): void {
const inputStr = this.inputEl.value;
const suggestions = this.getSuggestions(inputStr);
if (!suggestions) {
this.close();
return;
}
if (suggestions.length > 0) {
this.suggest.setSuggestions(suggestions);
// @ts-ignore
this.open(this.app.dom.appContainerEl, this.inputEl);
} else {
this.close();
}
}
open(container: HTMLElement, inputEl: HTMLElement): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.app.keymap.pushScope(this.scope);
container.appendChild(this.suggestEl);
this.popper = createPopper(inputEl, this.suggestEl, {
placement: 'bottom-start',
modifiers: [
{
name: 'sameWidth',
enabled: true,
fn: ({ state, instance }) => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: 'beforeWrite',
requires: ['computeStyles'],
},
],
});
}
close(): void {
app.keymap.popScope(this.scope);
this.suggest.setSuggestions([]);
if (this.popper) this.popper.destroy();
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}

12
src/ui/types.ts Normal file
View File

@ -0,0 +1,12 @@
import OpenAI from 'openai';
export interface IThread extends OpenAI.Beta.Thread {
metadata: {
name: string;
annotationFiles?: ThreadAnnotationFile[];
};
}
export interface ThreadAnnotationFile {
fileName: string;
fileId: string;
}

5
src/utils/Logs.ts Normal file
View File

@ -0,0 +1,5 @@
import { Notice } from 'obsidian';
export const createNotice = (message: string, timeout = 5000): void => {
new Notice(`Obsidian Intelligence: ${message}`, timeout);
};

37
src/utils/error.ts Normal file
View File

@ -0,0 +1,37 @@
import { log_error } from './log';
export class TemplaterError extends Error {
constructor(
msg: string,
public console_msg?: string,
) {
super(msg);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export async function errorWrapper<T>(
fn: () => Promise<T>,
msg: string,
): Promise<T> {
try {
return await fn();
} catch (e) {
if (!(e instanceof TemplaterError)) {
log_error(new TemplaterError(msg, e.message));
} else {
log_error(e);
}
return null as T;
}
}
export function errorWrapperSync<T>(fn: () => T, msg: string): T {
try {
return fn();
} catch (e) {
log_error(new TemplaterError(msg, e.message));
return null as T;
}
}

22
src/utils/log.ts Normal file
View File

@ -0,0 +1,22 @@
import { Notice } from 'obsidian';
import { TemplaterError } from './error';
export function log_update(msg: string): void {
const notice = new Notice('', 15000);
// TODO: Find better way for this
// @ts-ignore
notice.noticeEl.innerHTML = `<b>obsidian-agents update</b>:<br/>${msg}`;
}
export function log_error(e: Error | TemplaterError): void {
const notice = new Notice('', 8000);
if (e instanceof TemplaterError && e.console_msg) {
// TODO: Find a better way for this
// @ts-ignore
notice.noticeEl.innerHTML = `<b>obsidian-agents Error</b>:<br/>${e.message}<br/>Check console for more information`;
console.error(`obsidian-agents Error:`, e.message, '\n', e.console_msg);
} else {
// @ts-ignore
notice.noticeEl.innerHTML = `<b>obsidian-agents Error</b>:<br/>${e.message}`;
}
}

3
src/utils/templates.ts Normal file
View File

@ -0,0 +1,3 @@
export const defaultAssistantInstructions = `- the uploaded files you have are my notes - if i mention the titles or contents of files i have given you in my response or with [[this syntax]], please look through relevant notes and use them in your response
- include annotations to the files you retrieve for your response. include them in your response format [^x] where x is the index starting from 0
- Use markdown format by adding syntax and \n in your responses before bullet lists, numbers, and paragraphs.`;

94
src/utils/utils.ts Normal file
View File

@ -0,0 +1,94 @@
import { TemplaterError } from './error';
import {
App,
normalizePath,
TAbstractFile,
TFile,
TFolder,
Vault,
} from 'obsidian';
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function escape_RegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
export function generate_command_regex(): RegExp {
return /<%(?:-|_)?\s*[*~]{0,1}((?:.|\s)*?)(?:-|_)?%>/g;
}
export function generate_dynamic_command_regex(): RegExp {
return /(<%(?:-|_)?\s*[*~]{0,1})\+((?:.|\s)*?%>)/g;
}
export function resolve_tfolder(folder_str: string): TFolder {
folder_str = normalizePath(folder_str);
const folder = app.vault.getAbstractFileByPath(folder_str);
if (!folder) {
throw new TemplaterError(`Folder "${folder_str}" doesn't exist`);
}
if (!(folder instanceof TFolder)) {
throw new TemplaterError(`${folder_str} is a file, not a folder`);
}
return folder;
}
export function resolve_tfile(file_str: string): TFile {
file_str = normalizePath(file_str);
const file = app.vault.getAbstractFileByPath(file_str);
if (!file) {
throw new TemplaterError(`File "${file_str}" doesn't exist`);
}
if (!(file instanceof TFile)) {
throw new TemplaterError(`${file_str} is a folder, not a file`);
}
return file;
}
export function get_tfiles_from_folder(
folder_str: string,
extension?: string,
): Array<TFile> {
const folder = resolve_tfolder(folder_str);
const files: Array<TFile> = [];
Vault.recurseChildren(folder, (file: TAbstractFile) => {
if (
file instanceof TFile &&
extension &&
file.extension === extension
) {
files.push(file);
}
});
files.sort((a, b) => {
return a.basename.localeCompare(b.basename);
});
return files;
}
export function arraymove<T>(
arr: T[],
fromIndex: number,
toIndex: number,
): void {
if (toIndex < 0 || toIndex === arr.length) {
return;
}
const element = arr[fromIndex];
arr[fromIndex] = arr[toIndex];
arr[toIndex] = element;
}
export function get_active_file(app: App) {
return app.workspace.activeEditor?.file ?? app.workspace.getActiveFile();
}

View File

@ -1,8 +1,273 @@
/* .agent-view-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
.chatbox-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
height: 100%;
width: 100%;
overflow: hidden;
user-select: text;
}
This CSS file will be included with your plugin, and .messages-container {
available in the app when your plugin is enabled. border: 1px solid var(--color-base-70);
border-radius: 10px;
margin: 0.5rem 0;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
height: 100%;
overflow-y: scroll;
}
If your plugin does not need CSS, delete this file. .loader-container {
display: flex;
align-items: flex-end;
padding-bottom: 8px;
justify-content: center;
height: 100%;
width: 100%;
}
*/ .scroll-to-bottom-button {
position: absolute;
bottom: 25%;
left: 50%;
transform: translateX(-50%);
z-index: 1;
background-color: #fff;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chat-message {
width: 90%;
padding: 0px 8px;
margin: 10px;
border-radius: 5px;
}
.chat-message.user {
background-color: var(--color-blue);
align-self: flex-end;
}
.chat-message.assistant {
background-color: var(--color-base-50);
align-self: flex-start;
}
.chatbox .messages-container:hover .scroll-to-bottom-button {
display: block;
}
.annotation-tooltip-container {
display: flex;
flex-direction: column;
}
.message-footer {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding-bottom: 8px;
}
.copy-icon-container {
padding-top: 8px;
}
.copy-icon {
cursor: pointer;
}
.chat-message .message-text {
color: white;
& > :is(li)::marker {
color: white;
}
}
.message-input.container {
display: flex;
width: 100%;
max-height: 25%;
flex-direction: row;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
margin-bottom: 8px;
}
.message-input textarea.input {
max-height: 100%;
min-height: 32px;
flex-grow: 1;
resize: vertical;
}
.message-input button.send {
width: 15%;
}
.collapsible-container {
width: 100%;
max-height: 25%;
margin-top: 8px;
}
.collapsible-header {
display: flex;
align-items: center;
cursor: pointer;
}
.arrow {
margin-right: 4px;
}
.collapsible-content {
max-height: 100%;
overflow-y: scroll;
padding: 8px;
}
.file-input-container {
width: 100%;
display: flex;
gap: 8px;
justify-content: space-between;
margin-bottom: 8px;
}
.file-input-container input {
flex-grow: 1;
}
.files-list {
display: flex;
flex-direction: column;
}
.file-row {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 5px;
padding: 4x;
margin-bottom: 4px;
}
.file-row div.button-group {
display: flex;
align-items: center;
gap: 4px;
}
/* AssistantManager.tsx */
.chat-top-section-container {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
}
.dropdowns-container {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
}
.dropdown-container {
display: flex;
align-items: center;
justify-content: center;
flex-basis: 0;
flex-grow: 1;
width: 100%;
gap: 8px;
}
.thread-bar-container {
display: flex;
width: 100%;
gap: 8px;
}
.thread-bar-container input {
flex-basis: 0;
flex-grow: 1;
}
.dropdown-buttons-container {
display: flex;
align-self: flex-end;
align-items: center;
justify-content: center;
width: fit-content;
gap: 8px;
& > :is(button) {
padding: 0px 8px;
}
}
/* DropdownSelect.tsx */
.dropdown-select {
text-overflow: ellipsis;
text-wrap: nowrap;
max-width: 50%;
flex-basis: 0;
flex-grow: 1;
background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4"%3E%3Cpath fill="%23FFF" opacity="0.4" d="M287 69.4a17.6 17.6 0 0 0-13-5.4H18.4c-5 0-9.3 1.8-12.9 5.4A17.6 17.6 0 0 0 0 82.2c0 5 1.8 9.3 5.4 12.9l128 127.9c3.6 3.6 7.8 5.4 12.8 5.4s9.2-1.8 12.8-5.4L287 95c3.5-3.5 5.4-7.8 5.4-12.8 0-5-1.9-9.2-5.5-12.8z"/%3E%3C/svg%3E');
}
/* AssistantEditModal.ts */
.file-list {
display: flex;
flex-direction: column;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 5px;
padding: 4x;
margin-bottom: 4px;
}
.file-item span {
margin-right: 8px;
}
.form-setting-textarea textarea {
resize: vertical;
}
.modal-footer {
display: flex;
margin-top: 8px;
justify-content: flex-end;
}
/* .form-submit-button {
} */

View File

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": ".", "baseUrl": ".",
"inlineSourceMap": true, "inlineSourceMap": true,
"inlineSources": true, "inlineSources": true,
@ -10,15 +11,12 @@
"moduleResolution": "node", "moduleResolution": "node",
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react",
"strictNullChecks": true, "strictNullChecks": true,
"lib": [ "lib": ["DOM", "ES5", "ES6", "ES7"],
"DOM", "paths": {
"ES5", "@/*": ["src/*"]
"ES6", }
"ES7"
]
}, },
"include": [ "include": ["**/*.ts", "src/ui/AppView.tsx"]
"**/*.ts"
]
} }

View File

@ -1,14 +1,14 @@
import { readFileSync, writeFileSync } from "fs"; import { readFileSync, writeFileSync } from 'fs';
const targetVersion = process.env.npm_package_version; const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version // read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { minAppVersion } = manifest; const { minAppVersion } = manifest;
manifest.version = targetVersion; manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
// update versions.json with target version and minAppVersion from manifest.json // update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8")); let versions = JSON.parse(readFileSync('versions.json', 'utf8'));
versions[targetVersion] = minAppVersion; versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));