Files
my-vault/.obsidian/plugins/note-buddy/main.js
T

1092 lines
37 KiB
JavaScript

/*
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("<!")) {
throw new Error(
"Server returned HTML instead of JSON. Check the Service URL and ensure the server exposes /config/providers."
);
}
throw new Error(`Invalid JSON response: ${preview}...`);
}
const raw = data.providers || [];
this.cachedDefaults = data.default || {};
const providers = raw.map((p) => ({
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);
}
};