fix: full review pass (main process, voice pipeline, Hermes client, HUD) and e2e-validated local voice (v2.1.0.1)

Electron main
- utility-process channel: sherpa output copied into V8 buffers (Electron rejects external
  buffers), audio exchanged as base64; worker restart on timeout, identity-safe exit handling,
  deterministic native unload by restart
- webhook: loopback by default without secret, UTF-8-safe body assembly, clean restart, port validation
- files IPC: realpath-based root check, openPath allow-list (reveal-only outside document folders),
  Windows reserved names; store: debounced async atomic writes with backup of corrupt files;
  logger: streaming writes with rotation; log message size cap
- HTTP stream proxy: socket released on idle timeout / renderer destroyed, id validation
- model manager: inactivity timeout, retrying rm/rename (Windows locks), engine stopped before
  replacing a model; WAV decoder handles float/24-bit; IPC payload validation
- window: opaque rounded window on Windows, navigation lock-down, visibility events;
  Ctrl+Alt+Escape instead of the Task Manager shortcut; single-instance guard; EVEFLOW_USER_DATA

Voice pipeline
- abort semantics (SendHandle.aborted), abort before the stream opens, session id prefixes per transport
- hands-free re-arm after replies without speech, start/stop race, no chime on auto re-arm,
  no silence shipped to STT (400 ms pre-roll), no transcription of empty manual stops
- TTS: bounded prefetch, cancellable segments, non-interrupting notices, volume applied at play time
- SSE CRLF split, usage in chat completions, finish_reason length, phonetic regex hoisted

HUD
- core renderer: no canvas shadows, cached colours, reusable spectrum buffer, theme read on change,
  30 fps idle, stops when the window is hidden; ping flashes on send / tool / speech
- deltas coalesced per animation frame; stable auto-scroll; narrow selectors everywhere
- bundled fonts (offline), reduce-motion fix, error toasts, ops drawer below 1180 px,
  interim transcript and first-token latency in the core caption, Ctrl+K, dialog semantics,
  switch/aria roles, compact widget cleanup, Whisper small recommended for French
- docs/ROADMAP.md: audit results, e2e results and the plan towards a real JARVIS

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
Claude committed 2026-09-03 18:08:09 +00:00
1 parent b1b8d0d5d4
commit 5f0fa7a1e2
55 files changed
+1181 -433

No files matched your search

