mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-23 23:28:14 +02:00
Compare commits
1
Commits
main
..
d5f19a9b3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f19a9b3c |
No files matched your search
@@ -67,7 +67,7 @@ jobs:
|
||||
out/latest.yml
|
||||
if-no-files-found: error
|
||||
- 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' || github.ref == 'refs/heads/main') && 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' && steps.ver.outputs.released == 'false')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.ver.outputs.version }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# EveFlow 2 — Interface vocale JARVIS pour Hermes Agent
|
||||
|
||||
[](https://github.com/R0m1k3/EveFlow/actions)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases)
|
||||
[](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.
|
||||
@@ -29,7 +29,6 @@ La version 2 est une réécriture complète : plus de robot 3D, un pipeline voca
|
||||
* **Serveur MCP intégré** : Hermes se connecte à `http://<pc>:7842/mcp` et obtient les outils du PC (capture d'écran renvoyée en image, verrouillage, applications, URL, touches média, presse-papiers, recherche de fichiers, voix, notifications, état du HUD, affichage dans le fil). Même port et même secret que le webhook ; en mode chat completions, les mêmes outils sont proposés directement au modèle.
|
||||
* **Heures calmes et priorités** : plage horaire pendant laquelle les messages poussés s'affichent sans être lus ni faire clignoter le noyau (badge « non lus » à la place), thème nuit automatique, mots prioritaires lus quand même, résumé vocal des rapports longs (les premières phrases seulement).
|
||||
* **Mode mission** : un bouton dans la barre de commande bascule sur un second modèle Hermes (plus puissant) pour les tâches longues ; le modèle rapide reste utilisé pour la conversation courante.
|
||||
* **Résumé vocal des réponses longues** : seules les premières phrases (réglable) et la question finale sont lues, le reste s'affiche dans le fil ; la conversation Hermes est continue d'un message à l'autre (la session créée par Hermes est reprise à chaque run).
|
||||
* **Widget compact « glanceable »** : état (veille, écoute, réflexion, parle), dernière phrase de l'assistant, badge de non-lus, indicateurs heures calmes et mission.
|
||||
* **Voix Microsoft Edge** (moteur par défaut) : les voix neuronales de la lecture à voix haute d'Edge, gratuites, sans clé ni installation : Henri, Denise, Rémy, Vivienne, Éloise (fr-FR) et les voix fr-CA, fr-CH, fr-BE, plus de 300 voix dans 74 langues. Le rendu le plus naturel disponible ; nécessite une connexion.
|
||||
* **Voix JARVIS** : deux préréglages en un clic (Paramètres → Voix) : en ligne (Edge Henri) ou hors ligne (Supertonic 3, voix masculine grave, téléchargé automatiquement), timbre « JARVIS » (légèrement plus grave et posé, chaleur, présence, courte réverbération d'intercom), débit calme.
|
||||
@@ -47,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`).
|
||||
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).
|
||||
* **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`.
|
||||
* **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.
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "2.5.4",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "eveflow",
|
||||
"version": "2.5.4",
|
||||
"version": "2.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/orbitron": "^5.3.0",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "2.5.4",
|
||||
"releaseVersion": "2.5.4",
|
||||
"version": "2.5.1",
|
||||
"releaseVersion": "2.5.1",
|
||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||
"main": "dist-electron/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -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} 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,7 +15,8 @@ import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settin
|
||||
import { useVoice } from '../../state/voice';
|
||||
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
||||
import { ModelsSection } from './ModelsSection';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { HermesModelPicker } from './HermesModelPicker';
|
||||
import { REASONING_EFFORTS, isReasoningEffort } from '../../lib/hermesModels';
|
||||
|
||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
||||
|
||||
@@ -79,10 +80,6 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
const update = useSettings((s) => s.update);
|
||||
const reset = useSettings((s) => s.reset);
|
||||
const hermesModels = useHermes((s) => s.models);
|
||||
const modelsLoading = useHermes((s) => s.modelsLoading);
|
||||
const modelsError = useHermes((s) => s.modelsError);
|
||||
const modelsNotice = useHermes((s) => s.modelsNotice);
|
||||
const refreshHermesModels = useHermes((s) => s.refreshModels);
|
||||
const hermesWebhook = useHermes((s) => s.webhook);
|
||||
const hermesConnect = useHermes((s) => s.connect);
|
||||
const micDevices = useVoice((s) => s.micDevices);
|
||||
@@ -96,13 +93,6 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
|
||||
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== 'hermes') return;
|
||||
useHermes.setState({ models: [], modelsError: null });
|
||||
const timer = setTimeout(() => void refreshHermesModels(), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [section, settings.hermes.url, settings.hermes.apiKey, settings.hermes.sessionKey, refreshHermesModels]);
|
||||
|
||||
const speechProvider = settings.speech.provider;
|
||||
useEffect(() => {
|
||||
if (speechProvider !== 'edge' || edgeVoices.length) return;
|
||||
@@ -217,20 +207,18 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
<label>Clé API (API_SERVER_KEY)</label>
|
||||
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<ModelSelect label="Modèle IA" models={hermesModels} value={settings.hermes.model} defaultLabel="Défaut du serveur" onChange={(model) => update({ hermes: { model } })} />
|
||||
<div className="field">
|
||||
<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 } })} />
|
||||
</div>
|
||||
</div>
|
||||
<HermesModelPicker
|
||||
label="Modèle"
|
||||
model={settings.hermes.model}
|
||||
provider={settings.hermes.provider ?? ''}
|
||||
emptyLabel="(défaut du serveur)"
|
||||
withRefresh
|
||||
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">
|
||||
<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>
|
||||
<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 } })} />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
@@ -244,18 +232,20 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</div>
|
||||
<div className="field">
|
||||
<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 } })}>
|
||||
<option value="">Défaut</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<select className="select" value={settings.hermes.reasoningEffort} onChange={(e) => { if (isReasoningEffort(e.target.value)) update({ hermes: { reasoningEffort: e.target.value } }); }}>
|
||||
{REASONING_EFFORTS.map((e) => <option key={e.value} value={e.value}>{e.label}</option>)}
|
||||
</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 className="field">
|
||||
<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 } })} />
|
||||
<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>
|
||||
<HermesModelPicker
|
||||
label="Modèle « mission » (tâches longues)"
|
||||
model={settings.hermes.missionModel}
|
||||
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">
|
||||
<label>Instructions EveFlow (superposées au prompt Hermes)</label>
|
||||
<textarea className="textarea" value={settings.hermes.instructions} onChange={(e) => update({ hermes: { instructions: e.target.value } })} />
|
||||
@@ -557,13 +547,6 @@ export function SettingsDrawer({ onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<Toggle on={settings.speech.autoSpeak} onChange={(v) => update({ speech: { autoSpeak: v } })} label="Lire les réponses en streaming" hint="Chaque phrase est prononcée dès qu’elle est complète." />
|
||||
<Toggle on={settings.speech.summarizeReplies} onChange={(v) => update({ speech: { summarizeReplies: v } })} label="Résumé vocal des réponses longues" hint="Seules les premières phrases et la question finale sont lues ; la réponse complète reste dans le fil." />
|
||||
{settings.speech.summarizeReplies && (
|
||||
<div className="field">
|
||||
<label>Phrases lues : {settings.speech.replySentences}</label>
|
||||
<input className="range" type="range" min={1} max={10} step={1} value={settings.speech.replySentences} onChange={(e) => update({ speech: { replySentences: Number(e.target.value) } })} />
|
||||
</div>
|
||||
)}
|
||||
<Toggle on={settings.speech.speakIncoming} onChange={(v) => update({ speech: { speakIncoming: v } })} label="Lire les messages entrants (webhook, crons)" />
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<button className="btn small" onClick={testTts} disabled={settings.speech.provider === 'off'}><Volume2 size={13} /> Tester la voix</button>
|
||||
@@ -645,9 +628,8 @@ mcp_servers:
|
||||
<div className="card">
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label htmlFor="assistant-name">Nom de l’assistant</label>
|
||||
<input id="assistant-name" className="input" value={settings.assistantName} maxLength={60} placeholder="Jarvis, Nova, Alfred…" onChange={(e) => update({ assistantName: e.target.value })} onBlur={() => update({ assistantName: settings.assistantName.trim() || DEFAULT_SETTINGS.assistantName })} />
|
||||
<span className="hint">Choisissez le nom que vous voulez. Il est sauvegardé automatiquement et utilisé par l’assistant dès votre prochain message, y compris dans la conversation en cours.</span>
|
||||
<label>Nom de l’assistant</label>
|
||||
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Votre nom</label>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -129,33 +129,6 @@ export function chunkForSpeech(text: string, maxLength = 220): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Last speakable chunk of a reply when it is a question (« Tu veux que je… ? »), else null. */
|
||||
export function closingQuestion(text: string): string | null {
|
||||
const parts = speakableChunks(text);
|
||||
const last = parts[parts.length - 1];
|
||||
return last && /\?\s*$/.test(last) ? last : null;
|
||||
}
|
||||
|
||||
/** Chunks of the spoken form of a text (markdown and code already stripped), letters only. */
|
||||
function speakableChunks(text: string): string[] {
|
||||
return chunkForSpeech(cleanForSpeech(text)).filter((part) => /[\p{L}\p{N}]/u.test(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spoken digest of a long reply: the first `maxSentences` speakable chunks plus the closing
|
||||
* question when there is one, so a long answer stays short to listen to but the conversation
|
||||
* can go on. `truncated` tells the caller that the screen holds more than what is spoken.
|
||||
*/
|
||||
export function spokenDigest(text: string, maxSentences: number): { text: string; truncated: boolean } {
|
||||
const parts = speakableChunks(text);
|
||||
const limit = Math.max(1, Math.floor(maxSentences));
|
||||
if (parts.length <= limit) return { text: parts.join(' '), truncated: false };
|
||||
const head = parts.slice(0, limit);
|
||||
const question = closingQuestion(text);
|
||||
if (question && !head.includes(question)) head.push(question);
|
||||
return { text: head.join(' '), truncated: true };
|
||||
}
|
||||
|
||||
export function previewText(value: string, max = 96): string {
|
||||
const flat = value.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
|
||||
@@ -240,7 +240,7 @@ export async function sendMessage(text: string, images: string[] = [], source =
|
||||
const runIdRef = { id: null as string | null };
|
||||
|
||||
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}`);
|
||||
const handle = client.send(
|
||||
{
|
||||
@@ -269,8 +269,8 @@ export async function sendMessage(text: string, images: string[] = [], source =
|
||||
if (handle.aborted) {
|
||||
speech.discardStream();
|
||||
} else if (settings.speech.autoSpeak) {
|
||||
if (!streamedText && finalText) speech.sayReply(finalText);
|
||||
else speech.endStream(finalText);
|
||||
if (!streamedText && finalText) speech.say(finalText);
|
||||
else speech.endStream();
|
||||
} else {
|
||||
speech.discardStream();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
HermesJob,
|
||||
HermesJobDraft,
|
||||
HermesModel,
|
||||
HermesModelOptions,
|
||||
HermesProviderRow,
|
||||
HermesRunInfo,
|
||||
HermesSession,
|
||||
HermesSessionMessage,
|
||||
@@ -31,13 +33,6 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i
|
||||
|
||||
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
|
||||
* point at the Hermes API. Returns a human explanation, or null when the body is not HTML.
|
||||
@@ -207,44 +202,27 @@ export class HermesClient {
|
||||
|
||||
async models(): Promise<HermesModel[]> {
|
||||
const payload = await this.request<unknown>('/v1/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()];
|
||||
return extractArray<HermesModel>(payload, ['data', 'models']);
|
||||
}
|
||||
|
||||
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 };
|
||||
/**
|
||||
* 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[]> {
|
||||
@@ -334,12 +312,10 @@ export class HermesClient {
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 };
|
||||
async startRun(body: { input: string; session_id?: string; instructions?: string }): Promise<{ run_id: string; status: string }> {
|
||||
const payload: Rec = { input: body.input, ...this.modelFields() };
|
||||
if (body.session_id) payload.session_id = body.session_id;
|
||||
if (body.instructions) payload.instructions = body.instructions;
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -432,8 +408,7 @@ export class HermesClient {
|
||||
const run = await this.startRun({
|
||||
input: options.text,
|
||||
session_id: plainSession(options.sessionId) || undefined,
|
||||
instructions: this.config.instructions || undefined,
|
||||
...modelSelection(this.config.model)
|
||||
instructions: this.config.instructions || undefined
|
||||
});
|
||||
const runId = run.run_id;
|
||||
if (isAborted()) {
|
||||
@@ -447,7 +422,6 @@ export class HermesClient {
|
||||
let finalText = '';
|
||||
let streamedText = '';
|
||||
let completed = false;
|
||||
let sessionSeen = false;
|
||||
let failure: string | null = null;
|
||||
|
||||
const handle = await this.streamRunEvents(runId, (event) => {
|
||||
@@ -456,7 +430,6 @@ export class HermesClient {
|
||||
completed = true;
|
||||
finalText = event.text ?? '';
|
||||
}
|
||||
if (event.kind === 'session') sessionSeen = true;
|
||||
if (event.kind === 'error') failure = event.message;
|
||||
onEvent(event);
|
||||
});
|
||||
@@ -467,9 +440,6 @@ export class HermesClient {
|
||||
});
|
||||
|
||||
await handle.done;
|
||||
// The run events never carry the session id; only the run status does. Without adopting it,
|
||||
// every message would start a fresh Hermes session and the conversation would lose its context.
|
||||
if (!sessionSeen) await this.adoptRunSession(runId, onEvent);
|
||||
if (stopped || isAborted()) return streamedText;
|
||||
if (failure) throw new Error(failure);
|
||||
|
||||
@@ -490,16 +460,6 @@ export class HermesClient {
|
||||
return finalText || streamedText;
|
||||
}
|
||||
|
||||
/** Reads the session Hermes actually attached to a run and reports it, so the next run continues it. */
|
||||
private async adoptRunSession(runId: string, onEvent: SendOptions['onEvent']): Promise<void> {
|
||||
try {
|
||||
const info = await this.getRun(runId);
|
||||
if (info.session_id) onEvent({ kind: 'session', sessionId: String(info.session_id) });
|
||||
} catch (err) {
|
||||
Log.warn('hermes', `run ${runId}: session id unavailable (${(err as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void, isAborted: () => boolean): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
let sessionId = options.sessionId;
|
||||
@@ -510,9 +470,8 @@ export class HermesClient {
|
||||
}
|
||||
if (isAborted()) return '';
|
||||
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;
|
||||
Object.assign(body, modelSelection(this.config.model));
|
||||
|
||||
let streamed = '';
|
||||
let finalText = '';
|
||||
@@ -549,12 +508,11 @@ export class HermesClient {
|
||||
let fullText = '';
|
||||
let toolsAllowed = useTools;
|
||||
for (let iteration = 0; iteration < 6 && !aborted(); iteration++) {
|
||||
const payload: Rec = { model: 'hermes-agent', ...modelSelection(this.config.model), messages, stream: true };
|
||||
const payload: Rec = { ...this.modelFields(), model: this.config.model || 'hermes-agent', messages, stream: true };
|
||||
if (toolsAllowed) {
|
||||
payload.tools = options.localToolDefinitions;
|
||||
payload.tool_choice = 'auto';
|
||||
}
|
||||
if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort };
|
||||
|
||||
const toolCalls = new Map<number, CompletionToolCallAccumulator>();
|
||||
let finishReason: string | null = null;
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
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 {
|
||||
url: string;
|
||||
apiKey: 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). */
|
||||
sessionKey: string;
|
||||
transport: HermesTransport;
|
||||
reasoningEffort: '' | 'low' | 'medium' | 'high';
|
||||
reasoningEffort: HermesReasoningEffort;
|
||||
/** Extra instructions layered on top of the Hermes system prompt. */
|
||||
instructions: string;
|
||||
/** Model used in "mission" mode (long tasks); empty = same as `model`. */
|
||||
missionModel: string;
|
||||
missionProvider?: string;
|
||||
/** Expose EveFlow client tools in chat-completions mode. */
|
||||
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 {
|
||||
object?: string;
|
||||
platform?: string;
|
||||
@@ -35,8 +61,6 @@ export interface HermesHealth {
|
||||
|
||||
export interface HermesModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
available?: boolean;
|
||||
owned_by?: string;
|
||||
provider?: string;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
/**
|
||||
* Singleton facade over the TTS engine bound to the settings store, exposing speaking state
|
||||
* to the voice and chat stores. Long replies are spoken as a digest (first sentences plus the
|
||||
* closing question) so listening stays short while the full text remains on screen.
|
||||
* to the voice and chat stores.
|
||||
*/
|
||||
import { useChat } from '../../state/chat';
|
||||
import { useSettings } from '../../state/settings';
|
||||
import { useVoice } from '../../state/voice';
|
||||
import { chunkForSpeech, closingQuestion, extractSentences, spokenDigest } from '../../lib/text';
|
||||
import { TtsEngine } from './tts';
|
||||
|
||||
export const DIGEST_NOTICE = 'Le détail complet est affiché à l’écran.';
|
||||
|
||||
class SpeechFacade {
|
||||
private engine: TtsEngine | null = null;
|
||||
private streaming = false;
|
||||
private streamEnabled = true;
|
||||
private streamBuffer = '';
|
||||
private spokenCount = 0;
|
||||
private truncated = false;
|
||||
|
||||
private get tts(): TtsEngine {
|
||||
if (!this.engine) {
|
||||
@@ -42,73 +35,31 @@ class SpeechFacade {
|
||||
return this.engine?.isActive ?? false;
|
||||
}
|
||||
|
||||
/** Number of spoken chunks allowed for one reply; unbounded when the digest is off. */
|
||||
private replyLimit(): number {
|
||||
const { summarizeReplies, replySentences } = useSettings.getState().settings.speech;
|
||||
return summarizeReplies ? Math.max(1, Math.floor(replySentences || 1)) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
say(text: string, options: { interrupt?: boolean } = {}): void {
|
||||
if (useSettings.getState().settings.speech.provider === 'off') return;
|
||||
// While an answer streams, spoken notices are inserted without discarding the rest.
|
||||
this.tts.speak(text, { interrupt: options.interrupt ?? !this.streaming });
|
||||
}
|
||||
|
||||
/** Speak a finished (non-streamed) assistant reply, digested when it is long. */
|
||||
sayReply(text: string): void {
|
||||
const limit = this.replyLimit();
|
||||
if (!Number.isFinite(limit)) return this.say(text);
|
||||
const digest = spokenDigest(text, limit);
|
||||
this.say(digest.truncated ? `${digest.text} ${DIGEST_NOTICE}` : digest.text);
|
||||
}
|
||||
|
||||
pushStream(delta: string): void {
|
||||
if (!useSettings.getState().settings.speech.autoSpeak) return;
|
||||
this.streaming = true;
|
||||
if (!this.streamEnabled) return;
|
||||
this.streamBuffer += delta;
|
||||
const { sentences, rest } = extractSentences(this.streamBuffer);
|
||||
this.streamBuffer = rest;
|
||||
for (const sentence of sentences) this.enqueueWithinLimit(sentence);
|
||||
if (this.streamEnabled) this.tts.pushStream(delta);
|
||||
}
|
||||
|
||||
private enqueueWithinLimit(text: string): void {
|
||||
if (this.truncated) return;
|
||||
if (this.spokenCount >= this.replyLimit()) {
|
||||
this.truncated = true;
|
||||
return;
|
||||
}
|
||||
if (this.tts.enqueue(text)) this.spokenCount++;
|
||||
}
|
||||
|
||||
/** Flush a streamed reply; `fullText` lets a digested reply end with its closing question. */
|
||||
endStream(fullText = ''): void {
|
||||
if (!this.streaming) return;
|
||||
const rest = this.streamBuffer.trim();
|
||||
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueueWithinLimit(chunk);
|
||||
if (this.truncated) {
|
||||
const question = closingQuestion(fullText);
|
||||
if (question) this.tts.enqueue(question);
|
||||
this.tts.enqueue(DIGEST_NOTICE);
|
||||
}
|
||||
this.resetStream();
|
||||
endStream(): void {
|
||||
if (this.streaming) this.tts.endStream();
|
||||
this.streaming = false;
|
||||
}
|
||||
|
||||
discardStream(): void {
|
||||
this.resetStream();
|
||||
this.streaming = false;
|
||||
this.tts.stop();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.resetStream();
|
||||
this.engine?.stop();
|
||||
}
|
||||
|
||||
private resetStream(): void {
|
||||
this.streaming = false;
|
||||
this.streamBuffer = '';
|
||||
this.spokenCount = 0;
|
||||
this.truncated = false;
|
||||
this.engine?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,14 +115,12 @@ export class TtsEngine {
|
||||
if (rest) for (const chunk of chunkForSpeech(rest)) this.enqueue(chunk);
|
||||
}
|
||||
|
||||
/** Queue one chunk; false when nothing speakable remains once markdown and code are stripped. */
|
||||
enqueue(text: string): boolean {
|
||||
enqueue(text: string): void {
|
||||
const item = this.makeItem(text);
|
||||
if (!item) return false;
|
||||
if (!item) return;
|
||||
this.queue.push(item);
|
||||
this.fillPrefetch();
|
||||
void this.drain();
|
||||
return true;
|
||||
}
|
||||
|
||||
private makeItem(text: string): QueueItem | null {
|
||||
|
||||
+35
-37
@@ -9,6 +9,7 @@ import type {
|
||||
HermesJob,
|
||||
HermesJobDraft,
|
||||
HermesModel,
|
||||
HermesModelOptions,
|
||||
HermesSession,
|
||||
HermesSkill,
|
||||
HermesToolset
|
||||
@@ -33,10 +34,9 @@ interface HermesStore {
|
||||
capabilities: HermesCapabilities | null;
|
||||
health: HermesHealth | null;
|
||||
models: HermesModel[];
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
modelsNotice: string | null;
|
||||
refreshModels: (refresh?: boolean) => Promise<void>;
|
||||
/** Provider/model inventory from /api/model/options (null until fetched or when unsupported). */
|
||||
modelOptions: HermesModelOptions | null;
|
||||
modelOptionsError: string | null;
|
||||
skills: HermesSkill[];
|
||||
toolsets: HermesToolset[];
|
||||
sessions: HermesSession[];
|
||||
@@ -51,11 +51,13 @@ interface HermesStore {
|
||||
webhook: WebhookStatus | null;
|
||||
busy: boolean;
|
||||
|
||||
client: (modelOverride?: string) => HermesClient;
|
||||
client: (modelOverride?: string, providerOverride?: string) => HermesClient;
|
||||
connect: () => Promise<void>;
|
||||
refreshJobs: () => Promise<void>;
|
||||
refreshSessions: () => 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>;
|
||||
createJob: (draft: HermesJobDraft) => Promise<void>;
|
||||
updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>;
|
||||
@@ -67,7 +69,6 @@ interface HermesStore {
|
||||
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
||||
let connectInflight: Promise<void> | null = null;
|
||||
let lastCacheSnapshot = '';
|
||||
let modelsRequest = 0;
|
||||
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
||||
|
||||
function runFromJob(job: HermesJob): JobRun | null {
|
||||
@@ -92,9 +93,8 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
capabilities: null,
|
||||
health: null,
|
||||
models: [],
|
||||
modelsLoading: false,
|
||||
modelsError: null,
|
||||
modelsNotice: null,
|
||||
modelOptions: null,
|
||||
modelOptionsError: null,
|
||||
skills: [],
|
||||
toolsets: [],
|
||||
sessions: [],
|
||||
@@ -107,13 +107,13 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
webhook: null,
|
||||
busy: false,
|
||||
|
||||
client: (modelOverride) => {
|
||||
const settings = useSettings.getState().settings;
|
||||
const config = settings.hermes;
|
||||
const model = (modelOverride ?? '').trim() || config.model.trim();
|
||||
const name = settings.assistantName.trim() || 'JARVIS';
|
||||
const identity = `Identité de l'assistant dans cette conversation : ton nom est ${JSON.stringify(name)}. Utilise ce nom pour te présenter et parler de toi. EveFlow est le nom de l'application, pas ton nom. Cette identité remplace les anciens noms ou personas présents dans l'historique, la mémoire ou les instructions précédentes. Ne rappelle pas ton nom dans chaque réponse.`;
|
||||
return new HermesClient({ ...config, model, instructions: [config.instructions.trim(), identity].filter(Boolean).join('\n\n') });
|
||||
client: (modelOverride, providerOverride) => {
|
||||
const config = useSettings.getState().settings.hermes;
|
||||
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
|
||||
const override = (modelOverride ?? '').trim();
|
||||
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: () => {
|
||||
@@ -170,32 +170,30 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
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 () => {
|
||||
const client = get().client();
|
||||
const [, skills, toolsets] = await Promise.all([
|
||||
get().refreshModels(),
|
||||
const [models, skills, toolsets] = await Promise.all([
|
||||
client.models().catch(() => [] as HermesModel[]),
|
||||
client.skills().catch(() => [] as HermesSkill[]),
|
||||
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 () => {
|
||||
|
||||
@@ -33,9 +33,6 @@ export interface VoiceSettings extends SttConfig {
|
||||
export interface SpeechSettings extends TtsConfig {
|
||||
autoSpeak: boolean;
|
||||
speakIncoming: boolean;
|
||||
/** Speak only the first sentences (and the closing question) of long replies; the full text stays on screen. */
|
||||
summarizeReplies: boolean;
|
||||
replySentences: number;
|
||||
}
|
||||
|
||||
export interface WebhookSettings {
|
||||
@@ -90,9 +87,11 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
model: '',
|
||||
sessionKey: 'eveflow-desktop',
|
||||
transport: 'auto',
|
||||
provider: '',
|
||||
reasoningEffort: '',
|
||||
missionProvider: '',
|
||||
instructions:
|
||||
"Réponds en français, de façon concise et orale quand la question est simple; si la réponse est longue, commence par une ou deux phrases qui en donnent l'essentiel, puis le détail. Tu es dans une conversation continue : tiens compte des échanges précédents sans redemander ce qui a déjà été dit. 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,
|
||||
missionModel: ''
|
||||
},
|
||||
@@ -136,9 +135,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
voiceGender: 'male',
|
||||
timbre: 'jarvis',
|
||||
autoSpeak: true,
|
||||
speakIncoming: true,
|
||||
summarizeReplies: true,
|
||||
replySentences: 4
|
||||
speakIncoming: true
|
||||
},
|
||||
webhook: { enabled: true, port: 7842, secret: '' },
|
||||
notifications: { quietEnabled: false, quietStart: '22:30', quietEnd: '07:30', priorityKeywords: 'urgent, alerte, alarme, panne', summarizeIncoming: true, summarySentences: 2, nightTheme: true },
|
||||
|
||||
+37
-114
@@ -1,122 +1,45 @@
|
||||
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';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { flattenModelOptions, groupModelChoices, isReasoningEffort, modelChoiceValue, parseModelChoice } from '../src/lib/hermesModels';
|
||||
|
||||
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 }); });
|
||||
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('Hermes models', () => {
|
||||
it.each(['runs', 'sessions', 'completions'] as const)('sends the configured assistant identity over %s, including after a rename', async (transport) => {
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const capture = async (req: { body?: string }) => { requests.push(JSON.parse(req.body!)); throw new Error('stop'); };
|
||||
vi.mocked(httpFetch).mockImplementation(capture);
|
||||
vi.mocked(httpStream).mockImplementation(capture);
|
||||
for (const name of ['Jarvis', 'Nova']) {
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, assistantName: name, hermes: { ...config, instructions: 'Tu es Eve. Réponds en français.' } } });
|
||||
const send = useHermes.getState().client().send({ text: 'Qui es-tu ?', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport);
|
||||
await expect(send.result).rejects.toThrow('stop');
|
||||
const body = requests.at(-1)!;
|
||||
const instructions = transport === 'completions' ? (body.messages as { content: string }[])[0].content : body.instructions as string;
|
||||
expect(instructions).toContain(`ton nom est "${name}"`);
|
||||
expect(instructions).toContain('Réponds en français.');
|
||||
expect(instructions).toContain("EveFlow est le nom de l'application, pas ton nom");
|
||||
}
|
||||
expect(useSettings.getState().settings.hermes.instructions).toBe('Tu es Eve. Réponds en français.');
|
||||
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('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('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('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('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);
|
||||
});
|
||||
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' });
|
||||
});
|
||||
|
||||
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('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()); }
|
||||
it('validates reasoning effort levels', () => {
|
||||
expect(isReasoningEffort('xhigh')).toBe(true);
|
||||
expect(isReasoningEffort('')).toBe(true);
|
||||
expect(isReasoningEffort('turbo')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
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 } from '../src/state/settings';
|
||||
import type { HermesStreamEvent } from '../src/services/hermes/types';
|
||||
|
||||
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' };
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** Run started, streamed to completion over SSE (which never carries the session id), status read afterwards. */
|
||||
function mockRun(sessionId: string) {
|
||||
const bodies: Array<{ url: string; body: unknown }> = [];
|
||||
vi.mocked(httpFetch).mockImplementation(async (req) => {
|
||||
bodies.push({ url: req.url, body: req.body ? JSON.parse(req.body as string) : undefined });
|
||||
if (req.url.endsWith('/v1/runs')) return { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'started' }) };
|
||||
return { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'completed', session_id: sessionId, output: 'Salut.' }) };
|
||||
});
|
||||
vi.mocked(httpStream).mockImplementation(async (_req, handlers) => {
|
||||
// Chunks arrive on a later tick, after the client has accepted the stream (as over IPC).
|
||||
const done = new Promise<void>((resolve) => setTimeout(() => {
|
||||
handlers.onChunk('event: message.delta\ndata: {"run_id":"run_1","delta":"Salut."}\n\n');
|
||||
handlers.onChunk('event: run.completed\ndata: {"run_id":"run_1","status":"completed","output":"Salut."}\n\n');
|
||||
resolve();
|
||||
}, 0));
|
||||
return { start: { id: 'stream-1', ok: true, status: 200, statusText: '', headers: {} }, done, abort: () => undefined };
|
||||
});
|
||||
return bodies;
|
||||
}
|
||||
|
||||
describe('Hermes runs session continuity', () => {
|
||||
it('adopts the session Hermes attached to the run so the next message continues it', async () => {
|
||||
const bodies = mockRun('api_abc');
|
||||
const events: HermesStreamEvent[] = [];
|
||||
const client = new HermesClient(config);
|
||||
const reply = await client.send({ text: 'Bonjour', sessionId: 'eveflow-local', history: [], onEvent: (e) => events.push(e) }, 'runs').result;
|
||||
expect(reply).toBe('Salut.');
|
||||
expect(events.map((e) => e.kind)).toEqual(['run.started', 'delta', 'completed', 'session']);
|
||||
expect(events[3]).toEqual({ kind: 'session', sessionId: 'api_abc' });
|
||||
expect(bodies.map((b) => b.url)).toEqual(['https://example.test/v1/runs', 'https://example.test/v1/runs/run_1']);
|
||||
expect(bodies[0].body).toEqual(expect.objectContaining({ input: 'Bonjour', session_id: 'eveflow-local' }));
|
||||
const again = await client.send({ text: 'Et ensuite ?', sessionId: 'api_abc', history: [], onEvent: () => undefined }, 'runs').result;
|
||||
expect(again).toBe('Salut.');
|
||||
expect(bodies[2].body).toEqual(expect.objectContaining({ session_id: 'api_abc' }));
|
||||
});
|
||||
it('does not fail the reply when the run status is unavailable', async () => {
|
||||
mockRun('api_abc');
|
||||
vi.mocked(httpFetch).mockImplementation(async (req) =>
|
||||
req.url.endsWith('/v1/runs')
|
||||
? { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'started' }) }
|
||||
: { ok: false, status: 500, statusText: '', headers: {}, text: 'boom' });
|
||||
const events: HermesStreamEvent[] = [];
|
||||
const reply = await new HermesClient(config).send({ text: 'Bonjour', sessionId: 'x', history: [], onEvent: (e) => events.push(e) }, 'runs').result;
|
||||
expect(reply).toBe('Salut.');
|
||||
expect(events.some((e) => e.kind === 'session')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_SETTINGS, useSettings } from '../src/state/settings';
|
||||
|
||||
const enqueue = vi.fn((text: string) => /[\p{L}\p{N}]/u.test(text.replace(/```[\s\S]*?```/g, '')));
|
||||
const speak = vi.fn();
|
||||
const stop = vi.fn();
|
||||
vi.mock('../src/services/voice/tts', () => ({
|
||||
TtsEngine: class {
|
||||
enqueue = enqueue;
|
||||
speak = speak;
|
||||
stop = stop;
|
||||
onState() { return () => undefined; }
|
||||
updateConfig() { /* noop */ }
|
||||
get isActive() { return false; }
|
||||
}
|
||||
}));
|
||||
|
||||
const { speech, DIGEST_NOTICE } = await import('../src/services/voice/speech');
|
||||
|
||||
function stream(text: string, size = 7): void {
|
||||
for (let i = 0; i < text.length; i += size) speech.pushStream(text.slice(i, i + size));
|
||||
}
|
||||
const spoken = () => enqueue.mock.calls.map((c) => c[0]);
|
||||
|
||||
describe('spoken digest of streamed replies', () => {
|
||||
beforeEach(() => {
|
||||
enqueue.mockClear();
|
||||
speak.mockClear();
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, speech: { ...DEFAULT_SETTINGS.speech, summarizeReplies: true, replySentences: 2 } } });
|
||||
});
|
||||
afterEach(() => speech.stop());
|
||||
|
||||
it('stops after the configured sentences, then adds the closing question and a notice', () => {
|
||||
const text = 'Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. Quatrième phrase encore. Tu veux la suite ?';
|
||||
stream(text);
|
||||
speech.endStream(text);
|
||||
expect(spoken()).toEqual(['Première phrase du rapport.', 'Deuxième phrase utile.', 'Tu veux la suite ?', DIGEST_NOTICE]);
|
||||
});
|
||||
|
||||
it('speaks short replies in full without any notice', () => {
|
||||
const text = 'Une phrase courte. Une seconde phrase';
|
||||
stream(text);
|
||||
speech.endStream(text);
|
||||
expect(spoken()).toEqual(['Une phrase courte.', 'Une seconde phrase']);
|
||||
});
|
||||
|
||||
it('speaks everything when the digest is disabled', () => {
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, speech: { ...DEFAULT_SETTINGS.speech, summarizeReplies: false } } });
|
||||
const text = 'Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. Quatrième phrase encore.';
|
||||
stream(text);
|
||||
speech.endStream(text);
|
||||
expect(spoken()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('digests a reply delivered in one piece', () => {
|
||||
speech.sayReply('Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. On continue ?');
|
||||
expect(speak).toHaveBeenCalledWith(`Première phrase du rapport. Deuxième phrase utile. On continue ? ${DIGEST_NOTICE}`, expect.anything());
|
||||
});
|
||||
});
|
||||
+1
-19
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chunkForSpeech, cleanForSpeech, closingQuestion, extractSentences, isTranscriptNoise, preprocessMedia, spokenDigest } from '../src/lib/text';
|
||||
import { chunkForSpeech, cleanForSpeech, extractSentences, isTranscriptNoise, preprocessMedia } from '../src/lib/text';
|
||||
|
||||
describe('text', () => {
|
||||
it('cleans markdown for speech', () => {
|
||||
@@ -40,21 +40,3 @@ describe('isTranscriptNoise', () => {
|
||||
expect(isTranscriptNoise('Oui')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spokenDigest', () => {
|
||||
const reply = 'Voici la réponse. Elle contient plusieurs phrases. Une troisième pour la forme. Et une quatrième.\n\n```js\nconsole.log(1)\n```\n\nTu veux que je continue ?';
|
||||
it('speaks the first sentences and keeps the closing question', () => {
|
||||
const digest = spokenDigest(reply, 2);
|
||||
expect(digest.truncated).toBe(true);
|
||||
expect(digest.text).toBe('Voici la réponse. Elle contient plusieurs phrases. Tu veux que je continue ?');
|
||||
});
|
||||
it('leaves short replies untouched', () => {
|
||||
const digest = spokenDigest('Bonjour Michael. Tout va bien.', 4);
|
||||
expect(digest).toEqual({ text: 'Bonjour Michael. Tout va bien.', truncated: false });
|
||||
});
|
||||
it('ignores code blocks when counting and only reports a real question', () => {
|
||||
expect(spokenDigest('Une phrase.\n\n```\ncode\n```\n', 1).truncated).toBe(false);
|
||||
expect(closingQuestion(reply)).toBe('Tu veux que je continue ?');
|
||||
expect(closingQuestion('Aucune question ici.')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user