mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-17 04:08:17 +02:00
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:
1 parent
c75c1d674c
commit
df25dd591c
7 files changed
+134
-7
No files matched your search
@@ -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(', ')}…` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user