From d910239a7f07f00d6dd4278a61a989688fa4a6b4 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Wed, 8 Oct 2025 22:06:25 +0100 Subject: [PATCH] Add wiki-link support, API key validation, and refactor AI provider initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement clickable wiki-links in assistant responses with WorkSpaceService for note navigation. Add API key validation with visual feedback in ChatWindow, automatically opening settings when empty. Extract settings helper to reduce code duplication. Refactor Gemini class to resolve API key from plugin settings rather than constructor parameter. Update system prompt with wiki-link usage guidelines. Remove unused ODB cache implementation and loadExternalCSS helper. Improve UI with enhanced empty state styling and input textarea scrolling behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- AIAgentSettingTab.ts | 79 ++++++++ AIClasses/Gemini/Gemini.ts | 17 +- AIClasses/SystemPrompt.ts | 12 ++ Components/ChatArea.svelte | 1 + Components/ChatWindow.svelte | 65 ++++++- Components/TopBar.svelte | 6 +- Enums/Selector.ts | 3 + Helpers/Helpers.ts | 21 +-- ODB/Core/DynamicRecord.ts | 157 ---------------- ODB/Core/OdbCache.ts | 131 -------------- Services/FileSystemService.ts | 7 + Services/ServiceRegistration.ts | 8 +- Services/Services.ts | 2 +- Services/StreamingMarkdownService.ts | 13 ++ Services/StreamingService.ts | 136 +++++++------- Services/WorkSpaceService.ts | 17 ++ main.ts | 115 +----------- package-lock.json | 259 ++++++++++++++++++++++++--- package.json | 1 + styles.css | 26 +++ 20 files changed, 547 insertions(+), 529 deletions(-) create mode 100644 AIAgentSettingTab.ts create mode 100644 Enums/Selector.ts delete mode 100644 ODB/Core/DynamicRecord.ts delete mode 100644 ODB/Core/OdbCache.ts create mode 100644 Services/WorkSpaceService.ts diff --git a/AIAgentSettingTab.ts b/AIAgentSettingTab.ts new file mode 100644 index 0000000..e22d37c --- /dev/null +++ b/AIAgentSettingTab.ts @@ -0,0 +1,79 @@ +import { AIProvider } from "Enums/ApiProvider"; +import type AIAgentPlugin from "main"; +import { PluginSettingTab, Setting, App, setIcon, setTooltip } from "obsidian"; + +export class AIAgentSettingTab extends PluginSettingTab { + plugin: AIAgentPlugin; + apiKeySetting: Setting | null = null; + + constructor(app: App, plugin: AIAgentPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + + containerEl.empty(); + + new Setting(containerEl) + .setName("API Provider") + .setDesc("Select the API provider to use.") + .addDropdown((dropdown) => + dropdown + .addOption(AIProvider.Gemini, AIProvider.Gemini) + .addOption(AIProvider.OpenAI, AIProvider.OpenAI) + .setValue(this.plugin.settings.apiProvider) + .onChange(async (value) => { + this.plugin.settings.apiProvider = value; + await this.plugin.saveSettings(); + }) + ); + + let inputEl: HTMLInputElement; + this.apiKeySetting = new Setting(containerEl) + .setName("API Key") + .setDesc("Enter your API key here.") + .addText(text => { + text.setPlaceholder("Enter your API key") + .setValue(this.plugin.settings.apiKey) + .onChange(async (value) => { + this.plugin.settings.apiKey = value; + await this.plugin.saveSettings(); + this.highlightApiKey(); + }); + text.inputEl.type = "password"; + inputEl = text.inputEl; + }) + .addExtraButton(button => { + button + .setTooltip("Show API Key") + .onClick(() => { + if (inputEl.type === "password") { + inputEl.type = "text"; + setIcon(button.extraSettingsEl, "eye-off"); + setTooltip(button.extraSettingsEl, "Hide API Key"); + } else { + inputEl.type = "password"; + setIcon(button.extraSettingsEl, "eye"); + setTooltip(button.extraSettingsEl, "Show API Key"); + } + }); + setIcon(button.extraSettingsEl, "eye"); + }); + + this.highlightApiKey(); + } + + highlightApiKey(): void { + if (this.apiKeySetting) { + if (this.plugin.settings.apiKey.trim() === "") { + this.apiKeySetting.settingEl.removeClass("api-key-setting-ok"); + this.apiKeySetting.settingEl.addClass("api-key-setting-error"); + } else { + this.apiKeySetting.settingEl.removeClass("api-key-setting-error"); + this.apiKeySetting.settingEl.addClass("api-key-setting-ok"); + } + } + } +} \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index c6471c8..4e1f87b 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -7,24 +7,23 @@ import type { Conversation } from "Conversations/Conversation"; import { Role } from "Enums/Role"; import { AIProviderURL } from "Enums/ApiProvider"; import { AIFunctionCall } from "AIClasses/AIFunctionCall"; -import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; +import type AIAgentPlugin from "main"; +import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; export class Gemini implements IAIClass { private readonly REQUEST_WEB_SEARCH: string = "request_web_search"; private readonly apiKey: string; - private readonly aiPrompt: IPrompt; - private readonly streamingService: StreamingService; - private readonly aiFunctionDefinitions: AIFunctionDefinitions; + private readonly aiPrompt: IPrompt = Resolve(Services.IPrompt); + private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin); + private readonly streamingService: StreamingService = Resolve(Services.StreamingService); + private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); private accumulatedFunctionName: string | null = null; private accumulatedFunctionArgs: Record = {}; - public constructor(apiKey: string) { - this.apiKey = apiKey; - this.aiPrompt = Resolve(Services.IPrompt); - this.streamingService = Resolve(Services.StreamingService); - this.aiFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); + public constructor() { + this.apiKey = this.plugin.settings.apiKey; } public async* streamRequest(conversation: Conversation): AsyncGenerator { diff --git a/AIClasses/SystemPrompt.ts b/AIClasses/SystemPrompt.ts index 123d647..b797486 100644 --- a/AIClasses/SystemPrompt.ts +++ b/AIClasses/SystemPrompt.ts @@ -14,6 +14,18 @@ When users need help with their Obsidian vault, you have access to tools that al - Assist with Markdown formatting specific to Obsidian - Help with plugins and vault configuration +When referencing notes from the user's vault, ALWAYS use Obsidian wiki-link syntax: [[note name]]. + +Examples: +- "I found relevant information in [[project ideas]]" +- "This relates to what you mentioned in [[meeting notes 2024-10-07]]" +- "See [[research paper]] for more details" + +Guidelines: +- Use just the note name without file extension +- If uncertain about exact name, use your best guess - the system will try to match it +- You can reference notes in subfolders as [[folder/note name]] + ### General Assistance You are also capable of helping with: - General questions and conversations diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 7fd121e..0ddd2cc 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -249,6 +249,7 @@ .conversation-empty-state { margin: auto; font-style: italic; + font-size: var(--font-ui-medium); color: var(--text-muted); pointer-events: none; } diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 771b975..2fbbf87 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -4,9 +4,9 @@ import { Services } from "Services/Services"; import ChatArea from "./ChatArea.svelte"; import type { IAIClass } from "AIClasses/IAIClass"; - import { tick } from "svelte"; + import { tick, onMount } from "svelte"; import { ConversationFileSystemService } from "Services/ConversationFileSystemService"; - import { setIcon } from "obsidian"; + import { Notice, setIcon } from "obsidian"; import { conversationStore } from "../Stores/conversationStore"; import { Role } from "Enums/Role"; import { Conversation } from "Conversations/Conversation"; @@ -14,10 +14,17 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { AIFunctionService } from "Services/AIFunctionService"; import type { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; + import type AIAgentPlugin from "main"; + import { openPluginSettings } from "Helpers/Helpers"; + import { Selector } from "Enums/Selector"; + import type { WorkSpaceService } from "Services/WorkSpaceService"; + + let plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin); let ai: IAIClass = Resolve(Services.IAIClass); let conversationService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService); let aiFunctionService: AIFunctionService = Resolve(Services.AIFunctionService); + let workSpaceService: WorkSpaceService = Resolve(Services.WorkSpaceService); let semaphore: Semaphore = new Semaphore(1, false); let textareaElement: HTMLTextAreaElement; @@ -25,6 +32,7 @@ let submitButton: HTMLButtonElement; let userRequest = ""; + let hasNoApiKey = false; let isSubmitting = false; let isStreaming = false; @@ -32,12 +40,51 @@ let currentThought: string | null = null; + onMount(() => { + if (chatContainer) { + plugin.registerDomEvent(chatContainer, 'click', handleLinkClick); + } + }); + + async function handleLinkClick(evt: MouseEvent) { + const target = evt.target as HTMLElement; + + const link = target.closest(`.${Selector.MarkDownLink}`) as HTMLAnchorElement | null; + if (!link) { + return; + } + + const href = link.getAttribute('href'); + if (!href || !href.startsWith('#/page/')) { + return; + } + + evt.preventDefault(); + evt.stopPropagation(); + + const encodedPath = href.replace('#/page/', ''); + const notePath = decodeURIComponent(encodedPath); + await workSpaceService.openNote(notePath); + } + + function handleNoApiKey(): boolean { + hasNoApiKey = plugin.settings.apiKey.trim() == ""; + if (hasNoApiKey) { + openPluginSettings(plugin); + } + return hasNoApiKey; + } + async function handleSubmit() { if (!await semaphore.wait()) { return; } try { + if (handleNoApiKey()) { + return; + } + if (userRequest.trim() === "" || isSubmitting) { return; } @@ -103,6 +150,7 @@ } if (chunk.content) { + currentThought = null; accumulatedContent += chunk.content; conversation.contents = conversation.contents.map((msg, messageIndex) => messageIndex === aiMessageIndex @@ -193,6 +241,7 @@