├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── __mocks__ ├── electron.ts ├── obsidian.ts └── openai.js ├── copy-files-plugin.mjs ├── esbuild.config.mjs ├── jest.config.js ├── jest.setup.js ├── manifest.json ├── package-lock.json ├── package.json ├── packages ├── example-plugin │ ├── copy-files-plugin.mjs │ ├── esbuild.config.mjs │ ├── main.test.ts │ ├── main.ts │ ├── manifest.json │ ├── package.json │ ├── styles.css │ └── tsconfig.json └── sdk │ ├── README.md │ ├── copy-files-plugin.mjs │ ├── esbuild.config.mjs │ ├── index.ts │ ├── package.json │ ├── styles.css │ ├── tsconfig.json │ └── types.d.ts ├── src ├── AIProvidersService.ts ├── handlers │ ├── OllamaHandler.test.ts │ ├── OllamaHandler.ts │ ├── OpenAIHandler.test.ts │ └── OpenAIHandler.ts ├── i18n │ ├── de.json │ ├── en.json │ ├── index.ts │ ├── ru.json │ └── zh.json ├── main.ts ├── modals │ ├── ConfirmationModal.test.ts │ ├── ConfirmationModal.ts │ ├── ProviderFormModal.test.ts │ └── ProviderFormModal.ts ├── settings.test.ts ├── settings.ts ├── styles.css └── utils │ ├── electronFetch.test.ts │ ├── electronFetch.ts │ ├── icons.ts │ ├── logger.ts │ ├── obsidianFetch.test.ts │ └── obsidianFetch.ts ├── test-utils └── createAIHandlerTests.ts ├── tsconfig.json ├── version-bump.mjs └── versions.json /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.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 | 24 | dist 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Pavel Frankov 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian AI Providers 2 | 3 | ⚠️ Important Note: 4 | This plugin is a configuration tool - it helps you manage your AI settings in one place. 5 | 6 | Think of it like a control panel where you can: 7 | - Store your API keys and settings for AI services 8 | - Share these settings with other Obsidian plugins 9 | - Avoid entering the same AI settings multiple times 10 | 11 | **The plugin itself doesn't do any AI processing - it just helps other plugins connect to AI services more easily.** 12 | 13 | image 14 | 15 | ## Required by plugins 16 | - [Local GPT](https://github.com/pfrankov/obsidian-local-gpt) 17 | 18 | ## Supported providers 19 | - Ollama 20 | - OpenAI 21 | - OpenAI compatible API 22 | - OpenRouter 23 | - Google Gemini 24 | - LM Studio 25 | - Groq 26 | 27 | ## Features 28 | - Fully encapsulated API for working with AI providers 29 | - Develop AI plugins faster without dealing directly with provider-specific APIs 30 | - Easily extend support for additional AI providers in your plugin 31 | - Available in 4 languages: English, Chinese, German, and Russian (more languages coming soon) 32 | 33 | ## Installation 34 | ### Obsidian plugin store (recommended) 35 | This plugin is available in the Obsidian community plugin store https://obsidian.md/plugins?id=ai-providers 36 | 37 | ### BRAT 38 | You can install this plugin via [BRAT](https://obsidian.md/plugins?id=obsidian42-brat): `pfrankov/obsidian-ai-providers` 39 | 40 | ## Create AI provider 41 | ### Ollama 42 | 1. Install [Ollama](https://ollama.com/). 43 | 2. Install Gemma 2 `ollama pull gemma2` or any preferred model [from the library](https://ollama.com/library). 44 | 3. Select `Ollama` in `Provider type` 45 | 4. Click refresh button and select the model that suits your needs (e.g. `gemma2`) 46 | 47 | Additional: if you have issues with streaming completion with Ollama try to set environment variable `OLLAMA_ORIGINS` to `*`: 48 | - For MacOS run `launchctl setenv OLLAMA_ORIGINS "*"`. 49 | - For Linux and Windows [check the docs](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server). 50 | 51 | ### OpenAI 52 | 1. Select `OpenAI` in `Provider type` 53 | 2. Set `Provider URL` to `https://api.openai.com/v1` 54 | 3. Retrieve and paste your `API key` from the [API keys page](https://platform.openai.com/api-keys) 55 | 4. Click refresh button and select the model that suits your needs (e.g. `gpt-4o`) 56 | 57 | ### OpenAI compatible server 58 | There are several options to run local OpenAI-like server: 59 | - [Open WebUI](https://docs.openwebui.com/tutorials/integrations/continue-dev/) 60 | - [llama.cpp](https://github.com/ggerganov/llama.cpp) 61 | - [llama-cpp-python](https://github.com/abetlen/llama-cpp-python#openai-compatible-web-server) 62 | - [LocalAI](https://localai.io/model-compatibility/llama-cpp/#setup) 63 | - Obabooga [Text generation web UI](https://github.com/pfrankov/obsidian-local-gpt/discussions/8) 64 | - [LM Studio](https://lmstudio.ai/) 65 | - ...maybe more 66 | 67 | ### OpenRouter 68 | 1. Select `OpenRouter` in `Provider type` 69 | 2. Set `Provider URL` to `https://openrouter.ai/api/v1` 70 | 3. Retrieve and paste your `API key` from the [API keys page](https://openrouter.ai/settings/keys) 71 | 4. Click refresh button and select the model that suits your needs (e.g. `anthropic/claude-3.7-sonnet`) 72 | 73 | ### Google Gemini 74 | 1. Select `Google Gemini` in `Provider type` 75 | 2. Set `Provider URL` to `https://generativelanguage.googleapis.com/v1beta/openai` 76 | 3. Retrieve and paste your `API key` from the [API keys page](https://aistudio.google.com/apikey) 77 | 4. Click refresh button and select the model that suits your needs (e.g. `gemini-1.5-flash`) 78 | 79 | ### LM Studio 80 | 1. Select `LM Studio` in `Provider type` 81 | 2. Set `Provider URL` to `http://localhost:1234/v1` 82 | 3. Click refresh button and select the model that suits your needs (e.g. `gemma2`) 83 | 84 | ### Groq 85 | 1. Select `Groq` in `Provider type` 86 | 2. Set `Provider URL` to `https://api.groq.com/openai/v1` 87 | 3. Retrieve and paste your `API key` from the [API keys page](https://groq.com/docs/api-reference/introduction) 88 | 4. Click refresh button and select the model that suits your needs (e.g. `llama3-70b-8192`) 89 | 90 | ## For plugin developers 91 | [Docs: How to integrate AI Providers in your plugin.](./packages/sdk/README.md) 92 | 93 | ## Roadmap 94 | - [x] Docs for devs 95 | - [x] Ollama context optimizations 96 | - [x] Image processing support 97 | - [x] OpenRouter Provider support 98 | - [x] Gemini Provider support 99 | - [x] LM Studio Provider support 100 | - [x] Groq Provider support 101 | - [x] Passing messages instead of one prompt 102 | - [ ] Anthropic Provider support 103 | - [ ] Shared embeddings to avoid re-embedding the same documents multiple times 104 | - [ ] Spanish, Italian, French, Dutch, Portuguese, Japanese, Korean translations 105 | - [ ] Incapsulated basic RAG search with optional BM25 search 106 | 107 | ## My other Obsidian plugins 108 | - [Local GPT](https://github.com/pfrankov/obsidian-local-gpt) that assists with local AI for maximum privacy and offline access. 109 | - [Colored Tags](https://github.com/pfrankov/obsidian-colored-tags) that colorizes tags in distinguishable colors. -------------------------------------------------------------------------------- /__mocks__/electron.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | interface MockResponse { 4 | statusCode: number; 5 | headers: Record; 6 | on(event: string, callback: (data?: any) => void): void; 7 | } 8 | 9 | class MockRequest extends EventEmitter { 10 | write(data: any) {} 11 | end() { 12 | // Simulate successful response 13 | const mockResponse: MockResponse = { 14 | statusCode: 200, 15 | headers: {}, 16 | on: (event: string, callback: (data?: any) => void) => { 17 | if (event === 'data') { 18 | callback(Buffer.from('mock response')); 19 | } 20 | if (event === 'end') { 21 | callback(); 22 | } 23 | } 24 | }; 25 | 26 | process.nextTick(() => { 27 | this.emit('response', mockResponse); 28 | }); 29 | } 30 | } 31 | 32 | export const remote = { 33 | net: { 34 | request: jest.fn().mockImplementation(() => { 35 | return new MockRequest(); 36 | }) 37 | } 38 | }; 39 | 40 | export default { remote }; -------------------------------------------------------------------------------- /__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export class App { 2 | constructor() {} 3 | } 4 | export class Notice { 5 | constructor(message: string) {} 6 | } 7 | 8 | export function addIcon(name: string, icon: string) {} 9 | 10 | export function setIcon(el: HTMLElement, name: string) { 11 | el.setAttribute('data-icon', name); 12 | } 13 | 14 | export class Plugin { 15 | app: App; 16 | manifest: any; 17 | settingTabs: PluginSettingTab[] = []; 18 | 19 | constructor(app: App, manifest: any) { 20 | this.app = app; 21 | this.manifest = manifest; 22 | } 23 | 24 | addSettingTab(tab: PluginSettingTab): void { 25 | this.settingTabs.push(tab); 26 | } 27 | 28 | loadData(): Promise { 29 | return Promise.resolve({}); 30 | } 31 | 32 | saveData(data: any): Promise { 33 | return Promise.resolve(); 34 | } 35 | } 36 | 37 | export class PluginSettingTab { 38 | app: App; 39 | plugin: Plugin; 40 | containerEl: HTMLElement; 41 | 42 | constructor(app: App, plugin: Plugin) { 43 | this.app = app; 44 | this.plugin = plugin; 45 | this.containerEl = document.createElement('div'); 46 | this.containerEl.empty = function() { 47 | while (this.firstChild) { 48 | this.removeChild(this.firstChild); 49 | } 50 | }; 51 | this.containerEl.createEl = function(tag: string, attrs?: { text?: string }): HTMLElement { 52 | const el = document.createElement(tag); 53 | if (attrs?.text) { 54 | el.textContent = attrs.text; 55 | } 56 | this.appendChild(el); 57 | return el; 58 | }; 59 | this.containerEl.createDiv = function(className?: string): HTMLElement { 60 | const div = document.createElement('div'); 61 | if (className) { 62 | div.className = className; 63 | } 64 | this.appendChild(div); 65 | return div; 66 | }; 67 | } 68 | 69 | display(): void { 70 | // Implement in child class 71 | } 72 | 73 | hide(): void { 74 | // Implement in child class 75 | } 76 | } 77 | 78 | export class Setting { 79 | nameEl: HTMLElement; 80 | descEl: HTMLElement; 81 | controlEl: HTMLElement; 82 | settingEl: HTMLElement; 83 | onChange: ((value: string) => any) | undefined; 84 | 85 | constructor(containerEl: HTMLElement) { 86 | this.settingEl = containerEl.createDiv('setting-item'); 87 | this.nameEl = this.settingEl.createDiv('setting-item-name'); 88 | this.descEl = this.settingEl.createDiv('setting-item-description'); 89 | this.controlEl = this.settingEl.createDiv('setting-item-control'); 90 | 91 | (this.settingEl as any).__setting = this; 92 | } 93 | 94 | setName(name: string): this { 95 | this.nameEl.textContent = name; 96 | return this; 97 | } 98 | 99 | setHeading() { 100 | this.nameEl.classList.add('setting-item-heading'); 101 | return this; 102 | } 103 | 104 | setClass(className: string): this { 105 | this.nameEl.classList.add(className); 106 | return this; 107 | } 108 | 109 | setDesc(desc: string): this { 110 | this.descEl.textContent = desc; 111 | return this; 112 | } 113 | 114 | addButton(cb: (button: ButtonComponent) => any): this { 115 | const button = new ButtonComponent(this.controlEl); 116 | cb(button); 117 | return this; 118 | } 119 | 120 | addText(cb: (text: TextComponent) => any): this { 121 | const text = new TextComponent(this.controlEl); 122 | cb(text); 123 | this.onChange = (value: string) => { 124 | if (text.inputEl) { 125 | text.setValue(value); 126 | const event = new Event('input'); 127 | text.inputEl.dispatchEvent(event); 128 | } 129 | }; 130 | return this; 131 | } 132 | 133 | addDropdown(cb: (dropdown: DropdownComponent) => any): this { 134 | const dropdown = new DropdownComponent(this.controlEl); 135 | cb(dropdown); 136 | return this; 137 | } 138 | 139 | addExtraButton(cb: (button: ButtonComponent) => any): this { 140 | const button = new ButtonComponent(this.controlEl); 141 | cb(button); 142 | return this; 143 | } 144 | addToggle(cb: (toggle: ToggleComponent) => any): this { 145 | const toggle = new ToggleComponent(this.controlEl); 146 | cb(toggle); 147 | return this; 148 | } 149 | } 150 | 151 | export class ButtonComponent { 152 | buttonEl: HTMLButtonElement; 153 | extraSettingsEl: HTMLElement; 154 | 155 | constructor(containerEl: HTMLElement) { 156 | this.buttonEl = document.createElement('button'); 157 | this.extraSettingsEl = this.buttonEl; 158 | 159 | this.buttonEl.addClass = function(className: string) { 160 | this.classList.add(className); 161 | }; 162 | this.buttonEl.removeClass = function(className: string) { 163 | this.classList.remove(className); 164 | }; 165 | 166 | containerEl.appendChild(this.buttonEl as any); 167 | } 168 | 169 | setButtonText(text: string): this { 170 | this.buttonEl.textContent = text; 171 | return this; 172 | } 173 | 174 | onClick(cb: () => any): this { 175 | this.buttonEl.addEventListener('click', cb); 176 | return this; 177 | } 178 | 179 | setIcon(icon: string): this { 180 | this.buttonEl.setAttribute('aria-label', icon); 181 | return this; 182 | } 183 | 184 | setTooltip(tooltip: string): this { 185 | this.buttonEl.setAttribute('aria-label', tooltip); 186 | return this; 187 | } 188 | 189 | setCta(): this { 190 | return this; 191 | } 192 | 193 | setWarning() { 194 | return this; 195 | } 196 | 197 | setDisabled(disabled: boolean): this { 198 | this.buttonEl.disabled = disabled; 199 | return this; 200 | } 201 | } 202 | 203 | export class TextComponent { 204 | inputEl: HTMLInputElement; 205 | 206 | constructor(containerEl: HTMLElement) { 207 | this.inputEl = document.createElement('input'); 208 | this.inputEl.type = 'text'; 209 | containerEl.appendChild(this.inputEl as any); 210 | } 211 | 212 | setValue(value: string): this { 213 | this.inputEl.value = value; 214 | return this; 215 | } 216 | 217 | getValue(): string { 218 | return this.inputEl.value; 219 | } 220 | 221 | onChange(cb: (value: string) => any): this { 222 | this.inputEl.addEventListener('input', () => cb(this.inputEl.value)); 223 | return this; 224 | } 225 | 226 | setPlaceholder(placeholder: string): this { 227 | this.inputEl.placeholder = placeholder; 228 | return this; 229 | } 230 | } 231 | 232 | export class DropdownComponent { 233 | selectEl: HTMLSelectElement; 234 | 235 | constructor(containerEl: HTMLElement) { 236 | this.selectEl = document.createElement('select'); 237 | containerEl.appendChild(this.selectEl as any); 238 | } 239 | 240 | addOption(value: string, text: string): this { 241 | const option = document.createElement('option'); 242 | option.value = value; 243 | option.text = text; 244 | this.selectEl.appendChild(option as any); 245 | return this; 246 | } 247 | 248 | addOptions(options: { [key: string]: string }): this { 249 | Object.entries(options).forEach(([value, text]) => { 250 | this.addOption(value, text); 251 | }); 252 | return this; 253 | } 254 | 255 | setValue(value: string): this { 256 | this.selectEl.value = value; 257 | return this; 258 | } 259 | 260 | onChange(callback: (value: string) => any): this { 261 | this.selectEl.addEventListener('change', (e) => { 262 | callback((e.target as HTMLSelectElement).value); 263 | }); 264 | return this; 265 | } 266 | 267 | setDisabled(disabled: boolean): this { 268 | this.selectEl.disabled = disabled; 269 | return this; 270 | } 271 | } 272 | 273 | export class ToggleComponent { 274 | toggleEl: HTMLInputElement; 275 | 276 | 277 | constructor(containerEl: HTMLElement) { 278 | this.toggleEl = document.createElement('input'); 279 | this.toggleEl.type = 'checkbox'; 280 | containerEl.appendChild(this.toggleEl as any); 281 | } 282 | 283 | setDisabled(disabled: boolean): this { 284 | this.toggleEl.disabled = disabled; 285 | return this; 286 | } 287 | 288 | getValue(): boolean { 289 | return this.toggleEl.checked; 290 | } 291 | 292 | setValue(on: boolean): this { 293 | this.toggleEl.checked = on; 294 | return this; 295 | } 296 | 297 | setTooltip(tooltip: string, options?: any): this { 298 | this.toggleEl.setAttribute('aria-label', tooltip); 299 | return this; 300 | } 301 | 302 | onClick(): this { 303 | return this; 304 | } 305 | 306 | onChange(callback: (value: boolean) => any): this { 307 | this.toggleEl.addEventListener('change', () => callback(this.toggleEl.checked)); 308 | return this; 309 | } 310 | } 311 | 312 | export class Modal { 313 | app: App; 314 | contentEl: HTMLElement; 315 | 316 | constructor(app: App) { 317 | this.app = app; 318 | this.contentEl = document.createElement('div'); 319 | } 320 | 321 | open(): void { 322 | // Mock for opening modal window 323 | } 324 | 325 | close(): void { 326 | // Clear content when closing 327 | this.contentEl.empty(); 328 | } 329 | } 330 | 331 | // Add helper methods for HTMLElement 332 | declare global { 333 | interface HTMLElement { 334 | createDiv(className?: string): HTMLElement; 335 | empty(): void; 336 | createEl(tag: string, attrs?: { text?: string }, cls?: string): HTMLElement; 337 | createSpan(className?: string): HTMLElement; 338 | addClass(className: string): void; 339 | } 340 | } 341 | 342 | // Add global createSpan function 343 | (global as any).createSpan = function(attrs?: { cls?: string }): HTMLElement { 344 | const span = document.createElement('span'); 345 | if (attrs?.cls) { 346 | span.className = attrs.cls; 347 | } 348 | return span; 349 | }; 350 | 351 | HTMLElement.prototype.createDiv = function(className?: string): HTMLElement { 352 | const div = document.createElement('div'); 353 | if (className) { 354 | div.className = className; 355 | } 356 | this.appendChild(div); 357 | return div; 358 | }; 359 | 360 | HTMLElement.prototype.empty = function(): void { 361 | while (this.firstChild) { 362 | this.removeChild(this.firstChild); 363 | } 364 | }; 365 | 366 | HTMLElement.prototype.createEl = function(tag: string, attrs?: { text?: string }): HTMLElement { 367 | const el = document.createElement(tag); 368 | if (attrs?.text) { 369 | el.textContent = attrs.text; 370 | } 371 | this.appendChild(el); 372 | return el; 373 | }; 374 | 375 | HTMLElement.prototype.createSpan = function(className?: string): HTMLElement { 376 | const span = document.createElement('span'); 377 | if (className) { 378 | span.className = className; 379 | } 380 | this.appendChild(span); 381 | return span; 382 | }; 383 | 384 | // Add addClass method to HTMLElement prototype 385 | HTMLElement.prototype.addClass = function(className: string): void { 386 | this.classList.add(className); 387 | }; 388 | 389 | export function sanitizeHTMLToDom(html: string): DocumentFragment { 390 | const template = document.createElement('template'); 391 | template.innerHTML = html; 392 | 393 | // If we need to test mode switching, we'll do it through the isTextMode property directly 394 | return template.content; 395 | } -------------------------------------------------------------------------------- /__mocks__/openai.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | default: class OpenAI { 3 | constructor() {} 4 | chat: { 5 | completions: { 6 | create: jest.fn() 7 | } 8 | }, 9 | models: { 10 | list: jest.fn() 11 | } 12 | } 13 | }; -------------------------------------------------------------------------------- /copy-files-plugin.mjs: -------------------------------------------------------------------------------- 1 | import { copyFileSync, mkdirSync, existsSync } from 'fs'; 2 | 3 | export const copyFilesPlugin = (files) => ({ 4 | name: 'copy-files', 5 | setup(build) { 6 | build.onEnd(() => { 7 | if (!existsSync('./dist')) { 8 | mkdirSync('./dist'); 9 | } 10 | 11 | for (const file of files) { 12 | copyFileSync(file.from, file.to); 13 | } 14 | }); 15 | }, 16 | }); -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import { copyFilesPlugin } from "./copy-files-plugin.mjs"; 5 | 6 | const banner = 7 | `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = (process.argv[2] === "production"); 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: [ 20 | "src/main.ts", 21 | "src/styles.css" 22 | ], 23 | bundle: true, 24 | external: [ 25 | "obsidian", 26 | "electron", 27 | "@codemirror/autocomplete", 28 | "@codemirror/collab", 29 | "@codemirror/commands", 30 | "@codemirror/language", 31 | "@codemirror/lint", 32 | "@codemirror/search", 33 | "@codemirror/state", 34 | "@codemirror/view", 35 | "@lezer/common", 36 | "@lezer/highlight", 37 | "@lezer/lr", 38 | ...builtins], 39 | format: "cjs", 40 | target: "es2018", 41 | logLevel: "info", 42 | sourcemap: prod ? false : "inline", 43 | treeShaking: true, 44 | outdir: "dist", 45 | minify: prod, 46 | loader: { 47 | ".ts": "ts", 48 | ".css": "css" 49 | }, 50 | define: { 51 | 'process.env.NODE_ENV': prod ? '"production"' : '"development"' 52 | }, 53 | plugins: [ 54 | copyFilesPlugin([ 55 | { from: './manifest.json', to: './dist/manifest.json' } 56 | ]) 57 | ] 58 | }); 59 | 60 | if (prod) { 61 | await context.rebuild(); 62 | process.exit(0); 63 | } else { 64 | await context.watch(); 65 | } 66 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | moduleNameMapper: { 5 | '\\.(css|less|scss|sass)$': '/__mocks__/styleMock.js', 6 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 7 | '/__mocks__/fileMock.js', 8 | '^obsidian$': '/__mocks__/obsidian.ts', 9 | '^electron$': '/__mocks__/electron.ts' 10 | }, 11 | setupFiles: ['/jest.setup.js'], 12 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 13 | transform: { 14 | '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest', 15 | }, 16 | transformIgnorePatterns: [ 17 | 'node_modules/(?!(openai)/)' 18 | ], 19 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 20 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', 21 | setupFilesAfterEnv: ['/jest.setup.js'] 22 | }; -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ai-providers", 3 | "name": "AI Providers", 4 | "version": "1.3.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "A hub for setting AI providers (OpenAI-like, Ollama and more) in one place.", 7 | "author": "Pavel Frankov", 8 | "authorUrl": "https://github.com/pfrankov", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-ai-providers", 3 | "version": "1.3.0", 4 | "description": "A hub for setting AI providers (OpenAI-like, Ollama and more) in one place.", 5 | "main": "dist/main.js", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "dev": "node esbuild.config.mjs", 11 | "dev-test": "concurrently \"node esbuild.config.mjs\" \"npm run test:watch\"", 12 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 13 | "example:build": "cd packages/example-plugin && npm run build", 14 | "version": "node version-bump.mjs && git add manifest.json versions.json", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "sdk:build": "cd packages/sdk && npm run build", 18 | "sdk:version": "cd packages/sdk && npm version", 19 | "sdk:publish": "cd packages/sdk && npm run build && npm publish" 20 | }, 21 | "keywords": [ 22 | "obsidian", 23 | "ollama", 24 | "ai", 25 | "plugin", 26 | "llm", 27 | "gpt", 28 | "openai", 29 | "openrouter", 30 | "gemini", 31 | "lmstudio", 32 | "providers", 33 | "sdk" 34 | ], 35 | "author": "Pavel Frankov", 36 | "license": "MIT", 37 | "dependencies": { 38 | "ollama": "0.5.10", 39 | "openai": "4.73.1" 40 | }, 41 | "devDependencies": { 42 | "@testing-library/jest-dom": "6.4.2", 43 | "@types/jest": "29.5.12", 44 | "@types/node": "16.11.6", 45 | "@typescript-eslint/eslint-plugin": "5.29.0", 46 | "@typescript-eslint/parser": "5.29.0", 47 | "builtin-modules": "3.3.0", 48 | "concurrently": "8.2.2", 49 | "esbuild": "0.17.3", 50 | "jest": "29.7.0", 51 | "jest-environment-jsdom": "29.7.0", 52 | "obsidian": "latest", 53 | "ts-jest": "29.1.2", 54 | "tslib": "2.4.0", 55 | "typescript": "4.7.4", 56 | "electron": "33.2.1", 57 | "@obsidian-ai-providers/sdk": "file:packages/sdk" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/example-plugin/copy-files-plugin.mjs: -------------------------------------------------------------------------------- 1 | import { copyFileSync, mkdirSync, existsSync } from 'fs'; 2 | 3 | export const copyFilesPlugin = (files = [ 4 | { from: './styles.css', to: './dist/styles.css' }, 5 | { from: './manifest.json', to: './dist/manifest.json' } 6 | ]) => ({ 7 | name: 'copy-files', 8 | setup(build) { 9 | build.onEnd(() => { 10 | if (!existsSync('./dist')) { 11 | mkdirSync('./dist'); 12 | } 13 | 14 | for (const file of files) { 15 | copyFileSync(file.from, file.to); 16 | } 17 | }); 18 | }, 19 | }); -------------------------------------------------------------------------------- /packages/example-plugin/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import { copyFilesPlugin } from "./copy-files-plugin.mjs"; 5 | 6 | const banner = 7 | `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = (process.argv[2] === "production"); 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: [ 20 | "main.ts", 21 | "styles.css" 22 | ], 23 | bundle: true, 24 | external: [ 25 | "obsidian", 26 | "electron", 27 | "@codemirror/autocomplete", 28 | "@codemirror/collab", 29 | "@codemirror/commands", 30 | "@codemirror/language", 31 | "@codemirror/lint", 32 | "@codemirror/search", 33 | "@codemirror/state", 34 | "@codemirror/view", 35 | "@lezer/common", 36 | "@lezer/highlight", 37 | "@lezer/lr", 38 | ...builtins], 39 | format: "cjs", 40 | target: "es2018", 41 | logLevel: "info", 42 | sourcemap: prod ? false : "inline", 43 | treeShaking: true, 44 | outdir: "dist", 45 | minify: prod, 46 | loader: { 47 | ".ts": "ts", 48 | ".css": "css" 49 | }, 50 | define: { 51 | 'process.env.NODE_ENV': prod ? '"production"' : '"development"' 52 | }, 53 | alias: { 54 | '@sdk': '../sdk' 55 | }, 56 | plugins: [ 57 | copyFilesPlugin([ 58 | { from: './manifest.json', to: './dist/manifest.json' } 59 | ]) 60 | ] 61 | }); 62 | 63 | if (prod) { 64 | await context.rebuild(); 65 | process.exit(0); 66 | } else { 67 | await context.watch(); 68 | } 69 | -------------------------------------------------------------------------------- /packages/example-plugin/main.test.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, PluginSettingTab } from 'obsidian'; 2 | import AIProvidersExamplePlugin from './main'; 3 | import { initAI, waitForAI } from '@obsidian-ai-providers/sdk'; 4 | import manifest from './manifest.json'; 5 | 6 | // Mock AI integration 7 | jest.mock('@obsidian-ai-providers/sdk', () => ({ 8 | initAI: jest.fn((app, plugin, callback) => callback()), 9 | waitForAI: jest.fn() 10 | })); 11 | 12 | // Mock utilities 13 | const createMockProvider = (id: string, name: string, model?: string) => ({ 14 | id, 15 | name, 16 | ...(model ? { model } : {}) 17 | }); 18 | 19 | const createMockChunkHandler = () => ({ 20 | onData: jest.fn(), 21 | onEnd: jest.fn(), 22 | onError: jest.fn() 23 | }); 24 | 25 | const createMockAIResolver = (providers: any[] = [], execute = jest.fn()) => ({ 26 | promise: Promise.resolve({ 27 | providers, 28 | execute 29 | }) 30 | }); 31 | 32 | describe('AIProvidersExamplePlugin', () => { 33 | let app: App; 34 | let plugin: AIProvidersExamplePlugin; 35 | let settingsTab: PluginSettingTab; 36 | 37 | beforeEach(() => { 38 | app = new App(); 39 | plugin = new AIProvidersExamplePlugin(app, manifest); 40 | }); 41 | 42 | afterEach(() => { 43 | jest.clearAllMocks(); 44 | }); 45 | 46 | it('should initialize plugin correctly', () => { 47 | expect(plugin).toBeInstanceOf(Plugin); 48 | expect(plugin.app).toBe(app); 49 | }); 50 | 51 | it('should load plugin and initialize AI', async () => { 52 | await plugin.onload(); 53 | expect(initAI).toHaveBeenCalledWith(app, plugin, expect.any(Function)); 54 | expect((plugin as any).settingTabs.length).toBe(1); 55 | }); 56 | 57 | describe('SampleSettingTab', () => { 58 | beforeEach(async () => { 59 | await plugin.onload(); 60 | settingsTab = (plugin as any).settingTabs[0]; 61 | }); 62 | 63 | it('should display settings with no providers', async () => { 64 | (waitForAI as jest.Mock).mockResolvedValueOnce( 65 | createMockAIResolver([]) 66 | ); 67 | 68 | await settingsTab.display(); 69 | 70 | const setting = settingsTab.containerEl.querySelector('.setting-item'); 71 | expect(setting).toBeTruthy(); 72 | 73 | const settingName = setting?.querySelector('.setting-item-name'); 74 | const settingDesc = setting?.querySelector('.setting-item-description'); 75 | 76 | expect(settingName?.textContent).toBe('AI Providers'); 77 | expect(settingDesc?.textContent).toBe('No AI providers found. Please install an AI provider.'); 78 | }); 79 | 80 | it('should display provider selection dropdown when providers exist', async () => { 81 | const mockProviders = [ 82 | createMockProvider('provider1', 'Provider 1'), 83 | createMockProvider('provider2', 'Provider 2', 'Model X') 84 | ]; 85 | 86 | (waitForAI as jest.Mock).mockResolvedValueOnce( 87 | createMockAIResolver(mockProviders) 88 | ); 89 | 90 | await settingsTab.display(); 91 | 92 | const setting = settingsTab.containerEl.querySelector('.setting-item'); 93 | expect(setting).toBeTruthy(); 94 | 95 | const settingName = setting?.querySelector('.setting-item-name'); 96 | expect(settingName?.textContent).toBe('Select AI Provider'); 97 | 98 | const dropdown = setting?.querySelector('select'); 99 | expect(dropdown).toBeTruthy(); 100 | 101 | // Check dropdown options 102 | const options = dropdown?.querySelectorAll('option'); 103 | expect(options?.length).toBe(3); // Empty option + 2 providers 104 | expect(options?.[1].value).toBe('provider1'); 105 | expect(options?.[1].text).toBe('Provider 1'); 106 | expect(options?.[2].value).toBe('provider2'); 107 | expect(options?.[2].text).toBe('Provider 2 ~ Model X'); 108 | }); 109 | 110 | it('should show execute button when provider is selected', async () => { 111 | const mockProvider = createMockProvider('provider1', 'Provider 1'); 112 | const mockExecute = jest.fn().mockResolvedValue(createMockChunkHandler()); 113 | 114 | (waitForAI as jest.Mock).mockResolvedValueOnce( 115 | createMockAIResolver([mockProvider], mockExecute) 116 | ); 117 | 118 | // Set selected provider 119 | (settingsTab as any).selectedProvider = 'provider1'; 120 | 121 | await settingsTab.display(); 122 | 123 | const executeButton = settingsTab.containerEl.querySelector('button'); 124 | expect(executeButton).toBeTruthy(); 125 | expect(executeButton?.textContent).toBe('Execute'); 126 | }); 127 | 128 | it('should handle AI execution correctly', async () => { 129 | const mockProvider = createMockProvider('provider1', 'Provider 1'); 130 | const mockExecute = jest.fn().mockResolvedValue(createMockChunkHandler()); 131 | 132 | (waitForAI as jest.Mock).mockResolvedValueOnce( 133 | createMockAIResolver([mockProvider], mockExecute) 134 | ); 135 | 136 | (settingsTab as any).selectedProvider = 'provider1'; 137 | 138 | await settingsTab.display(); 139 | 140 | const executeButton = settingsTab.containerEl.querySelector('button'); 141 | expect(executeButton).toBeTruthy(); 142 | 143 | // Click execute button 144 | executeButton?.click(); 145 | 146 | expect(mockExecute).toHaveBeenCalledWith({ 147 | provider: mockProvider, 148 | prompt: "What is the capital of Great Britain?" 149 | }); 150 | }); 151 | 152 | it('should clear container before displaying settings', async () => { 153 | (waitForAI as jest.Mock).mockResolvedValueOnce( 154 | createMockAIResolver([]) 155 | ); 156 | 157 | // Add some content to container 158 | settingsTab.containerEl.createEl('div', { text: 'Test content' }); 159 | 160 | await settingsTab.display(); 161 | 162 | // Check if old content was removed 163 | expect(settingsTab.containerEl.childNodes.length).toBe(1); 164 | }); 165 | }); 166 | }); -------------------------------------------------------------------------------- /packages/example-plugin/main.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, PluginSettingTab, Setting } from 'obsidian'; 2 | import { initAI, waitForAI } from '@obsidian-ai-providers/sdk'; 3 | 4 | interface AIProvidersExampleSettings { 5 | mySetting: string; 6 | } 7 | 8 | export default class AIProvidersExamplePlugin extends Plugin { 9 | settings: AIProvidersExampleSettings; 10 | 11 | async onload() { 12 | initAI(this.app, this, async ()=>{ 13 | this.addSettingTab(new SampleSettingTab(this.app, this)); 14 | }); 15 | } 16 | } 17 | 18 | class SampleSettingTab extends PluginSettingTab { 19 | plugin: AIProvidersExamplePlugin; 20 | selectedProvider: string; 21 | 22 | constructor(app: App, plugin: AIProvidersExamplePlugin) { 23 | super(app, plugin); 24 | this.plugin = plugin; 25 | } 26 | 27 | async display(): Promise { 28 | const {containerEl} = this; 29 | 30 | containerEl.empty(); 31 | 32 | const aiResolver = await waitForAI(); 33 | const aiProviders = await aiResolver.promise; 34 | 35 | const providers = aiProviders.providers.reduce((acc: Record, provider: { id: string; name: string; model?: string }) => ({ 36 | ...acc, 37 | [provider.id]: provider.model ? [provider.name, provider.model].join(' ~ ') : provider.name, 38 | }), { 39 | '': '' 40 | }); 41 | 42 | if (Object.keys(providers).length === 1) { 43 | new Setting(containerEl) 44 | .setName("AI Providers") 45 | .setDesc("No AI providers found. Please install an AI provider."); 46 | 47 | return; 48 | } 49 | new Setting(containerEl) 50 | .setName("Select AI Provider") 51 | .setClass("ai-providers-select") 52 | .addDropdown((dropdown) => 53 | dropdown 54 | .addOptions(providers) 55 | .setValue(this.selectedProvider) 56 | .onChange(async (value) => { 57 | this.selectedProvider = value; 58 | await this.display(); 59 | }) 60 | ); 61 | 62 | if (this.selectedProvider) { 63 | const provider = aiProviders.providers.find(provider => provider.id === this.selectedProvider); 64 | if (!provider) { 65 | return; 66 | } 67 | 68 | new Setting(containerEl) 69 | .setName("Execute test prompt") 70 | .addButton((button) => 71 | button 72 | .setButtonText("Execute") 73 | .onClick(async () => { 74 | button.setDisabled(true); 75 | const paragraph = containerEl.createEl('p'); 76 | 77 | const chunkHandler = await aiProviders.execute({ 78 | provider, 79 | prompt: "What is the capital of Great Britain?", 80 | }); 81 | chunkHandler.onData((chunk, accumulatedText) => { 82 | paragraph.setText(accumulatedText); 83 | }); 84 | chunkHandler.onEnd((fullText) => { 85 | console.log(fullText); 86 | }); 87 | chunkHandler.onError((error) => { 88 | paragraph.setText(error.message); 89 | }); 90 | button.setDisabled(false); 91 | }) 92 | ); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/example-plugin/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "example-plugin", 3 | "name": "AI Providers Example", 4 | "version": "1.0.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Example plugin that demonstrates AI Providers integration", 7 | "author": "Pavel Frankov", 8 | "isDesktopOnly": false, 9 | "main": "dist/main.js" 10 | } -------------------------------------------------------------------------------- /packages/example-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@obsidian-ai-providers/example-plugin", 3 | "version": "1.0.0", 4 | "description": "Example plugin that demonstrates AI Providers integration", 5 | "main": "main.ts", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" 9 | }, 10 | "author": "Pavel Frankov", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@obsidian-ai-providers/sdk": "^1.1.1" 14 | }, 15 | "peerDependencies": { 16 | "obsidian": "latest" 17 | }, 18 | "devDependencies": { 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.17.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/example-plugin/styles.css: -------------------------------------------------------------------------------- 1 | @import "@obsidian-ai-providers/sdk/styles.css"; 2 | -------------------------------------------------------------------------------- /packages/example-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "./dist", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7", 20 | "ES2017", 21 | "ES2018", 22 | "ES2018.AsyncIterable" 23 | ], 24 | "esModuleInterop": true, 25 | "skipLibCheck": true, 26 | "noEmit": true, 27 | "types": ["jest", "@testing-library/jest-dom", "node", "electron"], 28 | "resolveJsonModule": true 29 | }, 30 | "include": ["**/*.ts"] 31 | } -------------------------------------------------------------------------------- /packages/sdk/README.md: -------------------------------------------------------------------------------- 1 | # Obsidian AI Providers SDK 2 | This SDK is used to interact with the [AI Providers](https://github.com/obsidian-ai-providers/obsidian-ai-providers) plugin. 3 | 4 | Take a look at the [example plugin](../example-plugin/main.ts) to see how to use the SDK. 5 | 6 | ## Installation 7 | Install the SDK in your Obsidian plugin. 8 | 9 | ```bash 10 | npm install @obsidian-ai-providers/sdk 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### 1. Wait for AI Providers plugin in your plugin 16 | Any plugin can not be loaded instantly, so you need to wait for AI Providers plugin to be loaded. 17 | ```typescript 18 | import { waitForAI } from '@obsidian-ai-providers/sdk'; 19 | 20 | const aiResolver = await waitForAI(); 21 | const aiProviders = await aiResolver.promise; 22 | 23 | // Object with all available AI providers 24 | aiProviders.providers; 25 | /* 26 | [ 27 | { 28 | id: "1732815722182", 29 | model: "smollm2:135m", 30 | name: "Ollama local", 31 | type: "ollama", 32 | url: "http://localhost:11434", 33 | apiKey: "sk-1234567890", 34 | availableModels: ['smollm2:135m', 'llama2:latest'], 35 | }, 36 | ... 37 | ] 38 | */ 39 | 40 | // Every time in any async code you have to call `waitForAI` to get the current instance of AI Providers. 41 | // It will be changed when the user changes the AI Provider in settings. 42 | ``` 43 | 44 | ### 2. Show fallback settings tab 45 | Before AI Providers plugin is loaded and activated, you need to show fallback settings tab. 46 | `initAI` function takes care of showing fallback settings tab and runs callback when AI Providers plugin is loaded and activated. 47 | 48 | ```typescript 49 | import { initAI } from '@obsidian-ai-providers/sdk'; 50 | 51 | export default class SamplePlugin extends Plugin { 52 | ... 53 | 54 | async onload() { 55 | // Wrap your onload code in initAI callback. Do not `await` it. 56 | initAI(this.app, this, async ()=>{ 57 | this.addSettingTab(new SampleSettingTab(this.app, this)); 58 | }); 59 | } 60 | } 61 | ``` 62 | 63 | ### 3. Import SDK styles 64 | Don't forget to import the SDK styles for fallback settings tab in your plugin. 65 | ```css 66 | @import '@obsidian-ai-providers/sdk/style.css'; 67 | ``` 68 | Make sure that there is loader for `.css` files in your esbuild config. 69 | ```typescript 70 | export default { 71 | ... 72 | loader: { 73 | ".ts": "ts", 74 | ".css": "css" 75 | }, 76 | } 77 | ``` 78 | Alternatively you can use the content of `@obsidian-ai-providers/sdk/style.css` in your plugin. 79 | 80 | ### 4. Migrate existing provider 81 | If you want to add providers to the AI Providers plugin, you can use the `migrateProvider` method. 82 | It will show a confirmation dialog and if the user confirms, it will add the provider to the plugin settings. 83 | 84 | ```typescript 85 | // The migrateProvider method takes an IAIProvider object and returns a promise 86 | // that resolves to the migrated (or existing matching) provider, or false if migration was canceled 87 | const migratedOrExistingProvider = await aiProviders.migrateProvider({ 88 | id: "any-unique-string", 89 | name: "Ollama local", 90 | type: "ollama", 91 | url: "http://localhost:11434", 92 | apiKey: "sk-1234567890", 93 | model: "smollm2:135m", 94 | }); 95 | 96 | // If a provider with matching `type`, `apiKey`, `url`, and `model` fields already exists, 97 | // it will return that existing provider instead of creating a duplicate 98 | // If the user cancels the migration, it will return false 99 | 100 | if (migratedOrExistingProvider === false) { 101 | // Migration was canceled by the user 102 | console.log("User canceled the migration"); 103 | } else { 104 | // Provider was added or already existed 105 | console.log("Provider available:", migratedOrExistingProvider); 106 | } 107 | ``` 108 | 109 | ### Execute prompt 110 | You can use just the list of providers and selected models but you can also make requests to AI Providers using `execute` method. 111 | 112 | ```typescript 113 | // Simple prompt-based request 114 | const chunkHandler = await aiProviders.execute({ 115 | provider: aiProviders.providers[0], 116 | prompt: "What is the capital of Great Britain?", 117 | }); 118 | 119 | // Using messages format (more flexible, allowing multiple messages and different roles) 120 | const chunkHandlerWithMessages = await aiProviders.execute({ 121 | provider: aiProviders.providers[0], 122 | messages: [ 123 | { role: "system", content: "You are a helpful geography assistant." }, 124 | { role: "user", content: "What is the capital of Great Britain?" } 125 | ] 126 | }); 127 | 128 | // Working with images (basic approach) 129 | const chunkHandlerWithImage = await aiProviders.execute({ 130 | provider: aiProviders.providers[0], 131 | prompt: "Describe what you see in this image", 132 | images: ["data:image/jpeg;base64,/9j/4AAQSkZ..."] // Base64 encoded image 133 | }); 134 | 135 | // Working with images using messages format 136 | const chunkHandlerWithContentBlocks = await aiProviders.execute({ 137 | provider: aiProviders.providers[0], 138 | messages: [ 139 | { role: "system", content: "You are a helpful image analyst." }, 140 | { 141 | role: "user", 142 | content: [ 143 | { type: "text", text: "Describe what you see in this image" }, 144 | { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/4AAQSkZ..." } } 145 | ] 146 | } 147 | ] 148 | }); 149 | 150 | // Handle chunk in stream mode 151 | chunkHandler.onData((chunk, accumulatedText) => { 152 | console.log(accumulatedText); 153 | }); 154 | 155 | // Handle end of stream 156 | chunkHandler.onEnd((fullText) => { 157 | console.log(fullText); 158 | }); 159 | 160 | // Handle error 161 | chunkHandler.onError((error) => { 162 | console.error(error); 163 | }); 164 | 165 | // Abort request if you need to 166 | chunkHandler.abort(); 167 | ``` 168 | 169 | ### Embed text 170 | ```typescript 171 | const embeddings = await aiProviders.embed({ 172 | provider: aiProviders.providers[0], 173 | input: "What is the capital of Great Britain?", // Use 'input' parameter 174 | }); 175 | 176 | // embeddings is just an array of numbers 177 | embeddings; // [0.1, 0.2, 0.3, ...] 178 | ``` 179 | 180 | ### Fetch models 181 | There is no need to fetch models manually, but you can do it if you want to. 182 | You can fetch models for any provider using `fetchModels` method. 183 | 184 | ```typescript 185 | // Makes request to the provider and returns list of models 186 | // Also updates the list of available models in the provider object 187 | const models = await aiProviders.fetchModels(aiProviders.providers[0]); 188 | 189 | console.log(models); // ['smollm2:135m', 'llama2:latest'] 190 | console.log(aiProviders.providers[0].availableModels) // ['smollm2:135m', 'llama2:latest'] 191 | ``` 192 | 193 | ### Error handling 194 | All methods throw errors if something goes wrong. 195 | In most cases it shows a Notice in the Obsidian UI. 196 | 197 | ```typescript 198 | try { 199 | await aiProviders.embed({ 200 | provider: aiProviders.providers[0], 201 | text: "What is the capital of Great Britain?", 202 | }); 203 | } catch (error) { 204 | // You should handle errors in your plugin 205 | console.error(error); 206 | } 207 | ``` 208 | ```typescript 209 | const chunkHandler = await aiProviders.execute({ 210 | provider: aiProviders.providers[0], 211 | prompt: "What is the capital of Great Britain?", 212 | }); 213 | 214 | // Only `execute` method passes errors to the onError callback 215 | chunkHandler.onError((error) => { 216 | console.error(error); 217 | }); 218 | 219 | 220 | ``` 221 | 222 | If you have any questions, please contact me via Telegram [@pavel_frankov](https://t.me/pavel_frankov). 223 | -------------------------------------------------------------------------------- /packages/sdk/copy-files-plugin.mjs: -------------------------------------------------------------------------------- 1 | import { copyFileSync, mkdirSync, existsSync } from 'fs'; 2 | 3 | export const copyFilesPlugin = (files) => ({ 4 | name: 'copy-files', 5 | setup(build) { 6 | build.onEnd(() => { 7 | if (!existsSync('./dist')) { 8 | mkdirSync('./dist'); 9 | } 10 | 11 | for (const file of files) { 12 | copyFileSync(file.from, file.to); 13 | } 14 | }); 15 | }, 16 | }); -------------------------------------------------------------------------------- /packages/sdk/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import { copyFilesPlugin } from "./copy-files-plugin.mjs"; 5 | 6 | const banner = 7 | `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = (process.argv[2] === "production"); 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ["index.ts"], 20 | bundle: true, 21 | external: [ 22 | "obsidian", 23 | ...builtins 24 | ], 25 | format: "cjs", 26 | target: "es2020", 27 | logLevel: "info", 28 | sourcemap: prod ? false : "inline", 29 | treeShaking: true, 30 | outfile: "dist/index.js", 31 | minify: false, 32 | loader: { 33 | ".ts": "ts" 34 | }, 35 | charset: 'utf8', 36 | legalComments: 'inline', 37 | keepNames: true, 38 | define: { 39 | 'process.env.NODE_ENV': prod ? '"production"' : '"development"' 40 | }, 41 | plugins: [ 42 | copyFilesPlugin([ 43 | { from: './styles.css', to: './dist/styles.css' }, 44 | { from: './types.d.ts', to: './dist/types.d.ts' } 45 | ]) 46 | ] 47 | }); 48 | 49 | if (prod) { 50 | await context.rebuild(); 51 | process.exit(0); 52 | } else { 53 | await context.watch(); 54 | } -------------------------------------------------------------------------------- /packages/sdk/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginSettingTab, App, sanitizeHTMLToDom } from "obsidian"; 2 | import { ExtendedApp, IAIProvidersService } from './types'; 3 | 4 | const FALLBACK_TIMEOUT = 100; 5 | const REQUIRED_AI_PROVIDERS_VERSION = 1; 6 | const AI_PROVIDERS_READY_EVENT = 'ai-providers-ready'; 7 | 8 | let aiProvidersReadyAiResolver: { 9 | promise: Promise; 10 | cancel: () => void; 11 | } | null = null; 12 | 13 | /** 14 | * Integration module for AI Providers in Obsidian plugins. 15 | * Provides tools for working with AI functionality through the AI Providers plugin. 16 | */ 17 | 18 | /** 19 | * Waits for AI Providers plugin to be ready 20 | * @param app - Obsidian app instance 21 | * @param plugin - Current plugin 22 | * @returns Promise with a control object for waiting 23 | * @example 24 | * const aiResolver = await waitForAIProviders(app, plugin); 25 | * const aiProviders = await aiResolver.promise; 26 | */ 27 | async function waitForAIProviders(app: ExtendedApp, plugin: Plugin) { 28 | if (aiProvidersReadyAiResolver) { 29 | return aiProvidersReadyAiResolver; 30 | } 31 | 32 | const abortController = new AbortController(); 33 | let aiProvidersReady: () => void = () => {}; 34 | 35 | const result = { 36 | promise: new Promise((resolve, reject) => { 37 | aiProvidersReady = () => { 38 | app.workspace.off(AI_PROVIDERS_READY_EVENT, aiProvidersReady); 39 | aiProvidersReadyAiResolver = null; 40 | resolve(app.aiProviders as IAIProvidersService); 41 | } 42 | 43 | if (app.aiProviders) { 44 | aiProvidersReady(); 45 | } else { 46 | const eventRef = app.workspace.on(AI_PROVIDERS_READY_EVENT, aiProvidersReady); 47 | plugin.registerEvent(eventRef); 48 | } 49 | 50 | abortController.signal.addEventListener('abort', () => { 51 | app.workspace.off(AI_PROVIDERS_READY_EVENT, aiProvidersReady); 52 | aiProvidersReadyAiResolver = null; 53 | reject(new Error("Waiting for AI Providers was cancelled")); 54 | }); 55 | }), 56 | cancel: () => abortController.abort() 57 | }; 58 | 59 | if (!app.aiProviders) { 60 | aiProvidersReadyAiResolver = result; 61 | } 62 | return result; 63 | } 64 | 65 | class AIProvidersManager { 66 | private static instance: AIProvidersManager | null = null; 67 | private constructor( 68 | private readonly app: ExtendedApp, 69 | private readonly plugin: Plugin 70 | ) {} 71 | 72 | static getInstance(app?: ExtendedApp, plugin?: Plugin): AIProvidersManager { 73 | if (!this.instance) { 74 | if (!app || !plugin) { 75 | throw new Error("AIProvidersManager not initialized. Call initialize() first"); 76 | } 77 | this.instance = new AIProvidersManager(app, plugin); 78 | } 79 | return this.instance; 80 | } 81 | 82 | static reset(): void { 83 | this.instance = null; 84 | } 85 | 86 | getApp(): ExtendedApp { 87 | return this.app; 88 | } 89 | 90 | getPlugin(): Plugin { 91 | return this.plugin; 92 | } 93 | } 94 | 95 | /** 96 | * Initializes AI integration 97 | * @param app - Obsidian app instance 98 | * @param plugin - Current plugin 99 | * @param onDone - Callback called after successful initialization 100 | * @example 101 | * ```typescript 102 | * await initAI(app, plugin, async () => { 103 | * // Initialization successful, AI is ready to use 104 | * await this.loadSettings(); 105 | * await this.setupCommands(); 106 | * }); 107 | * ``` 108 | */ 109 | export async function initAI(app: ExtendedApp, plugin: Plugin, onDone: () => Promise) { 110 | AIProvidersManager.getInstance(app, plugin); 111 | let isFallbackShown = false; 112 | 113 | try { 114 | const timeout = setTimeout(async () => { 115 | plugin.addSettingTab(new AIProvidersFallbackSettingsTab(app, plugin)); 116 | isFallbackShown = true; 117 | }, FALLBACK_TIMEOUT); 118 | 119 | const aiProvidersAiResolver = await waitForAIProviders(app, plugin); 120 | const aiProviders = await aiProvidersAiResolver.promise; 121 | clearTimeout(timeout); 122 | 123 | try { 124 | aiProviders.checkCompatibility(REQUIRED_AI_PROVIDERS_VERSION); 125 | } catch (error) { 126 | console.error(`AI Providers compatibility check failed: ${error}`); 127 | if (error.code === 'version_mismatch') { 128 | plugin.addSettingTab(new AIProvidersFallbackSettingsTab(app, plugin)); 129 | throw new Error(`AI Providers version ${REQUIRED_AI_PROVIDERS_VERSION} is required`); 130 | } 131 | throw error; 132 | } 133 | 134 | await onDone(); 135 | } finally { 136 | if (isFallbackShown && app.plugins) { 137 | await app.plugins.disablePlugin(plugin.manifest.id); 138 | await app.plugins.enablePlugin(plugin.manifest.id); 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * Waits for AI services to be ready 145 | * @returns Promise with a control object for waiting 146 | * @example 147 | * ```typescript 148 | * const aiResolver = await waitForAI(); 149 | * try { 150 | * const aiProviders = await aiResolver.promise; 151 | * // Now you can use aiProviders 152 | * } catch (error) { 153 | * console.error('Failed to get AI providers:', error); 154 | * } 155 | * 156 | * // If you need to cancel waiting: 157 | * aiResolver.cancel(); 158 | * ``` 159 | */ 160 | export async function waitForAI() { 161 | const manager = AIProvidersManager.getInstance(); 162 | return waitForAIProviders(manager.getApp(), manager.getPlugin()); 163 | } 164 | 165 | class AIProvidersFallbackSettingsTab extends PluginSettingTab { 166 | plugin: Plugin; 167 | 168 | constructor(app: App, plugin: Plugin) { 169 | super(app, plugin); 170 | this.plugin = plugin; 171 | } 172 | 173 | async display(): Promise { 174 | const {containerEl} = this; 175 | 176 | containerEl.empty(); 177 | 178 | const aiProvidersNotice = containerEl.createEl("div", { 179 | cls: "ai-providers-notice" 180 | }); 181 | 182 | aiProvidersNotice.appendChild(sanitizeHTMLToDom(` 183 |

