fix: page web reçue à la place de l'API Hermes détectée, URL découverte automatiquement (v2.4.0.2) (#17)

- Toute réponse HTML (portail de connexion, tableau de bord, erreur de proxy)
  est reconnue et expliquée avec le titre de la page, au lieu d'être prise
  pour un état « ok » ou de produire une réponse vide.
- Découverte automatique : quand la liaison échoue, EveFlow essaie l'API sur le
  même hôte (port 8642, /api, /v1, sous-domaines api. et hermes.) et corrige
  l'URL si un serveur Hermes répond. Le bouton « Tester la liaison » fait de
  même et affiche les essais.

Tests : 47 tests unitaires ; e2e Electron vert.


Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
LogiFlowandClaude authored and GitHub committed 2026-09-04 08:06:05 +02:00
1 parent c75c1d674c
commit df25dd591c
7 files changed
+134 -7

No files matched your search

+11 -2
View File
@@ -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(', ')}…` });
}
}
};
+64 -3
View File
@@ -31,6 +31,63 @@ const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.i
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
/**
* 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('<!doctype html') && !head.startsWith('<html') && !/^<\?xml[^>]*>\s*<html/.test(head)) return null;
const title = /<title[^>]*>([^<]{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<string>();
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<string | null> {
for (const candidate of hermesUrlCandidates(config.url)) {
onProgress?.(candidate);
const client = new HermesClient({ ...config, url: candidate });
try {
await client.request<unknown>('/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<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
async request<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
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<T>(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];
+20 -1
View File
@@ -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<HermesStore>((set, get) => ({
jobs: [],
jobRuns: [],
jobsError: null,
discovering: false,
transport: 'completions',
lastSyncAt: null,
webhook: null,
@@ -130,6 +133,22 @@ export const useHermes = create<HermesStore>((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}`);
}