mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-12 01:39:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57340b124 | ||
|
|
992693c9cf | ||
|
|
5991410685 | ||
|
|
9f95f79943 | ||
|
|
c9b44433ac | ||
|
|
5201742f45 |
No files matched your search
@@ -67,7 +67,7 @@ jobs:
|
|||||||
out/latest.yml
|
out/latest.yml
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
- name: Publish GitHub release
|
- name: Publish GitHub release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master' && steps.ver.outputs.released == 'false')
|
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') && steps.ver.outputs.released == 'false')
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ steps.ver.outputs.version }}
|
tag_name: v${{ steps.ver.outputs.version }}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.1.0",
|
"version": "2.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.1.0",
|
"version": "2.5.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/orbitron": "^5.3.0",
|
"@fontsource/orbitron": "^5.3.0",
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "eveflow",
|
"name": "eveflow",
|
||||||
"version": "2.5.0",
|
"version": "2.5.3",
|
||||||
"releaseVersion": "2.5.0",
|
"releaseVersion": "2.5.3",
|
||||||
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
"description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useId, useState } from 'react';
|
||||||
|
import type { HermesModel } from '../../services/hermes/types';
|
||||||
|
|
||||||
|
export function ModelSelect({ label, value, models, defaultLabel, onChange }: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
models: HermesModel[];
|
||||||
|
defaultLabel: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [manual, setManual] = useState(false);
|
||||||
|
return <div className="field">
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
<select id={id} className="select" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="">{defaultLabel}</option>
|
||||||
|
{value && !models.some((m) => m.id === value) && <option value={value}>{value} (configuré)</option>}
|
||||||
|
{models.map((m) => <option key={m.id} value={m.id} disabled={m.available === false}>{m.name || m.id}{m.provider || m.owned_by ? ` · ${m.provider || m.owned_by}` : ''}{m.available === false ? ' (indisponible)' : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn small" aria-expanded={manual} onClick={() => setManual(!manual)}>
|
||||||
|
{manual ? 'Masquer la saisie manuelle' : 'Saisir un autre identifiant'}
|
||||||
|
</button>
|
||||||
|
{manual && <input className="input" aria-label={`${label} : identifiant manuel`} value={value} placeholder={defaultLabel} onChange={(e) => onChange(e.target.value)} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { DEFAULT_SETTINGS, useSettings, type HudTheme } from '../../state/settin
|
|||||||
import { useVoice } from '../../state/voice';
|
import { useVoice } from '../../state/voice';
|
||||||
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
import { installedModels, useVoiceModels } from '../../state/voiceModels';
|
||||||
import { ModelsSection } from './ModelsSection';
|
import { ModelsSection } from './ModelsSection';
|
||||||
|
import { ModelSelect } from './ModelSelect';
|
||||||
|
|
||||||
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
type Section = 'general' | 'hermes' | 'voice' | 'speech' | 'models' | 'webhook' | 'notifications' | 'ui';
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
const reset = useSettings((s) => s.reset);
|
const reset = useSettings((s) => s.reset);
|
||||||
const hermesModels = useHermes((s) => s.models);
|
const hermesModels = useHermes((s) => s.models);
|
||||||
|
const modelsLoading = useHermes((s) => s.modelsLoading);
|
||||||
|
const modelsError = useHermes((s) => s.modelsError);
|
||||||
|
const modelsNotice = useHermes((s) => s.modelsNotice);
|
||||||
|
const refreshHermesModels = useHermes((s) => s.refreshModels);
|
||||||
const hermesWebhook = useHermes((s) => s.webhook);
|
const hermesWebhook = useHermes((s) => s.webhook);
|
||||||
const hermesConnect = useHermes((s) => s.connect);
|
const hermesConnect = useHermes((s) => s.connect);
|
||||||
const micDevices = useVoice((s) => s.micDevices);
|
const micDevices = useVoice((s) => s.micDevices);
|
||||||
@@ -91,6 +96,13 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
|
const [edgeVoices, setEdgeVoices] = useState<EdgeVoice[]>([]);
|
||||||
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
const [webhookSecretVisible, setWebhookSecretVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section !== 'hermes') return;
|
||||||
|
useHermes.setState({ models: [], modelsError: null });
|
||||||
|
const timer = setTimeout(() => void refreshHermesModels(), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [section, settings.hermes.url, settings.hermes.apiKey, settings.hermes.sessionKey, refreshHermesModels]);
|
||||||
|
|
||||||
const speechProvider = settings.speech.provider;
|
const speechProvider = settings.speech.provider;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (speechProvider !== 'edge' || edgeVoices.length) return;
|
if (speechProvider !== 'edge' || edgeVoices.length) return;
|
||||||
@@ -206,18 +218,20 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
<input className="input" type="password" value={settings.hermes.apiKey} onChange={(e) => update({ hermes: { apiKey: e.target.value } })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<ModelSelect label="Modèle IA" models={hermesModels} value={settings.hermes.model} defaultLabel="Défaut du serveur" onChange={(model) => update({ hermes: { model } })} />
|
||||||
<label>Modèle</label>
|
|
||||||
<input className="input" list="hermes-models" value={settings.hermes.model} placeholder="(défaut du serveur)" onChange={(e) => update({ hermes: { model: e.target.value } })} />
|
|
||||||
<datalist id="hermes-models">
|
|
||||||
{hermesModels.map((m) => <option key={m.id} value={m.id} />)}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
<label>Clé de mémoire (X-Hermes-Session-Key)</label>
|
||||||
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
<input className="input" value={settings.hermes.sessionKey} onChange={(e) => update({ hermes: { sessionKey: e.target.value } })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<button className="btn small" disabled={modelsLoading} onClick={() => void refreshHermesModels(true)}>
|
||||||
|
{modelsLoading ? <Loader2 size={13} className="spin" /> : <RotateCcw size={13} />}
|
||||||
|
{modelsLoading ? 'Chargement des modèles…' : 'Actualiser les modèles'}
|
||||||
|
</button>
|
||||||
|
<span className="hint" role="status">{modelsLoading ? 'Interrogation du serveur Hermes…' : modelsError || (hermesModels.length ? `${hermesModels.length} modèle(s) disponible(s).` : 'Aucun modèle annoncé par le serveur.')}</span>
|
||||||
|
<span className={modelsNotice ? 'test-result fail' : 'hint'}>{modelsNotice || 'Modèles des fournisseurs configurés dans Hermes. Les modèles sans accès sont indiqués comme indisponibles.'}</span>
|
||||||
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Transport</label>
|
<label>Transport</label>
|
||||||
@@ -239,8 +253,7 @@ export function SettingsDrawer({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Modèle « mission » (tâches longues)</label>
|
<ModelSelect label="Modèle mission (tâches longues)" models={hermesModels} value={settings.hermes.missionModel} defaultLabel="Même modèle que le principal" onChange={(missionModel) => update({ hermes: { missionModel } })} />
|
||||||
<input className="input" list="hermes-models" value={settings.hermes.missionModel} placeholder="(même modèle)" onChange={(e) => update({ hermes: { missionModel: e.target.value } })} />
|
|
||||||
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
|
<span className="hint">Utilisé quand le mode Mission est activé dans la barre de commande. Laissez vide pour garder le modèle principal.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -625,8 +638,9 @@ mcp_servers:
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Nom de l’assistant</label>
|
<label htmlFor="assistant-name">Nom de l’assistant</label>
|
||||||
<input className="input" value={settings.assistantName} onChange={(e) => update({ assistantName: e.target.value.toUpperCase().slice(0, 18) || 'JARVIS' })} />
|
<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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Votre nom</label>
|
<label>Votre nom</label>
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i
|
|||||||
|
|
||||||
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
||||||
|
|
||||||
|
/** Stored picker IDs include the provider so identical model names remain distinct. */
|
||||||
|
export function modelSelection(value: string): { model?: string; provider?: string } {
|
||||||
|
const id = value.trim();
|
||||||
|
const separator = id.indexOf('::');
|
||||||
|
return separator > 0 ? { provider: id.slice(0, separator), model: id.slice(separator + 2) } : id ? { model: id } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A web page (login portal, dashboard, reverse-proxy error) instead of JSON means the URL does not
|
* A web page (login portal, dashboard, reverse-proxy error) instead of JSON means the URL does not
|
||||||
* point at the Hermes API. Returns a human explanation, or null when the body is not HTML.
|
* point at the Hermes API. Returns a human explanation, or null when the body is not HTML.
|
||||||
@@ -200,7 +207,44 @@ export class HermesClient {
|
|||||||
|
|
||||||
async models(): Promise<HermesModel[]> {
|
async models(): Promise<HermesModel[]> {
|
||||||
const payload = await this.request<unknown>('/v1/models');
|
const payload = await this.request<unknown>('/v1/models');
|
||||||
return extractArray<HermesModel>(payload, ['data', 'models']);
|
const readEntries = (value: unknown): unknown[] => {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (isRec(value)) {
|
||||||
|
if (Array.isArray(value.models)) return value.models;
|
||||||
|
if (value.data !== undefined) return readEntries(value.data);
|
||||||
|
}
|
||||||
|
throw new Error('Le serveur a renvoyé une liste de modèles invalide.');
|
||||||
|
};
|
||||||
|
const entries = readEntries(payload);
|
||||||
|
const models = new Map<string, HermesModel>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const id = typeof entry === 'string' ? entry.trim() : isRec(entry) && typeof entry.id === 'string' ? entry.id.trim() : '';
|
||||||
|
if (id && !models.has(id)) models.set(id, { ...(isRec(entry) ? entry : {}), id });
|
||||||
|
}
|
||||||
|
return [...models.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async modelCatalog(refresh = false): Promise<{ models: HermesModel[]; notice: string | null }> {
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await this.request<unknown>(`/api/model/options${refresh ? '?refresh=true' : ''}`, { timeoutMs: 30_000 });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof HttpError) || ![404, 405].includes(err.status)) throw err;
|
||||||
|
return { models: await this.models(), notice: 'Ce serveur ne propose pas le catalogue IA (/api/model/options). Mettez Hermes à jour pour choisir le fournisseur et son modèle. La liste ci-dessous contient uniquement les alias de connexion.' };
|
||||||
|
}
|
||||||
|
if (!isRec(payload) || !Array.isArray(payload.providers)) throw new Error('Catalogue IA Hermes invalide : liste des fournisseurs absente.');
|
||||||
|
const models = new Map<string, HermesModel>();
|
||||||
|
for (const row of payload.providers) {
|
||||||
|
if (!isRec(row) || typeof row.slug !== 'string' || !row.slug.trim() || !Array.isArray(row.models)) continue;
|
||||||
|
const unavailable = Array.isArray(row.unavailable_models) ? row.unavailable_models : [];
|
||||||
|
for (const model of row.models) {
|
||||||
|
if (typeof model !== 'string' || !model.trim()) continue;
|
||||||
|
const id = `${row.slug}::${model}`;
|
||||||
|
models.set(id, { id, name: model, provider: typeof row.name === 'string' ? row.name : row.slug,
|
||||||
|
available: row.authenticated !== false && !unavailable.includes(model) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { models: [...models.values()], notice: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async skills(): Promise<HermesSkill[]> {
|
async skills(): Promise<HermesSkill[]> {
|
||||||
@@ -290,11 +334,12 @@ export class HermesClient {
|
|||||||
|
|
||||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string }): Promise<{ run_id: string; status: string }> {
|
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string; provider?: string }): Promise<{ run_id: string; status: string }> {
|
||||||
const payload: Rec = { input: body.input };
|
const payload: Rec = { input: body.input };
|
||||||
if (body.session_id) payload.session_id = body.session_id;
|
if (body.session_id) payload.session_id = body.session_id;
|
||||||
if (body.instructions) payload.instructions = body.instructions;
|
if (body.instructions) payload.instructions = body.instructions;
|
||||||
if (body.model) payload.model = body.model;
|
if (body.model) payload.model = body.model;
|
||||||
|
if (body.provider) payload.provider = body.provider;
|
||||||
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
|
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +433,7 @@ export class HermesClient {
|
|||||||
input: options.text,
|
input: options.text,
|
||||||
session_id: plainSession(options.sessionId) || undefined,
|
session_id: plainSession(options.sessionId) || undefined,
|
||||||
instructions: this.config.instructions || undefined,
|
instructions: this.config.instructions || undefined,
|
||||||
model: this.config.model || undefined
|
...modelSelection(this.config.model)
|
||||||
});
|
});
|
||||||
const runId = run.run_id;
|
const runId = run.run_id;
|
||||||
if (isAborted()) {
|
if (isAborted()) {
|
||||||
@@ -452,7 +497,7 @@ export class HermesClient {
|
|||||||
const realId = sessionId.slice(3);
|
const realId = sessionId.slice(3);
|
||||||
const body: Rec = { input: options.text };
|
const body: Rec = { input: options.text };
|
||||||
if (this.config.instructions) body.instructions = this.config.instructions;
|
if (this.config.instructions) body.instructions = this.config.instructions;
|
||||||
if (this.config.model) body.model = this.config.model;
|
Object.assign(body, modelSelection(this.config.model));
|
||||||
|
|
||||||
let streamed = '';
|
let streamed = '';
|
||||||
let finalText = '';
|
let finalText = '';
|
||||||
@@ -489,7 +534,7 @@ export class HermesClient {
|
|||||||
let fullText = '';
|
let fullText = '';
|
||||||
let toolsAllowed = useTools;
|
let toolsAllowed = useTools;
|
||||||
for (let iteration = 0; iteration < 6 && !aborted(); iteration++) {
|
for (let iteration = 0; iteration < 6 && !aborted(); iteration++) {
|
||||||
const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true };
|
const payload: Rec = { model: 'hermes-agent', ...modelSelection(this.config.model), messages, stream: true };
|
||||||
if (toolsAllowed) {
|
if (toolsAllowed) {
|
||||||
payload.tools = options.localToolDefinitions;
|
payload.tools = options.localToolDefinitions;
|
||||||
payload.tool_choice = 'auto';
|
payload.tool_choice = 'auto';
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface HermesHealth {
|
|||||||
|
|
||||||
export interface HermesModel {
|
export interface HermesModel {
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
available?: boolean;
|
||||||
owned_by?: string;
|
owned_by?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
+35
-7
@@ -33,6 +33,10 @@ interface HermesStore {
|
|||||||
capabilities: HermesCapabilities | null;
|
capabilities: HermesCapabilities | null;
|
||||||
health: HermesHealth | null;
|
health: HermesHealth | null;
|
||||||
models: HermesModel[];
|
models: HermesModel[];
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsError: string | null;
|
||||||
|
modelsNotice: string | null;
|
||||||
|
refreshModels: (refresh?: boolean) => Promise<void>;
|
||||||
skills: HermesSkill[];
|
skills: HermesSkill[];
|
||||||
toolsets: HermesToolset[];
|
toolsets: HermesToolset[];
|
||||||
sessions: HermesSession[];
|
sessions: HermesSession[];
|
||||||
@@ -63,6 +67,7 @@ interface HermesStore {
|
|||||||
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
||||||
let connectInflight: Promise<void> | null = null;
|
let connectInflight: Promise<void> | null = null;
|
||||||
let lastCacheSnapshot = '';
|
let lastCacheSnapshot = '';
|
||||||
|
let modelsRequest = 0;
|
||||||
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
||||||
|
|
||||||
function runFromJob(job: HermesJob): JobRun | null {
|
function runFromJob(job: HermesJob): JobRun | null {
|
||||||
@@ -87,6 +92,9 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
capabilities: null,
|
capabilities: null,
|
||||||
health: null,
|
health: null,
|
||||||
models: [],
|
models: [],
|
||||||
|
modelsLoading: false,
|
||||||
|
modelsError: null,
|
||||||
|
modelsNotice: null,
|
||||||
skills: [],
|
skills: [],
|
||||||
toolsets: [],
|
toolsets: [],
|
||||||
sessions: [],
|
sessions: [],
|
||||||
@@ -100,10 +108,12 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
busy: false,
|
busy: false,
|
||||||
|
|
||||||
client: (modelOverride) => {
|
client: (modelOverride) => {
|
||||||
const config = useSettings.getState().settings.hermes;
|
const settings = useSettings.getState().settings;
|
||||||
// Without an explicit model, use the alias advertised by /v1/models (Hermes rejects unknown names).
|
const config = settings.hermes;
|
||||||
const model = (modelOverride ?? '').trim() || config.model.trim() || get().models[0]?.id || '';
|
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: () => {
|
connect: () => {
|
||||||
@@ -160,14 +170,32 @@ export const useHermes = create<HermesStore>((set, get) => ({
|
|||||||
return connectInflight;
|
return connectInflight;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshModels: async (refresh = false) => {
|
||||||
|
const request = ++modelsRequest;
|
||||||
|
const config = useSettings.getState().settings.hermes;
|
||||||
|
const current = () => {
|
||||||
|
const now = useSettings.getState().settings.hermes;
|
||||||
|
return request === modelsRequest && now.url === config.url && now.apiKey === config.apiKey && now.sessionKey === config.sessionKey;
|
||||||
|
};
|
||||||
|
set({ models: [], modelsLoading: true, modelsError: null, modelsNotice: null });
|
||||||
|
try {
|
||||||
|
const { models, notice } = await new HermesClient(config).modelCatalog(refresh);
|
||||||
|
if (current()) set({ models, modelsNotice: notice });
|
||||||
|
} catch (err) {
|
||||||
|
if (current()) set({ modelsError: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
if (request === modelsRequest) set({ modelsLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
refreshCatalog: async () => {
|
refreshCatalog: async () => {
|
||||||
const client = get().client();
|
const client = get().client();
|
||||||
const [models, skills, toolsets] = await Promise.all([
|
const [, skills, toolsets] = await Promise.all([
|
||||||
client.models().catch(() => [] as HermesModel[]),
|
get().refreshModels(),
|
||||||
client.skills().catch(() => [] as HermesSkill[]),
|
client.skills().catch(() => [] as HermesSkill[]),
|
||||||
client.toolsets().catch(() => [] as HermesToolset[])
|
client.toolsets().catch(() => [] as HermesToolset[])
|
||||||
]);
|
]);
|
||||||
set({ models, skills, toolsets });
|
set({ skills, toolsets });
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshSessions: async () => {
|
refreshSessions: async () => {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
transport: 'auto',
|
transport: 'auto',
|
||||||
reasoningEffort: '',
|
reasoningEffort: '',
|
||||||
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é.",
|
"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,
|
||||||
missionModel: ''
|
missionModel: ''
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { HermesClient } from '../src/services/hermes/client';
|
||||||
|
import { httpFetch, httpStream } from '../src/lib/transport';
|
||||||
|
import { DEFAULT_SETTINGS, useSettings } from '../src/state/settings';
|
||||||
|
import { useHermes } from '../src/state/hermes';
|
||||||
|
import { createElement, act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { ModelSelect } from '../src/components/settings/ModelSelect';
|
||||||
|
|
||||||
|
vi.mock('../src/lib/transport', async (original) => ({ ...await original<typeof import('../src/lib/transport')>(), httpFetch: vi.fn(), httpStream: vi.fn() }));
|
||||||
|
const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test/v1/', apiKey: ' test-key ' };
|
||||||
|
function respond(payload: unknown, status = 200) {
|
||||||
|
vi.mocked(httpFetch).mockResolvedValue({ ok: status === 200, status, statusText: '', headers: {}, text: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); useSettings.setState({ settings: DEFAULT_SETTINGS }); });
|
||||||
|
|
||||||
|
describe('Hermes models', () => {
|
||||||
|
it.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'] },
|
||||||
|
{ slug: 'second', name: 'Second', authenticated: false, models: ['same'] }
|
||||||
|
] });
|
||||||
|
const result = await new HermesClient(config).modelCatalog(true);
|
||||||
|
expect(httpFetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.test/api/model/options?refresh=true' }));
|
||||||
|
expect(result.models.map(m => [m.id, m.available])).toEqual([['first::same', true], ['first::locked', false], ['second::same', false]]);
|
||||||
|
expect(result.notice).toBeNull();
|
||||||
|
});
|
||||||
|
it('explains old servers instead of presenting the connection alias as an LLM catalog', async () => {
|
||||||
|
vi.mocked(httpFetch).mockResolvedValueOnce({ ok: false, status: 404, statusText: '', headers: {}, text: '' })
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ data: [{ id: 'hermes-agent' }] }) });
|
||||||
|
const result = await new HermesClient(config).modelCatalog();
|
||||||
|
expect(result.models).toEqual([{ id: 'hermes-agent' }]);
|
||||||
|
expect(result.notice).toContain('Mettez Hermes à jour');
|
||||||
|
});
|
||||||
|
it.each(['runs', 'sessions', 'completions'] as const)('sends provider and actual model separately over %s', async (transport) => {
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
vi.mocked(httpFetch).mockImplementation(async req => {
|
||||||
|
requests.push(JSON.parse(req.body as string));
|
||||||
|
throw new Error('stop');
|
||||||
|
});
|
||||||
|
vi.mocked(httpStream).mockImplementation(async req => {
|
||||||
|
requests.push(JSON.parse(req.body as string));
|
||||||
|
throw new Error('stop');
|
||||||
|
});
|
||||||
|
const send = new HermesClient({ ...config, model: 'custom:local::real-model' }).send({ text: 'Bonjour', sessionId: 'hs:test', history: [], onEvent: vi.fn() }, transport);
|
||||||
|
await expect(send.result).rejects.toThrow('stop');
|
||||||
|
expect(requests[0]).toMatchObject({ model: 'real-model', provider: 'custom:local' });
|
||||||
|
});
|
||||||
|
it('uses authenticated discovery and preserves provider labels, order and unique valid IDs', async () => {
|
||||||
|
respond({ data: [{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'b' }, {}, ' c '] });
|
||||||
|
expect(await new HermesClient(config).models()).toEqual([{ id: 'b', provider: 'provider-b' }, { id: 'a' }, { id: 'c' }]);
|
||||||
|
expect(httpFetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://example.test/v1/models', headers: expect.objectContaining({ Authorization: 'Bearer test-key' }) }));
|
||||||
|
});
|
||||||
|
it('accepts nested catalogs and empty lists, rejects malformed responses', async () => {
|
||||||
|
respond({ data: { models: ['a'] } });
|
||||||
|
expect(await new HermesClient(config).models()).toEqual([{ id: 'a' }]);
|
||||||
|
respond({ data: [] });
|
||||||
|
expect(await new HermesClient(config).models()).toEqual([]);
|
||||||
|
respond({ data: { error: 'unavailable' } });
|
||||||
|
await expect(new HermesClient(config).models()).rejects.toThrow('invalide');
|
||||||
|
});
|
||||||
|
it('surfaces authentication failures without clearing the selected model', async () => {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, model: 'chosen' } } });
|
||||||
|
respond({}, 401);
|
||||||
|
await useHermes.getState().refreshModels();
|
||||||
|
expect(useHermes.getState().modelsError).toContain('401');
|
||||||
|
expect(useHermes.getState().modelsLoading).toBe(false);
|
||||||
|
expect(useSettings.getState().settings.hermes.model).toBe('chosen');
|
||||||
|
});
|
||||||
|
it('discards results from a previous server or an older refresh', async () => {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: config } });
|
||||||
|
let finish!: (value: { models: { id: string }[]; notice: null }) => void;
|
||||||
|
vi.spyOn(HermesClient.prototype, 'modelCatalog').mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ models: [{ id: 'new' }], notice: null });
|
||||||
|
const old = useHermes.getState().refreshModels();
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, url: 'https://new.test' } } });
|
||||||
|
await useHermes.getState().refreshModels();
|
||||||
|
finish({ models: [{ id: 'old' }], notice: null });
|
||||||
|
await old;
|
||||||
|
expect(useHermes.getState().models).toEqual([{ id: 'new' }]);
|
||||||
|
});
|
||||||
|
it('sends the selected principal model and the mission override on new runs', async () => {
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, hermes: { ...config, model: 'principal', missionModel: 'mission' } } });
|
||||||
|
const start = vi.spyOn(HermesClient.prototype, 'startRun').mockRejectedValue(new Error('stop before network'));
|
||||||
|
for (const [override, expected] of [[undefined, 'principal'], ['mission', 'mission']] as const) {
|
||||||
|
const send = useHermes.getState().client(override).send({ text: 'Bonjour', sessionId: 'test-session', history: [], onEvent: vi.fn() }, 'runs');
|
||||||
|
await expect(send.result).rejects.toThrow('stop before network');
|
||||||
|
expect(start).toHaveBeenLastCalledWith(expect.objectContaining({ model: expected }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('offers a visible selection, preserves a custom value and supports the default', async () => {
|
||||||
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(createElement(ModelSelect, { label: 'Modèle IA', value: 'custom', models: [{ id: 'available' }], defaultLabel: 'Défaut', onChange })));
|
||||||
|
const select = container.querySelector('select')!;
|
||||||
|
expect(select.value).toBe('custom');
|
||||||
|
await act(async () => { select.value = 'available'; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith('available');
|
||||||
|
await act(async () => { select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith('');
|
||||||
|
} finally { await act(async () => root.unmount()); }
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user