Initial commit: Obsidian Hermes Chat plugin
- Streaming chat sidebar with OpenAI-compatible API client - Path-referencing context injection via MCP tools - Dark theme styling with Obsidian CSS variables - Settings: gateway URL, API key, context/model toggles - SSE streaming with fetch timeout protection - Session continuity via X-Hermes-Session-Id headers
This commit is contained in:
commit
3b35f59ec8
14 changed files with 1894 additions and 0 deletions
115
README.md
Normal file
115
README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Hermes Chat — Obsidian Plugin
|
||||||
|
|
||||||
|
Chat with Hermes Agent directly from Obsidian via REST API.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Obsidian** 1.7.2 or later
|
||||||
|
- **Hermes Gateway** with the API Server adapter enabled and running
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Build the plugin:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Copy the plugin folder into your Obsidian vault:
|
||||||
|
```
|
||||||
|
<vault>/.obsidian/plugins/obsidian-hermes-plugin/
|
||||||
|
```
|
||||||
|
|
||||||
|
Required files in the plugin folder:
|
||||||
|
- `main.js` (built output)
|
||||||
|
- `manifest.json`
|
||||||
|
- `styles.css` (dark theme styling)
|
||||||
|
|
||||||
|
3. Restart Obsidian or reload plugins:
|
||||||
|
- **Settings → Community plugins →** enable **Hermes Chat**
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
After enabling the plugin, open **Settings → Hermes Chat**:
|
||||||
|
|
||||||
|
| Setting | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| **Gateway URL** | Base URL of your Hermes Gateway API server | `http://192.168.1.12:8642/v1` |
|
||||||
|
| **API Key** | API key for authenticating with the Hermes Gateway | (pre-filled) |
|
||||||
|
| **Show model info** | Display the current model name in the status bar | Enabled |
|
||||||
|
| **Enable context injection** | Automatically send the active note as context | Enabled |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Opening the Chat
|
||||||
|
|
||||||
|
- **Ribbon icon:** Click the message-square icon in the left ribbon
|
||||||
|
- **Keyboard:** `Ctrl+Shift+H` (customizable in **Settings → Hotkeys** → "Open Hermes Chat")
|
||||||
|
- **Command palette:** `Ctrl+P` → "Open Hermes Chat"
|
||||||
|
|
||||||
|
The chat opens as a sidebar panel on the right. You can resize it by dragging the edge.
|
||||||
|
|
||||||
|
### Sending Messages
|
||||||
|
|
||||||
|
Type in the input area at the bottom and press **Enter** to send. Press **Shift+Enter** for a newline. Responses stream in token-by-token — you'll see Hermes's reply appear in real time.
|
||||||
|
|
||||||
|
The status bar at the bottom of the chat shows the current model and session ID.
|
||||||
|
|
||||||
|
### Context Injection (Sharing Notes with Hermes)
|
||||||
|
|
||||||
|
Instead of sending your entire note content with every message (burning context tokens), the plugin sends a **vault-relative path reference**. Hermes reads the file on-demand from Helm using `mcp_obsidian_get_vault_document` — zero context wasted, same result.
|
||||||
|
|
||||||
|
This works seamlessly when you edit your vault via SMB from LPC. Hermes and Obsidian both see the same files at the same paths.
|
||||||
|
|
||||||
|
There are two ways to trigger context injection:
|
||||||
|
|
||||||
|
#### Automatic (recommended)
|
||||||
|
|
||||||
|
Enable **"Enable context injection"** in plugin settings and keep your active note open. When you send a message, the plugin prepends a system message like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Current note: obsidian-skt-revised/01 - Sessions/Session 67.md]
|
||||||
|
Use mcp_obsidian_get_vault_document(vault_name="obsidian-skt-revised", doc_path="01 - Sessions/Session 67.md") to read the full contents.
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermes then reads the file on Helm and responds with full awareness of your note — no copy-paste, no wasted tokens.
|
||||||
|
|
||||||
|
**Toggle it off** when you want a clean chat without note context.
|
||||||
|
|
||||||
|
#### Manual (one-shot)
|
||||||
|
|
||||||
|
Use **"Send Current Note as Context"** from the command palette (`Ctrl+P`). This opens the chat sidebar with context queued for your next message.
|
||||||
|
|
||||||
|
#### Selection-only (embedded)
|
||||||
|
|
||||||
|
If you select text before sending, the plugin embeds your selection directly instead of sending a path reference. This is useful for quick questions about a specific passage — the selection is small and intentional, so embedding it costs few tokens.
|
||||||
|
|
||||||
|
### Session Continuity
|
||||||
|
|
||||||
|
The plugin maintains a persistent session with Hermes. Your conversation history is preserved across messages — Hermes remembers what you discussed earlier in the session, just like the CLI. The session ID is visible in the status bar.
|
||||||
|
|
||||||
|
### Status Bar
|
||||||
|
|
||||||
|
| Message | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `Hermes: connected` | Gateway is reachable and healthy |
|
||||||
|
| `Hermes: unreachable` | Gateway is down or unreachable — check that the API server is running on Helm |
|
||||||
|
| `Hermes: checking...` | Plugin is verifying connectivity on startup |
|
||||||
|
| `Model: hermes-agent \| Session: api-abc123` | Shown in the chat sidebar footer — current model and session |
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| **Gateway URL** | Base URL of your Hermes Gateway API server | `http://192.168.1.12:8642/v1` |
|
||||||
|
| **API Key** | API key for authenticating with the Hermes Gateway | (pre-configured) |
|
||||||
|
| **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 |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # watch mode — rebuilds on changes
|
||||||
|
npm run build # production build (minified, no sourcemap)
|
||||||
|
```
|
||||||
25
esbuild.config.mjs
Normal file
25
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import esbuild from "esbuild";
|
||||||
|
import process from "process";
|
||||||
|
|
||||||
|
const prod = process.argv.includes("production");
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
entryPoints: ["src/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
platform: "node",
|
||||||
|
external: ["obsidian"],
|
||||||
|
outfile: "main.js",
|
||||||
|
sourcemap: prod ? false : "inline",
|
||||||
|
minify: prod,
|
||||||
|
treeShaking: true,
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prod) {
|
||||||
|
await context.rebuild();
|
||||||
|
console.log("Production build complete.");
|
||||||
|
await context.dispose();
|
||||||
|
} else {
|
||||||
|
await context.watch();
|
||||||
|
console.log("Watching for changes...");
|
||||||
|
}
|
||||||
8
main.js
Normal file
8
main.js
Normal file
File diff suppressed because one or more lines are too long
8
manifest.json
Normal file
8
manifest.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"id": "hermes-chat",
|
||||||
|
"name": "Hermes Chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "1.7.2",
|
||||||
|
"author": "Lukas Parsons",
|
||||||
|
"description": "Chat with Hermes Agent directly from Obsidian via REST API"
|
||||||
|
}
|
||||||
606
package-lock.json
generated
Normal file
606
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,606 @@
|
||||||
|
{
|
||||||
|
"name": "obsidian-hermes-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "obsidian-hermes-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"esbuild": "^0.23.0",
|
||||||
|
"obsidian": "latest",
|
||||||
|
"typescript": "~5.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.38.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||||
|
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.5.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/codemirror": {
|
||||||
|
"version": "5.60.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
|
||||||
|
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/tern": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/estree": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/tern": {
|
||||||
|
"version": "0.23.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||||
|
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.23.1",
|
||||||
|
"@esbuild/android-arm": "0.23.1",
|
||||||
|
"@esbuild/android-arm64": "0.23.1",
|
||||||
|
"@esbuild/android-x64": "0.23.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.23.1",
|
||||||
|
"@esbuild/darwin-x64": "0.23.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.23.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.23.1",
|
||||||
|
"@esbuild/linux-arm": "0.23.1",
|
||||||
|
"@esbuild/linux-arm64": "0.23.1",
|
||||||
|
"@esbuild/linux-ia32": "0.23.1",
|
||||||
|
"@esbuild/linux-loong64": "0.23.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.23.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.23.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.23.1",
|
||||||
|
"@esbuild/linux-s390x": "0.23.1",
|
||||||
|
"@esbuild/linux-x64": "0.23.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.23.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.23.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.23.1",
|
||||||
|
"@esbuild/sunos-x64": "0.23.1",
|
||||||
|
"@esbuild/win32-arm64": "0.23.1",
|
||||||
|
"@esbuild/win32-ia32": "0.23.1",
|
||||||
|
"@esbuild/win32-x64": "0.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/moment": {
|
||||||
|
"version": "2.29.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||||
|
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/obsidian": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/codemirror": "5.60.8",
|
||||||
|
"moment": "2.29.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@codemirror/state": "6.5.0",
|
||||||
|
"@codemirror/view": "6.38.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "obsidian-hermes-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Chat with Hermes Agent directly from Obsidian via REST API",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node esbuild.config.mjs",
|
||||||
|
"build": "node esbuild.config.mjs production"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"esbuild": "^0.23.0",
|
||||||
|
"obsidian": "latest",
|
||||||
|
"typescript": "~5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
378
src/api-client.ts
Normal file
378
src/api-client.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
import type { GatewayConfig, ChatCompletionResponse } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client for the Hermes Gateway's OpenAI-compatible REST API.
|
||||||
|
* Handles Bearer auth, SSE streaming, session header continuity,
|
||||||
|
* and model/capabilities discovery.
|
||||||
|
*
|
||||||
|
* Uses the standard fetch() API available in Obsidian's Electron runtime.
|
||||||
|
*/
|
||||||
|
export class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private apiKey: string;
|
||||||
|
private model: string;
|
||||||
|
private currentSessionId: string | null;
|
||||||
|
|
||||||
|
constructor(config: GatewayConfig) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/+$/, ""); // strip trailing slashes
|
||||||
|
this.apiKey = config.apiKey;
|
||||||
|
this.model = config.model;
|
||||||
|
this.currentSessionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update configuration without recreating the client. Preserves session. */
|
||||||
|
updateConfig(config: Partial<GatewayConfig>): void {
|
||||||
|
if (config.baseUrl !== undefined) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
if (config.apiKey !== undefined) {
|
||||||
|
this.apiKey = config.apiKey;
|
||||||
|
}
|
||||||
|
if (config.model !== undefined) {
|
||||||
|
this.model = config.model;
|
||||||
|
}
|
||||||
|
// Do NOT reset currentSessionId — preserve conversation continuity
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current session ID, if one has been established. */
|
||||||
|
getSessionId(): string | null {
|
||||||
|
return this.currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The root URL without the /v1 suffix, e.g. http://192.168.1.12:8642 */
|
||||||
|
private get rootUrl(): string {
|
||||||
|
// baseUrl ends with /v1; strip it to reach the server root
|
||||||
|
if (this.baseUrl.endsWith("/v1")) {
|
||||||
|
return this.baseUrl.slice(0, -3);
|
||||||
|
}
|
||||||
|
return new URL("/", this.baseUrl).href.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with a configurable timeout. Aborts the request if it takes
|
||||||
|
* longer than `timeoutMs` milliseconds.
|
||||||
|
*/
|
||||||
|
private async fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
timeoutMs: number = 60000
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a useful error message from a non-OK response.
|
||||||
|
* Tries JSON first, falls back to plain text (truncated), and finally
|
||||||
|
* to the HTTP status line.
|
||||||
|
*/
|
||||||
|
private async extractErrorBody(response: Response): Promise<string> {
|
||||||
|
let detail = "";
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
// Only attempt JSON if the server says it sent JSON
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
const errBody = await response.json();
|
||||||
|
if (errBody.error?.message) {
|
||||||
|
detail = errBody.error.message;
|
||||||
|
} else if (typeof errBody.message === "string") {
|
||||||
|
detail = errBody.message;
|
||||||
|
} else if (typeof errBody.error === "string") {
|
||||||
|
detail = errBody.error;
|
||||||
|
}
|
||||||
|
if (detail) return detail;
|
||||||
|
} catch {
|
||||||
|
// JSON parsing failed — fall through to text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try reading as plain text (e.g. HTML error page, gateway nginx page)
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.trim()) {
|
||||||
|
// Truncate to avoid dumping huge HTML pages into error messages
|
||||||
|
detail = text.trim().substring(0, 300);
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Body isn't readable at all
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultimate fallback
|
||||||
|
return response.statusText || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a chat completion request (non-streaming).
|
||||||
|
* Returns the assistant's response with session and usage metadata.
|
||||||
|
*/
|
||||||
|
async chat(
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
stream: boolean = false
|
||||||
|
): Promise<ChatCompletionResponse> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.currentSessionId) {
|
||||||
|
headers["X-Hermes-Session-Id"] = this.currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.baseUrl}/chat/completions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages,
|
||||||
|
stream,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.extractErrorBody(response);
|
||||||
|
throw new Error(`API error ${response.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture session ID from response headers for continuity
|
||||||
|
const sessionId = response.headers.get("X-Hermes-Session-Id");
|
||||||
|
if (sessionId) {
|
||||||
|
this.currentSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error.message || "Unknown API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = data.choices?.[0];
|
||||||
|
const content: string = choice?.message?.content ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
sessionId: this.currentSessionId ?? "",
|
||||||
|
model: data.model ?? this.model,
|
||||||
|
usage: data.usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a streaming chat completion request.
|
||||||
|
* Calls `onDelta` with each text chunk as it arrives via SSE.
|
||||||
|
* Returns the final ChatCompletionResponse after the stream concludes.
|
||||||
|
*/
|
||||||
|
async streamChat(
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
onDelta: (text: string) => void
|
||||||
|
): Promise<ChatCompletionResponse> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.currentSessionId) {
|
||||||
|
headers["X-Hermes-Session-Id"] = this.currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.baseUrl}/chat/completions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.extractErrorBody(response);
|
||||||
|
throw new Error(`API error ${response.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture session ID from response headers
|
||||||
|
const sessionId = response.headers.get("X-Hermes-Session-Id");
|
||||||
|
if (sessionId) {
|
||||||
|
this.currentSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the SSE stream
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
let fullContent = "";
|
||||||
|
let model = this.model;
|
||||||
|
let usage: ChatCompletionResponse["usage"];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// SSE events are separated by double newlines
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
// The last part may be incomplete; keep it in the buffer
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const lines = part.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
const jsonStr = line.slice(6).trim();
|
||||||
|
|
||||||
|
if (jsonStr === "[DONE]") {
|
||||||
|
// Stream finished signal — continue processing any remaining data
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(jsonStr);
|
||||||
|
|
||||||
|
if (event.error) {
|
||||||
|
throw new Error(event.error.message || "Stream error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = event.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
fullContent += delta;
|
||||||
|
onDelta(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture model and usage from the final event
|
||||||
|
if (event.model) {
|
||||||
|
model = event.model;
|
||||||
|
}
|
||||||
|
if (event.usage) {
|
||||||
|
usage = event.usage;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If it's our own thrown error, rethrow
|
||||||
|
if (
|
||||||
|
e instanceof Error &&
|
||||||
|
e.message !== "Unexpected token" &&
|
||||||
|
!e.message.includes("JSON")
|
||||||
|
) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// Otherwise skip malformed SSE lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any remaining data in the buffer
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
const jsonStr = line.slice(6).trim();
|
||||||
|
if (jsonStr === "[DONE]") continue;
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(jsonStr);
|
||||||
|
const delta = event.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
fullContent += delta;
|
||||||
|
onDelta(delta);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: fullContent,
|
||||||
|
sessionId: this.currentSessionId ?? "",
|
||||||
|
model,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available models from the gateway.
|
||||||
|
* GET /v1/models
|
||||||
|
*/
|
||||||
|
async getModels(): Promise<{ id: string }[]> {
|
||||||
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.baseUrl}/models`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.extractErrorBody(response);
|
||||||
|
throw new Error(`Failed to fetch models: ${response.status} — ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch gateway capabilities.
|
||||||
|
* GET /v1/capabilities
|
||||||
|
*/
|
||||||
|
async getCapabilities(): Promise<any> {
|
||||||
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.baseUrl}/capabilities`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.extractErrorBody(response);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch capabilities: ${response.status} — ${detail}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check against the gateway root.
|
||||||
|
* GET /health → returns true if status === "ok"
|
||||||
|
* Uses a shorter 10-second timeout since this is a liveness probe.
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(
|
||||||
|
`${this.rootUrl}/health`,
|
||||||
|
{},
|
||||||
|
10000 // 10-second timeout for health checks
|
||||||
|
);
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json();
|
||||||
|
return data.status === "ok";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/chat-view.ts
Normal file
227
src/chat-view.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||||
|
import type { Message, ChatState, ChatCompletionResponse } from "./types";
|
||||||
|
import { ContextBridge } from "./context-bridge";
|
||||||
|
|
||||||
|
// ApiClient interface — the concrete implementation (api-client.ts) is
|
||||||
|
// instantiated with GatewayConfig and passed to ChatView via setApiClient().
|
||||||
|
export interface ApiClient {
|
||||||
|
chat(
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
stream?: boolean
|
||||||
|
): Promise<ChatCompletionResponse>;
|
||||||
|
|
||||||
|
streamChat(
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
onDelta: (text: string) => void
|
||||||
|
): Promise<ChatCompletionResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VIEW_TYPE = "hermes-chat-view";
|
||||||
|
|
||||||
|
export class ChatView extends ItemView {
|
||||||
|
private apiClient: ApiClient | null = null;
|
||||||
|
private contextBridge: ContextBridge | null = null;
|
||||||
|
private chatState: ChatState;
|
||||||
|
private messagesContainer: HTMLElement | null = null;
|
||||||
|
private statusBar: HTMLElement | null = null;
|
||||||
|
private loadingEl: HTMLElement | null = null;
|
||||||
|
private textarea: HTMLTextAreaElement | null = null;
|
||||||
|
private sendButton: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf, apiClient?: ApiClient, contextBridge?: ContextBridge) {
|
||||||
|
super(leaf);
|
||||||
|
this.apiClient = apiClient ?? null;
|
||||||
|
this.contextBridge = contextBridge ?? null;
|
||||||
|
this.chatState = {
|
||||||
|
sessionId: null,
|
||||||
|
messages: [],
|
||||||
|
isLoading: false,
|
||||||
|
model: "hermes-agent",
|
||||||
|
contextActive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return VIEW_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return "Hermes";
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen(): Promise<void> {
|
||||||
|
const container = this.containerEl.children[1];
|
||||||
|
container.empty();
|
||||||
|
container.addClass("hermes-chat-container");
|
||||||
|
|
||||||
|
// --- Message list area ---
|
||||||
|
this.messagesContainer = container.createDiv("chat-messages");
|
||||||
|
this.messagesContainer.id = "chat-messages";
|
||||||
|
|
||||||
|
// Loading indicator (hidden by default)
|
||||||
|
this.loadingEl = this.messagesContainer.createDiv("chat-loading");
|
||||||
|
this.loadingEl.setText("Thinking\u2026");
|
||||||
|
this.loadingEl.style.display = "none";
|
||||||
|
|
||||||
|
// --- Input area ---
|
||||||
|
const inputArea = container.createDiv("chat-input-area");
|
||||||
|
|
||||||
|
this.textarea = inputArea.createEl("textarea");
|
||||||
|
this.textarea.placeholder = "Type your message\u2026";
|
||||||
|
this.textarea.setAttribute("rows", "1");
|
||||||
|
this.textarea.addEventListener("keydown", (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleSend();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sendButton = inputArea.createEl("button");
|
||||||
|
this.sendButton.setText("Send");
|
||||||
|
this.sendButton.addEventListener("click", () => this.handleSend());
|
||||||
|
|
||||||
|
// --- Status bar ---
|
||||||
|
this.statusBar = container.createDiv("chat-status-bar");
|
||||||
|
this.updateStatus(this.chatState.model, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClose(): Promise<void> {
|
||||||
|
// No persistent cleanup needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Set the API client for real streaming calls (called by main.ts). */
|
||||||
|
setApiClient(client: ApiClient): void {
|
||||||
|
this.apiClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle whether context (e.g. note content) should be sent with requests. */
|
||||||
|
setContextActive(active: boolean): void {
|
||||||
|
this.chatState.contextActive = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a message bubble to the chat display and scroll to bottom. */
|
||||||
|
appendMessage(role: "user" | "assistant" | "system", content: string): void {
|
||||||
|
if (!this.messagesContainer) return;
|
||||||
|
|
||||||
|
const wrapper = this.messagesContainer.createDiv("chat-message");
|
||||||
|
wrapper.addClass(`chat-message-${role}`);
|
||||||
|
|
||||||
|
const roleLabel = wrapper.createDiv("chat-message-role");
|
||||||
|
roleLabel.setText(
|
||||||
|
role === "user" ? "You" : role === "assistant" ? "Hermes" : "System"
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentEl = wrapper.createDiv("chat-message-content");
|
||||||
|
contentEl.setText(content);
|
||||||
|
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show or hide the "Thinking\u2026" loading indicator and lock input. */
|
||||||
|
setLoading(isLoading: boolean): void {
|
||||||
|
this.chatState.isLoading = isLoading;
|
||||||
|
if (this.loadingEl) {
|
||||||
|
this.loadingEl.style.display = isLoading ? "block" : "none";
|
||||||
|
}
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.disabled = isLoading;
|
||||||
|
}
|
||||||
|
if (this.sendButton) {
|
||||||
|
this.sendButton.disabled = isLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the bottom status bar with current model and session info. */
|
||||||
|
updateStatus(model: string, sessionId: string | null): void {
|
||||||
|
this.chatState.model = model;
|
||||||
|
this.chatState.sessionId = sessionId;
|
||||||
|
if (this.statusBar) {
|
||||||
|
const sessionPart = sessionId ? `Session: ${sessionId}` : "Session: \u2014";
|
||||||
|
this.statusBar.setText(`Model: ${model} | ${sessionPart}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all messages from the display. */
|
||||||
|
clearChat(): void {
|
||||||
|
if (!this.messagesContainer) return;
|
||||||
|
const children = Array.from(this.messagesContainer.children);
|
||||||
|
for (const child of children) {
|
||||||
|
if (child !== this.loadingEl) {
|
||||||
|
child.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chatState.messages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
if (this.messagesContainer) {
|
||||||
|
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSend(): Promise<void> {
|
||||||
|
if (!this.textarea) return;
|
||||||
|
const userInput = this.textarea.value.trim();
|
||||||
|
if (!userInput) return;
|
||||||
|
this.textarea.value = "";
|
||||||
|
|
||||||
|
// Display user message
|
||||||
|
this.appendMessage("user", userInput);
|
||||||
|
|
||||||
|
// Check API client availability
|
||||||
|
if (!this.apiClient) {
|
||||||
|
this.appendMessage(
|
||||||
|
"assistant",
|
||||||
|
"[API client not configured. Check plugin settings.]"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setLoading(true);
|
||||||
|
|
||||||
|
// Create a placeholder div for the streaming assistant response
|
||||||
|
const assistantMsgDiv = this.messagesContainer?.createDiv(
|
||||||
|
"chat-message chat-message-assistant"
|
||||||
|
);
|
||||||
|
const roleLabel = assistantMsgDiv?.createDiv("chat-message-role");
|
||||||
|
if (roleLabel) roleLabel.setText("Hermes");
|
||||||
|
const contentDiv = assistantMsgDiv?.createDiv("chat-message-content");
|
||||||
|
if (contentDiv) contentDiv.setText("");
|
||||||
|
|
||||||
|
let fullContent = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build messages with optional note context
|
||||||
|
const messages = this.contextBridge
|
||||||
|
? this.contextBridge.buildMessages(userInput, this.chatState.contextActive)
|
||||||
|
: [{ role: "user", content: userInput }];
|
||||||
|
const response = await this.apiClient!.streamChat(
|
||||||
|
messages,
|
||||||
|
(delta: string) => {
|
||||||
|
fullContent += delta;
|
||||||
|
if (contentDiv) contentDiv.setText(fullContent);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.sessionId) {
|
||||||
|
this.chatState.sessionId = response.sessionId;
|
||||||
|
this.updateStatus(response.model, response.sessionId);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (contentDiv) {
|
||||||
|
contentDiv.setText(`Error: ${error.message || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/context-bridge.ts
Normal file
113
src/context-bridge.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { App, MarkdownView } from "obsidian";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the active Obsidian note and produces structured context for Hermes.
|
||||||
|
*
|
||||||
|
* Strategy: instead of embedding full note content (wastes context tokens),
|
||||||
|
* we send a vault-relative path reference. Hermes reads the file on-demand
|
||||||
|
* via mcp_obsidian_get_vault_document — zero context burned, same result.
|
||||||
|
*
|
||||||
|
* If the user has text selected, we embed that selection directly (it's small
|
||||||
|
* and intentional). Otherwise we send a path reference.
|
||||||
|
*/
|
||||||
|
export class ContextBridge {
|
||||||
|
private app: App;
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the active note as a vault-relative reference.
|
||||||
|
* Returns null if no markdown editor is active.
|
||||||
|
*/
|
||||||
|
getActiveNoteContext(): {
|
||||||
|
vault: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
} | null {
|
||||||
|
const activeView = this.app.workspace.activeEditor;
|
||||||
|
if (!activeView || !activeView.editor) return null;
|
||||||
|
|
||||||
|
// Verify it's a Markdown file (not canvas, PDF, etc.)
|
||||||
|
const leaf = this.app.workspace.activeLeaf;
|
||||||
|
if (!leaf || !(leaf.view instanceof MarkdownView)) return null;
|
||||||
|
|
||||||
|
const file = leaf.view.file;
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
vault: this.app.vault.getName(),
|
||||||
|
path: file.path,
|
||||||
|
title: file.basename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected text in the active editor.
|
||||||
|
* Returns null if no text is selected.
|
||||||
|
*/
|
||||||
|
getSelectionContext(): string | null {
|
||||||
|
const activeView = this.app.workspace.activeEditor;
|
||||||
|
if (!activeView || !activeView.editor) return null;
|
||||||
|
|
||||||
|
const selection = activeView.editor.getSelection();
|
||||||
|
return selection || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a note reference as a system message that tells Hermes
|
||||||
|
* which MCP tool call to use to read the file on Helm.
|
||||||
|
*/
|
||||||
|
formatContextMessage(ref: {
|
||||||
|
vault: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
}): { role: string; content: string } {
|
||||||
|
return {
|
||||||
|
role: "system",
|
||||||
|
content: [
|
||||||
|
`[Current note: ${ref.vault}/${ref.path}]`,
|
||||||
|
`Use mcp_obsidian_get_vault_document(vault_name="${ref.vault}", doc_path="${ref.path}") to read the full contents.`,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the messages array for an API call.
|
||||||
|
*
|
||||||
|
* - If text is selected in the active note: embed it directly as context.
|
||||||
|
* - Otherwise if context injection is enabled: send a path reference
|
||||||
|
* so Hermes can read the file via MCP on Helm.
|
||||||
|
* - Always appends the user's chat message.
|
||||||
|
*/
|
||||||
|
buildMessages(
|
||||||
|
userInput: string,
|
||||||
|
includeContext: boolean
|
||||||
|
): { role: string; content: string }[] {
|
||||||
|
const messages: { role: string; content: string }[] = [];
|
||||||
|
|
||||||
|
if (includeContext) {
|
||||||
|
// Prefer selected text (small, intentional, works anywhere)
|
||||||
|
const selection = this.getSelectionContext();
|
||||||
|
if (selection) {
|
||||||
|
const ref = this.getActiveNoteContext();
|
||||||
|
const source = ref ? `${ref.vault}/${ref.path}` : "current note";
|
||||||
|
messages.push({
|
||||||
|
role: "system",
|
||||||
|
content: `[Selection from ${source}]\n\n${selection}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No selection — send path reference for Hermes to read via MCP
|
||||||
|
const ref = this.getActiveNoteContext();
|
||||||
|
if (ref) {
|
||||||
|
messages.push(this.formatContextMessage(ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({ role: "user", content: userInput });
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/main.ts
Normal file
127
src/main.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { Plugin, WorkspaceLeaf } from "obsidian";
|
||||||
|
import { HermesChatSettingTab, HermesChatSettings, DEFAULT_SETTINGS } from "./settings";
|
||||||
|
import { ChatView, VIEW_TYPE } from "./chat-view";
|
||||||
|
import { ApiClient } from "./api-client";
|
||||||
|
import { ContextBridge } from "./context-bridge";
|
||||||
|
import type { GatewayConfig } 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
|
||||||
|
this.registerView(
|
||||||
|
VIEW_TYPE,
|
||||||
|
(leaf: WorkspaceLeaf) => {
|
||||||
|
const view = new ChatView(leaf, this.apiClient!, this.contextBridge!);
|
||||||
|
view.setContextActive(this.settings.enableContextInjection);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add settings tab
|
||||||
|
this.addSettingTab(new HermesChatSettingTab(this.app, this));
|
||||||
|
|
||||||
|
// Ribbon icon to activate chat view
|
||||||
|
this.addRibbonIcon("message-square", "Open Hermes Chat", () => {
|
||||||
|
this.activateView();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status bar item showing model name
|
||||||
|
if (this.settings.showModelInfo) {
|
||||||
|
this.statusBarItem = this.addStatusBarItem();
|
||||||
|
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);
|
||||||
|
this.contextBridge = new ContextBridge(this.app);
|
||||||
|
|
||||||
|
// Command: Open Hermes Chat
|
||||||
|
this.addCommand({
|
||||||
|
id: "open-hermes-chat",
|
||||||
|
name: "Open Hermes Chat",
|
||||||
|
callback: () => {
|
||||||
|
this.activateView();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Command: Send Current Note as Context
|
||||||
|
this.addCommand({
|
||||||
|
id: "send-note-as-context",
|
||||||
|
name: "Send Current Note as Context",
|
||||||
|
callback: () => {
|
||||||
|
const context = this.contextBridge?.getActiveNoteContext();
|
||||||
|
if (context) {
|
||||||
|
this.activateView();
|
||||||
|
// Note: actual context injection happens in chat-view's handleSend via the contextBridge
|
||||||
|
console.log(`Context ready: vault=${context.vault}, path=${context.path}`);
|
||||||
|
} else {
|
||||||
|
console.log("No active note to send as context");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check gateway connectivity
|
||||||
|
if (this.statusBarItem && this.apiClient) {
|
||||||
|
this.statusBarItem.setText("Hermes: checking...");
|
||||||
|
this.apiClient.healthCheck().then((healthy) => {
|
||||||
|
if (this.statusBarItem) {
|
||||||
|
this.statusBarItem.setText(healthy ? "Hermes: connected" : "Hermes: unreachable");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onunload() {
|
||||||
|
// Detach the view when plugin is unloaded
|
||||||
|
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateView() {
|
||||||
|
const { workspace } = this.app;
|
||||||
|
|
||||||
|
// Check if view already exists
|
||||||
|
const existing = workspace.getLeavesOfType(VIEW_TYPE);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
workspace.revealLeaf(existing[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new leaf in right sidebar
|
||||||
|
const leaf = workspace.getRightLeaf(false);
|
||||||
|
if (leaf) {
|
||||||
|
await leaf.setViewState({ type: VIEW_TYPE, active: true });
|
||||||
|
workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
await this.saveData(this.settings);
|
||||||
|
this.syncContextToViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push enableContextInjection setting to any open ChatViews. */
|
||||||
|
private syncContextToViews(): void {
|
||||||
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE);
|
||||||
|
for (const leaf of leaves) {
|
||||||
|
if (leaf.view instanceof ChatView) {
|
||||||
|
leaf.view.setContextActive(this.settings.enableContextInjection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/settings.ts
Normal file
83
src/settings.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type HermesChatPlugin from "./main";
|
||||||
|
|
||||||
|
export interface HermesChatSettings {
|
||||||
|
gatewayUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
showModelInfo: boolean;
|
||||||
|
enableContextInjection: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: HermesChatSettings = {
|
||||||
|
gatewayUrl: "http://192.168.1.12:8642/v1",
|
||||||
|
apiKey: "0f224b188b52e8728d135ec339f8db8dfbf82d89a60021dcfe20d91c6174cf9b",
|
||||||
|
showModelInfo: true,
|
||||||
|
enableContextInjection: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HermesChatSettingTab extends PluginSettingTab {
|
||||||
|
plugin: HermesChatPlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: HermesChatPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "Hermes Chat Settings" });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Gateway URL")
|
||||||
|
.setDesc("Base URL of your Hermes Gateway API server (e.g. http://192.168.1.12:8642/v1)")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("http://192.168.1.12:8642/v1")
|
||||||
|
.setValue(this.plugin.settings.gatewayUrl)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.gatewayUrl = value;
|
||||||
|
await this.plugin.saveData(this.plugin.settings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("API Key")
|
||||||
|
.setDesc("API key for authenticating with the Hermes Gateway")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("Enter your API key")
|
||||||
|
.setValue(this.plugin.settings.apiKey)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.apiKey = value;
|
||||||
|
await this.plugin.saveData(this.plugin.settings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Show model info")
|
||||||
|
.setDesc("Display the current model name in the status bar")
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle
|
||||||
|
.setValue(this.plugin.settings.showModelInfo)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.showModelInfo = value;
|
||||||
|
await this.plugin.saveData(this.plugin.settings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Enable context injection")
|
||||||
|
.setDesc("Automatically send the active note as context with each message")
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle
|
||||||
|
.setValue(this.plugin.settings.enableContextInjection)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.enableContextInjection = value;
|
||||||
|
await this.plugin.saveData(this.plugin.settings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/types.ts
Normal file
31
src/types.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayConfig {
|
||||||
|
baseUrl: string; // e.g. http://192.168.1.12:8642/v1
|
||||||
|
apiKey: string;
|
||||||
|
model: string; // default: 'hermes-agent'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatState {
|
||||||
|
sessionId: string | null;
|
||||||
|
messages: Message[];
|
||||||
|
isLoading: boolean;
|
||||||
|
model: string;
|
||||||
|
contextActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionResponse {
|
||||||
|
content: string;
|
||||||
|
sessionId: string;
|
||||||
|
model: string;
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
139
styles.css
Normal file
139
styles.css
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Hermes Chat — Dark Theme
|
||||||
|
Uses Obsidian CSS variables for native theme compatibility.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* --- Container --- */
|
||||||
|
.hermes-chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Message list --- */
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-assistant {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Message internals --- */
|
||||||
|
.chat-message-role {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Loading indicator --- */
|
||||||
|
.chat-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Input area --- */
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: none;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area textarea:focus {
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area textarea:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area button:hover {
|
||||||
|
background: var(--interactive-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Status bar --- */
|
||||||
|
.chat-status-bar {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Scrollbar (thin, dark) --- */
|
||||||
|
.chat-messages::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--background-modifier-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"outDir": ".",
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue