mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-14 02:36:32 +02:00
- electron/voice: model catalog (Whisper base/small/turbo, SenseVoice, Kokoro v1.0, Piper fr), streaming tar.bz2 downloader with progress, sherpa-onnx worker running in an Electron utilityProcess (transcribe / synthesize / status / unload), IPC + bridge. - Renderer: 'local' providers for STT and TTS, Settings → Modèles locaux (download, progress, delete, activate), speaker selection, hands-free wake word with a tolerant matcher (accents, punctuation, edit distance) and attention window after a bare wake word. - Packaging: native addon and worker unpacked from the asar; release 2.1.0. Validated on Linux: Kokoro (fr) → Whisper base round trip, Piper (fr) → Whisper round trip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
466 lines
27 KiB
TypeScript
466 lines
27 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { X, Settings, CheckCircle2, XCircle, Loader2, Mic, Volume2, RotateCcw, Play } from 'lucide-react';
|
||
import { bridge } from '../../lib/bridge';
|
||
import { HermesClient } from '../../services/hermes/client';
|
||
import { listSystemVoices } from '../../services/voice/tts';
|
||
import { speech } from '../../services/voice/speech';
|
||
import { listMicrophones } from '../../services/voice/capture';
|
||
import { transcribeWav } from '../../services/voice/stt';
|
||
import { encodeWav } from '../../services/voice/wav';
|
||
import { useHermes } from '../../state/hermes';
|
||
import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settings';
|
||
import { useVoice } from '../../state/voice';
|
||
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
||
import { ModelsSection } from './ModelsSection';
|
||
|
||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'ui';
|
||
|
||
interface Props {
|
||
onClose: () => void;
|
||
}
|
||
|
||
function Toggle({ on, onChange, label, hint }: { on: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
|
||
return (
|
||
<div className="switch" onClick={() => onChange(!on)}>
|
||
<div className="text">
|
||
<span>{label}</span>
|
||
{hint && <small>{hint}</small>}
|
||
</div>
|
||
<span className={`toggle${on ? ' on' : ''}`} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type TestState = { status: 'idle' | 'running' | 'ok' | 'fail'; message: string };
|
||
|
||
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 micDevices = useVoice((s) => s.micDevices);
|
||
const voiceModels = useVoiceModels((s) => s.models);
|
||
const refreshModels = useVoiceModels((s) => s.refresh);
|
||
const [section, setSection] = useState<Section>('hermes');
|
||
const [hermesTest, setHermesTest] = useState<TestState>({ status: 'idle', message: '' });
|
||
const [sttTest, setSttTest] = useState<TestState>({ status: 'idle', message: '' });
|
||
const [ttsTest, setTtsTest] = useState<TestState>({ status: 'idle', message: '' });
|
||
const [voices, setVoices] = useState(listSystemVoices());
|
||
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const refresh = () => setVoices(listSystemVoices());
|
||
refresh();
|
||
speechSynthesis.addEventListener?.('voiceschanged', refresh);
|
||
void listMicrophones().then((d) => useVoice.getState().setMicDevices(d));
|
||
void refreshModels();
|
||
return () => speechSynthesis.removeEventListener?.('voiceschanged', refresh);
|
||
}, [refreshModels]);
|
||
|
||
const testHermes = async () => {
|
||
setHermesTest({ status: 'running', message: 'connexion…' });
|
||
try {
|
||
const client = new HermesClient(settings.hermes);
|
||
const health = await client.health();
|
||
let caps = '';
|
||
try {
|
||
const c = await client.capabilities();
|
||
caps = ` · modèle ${String(c.model ?? '?')} · ${Object.entries(c.features ?? {}).filter(([, v]) => v === true).length} fonctions`;
|
||
} catch (err) {
|
||
const message = (err as Error).message;
|
||
caps = /404/.test(message)
|
||
? ' · /v1/capabilities absent (Hermes ancien : transport chat completions)'
|
||
: ` · capabilities : ${message}`;
|
||
}
|
||
const via = bridge() ? 'via Electron' : 'via navigateur';
|
||
setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` });
|
||
void hermesStore.connect();
|
||
} catch (err) {
|
||
setHermesTest({ status: 'fail', message: (err as Error).message });
|
||
}
|
||
};
|
||
|
||
const testStt = async () => {
|
||
setSttTest({ status: 'running', message: 'envoi d’un WAV de test…' });
|
||
try {
|
||
const samples = new Float32Array(16000);
|
||
for (let i = 0; i < samples.length; i++) samples[i] = Math.sin(i / 20) * 0.05;
|
||
const text = await transcribeWav(encodeWav(samples, 16000), settings.voice);
|
||
setSttTest({ status: 'ok', message: `endpoint OK (réponse: "${text.slice(0, 40) || 'vide'}")` });
|
||
} catch (err) {
|
||
setSttTest({ status: 'fail', message: (err as Error).message });
|
||
}
|
||
};
|
||
|
||
const testTts = () => {
|
||
setTtsTest({ status: 'running', message: 'synthèse…' });
|
||
speech.say(`Bonjour, je suis ${settings.assistantName}. Les systèmes sont opérationnels.`);
|
||
setTimeout(() => setTtsTest({ status: 'ok', message: 'commande envoyée (écoutez le résultat)' }), 600);
|
||
};
|
||
|
||
const applyWebhook = async () => {
|
||
const api = bridge();
|
||
if (!api) return;
|
||
await api.store.set('webhook', settings.webhook);
|
||
const status = await api.hermes.webhookRestart();
|
||
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'],
|
||
['speech', 'Voix / TTS'],
|
||
['models', 'Modèles locaux'],
|
||
['webhook', 'Webhook'],
|
||
['general', 'Général'],
|
||
['ui', 'Interface']
|
||
];
|
||
|
||
return (
|
||
<aside className="settings-drawer">
|
||
<header className="panel-head">
|
||
<Settings size={14} />
|
||
<span>Configuration</span>
|
||
<span className="spacer" />
|
||
<button className="icon-btn" onClick={onClose} title="Fermer"><X size={16} /></button>
|
||
</header>
|
||
<nav className="settings-nav">
|
||
{nav.map(([id, label]) => (
|
||
<button key={id} className={section === id ? 'active' : ''} onClick={() => setSection(id)}>{label}</button>
|
||
))}
|
||
</nav>
|
||
<div className="settings-body">
|
||
{section === 'hermes' && (
|
||
<>
|
||
<div className="card">
|
||
<div className="field">
|
||
<label>URL du serveur API Hermes</label>
|
||
<input className="input" value={settings.hermes.url} placeholder="http://127.0.0.1:8642" onChange={(e) => update({ hermes: { url: e.target.value } })} />
|
||
<span className="hint">Serveur lancé avec <code>hermes gateway</code> (API_SERVER_ENABLED=true, port 8642 par défaut). L’URL peut se terminer par /v1.</span>
|
||
</div>
|
||
<div className="field">
|
||
<label>Clé API (API_SERVER_KEY)</label>
|
||
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<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} />)}
|
||
</datalist>
|
||
</div>
|
||
<div className="field">
|
||
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
||
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
||
</div>
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Transport</label>
|
||
<select className="select" value={settings.hermes.transport} onChange={(e) => update({ hermes: { transport: e.target.value as typeof settings.hermes.transport } })}>
|
||
<option value="auto">Auto (selon /v1/capabilities)</option>
|
||
<option value="runs">Runs API (SSE, approbations, steer)</option>
|
||
<option value="sessions">Sessions API (mémoire serveur)</option>
|
||
<option value="completions">Chat completions (OpenAI + outils EveFlow)</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Effort de raisonnement</label>
|
||
<select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => update({ hermes: { reasoningEffort: e.target.value as typeof settings.hermes.reasoningEffort } })}>
|
||
<option value="">Défaut</option>
|
||
<option value="low">Low</option>
|
||
<option value="medium">Medium</option>
|
||
<option value="high">High</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
|
||
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
|
||
</div>
|
||
<Toggle on={settings.hermes.localTools} onChange={(v) => update({ hermes: { localTools: v } })} label="Outils EveFlow côté client" hint="En mode chat completions : état du HUD, fichiers partagés, notifications." />
|
||
<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>
|
||
</>
|
||
)}
|
||
|
||
{section === 'voice' && (
|
||
<div className="card">
|
||
<div className="field">
|
||
<label>Moteur de reconnaissance</label>
|
||
<select className="select" value={settings.voice.provider} onChange={(e) => update({ voice: { provider: e.target.value as typeof settings.voice.provider } })}>
|
||
<option value="local">Local dans l’application (Whisper via sherpa-onnx, hors ligne)</option>
|
||
<option value="openai-compatible">API compatible OpenAI (Qwen3-ASR, Whisper, Speaches, LocalAI…)</option>
|
||
<option value="browser">Reconnaissance Chromium (en ligne, secours)</option>
|
||
</select>
|
||
</div>
|
||
{settings.voice.provider === 'local' && (
|
||
<div className="field">
|
||
<label>Modèle local</label>
|
||
{installedModels(voiceModels, 'stt').length === 0 ? (
|
||
<span className="hint">Aucun modèle installé. <a href="#" onClick={(e) => { e.preventDefault(); setSection('models'); }}>Téléchargez Whisper base dans « Modèles locaux »</a>.</span>
|
||
) : (
|
||
<select className="select" value={settings.voice.localModel} onChange={(e) => update({ voice: { localModel: e.target.value } })}>
|
||
{installedModels(voiceModels, 'stt').map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
{settings.voice.provider === 'openai-compatible' && (
|
||
<>
|
||
<div className="field">
|
||
<label>URL de l’API STT</label>
|
||
<input className="input" value={settings.voice.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ voice: { apiUrl: e.target.value } })} />
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Modèle</label>
|
||
<input className="input" value={settings.voice.model} onChange={(e) => update({ voice: { model: e.target.value } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Clé API</label>
|
||
<input className="input" type="password" value={settings.voice.apiKey} onChange={(e) => update({ voice: { apiKey: e.target.value } })} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Langue</label>
|
||
<select className="select" value={settings.voice.language} onChange={(e) => update({ voice: { language: e.target.value }, language: e.target.value })}>
|
||
<option value="fr-FR">Français</option>
|
||
<option value="en-US">English</option>
|
||
<option value="es-ES">Español</option>
|
||
<option value="de-DE">Deutsch</option>
|
||
<option value="it-IT">Italiano</option>
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Microphone</label>
|
||
<select className="select" value={settings.voice.micDeviceId} onChange={(e) => update({ voice: { micDeviceId: e.target.value } })}>
|
||
<option value="">Par défaut du système</option>
|
||
{micDevices.map((d) => <option key={d.deviceId} value={d.deviceId}>{d.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Mode de capture</label>
|
||
<div className="seg">
|
||
<button className={settings.voice.captureMode === 'auto' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'auto' } })}>Auto (détection de silence)</button>
|
||
<button className={settings.voice.captureMode === 'manual' ? 'active' : ''} onClick={() => update({ voice: { captureMode: 'manual' } })}>Manuel (clic pour arrêter)</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Sensibilité : {settings.voice.sensitivity}/5</label>
|
||
<input className="range" type="range" min={1} max={5} step={1} value={settings.voice.sensitivity} onChange={(e) => update({ voice: { sensitivity: Number(e.target.value) } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Silence de fin : {settings.voice.silenceMs} ms</label>
|
||
<input className="range" type="range" min={400} max={2500} step={100} value={settings.voice.silenceMs} onChange={(e) => update({ voice: { silenceMs: Number(e.target.value) } })} />
|
||
</div>
|
||
</div>
|
||
<Toggle on={settings.voice.handsFree} onChange={(v) => { update({ voice: { handsFree: v } }); useVoice.getState().setHandsFree(v); }} label="Mains libres au démarrage" hint="Le micro se réactive automatiquement après chaque réponse." />
|
||
<Toggle on={settings.voice.wakeChime} onChange={(v) => update({ voice: { wakeChime: v } })} label="Signal sonore d’écoute" />
|
||
<Toggle on={settings.voice.wakeWordEnabled} onChange={(v) => update({ voice: { wakeWordEnabled: v } })} label="Mot d’activation en mains libres" hint="Seules les phrases commençant par ce mot sont envoyées à Hermes ; le reste est ignoré. Recommandé avec la reconnaissance locale." />
|
||
{settings.voice.wakeWordEnabled && (
|
||
<div className="field" style={{ marginTop: 8 }}>
|
||
<label>Mot d’activation</label>
|
||
<input className="input" value={settings.voice.wakeWord} placeholder="jarvis" onChange={(e) => update({ voice: { wakeWord: e.target.value.toLowerCase() } })} />
|
||
</div>
|
||
)}
|
||
<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>
|
||
)}
|
||
|
||
{section === 'speech' && (
|
||
<div className="card">
|
||
<div className="field">
|
||
<label>Moteur de synthèse</label>
|
||
<select className="select" value={settings.speech.provider} onChange={(e) => update({ speech: { provider: e.target.value as typeof settings.speech.provider } })}>
|
||
<option value="local">Local dans l’application (Kokoro / Piper via sherpa-onnx, hors ligne)</option>
|
||
<option value="openai-compatible">API compatible OpenAI /v1/audio/speech (Kokoro, Piper, OpenAI, LocalAI…)</option>
|
||
<option value="system">Voix système Windows</option>
|
||
<option value="google-free">Google Translate (gratuit, en ligne)</option>
|
||
<option value="off">Désactivée</option>
|
||
</select>
|
||
</div>
|
||
{settings.speech.provider === 'openai-compatible' && (
|
||
<>
|
||
<div className="field">
|
||
<label>URL de l’API TTS</label>
|
||
<input className="input" value={settings.speech.apiUrl} placeholder="http://127.0.0.1:8000/v1" onChange={(e) => update({ speech: { apiUrl: e.target.value } })} />
|
||
</div>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Modèle</label>
|
||
<input className="input" value={settings.speech.model} onChange={(e) => update({ speech: { model: e.target.value } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Voix</label>
|
||
<input className="input" value={settings.speech.voice} placeholder="alloy, onyx, af_heart…" onChange={(e) => update({ speech: { voice: e.target.value } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Clé API</label>
|
||
<input className="input" type="password" value={settings.speech.apiKey} onChange={(e) => update({ speech: { apiKey: e.target.value } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Format</label>
|
||
<select className="select" value={settings.speech.format} onChange={(e) => update({ speech: { format: e.target.value as typeof settings.speech.format } })}>
|
||
<option value="mp3">mp3</option>
|
||
<option value="wav">wav</option>
|
||
<option value="opus">opus</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
{settings.speech.provider === 'local' && (
|
||
installedModels(voiceModels, 'tts').length === 0 ? (
|
||
<div className="field">
|
||
<span className="hint">Aucun modèle de voix installé. <a href="#" onClick={(e) => { e.preventDefault(); setSection('models'); }}>Téléchargez Kokoro dans « Modèles locaux »</a>.</span>
|
||
</div>
|
||
) : (
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Modèle local</label>
|
||
<select className="select" value={settings.speech.localModel} onChange={(e) => {
|
||
const m = voiceModels.find((x) => x.id === e.target.value);
|
||
update({ speech: { localModel: e.target.value, localSpeaker: m?.speakers?.[0]?.id ?? 0 } });
|
||
}}>
|
||
{installedModels(voiceModels, 'tts').map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Voix</label>
|
||
<select className="select" value={settings.speech.localSpeaker} onChange={(e) => update({ speech: { localSpeaker: Number(e.target.value) } })}>
|
||
{(voiceModels.find((m) => m.id === settings.speech.localModel)?.speakers ?? [{ id: 0, name: 'Voix 0', lang: '' }]).map((sp) => (
|
||
<option key={sp.id} value={sp.id}>{sp.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
{settings.speech.provider === 'system' && (
|
||
<div className="field">
|
||
<label>Voix système</label>
|
||
<select className="select" value={settings.speech.systemVoice} onChange={(e) => update({ speech: { systemVoice: e.target.value } })}>
|
||
<option value="">Automatique ({settings.speech.language})</option>
|
||
{voices.map((v) => <option key={v.name} value={v.name}>{v.name} · {v.lang}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Vitesse : {settings.speech.speed.toFixed(2)}</label>
|
||
<input className="range" type="range" min={0.6} max={1.8} step={0.05} value={settings.speech.speed} onChange={(e) => update({ speech: { speed: Number(e.target.value) } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Volume : {Math.round(settings.speech.volume * 100)}%</label>
|
||
<input className="range" type="range" min={0} max={1.2} step={0.05} value={settings.speech.volume} onChange={(e) => update({ speech: { volume: Number(e.target.value) } })} />
|
||
</div>
|
||
</div>
|
||
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
|
||
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
|
||
<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>
|
||
)}
|
||
|
||
{section === 'models' && <ModelsSection />}
|
||
|
||
{section === 'webhook' && (
|
||
<div className="card">
|
||
<Toggle on={settings.webhook.enabled} onChange={(v) => update({ webhook: { enabled: v } })} label="Serveur webhook local" hint="Hermes (crons, gateway, scripts) peut pousser des messages vers EveFlow." />
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Port</label>
|
||
<input className="input" type="number" min={1024} max={65535} value={settings.webhook.port} onChange={(e) => update({ webhook: { port: Number(e.target.value) || 7842 } })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Secret (en-tête X-EveFlow-Secret)</label>
|
||
<div className="row">
|
||
<input className="input" type={webhookSecretVisible ? 'text' : 'password'} value={settings.webhook.secret} onChange={(e) => update({ webhook: { secret: e.target.value } })} />
|
||
<button className="btn ghost small" onClick={() => setWebhookSecretVisible((v) => !v)}>{webhookSecretVisible ? 'cacher' : 'voir'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Exemple d’appel</label>
|
||
<pre className="input" style={{ whiteSpace: 'pre-wrap', fontSize: 11.5, userSelect: 'text' }}>{`curl -X POST http://<ip-de-ce-pc>:${settings.webhook.port}/eveflow/hook \\
|
||
-H "Content-Type: application/json"${settings.webhook.secret ? ' \\\n -H "X-EveFlow-Secret: ***"' : ''} \\
|
||
-d '{"role":"assistant","text":"Rapport terminé","source":"telegram"}'`}</pre>
|
||
<span className="hint">Formats acceptés : {'{role,text}'}, {'{event:"run.completed",input,output}'}, {'{event:"job.completed",job:{name},output,status}'}, {'{type:"message",payload:{text}}'}.</span>
|
||
</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>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{section === 'general' && (
|
||
<div className="card">
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Nom de l’assistant</label>
|
||
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Votre nom</label>
|
||
<input className="input" value={settings.userName} placeholder="Monsieur" onChange={(e) => update({ userName: e.target.value.slice(0, 24) })} />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label>Thème du HUD</label>
|
||
<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 })} />;
|
||
})}
|
||
</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>
|
||
</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>
|
||
</div>
|
||
)}
|
||
|
||
{section === 'ui' && (
|
||
<div className="card">
|
||
<Toggle on={settings.ui.showTelemetry} onChange={(v) => update({ ui: { showTelemetry: v } })} label="Télémétrie système" />
|
||
<Toggle on={settings.ui.showReasoning} onChange={(v) => update({ ui: { showReasoning: v } })} label="Afficher le raisonnement du modèle" hint="Quand Hermes le transmet (reasoning.available)." />
|
||
<Toggle on={settings.ui.reduceMotion} onChange={(v) => update({ ui: { reduceMotion: v } })} label="Réduire les animations" />
|
||
<div className="field" style={{ marginTop: 10 }}>
|
||
<label>Opacité du widget compact : {Math.round(settings.ui.compactOpacity * 100)}%</label>
|
||
<input className="range" type="range" min={0.5} max={1} step={0.02} value={settings.ui.compactOpacity} onChange={(e) => update({ ui: { compactOpacity: Number(e.target.value) } })} />
|
||
</div>
|
||
<span className="hint">Valeurs par défaut : {DEFAULT_SETTINGS.assistantName}, thème {DEFAULT_SETTINGS.theme}.</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|