mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f95f79943 | ||
|
|
c9b44433ac | ||
|
|
5201742f45 |
No files matched your search
@@ -67,7 +67,7 @@ jobs:
|
|||||||
out/latest.yml
|
out/latest.yml
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
- name: Publish GitHub release
|
- name: Publish GitHub release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.ver.outputs.released == 'false')
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && steps.ver.outputs.released == 'false')
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ steps.ver.outputs.version }}
|
tag_name: v${{ steps.ver.outputs.version }}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.1.0",
|
"version": "2.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.1.0",
|
"version": "2.5.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/orbitron": "^5.3.0",
|
"@fontsource/orbitron": "^5.3.0",
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.0",
|
"version": "2.5.2",
|
||||||
"releaseVersion": "2.5.0",
|
"releaseVersion": "2.5.2",
|
||||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -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} disabled={m.available === false}>{m.name || m.id}{m.provider || m.owned_by ? ` · ${m.provider || m.owned_by}` : ''}{m.available === false ? ' (indisponible)' : ''}</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>;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settin
|
|||||||
import { useVoice } from '../../state/voice';
|
import { useVoice } from '../../state/voice';
|
||||||
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
||||||
import { ModelsSection } from './ModelsSection';
|
import { ModelsSection } from './ModelsSection';
|
||||||
|
import { ModelSelect } from './ModelSelect';
|
||||||
|
|
||||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
const reset = useSettings((s) => s.reset);
|
const reset = useSettings((s) => s.reset);
|
||||||
const hermesModels = useHermes((s) => s.models);
|
const hermesModels = useHermes((s) => s.models);
|
||||||
|
const modelsLoading = useHermes((s) => s.modelsLoading);
|
||||||
|
const modelsError = useHermes((s) => s.modelsError);
|
||||||
|
const modelsNotice = useHermes((s) => s.modelsNotice);
|
||||||
|
const refreshHermesModels = useHermes((s) => s.refreshModels);
|
||||||
const hermesWebhook = useHermes((s) => s.webhook);
|
const hermesWebhook = useHermes((s) => s.webhook);
|
||||||
const hermesConnect = useHermes((s) => s.connect);
|
const hermesConnect = useHermes((s) => s.connect);
|
||||||
const micDevices = useVoice((s) => s.micDevices);
|
const micDevices = useVoice((s) => s.micDevices);
|
||||||
@@ -91,6 +96,13 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
|
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
|
||||||
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
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;
|
const speechProvider = settings.speech.provider;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (speechProvider !== 'edge' || edgeVoices.length) return;
|
if (speechProvider !== 'edge' || edgeVoices.length) return;
|
||||||
@@ -206,18 +218,20 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<ModelSelect label="Modèle IA" models={hermesModels} value={settings.hermes.model} defaultLabel="Défaut du serveur" onChange={(model) => update({ hermes: { model } })} />
|
||||||
<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>
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
<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 } })} />
|
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<button className="btn small" disabled={modelsLoading} onClick={() => void refreshHermesModels(true)}>
|
||||||
|
{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={modelsNotice ? 'test-result fail' : 'hint'}>{modelsNotice || 'Modèles des fournisseurs configurés dans Hermes. Les modèles sans accès sont indiqués comme indisponibles.'}</span>
|
||||||
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Transport</label>
|
<label>Transport</label>
|
||||||
@@ -239,8 +253,7 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Modèle « mission » (tâches longues)</label>
|
<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 } })} />
|
||||||
<input className="input" list="hermes-models" value={settings.hermes.missionModel} placeholder="(même modèle)" onChange={(e) => update({ hermes: { missionModel: e.target.value } })} />
|
|
||||||
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
|
<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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i
|
|||||||
|
|
||||||
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
||||||
|
|
||||||
|
/** Stored picker IDs include the provider so identical model names remain distinct. */
|
||||||
|
export function modelSelection(value: string): { model?: string; provider?: string } {
|
||||||
|
const id = value.trim();
|
||||||
|
const separator = id.indexOf('::');
|
||||||
|
return separator > 0 ? { provider: id.slice(0, separator), model: id.slice(separator + 2) } : id ? { model: id } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A web page (login portal, dashboard, reverse-proxy error) instead of JSON means the URL does not
|
* A web page (login portal, dashboard, reverse-proxy error) instead of JSON means the URL does not
|
||||||
* point at the Hermes API. Returns a human explanation, or null when the body is not HTML.
|
* point at the Hermes API. Returns a human explanation, or null when the body is not HTML.
|
||||||
@@ -200,7 +207,44 @@ export class HermesClient {
|
|||||||
|
|
||||||
async models(): Promise<HermesModel[]> {
|
async models(): Promise<HermesModel[]> {
|
||||||
const payload = await this.request<unknown>('/v1/models');
|
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 modelCatalog(refresh = false): Promise<{ models: HermesModel[]; notice: string | null }> {
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await this.request<unknown>(`/api/model/options${refresh ? '?refresh=true' : ''}`, { timeoutMs: 30_000 });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof HttpError) || ![404, 405].includes(err.status)) throw err;
|
||||||
|
return { models: await this.models(), notice: 'Ce serveur ne propose pas le catalogue IA (/api/model/options). Mettez Hermes à jour pour choisir le fournisseur et son modèle. La liste ci-dessous contient uniquement les alias de connexion.' };
|
||||||
|
}
|
||||||
|
if (!isRec(payload) || !Array.isArray(payload.providers)) throw new Error('Catalogue IA Hermes invalide : liste des fournisseurs absente.');
|
||||||
|
const models = new Map<string, HermesModel>();
|
||||||
|
for (const row of payload.providers) {
|
||||||
|
if (!isRec(row) || typeof row.slug !== 'string' || !row.slug.trim() || !Array.isArray(row.models)) continue;
|
||||||
|
const unavailable = Array.isArray(row.unavailable_models) ? row.unavailable_models : [];
|
||||||
|
for (const model of row.models) {
|
||||||
|
if (typeof model !== 'string' || !model.trim()) continue;
|
||||||
|
const id = `${row.slug}::${model}`;
|
||||||
|
models.set(id, { id, name: model, provider: typeof row.name === 'string' ? row.name : row.slug,
|
||||||
|
available: row.authenticated !== false && !unavailable.includes(model) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { models: [...models.values()], notice: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async skills(): Promise<HermesSkill[]> {
|
async skills(): Promise<HermesSkill[]> {
|
||||||
@@ -290,11 +334,12 @@ export class HermesClient {
|
|||||||
|
|
||||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string }): Promise<{ run_id: string; status: string }> {
|
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string; provider?: string }): Promise<{ run_id: string; status: string }> {
|
||||||
const payload: Rec = { input: body.input };
|
const payload: Rec = { input: body.input };
|
||||||
if (body.session_id) payload.session_id = body.session_id;
|
if (body.session_id) payload.session_id = body.session_id;
|
||||||
if (body.instructions) payload.instructions = body.instructions;
|
if (body.instructions) payload.instructions = body.instructions;
|
||||||
if (body.model) payload.model = body.model;
|
if (body.model) payload.model = body.model;
|
||||||
|
if (body.provider) payload.provider = body.provider;
|
||||||
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
|
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +433,7 @@ export class HermesClient {
|
|||||||
input: options.text,
|
input: options.text,
|
||||||
session_id: plainSession(options.sessionId) || undefined,
|
session_id: plainSession(options.sessionId) || undefined,
|
||||||
instructions: this.config.instructions || undefined,
|
instructions: this.config.instructions || undefined,
|
||||||
model: this.config.model || undefined
|
...modelSelection(this.config.model)
|
||||||
});
|
});
|
||||||
const runId = run.run_id;
|
const runId = run.run_id;
|
||||||
if (isAborted()) {
|
if (isAborted()) {
|
||||||
@@ -452,7 +497,7 @@ export class HermesClient {
|
|||||||
const realId = sessionId.slice(3);
|
const realId = sessionId.slice(3);
|
||||||
const body: Rec = { input: options.text };
|
const body: Rec = { input: options.text };
|
||||||
if (this.config.instructions) body.instructions = this.config.instructions;
|
if (this.config.instructions) body.instructions = this.config.instructions;
|
||||||
if (this.config.model) body.model = this.config.model;
|
Object.assign(body, modelSelection(this.config.model));
|
||||||
|
|
||||||
let streamed = '';
|
let streamed = '';
|
||||||
let finalText = '';
|
let finalText = '';
|
||||||
@@ -489,7 +534,7 @@ export class HermesClient {
|
|||||||
let fullText = '';
|
let fullText = '';
|
||||||
let toolsAllowed = useTools;
|
let toolsAllowed = useTools;
|
||||||
for (let iteration = 0; iteration < 6 && !aborted(); iteration++) {
|
for (let iteration = 0; iteration < 6 && !aborted(); iteration++) {
|
||||||
const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true };
|
const payload: Rec = { model: 'hermes-agent', ...modelSelection(this.config.model), messages, stream: true };
|
||||||
if (toolsAllowed) {
|
if (toolsAllowed) {
|
||||||
payload.tools = options.localToolDefinitions;
|
payload.tools = options.localToolDefinitions;
|
||||||
payload.tool_choice = 'auto';
|
payload.tool_choice = 'auto';
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface HermesHealth {
|
|||||||
|
|
||||||
export interface HermesModel {
|
export interface HermesModel {
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
available?: boolean;
|
||||||
owned_by?: string;
|
owned_by?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
+30
-4
@@ -33,6 +33,10 @@ interface HermesStore {
|
|||||||
capabilities: HermesCapabilities | null;
|
capabilities: HermesCapabilities | null;
|
||||||
health: HermesHealth | null;
|
health: HermesHealth | null;
|
||||||
models: HermesModel[];
|
models: HermesModel[];
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsError: string | null;
|
||||||
|
modelsNotice: string | null;
|
||||||
|
refreshModels: (refresh?: boolean) => Promise<void>;
|
||||||
skills: HermesSkill[];
|
skills: HermesSkill[];
|
||||||
toolsets: HermesToolset[];
|
toolsets: HermesToolset[];
|
||||||
sessions: HermesSession[];
|
sessions: HermesSession[];
|
||||||
@@ -63,6 +67,7 @@ interface HermesStore {
|
|||||||
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
||||||
let connectInflight: Promise<void> | null = null;
|
let connectInflight: Promise<void> | null = null;
|
||||||
let lastCacheSnapshot = '';
|
let lastCacheSnapshot = '';
|
||||||
|
let modelsRequest = 0;
|
||||||
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
||||||
|
|
||||||
function runFromJob(job: HermesJob): JobRun | null {
|
function runFromJob(job: HermesJob): JobRun | null {
|
||||||
@@ -87,6 +92,9 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
capabilities: null,
|
capabilities: null,
|
||||||
health: null,
|
health: null,
|
||||||
models: [],
|
models: [],
|
||||||
|
modelsLoading: false,
|
||||||
|
modelsError: null,
|
||||||
|
modelsNotice: null,
|
||||||
skills: [],
|
skills: [],
|
||||||
toolsets: [],
|
toolsets: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
@@ -102,7 +110,7 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
client: (modelOverride) => {
|
client: (modelOverride) => {
|
||||||
const config = useSettings.getState().settings.hermes;
|
const config = useSettings.getState().settings.hermes;
|
||||||
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
|
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
|
||||||
const model = (modelOverride ?? '').trim() || config.model.trim() || get().models[0]?.id || '';
|
const model = (modelOverride ?? '').trim() || config.model.trim();
|
||||||
return new HermesClient({ ...config, model });
|
return new HermesClient({ ...config, model });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -160,14 +168,32 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
return connectInflight;
|
return connectInflight;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshModels: async (refresh = false) => {
|
||||||
|
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, modelsNotice: null });
|
||||||
|
try {
|
||||||
|
const { models, notice } = await new HermesClient(config).modelCatalog(refresh);
|
||||||
|
if (current()) set({ models, modelsNotice: notice });
|
||||||
|
} catch (err) {
|
||||||
|
if (current()) set({ modelsError: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
if (request === modelsRequest) set({ modelsLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
refreshCatalog: async () => {
|
refreshCatalog: async () => {
|
||||||
const client = get().client();
|
const client = get().client();
|
||||||
const [models, skills, toolsets] = await Promise.all([
|
const [, skills, toolsets] = await Promise.all([
|
||||||
client.models().catch(() => [] as HermesModel[]),
|
get().refreshModels(),
|
||||||
client.skills().catch(() => [] as HermesSkill[]),
|
client.skills().catch(() => [] as HermesSkill[]),
|
||||||
client.toolsets().catch(() => [] as HermesToolset[])
|
client.toolsets().catch(() => [] as HermesToolset[])
|
||||||
]);
|
]);
|
||||||
set({ models, skills, toolsets });
|
set({ skills, toolsets });
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshSessions: async () => {
|
refreshSessions: async () => {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { HermesClient } from '../src/services/hermes/client';
|
||||||
|
import { httpFetch, httpStream } 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(), httpStream: 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('reads the real provider inventory, keeps providers distinct and marks inaccessible models', async () => {
|
||||||
|
respond({ providers: [
|
||||||
|
{ slug: 'first', name: 'First', authenticated: true, models: ['same', 'locked'], unavailable_models: ['locked'] },
|
||||||
|
{ slug: 'second', name: 'Second', authenticated: false, models: ['same'] }
|
||||||
|
] });
|
||||||
|
const result = await new HermesClient(config).modelCatalog(true);
|
||||||
|
expect(httpFetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.test/api/model/options?refresh=true' }));
|
||||||
|
expect(result.models.map(m => [m.id, m.available])).toEqual([['first::same', true], ['first::locked', false], ['second::same', false]]);
|
||||||
|
expect(result.notice).toBeNull();
|
||||||
|
});
|
||||||
|
it('explains old servers instead of presenting the connection alias as an LLM catalog', async () => {
|
||||||
|
vi.mocked(httpFetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: '', headers: {}, text: '' })
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ data: [{ id: 'hermes-agent' }] }) });
|
||||||
|
const result = await new HermesClient(config).modelCatalog();
|
||||||
|
expect(result.models).toEqual([{ id: 'hermes-agent' }]);
|
||||||
|
expect(result.notice).toContain('Mettez Hermes à jour');
|
||||||
|
});
|
||||||
|
it.each(['runs', 'sessions', 'completions'] as const)('sends provider and actual model separately over %s', async (transport) => {
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
vi.mocked(httpFetch).mockImplementation(async req => {
|
||||||
|
requests.push(JSON.parse(req.body as string));
|
||||||
|
throw new Error('stop');
|
||||||
|
});
|
||||||
|
vi.mocked(httpStream).mockImplementation(async req => {
|
||||||
|
requests.push(JSON.parse(req.body as string));
|
||||||
|
throw new Error('stop');
|
||||||
|
});
|
||||||
|
const send = new HermesClient({ ...config, model: 'custom:local::real-model' }).send({ text: 'Bonjour', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport);
|
||||||
|
await expect(send.result).rejects.toThrow('stop');
|
||||||
|
expect(requests[0]).toMatchObject({ model: 'real-model', provider: 'custom:local' });
|
||||||
|
});
|
||||||
|
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: { models: { id: string }[]; notice: null }) => void;
|
||||||
|
vi.spyOn(HermesClient.prototype, 'modelCatalog').mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ models: [{ id: 'new' }], notice: null });
|
||||||
|
const old = useHermes.getState().refreshModels();
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, url: 'https://new.test' } } });
|
||||||
|
await useHermes.getState().refreshModels();
|
||||||
|
finish({ models: [{ id: 'old' }], notice: null });
|
||||||
|
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()); }
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user