feat: add Hermes AI model selectors and discovery feedback (2.5.1)

This commit is contained in:
Michael committed 2026-09-05 08:36:27 +02:00
1 parent b8073c0be6
commit 5201742f45
7 files changed
+166 -17

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import { useId, useState } from 'react';
import type { HermesModel } from '../../services/hermes/types';
export function ModelSelect({ label, value, models, defaultLabel, onChange }: {
label: string;
value: string;
models: HermesModel[];
defaultLabel: string;
onChange: (value: string) => void;
}) {
const id = useId();
const [manual, setManual] = useState(false);
return <div className="field">
<label htmlFor={id}>{label}</label>
<select id={id} className="select" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{defaultLabel}</option>
{value && !models.some((m) => m.id === value) && <option value={value}>{value} (configuré)</option>}
{models.map((m) => <option key={m.id} value={m.id}>{m.id}{m.provider || m.owned_by ? ` · ${m.provider || m.owned_by}` : ''}</option>)}
</select>
<button type="button" className="btn small" aria-expanded={manual} onClick={() => setManual(!manual)}>
{manual ? 'Masquer la saisie manuelle' : 'Saisir un autre identifiant'}
</button>
{manual && <input className="input" aria-label={`${label} : identifiant manuel`} value={value} placeholder={defaultLabel} onChange={(e) => onChange(e.target.value)} />}
</div>;
}
+21 -9
View File
@@ -15,6 +15,7 @@ import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settin
import { useVoice } from '../../state/voice';
import { installedModels, useVoiceModels } from '../../state/voiceModels';
import { ModelsSection } from './ModelsSection';
import { ModelSelect } from './ModelSelect';
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
@@ -78,6 +79,9 @@ export function SettingsDrawer({ onClose }: Props) {
const update = useSettings((s) => s.update);
const reset = useSettings((s) => s.reset);
const hermesModels = useHermes((s) => s.models);
const modelsLoading = useHermes((s) => s.modelsLoading);
const modelsError = useHermes((s) => s.modelsError);
const refreshHermesModels = useHermes((s) => s.refreshModels);
const hermesWebhook = useHermes((s) => s.webhook);
const hermesConnect = useHermes((s) => s.connect);
const micDevices = useVoice((s) => s.micDevices);
@@ -91,6 +95,13 @@ export function SettingsDrawer({ onClose }: Props) {
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
useEffect(() => {
if (section !== 'hermes') return;
useHermes.setState({ models: [], modelsError: null });
const timer = setTimeout(() => void refreshHermesModels(), 500);
return () => clearTimeout(timer);
}, [section, settings.hermes.url, settings.hermes.apiKey, settings.hermes.sessionKey, refreshHermesModels]);
const speechProvider = settings.speech.provider;
useEffect(() => {
if (speechProvider !== 'edge' || edgeVoices.length) return;
@@ -206,18 +217,20 @@ export function SettingsDrawer({ onClose }: Props) {
<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">
{hermesModels.map((m) => <option key={m.id} value={m.id} />)}
</datalist>
</div>
<ModelSelect label="Modèle IA" models={hermesModels} value={settings.hermes.model} defaultLabel="Défaut du serveur" onChange={(model) => update({ hermes: { model } })} />
<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="field">
<button className="btn small" disabled={modelsLoading} onClick={() => void refreshHermesModels()}>
{modelsLoading ? <Loader2 size={13} className="spin" /> : <RotateCcw size={13} />}
{modelsLoading ? 'Chargement des modèles…' : 'Actualiser les modèles'}
</button>
<span className="hint" role="status">{modelsLoading ? 'Interrogation du serveur Hermes…' : modelsError || (hermesModels.length ? `${hermesModels.length} modèle(s) disponible(s).` : 'Aucun modèle annoncé par le serveur.')}</span>
<span className="hint">Cette liste contient les modèles exposés par votre serveur Hermes. Si seul « hermes-agent » apparaît, les autres modèles doivent être configurés et exposés côté serveur.</span>
</div>
<div className="grid-2">
<div className="field">
<label>Transport</label>
@@ -239,8 +252,7 @@ export function SettingsDrawer({ onClose }: Props) {
</div>
</div>
<div className="field">
<label>Modèle « mission » (tâches longues)</label>
<input className="input" list="hermes-models" value={settings.hermes.missionModel} placeholder="(même modèle)" onChange={(e) => update({ hermes: { missionModel: e.target.value } })} />
<ModelSelect label="Modèle mission (tâches longues)" models={hermesModels} value={settings.hermes.missionModel} defaultLabel="Même modèle que le principal" onChange={(missionModel) => update({ hermes: { missionModel } })} />
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
</div>
<div className="field">