This commit is contained in:
oliver committed 2026-11-04 13:32:37 -03:00
commit 5245c0a856
42 files changed
+11202

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Optional: point the Vite dev server at a Hermes API server to avoid CORS in dev.
# The dev server will proxy /api/* and /v1/* to this origin.
HERMES_DEV_TARGET=https://api.example.com
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.example
.DS_Store
*.log
.vite
*.tsbuildinfo
+96
View File
@@ -0,0 +1,96 @@
# PolyForm Internal Use License 1.0.0
<https://polyformproject.org/licenses/internal-use/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the software
to do everything you might do with the software that would
otherwise infringe the licensor's copyright in it for any
permitted purpose. However, you may only make changes or
new works based on the software according to [Changes and New
Works License](#changes-and-new-works-license), and you may
not distribute the software.
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## Internal Business Use
Use of the software for the internal business operations of
you and your company is use for a permitted purpose.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+194
View File
@@ -0,0 +1,194 @@
# Hermes PWA
A mobile-first **Progressive Web App** front end for a self-hosted
[Hermes Agent](https://hermes-agent.nousresearch.com) (Nous Research). It talks
directly to the **Hermes Agent API Server** — the OpenAI-compatible HTTP + SSE
endpoint — so you can chat with your agent, browse sessions, and use text, voice,
and images from an installable app.
Branding (logo, colour scheme, app icons) follows [derez.ai](https://derez.ai/);
the logo in the sidebar links to the homepage.
---
## Which URL do I use? (dashboard vs API server)
Hermes exposes **two different servers**. This app needs the second one.
| URL | What it is | Auth | Works with this app? |
| --- | --- | --- | --- |
| `https://your-host/` (e.g. `beaver.derez.ai`) | The **web dashboard** | username + password → session cookie | ❌ No. It only allows CORS from its own origin and never sends `Access-Control-Allow-Credentials: true`, so a separately-hosted app can't use its cookie. |
| `https://api.your-host/` (e.g. `api.beaver.derez.ai`, default port `8642`) | The **API Server** | `Authorization: Bearer <API_SERVER_KEY>` | ✅ **Yes — this is the one.** |
So enter the **API server URL** and your **`API_SERVER_KEY`** on the config
screen. There is no QR login and no username/password here — the API server uses
a bearer token.
### Enabling the API server (on the Hermes host)
If it isn't already running, add to `~/.hermes/.env` and start the gateway:
```bash
API_SERVER_ENABLED=true
API_SERVER_KEY=choose-a-long-random-secret
# Only needed if a browser calls the API directly (see CORS below):
# API_SERVER_CORS_ORIGINS=https://your-pwa-origin
```
```bash
hermes gateway # → [API Server] API server listening on http://127.0.0.1:8642
```
Verify from anywhere:
```bash
curl -s https://api.your-host/health
# {"status": "ok", "platform": "hermes-agent", "version": "..."}
curl -s -H "Authorization: Bearer $API_SERVER_KEY" https://api.your-host/v1/capabilities
```
---
## Reaching the API server (CORS)
The API server has **no browser CORS by default**. Two options:
### Option A — same origin (recommended): use the bundled proxy
`server/proxy.mjs` serves the built app **and** proxies `/api/*` and `/v1/*` to
the API server, so everything is same-origin and CORS never applies.
**You normally paste the API key into the app in the browser** (it's saved in
that browser's `localStorage`). The proxy does not need the key:
```bash
HERMES_TARGET=https://api.beaver.derez.ai PORT=8787 ./run.sh serve
# open http://localhost:8787
```
Then in the app's config screen, tick **"Same origin (behind the bundled
proxy)"** and paste your API key. Done — no key on the server, no CORS.
> `HERMES_API_KEY` is **optional**. Only set it if you'd rather keep the key on
the proxy so it never reaches the browser:
>
> ```bash
> HERMES_TARGET=https://api.beaver.derez.ai HERMES_API_KEY=your-key PORT=8787 ./run.sh serve
> ```
### Option B — direct, cross-origin
Set an explicit allowlist on the Hermes host and enter the API URL + key in the
app's config screen:
```bash
API_SERVER_CORS_ORIGINS=https://your-pwa-origin
```
For local development you can skip CORS entirely with a dev proxy:
```bash
# .env
HERMES_DEV_TARGET=https://api.beaver.derez.ai
```
---
## Quick start
```bash
./run.sh # dev server -> http://localhost:5173
```
Then on the config screen enter your **API server URL** and **API key**.
### `run.sh` modes
```bash
./run.sh # dev server (http://localhost:5173)
./run.sh build # production build into dist/
./run.sh serve # build if needed, then host + proxy /api/*, /v1/* (needs HERMES_TARGET)
./run.sh preview # build, then serve the build with vite preview
./run.sh icons # regenerate the PWA icon set
./run.sh typecheck # type-check without emitting
```
| Variable | Used by | Meaning |
| --- | --- | --- |
| `HERMES_TARGET` | `serve` | API server base URL to proxy to |
| `HERMES_API_KEY` | `serve` | Optional — injected as `Authorization: Bearer …` |
| `PORT` | `serve` | Port to listen on (default `8787`) |
| `HERMES_DEV_TARGET` | `dev` | API server base URL to proxy to during development |
---
## Features
- **Config screen** — add one or more API-server connections (URL, API key,
optional profile prefix, custom headers) with a "Test" probe.
- **Sessions** — list, open, create, rename, fork, and delete; filter by title.
- **Streaming chat** — token-by-token replies over SSE, with live tool-call
cards, cancel, and mid-turn steer.
- **Images** — attach/paste/drag-drop; sent inline as `data:` URLs.
- **Voice** — dictation via the browser's Web Speech API and spoken replies via
on-device speech synthesis. Tap the mic to speak your prompt; tap again to stop.
Requires a **secure context** (HTTPS or `localhost`) and a browser with the Web
Speech API (Chrome, Edge, Safari). Note: Chrome's dictation sends audio to
Google's servers.
- **Installable PWA** — offline app shell, home-screen install, standalone display.
---
## Notes & limitations
- **Voice is client-side.** The API server has no `/transcribe` or `/tts`
endpoint, so dictation uses the browser's `SpeechRecognition` and replies use
`speechSynthesis`. Support varies by browser (best on Chrome/Edge/Safari).
- **Profiles.** The API server routes profiles via a `/p/<profile>/…` prefix, and
each profile has its own `API_SERVER_KEY`. Add one connection per profile to
see them all; the sidebar switches between connections.
- **Session ownership.** The API server can create and drive its own sessions,
but it refuses to write to a session owned by another surface (CLI, TUI,
dashboard, messaging) — those open read-only.
- **Response shapes.** The Sessions REST API's exact JSON envelope isn't fully
documented, so the client normalises several shapes (`sessions`/`data`/`items`,
`id`/`session_id`, string/array content). If a field looks off on your version,
it's a one-line tweak in `src/api/client.ts`.
## Project layout
```
src/
api/
client.ts # HermesClient: bearer auth, sessions REST, SSE chat
types.ts # API server types + normalised UI types
hooks/
useSpeechRecognition.ts # browser dictation
useTts.ts # browser speech synthesis
lib/
config.ts # saved connections (localStorage)
format.ts # time / token formatting
markdown.tsx # tiny safe markdown renderer
state/
store.ts # zustand store: connections, sessions, streaming
components/ # ConfigScreen, ConnectionForm, Sidebar, ChatView, Composer, …
server/
proxy.mjs # optional static host + /api,/v1 reverse proxy (no deps)
scripts/
gen-icons.mjs # dependency-free PNG icon generator
```
## License
Licensed under the [PolyForm Internal Use License 1.0.0](./LICENSE) — internal
business use only; redistribution is not permitted. The license text is also
shipped in the build output as `dist/LICENSE`.
## API endpoints used
`GET /health`, `GET /v1/capabilities`, `GET /v1/models`,
`GET|POST /api/sessions`, `GET|PATCH|DELETE /api/sessions/{id}`,
`GET /api/sessions/{id}/messages`, `POST /api/sessions/{id}/fork`,
`POST /api/sessions/{id}/chat/stream` (SSE), and
`POST /v1/runs/{id}/stop|steer|approval|clarify`.
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
/>
<meta name="theme-color" content="#000810" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hermes" />
<title>Hermes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5790
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "hermes-pwa",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "A mobile-first PWA client for a self-hosted Hermes Agent (Hermes Web UI bridge API).",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"icons": "node scripts/gen-icons.mjs",
"serve": "node server/proxy.mjs",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.5"
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34">
<defs>
<linearGradient id="b" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#003545"/>
<stop offset="100%" stop-color="#00202e"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="34" height="34" rx="10" fill="url(#b)" stroke="#005570" stroke-width="1.2"/>
<rect x="6" y="11" width="22" height="15" rx="2.5" fill="none" stroke="#00f5ff" stroke-width="1.8"/>
<path d="M17 11V7" stroke="#00f5ff" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="17" cy="5.5" r="1.5" fill="#00f5ff"/>
<circle cx="10" cy="15.5" r="1" fill="#00f5ff"/>
<circle cx="14" cy="15.5" r="1" fill="#00f5ff"/>
<path d="M10 20h14" stroke="#00f5ff" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Executable
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env sh
#
# Convenience runner for the Hermes PWA.
#
# ./run.sh # dev server -> http://localhost:5173
# ./run.sh dev # same as above
# ./run.sh build # production build into dist/
# ./run.sh serve # build if needed, then host + proxy /api/* (needs HERMES_TARGET)
# ./run.sh preview # build, then serve the build with vite preview
# ./run.sh icons # regenerate the PWA icon set
# ./run.sh typecheck # type-check without emitting
#
# Environment:
# HERMES_TARGET Hermes base URL for `serve`, e.g. https://hermes.example.com
# PORT Port for `serve` (default 8787)
# HERMES_DEV_TARGET Hermes base URL to proxy /api/* during `dev` (avoids CORS)
#
set -eu
cd "$(dirname "$0")"
MODE="${1:-dev}"
log() { printf '\033[1;34m>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mx\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "Node.js is required (>=18). Install it and retry."
command -v npm >/dev/null 2>&1 || die "npm is required. Install it and retry."
ensure_deps() {
if [ ! -d node_modules ]; then
log "Installing dependencies..."
npm install --no-audit --no-fund
fi
}
ensure_icons() {
if [ ! -f public/icons/icon-512.png ]; then
log "Generating PWA icons..."
npm run icons
fi
}
case "$MODE" in
dev)
ensure_deps
ensure_icons
if [ -n "${HERMES_DEV_TARGET:-}" ]; then
log "Proxying /api/* to $HERMES_DEV_TARGET"
fi
log "Starting dev server..."
exec npm run dev
;;
build)
ensure_deps
ensure_icons
log "Building..."
exec npm run build
;;
serve)
[ -n "${HERMES_TARGET:-}" ] || die "Set HERMES_TARGET, e.g. HERMES_TARGET=https://hermes.example.com ./run.sh serve"
ensure_deps
ensure_icons
if [ ! -d dist ]; then
log "No build found - building first..."
npm run build
fi
log "Serving on http://localhost:${PORT:-8787} (proxying /api/* to $HERMES_TARGET)"
exec npm run serve
;;
preview)
ensure_deps
ensure_icons
npm run build
exec npm run preview
;;
icons)
ensure_deps
exec npm run icons
;;
typecheck)
ensure_deps
exec npm run typecheck
;;
*)
die "Unknown mode '$MODE'. Use: dev | build | serve | preview | icons | typecheck"
;;
esac
+213
View File
@@ -0,0 +1,213 @@
// Generates the PWA icon set with no external dependencies.
// A tiny, correct PNG encoder (RGBA, filter 0) built on Node's zlib.
//
// node scripts/gen-icons.mjs
//
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, "../public/icons");
// ---- PNG encoding -----------------------------------------------------------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, "ascii");
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePng(width, height, rgba) {
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0; // filter: none
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
}
const idat = deflateSync(raw, { level: 9 });
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
}
// ---- Drawing ----------------------------------------------------------------
function makeCanvas(size) {
return { size, data: Buffer.alloc(size * size * 4, 0) };
}
function blend(canvas, x, y, [r, g, b], a) {
if (x < 0 || y < 0 || x >= canvas.size || y >= canvas.size || a <= 0) return;
const i = (y * canvas.size + x) * 4;
const d = canvas.data;
const sa = Math.min(1, a);
const da = d[i + 3] / 255;
const outA = sa + da * (1 - sa);
if (outA === 0) return;
d[i] = Math.round((r * sa + d[i] * da * (1 - sa)) / outA);
d[i + 1] = Math.round((g * sa + d[i + 1] * da * (1 - sa)) / outA);
d[i + 2] = Math.round((b * sa + d[i + 2] * da * (1 - sa)) / outA);
d[i + 3] = Math.round(outA * 255);
}
// Signed distance to a rounded rectangle, used for antialiased edges.
function roundedRectSdf(px, py, cx, cy, hw, hh, r) {
const qx = Math.abs(px - cx) - (hw - r);
const qy = Math.abs(py - cy) - (hh - r);
const ax = Math.max(qx, 0);
const ay = Math.max(qy, 0);
return Math.sqrt(ax * ax + ay * ay) + Math.min(Math.max(qx, qy), 0) - r;
}
function fillRoundedRect(canvas, cx, cy, hw, hh, r, color, alpha = 1) {
const { size } = canvas;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const d = roundedRectSdf(x + 0.5, y + 0.5, cx, cy, hw, hh, r);
const cov = Math.min(1, Math.max(0, 0.5 - d));
if (cov > 0) blend(canvas, x, y, color, cov * alpha);
}
}
}
function gradientAt(y, size, top, bottom) {
const t = Math.max(0, Math.min(1, y / (size - 1)));
return [
Math.round(top[0] + (bottom[0] - top[0]) * t),
Math.round(top[1] + (bottom[1] - top[1]) * t),
Math.round(top[2] + (bottom[2] - top[2]) * t),
];
}
function fillRoundedRectGradient(canvas, cx, cy, hw, hh, r, top, bottom) {
const { size } = canvas;
for (let y = 0; y < size; y++) {
const color = gradientAt(y + 0.5, size, top, bottom);
for (let x = 0; x < size; x++) {
const d = roundedRectSdf(x + 0.5, y + 0.5, cx, cy, hw, hh, r);
const cov = Math.min(1, Math.max(0, 0.5 - d));
if (cov > 0) blend(canvas, x, y, color, cov);
}
}
}
function fillCircle(canvas, cx, cy, r, color, alpha = 1) {
const { size } = canvas;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy) - r;
const cov = Math.min(1, Math.max(0, 0.5 - d));
if (cov > 0) blend(canvas, x, y, color, cov * alpha);
}
}
}
// derez.ai palette
const TOP = [0x00, 0x35, 0x45];
const BOTTOM = [0x00, 0x20, 0x2e];
const CYAN = [0x00, 0xf5, 0xff];
// Draws the derez.ai mark: a teal tile with the cyan terminal glyph.
function drawIcon(size, { maskable }) {
const canvas = makeCanvas(size);
const s = size;
const pad = maskable ? 0 : s * 0.05;
const radius = maskable ? 0 : s * 0.24;
fillRoundedRectGradient(canvas, s / 2, s / 2, s / 2 - pad, s / 2 - pad, radius, TOP, BOTTOM);
// The glyph is designed in a 34x34 box; scale it into the tile.
const k = (s * (maskable ? 0.7 : 0.84)) / 34;
const ox = (s - 34 * k) / 2;
const oy = (s - 34 * k) / 2;
const X = (v) => ox + v * k;
const Y = (v) => oy + v * k;
const stroke = 1.8 * k;
// Monitor outline: cyan rounded rect, then erase the interior with the tile gradient.
const mcx = X(17);
const mcy = Y(18.5);
const mhw = 11 * k;
const mhh = 7.5 * k;
const mr = 2.5 * k;
fillRoundedRect(canvas, mcx, mcy, mhw, mhh, mr, CYAN, 1);
fillRoundedRectGradient(
canvas,
mcx,
mcy,
mhw - stroke,
mhh - stroke,
Math.max(0, mr - stroke),
TOP,
BOTTOM,
);
// Antenna stalk + dot.
fillRoundedRect(canvas, X(17), Y(9), stroke / 2, 2 * k, stroke / 2, CYAN, 1);
fillCircle(canvas, X(17), Y(5.5), 1.5 * k, CYAN);
// Indicator dots.
fillCircle(canvas, X(10), Y(15.5), 1 * k, CYAN);
fillCircle(canvas, X(14), Y(15.5), 1 * k, CYAN);
// Bottom bar.
fillRoundedRect(canvas, X(17), Y(20), 7 * k, 0.75 * k, 0.75 * k, CYAN, 1);
return canvas;
}
function write(name, canvas) {
writeFileSync(resolve(OUT, name), encodePng(canvas.size, canvas.size, canvas.data));
console.log("wrote", name, `${canvas.size}x${canvas.size}`);
}
mkdirSync(OUT, { recursive: true });
write("icon-192.png", drawIcon(192, { maskable: false }));
write("icon-512.png", drawIcon(512, { maskable: false }));
write("maskable-512.png", drawIcon(512, { maskable: true }));
write("apple-touch-icon.png", drawIcon(180, { maskable: false }));
// Matching SVG favicon (the derez.ai mark, crisp at any size).
const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34">
<defs>
<linearGradient id="b" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#003545"/>
<stop offset="100%" stop-color="#00202e"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="34" height="34" rx="10" fill="url(#b)" stroke="#005570" stroke-width="1.2"/>
<rect x="6" y="11" width="22" height="15" rx="2.5" fill="none" stroke="#00f5ff" stroke-width="1.8"/>
<path d="M17 11V7" stroke="#00f5ff" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="17" cy="5.5" r="1.5" fill="#00f5ff"/>
<circle cx="10" cy="15.5" r="1" fill="#00f5ff"/>
<circle cx="14" cy="15.5" r="1" fill="#00f5ff"/>
<path d="M10 20h14" stroke="#00f5ff" stroke-width="1.5" stroke-linecap="round"/>
</svg>
`;
writeFileSync(resolve(__dirname, "../public/favicon.svg"), favicon);
console.log("wrote favicon.svg");
+138
View File
@@ -0,0 +1,138 @@
// Optional reverse proxy + static host for the built PWA.
//
// HERMES_TARGET=https://hermes.example.com PORT=8787 node server/proxy.mjs
//
// Why use it? A browser PWA served from a different origin than Hermes needs
// the Hermes server to send CORS headers *and* allow credentialed cookies.
// Serving the app from this proxy puts the app and the API on the same origin,
// so the `hermes_session` cookie just works — and SSE streams pass through
// unbuffered. It uses only Node built-ins (no dependencies).
//
import { createServer } from "node:http";
import { request as httpsRequest } from "node:https";
import { request as httpRequest } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = resolve(fileURLToPath(import.meta.url), "..");
const DIST = resolve(__dirname, "../dist");
const PORT = Number(process.env.PORT || 8787);
const TARGET = process.env.HERMES_TARGET;
// Optional: inject the API key server-side so it never reaches the browser.
const API_KEY = process.env.HERMES_API_KEY;
if (!TARGET) {
console.error("Set HERMES_TARGET to your Hermes base URL, e.g.");
console.error(" HERMES_TARGET=https://hermes.example.com node server/proxy.mjs");
process.exit(1);
}
const target = new URL(TARGET);
const isHttps = target.protocol === "https:";
const doRequest = isHttps ? httpsRequest : httpRequest;
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".ico": "image/x-icon",
".webmanifest": "application/manifest+json",
".woff2": "font/woff2",
".map": "application/json",
};
function proxy(req, res) {
const headers = { ...req.headers };
headers.host = target.host;
if (API_KEY && !headers.authorization) {
headers.authorization = `Bearer ${API_KEY}`;
}
// Let the upstream decide; we forward the original origin for CSRF checks.
const options = {
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: req.url,
headers,
};
const upstream = doRequest(options, (upRes) => {
// Strip hop-by-hop headers.
const outHeaders = { ...upRes.headers };
delete outHeaders["transfer-encoding"];
delete outHeaders["connection"];
// Ensure SSE is not buffered by any intermediary.
if (String(outHeaders["content-type"] || "").includes("text/event-stream")) {
outHeaders["x-accel-buffering"] = "no";
outHeaders["cache-control"] = "no-cache";
}
res.writeHead(upRes.statusCode || 502, outHeaders);
upRes.pipe(res);
});
upstream.on("error", (err) => {
res.writeHead(502, { "content-type": "application/json" });
res.end(JSON.stringify({ error: `Upstream error: ${err.message}` }));
});
req.pipe(upstream);
}
async function serveStatic(req, res) {
let pathname = decodeURIComponent((req.url || "/").split("?")[0]);
if (pathname === "/") pathname = "/index.html";
const filePath = normalize(join(DIST, pathname));
if (!filePath.startsWith(DIST)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
try {
const info = await stat(filePath);
if (info.isDirectory()) throw new Error("dir");
const data = await readFile(filePath);
const type = MIME[extname(filePath)] || "application/octet-stream";
const immutable = pathname.startsWith("/assets/");
res.writeHead(200, {
"content-type": type,
"cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache",
});
res.end(data);
} catch {
// SPA fallback.
try {
const html = await readFile(join(DIST, "index.html"));
res.writeHead(200, { "content-type": MIME[".html"], "cache-control": "no-cache" });
res.end(html);
} catch {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not found. Did you run `npm run build` first?");
}
}
}
const server = createServer((req, res) => {
const path = req.url || "";
if (path.startsWith("/api/") || path.startsWith("/v1/") || path.startsWith("/health")) {
proxy(req, res);
} else {
void serveStatic(req, res);
}
});
server.listen(PORT, () => {
console.log(`Hermes PWA proxy listening on http://localhost:${PORT}`);
console.log(` serving: ${DIST}`);
console.log(` proxying: /api/*, /v1/* -> ${target.origin}`);
if (API_KEY) console.log(" injecting: Authorization: Bearer <HERMES_API_KEY>");
});
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useRef } from "react";
import { useStore } from "./state/store";
import { isConfigured } from "./lib/config";
import { useTts } from "./hooks/useTts";
import { ConfigScreen } from "./components/ConfigScreen";
import { DerezLogoLink } from "./components/DerezLogo";
import { Sidebar } from "./components/Sidebar";
import { ChatView } from "./components/ChatView";
import { SettingsPanel } from "./components/SettingsPanel";
import { Toast } from "./components/Toast";
function AutoSpeak() {
const messages = useStore((s) => s.messages);
const streaming = useStore((s) => s.streaming);
const speakReplies = useStore((s) => s.config.speakReplies);
const { speak } = useTts();
const lastSpoken = useRef<string | null>(null);
useEffect(() => {
if (!speakReplies || streaming) return;
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return;
if (!last.id || last.id === lastSpoken.current) return;
if (!last.content.trim()) return;
lastSpoken.current = last.id;
speak(last.content);
}, [messages, streaming, speakReplies, speak]);
return null;
}
export function App() {
const ready = useStore((s) => s.ready);
const config = useStore((s) => s.config);
const connectionError = useStore((s) => s.connectionError);
const showSettings = useStore((s) => s.showSettings);
const bootstrap = useStore((s) => s.bootstrap);
const connect = useStore((s) => s.connect);
useEffect(() => {
void bootstrap();
}, [bootstrap]);
if (!ready) {
return (
<div className="screen">
<div className="spinner" />
</div>
);
}
if (!isConfigured(config)) {
return (
<>
<ConfigScreen />
{showSettings && <SettingsPanel />}
<Toast />
</>
);
}
if (connectionError) {
return (
<div className="screen">
<div className="card">
<div style={{ marginBottom: 18 }}>
<DerezLogoLink height={30} />
</div>
<h1>Can’t reach Hermes</h1>
<p className="lead">{connectionError}</p>
<div className="actions">
<button className="btn" onClick={() => useStore.getState().setShowSettings(true)}>
Settings
</button>
<button className="btn primary" onClick={() => connect()}>
Retry
</button>
</div>
</div>
{showSettings && <SettingsPanel />}
<Toast />
</div>
);
}
return (
<>
<div className="app">
<Sidebar />
<ChatView />
</div>
<AutoSpeak />
{showSettings && <SettingsPanel />}
<Toast />
</>
);
}
+412
View File
@@ -0,0 +1,412 @@
import type {
ApiMessage,
ApiSession,
Attachment,
Capabilities,
ChatMessage,
ModelList,
Session,
ToolCall,
} from "./types";
export class HermesError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.name = "HermesError";
this.status = status;
this.body = body;
}
}
export interface HermesClientOptions {
baseUrl: string;
apiKey: string;
profile?: string;
/** Use the app's own origin (relative URLs) instead of an absolute base URL. */
sameOrigin?: boolean;
customHeaders?: { name: string; value: string }[];
}
function normalizeBase(baseUrl: string): string {
let b = baseUrl.trim();
if (!b) return "";
if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
return b.replace(/\/+$/, "");
}
function errorMessage(body: unknown, status: number): string {
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
const err = b.error;
if (err && typeof err === "object") {
const m = (err as Record<string, unknown>).message;
if (typeof m === "string" && m) return m;
}
const msg = b.error ?? b.message ?? b.detail;
if (typeof msg === "string" && msg) return msg;
}
if (typeof body === "string" && body) return body;
return `Request failed (HTTP ${status})`;
}
/** Pull an array out of a response that may be a bare list or wrapped. */
function pickArray<T>(body: unknown, keys: string[]): T[] {
if (Array.isArray(body)) return body as T[];
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
for (const k of keys) {
if (Array.isArray(b[k])) return b[k] as T[];
}
}
return [];
}
function toEpoch(v: unknown): number | undefined {
if (typeof v === "number") return v > 1e12 ? v : v * 1000;
if (typeof v === "string") {
const n = Number(v);
if (!Number.isNaN(n) && v.trim() !== "") return n > 1e12 ? n : n * 1000;
const d = Date.parse(v);
if (!Number.isNaN(d)) return d;
}
return undefined;
}
export function normalizeSession(s: ApiSession): Session {
const id = String(s.id ?? s.session_id ?? "");
const title = String(s.title ?? "").trim();
const inputTokens = typeof s.input_tokens === "number" ? s.input_tokens : undefined;
const outputTokens = typeof s.output_tokens === "number" ? s.output_tokens : undefined;
return {
id,
title: title || "Untitled",
preview: typeof s.preview === "string" ? s.preview : undefined,
source: typeof s.source === "string" ? s.source : undefined,
model: typeof s.model === "string" ? s.model : undefined,
provider: typeof s.provider === "string" ? s.provider : undefined,
messageCount: typeof s.message_count === "number" ? s.message_count : undefined,
toolCallCount: typeof s.tool_call_count === "number" ? s.tool_call_count : undefined,
updatedAt: toEpoch(s.last_active ?? s.ended_at ?? s.started_at),
endReason: (s.end_reason as string | null) ?? null,
pinned: !!s.pinned,
archived: !!s.archived,
costUsd: typeof s.estimated_cost_usd === "number" ? s.estimated_cost_usd : undefined,
tokens:
inputTokens !== undefined || outputTokens !== undefined
? (inputTokens ?? 0) + (outputTokens ?? 0)
: undefined,
};
}
function contentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((p) => {
if (typeof p === "string") return p;
if (p && typeof p === "object") {
const o = p as Record<string, unknown>;
if (typeof o.text === "string") return o.text;
if (typeof o.content === "string") return o.content;
}
return "";
})
.join("");
}
return "";
}
export function normalizeMessage(m: ApiMessage, index: number): ChatMessage {
const role = String(m.role ?? "assistant");
const toolCalls: ToolCall[] = Array.isArray(m.tool_calls)
? m.tool_calls.map((t, i) => ({
id: String(t.id ?? t.call_id ?? `tc-${index}-${i}`),
name: String(t.function?.name ?? "tool"),
preview: typeof t.function?.arguments === "string" ? t.function.arguments : undefined,
status: "done" as const,
}))
: [];
const reasoning =
(typeof m.reasoning_content === "string" && m.reasoning_content) ||
(typeof m.reasoning === "string" && m.reasoning) ||
undefined;
return {
id: String(m.id ?? `msg-${index}`),
role,
content: contentToText(m.content) || (typeof m.text === "string" ? m.text : ""),
reasoning: reasoning || undefined,
toolCalls: toolCalls.length ? toolCalls : undefined,
toolName: typeof m.tool_name === "string" ? m.tool_name : undefined,
timestamp: toEpoch(m.timestamp ?? m.created_at),
};
}
export class HermesClient {
readonly baseUrl: string;
private apiKey: string;
private profile?: string;
private customHeaders: { name: string; value: string }[];
constructor(opts: HermesClientOptions) {
// An empty base URL (or the same-origin flag) means "relative to this page".
this.baseUrl = opts.sameOrigin ? "" : normalizeBase(opts.baseUrl);
this.apiKey = opts.apiKey;
this.profile = opts.profile?.trim() || undefined;
this.customHeaders = opts.customHeaders ?? [];
}
private headers(extra?: Record<string, string>): Record<string, string> {
const h: Record<string, string> = { ...extra };
if (this.apiKey) h["Authorization"] = `Bearer ${this.apiKey}`;
for (const { name, value } of this.customHeaders) {
if (name.trim()) h[name.trim()] = value;
}
return h;
}
private url(path: string): string {
const prefix = this.profile ? `/p/${encodeURIComponent(this.profile)}` : "";
return `${this.baseUrl}${prefix}${path}`;
}
private async request<T>(path: string, init: RequestInit & { json?: unknown } = {}): Promise<T> {
const { json, ...rest } = init;
const headers = this.headers({
Accept: "application/json",
...(json !== undefined ? { "Content-Type": "application/json" } : {}),
...((rest.headers as Record<string, string>) ?? {}),
});
let res: Response;
try {
res = await fetch(this.url(path), {
...rest,
headers,
body: json !== undefined ? JSON.stringify(json) : rest.body,
});
} catch (err) {
const where = this.baseUrl || "this origin";
throw new HermesError(
`Could not reach ${where}. Check the URL, and that the API server allows this origin (API_SERVER_CORS_ORIGINS).`,
0,
err,
);
}
const text = await res.text();
let body: unknown;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) throw new HermesError(errorMessage(body, res.status), res.status, body);
return body as T;
}
// ---- Discovery / auth check ---------------------------------------------
/** Public liveness probe (no auth). */
health(): Promise<{ status?: string; platform?: string; version?: string }> {
return this.request("/health");
}
/** Authenticated feature probe — also verifies the API key. */
capabilities(): Promise<Capabilities> {
return this.request<Capabilities>("/v1/capabilities");
}
async models(): Promise<string[]> {
const res = await this.request<ModelList>("/v1/models");
return (res.data ?? []).map((m) => m.id).filter(Boolean);
}
// ---- Sessions ------------------------------------------------------------
async listSessions(opts: { limit?: number; offset?: number; source?: string } = {}): Promise<Session[]> {
const q = new URLSearchParams();
q.set("limit", String(opts.limit ?? 200));
if (opts.offset) q.set("offset", String(opts.offset));
if (opts.source) q.set("source", opts.source);
const body = await this.request<unknown>(`/api/sessions?${q.toString()}`);
return pickArray<ApiSession>(body, ["sessions", "data", "items", "results"])
// Hide subagent children and sessions the server marks hidden.
.filter((s) => !s.hidden && !s.is_internal_child)
.map(normalizeSession);
}
async createSession(title?: string): Promise<Session> {
const body = await this.request<unknown>("/api/sessions", {
method: "POST",
json: title ? { title } : {},
});
const obj = (body as Record<string, unknown>)?.session ?? body;
return normalizeSession(obj as ApiSession);
}
async getSession(id: string): Promise<Session> {
const body = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}`);
const obj = (body as Record<string, unknown>)?.session ?? body;
return normalizeSession(obj as ApiSession);
}
async updateSession(id: string, patch: { title?: string; end_reason?: string }): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(id)}`, { method: "PATCH", json: patch });
}
async deleteSession(id: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
}
async forkSession(id: string, title?: string): Promise<Session> {
const body = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/fork`, {
method: "POST",
json: title ? { title } : {},
});
const obj = (body as Record<string, unknown>)?.session ?? body;
return normalizeSession(obj as ApiSession);
}
async getMessages(
id: string,
opts: { limit?: number; offset?: number; order?: "oldest" | "latest"; inlineImages?: boolean } = {},
): Promise<ChatMessage[]> {
const q = new URLSearchParams();
q.set("limit", String(opts.limit ?? 500));
if (opts.offset) q.set("offset", String(opts.offset));
if (opts.order) q.set("order", opts.order);
if (opts.inlineImages === false) q.set("inline_images", "false");
const body = await this.request<unknown>(
`/api/sessions/${encodeURIComponent(id)}/messages?${q.toString()}`,
);
return pickArray<ApiMessage>(body, ["messages", "data", "items", "results"]).map(normalizeMessage);
}
// ---- Chat (SSE) ----------------------------------------------------------
/**
* Runs one turn and streams the response. `input` is a string, or an array of
* OpenAI-style content parts when images are attached.
*/
async chatStream(
sessionId: string,
body: {
input: string | Array<Record<string, unknown>>;
model?: string;
provider?: string;
model_options?: Record<string, unknown>;
},
onEvent: (event: string, data: unknown) => void,
signal?: AbortSignal,
): Promise<void> {
await this.stream(
`/api/sessions/${encodeURIComponent(sessionId)}/chat/stream`,
onEvent,
signal,
{ method: "POST", json: body },
);
}
stopRun(runId: string): Promise<unknown> {
return this.request(`/v1/runs/${encodeURIComponent(runId)}/stop`, { method: "POST" });
}
steerRun(runId: string, text: string): Promise<unknown> {
return this.request(`/v1/runs/${encodeURIComponent(runId)}/steer`, {
method: "POST",
json: { input: text },
});
}
respondApproval(runId: string, body: Record<string, unknown>): Promise<unknown> {
return this.request(`/v1/runs/${encodeURIComponent(runId)}/approval`, { method: "POST", json: body });
}
respondClarify(runId: string, body: Record<string, unknown>): Promise<unknown> {
return this.request(`/v1/runs/${encodeURIComponent(runId)}/clarify`, { method: "POST", json: body });
}
// ---- SSE transport -------------------------------------------------------
async stream(
path: string,
onEvent: (event: string, data: unknown) => void,
signal?: AbortSignal,
init: { method?: string; json?: unknown } = {},
): Promise<void> {
const res = await fetch(this.url(path), {
method: init.method ?? "GET",
headers: this.headers({
Accept: "text/event-stream",
...(init.json !== undefined ? { "Content-Type": "application/json" } : {}),
}),
body: init.json !== undefined ? JSON.stringify(init.json) : undefined,
signal,
});
if (!res.ok || !res.body) {
const t = await res.text().catch(() => "");
let body: unknown = t;
try {
body = JSON.parse(t);
} catch {
/* keep text */
}
throw new HermesError(errorMessage(body, res.status), res.status, body);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (raw: string) => {
let event = "message";
const dataLines: string[] = [];
for (const line of raw.split("\n")) {
if (!line || line.startsWith(":")) continue; // comment / keepalive
const idx = line.indexOf(":");
const field = idx === -1 ? line : line.slice(0, idx);
let value = idx === -1 ? "" : line.slice(idx + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "event") event = value;
else if (field === "data") dataLines.push(value);
}
if (!dataLines.length) return;
const dataStr = dataLines.join("\n");
if (dataStr === "[DONE]") {
onEvent("done", {});
return;
}
let data: unknown = dataStr;
try {
data = JSON.parse(dataStr);
} catch {
/* leave as string */
}
onEvent(event, data);
};
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
dispatch(raw);
}
}
if (buffer.trim()) dispatch(buffer);
} finally {
reader.releaseLock?.();
}
}
}
export type { Attachment };
+151
View File
@@ -0,0 +1,151 @@
// Types for the Hermes Agent API Server (Nous Research).
// Reference: https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server
//
// Auth is a bearer token (API_SERVER_KEY). The server is OpenAI-compatible and
// also exposes a REST Sessions API plus an SSE chat stream per session.
export interface Capabilities {
object?: string;
platform?: string;
model?: string;
auth?: { type?: string; required?: boolean };
features?: Record<string, boolean>;
endpoints?: Record<string, string>;
[k: string]: unknown;
}
export interface ModelEntry {
id: string;
object?: string;
owned_by?: string;
[k: string]: unknown;
}
export interface ModelList {
object?: string;
data?: ModelEntry[];
}
/** A session row as returned by GET /api/sessions (verified against a live
* Hermes Agent 0.22.2 API server). */
export interface ApiSession {
id?: string;
session_id?: string;
title?: string;
source?: string;
user_id?: string | null;
model?: string;
provider?: string;
message_count?: number;
tool_call_count?: number;
started_at?: number;
ended_at?: number | null;
last_active?: number;
end_reason?: string | null;
preview?: string;
pinned?: boolean;
archived?: boolean;
hidden?: boolean;
is_internal_child?: boolean;
input_tokens?: number;
output_tokens?: number;
estimated_cost_usd?: number | null;
[k: string]: unknown;
}
/** A tool call as embedded in an assistant message. */
export interface ApiToolCall {
id?: string;
call_id?: string;
response_item_id?: string;
type?: string;
function?: { name?: string; arguments?: string };
[k: string]: unknown;
}
/** A message row as returned by GET /api/sessions/{id}/messages
* (verified against a live Hermes Agent 0.22.2 API server). */
export interface ApiMessage {
id?: string | number;
session_id?: string;
role?: string;
content?: unknown;
text?: string;
tool_call_id?: string | null;
tool_calls?: ApiToolCall[] | null;
tool_name?: string | null;
timestamp?: string | number;
created_at?: string | number;
token_count?: number | null;
finish_reason?: string | null;
reasoning?: string | null;
reasoning_content?: string | null;
display_kind?: string | null;
[k: string]: unknown;
}
/** Normalised session used throughout the UI. */
export interface Session {
id: string;
title: string;
preview?: string;
source?: string;
model?: string;
provider?: string;
messageCount?: number;
toolCallCount?: number;
updatedAt?: number;
endReason?: string | null;
pinned?: boolean;
archived?: boolean;
costUsd?: number;
tokens?: number;
}
/** Normalised message used throughout the UI. */
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system" | "tool" | string;
content: string;
reasoning?: string;
toolCalls?: ToolCall[];
/** For role "tool": the tool that produced this result. */
toolName?: string;
attachments?: Attachment[];
timestamp?: number;
streaming?: boolean;
error?: boolean;
}
export interface ToolCall {
id: string;
name: string;
preview?: string;
status: "running" | "done" | "error";
duration?: number;
}
export interface Attachment {
name: string;
mime: string;
/** data: URL (base64) — the API server accepts inline images. */
dataUrl: string;
size?: number;
}
/** A saved connection = one API server (optionally a profile prefix). */
export interface Connection {
id: string;
name: string;
baseUrl: string;
apiKey: string;
/** Optional multi-profile prefix, e.g. "work" -> /p/work/... */
profile?: string;
/**
* When true, requests go to the app's own origin (relative URLs) — for use
* behind the bundled proxy, where the API is same-origin and CORS never
* applies. The API key still comes from the browser.
*/
sameOrigin?: boolean;
customHeaders?: { name: string; value: string }[];
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../state/store";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
import { FilterIcon, MenuIcon, PlusIcon } from "./Icons";
function FilterMenu() {
const showToolCalls = useStore((s) => s.config.showToolCalls);
const showReasoning = useStore((s) => s.config.showReasoning);
const showSystem = useStore((s) => s.config.showSystem);
const updatePrefs = useStore((s) => s.updatePrefs);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const items: Array<[string, boolean, (v: boolean) => void]> = [
["Tool calls & results", showToolCalls, (v) => updatePrefs({ showToolCalls: v })],
["Reasoning", showReasoning, (v) => updatePrefs({ showReasoning: v })],
["System messages", showSystem, (v) => updatePrefs({ showSystem: v })],
];
return (
<div className="profile-switch" ref={ref} style={{ width: "auto" }}>
<button
className={`icon-btn ${open ? "active" : ""}`}
title="Filter messages"
onClick={() => setOpen((v) => !v)}
>
<FilterIcon width={19} height={19} />
</button>
{open && (
<div className="menu" style={{ left: "auto", right: 0, width: 230 }}>
{items.map(([label, checked, onChange]) => (
<label className="menu-item toggle" key={label}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
))}
</div>
)}
</div>
);
}
export function ChatView() {
const session = useStore((s) => s.session);
const messages = useStore((s) => s.messages);
const sessionLoading = useStore((s) => s.sessionLoading);
const streamError = useStore((s) => s.streamError);
const setSidebarOpen = useStore((s) => s.setSidebarOpen);
const newSession = useStore((s) => s.newSession);
const scrollRef = useRef<HTMLDivElement>(null);
const stickRef = useRef(true);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
};
useEffect(() => {
const el = scrollRef.current;
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
}, [messages]);
const header = (
<div className="chat-head">
<button className="icon-btn menu-btn" onClick={() => setSidebarOpen(true)}>
<MenuIcon width={20} height={20} />
</button>
<div className="titles">
<div className="t">{session?.title ?? "Hermes"}</div>
<div className="s">
{session?.model && <span className="pill">{session.model}</span>}
{session?.source && <span>{session.source}</span>}
</div>
</div>
<FilterMenu />
<button className="icon-btn" title="New chat" onClick={() => newSession()}>
<PlusIcon width={19} height={19} />
</button>
</div>
);
if (!session && !sessionLoading) {
return (
<div className="main">
{header}
<div className="center-fill">
<div>
<div style={{ fontSize: 40, marginBottom: 12 }}>💬</div>
<div style={{ color: "var(--text)", fontWeight: 600, marginBottom: 6 }}>
No conversation open
</div>
<div>Pick a session from the sidebar, or start a new chat.</div>
<button className="btn primary" style={{ marginTop: 18 }} onClick={() => newSession()}>
New chat
</button>
</div>
</div>
</div>
);
}
return (
<div className="main">
{header}
<div className="messages" ref={scrollRef} onScroll={onScroll}>
<div className="messages-inner">
{sessionLoading && (
<div className="center-fill">
<div className="spinner" />
</div>
)}
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
{streamError && <div className="alert error">{streamError}</div>}
</div>
</div>
<Composer />
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../state/store";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import { useRecorder } from "../hooks/useRecorder";
import { transcribeAudio } from "../lib/transcribe";
import type { Attachment } from "../api/types";
import { CloseIcon, ImageIcon, MicIcon, SendIcon, StopIcon } from "./Icons";
function fileToAttachment(file: File): Promise<Attachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
name: file.name,
mime: file.type || "application/octet-stream",
dataUrl: String(reader.result),
size: file.size,
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
const isCoarsePointer = () =>
typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches;
export function Composer() {
const streaming = useStore((s) => s.streaming);
const send = useStore((s) => s.send);
const steer = useStore((s) => s.steer);
const cancel = useStore((s) => s.cancel);
const steerLeftover = useStore((s) => s.steerLeftover);
const clearSteerLeftover = useStore((s) => s.clearSteerLeftover);
const setToast = useStore((s) => s.setToast);
const transcribeUrl = useStore((s) => s.config.transcribeUrl);
const transcribeKey = useStore((s) => s.config.transcribeKey);
const transcribeModel = useStore((s) => s.config.transcribeModel);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [transcribing, setTranscribing] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const speech = useSpeechRecognition();
const recorder = useRecorder();
const serverDictation = !speech.supported && !!transcribeUrl.trim();
const dictationAvailable = speech.supported || serverDictation;
const active = speech.listening || recorder.recording;
useEffect(() => {
if (steerLeftover) {
setText((t) => (t ? t : steerLeftover));
clearSteerLeftover();
}
}, [steerLeftover, clearSteerLeftover]);
useEffect(() => {
if (speech.error) setToast(speech.error);
}, [speech.error, setToast]);
useEffect(() => {
if (recorder.error) setToast(recorder.error);
}, [recorder.error, setToast]);
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
}, [text]);
const addFiles = async (files: File[]) => {
for (const file of files) {
try {
const att = await fileToAttachment(file);
setAttachments((prev) => [...prev, att]);
} catch {
setToast(`Could not attach ${file.name}`);
}
}
};
const doSend = () => {
const trimmed = text.trim();
if (!trimmed && !attachments.length) return;
if (streaming) {
void steer(trimmed);
setText("");
return;
}
void send(trimmed, attachments);
setText("");
setAttachments([]);
};
const append = (transcript: string) => {
const t = transcript.trim();
if (t) setText((prev) => (prev ? `${prev} ${t}` : t));
};
const stopDictation = async () => {
if (speech.listening) {
speech.stop();
return;
}
if (recorder.recording) {
const blob = await recorder.stop();
if (!blob) return;
setTranscribing(true);
try {
const transcript = await transcribeAudio(blob, {
url: transcribeUrl.trim(),
key: transcribeKey.trim() || undefined,
model: transcribeModel.trim() || undefined,
});
append(transcript);
} catch (err) {
setToast(err instanceof Error ? err.message : "Transcription failed.");
} finally {
setTranscribing(false);
}
}
};
const toggleMic = async () => {
if (active) {
await stopDictation();
return;
}
if (speech.supported) {
if (!window.isSecureContext) {
setToast("Dictation needs a secure context (HTTPS or localhost).");
return;
}
speech.start(append);
return;
}
if (serverDictation) {
const ok = await recorder.start();
if (!ok) setToast("Could not access the microphone.");
return;
}
setToast(
"Dictation isn't available in this browser. Use Chrome, Edge, or Safari — or set a transcription endpoint in Settings.",
);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !isCoarsePointer()) {
e.preventDefault();
doSend();
}
};
const canSend = text.trim().length > 0 || attachments.length > 0;
const pillText = transcribing
? "Transcribing…"
: speech.listening
? speech.interim || "Listening…"
: recorder.recording
? "Recording… tap the mic to stop"
: "";
return (
<div className="composer-wrap">
<div
className="composer"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (e.dataTransfer.files?.length) void addFiles(Array.from(e.dataTransfer.files));
}}
>
{attachments.length > 0 && (
<div className="attach-chips">
{attachments.map((a) => (
<div className="chip" key={a.name + a.size}>
{/^image\//.test(a.mime) ? (
<img src={a.dataUrl} alt={a.name} />
) : (
<ImageIcon width={15} height={15} />
)}
<span className="nm">{a.name}</span>
<button
title="Remove"
onClick={() => setAttachments((prev) => prev.filter((x) => x !== a))}
>
<CloseIcon width={14} height={14} />
</button>
</div>
))}
</div>
)}
{pillText && (
<div className="recording-pill" style={{ padding: "2px 4px" }}>
{!transcribing && <span className="pulse" />}
<span style={{ color: "var(--text-dim)" }}>{pillText}</span>
</div>
)}
<textarea
ref={taRef}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
onPaste={(e) => {
const files = Array.from(e.clipboardData.files);
if (files.length) {
e.preventDefault();
void addFiles(files);
}
}}
placeholder={streaming ? "Steer the running turn…" : "Message Hermes…"}
/>
<div className="composer-row">
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => {
if (e.target.files?.length) void addFiles(Array.from(e.target.files));
e.target.value = "";
}}
/>
<button className="icon-btn" title="Attach image" onClick={() => fileRef.current?.click()}>
<ImageIcon width={19} height={19} />
</button>
<button
className={`icon-btn ${active ? "active" : ""}`}
title={
active
? "Stop dictation"
: dictationAvailable
? "Dictate (speak your prompt)"
: "Dictation unavailable — see Settings"
}
onClick={toggleMic}
disabled={transcribing}
>
<MicIcon width={19} height={19} />
</button>
<div className="spacer" />
{streaming ? (
<button className="send-btn stop" title="Stop" onClick={() => cancel()}>
<StopIcon width={18} height={18} />
</button>
) : (
<button className="send-btn" title="Send" disabled={!canSend} onClick={doSend}>
<SendIcon width={18} height={18} />
</button>
)}
</div>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useStore } from "../state/store";
import { ConnectionForm } from "./ConnectionForm";
import { DerezLogoLink } from "./DerezLogo";
import { EditIcon, TrashIcon } from "./Icons";
export function ConfigScreen() {
const config = useStore((s) => s.config);
const saveConnection = useStore((s) => s.saveConnection);
const removeConnection = useStore((s) => s.removeConnection);
const setActiveConnection = useStore((s) => s.setActiveConnection);
const [editingId, setEditingId] = useState<string | null>(null);
return (
<div className="screen">
<div className="card">
<div style={{ marginBottom: 18 }}>
<DerezLogoLink height={30} />
</div>
<h1>Connect to your Hermes</h1>
<p className="lead">
Point this app at your Hermes <strong>API Server</strong> (the OpenAI-compatible endpoint),
not the dashboard. It authenticates with your <code>API_SERVER_KEY</code>.
</p>
<ConnectionForm onSave={saveConnection} />
{config.connections.length > 0 && (
<div className="section" style={{ marginTop: 24, marginBottom: 0 }}>
<h3>Saved connections</h3>
{config.connections.map((c) => (
<div key={c.id}>
<div className="kv">
<span
className="link-btn"
style={{ cursor: "pointer" }}
onClick={() => setActiveConnection(c.id)}
>
{c.name}
{c.profile ? ` · ${c.profile}` : ""}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
{c.sameOrigin ? "same origin" : c.baseUrl}
<button
className={`icon-btn ${editingId === c.id ? "active" : ""}`}
title="Edit"
onClick={() => setEditingId(editingId === c.id ? null : c.id)}
>
<EditIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Remove" onClick={() => removeConnection(c.id)}>
<TrashIcon width={15} height={15} />
</button>
</span>
</div>
{editingId === c.id && (
<div style={{ margin: "10px 0 18px" }}>
<ConnectionForm
initial={c}
onSave={async (conn) => {
await saveConnection(conn);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
saveLabel="Save changes"
/>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from "react";
import { HermesClient } from "../api/client";
import type { Connection } from "../api/types";
import { newConnectionId } from "../lib/config";
import { CloseIcon, PlusIcon } from "./Icons";
export function emptyConnection(): Connection {
return {
id: newConnectionId(),
name: "My Hermes",
baseUrl: "",
apiKey: "",
profile: "",
customHeaders: [],
};
}
export function ConnectionForm({
initial,
onSave,
onCancel,
saveLabel = "Save & connect",
}: {
initial?: Connection;
onSave: (conn: Connection) => void | Promise<void>;
onCancel?: () => void;
saveLabel?: string;
}) {
const [draft, setDraft] = useState<Connection>(initial ?? emptyConnection());
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const patch = (p: Partial<Connection>) => setDraft((d) => ({ ...d, ...p }));
const test = async () => {
setTesting(true);
setResult(null);
try {
const client = new HermesClient({
baseUrl: draft.baseUrl,
apiKey: draft.apiKey,
profile: draft.profile,
sameOrigin: draft.sameOrigin,
customHeaders: draft.customHeaders,
});
const health = await client.health();
const caps = await client.capabilities();
const features = caps.features ? Object.keys(caps.features).length : 0;
setResult({
ok: true,
msg: `Connected to ${health.platform ?? "hermes-agent"} ${health.version ?? ""} — API key accepted (${features} features).`,
});
} catch (err) {
setResult({ ok: false, msg: err instanceof Error ? err.message : String(err) });
} finally {
setTesting(false);
}
};
return (
<div>
{result && <div className={`alert ${result.ok ? "ok" : "error"}`}>{result.msg}</div>}
<div className="field">
<label>Name</label>
<input value={draft.name} onChange={(e) => patch({ name: e.target.value })} placeholder="My Hermes" />
</div>
<div className="field">
<label className="toggle" style={{ marginBottom: 8 }}>
<input
type="checkbox"
checked={!!draft.sameOrigin}
onChange={(e) => patch({ sameOrigin: e.target.checked })}
/>
Same origin (behind the bundled proxy)
</label>
{draft.sameOrigin ? (
<div className="hint" style={{ marginTop: 0 }}>
Requests go to this app's own origin, which the proxy forwards to your API server — no
CORS needed. Run the proxy with <code>HERMES_TARGET</code> (no key required).
</div>
) : (
<>
<input
value={draft.baseUrl}
onChange={(e) => patch({ baseUrl: e.target.value })}
placeholder="https://api.example.com"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
<div className="hint">
The API server origin (default port <code>8642</code>), e.g.{" "}
<code>https://api.beaver.derez.ai</code>. Needs <code>API_SERVER_CORS_ORIGINS</code> to
allow this app's origin.
</div>
</>
)}
</div>
<div className="field">
<label>API key (API_SERVER_KEY)</label>
<input
type="password"
value={draft.apiKey}
onChange={(e) => patch({ apiKey: e.target.value })}
placeholder="Bearer token"
autoCapitalize="none"
/>
<div className="hint">Stored only on this device. Sent as <code>Authorization: Bearer …</code>.</div>
</div>
<div className="field">
<label>Profile (optional)</label>
<input
value={draft.profile ?? ""}
onChange={(e) => patch({ profile: e.target.value })}
placeholder="default"
autoCapitalize="none"
/>
<div className="hint">
Multi-profile gateways route to <code>/p/&lt;profile&gt;/…</code>. Each profile needs its own
key — add one connection per profile to see them all.
</div>
</div>
<div className="field">
<label>Custom headers (optional)</label>
{(draft.customHeaders ?? []).map((h, i) => (
<div className="header-row" key={i}>
<input
value={h.name}
placeholder="Header name"
onChange={(e) => {
const next = [...(draft.customHeaders ?? [])];
next[i] = { ...next[i], name: e.target.value };
patch({ customHeaders: next });
}}
/>
<input
value={h.value}
placeholder="Value"
onChange={(e) => {
const next = [...(draft.customHeaders ?? [])];
next[i] = { ...next[i], value: e.target.value };
patch({ customHeaders: next });
}}
/>
<button
className="icon-btn"
title="Remove"
onClick={() => patch({ customHeaders: (draft.customHeaders ?? []).filter((_, j) => j !== i) })}
>
<CloseIcon width={16} height={16} />
</button>
</div>
))}
<button
className="btn"
style={{ marginTop: 8 }}
onClick={() => patch({ customHeaders: [...(draft.customHeaders ?? []), { name: "", value: "" }] })}
>
<PlusIcon width={15} height={15} style={{ verticalAlign: "-2px", marginRight: 6 }} />
Add header
</button>
</div>
<div className="actions">
{onCancel && (
<button className="btn" onClick={onCancel}>
Cancel
</button>
)}
<button
className="btn"
onClick={test}
disabled={testing || (!draft.sameOrigin && !draft.baseUrl.trim())}
>
{testing ? "Testing…" : "Test"}
</button>
<button
className="btn primary"
onClick={() => onSave({ ...draft, name: draft.name.trim() || "Hermes" })}
disabled={!draft.sameOrigin && !draft.baseUrl.trim()}
>
{saveLabel}
</button>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useId } from "react";
/**
* The derez.ai logo (icon + wordmark), taken from https://derez.ai/assets/images/logo.svg
*/
export function DerezLogo({ height = 26, withText = true }: { height?: number; withText?: boolean }) {
const gid = useId().replace(/:/g, "");
const width = withText ? (height * 132) / 34 : height;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox={withText ? "0 0 132 34" : "0 0 34 34"}
role="img"
aria-label="derez.ai"
>
<defs>
<linearGradient id={`box-bg-${gid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#003545" />
<stop offset="100%" stopColor="#00202e" />
</linearGradient>
</defs>
<rect
x="0"
y="0"
width="34"
height="34"
rx="10"
fill={`url(#box-bg-${gid})`}
stroke="#005570"
strokeWidth="1.2"
/>
<rect x="6" y="11" width="22" height="15" rx="2.5" fill="none" stroke="#00f5ff" strokeWidth="1.8" />
<path d="M17 11V7" stroke="#00f5ff" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="17" cy="5.5" r="1.5" fill="#00f5ff" />
<circle cx="10" cy="15.5" r="1" fill="#00f5ff" />
<circle cx="14" cy="15.5" r="1" fill="#00f5ff" />
<path d="M10 20h14" stroke="#00f5ff" strokeWidth="1.5" strokeLinecap="round" />
{withText && (
<text
x="46"
y="23"
fill="#00f5ff"
fontFamily="'Courier New', Courier, monospace"
fontSize="17"
fontWeight="700"
letterSpacing="-0.5"
>
derez.ai
</text>
)}
</svg>
);
}
/** The logo wrapped in a link to the derez.ai homepage. */
export function DerezLogoLink({ height = 26 }: { height?: number }) {
return (
<a
className="brand-link"
href="https://derez.ai/"
target="_blank"
rel="noopener noreferrer"
title="derez.ai — AI Agent Hosting"
>
<DerezLogo height={height} />
</a>
);
}
+175
View File
@@ -0,0 +1,175 @@
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = (props: P) => ({
width: 20,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
...props,
});
export const PlusIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const SearchIcon = (p: P) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
export const SendIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 12 20 4l-6 16-3-7-7-1Z" />
</svg>
);
export const MicIcon = (p: P) => (
<svg {...base(p)}>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
export const ImageIcon = (p: P) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9.5" r="1.5" />
<path d="m21 16-5-5L5 20" />
</svg>
);
export const StopIcon = (p: P) => (
<svg {...base(p)}>
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none" />
</svg>
);
export const SettingsIcon = (p: P) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2 2 2 0 1 1-4 0 1.7 1.7 0 0 0-2.9-1.2l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.7 1.7 0 0 0 4.6 15a2 2 0 1 1 0-4 1.7 1.7 0 0 0 1.2-2.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.7 1.7 0 0 0 11.5 4a2 2 0 1 1 4 0 1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1A1.7 1.7 0 0 0 19.4 11a2 2 0 1 1 0 4Z" />
</svg>
);
export const MenuIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export const CloseIcon = (p: P) => (
<svg {...base(p)}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
export const PinIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 17v5M9 3h6l-1 6 3 3H7l3-3-1-6Z" />
</svg>
);
export const ArchiveIcon = (p: P) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4" />
</svg>
);
export const TrashIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
);
export const ChevronDownIcon = (p: P) => (
<svg {...base(p)}>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const SpeakerIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 9v6h4l5 4V5L8 9H4Z" />
<path d="M16.5 8.5a5 5 0 0 1 0 7" />
</svg>
);
export const UserIcon = (p: P) => (
<svg {...base(p)}>
<circle cx="12" cy="8" r="4" />
<path d="M4 20a8 8 0 0 1 16 0" />
</svg>
);
export const BotIcon = (p: P) => (
<svg {...base(p)}>
<rect x="4" y="8" width="16" height="11" rx="3" />
<path d="M12 8V4M9 13h.01M15 13h.01" />
</svg>
);
export const CheckIcon = (p: P) => (
<svg {...base(p)}>
<path d="m5 13 4 4L19 7" />
</svg>
);
export const RefreshIcon = (p: P) => (
<svg {...base(p)}>
<path d="M21 12a9 9 0 1 1-3-6.7L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
export const LogoutIcon = (p: P) => (
<svg {...base(p)}>
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l-5-5 5-5M5 12h11" />
</svg>
);
export const EditIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
export const SparkIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18" />
</svg>
);
export const BrainIcon = (p: P) => (
<svg {...base(p)}>
<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-1 5.8V15a3 3 0 0 0 4 2.8V20h2V4H9ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 1 5.8V15a3 3 0 0 1-4 2.8V20h-2V4h2Z" />
</svg>
);
export const DownloadIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 3v12M7 11l5 5 5-5M5 21h14" />
</svg>
);
export const FilterIcon = (p: P) => (
<svg {...base(p)}>
<path d="M3 5h18l-7 8v6l-4-2v-4L3 5Z" />
</svg>
);
export const ToolIcon = (p: P) => (
<svg {...base(p)}>
<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.3 2.3-2-2 2.3-2.3Z" />
</svg>
);
+151
View File
@@ -0,0 +1,151 @@
import { useState } from "react";
import type { Attachment, ChatMessage, ToolCall } from "../api/types";
import { renderMarkdown } from "../lib/markdown";
import { useTts } from "../hooks/useTts";
import { useStore } from "../state/store";
import { DerezLogo } from "./DerezLogo";
import { BrainIcon, CheckIcon, SpeakerIcon, ToolIcon, UserIcon } from "./Icons";
function Attachments({ items }: { items: Attachment[] }) {
if (!items?.length) return null;
return (
<div className="attachments">
{items.map((a, i) => {
const isImage = /^image\//.test(a.mime) || a.dataUrl.startsWith("data:image");
return (
<div className="attachment" key={i}>
{isImage ? <img src={a.dataUrl} alt={a.name} /> : <ToolIcon width={16} height={16} />}
<span className="nm">{a.name}</span>
</div>
);
})}
</div>
);
}
function ToolCard({ call }: { call: ToolCall }) {
const [open, setOpen] = useState(false);
const label =
call.status === "error" ? "error" : call.status === "done" ? "done" : "running…";
return (
<div className="tool-card">
<div
className="head"
onClick={() => setOpen((v) => !v)}
style={{ cursor: call.preview ? "pointer" : "default" }}
>
<ToolIcon width={15} height={15} style={{ color: "var(--text-dim)" }} />
<span className="name">{call.name}</span>
<span className={`status ${call.status === "error" ? "err" : call.status === "done" ? "ok" : ""}`}>
{label}
</span>
</div>
{call.preview && open && <div className="preview">{call.preview}</div>}
</div>
);
}
function ToolResultCard({ message }: { message: ChatMessage }) {
const [open, setOpen] = useState(false);
return (
<div className="tool-card">
<div className="head" onClick={() => setOpen((v) => !v)} style={{ cursor: "pointer" }}>
<ToolIcon width={15} height={15} style={{ color: "var(--text-dim)" }} />
<span className="name">{message.toolName ?? "tool result"}</span>
<span className="status ok">result</span>
</div>
{open && <div className="preview">{renderMarkdown(message.content)}</div>}
</div>
);
}
function SystemNote({ message }: { message: ChatMessage }) {
return (
<div style={{ fontSize: 12.5, color: "var(--text-faint)", padding: "2px 0 2px 41px", whiteSpace: "pre-wrap" }}>
{message.content}
</div>
);
}
export function MessageBubble({ message }: { message: ChatMessage }) {
const { speak } = useTts();
const [copied, setCopied] = useState(false);
const showToolCalls = useStore((s) => s.config.showToolCalls);
const showReasoning = useStore((s) => s.config.showReasoning);
const showSystem = useStore((s) => s.config.showSystem);
if (message.role === "tool") return showToolCalls ? <ToolResultCard message={message} /> : null;
if (message.role === "system") return showSystem ? <SystemNote message={message} /> : null;
const isUser = message.role === "user";
const text = message.content;
const toolCalls = message.toolCalls ?? [];
const attachments = message.attachments ?? [];
const streaming = !!message.streaming;
// Hide the whole bubble (avatar + label included) when nothing is visible
// after filtering — e.g. an assistant turn that was only tool calls.
const hasVisible =
!!text ||
(showReasoning && !!message.reasoning) ||
(showToolCalls && toolCalls.length > 0) ||
attachments.length > 0 ||
streaming;
if (!hasVisible) return null;
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {
/* ignore */
}
};
return (
<div className={`msg ${isUser ? "user" : "assistant"}`}>
<div className={`avatar ${isUser ? "user" : "brand"}`}>
{isUser ? <UserIcon width={16} height={16} /> : <DerezLogo height={30} withText={false} />}
</div>
<div className="content">
<div className="who">{isUser ? "You" : "Hermes"}</div>
{message.reasoning && showReasoning && (
<details className="reasoning">
<summary>
<BrainIcon width={14} height={14} />
Reasoning
</summary>
<div className="body">{message.reasoning}</div>
</details>
)}
{showToolCalls && toolCalls.map((c) => <ToolCard key={c.id} call={c} />)}
{text ? (
<div className="bubble">{isUser ? text : renderMarkdown(text)}</div>
) : streaming ? (
<div className="typing">
<span />
<span />
<span />
</div>
) : null}
<Attachments items={attachments} />
{!isUser && text && !streaming && (
<div className="msg-actions">
<button className="icon-btn" title="Copy" onClick={copy}>
{copied ? <CheckIcon width={15} height={15} /> : <span style={{ fontSize: 13 }}>⧉</span>}
</button>
<button className="icon-btn" title="Speak" onClick={() => speak(text)}>
<SpeakerIcon width={15} height={15} />
</button>
</div>
)}
</div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useState } from "react";
import { useStore } from "../state/store";
import { activeConnection } from "../lib/config";
import { ConnectionForm } from "./ConnectionForm";
import { CheckIcon, CloseIcon, EditIcon, PlusIcon, TrashIcon } from "./Icons";
export function SettingsPanel() {
const config = useStore((s) => s.config);
const capabilities = useStore((s) => s.capabilities);
const models = useStore((s) => s.models);
const saveConnection = useStore((s) => s.saveConnection);
const removeConnection = useStore((s) => s.removeConnection);
const setActiveConnection = useStore((s) => s.setActiveConnection);
const updatePrefs = useStore((s) => s.updatePrefs);
const setShowSettings = useStore((s) => s.setShowSettings);
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const current = activeConnection(config);
const features = capabilities?.features ? Object.entries(capabilities.features) : [];
return (
<div className="panel" onClick={() => setShowSettings(false)}>
<div className="panel-inner" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Settings</h2>
<button className="icon-btn" onClick={() => setShowSettings(false)}>
<CloseIcon width={20} height={20} />
</button>
</div>
<div className="section">
<h3>Connections</h3>
{config.connections.map((c) => (
<div key={c.id}>
<div className="kv">
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
className="link-btn"
style={{ cursor: "pointer" }}
onClick={() => setActiveConnection(c.id)}
>
{c.name}
{c.profile ? ` · ${c.profile}` : ""}
</button>
{c.id === config.activeConnectionId && (
<CheckIcon width={14} height={14} style={{ color: "var(--accent)" }} />
)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
{c.sameOrigin ? "same origin" : c.baseUrl}
<button
className={`icon-btn ${editingId === c.id ? "active" : ""}`}
title="Edit"
onClick={() => {
setAdding(false);
setEditingId(editingId === c.id ? null : c.id);
}}
>
<EditIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Remove" onClick={() => removeConnection(c.id)}>
<TrashIcon width={15} height={15} />
</button>
</span>
</div>
{editingId === c.id && (
<div style={{ margin: "10px 0 18px" }}>
<ConnectionForm
initial={c}
onSave={async (conn) => {
await saveConnection(conn);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
saveLabel="Save changes"
/>
</div>
)}
</div>
))}
{adding ? (
<div style={{ marginTop: 12 }}>
<ConnectionForm
onSave={async (conn) => {
await saveConnection(conn);
setAdding(false);
}}
onCancel={() => setAdding(false)}
saveLabel="Add"
/>
</div>
) : (
<button
className="btn"
style={{ marginTop: 12 }}
onClick={() => {
setEditingId(null);
setAdding(true);
}}
>
<PlusIcon width={15} height={15} style={{ verticalAlign: "-2px", marginRight: 6 }} />
Add connection
</button>
)}
</div>
<div className="section">
<h3>Voice</h3>
<label className="toggle" style={{ marginBottom: 12 }}>
<input
type="checkbox"
checked={config.speakReplies}
onChange={(e) => updatePrefs({ speakReplies: e.target.checked })}
/>
Speak replies automatically
</label>
<div className="field">
<label>Voice (browser)</label>
<input
value={config.ttsVoice}
onChange={(e) => updatePrefs({ ttsVoice: e.target.value })}
placeholder="System default"
/>
</div>
</div>
<div className="section">
<h3>Dictation</h3>
<div className="hint" style={{ marginTop: 0, marginBottom: 10 }}>
Chrome, Edge, and Safari dictate on-device via the Web Speech API. Firefox has no speech
API, so set an OpenAI-compatible transcription endpoint below to dictate there.
</div>
<div className="field">
<label>Transcription endpoint</label>
<input
value={config.transcribeUrl}
onChange={(e) => updatePrefs({ transcribeUrl: e.target.value })}
placeholder="https://api.openai.com/v1/audio/transcriptions"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
<div className="hint">
Any OpenAI-compatible <code>/v1/audio/transcriptions</code> — OpenAI, Groq, or a local
whisper.cpp server. Leave empty to disable.
</div>
</div>
<div className="field">
<label>API key</label>
<input
type="password"
value={config.transcribeKey}
onChange={(e) => updatePrefs({ transcribeKey: e.target.value })}
placeholder="sk-…"
autoCapitalize="none"
/>
</div>
<div className="field">
<label>Model</label>
<input
value={config.transcribeModel}
onChange={(e) => updatePrefs({ transcribeModel: e.target.value })}
placeholder="whisper-1"
autoCapitalize="none"
/>
</div>
</div>
<div className="section">
<h3>Media</h3>
<div className="hint" style={{ marginTop: 0, marginBottom: 10 }}>
Your agent emits <code>MEDIA:/path/to/file.png</code> for generated files. The Hermes API
server can't serve those, so point this at a URL where the agent's workspace is reachable
to render them as images.
</div>
<div className="field">
<label>Media base URL</label>
<input
value={config.mediaBaseUrl}
onChange={(e) => updatePrefs({ mediaBaseUrl: e.target.value })}
placeholder="https://files.example.com"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
</div>
<div className="field">
<label>Strip path prefix</label>
<input
value={config.mediaPathPrefix}
onChange={(e) => updatePrefs({ mediaPathPrefix: e.target.value })}
placeholder="/root/workspace"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<div className="hint">
Removed from the start of the path before appending. E.g. base{" "}
<code>https://files.example.com</code> + prefix <code>/root/workspace</code> turns{" "}
<code>MEDIA:/root/workspace/a.png</code> into{" "}
<code>https://files.example.com/a.png</code>.
</div>
</div>
</div>
<div className="section">
<h3>Server</h3>
<div className="kv">
<span>Connection</span>
<span>{current?.name ?? "—"}</span>
</div>
<div className="kv">
<span>Platform</span>
<span>{capabilities?.platform ?? "—"}</span>
</div>
<div className="kv">
<span>Model</span>
<span>{capabilities?.model ?? "—"}</span>
</div>
<div className="kv">
<span>Models available</span>
<span>{models.length || "—"}</span>
</div>
{features.length > 0 && (
<div style={{ marginTop: 10 }}>
<div className="hint" style={{ marginBottom: 6 }}>Features</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{features
.filter(([, v]) => v === true)
.map(([k]) => (
<span
key={k}
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 20,
background: "var(--bg-elev2)",
border: "1px solid var(--border)",
color: "var(--text-dim)",
}}
>
{k}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../state/store";
import { formatCost, relativeTime, shortSource } from "../lib/format";
import type { Session } from "../api/types";
import { DerezLogoLink } from "./DerezLogo";
import {
CheckIcon,
ChevronDownIcon,
EditIcon,
PlusIcon,
RefreshIcon,
SearchIcon,
SettingsIcon,
TrashIcon,
} from "./Icons";
function SessionItem({ s }: { s: Session }) {
const activeSessionId = useStore((st) => st.activeSessionId);
const openSession = useStore((st) => st.openSession);
const deleteSession = useStore((st) => st.deleteSession);
const renameSession = useStore((st) => st.renameSession);
const forkSession = useStore((st) => st.forkSession);
const active = activeSessionId === s.id;
const onRename = () => {
const next = window.prompt("Rename session", s.title);
if (next && next.trim()) void renameSession(s.id, next.trim());
};
const onDelete = () => {
if (window.confirm(`Delete "${s.title}"? This cannot be undone.`)) void deleteSession(s.id);
};
return (
<div
className={`session-item ${active ? "active" : ""}`}
onClick={() => openSession(s.id)}
title={s.preview || undefined}
>
<div className="body">
<div className="title">{s.title}</div>
<div className="meta">
<span>{relativeTime(s.updatedAt)}</span>
{typeof s.messageCount === "number" && s.messageCount > 0 && <span>{s.messageCount} msg</span>}
{s.source && <span>{shortSource(s.source)}</span>}
{s.costUsd ? <span>{formatCost(s.costUsd)}</span> : null}
</div>
</div>
<div className="actions" onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title="Rename" onClick={onRename}>
<EditIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Fork" onClick={() => forkSession(s.id)}>
<PlusIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Delete" onClick={onDelete}>
<TrashIcon width={15} height={15} />
</button>
</div>
</div>
);
}
function ConnectionSwitcher() {
const connections = useStore((s) => s.config.connections);
const activeId = useStore((s) => s.config.activeConnectionId);
const setActiveConnection = useStore((s) => s.setActiveConnection);
const setShowSettings = useStore((s) => s.setShowSettings);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const current = connections.find((c) => c.id === activeId) ?? connections[0];
return (
<div className="profile-switch" ref={ref}>
<button className="profile-btn" onClick={() => setOpen((v) => !v)}>
<span className="dot" />
<span className="name">{current?.name ?? "No connection"}</span>
{current?.profile && <span className="sub">{current.profile}</span>}
<ChevronDownIcon width={15} height={15} style={{ marginLeft: 6, color: "var(--text-faint)" }} />
</button>
{open && (
<div className="menu">
{connections.map((c) => (
<button
key={c.id}
className="menu-item"
onClick={() => {
setOpen(false);
if (c.id !== activeId) void setActiveConnection(c.id);
}}
>
<span>{c.name}</span>
{c.profile && <span style={{ fontSize: 11, color: "var(--text-faint)" }}>{c.profile}</span>}
{c.id === activeId && <CheckIcon width={15} height={15} className="check" />}
</button>
))}
<button
className="menu-item"
onClick={() => {
setOpen(false);
setShowSettings(true);
}}
>
<PlusIcon width={15} height={15} />
<span>Add connection…</span>
</button>
</div>
)}
</div>
);
}
export function Sidebar() {
const sessions = useStore((s) => s.sessions);
const sessionsLoading = useStore((s) => s.sessionsLoading);
const searchQuery = useStore((s) => s.searchQuery);
const setSearch = useStore((s) => s.setSearch);
const newSession = useStore((s) => s.newSession);
const loadSessions = useStore((s) => s.loadSessions);
const setShowSettings = useStore((s) => s.setShowSettings);
const sidebarOpen = useStore((s) => s.sidebarOpen);
const setSidebarOpen = useStore((s) => s.setSidebarOpen);
return (
<>
<div className={`scrim ${sidebarOpen ? "show" : ""}`} onClick={() => setSidebarOpen(false)} />
<aside className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="sidebar-head">
<div className="brand">
<DerezLogoLink height={26} />
<div className="brand-actions">
<button className="icon-btn" title="Refresh" onClick={() => loadSessions()}>
<RefreshIcon width={17} height={17} />
</button>
<button className="icon-btn" title="Settings" onClick={() => setShowSettings(true)}>
<SettingsIcon width={17} height={17} />
</button>
</div>
</div>
<ConnectionSwitcher />
<button className="new-chat" onClick={() => newSession()}>
<PlusIcon width={17} height={17} />
New chat
</button>
<div className="search-wrap">
<SearchIcon width={16} height={16} />
<input
value={searchQuery}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter sessions…"
/>
</div>
</div>
<div className="sidebar-tools">
<span>{sessions.length} sessions</span>
</div>
<div className="session-list">
{sessionsLoading && sessions.length === 0 && <div className="empty">Loading sessions…</div>}
{!sessionsLoading && sessions.length === 0 && (
<div className="empty">
No sessions yet.
<br />
Start a new chat to begin.
</div>
)}
{sessions.map((s) => (
<SessionItem key={s.id} s={s} />
))}
</div>
</aside>
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useStore } from "../state/store";
export function Toast() {
const toast = useStore((s) => s.toast);
const setToast = useStore((s) => s.setToast);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(t);
}, [toast, setToast]);
if (!toast) return null;
return (
<div className="toast" onClick={() => setToast(null)}>
{toast}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useRef, useState } from "react";
/**
* Push-to-talk audio capture via MediaRecorder. `stop()` resolves with the
* recorded Blob (or null if nothing was captured). Works in Firefox, Chrome,
* Safari — unlike the Web Speech API, which Firefox lacks.
*/
export function useRecorder() {
const [recording, setRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const mediaRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const resolveRef = useRef<((b: Blob | null) => void) | null>(null);
const start = useCallback(async (): Promise<boolean> => {
setError(null);
if (!navigator.mediaDevices?.getUserMedia) {
setError("Microphone capture is not supported in this browser.");
return false;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream);
chunksRef.current = [];
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mr.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunksRef.current, { type: mr.mimeType || "audio/webm" });
resolveRef.current?.(blob.size > 0 ? blob : null);
resolveRef.current = null;
};
mediaRef.current = mr;
mr.start();
setRecording(true);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Could not access the microphone.");
return false;
}
}, []);
const stop = useCallback((): Promise<Blob | null> => {
return new Promise((resolve) => {
const mr = mediaRef.current;
if (!mr || mr.state === "inactive") {
setRecording(false);
resolve(null);
return;
}
resolveRef.current = resolve;
mr.stop();
setRecording(false);
});
}, []);
return { recording, error, start, stop };
}
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useRef, useState } from "react";
// Minimal typings for the Web Speech API (not in the default TS DOM lib).
interface SpeechRecognitionResultLike {
0: { transcript: string };
isFinal: boolean;
}
interface SpeechRecognitionEventLike {
resultIndex: number;
results: { length: number; [i: number]: SpeechRecognitionResultLike };
}
interface SpeechRecognitionLike {
lang: string;
continuous: boolean;
interimResults: boolean;
start(): void;
stop(): void;
abort(): void;
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onerror: ((e: { error?: string }) => void) | null;
onend: (() => void) | null;
}
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
function getCtor(): SpeechRecognitionCtor | null {
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
/**
* Dictation via the browser's Web Speech API. Emits interim text live and a
* final transcript when recognition ends.
*/
export function useSpeechRecognition() {
const [listening, setListening] = useState(false);
const [interim, setInterim] = useState("");
const [error, setError] = useState<string | null>(null);
const recRef = useRef<SpeechRecognitionLike | null>(null);
const finalRef = useRef("");
const supported = typeof window !== "undefined" && !!getCtor();
const stop = useCallback(() => {
recRef.current?.stop();
setListening(false);
}, []);
const start = useCallback(
(onFinal: (text: string) => void) => {
const Ctor = getCtor();
if (!Ctor) {
setError("Speech recognition isn't supported in this browser.");
return;
}
setError(null);
setInterim("");
finalRef.current = "";
const rec = new Ctor();
rec.lang = navigator.language || "en-US";
// Keep listening until the user stops, so a pause doesn't cut the prompt off.
rec.continuous = true;
rec.interimResults = true;
rec.onresult = (e) => {
let live = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) finalRef.current += r[0].transcript;
else live += r[0].transcript;
}
setInterim(live);
};
rec.onerror = (e) => {
setError(e.error === "not-allowed" ? "Microphone permission denied." : "Speech recognition error.");
setListening(false);
};
rec.onend = () => {
setListening(false);
setInterim("");
const text = finalRef.current.trim();
if (text) onFinal(text);
};
recRef.current = rec;
rec.start();
setListening(true);
},
[],
);
return { supported, listening, interim, error, start, stop };
}
+34
View File
@@ -0,0 +1,34 @@
import { useCallback, useRef } from "react";
import { useStore } from "../state/store";
/**
* Speaks text with the browser's on-device speech synthesis. The Hermes API
* server has no TTS endpoint, so replies are voiced client-side.
*/
export function useTts() {
const voiceName = useStore((s) => s.config.ttsVoice);
const audioRef = useRef<SpeechSynthesisUtterance | null>(null);
const stop = useCallback(() => {
if (typeof speechSynthesis !== "undefined") speechSynthesis.cancel();
audioRef.current = null;
}, []);
const speak = useCallback(
(text: string) => {
const clean = text.trim();
if (!clean || typeof speechSynthesis === "undefined") return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(clean);
if (voiceName) {
const voice = speechSynthesis.getVoices().find((v) => v.name === voiceName);
if (voice) u.voice = voice;
}
audioRef.current = u;
speechSynthesis.speak(u);
},
[voiceName],
);
return { speak, stop };
}
+98
View File
@@ -0,0 +1,98 @@
import type { Connection } from "../api/types";
export interface AppConfig {
/** Saved API-server connections (one per server / profile). */
connections: Connection[];
activeConnectionId: string | null;
/** Speak assistant replies aloud (browser speech synthesis). */
speakReplies: boolean;
/** Preferred browser TTS voice name (empty = default). */
ttsVoice: string;
/** Message-type filters for the transcript. */
showToolCalls: boolean;
showReasoning: boolean;
showSystem: boolean;
/**
* Fallback dictation for browsers without the Web Speech API (e.g. Firefox):
* an OpenAI-compatible transcription endpoint. Empty = disabled.
*/
transcribeUrl: string;
transcribeKey: string;
transcribeModel: string;
/**
* The agent emits `MEDIA:/abs/path.png` for generated files. The API server
* can't serve those, so if you expose the agent's workspace over HTTP, set a
* base URL here and the app will render the image.
*/
mediaBaseUrl: string;
/** Path prefix to strip before appending to the media base URL. */
mediaPathPrefix: string;
/** Bumped when filter defaults change, so existing installs pick them up once. */
prefsVersion?: number;
}
const PREFS_VERSION = 2;
export const DEFAULT_CONFIG: AppConfig = {
connections: [],
activeConnectionId: null,
speakReplies: false,
ttsVoice: "",
// Default view: just the conversation. Tool calls and reasoning are hidden.
showToolCalls: false,
showReasoning: false,
showSystem: true,
transcribeUrl: "",
transcribeKey: "",
transcribeModel: "whisper-1",
mediaBaseUrl: "",
mediaPathPrefix: "",
prefsVersion: PREFS_VERSION,
};
const KEY = "hermes.config.v2";
export function loadConfig(): AppConfig {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULT_CONFIG };
const parsed = JSON.parse(raw) as Partial<AppConfig>;
const merged: AppConfig = {
...DEFAULT_CONFIG,
...parsed,
connections: Array.isArray(parsed.connections) ? parsed.connections : [],
};
// One-time migration: adopt the new filter defaults for existing installs.
if ((parsed.prefsVersion ?? 1) < PREFS_VERSION) {
merged.showToolCalls = DEFAULT_CONFIG.showToolCalls;
merged.showReasoning = DEFAULT_CONFIG.showReasoning;
merged.showSystem = DEFAULT_CONFIG.showSystem;
merged.prefsVersion = PREFS_VERSION;
}
return merged;
} catch {
return { ...DEFAULT_CONFIG };
}
}
export function saveConfig(config: AppConfig): void {
localStorage.setItem(KEY, JSON.stringify(config));
}
export function activeConnection(config: AppConfig): Connection | null {
if (!config.connections.length) return null;
return (
config.connections.find((c) => c.id === config.activeConnectionId) ??
config.connections[0] ??
null
);
}
export function isConfigured(config: AppConfig): boolean {
const c = activeConnection(config);
return !!c && (c.sameOrigin === true || c.baseUrl.trim().length > 0);
}
export function newConnectionId(): string {
return `conn-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
}
+37
View File
@@ -0,0 +1,37 @@
/** Derives a sensible filename for an image URL (data: or remote). */
export function imageFilename(url: string, alt: string): string {
const base =
(alt || "image")
.trim()
.replace(/[^\w.-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 48) || "image";
let ext = "";
const dataMatch = /^data:image\/([a-zA-Z0-9.+-]+)/.exec(url);
if (dataMatch) {
ext = dataMatch[1].toLowerCase();
} else {
try {
const path = new URL(url, location.href).pathname;
const m = /\.([a-zA-Z0-9]{2,5})$/.exec(path);
if (m) ext = m[1].toLowerCase();
} catch {
/* ignore */
}
}
if (ext === "jpeg") ext = "jpg";
if (!ext) ext = "png";
return `${base}.${ext}`;
}
/** Triggers a browser download for an image URL (works for data: URLs). */
export function downloadImage(url: string, alt: string): void {
const a = document.createElement("a");
a.href = url;
a.download = imageFilename(url, alt);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}
+36
View File
@@ -0,0 +1,36 @@
export function relativeTime(ms?: number): string {
if (!ms) return "";
const value = ms > 1e12 ? ms : ms * 1000;
const diff = Date.now() - value;
const s = Math.round(diff / 1000);
if (s < 60) return "just now";
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(value).toLocaleDateString();
}
export function formatTokens(n?: number): string {
if (!n) return "0";
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1_000_000).toFixed(2)}M`;
}
export function formatCost(cost?: number): string {
if (cost === undefined || cost === null || cost === 0) return "";
if (cost < 0.01) return `$${cost.toFixed(4)}`;
return `$${cost.toFixed(2)}`;
}
export function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function shortSource(source?: string): string {
if (!source) return "";
return source.length > 14 ? `${source.slice(0, 13)}…` : source;
}
+138
View File
@@ -0,0 +1,138 @@
import { useState, type ReactNode } from "react";
import { downloadImage } from "./download";
import { mediaName, mediaUrl } from "./media";
import { useStore } from "../state/store";
import { DownloadIcon, ToolIcon } from "../components/Icons";
// A deliberately small, dependency-free markdown renderer. It escapes all text
// (no raw HTML) and supports the subset that matters for chat: fenced code
// blocks, inline code, images (including data: URLs), links, bold, italic, and
// autolinks.
const INLINE =
/(`[^`]+`)|(!\[[^\]]*\]\([^)]*\))|(\[[^\]]*\]\([^)]*\))|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|(https?:\/\/[^\s<)]+)|(data:image\/[^\s)]+)|(MEDIA:\S+)/g;
/** Splits `alt](url` (the inside of `![alt](url)` / `[text](url)`) into parts. */
function splitLink(inner: string): { label: string; url: string } {
const idx = inner.indexOf("](");
if (idx === -1) return { label: inner, url: "" };
return { label: inner.slice(0, idx), url: inner.slice(idx + 2) };
}
/** Some servers wrap long data URLs across lines; strip whitespace so the
* browser can decode them. */
function cleanUrl(url: string): string {
return url.startsWith("data:") ? url.replace(/\s+/g, "") : url;
}
/** A `MEDIA:<path>` reference: an image if a media base URL is configured,
* otherwise an attachment card. */
function MediaRef({ path }: { path: string }) {
const baseUrl = useStore((s) => s.config.mediaBaseUrl);
const prefix = useStore((s) => s.config.mediaPathPrefix);
const [failed, setFailed] = useState(false);
const url = mediaUrl(path, baseUrl, prefix);
const name = mediaName(path);
if (url && !failed) {
return (
<span className="md-figure">
<img src={url} alt={name} onError={() => setFailed(true)} />
<button type="button" className="md-download" onClick={() => downloadImage(url, name)}>
<DownloadIcon width={15} height={15} />
Download
</button>
</span>
);
}
return (
<span className="media-ref" title={path}>
<ToolIcon width={15} height={15} />
<span className="nm">{name}</span>
<span className="path">{path}</span>
</span>
);
}
/** An inline image with a download button beneath it. */
function ImageFigure({ url, alt }: { url: string; alt: string }) {
const src = cleanUrl(url);
return (
<span className="md-figure">
<img src={src} alt={alt} />
<button type="button" className="md-download" onClick={() => downloadImage(src, alt)}>
<DownloadIcon width={15} height={15} />
Download
</button>
</span>
);
}
function renderInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
let last = 0;
let i = 0;
let m: RegExpExecArray | null;
INLINE.lastIndex = 0;
while ((m = INLINE.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index));
const token = m[0];
const key = `${keyPrefix}-${i++}`;
if (m[1]) {
nodes.push(<code key={key}>{token.slice(1, -1)}</code>);
} else if (m[2]) {
const { label, url } = splitLink(token.slice(2, -1));
nodes.push(<ImageFigure key={key} url={url} alt={label} />);
} else if (m[3]) {
const { label, url } = splitLink(token.slice(1, -1));
nodes.push(
<a key={key} href={url} target="_blank" rel="noreferrer noopener">
{label}
</a>,
);
} else if (m[4]) {
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
} else if (m[5]) {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
} else if (m[6]) {
nodes.push(
<a key={key} href={token} target="_blank" rel="noreferrer noopener">
{token}
</a>,
);
} else if (m[7]) {
nodes.push(<ImageFigure key={key} url={token} alt="image" />);
} else if (m[8]) {
nodes.push(<MediaRef key={key} path={token.slice("MEDIA:".length)} />);
}
last = m.index + token.length;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
}
export function renderMarkdown(text: string): ReactNode {
const parts: ReactNode[] = [];
const fence = /```([^\n`]*)\n?([\s\S]*?)```/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = fence.exec(text)) !== null) {
if (m.index > last) {
parts.push(<span key={`t${i}`}>{renderInline(text.slice(last, m.index), `t${i}`)}</span>);
}
parts.push(
<pre key={`c${i}`}>
<code>{m[2].replace(/\n$/, "")}</code>
</pre>,
);
last = m.index + m[0].length;
i++;
}
if (last < text.length) {
parts.push(<span key={`t${i}`}>{renderInline(text.slice(last), `t${i}`)}</span>);
}
return parts;
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The agent emits `MEDIA:<absolute path>` for files it generated. The Hermes
* API server does not serve those files, so the app can only show them if the
* host exposes the workspace over HTTP. This builds that URL.
*/
export function mediaUrl(path: string, baseUrl: string, stripPrefix: string): string | null {
const base = baseUrl.trim();
if (!base) return null;
let p = path.trim();
const prefix = stripPrefix.trim();
if (prefix && p.startsWith(prefix)) p = p.slice(prefix.length);
if (!p.startsWith("/")) p = `/${p}`;
return base.replace(/\/+$/, "") + p;
}
export function mediaName(path: string): string {
const clean = path.trim().replace(/[/\\]+$/, "");
const parts = clean.split(/[/\\]/);
return parts[parts.length - 1] || clean;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Sends recorded audio to an OpenAI-compatible transcription endpoint
* (`POST <url>` with a multipart `file` field). Works with OpenAI, Groq,
* a local whisper.cpp / faster-whisper server, and similar.
*/
export async function transcribeAudio(
blob: Blob,
opts: { url: string; key?: string; model?: string },
): Promise<string> {
const form = new FormData();
const ext = blob.type.includes("ogg") ? "ogg" : blob.type.includes("mp4") ? "mp4" : "webm";
form.append("file", blob, `recording.${ext}`);
if (opts.model) form.append("model", opts.model);
const headers: Record<string, string> = {};
if (opts.key) headers["Authorization"] = `Bearer ${opts.key}`;
let res: Response;
try {
res = await fetch(opts.url, { method: "POST", headers, body: form });
} catch {
throw new Error(`Could not reach the transcription endpoint (${opts.url}).`);
}
const text = await res.text();
let body: unknown;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = text;
}
if (!res.ok) {
const msg =
body && typeof body === "object"
? ((body as Record<string, unknown>).error as { message?: string } | string | undefined)
: undefined;
const detail =
typeof msg === "string" ? msg : (msg as { message?: string } | undefined)?.message;
throw new Error(detail || `Transcription failed (HTTP ${res.status}).`);
}
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
if (typeof b.text === "string") return b.text;
if (typeof b.transcript === "string") return b.transcript;
}
if (typeof body === "string") return body;
return "";
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { registerSW } from "virtual:pwa-register";
import { App } from "./App";
import "./styles.css";
registerSW({ immediate: true });
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
+546
View File
@@ -0,0 +1,546 @@
import { create } from "zustand";
import { HermesClient, HermesError } from "../api/client";
import type {
Attachment,
Capabilities,
ChatMessage,
Connection,
Session,
ToolCall,
} from "../api/types";
import {
DEFAULT_CONFIG,
activeConnection,
loadConfig,
saveConfig,
type AppConfig,
} from "../lib/config";
let chatAbort: AbortController | null = null;
interface AppState {
config: AppConfig;
client: HermesClient | null;
ready: boolean;
connectionError: string | null;
capabilities: Capabilities | null;
models: string[];
sessions: Session[];
sessionsLoading: boolean;
searchQuery: string;
activeSessionId: string | null;
session: Session | null;
messages: ChatMessage[];
sessionLoading: boolean;
streaming: boolean;
runId: string | null;
streamError: string | null;
steerLeftover: string | null;
sidebarOpen: boolean;
showSettings: boolean;
toast: string | null;
bootstrap: () => Promise<void>;
saveConnection: (conn: Connection) => Promise<void>;
removeConnection: (id: string) => Promise<void>;
setActiveConnection: (id: string) => Promise<void>;
updatePrefs: (
patch: Partial<
Pick<
AppConfig,
| "speakReplies"
| "ttsVoice"
| "showToolCalls"
| "showReasoning"
| "showSystem"
| "transcribeUrl"
| "transcribeKey"
| "transcribeModel"
| "mediaBaseUrl"
| "mediaPathPrefix"
>
>,
) => void;
connect: () => Promise<void>;
loadSessions: () => Promise<void>;
setSearch: (q: string) => void;
openSession: (id: string) => Promise<void>;
newSession: () => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
deleteSession: (id: string) => Promise<void>;
forkSession: (id: string) => Promise<void>;
send: (text: string, attachments?: Attachment[]) => Promise<void>;
cancel: () => Promise<void>;
steer: (text: string) => Promise<void>;
clearSteerLeftover: () => void;
setSidebarOpen: (v: boolean) => void;
setShowSettings: (v: boolean) => void;
setToast: (t: string | null) => void;
}
function buildClient(conn: Connection): HermesClient {
return new HermesClient({
baseUrl: conn.baseUrl,
apiKey: conn.apiKey,
profile: conn.profile,
sameOrigin: conn.sameOrigin,
customHeaders: conn.customHeaders,
});
}
function errText(err: unknown): string {
if (err instanceof HermesError) return err.message;
if (err instanceof Error) return err.message;
return String(err);
}
function updateMessage(
messages: ChatMessage[],
id: string,
fn: (m: ChatMessage) => ChatMessage,
): ChatMessage[] {
return messages.map((m) => (m.id === id ? fn(m) : m));
}
export const useStore = create<AppState>()((set, get) => ({
config: { ...DEFAULT_CONFIG },
client: null,
ready: false,
connectionError: null,
capabilities: null,
models: [],
sessions: [],
sessionsLoading: false,
searchQuery: "",
activeSessionId: null,
session: null,
messages: [],
sessionLoading: false,
streaming: false,
runId: null,
streamError: null,
steerLeftover: null,
sidebarOpen: false,
showSettings: false,
toast: null,
async bootstrap() {
const config = loadConfig();
set({ config, ready: true });
if (activeConnection(config)) await get().connect();
},
async saveConnection(conn) {
const config = get().config;
const exists = config.connections.some((c) => c.id === conn.id);
const connections = exists
? config.connections.map((c) => (c.id === conn.id ? conn : c))
: [...config.connections, conn];
const next: AppConfig = { ...config, connections, activeConnectionId: conn.id };
saveConfig(next);
set({ config: next });
await get().connect();
},
async removeConnection(id) {
const config = get().config;
const connections = config.connections.filter((c) => c.id !== id);
const activeConnectionId =
config.activeConnectionId === id ? (connections[0]?.id ?? null) : config.activeConnectionId;
const next: AppConfig = { ...config, connections, activeConnectionId };
saveConfig(next);
set({ config: next, sessions: [], session: null, messages: [], activeSessionId: null });
if (activeConnection(next)) await get().connect();
},
async setActiveConnection(id) {
const config = { ...get().config, activeConnectionId: id };
saveConfig(config);
set({ config, sessions: [], session: null, messages: [], activeSessionId: null });
await get().connect();
},
updatePrefs(patch) {
const config = { ...get().config, ...patch };
saveConfig(config);
set({ config });
},
async connect() {
const conn = activeConnection(get().config);
if (!conn || (!conn.sameOrigin && !conn.baseUrl.trim())) return;
const client = buildClient(conn);
set({ client, connectionError: null, capabilities: null });
try {
const capabilities = await client.capabilities();
set({ capabilities });
void client.models().then((models) => set({ models })).catch(() => {});
await get().loadSessions();
} catch (err) {
set({ connectionError: errText(err), capabilities: null });
}
},
async loadSessions() {
const { client, searchQuery } = get();
if (!client) return;
set({ sessionsLoading: true });
try {
const sessions = await client.listSessions({ limit: 200 });
const q = searchQuery.trim().toLowerCase();
const filtered = q
? sessions.filter((s) => s.title.toLowerCase().includes(q))
: sessions;
set({ sessions: filtered, sessionsLoading: false });
} catch (err) {
set({ sessionsLoading: false, toast: errText(err) });
}
},
setSearch(q) {
set({ searchQuery: q });
void get().loadSessions();
},
async openSession(id) {
const { client } = get();
if (!client) return;
set({
activeSessionId: id,
sessionLoading: true,
session: null,
messages: [],
sidebarOpen: false,
streamError: null,
});
try {
const [session, messages] = await Promise.all([
client.getSession(id).catch(() => null),
client.getMessages(id, { limit: 500, order: "oldest" }),
]);
set({
session: session ?? { id, title: "Untitled" },
messages,
sessionLoading: false,
});
} catch (err) {
set({ sessionLoading: false, toast: errText(err) });
}
},
async newSession() {
const { client } = get();
if (!client) return;
try {
const session = await client.createSession();
set({
session,
messages: [],
activeSessionId: session.id,
sidebarOpen: false,
streamError: null,
});
await get().loadSessions();
} catch (err) {
set({ toast: errText(err) });
}
},
async renameSession(id, title) {
const { client } = get();
if (!client) return;
try {
await client.updateSession(id, { title });
set({
sessions: get().sessions.map((s) => (s.id === id ? { ...s, title } : s)),
session: get().session?.id === id ? { ...(get().session as Session), title } : get().session,
});
} catch (err) {
set({ toast: errText(err) });
}
},
async deleteSession(id) {
const { client } = get();
if (!client) return;
try {
await client.deleteSession(id);
const wasActive = get().activeSessionId === id;
set({
sessions: get().sessions.filter((s) => s.id !== id),
...(wasActive ? { activeSessionId: null, session: null, messages: [] } : {}),
});
} catch (err) {
set({ toast: errText(err) });
}
},
async forkSession(id) {
const { client } = get();
if (!client) return;
try {
const session = await client.forkSession(id);
await get().loadSessions();
await get().openSession(session.id);
} catch (err) {
set({ toast: errText(err) });
}
},
async send(text, attachments) {
const { client, activeSessionId, messages } = get();
if (!client || !activeSessionId) return;
const trimmed = text.trim();
if (!trimmed && !attachments?.length) return;
if (get().streaming) return;
const userMsg: ChatMessage = {
id: `local-user-${Date.now()}`,
role: "user",
content: trimmed,
attachments,
timestamp: Date.now(),
};
const assistantId = `local-assistant-${Date.now()}`;
const assistantMsg: ChatMessage = {
id: assistantId,
role: "assistant",
content: "",
timestamp: Date.now(),
streaming: true,
toolCalls: [],
};
set({
messages: [...messages, userMsg, assistantMsg],
streaming: true,
streamError: null,
runId: null,
});
// Build the input: a plain string, or OpenAI-style content parts with images.
let input: string | Array<Record<string, unknown>> = trimmed;
if (attachments?.length) {
const parts: Array<Record<string, unknown>> = [];
if (trimmed) parts.push({ type: "text", text: trimmed });
for (const a of attachments) {
parts.push({ type: "image_url", image_url: { url: a.dataUrl } });
}
input = parts;
}
try {
chatAbort = new AbortController();
await client.chatStream(
activeSessionId,
{ input },
(event, data) => applyStreamEvent(set, get, event, data, assistantId),
chatAbort.signal,
);
} catch (err) {
if ((err as Error)?.name !== "AbortError") {
const msg =
err instanceof HermesError && err.status === 409
? "This session is owned by another surface (CLI, dashboard, or messaging) and can't be continued from the API server. Start a new chat to talk to the agent here."
: errText(err);
set({ streamError: msg });
}
} finally {
set({ streaming: false, runId: null });
set({
messages: updateMessage(get().messages, assistantId, (m) => ({ ...m, streaming: false })),
});
void get().loadSessions();
}
},
async cancel() {
const { client, runId } = get();
chatAbort?.abort();
if (client && runId) {
try {
await client.stopRun(runId);
} catch (err) {
set({ toast: errText(err) });
}
}
set({ streaming: false });
},
async steer(text) {
const { client, runId } = get();
if (!client || !runId || !text.trim()) return;
try {
await client.steerRun(runId, text.trim());
} catch (err) {
set({ toast: errText(err) });
}
},
clearSteerLeftover() {
set({ steerLeftover: null });
},
setSidebarOpen(v) {
set({ sidebarOpen: v });
},
setShowSettings(v) {
set({ showSettings: v });
},
setToast(t) {
set({ toast: t });
},
}));
// ---- stream event handling --------------------------------------------------
type Set = (partial: Partial<AppState>) => void;
type Get = () => AppState;
function textOf(d: Record<string, unknown>): string {
for (const k of ["text", "delta", "content", "output_text"]) {
const v = d[k];
if (typeof v === "string") return v;
}
return "";
}
function toolKey(d: Record<string, unknown>): string {
return String(d.id ?? d.tool_call_id ?? d.tool_use_id ?? d.tool ?? d.name ?? Math.random());
}
function applyStreamEvent(set: Set, get: Get, event: string, data: unknown, assistantId: string) {
const d = (data ?? {}) as Record<string, unknown>;
// Capture the run id wherever it appears so cancel/steer can target it.
if (typeof d.run_id === "string" && d.run_id !== get().runId) {
set({ runId: d.run_id });
}
switch (event) {
case "assistant.delta":
case "message.delta": {
const text = textOf(d);
if (!text) break;
set({
messages: updateMessage(get().messages, assistantId, (m) => ({
...m,
content: m.content + text,
})),
});
break;
}
case "assistant.commentary":
case "message.interim": {
if (d.already_streamed) break;
const text = textOf(d);
if (!text) break;
set({
messages: updateMessage(get().messages, assistantId, (m) => ({ ...m, content: text })),
});
break;
}
case "assistant.completed":
case "message.complete": {
const text = textOf(d);
set({
messages: updateMessage(get().messages, assistantId, (m) => ({
...m,
content: text || m.content,
streaming: false,
})),
});
break;
}
case "tool.started": {
const call: ToolCall = {
id: toolKey(d),
name: String(d.tool ?? d.name ?? "tool"),
preview: typeof d.preview === "string" ? d.preview : undefined,
status: "running",
};
set({
messages: updateMessage(get().messages, assistantId, (m) => {
const list = m.toolCalls ?? [];
const exists = list.some((t) => t.id === call.id);
return {
...m,
toolCalls: exists ? list.map((t) => (t.id === call.id ? { ...t, ...call } : t)) : [...list, call],
};
}),
});
break;
}
case "tool.completed":
case "tool.failed": {
const isError = event === "tool.failed" || d.error === true || typeof d.error === "string";
const call: ToolCall = {
id: toolKey(d),
name: String(d.tool ?? d.name ?? "tool"),
preview: typeof d.preview === "string" ? d.preview : undefined,
status: isError ? "error" : "done",
duration: typeof d.duration === "number" ? d.duration : undefined,
};
set({
messages: updateMessage(get().messages, assistantId, (m) => {
const list = m.toolCalls ?? [];
const exists = list.some((t) => t.id === call.id);
return {
...m,
toolCalls: exists ? list.map((t) => (t.id === call.id ? { ...t, ...call } : t)) : [...list, call],
};
}),
});
break;
}
case "run.completed": {
const output = typeof d.output === "string" ? d.output : "";
set({
messages: updateMessage(get().messages, assistantId, (m) => ({
...m,
content: output || m.content,
streaming: false,
})),
});
if (typeof d.pending_steer === "string" && d.pending_steer) {
set({ steerLeftover: d.pending_steer });
}
break;
}
case "run.failed":
case "run.cancelled": {
const output = typeof d.output === "string" ? d.output : "";
const err = typeof d.error === "string" ? d.error : undefined;
set({
messages: updateMessage(get().messages, assistantId, (m) => ({
...m,
content: output || m.content,
streaming: false,
error: event === "run.failed",
})),
...(err ? { streamError: err } : {}),
});
break;
}
case "error": {
set({ streamError: String(d.error ?? d.message ?? "The turn failed."), streaming: false });
break;
}
default:
// Unknown event types are ignored by design.
break;
}
}
+1164
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vite-plugin-pwa/client", "node"]
},
"include": ["src"]
}
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
/** Emits the repo's LICENSE file into the build output. */
function licensePlugin(): Plugin {
return {
name: "hermes-pwa-license",
apply: "build",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "LICENSE",
source: readFileSync(fileURLToPath(new URL("LICENSE", import.meta.url)), "utf-8"),
});
},
};
}
// During `npm run dev` you can point the dev server at a Hermes host to avoid
// CORS entirely: set HERMES_DEV_TARGET=https://hermes.example.com in a .env file
// (or the environment) and the dev server proxies /api/* to it.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, ".", "");
const target = env.HERMES_DEV_TARGET;
return {
server: {
port: 5173,
proxy: target
? Object.fromEntries(
["/api", "/v1", "/health"].map((prefix) => [
prefix,
{
target,
changeOrigin: true,
secure: false,
// SSE needs buffering disabled.
configure: (proxy) => {
proxy.on("proxyRes", (proxyRes) => {
proxyRes.headers["x-accel-buffering"] = "no";
});
},
},
]),
)
: undefined,
},
plugins: [
react(),
licensePlugin(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icons/apple-touch-icon.png", "favicon.svg"],
manifest: {
name: "Hermes",
short_name: "Hermes",
description: "Chat with your Hermes Agent profiles, sessions, voice and images.",
theme_color: "#000810",
background_color: "#000810",
display: "standalone",
orientation: "portrait-primary",
start_url: "/",
scope: "/",
icons: [
{ src: "icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "icons/icon-512.png", sizes: "512x512", type: "image/png" },
{
src: "icons/maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
// Never cache API traffic — it is authenticated and dynamic.
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
handler: "NetworkOnly",
},
],
},
devOptions: {
enabled: false,
},
}),
],
};
});