mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-24 15:48:13 +02:00
Compare commits
1
Commits
v2.5.1
..
d5f19a9b3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f19a9b3c |
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
|
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
|
||||||
|
|
||||||
[](https://github.com/R0m1k3/EveFlow/actions)
|
[](https://github.com/R0m1k3/EveFlow/actions)
|
||||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||||
[](LICENSE)
|
[](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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.1",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.1",
|
"version": "2.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/orbitron": "^5.3.0",
|
"@fontsource/orbitron": "^5.3.0",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -15,7 +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 { ModelSelect } from './ModelSelect';
|
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';
|
||||||
|
|
||||||
@@ -79,9 +80,6 @@ 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 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);
|
||||||
@@ -95,13 +93,6 @@ 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;
|
||||||
@@ -216,20 +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
|
||||||
<ModelSelect label="Modèle IA" models={hermesModels} value={settings.hermes.model} defaultLabel="Défaut du serveur" onChange={(model) => update({ hermes: { model } })} />
|
label="Modèle"
|
||||||
<div className="field">
|
model={settings.hermes.model}
|
||||||
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
provider={settings.hermes.provider ?? ''}
|
||||||
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
emptyLabel="(défaut du serveur)"
|
||||||
</div>
|
withRefresh
|
||||||
</div>
|
onChange={(c) => update({ hermes: { model: c.model, provider: c.provider } })}
|
||||||
|
hint={hermesModels.length && !settings.hermes.model ? `Sans choix, Hermes utilise « ${hermesModels[0]?.id} » (son modèle configuré).` : undefined}
|
||||||
|
/>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<button className="btn small" disabled={modelsLoading} onClick={() => void refreshHermesModels()}>
|
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
||||||
{modelsLoading ? <Loader2 size={13} className="spin" /> : <RotateCcw size={13} />}
|
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
||||||
{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>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -243,18 +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
|
||||||
<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 } })} />
|
label="Modèle « mission » (tâches longues)"
|
||||||
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
|
model={settings.hermes.missionModel}
|
||||||
</div>
|
provider={settings.hermes.missionProvider ?? ''}
|
||||||
|
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 } })} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
HermesJob,
|
HermesJob,
|
||||||
HermesJobDraft,
|
HermesJobDraft,
|
||||||
HermesModel,
|
HermesModel,
|
||||||
|
HermesModelOptions,
|
||||||
|
HermesProviderRow,
|
||||||
HermesRunInfo,
|
HermesRunInfo,
|
||||||
HermesSession,
|
HermesSession,
|
||||||
HermesSessionMessage,
|
HermesSessionMessage,
|
||||||
@@ -200,21 +202,27 @@ 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');
|
||||||
const readEntries = (value: unknown): unknown[] => {
|
return extractArray<HermesModel>(payload, ['data', 'models']);
|
||||||
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);
|
* 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.
|
||||||
throw new Error('Le serveur a renvoyé une liste de modèles invalide.');
|
*/
|
||||||
};
|
async modelOptions(refresh = false): Promise<HermesModelOptions> {
|
||||||
const entries = readEntries(payload);
|
const payload = await this.request<unknown>(`/api/model/options${refresh ? '?refresh=1' : ''}`, { timeoutMs: refresh ? 60_000 : 20_000 });
|
||||||
const models = new Map<string, HermesModel>();
|
if (!isRec(payload)) throw new Error('Réponse /api/model/options invalide');
|
||||||
for (const entry of entries) {
|
const providers = extractArray<HermesProviderRow>(payload, ['providers']);
|
||||||
const id = typeof entry === 'string' ? entry.trim() : isRec(entry) && typeof entry.id === 'string' ? entry.id.trim() : '';
|
return { providers, model: typeof payload.model === 'string' ? payload.model : null, provider: typeof payload.provider === 'string' ? payload.provider : null };
|
||||||
if (id && !models.has(id)) models.set(id, { ...(isRec(entry) ? entry : {}), id });
|
}
|
||||||
}
|
|
||||||
return [...models.values()];
|
/** 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[]> {
|
||||||
@@ -304,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,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()) {
|
||||||
@@ -464,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 = '';
|
||||||
@@ -503,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+33
-31
@@ -9,6 +9,7 @@ import type {
|
|||||||
HermesJob,
|
HermesJob,
|
||||||
HermesJobDraft,
|
HermesJobDraft,
|
||||||
HermesModel,
|
HermesModel,
|
||||||
|
HermesModelOptions,
|
||||||
HermesSession,
|
HermesSession,
|
||||||
HermesSkill,
|
HermesSkill,
|
||||||
HermesToolset
|
HermesToolset
|
||||||
@@ -33,9 +34,9 @@ interface HermesStore {
|
|||||||
capabilities: HermesCapabilities | null;
|
capabilities: HermesCapabilities | null;
|
||||||
health: HermesHealth | null;
|
health: HermesHealth | null;
|
||||||
models: HermesModel[];
|
models: HermesModel[];
|
||||||
modelsLoading: boolean;
|
/** Provider/model inventory from /api/model/options (null until fetched or when unsupported). */
|
||||||
modelsError: string | null;
|
modelOptions: HermesModelOptions | null;
|
||||||
refreshModels: () => Promise<void>;
|
modelOptionsError: string | null;
|
||||||
skills: HermesSkill[];
|
skills: HermesSkill[];
|
||||||
toolsets: HermesToolset[];
|
toolsets: HermesToolset[];
|
||||||
sessions: HermesSession[];
|
sessions: HermesSession[];
|
||||||
@@ -50,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>;
|
||||||
@@ -66,7 +69,6 @@ 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 {
|
||||||
@@ -91,8 +93,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
capabilities: null,
|
capabilities: null,
|
||||||
health: null,
|
health: null,
|
||||||
models: [],
|
models: [],
|
||||||
modelsLoading: false,
|
modelOptions: null,
|
||||||
modelsError: null,
|
modelOptionsError: null,
|
||||||
skills: [],
|
skills: [],
|
||||||
toolsets: [],
|
toolsets: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
@@ -105,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: () => {
|
||||||
@@ -166,32 +170,30 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
return connectInflight;
|
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 () => {
|
refreshCatalog: async () => {
|
||||||
const client = get().client();
|
const client = get().client();
|
||||||
const [, skills, toolsets] = await Promise.all([
|
const [models, skills, toolsets] = await Promise.all([
|
||||||
get().refreshModels(),
|
client.models().catch(() => [] as HermesModel[]),
|
||||||
client.skills().catch(() => [] as HermesSkill[]),
|
client.skills().catch(() => [] as HermesSkill[]),
|
||||||
client.toolsets().catch(() => [] as HermesToolset[])
|
client.toolsets().catch(() => [] as HermesToolset[])
|
||||||
]);
|
]);
|
||||||
set({ 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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+37
-66
@@ -1,74 +1,45 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { HermesClient } from '../src/services/hermes/client';
|
import { flattenModelOptions, groupModelChoices, isReasoningEffort, modelChoiceValue, parseModelChoice } from '../src/lib/hermesModels';
|
||||||
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 payload = {
|
||||||
const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test/v1/', apiKey: ' test-key ' };
|
model: 'claude-fable-5-1',
|
||||||
function respond(payload: unknown, status = 200) {
|
provider: 'anthropic',
|
||||||
vi.mocked(httpFetch).mockResolvedValue({ ok: status === 200, status, statusText: '', headers: {}, text: JSON.stringify(payload) });
|
providers: [
|
||||||
}
|
{ slug: 'anthropic', name: 'Anthropic', is_current: true, models: ['claude-opus-5', 'claude-fable-5-1', 'claude-opus-5'], authenticated: true },
|
||||||
afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); });
|
{ 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('Hermes models', () => {
|
describe('flattenModelOptions', () => {
|
||||||
it('uses authenticated discovery and preserves provider labels, order and unique valid IDs', async () => {
|
it('lists authenticated providers, current model first in its row, without duplicates or blanks', () => {
|
||||||
respond({ data: [{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'b' }, {}, ' c '] });
|
const choices = flattenModelOptions(payload);
|
||||||
expect(await new HermesClient(config).models()).toEqual([{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'c' }]);
|
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(httpFetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.test/v1/models', headers: expect.objectContaining({ Authorization: 'Bearer test-key' }) }));
|
expect(choices[0].current).toBe(true);
|
||||||
|
expect(choices[1].current).toBe(false);
|
||||||
});
|
});
|
||||||
it('accepts nested catalogs and empty lists, rejects malformed responses', async () => {
|
it('tolerates an empty or malformed payload', () => {
|
||||||
respond({ data: { models: ['a'] } });
|
expect(flattenModelOptions(null)).toEqual([]);
|
||||||
expect(await new HermesClient(config).models()).toEqual([{ id: 'a' }]);
|
expect(flattenModelOptions({ providers: [{ slug: 'x', name: 'x', models: 'nope' } as unknown as { slug: string; name: string; models: string[] }] })).toEqual([]);
|
||||||
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 () => {
|
it('groups by provider in row order', () => {
|
||||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, model: 'chosen' } } });
|
const groups = groupModelChoices(flattenModelOptions(payload));
|
||||||
respond({}, 401);
|
expect(groups.map((g) => g.providerName)).toEqual(['Anthropic', 'OpenRouter', 'Custom endpoint']);
|
||||||
await useHermes.getState().refreshModels();
|
expect(groups[0].models).toHaveLength(2);
|
||||||
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;
|
describe('model choice value', () => {
|
||||||
vi.spyOn(HermesClient.prototype, 'models').mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue([{ id: 'new' }]);
|
it('round-trips provider and model, including model ids containing slashes and colons', () => {
|
||||||
const old = useHermes.getState().refreshModels();
|
const v = modelChoiceValue('openrouter', 'deepseek/deepseek-v4:free');
|
||||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, url: 'https://new.test' } } });
|
expect(parseModelChoice(v)).toEqual({ provider: 'openrouter', model: 'deepseek/deepseek-v4:free' });
|
||||||
await useHermes.getState().refreshModels();
|
expect(parseModelChoice('hermes-agent')).toEqual({ provider: '', model: 'hermes-agent' });
|
||||||
finish([{ id: 'old' }]);
|
expect(parseModelChoice('::')).toEqual({ provider: '', model: '' });
|
||||||
await old;
|
|
||||||
expect(useHermes.getState().models).toEqual([{ id: 'new' }]);
|
|
||||||
});
|
});
|
||||||
it('sends the selected principal model and the mission override on new runs', async () => {
|
it('validates reasoning effort levels', () => {
|
||||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, model: 'principal', missionModel: 'mission' } } });
|
expect(isReasoningEffort('xhigh')).toBe(true);
|
||||||
const start = vi.spyOn(HermesClient.prototype, 'startRun').mockRejectedValue(new Error('stop before network'));
|
expect(isReasoningEffort('')).toBe(true);
|
||||||
for (const [override, expected] of [[undefined, 'principal'], ['mission', 'mission']] as const) {
|
expect(isReasoningEffort('turbo')).toBe(false);
|
||||||
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