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
+ + + + {manual && onChange(e.target.value)} />} +
; +} diff --git a/src/components/settings/SettingsDrawer.tsx b/src/components/settings/SettingsDrawer.tsx index 83dfa54..4577ee7 100644 --- a/src/components/settings/SettingsDrawer.tsx +++ b/src/components/settings/SettingsDrawer.tsx @@ -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([]); 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 } })} />
-
- - update({ hermes: { model: e.target.value } })} /> - - {hermesModels.map((m) => -
+ update({ hermes: { model } })} />
update({ hermes: { sessionKey: e.target.value } })} />
+
+ + {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. +
@@ -239,8 +252,7 @@ export function SettingsDrawer({ onClose }: Props) {
- - 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()); } + }); +});