mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-14 18:56:55 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e79a98206 | ||
|
|
27985c0740 | ||
|
|
e57340b124 | ||
|
|
e6437f4bb2 | ||
|
|
992693c9cf | ||
|
|
5991410685 |
No files matched your search
@@ -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.
|
||||
@@ -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`).
|
||||
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).
|
||||
* **Effort de raisonnement** : les huit niveaux d'Hermes (none, minimal, low, medium, high, xhigh, max, ultra) au choix dans Paramètres → Hermes, envoyés dans `model_options.reasoning_effort` sur les trois transports (runs, sessions, chat completions).
|
||||
* 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 |
|
||||
| Effort de raisonnement Hermes (2.5.4) | huit niveaux (none → ultra) dans les réglages, transmis dans `model_options.reasoning_effort` sur runs, sessions et chat completions (auparavant chat completions seulement, trois niveaux) ; test des trois transports |
|
||||
| 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.2",
|
||||
"version": "2.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "eveflow",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/orbitron": "^5.3.0",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "2.5.2",
|
||||
"releaseVersion": "2.5.2",
|
||||
"version": "2.5.4",
|
||||
"releaseVersion": "2.5.4",
|
||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||
"main": "dist-electron/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useVoice } from '../../state/voice';
|
||||
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
||||
import { ModelsSection } from './ModelsSection';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { REASONING_EFFORTS, isReasoningEffort } from '../../lib/hermesReasoning';
|
||||
|
||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
||||
|
||||
@@ -244,12 +245,10 @@ 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">
|
||||
@@ -638,8 +637,9 @@ mcp_servers:
|
||||
<div className="card">
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Nom de l’assistant</label>
|
||||
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
|
||||
<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>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Votre nom</label>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Reasoning effort levels accepted by Hermes (`agent.reasoning_effort` / `model_options.reasoning_effort`). */
|
||||
import type { HermesReasoningEffort } from '../services/hermes/types';
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -160,6 +160,11 @@ export class HermesClient {
|
||||
return hermesBaseUrl(this.config.url);
|
||||
}
|
||||
|
||||
/** `model_options.reasoning_effort`, accepted by runs, session chat and chat completions alike. */
|
||||
private reasoningOptions(): Rec {
|
||||
return this.config.reasoningEffort ? { model_options: { reasoning_effort: this.config.reasoningEffort } } : {};
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}, json = true): Record<string, string> {
|
||||
const h: Record<string, string> = { ...extra };
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
@@ -335,7 +340,7 @@ 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 };
|
||||
const payload: Rec = { input: body.input, ...this.reasoningOptions() };
|
||||
if (body.session_id) payload.session_id = body.session_id;
|
||||
if (body.instructions) payload.instructions = body.instructions;
|
||||
if (body.model) payload.model = body.model;
|
||||
@@ -495,7 +500,7 @@ export class HermesClient {
|
||||
}
|
||||
if (isAborted()) return '';
|
||||
const realId = sessionId.slice(3);
|
||||
const body: Rec = { input: options.text };
|
||||
const body: Rec = { input: options.text, ...this.reasoningOptions() };
|
||||
if (this.config.instructions) body.instructions = this.config.instructions;
|
||||
Object.assign(body, modelSelection(this.config.model));
|
||||
|
||||
@@ -539,7 +544,7 @@ export class HermesClient {
|
||||
payload.tools = options.localToolDefinitions;
|
||||
payload.tool_choice = 'auto';
|
||||
}
|
||||
if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort };
|
||||
Object.assign(payload, this.reasoningOptions());
|
||||
|
||||
const toolCalls = new Map<number, CompletionToolCallAccumulator>();
|
||||
let finishReason: string | null = null;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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;
|
||||
@@ -7,7 +10,7 @@ export interface HermesConfig {
|
||||
/** 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`. */
|
||||
|
||||
+5
-3
@@ -108,10 +108,12 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
||||
busy: false,
|
||||
|
||||
client: (modelOverride) => {
|
||||
const config = useSettings.getState().settings.hermes;
|
||||
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
|
||||
const settings = useSettings.getState().settings;
|
||||
const config = settings.hermes;
|
||||
const model = (modelOverride ?? '').trim() || config.model.trim();
|
||||
return new HermesClient({ ...config, model });
|
||||
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') });
|
||||
},
|
||||
|
||||
connect: () => {
|
||||
|
||||
@@ -89,7 +89,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
transport: 'auto',
|
||||
reasoningEffort: '',
|
||||
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é.",
|
||||
"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: ''
|
||||
},
|
||||
|
||||
@@ -15,6 +15,23 @@ function respond(payload: unknown, status = 200) {
|
||||
afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); });
|
||||
|
||||
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.');
|
||||
});
|
||||
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'] },
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 { REASONING_EFFORTS, isReasoningEffort } from '../src/lib/hermesReasoning';
|
||||
|
||||
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', apiKey: 'k' };
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('reasoning effort levels', () => {
|
||||
it('lists every level Hermes accepts, server default first', () => {
|
||||
expect(REASONING_EFFORTS.map((e) => e.value)).toEqual(['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
||||
expect(isReasoningEffort('xhigh')).toBe(true);
|
||||
expect(isReasoningEffort('')).toBe(true);
|
||||
expect(isReasoningEffort('turbo')).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['runs', 'sessions', 'completions'] as const)('sends model_options.reasoning_effort over %s', async (transport) => {
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const capture = async (req: { body?: string }) => {
|
||||
requests.push(JSON.parse(req.body ?? '{}') as Record<string, unknown>);
|
||||
throw new Error('stop');
|
||||
};
|
||||
vi.mocked(httpFetch).mockImplementation(capture as never);
|
||||
vi.mocked(httpStream).mockImplementation(capture as never);
|
||||
const send = new HermesClient({ ...config, reasoningEffort: 'high' }).send({ text: 'Bonjour', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport);
|
||||
await expect(send.result).rejects.toThrow('stop');
|
||||
expect(requests[0]).toMatchObject({ model_options: { reasoning_effort: 'high' } });
|
||||
});
|
||||
|
||||
it('omits model_options when the effort is left to the server', async () => {
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
vi.mocked(httpFetch).mockImplementation((async (req: { body?: string }) => {
|
||||
requests.push(JSON.parse(req.body ?? '{}') as Record<string, unknown>);
|
||||
throw new Error('stop');
|
||||
}) as never);
|
||||
const send = new HermesClient({ ...config, reasoningEffort: '' }).send({ text: 'Bonjour', sessionId: 'test', history: [], onEvent: vi.fn() }, 'runs');
|
||||
await expect(send.result).rejects.toThrow('stop');
|
||||
expect(requests[0]).not.toHaveProperty('model_options');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user