├── .npmrc ├── bun.lockb ├── .prettierrc ├── env.d.ts ├── versions.json ├── .gitignore ├── src ├── queue.ts ├── services │ └── apiClient.ts ├── components │ ├── ModalLoading.ts │ ├── ModalOnboarding.ts │ ├── SetingTab.ts │ ├── ChatView.ts │ └── chat.ts ├── fns │ ├── createHelpLinks.ts │ ├── openView.ts │ └── createApiForm.ts ├── extends │ └── canvas │ │ └── types.ts └── main.ts ├── manifest.json ├── tsconfig.json ├── LICENSE ├── version-bump.mjs ├── CHANGELOG.md ├── git-conventional-commits.yaml ├── package.json ├── esbuild.config.mjs ├── styles.css ├── README.md └── .github └── workflows └── release.yml /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nguyenvanduocit/obsidian-ai-assistant/HEAD/bun.lockb -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | declare type FramePosition = 'left' | 'center' | 'right' 4 | 5 | declare type Message = { 6 | role: string 7 | content: string 8 | } 9 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.2.0": "0.15.0", 3 | "0.2.1": "0.15.0", 4 | "0.2.2": "0.15.0", 5 | "0.2.3": "0.15.0", 6 | "0.2.4": "0.15.0", 7 | "0.2.5": "0.15.0", 8 | "0.3.0": "0.15.0" 9 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/queue.ts: -------------------------------------------------------------------------------- 1 | const queue: Array = [] 2 | let onQueueAddedFn: () => void | undefined 3 | 4 | export const enqueue = (item: string) => { 5 | queue.push(item) 6 | 7 | if (onQueueAddedFn) { 8 | onQueueAddedFn() 9 | } 10 | } 11 | 12 | export const dequeue = () => { 13 | return queue.shift() 14 | } 15 | 16 | export const onQueueAdded = (callback: () => void) => { 17 | onQueueAddedFn = callback 18 | } 19 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ai-assistant", 3 | "name": "AI Assistant", 4 | "version": "0.3.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "A plugin for Obsidian that uses OpenAI's API to assist users in their note-taking and writing process", 7 | "author": "duocnv", 8 | "authorUrl": "https://twitter.com/duocdev", 9 | "fundingUrl": { 10 | "Buy Me a Coffee": "https://paypal.me/duocnguyen", 11 | "Follow me": "https://twitter.com/duocdev" 12 | } 13 | } -------------------------------------------------------------------------------- /src/services/apiClient.ts: -------------------------------------------------------------------------------- 1 | import OpenAI, {ClientOptions} from "openai"; 2 | 3 | 4 | let apiClient: OpenAI | null = null 5 | 6 | export const getOpenaiClient = (openaiApiKey: string): OpenAI => { 7 | 8 | if (!apiClient) { 9 | const configuration = { 10 | apiKey: openaiApiKey, 11 | dangerouslyAllowBrowser: true 12 | } as ClientOptions 13 | 14 | apiClient = new OpenAI(configuration) 15 | } 16 | 17 | return apiClient 18 | } 19 | 20 | -------------------------------------------------------------------------------- /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": ["DOM", "ES5", "ES6", "ES7"] 15 | }, 16 | "include": ["src", "env.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All Rights Reserved 2 | 3 | Copyright (c) 2023 Nguyen Van Duoc 4 | 5 | Created by Nguyen Van Duoc 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 8 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 9 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 10 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 11 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 12 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 13 | THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /src/components/ModalLoading.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian' 2 | 3 | export class ModalLoading extends Modal { 4 | constructor(app: App) { 5 | super(app) 6 | 7 | this.modalEl.addClass('ai-assistant-modal-loading') 8 | 9 | const { contentEl } = this 10 | contentEl.createEl('div', { 11 | text: 'AI Assistant is processing, to ensure the selected text is correct, we have to block the UI. Please wait a few seconds...', 12 | cls: 'center' 13 | }) 14 | } 15 | close() { 16 | 17 | } 18 | 19 | forceClose() { 20 | super.close() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/fns/createHelpLinks.ts: -------------------------------------------------------------------------------- 1 | export const createHelpLinks = (containerEl: HTMLElement) => { 2 | const helpContainerEl = containerEl.createDiv('help-container') 3 | helpContainerEl.createEl('a', { 4 | attr: { 5 | style: 'display:block', 6 | href: 'https://youtu.be/0cWN_JhoZm4' 7 | }, 8 | text: `AI file rename` 9 | }) 10 | 11 | helpContainerEl.createEl('a', { 12 | attr: { 13 | style: 'display:block', 14 | href: 'https://www.youtube.com/watch?v=qU3DSY7eXA8&ab_channel=fridayDeployment' 15 | }, 16 | text: `Summarize text` 17 | }) 18 | 19 | helpContainerEl.createEl('a', { 20 | attr: { 21 | style: 'display:block', 22 | href: 'https://www.youtube.com/watch?v=qU3DSY7eXA8&ab_channel=fridayDeployment' 23 | }, 24 | text: `Explain` 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.0.11](https://github.com/nguyenvanduocit/obsidian-ai-assistant/compare/0.0.10...0.0.11) (2023-02-05) 6 | 7 | ### [0.0.10](https://github.com/nguyenvanduocit/obsidian-ai-assistant/compare/0.0.9...0.0.10) (2023-02-05) 8 | 9 | ### Features 10 | 11 | - update ([9117005](https://github.com/nguyenvanduocit/obsidian-ai-assistant/commit/9117005186fed6fa2d287ca483faafd5ae3fe142)) 12 | 13 | ### [0.0.9](https://github.com/nguyenvanduocit/obsidian-ai-assistant/compare/0.0.8...0.0.9) (2023-02-05) 14 | 15 | ### [0.0.8](https://github.com/nguyenvanduocit/obsidian-ai-assistant/compare/0.0.7...0.0.8) (2023-02-05) 16 | 17 | ### [0.0.6](https://github.com/nguyenvanduocit/obsidian-ai-assistant/compare/0.0.7...0.0.6) (2023-02-05) 18 | -------------------------------------------------------------------------------- /git-conventional-commits.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | convention: 3 | commitTypes: 4 | - feat 5 | - fix 6 | - chore 7 | - perf 8 | - refactor 9 | - style 10 | - test 11 | - build 12 | - ops 13 | - docs 14 | - merge 15 | commitScopes: [] 16 | releaseTagGlobPattern: v[0-9]*.[0-9]*.[0-9]* 17 | changelog: 18 | commitTypes: 19 | - feat 20 | - fix 21 | - perf 22 | - merge 23 | includeInvalidCommits: true 24 | commitScopes: [] 25 | commitIgnoreRegexPattern: '^WIP ' 26 | headlines: 27 | feat: Features 28 | fix: Bug Fixes 29 | perf: Performance Improvements 30 | merge: Merges 31 | breakingChange: BREAKING CHANGES 32 | commitUrl: https://github.com/qoomon/git-conventional-commits/commit/%commit% 33 | commitRangeUrl: https://github.com/qoomon/git-conventional-commits/compare/%from%...%to%?diff=split 34 | issueRegexPattern: '#[0-9]+' 35 | issueUrl: https://github.com/qoomon/git-conventional-commits/issues/%issue% 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-ai-assistant", 3 | "version": "0.3.0", 4 | "description": "Embed any website to Obsidian, from now all, you have anything you need in one place.", 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 && git add manifest.json versions.json", 10 | "format": "prettier --write ." 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/chrome": "^0.0.203", 17 | "@types/node": "^16.11.6", 18 | "@typescript-eslint/eslint-plugin": "5.29.0", 19 | "@typescript-eslint/parser": "5.29.0", 20 | "builtin-modules": "3.3.0", 21 | "esbuild": "0.14.47", 22 | "obsidian": "latest", 23 | "prettier": "^2.8.1", 24 | "tslib": "2.4.0", 25 | "typescript": "4.7.4" 26 | }, 27 | "dependencies": { 28 | "@codemirror/language": "^6.6.0", 29 | "@codemirror/state": "^6.2.0", 30 | "@codemirror/view": "^6.9.1", 31 | "openai": "^4.20.0", 32 | "petite-vue": "^0.4.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/fns/openView.ts: -------------------------------------------------------------------------------- 1 | import { Workspace, WorkspaceLeaf } from 'obsidian' 2 | 3 | export const openView = async ( 4 | workspace: Workspace, 5 | id: string, 6 | position?: FramePosition 7 | ): Promise => { 8 | let leafs = workspace.getLeavesOfType(id) 9 | if (leafs.length > 0) { 10 | workspace.revealLeaf(leafs[0]) 11 | return 12 | } 13 | 14 | const leaf = await createView(workspace, id, position) 15 | workspace.revealLeaf(leaf) 16 | return 17 | } 18 | 19 | const createView = async ( 20 | workspace: Workspace, 21 | id: string, 22 | position?: FramePosition 23 | ): Promise => { 24 | let leaf: WorkspaceLeaf | undefined 25 | switch (position) { 26 | case 'left': 27 | leaf = workspace.getLeftLeaf(false) 28 | break 29 | case 'center': 30 | leaf = workspace.getLeaf(false) 31 | break 32 | case 'right': 33 | default: 34 | leaf = workspace.getRightLeaf(false) 35 | break 36 | } 37 | 38 | await leaf?.setViewState({ type: id, active: true }) 39 | 40 | return leaf 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ModalOnboarding.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian' 2 | import { createHelpLinks } from '../fns/createHelpLinks' 3 | 4 | export class ModalOnBoarding extends Modal { 5 | constructor(app: App) { 6 | super(app) 7 | } 8 | 9 | onOpen() { 10 | const { contentEl } = this 11 | contentEl.createEl('h2', { text: 'Welcome to AI Assistant' }) 12 | contentEl.createEl('p', { 13 | text: 'Before you start, take a look at the video below to see how it works.' 14 | }) 15 | 16 | createHelpLinks(contentEl) 17 | 18 | contentEl.createEl('p', { 19 | text: 'After that, please set your OpenAI API key in the plugin settings.' 20 | }) 21 | 22 | // add twitter link 23 | contentEl 24 | .createEl('p', { 25 | text: 'If you get any issues, please let me know on Twitter ' 26 | }) 27 | .createEl('a', { 28 | text: '@duocdev', 29 | cls: 'mod-cta', 30 | href: 'https://twitter.com/duocdev' 31 | }) 32 | } 33 | 34 | onClose() { 35 | const { contentEl } = this 36 | contentEl.empty() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | import process from 'process' 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | ` 10 | 11 | const prod = process.argv[2] === 'production' 12 | 13 | esbuild 14 | .build({ 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 | ], 36 | format: 'cjs', 37 | watch: !prod, 38 | target: 'es2018', 39 | logLevel: 'info', 40 | sourcemap: prod ? false : 'inline', 41 | treeShaking: true, 42 | outfile: 'main.js' 43 | }) 44 | .catch(() => process.exit(1)) 45 | -------------------------------------------------------------------------------- /src/components/SetingTab.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian' 2 | import AiAssistantPlugin from '../main' 3 | import { createApiForm } from '../fns/createApiForm' 4 | import { createHelpLinks } from '../fns/createHelpLinks' 5 | 6 | export class SettingTab extends PluginSettingTab { 7 | plugin: AiAssistantPlugin 8 | shouldNotify: boolean 9 | 10 | constructor(app: App, plugin: AiAssistantPlugin) { 11 | super(app, plugin) 12 | this.plugin = plugin 13 | } 14 | 15 | display(): void { 16 | this.shouldNotify = false 17 | const { containerEl } = this 18 | containerEl.empty() 19 | 20 | createApiForm(containerEl, this.plugin) 21 | 22 | containerEl.createEl('hr') 23 | 24 | containerEl.createEl('h3', { text: 'Help' }) 25 | 26 | createHelpLinks(containerEl) 27 | 28 | new Setting(containerEl) 29 | .setName('Follow me on Twitter') 30 | .setDesc('@duocdev') 31 | .addButton((button) => { 32 | button.setCta() 33 | button.setButtonText('Follow for update').onClick(() => { 34 | window.open('https://twitter.com/duocdev') 35 | }) 36 | }) 37 | .addButton((button) => { 38 | button.buttonEl.outerHTML = 39 | "" 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/ChatView.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf, Menu, Notice } from 'obsidian' 2 | import SharePlugin from '../main' 3 | export const VIEW_TYPE_CHAT = 'ai-assistant-chat-view' 4 | import { CreateApp } from './chat' 5 | export class ChatView extends ItemView { 6 | private readonly plugin: SharePlugin 7 | private responseEl: HTMLElement 8 | private chatView: any 9 | 10 | constructor(leaf: WorkspaceLeaf, plugin: SharePlugin) { 11 | super(leaf) 12 | this.plugin = plugin 13 | } 14 | 15 | async onload(): Promise { 16 | this.renderView() 17 | this.chatView = CreateApp(this.responseEl, this.plugin) 18 | } 19 | 20 | renderView(): void { 21 | this.contentEl.empty() 22 | this.contentEl.addClass('ai-assistant-view') 23 | 24 | this.responseEl = this.contentEl.createEl('div', { 25 | cls: 'ai-assistant--container' 26 | }) 27 | } 28 | 29 | onunload() { 30 | super.onunload() 31 | } 32 | 33 | onPaneMenu(menu: Menu, source: string): void { 34 | super.onPaneMenu(menu, source) 35 | menu.addItem((item) => { 36 | item.setTitle('Help...') 37 | item.setIcon('globe') 38 | item.onClick(() => { 39 | open('https://twitter.com/duocdev') 40 | }) 41 | }) 42 | } 43 | 44 | getViewType(): string { 45 | return VIEW_TYPE_CHAT 46 | } 47 | 48 | getDisplayText(): string { 49 | return 'AI Assistant' 50 | } 51 | 52 | getIcon(): string { 53 | return 'ai-assistant' 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .ai-assistant-view * { 2 | box-sizing: border-box; 3 | } 4 | 5 | .ai-assistant-view { 6 | padding: 10px; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .ai-assistant.svg-icon path { 12 | stroke: var(--icon-color) !important; 13 | } 14 | 15 | .ai-assistant-modal-loading .modal-close-button { 16 | display: none; 17 | } 18 | 19 | .ai-assistant--container { 20 | font-size: 15px; 21 | display: grid; 22 | height: 100%; 23 | grid-template-rows: 1fr 50px; 24 | grid-template-columns: 1fr; 25 | } 26 | 27 | .ai-assistant--messages { 28 | grid-row: 1 / 2; 29 | height: 100%; 30 | overflow-y: auto; 31 | } 32 | 33 | .ai-assistant--message { 34 | margin-bottom: 20px; 35 | } 36 | 37 | .ai-assistant--message.user { 38 | display: flex; 39 | justify-content: flex-end; 40 | } 41 | 42 | .ai-assistant--message-content { 43 | display: inline-block; 44 | padding: 10px; 45 | border-radius: 10px; 46 | background-color: #ffffff17; 47 | max-width: 90%; 48 | white-space: pre-wrap; 49 | } 50 | 51 | .ai-assistant--input { 52 | grid-row: 2 / 3; 53 | border-top: 1px solid var(--border-color); 54 | padding: 10px 10px; 55 | 56 | display: grid; 57 | grid-template-columns: 1fr 100px; 58 | } 59 | 60 | .ai-assistant--input-text { 61 | background: var(--background-modifier-form-field); 62 | border: var(--input-border-width) solid var(--background-modifier-border); 63 | color: var(--text-normal); 64 | font-family: inherit; 65 | padding: var(--size-4-1) var(--size-4-2); 66 | font-size: var(--font-ui-small); 67 | border-radius: var(--input-radius); 68 | outline: none; 69 | } 70 | 71 | .ai-assistant--input textarea { 72 | height: 100%; 73 | } 74 | 75 | .ai-assistant--input button { 76 | margin-left: 10px; 77 | } 78 | -------------------------------------------------------------------------------- /src/fns/createApiForm.ts: -------------------------------------------------------------------------------- 1 | import AiAssistantPlugin from '../main' 2 | import {Notice, Setting, TextComponent} from 'obsidian' 3 | 4 | export const createApiForm = ( 5 | containerEl: HTMLElement, 6 | plugin: AiAssistantPlugin 7 | ) => { 8 | let apiKey = plugin.settings.openaiApiKey 9 | let temperature = plugin.settings.temperature 10 | 11 | new Setting(containerEl) 12 | .setName('OpenAI API Key') 13 | .setDesc( 14 | 'You can get your API key from https://platform.openai.com/account/api-keys' 15 | ) 16 | .addText((text) => 17 | text 18 | .setValue(plugin.settings.openaiApiKey) 19 | .onChange(async (value) => { 20 | apiKey = value 21 | }) 22 | ) 23 | 24 | new Setting(containerEl) 25 | .setName('Model') 26 | .setDesc('Enter the model you want to use') 27 | .addText((text: TextComponent) => { 28 | text.setPlaceholder('Enter model here') 29 | .setValue(plugin.settings.model || '') 30 | .onChange(async (value: string) => { 31 | plugin.settings.model = value; 32 | await plugin.saveSettings(); 33 | }); 34 | }); 35 | 36 | // temperature 37 | new Setting(containerEl) 38 | .setName('Temperature') 39 | .setDesc( 40 | 'The temperature of the model. Higher values will make the model more creative and generate more surprising results, but also more mistakes. Try 0.9 for more creative results and 0 for more conservative results.' 41 | ) 42 | .addSlider((slider) => 43 | slider 44 | .setLimits(0, 1, 0.1) 45 | .setValue(plugin.settings.temperature) 46 | .onChange(async (value) => { 47 | temperature = value 48 | }) 49 | ) 50 | 51 | // save button 52 | new Setting(containerEl).addButton((button) => { 53 | button.setButtonText('Save settings').onClick(async () => { 54 | plugin.settings.openaiApiKey = apiKey 55 | plugin.settings.temperature = temperature 56 | 57 | await plugin.saveSettings() 58 | new Notice('AI Assistant settings saved.') 59 | }) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![IMAGE ALT TEXT HERE](https://i.ytimg.com/vi/6YktLCGpz5s/maxresdefault.jpg)](https://www.youtube.com/watch?v=6YktLCGpz5s) 2 | 3 | # Obsidian AI Assistant 4 | 5 | A plugin for Obsidian that uses OpenAI's API to assist users in their note-taking and writing process. 6 | 7 | ## Features 8 | 9 | - [x] File rename: Suggest a new name for the file based on its content. 10 | - [x] Text completion: Suggest text based on what the user has already written, making it easier to write and faster to complete thoughts. 11 | - [ ] Text correction: The plugin can identify and suggest corrections for spelling, grammar, and style errors in real-time, making it easier to produce error-free writing. 12 | - [ ] Question answering: Answer questions based on the text in the note, providing quick access to information without leaving Obsidian. 13 | 14 | ## Usage 15 | 16 | ### File rename 17 | 18 | 1. Right-click on a file in the file explorer and select "AI Rename". 19 | 2. The plugin will suggest a new name for the file based on its content. 20 | 21 | [View demo](https://www.youtube.com/watch?v=0cWN_JhoZm4) 22 | 23 | ### Text completion 24 | 25 | 1. Type your text in the editor. 26 | 2. Use palette command `AI Complete` to get suggestions. 27 | 28 | If your cursor is at the end of the paragraph, the plugin will suggest text to complete the paragraph. If your cursor is in the new line, the plugin will suggest text based on the whole document. 29 | 30 | [View demo](https://youtu.be/E0b9k6GlyL4) 31 | 32 | ### Text summarization 33 | 34 | 1. Select the text you want to summarize. 35 | 2. Press right-click and select "AI Summarize". 36 | 37 | [View demo](https://www.youtube.com/watch?v=qU3DSY7eXA8) 38 | 39 | ### Text explanation 40 | 41 | 1. Select the text you want to explain. 42 | 2. Press right-click and select "AI Explain". 43 | 44 | [View demo](https://www.youtube.com/watch?v=qU3DSY7eXA8) 45 | 46 | ### Question answering 47 | 48 | TBD 49 | 50 | ## Requirements 51 | 52 | - Obsidian v0.9.8 or later 53 | - An OpenAI API key 54 | 55 | ## Support 56 | 57 | For support, please open an issue on the GitHub repository or contact us via email. 58 | 59 | ## Contributing 60 | 61 | Contributions are welcome! Please see the contribution guidelines for more information. 62 | 63 | ## License 64 | 65 | The plugin is released under the MIT License. 66 | -------------------------------------------------------------------------------- /src/extends/canvas/types.ts: -------------------------------------------------------------------------------- 1 | import { EventRef, Menu, Point, View, Workspace } from "obsidian"; 2 | import { 3 | AllCanvasNodeData, 4 | CanvasEdgeData, 5 | CanvasData, 6 | NodeSide, 7 | } from "obsidian/canvas"; 8 | 9 | export type Canvas = { 10 | menu: { 11 | selection: { 12 | bbox: CanvasBBox; 13 | }; 14 | }; 15 | nodes: Map; 16 | edges: Map; 17 | data: CanvasData; 18 | view: View; 19 | pointer: Point; 20 | requestSave: () => void; 21 | removeNode: (node: CanvasNode) => void; 22 | removeEdge: (node: CanvasEdge) => void; 23 | createTextNode: (args: { 24 | pos: Point; 25 | text?: string; 26 | size?: Size; 27 | }) => CanvasNode; 28 | selection: Set; 29 | edgeTo: Map>; 30 | edgeFrom: Map>; 31 | [key: string]: any; 32 | }; 33 | 34 | export type CanvasNode = { 35 | getData: () => AllCanvasNodeData; 36 | setColor: (color: string) => void; 37 | setIsEditing: (issEditing: boolean) => void; 38 | x: number; 39 | y: number; 40 | width: number; 41 | height: number; 42 | bbox: CanvasBBox; 43 | nodeEl: HTMLDivElement; 44 | canvas: Canvas; 45 | [key: string]: any; 46 | }; 47 | export type CanvasEdge = { 48 | getData: () => CanvasEdgeData; 49 | from: { side: NodeSide; node: CanvasNode; end: NodeSide }; 50 | to: { side: NodeSide; node: CanvasNode; end: NodeSide }; 51 | update: ( 52 | from: { side: NodeSide; node: CanvasNode; end: NodeSide }, 53 | to: { side: NodeSide; node: CanvasNode; end: NodeSide } 54 | ) => void; 55 | [key: string]: any; 56 | }; 57 | 58 | export type Size = { width: number; height: number }; 59 | 60 | export type CanvasBBox = { 61 | minX: number; 62 | minY: number; 63 | maxX: number; 64 | maxY: number; 65 | }; 66 | 67 | export type WorkspaceWithCanvas = { 68 | on( 69 | name: "canvas:creation-menu", 70 | callback: (menu: Menu, canvas: Canvas, pos: Point, size?: Size) => any, 71 | ctx?: any 72 | ): EventRef; 73 | on( 74 | name: "canvas:node:initialize", 75 | callback: (node: CanvasNode) => any, 76 | ctx?: any 77 | ): EventRef; 78 | on( 79 | name: "canvas:node-menu", 80 | callback: (menu: Menu, node: CanvasNode) => any, 81 | ctx?: any 82 | ): EventRef; 83 | on( 84 | name: "canvas:node-connection-drop-menu", 85 | callback: (menu: Menu, from: CanvasNode, edge: CanvasEdge) => any, 86 | ctx?: any 87 | ): EventRef; 88 | on( 89 | name: "canvas:edge-menu", 90 | callback: (menu: Menu, edge: CanvasEdge) => any, 91 | ctx?: any 92 | ): EventRef; 93 | on( 94 | name: "canvas:selection-menu", 95 | callback: (menu: Menu, canvas: Canvas) => any, 96 | ctx?: any 97 | ): EventRef; 98 | } & Workspace; 99 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | env: 8 | PLUGIN_NAME: obsidian-ai-assistant 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: '14.x' 18 | - name: Build 19 | id: build 20 | run: | 21 | yarn 22 | yarn run build --if-present 23 | mkdir ${{ env.PLUGIN_NAME }} 24 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 25 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 26 | ls 27 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 28 | - name: Create Release 29 | id: create_release 30 | uses: actions/create-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | VERSION: ${{ github.ref }} 34 | with: 35 | tag_name: ${{ github.ref }} 36 | release_name: ${{ github.ref }} 37 | draft: false 38 | prerelease: false 39 | - name: Upload zip file 40 | id: upload-zip 41 | uses: actions/upload-release-asset@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | with: 45 | upload_url: ${{ steps.create_release.outputs.upload_url }} 46 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 47 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 48 | asset_content_type: application/zip 49 | - name: Upload main.js 50 | id: upload-main 51 | uses: actions/upload-release-asset@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | upload_url: ${{ steps.create_release.outputs.upload_url }} 56 | asset_path: ./main.js 57 | asset_name: main.js 58 | asset_content_type: text/javascript 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 | 70 | - name: Upload 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 | -------------------------------------------------------------------------------- /src/components/chat.ts: -------------------------------------------------------------------------------- 1 | import { createApp, nextTick } from 'petite-vue' 2 | import SharePlugin from '../main' 3 | import { dequeue, onQueueAdded } from '../queue' 4 | 5 | const template = ` 6 |
7 |
8 |
9 |
10 |
{{loadingMessage}}
11 |
12 | 13 |
14 |
15 | 16 |
17 | ` 18 | 19 | const loadingMessages = [ 20 | 'Thinking...', 21 | 'Processing...', 22 | 'Wait a second...', 23 | "I'm working on it...", 24 | 'Loading... almost there!', 25 | "I'm brewing up some fresh responses, just for you!" 26 | ] 27 | 28 | export const CreateApp = (el: HTMLElement, plugin: SharePlugin) => { 29 | el.innerHTML = template 30 | const app = createApp({ 31 | messages: Array(), 32 | currentMessage: '', 33 | loadingMessage: '', 34 | 35 | mounted() { 36 | this.processQueue() 37 | onQueueAdded(async () => { 38 | await this.processQueue() 39 | }) 40 | }, 41 | 42 | async regenerate() { 43 | this.messages.pop() 44 | const response = await plugin.createCompletion(this.messages) 45 | this.messages.push({ role: 'assistant', content: response }) 46 | 47 | await this.scrollToBottom() 48 | }, 49 | 50 | async processQueue() { 51 | const queuedText = dequeue() 52 | if (queuedText) { 53 | this.currentMessage = queuedText 54 | await this.processRequest() 55 | } 56 | }, 57 | 58 | async onEnter(e: Event) { 59 | const target = e.target as HTMLElement 60 | if (target.innerText.trim() === '') { 61 | return 62 | } 63 | const keyboardEv = e as KeyboardEvent 64 | if (keyboardEv.shiftKey) { 65 | return 66 | } 67 | e.preventDefault() 68 | 69 | this.currentMessage = target.innerText.trim() 70 | target.innerText = '' 71 | 72 | await this.processRequest() 73 | }, 74 | 75 | async processRequest() { 76 | this.messages.push({ role: 'user', content: this.currentMessage }) 77 | this.currentMessage = '' 78 | await this.scrollToBottom() 79 | 80 | this.loadingMessage = 81 | loadingMessages[ 82 | Math.floor(Math.random() * loadingMessages.length) 83 | ] 84 | const response = await plugin.createCompletion(this.messages) 85 | this.messages.push({ role: 'assistant', content: response }) 86 | await this.scrollToBottom() 87 | 88 | this.loadingMessage = '' 89 | }, 90 | async scrollToBottom() { 91 | await nextTick(() => { 92 | // scroll to the last message 93 | const messages = el.querySelector('.ai-assistant--messages') 94 | if (messages) { 95 | messages.scrollTop = messages.scrollHeight 96 | } 97 | }) 98 | } 99 | }) 100 | 101 | app.mount(el) 102 | 103 | return app 104 | } 105 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {addIcon, Editor, MarkdownFileInfo, MarkdownView, Menu, Notice, Plugin, Point, TFile} from 'obsidian' 2 | import {SettingTab} from './components/SetingTab' 3 | import {ModalOnBoarding} from './components/ModalOnboarding' 4 | import {openView} from './fns/openView' 5 | import {enqueue} from './queue' 6 | import {ChatView, VIEW_TYPE_CHAT} from './components/ChatView' 7 | import {ModalLoading} from './components/ModalLoading' 8 | import OpenAI from "openai"; 9 | import {getOpenaiClient} from "./services/apiClient"; 10 | import {CanvasNode, WorkspaceWithCanvas} from "./extends/canvas/types"; 11 | 12 | interface PluginSetting { 13 | isFirstRun: boolean 14 | openaiApiKey: string 15 | temperature: number 16 | model: string 17 | explainTemplate: string 18 | summarizeTemplate: string 19 | fixWritingTemplate: string 20 | makeShorter: string 21 | makeLonger: string 22 | } 23 | 24 | const DEFAULT_SETTINGS: Partial = { 25 | isFirstRun: true, 26 | openaiApiKey: '', 27 | temperature: 0.5, 28 | model: 'gpt-3.5-turbo', 29 | explainTemplate: `explain:\n\n"""\n{text}\n"""`, 30 | summarizeTemplate: `{text}\n\nTl;dr`, 31 | fixWritingTemplate: `Correct this to standard English: \n\n"""\n{text}\n"""`, 32 | makeShorter: `Make this paragraph shorter: \n\n"""\n{text}\n"""`, 33 | makeLonger: `Make this paragraph longer: \n\n"""\n{text}\n"""` 34 | } 35 | 36 | export default class AiAssistantPlugin extends Plugin { 37 | private loadingModal: ModalLoading | null = null 38 | private openai: OpenAI | null = null 39 | private statusBarItem: HTMLElement | null = null 40 | 41 | getOpenaiClient(): OpenAI { 42 | if (this.settings.openaiApiKey === '') { 43 | new Notice('Please set OpenAI API key') 44 | throw new Error('Please set OpenAI API key') 45 | } 46 | if (!this.openai) { 47 | this.openai = getOpenaiClient(this.settings.openaiApiKey) 48 | } 49 | 50 | return this.openai 51 | } 52 | 53 | async onload() { 54 | await this.loadSettings() 55 | 56 | // onboarding 57 | if (this.settings.isFirstRun) { 58 | new ModalOnBoarding(this.app).open() 59 | 60 | this.settings.isFirstRun = false 61 | await this.saveSettings() 62 | } 63 | 64 | // check if api key is set 65 | if (!this.settings.openaiApiKey) { 66 | this.updateStatusBar('Please set OpenAI API key') 67 | } 68 | 69 | this.addSettingTab(new SettingTab(this.app, this)) 70 | 71 | this.registerView(VIEW_TYPE_CHAT, (leaf) => { 72 | return new ChatView(leaf, this) 73 | }) 74 | 75 | this.setupIcon() 76 | this.setupCommands() 77 | this.setupFileMenu() 78 | this.setupEditorMenu() 79 | this.setupCanvasMenu() 80 | } 81 | 82 | setupIcon() { 83 | addIcon( 84 | 'ai-assistant', 85 | `` 86 | ) 87 | 88 | this.addRibbonIcon('ai-assistant', 'AI Assistant', async () => { 89 | await openView(this.app.workspace, VIEW_TYPE_CHAT) 90 | }) 91 | } 92 | 93 | setupEditorMenu() { 94 | this.registerEvent(this.app.workspace.on('editor-menu', this.setupEditorMenuEvents.bind(this))) 95 | } 96 | 97 | setupCanvasMenu() { 98 | const workspace = this.app.workspace as unknown as WorkspaceWithCanvas; 99 | 100 | this.registerEvent( 101 | workspace.on('canvas:node-menu', (menu: Menu, node: CanvasNode) => { 102 | menu.addSeparator(); 103 | menu.addItem((item) => 104 | item 105 | .setTitle('Copy style') 106 | .setSection('extra') 107 | .onClick(() => { 108 | // Some node action 109 | }) 110 | ); 111 | }) 112 | ); 113 | } 114 | 115 | async onunload() { 116 | this.app.workspace.detachLeavesOfType(VIEW_TYPE_CHAT) 117 | } 118 | 119 | showLoadingModal() { 120 | if (this.loadingModal === null) { 121 | this.loadingModal = new ModalLoading(this.app) 122 | } 123 | 124 | this.loadingModal.open() 125 | } 126 | 127 | hideLoadingModal() { 128 | if (this.loadingModal !== null) { 129 | this.loadingModal.forceClose() 130 | } 131 | } 132 | 133 | setupCommands() { 134 | this.addCommand({ 135 | id: 'complete', 136 | name: 'Complete', 137 | editorCallback: async (editor: Editor) => { 138 | await this.aiComplete(editor) 139 | } 140 | }) 141 | 142 | this.addCommand({ 143 | id: 'rename-file', 144 | name: 'Rename file', 145 | editorCallback: async (editor: Editor) => { 146 | await this.aiRenameFile( 147 | this.app.workspace.getActiveFile() as TFile 148 | ) 149 | } 150 | }) 151 | } 152 | 153 | setupFileMenu() { 154 | // file rename 155 | this.registerEvent( 156 | this.app.workspace.on('file-menu', (menu, file) => { 157 | menu.addItem((item) => { 158 | item.setTitle('AI Rename') 159 | .setIcon('document') 160 | .onClick(async () => { 161 | try { 162 | await this.aiRenameFile(file as TFile) 163 | } catch (e) { 164 | this.clearStatusBarItem() 165 | new Notice('Error: ' + e) 166 | } 167 | }) 168 | }) 169 | }) 170 | ) 171 | } 172 | 173 | async openRightView(instruction: string, model?: string) { 174 | enqueue(instruction) 175 | await openView(this.app.workspace, VIEW_TYPE_CHAT) 176 | } 177 | 178 | setupEditorMenuEvents(menu: Menu, editor: Editor, info: MarkdownView | MarkdownFileInfo) { 179 | 180 | 181 | const selection = editor.getSelection().trim() 182 | 183 | if (selection === '') { 184 | menu.addItem((item) => { 185 | item.setTitle('AI complete').onClick(async () => { 186 | await this.aiComplete(editor) 187 | }) 188 | }) 189 | } 190 | 191 | menu.addItem((item) => { 192 | item.setTitle('AI translate').onClick( 193 | async () => { 194 | this.showLoadingModal() 195 | const translated = await this.translate(selection) 196 | if (translated) { 197 | editor.replaceSelection(translated) 198 | } 199 | this.hideLoadingModal() 200 | } 201 | ) 202 | }) 203 | 204 | menu.addItem((item) => { 205 | item.setTitle('AI explain').onClick(async () => { 206 | await this.openRightView( 207 | this.settings.explainTemplate.replace( 208 | '{text}', 209 | selection 210 | ) 211 | ) 212 | }) 213 | }) 214 | 215 | menu.addItem((item) => { 216 | item.setTitle('AI fix spelling & grammar').onClick( 217 | async () => { 218 | await this.openRightView( 219 | this.settings.fixWritingTemplate.replace( 220 | '{text}', 221 | selection 222 | ) 223 | ) 224 | } 225 | ) 226 | }) 227 | 228 | menu.addItem((item) => { 229 | item.setTitle('AI make shorter').onClick(async () => { 230 | await this.openRightView( 231 | this.settings.makeShorter.replace( 232 | '{text}', 233 | selection 234 | ) 235 | ) 236 | }) 237 | }) 238 | 239 | menu.addItem((item) => { 240 | item.setTitle('AI make longer').onClick(async () => { 241 | 242 | await this.openRightView( 243 | this.settings.makeLonger.replace( 244 | '{text}', 245 | selection 246 | ) 247 | ) 248 | }) 249 | }) 250 | 251 | if (editor.getSelection().split(' ').length > 10) { 252 | menu.addItem((item) => { 253 | item.setTitle('AI summarize').onClick(async () => { 254 | await this.openRightView( 255 | this.settings.summarizeTemplate.replace( 256 | '{text}', 257 | selection 258 | ) 259 | ) 260 | }) 261 | }) 262 | } 263 | } 264 | 265 | // use openai to complete text, then replace selection 266 | async aiComplete(editor: Editor) { 267 | const cursor = editor.getCursor() 268 | const line = editor.getLine(cursor.line) 269 | 270 | this.showLoadingModal() 271 | let content: string | null 272 | 273 | if (line.trim() === '') { 274 | content = await this.createCompletion( 275 | 'continue write this:' + editor.getValue() 276 | ) 277 | } else { 278 | const text = line.substring(0, cursor.ch) 279 | content = await this.createCompletion('continue write this:' + text) 280 | } 281 | 282 | if (content) { 283 | editor.replaceSelection(content) 284 | } 285 | 286 | this.hideLoadingModal() 287 | } 288 | 289 | async aiRenameFile(file: TFile) { 290 | this.showLoadingModal() 291 | 292 | let fileContent = await this.app.vault.read(file) 293 | 294 | new Notice('Generating file name...') 295 | // update status bar 296 | const prompt = 297 | `Write short title for this note, follow best practice, straightforward, do not use special characters: \n\n` + fileContent + `\n\n` 298 | 299 | let fileName = await this.createCompletion(prompt) 300 | if (!fileName) { 301 | new Notice('Cannot generate file name') 302 | return 303 | } 304 | 305 | // trim " and ' 306 | fileName = fileName.replace(/^['"]/, '').replace(/['"]$/, '') 307 | if (!fileName) { 308 | new Notice('Cannot generate file name') 309 | return 310 | } 311 | // obsidian rename file 312 | const newFilePath = file.path.replace(file.basename, fileName) 313 | await this.app.vault.rename(file, newFilePath) 314 | new Notice('File renamed') 315 | 316 | this.hideLoadingModal() 317 | } 318 | 319 | async createCompletion( 320 | prompt: string, 321 | ): Promise { 322 | const client = getOpenaiClient(this.settings.openaiApiKey) 323 | const runner = client.beta.chat.completions 324 | .stream({ 325 | model: this.settings.model, 326 | messages: [{role: 'user', content: prompt}], 327 | }) 328 | 329 | const result = await runner.finalChatCompletion(); 330 | 331 | return result.choices[0].message.content 332 | } 333 | 334 | // status bar 335 | updateStatusBar(text: string) { 336 | if (this.statusBarItem === null) { 337 | this.statusBarItem = this.addStatusBarItem() 338 | } 339 | 340 | this.statusBarItem.innerText = text 341 | } 342 | 343 | clearStatusBarItem() { 344 | if (this.statusBarItem && this.statusBarItem.innerText !== '') { 345 | this.statusBarItem.innerText = '' 346 | } 347 | } 348 | 349 | // settings 350 | settings: PluginSetting 351 | 352 | async loadSettings() { 353 | this.settings = await this.loadData() 354 | // merge default settings 355 | this.settings = Object.assign({}, DEFAULT_SETTINGS, this.settings) 356 | } 357 | 358 | async saveSettings() { 359 | await this.saveData(this.settings) 360 | } 361 | 362 | private async translate(text: string): Promise { 363 | const client = getOpenaiClient(this.settings.openaiApiKey); 364 | const runner = client.beta.chat.completions.stream({ 365 | model: this.settings.model, 366 | messages: [{ 367 | role: 'system', 368 | content: 'You are a interpreter, when user say something in English, you translate it to Vietnamese and vice versa.' 369 | }, 370 | {role: 'user', content: `${text}`}], 371 | }); 372 | 373 | const result = await runner.finalChatCompletion(); 374 | 375 | return result.choices[0].message.content; 376 | } 377 | } 378 | --------------------------------------------------------------------------------