feat: choix du modèle Hermes parmi les fournisseurs configurés et niveau d'effort complet (v2.5.1)

- Menu déroulant des modèles alimenté par GET /api/model/options (tous les
  fournisseurs authentifiés et leurs modèles, modèle courant du serveur marqué),
  bouton d'actualisation (?refresh=1), saisie libre en repli quand l'endpoint
  n'existe pas. /v1/models ne renvoie que l'alias « hermes-agent ».
- Le fournisseur choisi et l'effort de raisonnement (none, minimal, low, medium,
  high, xhigh, max, ultra) sont envoyés à chaque requête sur les trois transports
  (runs, sessions, chat completions) ; l'effort n'était transmis qu'en chat
  completions.
- Modèle « mission » sélectionné de la même façon (modèle + fournisseur).
- Tests des helpers du sélecteur ; README et feuille de route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMpgFriwxiBgurUVb21oCE
This commit is contained in:
Claude committed 2026-09-04 15:47:35 +00:00
1 parent b8073c0be6
commit d5f19a9b3c
12 files changed
+328 -40

No files matched your search

+2 -1
View File
@@ -1,7 +1,7 @@
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent # EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
[![Build](https://img.shields.io/github/actions/workflow/status/R0m1k3/EveFlow/windows-release.yml?style=flat-square)](https://github.com/R0m1k3/EveFlow/actions) [![Build](https://img.shields.io/github/actions/workflow/status/R0m1k3/EveFlow/windows-release.yml?style=flat-square)](https://github.com/R0m1k3/EveFlow/actions)
[![Version](https://img.shields.io/badge/version-2.5.0-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases) [![Version](https://img.shields.io/badge/version-2.5.1-brightgreen.svg?style=flat-square)](https://github.com/R0m1k3/EveFlow/releases)
[![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat-square)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat-square)](LICENSE)
**EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD. **EveFlow** est un compagnon de bureau Windows qui transforme [Hermes Agent](https://hermes-agent.nousresearch.com/) en assistant vocal à la JARVIS : un noyau holographique réactif au son, une conversation en streaming, les outils, sous-agents, approbations, crons, skills et sessions d'Hermes pilotés depuis un seul HUD.
@@ -46,6 +46,7 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca
1. **Runs API** (`POST /v1/runs` + `GET /v1/runs/{id}/events`) : deltas, outils, sous-agents, `approval.request`, `run.completed`, arrêt (`/stop`) et injection de consignes en cours de run (`/steer`). 1. **Runs API** (`POST /v1/runs` + `GET /v1/runs/{id}/events`) : deltas, outils, sous-agents, `approval.request`, `run.completed`, arrêt (`/stop`) et injection de consignes en cours de run (`/steer`).
2. **Sessions API** (`/api/sessions/{id}/chat/stream`) : mémoire côté serveur, fork, suppression, relecture de l'historique. 2. **Sessions API** (`/api/sessions/{id}/chat/stream`) : mémoire côté serveur, fork, suppression, relecture de l'historique.
3. **Chat completions** OpenAI (`/v1/chat/completions`) avec `hermes.tool.progress`, continuité de session (`X-Hermes-Session-Id`) et outils EveFlow côté client (état du HUD, fichiers partagés, notifications). 3. **Chat completions** OpenAI (`/v1/chat/completions`) avec `hermes.tool.progress`, continuité de session (`X-Hermes-Session-Id`) et outils EveFlow côté client (état du HUD, fichiers partagés, notifications).
* **Choix du modèle et de l'effort** : menu déroulant alimenté par `GET /api/model/options` (tous les fournisseurs authentifiés d'Hermes et leurs modèles, `/v1/models` ne renvoyant que l'alias « hermes-agent »), bouton d'actualisation, saisie libre en repli. Modèle et fournisseur sont envoyés à chaque requête, ainsi que l'effort de raisonnement (none → ultra) sur les trois transports. Modèle « mission » choisi de la même façon.
* Mémoire longue durée via `X-Hermes-Session-Key`. * Mémoire longue durée via `X-Hermes-Session-Key`.
* **Approbations** d'outils affichées dans le HUD : une fois, pour la session, toujours, refuser. * **Approbations** d'outils affichées dans le HUD : une fois, pour la session, toujours, refuser.
* **Crons** : création en langage naturel (`every 1h`, `weekdays at 9am`, `in 30m`, expression cron), pause/reprise, exécution immédiate, édition, historique des résultats lus à voix haute. * **Crons** : création en langage naturel (`every 1h`, `weekdays at 9am`, `in 30m`, expression cron), pause/reprise, exécution immédiate, édition, historique des résultats lus à voix haute.
+1
View File
@@ -84,6 +84,7 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai)
| Correctifs 2.4.0.1 | réponse vide en chat completions désormais expliquée (JSON non streamé ou erreur HTTP 200), la liaison ne passe plus en « dégradé » quand seule l'API des crons échoue, transcriptions parasites (« (cliquant) », « *Claire* ») ignorées, préférence de voix masculine/féminine | | Correctifs 2.4.0.1 | réponse vide en chat completions désormais expliquée (JSON non streamé ou erreur HTTP 200), la liaison ne passe plus en « dégradé » quand seule l'API des crons échoue, transcriptions parasites (« (cliquant) », « *Claire* ») ignorées, préférence de voix masculine/féminine |
| Parakeet v3 vs Whisper base sur trois phrases Piper (fr) (2.4.1) | Parakeet : 3/3 exactes avec ponctuation, 0,4 à 0,5 s à chaud (6,7 s au premier appel) ; Whisper base : erreurs sur « Jarvis », « Peux-tu », 0,7 à 0,9 s | | Parakeet v3 vs Whisper base sur trois phrases Piper (fr) (2.4.1) | Parakeet : 3/3 exactes avec ponctuation, 0,4 à 0,5 s à chaud (6,7 s au premier appel) ; Whisper base : erreurs sur « Jarvis », « Peux-tu », 0,7 à 0,9 s |
| Worklets audio sous CSP stricte (2.4.1) | chargement des modules statiques OK dans l'application empaquetée | | Worklets audio sous CSP stricte (2.4.1) | chargement des modules statiques OK dans l'application empaquetée |
| Choix du modèle Hermes (2.5.1) | menu alimenté par `/api/model/options` (fournisseurs authentifiés + modèles, modèle courant du serveur marqué), fournisseur et effort de raisonnement (none → ultra) envoyés sur runs, sessions et chat completions ; `/v1/models` ne renvoie que « hermes-agent » |
| Voix françaises (2.5.0) | Edge Henri : MP3 reçu de bout en bout (32 ko pour 4 s). Supertonic 3 en français : 10 voix, RTF 0,14 sur 4 cœurs (8,7 s d'audio en 1,3 s), genres déterminés par mesure de la fréquence fondamentale (voix 0-4 : 170-210 Hz, voix 5-9 : 92-137 Hz) ; le worker compilé accepte `language` et bascule sur l'anglais pour une langue inconnue | | Voix françaises (2.5.0) | Edge Henri : MP3 reçu de bout en bout (32 ko pour 4 s). Supertonic 3 en français : 10 voix, RTF 0,14 sur 4 cœurs (8,7 s d'audio en 1,3 s), genres déterminés par mesure de la fréquence fondamentale (voix 0-4 : 170-210 Hz, voix 5-9 : 92-137 Hz) ; le worker compilé accepte `language` et bascule sur l'anglais pour une langue inconnue |
| Reconnaissance française : Parakeet v3 vs Qwen3-ASR 0.6B int8 (2.5.0) | Six phrases Supertonic (3,8 s) : Parakeet WER 8,5 % (erreurs surtout de forme : « 14h30 »), 384 ms par phrase ; Qwen3-ASR WER 15,3 % (« mémoires vivres »), 1 477 ms, 940 Mo. Qwen3-ASR n'est pas ajouté au catalogue | | Reconnaissance française : Parakeet v3 vs Qwen3-ASR 0.6B int8 (2.5.0) | Six phrases Supertonic (3,8 s) : Parakeet WER 8,5 % (erreurs surtout de forme : « 14h30 »), 384 ms par phrase ; Qwen3-ASR WER 15,3 % (« mémoires vivres »), 1 477 ms, 940 Mo. Qwen3-ASR n'est pas ajouté au catalogue |
| Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) | | Serveur MCP (2.4.0) | initialize, tools/list (14 outils), tools/call côté principal (presse-papiers, capture image) et côté renderer (état, message dans le fil) |
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "eveflow", "name": "eveflow",
"version": "2.5.0", "version": "2.5.1",
"releaseVersion": "2.5.0", "releaseVersion": "2.5.1",
"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,101 @@
/**
* Model selector fed by GET /api/model/options (every authenticated provider and its models),
* with a free-text fallback for servers that do not expose the inventory.
*/
import { useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { flattenModelOptions, groupModelChoices, modelChoiceValue, parseModelChoice } from '../../lib/hermesModels';
import { useHermes } from '../../state/hermes';
interface Props {
label: string;
model: string;
provider: string;
/** Label of the "no explicit model" option (server default / same as main model). */
emptyLabel: string;
onChange: (choice: { model: string; provider: string }) => void;
/** Show the refresh button (once per settings card is enough). */
withRefresh?: boolean;
hint?: string;
}
const CUSTOM = '__custom__';
export function HermesModelPicker({ label, model, provider, emptyLabel, onChange, withRefresh, hint }: Props) {
const options = useHermes((s) => s.modelOptions);
const error = useHermes((s) => s.modelOptionsError);
const [refreshing, setRefreshing] = useState(false);
const [custom, setCustom] = useState(false);
const choices = flattenModelOptions(options);
const groups = groupModelChoices(choices);
const value = model ? modelChoiceValue(provider, model) : '';
const known = !model || choices.some((c) => modelChoiceValue(c.provider, c.model) === value);
const freeText = custom || (choices.length === 0 && !!model) || !known;
const refresh = async (full: boolean) => {
setRefreshing(true);
try {
await useHermes.getState().refreshModelOptions(full);
} finally {
setRefreshing(false);
}
};
return (
<div className="field">
<label>{label}</label>
{choices.length > 0 && !freeText ? (
<select
className="select"
value={value}
onChange={(e) => {
if (e.target.value === CUSTOM) {
setCustom(true);
return;
}
onChange(e.target.value ? parseModelChoice(e.target.value) : { model: '', provider: '' });
}}
>
<option value="">{emptyLabel}{options?.model ? ` · serveur : ${options.model}` : ''}</option>
{groups.map((g) => (
<optgroup key={g.provider} label={g.providerName}>
{g.models.map((c) => (
<option key={modelChoiceValue(c.provider, c.model)} value={modelChoiceValue(c.provider, c.model)}>
{c.model}{c.current ? ' (actuel sur le serveur)' : ''}
</option>
))}
</optgroup>
))}
<option value={CUSTOM}>Autre (saisie libre)…</option>
</select>
) : (
<div className="row" style={{ gap: 6 }}>
<input
className="input"
value={model}
placeholder={emptyLabel}
onChange={(e) => onChange({ model: e.target.value, provider: '' })}
/>
{choices.length > 0 && (
<button className="btn small ghost" onClick={() => { setCustom(false); if (!known) onChange({ model: '', provider: '' }); }}>Liste</button>
)}
</div>
)}
<div className="row" style={{ gap: 8, marginTop: 4, alignItems: 'center' }}>
{withRefresh && (
<button className="btn small ghost" disabled={refreshing} onClick={() => void refresh(choices.length > 0)}>
<RefreshCw size={12} /> {refreshing ? 'chargement…' : choices.length ? 'Actualiser les modèles' : 'Charger les modèles'}
</button>
)}
<span className="hint">
{choices.length > 0
? `${choices.length} modèles chez ${groups.length} fournisseur${groups.length > 1 ? 's' : ''} (Hermes /api/model/options).`
: error
? `Liste indisponible (${error}). Hermes ancien ou clé API manquante : saisissez le nom du modèle.`
: 'Liste non chargée : testez la liaison ou cliquez sur « Charger les modèles ».'}
{hint ? ` ${hint}` : ''}
</span>
</div>
</div>
);
}
+25 -22
View File
@@ -15,6 +15,8 @@ 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 { HermesModelPicker } from './HermesModelPicker';
import { REASONING_EFFORTS, isReasoningEffort } from '../../lib/hermesModels';
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui'; type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
@@ -205,18 +207,18 @@ export function SettingsDrawer({ onClose }: Props) {
<label>Clé API (API_SERVER_KEY)</label> <label>Clé API (API_SERVER_KEY)</label>
<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"> <HermesModelPicker
<div className="field"> label="Modèle"
<label>Modèle</label> model={settings.hermes.model}
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} /> provider={settings.hermes.provider ?? ''}
<datalist id="hermes-models"> emptyLabel="(défaut du serveur)"
{hermesModels.map((m) => <option key={m.id} value={m.id} />)} withRefresh
</datalist> onChange={(c) => update({ hermes: { model: c.model, provider: c.provider } })}
</div> hint={hermesModels.length && !settings.hermes.model ? `Sans choix, Hermes utilise « ${hermesModels[0]?.id} » (son modèle configuré).` : undefined}
<div className="field"> />
<label>Clé de mémoire (X-Hermes-Session-Key)</label> <div className="field">
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} /> <label>Clé de mémoire (X-Hermes-Session-Key)</label>
</div> <input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
</div> </div>
<div className="grid-2"> <div className="grid-2">
<div className="field"> <div className="field">
@@ -230,19 +232,20 @@ export function SettingsDrawer({ onClose }: Props) {
</div> </div>
<div className="field"> <div className="field">
<label>Effort de raisonnement</label> <label>Effort de raisonnement</label>
<select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => update({ hermes: { reasoningEffort: e.target.value as typeof settings.hermes.reasoningEffort } })}> <select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => { if (isReasoningEffort(e.target.value)) update({ hermes: { reasoningEffort: e.target.value } }); }}>
<option value="">Défaut</option> {REASONING_EFFORTS.map((e) => <option key={e.value} value={e.value}>{e.label}</option>)}
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select> </select>
<span className="hint">Envoyé à chaque requête (runs, sessions, chat completions) dans model_options.reasoning_effort. Plus c’est haut, plus la réponse est réfléchie et lente.</span>
</div> </div>
</div> </div>
<div className="field"> <HermesModelPicker
<label>Modèle « mission » (tâches longues)</label> label="Modèle « mission » (tâches longues)"
<input className="input" list="hermes-models" value={settings.hermes.missionModel} placeholder="(même modèle)" onChange={(e) => update({ hermes: { missionModel: e.target.value } })} /> model={settings.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> provider={settings.hermes.missionProvider ?? ''}
</div> emptyLabel="(même modèle)"
onChange={(c) => update({ hermes: { missionModel: c.model, missionProvider: c.provider } })}
hint="Utilisé quand le mode Mission est activé dans la barre de commande."
/>
<div className="field"> <div className="field">
<label>Instructions EveFlow (superposées au prompt Hermes)</label> <label>Instructions EveFlow (superposées au prompt Hermes)</label>
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} /> <textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
+65
View File
@@ -0,0 +1,65 @@
/** Pure helpers for the Hermes model picker (GET /api/model/options) and reasoning effort levels. */
import type { HermesModelOptions, HermesProviderRow, HermesReasoningEffort } from '../services/hermes/types';
export interface HermesModelChoice {
provider: string;
providerName: string;
model: string;
/** Selected on the Hermes server right now. */
current: boolean;
}
/** Composite value used by the <select>: "provider::model" (provider may be empty). */
export function modelChoiceValue(provider: string, model: string): string {
return `${provider}::${model}`;
}
export function parseModelChoice(value: string): { provider: string; model: string } {
const at = value.indexOf('::');
if (at < 0) return { provider: '', model: value };
return { provider: value.slice(0, at), model: value.slice(at + 2) };
}
/** Flatten the provider rows into selectable models: authenticated providers first, server-current model first inside its row. */
export function flattenModelOptions(options: HermesModelOptions | null | undefined): HermesModelChoice[] {
const rows = (options?.providers ?? []).filter((r): r is HermesProviderRow => !!r && typeof r.slug === 'string' && Array.isArray(r.models));
const usable = rows.filter((r) => r.authenticated !== false && r.models.length > 0);
const current = (options?.model ?? '').trim();
const out: HermesModelChoice[] = [];
for (const row of usable) {
const models = [...new Set(row.models.map((m) => String(m).trim()).filter(Boolean))];
const list = row.is_current && current && models.includes(current) ? [current, ...models.filter((m) => m !== current)] : models;
for (const model of list) out.push({ provider: row.slug, providerName: row.name || row.slug, model, current: !!row.is_current && model === current });
}
return out;
}
/** Group choices by provider for <optgroup>, keeping the row order. */
export function groupModelChoices(choices: HermesModelChoice[]): Array<{ provider: string; providerName: string; models: HermesModelChoice[] }> {
const groups: Array<{ provider: string; providerName: string; models: HermesModelChoice[] }> = [];
for (const c of choices) {
let g = groups.find((x) => x.provider === c.provider);
if (!g) {
g = { provider: c.provider, providerName: c.providerName, models: [] };
groups.push(g);
}
g.models.push(c);
}
return groups;
}
export const REASONING_EFFORTS: Array<{ value: HermesReasoningEffort; label: string }> = [
{ value: '', label: 'Défaut du serveur (medium)' },
{ value: 'none', label: 'none : pas de réflexion' },
{ value: 'minimal', label: 'minimal' },
{ value: 'low', label: 'low : rapide' },
{ value: 'medium', label: 'medium : équilibré' },
{ value: 'high', label: 'high : réponses plus fouillées' },
{ value: 'xhigh', label: 'xhigh' },
{ value: 'max', label: 'max' },
{ value: 'ultra', label: 'ultra : le plus lent' }
];
export function isReasoningEffort(value: string): value is HermesReasoningEffort {
return REASONING_EFFORTS.some((e) => e.value === value);
}
+1 -1
View File
@@ -240,7 +240,7 @@ export async function sendMessage(text: string, images: string[] = [], source =
const runIdRef = { id: null as string | null }; const runIdRef = { id: null as string | null };
const mission = chat.missionMode && settings.hermes.missionModel.trim(); const mission = chat.missionMode && settings.hermes.missionModel.trim();
const client = hermes.client(mission ? settings.hermes.missionModel : undefined); const client = hermes.client(mission ? settings.hermes.missionModel : undefined, mission ? settings.hermes.missionProvider : undefined);
if (mission) Log.info('hermes', `mission mode → model ${settings.hermes.missionModel}`); if (mission) Log.info('hermes', `mission mode → model ${settings.hermes.missionModel}`);
const handle = client.send( const handle = client.send(
{ {
+27 -9
View File
@@ -15,6 +15,8 @@ import type {
HermesJob, HermesJob,
HermesJobDraft, HermesJobDraft,
HermesModel, HermesModel,
HermesModelOptions,
HermesProviderRow,
HermesRunInfo, HermesRunInfo,
HermesSession, HermesSession,
HermesSessionMessage, HermesSessionMessage,
@@ -203,6 +205,26 @@ export class HermesClient {
return extractArray<HermesModel>(payload, ['data', 'models']); return extractArray<HermesModel>(payload, ['data', 'models']);
} }
/**
* Provider/model inventory of the dashboard picker (/v1/models only advertises the "hermes-agent"
* alias). `refresh` re-probes every provider and busts the server cache.
*/
async modelOptions(refresh = false): Promise<HermesModelOptions> {
const payload = await this.request<unknown>(`/api/model/options${refresh ? '?refresh=1' : ''}`, { timeoutMs: refresh ? 60_000 : 20_000 });
if (!isRec(payload)) throw new Error('Réponse /api/model/options invalide');
const providers = extractArray<HermesProviderRow>(payload, ['providers']);
return { providers, model: typeof payload.model === 'string' ? payload.model : null, provider: typeof payload.provider === 'string' ? payload.provider : null };
}
/** Per-request model selection fields shared by runs, sessions and chat completions. */
private modelFields(): Rec {
const fields: Rec = {};
if (this.config.model) fields.model = this.config.model;
if (this.config.provider) fields.provider = this.config.provider;
if (this.config.reasoningEffort) fields.model_options = { reasoning_effort: this.config.reasoningEffort };
return fields;
}
async skills(): Promise<HermesSkill[]> { async skills(): Promise<HermesSkill[]> {
const payload = await this.request<unknown>('/v1/skills'); const payload = await this.request<unknown>('/v1/skills');
return extractArray<HermesSkill>(payload, ['skills', 'data', 'items']); return extractArray<HermesSkill>(payload, ['skills', 'data', 'items']);
@@ -290,11 +312,10 @@ 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 }): Promise<{ run_id: string; status: string }> {
const payload: Rec = { input: body.input }; const payload: Rec = { input: body.input, ...this.modelFields() };
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;
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 });
} }
@@ -387,8 +408,7 @@ export class HermesClient {
const run = await this.startRun({ const run = await this.startRun({
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
}); });
const runId = run.run_id; const runId = run.run_id;
if (isAborted()) { if (isAborted()) {
@@ -450,9 +470,8 @@ export class HermesClient {
} }
if (isAborted()) return ''; if (isAborted()) return '';
const realId = sessionId.slice(3); const realId = sessionId.slice(3);
const body: Rec = { input: options.text }; const body: Rec = { input: options.text, ...this.modelFields() };
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;
let streamed = ''; let streamed = '';
let finalText = ''; let finalText = '';
@@ -489,12 +508,11 @@ 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 = { ...this.modelFields(), model: this.config.model || 'hermes-agent', messages, stream: true };
if (toolsAllowed) { if (toolsAllowed) {
payload.tools = options.localToolDefinitions; payload.tools = options.localToolDefinitions;
payload.tool_choice = 'auto'; payload.tool_choice = 'auto';
} }
if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort };
const toolCalls = new Map<number, CompletionToolCallAccumulator>(); const toolCalls = new Map<number, CompletionToolCallAccumulator>();
let finishReason: string | null = null; let finishReason: string | null = null;
+27 -1
View File
@@ -1,21 +1,47 @@
export type HermesTransport = 'auto' | 'runs' | 'sessions' | 'completions'; export type HermesTransport = 'auto' | 'runs' | 'sessions' | 'completions';
/** Thinking depth accepted by Hermes (`model_options.reasoning_effort`); empty = server default (medium). */
export type HermesReasoningEffort = '' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra';
export interface HermesConfig { export interface HermesConfig {
url: string; url: string;
apiKey: string; apiKey: string;
model: string; model: string;
/** Hermes provider slug that serves `model` (from /api/model/options); empty = let Hermes resolve it. */
provider?: string;
/** Stable per-user key for long-term memory (X-Hermes-Session-Key). */ /** Stable per-user key for long-term memory (X-Hermes-Session-Key). */
sessionKey: string; sessionKey: string;
transport: HermesTransport; transport: HermesTransport;
reasoningEffort: '' | 'low' | 'medium' | 'high'; reasoningEffort: HermesReasoningEffort;
/** Extra instructions layered on top of the Hermes system prompt. */ /** Extra instructions layered on top of the Hermes system prompt. */
instructions: string; instructions: string;
/** Model used in "mission" mode (long tasks); empty = same as `model`. */ /** Model used in "mission" mode (long tasks); empty = same as `model`. */
missionModel: string; missionModel: string;
missionProvider?: string;
/** Expose EveFlow client tools in chat-completions mode. */ /** Expose EveFlow client tools in chat-completions mode. */
localTools: boolean; localTools: boolean;
} }
/** One provider row of GET /api/model/options (the dashboard / TUI picker inventory). */
export interface HermesProviderRow {
slug: string;
name: string;
models: string[];
is_current?: boolean;
is_user_defined?: boolean;
/** False for skeleton rows of providers without credentials. */
authenticated?: boolean;
warning?: string;
[key: string]: unknown;
}
export interface HermesModelOptions {
providers: HermesProviderRow[];
/** Model and provider currently selected on the server. */
model?: string | null;
provider?: string | null;
}
export interface HermesCapabilities { export interface HermesCapabilities {
object?: string; object?: string;
platform?: string; platform?: string;
+30 -4
View File
@@ -9,6 +9,7 @@ import type {
HermesJob, HermesJob,
HermesJobDraft, HermesJobDraft,
HermesModel, HermesModel,
HermesModelOptions,
HermesSession, HermesSession,
HermesSkill, HermesSkill,
HermesToolset HermesToolset
@@ -33,6 +34,9 @@ interface HermesStore {
capabilities: HermesCapabilities | null; capabilities: HermesCapabilities | null;
health: HermesHealth | null; health: HermesHealth | null;
models: HermesModel[]; models: HermesModel[];
/** Provider/model inventory from /api/model/options (null until fetched or when unsupported). */
modelOptions: HermesModelOptions | null;
modelOptionsError: string | null;
skills: HermesSkill[]; skills: HermesSkill[];
toolsets: HermesToolset[]; toolsets: HermesToolset[];
sessions: HermesSession[]; sessions: HermesSession[];
@@ -47,11 +51,13 @@ interface HermesStore {
webhook: WebhookStatus | null; webhook: WebhookStatus | null;
busy: boolean; busy: boolean;
client: (modelOverride?: string) => HermesClient; client: (modelOverride?: string, providerOverride?: string) => HermesClient;
connect: () => Promise<void>; connect: () => Promise<void>;
refreshJobs: () => Promise<void>; refreshJobs: () => Promise<void>;
refreshSessions: () => Promise<void>; refreshSessions: () => Promise<void>;
refreshCatalog: () => Promise<void>; refreshCatalog: () => Promise<void>;
/** Fetch the provider/model inventory; `refresh` asks the server to re-probe every provider. */
refreshModelOptions: (refresh?: boolean) => Promise<HermesModelOptions | null>;
refreshWebhook: () => Promise<void>; refreshWebhook: () => Promise<void>;
createJob: (draft: HermesJobDraft) => Promise<void>; createJob: (draft: HermesJobDraft) => Promise<void>;
updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>; updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>;
@@ -87,6 +93,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
capabilities: null, capabilities: null,
health: null, health: null,
models: [], models: [],
modelOptions: null,
modelOptionsError: null,
skills: [], skills: [],
toolsets: [], toolsets: [],
sessions: [], sessions: [],
@@ -99,11 +107,13 @@ export const useHermes = create<HermesStore>((set, get) => ({
webhook: null, webhook: null,
busy: false, busy: false,
client: (modelOverride) => { client: (modelOverride, providerOverride) => {
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 override = (modelOverride ?? '').trim();
return new HermesClient({ ...config, model }); const model = override || config.model.trim() || get().models[0]?.id || '';
const provider = override ? (providerOverride ?? '').trim() : (config.provider ?? '').trim();
return new HermesClient({ ...config, model, provider });
}, },
connect: () => { connect: () => {
@@ -168,6 +178,22 @@ export const useHermes = create<HermesStore>((set, get) => ({
client.toolsets().catch(() => [] as HermesToolset[]) client.toolsets().catch(() => [] as HermesToolset[])
]); ]);
set({ models, skills, toolsets }); set({ models, skills, toolsets });
void get().refreshModelOptions();
},
refreshModelOptions: async (refresh = false) => {
if (!useSettings.getState().settings.hermes.url.trim()) return null;
try {
const modelOptions = await get().client().modelOptions(refresh);
set({ modelOptions, modelOptionsError: null });
return modelOptions;
} catch (err) {
const message = (err as Error).message;
// Older Hermes versions have no /api/model/options: the picker falls back to a free text field.
set({ modelOptionsError: message });
Log.debug('hermes', `model options unavailable: ${message}`);
return null;
}
}, },
refreshSessions: async () => { refreshSessions: async () => {
+2
View File
@@ -87,7 +87,9 @@ export const DEFAULT_SETTINGS: Settings = {
model: '', model: '',
sessionKey: 'eveflow-desktop', sessionKey: 'eveflow-desktop',
transport: 'auto', transport: 'auto',
provider: '',
reasoningEffort: '', reasoningEffort: '',
missionProvider: '',
instructions: instructions:
"Tu es l'interface vocale EveFlow (style JARVIS). Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.", "Tu es l'interface vocale EveFlow (style JARVIS). Réponds en français, de façon concise et orale quand la question est simple; utilise le Markdown uniquement pour le contenu structuré (code, listes, tableaux). Les images doivent être des URL http(s) ou des fichiers du dossier partagé.",
localTools: true, localTools: true,
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { flattenModelOptions, groupModelChoices, isReasoningEffort, modelChoiceValue, parseModelChoice } from '../src/lib/hermesModels';
const payload = {
model: 'claude-fable-5-1',
provider: 'anthropic',
providers: [
{ slug: 'anthropic', name: 'Anthropic', is_current: true, models: ['claude-opus-5', 'claude-fable-5-1', 'claude-opus-5'], authenticated: true },
{ slug: 'openrouter', name: 'OpenRouter', models: ['deepseek/deepseek-v4', ' '], authenticated: true },
{ slug: 'nous', name: 'Nous Portal', models: [], authenticated: false, warning: 'paste NOUS_API_KEY to activate' },
{ slug: 'custom', name: 'Custom endpoint', models: ['qwen3'], authenticated: true }
]
};
describe('flattenModelOptions', () => {
it('lists authenticated providers, current model first in its row, without duplicates or blanks', () => {
const choices = flattenModelOptions(payload);
expect(choices.map((c) => `${c.provider}/${c.model}`)).toEqual(['anthropic/claude-fable-5-1', 'anthropic/claude-opus-5', 'openrouter/deepseek/deepseek-v4', 'custom/qwen3']);
expect(choices[0].current).toBe(true);
expect(choices[1].current).toBe(false);
});
it('tolerates an empty or malformed payload', () => {
expect(flattenModelOptions(null)).toEqual([]);
expect(flattenModelOptions({ providers: [{ slug: 'x', name: 'x', models: 'nope' } as unknown as { slug: string; name: string; models: string[] }] })).toEqual([]);
});
it('groups by provider in row order', () => {
const groups = groupModelChoices(flattenModelOptions(payload));
expect(groups.map((g) => g.providerName)).toEqual(['Anthropic', 'OpenRouter', 'Custom endpoint']);
expect(groups[0].models).toHaveLength(2);
});
});
describe('model choice value', () => {
it('round-trips provider and model, including model ids containing slashes and colons', () => {
const v = modelChoiceValue('openrouter', 'deepseek/deepseek-v4:free');
expect(parseModelChoice(v)).toEqual({ provider: 'openrouter', model: 'deepseek/deepseek-v4:free' });
expect(parseModelChoice('hermes-agent')).toEqual({ provider: '', model: 'hermes-agent' });
expect(parseModelChoice('::')).toEqual({ provider: '', model: '' });
});
it('validates reasoning effort levels', () => {
expect(isReasoningEffort('xhigh')).toBe(true);
expect(isReasoningEffort('')).toBe(true);
expect(isReasoningEffort('turbo')).toBe(false);
});
});