2026-01-26 17:57:11 +08:00
/*
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"
};
2026-01-30 10:38:04 +08:00
var MODELS_CACHE_TTL = 5 * 60 * 1e3 ;
2026-01-26 17:57:11 +08:00
// src/service.ts
2026-01-30 10:38:04 +08:00
var OpenCodeClient = class _OpenCodeClient {
constructor ( serviceUrl , defaultModelId , onModelUnavailable ) {
2026-01-26 17:57:11 +08:00
this . session = null ;
2026-01-30 10:38:04 +08:00
this . cachedProviders = null ;
this . providersCacheTime = 0 ;
this . cachedDefaults = null ;
2026-01-26 17:57:11 +08:00
this . serviceUrl = serviceUrl ;
2026-01-30 10:38:04 +08:00
this . defaultModelId = defaultModelId ;
this . onModelUnavailable = onModelUnavailable ;
2026-01-26 17:57:11 +08:00
}
2026-01-30 10:38:04 +08:00
static {
this . DEFAULT_REQUEST_TIMEOUT_MS = 1e4 ;
}
static {
this . MESSAGE_REQUEST_TIMEOUT_MS = 6e4 ;
}
async request ( options , timeoutMs = _OpenCodeClient . DEFAULT_REQUEST_TIMEOUT_MS ) {
2026-01-26 17:57:11 +08:00
return await Promise . race ([
( 0 , import_obsidian . requestUrl )( options ),
new Promise (
2026-01-30 10:38:04 +08:00
( _ , reject ) => setTimeout (() => reject ( new Error ( `Request timeout after ${ timeoutMs / 1e3 } seconds` )), timeoutMs )
2026-01-26 17:57:11 +08:00
)
]);
}
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 {
2026-01-30 10:38:04 +08:00
sessionID : data . id ,
createTime : data . time . created ,
2026-01-26 17:57:11 +08:00
title : data . title
};
}
2026-01-30 10:38:04 +08:00
if ( response . status === 400 ) {
const details = this . formatBadRequest ( response . json , response . text );
throw new Error ( details );
}
throw new Error ( `HTTP ${ response . status } : ${ response . text } ` );
2026-01-26 17:57:11 +08:00
} catch ( error ) {
const err = error ;
throw new Error ( `Failed to create session: ${ err . message } ` );
}
}
2026-01-30 10:38:04 +08:00
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" ;
}
2026-01-26 17:57:11 +08:00
async sendMessageInternal ( sessionID , message ) {
const url = ` ${ this . serviceUrl } /session/ ${ sessionID } /message` ;
try {
2026-01-30 10:38:04 +08:00
const response = await this . request (
{
url ,
method : "POST" ,
headers : {
"Content-Type" : "application/json"
},
body : JSON . stringify ( message )
2026-01-26 17:57:11 +08:00
},
2026-01-30 10:38:04 +08:00
_OpenCodeClient . MESSAGE_REQUEST_TIMEOUT_MS
);
2026-01-26 17:57:11 +08:00
if ( response . status === 200 ) {
2026-01-30 10:38:04 +08:00
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 ;
2026-01-26 17:57:11 +08:00
}
2026-01-30 10:38:04 +08:00
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 } ` );
2026-01-26 17:57:11 +08:00
} 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 = {
2026-01-30 10:38:04 +08:00
parts : [{ type : "text" , text : input }]
2026-01-26 17:57:11 +08:00
};
2026-01-30 10:38:04 +08:00
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" );
2026-01-26 17:57:11 +08:00
return assistantText ;
}
async sendMessageToSession ( sessionID , message ) {
return await this . sendMessageInternal ( sessionID , message );
}
2026-01-30 10:38:04 +08:00
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 ;
}
2026-02-04 13:41:36 +08:00
/**
* 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
*/
2026-01-30 10:38:04 +08:00
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" ));
}
2026-01-26 17:57:11 +08:00
};
// 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 = [];
2026-02-04 13:41:36 +08:00
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 ();
}
};
2026-01-30 10:38:04 +08:00
this . client = new OpenCodeClient (
plugin . settings . serviceUrl ,
plugin . settings . defaultModelId ,
plugin . onModelUnavailable ? . bind ( plugin )
);
2026-01-26 17:57:11 +08:00
}
2026-02-04 13:41:36 +08:00
/**
* 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 );
}
}
2026-01-26 17:57:11 +08:00
getViewType () {
return VIEW_TYPE_CHAT ;
}
getDisplayText () {
return "NoteBuddy Chat" ;
}
async onOpen () {
2026-02-04 13:41:36 +08:00
if ( ! this . isInitialized ) {
await this . loadChatState ();
this . isInitialized = true ;
}
2026-01-26 17:57:11 +08:00
const container = this . containerEl . children [ 1 ];
container . empty ();
2026-02-04 13:41:36 +08:00
container . addClass ( "note-buddy-chat-view" );
2026-01-26 17:57:11 +08:00
const chatContainer = container . createDiv ({ cls : "nb-chat-container" });
2026-02-04 13:41:36 +08:00
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 ();
2026-01-26 17:57:11 +08:00
const messagesContainer = chatContainer . createDiv ({ cls : "nb-messages-container" });
this . messagesContainer = messagesContainer ;
2026-02-04 13:41:36 +08:00
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 ();
2026-01-26 17:57:11 +08:00
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" ,
2026-02-04 13:41:36 +08:00
attr : { "aria-label" : "Send message" , title : "Send message" }
2026-01-26 17:57:11 +08:00
});
2026-02-04 13:41:36 +08:00
( 0 , import_obsidian2 . setIcon )( sendButton , "send" );
2026-01-26 17:57:11 +08:00
const sendMessage = async () => {
const message = textarea . value . trim ();
if ( ! message ) return ;
2026-01-30 10:38:04 +08:00
this . messages . push ({ kind : "userText" , text : message });
2026-01-26 17:57:11 +08:00
this . renderMessages ();
textarea . value = "" ;
2026-02-04 13:41:36 +08:00
this . showLoading ();
2026-01-26 17:57:11 +08:00
try {
if ( ! this . plugin . sessionState ) {
2026-02-04 13:41:36 +08:00
this . plugin . sessionState = await this . sendWithTimeout (
this . client . createSession (),
3e4
);
2026-01-26 17:57:11 +08:00
}
const sendData = {
2026-01-30 10:38:04 +08:00
parts : [{ type : "text" , text : message }]
2026-01-26 17:57:11 +08:00
};
2026-01-30 10:38:04 +08:00
if ( this . plugin . settings . defaultModelId ) {
const parts = this . plugin . settings . defaultModelId . split ( "/" );
if ( parts . length === 2 ) {
sendData . model = {
providerID : parts [ 0 ],
modelID : parts [ 1 ]
};
}
}
2026-01-26 17:57:11 +08:00
let response ;
try {
2026-02-04 13:41:36 +08:00
response = await this . sendWithTimeout (
this . client . sendMessageToSession ( this . plugin . sessionState . sessionID , sendData ),
3e4
);
2026-01-26 17:57:11 +08:00
} catch ( sendError ) {
const err = sendError ;
2026-01-30 10:38:04 +08:00
if ( err . message . includes ( "Session not found" )) {
2026-02-04 13:41:36 +08:00
this . plugin . sessionState = await this . sendWithTimeout (
this . client . createSession (),
3e4
);
response = await this . sendWithTimeout (
this . client . sendMessageToSession ( this . plugin . sessionState . sessionID , sendData ),
3e4
);
2026-01-26 17:57:11 +08:00
} else {
throw sendError ;
}
}
2026-02-04 13:41:36 +08:00
this . hideLoading ();
2026-01-30 10:38:04 +08:00
for ( const part of response . parts ) {
this . messages . push ({ kind : "assistantPart" , part });
}
2026-01-26 17:57:11 +08:00
this . renderMessages ();
2026-02-04 13:41:36 +08:00
await this . saveChatState ();
2026-01-26 17:57:11 +08:00
} catch ( error ) {
2026-02-04 13:41:36 +08:00
this . hideLoading ();
2026-01-26 17:57:11 +08:00
console . error ( "[NoteBuddy] Send failed:" , error );
2026-02-04 13:41:36 +08:00
this . messages . push ({
kind : "error" ,
text : `Failed to send message: ${ error . message } ` ,
retryMessage : message
});
this . renderMessages ();
await this . saveChatState ();
2026-01-26 17:57:11 +08:00
}
};
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 () {
2026-02-04 13:41:36 +08:00
const shouldRestoreScroll = ! this . isUserNearBottom ;
const previousScrollTop = this . previousScrollTop ;
2026-01-26 17:57:11 +08:00
this . messagesContainer . empty ();
if ( this . messages . length === 0 ) {
2026-02-04 13:41:36 +08:00
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 ;
2026-01-26 17:57:11 +08:00
return ;
}
2026-01-30 10:38:04 +08:00
for ( const item of this . messages ) {
if ( item . kind === "userText" ) {
2026-02-04 13:41:36 +08:00
this . renderMessageBubble ( "user" , item . text , this . messagesContainer );
continue ;
}
if ( item . kind === "error" ) {
this . renderErrorMessage ( item . text , item . retryMessage );
2026-01-30 10:38:04 +08:00
continue ;
}
const part = item . part ;
const messageEl = this . messagesContainer . createDiv ({ cls : "nb-message nb-message-assistant" });
switch ( part . type ) {
case "text" :
2026-02-04 13:41:36 +08:00
this . renderMarkdown ( part . text , messageEl );
2026-01-30 10:38:04 +08:00
break ;
case "reasoning" :
2026-02-04 13:41:36 +08:00
messageEl . createEl ( "div" , { text : "Reasoning:" , cls : "nb-part-label" });
this . renderMarkdown ( part . text , messageEl );
2026-01-30 10:38:04 +08:00
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 ;
}
2026-01-26 17:57:11 +08:00
}
2026-02-04 13:41:36 +08:00
if ( shouldRestoreScroll ) {
this . messagesContainer . scrollTop = previousScrollTop ;
this . showNewMessageIndicator ();
} else {
this . maybeAutoScroll ();
}
2026-01-26 17:57:11 +08:00
}
};
// src/settings.ts
var import_obsidian3 = require ( "obsidian" );
var NoteBuddySettingTab = class extends import_obsidian3 . PluginSettingTab {
constructor ( app , plugin ) {
super ( app , plugin );
2026-01-30 10:38:04 +08:00
this . providers = [];
this . isLoadingModels = false ;
2026-01-26 17:57:11 +08:00
this . plugin = plugin ;
}
2026-01-30 10:38:04 +08:00
async display ( useCachedProviders ) {
2026-01-26 17:57:11 +08:00
const { containerEl } = this ;
containerEl . empty ();
containerEl . createEl ( "h2" , { text : "NoteBuddy Settings" });
2026-01-30 10:38:04 +08:00
if ( ! useCachedProviders ) {
try {
await this . loadProviders ();
} catch {
}
}
2026-02-04 13:41:36 +08:00
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 (
2026-01-26 17:57:11 +08:00
( text ) => text . setPlaceholder ( "http://127.0.0.1:4096" ). setValue ( this . plugin . settings . serviceUrl ). onChange ( async ( value ) => {
2026-02-04 13:41:36 +08:00
const previousServiceUrl = this . plugin . settings . serviceUrl ;
const validationEl = text . inputEl . parentElement ? . querySelector ( ".nb-settings-validation-error, .nb-settings-validation-success" );
if ( validationEl ) {
validationEl . remove ();
}
2026-01-26 17:57:11 +08:00
if ( ! value . startsWith ( "http://" ) && ! value . startsWith ( "https://" )) {
2026-02-04 13:41:36 +08:00
const errorEl = text . inputEl . parentElement ? . createDiv ({ cls : "nb-settings-validation-error" });
if ( errorEl ) errorEl . textContent = "Service URL must start with http:// or https://" ;
2026-01-26 17:57:11 +08:00
return ;
}
try {
const url = new URL ( value );
if ( ! url . hostname || url . hostname === "" ) {
2026-02-04 13:41:36 +08:00
const errorEl = text . inputEl . parentElement ? . createDiv ({ cls : "nb-settings-validation-error" });
if ( errorEl ) errorEl . textContent = "Service URL must include a valid hostname" ;
2026-01-26 17:57:11 +08:00
return ;
}
if ( ! url . port || url . port === "" ) {
2026-02-04 13:41:36 +08:00
const errorEl = text . inputEl . parentElement ? . createDiv ({ cls : "nb-settings-validation-error" });
if ( errorEl ) errorEl . textContent = "Service URL must include a port number" ;
2026-01-26 17:57:11 +08:00
return ;
}
2026-02-04 13:41:36 +08:00
const successEl = text . inputEl . parentElement ? . createDiv ({ cls : "nb-settings-validation-success" });
if ( successEl ) successEl . textContent = "\u2713 Valid URL format" ;
2026-01-26 17:57:11 +08:00
} catch ( e ) {
2026-02-04 13:41:36 +08:00
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 );
2026-01-26 17:57:11 +08:00
return ;
}
this . plugin . settings . serviceUrl = value ;
await this . plugin . saveSettings ();
})
);
2026-02-04 13:41:36 +08:00
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 );
2026-01-30 10:38:04 +08:00
const client = new OpenCodeClient ( this . plugin . settings . serviceUrl , this . plugin . settings . defaultModelId );
2026-01-26 17:57:11 +08:00
const result = await client . healthCheck ();
2026-02-04 13:41:36 +08:00
button . setButtonText ( "Test" );
button . setDisabled ( false );
2026-01-26 17:57:11 +08:00
if ( result . status === "connected" ) {
2026-02-04 13:41:36 +08:00
new import_obsidian3 . Notice ( "\u2713 Connection successful!" );
2026-01-26 17:57:11 +08:00
} else {
2026-02-04 13:41:36 +08:00
new import_obsidian3 . Notice ( `\u2717 Connection failed: ${ result . lastError } ` );
2026-01-26 17:57:11 +08:00
}
})
);
2026-02-04 13:41:36 +08:00
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 );
2026-01-30 10:38:04 +08:00
}
2026-02-04 13:41:36 +08:00
/**
* 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)
*/
2026-01-30 10:38:04 +08:00
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 ;
}
}
2026-02-04 13:41:36 +08:00
/**
* 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
*/
2026-01-30 10:38:04 +08:00
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 (
2026-02-04 13:41:36 +08:00
( button ) => button . setIcon ( "refresh-cw" ). setButtonText ( "Refresh" ). onClick ( async () => {
2026-01-30 10:38:04 +08:00
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 );
}
})
);
2026-01-26 17:57:11 +08:00
}
};
// 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 ());
}
2026-01-30 10:38:04 +08:00
// Callback for when selected model is unavailable
onModelUnavailable () {
new import_obsidian4 . Notice ( "Selected model is unavailable. Using server default instead." );
}
2026-01-26 17:57:11 +08:00
async saveSettings () {
await this . saveData ( this . settings );
}
};