diff --git a/README.md b/README.md index 2efb894..6770005 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,14 @@ The plugin maintains a persistent session with Hermes. Your conversation history | **Show model info** | Display the current model name in the status bar | Enabled | | **Enable context injection** | Automatically send the active note as context with each message | Enabled | +## Roadmap + +- ~~**Multi-tab chat support**~~ — open multiple Hermes chat tabs simultaneously, each with its own isolated session, message history, and note context. *(shipped: each tab gets its own API client + session; use Ctrl+P → "Open Hermes Chat in New Tab")* +- ~~**Slash commands**~~ — `/prep-session`, `/sync-content`, `/lint-wiki`, and user-defined custom commands in plugin settings. *(shipped: built-in commands + JSON-configurable custom commands)* +- ~~**Slash command autocomplete**~~ — on `/` trigger in the chat input, show a suggestion dropdown with arrow-key navigation and click-to-select. *(shipped)* +- **Quick Actions button bar** — one-click buttons above the chat input for common workflows (Sync Content, Prep Session, Lint Wiki). Configurable in settings. +- **Hermes-side `/commands` endpoint** — server publishes a manifest of available slash commands; plugin fetches and merges with local commands on load. + ## Development ```bash diff --git a/main.js b/main.js index 818cc03..aedd6c9 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,14 @@ -"use strict";var S=Object.defineProperty;var H=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var L=Object.prototype.hasOwnProperty;var M=(o,t)=>{for(var e in t)S(o,e,{get:t[e],enumerable:!0})},N=(o,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of U(t))!L.call(o,i)&&i!==e&&S(o,i,{get:()=>t[i],enumerable:!(s=H(t,i))||s.enumerable});return o};var j=o=>N(S({},"__esModule",{value:!0}),o);var K={};M(K,{default:()=>x});module.exports=j(K);var D=require("obsidian");var h=require("obsidian"),$={gatewayUrl:"http://192.168.1.12:8642/v1",apiKey:"0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",showModelInfo:!0,enableContextInjection:!0},v=class extends h.PluginSettingTab{plugin;constructor(t,e){super(t,e),this.plugin=e}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"Hermes Chat Settings"}),new h.Setting(t).setName("Gateway URL").setDesc("Base URL of your Hermes Gateway API server (e.g. http://192.168.1.12:8642/v1)").addText(e=>e.setPlaceholder("http://192.168.1.12:8642/v1").setValue(this.plugin.settings.gatewayUrl).onChange(async s=>{this.plugin.settings.gatewayUrl=s,await this.plugin.saveData(this.plugin.settings)})),new h.Setting(t).setName("API Key").setDesc("API key for authenticating with the Hermes Gateway").addText(e=>e.setPlaceholder("Enter your API key").setValue(this.plugin.settings.apiKey).onChange(async s=>{this.plugin.settings.apiKey=s,await this.plugin.saveData(this.plugin.settings)})),new h.Setting(t).setName("Show model info").setDesc("Display the current model name in the status bar").addToggle(e=>e.setValue(this.plugin.settings.showModelInfo).onChange(async s=>{this.plugin.settings.showModelInfo=s,await this.plugin.saveData(this.plugin.settings)})),new h.Setting(t).setName("Enable context injection").setDesc("Automatically send the active note as context with each message").addToggle(e=>e.setValue(this.plugin.settings.enableContextInjection).onChange(async s=>{this.plugin.settings.enableContextInjection=s,await this.plugin.saveData(this.plugin.settings)}))}};var k=require("obsidian"),d="hermes-chat-view",f=class extends k.ItemView{apiClient=null;contextBridge=null;chatState;messagesContainer=null;statusBar=null;loadingEl=null;textarea=null;sendButton=null;constructor(t,e,s){super(t),this.apiClient=e??null,this.contextBridge=s??null,this.chatState={sessionId:null,messages:[],isLoading:!1,model:"hermes-agent",contextActive:!1}}getViewType(){return d}getDisplayText(){return"Hermes"}async onOpen(){let t=this.containerEl.children[1];t.empty(),t.addClass("hermes-chat-container"),this.messagesContainer=t.createDiv("chat-messages"),this.messagesContainer.id="chat-messages",this.loadingEl=this.messagesContainer.createDiv("chat-loading"),this.loadingEl.setText("Thinking\u2026"),this.loadingEl.style.display="none";let e=t.createDiv("chat-input-area");this.textarea=e.createEl("textarea"),this.textarea.placeholder="Type your message\u2026",this.textarea.setAttribute("rows","1"),this.textarea.addEventListener("keydown",s=>{s.key==="Enter"&&!s.shiftKey&&(s.preventDefault(),this.handleSend())}),this.sendButton=e.createEl("button"),this.sendButton.setText("Send"),this.sendButton.addEventListener("click",()=>this.handleSend()),this.statusBar=t.createDiv("chat-status-bar"),this.updateStatus(this.chatState.model,null)}async onClose(){}setApiClient(t){this.apiClient=t}setContextActive(t){this.chatState.contextActive=t}appendMessage(t,e){if(!this.messagesContainer)return;let s=this.messagesContainer.createDiv("chat-message");s.addClass(`chat-message-${t}`),s.createDiv("chat-message-role").setText(t==="user"?"You":t==="assistant"?"Hermes":"System"),s.createDiv("chat-message-content").setText(e),this.scrollToBottom()}setLoading(t){this.chatState.isLoading=t,this.loadingEl&&(this.loadingEl.style.display=t?"block":"none"),this.textarea&&(this.textarea.disabled=t),this.sendButton&&(this.sendButton.disabled=t)}updateStatus(t,e){if(this.chatState.model=t,this.chatState.sessionId=e,this.statusBar){let s=e?`Session: ${e}`:"Session: \u2014";this.statusBar.setText(`Model: ${t} | ${s}`)}}clearChat(){if(!this.messagesContainer)return;let t=Array.from(this.messagesContainer.children);for(let e of t)e!==this.loadingEl&&e.remove();this.chatState.messages=[]}scrollToBottom(){this.messagesContainer&&(this.messagesContainer.scrollTop=this.messagesContainer.scrollHeight)}async handleSend(){if(!this.textarea)return;let t=this.textarea.value.trim();if(!t)return;if(this.textarea.value="",this.appendMessage("user",t),!this.apiClient){this.appendMessage("assistant","[API client not configured. Check plugin settings.]");return}this.setLoading(!0);let e=this.messagesContainer?.createDiv("chat-message chat-message-assistant"),s=e?.createDiv("chat-message-role");s&&s.setText("Hermes");let i=e?.createDiv("chat-message-content");i&&i.setText("");let n="";try{let a=this.contextBridge?this.contextBridge.buildMessages(t,this.chatState.contextActive):[{role:"user",content:t}],c=await this.apiClient.streamChat(a,l=>{n+=l,i&&i.setText(n),this.scrollToBottom()});c.sessionId&&(this.chatState.sessionId=c.sessionId,this.updateStatus(c.model,c.sessionId))}catch(a){i&&i.setText(`Error: ${a.message||"Unknown error"}`)}finally{this.setLoading(!1)}}};var C=class{baseUrl;apiKey;model;currentSessionId;constructor(t){this.baseUrl=t.baseUrl.replace(/\/+$/,""),this.apiKey=t.apiKey,this.model=t.model,this.currentSessionId=null}updateConfig(t){t.baseUrl!==void 0&&(this.baseUrl=t.baseUrl.replace(/\/+$/,"")),t.apiKey!==void 0&&(this.apiKey=t.apiKey),t.model!==void 0&&(this.model=t.model)}getSessionId(){return this.currentSessionId}get rootUrl(){return this.baseUrl.endsWith("/v1")?this.baseUrl.slice(0,-3):new URL("/",this.baseUrl).href.replace(/\/+$/,"")}async fetchWithTimeout(t,e,s=6e4){let i=new AbortController,n=setTimeout(()=>i.abort(),s);try{return await fetch(t,{...e,signal:i.signal})}finally{clearTimeout(n)}}async extractErrorBody(t){let e="";if((t.headers.get("content-type")??"").includes("application/json"))try{let i=await t.json();if(i.error?.message?e=i.error.message:typeof i.message=="string"?e=i.message:typeof i.error=="string"&&(e=i.error),e)return e}catch{}try{let i=await t.text();if(i.trim())return e=i.trim().substring(0,300),e}catch{}return t.statusText||`HTTP ${t.status}`}async chat(t,e=!1){let s={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};this.currentSessionId&&(s["X-Hermes-Session-Id"]=this.currentSessionId);let i=await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`,{method:"POST",headers:s,body:JSON.stringify({model:this.model,messages:t,stream:e})});if(!i.ok){let p=await this.extractErrorBody(i);throw new Error(`API error ${i.status}: ${p}`)}let n=i.headers.get("X-Hermes-Session-Id");n&&(this.currentSessionId=n);let a=await i.json();if(a.error)throw new Error(a.error.message||"Unknown API error");return{content:a.choices?.[0]?.message?.content??"",sessionId:this.currentSessionId??"",model:a.model??this.model,usage:a.usage}}async streamChat(t,e){let s={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};this.currentSessionId&&(s["X-Hermes-Session-Id"]=this.currentSessionId);let i=await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`,{method:"POST",headers:s,body:JSON.stringify({model:this.model,messages:t,stream:!0})});if(!i.ok){let u=await this.extractErrorBody(i);throw new Error(`API error ${i.status}: ${u}`)}let n=i.headers.get("X-Hermes-Session-Id");n&&(this.currentSessionId=n);let a=i.body?.getReader();if(!a)throw new Error("Response body is not readable");let c=new TextDecoder("utf-8"),l="",p="",T=this.model,I;for(;;){let{done:u,value:y}=await a.read();if(u)break;l+=c.decode(y,{stream:!0});let g=l.split(` +"use strict";var E=Object.defineProperty;var N=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var U=Object.prototype.hasOwnProperty;var M=(o,t)=>{for(var s in t)E(o,s,{get:t[s],enumerable:!0})},R=(o,t,s,e)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of H(t))!U.call(o,i)&&i!==s&&E(o,i,{get:()=>t[i],enumerable:!(e=N(t,i))||e.enumerable});return o};var O=o=>R(E({},"__esModule",{value:!0}),o);var K={};M(K,{default:()=>S});module.exports=O(K);var P=require("obsidian");var d=require("obsidian"),D=[{trigger:"/prep-session",description:"Prep the next D&D session",prompt:"Run the full session-prep pipeline for the next SKT session: scan recent session notes, identify loose threads, generate a session guide with modular flow-chart sections."},{trigger:"/sync-content",description:"Sync Content Sink notes to vault",prompt:"/run content-sink-sync"},{trigger:"/lint-wiki",description:"Lint the campaign wiki",prompt:"Scan the campaign wiki for broken links, orphaned entities, missing templates, and formatting inconsistencies. Report a prioritized list of issues."}],$={gatewayUrl:"http://192.168.1.12:8642/v1",apiKey:"0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",showModelInfo:!0,enableContextInjection:!0,customCommands:[]},C=class extends d.PluginSettingTab{plugin;constructor(t,s){super(t,s),this.plugin=s}display(){let{containerEl:t}=this;t.empty(),t.createEl("h2",{text:"Hermes Chat Settings"}),new d.Setting(t).setName("Gateway URL").setDesc("Base URL of your Hermes Gateway API server (e.g. http://192.168.1.12:8642/v1)").addText(i=>i.setPlaceholder("http://192.168.1.12:8642/v1").setValue(this.plugin.settings.gatewayUrl).onChange(async n=>{this.plugin.settings.gatewayUrl=n,await this.plugin.saveData(this.plugin.settings)})),new d.Setting(t).setName("API Key").setDesc("API key for authenticating with the Hermes Gateway").addText(i=>i.setPlaceholder("Enter your API key").setValue(this.plugin.settings.apiKey).onChange(async n=>{this.plugin.settings.apiKey=n,await this.plugin.saveData(this.plugin.settings)})),new d.Setting(t).setName("Show model info").setDesc("Display the current model name in the status bar").addToggle(i=>i.setValue(this.plugin.settings.showModelInfo).onChange(async n=>{this.plugin.settings.showModelInfo=n,await this.plugin.saveData(this.plugin.settings)})),new d.Setting(t).setName("Enable context injection").setDesc("Automatically send the active note as context with each message").addToggle(i=>i.setValue(this.plugin.settings.enableContextInjection).onChange(async n=>{this.plugin.settings.enableContextInjection=n,await this.plugin.saveData(this.plugin.settings)})),t.createEl("h3",{text:"Slash Commands"}),t.createEl("p",{text:"Type / in the chat input to see available commands. Built-in: /prep-session, /sync-content, /lint-wiki. Add custom commands below as JSON."}),t.createDiv("setting-item-description").setText('Format: [{"trigger":"/my-cmd","description":"What it does","prompt":"Full prompt text..."}]');let e=t.createEl("textarea");e.setAttribute("rows","6"),e.style.width="100%",e.style.fontFamily="monospace",e.style.fontSize="12px",e.value=JSON.stringify(this.plugin.settings.customCommands,null,2),e.addEventListener("change",async()=>{try{let i=JSON.parse(e.value);Array.isArray(i)&&(this.plugin.settings.customCommands=i,await this.plugin.saveData(this.plugin.settings))}catch{}})}};var w=require("obsidian"),g="hermes-chat-view",f=class extends w.ItemView{apiClient=null;contextBridge=null;chatState;messagesContainer=null;statusBar=null;loadingEl=null;textarea=null;sendButton=null;capturedNote=null;slashCommands=[];suggestEl=null;suggestIndex=-1;filteredCommands=[];constructor(t,s,e){super(t),this.apiClient=s??null,this.contextBridge=e??null,this.chatState={sessionId:null,messages:[],isLoading:!1,model:"hermes-agent",contextActive:!1}}getViewType(){return g}getDisplayText(){return"Hermes"}async onOpen(){let t=this.containerEl.children[1];t.empty(),t.addClass("hermes-chat-container"),this.messagesContainer=t.createDiv("chat-messages"),this.messagesContainer.id="chat-messages",this.containerEl.addEventListener("focusin",()=>{this.contextBridge&&(this.capturedNote=this.contextBridge.snapshot())}),this.registerEvent(this.app.workspace.on("active-leaf-change",e=>{e?.view instanceof w.MarkdownView&&this.contextBridge&&(this.capturedNote=this.contextBridge.snapshot())})),this.loadingEl=this.messagesContainer.createDiv("chat-loading"),this.loadingEl.setText("Thinking\u2026"),this.loadingEl.style.display="none";let s=t.createDiv("chat-input-area");this.textarea=s.createEl("textarea"),this.textarea.placeholder="Type your message\u2026",this.textarea.setAttribute("rows","1"),this.textarea.addEventListener("keydown",e=>{if(this.suggestEl&&this.suggestEl.style.display!=="none"){if(e.key==="ArrowDown"){e.preventDefault(),this.suggestMove(1);return}if(e.key==="ArrowUp"){e.preventDefault(),this.suggestMove(-1);return}if(e.key==="Enter"&&!e.shiftKey){e.preventDefault(),this.suggestIndex>=0&&this.suggestIndex=0&&this.suggestIndex{this.checkSlashTrigger()}),this.sendButton=s.createEl("button"),this.sendButton.setText("Send"),this.sendButton.addEventListener("click",()=>this.handleSend()),this.suggestEl=t.createDiv("hermes-slash-suggest"),this.suggestEl.style.display="none",this.suggestEl.style.position="absolute",this.suggestEl.style.zIndex="1000",this.statusBar=t.createDiv("chat-status-bar"),this.updateStatus(this.chatState.model,null)}async onClose(){}setApiClient(t){this.apiClient=t}setContextActive(t){this.chatState.contextActive=t}setSlashCommands(t){this.slashCommands=t}appendMessage(t,s){if(!this.messagesContainer)return;let e=this.messagesContainer.createDiv("chat-message");e.addClass(`chat-message-${t}`),e.createDiv("chat-message-role").setText(t==="user"?"You":t==="assistant"?"Hermes":"System"),e.createDiv("chat-message-content").setText(s),this.scrollToBottom()}setLoading(t){this.chatState.isLoading=t,this.loadingEl&&(this.loadingEl.style.display=t?"block":"none"),this.textarea&&(this.textarea.disabled=t),this.sendButton&&(this.sendButton.disabled=t)}updateStatus(t,s){if(this.chatState.model=t,this.chatState.sessionId=s,this.statusBar){let e=s?`Session: ${s}`:"Session: \u2014";this.statusBar.setText(`Model: ${t} | ${e}`)}}clearChat(){if(!this.messagesContainer)return;let t=Array.from(this.messagesContainer.children);for(let s of t)s!==this.loadingEl&&s.remove();this.chatState.messages=[]}scrollToBottom(){this.messagesContainer&&(this.messagesContainer.scrollTop=this.messagesContainer.scrollHeight)}checkSlashTrigger(){if(!this.textarea)return;let t=this.textarea.value,s=this.textarea.selectionStart,e=s-1;for(;e>=0&&t[e]!==" "&&t[e]!==` +`;)e--;e++;let i=t.slice(e,s);if(i.startsWith("/")&&(e===0||t[e-1]===" "||t[e-1]===` +`)){let n=i.slice(1).toLowerCase();if(this.filteredCommands=this.slashCommands.filter(a=>a.trigger.toLowerCase().includes(n)||a.description.toLowerCase().includes(n)),this.filteredCommands.length>0){this.suggestShow();return}}this.suggestClose()}suggestShow(){if(!this.suggestEl||!this.textarea)return;this.suggestIndex=-1,this.suggestEl.empty();for(let e=0;ethis.suggestSelect(i)),n.addEventListener("mouseenter",()=>{this.suggestIndex=e,this.suggestHighlight()})}let t=this.textarea.getBoundingClientRect(),s=this.containerEl.getBoundingClientRect();this.suggestEl.style.left=`${t.left-s.left}px`,this.suggestEl.style.top=`${t.bottom-s.top}px`,this.suggestEl.style.width=`${t.width}px`,this.suggestEl.style.display="block"}suggestClose(){this.suggestEl&&(this.suggestEl.style.display="none",this.suggestEl.empty()),this.suggestIndex=-1,this.filteredCommands=[]}suggestMove(t){this.filteredCommands.length!==0&&(this.suggestIndex+=t,this.suggestIndex<0&&(this.suggestIndex=this.filteredCommands.length-1),this.suggestIndex>=this.filteredCommands.length&&(this.suggestIndex=0),this.suggestHighlight())}suggestHighlight(){if(!this.suggestEl)return;this.suggestEl.querySelectorAll(".hermes-slash-item").forEach((s,e)=>{e===this.suggestIndex?s.addClass("hermes-slash-item-active"):s.removeClass("hermes-slash-item-active")})}suggestSelect(t){if(!this.textarea)return;let s=this.textarea.value,e=this.textarea.selectionStart,i=e-1;for(;i>=0&&s[i]!==" "&&s[i]!==` +`;)i--;i++;let n=s.slice(0,i),a=s.slice(e);this.textarea.value=n+t.prompt+a,this.suggestClose(),this.textarea.focus()}resolveSlashCommand(t){let s=t.trim();for(let e of this.slashCommands)if(s.startsWith(e.trigger))return e.prompt+s.slice(e.trigger.length);return t}async handleSend(){if(!this.textarea)return;let t=this.textarea.value.trim();if(!t)return;this.textarea.value="";let s=this.resolveSlashCommand(t);if(this.appendMessage("user",s),!this.apiClient){this.appendMessage("assistant","[API client not configured. Check plugin settings.]");return}this.setLoading(!0);let e=this.messagesContainer?.createDiv("chat-message chat-message-assistant"),i=e?.createDiv("chat-message-role");i&&i.setText("Hermes");let n=e?.createDiv("chat-message-content");n&&n.setText("");let a="";try{let h;if(this.chatState.contextActive&&this.capturedNote){let r=this.capturedNote;r.selection?h=[{role:"system",content:`[Selection from ${r.vault}/${r.path}] -`);l=g.pop()??"";for(let E of g){let m=E.split(` -`);for(let B of m){if(!B.startsWith("data: "))continue;let A=B.slice(6).trim();if(A!=="[DONE]")try{let r=JSON.parse(A);if(r.error)throw new Error(r.error.message||"Stream error");let b=r.choices?.[0]?.delta?.content;b&&(p+=b,e(b)),r.model&&(T=r.model),r.usage&&(I=r.usage)}catch(r){if(r instanceof Error&&r.message!=="Unexpected token"&&!r.message.includes("JSON"))throw r}}}}if(l.trim()){let u=l.split(` -`);for(let y of u){if(!y.startsWith("data: "))continue;let g=y.slice(6).trim();if(g!=="[DONE]")try{let m=JSON.parse(g).choices?.[0]?.delta?.content;m&&(p+=m,e(m))}catch{}}}return{content:p,sessionId:this.currentSessionId??"",model:T,usage:I}}async getModels(){let t=await this.fetchWithTimeout(`${this.baseUrl}/models`,{headers:{Authorization:`Bearer ${this.apiKey}`}});if(!t.ok){let s=await this.extractErrorBody(t);throw new Error(`Failed to fetch models: ${t.status} \u2014 ${s}`)}return(await t.json()).data??[]}async getCapabilities(){let t=await this.fetchWithTimeout(`${this.baseUrl}/capabilities`,{headers:{Authorization:`Bearer ${this.apiKey}`}});if(!t.ok){let e=await this.extractErrorBody(t);throw new Error(`Failed to fetch capabilities: ${t.status} \u2014 ${e}`)}return t.json()}async healthCheck(){try{let t=await this.fetchWithTimeout(`${this.rootUrl}/health`,{},1e4);return t.ok?(await t.json()).status==="ok":!1}catch{return!1}}};var P=require("obsidian"),w=class{app;constructor(t){this.app=t}getActiveNoteContext(){let t=this.app.workspace.activeEditor;if(!t||!t.editor)return null;let e=this.app.workspace.activeLeaf;if(!e||!(e.view instanceof P.MarkdownView))return null;let s=e.view.file;return s?{vault:this.app.vault.getName(),path:s.path,title:s.basename}:null}getSelectionContext(){let t=this.app.workspace.activeEditor;return!t||!t.editor?null:t.editor.getSelection()||null}formatContextMessage(t){return{role:"system",content:[`[Current note: ${t.vault}/${t.path}]`,`Use mcp_obsidian_get_vault_document(vault_name="${t.vault}", doc_path="${t.path}") to read the full contents.`].join(` -`)}}buildMessages(t,e){let s=[];if(e){let i=this.getSelectionContext();if(i){let n=this.getActiveNoteContext(),a=n?`${n.vault}/${n.path}`:"current note";s.push({role:"system",content:`[Selection from ${a}] +${r.selection}`},{role:"user",content:s}]:h=[{role:"system",content:[`[Current note: ${r.vault}/${r.path}]`,`Use mcp_obsidian_get_vault_document(vault_name="${r.vault}", doc_path="${r.path}") to read the full contents.`].join(` +`)},{role:"user",content:s}]}else h=this.contextBridge?this.contextBridge.buildMessages(s,this.chatState.contextActive):[{role:"user",content:s}];let c=await this.apiClient.streamChat(h,r=>{a+=r,n&&n.setText(a),this.scrollToBottom()});c.sessionId&&(this.chatState.sessionId=c.sessionId,this.updateStatus(c.model,c.sessionId))}catch(h){n&&n.setText(`Error: ${h.message||"Unknown error"}`)}finally{this.setLoading(!1)}}};var v=class{baseUrl;apiKey;model;currentSessionId;constructor(t){this.baseUrl=t.baseUrl.replace(/\/+$/,""),this.apiKey=t.apiKey,this.model=t.model,this.currentSessionId=null}updateConfig(t){t.baseUrl!==void 0&&(this.baseUrl=t.baseUrl.replace(/\/+$/,"")),t.apiKey!==void 0&&(this.apiKey=t.apiKey),t.model!==void 0&&(this.model=t.model)}getSessionId(){return this.currentSessionId}get rootUrl(){return this.baseUrl.endsWith("/v1")?this.baseUrl.slice(0,-3):new URL("/",this.baseUrl).href.replace(/\/+$/,"")}async fetchWithTimeout(t,s,e=6e4){let i=new AbortController,n=setTimeout(()=>i.abort(),e);try{return await fetch(t,{...s,signal:i.signal})}finally{clearTimeout(n)}}async extractErrorBody(t){let s="";if((t.headers.get("content-type")??"").includes("application/json"))try{let i=await t.json();if(i.error?.message?s=i.error.message:typeof i.message=="string"?s=i.message:typeof i.error=="string"&&(s=i.error),s)return s}catch{}try{let i=await t.text();if(i.trim())return s=i.trim().substring(0,300),s}catch{}return t.statusText||`HTTP ${t.status}`}async chat(t,s=!1){let e={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};this.currentSessionId&&(e["X-Hermes-Session-Id"]=this.currentSessionId);let i=await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`,{method:"POST",headers:e,body:JSON.stringify({model:this.model,messages:t,stream:s})});if(!i.ok){let r=await this.extractErrorBody(i);throw new Error(`API error ${i.status}: ${r}`)}let n=i.headers.get("X-Hermes-Session-Id");n&&(this.currentSessionId=n);let a=await i.json();if(a.error)throw new Error(a.error.message||"Unknown API error");return{content:a.choices?.[0]?.message?.content??"",sessionId:this.currentSessionId??"",model:a.model??this.model,usage:a.usage}}async streamChat(t,s){let e={Authorization:`Bearer ${this.apiKey}`,"Content-Type":"application/json"};this.currentSessionId&&(e["X-Hermes-Session-Id"]=this.currentSessionId);let i=await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`,{method:"POST",headers:e,body:JSON.stringify({model:this.model,messages:t,stream:!0})});if(!i.ok){let u=await this.extractErrorBody(i);throw new Error(`API error ${i.status}: ${u}`)}let n=i.headers.get("X-Hermes-Session-Id");n&&(this.currentSessionId=n);let a=i.body?.getReader();if(!a)throw new Error("Response body is not readable");let h=new TextDecoder("utf-8"),c="",r="",I=this.model,T;for(;;){let{done:u,value:y}=await a.read();if(u)break;c+=h.decode(y,{stream:!0});let p=c.split(` -${i}`})}else{let n=this.getActiveNoteContext();n&&s.push(this.formatContextMessage(n))}}return s.push({role:"user",content:t}),s}};var x=class extends D.Plugin{settings;statusBarItem=null;apiClient=null;contextBridge=null;async onload(){await this.loadSettings(),this.registerView(d,e=>{let s=new f(e,this.apiClient,this.contextBridge);return s.setContextActive(this.settings.enableContextInjection),s}),this.addSettingTab(new v(this.app,this)),this.addRibbonIcon("message-square","Open Hermes Chat",()=>{this.activateView()}),this.settings.showModelInfo&&(this.statusBarItem=this.addStatusBarItem(),this.statusBarItem.setText("Hermes: connecting..."));let t={baseUrl:this.settings.gatewayUrl,apiKey:this.settings.apiKey,model:"hermes-agent"};this.apiClient=new C(t),this.contextBridge=new w(this.app),this.addCommand({id:"open-hermes-chat",name:"Open Hermes Chat",callback:()=>{this.activateView()}}),this.addCommand({id:"send-note-as-context",name:"Send Current Note as Context",callback:()=>{let e=this.contextBridge?.getActiveNoteContext();e?(this.activateView(),console.log(`Context ready: vault=${e.vault}, path=${e.path}`)):console.log("No active note to send as context")}}),this.statusBarItem&&this.apiClient&&(this.statusBarItem.setText("Hermes: checking..."),this.apiClient.healthCheck().then(e=>{this.statusBarItem&&this.statusBarItem.setText(e?"Hermes: connected":"Hermes: unreachable")}))}async onunload(){this.app.workspace.detachLeavesOfType(d)}async activateView(){let{workspace:t}=this.app,e=t.getLeavesOfType(d);if(e.length>0){t.revealLeaf(e[0]);return}let s=t.getRightLeaf(!1);s&&(await s.setViewState({type:d,active:!0}),t.revealLeaf(s))}async loadSettings(){this.settings=Object.assign({},$,await this.loadData())}async saveSettings(){await this.saveData(this.settings),this.syncContextToViews()}syncContextToViews(){let t=this.app.workspace.getLeavesOfType(d);for(let e of t)e.view instanceof f&&e.view.setContextActive(this.settings.enableContextInjection)}}; +`);c=p.pop()??"";for(let k of p){let m=k.split(` +`);for(let A of m){if(!A.startsWith("data: "))continue;let B=A.slice(6).trim();if(B!=="[DONE]")try{let l=JSON.parse(B);if(l.error)throw new Error(l.error.message||"Stream error");let b=l.choices?.[0]?.delta?.content;b&&(r+=b,s(b)),l.model&&(I=l.model),l.usage&&(T=l.usage)}catch(l){if(l instanceof Error&&l.message!=="Unexpected token"&&!l.message.includes("JSON"))throw l}}}}if(c.trim()){let u=c.split(` +`);for(let y of u){if(!y.startsWith("data: "))continue;let p=y.slice(6).trim();if(p!=="[DONE]")try{let m=JSON.parse(p).choices?.[0]?.delta?.content;m&&(r+=m,s(m))}catch{}}}return{content:r,sessionId:this.currentSessionId??"",model:I,usage:T}}async getModels(){let t=await this.fetchWithTimeout(`${this.baseUrl}/models`,{headers:{Authorization:`Bearer ${this.apiKey}`}});if(!t.ok){let e=await this.extractErrorBody(t);throw new Error(`Failed to fetch models: ${t.status} \u2014 ${e}`)}return(await t.json()).data??[]}async getCapabilities(){let t=await this.fetchWithTimeout(`${this.baseUrl}/capabilities`,{headers:{Authorization:`Bearer ${this.apiKey}`}});if(!t.ok){let s=await this.extractErrorBody(t);throw new Error(`Failed to fetch capabilities: ${t.status} \u2014 ${s}`)}return t.json()}async healthCheck(){try{let t=await this.fetchWithTimeout(`${this.rootUrl}/health`,{},1e4);return t.ok?(await t.json()).status==="ok":!1}catch{return!1}}};var L=require("obsidian"),x=class{app;constructor(t){this.app=t}getActiveNoteContext(){let t=this.app.workspace.activeEditor;if(!t||!t.editor)return null;let s=this.app.workspace.activeLeaf;if(!s||!(s.view instanceof L.MarkdownView))return null;let e=s.view.file;return e?{vault:this.app.vault.getName(),path:e.path,title:e.basename}:null}snapshot(){let t=this.getActiveNoteContext();return t?{...t,selection:this.getSelectionContext()}:null}getSelectionContext(){let t=this.app.workspace.activeEditor;return!t||!t.editor?null:t.editor.getSelection()||null}formatContextMessage(t){return{role:"system",content:[`[Current note: ${t.vault}/${t.path}]`,`Use mcp_obsidian_get_vault_document(vault_name="${t.vault}", doc_path="${t.path}") to read the full contents.`].join(` +`)}}buildMessages(t,s){let e=[];if(s){let i=this.getSelectionContext();if(i){let n=this.getActiveNoteContext(),a=n?`${n.vault}/${n.path}`:"current note";e.push({role:"system",content:`[Selection from ${a}] + +${i}`})}else{let n=this.getActiveNoteContext();n&&e.push(this.formatContextMessage(n))}}return e.push({role:"user",content:t}),e}};var S=class extends P.Plugin{settings;statusBarItem=null;contextBridge=null;async onload(){await this.loadSettings(),this.registerView(g,t=>{let s=new v(this.getGatewayConfig()),e=new f(t,s,this.contextBridge);return e.setContextActive(this.settings.enableContextInjection),e.setSlashCommands(this.getAllCommands()),e}),this.addSettingTab(new C(this.app,this)),this.addRibbonIcon("message-square","Open Hermes Chat",()=>{this.activateView()}),this.settings.showModelInfo&&(this.statusBarItem=this.addStatusBarItem(),this.statusBarItem.setText("Hermes: connecting...")),this.contextBridge=new x(this.app),this.addCommand({id:"open-hermes-chat",name:"Open Hermes Chat",callback:()=>{this.activateView()}}),this.addCommand({id:"open-hermes-chat-new-tab",name:"Open Hermes Chat in New Tab",callback:()=>{this.openNewTab()}}),this.addCommand({id:"send-note-as-context",name:"Send Current Note as Context",callback:()=>{let t=this.contextBridge?.getActiveNoteContext();t?(this.activateView(),console.log(`Context ready: vault=${t.vault}, path=${t.path}`)):console.log("No active note to send as context")}}),this.statusBarItem&&(this.statusBarItem.setText("Hermes: checking..."),new v(this.getGatewayConfig()).healthCheck().then(s=>{this.statusBarItem&&this.statusBarItem.setText(s?"Hermes: connected":"Hermes: unreachable")}))}async onunload(){this.app.workspace.detachLeavesOfType(g)}async activateView(){let{workspace:t}=this.app,s=t.getLeavesOfType(g);if(s.length>0){t.revealLeaf(s[0]);return}let e=t.getRightLeaf(!1);e&&(await e.setViewState({type:g,active:!0}),t.revealLeaf(e))}async openNewTab(){let t=this.app.workspace.getRightLeaf(!1);t&&(await t.setViewState({type:g,active:!0}),this.app.workspace.revealLeaf(t))}getGatewayConfig(){return{baseUrl:this.settings.gatewayUrl,apiKey:this.settings.apiKey,model:"hermes-agent"}}async loadSettings(){this.settings=Object.assign({},$,await this.loadData())}async saveSettings(){await this.saveData(this.settings),this.syncSettingsToViews()}syncSettingsToViews(){let t=this.app.workspace.getLeavesOfType(g),s=this.getAllCommands();for(let e of t)e.view instanceof f&&(e.view.setContextActive(this.settings.enableContextInjection),e.view.setSlashCommands(s))}getAllCommands(){return[...D,...this.settings.customCommands]}}; diff --git a/src/chat-view.ts b/src/chat-view.ts index c8641d4..a6ac403 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -1,5 +1,5 @@ -import { ItemView, WorkspaceLeaf } from "obsidian"; -import type { Message, ChatState, ChatCompletionResponse } from "./types"; +import { ItemView, WorkspaceLeaf, MarkdownView } from "obsidian"; +import type { Message, ChatState, ChatCompletionResponse, SlashCommand } from "./types"; import { ContextBridge } from "./context-bridge"; // ApiClient interface — the concrete implementation (api-client.ts) is @@ -28,6 +28,21 @@ export class ChatView extends ItemView { private textarea: HTMLTextAreaElement | null = null; private sendButton: HTMLButtonElement | null = null; + /** Context captured on chat focus — avoids the timing bug where + * activeEditor is null by the time handleSend() runs. */ + private capturedNote: { + vault: string; + path: string; + title: string; + selection: string | null; + } | null = null; + + // --- Slash command state --- + private slashCommands: SlashCommand[] = []; + private suggestEl: HTMLElement | null = null; + private suggestIndex: number = -1; + private filteredCommands: SlashCommand[] = []; + constructor(leaf: WorkspaceLeaf, apiClient?: ApiClient, contextBridge?: ContextBridge) { super(leaf); this.apiClient = apiClient ?? null; @@ -58,6 +73,25 @@ export class ChatView extends ItemView { this.messagesContainer = container.createDiv("chat-messages"); this.messagesContainer.id = "chat-messages"; + // Capture note context on focus — this runs when the user clicks + // into chat from a markdown note, before activeEditor becomes null. + this.containerEl.addEventListener("focusin", () => { + if (this.contextBridge) { + this.capturedNote = this.contextBridge.snapshot(); + } + }); + + // Also capture when user switches to a different note while chat is + // already open (no focus change on chat itself, but we want the + // new note's context). + this.registerEvent( + this.app.workspace.on("active-leaf-change", (leaf) => { + if (leaf?.view instanceof MarkdownView && this.contextBridge) { + this.capturedNote = this.contextBridge.snapshot(); + } + }) + ); + // Loading indicator (hidden by default) this.loadingEl = this.messagesContainer.createDiv("chat-loading"); this.loadingEl.setText("Thinking\u2026"); @@ -69,17 +103,67 @@ export class ChatView extends ItemView { this.textarea = inputArea.createEl("textarea"); this.textarea.placeholder = "Type your message\u2026"; this.textarea.setAttribute("rows", "1"); + + // Keydown: handle Enter (send or autocomplete select), arrow keys, + // Escape (close suggest) this.textarea.addEventListener("keydown", (event: KeyboardEvent) => { + if (this.suggestEl && this.suggestEl.style.display !== "none") { + // Autocomplete is open — intercept navigation keys + if (event.key === "ArrowDown") { + event.preventDefault(); + this.suggestMove(1); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + this.suggestMove(-1); + return; + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (this.suggestIndex >= 0 && this.suggestIndex < this.filteredCommands.length) { + this.suggestSelect(this.filteredCommands[this.suggestIndex]); + } else { + this.suggestClose(); + } + return; + } + if (event.key === "Escape") { + event.preventDefault(); + this.suggestClose(); + return; + } + if (event.key === "Tab") { + event.preventDefault(); + if (this.suggestIndex >= 0 && this.suggestIndex < this.filteredCommands.length) { + this.suggestSelect(this.filteredCommands[this.suggestIndex]); + } + return; + } + } + + // Normal Enter = send if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); this.handleSend(); } }); + // Input event: detect '/' for slash command autocomplete + this.textarea.addEventListener("input", () => { + this.checkSlashTrigger(); + }); + this.sendButton = inputArea.createEl("button"); this.sendButton.setText("Send"); this.sendButton.addEventListener("click", () => this.handleSend()); + // --- Slash command suggestion dropdown --- + this.suggestEl = container.createDiv("hermes-slash-suggest"); + this.suggestEl.style.display = "none"; + this.suggestEl.style.position = "absolute"; + this.suggestEl.style.zIndex = "1000"; + // --- Status bar --- this.statusBar = container.createDiv("chat-status-bar"); this.updateStatus(this.chatState.model, null); @@ -103,6 +187,11 @@ export class ChatView extends ItemView { this.chatState.contextActive = active; } + /** Set available slash commands (built-in + custom from settings). */ + setSlashCommands(commands: SlashCommand[]): void { + this.slashCommands = commands; + } + /** Append a message bubble to the chat display and scroll to bottom. */ appendMessage(role: "user" | "assistant" | "system", content: string): void { if (!this.messagesContainer) return; @@ -167,13 +256,149 @@ export class ChatView extends ItemView { } } + // --------------------------------------------------------------------------- + // Slash command autocomplete + // --------------------------------------------------------------------------- + + /** Check if the textarea currently has a '/' trigger worth suggesting for. */ + private checkSlashTrigger(): void { + if (!this.textarea) return; + const value = this.textarea.value; + const cursorPos = this.textarea.selectionStart; + + // Find the start of the current "word" — the text from the last + // whitespace/newline/start to the cursor. + let wordStart = cursorPos - 1; + while (wordStart >= 0 && value[wordStart] !== " " && value[wordStart] !== "\n") { + wordStart--; + } + wordStart++; // first char of the current word + + const currentWord = value.slice(wordStart, cursorPos); + + // Only trigger if current word starts with '/' and we're at the beginning + // of a word (after whitespace/newline/start of input). + if (currentWord.startsWith("/") && (wordStart === 0 || value[wordStart - 1] === " " || value[wordStart - 1] === "\n")) { + const query = currentWord.slice(1).toLowerCase(); + this.filteredCommands = this.slashCommands.filter( + (cmd) => + cmd.trigger.toLowerCase().includes(query) || + cmd.description.toLowerCase().includes(query) + ); + if (this.filteredCommands.length > 0) { + this.suggestShow(); + return; + } + } + + this.suggestClose(); + } + + private suggestShow(): void { + if (!this.suggestEl || !this.textarea) return; + this.suggestIndex = -1; + this.suggestEl.empty(); + + for (let i = 0; i < this.filteredCommands.length; i++) { + const cmd = this.filteredCommands[i]; + const item = this.suggestEl.createDiv("hermes-slash-item"); + const triggerEl = item.createSpan("hermes-slash-trigger"); + triggerEl.setText(cmd.trigger); + const descEl = item.createSpan("hermes-slash-desc"); + descEl.setText(cmd.description); + + item.addEventListener("click", () => this.suggestSelect(cmd)); + item.addEventListener("mouseenter", () => { + this.suggestIndex = i; + this.suggestHighlight(); + }); + } + + // Position below textarea + const rect = this.textarea.getBoundingClientRect(); + const containerRect = this.containerEl.getBoundingClientRect(); + this.suggestEl.style.left = `${rect.left - containerRect.left}px`; + this.suggestEl.style.top = `${rect.bottom - containerRect.top}px`; + this.suggestEl.style.width = `${rect.width}px`; + this.suggestEl.style.display = "block"; + } + + private suggestClose(): void { + if (this.suggestEl) { + this.suggestEl.style.display = "none"; + this.suggestEl.empty(); + } + this.suggestIndex = -1; + this.filteredCommands = []; + } + + private suggestMove(delta: number): void { + if (this.filteredCommands.length === 0) return; + this.suggestIndex += delta; + if (this.suggestIndex < 0) this.suggestIndex = this.filteredCommands.length - 1; + if (this.suggestIndex >= this.filteredCommands.length) this.suggestIndex = 0; + this.suggestHighlight(); + } + + private suggestHighlight(): void { + if (!this.suggestEl) return; + const items = this.suggestEl.querySelectorAll(".hermes-slash-item"); + items.forEach((item, i) => { + if (i === this.suggestIndex) { + item.addClass("hermes-slash-item-active"); + } else { + item.removeClass("hermes-slash-item-active"); + } + }); + } + + /** Select a slash command: fill the textarea with the full prompt. */ + private suggestSelect(cmd: SlashCommand): void { + if (!this.textarea) return; + const value = this.textarea.value; + const cursorPos = this.textarea.selectionStart; + + // Find the word boundaries of the '/' trigger + let wordStart = cursorPos - 1; + while (wordStart >= 0 && value[wordStart] !== " " && value[wordStart] !== "\n") { + wordStart--; + } + wordStart++; + + // Replace the /trigger with the full prompt + const before = value.slice(0, wordStart); + const after = value.slice(cursorPos); + this.textarea.value = before + cmd.prompt + after; + this.suggestClose(); + this.textarea.focus(); + } + + /** Resolve slash commands in user input. If input starts with a known + * trigger, returns the full prompt. Otherwise returns input unchanged. */ + private resolveSlashCommand(input: string): string { + const trimmed = input.trim(); + for (const cmd of this.slashCommands) { + if (trimmed.startsWith(cmd.trigger)) { + return cmd.prompt + trimmed.slice(cmd.trigger.length); + } + } + return input; + } + + // --------------------------------------------------------------------------- + // Send + // --------------------------------------------------------------------------- + private async handleSend(): Promise { if (!this.textarea) return; - const userInput = this.textarea.value.trim(); - if (!userInput) return; + const rawInput = this.textarea.value.trim(); + if (!rawInput) return; this.textarea.value = ""; - // Display user message + // Resolve slash commands to full prompts before sending + const userInput = this.resolveSlashCommand(rawInput); + + // Display user message (show the resolved prompt, not the /trigger) this.appendMessage("user", userInput); // Check API client availability @@ -199,10 +424,34 @@ export class ChatView extends ItemView { let fullContent = ""; try { - // Build messages with optional note context - const messages = this.contextBridge - ? this.contextBridge.buildMessages(userInput, this.chatState.contextActive) - : [{ role: "user", content: userInput }]; + // Build messages using the captured note context (grabbed on focus, + // before activeEditor became null). Falls back to no context if + // nothing was captured. + let messages: { role: string; content: string }[]; + if (this.chatState.contextActive && this.capturedNote) { + const cn = this.capturedNote; + if (cn.selection) { + // Selection embedded directly — small, intentional, works anywhere + messages = [{ + role: "system", + content: `[Selection from ${cn.vault}/${cn.path}]\n\n${cn.selection}`, + }, { role: "user", content: userInput }]; + } else { + // Path reference — Hermes reads the file via MCP on Helm + messages = [{ + role: "system", + content: [ + `[Current note: ${cn.vault}/${cn.path}]`, + `Use mcp_obsidian_get_vault_document(vault_name="${cn.vault}", doc_path="${cn.path}") to read the full contents.`, + ].join("\n"), + }, { role: "user", content: userInput }]; + } + } else { + // No context captured (or injection disabled) — plain user message + messages = this.contextBridge + ? this.contextBridge.buildMessages(userInput, this.chatState.contextActive) + : [{ role: "user", content: userInput }]; + } const response = await this.apiClient!.streamChat( messages, (delta: string) => { diff --git a/src/context-bridge.ts b/src/context-bridge.ts index 7a274cf..227513f 100644 --- a/src/context-bridge.ts +++ b/src/context-bridge.ts @@ -43,6 +43,27 @@ export class ContextBridge { }; } + /** + * Snapshot the active note context AND selection in one atomic read. + * Call this when the chat view gains focus — captures the note state + * before the user's focus move clears activeEditor / deselects text. + * + * Returns null if no markdown editor is active. + */ + snapshot(): { + vault: string; + path: string; + title: string; + selection: string | null; + } | null { + const noteRef = this.getActiveNoteContext(); + if (!noteRef) return null; + return { + ...noteRef, + selection: this.getSelectionContext(), + }; + } + /** * Get the currently selected text in the active editor. * Returns null if no text is selected. diff --git a/src/main.ts b/src/main.ts index b90a2c0..4b0f7c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,25 +1,27 @@ import { Plugin, WorkspaceLeaf } from "obsidian"; -import { HermesChatSettingTab, HermesChatSettings, DEFAULT_SETTINGS } from "./settings"; +import { HermesChatSettingTab, HermesChatSettings, DEFAULT_SETTINGS, BUILTIN_COMMANDS } from "./settings"; import { ChatView, VIEW_TYPE } from "./chat-view"; import { ApiClient } from "./api-client"; import { ContextBridge } from "./context-bridge"; -import type { GatewayConfig } from "./types"; +import type { GatewayConfig, SlashCommand } from "./types"; export default class HermesChatPlugin extends Plugin { settings: HermesChatSettings; private statusBarItem: HTMLElement | null = null; - private apiClient: ApiClient | null = null; private contextBridge: ContextBridge | null = null; async onload() { await this.loadSettings(); - // Register the chat view + // Register the chat view — each instance gets its own ApiClient so + // multiple tabs maintain independent sessions with Hermes. this.registerView( VIEW_TYPE, (leaf: WorkspaceLeaf) => { - const view = new ChatView(leaf, this.apiClient!, this.contextBridge!); + const client = new ApiClient(this.getGatewayConfig()); + const view = new ChatView(leaf, client, this.contextBridge!); view.setContextActive(this.settings.enableContextInjection); + view.setSlashCommands(this.getAllCommands()); return view; } ); @@ -27,7 +29,7 @@ export default class HermesChatPlugin extends Plugin { // Add settings tab this.addSettingTab(new HermesChatSettingTab(this.app, this)); - // Ribbon icon to activate chat view + // Ribbon icon to activate chat view (reveals existing or opens new) this.addRibbonIcon("message-square", "Open Hermes Chat", () => { this.activateView(); }); @@ -38,16 +40,10 @@ export default class HermesChatPlugin extends Plugin { this.statusBarItem.setText("Hermes: connecting..."); } - // Initialize API client from settings - const gatewayConfig: GatewayConfig = { - baseUrl: this.settings.gatewayUrl, - apiKey: this.settings.apiKey, - model: "hermes-agent", - }; - this.apiClient = new ApiClient(gatewayConfig); + // Initialize shared context bridge (stateless — reads app.workspace) this.contextBridge = new ContextBridge(this.app); - // Command: Open Hermes Chat + // Command: Open Hermes Chat (reveal existing or open new) this.addCommand({ id: "open-hermes-chat", name: "Open Hermes Chat", @@ -56,6 +52,15 @@ export default class HermesChatPlugin extends Plugin { }, }); + // Command: Open Hermes Chat in New Tab (always fresh session) + this.addCommand({ + id: "open-hermes-chat-new-tab", + name: "Open Hermes Chat in New Tab", + callback: () => { + this.openNewTab(); + }, + }); + // Command: Send Current Note as Context this.addCommand({ id: "send-note-as-context", @@ -72,10 +77,11 @@ export default class HermesChatPlugin extends Plugin { }, }); - // Check gateway connectivity - if (this.statusBarItem && this.apiClient) { + // Check gateway connectivity (one-off probe — use a throwaway client) + if (this.statusBarItem) { this.statusBarItem.setText("Hermes: checking..."); - this.apiClient.healthCheck().then((healthy) => { + const probe = new ApiClient(this.getGatewayConfig()); + probe.healthCheck().then((healthy) => { if (this.statusBarItem) { this.statusBarItem.setText(healthy ? "Hermes: connected" : "Hermes: unreachable"); } @@ -106,22 +112,47 @@ export default class HermesChatPlugin extends Plugin { } } + /** Always open a fresh ChatView in a new leaf — separate session. */ + async openNewTab(): Promise { + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ type: VIEW_TYPE, active: true }); + this.app.workspace.revealLeaf(leaf); + } + } + + /** Build a GatewayConfig from current plugin settings. */ + private getGatewayConfig(): GatewayConfig { + return { + baseUrl: this.settings.gatewayUrl, + apiKey: this.settings.apiKey, + model: "hermes-agent", + }; + } + async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); - this.syncContextToViews(); + this.syncSettingsToViews(); } - /** Push enableContextInjection setting to any open ChatViews. */ - private syncContextToViews(): void { + /** Push settings changes (context toggle, slash commands) to all open ChatViews. */ + private syncSettingsToViews(): void { const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); + const commands = this.getAllCommands(); for (const leaf of leaves) { if (leaf.view instanceof ChatView) { leaf.view.setContextActive(this.settings.enableContextInjection); + leaf.view.setSlashCommands(commands); } } } + + /** Merge built-in commands with user-defined custom commands. */ + private getAllCommands(): SlashCommand[] { + return [...BUILTIN_COMMANDS, ...this.settings.customCommands]; + } } diff --git a/src/settings.ts b/src/settings.ts index 36a6ce8..3f7e32e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,13 +6,34 @@ export interface HermesChatSettings { apiKey: string; showModelInfo: boolean; enableContextInjection: boolean; + customCommands: { trigger: string; description: string; prompt: string }[]; } +/** Built-in slash commands shipped with the plugin. */ +export const BUILTIN_COMMANDS: { trigger: string; description: string; prompt: string }[] = [ + { + trigger: "/prep-session", + description: "Prep the next D&D session", + prompt: "Run the full session-prep pipeline for the next SKT session: scan recent session notes, identify loose threads, generate a session guide with modular flow-chart sections.", + }, + { + trigger: "/sync-content", + description: "Sync Content Sink notes to vault", + prompt: "/run content-sink-sync", + }, + { + trigger: "/lint-wiki", + description: "Lint the campaign wiki", + prompt: "Scan the campaign wiki for broken links, orphaned entities, missing templates, and formatting inconsistencies. Report a prioritized list of issues.", + }, +]; + export const DEFAULT_SETTINGS: HermesChatSettings = { gatewayUrl: "http://192.168.1.12:8642/v1", apiKey: "0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b", showModelInfo: true, enableContextInjection: true, + customCommands: [], }; export class HermesChatSettingTab extends PluginSettingTab { @@ -79,5 +100,34 @@ export class HermesChatSettingTab extends PluginSettingTab { await this.plugin.saveData(this.plugin.settings); }) ); + + // --- Slash commands --- + containerEl.createEl("h3", { text: "Slash Commands" }); + containerEl.createEl("p", { + text: "Type / in the chat input to see available commands. Built-in: /prep-session, /sync-content, /lint-wiki. Add custom commands below as JSON.", + }); + + const commandsDesc = containerEl.createDiv("setting-item-description"); + commandsDesc.setText( + 'Format: [{"trigger":"/my-cmd","description":"What it does","prompt":"Full prompt text..."}]' + ); + + const textarea = containerEl.createEl("textarea"); + textarea.setAttribute("rows", "6"); + textarea.style.width = "100%"; + textarea.style.fontFamily = "monospace"; + textarea.style.fontSize = "12px"; + textarea.value = JSON.stringify(this.plugin.settings.customCommands, null, 2); + textarea.addEventListener("change", async () => { + try { + const parsed = JSON.parse(textarea.value); + if (Array.isArray(parsed)) { + this.plugin.settings.customCommands = parsed; + await this.plugin.saveData(this.plugin.settings); + } + } catch { + // Invalid JSON — don't save, user is still typing + } + }); } } diff --git a/src/types.ts b/src/types.ts index 3d499ce..b4d2a21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,12 @@ export interface ChatState { contextActive: boolean; } +export interface SlashCommand { + trigger: string; // e.g. "/prep-session" + description: string; // e.g. "Prep the next D&D session" + prompt: string; // the full prompt sent to Hermes +} + export interface ChatCompletionResponse { content: string; sessionId: string; diff --git a/styles.css b/styles.css index 7b8a38a..42c2248 100644 --- a/styles.css +++ b/styles.css @@ -136,4 +136,46 @@ .chat-messages::-webkit-scrollbar-thumb:hover { background: var(--text-muted); +} + +/* --- Slash command autocomplete --- */ +.hermes-slash-suggest { + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-height: 200px; + overflow-y: auto; + min-width: 200px; +} + +.hermes-slash-item { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + cursor: pointer; + transition: background 0.1s; +} + +.hermes-slash-item:hover, +.hermes-slash-item-active { + background: var(--background-modifier-hover); +} + +.hermes-slash-trigger { + font-family: var(--font-monospace); + font-size: 0.8rem; + font-weight: 600; + color: var(--interactive-accent); + flex-shrink: 0; + min-width: 80px; +} + +.hermes-slash-desc { + font-size: 0.8rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } \ No newline at end of file