Compare commits

...
1 Commits
Author SHA1 Message Date
Michael 5201742f45 feat: add Hermes AI model selectors and discovery feedback (2.5.1) 2026-09-05 08:36:27 +02:00
7 changed files with 166 additions and 17 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "eveflow",
"version": "2.1.0",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eveflow",
"version": "2.1.0",
"version": "2.5.1",
"license": "MIT",
"dependencies": {
"@fontsource/orbitron": "^5.3.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "eveflow",
"version": "2.5.0",
"releaseVersion": "2.5.0",
"version": "2.5.1",
"releaseVersion": "2.5.1",
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
"main": "dist-electron/main.js",
"private": true,
+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">
+15 -1
View File
@@ -200,7 +200,21 @@ export class HermesClient {
async models(): Promise<HermesModel[]> {
const payload = await this.request<unknown>('/v1/models');
return extractArray<HermesModel>(payload, ['data', 'models']);
const readEntries = (value: unknown): unknown[] => {
if (Array.isArray(value)) return value;
if (isRec(value)) {
if (Array.isArray(value.models)) return value.models;
if (value.data !== undefined) return readEntries(value.data);
}
throw new Error('Le serveur a renvoyé une liste de modèles invalide.');
};
const entries = readEntries(payload);
const models = new Map<string, HermesModel>();
for (const entry of entries) {
const id = typeof entry === 'string' ? entry.trim() : isRec(entry) && typeof entry.id === 'string' ? entry.id.trim() : '';
if (id && !models.has(id)) models.set(id, { ...(isRec(entry) ? entry : {}), id });
}
return [...models.values()];
}
async skills(): Promise<HermesSkill[]> {
+27 -3
View File
@@ -33,6 +33,9 @@ interface HermesStore {
capabilities: HermesCapabilities | null;
health: HermesHealth | null;
models: HermesModel[];
modelsLoading: boolean;
modelsError: string | null;
refreshModels: () => Promise<void>;
skills: HermesSkill[];
toolsets: HermesToolset[];
sessions: HermesSession[];
@@ -63,6 +66,7 @@ interface HermesStore {
const CACHE_KEY = 'eveflow.hermes.cache.v2';
let connectInflight: Promise<void> | null = null;
let lastCacheSnapshot = '';
let modelsRequest = 0;
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
function runFromJob(job: HermesJob): JobRun | null {
@@ -87,6 +91,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
capabilities: null,
health: null,
models: [],
modelsLoading: false,
modelsError: null,
skills: [],
toolsets: [],
sessions: [],
@@ -160,14 +166,32 @@ export const useHermes = create<HermesStore>((set, get) => ({
return connectInflight;
},
refreshModels: async () => {
const request = ++modelsRequest;
const config = useSettings.getState().settings.hermes;
const current = () => {
const now = useSettings.getState().settings.hermes;
return request === modelsRequest && now.url === config.url && now.apiKey === config.apiKey && now.sessionKey === config.sessionKey;
};
set({ models: [], modelsLoading: true, modelsError: null });
try {
const models = await new HermesClient(config).models();
if (current()) set({ models });
} catch (err) {
if (current()) set({ modelsError: (err as Error).message });
} finally {
if (request === modelsRequest) set({ modelsLoading: false });
}
},
refreshCatalog: async () => {
const client = get().client();
const [models, skills, toolsets] = await Promise.all([
client.models().catch(() => [] as HermesModel[]),
const [, skills, toolsets] = await Promise.all([
get().refreshModels(),
client.skills().catch(() => [] as HermesSkill[]),
client.toolsets().catch(() => [] as HermesToolset[])
]);
set({ models, skills, toolsets });
set({ skills, toolsets });
},
refreshSessions: async () => {
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HermesClient } from '../src/services/hermes/client';
import { httpFetch } from '../src/lib/transport';
import { DEFAULT_SETTINGS, useSettings } from '../src/state/settings';
import { useHermes } from '../src/state/hermes';
import { createElement, act } from 'react';
import { createRoot } from 'react-dom/client';
import { ModelSelect } from '../src/components/settings/ModelSelect';
vi.mock('../src/lib/transport', async (original) => ({ ...await original<typeof import('../src/lib/transport')>(), httpFetch: vi.fn() }));
const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test/v1/', apiKey: ' test-key ' };
function respond(payload: unknown, status = 200) {
vi.mocked(httpFetch).mockResolvedValue({ ok: status === 200, status, statusText: '', headers: {}, text: JSON.stringify(payload) });
}
afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); });
describe('Hermes models', () => {
it('uses authenticated discovery and preserves provider labels, order and unique valid IDs', async () => {
respond({ data: [{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'b' }, {}, ' c '] });
expect(await new HermesClient(config).models()).toEqual([{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'c' }]);
expect(httpFetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.test/v1/models', headers: expect.objectContaining({ Authorization: 'Bearer test-key' }) }));
});
it('accepts nested catalogs and empty lists, rejects malformed responses', async () => {
respond({ data: { models: ['a'] } });
expect(await new HermesClient(config).models()).toEqual([{ id: 'a' }]);
respond({ data: [] });
expect(await new HermesClient(config).models()).toEqual([]);
respond({ data: { error: 'unavailable' } });
await expect(new HermesClient(config).models()).rejects.toThrow('invalide');
});
it('surfaces authentication failures without clearing the selected model', async () => {
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, model: 'chosen' } } });
respond({}, 401);
await useHermes.getState().refreshModels();
expect(useHermes.getState().modelsError).toContain('401');
expect(useHermes.getState().modelsLoading).toBe(false);
expect(useSettings.getState().settings.hermes.model).toBe('chosen');
});
it('discards results from a previous server or an older refresh', async () => {
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: config } });
let finish!: (value: { id: string }[]) => void;
vi.spyOn(HermesClient.prototype, 'models').mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue([{ id: 'new' }]);
const old = useHermes.getState().refreshModels();
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, url: 'https://new.test' } } });
await useHermes.getState().refreshModels();
finish([{ id: 'old' }]);
await old;
expect(useHermes.getState().models).toEqual([{ id: 'new' }]);
});
it('sends the selected principal model and the mission override on new runs', async () => {
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, model: 'principal', missionModel: 'mission' } } });
const start = vi.spyOn(HermesClient.prototype, 'startRun').mockRejectedValue(new Error('stop before network'));
for (const [override, expected] of [[undefined, 'principal'], ['mission', 'mission']] as const) {
const send = useHermes.getState().client(override).send({ text: 'Bonjour', sessionId: 'test-session', history: [], onEvent: vi.fn() }, 'runs');
await expect(send.result).rejects.toThrow('stop before network');
expect(start).toHaveBeenLastCalledWith(expect.objectContaining({ model: expected }));
}
});
it('offers a visible selection, preserves a custom value and supports the default', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const container = document.createElement('div');
const root = createRoot(container);
const onChange = vi.fn();
try {
await act(async () => root.render(createElement(ModelSelect, { label: 'Modèle IA', value: 'custom', models: [{ id: 'available' }], defaultLabel: 'Défaut', onChange })));
const select = container.querySelector('select')!;
expect(select.value).toBe('custom');
await act(async () => { select.value = 'available'; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(onChange).toHaveBeenLastCalledWith('available');
await act(async () => { select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(onChange).toHaveBeenLastCalledWith('');
} finally { await act(async () => root.unmount()); }
});
});