diff --git a/README.md b/README.md index 9179ca4..3ef0b99 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ mcp_servers: Sans secret, EveFlow n'écoute qu'en local (`127.0.0.1`) ; définissez un secret dans Paramètres → Webhook pour un Hermes distant. Outils exposés : `capture_screen`, `lock_session`, `open_app`, `open_url`, `media_key`, `clipboard_get`, `clipboard_set`, `find_files`, `speak_text`, `notify_user`, `set_hud_state`, `get_app_status`, `get_conversation_history`, `show_message`. +### L'URL répond par une page web ? + +Si « Tester la liaison » signale « Le serveur renvoie une page web … au lieu de l'API Hermes », l'URL saisie pointe vers un portail (page de connexion, tableau de bord) et non vers le serveur API. EveFlow cherche alors automatiquement l'API sur le même hôte (port 8642, chemins `/api` et `/v1`, sous-domaines `api.` ou `hermes.`) et corrige l'URL s'il la trouve. Sinon, ouvrez le port 8642 du serveur Hermes ou exposez-le sur un chemin dédié de votre proxy, sans authentification web devant lui (la clé `API_SERVER_KEY` suffit). + ## Développement ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 27b74ff..36870de 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -77,6 +77,7 @@ Sources : [jarvis-desktop-ai](https://github.com/ccarloshenri/jarvis-desktop-ai) | Silero VAD sur phrase Kokoro de 2,3 s (2.3.0) | un seul segment, début et fin détectés, 6,7 s d'audio traités en 190 ms | | Capture d'écran → Hermes (2.3.0) | JPEG de 107 ko reçu côté Hermes (mock chat completions) | | Intention locale « coupe le son » (2.3.0) | traitée sans Hermes, résultat affiché dans le fil | +| Correctif 2.4.0.2 | une page web (portail de connexion) reçue à la place de l'API Hermes est reconnue et expliquée ; l'API est recherchée automatiquement sur le même hôte (port 8642, /api, hermes.…) et l'URL corrigée | | 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 | | 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) | diff --git a/package.json b/package.json index 62c0cdc..10da26e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "eveflow", "version": "2.4.0", - "releaseVersion": "2.4.0.1", + "releaseVersion": "2.4.0.2", "description": "JARVIS-style desktop HUD for Hermes Agent: voice, streaming runs, scheduled jobs, skills and telemetry", "main": "dist-electron/main.js", "private": true, diff --git a/src/components/settings/SettingsDrawer.tsx b/src/components/settings/SettingsDrawer.tsx index 04723d2..5ccd804 100644 --- a/src/components/settings/SettingsDrawer.tsx +++ b/src/components/settings/SettingsDrawer.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { X, Settings, CheckCircle2, XCircle, Loader2, Mic, Volume2, RotateCcw, Play } from 'lucide-react'; import { bridge } from '../../lib/bridge'; -import { HermesClient } from '../../services/hermes/client'; +import { HermesClient, discoverHermesUrl, hermesUrlCandidates } from '../../services/hermes/client'; import { listSystemVoices } from '../../services/voice/tts'; import { speech } from '../../services/voice/speech'; import { ensurePreferredVoice } from '../../services/voice/voicePreference'; @@ -116,7 +116,16 @@ export function SettingsDrawer({ onClose }: Props) { setHermesTest({ status: 'ok', message: `${health.status} (${via})${caps}` }); void hermesConnect(); } catch (err) { - setHermesTest({ status: 'fail', message: (err as Error).message }); + const message = (err as Error).message; + setHermesTest({ status: 'running', message: `${message} — recherche de l’API sur le même hôte…` }); + const found = await discoverHermesUrl(settings.hermes, (u) => setHermesTest({ status: 'running', message: `essai ${u}…` })).catch(() => null); + if (found) { + update({ hermes: { url: found } }); + setHermesTest({ status: 'ok', message: `API Hermes trouvée : ${found} (URL corrigée automatiquement). Relancez le test.` }); + void hermesConnect(); + } else { + setHermesTest({ status: 'fail', message: `${message} Aucune API trouvée sur ${hermesUrlCandidates(settings.hermes.url).slice(0, 4).join(', ')}…` }); + } } }; diff --git a/src/services/hermes/client.ts b/src/services/hermes/client.ts index baf999e..616aea1 100644 --- a/src/services/hermes/client.ts +++ b/src/services/hermes/client.ts @@ -31,6 +31,63 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i export type ResolvedTransport = Exclude; +/** + * 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. + */ +export function describeHtml(body: string): string | null { + const head = body.slice(0, 600).trimStart().toLowerCase(); + if (!head.startsWith(']*>\s*]*>([^<]{1,120})<\/title>/i.exec(body)?.[1]?.trim(); + const login = /connecter|login|sign in|authentif|mot de passe|password/i.test(body.slice(0, 20_000)); + return `Le serveur renvoie une page web${title ? ` « ${title} »` : ''} au lieu de l'API Hermes${login ? ' (page de connexion : l’URL passe par un portail web)' : ''}. Utilisez l'URL directe du serveur API Hermes (port 8642 par défaut, ou le chemin /v1 exposé par votre proxy).`; +} + +/** Candidate API URLs derived from what the user typed (same host, other port or path). */ +export function hermesUrlCandidates(url: string): string[] { + const base = hermesBaseUrl(url); + if (!base) return []; + const out = new Set(); + const add = (u: string) => out.add(u.replace(/\/+$/, '')); + try { + const u = new URL(base.includes('://') ? base : `http://${base}`); + const host = u.hostname; + const scheme = u.protocol.replace(':', ''); + const path = u.pathname.replace(/\/+$/, ''); + if (path) add(`${scheme}://${u.host}`); + for (const suffix of ['/api', '/hermes', '/hermes/api', '/v1', '/api/v1']) add(`${scheme}://${u.host}${path}${suffix}`); + if (!u.port) { + add(`${scheme}://${host}:8642`); + add(`http://${host}:8642`); + add(`https://${host}:8642`); + } + for (const sub of ['api', 'hermes-api']) { + if (!host.startsWith(`${sub}.`) && host.includes('.')) add(`${scheme}://${sub}.${host}`); + } + if (host.startsWith('jarvis.')) add(`${scheme}://hermes.${host.slice('jarvis.'.length)}`); + } catch { + return []; + } + out.delete(base); + return [...out]; +} + +/** Probe candidate URLs until one answers Hermes JSON on /health or /v1/capabilities. */ +export async function discoverHermesUrl(config: HermesConfig, onProgress?: (url: string) => void): Promise { + for (const candidate of hermesUrlCandidates(config.url)) { + onProgress?.(candidate); + const client = new HermesClient({ ...config, url: candidate }); + try { + await client.request('/health', { timeoutMs: 4000 }); + return candidate; + } catch (err) { + if (err instanceof HttpError && (err.status === 401 || err.status === 403)) return candidate; // API found, key missing + continue; + } + } + return null; +} + export function hermesBaseUrl(url: string): string { let base = url.trim().replace(/\/+$/, ''); base = base.replace(/\/chat\/completions$/, ''); @@ -93,7 +150,7 @@ export class HermesClient { return h; } - private async request(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise { + async request(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise { if (!this.config.url.trim()) throw new Error("URL Hermes non configurée."); const url = `${this.base}${path}`; const res = await httpFetch({ @@ -103,11 +160,13 @@ export class HermesClient { body: init.body !== undefined ? JSON.stringify(init.body) : undefined, timeoutMs: init.timeoutMs ?? 20_000 }); - if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text); const text = res.text ?? ''; + const html = describeHtml(text); + if (html) throw new HttpError(res.status, html, text); + if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text); if (!text.trim()) return null as T; const parsed = tryParseJson(text); - if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path}`); + if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path} : ${text.slice(0, 120).replace(/\s+/g, ' ')}`); return parsed; } @@ -520,6 +579,8 @@ export class HermesClient { export function recoverCompletion(raw: string): { text?: string; error?: string } { const body = raw.trim(); if (!body) return {}; + const html = describeHtml(body); + if (html) return { error: html }; const candidates = body.startsWith('data:') || body.startsWith('event:') ? body.split(/\n+/).filter((l) => l.startsWith('data:')).map((l) => l.replace(/^data:\s*/, '')).filter((l) => l && l !== '[DONE]') : [body]; diff --git a/src/state/hermes.ts b/src/state/hermes.ts index 6c94940..5c788bc 100644 --- a/src/state/hermes.ts +++ b/src/state/hermes.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import type { WebhookStatus } from '../../shared/ipc'; import { Log } from '../lib/log'; import { persistGet, persistSet } from '../lib/persist'; -import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport } from '../services/hermes/client'; +import { HermesClient, jobOutput, jobStatus, resolveTransport, type ResolvedTransport, discoverHermesUrl } from '../services/hermes/client'; import type { HermesCapabilities, HermesHealth, @@ -40,6 +40,8 @@ interface HermesStore { jobRuns: JobRun[]; /** Last cron sync failure (the chat link stays independent of it). */ jobsError: string | null; + /** True while probing alternative API URLs after a failed connection. */ + discovering: boolean; transport: ResolvedTransport; lastSyncAt: number | null; webhook: WebhookStatus | null; @@ -91,6 +93,7 @@ export const useHermes = create((set, get) => ({ jobs: [], jobRuns: [], jobsError: null, + discovering: false, transport: 'completions', lastSyncAt: null, webhook: null, @@ -130,6 +133,22 @@ export const useHermes = create((set, get) => ({ void get().refreshSessions(); } catch (err) { const message = (err as Error).message; + // The URL answers with a web page (portal, dashboard) or nothing: look for the API on the same host. + if (!get().discovering && /page web|illisible|fetch failed|ECONNREFUSED|404/i.test(message)) { + set({ discovering: true, linkDetail: 'recherche de l’API Hermes…' }); + try { + const found = await discoverHermesUrl(config); + if (found) { + Log.info('hermes', `API found at ${found} (was ${config.url})`); + useSettings.getState().update({ hermes: { url: found } }); + set({ discovering: false, linkDetail: `URL corrigée automatiquement : ${found}` }); + await get().connect(); + return; + } + } finally { + set({ discovering: false }); + } + } set({ link: 'offline', linkDetail: message, transport: resolveTransport(config, null) }); Log.warn('hermes', `connection failed: ${message}`); } diff --git a/tests/hermesDiscovery.test.ts b/tests/hermesDiscovery.test.ts new file mode 100644 index 0000000..6f4a31e --- /dev/null +++ b/tests/hermesDiscovery.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { describeHtml, hermesUrlCandidates, recoverCompletion } from '../src/services/hermes/client'; + +describe('describeHtml', () => { + it('explains a login page instead of the API', () => { + const msg = describeHtml('Jarvis – Se connecterMot de passe'); + expect(msg).toContain('Jarvis – Se connecter'); + expect(msg).toContain('page de connexion'); + expect(msg).toContain('8642'); + }); + it('ignores JSON and SSE', () => { + expect(describeHtml('{"status":"ok"}')).toBeNull(); + expect(describeHtml('data: {"choices":[]}')).toBeNull(); + }); + it('is used by recoverCompletion', () => { + expect(recoverCompletion('Portal').error).toContain('Portal'); + }); +}); + +describe('hermesUrlCandidates', () => { + it('tries the API port, common paths and sibling hosts', () => { + const c = hermesUrlCandidates('http://jarvis.vonrodbox.eu'); + expect(c).toContain('http://jarvis.vonrodbox.eu:8642'); + expect(c).toContain('http://jarvis.vonrodbox.eu/api'); + expect(c).toContain('http://api.jarvis.vonrodbox.eu'); + expect(c).toContain('http://hermes.vonrodbox.eu'); + expect(c).not.toContain('http://jarvis.vonrodbox.eu'); + }); + it('keeps an explicit port and handles garbage', () => { + expect(hermesUrlCandidates('http://10.0.0.5:8642').some((u) => u.includes(':8642:'))).toBe(false); + expect(hermesUrlCandidates('')).toEqual([]); + }); +});