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:
1 parent
5245c0a856
commit
c7b653ff6a
54 files changed
+224
-34
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user