mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-18 04:38:54 +02:00
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:
55 files changed
+1181
-433
No files matched your search
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user