Restructure: move Vite project into app/, deploy build output to repo root

The webserver serves the repo checkout directly as static files, so
index.html at the repo root must be the production build (pointing at
hashed /assets/*.js) rather than the dev template (/src/main.tsx),
which was being served with an unrecognized application/octet-stream
MIME type and blocked by the browser.

- Move src/, public/, scripts/, server/, package.json, vite.config.ts,
  tsconfig.json, .env(.example), and the dev index.html into app/.
- Add app/scripts/deploy.mjs: copies app/dist/* into the repo root
  after every `vite build`, cleaning up stale hashed filenames.
- Wire it into `npm run build` (app/package.json).
- Update run.sh to operate from app/, and .gitignore for the new layout.
- Commit the current build output (index.html, assets/, manifest,
  sw.js, workbox-*.js, favicon.svg, icons/, LICENSE) to the repo root.
- Update README with the new project layout and deployment model.
This commit is contained in:
oliver committed 2026-11-04 14:05:46 -03:00
1 parent 5245c0a856
commit c7b653ff6a
54 files changed
+224 -34

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
+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 && node scripts/deploy.mjs",
"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

+48
View File
@@ -0,0 +1,48 @@
// Copies the production build (app/dist) into the repository root, so the
// webserver can serve the repo checkout directly as static files — no
// server-side build step, no special MIME-type config, no reverse proxy
// required. Run automatically as part of `npm run build`.
//
// node scripts/deploy.mjs
//
import { readdir, rm, cp, unlink, copyFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DIST = resolve(__dirname, "../dist");
const REPO_ROOT = resolve(__dirname, "../../");
const entries = await readdir(DIST, { withFileTypes: true });
for (const entry of entries) {
const src = join(DIST, entry.name);
const dest = join(REPO_ROOT, entry.name);
if (entry.isDirectory()) {
// Directories (assets/, icons/) are pure build output — wipe and replace
// so stale, superseded hashed filenames don't pile up.
await rm(dest, { recursive: true, force: true });
await cp(src, dest, { recursive: true });
console.log(` dir ${entry.name}/`);
continue;
}
// Hashed, content-addressed files (e.g. workbox-<hash>.js) change name
// between builds — remove any previous versions before copying the new one.
const hashedPrefixMatch = entry.name.match(/^(workbox-)[0-9a-f]+(\.js)$/);
if (hashedPrefixMatch) {
const [, prefix, ext] = hashedPrefixMatch;
const siblings = await readdir(REPO_ROOT);
for (const sibling of siblings) {
if (sibling.startsWith(prefix) && sibling.endsWith(ext) && sibling !== entry.name) {
await unlink(join(REPO_ROOT, sibling));
}
}
}
await copyFile(src, dest);
console.log(` file ${entry.name}`);
}
console.log(`\nDeployed build output from ${DIST} -> ${REPO_ROOT}`);
+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,
},
}),
],
};
});