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

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

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

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../state/store";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
import { FilterIcon, MenuIcon, PlusIcon } from "./Icons";
function FilterMenu() {
const showToolCalls = useStore((s) => s.config.showToolCalls);
const showReasoning = useStore((s) => s.config.showReasoning);
const showSystem = useStore((s) => s.config.showSystem);
const updatePrefs = useStore((s) => s.updatePrefs);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const items: Array<[string, boolean, (v: boolean) => void]> = [
["Tool calls & results", showToolCalls, (v) => updatePrefs({ showToolCalls: v })],
["Reasoning", showReasoning, (v) => updatePrefs({ showReasoning: v })],
["System messages", showSystem, (v) => updatePrefs({ showSystem: v })],
];
return (
<div className="profile-switch" ref={ref} style={{ width: "auto" }}>
<button
className={`icon-btn ${open ? "active" : ""}`}
title="Filter messages"
onClick={() => setOpen((v) => !v)}
>
<FilterIcon width={19} height={19} />
</button>
{open && (
<div className="menu" style={{ left: "auto", right: 0, width: 230 }}>
{items.map(([label, checked, onChange]) => (
<label className="menu-item toggle" key={label}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
))}
</div>
)}
</div>
);
}
export function ChatView() {
const session = useStore((s) => s.session);
const messages = useStore((s) => s.messages);
const sessionLoading = useStore((s) => s.sessionLoading);
const streamError = useStore((s) => s.streamError);
const setSidebarOpen = useStore((s) => s.setSidebarOpen);
const newSession = useStore((s) => s.newSession);
const scrollRef = useRef<HTMLDivElement>(null);
const stickRef = useRef(true);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
};
useEffect(() => {
const el = scrollRef.current;
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
}, [messages]);
const header = (
<div className="chat-head">
<button className="icon-btn menu-btn" onClick={() => setSidebarOpen(true)}>
<MenuIcon width={20} height={20} />
</button>
<div className="titles">
<div className="t">{session?.title ?? "Hermes"}</div>
<div className="s">
{session?.model && <span className="pill">{session.model}</span>}
{session?.source && <span>{session.source}</span>}
</div>
</div>
<FilterMenu />
<button className="icon-btn" title="New chat" onClick={() => newSession()}>
<PlusIcon width={19} height={19} />
</button>
</div>
);
if (!session && !sessionLoading) {
return (
<div className="main">
{header}
<div className="center-fill">
<div>
<div style={{ fontSize: 40, marginBottom: 12 }}>💬</div>
<div style={{ color: "var(--text)", fontWeight: 600, marginBottom: 6 }}>
No conversation open
</div>
<div>Pick a session from the sidebar, or start a new chat.</div>
<button className="btn primary" style={{ marginTop: 18 }} onClick={() => newSession()}>
New chat
</button>
</div>
</div>
</div>
);
}
return (
<div className="main">
{header}
<div className="messages" ref={scrollRef} onScroll={onScroll}>
<div className="messages-inner">
{sessionLoading && (
<div className="center-fill">
<div className="spinner" />
</div>
)}
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
{streamError && <div className="alert error">{streamError}</div>}
</div>
</div>
<Composer />
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../state/store";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
import { useRecorder } from "../hooks/useRecorder";
import { transcribeAudio } from "../lib/transcribe";
import type { Attachment } from "../api/types";
import { CloseIcon, ImageIcon, MicIcon, SendIcon, StopIcon } from "./Icons";
function fileToAttachment(file: File): Promise<Attachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
name: file.name,
mime: file.type || "application/octet-stream",
dataUrl: String(reader.result),
size: file.size,
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
const isCoarsePointer = () =>
typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches;
export function Composer() {
const streaming = useStore((s) => s.streaming);
const send = useStore((s) => s.send);
const steer = useStore((s) => s.steer);
const cancel = useStore((s) => s.cancel);
const steerLeftover = useStore((s) => s.steerLeftover);
const clearSteerLeftover = useStore((s) => s.clearSteerLeftover);
const setToast = useStore((s) => s.setToast);
const transcribeUrl = useStore((s) => s.config.transcribeUrl);
const transcribeKey = useStore((s) => s.config.transcribeKey);
const transcribeModel = useStore((s) => s.config.transcribeModel);
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [transcribing, setTranscribing] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const speech = useSpeechRecognition();
const recorder = useRecorder();
const serverDictation = !speech.supported && !!transcribeUrl.trim();
const dictationAvailable = speech.supported || serverDictation;
const active = speech.listening || recorder.recording;
useEffect(() => {
if (steerLeftover) {
setText((t) => (t ? t : steerLeftover));
clearSteerLeftover();
}
}, [steerLeftover, clearSteerLeftover]);
useEffect(() => {
if (speech.error) setToast(speech.error);
}, [speech.error, setToast]);
useEffect(() => {
if (recorder.error) setToast(recorder.error);
}, [recorder.error, setToast]);
useEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
}, [text]);
const addFiles = async (files: File[]) => {
for (const file of files) {
try {
const att = await fileToAttachment(file);
setAttachments((prev) => [...prev, att]);
} catch {
setToast(`Could not attach ${file.name}`);
}
}
};
const doSend = () => {
const trimmed = text.trim();
if (!trimmed && !attachments.length) return;
if (streaming) {
void steer(trimmed);
setText("");
return;
}
void send(trimmed, attachments);
setText("");
setAttachments([]);
};
const append = (transcript: string) => {
const t = transcript.trim();
if (t) setText((prev) => (prev ? `${prev} ${t}` : t));
};
const stopDictation = async () => {
if (speech.listening) {
speech.stop();
return;
}
if (recorder.recording) {
const blob = await recorder.stop();
if (!blob) return;
setTranscribing(true);
try {
const transcript = await transcribeAudio(blob, {
url: transcribeUrl.trim(),
key: transcribeKey.trim() || undefined,
model: transcribeModel.trim() || undefined,
});
append(transcript);
} catch (err) {
setToast(err instanceof Error ? err.message : "Transcription failed.");
} finally {
setTranscribing(false);
}
}
};
const toggleMic = async () => {
if (active) {
await stopDictation();
return;
}
if (speech.supported) {
if (!window.isSecureContext) {
setToast("Dictation needs a secure context (HTTPS or localhost).");
return;
}
speech.start(append);
return;
}
if (serverDictation) {
const ok = await recorder.start();
if (!ok) setToast("Could not access the microphone.");
return;
}
setToast(
"Dictation isn't available in this browser. Use Chrome, Edge, or Safari — or set a transcription endpoint in Settings.",
);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !isCoarsePointer()) {
e.preventDefault();
doSend();
}
};
const canSend = text.trim().length > 0 || attachments.length > 0;
const pillText = transcribing
? "Transcribing…"
: speech.listening
? speech.interim || "Listening…"
: recorder.recording
? "Recording… tap the mic to stop"
: "";
return (
<div className="composer-wrap">
<div
className="composer"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (e.dataTransfer.files?.length) void addFiles(Array.from(e.dataTransfer.files));
}}
>
{attachments.length > 0 && (
<div className="attach-chips">
{attachments.map((a) => (
<div className="chip" key={a.name + a.size}>
{/^image\//.test(a.mime) ? (
<img src={a.dataUrl} alt={a.name} />
) : (
<ImageIcon width={15} height={15} />
)}
<span className="nm">{a.name}</span>
<button
title="Remove"
onClick={() => setAttachments((prev) => prev.filter((x) => x !== a))}
>
<CloseIcon width={14} height={14} />
</button>
</div>
))}
</div>
)}
{pillText && (
<div className="recording-pill" style={{ padding: "2px 4px" }}>
{!transcribing && <span className="pulse" />}
<span style={{ color: "var(--text-dim)" }}>{pillText}</span>
</div>
)}
<textarea
ref={taRef}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
onPaste={(e) => {
const files = Array.from(e.clipboardData.files);
if (files.length) {
e.preventDefault();
void addFiles(files);
}
}}
placeholder={streaming ? "Steer the running turn…" : "Message Hermes…"}
/>
<div className="composer-row">
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => {
if (e.target.files?.length) void addFiles(Array.from(e.target.files));
e.target.value = "";
}}
/>
<button className="icon-btn" title="Attach image" onClick={() => fileRef.current?.click()}>
<ImageIcon width={19} height={19} />
</button>
<button
className={`icon-btn ${active ? "active" : ""}`}
title={
active
? "Stop dictation"
: dictationAvailable
? "Dictate (speak your prompt)"
: "Dictation unavailable — see Settings"
}
onClick={toggleMic}
disabled={transcribing}
>
<MicIcon width={19} height={19} />
</button>
<div className="spacer" />
{streaming ? (
<button className="send-btn stop" title="Stop" onClick={() => cancel()}>
<StopIcon width={18} height={18} />
</button>
) : (
<button className="send-btn" title="Send" disabled={!canSend} onClick={doSend}>
<SendIcon width={18} height={18} />
</button>
)}
</div>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useStore } from "../state/store";
import { ConnectionForm } from "./ConnectionForm";
import { DerezLogoLink } from "./DerezLogo";
import { EditIcon, TrashIcon } from "./Icons";
export function ConfigScreen() {
const config = useStore((s) => s.config);
const saveConnection = useStore((s) => s.saveConnection);
const removeConnection = useStore((s) => s.removeConnection);
const setActiveConnection = useStore((s) => s.setActiveConnection);
const [editingId, setEditingId] = useState<string | null>(null);
return (
<div className="screen">
<div className="card">
<div style={{ marginBottom: 18 }}>
<DerezLogoLink height={30} />
</div>
<h1>Connect to your Hermes</h1>
<p className="lead">
Point this app at your Hermes <strong>API Server</strong> (the OpenAI-compatible endpoint),
not the dashboard. It authenticates with your <code>API_SERVER_KEY</code>.
</p>
<ConnectionForm onSave={saveConnection} />
{config.connections.length > 0 && (
<div className="section" style={{ marginTop: 24, marginBottom: 0 }}>
<h3>Saved connections</h3>
{config.connections.map((c) => (
<div key={c.id}>
<div className="kv">
<span
className="link-btn"
style={{ cursor: "pointer" }}
onClick={() => setActiveConnection(c.id)}
>
{c.name}
{c.profile ? ` · ${c.profile}` : ""}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
{c.sameOrigin ? "same origin" : c.baseUrl}
<button
className={`icon-btn ${editingId === c.id ? "active" : ""}`}
title="Edit"
onClick={() => setEditingId(editingId === c.id ? null : c.id)}
>
<EditIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Remove" onClick={() => removeConnection(c.id)}>
<TrashIcon width={15} height={15} />
</button>
</span>
</div>
{editingId === c.id && (
<div style={{ margin: "10px 0 18px" }}>
<ConnectionForm
initial={c}
onSave={async (conn) => {
await saveConnection(conn);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
saveLabel="Save changes"
/>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useState } from "react";
import { HermesClient } from "../api/client";
import type { Connection } from "../api/types";
import { newConnectionId } from "../lib/config";
import { CloseIcon, PlusIcon } from "./Icons";
export function emptyConnection(): Connection {
return {
id: newConnectionId(),
name: "My Hermes",
baseUrl: "",
apiKey: "",
profile: "",
customHeaders: [],
};
}
export function ConnectionForm({
initial,
onSave,
onCancel,
saveLabel = "Save & connect",
}: {
initial?: Connection;
onSave: (conn: Connection) => void | Promise<void>;
onCancel?: () => void;
saveLabel?: string;
}) {
const [draft, setDraft] = useState<Connection>(initial ?? emptyConnection());
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const patch = (p: Partial<Connection>) => setDraft((d) => ({ ...d, ...p }));
const test = async () => {
setTesting(true);
setResult(null);
try {
const client = new HermesClient({
baseUrl: draft.baseUrl,
apiKey: draft.apiKey,
profile: draft.profile,
sameOrigin: draft.sameOrigin,
customHeaders: draft.customHeaders,
});
const health = await client.health();
const caps = await client.capabilities();
const features = caps.features ? Object.keys(caps.features).length : 0;
setResult({
ok: true,
msg: `Connected to ${health.platform ?? "hermes-agent"} ${health.version ?? ""} — API key accepted (${features} features).`,
});
} catch (err) {
setResult({ ok: false, msg: err instanceof Error ? err.message : String(err) });
} finally {
setTesting(false);
}
};
return (
<div>
{result && <div className={`alert ${result.ok ? "ok" : "error"}`}>{result.msg}</div>}
<div className="field">
<label>Name</label>
<input value={draft.name} onChange={(e) => patch({ name: e.target.value })} placeholder="My Hermes" />
</div>
<div className="field">
<label className="toggle" style={{ marginBottom: 8 }}>
<input
type="checkbox"
checked={!!draft.sameOrigin}
onChange={(e) => patch({ sameOrigin: e.target.checked })}
/>
Same origin (behind the bundled proxy)
</label>
{draft.sameOrigin ? (
<div className="hint" style={{ marginTop: 0 }}>
Requests go to this app's own origin, which the proxy forwards to your API server — no
CORS needed. Run the proxy with <code>HERMES_TARGET</code> (no key required).
</div>
) : (
<>
<input
value={draft.baseUrl}
onChange={(e) => patch({ baseUrl: e.target.value })}
placeholder="https://api.example.com"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
<div className="hint">
The API server origin (default port <code>8642</code>), e.g.{" "}
<code>https://api.beaver.derez.ai</code>. Needs <code>API_SERVER_CORS_ORIGINS</code> to
allow this app's origin.
</div>
</>
)}
</div>
<div className="field">
<label>API key (API_SERVER_KEY)</label>
<input
type="password"
value={draft.apiKey}
onChange={(e) => patch({ apiKey: e.target.value })}
placeholder="Bearer token"
autoCapitalize="none"
/>
<div className="hint">Stored only on this device. Sent as <code>Authorization: Bearer …</code>.</div>
</div>
<div className="field">
<label>Profile (optional)</label>
<input
value={draft.profile ?? ""}
onChange={(e) => patch({ profile: e.target.value })}
placeholder="default"
autoCapitalize="none"
/>
<div className="hint">
Multi-profile gateways route to <code>/p/&lt;profile&gt;/…</code>. Each profile needs its own
key — add one connection per profile to see them all.
</div>
</div>
<div className="field">
<label>Custom headers (optional)</label>
{(draft.customHeaders ?? []).map((h, i) => (
<div className="header-row" key={i}>
<input
value={h.name}
placeholder="Header name"
onChange={(e) => {
const next = [...(draft.customHeaders ?? [])];
next[i] = { ...next[i], name: e.target.value };
patch({ customHeaders: next });
}}
/>
<input
value={h.value}
placeholder="Value"
onChange={(e) => {
const next = [...(draft.customHeaders ?? [])];
next[i] = { ...next[i], value: e.target.value };
patch({ customHeaders: next });
}}
/>
<button
className="icon-btn"
title="Remove"
onClick={() => patch({ customHeaders: (draft.customHeaders ?? []).filter((_, j) => j !== i) })}
>
<CloseIcon width={16} height={16} />
</button>
</div>
))}
<button
className="btn"
style={{ marginTop: 8 }}
onClick={() => patch({ customHeaders: [...(draft.customHeaders ?? []), { name: "", value: "" }] })}
>
<PlusIcon width={15} height={15} style={{ verticalAlign: "-2px", marginRight: 6 }} />
Add header
</button>
</div>
<div className="actions">
{onCancel && (
<button className="btn" onClick={onCancel}>
Cancel
</button>
)}
<button
className="btn"
onClick={test}
disabled={testing || (!draft.sameOrigin && !draft.baseUrl.trim())}
>
{testing ? "Testing…" : "Test"}
</button>
<button
className="btn primary"
onClick={() => onSave({ ...draft, name: draft.name.trim() || "Hermes" })}
disabled={!draft.sameOrigin && !draft.baseUrl.trim()}
>
{saveLabel}
</button>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useId } from "react";
/**
* The derez.ai logo (icon + wordmark), taken from https://derez.ai/assets/images/logo.svg
*/
export function DerezLogo({ height = 26, withText = true }: { height?: number; withText?: boolean }) {
const gid = useId().replace(/:/g, "");
const width = withText ? (height * 132) / 34 : height;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox={withText ? "0 0 132 34" : "0 0 34 34"}
role="img"
aria-label="derez.ai"
>
<defs>
<linearGradient id={`box-bg-${gid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#003545" />
<stop offset="100%" stopColor="#00202e" />
</linearGradient>
</defs>
<rect
x="0"
y="0"
width="34"
height="34"
rx="10"
fill={`url(#box-bg-${gid})`}
stroke="#005570"
strokeWidth="1.2"
/>
<rect x="6" y="11" width="22" height="15" rx="2.5" fill="none" stroke="#00f5ff" strokeWidth="1.8" />
<path d="M17 11V7" stroke="#00f5ff" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="17" cy="5.5" r="1.5" fill="#00f5ff" />
<circle cx="10" cy="15.5" r="1" fill="#00f5ff" />
<circle cx="14" cy="15.5" r="1" fill="#00f5ff" />
<path d="M10 20h14" stroke="#00f5ff" strokeWidth="1.5" strokeLinecap="round" />
{withText && (
<text
x="46"
y="23"
fill="#00f5ff"
fontFamily="'Courier New', Courier, monospace"
fontSize="17"
fontWeight="700"
letterSpacing="-0.5"
>
derez.ai
</text>
)}
</svg>
);
}
/** The logo wrapped in a link to the derez.ai homepage. */
export function DerezLogoLink({ height = 26 }: { height?: number }) {
return (
<a
className="brand-link"
href="https://derez.ai/"
target="_blank"
rel="noopener noreferrer"
title="derez.ai — AI Agent Hosting"
>
<DerezLogo height={height} />
</a>
);
}
+175
View File
@@ -0,0 +1,175 @@
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement>;
const base = (props: P) => ({
width: 20,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
...props,
});
export const PlusIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const SearchIcon = (p: P) => (
<svg {...base(p)}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
export const SendIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 12 20 4l-6 16-3-7-7-1Z" />
</svg>
);
export const MicIcon = (p: P) => (
<svg {...base(p)}>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
</svg>
);
export const ImageIcon = (p: P) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9.5" r="1.5" />
<path d="m21 16-5-5L5 20" />
</svg>
);
export const StopIcon = (p: P) => (
<svg {...base(p)}>
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none" />
</svg>
);
export const SettingsIcon = (p: P) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2 2 2 0 1 1-4 0 1.7 1.7 0 0 0-2.9-1.2l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.7 1.7 0 0 0 4.6 15a2 2 0 1 1 0-4 1.7 1.7 0 0 0 1.2-2.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1A1.7 1.7 0 0 0 11.5 4a2 2 0 1 1 4 0 1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1A1.7 1.7 0 0 0 19.4 11a2 2 0 1 1 0 4Z" />
</svg>
);
export const MenuIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export const CloseIcon = (p: P) => (
<svg {...base(p)}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
export const PinIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 17v5M9 3h6l-1 6 3 3H7l3-3-1-6Z" />
</svg>
);
export const ArchiveIcon = (p: P) => (
<svg {...base(p)}>
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4" />
</svg>
);
export const TrashIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
);
export const ChevronDownIcon = (p: P) => (
<svg {...base(p)}>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const SpeakerIcon = (p: P) => (
<svg {...base(p)}>
<path d="M4 9v6h4l5 4V5L8 9H4Z" />
<path d="M16.5 8.5a5 5 0 0 1 0 7" />
</svg>
);
export const UserIcon = (p: P) => (
<svg {...base(p)}>
<circle cx="12" cy="8" r="4" />
<path d="M4 20a8 8 0 0 1 16 0" />
</svg>
);
export const BotIcon = (p: P) => (
<svg {...base(p)}>
<rect x="4" y="8" width="16" height="11" rx="3" />
<path d="M12 8V4M9 13h.01M15 13h.01" />
</svg>
);
export const CheckIcon = (p: P) => (
<svg {...base(p)}>
<path d="m5 13 4 4L19 7" />
</svg>
);
export const RefreshIcon = (p: P) => (
<svg {...base(p)}>
<path d="M21 12a9 9 0 1 1-3-6.7L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
export const LogoutIcon = (p: P) => (
<svg {...base(p)}>
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l-5-5 5-5M5 12h11" />
</svg>
);
export const EditIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
export const SparkIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18" />
</svg>
);
export const BrainIcon = (p: P) => (
<svg {...base(p)}>
<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-1 5.8V15a3 3 0 0 0 4 2.8V20h2V4H9ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 1 5.8V15a3 3 0 0 1-4 2.8V20h-2V4h2Z" />
</svg>
);
export const DownloadIcon = (p: P) => (
<svg {...base(p)}>
<path d="M12 3v12M7 11l5 5 5-5M5 21h14" />
</svg>
);
export const FilterIcon = (p: P) => (
<svg {...base(p)}>
<path d="M3 5h18l-7 8v6l-4-2v-4L3 5Z" />
</svg>
);
export const ToolIcon = (p: P) => (
<svg {...base(p)}>
<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.3 2.3-2-2 2.3-2.3Z" />
</svg>
);
+151
View File
@@ -0,0 +1,151 @@
import { useState } from "react";
import type { Attachment, ChatMessage, ToolCall } from "../api/types";
import { renderMarkdown } from "../lib/markdown";
import { useTts } from "../hooks/useTts";
import { useStore } from "../state/store";
import { DerezLogo } from "./DerezLogo";
import { BrainIcon, CheckIcon, SpeakerIcon, ToolIcon, UserIcon } from "./Icons";
function Attachments({ items }: { items: Attachment[] }) {
if (!items?.length) return null;
return (
<div className="attachments">
{items.map((a, i) => {
const isImage = /^image\//.test(a.mime) || a.dataUrl.startsWith("data:image");
return (
<div className="attachment" key={i}>
{isImage ? <img src={a.dataUrl} alt={a.name} /> : <ToolIcon width={16} height={16} />}
<span className="nm">{a.name}</span>
</div>
);
})}
</div>
);
}
function ToolCard({ call }: { call: ToolCall }) {
const [open, setOpen] = useState(false);
const label =
call.status === "error" ? "error" : call.status === "done" ? "done" : "running…";
return (
<div className="tool-card">
<div
className="head"
onClick={() => setOpen((v) => !v)}
style={{ cursor: call.preview ? "pointer" : "default" }}
>
<ToolIcon width={15} height={15} style={{ color: "var(--text-dim)" }} />
<span className="name">{call.name}</span>
<span className={`status ${call.status === "error" ? "err" : call.status === "done" ? "ok" : ""}`}>
{label}
</span>
</div>
{call.preview && open && <div className="preview">{call.preview}</div>}
</div>
);
}
function ToolResultCard({ message }: { message: ChatMessage }) {
const [open, setOpen] = useState(false);
return (
<div className="tool-card">
<div className="head" onClick={() => setOpen((v) => !v)} style={{ cursor: "pointer" }}>
<ToolIcon width={15} height={15} style={{ color: "var(--text-dim)" }} />
<span className="name">{message.toolName ?? "tool result"}</span>
<span className="status ok">result</span>
</div>
{open && <div className="preview">{renderMarkdown(message.content)}</div>}
</div>
);
}
function SystemNote({ message }: { message: ChatMessage }) {
return (
<div style={{ fontSize: 12.5, color: "var(--text-faint)", padding: "2px 0 2px 41px", whiteSpace: "pre-wrap" }}>
{message.content}
</div>
);
}
export function MessageBubble({ message }: { message: ChatMessage }) {
const { speak } = useTts();
const [copied, setCopied] = useState(false);
const showToolCalls = useStore((s) => s.config.showToolCalls);
const showReasoning = useStore((s) => s.config.showReasoning);
const showSystem = useStore((s) => s.config.showSystem);
if (message.role === "tool") return showToolCalls ? <ToolResultCard message={message} /> : null;
if (message.role === "system") return showSystem ? <SystemNote message={message} /> : null;
const isUser = message.role === "user";
const text = message.content;
const toolCalls = message.toolCalls ?? [];
const attachments = message.attachments ?? [];
const streaming = !!message.streaming;
// Hide the whole bubble (avatar + label included) when nothing is visible
// after filtering — e.g. an assistant turn that was only tool calls.
const hasVisible =
!!text ||
(showReasoning && !!message.reasoning) ||
(showToolCalls && toolCalls.length > 0) ||
attachments.length > 0 ||
streaming;
if (!hasVisible) return null;
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {
/* ignore */
}
};
return (
<div className={`msg ${isUser ? "user" : "assistant"}`}>
<div className={`avatar ${isUser ? "user" : "brand"}`}>
{isUser ? <UserIcon width={16} height={16} /> : <DerezLogo height={30} withText={false} />}
</div>
<div className="content">
<div className="who">{isUser ? "You" : "Hermes"}</div>
{message.reasoning && showReasoning && (
<details className="reasoning">
<summary>
<BrainIcon width={14} height={14} />
Reasoning
</summary>
<div className="body">{message.reasoning}</div>
</details>
)}
{showToolCalls && toolCalls.map((c) => <ToolCard key={c.id} call={c} />)}
{text ? (
<div className="bubble">{isUser ? text : renderMarkdown(text)}</div>
) : streaming ? (
<div className="typing">
<span />
<span />
<span />
</div>
) : null}
<Attachments items={attachments} />
{!isUser && text && !streaming && (
<div className="msg-actions">
<button className="icon-btn" title="Copy" onClick={copy}>
{copied ? <CheckIcon width={15} height={15} /> : <span style={{ fontSize: 13 }}>⧉</span>}
</button>
<button className="icon-btn" title="Speak" onClick={() => speak(text)}>
<SpeakerIcon width={15} height={15} />
</button>
</div>
)}
</div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useState } from "react";
import { useStore } from "../state/store";
import { activeConnection } from "../lib/config";
import { ConnectionForm } from "./ConnectionForm";
import { CheckIcon, CloseIcon, EditIcon, PlusIcon, TrashIcon } from "./Icons";
export function SettingsPanel() {
const config = useStore((s) => s.config);
const capabilities = useStore((s) => s.capabilities);
const models = useStore((s) => s.models);
const saveConnection = useStore((s) => s.saveConnection);
const removeConnection = useStore((s) => s.removeConnection);
const setActiveConnection = useStore((s) => s.setActiveConnection);
const updatePrefs = useStore((s) => s.updatePrefs);
const setShowSettings = useStore((s) => s.setShowSettings);
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const current = activeConnection(config);
const features = capabilities?.features ? Object.entries(capabilities.features) : [];
return (
<div className="panel" onClick={() => setShowSettings(false)}>
<div className="panel-inner" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Settings</h2>
<button className="icon-btn" onClick={() => setShowSettings(false)}>
<CloseIcon width={20} height={20} />
</button>
</div>
<div className="section">
<h3>Connections</h3>
{config.connections.map((c) => (
<div key={c.id}>
<div className="kv">
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
className="link-btn"
style={{ cursor: "pointer" }}
onClick={() => setActiveConnection(c.id)}
>
{c.name}
{c.profile ? ` · ${c.profile}` : ""}
</button>
{c.id === config.activeConnectionId && (
<CheckIcon width={14} height={14} style={{ color: "var(--accent)" }} />
)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
{c.sameOrigin ? "same origin" : c.baseUrl}
<button
className={`icon-btn ${editingId === c.id ? "active" : ""}`}
title="Edit"
onClick={() => {
setAdding(false);
setEditingId(editingId === c.id ? null : c.id);
}}
>
<EditIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Remove" onClick={() => removeConnection(c.id)}>
<TrashIcon width={15} height={15} />
</button>
</span>
</div>
{editingId === c.id && (
<div style={{ margin: "10px 0 18px" }}>
<ConnectionForm
initial={c}
onSave={async (conn) => {
await saveConnection(conn);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
saveLabel="Save changes"
/>
</div>
)}
</div>
))}
{adding ? (
<div style={{ marginTop: 12 }}>
<ConnectionForm
onSave={async (conn) => {
await saveConnection(conn);
setAdding(false);
}}
onCancel={() => setAdding(false)}
saveLabel="Add"
/>
</div>
) : (
<button
className="btn"
style={{ marginTop: 12 }}
onClick={() => {
setEditingId(null);
setAdding(true);
}}
>
<PlusIcon width={15} height={15} style={{ verticalAlign: "-2px", marginRight: 6 }} />
Add connection
</button>
)}
</div>
<div className="section">
<h3>Voice</h3>
<label className="toggle" style={{ marginBottom: 12 }}>
<input
type="checkbox"
checked={config.speakReplies}
onChange={(e) => updatePrefs({ speakReplies: e.target.checked })}
/>
Speak replies automatically
</label>
<div className="field">
<label>Voice (browser)</label>
<input
value={config.ttsVoice}
onChange={(e) => updatePrefs({ ttsVoice: e.target.value })}
placeholder="System default"
/>
</div>
</div>
<div className="section">
<h3>Dictation</h3>
<div className="hint" style={{ marginTop: 0, marginBottom: 10 }}>
Chrome, Edge, and Safari dictate on-device via the Web Speech API. Firefox has no speech
API, so set an OpenAI-compatible transcription endpoint below to dictate there.
</div>
<div className="field">
<label>Transcription endpoint</label>
<input
value={config.transcribeUrl}
onChange={(e) => updatePrefs({ transcribeUrl: e.target.value })}
placeholder="https://api.openai.com/v1/audio/transcriptions"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
<div className="hint">
Any OpenAI-compatible <code>/v1/audio/transcriptions</code> — OpenAI, Groq, or a local
whisper.cpp server. Leave empty to disable.
</div>
</div>
<div className="field">
<label>API key</label>
<input
type="password"
value={config.transcribeKey}
onChange={(e) => updatePrefs({ transcribeKey: e.target.value })}
placeholder="sk-…"
autoCapitalize="none"
/>
</div>
<div className="field">
<label>Model</label>
<input
value={config.transcribeModel}
onChange={(e) => updatePrefs({ transcribeModel: e.target.value })}
placeholder="whisper-1"
autoCapitalize="none"
/>
</div>
</div>
<div className="section">
<h3>Media</h3>
<div className="hint" style={{ marginTop: 0, marginBottom: 10 }}>
Your agent emits <code>MEDIA:/path/to/file.png</code> for generated files. The Hermes API
server can't serve those, so point this at a URL where the agent's workspace is reachable
to render them as images.
</div>
<div className="field">
<label>Media base URL</label>
<input
value={config.mediaBaseUrl}
onChange={(e) => updatePrefs({ mediaBaseUrl: e.target.value })}
placeholder="https://files.example.com"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="url"
/>
</div>
<div className="field">
<label>Strip path prefix</label>
<input
value={config.mediaPathPrefix}
onChange={(e) => updatePrefs({ mediaPathPrefix: e.target.value })}
placeholder="/root/workspace"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<div className="hint">
Removed from the start of the path before appending. E.g. base{" "}
<code>https://files.example.com</code> + prefix <code>/root/workspace</code> turns{" "}
<code>MEDIA:/root/workspace/a.png</code> into{" "}
<code>https://files.example.com/a.png</code>.
</div>
</div>
</div>
<div className="section">
<h3>Server</h3>
<div className="kv">
<span>Connection</span>
<span>{current?.name ?? "—"}</span>
</div>
<div className="kv">
<span>Platform</span>
<span>{capabilities?.platform ?? "—"}</span>
</div>
<div className="kv">
<span>Model</span>
<span>{capabilities?.model ?? "—"}</span>
</div>
<div className="kv">
<span>Models available</span>
<span>{models.length || "—"}</span>
</div>
{features.length > 0 && (
<div style={{ marginTop: 10 }}>
<div className="hint" style={{ marginBottom: 6 }}>Features</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{features
.filter(([, v]) => v === true)
.map(([k]) => (
<span
key={k}
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 20,
background: "var(--bg-elev2)",
border: "1px solid var(--border)",
color: "var(--text-dim)",
}}
>
{k}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../state/store";
import { formatCost, relativeTime, shortSource } from "../lib/format";
import type { Session } from "../api/types";
import { DerezLogoLink } from "./DerezLogo";
import {
CheckIcon,
ChevronDownIcon,
EditIcon,
PlusIcon,
RefreshIcon,
SearchIcon,
SettingsIcon,
TrashIcon,
} from "./Icons";
function SessionItem({ s }: { s: Session }) {
const activeSessionId = useStore((st) => st.activeSessionId);
const openSession = useStore((st) => st.openSession);
const deleteSession = useStore((st) => st.deleteSession);
const renameSession = useStore((st) => st.renameSession);
const forkSession = useStore((st) => st.forkSession);
const active = activeSessionId === s.id;
const onRename = () => {
const next = window.prompt("Rename session", s.title);
if (next && next.trim()) void renameSession(s.id, next.trim());
};
const onDelete = () => {
if (window.confirm(`Delete "${s.title}"? This cannot be undone.`)) void deleteSession(s.id);
};
return (
<div
className={`session-item ${active ? "active" : ""}`}
onClick={() => openSession(s.id)}
title={s.preview || undefined}
>
<div className="body">
<div className="title">{s.title}</div>
<div className="meta">
<span>{relativeTime(s.updatedAt)}</span>
{typeof s.messageCount === "number" && s.messageCount > 0 && <span>{s.messageCount} msg</span>}
{s.source && <span>{shortSource(s.source)}</span>}
{s.costUsd ? <span>{formatCost(s.costUsd)}</span> : null}
</div>
</div>
<div className="actions" onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title="Rename" onClick={onRename}>
<EditIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Fork" onClick={() => forkSession(s.id)}>
<PlusIcon width={15} height={15} />
</button>
<button className="icon-btn" title="Delete" onClick={onDelete}>
<TrashIcon width={15} height={15} />
</button>
</div>
</div>
);
}
function ConnectionSwitcher() {
const connections = useStore((s) => s.config.connections);
const activeId = useStore((s) => s.config.activeConnectionId);
const setActiveConnection = useStore((s) => s.setActiveConnection);
const setShowSettings = useStore((s) => s.setShowSettings);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const current = connections.find((c) => c.id === activeId) ?? connections[0];
return (
<div className="profile-switch" ref={ref}>
<button className="profile-btn" onClick={() => setOpen((v) => !v)}>
<span className="dot" />
<span className="name">{current?.name ?? "No connection"}</span>
{current?.profile && <span className="sub">{current.profile}</span>}
<ChevronDownIcon width={15} height={15} style={{ marginLeft: 6, color: "var(--text-faint)" }} />
</button>
{open && (
<div className="menu">
{connections.map((c) => (
<button
key={c.id}
className="menu-item"
onClick={() => {
setOpen(false);
if (c.id !== activeId) void setActiveConnection(c.id);
}}
>
<span>{c.name}</span>
{c.profile && <span style={{ fontSize: 11, color: "var(--text-faint)" }}>{c.profile}</span>}
{c.id === activeId && <CheckIcon width={15} height={15} className="check" />}
</button>
))}
<button
className="menu-item"
onClick={() => {
setOpen(false);
setShowSettings(true);
}}
>
<PlusIcon width={15} height={15} />
<span>Add connection…</span>
</button>
</div>
)}
</div>
);
}
export function Sidebar() {
const sessions = useStore((s) => s.sessions);
const sessionsLoading = useStore((s) => s.sessionsLoading);
const searchQuery = useStore((s) => s.searchQuery);
const setSearch = useStore((s) => s.setSearch);
const newSession = useStore((s) => s.newSession);
const loadSessions = useStore((s) => s.loadSessions);
const setShowSettings = useStore((s) => s.setShowSettings);
const sidebarOpen = useStore((s) => s.sidebarOpen);
const setSidebarOpen = useStore((s) => s.setSidebarOpen);
return (
<>
<div className={`scrim ${sidebarOpen ? "show" : ""}`} onClick={() => setSidebarOpen(false)} />
<aside className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="sidebar-head">
<div className="brand">
<DerezLogoLink height={26} />
<div className="brand-actions">
<button className="icon-btn" title="Refresh" onClick={() => loadSessions()}>
<RefreshIcon width={17} height={17} />
</button>
<button className="icon-btn" title="Settings" onClick={() => setShowSettings(true)}>
<SettingsIcon width={17} height={17} />
</button>
</div>
</div>
<ConnectionSwitcher />
<button className="new-chat" onClick={() => newSession()}>
<PlusIcon width={17} height={17} />
New chat
</button>
<div className="search-wrap">
<SearchIcon width={16} height={16} />
<input
value={searchQuery}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter sessions…"
/>
</div>
</div>
<div className="sidebar-tools">
<span>{sessions.length} sessions</span>
</div>
<div className="session-list">
{sessionsLoading && sessions.length === 0 && <div className="empty">Loading sessions…</div>}
{!sessionsLoading && sessions.length === 0 && (
<div className="empty">
No sessions yet.
<br />
Start a new chat to begin.
</div>
)}
{sessions.map((s) => (
<SessionItem key={s.id} s={s} />
))}
</div>
</aside>
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useStore } from "../state/store";
export function Toast() {
const toast = useStore((s) => s.toast);
const setToast = useStore((s) => s.setToast);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(t);
}, [toast, setToast]);
if (!toast) return null;
return (
<div className="toast" onClick={() => setToast(null)}>
{toast}
</div>
);
}