├── .npmrc ├── .eslintignore ├── src ├── llm │ ├── models.ts │ ├── dummy_llm.ts │ ├── factory.ts │ ├── base.ts │ └── openai_llm.ts ├── modals │ ├── deletion.ts │ ├── output.ts │ └── action_editor.ts ├── main.ts ├── preset.ts ├── handler.ts ├── action.ts └── settings.ts ├── .editorconfig ├── manifest.json ├── .gitignore ├── styles.css ├── versions.json ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE ├── esbuild.config.mjs ├── README.md └── .github └── workflows └── release.yml /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /src/llm/models.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIModel } from "./openai_llm"; 2 | 3 | export type Model = OpenAIModel | {}; 4 | 5 | export const DEFAULT_MODEL: Model = OpenAIModel.GPT_4O_MINI; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ai-editor", 3 | "name": "AI Editor", 4 | "version": "0.5.6", 5 | "minAppVersion": "0.15.0", 6 | "description": "Empower your note editor with LLM capabilities. Customizable to work for your use cases.", 7 | "author": "Zekun Shen", 8 | "authorUrl": "https://github.com/buszk", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .loader { 2 | border: 3px solid #f3f3f3; 3 | /* Light grey */ 4 | border-top: 3px solid #3498db; 5 | /* Blue */ 6 | border-radius: 50%; 7 | position: absolute; 8 | width: 24px; 9 | height: 24px; 10 | top: 50%; 11 | left: 50%; 12 | animation: spin 2s linear infinite; 13 | } 14 | 15 | @keyframes spin { 16 | 0% { 17 | transform: rotate(0deg); 18 | } 19 | 20 | 100% { 21 | transform: rotate(360deg); 22 | } 23 | } -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.15.0", 3 | "0.2.0": "0.15.0", 4 | "0.2.1": "0.15.0", 5 | "0.2.2": "0.15.0", 6 | "0.2.3": "0.15.0", 7 | "0.2.4": "0.15.0", 8 | "0.2.5": "0.15.0", 9 | "0.3.0": "0.15.0", 10 | "0.3.1": "0.15.0", 11 | "0.3.2": "0.15.0", 12 | "0.4.0": "0.15.0", 13 | "0.4.1": "0.15.0", 14 | "0.4.2": "0.15.0", 15 | "0.4.3": "0.15.0", 16 | "0.5.0": "0.15.0", 17 | "0.5.1": "0.15.0", 18 | "0.5.2": "0.15.0", 19 | "0.5.3": "0.15.0", 20 | "0.5.4": "0.15.0", 21 | "0.5.5": "0.15.0", 22 | "0.5.6": "0.15.0" 23 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-ai-editor", 3 | "version": "0.5.6", 4 | "description": "AI-powered Text Editor", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && npm i --package-lock-only && git add manifest.json versions.json package.json package-lock.json" 10 | }, 11 | "keywords": [], 12 | "author": "Zekun Shen", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | }, 24 | "dependencies": { 25 | "langchain": "^0.0.156", 26 | "openai": "^4.22.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modals/deletion.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | 3 | export class DeletionModal extends Modal { 4 | onDelete: () => void; 5 | 6 | constructor(app: App, onDelete: () => void) { 7 | super(app); 8 | this.onDelete = onDelete; 9 | } 10 | 11 | onOpen() { 12 | const { contentEl } = this; 13 | contentEl.empty(); 14 | 15 | contentEl.createEl("h1", { text: "Deletion" }); 16 | 17 | contentEl.createEl("p", { 18 | text: "Are you sure about deleting custome action?", 19 | }); 20 | 21 | new Setting(contentEl) 22 | .addButton((button) => { 23 | button 24 | .setButtonText("Back to safety") 25 | .setCta() 26 | .onClick(() => { 27 | this.close(); 28 | }); 29 | }) 30 | .addButton((button) => { 31 | button 32 | .setButtonText("Delete") 33 | .setWarning() 34 | .onClick(() => { 35 | this.onDelete(); 36 | this.close(); 37 | }); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/llm/dummy_llm.ts: -------------------------------------------------------------------------------- 1 | import { LLM } from "./base"; 2 | 3 | const textForTesting = 4 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; 5 | 6 | export class DummyLLM extends LLM { 7 | autocomplete(text: string): Promise { 8 | return new Promise((resolve) => { 9 | resolve(textForTesting); 10 | }); 11 | } 12 | 13 | autocompleteStreamingInner( 14 | _: string, 15 | callback: (text: string) => void 16 | ): Promise { 17 | return new Promise(async (resolve) => { 18 | const split = textForTesting.split(" "); 19 | for (const element of split) { 20 | callback(element + " "); 21 | await new Promise((r) => setTimeout(r, 20)); 22 | } 23 | resolve(); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zekun Shen (buszk) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /src/llm/factory.ts: -------------------------------------------------------------------------------- 1 | import { UserAction } from "src/action"; 2 | import { AIEditorSettings } from "src/settings"; 3 | import { LLM } from "./base"; 4 | import { DummyLLM } from "./dummy_llm"; 5 | import { OpenAILLM, OpenAIModel } from "./openai_llm"; 6 | import { Notice } from "obsidian"; 7 | import { DEFAULT_MODEL } from "./models"; 8 | 9 | export class LLMFactory { 10 | settings: AIEditorSettings; 11 | constructor(settings: AIEditorSettings) { 12 | this.settings = settings; 13 | } 14 | 15 | createLLM(userAction: UserAction): LLM { 16 | if (this.settings.testingMode) { 17 | return new DummyLLM(); 18 | } 19 | 20 | if (userAction.model == undefined) { 21 | new Notice("Model not set, using default"); 22 | userAction.model = DEFAULT_MODEL; 23 | } 24 | 25 | if (Object.values(OpenAIModel).includes(userAction.model as any)) { 26 | if (!this.settings.openAiApiKey) { 27 | throw "API key is not set in plugin settings"; 28 | } 29 | return new OpenAILLM( 30 | this.settings.openAiApiKey, 31 | userAction.model as OpenAIModel 32 | ); 33 | } 34 | 35 | throw `Model ${userAction.model.toString()} not found`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian AI Editor Plugin 2 | 3 | The Obsidian AI Editor Plugin is a powerful tool that integrates artificial intelligence capabilities into the Obsidian App. With this plugin, you can enhance your writing, research, and brainstorming processes by leveraging AI-generated suggestions and content. Whether you're looking for creative prompts, relevant information, or grammar improvements, the AI Editor Plugin has you covered. 4 | 5 | # Features 6 | - AI-Powered Processing: Ask LLM to process your documents, easily integrated inside Obsidian. 7 | - Unlock Your Creativity: The plugin is designed to be configurable and fit your need. It's easy to create your own command and integrate LLM as part of workflow. 8 | - Text-summarization (Demo): We configured text summarization command to demonstrate the plugin. 9 | 10 | # Usage 11 | 1. Get API Key from OpenAI from this [page](https://platform.openai.com/account/api-keys). You will have to link your credit card. 12 | 2. Go to Settings in Obsidian. Click AI Editor tab. Enter your API key. 13 | 3. Go to any note you want to summarize. Use the initial TLDR (Too Long; Didn't Read) command. 14 | 4. Go back to Settings to add more commands. You can configure basic command options like prompts, text selection and output action. 15 | 5. Note that you need to reload the app to get new command registered for now. 16 | 17 | # Feedback and Contribution 18 | If you have any suggestions, encounter issues, or wish to contribute to the development of the plugin, checkout the [GitHub repository](https://github.com/buszk/obsidian-ai-editor). -------------------------------------------------------------------------------- /src/llm/base.ts: -------------------------------------------------------------------------------- 1 | export abstract class LLM { 2 | // For streaming mode, this is the timeout between each callback 3 | // For non-streaming mode, this is the timeout for the whole query 4 | queryTimeout = 15000; 5 | 6 | abstract autocomplete(text: string): Promise; 7 | 8 | abstract autocompleteStreamingInner( 9 | text: string, 10 | callback: (text: string) => void 11 | ): Promise; 12 | 13 | async autocompleteStreaming( 14 | text: string, 15 | callback: (text: string) => void 16 | ): Promise { 17 | var last_tick = new Date().getTime(); 18 | var has_timeout = false; 19 | 20 | // define a wrapper function to update last_tick 21 | function callback_wrapper(text: string): void { 22 | // Once start the query promise, we cannot cancel it. 23 | // Ignore the callback if timeout has already happened. 24 | if (has_timeout) { 25 | return; 26 | } 27 | last_tick = new Date().getTime(); 28 | callback(text); 29 | } 30 | 31 | let promise = this.autocompleteStreamingInner(text, callback_wrapper); 32 | return new Promise((resolve, reject) => { 33 | const intervalId = setInterval(() => { 34 | let now = new Date().getTime(); 35 | if (now - last_tick > this.queryTimeout) { 36 | has_timeout = true; 37 | clearInterval(intervalId); 38 | reject( 39 | "Timeout: last streaming output is " + 40 | (now - last_tick) + 41 | "ms ago." 42 | ); 43 | } 44 | }, 1000); 45 | promise 46 | .then((_) => { 47 | clearInterval(intervalId); 48 | resolve(); 49 | }) 50 | .catch((error) => { 51 | clearInterval(intervalId); 52 | reject(error); 53 | }); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ActionHandler } from "src/handler"; 2 | import { Editor, MarkdownView, Plugin } from "obsidian"; 3 | import { AIEditorSettingTab, AIEditorSettings } from "src/settings"; 4 | import { DEFAULT_ACTIONS } from "src/preset"; 5 | import { DEFAULT_MODEL } from "./llm/models"; 6 | 7 | const DEFAULT_SETTINGS: AIEditorSettings = { 8 | openAiApiKey: "", 9 | testingMode: false, 10 | defaultModel: DEFAULT_MODEL, 11 | customActions: DEFAULT_ACTIONS, 12 | }; 13 | 14 | export default class AIEditor extends Plugin { 15 | settings: AIEditorSettings; 16 | 17 | registerActions() { 18 | let actions = this.settings.customActions; 19 | let handler = new ActionHandler(this.settings); 20 | actions.forEach((action, i) => { 21 | this.addCommand({ 22 | // When user edit the settings, this method is called to updated command. 23 | // Use index as id to avoid creating duplicates 24 | id: `user-action-${i}`, 25 | name: action.name, 26 | editorCallback: async (editor: Editor, view: MarkdownView) => { 27 | await handler.process( 28 | this.app, 29 | this.settings, 30 | action, 31 | editor, 32 | view 33 | ); 34 | }, 35 | }); 36 | }); 37 | } 38 | 39 | async onload() { 40 | await this.loadSettings(); 41 | this.addCommand({ 42 | id: "reload-commands", 43 | name: "Reload commands", 44 | callback: () => { 45 | this.registerActions(); 46 | }, 47 | }); 48 | this.registerActions(); 49 | 50 | // This adds a settings tab so the user can configure various aspects of the plugin 51 | this.addSettingTab(new AIEditorSettingTab(this.app, this)); 52 | } 53 | 54 | onunload() {} 55 | 56 | async loadSettings() { 57 | this.settings = Object.assign( 58 | {}, 59 | DEFAULT_SETTINGS, 60 | await this.loadData() 61 | ); 62 | } 63 | 64 | async saveSettings() { 65 | await this.saveData(this.settings); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/preset.ts: -------------------------------------------------------------------------------- 1 | import { UserAction, Selection, Location } from "src/action"; 2 | import { DEFAULT_MODEL } from "./llm/models"; 3 | 4 | export const SUMMARY_DOC_ACTION: UserAction = { 5 | name: "Summarize document", 6 | prompt: "Summarize the following in a paragraph", 7 | sel: Selection.ALL, 8 | loc: Location.INSERT_HEAD, 9 | format: "**Summary**: {{result}}\n\n", 10 | modalTitle: "Check summary", 11 | model: DEFAULT_MODEL, 12 | }; 13 | 14 | export const COMPLETION_ACTION: UserAction = { 15 | name: "Text Completion", 16 | prompt: "Complete the following text", 17 | sel: Selection.CURSOR, 18 | loc: Location.APPEND_CURRENT, 19 | format: "{{result}}", 20 | modalTitle: "Check result", 21 | model: DEFAULT_MODEL, 22 | }; 23 | 24 | export const REWRITE_ACTION: UserAction = { 25 | name: "Rewrite selection (formal)", 26 | prompt: "Rewrite the following text in a professional tone", 27 | sel: Selection.CURSOR, 28 | loc: Location.REPLACE_CURRENT, 29 | format: "{{result}}", 30 | modalTitle: "Check result", 31 | model: DEFAULT_MODEL, 32 | }; 33 | 34 | export const HASHTAG_ACTION: UserAction = { 35 | name: "Generate hashtags", 36 | prompt: "Generate hashtags for the following text", 37 | sel: Selection.ALL, 38 | loc: Location.APPEND_BOTTOM, 39 | format: "\n{{result}}", 40 | modalTitle: "Check result", 41 | model: DEFAULT_MODEL, 42 | }; 43 | 44 | export const APPEND_TO_TASK_LIST: UserAction = { 45 | name: "Append to task list", 46 | prompt: "Summarize the following as an actionable task in a short sentence", 47 | sel: Selection.ALL, 48 | loc: Location.APPEND_TO_FILE, 49 | locationExtra: { fileName: "Tasks.md" }, 50 | format: "\n- [ ] {{result}}", 51 | modalTitle: "Check result", 52 | model: DEFAULT_MODEL, 53 | }; 54 | 55 | // Default actions 56 | export const DEFAULT_ACTIONS: Array = [ 57 | SUMMARY_DOC_ACTION, 58 | COMPLETION_ACTION, 59 | REWRITE_ACTION, 60 | HASHTAG_ACTION, 61 | APPEND_TO_TASK_LIST, 62 | ]; 63 | -------------------------------------------------------------------------------- /src/modals/output.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | 3 | export class OutputModal extends Modal { 4 | title: string; 5 | format: (generated: string) => string; 6 | generated: string; 7 | editMode: boolean = false; 8 | 9 | onAccept: (result: string) => Promise; 10 | 11 | constructor( 12 | app: App, 13 | title: string, 14 | format: (generated: string) => string, 15 | onAccept: (result: string) => Promise, 16 | initial_text: string = "" 17 | ) { 18 | super(app); 19 | this.onAccept = onAccept; 20 | this.title = title; 21 | this.format = format; 22 | this.generated = initial_text; 23 | } 24 | 25 | onOpen() { 26 | const { contentEl } = this; 27 | 28 | contentEl.createEl("h1", { text: this.title }); 29 | contentEl.createEl("hr"); 30 | 31 | let textEl: HTMLElement; 32 | if (this.editMode) { 33 | textEl = contentEl.createEl("textarea", { 34 | text: this.format(this.generated), 35 | attr: { 36 | style: "width: 100%", 37 | rows: "9", 38 | oninput: "this.innerHTML = this.value", 39 | }, 40 | }); 41 | } else { 42 | textEl = contentEl.createEl("p", { 43 | text: this.format(this.generated), 44 | }); 45 | } 46 | contentEl.createEl("br"); 47 | 48 | new Setting(contentEl) 49 | .addButton((btn) => 50 | btn 51 | .setButtonText("Add to Note") 52 | .setCta() 53 | .onClick(async () => { 54 | this.close(); 55 | await this.onAccept(textEl.innerText); 56 | }) 57 | ) 58 | .addButton((btn) => 59 | btn.setButtonText("Edit").onClick(() => { 60 | this.editMode = true; 61 | this.onClose(); 62 | this.onOpen(); 63 | }) 64 | ) 65 | .addButton((btn) => 66 | btn.setButtonText("Ignore").onClick(() => { 67 | this.close(); 68 | }) 69 | ); 70 | } 71 | 72 | addToken(token: string) { 73 | this.generated = this.generated + token; 74 | this.contentEl.empty(); 75 | this.onOpen(); 76 | } 77 | 78 | onClose() { 79 | let { contentEl } = this; 80 | contentEl.empty(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: '14.x' # You might need to adjust this value to your own version 18 | # Get the version number and put it in a variable 19 | - name: Get Version 20 | id: version 21 | run: | 22 | echo "::set-output name=tag::$(git describe --abbrev=0)" 23 | # Build the plugin 24 | - name: Build 25 | id: build 26 | run: | 27 | npm install 28 | npm run build --if-present 29 | # Package the required files into a zip 30 | - name: Package 31 | run: | 32 | mkdir ${{ github.event.repository.name }} 33 | cp main.js manifest.json styles.css README.md ${{ github.event.repository.name }} 34 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 35 | # Create the release on github 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | VERSION: ${{ github.ref }} 42 | with: 43 | tag_name: ${{ github.ref }} 44 | release_name: ${{ github.ref }} 45 | draft: false 46 | prerelease: false 47 | # Upload the main.js 48 | - name: Upload main.js 49 | id: upload-main 50 | uses: actions/upload-release-asset@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | upload_url: ${{ steps.create_release.outputs.upload_url }} 55 | asset_path: ./main.js 56 | asset_name: main.js 57 | asset_content_type: text/javascript 58 | # Upload the manifest.json 59 | - name: Upload manifest.json 60 | id: upload-manifest 61 | uses: actions/upload-release-asset@v1 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | with: 65 | upload_url: ${{ steps.create_release.outputs.upload_url }} 66 | asset_path: ./manifest.json 67 | asset_name: manifest.json 68 | asset_content_type: application/json 69 | # Upload the style.css 70 | - name: Upload styles.css 71 | id: upload-css 72 | uses: actions/upload-release-asset@v1 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | upload_url: ${{ steps.create_release.outputs.upload_url }} 77 | asset_path: ./styles.css 78 | asset_name: styles.css 79 | asset_content_type: text/css 80 | # TODO: release notes??? -------------------------------------------------------------------------------- /src/llm/openai_llm.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from "langchain/llms/openai"; 2 | import { LLM } from "./base"; 3 | 4 | export enum OpenAIModel { 5 | GPT_3_5_16k = "gpt-3.5-turbo-16k", 6 | GPT_3_5_INSTRUCT = "gpt-3.5-turbo-instruct", 7 | GPT_3_5_TURBO_PREVIEW = "gpt-3.5-turbo-1106", 8 | GPT_3_5_TURBO = "gpt-3.5-turbo", 9 | GPT_4 = "gpt-4", 10 | GPT_4_32K = "gpt-4-32k", 11 | GPT_4_TURBO_PREVIEW = "gpt-4-1106-preview", 12 | GPT_4_TURBO = "gpt-4-turbo", 13 | GPT_4O = "gpt-4o", 14 | GPT_4O_AUDIO_PREVIEW = "gpt-4o-audio-preview", 15 | GPT_4O_AUDIO_PREVIEW_2024_10_01 = "gpt-4o-audio-preview-2024-10-01", 16 | GPT_4O_MINI = "gpt-4o-mini", 17 | GPT_4O_MINI_2024_07_18 = "gpt-4o-mini-2024-07-18", 18 | O1_MINI = "o1-mini", 19 | O1_MINI_2024_09_12 = "o1-mini-2024-09-12", 20 | O1_PREVIEW = "o1-preview", 21 | O1_PREVIEW_2024_09_12 = "o1-preview-2024-09-12", 22 | CHATGPT_4O_LATEST = "chatgpt-4o-latest", 23 | GPT_4O_2024_05_13 = "gpt-4o-2024-05-13", 24 | GPT_4O_2024_08_06 = "gpt-4o-2024-08-06", 25 | GPT_4O_2024_11_20 = "gpt-4o-2024-11-20", 26 | GPT_4_TURBO_PREVIEW_2024 = "gpt-4-turbo-preview", 27 | GPT_4_0314 = "gpt-4-0314", 28 | GPT_4_0613 = "gpt-4-0613", 29 | GPT_4_32K_0314 = "gpt-4-32k-0314", 30 | GPT_4_32K_0613 = "gpt-4-32k-0613", 31 | GPT_4_TURBO_2024_04_09 = "gpt-4-turbo-2024-04-09", 32 | GPT_4_1106_PREVIEW = "gpt-4-1106-preview", 33 | GPT_4_0125_PREVIEW = "gpt-4-0125-preview", 34 | GPT_4_VISION_PREVIEW = "gpt-4-vision-preview", 35 | GPT_4_1106_VISION_PREVIEW = "gpt-4-1106-vision-preview", 36 | GPT_3_5_TURBO_0301 = "gpt-3.5-turbo-0301", 37 | GPT_3_5_TURBO_0613 = "gpt-3.5-turbo-0613", 38 | GPT_3_5_TURBO_1106 = "gpt-3.5-turbo-1106", 39 | GPT_3_5_TURBO_0125 = "gpt-3.5-turbo-0125", 40 | GPT_3_5_TURBO_16K_0613 = "gpt-3.5-turbo-16k-0613", 41 | FT_GPT_3_5_TURBO = "ft:gpt-3.5-turbo", 42 | FT_GPT_3_5_TURBO_0125 = "ft:gpt-3.5-turbo-0125", 43 | FT_GPT_3_5_TURBO_1106 = "ft:gpt-3.5-turbo-1106", 44 | FT_GPT_3_5_TURBO_0613 = "ft:gpt-3.5-turbo-0613", 45 | FT_GPT_4_0613 = "ft:gpt-4-0613", 46 | FT_GPT_4O_2024_08_06 = "ft:gpt-4o-2024-08-06", 47 | FT_GPT_4O_2024_11_20 = "ft:gpt-4o-2024-11-20", 48 | FT_GPT_4O_MINI_2024_07_18 = "ft:gpt-4o-mini-2024-07-18", 49 | } 50 | 51 | export class OpenAILLM extends LLM { 52 | llm: OpenAI; 53 | 54 | constructor( 55 | apiKey: string, 56 | modelName: OpenAIModel = OpenAIModel.GPT_3_5_TURBO_PREVIEW, 57 | temperature: number = 0.7 58 | ) { 59 | super(); 60 | this.llm = new OpenAI({ 61 | modelName: modelName.toString(), 62 | openAIApiKey: apiKey, 63 | temperature: temperature, 64 | streaming: true, 65 | cache: true, 66 | }); 67 | } 68 | 69 | async autocomplete(text: string): Promise { 70 | let response = await this.llm.call(text, { 71 | timeout: this.queryTimeout, 72 | }); 73 | return response.trim(); 74 | } 75 | 76 | async autocompleteStreamingInner( 77 | text: string, 78 | callback: (text: string) => void 79 | ): Promise { 80 | await this.llm.call(text, { 81 | callbacks: [{ handleLLMNewToken: callback }], 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { UserAction, Selection, Location } from "src/action"; 2 | import { OutputModal } from "src/modals/output"; 3 | import { App, Editor, MarkdownView, Notice, TFile, Vault } from "obsidian"; 4 | import { AIEditorSettings } from "src/settings"; 5 | import { LLMFactory } from "./llm/factory"; 6 | import { CharacterTextSplitter } from "langchain/text_splitter"; 7 | 8 | export class ActionHandler { 9 | llmFactory: LLMFactory; 10 | 11 | constructor(settings: AIEditorSettings) { 12 | this.llmFactory = new LLMFactory(settings); 13 | } 14 | 15 | getAPIKey(settings: AIEditorSettings) { 16 | const apiKey = settings.openAiApiKey; 17 | if (!apiKey) { 18 | new Notice("API key is not set in plugin settings"); 19 | throw "API key not set"; 20 | } 21 | return apiKey; 22 | } 23 | 24 | getTextInput(sel: Selection, editor: Editor) { 25 | switch (sel) { 26 | case Selection.ALL: 27 | return editor.getValue(); 28 | case Selection.CURSOR: 29 | return editor.getSelection(); 30 | default: 31 | console.log(`Selection ${sel}`); 32 | throw "Selection not implemented"; 33 | } 34 | } 35 | 36 | async addToNote( 37 | location: Location, 38 | text: string, 39 | editor: Editor, 40 | vault?: Vault, 41 | locationExtra?: { fileName: string } 42 | ) { 43 | switch (location) { 44 | case Location.INSERT_HEAD: 45 | editor.setCursor(0, 0); 46 | editor.replaceRange(text, editor.getCursor()); 47 | break; 48 | case Location.APPEND_BOTTOM: 49 | editor.setCursor(editor.lastLine()); 50 | editor.replaceRange(text, editor.getCursor()); 51 | break; 52 | case Location.APPEND_CURRENT: 53 | text = editor.getSelection() + text; 54 | editor.replaceSelection(text); 55 | break; 56 | case Location.REPLACE_CURRENT: 57 | editor.replaceSelection(text); 58 | break; 59 | case Location.APPEND_TO_FILE: 60 | let fileName = locationExtra?.fileName; 61 | if (vault && fileName) { 62 | await this.appendToFileInVault(vault, fileName, text); 63 | } 64 | break; 65 | default: 66 | throw "Location not implemented"; 67 | } 68 | } 69 | 70 | private async appendToFileInVault( 71 | vault: Vault, 72 | fileName: string, 73 | text: string 74 | ) { 75 | let file: TFile = await getFile(vault, fileName); 76 | vault.append(file, text); 77 | } 78 | 79 | async process( 80 | app: App, 81 | settings: AIEditorSettings, 82 | action: UserAction, 83 | editor: Editor, 84 | view: MarkdownView 85 | ) { 86 | console.log(editor.getSelection()); 87 | const text = this.getTextInput(action.sel, editor); 88 | new Notice("Please wait... Querying OpenAI API..."); 89 | 90 | const spinner = view.contentEl.createEl("div", { cls: "loader" }); 91 | 92 | const modal = new OutputModal( 93 | app, 94 | action.modalTitle, 95 | (text: string) => action.format.replace("{{result}}", text), 96 | async (result: string) => { 97 | await this.addToNote( 98 | action.loc, 99 | result, 100 | editor, 101 | view.file?.vault, 102 | action.locationExtra 103 | ); 104 | } 105 | ); 106 | let modalDisplayed = false; 107 | try { 108 | const llm = this.llmFactory.createLLM(action); 109 | await llm.autocompleteStreaming( 110 | action.prompt + "\n" + text, 111 | (token) => { 112 | if (!modalDisplayed) { 113 | modalDisplayed = true; 114 | modal.open(); 115 | spinner.remove(); 116 | } 117 | modal.addToken(token); 118 | } 119 | ); 120 | } catch (error) { 121 | console.log(error); 122 | new Notice(`Autocomplete error:\n${error}`); 123 | } 124 | spinner.remove(); 125 | } 126 | } 127 | 128 | async function getFile(vault: Vault, fileName: string) { 129 | let file = vault.getAbstractFileByPath(fileName); 130 | if (file == null) { 131 | return await vault.create(fileName, ""); 132 | } else if (file instanceof TFile) { 133 | return file; 134 | } else { 135 | throw "Not a file path"; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "./llm/models"; 2 | import { OpenAIModel } from "./llm/openai_llm"; 3 | 4 | export enum Selection { 5 | ALL = "ALL", 6 | CURSOR = "CURSOR", 7 | } 8 | 9 | export enum Location { 10 | INSERT_HEAD = "INSERT_HEAD", 11 | APPEND_BOTTOM = "APPEND_BOTTOM", 12 | APPEND_CURRENT = "APPEND_CURRENT", 13 | APPEND_TO_FILE = "APPEND_TO_FILE", 14 | REPLACE_CURRENT = "REPLACE_CURRENT", 15 | } 16 | 17 | 18 | export interface UserAction { 19 | name: string; 20 | prompt: string; 21 | sel: Selection; 22 | loc: Location; 23 | locationExtra?: { fileName: string }; 24 | format: string; 25 | modalTitle: string; 26 | model: Model; 27 | } 28 | 29 | const SELECTION_SETTING: { [key: string]: string } = { 30 | [Selection.ALL.toString()]: "Select the whole document", 31 | [Selection.CURSOR.toString()]: "Input selected text by cursor", 32 | }; 33 | 34 | const LOCATION_SETTING: { [key: string]: string } = { 35 | [Location.INSERT_HEAD.toString()]: 36 | "Insert at the beginning of the document", 37 | [Location.APPEND_BOTTOM.toString()]: "Append to the end of the document", 38 | [Location.APPEND_CURRENT.toString()]: 39 | "Append to the end of current selection", 40 | [Location.REPLACE_CURRENT.toString()]: "Replace the current selection", 41 | [Location.APPEND_TO_FILE.toString()]: "Append to a file specified below", 42 | }; 43 | 44 | const MODEL_NAMES: { [key: string]: string } = { 45 | [OpenAIModel.GPT_3_5_16k]: "OpenAI GPT-3.5-16k", 46 | [OpenAIModel.GPT_3_5_INSTRUCT]: "OpenAI GPT-3.5-instruct", 47 | [OpenAIModel.GPT_3_5_TURBO_PREVIEW]: "OpenAI GPT-3.5-turbo-preview", 48 | [OpenAIModel.GPT_3_5_TURBO]: "OpenAI GPT-3.5-turbo", 49 | [OpenAIModel.GPT_4]: "OpenAI GPT-4", 50 | [OpenAIModel.GPT_4_32K]: "OpenAI GPT-4-32k", 51 | [OpenAIModel.GPT_4_TURBO_PREVIEW]: "OpenAI GPT-4-turbo-preview", 52 | [OpenAIModel.GPT_4_TURBO]: "OpenAI GPT-4-turbo", 53 | [OpenAIModel.GPT_4O]: "OpenAI GPT-4o", 54 | [OpenAIModel.GPT_4O_AUDIO_PREVIEW]: "OpenAI GPT-4o-audio-preview", 55 | [OpenAIModel.GPT_4O_AUDIO_PREVIEW_2024_10_01]: "OpenAI GPT-4o-audio-preview-2024-10-01", 56 | [OpenAIModel.GPT_4O_MINI]: "OpenAI GPT-4o-mini", 57 | [OpenAIModel.GPT_4O_MINI_2024_07_18]: "OpenAI GPT-4o-mini-2024-07-18", 58 | [OpenAIModel.O1_MINI]: "OpenAI O1-mini", 59 | [OpenAIModel.O1_MINI_2024_09_12]: "OpenAI O1-mini-2024-09-12", 60 | [OpenAIModel.O1_PREVIEW]: "OpenAI O1-preview", 61 | [OpenAIModel.O1_PREVIEW_2024_09_12]: "OpenAI O1-preview-2024-09-12", 62 | [OpenAIModel.CHATGPT_4O_LATEST]: "OpenAI ChatGPT-4o-latest", 63 | [OpenAIModel.GPT_4O_2024_05_13]: "OpenAI GPT-4o-2024-05-13", 64 | [OpenAIModel.GPT_4O_2024_08_06]: "OpenAI GPT-4o-2024-08-06", 65 | [OpenAIModel.GPT_4O_2024_11_20]: "OpenAI GPT-4o-2024-11-20", 66 | [OpenAIModel.GPT_4_TURBO_PREVIEW_2024]: "OpenAI GPT-4-turbo-preview-2024", 67 | [OpenAIModel.GPT_4_0314]: "OpenAI GPT-4-0314", 68 | [OpenAIModel.GPT_4_0613]: "OpenAI GPT-4-0613", 69 | [OpenAIModel.GPT_4_32K_0314]: "OpenAI GPT-4-32k-0314", 70 | [OpenAIModel.GPT_4_32K_0613]: "OpenAI GPT-4-32k-0613", 71 | [OpenAIModel.GPT_4_TURBO_2024_04_09]: "OpenAI GPT-4-turbo-2024-04-09", 72 | [OpenAIModel.GPT_4_0125_PREVIEW]: "OpenAI GPT-4-0125-preview", 73 | [OpenAIModel.GPT_4_VISION_PREVIEW]: "OpenAI GPT-4-vision-preview", 74 | [OpenAIModel.GPT_4_1106_VISION_PREVIEW]: "OpenAI GPT-4-1106-vision-preview", 75 | [OpenAIModel.GPT_3_5_TURBO_0301]: "OpenAI GPT-3.5-turbo-0301", 76 | [OpenAIModel.GPT_3_5_TURBO_0613]: "OpenAI GPT-3.5-turbo-0613", 77 | [OpenAIModel.GPT_3_5_TURBO_0125]: "OpenAI GPT-3.5-turbo-0125", 78 | [OpenAIModel.GPT_3_5_TURBO_16K_0613]: "OpenAI GPT-3.5-turbo-16k-0613", 79 | [OpenAIModel.FT_GPT_3_5_TURBO]: "OpenAI FT-GPT-3.5-turbo", 80 | [OpenAIModel.FT_GPT_3_5_TURBO_0125]: "OpenAI FT-GPT-3.5-turbo-0125", 81 | [OpenAIModel.FT_GPT_3_5_TURBO_1106]: "OpenAI FT-GPT-3.5-turbo-1106", 82 | [OpenAIModel.FT_GPT_3_5_TURBO_0613]: "OpenAI FT-GPT-3.5-turbo-0613", 83 | [OpenAIModel.FT_GPT_4_0613]: "OpenAI FT-GPT-4-0613", 84 | [OpenAIModel.FT_GPT_4O_2024_08_06]: "OpenAI FT-GPT-4o-2024-08-06", 85 | [OpenAIModel.FT_GPT_4O_2024_11_20]: "OpenAI FT-GPT-4o-2024-11-20", 86 | [OpenAIModel.FT_GPT_4O_MINI_2024_07_18]: "OpenAI FT-GPT-4o-mini-2024-07-18", 87 | }; 88 | 89 | export function locationDictionary(): { [key: string]: string } { 90 | return Object.values(Location).reduce((obj, value) => { 91 | obj[value] = LOCATION_SETTING[value]; 92 | return obj; 93 | }, {} as { [key: string]: string }); 94 | } 95 | 96 | export function selectionDictionary(): { [key: string]: string } { 97 | return Object.values(Selection).reduce((obj, value) => { 98 | obj[value] = SELECTION_SETTING[value]; 99 | return obj; 100 | }, {} as { [key: string]: string }); 101 | } 102 | 103 | export function modelDictionary(): { [key: string]: string } { 104 | return Object.values(OpenAIModel).reduce((obj, value) => { 105 | obj[value] = MODEL_NAMES[value]; 106 | return obj; 107 | }, {} as { [key: string]: string }); 108 | } 109 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from "obsidian"; 2 | import { 3 | Location, 4 | Selection, 5 | UserAction, 6 | modelDictionary, 7 | } from "src/action"; 8 | import AIEditor from "src/main"; 9 | import { Model } from "./llm/models"; 10 | import { OpenAIModel } from "./llm/openai_llm"; 11 | import { ActionEditModal } from "./modals/action_editor"; 12 | 13 | 14 | export interface AIEditorSettings { 15 | openAiApiKey: string; 16 | testingMode: boolean; 17 | defaultModel: Model; 18 | customActions: Array; 19 | } 20 | 21 | export class AIEditorSettingTab extends PluginSettingTab { 22 | plugin: AIEditor; 23 | 24 | constructor(app: App, plugin: AIEditor) { 25 | super(app, plugin); 26 | this.plugin = plugin; 27 | } 28 | 29 | display(): void { 30 | const { containerEl } = this; 31 | 32 | containerEl.empty(); 33 | 34 | containerEl.createEl("h1", { text: "General" }); 35 | 36 | new Setting(containerEl).setName("OpenAI API Key").addText((text) => 37 | text 38 | .setPlaceholder("Enter your API key") 39 | .setValue(this.plugin.settings.openAiApiKey) 40 | .onChange(async (value) => { 41 | this.plugin.settings.openAiApiKey = value; 42 | await this.plugin.saveSettings(); 43 | }) 44 | ); 45 | new Setting(containerEl) 46 | .setName("Default LLM model") 47 | .addDropdown((dropdown) => 48 | dropdown 49 | .addOptions(modelDictionary()) 50 | .onChange((value) => { 51 | this.plugin.settings.defaultModel = 52 | value as OpenAIModel; 53 | this.plugin.saveSettings(); 54 | }) 55 | .setValue(this.plugin.settings.defaultModel.toString()) 56 | ); 57 | new Setting(containerEl) 58 | .setName("Testing mode") 59 | .setDesc( 60 | "Use testing mode to test custom action without calling to OpenAI API" 61 | ) 62 | .addToggle((toggle) => 63 | toggle 64 | .setValue(this.plugin.settings.testingMode) 65 | .onChange(async (value) => { 66 | this.plugin.settings.testingMode = value; 67 | await this.plugin.saveSettings(); 68 | }) 69 | ); 70 | containerEl.createEl("h1", { text: "Custom actions" }); 71 | 72 | this.createButton( 73 | containerEl, 74 | "Create custom action", 75 | "New", 76 | () => { 77 | this.displayActionEditModalForNewAction(); 78 | }, 79 | true 80 | ); 81 | 82 | for (let i = 0; i < this.plugin.settings.customActions.length; i++) { 83 | this.displayActionByIndex(containerEl, i); 84 | } 85 | } 86 | 87 | displayActionByIndex(containerEl: HTMLElement, index: number): void { 88 | const userAction = this.plugin.settings.customActions.at(index); 89 | if (userAction != undefined) { 90 | this.createButton(containerEl, userAction.name, "Edit", () => { 91 | this.displayActionEditModalByActionAndIndex(userAction, index); 92 | }); 93 | } 94 | } 95 | 96 | createButton( 97 | containerEl: HTMLElement, 98 | name: string, 99 | buttonText: string, 100 | onClickHandler: () => void, 101 | cta = false 102 | ): void { 103 | new Setting(containerEl).setName(name).addButton((button) => { 104 | button.setButtonText(buttonText).onClick(onClickHandler); 105 | if (cta) { 106 | button.setCta(); 107 | } 108 | }); 109 | } 110 | 111 | private displayActionEditModalForNewAction() { 112 | const DUMMY_ACTION: UserAction = { 113 | name: "Action Name", 114 | prompt: "Enter your prompt", 115 | sel: Selection.ALL, 116 | loc: Location.INSERT_HEAD, 117 | format: "{{result}}\n", 118 | modalTitle: "Check result", 119 | model: this.plugin.settings.defaultModel, 120 | }; 121 | new ActionEditModal( 122 | this.app, 123 | this.plugin, 124 | DUMMY_ACTION, 125 | async (action: UserAction) => { 126 | this.plugin.settings.customActions.push(action); 127 | await this.saveSettingsAndRefresh(); 128 | }, 129 | undefined 130 | ).open(); 131 | } 132 | 133 | private displayActionEditModalByActionAndIndex( 134 | userAction: UserAction, 135 | index: number 136 | ) { 137 | new ActionEditModal( 138 | this.app, 139 | this.plugin, 140 | userAction, 141 | async (action: UserAction) => { 142 | await this.saveUserActionAndRefresh(index, action); 143 | }, 144 | async () => { 145 | await this.deleteUserActionAndRefresh(index); 146 | } 147 | ).open(); 148 | } 149 | 150 | private async deleteUserActionAndRefresh(index: number) { 151 | const actionToDelete = this.plugin.settings.customActions.at(index); 152 | if (actionToDelete != undefined) { 153 | this.plugin.settings.customActions.remove(actionToDelete); 154 | await this.saveSettingsAndRefresh(); 155 | } 156 | } 157 | 158 | private async saveUserActionAndRefresh(index: number, action: UserAction) { 159 | this.plugin.settings.customActions[index] = action; 160 | await this.saveSettingsAndRefresh(); 161 | } 162 | 163 | private async saveSettingsAndRefresh() { 164 | await this.plugin.saveSettings(); 165 | this.plugin.registerActions(); 166 | this.display(); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/modals/action_editor.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | import { 3 | UserAction, 4 | selectionDictionary, 5 | Selection, 6 | Location, 7 | locationDictionary, 8 | modelDictionary, 9 | } from "../action"; 10 | import { OpenAIModel } from "../llm/openai_llm"; 11 | import { DeletionModal } from "./deletion"; 12 | import AIEditor from "src/main"; 13 | 14 | export class ActionEditModal extends Modal { 15 | action: UserAction; 16 | plugin: AIEditor; 17 | onSave: (userAction: UserAction) => void; 18 | onDelete?: () => void; 19 | 20 | constructor( 21 | app: App, 22 | plugin: AIEditor, 23 | user_action: UserAction, 24 | onSave: (userAction: UserAction) => void, 25 | onDelete?: () => void 26 | ) { 27 | super(app); 28 | this.plugin = plugin; 29 | this.action = user_action; 30 | this.onSave = onSave; 31 | this.onDelete = onDelete; 32 | } 33 | onOpen() { 34 | const { contentEl } = this; 35 | contentEl.empty(); 36 | 37 | contentEl.createEl("h1", { text: "Edit Action" }); 38 | 39 | this.createTextSetting( 40 | contentEl, 41 | "Action Name", 42 | "", 43 | this.action.name, 44 | async (value) => { 45 | this.action.name = value; 46 | } 47 | ); 48 | 49 | new Setting(contentEl) 50 | .setName("LLM Model selection") 51 | .setDesc("What model would be used to process your input") 52 | .addDropdown((dropdown) => { 53 | if (this.action.model == undefined) { 54 | this.action.model = 55 | this.plugin.settings.defaultModel.toString(); 56 | } 57 | dropdown 58 | .addOptions(modelDictionary()) 59 | .setValue(this.action.model.toString()) 60 | .onChange((value) => { 61 | this.action.model = value as OpenAIModel; 62 | }); 63 | }); 64 | 65 | this.createTextSetting( 66 | contentEl, 67 | "Prompt", 68 | "Prompt for LLM to process your input", 69 | this.action.prompt, 70 | async (value) => { 71 | this.action.prompt = value; 72 | } 73 | ); 74 | this.createTextSetting( 75 | contentEl, 76 | "Output Format", 77 | "Format your LLM output. Use {{result}} as placeholder.", 78 | this.action.format, 79 | async (value) => { 80 | this.action.format = value; 81 | } 82 | ); 83 | this.createTextSetting( 84 | contentEl, 85 | "Modal title", 86 | "Customize your confirmation window title", 87 | this.action.modalTitle, 88 | async (value) => { 89 | this.action.modalTitle = value; 90 | } 91 | ); 92 | new Setting(contentEl) 93 | .setName("Input selection") 94 | .setDesc("What input would be sent to LLM?") 95 | .addDropdown((dropdown) => { 96 | if (this.action.sel == undefined) { 97 | this.action.sel = Selection.ALL; 98 | } 99 | dropdown 100 | .addOptions(selectionDictionary()) 101 | .setValue(this.action.sel.toString()) 102 | .onChange((value) => { 103 | this.action.sel = value as Selection; 104 | }); 105 | }); 106 | new Setting(contentEl) 107 | .setName("Output location") 108 | .setDesc( 109 | "Where do you to put the generated output after formatting?" 110 | ) 111 | .addDropdown((dropdown) => { 112 | if (this.action.loc == undefined) { 113 | this.action.loc = Location.INSERT_HEAD; 114 | } 115 | dropdown 116 | .addOptions(locationDictionary()) 117 | .setValue(this.action.loc) 118 | .onChange((value) => { 119 | this.action.loc = value as Location; 120 | this.onOpen(); 121 | }); 122 | }); 123 | if (this.action.loc == Location.APPEND_TO_FILE) { 124 | new Setting(contentEl) 125 | .setName("File name") 126 | .setDesc("File name to append to") 127 | .addText((text) => { 128 | text.setPlaceholder("Enter file name") 129 | .setValue(this.action.locationExtra?.fileName || "") 130 | .onChange(async (value) => { 131 | this.action.locationExtra = { 132 | fileName: value, 133 | }; 134 | }); 135 | }); 136 | } 137 | 138 | new Setting(contentEl) 139 | .addButton((button) => { 140 | if (this.onDelete) { 141 | let onDelete = this.onDelete; 142 | button 143 | .setButtonText("Delete") 144 | .setWarning() 145 | .onClick(async () => { 146 | new DeletionModal(this.app, () => { 147 | onDelete(); 148 | this.close(); 149 | }).open(); 150 | }); 151 | } else { 152 | button.setButtonText("Ignore").onClick(() => { 153 | this.close(); 154 | }); 155 | } 156 | }) 157 | .addButton((button) => { 158 | button 159 | .setButtonText("Save") 160 | .setCta() 161 | .onClick(async () => { 162 | await this.onSave(this.action); 163 | this.close(); 164 | }); 165 | }); 166 | } 167 | 168 | onClose() { 169 | let { contentEl } = this; 170 | contentEl.empty(); 171 | } 172 | 173 | createTextSetting( 174 | containerEl: HTMLElement, 175 | name: string, 176 | desc: string, 177 | value: string, 178 | onSave: (newValue: string) => Promise 179 | ): void { 180 | new Setting(containerEl) 181 | .setName(name) 182 | .setDesc(desc) 183 | .addTextArea((text) => { 184 | text.setValue(value).onChange(async (newValue) => { 185 | await onSave(newValue); 186 | }); 187 | }); 188 | } 189 | } 190 | --------------------------------------------------------------------------------