1092 lines
37 KiB
JavaScript
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);
|
|
}
|
|
};
|