+24 -6
View File
@@ -12,18 +12,36 @@ interface Props {
export function ChatPanel({ compact }: Props) {
const messages = useChat((s) => s.messages);
const clear = useChat((s) => s.clear);
const settings = useSettings((s) => s.settings);
const assistantName = useSettings((s) => s.settings.assistantName);
const userName = useSettings((s) => s.settings.userName);
const showReasoning = useSettings((s) => s.settings.ui.showReasoning);
const listRef = useRef<HTMLDivElement>(null);
const programmatic = useRef(false);
const [zoom, setZoom] = useState<string | null>(null);
const [stick, setStick] = useState(true);
useEffect(() => {
if (!stick) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
if (!el) return;
programmatic.current = true;
el.scrollTop = el.scrollHeight;
}, [messages, stick]);
useEffect(() => {
if (!zoom) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setZoom(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoom]);
const onScroll = () => {
if (programmatic.current) {
programmatic.current = false;
return;
}
const el = listRef.current;
if (!el) return;
setStick(el.scrollHeight - el.scrollTop - el.clientHeight < 80);
@@ -47,7 +65,7 @@ export function ChatPanel({ compact }: Props) {
<div ref={listRef} className={`chat-list${compact ? ' compact' : ''}`} onScroll={onScroll}>
{visible.length === 0 && (
<div className="empty">
{compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${settings.assistantName}.`}
{compact ? 'En attente…' : `Canal ouvert. Parlez ou écrivez à ${assistantName}.`}
{!compact && (
<div className="muted" style={{ marginTop: 8, fontSize: 12 }}>
<span className="kbd">Ctrl</span> + <span className="kbd">Shift</span> + <span className="kbd">Espace</span> active le micro depuis n’importe où.
@@ -59,9 +77,9 @@ export function ChatPanel({ compact }: Props) {
<MessageBubble
key={m.id}
message={m}
assistantName={settings.assistantName}
userName={settings.userName}
showReasoning={settings.ui.showReasoning}
assistantName={assistantName}
userName={userName}
showReasoning={showReasoning}
compact={compact}
onOpenImage={setZoom}
/>
+22 -3
View File
@@ -67,17 +67,34 @@ export function CommandBar({ compact }: Props) {
const submit = async () => {
const text = draft.trim();
if (steerMode && currentRunId) {
if (steerMode) {
if (!currentRunId) {
setSteerMode(false);
return;
}
if (await steer(text)) setDraft('');
inputRef.current?.focus();
return;
}
if (!text && images.length === 0) return;
const attached = images;
setDraft('');
setImages([]);
inputRef.current?.focus();
await sendMessage(text, attached);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -131,6 +148,8 @@ export function CommandBar({ compact }: Props) {
className={`mic-btn ${micClass}`}
style={{ ['--level' as string]: level }}
title={micTitle}
aria-label={micTitle}
aria-pressed={listening}
onClick={() => voiceController.toggle()}
disabled={phase === 'transcribing'}
>
@@ -154,11 +173,11 @@ export function CommandBar({ compact }: Props) {
)}
<input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void onFiles(e.target.files)} />
{isSending && !steerMode ? (
<button className="send-btn stop" title="Interrompre (Échap)" onClick={stopGeneration}>
<button className="send-btn stop" title="Interrompre (Échap)" aria-label="Interrompre" onClick={stopGeneration}>
<Square size={18} />
</button>
) : (
<button className="send-btn primary" title={steerMode ? 'Envoyer la consigne' : 'Envoyer (Entrée)'} onClick={() => void submit()} disabled={!draft.trim() && images.length === 0}>
<button className="send-btn primary" title={steerMode ? 'Envoyer la consigne' : 'Envoyer (Entrée)'} aria-label={steerMode ? 'Envoyer la consigne' : 'Envoyer'} onClick={() => void submit()} disabled={!draft.trim() && images.length === 0}>
{steerMode ? <Navigation size={18} /> : <Send size={18} />}
</button>
)}
+9 -4
View File
@@ -1,4 +1,4 @@
import { memo, useState } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, Volume2, Check } from 'lucide-react';
@@ -28,6 +28,10 @@ function openLink(href: string): void {
export const MessageBubble = memo(function MessageBubble({ message, assistantName, userName, showReasoning, compact, onOpenImage }: Props) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<number | null>(null);
useEffect(() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
}, []);
const isUser = message.role === 'user';
const who = isUser ? userName || 'VOUS' : message.role === 'system' ? 'SYSTÈME' : assistantName;
const status = message.status ?? 'done';
@@ -35,7 +39,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
const copy = () => {
navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1200);
}).catch(() => undefined);
};
@@ -50,8 +55,8 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
{message.usage?.total_tokens ? <span>{message.usage.total_tokens} tok</span> : null}
{message.role === 'assistant' && status === 'done' && (
<span className="msg-actions">
<button className="icon-btn" title="Copier" onClick={copy}>{copied ? <Check size={13} /> : <Copy size={13} />}</button>
<button className="icon-btn" title="Lire à voix haute" onClick={() => speech.say(message.content)}><Volume2 size={13} /></button>
<button className="icon-btn" title="Copier" aria-label="Copier le message" onClick={copy}>{copied ? <Check size={13} /> : <Copy size={13} />}</button>
<button className="icon-btn" title="Lire à voix haute" aria-label="Lire à voix haute" onClick={() => speech.say(message.content)}><Volume2 size={13} /></button>
</span>
)}
</div>
+16 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ShieldAlert, HelpCircle, KeyRound } from 'lucide-react';
import { resolvePending } from '../../services/conversation';
import { useChat, type PendingRequest } from '../../state/chat';
@@ -7,10 +7,22 @@ function RequestCard({ request }: { request: PendingRequest }) {
const [answer, setAnswer] = useState('');
const icon = request.kind === 'approval' ? <ShieldAlert size={16} /> : request.kind === 'clarify' ? <HelpCircle size={16} /> : <KeyRound size={16} />;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
if (request.kind === 'approval') void resolvePending(request, 'deny');
else useChat.getState().removePending(request.id);
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [request]);
return (
<div className="modal">
<div className="modal" role="dialog" aria-modal="true" aria-labelledby={`req-${request.id}`}>
<div className="request-card">
<div className="title">
<div className="title" id={`req-${request.id}`}>
{icon}
{request.title}
{request.tool && <span className="chip warn">{request.tool}</span>}
@@ -20,7 +32,7 @@ function RequestCard({ request }: { request: PendingRequest }) {
{request.kind === 'approval' ? (
<div className="actions">
<button className="btn danger" onClick={() => void resolvePending(request, 'deny')}>Refuser</button>
<button className="btn" onClick={() => void resolvePending(request, 'once')}>Autoriser une fois</button>
<button className="btn" autoFocus onClick={() => void resolvePending(request, 'once')}>Autoriser une fois</button>
<button className="btn" onClick={() => void resolvePending(request, 'session')}>Pour la session</button>
<button className="btn primary" onClick={() => void resolvePending(request, 'always')}>Toujours</button>
</div>
+4
View File
@@ -40,6 +40,10 @@ export function ResilientImage({ src = '', alt, onOpen }: Props) {
setResolved(cache.get(src)!);
return;
}
if (failures.has(src)) {
setError(failures.get(src)!);
return;
}
if (src.startsWith('data:') || src.startsWith('blob:') || /^https?:\/\//i.test(src)) {
ok(src);
return;
+4 -3
View File
@@ -5,12 +5,13 @@ import { CoreStage } from '../hud/CoreStage';
import { ChatPanel } from '../chat/ChatPanel';
export function CompactWidget() {
const settings = useSettings((s) => s.settings);
const assistantName = useSettings((s) => s.settings.assistantName);
const compactOpacity = useSettings((s) => s.settings.ui.compactOpacity);
const api = bridge();
return (
<div className="compact-root" style={{ ['--compact-opacity' as string]: settings.ui.compactOpacity }}>
<div className="compact-root" style={{ ['--compact-opacity' as string]: compactOpacity }}>
<div className="compact-head">
<span className="title">{settings.assistantName}</span>
<span className="title">{assistantName}</span>
{api && (
<>
<button className="icon-btn" title="Mode HUD complet" onClick={() => api.window.setMode('hud')}><Maximize2 size={14} /></button>
+27 -7
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useChat } from '../../state/chat';
import { useHermes } from '../../state/hermes';
import { useSettings } from '../../state/settings';
@@ -21,21 +22,38 @@ interface Props {
export function CoreStage({ compact }: Props) {
const hud = useChat((s) => s.hudOverride ?? s.hud);
const activity = useChat((s) => s.activity);
const running = useChat((s) => {
for (let i = s.activity.length - 1; i >= 0; i--) if (s.activity[i].status === 'running') return s.activity[i];
return undefined;
});
const isSending = useChat((s) => s.isSending);
const error = useChat((s) => s.error);
const pulse = useChat((s) => s.pingCount);
const latencyMs = useChat((s) => s.latencyMs);
const phase = useVoice((s) => s.phase);
const level = useVoice((s) => s.inputLevel);
const transcript = useVoice((s) => s.lastTranscript);
const interim = useVoice((s) => s.interim);
const voiceError = useVoice((s) => s.error);
const settings = useSettings((s) => s.settings);
const assistantName = useSettings((s) => s.settings.assistantName);
const reduceMotion = useSettings((s) => s.settings.ui.reduceMotion);
const transport = useHermes((s) => s.transport);
const link = useHermes((s) => s.link);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!isSending) {
setElapsed(0);
return;
}
const started = Date.now();
const timer = setInterval(() => setElapsed(Date.now() - started), 250);
return () => clearInterval(timer);
}, [isSending]);
const running = activity.filter((a) => a.status === 'running').slice(-1)[0];
let sub = '';
if (hud === 'listening') sub = phase === 'speech' ? 'parole détectée…' : phase === 'transcribing' ? 'transcription…' : 'parlez maintenant';
else if (hud === 'thinking') sub = running ? `${running.name}${running.detail ? ` · ${previewText(running.detail, 60)}` : ''}` : isSending ? `Hermes · ${transport}` : '';
else if (hud === 'thinking') sub = running ? `${running.name}${running.detail ? ` · ${previewText(running.detail, 60)}` : ''}` : isSending ? `Hermes · ${transport} · ${(elapsed / 1000).toFixed(1)} s` : '';
else if (hud === 'error') sub = previewText(error ?? voiceError ?? 'erreur', 90);
else if (hud === 'speaking') sub = 'synthèse vocale';
else if (transcript) sub = `« ${previewText(transcript, 80)} »`;
@@ -45,16 +63,18 @@ export function CoreStage({ compact }: Props) {
<div className={compact ? 'compact-core' : 'core-stage'}>
{!compact && (
<div className="core-name">
{settings.assistantName}
{assistantName}
<small>EVEFLOW · HERMES INTERFACE</small>
</div>
)}
<div className={compact ? undefined : 'core-canvas-wrap'} style={compact ? { position: 'absolute', inset: '0 0 36px 0' } : undefined}>
<JarvisCore state={hud} inputLevel={level} reduceMotion={settings.ui.reduceMotion} />
<JarvisCore state={hud} inputLevel={level} reduceMotion={reduceMotion} pulse={pulse} />
</div>
<div className="core-caption">
<div className="core-caption" aria-live="polite">
{!compact && (phase === 'speech' || phase === 'transcribing') && <div className="core-interim">{interim || (phase === 'transcribing' ? '…' : '')}</div>}
<div className={`core-state ${hud}`}>{STATE_LABEL[hud] ?? hud}</div>
{sub && <div className="core-sub">{sub}</div>}
{!compact && latencyMs !== null && <div className="core-latency">premier mot · {latencyMs} ms</div>}
</div>
</div>
);
+140 -62
View File
@@ -2,8 +2,13 @@
* JARVIS-style arc reactor core rendered on a 2D canvas.
* Rotating tick rings, segmented arcs and an audio-reactive radial spectrum that reads the
* real TTS output (or the microphone while listening).
*
* Performance notes: no canvas shadows (glow is a second wide low-alpha pass), colour strings
* are cached, the spectrum buffer is reused, the theme accent is read once per theme change,
* idle runs at 30 fps and the loop stops entirely while the window is hidden.
*/
import { useEffect, useRef } from 'react';
import { bridge } from '../../lib/bridge';
import { audioBus } from '../../services/voice/audioBus';
import type { HudState } from '../../state/chat';
@@ -12,12 +17,15 @@ interface Props {
/** 0..1 override for the microphone level (VAD), used while listening. */
inputLevel?: number;
reduceMotion?: boolean;
/** Bumped by the parent to trigger a short "ping" flash (send, tool start, speech start). */
pulse?: number;
className?: string;
}
type Rgb = [number, number, number];
interface Palette {
main: [number, number, number];
glow: [number, number, number];
main: Rgb;
glow: Rgb;
}
const PALETTES: Record<HudState, Palette> = {
@@ -31,23 +39,30 @@ const PALETTES: Record<HudState, Palette> = {
};
const BARS = 72;
const TICKS = 120;
const SEGMENTS = 6;
function readThemeAccent(): [number, number, number] | null {
function readThemeAccent(): Rgb | null {
const raw = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim();
if (!raw) return null;
const parts = raw.split(',').map((p) => Number(p.trim()));
return parts.length === 3 && parts.every((n) => Number.isFinite(n)) ? (parts as [number, number, number]) : null;
return parts.length === 3 && parts.every((n) => Number.isFinite(n)) ? (parts as Rgb) : null;
}
export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, className }: Props) {
export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, pulse = 0, className }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const stateRef = useRef(state);
const inputRef = useRef(inputLevel);
const motionRef = useRef(reduceMotion);
const pulseRef = useRef(0);
stateRef.current = state;
inputRef.current = inputLevel;
motionRef.current = reduceMotion;
useEffect(() => {
if (pulse > 0) pulseRef.current = 1;
}, [pulse]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -59,13 +74,33 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
let height = 0;
let dpr = 1;
const bars = new Float32Array(BARS);
const color = { main: [52, 228, 255] as [number, number, number], glow: [52, 228, 255] as [number, number, number] };
const spectrumBuffer = new Float32Array(BARS);
const color = { main: [52, 228, 255] as Rgb, glow: [52, 228, 255] as Rgb };
let energy = 0;
let spin = 0;
let spin2 = 0;
let spin3 = 0;
let last = performance.now();
let visible = true;
let visible = document.visibilityState === 'visible';
let frame = 0;
let accent = readThemeAccent();
const colorCache = new Map<number, string>();
// Quantised rgba cache: colours are quantised to integers and alpha to 1/64 steps.
const rgba = (c: Rgb, a: number): string => {
const r = c[0] | 0;
const g = c[1] | 0;
const b = c[2] | 0;
const q = Math.max(0, Math.min(64, Math.round(a * 64)));
const key = (((r << 8) | g) << 8 | b) * 65 + q;
let s = colorCache.get(key);
if (!s) {
s = `rgba(${r},${g},${b},${(q / 64).toFixed(3)})`;
if (colorCache.size > 4000) colorCache.clear();
colorCache.set(key, s);
}
return s;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
@@ -79,41 +114,70 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
observer.observe(canvas);
resize();
const themeObserver = new MutationObserver(() => {
accent = readThemeAccent();
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const start = () => {
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(draw);
}
};
const onVisibility = () => {
visible = document.visibilityState === 'visible';
if (visible) start();
};
document.addEventListener('visibilitychange', onVisibility);
// backgroundThrottling is off, so the Page Visibility API never fires: main tells us instead.
const offWindow = bridge()?.window.onVisibility?.((shown) => {
visible = shown;
if (visible) start();
});
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const rgba = (c: [number, number, number], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const draw = (now: number) => {
if (!visible) {
raf = 0;
return;
}
raf = requestAnimationFrame(draw);
if (!visible) return;
const st = stateRef.current;
frame++;
// Idle economy: 30 fps is plenty for the breathing animation.
if (st === 'idle' && pulseRef.current < 0.01 && frame % 2 === 1) return;
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const st = stateRef.current;
const motion = motionRef.current ? 0.25 : 1;
// ── target colours & energy
const target = PALETTES[st];
const themeAccent = st === 'idle' || st === 'listening' || st === 'speaking' ? readThemeAccent() : null;
const themeAccent = st === 'idle' || st === 'listening' || st === 'speaking' ? accent : null;
const mainTarget = themeAccent ?? target.main;
const glowTarget = themeAccent ?? target.glow;
for (let i = 0; i < 3; i++) {
color.main[i] = lerp(color.main[i], mainTarget[i], 0.08);
color.glow[i] = lerp(color.glow[i], (themeAccent ?? target.glow)[i], 0.08);
color.glow[i] = lerp(color.glow[i], glowTarget[i], 0.08);
}
const speaking = st === 'speaking';
const listening = st === 'listening';
const spectrum = speaking ? audioBus.spectrum(BARS, 'output') : listening ? audioBus.spectrum(BARS, 'input') : null;
const spectrum = speaking
? audioBus.spectrum(BARS, 'output', spectrumBuffer)
: listening
? audioBus.spectrum(BARS, 'input', spectrumBuffer)
: null;
const level = speaking ? audioBus.outputLevel() : listening ? Math.max(inputRef.current, audioBus.inputLevel()) : 0;
const targetEnergy = st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level;
energy = lerp(energy, targetEnergy, speaking || listening ? 0.35 : 0.08);
pulseRef.current = Math.max(0, pulseRef.current - dt * 2.2);
const ping = pulseRef.current;
const targetEnergy = Math.max(ping, st === 'thinking' ? 0.45 : st === 'alert' || st === 'error' ? 0.6 : level);
energy = lerp(energy, targetEnergy, speaking || listening || ping > 0 ? 0.35 : 0.08);
for (let i = 0; i < BARS; i++) {
let t = 0;
if (spectrum) {
const v = spectrum[i];
t = Math.pow(v, 1.4);
t = v * Math.sqrt(v);
if (listening) t = Math.max(t, inputRef.current * (0.5 + 0.5 * Math.sin(now / 90 + i)));
} else if (st === 'thinking') {
t = 0.15 + 0.12 * Math.sin(now / 260 + i * 0.35);
@@ -122,10 +186,10 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
} else {
t = 0.12 + 0.08 * Math.sin(now / 140 + i * 0.7);
}
t = Math.max(t, ping * 0.6);
bars[i] = lerp(bars[i], t, t > bars[i] ? 0.5 : 0.12);
}
// ── rotation
const speed = (st === 'thinking' ? 1.6 : st === 'alert' || st === 'error' ? 1.2 : speaking ? 0.7 : 0.35) * motion;
spin += dt * 0.35 * speed;
spin2 -= dt * 0.55 * speed;
@@ -139,7 +203,7 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
const main = color.main;
const glow = color.glow;
// ── ambient glow
// ambient glow
const halo = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.05);
halo.addColorStop(0, rgba(glow, 0.18 + energy * 0.25));
halo.addColorStop(0.5, rgba(glow, 0.05));
@@ -147,53 +211,51 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.fillStyle = halo;
ctx.fillRect(0, 0, width, height);
// ── outer tick ring
// outer tick ring: three batched paths (long / mid / short ticks)
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(spin);
ctx.strokeStyle = rgba(main, 0.55);
ctx.lineWidth = 1;
for (let i = 0; i < 120; i++) {
const a = (i / 120) * Math.PI * 2;
const long = i % 10 === 0;
const mid = i % 5 === 0;
const r0 = R * (long ? 0.93 : mid ? 0.955 : 0.97);
ctx.globalAlpha = long ? 0.9 : mid ? 0.6 : 0.3;
for (let group = 0; group < 3; group++) {
ctx.beginPath();
ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0);
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R);
for (let i = 0; i < TICKS; i++) {
const kind = i % 10 === 0 ? 0 : i % 5 === 0 ? 1 : 2;
if (kind !== group) continue;
const a = (i / TICKS) * Math.PI * 2;
const r0 = R * (kind === 0 ? 0.93 : kind === 1 ? 0.955 : 0.97);
ctx.moveTo(Math.cos(a) * r0, Math.sin(a) * r0);
ctx.lineTo(Math.cos(a) * R, Math.sin(a) * R);
}
ctx.strokeStyle = rgba(main, group === 0 ? 0.5 : group === 1 ? 0.33 : 0.17);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI * 2);
ctx.strokeStyle = rgba(main, 0.35);
ctx.stroke();
ctx.restore();
// ── segmented arc ring
// segmented arc ring
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(spin2);
ctx.lineCap = 'butt';
const segments = 6;
for (let i = 0; i < segments; i++) {
const start = (i / segments) * Math.PI * 2;
const span = (Math.PI * 2) / segments - 0.22;
for (let i = 0; i < SEGMENTS; i++) {
const startAngle = (i / SEGMENTS) * Math.PI * 2;
const span = (Math.PI * 2) / SEGMENTS - 0.22;
ctx.beginPath();
ctx.arc(0, 0, R * 0.86, start, start + span);
ctx.arc(0, 0, R * 0.86, startAngle, startAngle + span);
ctx.lineWidth = 6;
ctx.strokeStyle = rgba(main, 0.16 + (i % 2 === 0 ? 0.18 : 0.05) + energy * 0.2);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, R * 0.82, start + 0.08, start + span * 0.45);
ctx.arc(0, 0, R * 0.82, startAngle + 0.08, startAngle + span * 0.45);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(main, 0.75);
ctx.stroke();
}
ctx.restore();
// ── dashed ring
// dashed ring
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(spin3);
@@ -206,36 +268,46 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.setLineDash([]);
ctx.restore();
// ── audio spectrum bars
// audio spectrum bars: one wide low-alpha glow pass, then one crisp pass per intensity bucket
ctx.save();
ctx.translate(cx, cy);
ctx.lineCap = 'round';
const inner = R * 0.5;
const maxLen = R * 0.16;
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(glow, 0.1 + energy * 0.12);
ctx.beginPath();
for (let i = 0; i < BARS; i++) {
const a = (i / BARS) * Math.PI * 2 - Math.PI / 2;
const len = 2 + bars[i] * maxLen;
const x0 = Math.cos(a) * inner;
const y0 = Math.sin(a) * inner;
const x1 = Math.cos(a) * (inner + len);
const y1 = Math.sin(a) * (inner + len);
ctx.lineWidth = 2.4;
ctx.strokeStyle = rgba(main, 0.35 + bars[i] * 0.65);
ctx.shadowBlur = 8 * bars[i];
ctx.shadowColor = rgba(glow, 0.8);
ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner);
ctx.lineTo(Math.cos(a) * (inner + len), Math.sin(a) * (inner + len));
}
ctx.stroke();
ctx.lineWidth = 2.4;
for (let bucket = 0; bucket < 4; bucket++) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
let any = false;
for (let i = 0; i < BARS; i++) {
const b = Math.min(3, Math.floor(bars[i] * 4));
if (b !== bucket) continue;
any = true;
const a = (i / BARS) * Math.PI * 2 - Math.PI / 2;
const len = 2 + bars[i] * maxLen;
ctx.moveTo(Math.cos(a) * inner, Math.sin(a) * inner);
ctx.lineTo(Math.cos(a) * (inner + len), Math.sin(a) * (inner + len));
}
if (!any) continue;
ctx.strokeStyle = rgba(main, 0.35 + (bucket + 0.5) * 0.16);
ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.restore();
// ── reactor core
// reactor core
const coreR = R * 0.36;
const breath = 1 + Math.sin(now / 700) * 0.02 + energy * 0.08;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, coreR * breath);
core.addColorStop(0, `rgba(255,255,255,${0.75 + energy * 0.25})`);
core.addColorStop(0, `rgba(255,255,255,${(0.75 + energy * 0.25).toFixed(3)})`);
core.addColorStop(0.25, rgba(main, 0.55 + energy * 0.3));
core.addColorStop(0.7, rgba(glow, 0.12));
core.addColorStop(1, 'rgba(0,0,0,0)');
@@ -253,7 +325,6 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.strokeStyle = rgba(main, 0.7 - i * 0.2);
ctx.stroke();
}
// reactor triangle
ctx.rotate(-spin2 * 0.5);
ctx.beginPath();
for (let i = 0; i < 3; i++) {
@@ -268,24 +339,28 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
ctx.stroke();
ctx.restore();
// ── thinking orbiters
// thinking orbiters (radial gradient dots instead of shadow blur)
if (st === 'thinking' || st === 'alert') {
ctx.save();
ctx.translate(cx, cy);
for (let i = 0; i < 3; i++) {
const a = spin3 * 2 + (i / 3) * Math.PI * 2;
const r = R * 0.78;
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
const dot = ctx.createRadialGradient(x, y, 0, x, y, 11);
dot.addColorStop(0, rgba(main, 1));
dot.addColorStop(0.35, rgba(glow, 0.6));
dot.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = dot;
ctx.beginPath();
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, 3.5, 0, Math.PI * 2);
ctx.fillStyle = rgba(main, 0.95);
ctx.shadowBlur = 14;
ctx.shadowColor = rgba(glow, 1);
ctx.arc(x, y, 11, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ── listening ring (input level)
// listening ring (input level)
if (listening) {
ctx.save();
ctx.translate(cx, cy);
@@ -298,11 +373,14 @@ export function JarvisCore({ state, inputLevel = 0, reduceMotion = false, classN
}
};
raf = requestAnimationFrame(draw);
start();
return () => {
cancelAnimationFrame(raf);
if (raf) cancelAnimationFrame(raf);
raf = 0;
observer.disconnect();
themeObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
offWindow?.();
};
}, []);
+12 -4
View File
@@ -1,4 +1,4 @@
import { Minus, X, Settings, PictureInPicture2, RefreshCw, Webhook } from 'lucide-react';
import { Minus, X, Settings, PictureInPicture2, RefreshCw, Webhook, PanelRight } from 'lucide-react';
import { bridge } from '../../lib/bridge';
import { useClock } from '../../hooks/useMetrics';
import { useHermes } from '../../state/hermes';
@@ -6,9 +6,11 @@ import { useSettings } from '../../state/settings';
interface Props {
onOpenSettings: () => void;
onToggleOps?: () => void;
opsBadge?: number;
}
export function TopBar({ onOpenSettings }: Props) {
export function TopBar({ onOpenSettings, onToggleOps, opsBadge = 0 }: Props) {
const now = useClock();
const link = useHermes((s) => s.link);
const linkDetail = useHermes((s) => s.linkDetail);
@@ -55,9 +57,15 @@ export function TopBar({ onOpenSettings }: Props) {
<RefreshCw size={14} className={link === 'checking' ? 'spin' : undefined} />
</button>
</div>
<span className="clock">{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
<span className="clock">{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}</span>
<div className="controls">
<button className="icon-btn" title="Paramètres" onClick={onOpenSettings}><Settings size={16} /></button>
{onToggleOps && (
<button className="icon-btn ops-toggle" title="Panneau Hermes Ops" aria-label="Panneau Hermes Ops" onClick={onToggleOps} style={{ position: 'relative' }}>
<PanelRight size={16} />
{opsBadge > 0 && <span className="badge" style={{ position: 'absolute', top: 2, right: 2, minWidth: 14, height: 14, fontSize: 9, borderRadius: 7, background: 'var(--danger)', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: '0 3px' }}>{opsBadge}</span>}
</button>
)}
<button className="icon-btn" title="Paramètres (Ctrl+,)" aria-label="Paramètres" onClick={onOpenSettings}><Settings size={16} /></button>
{api && (
<>
<button className="icon-btn" title="Mode compact flottant" onClick={() => api.window.setMode('compact')}><PictureInPicture2 size={16} /></button>
+11 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import {
Wrench, CalendarClock, Sparkles, Layers, History, Play, Pause, Trash2, Pencil, Plus, Save, X, RefreshCw,
CheckCircle2, XCircle, Loader2, Bot, Cpu, GitBranch, Webhook, FolderOpen
@@ -142,7 +143,10 @@ function HistoryList() {
<div
key={r.id}
className="row-item clickable"
role="button"
tabIndex={0}
style={{ opacity: r.read ? 0.7 : 1 }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.currentTarget as HTMLDivElement).click(); }}
onClick={() => {
markRead(r.id);
addMessage({ role: 'assistant', content: r.output, source: 'cron', jobName: r.jobName, status: 'done', timestamp: new Date(r.at).getTime() || Date.now() });
@@ -222,7 +226,7 @@ function SessionsTab() {
const transport = useHermes((s) => s.transport);
const sessionId = useSettings((s) => s.settings.hermesSessionId);
const setSessionId = useSettings((s) => s.setHermesSessionId);
const chat = useChat();
const chat = useChat.getState();
const activeId = sessionId.replace(/^hs:/, '');
const load = async (id: string) => {
@@ -255,7 +259,7 @@ function SessionsTab() {
{sessions.length === 0 ? <div className="empty">Aucune session listée par Hermes.</div> : (
<div className="list">
{sessions.map((s) => (
<div key={s.id} className={`row-item clickable`} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)}>
<div key={s.id} className="row-item clickable" role="button" tabIndex={0} style={s.id === activeId ? { borderColor: 'var(--accent)' } : undefined} onClick={() => void load(s.id)} onKeyDown={(e) => { if (e.key === 'Enter') void load(s.id); }}>
<div className="main">
<span className="name">{s.title || s.id}</span>
<span className="meta">{[s.platform ?? s.source, s.message_count ? `${s.message_count} msg` : null, formatDateTime(s.updated_at ?? s.created_at)].filter(Boolean).join(' · ')}</span>
@@ -273,7 +277,9 @@ function SessionsTab() {
}
function LinkTab() {
const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes();
const { link, linkDetail, health, capabilities, transport, models, webhook } = useHermes(
useShallow((s) => ({ link: s.link, linkDetail: s.linkDetail, health: s.health, capabilities: s.capabilities, transport: s.transport, models: s.models, webhook: s.webhook }))
);
const connect = useHermes((s) => s.connect);
const hermes = useSettings((s) => s.settings.hermes);
const [info, setInfo] = useState<{ sharedDir: string; logPath: string; version: string } | null>(null);
@@ -311,7 +317,7 @@ function LinkTab() {
<div className="section-title"><CheckCircle2 size={12} /> Santé</div>
<dl className="kv">
{Object.entries(checks).map(([k, v]) => (
<><dt key={`${k}-t`}>{k}</dt><dd key={`${k}-d`}>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></>
<Fragment key={k}><dt>{k}</dt><dd>{typeof v === 'object' ? JSON.stringify(v) : String(v)}</dd></Fragment>
))}
</dl>
</>
+7 -4
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Download, Trash2, X, CheckCircle2, Cpu, Mic, Volume2, AlertTriangle, RefreshCw } from 'lucide-react';
import type { VoiceModelStatus } from '../../../shared/voice';
import { bridge } from '../../lib/bridge';
import { useShallow } from 'zustand/react/shallow';
import { useVoiceModels } from '../../state/voiceModels';
import { useSettings } from '../../state/settings';
@@ -11,10 +12,11 @@ function formatMb(bytes: number): string {
function ModelRow({ model }: { model: VoiceModelStatus }) {
const progress = useVoiceModels((s) => s.progress[model.id]);
const { download, cancel, remove } = useVoiceModels();
const settings = useSettings((s) => s.settings);
const { download, cancel, remove } = useVoiceModels.getState();
const activeStt = useSettings((s) => s.settings.voice.localModel);
const activeTts = useSettings((s) => s.settings.speech.localModel);
const update = useSettings((s) => s.update);
const isActive = model.kind === 'stt' ? settings.voice.localModel === model.id : settings.speech.localModel === model.id;
const isActive = model.kind === 'stt' ? activeStt === model.id : activeTts === model.id;
const busy = !!progress && (progress.phase === 'download' || progress.phase === 'extract');
const activate = () => {
@@ -59,7 +61,8 @@ function ModelRow({ model }: { model: VoiceModelStatus }) {
}
export function ModelsSection() {
const { models, engine, error, refresh, checkEngine } = useVoiceModels();
const { models, engine, error } = useVoiceModels(useShallow((s) => ({ models: s.models, engine: s.engine, error: s.error })));
const { refresh, checkEngine } = useVoiceModels.getState();
useEffect(() => {
void refresh();
void checkEngine();
+23 -19
View File
@@ -21,23 +21,35 @@ interface Props {
function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
return (
<div className="switch" onClick={() => onChange(!on)}>
<button type="button" role="switch" aria-checked={on} className="switch" onClick={() => onChange(!on)}>
<div className="text">
<span>{label}</span>
{hint && <small>{hint}</small>}
</div>
<span className={`toggle${on ? ' on' : ''}`} />
</div>
</button>
);
}
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
function TestResult({ t }: { t: TestState }) {
if (t.status === 'idle') return null;
return (
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<span>{t.message}</span>
</div>
);
}
export function SettingsDrawer({ onClose }: Props) {
const settings = useSettings((s) => s.settings);
const update = useSettings((s) => s.update);
const reset = useSettings((s) => s.reset);
const hermesStore = useHermes();
const hermesModels = useHermes((s) => s.models);
const hermesWebhook = useHermes((s) => s.webhook);
const hermesConnect = useHermes((s) => s.connect);
const micDevices = useVoice((s) => s.micDevices);
const voiceModels = useVoiceModels((s) => s.models);
const refreshModels = useVoiceModels((s) => s.refresh);
@@ -74,7 +86,7 @@ export function SettingsDrawer({ onClose }: Props) {
}
const via = bridge() ? 'via Electron' : 'via navigateur';
setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` });
void hermesStore.connect();
void hermesConnect();
} catch (err) {
setHermesTest({ status: 'fail', message: (err as Error).message });
}
@@ -106,14 +118,6 @@ export function SettingsDrawer({ onClose }: Props) {
useHermes.setState({ webhook: status });
};
const Result = ({ t }: { t: TestState }) =>
t.status === 'idle' ? null : (
<div className={`test-result ${t.status === 'ok' ? 'ok' : t.status === 'fail' ? 'fail' : ''}`}>
{t.status === 'running' ? <Loader2 size={13} className="spin" /> : t.status === 'ok' ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
<span>{t.message}</span>
</div>
);
const nav: Array<[Section, string]> = [
['hermes', 'Hermes'],
['voice', 'Micro / STT'],
@@ -155,7 +159,7 @@ export function SettingsDrawer({ onClose }: Props) {
<label>Modèle</label>
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} />
<datalist id="hermes-models">
{hermesStore.models.map((m) => <option key={m.id} value={m.id} />)}
{hermesModels.map((m) => <option key={m.id} value={m.id} />)}
</datalist>
</div>
<div className="field">
@@ -191,7 +195,7 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="row" style={{ marginTop: 10 }}>
<button className="btn primary small" onClick={() => void testHermes()}><Play size={13} /> Tester la liaison</button>
</div>
<div style={{ marginTop: 8 }}><Result t={hermesTest} /></div>
<div style={{ marginTop: 8 }}><TestResult t={hermesTest} /></div>
</div>
</>
)}
@@ -284,7 +288,7 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={() => void testStt()} disabled={settings.voice.provider === 'browser'}><Mic size={13} /> Tester la reconnaissance</button>
</div>
<div style={{ marginTop: 8 }}><Result t={sttTest} /></div>
<div style={{ marginTop: 8 }}><TestResult t={sttTest} /></div>
</div>
)}
@@ -381,7 +385,7 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="row" style={{ marginTop: 10 }}>
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
</div>
<div style={{ marginTop: 8 }}><Result t={ttsTest} /></div>
<div style={{ marginTop: 8 }}><TestResult t={ttsTest} /></div>
</div>
)}
@@ -412,7 +416,7 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
<div className="row">
<button className="btn primary small" onClick={() => void applyWebhook()} disabled={!bridge()}>Appliquer et redémarrer</button>
{hermesStore.webhook && <span className={`status-pill ${hermesStore.webhook.listening ? 'online' : 'offline'}`}>{hermesStore.webhook.listening ? `port ${hermesStore.webhook.port}` : hermesStore.webhook.error ?? 'inactif'}</span>}
{hermesWebhook && <span className={`status-pill ${hermesWebhook.listening ? 'online' : 'offline'}`}>{hermesWebhook.listening ? `port ${hermesWebhook.port}` : hermesWebhook.error ?? 'inactif'}</span>}
</div>
</div>
)}
@@ -434,13 +438,13 @@ export function SettingsDrawer({ onClose }: Props) {
<div className="theme-swatches">
{(['arc', 'gold', 'crimson', 'emerald'] as HudTheme[]).map((t) => {
const colors: Record<HudTheme, string> = { arc: '#34e4ff', gold: '#ffc35a', crimson: '#ff5c6c', emerald: '#4ef0a8' };
return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} onClick={() => update({ theme: t })} />;
return <button key={t} className={`swatch${settings.theme === t ? ' active' : ''}`} style={{ background: colors[t] }} title={t} aria-label={`Thème ${t}`} aria-pressed={settings.theme === t} onClick={() => update({ theme: t })} />;
})}
</div>
</div>
<div className="field">
<label>Raccourcis globaux</label>
<span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Shift+Échap</span> couper la voix</span>
<span className="hint"><span className="kbd">Ctrl+Shift+Espace</span> micro · <span className="kbd">Ctrl+Shift+J</span> afficher/masquer · <span className="kbd">Ctrl+Alt+Échap</span> couper la voix · <span className="kbd">Ctrl+K</span> saisie</span>
</div>
<div className="divider" />
<button className="btn danger small" onClick={() => { if (confirm('Réinitialiser tous les paramètres ?')) reset(); }}><RotateCcw size={13} /> Réinitialiser</button>