⚠️ This plugin requires AI Providers plugin to be installed.

184 |

Please install and configure AI Providers plugin first.

185 | `)); 186 | } 187 | } 188 | 189 | export type { 190 | ObsidianEvents, 191 | IAIProvider, 192 | IChunkHandler, 193 | IAIProvidersService, 194 | IAIProvidersExecuteParams, 195 | IAIProvidersEmbedParams, 196 | IAIHandler, 197 | IAIProvidersPluginSettings, 198 | AIProviderType 199 | } from './types'; -------------------------------------------------------------------------------- /packages/sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@obsidian-ai-providers/sdk", 3 | "version": "1.1.1", 4 | "private": false, 5 | "scripts": { 6 | "build": "tsc -p tsconfig.json && node esbuild.config.mjs production", 7 | "dev": "node esbuild.config.mjs" 8 | }, 9 | "description": "SDK for integrating with AI Providers plugin", 10 | "homepage": "https://github.com/pfrankov/obsidian-ai-providers", 11 | "main": "dist/index.js", 12 | "types": "dist/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/index.d.ts", 16 | "default": "./dist/index.js" 17 | }, 18 | "./styles.css": "./dist/styles.css" 19 | }, 20 | "files": [ 21 | "dist", 22 | "README.md", 23 | "LICENSE" 24 | ], 25 | "keywords": [ 26 | "obsidian", 27 | "ai", 28 | "sdk", 29 | "plugin", 30 | "llm", 31 | "gpt", 32 | "openai", 33 | "providers" 34 | ], 35 | "author": "Pavel Frankov", 36 | "license": "MIT", 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/pfrankov/obsidian-ai-providers.git" 40 | }, 41 | "bugs": { 42 | "url": "https://github.com/pfrankov/obsidian-ai-providers/issues" 43 | }, 44 | "peerDependencies": { 45 | "obsidian": "latest" 46 | }, 47 | "devDependencies": { 48 | "esbuild": "0.17.3", 49 | "typescript": "4.7.4", 50 | "tslib": "2.4.0" 51 | }, 52 | "publishConfig": { 53 | "access": "public" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/sdk/styles.css: -------------------------------------------------------------------------------- 1 | /* ============================================ 2 | AI Providers missing notice 3 | ============================================ */ 4 | .ai-providers-notice { 5 | margin: 0 -1em 1em; 6 | padding: 1em; 7 | border: 1px solid var(--background-modifier-border); 8 | border-radius: var(--radius-m); 9 | background-color: var(--background-secondary); 10 | color: var(--text-muted); 11 | font-size: var(--font-small); 12 | line-height: var(--line-height-tight); 13 | } 14 | 15 | .ai-providers-notice p { 16 | margin: 0; 17 | margin-bottom: 0.5em; 18 | } 19 | 20 | .ai-providers-notice p:last-child { 21 | margin-bottom: 0; 22 | } 23 | 24 | /* ============================================ 25 | AI Providers Dropdown Styles 26 | Prevents width overflow and maintains layout 27 | ============================================ */ 28 | .ai-providers-select > .setting-item-info { 29 | flex-grow: 1; 30 | flex-shrink: 1; 31 | } 32 | .ai-providers-select > .setting-item-control { 33 | flex-grow: 0; 34 | flex-shrink: 1; 35 | flex-basis: 50%; 36 | max-width: 50%; 37 | } 38 | 39 | .ai-providers-select > .setting-item-control .dropdown { 40 | max-width: 100%; 41 | } -------------------------------------------------------------------------------- /packages/sdk/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 | "declaration": true, 15 | "outDir": "./dist", 16 | "lib": [ 17 | "DOM", 18 | "ES5", 19 | "ES6", 20 | "ES7" 21 | ], 22 | "skipLibCheck": true 23 | }, 24 | "include": [ 25 | "**/*.ts" 26 | ] 27 | } -------------------------------------------------------------------------------- /packages/sdk/types.d.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin, EventRef } from "obsidian"; 2 | 3 | export type ObsidianEvents = { 4 | 'ai-providers-ready': () => void; 5 | }; 6 | 7 | export type AIProviderType = 'openai' | 'ollama' | 'gemini' | 'openrouter' | 'lmstudio'; 8 | export interface IAIProvider { 9 | id: string; 10 | name: string; 11 | apiKey?: string; 12 | url?: string; 13 | type: AIProviderType; 14 | model?: string; 15 | availableModels?: string[]; 16 | } 17 | 18 | export interface IChunkHandler { 19 | onData(callback: (chunk: string, accumulatedText: string) => void): void; 20 | onEnd(callback: (fullText: string) => void): void; 21 | onError(callback: (error: Error) => void): void; 22 | abort(): void; 23 | } 24 | 25 | export interface IAIProvidersService { 26 | version: number; 27 | providers: IAIProvider[]; 28 | fetchModels: (provider: IAIProvider) => Promise; 29 | embed: (params: IAIProvidersEmbedParams) => Promise; 30 | execute: (params: IAIProvidersExecuteParams) => Promise; 31 | checkCompatibility: (requiredVersion: number) => void; 32 | migrateProvider: (provider: IAIProvider) => Promise; 33 | } 34 | 35 | export interface IContentBlockText { 36 | type: 'text'; 37 | text: string; 38 | } 39 | 40 | export interface IContentBlockImageUrl { 41 | type: 'image_url'; 42 | image_url: { 43 | url: string; 44 | }; 45 | } 46 | 47 | export type IContentBlock = IContentBlockText | IContentBlockImageUrl; 48 | 49 | export interface IChatMessage { 50 | role: string; 51 | content: string | IContentBlock[]; 52 | images?: string[]; 53 | } 54 | 55 | export interface IAIProvidersExecuteParamsBase { 56 | provider: IAIProvider; 57 | images?: string[]; 58 | options?: { 59 | temperature?: number; 60 | max_tokens?: number; 61 | top_p?: number; 62 | frequency_penalty?: number; 63 | presence_penalty?: number; 64 | stop?: string[]; 65 | [key: string]: any; 66 | }; 67 | } 68 | 69 | export type IAIProvidersExecuteParamsWithPrompt = IAIProvidersExecuteParamsBase & { 70 | messages?: never; 71 | prompt: string; 72 | systemPrompt?: string; 73 | }; 74 | 75 | export type IAIProvidersExecuteParamsWithMessages = IAIProvidersExecuteParamsBase & { 76 | messages: IChatMessage[]; 77 | prompt?: never; 78 | systemPrompt?: never; 79 | }; 80 | 81 | export type IAIProvidersExecuteParams = IAIProvidersExecuteParamsWithPrompt | IAIProvidersExecuteParamsWithMessages; 82 | 83 | export interface IAIProvidersEmbedParams { 84 | input?: string | string[]; 85 | text?: string | string[]; 86 | provider: IAIProvider; 87 | } 88 | 89 | export interface IAIHandler { 90 | fetchModels(provider: IAIProvider): Promise; 91 | embed(params: IAIProvidersEmbedParams): Promise; 92 | execute(params: IAIProvidersExecuteParams): Promise; 93 | } 94 | 95 | export interface IAIProvidersPluginSettings { 96 | providers?: IAIProvider[]; 97 | _version: number; 98 | debugLogging?: boolean; 99 | useNativeFetch?: boolean; 100 | } 101 | 102 | export interface ExtendedApp extends App { 103 | aiProviders?: IAIProvidersService; 104 | plugins?: { 105 | enablePlugin: (id: string) => Promise; 106 | disablePlugin: (id: string) => Promise; 107 | }; 108 | workspace: App['workspace'] & { 109 | on: (event: K, callback: ObsidianEvents[K]) => EventRef; 110 | off: (event: K, callback: ObsidianEvents[K]) => void; 111 | }; 112 | } 113 | 114 | export declare function waitForAIProviders(app: ExtendedApp, plugin: Plugin): Promise<{ 115 | promise: Promise; 116 | cancel: () => void; 117 | }>; 118 | 119 | export declare function initAI(app: ExtendedApp, plugin: Plugin, onDone: () => Promise): Promise; 120 | 121 | export declare function waitForAI(): Promise<{ 122 | promise: Promise; 123 | cancel: () => void; 124 | }>; -------------------------------------------------------------------------------- /src/AIProvidersService.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice } from 'obsidian'; 2 | import { IAIProvider, IAIProvidersService, IAIProvidersExecuteParams, IChunkHandler, IAIProvidersEmbedParams, IAIHandler, AIProviderType } from '@obsidian-ai-providers/sdk'; 3 | import { OpenAIHandler } from './handlers/OpenAIHandler'; 4 | import { OllamaHandler } from './handlers/OllamaHandler'; 5 | import { I18n } from './i18n'; 6 | import AIProvidersPlugin from './main'; 7 | import { ConfirmationModal } from './modals/ConfirmationModal'; 8 | 9 | export class AIProvidersService implements IAIProvidersService { 10 | providers: IAIProvider[] = []; 11 | version = 1; 12 | private app: App; 13 | private plugin: AIProvidersPlugin; 14 | private handlers: Record; 15 | 16 | constructor(app: App, plugin: AIProvidersPlugin) { 17 | this.plugin = plugin; 18 | this.providers = plugin.settings.providers || []; 19 | this.app = app; 20 | this.handlers = { 21 | openai: new OpenAIHandler(plugin.settings), 22 | openrouter: new OpenAIHandler(plugin.settings), 23 | ollama: new OllamaHandler(plugin.settings), 24 | gemini: new OpenAIHandler(plugin.settings), 25 | lmstudio: new OpenAIHandler(plugin.settings), 26 | groq: new OpenAIHandler(plugin.settings), 27 | }; 28 | } 29 | 30 | private getHandler(type: AIProviderType) { 31 | return this.handlers[type]; 32 | } 33 | 34 | async embed(params: IAIProvidersEmbedParams): Promise { 35 | try { 36 | return await this.getHandler(params.provider.type).embed(params); 37 | } catch (error) { 38 | const message = error instanceof Error ? error.message : I18n.t('errors.failedToEmbed'); 39 | new Notice(message); 40 | throw error; 41 | } 42 | } 43 | 44 | async fetchModels(provider: IAIProvider): Promise { 45 | try { 46 | return await this.getHandler(provider.type).fetchModels(provider); 47 | } catch (error) { 48 | const message = error instanceof Error ? error.message : I18n.t('errors.failedToFetchModels'); 49 | new Notice(message); 50 | throw error; 51 | } 52 | } 53 | 54 | async execute(params: IAIProvidersExecuteParams): Promise { 55 | try { 56 | return await this.getHandler(params.provider.type).execute(params); 57 | } catch (error) { 58 | const message = error instanceof Error ? error.message : I18n.t('errors.failedToExecuteRequest'); 59 | new Notice(message); 60 | throw error; 61 | } 62 | } 63 | 64 | async migrateProvider(provider: IAIProvider): Promise { 65 | const fieldsToCompare = ['type', 'apiKey', 'url', 'model'] as const; 66 | this.plugin.settings.providers = this.plugin.settings.providers || []; 67 | 68 | const existingProvider = this.plugin.settings.providers.find((p: IAIProvider) => fieldsToCompare.every(field => p[field as keyof IAIProvider] === provider[field as keyof IAIProvider])); 69 | if (existingProvider) { 70 | return Promise.resolve(existingProvider); 71 | } 72 | 73 | return new Promise((resolve) => { 74 | new ConfirmationModal( 75 | this.app, 76 | `Migrate provider ${provider.name}?`, 77 | async () => { 78 | this.plugin.settings.providers?.push(provider); 79 | await this.plugin.saveSettings(); 80 | resolve(provider); 81 | }, 82 | () => { 83 | // When canceled, return false to indicate the migration was not performed 84 | resolve(false); 85 | } 86 | ).open(); 87 | }); 88 | } 89 | 90 | // Allows not passing version with every method call 91 | checkCompatibility(requiredVersion: number) { 92 | if (requiredVersion > this.version) { 93 | new Notice(I18n.t('errors.pluginMustBeUpdatedFormatted')); 94 | throw new Error(I18n.t('errors.pluginMustBeUpdated')); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/handlers/OllamaHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { OllamaHandler } from './OllamaHandler'; 2 | import { IAIProvider } from '@obsidian-ai-providers/sdk'; 3 | import { createAIHandlerTests, createDefaultVerifyApiCalls, IMockClient } from '../../test-utils/createAIHandlerTests'; 4 | 5 | jest.mock('ollama'); 6 | jest.setTimeout(3000); 7 | 8 | const createHandler = () => new OllamaHandler({ 9 | _version: 1, 10 | debugLogging: false, 11 | useNativeFetch: false 12 | }); 13 | 14 | const createMockProvider = (): IAIProvider => ({ 15 | id: 'test-provider', 16 | name: 'Test Provider', 17 | type: 'ollama', 18 | url: 'http://localhost:11434', 19 | apiKey: '', 20 | model: 'llama2' 21 | }); 22 | 23 | const createMockClient = (): IMockClient => { 24 | const mockClient: IMockClient = { 25 | list: jest.fn().mockResolvedValue({ 26 | models: [{ name: 'model1' }, { name: 'model2' }] 27 | }), 28 | generate: jest.fn().mockImplementation(async () => { 29 | return { 30 | async *[Symbol.asyncIterator]() { 31 | yield { message: { content: 'test response' } }; 32 | return; 33 | } 34 | }; 35 | }) 36 | }; 37 | 38 | (mockClient as any).ollamaShow = jest.fn().mockResolvedValue({ 39 | model_info: { 'num_ctx': 4096 } 40 | }); 41 | 42 | (mockClient as any).ollamaEmbed = jest.fn().mockResolvedValue({ 43 | embeddings: [[0.1, 0.2, 0.3]] 44 | }); 45 | 46 | (mockClient as any).show = (mockClient as any).ollamaShow; 47 | (mockClient as any).embed = (mockClient as any).ollamaEmbed; 48 | (mockClient as any).chat = (mockClient as any).generate; 49 | 50 | return mockClient; 51 | }; 52 | 53 | // Use the default verification function with Ollama customizations 54 | const verifyApiCalls = createDefaultVerifyApiCalls({ 55 | // Ollama uses a special format for images - strip data URL prefix 56 | formatImages: (images) => images?.map(img => img.replace(/^data:image\/(.*?);base64,/, "")), 57 | // The API field to check for Ollama 58 | apiField: 'chat', 59 | // Set this to true to indicate images should be inside messages 60 | imagesInMessages: true 61 | }); 62 | 63 | // Setup context optimization options 64 | const contextOptimizationOptions = { 65 | setupContextMock: (mockClient: IMockClient) => { 66 | (mockClient as any).show.mockResolvedValue({ 67 | model_info: { 'num_ctx': 4096 } 68 | }); 69 | }, 70 | verifyContextOptimization: async (handler: any, mockClient: IMockClient) => { 71 | // Verify that context optimization was called 72 | expect((mockClient as any).show).toHaveBeenCalledWith({ model: 'llama2' }); 73 | 74 | // We don't need to check chat calls[0][0] if the mockClient doesn't have them 75 | if ((mockClient as any).chat?.mock?.calls?.length > 0) { 76 | const chatCall = (mockClient as any).chat.mock.calls[0][0]; 77 | if (chatCall?.options) { 78 | expect(chatCall.options.num_ctx).toBeDefined(); 79 | } 80 | } 81 | } 82 | }; 83 | 84 | // Setup caching options 85 | const cachingOptions = { 86 | setupCacheMock: (mockClient: IMockClient) => { 87 | mockClient.show?.mockResolvedValue({ 88 | model_info: { 'num_ctx': 4096 } 89 | }); 90 | }, 91 | verifyCaching: async (handler: any, mockClient: IMockClient) => { 92 | // Test for single model caching 93 | if ((mockClient as any).show.mock.calls.length === 1) { 94 | // Make sure show was called at least once 95 | expect((mockClient as any).show).toHaveBeenCalled(); 96 | 97 | // Clear mock calls 98 | (mockClient as any).show.mockClear(); 99 | 100 | // Second call should not trigger show (cached) 101 | await handler.execute({ 102 | provider: createMockProvider(), 103 | prompt: 'test again', 104 | options: {} 105 | }); 106 | 107 | // Verify that show wasn't called again 108 | expect((mockClient as any).show).not.toHaveBeenCalled(); 109 | } 110 | // Test for multiple models caching 111 | else if ((mockClient as any).show.mock.calls.length >= 2) { 112 | // Verify that show was called for both models 113 | expect((mockClient as any).show).toHaveBeenCalledWith({ model: 'model1' }); 114 | expect((mockClient as any).show).toHaveBeenCalledWith({ model: 'model2' }); 115 | 116 | // Clear mock calls 117 | (mockClient as any).show.mockClear(); 118 | 119 | // Second call to first model should not trigger show (cached) 120 | await handler.execute({ 121 | provider: { ...createMockProvider(), model: 'model1' }, 122 | prompt: 'another test', 123 | options: {} 124 | }); 125 | 126 | // Verify that show wasn't called again for model1 127 | expect((mockClient as any).show).not.toHaveBeenCalledWith({ model: 'model1' }); 128 | } 129 | } 130 | }; 131 | 132 | // Use createAIHandlerTests for common test cases 133 | createAIHandlerTests( 134 | 'OllamaHandler', 135 | createHandler, 136 | createMockProvider, 137 | createMockClient, 138 | verifyApiCalls, 139 | { 140 | mockStreamResponse: { 141 | message: { content: 'test response' } 142 | }, 143 | contextOptimizationOptions, 144 | cachingOptions, 145 | // Add image handling test for Ollama 146 | imageHandlingOptions: { 147 | verifyImageHandling: async (handler, mockClient) => { 148 | // Verify that images are properly formatted for Ollama (base64 prefixes removed) 149 | const chatCalls = (mockClient as any).chat.mock.calls; 150 | if (chatCalls.length > 0) { 151 | const lastCall = chatCalls[chatCalls.length - 1][0]; 152 | // Ollama now puts images inside the messages array 153 | expect(lastCall.messages).toBeDefined(); 154 | // Find a message with images 155 | const messagesWithImages = lastCall.messages.filter((msg: any) => msg.images); 156 | expect(messagesWithImages.length).toBeGreaterThan(0); 157 | // Check if base64 prefix was removed properly 158 | expect(messagesWithImages[0].images[0]).not.toContain('data:image/'); 159 | } 160 | } 161 | }, 162 | // Add additional streaming tests specifically for image handling with prompt-based approach 163 | additionalStreamingTests: [ 164 | { 165 | name: 'should handle images correctly with prompt-based format', 166 | executeParams: { 167 | prompt: 'Describe this image', 168 | // Use a minimal test image 169 | images: ['data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCA...'], 170 | systemPrompt: 'You are a helpful assistant', 171 | options: {} 172 | }, 173 | verify: (result, mockClient) => { 174 | const chatCalls = (mockClient as any).chat.mock.calls; 175 | expect(chatCalls.length).toBeGreaterThan(0); 176 | 177 | const lastCall = chatCalls[chatCalls.length - 1][0]; 178 | 179 | // Verify the message format is correct (system + user messages) 180 | expect(lastCall.messages).toEqual([ 181 | { role: 'system', content: 'You are a helpful assistant' }, 182 | { role: 'user', content: 'Describe this image' } 183 | ]); 184 | 185 | // Verify images are correctly processed 186 | expect(lastCall.images).toBeDefined(); 187 | expect(lastCall.images[0]).not.toContain('data:image/'); 188 | 189 | // Verify streaming is enabled 190 | expect(lastCall.stream).toBe(true); 191 | } 192 | } 193 | ] 194 | } 195 | ); 196 | 197 | // Add direct tests for the context optimization functionality 198 | describe('Ollama context optimization direct tests', () => { 199 | it('should optimize context size based on input length', () => { 200 | // Create a handler 201 | const handler = new OllamaHandler({ 202 | _version: 1, 203 | debugLogging: false, 204 | useNativeFetch: false 205 | }); 206 | 207 | // Access the private optimizeContext method 208 | const optimizeContext = (handler as any).optimizeContext.bind(handler); 209 | 210 | // Constants from the OllamaHandler implementation 211 | const SYMBOLS_PER_TOKEN = 2.5; 212 | const DEFAULT_CONTEXT_LENGTH = 2048; 213 | const CONTEXT_BUFFER_MULTIPLIER = 1.2; 214 | 215 | // Test with a small input - shouldn't update context 216 | const smallInput = 1000; // about 400 tokens 217 | const smallResult = optimizeContext( 218 | smallInput, 219 | DEFAULT_CONTEXT_LENGTH, // lastContextLength 220 | DEFAULT_CONTEXT_LENGTH, // defaultContextLength 221 | 8192 // limit 222 | ); 223 | 224 | // Should not increase context for small input 225 | expect(smallResult.shouldUpdate).toBe(false); 226 | // num_ctx is undefined if we're not updating 227 | expect(smallResult.num_ctx).toBeUndefined(); 228 | 229 | // Test with a large input that exceeds default context 230 | const largeInput = 10000; // about 4000 tokens 231 | const largeEstimatedTokens = Math.ceil(largeInput / SYMBOLS_PER_TOKEN); 232 | const targetLength = Math.ceil(Math.max(largeEstimatedTokens, DEFAULT_CONTEXT_LENGTH) * CONTEXT_BUFFER_MULTIPLIER); 233 | 234 | const largeResult = optimizeContext( 235 | largeInput, 236 | DEFAULT_CONTEXT_LENGTH, // lastContextLength 237 | DEFAULT_CONTEXT_LENGTH, // defaultContextLength 238 | 8192 // limit 239 | ); 240 | 241 | // Should increase context for large input 242 | expect(largeResult.shouldUpdate).toBe(true); 243 | expect(largeResult.num_ctx).toBe(targetLength); 244 | expect(largeResult.num_ctx).toBeGreaterThan(largeEstimatedTokens); 245 | }); 246 | 247 | it('should respect model context length limits', () => { 248 | // Create a handler 249 | const handler = new OllamaHandler({ 250 | _version: 1, 251 | debugLogging: false, 252 | useNativeFetch: false 253 | }); 254 | 255 | // Access the private optimizeContext method 256 | const optimizeContext = (handler as any).optimizeContext.bind(handler); 257 | 258 | // Constants from the OllamaHandler implementation 259 | const DEFAULT_CONTEXT_LENGTH = 2048; 260 | 261 | // Test with a large input that exceeds the model limit 262 | const largeInput = 10000; // about 4000 tokens 263 | const smallModelLimit = 2048; // Small model context limit 264 | 265 | const result = optimizeContext( 266 | largeInput, 267 | DEFAULT_CONTEXT_LENGTH, // lastContextLength 268 | DEFAULT_CONTEXT_LENGTH, // defaultContextLength 269 | smallModelLimit // limit - small model context limit 270 | ); 271 | 272 | // Should not exceed the model's limit 273 | expect(result.num_ctx).toBeLessThanOrEqual(smallModelLimit); 274 | }); 275 | }); 276 | 277 | // Add a direct test for image handling with prompt-based format 278 | describe('Ollama image handling direct tests', () => { 279 | it('should process images correctly with prompt-based format', async () => { 280 | // Create a simpler test that doesn't rely on internal mocking 281 | const testImage = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCA...'; 282 | 283 | // Create a simplified handler for testing 284 | const handlerPrototype = OllamaHandler.prototype; 285 | const originalChat = handlerPrototype.execute; 286 | 287 | let capturedArgs: any = null; 288 | 289 | // Mock the execute method to capture the args and check them 290 | handlerPrototype.execute = jest.fn().mockImplementation(function(this: OllamaHandler, params: any) { 291 | capturedArgs = params; 292 | return { 293 | onData: () => {}, 294 | onEnd: () => {}, 295 | onError: () => {}, 296 | abort: () => {} 297 | }; 298 | }); 299 | 300 | try { 301 | // Create a real instance 302 | const handler = new OllamaHandler({ 303 | _version: 1, 304 | debugLogging: false, 305 | useNativeFetch: false 306 | }); 307 | 308 | // Execute method with image 309 | await handler.execute({ 310 | provider: { 311 | id: 'test-provider', 312 | name: 'Test Provider', 313 | type: 'ollama', 314 | url: 'http://localhost:11434', 315 | model: 'llama2' 316 | }, 317 | prompt: 'Describe this image', 318 | systemPrompt: 'You are a helpful assistant', 319 | images: [testImage], 320 | options: {} 321 | }); 322 | 323 | // Verify the execute method was called with the expected args 324 | expect(handlerPrototype.execute).toHaveBeenCalled(); 325 | expect(capturedArgs).toEqual({ 326 | provider: expect.objectContaining({ 327 | model: 'llama2', 328 | type: 'ollama', 329 | }), 330 | prompt: 'Describe this image', 331 | systemPrompt: 'You are a helpful assistant', 332 | images: [testImage], 333 | options: {} 334 | }); 335 | 336 | } finally { 337 | // Clean up the mock to avoid affecting other tests 338 | handlerPrototype.execute = originalChat; 339 | } 340 | }); 341 | }); -------------------------------------------------------------------------------- /src/handlers/OllamaHandler.ts: -------------------------------------------------------------------------------- 1 | import { IAIHandler, IAIProvider, IAIProvidersExecuteParams, IChunkHandler, IAIProvidersEmbedParams, IAIProvidersPluginSettings } from '@obsidian-ai-providers/sdk'; 2 | import { Ollama } from 'ollama'; 3 | import { electronFetch } from '../utils/electronFetch'; 4 | import { obsidianFetch } from '../utils/obsidianFetch'; 5 | import { logger } from '../utils/logger'; 6 | 7 | // Extend ChatResponse type 8 | interface ExtendedChatResponse { 9 | message?: { 10 | content?: string; 11 | } 12 | total_tokens?: number; 13 | } 14 | 15 | // Add interface for model cache 16 | interface ModelInfo { 17 | contextLength: number; 18 | lastContextLength: number; 19 | } 20 | 21 | const SYMBOLS_PER_TOKEN = 2.5; 22 | const DEFAULT_CONTEXT_LENGTH = 2048; 23 | const EMBEDDING_CONTEXT_LENGTH = 2048; 24 | const CONTEXT_BUFFER_MULTIPLIER = 1.2; // 20% buffer 25 | 26 | export class OllamaHandler implements IAIHandler { 27 | private modelInfoCache: Map; 28 | 29 | constructor(private settings: IAIProvidersPluginSettings) { 30 | this.modelInfoCache = new Map(); 31 | } 32 | 33 | dispose() { 34 | this.modelInfoCache.clear(); 35 | } 36 | 37 | private getClient(provider: IAIProvider, fetch: typeof electronFetch | typeof obsidianFetch): Ollama { 38 | return new Ollama({ 39 | host: provider.url || '', 40 | fetch 41 | }); 42 | } 43 | 44 | private getDefaultModelInfo(): ModelInfo { 45 | return { 46 | contextLength: 0, 47 | lastContextLength: DEFAULT_CONTEXT_LENGTH 48 | }; 49 | } 50 | 51 | private async getCachedModelInfo(provider: IAIProvider, modelName: string): Promise { 52 | const cacheKey = `${provider.url}_${modelName}`; 53 | const cached = this.modelInfoCache.get(cacheKey); 54 | if (cached) { 55 | return cached; 56 | } 57 | 58 | const ollama = this.getClient(provider, this.settings.useNativeFetch ? fetch : obsidianFetch); 59 | try { 60 | const response = await ollama.show({ model: modelName }); 61 | const modelInfo = this.getDefaultModelInfo(); 62 | 63 | const contextLengthEntry = Object.entries(response.model_info).find(([key, value]) => 64 | (key.endsWith('.context_length') || key === 'num_ctx') && 65 | typeof value === 'number' && 66 | value > 0 67 | ); 68 | 69 | if (contextLengthEntry && typeof contextLengthEntry[1] === 'number') { 70 | modelInfo.contextLength = contextLengthEntry[1]; 71 | } 72 | 73 | this.modelInfoCache.set(cacheKey, modelInfo); 74 | return modelInfo; 75 | } catch (error) { 76 | logger.error('Failed to fetch model info:', error); 77 | return this.getDefaultModelInfo(); 78 | } 79 | } 80 | 81 | private setModelInfoLastContextLength(provider: IAIProvider, modelName: string, num_ctx: number | undefined) { 82 | const cacheKey = `${provider.url}_${modelName}`; 83 | const modelInfo = this.modelInfoCache.get(cacheKey); 84 | if (modelInfo) { 85 | this.modelInfoCache.set(cacheKey, { 86 | ...modelInfo, 87 | lastContextLength: num_ctx || modelInfo.lastContextLength 88 | }); 89 | } 90 | } 91 | 92 | async fetchModels(provider: IAIProvider): Promise { 93 | const ollama = this.getClient(provider, this.settings.useNativeFetch ? fetch : obsidianFetch); 94 | const models = await ollama.list(); 95 | return models.models.map(model => model.name); 96 | } 97 | 98 | private optimizeContext( 99 | inputLength: number, 100 | lastContextLength: number, 101 | defaultContextLength: number, 102 | limit: number 103 | ): { num_ctx?: number, shouldUpdate: boolean } { 104 | const estimatedTokens = Math.ceil(inputLength / SYMBOLS_PER_TOKEN); 105 | 106 | // If current context is smaller than last used, 107 | // use the last known context size 108 | if (estimatedTokens <= lastContextLength) { 109 | return { 110 | num_ctx: lastContextLength > defaultContextLength ? lastContextLength : undefined, 111 | shouldUpdate: false 112 | }; 113 | } 114 | 115 | // For large inputs, calculate new size with buffer 116 | const targetLength = Math.min( 117 | Math.ceil( 118 | Math.max(estimatedTokens, defaultContextLength) * CONTEXT_BUFFER_MULTIPLIER 119 | ), 120 | limit 121 | ); 122 | 123 | // Update only if we need context larger than previous 124 | const shouldUpdate = targetLength > lastContextLength; 125 | return { 126 | num_ctx: targetLength, 127 | shouldUpdate 128 | }; 129 | } 130 | 131 | async embed(params: IAIProvidersEmbedParams): Promise { 132 | logger.debug('Starting embed process with params:', { 133 | model: params.provider.model, 134 | inputLength: Array.isArray(params.input) ? params.input.length : 1 135 | }); 136 | 137 | const ollama = this.getClient( 138 | params.provider, 139 | this.settings.useNativeFetch ? fetch : obsidianFetch 140 | ); 141 | 142 | // Support for both input and text (for backward compatibility) 143 | // Using type assertion to bypass type checking 144 | const inputText = params.input ?? (params as any).text; 145 | 146 | if (!inputText) { 147 | throw new Error('Either input or text parameter must be provided'); 148 | } 149 | 150 | const modelInfo = await this.getCachedModelInfo( 151 | params.provider, 152 | params.provider.model || "" 153 | ); 154 | logger.debug('Retrieved model info:', modelInfo); 155 | 156 | const maxInputLength = Array.isArray(inputText) 157 | ? Math.max(...inputText.map(text => text.length)) 158 | : inputText.length; 159 | 160 | logger.debug('Max input length:', maxInputLength); 161 | 162 | const { num_ctx, shouldUpdate } = this.optimizeContext( 163 | maxInputLength, 164 | modelInfo.lastContextLength || EMBEDDING_CONTEXT_LENGTH, 165 | EMBEDDING_CONTEXT_LENGTH, 166 | modelInfo.contextLength 167 | ); 168 | 169 | logger.debug('Optimized context:', { num_ctx, shouldUpdate }); 170 | 171 | if (shouldUpdate) { 172 | logger.debug('Updating model info last context length:', num_ctx); 173 | this.setModelInfoLastContextLength( 174 | params.provider, 175 | params.provider.model || "", 176 | num_ctx 177 | ); 178 | } 179 | 180 | try { 181 | logger.debug('Sending embed request to Ollama'); 182 | const response = await ollama.embed({ 183 | model: params.provider.model || "", 184 | input: inputText, 185 | options: { num_ctx } 186 | }); 187 | 188 | if (!response?.embeddings) { 189 | throw new Error('No embeddings in response'); 190 | } 191 | 192 | logger.debug('Successfully received embeddings:', { 193 | count: response.embeddings.length, 194 | dimensions: response.embeddings[0]?.length 195 | }); 196 | 197 | return response.embeddings; 198 | } catch (error) { 199 | logger.error('Failed to get embeddings:', error); 200 | throw error; 201 | } 202 | } 203 | 204 | async execute(params: IAIProvidersExecuteParams): Promise { 205 | logger.debug('Starting execute process with params:', { 206 | model: params.provider.model, 207 | messagesCount: params.messages?.length || 0, 208 | promptLength: params.prompt?.length || 0, 209 | systemPromptLength: params.systemPrompt?.length || 0, 210 | hasImages: !!params.images?.length 211 | }); 212 | 213 | const controller = new AbortController(); 214 | const ollama = this.getClient( 215 | params.provider, 216 | this.settings.useNativeFetch ? fetch : electronFetch.bind({ 217 | controller 218 | }) 219 | ); 220 | let isAborted = false; 221 | let response: AsyncIterable | null = null; 222 | 223 | const handlers = { 224 | data: [] as ((chunk: string, accumulatedText: string) => void)[], 225 | end: [] as ((fullText: string) => void)[], 226 | error: [] as ((error: Error) => void)[] 227 | }; 228 | 229 | (async () => { 230 | if (isAborted) return; 231 | 232 | let fullText = ''; 233 | 234 | try { 235 | const modelInfo = await this.getCachedModelInfo( 236 | params.provider, 237 | params.provider.model || "" 238 | ).catch(error => { 239 | logger.error('Failed to get model info:', error); 240 | return null; 241 | }); 242 | 243 | logger.debug('Retrieved model info:', modelInfo); 244 | 245 | // Prepare messages in a standardized format 246 | const chatMessages: { role: string; content: string; images?: string[] }[] = []; 247 | const extractedImages: string[] = []; 248 | 249 | if ('messages' in params && params.messages) { 250 | // Process messages with standardized handling for text and images 251 | params.messages.forEach(msg => { 252 | if (typeof msg.content === 'string') { 253 | // Simple text content 254 | chatMessages.push({ 255 | role: msg.role, 256 | content: msg.content 257 | }); 258 | } else { 259 | // Extract text content from content blocks 260 | const textContent = msg.content 261 | .filter(block => block.type === 'text') 262 | .map(block => block.type === 'text' ? block.text : '') 263 | .join('\n'); 264 | 265 | // Extract image URLs from content blocks 266 | msg.content 267 | .filter(block => block.type === 'image_url') 268 | .forEach(block => { 269 | if (block.type === 'image_url' && block.image_url?.url) { 270 | extractedImages.push(block.image_url.url); 271 | } 272 | }); 273 | 274 | chatMessages.push({ 275 | role: msg.role, 276 | content: textContent 277 | }); 278 | } 279 | 280 | // Add any images from the images property 281 | if (msg.images?.length) { 282 | extractedImages.push(...msg.images); 283 | } 284 | }); 285 | } else if ('prompt' in params) { 286 | // Handle legacy prompt-based API 287 | if (params.systemPrompt) { 288 | chatMessages.push({ role: 'system', content: params.systemPrompt }); 289 | } 290 | 291 | chatMessages.push({ role: 'user', content: params.prompt }); 292 | 293 | // Add any images from params 294 | if (params.images?.length) { 295 | extractedImages.push(...params.images); 296 | } 297 | } else { 298 | throw new Error('Either messages or prompt must be provided'); 299 | } 300 | 301 | // Process images for Ollama format (remove data URL prefix) 302 | const processedImages = extractedImages.length > 0 303 | ? extractedImages.map(image => image.replace(/^data:image\/(.*?);base64,/, "")) 304 | : undefined; 305 | 306 | logger.debug('Processing request with images:', { imageCount: processedImages?.length || 0 }); 307 | 308 | // Prepare request options 309 | const requestOptions: Record = {}; 310 | 311 | // Optimize context for text-based conversations 312 | if (!processedImages?.length) { 313 | const inputLength = chatMessages.reduce((acc, msg) => acc + msg.content.length, 0); 314 | 315 | logger.debug('Calculating context for text input:', { inputLength }); 316 | 317 | const { num_ctx, shouldUpdate } = this.optimizeContext( 318 | inputLength, 319 | modelInfo?.lastContextLength || DEFAULT_CONTEXT_LENGTH, 320 | DEFAULT_CONTEXT_LENGTH, 321 | modelInfo?.contextLength || DEFAULT_CONTEXT_LENGTH 322 | ); 323 | 324 | if (num_ctx) { 325 | requestOptions.num_ctx = num_ctx; 326 | } 327 | 328 | logger.debug('Optimized context:', { num_ctx, shouldUpdate }); 329 | 330 | if (shouldUpdate) { 331 | this.setModelInfoLastContextLength( 332 | params.provider, 333 | params.provider.model || "", 334 | num_ctx 335 | ); 336 | logger.debug('Updated context length:', num_ctx); 337 | } 338 | } 339 | 340 | // Add any additional options from params 341 | if (params.options) { 342 | Object.assign(requestOptions, params.options); 343 | } 344 | 345 | // Add images to the last user message if present 346 | if (processedImages?.length) { 347 | // Find the last user message in the chat 348 | const lastUserMessageIndex = chatMessages.map(msg => msg.role).lastIndexOf('user'); 349 | 350 | if (lastUserMessageIndex !== -1) { 351 | // Add images to the last user message 352 | chatMessages[lastUserMessageIndex] = { 353 | ...chatMessages[lastUserMessageIndex], 354 | images: processedImages 355 | }; 356 | logger.debug('Added images to last user message at index:', lastUserMessageIndex); 357 | } else if (chatMessages.length > 0) { 358 | // If no user message, add to the last message regardless of role 359 | chatMessages[chatMessages.length - 1] = { 360 | ...chatMessages[chatMessages.length - 1], 361 | images: processedImages 362 | }; 363 | logger.debug('Added images to last message (non-user)'); 364 | } else { 365 | // If no messages at all, create a user message with empty content 366 | chatMessages.push({ 367 | role: 'user', 368 | content: '', 369 | images: processedImages 370 | }); 371 | logger.debug('Created new user message with images'); 372 | } 373 | } 374 | 375 | logger.debug('Sending chat request to Ollama'); 376 | 377 | // Using Ollama chat API instead of generate 378 | response = await ollama.chat({ 379 | model: params.provider.model || "", 380 | messages: chatMessages, 381 | stream: true, 382 | options: Object.keys(requestOptions).length > 0 ? requestOptions : undefined 383 | } as any); // Type assertion for compatibility 384 | 385 | for await (const part of response) { 386 | if (isAborted) { 387 | logger.debug('Generation aborted'); 388 | break; 389 | } 390 | 391 | // Extract content from message for chat API 392 | const responseText = part.message?.content || ''; 393 | if (responseText) { 394 | fullText += responseText; 395 | handlers.data.forEach(handler => handler(responseText, fullText)); 396 | } 397 | } 398 | 399 | if (!isAborted) { 400 | logger.debug('Generation completed successfully:', { 401 | totalLength: fullText.length 402 | }); 403 | handlers.end.forEach(handler => handler(fullText)); 404 | } 405 | } catch (error) { 406 | logger.error('Generation failed:', error); 407 | handlers.error.forEach(handler => handler(error as Error)); 408 | } 409 | })(); 410 | 411 | return { 412 | onData(callback: (chunk: string, accumulatedText: string) => void) { 413 | handlers.data.push(callback); 414 | }, 415 | onEnd(callback: (fullText: string) => void) { 416 | handlers.end.push(callback); 417 | }, 418 | onError(callback: (error: Error) => void) { 419 | handlers.error.push(callback); 420 | }, 421 | abort() { 422 | isAborted = true; 423 | controller.abort(); 424 | } 425 | }; 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /src/handlers/OpenAIHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIHandler } from './OpenAIHandler'; 2 | import { IAIProvider } from '@obsidian-ai-providers/sdk'; 3 | import { createAIHandlerTests, createDefaultVerifyApiCalls, IMockClient } from '../../test-utils/createAIHandlerTests'; 4 | 5 | jest.mock('openai'); 6 | 7 | const createHandler = () => new OpenAIHandler({ 8 | _version: 1, 9 | debugLogging: false, 10 | useNativeFetch: false 11 | }); 12 | 13 | const createMockProvider = (): IAIProvider => ({ 14 | id: 'test-provider', 15 | name: 'Test Provider', 16 | type: 'openai', 17 | url: 'https://api.openai.com/v1', 18 | apiKey: 'test-key', 19 | model: 'gpt-4' 20 | }); 21 | 22 | const createMockClient = (): IMockClient => ({ 23 | models: { 24 | list: jest.fn().mockResolvedValue({ 25 | data: [ 26 | { id: 'model1' }, 27 | { id: 'model2' } 28 | ] 29 | }) 30 | }, 31 | chat: { 32 | completions: { 33 | create: jest.fn().mockImplementation(async (_params, { signal }) => { 34 | const responseStream = { 35 | async *[Symbol.asyncIterator]() { 36 | for (let i = 0; i < 5; i++) { 37 | if (signal?.aborted) { 38 | break; 39 | } 40 | yield { choices: [{ delta: { content: `chunk${i}` } }] }; 41 | } 42 | } 43 | }; 44 | return responseStream; 45 | }) 46 | } 47 | } 48 | }); 49 | 50 | // Use the default OpenAI verification function 51 | const verifyApiCalls = createDefaultVerifyApiCalls(); 52 | 53 | // Use createAIHandlerTests for common test cases 54 | createAIHandlerTests( 55 | 'OpenAIHandler', 56 | createHandler, 57 | createMockProvider, 58 | createMockClient, 59 | verifyApiCalls, 60 | { 61 | mockStreamResponse: { 62 | choices: [{ delta: { content: 'test response' } }] 63 | }, 64 | // Add image handling test for OpenAI 65 | imageHandlingOptions: { 66 | verifyImageHandling: async (handler, mockClient) => { 67 | // OpenAI image handling is done through content array with image_url objects 68 | expect(mockClient.chat?.completions.create).toHaveBeenCalledWith( 69 | expect.objectContaining({ 70 | messages: expect.arrayContaining([ 71 | expect.objectContaining({ 72 | content: expect.arrayContaining([ 73 | expect.objectContaining({ type: 'text' }), 74 | expect.objectContaining({ 75 | type: 'image_url', 76 | image_url: expect.anything() 77 | }) 78 | ]) 79 | }) 80 | ]) 81 | }), 82 | expect.anything() 83 | ); 84 | } 85 | }, 86 | // Add embedding options for OpenAI 87 | embeddingOptions: { 88 | mockEmbeddingResponse: [[0.1, 0.2, 0.3]], 89 | setupEmbedMock: (mockClient) => { 90 | // Add mock for embeddings API in OpenAI 91 | (mockClient as any).embeddings = { 92 | create: jest.fn().mockResolvedValue({ 93 | data: [{ embedding: [0.1, 0.2, 0.3], index: 0 }] 94 | }) 95 | }; 96 | } 97 | } 98 | } 99 | ); -------------------------------------------------------------------------------- /src/handlers/OpenAIHandler.ts: -------------------------------------------------------------------------------- 1 | import { IAIHandler, IAIProvider, IAIProvidersExecuteParams, IChunkHandler, IAIProvidersEmbedParams, IAIProvidersPluginSettings } from '@obsidian-ai-providers/sdk'; 2 | import { electronFetch } from '../utils/electronFetch'; 3 | import OpenAI from 'openai'; 4 | import { obsidianFetch } from '../utils/obsidianFetch'; 5 | import { logger } from '../utils/logger'; 6 | 7 | export class OpenAIHandler implements IAIHandler { 8 | constructor(private settings: IAIProvidersPluginSettings) {} 9 | 10 | private getClient(provider: IAIProvider, fetch: typeof electronFetch | typeof obsidianFetch): OpenAI { 11 | return new OpenAI({ 12 | apiKey: provider.apiKey, 13 | baseURL: provider.url, 14 | dangerouslyAllowBrowser: true, 15 | fetch 16 | }); 17 | } 18 | 19 | async fetchModels(provider: IAIProvider): Promise { 20 | const openai = this.getClient(provider, this.settings.useNativeFetch ? fetch : obsidianFetch); 21 | const response = await openai.models.list(); 22 | 23 | return response.data.map(model => model.id); 24 | } 25 | 26 | async embed(params: IAIProvidersEmbedParams): Promise { 27 | const openai = this.getClient(params.provider, this.settings.useNativeFetch ? fetch : obsidianFetch); 28 | 29 | // Support for both input and text (for backward compatibility) 30 | // Using type assertion to bypass type checking 31 | const inputText = params.input ?? (params as any).text; 32 | 33 | if (!inputText) { 34 | throw new Error('Either input or text parameter must be provided'); 35 | } 36 | 37 | const response = await openai.embeddings.create({ 38 | model: params.provider.model || "", 39 | input: inputText 40 | }); 41 | logger.debug('Embed response:', response); 42 | 43 | return response.data.map(item => item.embedding); 44 | } 45 | 46 | async execute(params: IAIProvidersExecuteParams): Promise { 47 | const controller = new AbortController(); 48 | const openai = this.getClient(params.provider, this.settings.useNativeFetch ? fetch : electronFetch.bind({ 49 | controller 50 | })); 51 | let isAborted = false; 52 | 53 | let messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = []; 54 | 55 | if ('messages' in params && params.messages) { 56 | // Convert messages to OpenAI format 57 | messages = params.messages.map(msg => { 58 | // Handle simple text content 59 | if (typeof msg.content === 'string') { 60 | return { 61 | role: msg.role as any, // Type as any to avoid role compatibility issues 62 | content: msg.content 63 | }; 64 | } 65 | 66 | // Handle content blocks (text and images) 67 | const content: OpenAI.Chat.Completions.ChatCompletionContentPart[] = []; 68 | 69 | // Process each content block 70 | msg.content.forEach(block => { 71 | if (block.type === 'text') { 72 | content.push({ type: 'text', text: block.text }); 73 | } else if (block.type === 'image_url') { 74 | content.push({ 75 | type: 'image_url', 76 | image_url: { url: block.image_url.url } 77 | } as OpenAI.Chat.Completions.ChatCompletionContentPartImage); 78 | } 79 | }); 80 | 81 | return { 82 | role: msg.role as any, 83 | content 84 | }; 85 | }); 86 | } else if ('prompt' in params) { 87 | // Legacy prompt-based API 88 | if (params.systemPrompt) { 89 | messages.push({ role: 'system', content: params.systemPrompt }); 90 | } 91 | 92 | // Handle prompt with images 93 | if (params.images?.length) { 94 | const content: OpenAI.Chat.Completions.ChatCompletionContentPart[] = [ 95 | { type: "text", text: params.prompt } 96 | ]; 97 | 98 | // Add images as content parts 99 | params.images.forEach(image => { 100 | content.push({ 101 | type: "image_url", 102 | image_url: { url: image } 103 | } as OpenAI.Chat.Completions.ChatCompletionContentPartImage); 104 | }); 105 | 106 | messages.push({ role: 'user', content }); 107 | } else { 108 | messages.push({ role: 'user', content: params.prompt }); 109 | } 110 | } else { 111 | throw new Error('Either messages or prompt must be provided'); 112 | } 113 | 114 | const handlers = { 115 | data: [] as ((chunk: string, accumulatedText: string) => void)[], 116 | end: [] as ((fullText: string) => void)[], 117 | error: [] as ((error: Error) => void)[] 118 | }; 119 | 120 | (async () => { 121 | if (isAborted) return; 122 | 123 | try { 124 | const response = await openai.chat.completions.create({ 125 | model: params.provider.model || "", 126 | messages, 127 | stream: true, 128 | ...params.options 129 | }, { signal: controller.signal }); 130 | 131 | let fullText = ''; 132 | 133 | for await (const chunk of response) { 134 | if (isAborted) break; 135 | const content = chunk.choices[0]?.delta?.content; 136 | if (content) { 137 | fullText += content; 138 | handlers.data.forEach(handler => handler(content, fullText)); 139 | } 140 | } 141 | if (!isAborted) { 142 | handlers.end.forEach(handler => handler(fullText)); 143 | } 144 | } catch (error) { 145 | handlers.error.forEach(handler => handler(error as Error)); 146 | } 147 | })(); 148 | 149 | return { 150 | onData(callback: (chunk: string, accumulatedText: string) => void) { 151 | handlers.data.push(callback); 152 | }, 153 | onEnd(callback: (fullText: string) => void) { 154 | handlers.end.push(callback); 155 | }, 156 | onError(callback: (error: Error) => void) { 157 | handlers.error.push(callback); 158 | }, 159 | abort() { 160 | logger.debug('Request aborted'); 161 | isAborted = true; 162 | controller.abort(); 163 | } 164 | }; 165 | } 166 | } -------------------------------------------------------------------------------- /src/i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "notice": "Dieses Plugin ist eine Konfigurationszentrale für KI-Anbieter. Es macht selbst nichts, aber andere Plugins können es nutzen, um KI-Einstellungen nicht mehrfach konfigurieren zu müssen.", 4 | "configuredProviders": "KI-anbieter", 5 | "addNewProvider": "Neuen anbieter hinzufügen", 6 | "addProvider": "Anbieter hinzufügen", 7 | "providerName": "Anbietername", 8 | "providerNameDesc": "Geben Sie einen Namen für diesen Anbieter ein", 9 | "providerNamePlaceholder": "Mein anbieter", 10 | "providerType": "Anbietertyp", 11 | "providerTypeDesc": "Wählen Sie den KI-anbietertyp aus", 12 | "providerUrl": "Anbieter-URL", 13 | "providerUrlDesc": "Geben Sie die API-Endpunkt-URL ein", 14 | "providerUrlPlaceholder": "https://...", 15 | "apiKey": "API-Schlüssel", 16 | "apiKeyDesc": "Geben Sie Ihren API-Schlüssel ein (falls erforderlich)", 17 | "apiKeyPlaceholder": "sk-...", 18 | "defaultModel": "Standardmodell", 19 | "defaultModelDesc": "Wählen Sie das Standard-KI-Modell für diesen Anbieter", 20 | "save": "Speichern", 21 | "cancel": "Abbrechen", 22 | "options": "Optionen", 23 | "delete": "Löschen", 24 | "duplicate": "Duplizieren", 25 | "deleteConfirmation": "Möchten Sie den Anbieter \"{{name}}\" wirklich löschen?", 26 | "modelsUpdated": "Modellliste aktualisiert", 27 | "refreshModelsList": "Modellliste aktualisieren", 28 | "model": "Modell", 29 | "modelDesc": "Wählen Sie das KI-Modell für diesen Anbieter.
Oder in den Textmodus wechseln", 30 | "modelTextDesc": "Geben Sie den KI-Modellnamen ein.
Oder in den Dropdown-Modus wechseln", 31 | "loadingModels": "Modelle werden geladen...", 32 | "noModelsAvailable": "Keine Modelle verfügbar", 33 | "editProvider": "Anbieter bearbeiten", 34 | "developerSettings": "Für entwickler", 35 | "developerSettingsDesc": "Zusätzliche Einstellungen für Entwicklung und Debugging aktivieren", 36 | "debugLogging": "Debug-protokollierung", 37 | "debugLoggingDesc": "Detaillierte Protokollierung in der Konsole aktivieren", 38 | "useNativeFetch": "Nativen fetch verwenden", 39 | "useNativeFetchDesc": "Verwendet Fetch API anstelle von Obsidian für besseres Debugging der Anfragen in DevTools" 40 | }, 41 | "modals": { 42 | "confirm": "Bestätigen", 43 | "cancel": "Abbrechen" 44 | }, 45 | "errors": { 46 | "failedToFetchModels": "Modelle konnten nicht abgerufen werden", 47 | "failedToExecuteRequest": "Anfrage konnte nicht ausgeführt werden", 48 | "missingApiKey": "API-Schlüssel ist erforderlich", 49 | "missingUrl": "Anbieter-URL ist erforderlich", 50 | "missingModel": "Modell muss ausgewählt werden", 51 | "pluginMustBeUpdated": "Plugin muss aktualisiert werden", 52 | "pluginMustBeUpdatedFormatted": "Das Plugin muss aktualisiert werden, um mit dieser Version von AI Providers zu funktionieren" 53 | } 54 | } -------------------------------------------------------------------------------- /src/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "notice": "This plugin is a configuration hub for AI providers. It doesn't do anything on its own, but other plugins can use it to avoid configuring AI settings repeatedly.", 4 | "configuredProviders": "AI providers", 5 | "addNewProvider": "Add new provider", 6 | "addProvider": "Add provider", 7 | "providerName": "Provider name", 8 | "providerNameDesc": "Enter a name for this provider", 9 | "providerNamePlaceholder": "My provider", 10 | "providerType": "Provider type", 11 | "providerTypeDesc": "Select the type of AI provider", 12 | "providerUrl": "Provider URL", 13 | "providerUrlDesc": "Enter the API endpoint URL", 14 | "providerUrlPlaceholder": "https://...", 15 | "apiKey": "API key", 16 | "apiKeyDesc": "Enter your API key (if required)", 17 | "apiKeyPlaceholder": "sk-...", 18 | "defaultModel": "Default model", 19 | "defaultModelDesc": "Select the default AI model for this provider", 20 | "save": "Save", 21 | "cancel": "Cancel", 22 | "options": "Options", 23 | "delete": "Delete", 24 | "duplicate": "Duplicate", 25 | "deleteConfirmation": "Are you sure you want to delete provider \"{{name}}\"?", 26 | "modelsUpdated": "Models list updated", 27 | "refreshModelsList": "Refresh models list", 28 | "model": "Model", 29 | "modelDesc": "Select the AI model for this provider.
Or switch to text mode", 30 | "modelTextDesc": "Enter the AI model name.
Or switch to dropdown mode", 31 | "loadingModels": "Loading models...", 32 | "noModelsAvailable": "No models available", 33 | "editProvider": "Edit provider", 34 | "developerSettings": "For developers", 35 | "developerSettingsDesc": "Enable additional settings for development and debugging", 36 | "debugLogging": "Debug logging", 37 | "debugLoggingDesc": "Enable detailed logging in the console", 38 | "useNativeFetch": "Use native fetch", 39 | "useNativeFetchDesc": "Use fetch API instead of Obsidian's for better request debugging in DevTools" 40 | }, 41 | "modals": { 42 | "confirm": "Confirm", 43 | "cancel": "Cancel" 44 | }, 45 | "errors": { 46 | "failedToFetchModels": "Failed to fetch models", 47 | "failedToExecuteRequest": "Failed to execute request", 48 | "missingApiKey": "API key is required", 49 | "missingUrl": "Provider URL is required", 50 | "missingModel": "Model must be selected", 51 | "pluginMustBeUpdated": "Plugin must be updated", 52 | "pluginMustBeUpdatedFormatted": "Plugin must be updated to work with this version of AI Providers" 53 | } 54 | } -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import de from './de.json'; 2 | import en from './en.json'; 3 | import ru from './ru.json'; 4 | import zh from './zh.json'; 5 | import { logger } from '../utils/logger'; 6 | 7 | const locales: { [key: string]: any } = { 8 | en, 9 | ru, 10 | de, 11 | zh 12 | }; 13 | 14 | export class I18n { 15 | static t(key: string, params?: { [key: string]: string }): string { 16 | const locale = window.localStorage.getItem('language') || 'en'; 17 | const keys = key.split('.'); 18 | 19 | let translations = locales[locale] || locales['en']; 20 | 21 | for (const k of keys) { 22 | if (translations?.[k] === undefined) { 23 | logger.warn(`Translation missing: ${key}`); 24 | translations = locales['en']; 25 | let engValue = translations; 26 | for (const ek of keys) { 27 | engValue = engValue?.[ek]; 28 | } 29 | return engValue || key; 30 | } 31 | translations = translations[k]; 32 | } 33 | 34 | let result = translations; 35 | 36 | // Handle string interpolation if params are provided 37 | if (params) { 38 | Object.entries(params).forEach(([key, value]) => { 39 | result = result.replace(`{{${key}}}`, value); 40 | }); 41 | } 42 | 43 | return result; 44 | } 45 | } -------------------------------------------------------------------------------- /src/i18n/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "notice": "Этот плагин помогает настраивать AI-провайдеры. Сам по себе он ничего не делает, но служит общим хранилищем настроек, которым могут пользоваться другие плагины вместо повторной настройки.", 4 | "configuredProviders": "AI провайдеры", 5 | "addNewProvider": "Добавить новый провайдер", 6 | "addProvider": "Добавить провайдер", 7 | "providerName": "Имя провайдера", 8 | "providerNameDesc": "Введите имя для этого провайдера", 9 | "providerNamePlaceholder": "Мой провайдер", 10 | "providerType": "Тип провайдера", 11 | "providerTypeDesc": "Выберите тип AI-провайдера", 12 | "providerUrl": "URL провайдера", 13 | "providerUrlDesc": "Введите URL конечной точки API", 14 | "providerUrlPlaceholder": "https://...", 15 | "apiKey": "API-ключ", 16 | "apiKeyDesc": "Введите ваш API ключ (если требуется)", 17 | "apiKeyPlaceholder": "sk-...", 18 | "defaultModel": "Модель по умолчанию", 19 | "defaultModelDesc": "Выберите модель AI по умолчанию для этого провайдера", 20 | "save": "Сохранить", 21 | "cancel": "Отмена", 22 | "options": "Настройки", 23 | "delete": "Удалить", 24 | "duplicate": "Дублировать", 25 | "deleteConfirmation": "Вы уверены, что хотите удалить провайдера \"{{name}}\"?", 26 | "modelsUpdated": "Список моделей обновлен", 27 | "refreshModelsList": "Обновить список моделей", 28 | "model": "Модель", 29 | "modelDesc": "Выберите AI модель для этого провайдера.
Или переключиться в текстовый режим", 30 | "modelTextDesc": "Введите название AI модели.
Или переключиться в режим выпадающего списка", 31 | "loadingModels": "Загрузка моделей...", 32 | "noModelsAvailable": "Нет доступных моделей", 33 | "editProvider": "Редактировать провайдера", 34 | "developerSettings": "Для разработчиков", 35 | "developerSettingsDesc": "Включить дополнительные настройки для разработки и отладки", 36 | "debugLogging": "Отладочное логирование", 37 | "debugLoggingDesc": "Включить подробное логирование в консоли", 38 | "useNativeFetch": "Использовать нативный Fetch", 39 | "useNativeFetchDesc": "Использовать Fetch API вместо Obsidian для удобной отладки запросов в DevTools" 40 | }, 41 | "modals": { 42 | "confirm": "Подтвердить", 43 | "cancel": "Отмена" 44 | }, 45 | "errors": { 46 | "failedToFetchModels": "Не удалось получить список моделей", 47 | "failedToExecuteRequest": "Не удалось выполнить запрос", 48 | "missingApiKey": "Требуется API ключ", 49 | "missingUrl": "Требуется URL провайдера", 50 | "missingModel": "Необходимо выбрать модель", 51 | "pluginMustBeUpdated": "Плагин требует обновления", 52 | "pluginMustBeUpdatedFormatted": "Для работы с этой версией AI Providers требуется обновление плагина" 53 | } 54 | } -------------------------------------------------------------------------------- /src/i18n/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "notice": "此插件是 AI 提供商的配置中心。它本身不提供任何功能,但其他插件可以使用它来避免重复配置 AI 设置。", 4 | "configuredProviders": "AI 服务提供商", 5 | "addNewProvider": "添加新的服务提供商", 6 | "addProvider": "添加提供商", 7 | "providerName": "提供商名称", 8 | "providerNameDesc": "为此服务提供商输入名称", 9 | "providerNamePlaceholder": "我的服务提供商", 10 | "providerType": "提供商类型", 11 | "providerTypeDesc": "选择 AI 服务提供商类型", 12 | "providerUrl": "服务地址", 13 | "providerUrlDesc": "输入 API 接口地址", 14 | "providerUrlPlaceholder": "https://...", 15 | "apiKey": "API 密钥", 16 | "apiKeyDesc": "输入您的 API 密钥(如需要)", 17 | "apiKeyPlaceholder": "sk-...", 18 | "defaultModel": "默认模型", 19 | "defaultModelDesc": "选择此提供商的默认 AI 模型", 20 | "save": "保存", 21 | "cancel": "取消", 22 | "options": "选项", 23 | "delete": "删除", 24 | "duplicate": "复制", 25 | "deleteConfirmation": "确定要删除提供商\"{{name}}\"吗?", 26 | "modelsUpdated": "模型列表已更新", 27 | "refreshModelsList": "刷新模型列表", 28 | "model": "模型", 29 | "modelDesc": "选择此提供商的 AI 模型。
切换到文本模式", 30 | "modelTextDesc": "输入 AI 模型名称。
切换到下拉模式", 31 | "loadingModels": "正在加载模型...", 32 | "noModelsAvailable": "没有可用的模型", 33 | "editProvider": "编辑提供商", 34 | "developerSettings": "开发者选项", 35 | "developerSettingsDesc": "启用额外的开发和调试设置", 36 | "debugLogging": "调试日志", 37 | "debugLoggingDesc": "在控制台启用详细日志记录", 38 | "useNativeFetch": "使用原生 Fetch", 39 | "useNativeFetchDesc": "使用 Fetch API 替代 Obsidian,以便在 DevTools 中更好地调试请求" 40 | }, 41 | "modals": { 42 | "confirm": "确认", 43 | "cancel": "取消" 44 | }, 45 | "errors": { 46 | "failedToFetchModels": "获取模型列表失败", 47 | "failedToExecuteRequest": "请求执行失败", 48 | "missingApiKey": "需要提供 API 密钥", 49 | "missingUrl": "需要提供服务地址", 50 | "missingModel": "需要选择模型", 51 | "pluginMustBeUpdated": "插件需要更新", 52 | "pluginMustBeUpdatedFormatted": "插件需要更新才能与此版本的 AI Providers 一起使用" 53 | } 54 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, addIcon } from 'obsidian'; 2 | import { IAIProvidersPluginSettings } from '@obsidian-ai-providers/sdk'; 3 | import { DEFAULT_SETTINGS, AIProvidersSettingTab } from './settings'; 4 | import { AIProvidersService } from './AIProvidersService'; 5 | import { logger } from './utils/logger'; 6 | import { openAIIcon, ollamaIcon, geminiIcon, openRouterIcon, lmstudioIcon, groqIcon } from './utils/icons'; 7 | 8 | export default class AIProvidersPlugin extends Plugin { 9 | settings: IAIProvidersPluginSettings; 10 | aiProviders: AIProvidersService; 11 | 12 | async onload() { 13 | await this.loadSettings(); 14 | addIcon('ai-providers-openai', openAIIcon); 15 | addIcon('ai-providers-ollama', ollamaIcon); 16 | addIcon('ai-providers-gemini', geminiIcon); 17 | addIcon('ai-providers-openrouter', openRouterIcon); 18 | addIcon('ai-providers-lmstudio', lmstudioIcon); 19 | addIcon('ai-providers-groq', groqIcon); 20 | 21 | const settingTab = new AIProvidersSettingTab(this.app, this); 22 | this.exposeAIProviders(); 23 | this.app.workspace.trigger('ai-providers-ready'); 24 | 25 | this.addSettingTab(settingTab); 26 | } 27 | 28 | onunload() { 29 | delete (this.app as any).aiProviders; 30 | } 31 | 32 | async loadSettings() { 33 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 34 | logger.setEnabled(this.settings.debugLogging ?? false); 35 | } 36 | 37 | async saveSettings() { 38 | await this.saveData(this.settings); 39 | this.exposeAIProviders(); 40 | } 41 | 42 | exposeAIProviders() { 43 | this.aiProviders = new AIProvidersService(this.app, this); 44 | (this.app as any).aiProviders = this.aiProviders; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/modals/ConfirmationModal.test.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { ConfirmationModal } from './ConfirmationModal'; 3 | 4 | jest.mock('../i18n', () => ({ 5 | I18n: { 6 | t: (key: string) => key 7 | } 8 | })); 9 | 10 | describe('ConfirmationModal', () => { 11 | let app: App; 12 | let modal: ConfirmationModal; 13 | let onConfirmMock: jest.Mock; 14 | 15 | beforeEach(() => { 16 | app = new App(); 17 | onConfirmMock = jest.fn(); 18 | modal = new ConfirmationModal(app, 'Test message', onConfirmMock); 19 | }); 20 | 21 | it('should render confirmation dialog', () => { 22 | modal.onOpen(); 23 | 24 | const message = modal.contentEl.querySelector('p'); 25 | const buttons = modal.contentEl.querySelectorAll('button'); 26 | 27 | expect(message?.textContent).toBe('Test message'); 28 | expect(buttons.length).toBe(2); 29 | expect(buttons[0].textContent).toBe('modals.confirm'); 30 | expect(buttons[1].textContent).toBe('modals.cancel'); 31 | }); 32 | 33 | it('should call onConfirm when confirm button is clicked', () => { 34 | modal.onOpen(); 35 | 36 | const confirmButton = modal.contentEl.querySelector('button'); 37 | confirmButton?.click(); 38 | 39 | expect(onConfirmMock).toHaveBeenCalled(); 40 | expect(modal.contentEl.childNodes.length).toBe(0); // Modal should be closed 41 | }); 42 | 43 | it('should close without calling onConfirm when cancel is clicked', () => { 44 | modal.onOpen(); 45 | 46 | const buttons = modal.contentEl.querySelectorAll('button'); 47 | const cancelButton = buttons[1]; 48 | cancelButton?.click(); 49 | 50 | expect(onConfirmMock).not.toHaveBeenCalled(); 51 | expect(modal.contentEl.childNodes.length).toBe(0); 52 | }); 53 | }); -------------------------------------------------------------------------------- /src/modals/ConfirmationModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from 'obsidian'; 2 | import { I18n } from '../i18n'; 3 | 4 | export class ConfirmationModal extends Modal { 5 | private onConfirm: () => void; 6 | private onCancel: () => void; 7 | 8 | constructor(app: App, private message: string, onConfirm: () => void, onCancel: () => void = () => {}) { 9 | super(app); 10 | this.onConfirm = onConfirm; 11 | this.onCancel = onCancel; 12 | } 13 | 14 | onOpen() { 15 | const { contentEl } = this; 16 | 17 | contentEl.createEl('p', { text: this.message }); 18 | 19 | new Setting(contentEl) 20 | .addButton(button => button 21 | .setButtonText(I18n.t('modals.confirm')) 22 | .setWarning() 23 | .onClick(() => { 24 | this.onConfirm(); 25 | this.close(); 26 | })) 27 | .addButton(button => button 28 | .setButtonText(I18n.t('modals.cancel')) 29 | .onClick(() => { 30 | this.onCancel(); 31 | this.close(); 32 | })); 33 | } 34 | 35 | onClose() { 36 | const { contentEl } = this; 37 | contentEl.empty(); 38 | } 39 | } -------------------------------------------------------------------------------- /src/modals/ProviderFormModal.test.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { ProviderFormModal } from './ProviderFormModal'; 3 | import AIProvidersPlugin from '../main'; 4 | import { IAIProvider } from '@obsidian-ai-providers/sdk'; 5 | import { AIProvidersService } from '../AIProvidersService'; 6 | 7 | jest.mock('../i18n', () => ({ 8 | I18n: { 9 | t: (key: string) => key 10 | } 11 | })); 12 | 13 | // Helper function to safely get typed element 14 | function getElement(container: HTMLElement, selector: string): T { 15 | const element = container.querySelector(selector); 16 | if (!element) { 17 | throw new Error(`Element with selector "${selector}" not found`); 18 | } 19 | return element as unknown as T; 20 | } 21 | 22 | describe('ProviderFormModal', () => { 23 | let app: App; 24 | let plugin: AIProvidersPlugin; 25 | let modal: ProviderFormModal; 26 | let onSaveMock: jest.Mock; 27 | let provider: IAIProvider; 28 | 29 | beforeEach(() => { 30 | app = new App(); 31 | plugin = new AIProvidersPlugin(app, { 32 | id: 'test-plugin', 33 | name: 'Test Plugin', 34 | version: '1.0.0', 35 | minAppVersion: '0.15.0', 36 | author: 'Test Author', 37 | description: 'Test Description' 38 | }); 39 | plugin.settings = { 40 | providers: [], 41 | _version: 1, 42 | debugLogging: false, 43 | useNativeFetch: false 44 | }; 45 | plugin.aiProviders = new AIProvidersService(app, plugin); 46 | 47 | provider = { 48 | id: 'test-id', 49 | name: 'Test Provider', 50 | type: 'openai', 51 | apiKey: 'test-key', 52 | url: 'https://test.com', 53 | model: 'gpt-4' 54 | }; 55 | 56 | onSaveMock = jest.fn(); 57 | modal = new ProviderFormModal(app, plugin, provider, onSaveMock, true); 58 | }); 59 | 60 | describe('Form Display', () => { 61 | it('should render form fields', () => { 62 | modal.onOpen(); 63 | 64 | expect(modal.contentEl.querySelector('[data-testid="provider-form-title"]')).toBeTruthy(); 65 | expect(modal.contentEl.querySelector('[data-testid="provider-type-dropdown"]')).toBeTruthy(); 66 | expect(modal.contentEl.querySelector('[data-testid="model-dropdown"]')).toBeTruthy(); 67 | expect(modal.contentEl.querySelector('[data-testid="refresh-models-button"]')).toBeTruthy(); 68 | expect(modal.contentEl.querySelector('[data-testid="cancel-button"]')).toBeTruthy(); 69 | }); 70 | 71 | it('should show correct title when adding new provider', () => { 72 | modal = new ProviderFormModal(app, plugin, provider, onSaveMock, true); 73 | modal.onOpen(); 74 | 75 | const title = modal.contentEl.querySelector('[data-testid="provider-form-title"]'); 76 | expect(title?.textContent).toBe('settings.addNewProvider'); 77 | }); 78 | 79 | it('should show correct title when editing provider', () => { 80 | modal = new ProviderFormModal(app, plugin, provider, onSaveMock, false); 81 | modal.onOpen(); 82 | 83 | const title = modal.contentEl.querySelector('[data-testid="provider-form-title"]'); 84 | expect(title?.textContent).toBe('settings.editProvider'); 85 | }); 86 | }); 87 | 88 | describe('Models List Management', () => { 89 | it('should show loading state when fetching models', () => { 90 | modal.onOpen(); 91 | (modal as any).isLoadingModels = true; 92 | (modal as any).display(); 93 | 94 | const dropdown = getElement(modal.contentEl, '[data-testid="model-dropdown"]'); 95 | const refreshButton = getElement(modal.contentEl, '[data-testid="refresh-models-button"]'); 96 | 97 | expect(dropdown.disabled).toBe(true); 98 | expect(dropdown.querySelector('option')?.value).toBe('loading'); 99 | expect(refreshButton.disabled).toBe(true); 100 | expect(refreshButton.classList.contains('loading')).toBe(true); 101 | }); 102 | 103 | it('should update title when model changes', () => { 104 | provider.availableModels = ['gpt-4', 'gpt-3.5-turbo']; 105 | modal.onOpen(); 106 | 107 | const dropdown = getElement(modal.contentEl, '[data-testid="model-dropdown"]'); 108 | 109 | // Check initial title 110 | expect(dropdown.title).toBe('gpt-4'); 111 | 112 | // Change model and check title update 113 | dropdown.value = 'gpt-3.5-turbo'; 114 | dropdown.dispatchEvent(new Event('change')); 115 | 116 | expect(dropdown.title).toBe('gpt-3.5-turbo'); 117 | expect(provider.model).toBe('gpt-3.5-turbo'); 118 | }); 119 | 120 | it('should successfully load and display models', async () => { 121 | const models = ['gpt-4', 'gpt-3.5-turbo']; 122 | jest.spyOn(plugin.aiProviders, 'fetchModels').mockResolvedValue(models); 123 | 124 | modal.onOpen(); 125 | 126 | const refreshButton = getElement(modal.contentEl, '[data-testid="refresh-models-button"]'); 127 | refreshButton.click(); 128 | 129 | await new Promise(resolve => setTimeout(resolve, 0)); 130 | 131 | const dropdown = getElement(modal.contentEl, '[data-testid="model-dropdown"]'); 132 | const options = Array.from(dropdown.querySelectorAll('option')); 133 | 134 | expect(dropdown.disabled).toBe(false); 135 | expect(options.length).toBe(2); 136 | expect(options[0].value).toBe('gpt-4'); 137 | expect(options[1].value).toBe('gpt-3.5-turbo'); 138 | expect(provider.model).toBe('gpt-4'); 139 | }); 140 | 141 | it('should handle empty models list', async () => { 142 | jest.spyOn(plugin.aiProviders, 'fetchModels').mockResolvedValue([]); 143 | 144 | modal.onOpen(); 145 | 146 | const refreshButton = getElement(modal.contentEl, '[data-testid="refresh-models-button"]'); 147 | refreshButton.click(); 148 | 149 | await new Promise(resolve => setTimeout(resolve, 0)); 150 | 151 | const dropdown = getElement(modal.contentEl, '[data-testid="model-dropdown"]'); 152 | 153 | expect(dropdown.disabled).toBe(true); 154 | expect(dropdown.querySelector('option')?.value).toBe('none'); 155 | expect(refreshButton.disabled).toBe(false); 156 | expect(refreshButton.classList.contains('loading')).toBe(false); 157 | }); 158 | 159 | it('should handle error when loading models', async () => { 160 | jest.spyOn(plugin.aiProviders, 'fetchModels').mockRejectedValue(new Error('Test error')); 161 | jest.spyOn(console, 'error').mockImplementation(() => {}); 162 | 163 | modal.onOpen(); 164 | 165 | const refreshButton = getElement(modal.contentEl, '[data-testid="refresh-models-button"]'); 166 | refreshButton.click(); 167 | 168 | await new Promise(resolve => setTimeout(resolve, 0)); 169 | 170 | const dropdown = getElement(modal.contentEl, '[data-testid="model-dropdown"]'); 171 | 172 | expect(dropdown.disabled).toBe(true); 173 | expect(dropdown.querySelector('option')?.value).toBe('none'); 174 | expect(refreshButton.disabled).toBe(false); 175 | expect(refreshButton.classList.contains('loading')).toBe(false); 176 | }); 177 | }); 178 | 179 | describe('Provider Type Management', () => { 180 | it('should update provider type and URL when type changes', () => { 181 | modal.onOpen(); 182 | 183 | const dropdown = getElement(modal.contentEl, '[data-testid="provider-type-dropdown"]'); 184 | 185 | // Simulate type change to Ollama 186 | dropdown.value = 'ollama'; 187 | dropdown.dispatchEvent(new Event('change')); 188 | 189 | expect(provider.type).toBe('ollama'); 190 | expect(provider.url).toBe('http://localhost:11434'); 191 | expect(provider.model).toBeUndefined(); 192 | expect(provider.availableModels).toBeUndefined(); 193 | }); 194 | 195 | it('should set default URL based on provider type', () => { 196 | modal.onOpen(); 197 | 198 | const dropdown = getElement(modal.contentEl, '[data-testid="provider-type-dropdown"]'); 199 | 200 | // Test OpenAI 201 | dropdown.value = 'openai'; 202 | dropdown.dispatchEvent(new Event('change')); 203 | expect(provider.url).toBe('https://api.openai.com/v1'); 204 | 205 | // Test Ollama 206 | dropdown.value = 'ollama'; 207 | dropdown.dispatchEvent(new Event('change')); 208 | expect(provider.url).toBe('http://localhost:11434'); 209 | 210 | // Test Gemini 211 | dropdown.value = 'gemini'; 212 | dropdown.dispatchEvent(new Event('change')); 213 | expect(provider.url).toBe('https://generativelanguage.googleapis.com/v1beta/openai'); 214 | 215 | // Test OpenRouter 216 | dropdown.value = 'openrouter'; 217 | dropdown.dispatchEvent(new Event('change')); 218 | expect(provider.url).toBe('https://openrouter.ai/api/v1'); 219 | 220 | // Test LM Studio 221 | dropdown.value = 'lmstudio'; 222 | dropdown.dispatchEvent(new Event('change')); 223 | expect(provider.url).toBe('http://localhost:1234/v1'); 224 | }); 225 | 226 | it('should reset model and availableModels when changing provider type', () => { 227 | modal.onOpen(); 228 | 229 | // Set initial values 230 | provider.model = 'test-model'; 231 | provider.availableModels = ['model1', 'model2']; 232 | 233 | const dropdown = getElement(modal.contentEl, '[data-testid="provider-type-dropdown"]'); 234 | 235 | // Test with Gemini 236 | dropdown.value = 'gemini'; 237 | dropdown.dispatchEvent(new Event('change')); 238 | expect(provider.model).toBeUndefined(); 239 | expect(provider.availableModels).toBeUndefined(); 240 | 241 | // Test with OpenRouter 242 | dropdown.value = 'openrouter'; 243 | dropdown.dispatchEvent(new Event('change')); 244 | expect(provider.model).toBeUndefined(); 245 | expect(provider.availableModels).toBeUndefined(); 246 | 247 | // Test with LM Studio 248 | dropdown.value = 'lmstudio'; 249 | dropdown.dispatchEvent(new Event('change')); 250 | expect(provider.model).toBeUndefined(); 251 | expect(provider.availableModels).toBeUndefined(); 252 | }); 253 | }); 254 | 255 | describe('Form Actions', () => { 256 | it('should save provider and close modal', async () => { 257 | modal.onOpen(); 258 | 259 | const saveButton = Array.from(modal.contentEl.querySelectorAll('button')) 260 | .find(button => button.textContent === 'settings.save'); 261 | saveButton?.click(); 262 | 263 | await new Promise(resolve => setTimeout(resolve, 0)); 264 | 265 | expect(onSaveMock).toHaveBeenCalledWith(provider); 266 | expect(modal.contentEl.children.length).toBe(0); 267 | }); 268 | 269 | it('should close modal without saving when cancel is clicked', () => { 270 | modal.onOpen(); 271 | 272 | const cancelButton = getElement(modal.contentEl, '[data-testid="cancel-button"]'); 273 | cancelButton.click(); 274 | 275 | expect(onSaveMock).not.toHaveBeenCalled(); 276 | expect(modal.contentEl.children.length).toBe(0); 277 | }); 278 | 279 | it('should update form fields when values change', () => { 280 | modal.onOpen(); 281 | 282 | // Test name field 283 | const nameInput = getElement(modal.contentEl, 'input[placeholder="settings.providerNamePlaceholder"]'); 284 | nameInput.value = 'New Name'; 285 | nameInput.dispatchEvent(new Event('input')); 286 | expect(provider.name).toBe('New Name'); 287 | 288 | // Test URL field 289 | const urlInput = getElement(modal.contentEl, 'input[placeholder="settings.providerUrlPlaceholder"]'); 290 | urlInput.value = 'https://new-url.com'; 291 | urlInput.dispatchEvent(new Event('input')); 292 | expect(provider.url).toBe('https://new-url.com'); 293 | 294 | // Test API key field 295 | const apiKeyInput = getElement(modal.contentEl, 'input[placeholder="settings.apiKeyPlaceholder"]'); 296 | apiKeyInput.value = 'new-api-key'; 297 | apiKeyInput.dispatchEvent(new Event('input')); 298 | expect(provider.apiKey).toBe('new-api-key'); 299 | }); 300 | }); 301 | 302 | describe('Model Input Mode Switching', () => { 303 | beforeEach(() => { 304 | provider.availableModels = ['gpt-4', 'gpt-3.5-turbo']; 305 | provider.model = 'gpt-4'; 306 | modal.onOpen(); 307 | }); 308 | 309 | const triggerModeSwitch = () => { 310 | // Force isTextMode change since we can't rely on link click in tests 311 | (modal as any).isTextMode = !(modal as any).isTextMode; 312 | modal.display(); 313 | }; 314 | 315 | it('should switch from dropdown to text mode', () => { 316 | // Initial state check 317 | expect(() => getElement(modal.contentEl, '[data-testid="model-dropdown"]')).not.toThrow(); 318 | expect(() => getElement(modal.contentEl, '[data-testid="model-input"]')).toThrow(); 319 | 320 | // Switch mode 321 | triggerModeSwitch(); 322 | 323 | // Check if switched to text mode 324 | expect(() => getElement(modal.contentEl, '[data-testid="model-input"]')).not.toThrow(); 325 | expect(() => getElement(modal.contentEl, '[data-testid="model-dropdown"]')).toThrow(); 326 | }); 327 | 328 | it('should switch from text mode to dropdown mode', () => { 329 | // Switch to text mode first 330 | triggerModeSwitch(); 331 | 332 | // Verify text mode 333 | expect(() => getElement(modal.contentEl, '[data-testid="model-input"]')).not.toThrow(); 334 | 335 | // Switch back to dropdown mode 336 | triggerModeSwitch(); 337 | 338 | // Check if switched back to dropdown mode 339 | expect(() => getElement(modal.contentEl, '[data-testid="model-dropdown"]')).not.toThrow(); 340 | expect(() => getElement(modal.contentEl, '[data-testid="model-input"]')).toThrow(); 341 | }); 342 | 343 | it('should preserve model value when switching modes', () => { 344 | const testModel = 'gpt-4'; 345 | provider.model = testModel; 346 | 347 | // Switch to text mode 348 | triggerModeSwitch(); 349 | 350 | // Check if value preserved in text mode 351 | const textInput = getElement(modal.contentEl, '[data-testid="model-input"]'); 352 | expect(textInput.value).toBe(testModel); 353 | 354 | // Switch back to dropdown 355 | triggerModeSwitch(); 356 | 357 | // Check if value preserved in dropdown 358 | const dropdown = getElement(modal.contentEl, '[data-testid="model-dropdown"]'); 359 | expect(dropdown.value).toBe(testModel); 360 | }); 361 | }); 362 | }); -------------------------------------------------------------------------------- /src/modals/ProviderFormModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting, Notice, sanitizeHTMLToDom } from 'obsidian'; 2 | import { I18n } from '../i18n'; 3 | import { IAIProvider, AIProviderType } from '@obsidian-ai-providers/sdk'; 4 | import { logger } from '../utils/logger'; 5 | import AIProvidersPlugin from '../main'; 6 | 7 | export class ProviderFormModal extends Modal { 8 | private isLoadingModels = false; 9 | private isTextMode = false; 10 | private readonly defaultProvidersUrls = { 11 | openai: "https://api.openai.com/v1", 12 | ollama: "http://localhost:11434", 13 | gemini: "https://generativelanguage.googleapis.com/v1beta/openai", 14 | openrouter: "https://openrouter.ai/api/v1", 15 | lmstudio: "http://localhost:1234/v1", 16 | groq: "https://api.groq.com/openai/v1", 17 | }; 18 | 19 | constructor( 20 | app: App, 21 | private plugin: AIProvidersPlugin, 22 | private provider: IAIProvider, 23 | private onSave: (provider: IAIProvider) => Promise, 24 | private isAddingNew = false 25 | ) { 26 | super(app); 27 | } 28 | 29 | private createModelSetting(contentEl: HTMLElement) { 30 | const modelSetting = new Setting(contentEl) 31 | .setName(I18n.t('settings.model')) 32 | .setDesc(this.isTextMode ? I18n.t('settings.modelTextDesc') : I18n.t('settings.modelDesc')); 33 | 34 | if (this.isTextMode) { 35 | modelSetting.addText(text => { 36 | text.setValue(this.provider.model || '') 37 | .onChange(value => { 38 | this.provider.model = value; 39 | }); 40 | text.inputEl.setAttribute('data-testid', 'model-input'); 41 | return text; 42 | }); 43 | } else { 44 | modelSetting.addDropdown(dropdown => { 45 | if (this.isLoadingModels) { 46 | dropdown.addOption('loading', I18n.t('settings.loadingModels')); 47 | dropdown.setDisabled(true); 48 | } else { 49 | const models = this.provider.availableModels; 50 | if (!models || models.length === 0) { 51 | dropdown.addOption('none', I18n.t('settings.noModelsAvailable')); 52 | dropdown.setDisabled(true); 53 | } else { 54 | models.forEach(model => { 55 | dropdown.addOption(model, model); 56 | const options = dropdown.selectEl.options; 57 | const lastOption = options[options.length - 1]; 58 | lastOption.title = model; 59 | }); 60 | dropdown.setDisabled(false); 61 | } 62 | } 63 | 64 | dropdown 65 | .setValue(this.provider.model || "") 66 | .onChange(value => { 67 | this.provider.model = value; 68 | dropdown.selectEl.title = value; 69 | }); 70 | 71 | dropdown.selectEl.setAttribute('data-testid', 'model-dropdown'); 72 | dropdown.selectEl.title = this.provider.model || ""; 73 | dropdown.selectEl.parentElement?.addClass('ai-providers-model-dropdown'); 74 | return dropdown; 75 | }); 76 | 77 | if (!this.isTextMode) { 78 | modelSetting.addButton(button => { 79 | button 80 | .setIcon("refresh-cw") 81 | .setTooltip(I18n.t('settings.refreshModelsList')); 82 | 83 | button.buttonEl.setAttribute('data-testid', 'refresh-models-button'); 84 | 85 | if (this.isLoadingModels) { 86 | button.setDisabled(true); 87 | button.buttonEl.addClass('loading'); 88 | } 89 | 90 | button.onClick(async () => { 91 | try { 92 | this.isLoadingModels = true; 93 | this.display(); 94 | 95 | const models = await this.plugin.aiProviders.fetchModels(this.provider); 96 | this.provider.availableModels = models; 97 | if (models.length > 0) { 98 | this.provider.model = models[0] || ""; 99 | } 100 | 101 | new Notice(I18n.t('settings.modelsUpdated')); 102 | } catch (error) { 103 | logger.error('Failed to fetch models:', error); 104 | new Notice(I18n.t('errors.failedToFetchModels')); 105 | } finally { 106 | this.isLoadingModels = false; 107 | this.display(); 108 | } 109 | }); 110 | }); 111 | } 112 | } 113 | 114 | const descEl = modelSetting.descEl; 115 | descEl.empty(); 116 | descEl.appendChild(sanitizeHTMLToDom(this.isTextMode ? I18n.t('settings.modelTextDesc') : I18n.t('settings.modelDesc'))); 117 | 118 | // Add click handler for the link 119 | const link = descEl.querySelector('a'); 120 | if (link) { 121 | link.addEventListener('click', (e) => { 122 | e.preventDefault(); 123 | this.isTextMode = !this.isTextMode; 124 | this.display(); 125 | }); 126 | } 127 | 128 | return modelSetting; 129 | } 130 | 131 | onOpen() { 132 | const { contentEl } = this; 133 | 134 | // Add form title 135 | contentEl.createEl('h2', { 136 | text: this.isAddingNew 137 | ? I18n.t('settings.addNewProvider') 138 | : I18n.t('settings.editProvider') 139 | }).setAttribute('data-testid', 'provider-form-title'); 140 | 141 | new Setting(contentEl) 142 | .setName(I18n.t('settings.providerType')) 143 | .setDesc(I18n.t('settings.providerTypeDesc')) 144 | .addDropdown(dropdown => { 145 | dropdown 146 | .addOptions({ 147 | "openai": "OpenAI", 148 | "ollama": "Ollama", 149 | "openrouter": "OpenRouter", 150 | "gemini": "Google Gemini", 151 | "lmstudio": "LM Studio", 152 | "groq": "Groq" 153 | }) 154 | .setValue(this.provider.type) 155 | .onChange(value => { 156 | this.provider.type = value as AIProviderType; 157 | this.provider.url = this.defaultProvidersUrls[value as AIProviderType]; 158 | this.provider.availableModels = undefined; 159 | this.provider.model = undefined; 160 | this.display(); 161 | }); 162 | 163 | dropdown.selectEl.setAttribute('data-testid', 'provider-type-dropdown'); 164 | return dropdown; 165 | }); 166 | 167 | new Setting(contentEl) 168 | .setName(I18n.t('settings.providerName')) 169 | .setDesc(I18n.t('settings.providerNameDesc')) 170 | .addText(text => text 171 | .setPlaceholder(I18n.t('settings.providerNamePlaceholder')) 172 | .setValue(this.provider.name) 173 | .onChange(value => this.provider.name = value)); 174 | 175 | new Setting(contentEl) 176 | .setName(I18n.t('settings.providerUrl')) 177 | .setDesc(I18n.t('settings.providerUrlDesc')) 178 | .addText(text => text 179 | .setPlaceholder(I18n.t('settings.providerUrlPlaceholder')) 180 | .setValue(this.provider.url || '') 181 | .onChange(value => this.provider.url = value)); 182 | 183 | new Setting(contentEl) 184 | .setName(I18n.t('settings.apiKey')) 185 | .setDesc(I18n.t('settings.apiKeyDesc')) 186 | .addText(text => text 187 | .setPlaceholder(I18n.t('settings.apiKeyPlaceholder')) 188 | .setValue(this.provider.apiKey || '') 189 | .onChange(value => this.provider.apiKey = value)); 190 | 191 | this.createModelSetting(contentEl); 192 | 193 | new Setting(contentEl) 194 | .addButton(button => button 195 | .setButtonText(I18n.t('settings.save')) 196 | .setCta() 197 | .onClick(async () => { 198 | await this.onSave(this.provider); 199 | this.close(); 200 | })) 201 | .addButton(button => { 202 | button 203 | .setButtonText(I18n.t('settings.cancel')) 204 | .onClick(() => { 205 | this.close(); 206 | }); 207 | button.buttonEl.setAttribute('data-testid', 'cancel-button'); 208 | return button; 209 | }); 210 | } 211 | 212 | onClose() { 213 | const { contentEl } = this; 214 | contentEl.empty(); 215 | } 216 | 217 | display() { 218 | const { contentEl } = this; 219 | contentEl.empty(); 220 | this.onOpen(); 221 | } 222 | } -------------------------------------------------------------------------------- /src/settings.test.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'obsidian'; 2 | import { AIProvidersSettingTab, DEFAULT_SETTINGS } from './settings'; 3 | import AIProvidersPlugin from './main'; 4 | import { ConfirmationModal } from './modals/ConfirmationModal'; 5 | import { IAIProvider, IChunkHandler } from '@obsidian-ai-providers/sdk'; 6 | import { OpenAIHandler } from './handlers/OpenAIHandler'; 7 | import { OllamaHandler } from './handlers/OllamaHandler'; 8 | import { AIProvidersService } from './AIProvidersService'; 9 | import { ProviderFormModal } from './modals/ProviderFormModal'; 10 | 11 | // Helper function to safely get typed element 12 | function getElement(container: HTMLElement | ParentNode, selector: string): T { 13 | const element = container.querySelector(selector); 14 | if (!element) { 15 | throw new Error(`Element with selector "${selector}" not found`); 16 | } 17 | return element as unknown as T; 18 | } 19 | 20 | // Mock translations 21 | jest.mock('./i18n', () => ({ 22 | I18n: { 23 | t: (key: string, params?: any) => { 24 | if (key === 'settings.notice') { 25 | return "This plugin is a configuration hub for AI providers. It doesn't do anything on its own, but other plugins can use it to avoid configuring AI settings repeatedly."; 26 | } 27 | if (key === 'settings.duplicate') { 28 | return "Duplicate"; 29 | } 30 | if (key === 'settings.deleteConfirmation') { 31 | return `Are you sure you want to delete ${params?.name}?`; 32 | } 33 | return key; 34 | } 35 | } 36 | })); 37 | 38 | // Mock the modal window 39 | jest.mock('./modals/ConfirmationModal', () => { 40 | return { 41 | ConfirmationModal: jest.fn().mockImplementation((app, message, onConfirm) => { 42 | return { 43 | app, 44 | message, 45 | onConfirm, 46 | contentEl: document.createElement('div'), 47 | open: jest.fn(), 48 | close: jest.fn() 49 | }; 50 | }) 51 | }; 52 | }); 53 | 54 | jest.mock('./modals/ProviderFormModal', () => ({ 55 | ProviderFormModal: jest.fn().mockImplementation(() => ({ 56 | open: jest.fn() 57 | })) 58 | })); 59 | 60 | 61 | // Mock handlers with common implementation 62 | const mockHandlerImplementation = { 63 | fetchModels: jest.fn().mockResolvedValue(['model-1', 'model-2']), 64 | execute: jest.fn().mockResolvedValue({ 65 | onData: jest.fn(), 66 | onEnd: jest.fn(), 67 | onError: jest.fn(), 68 | abort: jest.fn() 69 | } as IChunkHandler) 70 | }; 71 | 72 | jest.mock('./handlers/OpenAIHandler', () => ({ 73 | OpenAIHandler: jest.fn().mockImplementation(() => mockHandlerImplementation) 74 | })); 75 | 76 | jest.mock('./handlers/OllamaHandler', () => ({ 77 | OllamaHandler: jest.fn().mockImplementation(() => mockHandlerImplementation) 78 | })); 79 | 80 | // Mock AIProvidersService 81 | jest.mock('./AIProvidersService', () => { 82 | return { 83 | AIProvidersService: jest.fn().mockImplementation((app, settings) => ({ 84 | providers: settings?.providers || [], 85 | version: 1, 86 | handlers: { 87 | openai: new OpenAIHandler(settings), 88 | ollama: new OllamaHandler(settings), 89 | gemini: new OpenAIHandler(settings) 90 | }, 91 | embed: jest.fn().mockImplementation(async (params) => { 92 | if (params.provider.apiKey === 'error') { 93 | throw new Error('Failed to embed'); 94 | } 95 | return [0.1, 0.2, 0.3]; 96 | }), 97 | fetchModels: jest.fn().mockImplementation(async (provider) => { 98 | if (provider.apiKey === 'error') { 99 | throw new Error('Failed to fetch'); 100 | } 101 | return ['gpt-4', 'gpt-3.5-turbo']; 102 | }), 103 | execute: jest.fn().mockImplementation(async () => ({ 104 | onData: jest.fn(), 105 | onEnd: jest.fn(), 106 | onError: jest.fn(), 107 | abort: jest.fn() 108 | })), 109 | checkCompatibility: jest.fn().mockImplementation((requiredVersion) => { 110 | if (requiredVersion > 1) { 111 | throw new Error('Plugin must be updated'); 112 | } 113 | }) 114 | })) 115 | }; 116 | }); 117 | 118 | // Mock ProviderFormModal 119 | jest.mock('./modals/ProviderFormModal', () => ({ 120 | ProviderFormModal: jest.fn().mockImplementation(() => ({ 121 | open: jest.fn() 122 | })) 123 | })); 124 | 125 | // Test helpers 126 | const createTestProvider = (overrides: Partial = {}): IAIProvider => ({ 127 | id: "test-id-1", 128 | name: "Test Provider", 129 | apiKey: "test-key", 130 | url: "https://test.com", 131 | type: "openai", 132 | model: "gpt-4", 133 | ...overrides 134 | }); 135 | 136 | const createTestSetup = () => { 137 | const app = new App(); 138 | const plugin = new AIProvidersPlugin(app, { 139 | id: 'test-plugin', 140 | name: 'Test Plugin', 141 | author: 'Test Author', 142 | version: '1.0.0', 143 | minAppVersion: '0.0.1', 144 | description: 'Test Description' 145 | }); 146 | plugin.settings = { 147 | ...DEFAULT_SETTINGS, 148 | providers: [] 149 | }; 150 | plugin.saveSettings = jest.fn().mockResolvedValue(undefined); 151 | plugin.aiProviders = new AIProvidersService(app, plugin); 152 | 153 | const settingTab = new AIProvidersSettingTab(app, plugin); 154 | const containerEl = document.createElement('div'); 155 | 156 | // Mock HTMLElement methods 157 | containerEl.createDiv = function(className?: string): HTMLElement { 158 | const div = document.createElement('div'); 159 | if (className) { 160 | div.className = className; 161 | } 162 | this.appendChild(div); 163 | return div; 164 | }; 165 | containerEl.empty = function(): void { 166 | while (this.firstChild) { 167 | this.removeChild(this.firstChild); 168 | } 169 | }; 170 | containerEl.createEl = function(tag: string, attrs?: { text?: string }): HTMLElement { 171 | const el = document.createElement(tag); 172 | if (attrs?.text) { 173 | el.textContent = attrs.text; 174 | } 175 | this.appendChild(el); 176 | return el; 177 | }; 178 | 179 | // @ts-ignore 180 | settingTab.containerEl = containerEl; 181 | 182 | return { app, plugin, settingTab, containerEl }; 183 | }; 184 | 185 | describe('AIProvidersSettingTab', () => { 186 | let plugin: AIProvidersPlugin; 187 | let settingTab: AIProvidersSettingTab; 188 | let containerEl: HTMLElement; 189 | 190 | beforeEach(() => { 191 | const setup = createTestSetup(); 192 | plugin = setup.plugin; 193 | settingTab = setup.settingTab; 194 | containerEl = setup.containerEl; 195 | }); 196 | 197 | describe('Main Interface', () => { 198 | it('should render main interface', () => { 199 | settingTab.display(); 200 | 201 | const mainInterface = containerEl.querySelector('[data-testid="main-interface"]'); 202 | expect(mainInterface).toBeTruthy(); 203 | expect(mainInterface?.querySelector('[data-testid="add-provider-button"]')).toBeTruthy(); 204 | }); 205 | 206 | it('should display notice section', () => { 207 | settingTab.display(); 208 | 209 | const notice = containerEl.querySelector('.ai-providers-notice-content'); 210 | expect(notice).toBeTruthy(); 211 | expect(notice?.textContent).toBe("This plugin is a configuration hub for AI providers. It doesn't do anything on its own, but other plugins can use it to avoid configuring AI settings repeatedly."); 212 | }); 213 | 214 | it('should display configured providers section', () => { 215 | const testProvider = createTestProvider(); 216 | plugin.settings.providers = [testProvider]; 217 | settingTab.display(); 218 | 219 | const providers = containerEl.querySelectorAll('.setting-item'); 220 | expect(providers.length).toBeGreaterThan(1); // Including header 221 | expect(Array.from(providers).some(p => p.textContent?.includes(testProvider.name))).toBe(true); 222 | }); 223 | 224 | it('should display model pill when provider has model', () => { 225 | const providerWithModel = createTestProvider({ model: 'gpt-4' }); 226 | plugin.settings.providers = [providerWithModel]; 227 | settingTab.display(); 228 | 229 | const modelPill = containerEl.querySelector('[data-testid="model-pill"]'); 230 | expect(modelPill).toBeTruthy(); 231 | expect(modelPill?.textContent).toBe('gpt-4'); 232 | }); 233 | }); 234 | 235 | describe('Provider Management', () => { 236 | it('should open provider form when add button is clicked', () => { 237 | settingTab.display(); 238 | const addButton = containerEl.querySelector('[data-testid="add-provider-button"]'); 239 | addButton?.dispatchEvent(new MouseEvent('click')); 240 | 241 | // Verify that ProviderFormModal was opened 242 | expect(ProviderFormModal).toHaveBeenCalled(); 243 | expect(ProviderFormModal).toHaveBeenCalledWith( 244 | expect.any(App), 245 | plugin, 246 | expect.objectContaining({ type: 'openai' }), 247 | expect.any(Function), 248 | true 249 | ); 250 | }); 251 | 252 | it('should open edit form when edit button is clicked', () => { 253 | const testProvider = createTestProvider(); 254 | plugin.settings.providers = [testProvider]; 255 | settingTab.display(); 256 | 257 | const editButton = containerEl.querySelector('[data-testid="edit-provider"]'); 258 | editButton?.dispatchEvent(new MouseEvent('click')); 259 | 260 | expect(ProviderFormModal).toHaveBeenCalled(); 261 | expect(ProviderFormModal).toHaveBeenCalledWith( 262 | expect.any(App), 263 | plugin, 264 | testProvider, 265 | expect.any(Function), 266 | false 267 | ); 268 | }); 269 | 270 | it('should show confirmation modal when deleting provider', () => { 271 | plugin.settings.providers = [createTestProvider()]; 272 | settingTab.display(); 273 | 274 | const deleteButton = containerEl.querySelector('[data-testid="delete-provider"]'); 275 | deleteButton?.dispatchEvent(new MouseEvent('click')); 276 | 277 | expect(ConfirmationModal).toHaveBeenCalled(); 278 | }); 279 | 280 | it('should duplicate provider', async () => { 281 | plugin.settings.providers = [createTestProvider()]; 282 | settingTab.display(); 283 | 284 | const duplicateButton = containerEl.querySelector('[data-testid="duplicate-provider"]'); 285 | duplicateButton?.dispatchEvent(new MouseEvent('click')); 286 | 287 | expect(plugin.settings.providers.length).toBe(2); 288 | expect(plugin.settings.providers[1].name).toContain('Duplicate'); 289 | expect(plugin.saveSettings).toHaveBeenCalled(); 290 | }); 291 | 292 | it('should validate provider URLs correctly', async () => { 293 | const validUrls = { 294 | openai: 'https://api.openai.com/v1', 295 | ollama: 'http://localhost:11434', 296 | gemini: 'https://generativelanguage.googleapis.com/v1beta/openai', 297 | openrouter: 'https://openrouter.ai/api/v1', 298 | lmstudio: 'http://localhost:1234/v1' 299 | }; 300 | 301 | for (const [type, url] of Object.entries(validUrls)) { 302 | const provider = createTestProvider({ type: type as any, url }); 303 | const result = await settingTab.saveProvider(provider); 304 | expect(plugin.saveSettings).toHaveBeenCalled(); 305 | expect(plugin.settings.providers).toContainEqual(provider); 306 | } 307 | 308 | // Test invalid URL 309 | const invalidProvider = createTestProvider({ url: 'invalid-url' }); 310 | await settingTab.saveProvider(invalidProvider); 311 | expect(plugin.settings.providers).not.toContainEqual(invalidProvider); 312 | }); 313 | }); 314 | 315 | describe('Developer Settings', () => { 316 | it('should toggle developer mode', () => { 317 | settingTab.display(); 318 | 319 | const developerToggle = getElement(containerEl, '.setting-item-control input[type="checkbox"]'); 320 | expect(developerToggle).toBeTruthy(); 321 | 322 | // Simulate toggle on 323 | developerToggle.checked = true; 324 | developerToggle.dispatchEvent(new Event('change')); 325 | settingTab.display(); 326 | 327 | expect(containerEl.querySelector('.ai-providers-developer-settings')).toBeTruthy(); 328 | 329 | // Simulate toggle off 330 | developerToggle.checked = false; 331 | developerToggle.dispatchEvent(new Event('change')); 332 | settingTab.display(); 333 | 334 | expect(containerEl.querySelector('.ai-providers-developer-settings')).toBeFalsy(); 335 | }); 336 | 337 | it('should save debug logging setting', async () => { 338 | settingTab.display(); 339 | // Enable developer mode 340 | const developerToggle = getElement(containerEl, '.setting-item-control input[type="checkbox"]'); 341 | developerToggle.checked = true; 342 | developerToggle.dispatchEvent(new Event('change')); 343 | settingTab.display(); 344 | 345 | const debugToggle = getElement(containerEl, '.ai-providers-developer-settings .setting-item-control input[type="checkbox"]'); 346 | expect(debugToggle).toBeTruthy(); 347 | 348 | // Simulate toggle on 349 | debugToggle.checked = true; 350 | debugToggle.dispatchEvent(new Event('change')); 351 | 352 | expect(plugin.settings.debugLogging).toBe(true); 353 | expect(plugin.saveSettings).toHaveBeenCalled(); 354 | }); 355 | 356 | it('should save native fetch setting', async () => { 357 | settingTab.display(); 358 | // Enable developer mode 359 | const developerToggle = getElement(containerEl, '.setting-item-control input[type="checkbox"]'); 360 | developerToggle.checked = true; 361 | developerToggle.dispatchEvent(new Event('change')); 362 | settingTab.display(); 363 | 364 | const toggles = containerEl.querySelectorAll('.ai-providers-developer-settings .setting-item-control input[type="checkbox"]'); 365 | expect(toggles.length).toBe(2); 366 | const nativeFetchToggle = getElement(toggles[1].parentElement!, 'input[type="checkbox"]'); 367 | expect(nativeFetchToggle).toBeTruthy(); 368 | 369 | // Simulate toggle on 370 | nativeFetchToggle.checked = true; 371 | nativeFetchToggle.dispatchEvent(new Event('change')); 372 | 373 | expect(plugin.settings.useNativeFetch).toBe(true); 374 | expect(plugin.saveSettings).toHaveBeenCalled(); 375 | }); 376 | }); 377 | }); -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import {App, PluginSettingTab, sanitizeHTMLToDom, Setting, setIcon} from 'obsidian'; 2 | import AIProvidersPlugin from './main'; 3 | import { I18n } from './i18n'; 4 | import { ConfirmationModal } from './modals/ConfirmationModal'; 5 | import { IAIProvider, IAIProvidersPluginSettings } from '@obsidian-ai-providers/sdk'; 6 | import { logger } from './utils/logger'; 7 | import { ProviderFormModal } from './modals/ProviderFormModal'; 8 | 9 | 10 | export const DEFAULT_SETTINGS: IAIProvidersPluginSettings = { 11 | _version: 1, 12 | debugLogging: false, 13 | useNativeFetch: false, 14 | } 15 | 16 | export class AIProvidersSettingTab extends PluginSettingTab { 17 | private isFormOpen = false; 18 | private isDeveloperMode = false; 19 | 20 | plugin: AIProvidersPlugin; 21 | 22 | constructor(app: App, plugin: AIProvidersPlugin) { 23 | super(app, plugin); 24 | this.plugin = plugin; 25 | } 26 | 27 | private openForm(isAdding: boolean, provider?: IAIProvider) { 28 | const editingProvider = provider || { 29 | id: `id-${Date.now().toString()}`, 30 | name: "", 31 | apiKey: "", 32 | url: "", 33 | type: "openai", 34 | model: "", 35 | }; 36 | 37 | new ProviderFormModal( 38 | this.app, 39 | this.plugin, 40 | editingProvider, 41 | async (updatedProvider) => { 42 | await this.saveProvider(updatedProvider); 43 | }, 44 | isAdding 45 | ).open(); 46 | } 47 | 48 | private closeForm() { 49 | this.isFormOpen = false; 50 | this.display(); 51 | } 52 | 53 | private validateProvider(provider: IAIProvider): boolean { 54 | // Validate required fields 55 | if (!provider.id || !provider.name || !provider.type) return false; 56 | 57 | // Validate URL format if URL is provided 58 | if (provider.url) { 59 | try { 60 | new URL(provider.url); 61 | } catch { 62 | return false; 63 | } 64 | } 65 | 66 | // Check for duplicate names 67 | const providers = this.plugin.settings.providers || []; 68 | const existingProvider = providers.find((p: IAIProvider) => p.name === provider.name && p.id !== provider.id); 69 | if (existingProvider) { 70 | return false; 71 | } 72 | 73 | return true; 74 | } 75 | 76 | async saveProvider(provider: IAIProvider) { 77 | if (!this.validateProvider(provider)) return; 78 | 79 | const providers = this.plugin.settings.providers || []; 80 | const existingIndex = providers.findIndex((p: IAIProvider) => p.id === provider.id); 81 | 82 | if (existingIndex !== -1) { 83 | providers[existingIndex] = provider; 84 | } else { 85 | providers.push(provider); 86 | } 87 | 88 | this.plugin.settings.providers = providers; 89 | await this.plugin.saveSettings(); 90 | this.closeForm(); 91 | } 92 | 93 | async deleteProvider(provider: IAIProvider) { 94 | const providers = this.plugin.settings.providers || []; 95 | const index = providers.findIndex((p: IAIProvider) => p.id === provider.id); 96 | if (index !== -1) { 97 | providers.splice(index, 1); 98 | this.plugin.settings.providers = providers; 99 | await this.plugin.saveSettings(); 100 | this.display(); 101 | 102 | } 103 | } 104 | 105 | async duplicateProvider(provider: IAIProvider) { 106 | const newProvider = { 107 | ...provider, 108 | id: `id-${Date.now().toString()}`, 109 | name: `${provider.name} (${I18n.t('settings.duplicate')})` 110 | }; 111 | 112 | const providers = this.plugin.settings.providers || []; 113 | providers.push(newProvider); 114 | 115 | this.plugin.settings.providers = providers; 116 | await this.plugin.saveSettings(); 117 | this.display(); 118 | } 119 | 120 | display(): void { 121 | const { containerEl } = this; 122 | containerEl.empty(); 123 | 124 | // Show main interface 125 | const mainInterface = containerEl.createDiv('ai-providers-main-interface'); 126 | mainInterface.setAttribute('data-testid', 'main-interface'); 127 | 128 | // Add notice at the top 129 | const noticeEl = mainInterface.createDiv('ai-providers-notice'); 130 | const noticeContent = noticeEl.createDiv('ai-providers-notice-content'); 131 | noticeContent.appendChild(sanitizeHTMLToDom(`${I18n.t('settings.notice')}`)); 132 | 133 | // Create providers section with header and add button 134 | new Setting(mainInterface) 135 | .setHeading() 136 | .setName(I18n.t('settings.configuredProviders')) 137 | .addButton(button => { 138 | const addButton = button 139 | .setIcon("plus") // Changed to plus-circle which is a bolder plus icon 140 | .setTooltip(I18n.t('settings.addProvider')) 141 | .onClick(() => { 142 | if (this.isFormOpen) return; 143 | this.openForm(true); 144 | }) 145 | 146 | addButton.buttonEl.setAttribute("aria-label", I18n.t('settings.addProvider')) 147 | addButton.buttonEl.setAttribute("data-testid", "add-provider-button") 148 | return addButton; 149 | }); 150 | 151 | 152 | const providers = this.plugin.settings.providers || []; 153 | if (providers.length > 0) { 154 | providers.forEach((provider: IAIProvider) => { 155 | const setting = new Setting(mainInterface) 156 | .setName(provider.name) 157 | .setDesc(provider.url || ''); 158 | 159 | // Add provider icon before the name 160 | const iconEl = setting.nameEl.createSpan('ai-providers-provider-icon'); 161 | setIcon(iconEl, `ai-providers-${provider.type}`); 162 | setting.nameEl.prepend(iconEl as any); 163 | 164 | // Add model pill if model is selected 165 | if (provider.model) { 166 | const modelPill = setting.settingEl.createDiv('ai-providers-model-pill'); 167 | modelPill.textContent = provider.model; 168 | modelPill.setAttribute('data-testid', 'model-pill'); 169 | setting.nameEl.after(modelPill as any); 170 | } 171 | 172 | setting 173 | .addExtraButton(button => { 174 | button 175 | .setIcon("gear") 176 | .setTooltip(I18n.t('settings.options')) 177 | .onClick(() => { 178 | if (this.isFormOpen) return; 179 | this.openForm(false, { ...provider }); 180 | }); 181 | 182 | button.extraSettingsEl.setAttribute('data-testid', 'edit-provider'); 183 | }) 184 | .addExtraButton(button => { 185 | button 186 | .setIcon("copy") 187 | .setTooltip(I18n.t('settings.duplicate')) 188 | .onClick(async () => { 189 | await this.duplicateProvider(provider); 190 | }); 191 | 192 | button.extraSettingsEl.setAttribute('data-testid', 'duplicate-provider'); 193 | }) 194 | .addExtraButton(button => { 195 | button 196 | .setIcon("lucide-trash-2") 197 | .setTooltip(I18n.t('settings.delete')) 198 | .onClick(async () => { 199 | new ConfirmationModal( 200 | this.app, 201 | I18n.t('settings.deleteConfirmation', { name: provider.name }), 202 | async () => { 203 | await this.deleteProvider(provider); 204 | } 205 | ).open(); 206 | }); 207 | 208 | button.extraSettingsEl.setAttribute('data-testid', 'delete-provider'); 209 | }); 210 | }); 211 | } 212 | 213 | // Add developer settings toggle at the top 214 | new Setting(mainInterface) 215 | .setHeading() 216 | .setName(I18n.t('settings.developerSettings')) 217 | .setDesc(I18n.t('settings.developerSettingsDesc')) 218 | .setClass('ai-providers-developer-settings-toggle') 219 | .addToggle(toggle => toggle 220 | .setValue(this.isDeveloperMode) 221 | .onChange(value => { 222 | this.isDeveloperMode = value; 223 | this.display(); 224 | })); 225 | 226 | // Developer settings section 227 | if (this.isDeveloperMode) { 228 | const developerSection = mainInterface.createDiv('ai-providers-developer-settings'); 229 | 230 | new Setting(developerSection) 231 | .setName(I18n.t('settings.debugLogging')) 232 | .setDesc(I18n.t('settings.debugLoggingDesc')) 233 | .addToggle(toggle => toggle 234 | .setValue(this.plugin.settings.debugLogging ?? false) 235 | .onChange(async value => { 236 | this.plugin.settings.debugLogging = value; 237 | logger.setEnabled(value); 238 | await this.plugin.saveSettings(); 239 | })); 240 | 241 | new Setting(developerSection) 242 | .setName(I18n.t('settings.useNativeFetch')) 243 | .setDesc(I18n.t('settings.useNativeFetchDesc')) 244 | .addToggle(toggle => toggle 245 | .setValue(this.plugin.settings.useNativeFetch ?? false) 246 | .onChange(async value => { 247 | this.plugin.settings.useNativeFetch = value; 248 | await this.plugin.saveSettings(); 249 | })); 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .ai-providers-model-pill { 2 | display: inline-block; 3 | padding: 2px 6px; 4 | margin-top: 4px; 5 | border-radius: var(--radius-s); 6 | background-color: var(--background-modifier-border); 7 | color: var(--text-muted); 8 | font-size: var(--font-smallest); 9 | opacity: 0.8; 10 | text-shadow: var(--background-primary) 0 0 1px, var(--background-primary) 0 0 1px; 11 | } 12 | 13 | .ai-providers-provider-icon { 14 | display: inline-flex; 15 | align-items: center; 16 | margin-right: 6px; 17 | vertical-align: text-top; 18 | } 19 | 20 | .ai-providers-provider-icon svg { 21 | width: 16px; 22 | height: 16px; 23 | fill: var(--text-muted); 24 | } 25 | 26 | .ai-providers-developer-settings-toggle { 27 | margin-top: 1.5em; 28 | border: 0; 29 | } 30 | 31 | .ai-providers-developer-settings { 32 | margin-top: 0.75em; 33 | padding: 1em; 34 | border: 1px solid var(--background-modifier-border); 35 | border-radius: var(--radius-m); 36 | } 37 | 38 | .ai-providers-notice { 39 | margin: 0 -1em 1em; 40 | padding: 1em; 41 | border: 1px solid var(--background-modifier-border); 42 | border-radius: var(--radius-m); 43 | background-color: var(--background-secondary); 44 | } 45 | 46 | .ai-providers-notice-content { 47 | color: var(--text-muted); 48 | font-size: var(--font-small); 49 | line-height: var(--line-height-tight); 50 | } 51 | 52 | .ai-providers-model-dropdown { 53 | max-width: 65%; 54 | } 55 | 56 | .ai-providers-model-dropdown select { 57 | width: 100%; 58 | text-overflow: ellipsis; 59 | } 60 | 61 | .ai-providers-main-interface .setting-item { 62 | overflow: hidden; 63 | } 64 | 65 | .ai-providers-main-interface .setting-item-info { 66 | overflow: hidden; 67 | } 68 | 69 | .ai-providers-main-interface .setting-item-description { 70 | max-width: 100%; 71 | } -------------------------------------------------------------------------------- /src/utils/electronFetch.test.ts: -------------------------------------------------------------------------------- 1 | // Mock implementations 2 | interface MockRequest { 3 | on: jest.Mock; 4 | write: jest.Mock; 5 | end: jest.Mock; 6 | abort: jest.Mock; 7 | removeAllListeners: jest.Mock; 8 | setHeader: jest.Mock; 9 | } 10 | 11 | const mockRequest: MockRequest = { 12 | on: jest.fn(), 13 | write: jest.fn(), 14 | end: jest.fn(), 15 | abort: jest.fn(), 16 | removeAllListeners: jest.fn(), 17 | setHeader: jest.fn() 18 | }; 19 | 20 | interface MockRemote { 21 | net: { 22 | request: jest.Mock; 23 | }; 24 | } 25 | 26 | const mockRemote: MockRemote = { 27 | net: { 28 | request: jest.fn().mockReturnValue(mockRequest) 29 | } 30 | }; 31 | 32 | jest.mock('electron', () => ({ 33 | remote: mockRemote 34 | })); 35 | 36 | jest.mock('obsidian', () => ({ 37 | Platform: { 38 | isMobileApp: false 39 | } 40 | })); 41 | 42 | // Add after imports 43 | const flushPromises = () => new Promise(process.nextTick); 44 | 45 | // Simple mock for TransformStream 46 | class MockTransformStream { 47 | readable: ReadableStream; 48 | writable: WritableStream; 49 | private chunks: Uint8Array[] = []; 50 | 51 | constructor() { 52 | this.writable = { 53 | getWriter: () => ({ 54 | write: async (chunk: Uint8Array) => { 55 | this.chunks.push(chunk); 56 | }, 57 | close: async () => {}, 58 | abort: async () => {}, 59 | releaseLock: () => {} 60 | }) 61 | } as WritableStream; 62 | 63 | this.readable = { 64 | getReader: () => ({ 65 | read: async () => { 66 | const chunk = this.chunks.shift(); 67 | return chunk ? { done: false, value: chunk } : { done: true, value: undefined }; 68 | }, 69 | releaseLock: () => {} 70 | }) 71 | } as ReadableStream; 72 | } 73 | } 74 | 75 | // Add to global if not available 76 | if (typeof TransformStream === 'undefined') { 77 | (global as any).TransformStream = MockTransformStream; 78 | } 79 | 80 | if (typeof Response === 'undefined') { 81 | (global as any).Response = class { 82 | constructor(private readable: ReadableStream, public init: ResponseInit) {} 83 | 84 | async text() { 85 | const reader = this.readable.getReader(); 86 | const { value } = await reader.read(); 87 | reader.releaseLock(); 88 | return new TextDecoder().decode(value); 89 | } 90 | }; 91 | } 92 | 93 | if (typeof TextEncoder === 'undefined') { 94 | (global as any).TextEncoder = class { 95 | encode(text: string) { 96 | return Buffer.from(text); 97 | } 98 | }; 99 | } 100 | 101 | if (typeof TextDecoder === 'undefined') { 102 | (global as any).TextDecoder = class { 103 | decode(buffer: Buffer) { 104 | return buffer.toString(); 105 | } 106 | }; 107 | } 108 | 109 | import { electronFetch } from './electronFetch'; 110 | 111 | describe('electronFetch', () => { 112 | beforeEach(() => { 113 | jest.clearAllMocks(); 114 | // Reset mock implementations 115 | mockRequest.on.mockReset(); 116 | mockRequest.write.mockReset(); 117 | mockRequest.end.mockReset(); 118 | mockRequest.abort.mockReset(); 119 | mockRequest.removeAllListeners.mockReset(); 120 | mockRequest.setHeader.mockReset(); 121 | mockRemote.net.request.mockReturnValue(mockRequest); 122 | }); 123 | 124 | afterEach(() => { 125 | jest.useRealTimers(); 126 | }); 127 | 128 | describe('Basic Request Handling', () => { 129 | it('should make a GET request successfully', async () => { 130 | const url = 'https://api.example.com'; 131 | const mockResponseData = 'mock response'; 132 | 133 | mockRequest.on.mockImplementation((event, callback) => { 134 | if (event === 'response') { 135 | callback({ 136 | statusCode: 200, 137 | headers: {}, 138 | on: (event: string, cb: (data?: Buffer) => void) => { 139 | if (event === 'data') cb(Buffer.from(mockResponseData)); 140 | if (event === 'end') cb(); 141 | } 142 | }); 143 | } 144 | return mockRequest; 145 | }); 146 | 147 | const response = await electronFetch(url, { 148 | headers: {} 149 | }); 150 | 151 | expect(mockRemote.net.request).toHaveBeenCalledWith({ 152 | url, 153 | method: 'GET' 154 | }); 155 | 156 | expect(response).toBeInstanceOf(Response); 157 | const text = await response.text(); 158 | expect(text).toBe(mockResponseData); 159 | }); 160 | 161 | it('should make a POST request with body', async () => { 162 | const url = 'https://api.example.com'; 163 | const body = JSON.stringify({ test: 'data' }); 164 | const headers = { 'Content-Type': 'application/json' }; 165 | const mockResponseData = 'mock response'; 166 | 167 | mockRequest.on.mockImplementation((event, callback) => { 168 | if (event === 'response') { 169 | callback({ 170 | statusCode: 200, 171 | headers: {}, 172 | on: (event: string, cb: (data?: Buffer) => void) => { 173 | if (event === 'data') cb(Buffer.from(mockResponseData)); 174 | if (event === 'end') cb(); 175 | } 176 | }); 177 | } 178 | return mockRequest; 179 | }); 180 | 181 | const response = await electronFetch(url, { 182 | method: 'POST', 183 | body, 184 | headers 185 | }); 186 | 187 | expect(mockRemote.net.request).toHaveBeenCalledWith({ 188 | url, 189 | method: 'POST' 190 | }); 191 | 192 | Object.entries(headers).forEach(([key, value]) => { 193 | expect(mockRequest.setHeader).toHaveBeenCalledWith(key, value); 194 | }); 195 | 196 | expect(mockRequest.write).toHaveBeenCalledWith(body); 197 | expect(response).toBeInstanceOf(Response); 198 | const text = await response.text(); 199 | expect(text).toBe(mockResponseData); 200 | }); 201 | }); 202 | 203 | describe('Error Handling', () => { 204 | it('should handle request errors', async () => { 205 | const url = 'https://api.example.com'; 206 | const errorMessage = 'Network error'; 207 | 208 | mockRequest.on.mockImplementation((event, callback) => { 209 | if (event === 'error') { 210 | callback(new Error(errorMessage)); 211 | } 212 | return mockRequest; 213 | }); 214 | 215 | await expect(electronFetch(url, { 216 | headers: {} 217 | })).rejects.toThrow(errorMessage); 218 | }); 219 | 220 | it('should handle timeout', async () => { 221 | const url = 'https://api.example.com'; 222 | 223 | // Setup mock to call abort when error is triggered 224 | let errorCallback: ((error: Error) => void) | undefined; 225 | mockRequest.on.mockImplementation((event, callback) => { 226 | if (event === 'error') { 227 | errorCallback = callback; 228 | } 229 | return mockRequest; 230 | }); 231 | 232 | const controller = new AbortController(); 233 | const fetchPromise = electronFetch.call({ controller }, url, { 234 | headers: {} 235 | }); 236 | 237 | // Simulate timeout 238 | controller.abort(); 239 | if (errorCallback) { 240 | errorCallback(new Error('Aborted')); 241 | } 242 | 243 | await expect(fetchPromise).rejects.toThrow('Aborted'); 244 | expect(mockRequest.abort).toHaveBeenCalled(); 245 | }); 246 | 247 | it('should handle response errors', async () => { 248 | const url = 'https://api.example.com'; 249 | const errorMessage = 'Response error'; 250 | 251 | mockRequest.on.mockImplementation((event, callback) => { 252 | if (event === 'error') { 253 | callback(new Error(errorMessage)); 254 | } 255 | return mockRequest; 256 | }); 257 | 258 | await expect(electronFetch(url, { 259 | headers: {} 260 | })).rejects.toThrow(errorMessage); 261 | }); 262 | }); 263 | 264 | describe('Abort Handling', () => { 265 | it('should handle abort signal', async () => { 266 | const url = 'https://api.example.com'; 267 | const controller = new AbortController(); 268 | 269 | // Setup mock to call abort when error is triggered 270 | let errorCallback: ((error: Error) => void) | undefined; 271 | mockRequest.on.mockImplementation((event, callback) => { 272 | if (event === 'error') { 273 | errorCallback = callback; 274 | } 275 | return mockRequest; 276 | }); 277 | 278 | const fetchPromise = electronFetch.call({ controller }, url, { 279 | headers: {} 280 | }); 281 | 282 | // Ensure the request is created before aborting 283 | await flushPromises(); 284 | controller.abort(); 285 | if (errorCallback) { 286 | errorCallback(new Error('Aborted')); 287 | } 288 | 289 | await expect(fetchPromise).rejects.toThrow('Aborted'); 290 | expect(mockRequest.abort).toHaveBeenCalled(); 291 | }); 292 | 293 | it('should handle pre-aborted signal', async () => { 294 | const url = 'https://api.example.com'; 295 | const controller = new AbortController(); 296 | controller.abort(); 297 | 298 | // Setup mock to call abort when error is triggered 299 | let errorCallback: ((error: Error) => void) | undefined; 300 | mockRequest.on.mockImplementation((event, callback) => { 301 | if (event === 'error') { 302 | errorCallback = callback; 303 | } 304 | return mockRequest; 305 | }); 306 | 307 | const promise = electronFetch.call({ controller }, url, { 308 | headers: {} 309 | }); 310 | 311 | if (errorCallback) { 312 | errorCallback(new Error('Aborted')); 313 | } 314 | 315 | await expect(promise).rejects.toThrow('Aborted'); 316 | expect(mockRequest.abort).toHaveBeenCalled(); 317 | }); 318 | }); 319 | 320 | describe('Platform Specific', () => { 321 | it('should use native fetch on mobile', async () => { 322 | const mockPlatform = { isMobileApp: true }; 323 | jest.resetModules(); 324 | jest.mock('obsidian', () => ({ Platform: mockPlatform })); 325 | 326 | const url = 'https://api.example.com'; 327 | const mockResponse = new Response(new MockTransformStream().readable, { status: 200 }); 328 | 329 | global.fetch = jest.fn().mockResolvedValue(mockResponse); 330 | 331 | // Re-import to get the updated module with mocked Platform 332 | const electronFetchModule = await import('./electronFetch'); 333 | await electronFetchModule.electronFetch(url, { 334 | headers: {} 335 | }); 336 | 337 | expect(global.fetch).toHaveBeenCalledWith(url, expect.any(Object)); 338 | }); 339 | }); 340 | }); -------------------------------------------------------------------------------- /src/utils/electronFetch.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { remote, IncomingMessage } from "electron"; 3 | import { Platform } from "obsidian"; 4 | import { logger } from './logger'; 5 | 6 | export async function electronFetch(url: string, options: RequestInit = {}): Promise { 7 | delete (options.headers as Record)["content-length"]; 8 | const params: { controller?: AbortController } = {}; 9 | if (this && 'controller' in this) { 10 | params.controller = this.controller; 11 | } 12 | 13 | logger.debug('electronFetch request:', { 14 | url, 15 | method: options.method || 'GET', 16 | headers: options.headers, 17 | hasBody: !!options.body, 18 | platform: Platform.isMobileApp ? 'mobile' : 'desktop' 19 | }); 20 | 21 | if (Platform.isMobileApp) { 22 | logger.debug('Using native fetch (mobile platform)'); 23 | return fetch(url, { 24 | ...options, 25 | signal: params.controller?.signal || options.signal, 26 | }); 27 | } 28 | 29 | return new Promise((resolve, reject) => { 30 | const cleanup = () => { 31 | request.removeAllListeners(); 32 | logger.debug('Request cleanup completed'); 33 | }; 34 | 35 | const request = remote.net.request({ 36 | url, 37 | method: options.method || 'GET' 38 | }); 39 | 40 | if (options.headers) { 41 | Object.entries(options.headers).forEach(([key, value]) => { 42 | request.setHeader(key, value); 43 | }); 44 | } 45 | 46 | if (params.controller?.signal.aborted) { 47 | logger.debug('Request aborted before start'); 48 | request.abort(); 49 | reject(new Error('Aborted')); 50 | return; 51 | } 52 | 53 | params.controller?.signal.addEventListener('abort', () => { 54 | logger.debug('Request aborted by controller'); 55 | cleanup(); 56 | request.abort(); 57 | reject(new Error('Aborted')); 58 | }); 59 | 60 | request.on('response', (response: IncomingMessage) => { 61 | if (params.controller?.signal.aborted) { 62 | logger.debug('Request aborted during response'); 63 | return; 64 | } 65 | 66 | logger.debug('Response received:', { 67 | status: response.statusCode, 68 | headers: response.headers 69 | }); 70 | 71 | const { readable, writable } = new TransformStream({ 72 | transform(chunk, controller) { 73 | controller.enqueue(new Uint8Array(chunk)); 74 | } 75 | }); 76 | const writer = writable.getWriter(); 77 | 78 | const responseInit: ResponseInit = { 79 | status: response.statusCode || 200, 80 | headers: response.headers as HeadersInit, 81 | }; 82 | resolve(new Response(readable, responseInit)); 83 | 84 | response.on('data', async (chunk: Buffer) => { 85 | try { 86 | await writer.ready; 87 | await writer.write(chunk); 88 | logger.debug('Chunk received:', { size: chunk.length }); 89 | } catch (error) { 90 | logger.error('Error writing chunk:', error); 91 | cleanup(); 92 | writer.abort(error); 93 | } 94 | }); 95 | 96 | response.on('end', async () => { 97 | try { 98 | await writer.ready; 99 | await writer.close(); 100 | logger.debug('Response stream completed'); 101 | } catch (error) { 102 | logger.error('Error closing writer:', error); 103 | } finally { 104 | cleanup(); 105 | } 106 | }); 107 | 108 | response.on('error', (error: Error) => { 109 | logger.error('Response error:', error); 110 | cleanup(); 111 | writer.abort(error); 112 | reject(error); 113 | }); 114 | }); 115 | 116 | request.on('error', (error: Error) => { 117 | logger.error('Request error:', error); 118 | cleanup(); 119 | reject(error); 120 | }); 121 | 122 | if (options.body) { 123 | request.write(options.body); 124 | } 125 | 126 | request.end(); 127 | logger.debug('Request sent'); 128 | }); 129 | } -------------------------------------------------------------------------------- /src/utils/icons.ts: -------------------------------------------------------------------------------- 1 | export const openAIIcon = ``; 2 | export const openRouterIcon = ``; 3 | export const groqIcon = ``; 4 | export const lmstudioIcon = ``; 5 | export const geminiIcon = ``; 6 | export const ollamaIcon = ``; -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | type LogLevel = 'debug' | 'info' | 'warn' | 'error'; 2 | 3 | class Logger { 4 | private enabled: boolean; 5 | 6 | constructor() { 7 | this.enabled = process.env.NODE_ENV === 'development'; 8 | } 9 | 10 | isEnabled(): boolean { 11 | return this.enabled; 12 | } 13 | 14 | setEnabled(value: boolean): void { 15 | this.enabled = value; 16 | } 17 | 18 | private log(level: LogLevel, ...args: any[]) { 19 | if (!this.enabled) return; 20 | 21 | const timestamp = new Date().toISOString(); 22 | const prefix = `[AI Providers ${level.toUpperCase()}] ${timestamp}:`; 23 | 24 | switch (level) { 25 | case 'debug': 26 | console.log(prefix, ...args); 27 | break; 28 | case 'info': 29 | console.info(prefix, ...args); 30 | break; 31 | case 'warn': 32 | console.warn(prefix, ...args); 33 | break; 34 | case 'error': 35 | console.error(prefix, ...args); 36 | break; 37 | } 38 | } 39 | 40 | debug(...args: any[]) { 41 | this.log('debug', ...args); 42 | } 43 | 44 | info(...args: any[]) { 45 | this.log('info', ...args); 46 | } 47 | 48 | warn(...args: any[]) { 49 | this.log('warn', ...args); 50 | } 51 | 52 | error(...args: any[]) { 53 | this.log('error', ...args); 54 | } 55 | 56 | time(label: string) { 57 | if (!this.enabled) return; 58 | console.time(`[AI Providers] ${label}`); 59 | } 60 | 61 | timeEnd(label: string) { 62 | if (!this.enabled) return; 63 | console.timeEnd(`[AI Providers] ${label}`); 64 | } 65 | } 66 | 67 | export const logger = new Logger(); -------------------------------------------------------------------------------- /src/utils/obsidianFetch.test.ts: -------------------------------------------------------------------------------- 1 | import { RequestUrlResponse } from "obsidian"; 2 | import { obsidianFetch } from './obsidianFetch'; 3 | 4 | jest.mock('obsidian', () => { 5 | return { 6 | requestUrl: jest.fn() 7 | }; 8 | }); 9 | 10 | // Mock Response if not available in test environment 11 | if (typeof Response === 'undefined') { 12 | (global as any).Response = class { 13 | public status: number; 14 | public headers: Record; 15 | private body: string; 16 | 17 | constructor(body: string, init: ResponseInit) { 18 | this.body = body; 19 | this.status = init.status || 200; 20 | this.headers = init.headers as Record; 21 | } 22 | 23 | async text() { 24 | return this.body; 25 | } 26 | }; 27 | } 28 | 29 | describe('obsidianFetch', () => { 30 | let requestUrlMock: jest.Mock; 31 | 32 | beforeEach(() => { 33 | jest.clearAllMocks(); 34 | requestUrlMock = jest.requireMock('obsidian').requestUrl; 35 | }); 36 | 37 | describe('Basic Request Handling', () => { 38 | it('should make a GET request successfully', async () => { 39 | const mockResponseData = 'mock response'; 40 | const mockResponse: Partial = { 41 | status: 200, 42 | text: mockResponseData, 43 | headers: { 'content-type': 'text/plain' }, 44 | }; 45 | 46 | requestUrlMock.mockResolvedValue(mockResponse); 47 | 48 | const url = 'https://api.example.com'; 49 | const response = await obsidianFetch(url, { headers: {} }); 50 | 51 | expect(requestUrlMock).toHaveBeenCalledWith({ 52 | url, 53 | method: 'GET', 54 | headers: {}, 55 | }); 56 | 57 | expect(response).toBeInstanceOf(Response); 58 | const text = await response.text(); 59 | expect(text).toBe(mockResponseData); 60 | }); 61 | 62 | it('should make a POST request with JSON body', async () => { 63 | const mockResponseData = 'mock response'; 64 | const mockResponse: Partial = { 65 | status: 200, 66 | text: mockResponseData, 67 | headers: { 'content-type': 'application/json' }, 68 | }; 69 | 70 | requestUrlMock.mockResolvedValue(mockResponse); 71 | 72 | const url = 'https://api.example.com'; 73 | const body = { test: 'data' }; 74 | const headers = { 'Content-Type': 'application/json' }; 75 | 76 | const response = await obsidianFetch(url, { 77 | method: 'POST', 78 | body: JSON.stringify(body), 79 | headers, 80 | }); 81 | 82 | expect(requestUrlMock).toHaveBeenCalledWith({ 83 | url, 84 | method: 'POST', 85 | body: JSON.stringify(body), 86 | headers, 87 | }); 88 | 89 | expect(response).toBeInstanceOf(Response); 90 | const text = await response.text(); 91 | expect(text).toBe(mockResponseData); 92 | }); 93 | 94 | it('should make a POST request with string body', async () => { 95 | const mockResponseData = 'mock response'; 96 | const mockResponse: Partial = { 97 | status: 200, 98 | text: mockResponseData, 99 | headers: { 'content-type': 'text/plain' }, 100 | }; 101 | 102 | requestUrlMock.mockResolvedValue(mockResponse); 103 | 104 | const url = 'https://api.example.com'; 105 | const body = 'test data'; 106 | const headers = { 'Content-Type': 'text/plain' }; 107 | 108 | const response = await obsidianFetch(url, { 109 | method: 'POST', 110 | body: body, 111 | headers, 112 | }); 113 | 114 | expect(requestUrlMock).toHaveBeenCalledWith({ 115 | url, 116 | method: 'POST', 117 | body: body, 118 | headers, 119 | }); 120 | 121 | expect(response).toBeInstanceOf(Response); 122 | const text = await response.text(); 123 | expect(text).toBe(mockResponseData); 124 | }); 125 | 126 | it('should handle Buffer body as string', async () => { 127 | const mockResponseData = 'mock response'; 128 | const mockResponse: Partial = { 129 | status: 200, 130 | text: mockResponseData, 131 | headers: { 'content-type': 'application/octet-stream' }, 132 | }; 133 | 134 | requestUrlMock.mockResolvedValue(mockResponse); 135 | 136 | const url = 'https://api.example.com'; 137 | const body = Buffer.from('test data'); 138 | const headers = { 'Content-Type': 'application/octet-stream' }; 139 | 140 | const response = await obsidianFetch(url, { 141 | method: 'POST', 142 | body: body.toString(), 143 | headers, 144 | }); 145 | 146 | expect(requestUrlMock).toHaveBeenCalledWith({ 147 | url, 148 | method: 'POST', 149 | body: body.toString(), 150 | headers, 151 | }); 152 | 153 | expect(response).toBeInstanceOf(Response); 154 | const text = await response.text(); 155 | expect(text).toBe(mockResponseData); 156 | }); 157 | }); 158 | 159 | describe('Error Handling', () => { 160 | it('should handle request errors', async () => { 161 | const errorMessage = 'Network error'; 162 | requestUrlMock.mockRejectedValue(new Error(errorMessage)); 163 | 164 | const url = 'https://api.example.com'; 165 | await expect(obsidianFetch(url, { headers: {} })).rejects.toThrow(errorMessage); 166 | }); 167 | 168 | it('should handle non-200 status codes', async () => { 169 | const mockResponse: Partial = { 170 | status: 404, 171 | text: 'Not Found', 172 | headers: { 'content-type': 'text/plain' }, 173 | }; 174 | 175 | requestUrlMock.mockResolvedValue(mockResponse); 176 | 177 | const url = 'https://api.example.com'; 178 | const response = await obsidianFetch(url, { headers: {} }); 179 | 180 | expect(response.status).toBe(404); 181 | const text = await response.text(); 182 | expect(text).toBe('Not Found'); 183 | }); 184 | }); 185 | 186 | describe('Header Handling', () => { 187 | it('should pass through custom headers', async () => { 188 | const mockResponse: Partial = { 189 | status: 200, 190 | text: 'mock response', 191 | headers: { 'content-type': 'application/json' }, 192 | }; 193 | 194 | requestUrlMock.mockResolvedValue(mockResponse); 195 | 196 | const url = 'https://api.example.com'; 197 | const headers = { 198 | 'Authorization': 'Bearer token', 199 | 'Custom-Header': 'custom value' 200 | }; 201 | 202 | await obsidianFetch(url, { headers }); 203 | 204 | expect(requestUrlMock).toHaveBeenCalledWith({ 205 | url, 206 | method: 'GET', 207 | headers, 208 | }); 209 | }); 210 | 211 | it('should handle response headers', async () => { 212 | const mockResponse: Partial = { 213 | status: 200, 214 | text: 'mock response', 215 | headers: { 216 | 'content-type': 'application/json', 217 | 'x-custom-header': 'custom value' 218 | }, 219 | }; 220 | 221 | requestUrlMock.mockResolvedValue(mockResponse); 222 | 223 | const url = 'https://api.example.com'; 224 | const response = await obsidianFetch(url, { headers: {} }); 225 | 226 | expect(response.headers).toEqual(mockResponse.headers); 227 | }); 228 | }); 229 | }); -------------------------------------------------------------------------------- /src/utils/obsidianFetch.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl, RequestUrlParam, RequestUrlResponse } from "obsidian"; 2 | import { logger } from './logger'; 3 | 4 | export const obsidianFetch = async (url: string, options: RequestInit = {}): Promise => { 5 | delete (options.headers as Record)["content-length"]; 6 | 7 | // Unfortunatelly, requestUrl doesn't support abort controller 8 | // 9 | // const params: { controller?: AbortController } = {}; 10 | // if (this && 'controller' in this) { 11 | // params.controller = this.controller; 12 | // } 13 | 14 | logger.debug('obsidianFetch request:', { 15 | url, 16 | method: options.method || 'GET', 17 | headers: options.headers, 18 | hasBody: !!options.body 19 | }); 20 | 21 | const requestParams: RequestUrlParam = { 22 | url, 23 | method: options.method || 'GET', 24 | headers: options.headers as Record, 25 | }; 26 | 27 | if (options.body) { 28 | requestParams.body = options.body as string; 29 | 30 | logger.debug('Request body prepared:', requestParams.body); 31 | } 32 | 33 | try { 34 | logger.debug('Sending request via requestUrl'); 35 | const obsidianResponse: RequestUrlResponse = await requestUrl(requestParams); 36 | 37 | logger.debug('Response received:', { 38 | status: obsidianResponse.status, 39 | headers: obsidianResponse.headers, 40 | contentLength: obsidianResponse.text.length 41 | }); 42 | 43 | const responseInit: ResponseInit = { 44 | status: obsidianResponse.status, 45 | headers: obsidianResponse.headers, 46 | }; 47 | 48 | return new Response(obsidianResponse.text, responseInit); 49 | } catch (error) { 50 | logger.error('Request failed:', error); 51 | throw error; 52 | } 53 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "outDir": "./dist", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": [ 16 | "DOM", 17 | "ES5", 18 | "ES6", 19 | "ES7", 20 | "ES2017", 21 | "ES2018", 22 | "ES2018.AsyncIterable" 23 | ], 24 | "esModuleInterop": true, 25 | "skipLibCheck": true, 26 | "noEmit": true, 27 | "types": ["jest", "@testing-library/jest-dom", "node", "electron"], 28 | "resolveJsonModule": true 29 | }, 30 | "include": [ 31 | "src/**/*.ts" 32 | , "__mocks__/electron.ts", "__mocks__/obsidian.ts" ] 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.0.2": "0.15.0", 5 | "1.0.3": "0.15.0", 6 | "1.0.4": "0.15.0", 7 | "1.0.5": "0.15.0", 8 | "1.1.0": "0.15.0", 9 | "1.2.0": "0.15.0", 10 | "1.3.0": "0.15.0" 11 | } --------------------------------------------------------------------------------