commit
b194cadccd
22
.eslintrc
22
.eslintrc
|
@ -2,22 +2,20 @@
|
||||||
"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",
|
||||||
"plugin:@typescript-eslint/recommended"
|
"plugin:@typescript-eslint/recommended"
|
||||||
],
|
],
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||||
"@typescript-eslint/ban-ts-comment": "off",
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
"no-prototype-builtins": "off",
|
"no-prototype-builtins": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off"
|
"@typescript-eslint/no-empty-function": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
97
README.md
97
README.md
|
@ -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
|
|
||||||
|
|
|
@ -1,48 +1,48 @@
|
||||||
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',
|
||||||
treeShaking: true,
|
sourcemap: prod ? false : 'inline',
|
||||||
outfile: "main.js",
|
treeShaking: true,
|
||||||
|
outfile: 'main.js',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prod) {
|
if (prod) {
|
||||||
await context.rebuild();
|
await context.rebuild();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
await context.watch();
|
await context.watch();
|
||||||
}
|
}
|
227
main.ts
227
main.ts
|
@ -1,134 +1,113 @@
|
||||||
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 ObsidianIntelligence extends Plugin {
|
||||||
|
settings: ObsidianIntelligenceSettings;
|
||||||
|
view: AppView;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
await this.loadSettings();
|
||||||
|
this.registerView(OBSIDIAN_INTELLIGENCE_VIEW_TYPE, (leaf) => new AppView(leaf, this));
|
||||||
|
|
||||||
|
const ribbonIconEl = this.addRibbonIcon(
|
||||||
|
'bot',
|
||||||
|
'Open Obsidian Intelligence',
|
||||||
|
(evt: MouseEvent) => {
|
||||||
|
this.activateView();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Perform additional things with the ribbon
|
||||||
|
ribbonIconEl.addClass('my-plugin-ribbon-class');
|
||||||
|
|
||||||
|
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
||||||
|
const statusBarItemEl = this.addStatusBarItem();
|
||||||
|
statusBarItemEl.setText('Status Bar Text');
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'obsidian-intelligence-view-open',
|
||||||
|
name: 'Open Obsidian Intelligence',
|
||||||
|
callback: () => {
|
||||||
|
this.activateView();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||||
|
this.addSettingTab(new OISettingTab(this.app, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
onunload() {}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
this.settings = Object.assign(
|
||||||
|
{},
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
await this.loadData(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateView() {
|
||||||
|
this.app.workspace.detachLeavesOfType(OBSIDIAN_INTELLIGENCE_VIEW_TYPE);
|
||||||
|
|
||||||
|
await this.app.workspace.getRightLeaf(false).setViewState({
|
||||||
|
type: OBSIDIAN_INTELLIGENCE_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.revealView();
|
||||||
|
}
|
||||||
|
async revealView() {
|
||||||
|
this.app.workspace.revealLeaf(
|
||||||
|
this.app.workspace.getLeavesOfType(OBSIDIAN_INTELLIGENCE_VIEW_TYPE)[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MyPlugin extends Plugin {
|
class OISettingTab extends PluginSettingTab {
|
||||||
settings: MyPluginSettings;
|
plugin: ObsidianIntelligence;
|
||||||
|
|
||||||
async onload() {
|
constructor(app: App, plugin: ObsidianIntelligence) {
|
||||||
await this.loadSettings();
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
// This creates an icon in the left ribbon.
|
display(): void {
|
||||||
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
|
const { containerEl } = this;
|
||||||
// Called when the user clicks the icon.
|
|
||||||
new Notice('This is a notice!');
|
|
||||||
});
|
|
||||||
// Perform additional things with the ribbon
|
|
||||||
ribbonIconEl.addClass('my-plugin-ribbon-class');
|
|
||||||
|
|
||||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
containerEl.empty();
|
||||||
const statusBarItemEl = this.addStatusBarItem();
|
|
||||||
statusBarItemEl.setText('Status Bar Text');
|
|
||||||
|
|
||||||
// This adds a simple command that can be triggered anywhere
|
new Setting(containerEl)
|
||||||
this.addCommand({
|
.setName('OpenAI Key')
|
||||||
id: 'open-sample-modal-simple',
|
.setDesc('Can find it https://platform.openai.com/api-keys')
|
||||||
name: 'Open sample modal (simple)',
|
.addText((text) =>
|
||||||
callback: () => {
|
text
|
||||||
new SampleModal(this.app).open();
|
.setPlaceholder('Enter your API Key')
|
||||||
}
|
.setValue(this.plugin.settings.openaiKey)
|
||||||
});
|
.onChange(async (value) => {
|
||||||
// This adds an editor command that can perform some operation on the current editor instance
|
this.plugin.settings.openaiKey = value;
|
||||||
this.addCommand({
|
await this.plugin.saveSettings();
|
||||||
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.addSettingTab(new SampleSettingTab(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() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSettings() {
|
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveSettings() {
|
|
||||||
await this.saveData(this.settings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SampleModal extends Modal {
|
|
||||||
constructor(app: App) {
|
|
||||||
super(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpen() {
|
|
||||||
const {contentEl} = this;
|
|
||||||
contentEl.setText('Woah!');
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose() {
|
|
||||||
const {contentEl} = this;
|
|
||||||
contentEl.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SampleSettingTab extends PluginSettingTab {
|
|
||||||
plugin: MyPlugin;
|
|
||||||
|
|
||||||
constructor(app: App, plugin: MyPlugin) {
|
|
||||||
super(app, plugin);
|
|
||||||
this.plugin = plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
display(): void {
|
|
||||||
const {containerEl} = this;
|
|
||||||
|
|
||||||
containerEl.empty();
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName('Setting #1')
|
|
||||||
.setDesc('It\'s a secret')
|
|
||||||
.addText(text => text
|
|
||||||
.setPlaceholder('Enter your secret')
|
|
||||||
.setValue(this.plugin.settings.mySetting)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.mySetting = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
|
@ -1,27 +1,39 @@
|
||||||
{
|
{
|
||||||
"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": [],
|
},
|
||||||
"author": "",
|
"keywords": [],
|
||||||
"license": "MIT",
|
"author": "",
|
||||||
"devDependencies": {
|
"license": "MIT",
|
||||||
"@types/node": "^16.11.6",
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
"@types/node": "^16.11.6",
|
||||||
"@typescript-eslint/parser": "5.29.0",
|
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||||
"builtin-modules": "3.3.0",
|
"@typescript-eslint/parser": "5.29.0",
|
||||||
"esbuild": "0.17.3",
|
"builtin-modules": "3.3.0",
|
||||||
"obsidian": "latest",
|
"esbuild": "0.17.3",
|
||||||
"tslib": "2.4.0",
|
"obsidian": "latest",
|
||||||
"typescript": "4.7.4"
|
"prettier": "^3.1.0",
|
||||||
},
|
"tslib": "2.4.0",
|
||||||
"dependencies": {
|
"typescript": "4.7.4"
|
||||||
"openai": "^4.16.1"
|
},
|
||||||
}
|
"dependencies": {
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
# This is a test file
|
||||||
|
|
||||||
|
wow
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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() {}
|
||||||
|
}
|
|
@ -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() {}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Notice } from 'obsidian';
|
||||||
|
|
||||||
|
export const createNotice = (message: string, timeout = 5000): void => {
|
||||||
|
new Notice(`Obsidian Intelligence: ${message}`, timeout);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.`;
|
|
@ -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();
|
||||||
|
}
|
275
styles.css
275
styles.css
|
@ -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 {
|
||||||
|
|
||||||
|
} */
|
||||||
|
|
|
@ -1,24 +1,22 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"allowSyntheticDefaultImports": true,
|
||||||
"inlineSourceMap": true,
|
"baseUrl": ".",
|
||||||
"inlineSources": true,
|
"inlineSourceMap": true,
|
||||||
"module": "ESNext",
|
"inlineSources": true,
|
||||||
"target": "ES6",
|
"module": "ESNext",
|
||||||
"allowJs": true,
|
"target": "ES6",
|
||||||
"noImplicitAny": true,
|
"allowJs": true,
|
||||||
"moduleResolution": "node",
|
"noImplicitAny": true,
|
||||||
"importHelpers": true,
|
"moduleResolution": "node",
|
||||||
"isolatedModules": true,
|
"importHelpers": true,
|
||||||
"strictNullChecks": true,
|
"isolatedModules": true,
|
||||||
"lib": [
|
"jsx": "react",
|
||||||
"DOM",
|
"strictNullChecks": true,
|
||||||
"ES5",
|
"lib": ["DOM", "ES5", "ES6", "ES7"],
|
||||||
"ES6",
|
"paths": {
|
||||||
"ES7"
|
"@/*": ["src/*"]
|
||||||
]
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "src/ui/AppView.tsx"]
|
||||||
"**/*.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'));
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"1.0.0": "0.15.0"
|
"1.0.0": "0.15.0"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue