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