Auto tagging obsidian notes w/ AI
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: organize code into modular structure

- Split monolithic code into separate files and modules
- Create dedicated services for tag generation, notifications, and note handling
- Implement proper typings and interfaces
- Add proper error handling and validation utilities
- Improve notification system with progress tracking
- Add proper project structure with src/ directory
- Add lint scripts to package.json

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>

+989 -815
+14
.eslintrc.js
··· 1 + module.exports = { 2 + root: true, 3 + parser: '@typescript-eslint/parser', 4 + plugins: ['@typescript-eslint'], 5 + extends: [ 6 + 'eslint:recommended', 7 + 'plugin:@typescript-eslint/recommended', 8 + ], 9 + rules: { 10 + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 11 + '@typescript-eslint/ban-ts-comment': 'off', 12 + 'no-console': ['warn', { allow: ['warn', 'error'] }], 13 + }, 14 + };
+3 -814
main.ts
··· 1 - import { 2 - App, 3 - MarkdownView, 4 - Modal, 5 - Notice, 6 - Plugin, 7 - PluginSettingTab, 8 - Setting, 9 - TFile, 10 - requestUrl, 11 - } from "obsidian"; 12 - 13 - // type AIProvider = 'Anthropic' | 'OpenAI' | 'Mistral' | 'Google' | 'Custom'; 14 - 15 - enum AIProvider { 16 - Anthropic = "anthropic", 17 - OpenAI = "openai", 18 - Mistral = "mistral", 19 - Google = "google", 20 - Custom = "custom", 21 - } 22 - 23 - interface AITaggerSettings { 24 - provider: AIProvider; 25 - apiKey: string; 26 - modelName: string; 27 - maxTags: number; 28 - promptOption: string; 29 - promptTemplate: string; 30 - customEndpoint: string; 31 - } 32 - 33 - // Define prompt templates 34 - const PROMPT_TEMPLATES = { 35 - standard: 36 - "Generate {maxTags} relevant tags for the following note content. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 37 - descriptive: 38 - "Analyze the following note content and generate {maxTags} descriptive tags that capture the main topics, concepts, and themes. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 39 - academic: 40 - "Review the following academic or research note and generate {maxTags} specific tags that would help categorize this content in an academic context. Include relevant field-specific terminology. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 41 - concise: 42 - "Generate {maxTags} short, concise tags for the following note content. Focus on single-word tags when possible. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase.\n\nContent:\n{content}", 43 - custom: "", 44 - }; 45 - 46 - // Define model configurations for each provider 47 - const MODEL_CONFIGS = { 48 - anthropic: { 49 - apiUrl: "https://api.anthropic.com/v1/messages", 50 - models: [ 51 - { id: "claude-3-5-sonnet-20240620", name: "Claude 3.5 Sonnet" }, 52 - { id: "claude-3-opus-20240229", name: "Claude 3 Opus" }, 53 - { id: "claude-3-sonnet-20240229", name: "Claude 3 Sonnet" }, 54 - { id: "claude-3-haiku-20240307", name: "Claude 3 Haiku" }, 55 - ], 56 - defaultModel: "claude-3-5-sonnet-20240620", 57 - apiKeyUrl: "https://console.anthropic.com/", 58 - }, 59 - openai: { 60 - apiUrl: "https://api.openai.com/v1/chat/completions", 61 - models: [ 62 - { id: "gpt-4o", name: "GPT-4o" }, 63 - { id: "gpt-4-turbo", name: "GPT-4 Turbo" }, 64 - { id: "gpt-4", name: "GPT-4" }, 65 - { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" }, 66 - ], 67 - defaultModel: "gpt-3.5-turbo", 68 - apiKeyUrl: "https://platform.openai.com/api-keys", 69 - }, 70 - mistral: { 71 - apiUrl: "https://api.mistral.ai/v1/chat/completions", 72 - models: [ 73 - { id: "mistral-large-latest", name: "Mistral Large" }, 74 - { id: "mistral-medium-latest", name: "Mistral Medium" }, 75 - { id: "mistral-small-latest", name: "Mistral Small" }, 76 - { id: "open-mistral-7b", name: "Open Mistral 7B" }, 77 - ], 78 - defaultModel: "mistral-small-latest", 79 - apiKeyUrl: "https://console.mistral.ai/api-keys/", 80 - }, 81 - google: { 82 - apiUrl: "https://generativelanguage.googleapis.com/v1beta/models/", 83 - models: [ 84 - { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, 85 - { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, 86 - { id: "gemini-1.0-pro", name: "Gemini 1.0 Pro" }, 87 - ], 88 - defaultModel: "gemini-1.5-flash", 89 - apiKeyUrl: "https://aistudio.google.com/app/apikey", 90 - }, 91 - custom: { 92 - apiUrl: "", 93 - models: [{ id: "custom-model", name: "Custom Model" }], 94 - defaultModel: "custom-model", 95 - apiKeyUrl: "", 96 - }, 97 - }; 98 - 99 - const DEFAULT_SETTINGS: AITaggerSettings = { 100 - provider: AIProvider.Anthropic, 101 - apiKey: "", 102 - modelName: MODEL_CONFIGS[AIProvider.Anthropic].defaultModel, 103 - maxTags: 5, 104 - promptOption: "standard", 105 - promptTemplate: PROMPT_TEMPLATES.standard, 106 - customEndpoint: "", 107 - }; 108 - 109 - export default class AITaggerPlugin extends Plugin { 110 - settings: AITaggerSettings; 111 - 112 - async onload() { 113 - await this.loadSettings(); 114 - 115 - // Create an icon in the left ribbon 116 - const ribbonIconEl = this.addRibbonIcon( 117 - "tag", 118 - "Auto-tag with AI", 119 - (evt: MouseEvent) => { 120 - // Check for required settings based on provider 121 - if (!this.settings.apiKey) { 122 - new ConfirmModal( 123 - this.app, 124 - "AI tagging API key missing", 125 - () => {}, 126 - true, 127 - "API key not configured. Please add your API key in the plugin settings." 128 - ).open(); 129 - return; 130 - } 131 - 132 - if ( 133 - this.settings.provider === AIProvider.Custom && 134 - !this.settings.customEndpoint 135 - ) { 136 - new ConfirmModal( 137 - this.app, 138 - "Custom API endpoint missing", 139 - () => {}, 140 - true, 141 - "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 142 - ).open(); 143 - return; 144 - } 145 - 146 - // Tag the current note directly when clicking the ribbon icon 147 - this.tagCurrentNote(); 148 - } 149 - ); 150 - ribbonIconEl.addClass("ai-tagger-ribbon-class"); 151 - 152 - // Add command to tag current note 153 - this.addCommand({ 154 - id: "tag-current-note", 155 - name: "Tag current note with AI", 156 - checkCallback: (checking: boolean) => { 157 - const markdownView = 158 - this.app.workspace.getActiveViewOfType(MarkdownView); 159 - if (markdownView) { 160 - if (!checking) { 161 - if (!this.settings.apiKey) { 162 - new ConfirmModal( 163 - this.app, 164 - "AI tagging API key missing", 165 - () => {}, 166 - true, 167 - "API key not configured. Please add your API key in the plugin settings." 168 - ).open(); 169 - return true; 170 - } 171 - 172 - if ( 173 - this.settings.provider === AIProvider.Custom && 174 - !this.settings.customEndpoint 175 - ) { 176 - new ConfirmModal( 177 - this.app, 178 - "Custom API endpoint missing", 179 - () => {}, 180 - true, 181 - "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 182 - ).open(); 183 - return true; 184 - } 185 - 186 - this.tagCurrentNote(); 187 - } 188 - return true; 189 - } 190 - return false; 191 - }, 192 - }); 193 - 194 - // Add command to tag all notes (but might need to limit this or add pagination) 195 - this.addCommand({ 196 - id: "tag-all-notes", 197 - name: "Tag all notes with AI", 198 - callback: () => { 199 - if (!this.settings.apiKey) { 200 - new ConfirmModal( 201 - this.app, 202 - "AI tagging API key missing", 203 - () => {}, 204 - true, 205 - "API key not configured. Please add your API key in the plugin settings." 206 - ).open(); 207 - return; 208 - } 209 - 210 - if ( 211 - this.settings.provider === "custom" && 212 - !this.settings.customEndpoint 213 - ) { 214 - new ConfirmModal( 215 - this.app, 216 - "Custom API endpoint missing", 217 - () => {}, 218 - true, 219 - "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 220 - ).open(); 221 - return; 222 - } 223 - 224 - new ConfirmModal( 225 - this.app, 226 - `This will tag all notes in your vault using the ${ 227 - MODEL_CONFIGS[this.settings.provider].models.find( 228 - (m) => m.id === this.settings.modelName 229 - )?.name || this.settings.modelName 230 - } model. This may take a while and consume API credits. Do you want to continue?`, 231 - () => this.tagAllNotes() 232 - ).open(); 233 - }, 234 - }); 235 - 236 - // Add settings tab 237 - this.addSettingTab(new AITaggerSettingTab(this.app, this)); 238 - } 239 - 240 - onunload() { 241 - // Nothing specific to clean up 242 - } 243 - 244 - async loadSettings() { 245 - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 246 - } 247 - 248 - async saveSettings() { 249 - await this.saveData(this.settings); 250 - } 251 - 252 - async tagCurrentNote() { 253 - if (!this.settings.apiKey) { 254 - new Notice( 255 - "API key not configured. Please add your API key in the plugin settings." 256 - ); 257 - return; 258 - } 259 - 260 - if (this.settings.provider === AIProvider.Custom && !this.settings.customEndpoint) { 261 - new Notice( 262 - "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 263 - ); 264 - return; 265 - } 266 - 267 - const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 268 - if (!activeView || !activeView.file) { 269 - new Notice("No active note to tag"); 270 - return; 271 - } 272 - 273 - const file = activeView.file; 274 - const content = await this.app.vault.read(file); 275 - 276 - // Create a persistent notice 277 - const notice = new Notice( 278 - "Analyzing note content and generating tags...", 279 - 0 280 - ); 281 - 282 - try { 283 - const tags = await this.generateTags(content); 284 - await this.updateNoteFrontmatter(file, tags); 285 - 286 - // Update the notice with success message 287 - notice.setMessage(`Successfully added tags: ${tags.join(", ")}`); 288 - 289 - // Hide after 3 seconds 290 - setTimeout(() => { 291 - notice.hide(); 292 - }, 3000); 293 - } catch (error) { 294 - console.error("Error tagging note:", error); 295 - const errorMessage = 296 - error instanceof Error ? error.message : String(error); 297 - 298 - // Update the notice with error message 299 - notice.setMessage(`Error tagging note: ${errorMessage}`); 300 - 301 - // Hide after 5 seconds for error messages (giving more time to read) 302 - setTimeout(() => { 303 - notice.hide(); 304 - }, 5000); 305 - } 306 - } 307 - 308 - async tagAllNotes() { 309 - if (!this.settings.apiKey) { 310 - new Notice( 311 - "API key not configured. Please add your API key in the plugin settings." 312 - ); 313 - return; 314 - } 315 - 316 - if (this.settings.provider === AIProvider.Custom && !this.settings.customEndpoint) { 317 - new Notice( 318 - "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 319 - ); 320 - return; 321 - } 322 - 323 - const files = this.app.vault.getMarkdownFiles(); 324 - let processed = 0; 325 - let successful = 0; 326 - 327 - // Create a persistent notice that we'll update 328 - const notice = new Notice(`Starting to tag ${files.length} notes...`, 0); 329 - 330 - for (const file of files) { 331 - try { 332 - // Update the notice with current file 333 - notice.setMessage( 334 - `Processing: ${file.path}\nProgress: ${processed}/${files.length} (${successful} successful)` 335 - ); 336 - 337 - const content = await this.app.vault.read(file); 338 - const tags = await this.generateTags(content); 339 - await this.updateNoteFrontmatter(file, tags); 340 - successful++; 341 - } catch (error) { 342 - console.error(`Error tagging note ${file.path}:`, error); 343 - } 344 - 345 - processed++; 346 - } 347 - 348 - // Final notice with completion message 349 - notice.setMessage( 350 - `Completed tagging ${successful}/${files.length} notes successfully` 351 - ); 352 - 353 - // Hide the notice after 3 seconds 354 - setTimeout(() => { 355 - notice.hide(); 356 - }, 3000); 357 - } 358 - 359 - async generateTags(content: string): Promise<string[]> { 360 - if (!this.settings.apiKey) { 361 - throw new Error( 362 - "API key not configured. Please add your API key in the plugin settings." 363 - ); 364 - } 365 - 366 - if (this.settings.provider === AIProvider.Custom && !this.settings.customEndpoint) { 367 - throw new Error( 368 - "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 369 - ); 370 - } 371 - 372 - // Replace placeholders in the prompt template 373 - const prompt = this.settings.promptTemplate 374 - .replace("{maxTags}", this.settings.maxTags.toString()) 375 - .replace("{content}", content); 376 - 377 - try { 378 - let response; 379 - let tagText: string; 380 - 381 - // Get provider configuration 382 - const providerConfig = MODEL_CONFIGS[this.settings.provider]; 383 - 384 - switch (this.settings.provider) { 385 - case AIProvider.Anthropic: 386 - response = await requestUrl({ 387 - url: providerConfig.apiUrl, 388 - method: "POST", 389 - headers: { 390 - "Content-Type": "application/json", 391 - "x-api-key": this.settings.apiKey, 392 - "anthropic-version": "2023-06-01", 393 - }, 394 - body: JSON.stringify({ 395 - model: this.settings.modelName, 396 - max_tokens: 1000, 397 - messages: [ 398 - { 399 - role: "user", 400 - content: prompt, 401 - }, 402 - ], 403 - }), 404 - }); 405 - 406 - if (response.status !== 200) { 407 - throw new Error( 408 - `API returned status code ${response.status}: ${JSON.stringify( 409 - response.json 410 - )}` 411 - ); 412 - } 413 - 414 - tagText = response.json.content[0].text; 415 - break; 416 - 417 - case AIProvider.OpenAI: 418 - case AIProvider.Mistral: 419 - case AIProvider.Custom: 420 - // OpenAI and Mistral use the same API format 421 - // For custom endpoints, we assume OpenAI compatible format 422 - const endpoint = 423 - this.settings.provider === AIProvider.Custom 424 - ? this.settings.customEndpoint 425 - : providerConfig.apiUrl; 426 - 427 - response = await requestUrl({ 428 - url: endpoint, 429 - method: "POST", 430 - headers: { 431 - "Content-Type": "application/json", 432 - Authorization: `Bearer ${this.settings.apiKey}`, 433 - }, 434 - body: JSON.stringify({ 435 - model: this.settings.modelName, 436 - messages: [ 437 - { 438 - role: "user", 439 - content: prompt, 440 - }, 441 - ], 442 - max_tokens: 1000, 443 - temperature: 0.3, 444 - }), 445 - }); 446 - 447 - if (response.status !== 200) { 448 - throw new Error( 449 - `API returned status code ${response.status}: ${JSON.stringify( 450 - response.json 451 - )}` 452 - ); 453 - } 454 - 455 - tagText = response.json.choices[0].message.content; 456 - break; 457 - 458 - case AIProvider.Google: 459 - // Google Gemini has a different API format 460 - const apiEndpoint = `${providerConfig.apiUrl}${this.settings.modelName}:generateContent?key=${this.settings.apiKey}`; 461 - 462 - response = await requestUrl({ 463 - url: apiEndpoint, 464 - method: "POST", 465 - headers: { 466 - "Content-Type": "application/json", 467 - }, 468 - body: JSON.stringify({ 469 - contents: [ 470 - { 471 - role: "user", 472 - parts: [{ text: prompt }], 473 - }, 474 - ], 475 - generationConfig: { 476 - temperature: 0.2, 477 - maxOutputTokens: 1000, 478 - }, 479 - }), 480 - }); 481 - 482 - if (response.status !== 200) { 483 - throw new Error( 484 - `API returned status code ${response.status}: ${JSON.stringify( 485 - response.json 486 - )}` 487 - ); 488 - } 489 - 490 - tagText = response.json.candidates[0].content.parts[0].text; 491 - break; 492 - 493 - default: 494 - throw new Error(`Unknown provider: ${this.settings.provider}`); 495 - } 496 - 497 - // Split by commas and trim whitespace 498 - return tagText 499 - .split(",") 500 - .map((tag: string) => tag.trim()) 501 - .filter((tag: string) => tag.length > 0); 502 - } catch (error) { 503 - console.error(`Error calling ${this.settings.provider} API:`, error); 504 - const errorMessage = 505 - error instanceof Error ? error.message : String(error); 506 - throw new Error(`Failed to generate tags: ${errorMessage}`); 507 - } 508 - } 509 - 510 - async updateNoteFrontmatter(file: TFile, newTags: string[]): Promise<void> { 511 - // Use FileManager.processFrontMatter to atomically update the frontmatter 512 - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { 513 - // Add new tags to existing tags or create new tags field 514 - if (!frontmatter.tags) { 515 - frontmatter.tags = newTags; 516 - } else { 517 - // Handle case where tags is a string 518 - if (typeof frontmatter.tags === "string") { 519 - frontmatter.tags = [frontmatter.tags, ...newTags]; 520 - } 521 - // Handle case where tags is already an array 522 - else if (Array.isArray(frontmatter.tags)) { 523 - frontmatter.tags = [...frontmatter.tags, ...newTags]; 524 - } 525 - 526 - // Remove duplicates 527 - frontmatter.tags = [...new Set(frontmatter.tags)]; 528 - } 529 - }); 530 - } 531 - } 532 - 533 - class ConfirmModal extends Modal { 534 - onConfirmCallback: () => void; 535 - message: string; 536 - hasError: boolean; 537 - errorMessage: string; 538 - 539 - constructor( 540 - app: App, 541 - message: string, 542 - onConfirmCallback: () => void, 543 - hasError = false, 544 - errorMessage = "" 545 - ) { 546 - super(app); 547 - this.message = message; 548 - this.onConfirmCallback = onConfirmCallback; 549 - this.hasError = hasError; 550 - this.errorMessage = errorMessage; 551 - } 552 - 553 - onOpen() { 554 - const { contentEl } = this; 555 - 556 - contentEl.createEl("p", { text: this.message }); 557 - 558 - if (this.hasError) { 559 - const errorEl = contentEl.createEl("p", { text: this.errorMessage }); 560 - errorEl.addClass("ai-tagger-error-message"); 561 - 562 - const buttonContainer = contentEl.createDiv(); 563 - buttonContainer.addClass("ai-tagger-modal-buttons"); 564 - 565 - const settingsButton = buttonContainer.createEl("button", { 566 - text: "Open settings", 567 - }); 568 - settingsButton.addEventListener("click", () => { 569 - this.close(); 570 - // Open settings tab 571 - // Using type assertion with a more specific interface would be better 572 - // if we had access to the internal Obsidian API types 573 - if ("setting" in this.app) { 574 - const appWithSetting = this.app as unknown as { 575 - setting: { open: () => void; openTabById: (id: string) => void }; 576 - }; 577 - appWithSetting.setting.open(); 578 - appWithSetting.setting.openTabById("ai-tagger"); 579 - } 580 - }); 581 - 582 - const cancelButton = buttonContainer.createEl("button", { 583 - text: "Cancel", 584 - }); 585 - cancelButton.addEventListener("click", () => { 586 - this.close(); 587 - }); 588 - } else { 589 - const buttonContainer = contentEl.createDiv(); 590 - buttonContainer.addClass("ai-tagger-modal-buttons"); 591 - 592 - const confirmButton = buttonContainer.createEl("button", { 593 - text: "Confirm", 594 - }); 595 - confirmButton.addEventListener("click", () => { 596 - this.onConfirmCallback(); 597 - this.close(); 598 - }); 599 - 600 - const cancelButton = buttonContainer.createEl("button", { 601 - text: "Cancel", 602 - }); 603 - cancelButton.addEventListener("click", () => { 604 - this.close(); 605 - }); 606 - } 607 - } 608 - 609 - onClose() { 610 - const { contentEl } = this; 611 - contentEl.empty(); 612 - } 613 - } 614 - 615 - class AITaggerSettingTab extends PluginSettingTab { 616 - plugin: AITaggerPlugin; 617 - 618 - constructor(app: App, plugin: AITaggerPlugin) { 619 - super(app, plugin); 620 - this.plugin = plugin; 621 - } 622 - 623 - display(): void { 624 - const { containerEl } = this; 625 - 626 - containerEl.empty(); 627 - 628 - // AI Provider selection 629 - new Setting(containerEl) 630 - .setName("AI Provider") 631 - .setDesc("Select which AI provider to use for generating tags.") 632 - .addDropdown((dropdown) => { 633 - dropdown 634 - .addOption(AIProvider.Anthropic, "Anthropic (Claude)") 635 - .addOption(AIProvider.OpenAI, "OpenAI (GPT)") 636 - .addOption(AIProvider.Mistral, "Mistral AI") 637 - .addOption(AIProvider.Google, "Google (Gemini)") 638 - .addOption(AIProvider.Custom, "Custom Endpoint (OpenAI Compatible)") 639 - .setValue(this.plugin.settings.provider) 640 - .onChange(async (value) => { 641 - const newProvider = value as AIProvider; 642 - this.plugin.settings.provider = newProvider; 643 - 644 - // Update model to default for the selected provider 645 - if (newProvider !== AIProvider.Custom) { 646 - this.plugin.settings.modelName = 647 - MODEL_CONFIGS[newProvider].defaultModel; 648 - } 649 - 650 - await this.plugin.saveSettings(); 651 - this.display(); // Refresh to show provider-specific options 652 - }); 653 - return dropdown; 654 - }); 655 - 656 - // Custom endpoint setting (only shown for custom provider) 657 - if (this.plugin.settings.provider === AIProvider.Custom) { 658 - new Setting(containerEl) 659 - .setName("Custom API Endpoint") 660 - .setDesc( 661 - "Enter the URL for your custom OpenAI-compatible API endpoint." 662 - ) 663 - .addText((text) => 664 - text 665 - .setPlaceholder("https://your-api-endpoint.com/v1/chat/completions") 666 - .setValue(this.plugin.settings.customEndpoint) 667 - .onChange(async (value) => { 668 - this.plugin.settings.customEndpoint = value; 669 - await this.plugin.saveSettings(); 670 - }) 671 - ); 672 - } 673 - 674 - // Get the current provider config 675 - const providerConfig = MODEL_CONFIGS[this.plugin.settings.provider]; 676 - 677 - // API Key with provider-specific description 678 - new Setting(containerEl) 679 - .setName("API key") 680 - .setDesc( 681 - `Your ${ 682 - this.plugin.settings.provider === AIProvider.Custom 683 - ? "" 684 - : this.plugin.settings.provider 685 - } API key. Required to use the AI service. ${ 686 - providerConfig.apiKeyUrl 687 - ? `Get it from ${providerConfig.apiKeyUrl} if you don't have one already.` 688 - : "" 689 - } We recommend using a dedicated key for this plugin.` 690 - ) 691 - .addText((text) => 692 - text 693 - .setPlaceholder("Enter your API key") 694 - .setValue(this.plugin.settings.apiKey) 695 - .onChange(async (value) => { 696 - this.plugin.settings.apiKey = value; 697 - await this.plugin.saveSettings(); 698 - }) 699 - ); 700 - 701 - // AI model selection (provider-specific) 702 - new Setting(containerEl) 703 - .setName("AI model") 704 - .setDesc("Choose which AI model to use for tag generation.") 705 - .addDropdown((dropdown) => { 706 - // Add models for the selected provider 707 - providerConfig.models.forEach((model) => { 708 - dropdown.addOption(model.id, model.name); 709 - }); 710 - 711 - // If current model isn't in the list, add it 712 - if ( 713 - !providerConfig.models.some( 714 - (m) => m.id === this.plugin.settings.modelName 715 - ) 716 - ) { 717 - dropdown.addOption( 718 - this.plugin.settings.modelName, 719 - this.plugin.settings.modelName + " (Custom)" 720 - ); 721 - } 722 - 723 - dropdown 724 - .setValue(this.plugin.settings.modelName) 725 - .onChange(async (value) => { 726 - this.plugin.settings.modelName = value; 727 - await this.plugin.saveSettings(); 728 - }); 729 - 730 - return dropdown; 731 - }); 732 - 733 - new Setting(containerEl) 734 - .setName("Maximum number of tags") 735 - .setDesc("Set the maximum number of tags to generate per note.") 736 - .addSlider((slider) => 737 - slider 738 - .setLimits(1, 20, 1) 739 - .setValue(this.plugin.settings.maxTags) 740 - .setDynamicTooltip() 741 - .onChange(async (value) => { 742 - this.plugin.settings.maxTags = value; 743 - await this.plugin.saveSettings(); 744 - }) 745 - ); 746 - 747 - // Prompt option dropdown 748 - new Setting(containerEl) 749 - .setName("Prompt style") 750 - .setDesc( 751 - "Choose a predefined prompt style or create your own custom prompt." 752 - ) 753 - .addDropdown((dropdown) => { 754 - dropdown 755 - .addOption("standard", "Standard") 756 - .addOption("descriptive", "Descriptive") 757 - .addOption("academic", "Academic") 758 - .addOption("concise", "Concise") 759 - .addOption("custom", "Custom") 760 - .setValue(this.plugin.settings.promptOption) 761 - .onChange(async (value) => { 762 - this.plugin.settings.promptOption = value; 763 - 764 - // Update prompt template if not using custom 765 - if (value !== "custom") { 766 - this.plugin.settings.promptTemplate = 767 - PROMPT_TEMPLATES[value as keyof typeof PROMPT_TEMPLATES]; 768 - 769 - // Force refresh to update the textarea with the new template 770 - this.display(); 771 - } else if (this.plugin.settings.promptTemplate === "") { 772 - // If switching to custom and no custom template yet, initialize with standard 773 - this.plugin.settings.promptTemplate = PROMPT_TEMPLATES.standard; 774 - this.display(); 775 - } 776 - 777 - await this.plugin.saveSettings(); 778 - }); 779 - return dropdown; 780 - }); 781 - 782 - // Only show prompt template textarea if custom option is selected 783 - if (this.plugin.settings.promptOption === "custom") { 784 - new Setting(containerEl) 785 - .setName("Custom prompt template") 786 - .setDesc( 787 - "Customize the prompt sent to the AI. Use {maxTags} and {content} as placeholders." 788 - ) 789 - .addTextArea((textarea) => 790 - textarea 791 - .setValue(this.plugin.settings.promptTemplate) 792 - .onChange(async (value) => { 793 - this.plugin.settings.promptTemplate = value; 794 - await this.plugin.saveSettings(); 795 - }) 796 - ) 797 - .setClass("ai-tagger-wide-setting"); 798 - } else { 799 - // Show the current template as read-only if not using custom 800 - new Setting(containerEl) 801 - .setName("Current prompt template") 802 - .setDesc( 803 - "This is the prompt template that will be used (read-only). Switch to Custom if you want to edit it." 804 - ) 805 - .addTextArea((textarea) => { 806 - textarea 807 - .setValue(this.plugin.settings.promptTemplate) 808 - .setDisabled(true); 809 - return textarea; 810 - }) 811 - .setClass("ai-tagger-wide-setting"); 812 - } 813 - } 814 - } 1 + // Re-export from src directory 2 + import AITaggerPlugin from './src/main'; 3 + export default AITaggerPlugin;
+3 -1
package.json
··· 6 6 "scripts": { 7 7 "dev": "node esbuild.config.mjs", 8 8 "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 - "version": "node version-bump.mjs && git add manifest.json versions.json" 9 + "version": "node version-bump.mjs && git add manifest.json versions.json", 10 + "lint": "eslint src/**/*.ts", 11 + "lint:fix": "eslint src/**/*.ts --fix" 10 12 }, 11 13 "keywords": [ 12 14 "obsidian",
+183
src/main.ts
··· 1 + import { 2 + App, 3 + MarkdownView, 4 + Plugin, 5 + TFile, 6 + } from "obsidian"; 7 + 8 + import { AITaggerSettings } from "./models/types"; 9 + import { DEFAULT_SETTINGS } from "./models/constants"; 10 + import { ConfirmModal } from "./ui/ConfirmModal"; 11 + import { AITaggerSettingTab } from "./ui/AITaggerSettingTab"; 12 + import { tagSingleNote, tagAllNotes } from "./services/noteService"; 13 + import { NotificationService } from "./services/notificationService"; 14 + import { validateApiSettings, getModelName } from "./utils/validationUtils"; 15 + 16 + export default class AITaggerPlugin extends Plugin { 17 + settings: AITaggerSettings; 18 + 19 + async onload() { 20 + await this.loadSettings(); 21 + 22 + // Create an icon in the left ribbon 23 + const ribbonIconEl = this.addRibbonIcon( 24 + "tag", 25 + "Auto-tag with AI", 26 + this.handleRibbonClick.bind(this) 27 + ); 28 + ribbonIconEl.addClass("ai-tagger-ribbon-class"); 29 + 30 + // Add command to tag current note 31 + this.addCommand({ 32 + id: "tag-current-note", 33 + name: "Tag current note with AI", 34 + checkCallback: this.checkAndTagCurrentNote.bind(this), 35 + }); 36 + 37 + // Add command to tag all notes 38 + this.addCommand({ 39 + id: "tag-all-notes", 40 + name: "Tag all notes with AI", 41 + callback: this.confirmAndTagAllNotes.bind(this), 42 + }); 43 + 44 + // Add settings tab 45 + this.addSettingTab(new AITaggerSettingTab(this.app, this)); 46 + } 47 + 48 + onunload() { 49 + // Nothing specific to clean up 50 + } 51 + 52 + async loadSettings() { 53 + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 54 + } 55 + 56 + async saveSettings() { 57 + await this.saveData(this.settings); 58 + } 59 + 60 + private handleRibbonClick() { 61 + const validation = validateApiSettings(this.settings); 62 + 63 + if (!validation.valid) { 64 + new ConfirmModal( 65 + this.app, 66 + "AI tagging configuration error", 67 + () => {}, 68 + true, 69 + validation.error 70 + ).open(); 71 + return; 72 + } 73 + 74 + // Tag the current note directly when clicking the ribbon icon 75 + this.tagCurrentNote(); 76 + } 77 + 78 + private checkAndTagCurrentNote(checking: boolean): boolean { 79 + const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); 80 + 81 + if (!markdownView) { 82 + return false; 83 + } 84 + 85 + if (checking) { 86 + return true; 87 + } 88 + 89 + const validation = validateApiSettings(this.settings); 90 + 91 + if (!validation.valid) { 92 + new ConfirmModal( 93 + this.app, 94 + "AI tagging configuration error", 95 + () => {}, 96 + true, 97 + validation.error 98 + ).open(); 99 + return true; 100 + } 101 + 102 + this.tagCurrentNote(); 103 + return true; 104 + } 105 + 106 + private confirmAndTagAllNotes() { 107 + const validation = validateApiSettings(this.settings); 108 + 109 + if (!validation.valid) { 110 + new ConfirmModal( 111 + this.app, 112 + "AI tagging configuration error", 113 + () => {}, 114 + true, 115 + validation.error 116 + ).open(); 117 + return; 118 + } 119 + 120 + const modelName = getModelName(this.settings); 121 + 122 + new ConfirmModal( 123 + this.app, 124 + `This will tag all notes in your vault using the ${modelName} model. This may take a while and consume API credits. Do you want to continue?`, 125 + () => this.tagAllNotes() 126 + ).open(); 127 + } 128 + 129 + private async tagCurrentNote() { 130 + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 131 + if (!activeView || !activeView.file) { 132 + NotificationService.showNotice("No active note to tag"); 133 + return; 134 + } 135 + 136 + const file = activeView.file; 137 + 138 + // Create a persistent notice 139 + const notification = NotificationService.showPersistentNotice( 140 + "Analyzing note content and generating tags..." 141 + ); 142 + 143 + try { 144 + const result = await tagSingleNote(this.app, file, this.settings); 145 + 146 + if (result.success) { 147 + notification.setSuccess(`Successfully added tags: ${result.tags.join(", ")}`); 148 + } else { 149 + notification.setError(`Error tagging note: ${result.error}`); 150 + } 151 + } catch (error) { 152 + console.error("Error tagging note:", error); 153 + const errorMessage = error instanceof Error ? error.message : String(error); 154 + notification.setError(`Error tagging note: ${errorMessage}`); 155 + } 156 + } 157 + 158 + private async tagAllNotes() { 159 + // Create a persistent notice that we'll update 160 + const notification = NotificationService.showPersistentNotice( 161 + "Starting to tag notes..." 162 + ); 163 + 164 + try { 165 + const result = await tagAllNotes( 166 + this.app, 167 + this.settings, 168 + (processed, successful, total, currentFile) => { 169 + notification.setProgress(processed, successful, total, currentFile); 170 + } 171 + ); 172 + 173 + // Final notice with completion message 174 + notification.setSuccess( 175 + `Completed tagging ${result.successful}/${result.total} notes successfully` 176 + ); 177 + } catch (error) { 178 + console.error("Error during bulk tagging:", error); 179 + const errorMessage = error instanceof Error ? error.message : String(error); 180 + notification.setError(`Error during bulk tagging: ${errorMessage}`); 181 + } 182 + } 183 + }
+77
src/models/constants.ts
··· 1 + import { AIProvider, AITaggerSettings, ProviderConfig } from "./types"; 2 + 3 + // Define prompt templates 4 + export const PROMPT_TEMPLATES = { 5 + standard: 6 + "Generate {maxTags} relevant tags for the following note content. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 7 + descriptive: 8 + "Analyze the following note content and generate {maxTags} descriptive tags that capture the main topics, concepts, and themes. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 9 + academic: 10 + "Review the following academic or research note and generate {maxTags} specific tags that would help categorize this content in an academic context. Include relevant field-specific terminology. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase and use hyphens for multi-word tags.\n\nContent:\n{content}", 11 + concise: 12 + "Generate {maxTags} short, concise tags for the following note content. Focus on single-word tags when possible. Return only the tags as a comma-separated list, without any additional commentary. Tags should be lowercase.\n\nContent:\n{content}", 13 + custom: "", 14 + }; 15 + 16 + // Define model configurations for each provider 17 + export const MODEL_CONFIGS: Record<AIProvider, ProviderConfig> = { 18 + [AIProvider.Anthropic]: { 19 + apiUrl: "https://api.anthropic.com/v1/messages", 20 + models: [ 21 + { id: "claude-3-5-sonnet-20240620", name: "Claude 3.5 Sonnet" }, 22 + { id: "claude-3-opus-20240229", name: "Claude 3 Opus" }, 23 + { id: "claude-3-sonnet-20240229", name: "Claude 3 Sonnet" }, 24 + { id: "claude-3-haiku-20240307", name: "Claude 3 Haiku" }, 25 + ], 26 + defaultModel: "claude-3-5-sonnet-20240620", 27 + apiKeyUrl: "https://console.anthropic.com/", 28 + }, 29 + [AIProvider.OpenAI]: { 30 + apiUrl: "https://api.openai.com/v1/chat/completions", 31 + models: [ 32 + { id: "gpt-4o", name: "GPT-4o" }, 33 + { id: "gpt-4-turbo", name: "GPT-4 Turbo" }, 34 + { id: "gpt-4", name: "GPT-4" }, 35 + { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" }, 36 + ], 37 + defaultModel: "gpt-3.5-turbo", 38 + apiKeyUrl: "https://platform.openai.com/api-keys", 39 + }, 40 + [AIProvider.Mistral]: { 41 + apiUrl: "https://api.mistral.ai/v1/chat/completions", 42 + models: [ 43 + { id: "mistral-large-latest", name: "Mistral Large" }, 44 + { id: "mistral-medium-latest", name: "Mistral Medium" }, 45 + { id: "mistral-small-latest", name: "Mistral Small" }, 46 + { id: "open-mistral-7b", name: "Open Mistral 7B" }, 47 + ], 48 + defaultModel: "mistral-small-latest", 49 + apiKeyUrl: "https://console.mistral.ai/api-keys/", 50 + }, 51 + [AIProvider.Google]: { 52 + apiUrl: "https://generativelanguage.googleapis.com/v1beta/models/", 53 + models: [ 54 + { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, 55 + { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, 56 + { id: "gemini-1.0-pro", name: "Gemini 1.0 Pro" }, 57 + ], 58 + defaultModel: "gemini-1.5-flash", 59 + apiKeyUrl: "https://aistudio.google.com/app/apikey", 60 + }, 61 + [AIProvider.Custom]: { 62 + apiUrl: "", 63 + models: [{ id: "custom-model", name: "Custom Model" }], 64 + defaultModel: "custom-model", 65 + apiKeyUrl: "", 66 + }, 67 + }; 68 + 69 + export const DEFAULT_SETTINGS: AITaggerSettings = { 70 + provider: AIProvider.Anthropic, 71 + apiKey: "", 72 + modelName: MODEL_CONFIGS[AIProvider.Anthropic].defaultModel, 73 + maxTags: 5, 74 + promptOption: "standard", 75 + promptTemplate: PROMPT_TEMPLATES.standard, 76 + customEndpoint: "", 77 + };
+38
src/models/types.ts
··· 1 + import { TFile } from "obsidian"; 2 + 3 + export enum AIProvider { 4 + Anthropic = "anthropic", 5 + OpenAI = "openai", 6 + Mistral = "mistral", 7 + Google = "google", 8 + Custom = "custom", 9 + } 10 + 11 + export interface AITaggerSettings { 12 + provider: AIProvider; 13 + apiKey: string; 14 + modelName: string; 15 + maxTags: number; 16 + promptOption: string; 17 + promptTemplate: string; 18 + customEndpoint: string; 19 + } 20 + 21 + export interface ModelInfo { 22 + id: string; 23 + name: string; 24 + } 25 + 26 + export interface ProviderConfig { 27 + apiUrl: string; 28 + models: ModelInfo[]; 29 + defaultModel: string; 30 + apiKeyUrl: string; 31 + } 32 + 33 + export interface TaggingResult { 34 + file: TFile; 35 + tags: string[]; 36 + success: boolean; 37 + error?: string; 38 + }
+88
src/services/noteService.ts
··· 1 + import { App, TFile } from "obsidian"; 2 + import { TaggingResult } from "../models/types"; 3 + import { generateTags } from "./tagGenerator"; 4 + import { AITaggerSettings } from "../models/types"; 5 + 6 + export async function updateNoteFrontmatter( 7 + app: App, 8 + file: TFile, 9 + newTags: string[] 10 + ): Promise<void> { 11 + // Use FileManager.processFrontMatter to atomically update the frontmatter 12 + await app.fileManager.processFrontMatter(file, (frontmatter) => { 13 + // Add new tags to existing tags or create new tags field 14 + if (!frontmatter.tags) { 15 + frontmatter.tags = newTags; 16 + } else { 17 + // Handle case where tags is a string 18 + if (typeof frontmatter.tags === "string") { 19 + frontmatter.tags = [frontmatter.tags, ...newTags]; 20 + } 21 + // Handle case where tags is already an array 22 + else if (Array.isArray(frontmatter.tags)) { 23 + frontmatter.tags = [...frontmatter.tags, ...newTags]; 24 + } 25 + 26 + // Remove duplicates 27 + frontmatter.tags = [...new Set(frontmatter.tags)]; 28 + } 29 + }); 30 + } 31 + 32 + export async function tagSingleNote( 33 + app: App, 34 + file: TFile, 35 + settings: AITaggerSettings 36 + ): Promise<TaggingResult> { 37 + try { 38 + const content = await app.vault.read(file); 39 + const tags = await generateTags(content, settings); 40 + await updateNoteFrontmatter(app, file, tags); 41 + 42 + return { 43 + file, 44 + tags, 45 + success: true 46 + }; 47 + } catch (error) { 48 + console.error(`Error tagging note ${file.path}:`, error); 49 + const errorMessage = error instanceof Error ? error.message : String(error); 50 + 51 + return { 52 + file, 53 + tags: [], 54 + success: false, 55 + error: errorMessage 56 + }; 57 + } 58 + } 59 + 60 + export async function tagAllNotes( 61 + app: App, 62 + settings: AITaggerSettings, 63 + progressCallback?: (processed: number, successful: number, total: number, currentFile: string) => void 64 + ): Promise<{ successful: number, total: number }> { 65 + const files = app.vault.getMarkdownFiles(); 66 + let processed = 0; 67 + let successful = 0; 68 + 69 + for (const file of files) { 70 + try { 71 + // Report progress if callback is provided 72 + if (progressCallback) { 73 + progressCallback(processed, successful, files.length, file.path); 74 + } 75 + 76 + const content = await app.vault.read(file); 77 + const tags = await generateTags(content, settings); 78 + await updateNoteFrontmatter(app, file, tags); 79 + successful++; 80 + } catch (error) { 81 + console.error(`Error tagging note ${file.path}:`, error); 82 + } 83 + 84 + processed++; 85 + } 86 + 87 + return { successful, total: files.length }; 88 + }
+63
src/services/notificationService.ts
··· 1 + import { Notice } from "obsidian"; 2 + 3 + interface PersistentNoticeOptions { 4 + duration?: number; 5 + errorDuration?: number; 6 + } 7 + 8 + export class NotificationService { 9 + private static defaultOptions: PersistentNoticeOptions = { 10 + duration: 3000, // 3 seconds for regular notices 11 + errorDuration: 5000, // 5 seconds for error notices 12 + }; 13 + 14 + /** 15 + * Shows a persistent notice that can be updated 16 + */ 17 + static showPersistentNotice( 18 + initialMessage: string, 19 + options: PersistentNoticeOptions = {} 20 + ): { 21 + notice: Notice; 22 + setSuccess: (message: string) => void; 23 + setError: (message: string) => void; 24 + setProgress: (processed: number, successful: number, total: number, currentFile: string) => void; 25 + } { 26 + const opts = { ...this.defaultOptions, ...options }; 27 + const notice = new Notice(initialMessage, 0); // 0 means don't auto-hide 28 + 29 + return { 30 + notice, 31 + 32 + setSuccess: (message: string) => { 33 + notice.setMessage(message); 34 + setTimeout(() => notice.hide(), opts.duration); 35 + }, 36 + 37 + setError: (message: string) => { 38 + notice.setMessage(message); 39 + setTimeout(() => notice.hide(), opts.errorDuration); 40 + }, 41 + 42 + setProgress: (processed: number, successful: number, total: number, currentFile: string) => { 43 + notice.setMessage( 44 + `Processing: ${currentFile}\nProgress: ${processed}/${total} (${successful} successful)` 45 + ); 46 + } 47 + }; 48 + } 49 + 50 + /** 51 + * Shows a simple notice 52 + */ 53 + static showNotice(message: string, duration = 3000): Notice { 54 + return new Notice(message, duration); 55 + } 56 + 57 + /** 58 + * Shows an error notice 59 + */ 60 + static showError(message: string, duration = 5000): Notice { 61 + return new Notice(message, duration); 62 + } 63 + }
+180
src/services/tagGenerator.ts
··· 1 + import { requestUrl } from "obsidian"; 2 + import { MODEL_CONFIGS } from "../models/constants"; 3 + import { AIProvider, AITaggerSettings } from "../models/types"; 4 + 5 + export async function generateTags( 6 + content: string, 7 + settings: AITaggerSettings 8 + ): Promise<string[]> { 9 + validateSettings(settings); 10 + 11 + // Replace placeholders in the prompt template 12 + const prompt = settings.promptTemplate 13 + .replace("{maxTags}", settings.maxTags.toString()) 14 + .replace("{content}", content); 15 + 16 + try { 17 + const tagText = await fetchTagsFromProvider(settings, prompt); 18 + 19 + // Split by commas and trim whitespace 20 + return tagText 21 + .split(",") 22 + .map((tag: string) => tag.trim()) 23 + .filter((tag: string) => tag.length > 0); 24 + } catch (error) { 25 + console.error(`Error calling ${settings.provider} API:`, error); 26 + const errorMessage = error instanceof Error ? error.message : String(error); 27 + throw new Error(`Failed to generate tags: ${errorMessage}`); 28 + } 29 + } 30 + 31 + function validateSettings(settings: AITaggerSettings): void { 32 + if (!settings.apiKey) { 33 + throw new Error( 34 + "API key not configured. Please add your API key in the plugin settings." 35 + ); 36 + } 37 + 38 + if (settings.provider === AIProvider.Custom && !settings.customEndpoint) { 39 + throw new Error( 40 + "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 41 + ); 42 + } 43 + } 44 + 45 + async function fetchTagsFromProvider( 46 + settings: AITaggerSettings, 47 + prompt: string 48 + ): Promise<string> { 49 + const providerConfig = MODEL_CONFIGS[settings.provider]; 50 + 51 + switch (settings.provider) { 52 + case AIProvider.Anthropic: 53 + return await fetchFromAnthropic(settings, prompt, providerConfig.apiUrl); 54 + 55 + case AIProvider.OpenAI: 56 + case AIProvider.Mistral: 57 + case AIProvider.Custom: 58 + const endpoint = 59 + settings.provider === AIProvider.Custom 60 + ? settings.customEndpoint 61 + : providerConfig.apiUrl; 62 + return await fetchFromOpenAICompatible(settings, prompt, endpoint); 63 + 64 + case AIProvider.Google: 65 + return await fetchFromGoogle(settings, prompt, providerConfig.apiUrl); 66 + 67 + default: 68 + throw new Error(`Unknown provider: ${settings.provider}`); 69 + } 70 + } 71 + 72 + async function fetchFromAnthropic( 73 + settings: AITaggerSettings, 74 + prompt: string, 75 + apiUrl: string 76 + ): Promise<string> { 77 + const response = await requestUrl({ 78 + url: apiUrl, 79 + method: "POST", 80 + headers: { 81 + "Content-Type": "application/json", 82 + "x-api-key": settings.apiKey, 83 + "anthropic-version": "2023-06-01", 84 + }, 85 + body: JSON.stringify({ 86 + model: settings.modelName, 87 + max_tokens: 1000, 88 + messages: [ 89 + { 90 + role: "user", 91 + content: prompt, 92 + }, 93 + ], 94 + }), 95 + }); 96 + 97 + if (response.status !== 200) { 98 + throw new Error( 99 + `API returned status code ${response.status}: ${JSON.stringify( 100 + response.json 101 + )}` 102 + ); 103 + } 104 + 105 + return response.json.content[0].text; 106 + } 107 + 108 + async function fetchFromOpenAICompatible( 109 + settings: AITaggerSettings, 110 + prompt: string, 111 + endpoint: string 112 + ): Promise<string> { 113 + const response = await requestUrl({ 114 + url: endpoint, 115 + method: "POST", 116 + headers: { 117 + "Content-Type": "application/json", 118 + Authorization: `Bearer ${settings.apiKey}`, 119 + }, 120 + body: JSON.stringify({ 121 + model: settings.modelName, 122 + messages: [ 123 + { 124 + role: "user", 125 + content: prompt, 126 + }, 127 + ], 128 + max_tokens: 1000, 129 + temperature: 0.3, 130 + }), 131 + }); 132 + 133 + if (response.status !== 200) { 134 + throw new Error( 135 + `API returned status code ${response.status}: ${JSON.stringify( 136 + response.json 137 + )}` 138 + ); 139 + } 140 + 141 + return response.json.choices[0].message.content; 142 + } 143 + 144 + async function fetchFromGoogle( 145 + settings: AITaggerSettings, 146 + prompt: string, 147 + apiUrl: string 148 + ): Promise<string> { 149 + const apiEndpoint = `${apiUrl}${settings.modelName}:generateContent?key=${settings.apiKey}`; 150 + 151 + const response = await requestUrl({ 152 + url: apiEndpoint, 153 + method: "POST", 154 + headers: { 155 + "Content-Type": "application/json", 156 + }, 157 + body: JSON.stringify({ 158 + contents: [ 159 + { 160 + role: "user", 161 + parts: [{ text: prompt }], 162 + }, 163 + ], 164 + generationConfig: { 165 + temperature: 0.2, 166 + maxOutputTokens: 1000, 167 + }, 168 + }), 169 + }); 170 + 171 + if (response.status !== 200) { 172 + throw new Error( 173 + `API returned status code ${response.status}: ${JSON.stringify( 174 + response.json 175 + )}` 176 + ); 177 + } 178 + 179 + return response.json.candidates[0].content.parts[0].text; 180 + }
+217
src/ui/AITaggerSettingTab.ts
··· 1 + import { App, PluginSettingTab, Setting } from "obsidian"; 2 + import { AIProvider } from "../models/types"; 3 + import { MODEL_CONFIGS, PROMPT_TEMPLATES } from "../models/constants"; 4 + import AITaggerPlugin from "../main"; 5 + 6 + export class AITaggerSettingTab extends PluginSettingTab { 7 + plugin: AITaggerPlugin; 8 + 9 + constructor(app: App, plugin: AITaggerPlugin) { 10 + super(app, plugin); 11 + this.plugin = plugin; 12 + } 13 + 14 + display(): void { 15 + const { containerEl } = this; 16 + containerEl.empty(); 17 + 18 + this.addProviderSection(containerEl); 19 + this.addApiSection(containerEl); 20 + this.addTaggingOptionsSection(containerEl); 21 + this.addPromptSection(containerEl); 22 + } 23 + 24 + private addProviderSection(containerEl: HTMLElement): void { 25 + // AI Provider selection 26 + new Setting(containerEl) 27 + .setName("AI Provider") 28 + .setDesc("Select which AI provider to use for generating tags.") 29 + .addDropdown((dropdown) => { 30 + dropdown 31 + .addOption(AIProvider.Anthropic, "Anthropic (Claude)") 32 + .addOption(AIProvider.OpenAI, "OpenAI (GPT)") 33 + .addOption(AIProvider.Mistral, "Mistral AI") 34 + .addOption(AIProvider.Google, "Google (Gemini)") 35 + .addOption(AIProvider.Custom, "Custom Endpoint (OpenAI Compatible)") 36 + .setValue(this.plugin.settings.provider) 37 + .onChange(async (value) => { 38 + const newProvider = value as AIProvider; 39 + this.plugin.settings.provider = newProvider; 40 + 41 + // Update model to default for the selected provider 42 + if (newProvider !== AIProvider.Custom) { 43 + this.plugin.settings.modelName = 44 + MODEL_CONFIGS[newProvider].defaultModel; 45 + } 46 + 47 + await this.plugin.saveSettings(); 48 + this.display(); // Refresh to show provider-specific options 49 + }); 50 + return dropdown; 51 + }); 52 + 53 + // Custom endpoint setting (only shown for custom provider) 54 + if (this.plugin.settings.provider === AIProvider.Custom) { 55 + new Setting(containerEl) 56 + .setName("Custom API Endpoint") 57 + .setDesc( 58 + "Enter the URL for your custom OpenAI-compatible API endpoint." 59 + ) 60 + .addText((text) => 61 + text 62 + .setPlaceholder("https://your-api-endpoint.com/v1/chat/completions") 63 + .setValue(this.plugin.settings.customEndpoint) 64 + .onChange(async (value) => { 65 + this.plugin.settings.customEndpoint = value; 66 + await this.plugin.saveSettings(); 67 + }) 68 + ); 69 + } 70 + } 71 + 72 + private addApiSection(containerEl: HTMLElement): void { 73 + // Get the current provider config 74 + const providerConfig = MODEL_CONFIGS[this.plugin.settings.provider]; 75 + 76 + // API Key with provider-specific description 77 + new Setting(containerEl) 78 + .setName("API key") 79 + .setDesc( 80 + `Your ${ 81 + this.plugin.settings.provider === AIProvider.Custom 82 + ? "" 83 + : this.plugin.settings.provider 84 + } API key. Required to use the AI service. ${ 85 + providerConfig.apiKeyUrl 86 + ? `Get it from ${providerConfig.apiKeyUrl} if you don't have one already.` 87 + : "" 88 + } We recommend using a dedicated key for this plugin.` 89 + ) 90 + .addText((text) => 91 + text 92 + .setPlaceholder("Enter your API key") 93 + .setValue(this.plugin.settings.apiKey) 94 + .onChange(async (value) => { 95 + this.plugin.settings.apiKey = value; 96 + await this.plugin.saveSettings(); 97 + }) 98 + ); 99 + 100 + // AI model selection (provider-specific) 101 + new Setting(containerEl) 102 + .setName("AI model") 103 + .setDesc("Choose which AI model to use for tag generation.") 104 + .addDropdown((dropdown) => { 105 + // Add models for the selected provider 106 + providerConfig.models.forEach((model) => { 107 + dropdown.addOption(model.id, model.name); 108 + }); 109 + 110 + // If current model isn't in the list, add it 111 + if ( 112 + !providerConfig.models.some( 113 + (m) => m.id === this.plugin.settings.modelName 114 + ) 115 + ) { 116 + dropdown.addOption( 117 + this.plugin.settings.modelName, 118 + this.plugin.settings.modelName + " (Custom)" 119 + ); 120 + } 121 + 122 + dropdown 123 + .setValue(this.plugin.settings.modelName) 124 + .onChange(async (value) => { 125 + this.plugin.settings.modelName = value; 126 + await this.plugin.saveSettings(); 127 + }); 128 + 129 + return dropdown; 130 + }); 131 + } 132 + 133 + private addTaggingOptionsSection(containerEl: HTMLElement): void { 134 + new Setting(containerEl) 135 + .setName("Maximum number of tags") 136 + .setDesc("Set the maximum number of tags to generate per note.") 137 + .addSlider((slider) => 138 + slider 139 + .setLimits(1, 20, 1) 140 + .setValue(this.plugin.settings.maxTags) 141 + .setDynamicTooltip() 142 + .onChange(async (value) => { 143 + this.plugin.settings.maxTags = value; 144 + await this.plugin.saveSettings(); 145 + }) 146 + ); 147 + } 148 + 149 + private addPromptSection(containerEl: HTMLElement): void { 150 + // Prompt option dropdown 151 + new Setting(containerEl) 152 + .setName("Prompt style") 153 + .setDesc( 154 + "Choose a predefined prompt style or create your own custom prompt." 155 + ) 156 + .addDropdown((dropdown) => { 157 + dropdown 158 + .addOption("standard", "Standard") 159 + .addOption("descriptive", "Descriptive") 160 + .addOption("academic", "Academic") 161 + .addOption("concise", "Concise") 162 + .addOption("custom", "Custom") 163 + .setValue(this.plugin.settings.promptOption) 164 + .onChange(async (value) => { 165 + this.plugin.settings.promptOption = value; 166 + 167 + // Update prompt template if not using custom 168 + if (value !== "custom") { 169 + this.plugin.settings.promptTemplate = 170 + PROMPT_TEMPLATES[value as keyof typeof PROMPT_TEMPLATES]; 171 + 172 + // Force refresh to update the textarea with the new template 173 + this.display(); 174 + } else if (this.plugin.settings.promptTemplate === "") { 175 + // If switching to custom and no custom template yet, initialize with standard 176 + this.plugin.settings.promptTemplate = PROMPT_TEMPLATES.standard; 177 + this.display(); 178 + } 179 + 180 + await this.plugin.saveSettings(); 181 + }); 182 + return dropdown; 183 + }); 184 + 185 + // Only show prompt template textarea if custom option is selected 186 + if (this.plugin.settings.promptOption === "custom") { 187 + new Setting(containerEl) 188 + .setName("Custom prompt template") 189 + .setDesc( 190 + "Customize the prompt sent to the AI. Use {maxTags} and {content} as placeholders." 191 + ) 192 + .addTextArea((textarea) => 193 + textarea 194 + .setValue(this.plugin.settings.promptTemplate) 195 + .onChange(async (value) => { 196 + this.plugin.settings.promptTemplate = value; 197 + await this.plugin.saveSettings(); 198 + }) 199 + ) 200 + .setClass("ai-tagger-wide-setting"); 201 + } else { 202 + // Show the current template as read-only if not using custom 203 + new Setting(containerEl) 204 + .setName("Current prompt template") 205 + .setDesc( 206 + "This is the prompt template that will be used (read-only). Switch to Custom if you want to edit it." 207 + ) 208 + .addTextArea((textarea) => { 209 + textarea 210 + .setValue(this.plugin.settings.promptTemplate) 211 + .setDisabled(true); 212 + return textarea; 213 + }) 214 + .setClass("ai-tagger-wide-setting"); 215 + } 216 + } 217 + }
+83
src/ui/ConfirmModal.ts
··· 1 + import { App, Modal } from "obsidian"; 2 + 3 + export class ConfirmModal extends Modal { 4 + private onConfirmCallback: () => void; 5 + private message: string; 6 + private hasError: boolean; 7 + private errorMessage: string; 8 + 9 + constructor( 10 + app: App, 11 + message: string, 12 + onConfirmCallback: () => void, 13 + hasError = false, 14 + errorMessage = "" 15 + ) { 16 + super(app); 17 + this.message = message; 18 + this.onConfirmCallback = onConfirmCallback; 19 + this.hasError = hasError; 20 + this.errorMessage = errorMessage; 21 + } 22 + 23 + onOpen() { 24 + const { contentEl } = this; 25 + 26 + contentEl.createEl("p", { text: this.message }); 27 + 28 + if (this.hasError) { 29 + const errorEl = contentEl.createEl("p", { text: this.errorMessage }); 30 + errorEl.addClass("ai-tagger-error-message"); 31 + 32 + const buttonContainer = contentEl.createDiv(); 33 + buttonContainer.addClass("ai-tagger-modal-buttons"); 34 + 35 + const settingsButton = buttonContainer.createEl("button", { 36 + text: "Open settings", 37 + }); 38 + settingsButton.addEventListener("click", () => { 39 + this.close(); 40 + // Open settings tab 41 + // Using type assertion with a more specific interface would be better 42 + // if we had access to the internal Obsidian API types 43 + if ("setting" in this.app) { 44 + const appWithSetting = this.app as unknown as { 45 + setting: { open: () => void; openTabById: (id: string) => void }; 46 + }; 47 + appWithSetting.setting.open(); 48 + appWithSetting.setting.openTabById("ai-tagger"); 49 + } 50 + }); 51 + 52 + const cancelButton = buttonContainer.createEl("button", { 53 + text: "Cancel", 54 + }); 55 + cancelButton.addEventListener("click", () => { 56 + this.close(); 57 + }); 58 + } else { 59 + const buttonContainer = contentEl.createDiv(); 60 + buttonContainer.addClass("ai-tagger-modal-buttons"); 61 + 62 + const confirmButton = buttonContainer.createEl("button", { 63 + text: "Confirm", 64 + }); 65 + confirmButton.addEventListener("click", () => { 66 + this.onConfirmCallback(); 67 + this.close(); 68 + }); 69 + 70 + const cancelButton = buttonContainer.createEl("button", { 71 + text: "Cancel", 72 + }); 73 + cancelButton.addEventListener("click", () => { 74 + this.close(); 75 + }); 76 + } 77 + } 78 + 79 + onClose() { 80 + const { contentEl } = this; 81 + contentEl.empty(); 82 + } 83 + }
+40
src/utils/validationUtils.ts
··· 1 + import { AIProvider, AITaggerSettings } from "../models/types"; 2 + 3 + export function validateApiSettings(settings: AITaggerSettings): { valid: boolean; error?: string } { 4 + if (!settings.apiKey) { 5 + return { 6 + valid: false, 7 + error: "API key not configured. Please add your API key in the plugin settings." 8 + }; 9 + } 10 + 11 + if (settings.provider === AIProvider.Custom && !settings.customEndpoint) { 12 + return { 13 + valid: false, 14 + error: "Custom API endpoint not configured. Please add your endpoint URL in the plugin settings." 15 + }; 16 + } 17 + 18 + return { valid: true }; 19 + } 20 + 21 + export function getModelName(settings: AITaggerSettings): string { 22 + const provider = settings.provider; 23 + const modelId = settings.modelName; 24 + 25 + const model = provider === AIProvider.Custom 26 + ? { name: modelId } 27 + : findModelById(provider, modelId); 28 + 29 + return model?.name || modelId; 30 + } 31 + 32 + function findModelById(provider: AIProvider, modelId: string) { 33 + const models = require("../models/constants").MODEL_CONFIGS[provider].models; 34 + return models.find((m: { id: string; name: string }) => m.id === modelId); 35 + } 36 + 37 + export function formatTagList(tags: string[]): string { 38 + if (tags.length === 0) return "No tags generated"; 39 + return tags.join(", "); 40 + }