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 @@