mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-16 11:47:55 +02:00
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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, discoverHermesUrl } from '../services/hermes/client';
|
||
import type {
|
||
HermesCapabilities,
|
||
HermesHealth,
|
||
HermesJob,
|
||
HermesJobDraft,
|
||
HermesModel,
|
||
HermesSession,
|
||
HermesSkill,
|
||
HermesToolset
|
||
} from '../services/hermes/types';
|
||
import { useSettings } from './settings';
|
||
|
||
export type LinkState = 'unknown' | 'checking' | 'online' | 'degraded' | 'offline';
|
||
|
||
export interface JobRun {
|
||
id: string;
|
||
jobId: string;
|
||
jobName: string;
|
||
status: 'ok' | 'failed';
|
||
output: string;
|
||
at: string;
|
||
read: boolean;
|
||
}
|
||
|
||
interface HermesStore {
|
||
link: LinkState;
|
||
linkDetail: string;
|
||
capabilities: HermesCapabilities | null;
|
||
health: HermesHealth | null;
|
||
models: HermesModel[];
|
||
modelsLoading: boolean;
|
||
modelsError: string | null;
|
||
modelsNotice: string | null;
|
||
refreshModels: (refresh?: boolean) => Promise<void>;
|
||
skills: HermesSkill[];
|
||
toolsets: HermesToolset[];
|
||
sessions: HermesSession[];
|
||
jobs: HermesJob[];
|
||
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;
|
||
busy: boolean;
|
||
|
||
client: (modelOverride?: string) => HermesClient;
|
||
connect: () => Promise<void>;
|
||
refreshJobs: () => Promise<void>;
|
||
refreshSessions: () => Promise<void>;
|
||
refreshCatalog: () => Promise<void>;
|
||
refreshWebhook: () => Promise<void>;
|
||
createJob: (draft: HermesJobDraft) => Promise<void>;
|
||
updateJob: (id: string, patch: Partial<HermesJobDraft>) => Promise<void>;
|
||
jobAction: (id: string, action: 'pause' | 'resume' | 'run' | 'delete') => Promise<void>;
|
||
markRunRead: (id: string) => void;
|
||
loadCache: () => Promise<void>;
|
||
}
|
||
|
||
const CACHE_KEY = 'eveflow.hermes.cache.v2';
|
||
let connectInflight: Promise<void> | null = null;
|
||
let lastCacheSnapshot = '';
|
||
let modelsRequest = 0;
|
||
const isTerminal = (status: string) => ['ok', 'failed', 'delivery_failed', 'completed', 'error'].includes(status.toLowerCase());
|
||
|
||
function runFromJob(job: HermesJob): JobRun | null {
|
||
const output = jobOutput(job);
|
||
const status = String(job.last_status ?? '').toLowerCase();
|
||
if (!output || !job.last_run_at || !isTerminal(status || 'ok')) return null;
|
||
const at = String(job.last_run_at);
|
||
return {
|
||
id: `${job.id}:${at}`,
|
||
jobId: job.id,
|
||
jobName: job.name || job.id,
|
||
status: status.includes('fail') || status === 'error' ? 'failed' : 'ok',
|
||
output,
|
||
at,
|
||
read: false
|
||
};
|
||
}
|
||
|
||
export const useHermes = create<HermesStore>((set, get) => ({
|
||
link: 'unknown',
|
||
linkDetail: '',
|
||
capabilities: null,
|
||
health: null,
|
||
models: [],
|
||
modelsLoading: false,
|
||
modelsError: null,
|
||
modelsNotice: null,
|
||
skills: [],
|
||
toolsets: [],
|
||
sessions: [],
|
||
jobs: [],
|
||
jobRuns: [],
|
||
jobsError: null,
|
||
discovering: false,
|
||
transport: 'completions',
|
||
lastSyncAt: null,
|
||
webhook: null,
|
||
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 model = (modelOverride ?? '').trim() || config.model.trim();
|
||
return new HermesClient({ ...config, model });
|
||
},
|
||
|
||
connect: () => {
|
||
if (connectInflight) return connectInflight;
|
||
connectInflight = (async () => {
|
||
const config = useSettings.getState().settings.hermes;
|
||
if (!config.url.trim()) {
|
||
set({ link: 'offline', linkDetail: 'URL Hermes non configurée' });
|
||
return;
|
||
}
|
||
set({ link: 'checking', linkDetail: '' });
|
||
const client = get().client();
|
||
try {
|
||
const health = await client.health();
|
||
let capabilities: HermesCapabilities | null = null;
|
||
try {
|
||
capabilities = await client.capabilities();
|
||
} catch (err) {
|
||
// A web page on /v1/* means the whole API is behind a portal even if /health passed through.
|
||
if (/page web/.test((err as Error).message)) throw err;
|
||
Log.warn('hermes', `capabilities unavailable: ${(err as Error).message}`);
|
||
}
|
||
const transport = resolveTransport(config, capabilities);
|
||
const degraded = !isHealthyStatus(health.status);
|
||
set({ health, capabilities, transport, link: degraded ? 'degraded' : 'online', linkDetail: degraded ? describeHealth(health) : '' });
|
||
Log.info('hermes', `connected (${transport})`, { status: health.status, model: capabilities?.model });
|
||
void get().refreshCatalog();
|
||
void get().refreshJobs();
|
||
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}`);
|
||
}
|
||
})().finally(() => {
|
||
connectInflight = null;
|
||
});
|
||
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 () => {
|
||
const client = get().client();
|
||
const [, skills, toolsets] = await Promise.all([
|
||
get().refreshModels(),
|
||
client.skills().catch(() => [] as HermesSkill[]),
|
||
client.toolsets().catch(() => [] as HermesToolset[])
|
||
]);
|
||
set({ skills, toolsets });
|
||
},
|
||
|
||
refreshSessions: async () => {
|
||
try {
|
||
const sessions = await get().client().listSessions();
|
||
set({ sessions });
|
||
} catch (err) {
|
||
Log.debug('hermes', `sessions unavailable: ${(err as Error).message}`);
|
||
}
|
||
},
|
||
|
||
refreshJobs: async () => {
|
||
if (!useSettings.getState().settings.hermes.url.trim()) return;
|
||
try {
|
||
const jobs = await get().client().listJobs();
|
||
const previous = get().jobRuns;
|
||
const known = new Map(previous.map((r) => [r.id, r]));
|
||
const incoming = jobs.map(runFromJob).filter((r): r is JobRun => !!r).map((r) => ({ ...r, read: known.get(r.id)?.read ?? false }));
|
||
const merged = [...incoming, ...previous.filter((r) => !incoming.some((i) => i.id === r.id))]
|
||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||
.slice(0, 100);
|
||
set({ jobs, jobRuns: merged, jobsError: null, lastSyncAt: Date.now(), link: get().link === 'offline' ? 'online' : get().link });
|
||
const snapshot = JSON.stringify({ jobs, jobRuns: merged });
|
||
if (snapshot !== lastCacheSnapshot) {
|
||
lastCacheSnapshot = snapshot;
|
||
persistSet(CACHE_KEY, { jobs, jobRuns: merged, syncedAt: Date.now() });
|
||
}
|
||
} catch (err) {
|
||
const message = (err as Error).message;
|
||
Log.warn('hermes', `jobs sync failed: ${message}`);
|
||
// The cron API can be absent or restricted on a given Hermes: the chat link is unaffected.
|
||
set({ jobsError: /HTTP 404/.test(message) ? 'API des crons absente sur ce serveur Hermes' : message });
|
||
}
|
||
},
|
||
|
||
refreshWebhook: async () => {
|
||
const api = window.eveflow;
|
||
if (!api) return;
|
||
try {
|
||
set({ webhook: await api.hermes.webhookStatus() });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
|
||
createJob: async (draft) => {
|
||
set({ busy: true });
|
||
try {
|
||
await get().client().createJob(draft);
|
||
await get().refreshJobs();
|
||
} finally {
|
||
set({ busy: false });
|
||
}
|
||
},
|
||
updateJob: async (id, patch) => {
|
||
set({ busy: true });
|
||
try {
|
||
await get().client().updateJob(id, patch);
|
||
await get().refreshJobs();
|
||
} finally {
|
||
set({ busy: false });
|
||
}
|
||
},
|
||
jobAction: async (id, action) => {
|
||
set({ busy: true });
|
||
const client = get().client();
|
||
try {
|
||
if (action === 'pause') await client.pauseJob(id);
|
||
else if (action === 'resume') await client.resumeJob(id);
|
||
else if (action === 'run') await client.runJob(id);
|
||
else await client.deleteJob(id);
|
||
await get().refreshJobs();
|
||
} finally {
|
||
set({ busy: false });
|
||
}
|
||
},
|
||
markRunRead: (id) => {
|
||
const jobRuns = get().jobRuns.map((r) => (r.id === id ? { ...r, read: true } : r));
|
||
set({ jobRuns });
|
||
persistSet(CACHE_KEY, { jobs: get().jobs, jobRuns, syncedAt: get().lastSyncAt });
|
||
},
|
||
loadCache: async () => {
|
||
const cache = await persistGet<{ jobs?: HermesJob[]; jobRuns?: JobRun[]; syncedAt?: number }>(CACHE_KEY);
|
||
if (cache) set({ jobs: cache.jobs ?? [], jobRuns: cache.jobRuns ?? [], lastSyncAt: cache.syncedAt ?? null });
|
||
}
|
||
}));
|
||
|
||
export { jobStatus };
|
||
|
||
const HEALTHY = new Set(['ok', 'healthy', 'up', 'alive', 'pass', 'ready', 'running', 'online', 'true']);
|
||
|
||
/** Hermes /health reports "ok"; /health/detailed may report "healthy", "degraded" or "unhealthy". */
|
||
export function isHealthyStatus(status: unknown): boolean {
|
||
if (status === undefined || status === null || status === '') return true;
|
||
return HEALTHY.has(String(status).toLowerCase());
|
||
}
|
||
|
||
/** Human summary of a degraded health payload: failing checks by name. */
|
||
export function describeHealth(health: Record<string, unknown>): string {
|
||
const failing: string[] = [];
|
||
const visit = (node: unknown, prefix: string, depth: number) => {
|
||
if (!node || typeof node !== 'object' || depth > 3) return;
|
||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||
if (key === 'status') continue;
|
||
if (value && typeof value === 'object') {
|
||
const rec = value as Record<string, unknown>;
|
||
const st = rec.status ?? rec.ok ?? rec.healthy;
|
||
if (st !== undefined && !isHealthyStatus(st)) failing.push(prefix + key);
|
||
else visit(value, `${prefix}${key}.`, depth + 1);
|
||
} else if (typeof value === 'boolean' && !value && /ok|healthy|ready|connected|available/i.test(key)) failing.push(prefix + key);
|
||
}
|
||
};
|
||
visit(health, '', 0);
|
||
const base = `état ${String(health.status)}`;
|
||
return failing.length ? `${base} · ${failing.slice(0, 4).join(', ')}` : base;
|
||
}
|