/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ "use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/main.ts var main_exports = {}; __export(main_exports, { default: () => NoteBuddyPlugin }); module.exports = __toCommonJS(main_exports); var import_obsidian4 = require("obsidian"); // src/chat-view.ts var import_obsidian2 = require("obsidian"); // src/service.ts var import_obsidian = require("obsidian"); // src/models.ts var defaultSettings = { serviceUrl: "http://127.0.0.1:4096" }; var MODELS_CACHE_TTL = 5 * 60 * 1e3; // src/service.ts var OpenCodeClient = class _OpenCodeClient { constructor(serviceUrl, defaultModelId, onModelUnavailable) { this.session = null; this.cachedProviders = null; this.providersCacheTime = 0; this.cachedDefaults = null; this.serviceUrl = serviceUrl; this.defaultModelId = defaultModelId; this.onModelUnavailable = onModelUnavailable; } static { this.DEFAULT_REQUEST_TIMEOUT_MS = 1e4; } static { this.MESSAGE_REQUEST_TIMEOUT_MS = 6e4; } async request(options, timeoutMs = _OpenCodeClient.DEFAULT_REQUEST_TIMEOUT_MS) { return await Promise.race([ (0, import_obsidian.requestUrl)(options), new Promise( (_, reject) => setTimeout(() => reject(new Error(`Request timeout after ${timeoutMs / 1e3} seconds`)), timeoutMs) ) ]); } async healthCheck() { const url = `${this.serviceUrl}/global/health`; try { const response = await this.request({ url, method: "GET" }); if (response.status === 200 && response.json.healthy === true) { return { status: "connected" /* Connected */, lastTestTime: Date.now() }; } else { return { status: "disconnected" /* Disconnected */, lastTestTime: Date.now(), lastError: `Service unhealthy or unreachable` }; } } catch (error) { const err = error; return { status: "disconnected" /* Disconnected */, lastTestTime: Date.now(), lastError: err.message || "Unknown error" }; } } async createSession() { const url = `${this.serviceUrl}/session`; try { const response = await this.request({ url, method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); if (response.status === 200) { const data = response.json; return { sessionID: data.id, createTime: data.time.created, title: data.title }; } if (response.status === 400) { const details = this.formatBadRequest(response.json, response.text); throw new Error(details); } throw new Error(`HTTP ${response.status}: ${response.text}`); } catch (error) { const err = error; throw new Error(`Failed to create session: ${err.message}`); } } formatBadRequest(payload, fallbackText) { if (!payload || typeof payload !== "object") { return `Bad request: ${fallbackText || "Unknown error"}`; } const p = payload; const errText = typeof p.errors === "string" ? p.errors : p.errors ? JSON.stringify(p.errors) : ""; const dataText = p.data ? JSON.stringify(p.data) : ""; const msg = [errText, dataText].filter(Boolean).join(" "); return msg ? `Bad request: ${msg}` : `Bad request: ${fallbackText || "Unknown error"}`; } formatNotFound(payload, fallbackText) { if (!payload || typeof payload !== "object") { return fallbackText || "Not found"; } const p = payload; const name = typeof p.name === "string" ? p.name : ""; const data = p.data ? JSON.stringify(p.data) : ""; return [name, data].filter(Boolean).join(" ") || fallbackText || "Not found"; } async sendMessageInternal(sessionID, message) { const url = `${this.serviceUrl}/session/${sessionID}/message`; try { const response = await this.request( { url, method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(message) }, _OpenCodeClient.MESSAGE_REQUEST_TIMEOUT_MS ); if (response.status === 200) { const raw = (response.text ?? "").trim(); if (!raw) { throw new Error("Server returned empty response"); } let data; try { data = JSON.parse(raw); } catch { throw new Error(`Invalid JSON response: ${raw.slice(0, 50)}${raw.length > 50 ? "..." : ""}`); } if (!data || typeof data !== "object" || !Array.isArray(data.parts)) { throw new Error("Invalid response shape (expected {info, parts[]})"); } return data; } if (response.status === 400) { const payload = response.json; throw new Error(this.formatBadRequest(payload, response.text)); } if (response.status === 404) { const payload = response.json; throw new Error(`Session not found: ${this.formatNotFound(payload, response.text)}`); } throw new Error(`HTTP ${response.status}: ${response.text}`); } catch (error) { const err = error; throw new Error(`Failed to send message: ${err.message}`); } } async sendMessage(input) { if (!this.session) { this.session = await this.createSession(); } const message = { parts: [{ type: "text", text: input }] }; if (this.defaultModelId) { const parts = this.defaultModelId.split("/"); if (parts.length === 2) { message.model = { providerID: parts[0], modelID: parts[1] }; } } let response; try { response = await this.sendMessageInternal(this.session.sessionID, message); } catch (error) { const err = error; if (message.model && this.isLikelyModelError(err)) { console.log("[NoteBuddy] Selected model unavailable, falling back to server default"); if (this.onModelUnavailable) { this.onModelUnavailable(); } const fallbackMessage = { parts: message.parts }; response = await this.sendMessageInternal(this.session.sessionID, fallbackMessage); } else { throw error; } } const assistantText = response.parts.filter((part) => part.type === "text").map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n"); return assistantText; } async sendMessageToSession(sessionID, message) { return await this.sendMessageInternal(sessionID, message); } isModelsCacheValid() { if (!this.cachedProviders) { return false; } const now = Date.now(); return now - this.providersCacheTime < MODELS_CACHE_TTL; } clearModelsCache() { this.cachedProviders = null; this.providersCacheTime = 0; this.cachedDefaults = null; } /** * Fetches available AI providers and models from the OpenCode server. * * Results are cached for 5 minutes to reduce API calls. The cache can be bypassed * by setting forceRefresh to true or by calling clearModelsCache() first. * * @param forceRefresh - If true, bypasses cache and fetches fresh data from server * @returns Array of providers with their available models * @throws Error if the server is unreachable, returns invalid JSON, or responds with non-200 status */ async getCapabilities(forceRefresh = false) { const now = Date.now(); if (!forceRefresh && this.cachedProviders && now - this.providersCacheTime < MODELS_CACHE_TTL) { return this.cachedProviders; } const url = `${this.serviceUrl}/config/providers`; try { const response = await this.request({ url, method: "GET" }); if (response.status === 200) { let data; try { data = response.json; } catch { const preview = (response.text || "").trim().slice(0, 50); if (preview.toLowerCase().startsWith(" ({ id: p.id, name: p.name, models: Object.entries(p.models || {}).map(([id, m]) => ({ id, name: m?.name ?? id })) })); this.cachedProviders = providers; this.providersCacheTime = now; return providers; } else { throw new Error(`HTTP ${response.status}: ${response.text}`); } } catch (error) { const err = error; throw new Error(`Failed to get capabilities: ${err.message}`); } } isLikelyModelError(err) { const msg = (err.message || "").toLowerCase(); return msg.includes("model") && (msg.includes("not found") || msg.includes("invalid") || msg.includes("unknown")); } }; // src/chat-view.ts var VIEW_TYPE_CHAT = "note-buddy-chat-view"; var ChatView = class extends import_obsidian2.ItemView { constructor(leaf, plugin) { super(leaf); this.plugin = plugin; this.messages = []; this.isUserNearBottom = true; this.hasInitialScroll = false; this.previousScrollTop = 0; this.previousWasNearBottom = true; this.isInitialized = false; this.handleMessagesScroll = () => { const threshold = 32; const distanceToBottom = this.messagesContainer.scrollHeight - this.messagesContainer.scrollTop - this.messagesContainer.clientHeight; this.isUserNearBottom = distanceToBottom <= threshold; this.previousScrollTop = this.messagesContainer.scrollTop; this.previousWasNearBottom = this.isUserNearBottom; if (this.isUserNearBottom) { this.hideNewMessageIndicator(); } }; this.client = new OpenCodeClient( plugin.settings.serviceUrl, plugin.settings.defaultModelId, plugin.onModelUnavailable?.bind(plugin) ); } /** * Show loading indicator */ showLoading() { if (this.loadingIndicator) return; this.loadingIndicator = this.messagesContainer.createDiv({ cls: "nb-loading-indicator" }); const dotsContainer = this.loadingIndicator.createDiv({ cls: "nb-loading-dots" }); dotsContainer.createDiv({ cls: "nb-loading-dot" }); dotsContainer.createDiv({ cls: "nb-loading-dot" }); dotsContainer.createDiv({ cls: "nb-loading-dot" }); this.maybeAutoScroll(); } /** * Hide loading indicator */ hideLoading() { if (this.loadingIndicator) { this.loadingIndicator.remove(); this.loadingIndicator = void 0; } } /** * Scroll to bottom of messages */ scrollToBottom() { this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; this.hideNewMessageIndicator(); } showNewMessageIndicator() { if (!this.newMessageIndicator) return; this.newMessageIndicator.style.display = "flex"; } hideNewMessageIndicator() { if (!this.newMessageIndicator) return; this.newMessageIndicator.style.display = "none"; } maybeAutoScroll() { if (!this.messagesContainer) return; if (!this.hasInitialScroll) { this.scrollToBottom(); this.hasInitialScroll = true; return; } if (this.isUserNearBottom) { this.scrollToBottom(); } else { this.showNewMessageIndicator(); } } /** * Render error message with retry button */ renderErrorMessage(errorText, retryMessage) { const errorContainer = this.messagesContainer.createDiv({ cls: "nb-error-container" }); errorContainer.createDiv({ cls: "nb-error-message", text: errorText }); if (retryMessage) { const retryButton = errorContainer.createEl("button", { cls: "nb-retry-button", text: "Retry" }); retryButton.onclick = () => { const errorIndex = this.messages.findIndex((m) => m.kind === "error" && m.text === errorText); if (errorIndex !== -1) { this.messages.splice(errorIndex, 1); } this.retrySendMessage(retryMessage); }; } } /** * Send message with timeout */ async sendWithTimeout(promise, timeoutMs) { return Promise.race([ promise, new Promise( (_, reject) => setTimeout(() => reject(new Error("Request timed out after 30 seconds")), timeoutMs) ) ]); } /** * Retry sending a message */ async retrySendMessage(message) { this.renderMessages(); this.showLoading(); try { if (!this.plugin.sessionState) { this.plugin.sessionState = await this.sendWithTimeout( this.client.createSession(), 3e4 ); } const sendData = { parts: [{ type: "text", text: message }] }; if (this.plugin.settings.defaultModelId) { const parts = this.plugin.settings.defaultModelId.split("/"); if (parts.length === 2) { sendData.model = { providerID: parts[0], modelID: parts[1] }; } } let response; try { response = await this.sendWithTimeout( this.client.sendMessageToSession(this.plugin.sessionState.sessionID, sendData), 3e4 ); } catch (sendError) { const err = sendError; if (err.message.includes("Session not found")) { this.plugin.sessionState = await this.sendWithTimeout( this.client.createSession(), 3e4 ); response = await this.sendWithTimeout( this.client.sendMessageToSession(this.plugin.sessionState.sessionID, sendData), 3e4 ); } else { throw sendError; } } this.hideLoading(); for (const part of response.parts) { this.messages.push({ kind: "assistantPart", part }); } this.renderMessages(); await this.saveChatState(); } catch (error) { this.hideLoading(); this.messages.push({ kind: "error", text: `Failed to send message: ${error.message}`, retryMessage: message }); this.renderMessages(); await this.saveChatState(); } } /** * Clear chat history with confirmation */ async clearChat() { const confirmed = confirm("Are you sure you want to clear the chat history? This cannot be undone."); if (!confirmed) return; this.messages = []; this.plugin.sessionState = void 0; await this.saveChatState(); this.renderMessages(); try { this.plugin.sessionState = await this.sendWithTimeout( this.client.createSession(), 3e4 ); await this.saveChatState(); } catch (error) { console.error("[NoteBuddy] Failed to create new session after clearing chat:", error); } } /** * Render markdown text to HTML elements * Supports: code blocks, inline code, bold, italic, lists */ renderMarkdown(text, container) { const lines = text.split("\n"); let inCodeBlock = false; let codeBlockContent = []; let listItems = []; let inList = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith("```")) { if (inCodeBlock) { const pre = container.createEl("pre"); const code = pre.createEl("code"); code.textContent = codeBlockContent.join("\n"); codeBlockContent = []; inCodeBlock = false; } else { inCodeBlock = true; } continue; } if (inCodeBlock) { codeBlockContent.push(line); continue; } const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s+(.+)$/); if (listMatch) { if (!inList) { inList = true; listItems = []; } listItems.push(listMatch[3]); continue; } else if (inList) { const ul = container.createEl("ul"); listItems.forEach((item) => { const li = ul.createEl("li"); this.renderInlineMarkdown(item, li); }); listItems = []; inList = false; } if (line.trim()) { const p = container.createEl("span"); this.renderInlineMarkdown(line, p); if (i < lines.length - 1) { container.createEl("br"); } } } if (inList) { const ul = container.createEl("ul"); listItems.forEach((item) => { const li = ul.createEl("li"); this.renderInlineMarkdown(item, li); }); } } /** * Render inline markdown (bold, italic, inline code) */ renderInlineMarkdown(text, container) { const codeRegex = /`([^`]+)`/g; let lastIndex = 0; let match; const parts = []; while ((match = codeRegex.exec(text)) !== null) { if (match.index > lastIndex) { parts.push({ type: "text", content: text.substring(lastIndex, match.index) }); } parts.push({ type: "code", content: match[1] }); lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { parts.push({ type: "text", content: text.substring(lastIndex) }); } parts.forEach((part) => { if (part.type === "code") { container.createEl("code", { text: part.content }); } else { this.renderTextWithFormatting(part.content, container); } }); } /** * Render a message bubble with appropriate styling */ renderMessageBubble(kind, content, container) { const messageEl = container.createDiv({ cls: `nb-message nb-message-${kind}` }); this.renderMarkdown(content, messageEl); return messageEl; } /** * Render text with bold and italic formatting */ renderTextWithFormatting(text, container) { const boldRegex = /\*\*([^*]+)\*\*/g; const italicRegex = /\*([^*]+)\*/g; let result = text; const elements = []; let match; while ((match = boldRegex.exec(text)) !== null) { elements.push({ start: match.index, end: match.index + match[0].length, type: "bold" }); } while ((match = italicRegex.exec(text)) !== null) { const currentMatch = match; const isBold = elements.some((e) => e.start <= currentMatch.index && currentMatch.index < e.end); if (!isBold) { elements.push({ start: currentMatch.index, end: currentMatch.index + currentMatch[0].length, type: "italic" }); } } elements.sort((a, b) => a.start - b.start); let lastIndex = 0; elements.forEach((el) => { if (el.start > lastIndex) { container.appendText(text.substring(lastIndex, el.start)); } const content = text.substring(el.start, el.end); if (el.type === "bold") { const cleaned = content.replace(/\*\*/g, ""); container.createEl("strong", { text: cleaned }); } else { const cleaned = content.replace(/\*/g, ""); container.createEl("em", { text: cleaned }); } lastIndex = el.end; }); if (lastIndex < text.length) { container.appendText(text.substring(lastIndex)); } } /** * Save chat state to plugin data */ async saveChatState() { try { const chatState = { messages: this.messages, sessionState: this.plugin.sessionState }; await this.plugin.saveData({ ...this.plugin.settings, chatState }); } catch (error) { console.error("[NoteBuddy] Failed to save chat state:", error); } } /** * Load chat state from plugin data */ async loadChatState() { try { const data = await this.plugin.loadData(); if (data?.chatState) { this.messages = data.chatState.messages || []; if (data.chatState.sessionState) { this.plugin.sessionState = data.chatState.sessionState; } } } catch (error) { console.error("[NoteBuddy] Failed to load chat state:", error); } } getViewType() { return VIEW_TYPE_CHAT; } getDisplayText() { return "NoteBuddy Chat"; } async onOpen() { if (!this.isInitialized) { await this.loadChatState(); this.isInitialized = true; } const container = this.containerEl.children[1]; container.empty(); container.addClass("note-buddy-chat-view"); const chatContainer = container.createDiv({ cls: "nb-chat-container" }); const toolbar = chatContainer.createDiv({ cls: "nb-toolbar" }); toolbar.createDiv({ cls: "nb-toolbar-title", text: "NoteBuddy Chat" }); const toolbarActions = toolbar.createDiv({ cls: "nb-toolbar-actions" }); const clearButton = toolbarActions.createEl("button", { cls: "nb-toolbar-button", attr: { "aria-label": "Clear chat", title: "Clear chat" } }); (0, import_obsidian2.setIcon)(clearButton, "trash-2"); clearButton.onclick = () => this.clearChat(); const messagesContainer = chatContainer.createDiv({ cls: "nb-messages-container" }); this.messagesContainer = messagesContainer; this.messagesContainer.addEventListener("scroll", this.handleMessagesScroll); this.previousScrollTop = 0; this.previousWasNearBottom = true; const newMessageIndicator = chatContainer.createDiv({ cls: "nb-new-message-indicator" }); const indicatorButton = newMessageIndicator.createEl("button", { cls: "nb-new-message-button", text: "\u65B0\u6D88\u606F", attr: { "aria-label": "New messages" } }); indicatorButton.onclick = () => { this.scrollToBottom(); }; this.newMessageIndicator = newMessageIndicator; this.hideNewMessageIndicator(); this.renderMessages(); const inputContainer = chatContainer.createDiv({ cls: "nb-input-container" }); const textarea = inputContainer.createEl("textarea", { cls: "nb-chat-input", attr: { placeholder: "Type your message..." } }); const sendButton = inputContainer.createEl("button", { cls: "nb-send-button", attr: { "aria-label": "Send message", title: "Send message" } }); (0, import_obsidian2.setIcon)(sendButton, "send"); const sendMessage = async () => { const message = textarea.value.trim(); if (!message) return; this.messages.push({ kind: "userText", text: message }); this.renderMessages(); textarea.value = ""; this.showLoading(); try { if (!this.plugin.sessionState) { this.plugin.sessionState = await this.sendWithTimeout( this.client.createSession(), 3e4 ); } const sendData = { parts: [{ type: "text", text: message }] }; if (this.plugin.settings.defaultModelId) { const parts = this.plugin.settings.defaultModelId.split("/"); if (parts.length === 2) { sendData.model = { providerID: parts[0], modelID: parts[1] }; } } let response; try { response = await this.sendWithTimeout( this.client.sendMessageToSession(this.plugin.sessionState.sessionID, sendData), 3e4 ); } catch (sendError) { const err = sendError; if (err.message.includes("Session not found")) { this.plugin.sessionState = await this.sendWithTimeout( this.client.createSession(), 3e4 ); response = await this.sendWithTimeout( this.client.sendMessageToSession(this.plugin.sessionState.sessionID, sendData), 3e4 ); } else { throw sendError; } } this.hideLoading(); for (const part of response.parts) { this.messages.push({ kind: "assistantPart", part }); } this.renderMessages(); await this.saveChatState(); } catch (error) { this.hideLoading(); console.error("[NoteBuddy] Send failed:", error); this.messages.push({ kind: "error", text: `Failed to send message: ${error.message}`, retryMessage: message }); this.renderMessages(); await this.saveChatState(); } }; sendButton.onclick = sendMessage; textarea.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); } async onClose() { console.log("[NoteBuddy] Chat view closed"); } renderMessages() { const shouldRestoreScroll = !this.isUserNearBottom; const previousScrollTop = this.previousScrollTop; this.messagesContainer.empty(); if (this.messages.length === 0) { const emptyState = this.messagesContainer.createDiv({ cls: "nb-empty-state" }); emptyState.createDiv({ cls: "nb-empty-title", text: "Welcome to NoteBuddy" }); emptyState.createDiv({ cls: "nb-empty-subtitle", text: "Ask your notes a question or start a new conversation." }); this.hideNewMessageIndicator(); this.hasInitialScroll = true; return; } for (const item of this.messages) { if (item.kind === "userText") { this.renderMessageBubble("user", item.text, this.messagesContainer); continue; } if (item.kind === "error") { this.renderErrorMessage(item.text, item.retryMessage); continue; } const part = item.part; const messageEl = this.messagesContainer.createDiv({ cls: "nb-message nb-message-assistant" }); switch (part.type) { case "text": this.renderMarkdown(part.text, messageEl); break; case "reasoning": messageEl.createEl("div", { text: "Reasoning:", cls: "nb-part-label" }); this.renderMarkdown(part.text, messageEl); break; case "tool": messageEl.createEl("strong", { text: `Tool: ${part.tool}` }); messageEl.createEl("pre", { text: JSON.stringify(part.state ?? {}, null, 2) }); break; case "patch": messageEl.createEl("strong", { text: `Patch: ${part.hash}` }); messageEl.createEl("pre", { text: (part.files || []).join("\n") }); break; case "file": messageEl.createEl("strong", { text: `File: ${part.filename ?? part.url}` }); messageEl.createEl("div", { text: `mime: ${part.mime}` }); messageEl.createEl("div", { text: `url: ${part.url}` }); break; case "agent": messageEl.createEl("strong", { text: `Agent: ${part.name}` }); break; case "step_start": messageEl.createEl("strong", { text: `Step start${part.title ? `: ${part.title}` : ""}` }); break; case "step_finish": messageEl.createEl("strong", { text: `Step finish${part.title ? `: ${part.title}` : ""}` }); break; case "snapshot": messageEl.createEl("strong", { text: "Snapshot" }); messageEl.createEl("pre", { text: JSON.stringify(part, null, 2) }); break; case "retry": messageEl.createEl("strong", { text: "Retry" }); messageEl.createEl("pre", { text: JSON.stringify(part, null, 2) }); break; case "compaction": messageEl.createEl("strong", { text: "Compaction" }); messageEl.createEl("pre", { text: JSON.stringify(part, null, 2) }); break; case "unknown": messageEl.createEl("strong", { text: `Unsupported part type: ${part.originalType}` }); messageEl.createEl("pre", { text: JSON.stringify(part, null, 2) }); break; } } if (shouldRestoreScroll) { this.messagesContainer.scrollTop = previousScrollTop; this.showNewMessageIndicator(); } else { this.maybeAutoScroll(); } } }; // src/settings.ts var import_obsidian3 = require("obsidian"); var NoteBuddySettingTab = class extends import_obsidian3.PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.providers = []; this.isLoadingModels = false; this.plugin = plugin; } async display(useCachedProviders) { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "NoteBuddy Settings" }); if (!useCachedProviders) { try { await this.loadProviders(); } catch { } } const connectionGroup = containerEl.createDiv({ cls: "nb-settings-group" }); connectionGroup.createEl("h3", { text: "Connection Settings", cls: "nb-settings-group-title" }); new import_obsidian3.Setting(connectionGroup).setName("Service URL").setDesc("The URL of the OpenCode service (must include protocol, hostname, and port)").addText( (text) => text.setPlaceholder("http://127.0.0.1:4096").setValue(this.plugin.settings.serviceUrl).onChange(async (value) => { const previousServiceUrl = this.plugin.settings.serviceUrl; const validationEl = text.inputEl.parentElement?.querySelector(".nb-settings-validation-error, .nb-settings-validation-success"); if (validationEl) { validationEl.remove(); } if (!value.startsWith("http://") && !value.startsWith("https://")) { const errorEl = text.inputEl.parentElement?.createDiv({ cls: "nb-settings-validation-error" }); if (errorEl) errorEl.textContent = "Service URL must start with http:// or https://"; return; } try { const url = new URL(value); if (!url.hostname || url.hostname === "") { const errorEl = text.inputEl.parentElement?.createDiv({ cls: "nb-settings-validation-error" }); if (errorEl) errorEl.textContent = "Service URL must include a valid hostname"; return; } if (!url.port || url.port === "") { const errorEl = text.inputEl.parentElement?.createDiv({ cls: "nb-settings-validation-error" }); if (errorEl) errorEl.textContent = "Service URL must include a port number"; return; } const successEl = text.inputEl.parentElement?.createDiv({ cls: "nb-settings-validation-success" }); if (successEl) successEl.textContent = "\u2713 Valid URL format"; } catch (e) { const errorEl = text.inputEl.parentElement?.createDiv({ cls: "nb-settings-validation-error" }); if (errorEl) errorEl.textContent = "Service URL must be a valid URL format"; return; } if (this.plugin.sessionState && previousServiceUrl !== value) { if (this.lastServiceUrlWarning !== value) { new import_obsidian3.Notice("Active chat detected. Clear the current conversation before switching the Service URL."); this.lastServiceUrlWarning = value; } text.setValue(previousServiceUrl); return; } this.plugin.settings.serviceUrl = value; await this.plugin.saveSettings(); }) ); new import_obsidian3.Setting(connectionGroup).setName("Test Connection").setDesc("Test the connection to the service").addButton( (button) => button.setIcon("plug").setButtonText("Test").setCta().onClick(async () => { button.setButtonText("Testing..."); button.setDisabled(true); const client = new OpenCodeClient(this.plugin.settings.serviceUrl, this.plugin.settings.defaultModelId); const result = await client.healthCheck(); button.setButtonText("Test"); button.setDisabled(false); if (result.status === "connected") { new import_obsidian3.Notice("\u2713 Connection successful!"); } else { new import_obsidian3.Notice(`\u2717 Connection failed: ${result.lastError}`); } }) ); const modelGroup = containerEl.createDiv({ cls: "nb-settings-group" }); modelGroup.createEl("h3", { text: "Model Selection", cls: "nb-settings-group-title" }); this.displayModelSelection(modelGroup); this.displayRefreshButton(modelGroup); } /** * Loads available AI providers and models from the OpenCode server. * * Sets the loading state and updates the providers list. On error, sets providers * to an empty array and logs the error. The loading state is always cleared in finally. * * @param client - Optional OpenCodeClient instance to use (creates new one if not provided) * @param forceRefresh - If true, bypasses cache and fetches fresh data from server * @throws Error if the server request fails (error is logged and re-thrown) */ async loadProviders(client, forceRefresh) { if (this.isLoadingModels) { return; } this.isLoadingModels = true; try { const c = client ?? new OpenCodeClient(this.plugin.settings.serviceUrl, this.plugin.settings.defaultModelId); this.providers = await c.getCapabilities(forceRefresh ?? false); } catch (error) { this.providers = []; console.error("[NoteBuddy] Failed to load providers:", error); throw error; } finally { this.isLoadingModels = false; } } /** * Displays the model selection dropdown in the settings UI. * * Builds a dropdown with options in "Provider Name - Model Name" format, including * a "Use server default" option. The current selection is restored from settings, * and changes are immediately persisted via plugin.saveSettings(). * * @param containerEl - The HTML element to add the dropdown setting to */ displayModelSelection(containerEl) { const modelOptions = {}; modelOptions[""] = "Use server default"; for (const provider of this.providers) { for (const model of provider.models) { const value = `${provider.id}/${model.id}`; modelOptions[value] = `${provider.name} - ${model.name}`; } } const currentValue = this.getCurrentModelSelection(); new import_obsidian3.Setting(containerEl).setName("Default Model").setDesc("Select the default AI model to use for conversations").addDropdown( (dropdown) => dropdown.addOptions(modelOptions).setValue(currentValue).onChange(async (value) => { if (value) { this.plugin.settings.defaultModelId = value; } else { this.plugin.settings.defaultModelId = void 0; } await this.plugin.saveSettings(); }) ); } getCurrentModelSelection() { return this.plugin.settings.defaultModelId || ""; } displayRefreshButton(container) { new import_obsidian3.Setting(container).setName("Refresh Models").setDesc("Reload the list of available models from the service").addButton( (button) => button.setIcon("refresh-cw").setButtonText("Refresh").onClick(async () => { const client = new OpenCodeClient(this.plugin.settings.serviceUrl, this.plugin.settings.defaultModelId); client.clearModelsCache(); try { await this.loadProviders(client, true); new import_obsidian3.Notice("Models refreshed"); await this.display(true); } catch (error) { console.error("[NoteBuddy] Failed to load providers:", error); new import_obsidian3.Notice(`Failed to refresh models: ${error.message}`); await this.display(true); } }) ); } }; // src/main.ts var DEFAULT_SETTINGS = defaultSettings; var NoteBuddyPlugin = class extends import_obsidian4.Plugin { constructor() { super(...arguments); this.ribbonIconEl = null; } async onload() { console.log("Loading NoteBuddy plugin..."); await this.loadSettings(); this.registerView( VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this) ); this.addRibbonIcon("bot", "NoteBuddy", () => { this.activateView(); }); this.addCommand({ id: "open-chat-view", name: "Open NoteBuddy Chat", callback: () => { this.activateView(); } }); this.app.workspace.onLayoutReady(() => { this.activateView(); }); this.addSettingTab(new NoteBuddySettingTab(this.app, this)); } onunload() { console.log("Unloading NoteBuddy plugin..."); } async activateView() { const { workspace } = this.app; let leaf = null; const leaves = workspace.getLeavesOfType(VIEW_TYPE_CHAT); if (leaves.length > 0) { leaf = leaves[0]; } else { leaf = workspace.getRightLeaf(false); } if (leaf) { await workspace.setActiveLeaf(leaf); await leaf.setViewState({ type: VIEW_TYPE_CHAT, active: true }); } } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } // Callback for when selected model is unavailable onModelUnavailable() { new import_obsidian4.Notice("Selected model is unavailable. Using server default instead."); } async saveSettings() { await this.saveData(this.settings); } };