diff --git a/package-lock.json b/package-lock.json
index f91b7b0..0a09e71 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 5da9837..71e4d2c 100644
--- a/package.json
+++ b/package.json
@@ -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,
diff --git a/src/components/settings/ModelSelect.tsx b/src/components/settings/ModelSelect.tsx
new file mode 100644
index 0000000..ce29fae
--- /dev/null
+++ b/src/components/settings/ModelSelect.tsx
@@ -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
([]);
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) {
update({ hermes: { apiKey: e.target.value } })} />
+
+ void refreshHermesModels()}>
+ {modelsLoading ? : }
+ {modelsLoading ? 'Chargement des modèles…' : 'Actualiser les modèles'}
+
+ {modelsLoading ? 'Interrogation du serveur Hermes…' : modelsError || (hermesModels.length ? `${hermesModels.length} modèle(s) disponible(s).` : 'Aucun modèle annoncé par le serveur.')}
+ 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.
+
Transport
@@ -239,8 +252,7 @@ export function SettingsDrawer({ onClose }: Props) {
- Modèle « mission » (tâches longues)
- update({ hermes: { missionModel: e.target.value } })} />
+ update({ hermes: { missionModel } })} />
Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.
diff --git a/src/services/hermes/client.ts b/src/services/hermes/client.ts
index 6cb9f7a..8170d81 100644
--- a/src/services/hermes/client.ts
+++ b/src/services/hermes/client.ts
@@ -200,7 +200,21 @@ export class HermesClient {
async models(): Promise {
const payload = await this.request('/v1/models');
- return extractArray(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();
+ 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 {
diff --git a/src/state/hermes.ts b/src/state/hermes.ts
index c61db10..45d7912 100644
--- a/src/state/hermes.ts
+++ b/src/state/hermes.ts
@@ -33,6 +33,9 @@ interface HermesStore {
capabilities: HermesCapabilities | null;
health: HermesHealth | null;
models: HermesModel[];
+ modelsLoading: boolean;
+ modelsError: string | null;
+ refreshModels: () => Promise;
skills: HermesSkill[];
toolsets: HermesToolset[];
sessions: HermesSession[];
@@ -63,6 +66,7 @@ interface HermesStore {
const CACHE_KEY = 'eveflow.hermes.cache.v2';
let connectInflight: Promise | 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((set, get) => ({
capabilities: null,
health: null,
models: [],
+ modelsLoading: false,
+ modelsError: null,
skills: [],
toolsets: [],
sessions: [],
@@ -160,14 +166,32 @@ export const useHermes = create((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 () => {
diff --git a/tests/hermesModels.test.ts b/tests/hermesModels.test.ts
new file mode 100644
index 0000000..c93d78a
--- /dev/null
+++ b/tests/hermesModels.test.ts
@@ -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(), 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()); }
+ });
+});