mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-15 11:17:17 +02:00
Add Hermes cron synchronization cockpit
This commit is contained in:
1 parent
1caa738dfb
commit
17e573911d
3 files changed
+929
-105
No files matched your search
+452
-36
@@ -3,13 +3,15 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
Send, Mic, MicOff, Settings, MessageSquare, Volume2, VolumeX,
|
||||
Sparkles, Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip
|
||||
Sparkles, Cpu, Minimize2, X, AlertCircle, StopCircle, Paperclip,
|
||||
CalendarClock, History, Wrench, Play, Pause, RefreshCw, Trash2, Plus,
|
||||
CheckCircle2, WifiOff, Edit3, Save
|
||||
} from 'lucide-react';
|
||||
import { ThreeCanvas, ToolEvent } from './components/ThreeCanvas';
|
||||
import { AudioService } from './services/audioService';
|
||||
import { AgentService, AgentConfig, AgentProvider } from './services/agentService';
|
||||
import { EmotionService, EveAvatar, EveEmotion } from './services/emotionService';
|
||||
import { PollingService } from './services/pollingService';
|
||||
import { HermesJob, HermesJobDraft, PollingService } from './services/pollingService';
|
||||
import { AppCallbacks } from './services/toolRegistry';
|
||||
import { version } from '../package.json';
|
||||
|
||||
@@ -19,7 +21,7 @@ interface Message {
|
||||
content: string;
|
||||
emotion?: EveEmotion;
|
||||
timestamp: Date;
|
||||
source?: 'eveflow' | 'telegram' | 'webhook';
|
||||
source?: 'eveflow' | 'telegram' | 'webhook' | 'cron';
|
||||
images?: string[]; // base64 Data URLs pour affichage local direct
|
||||
}
|
||||
|
||||
@@ -30,6 +32,25 @@ interface Attachment {
|
||||
size: number;
|
||||
}
|
||||
|
||||
type HermesCockpitTab = 'tools' | 'crons' | 'history';
|
||||
type HermesSyncState = 'idle' | 'syncing' | 'online' | 'offline';
|
||||
|
||||
interface HermesRunRecord {
|
||||
id: string;
|
||||
jobId: string;
|
||||
jobName: string;
|
||||
status: 'succeeded' | 'failed';
|
||||
result: string;
|
||||
at: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface HermesCronCache {
|
||||
jobs: HermesJob[];
|
||||
runs: HermesRunRecord[];
|
||||
lastSyncAt?: string;
|
||||
}
|
||||
|
||||
export const TTS_LONG_TEXT_THRESHOLD = 450;
|
||||
|
||||
// Helper de compression d'image asynchrone via Canvas HTML5 (Zero Trust & Zero Dependency)
|
||||
@@ -487,32 +508,245 @@ const createWelcomeMessage = (avatarId: EveAvatar): Message => ({
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
const ToolMonitorWindow: React.FC<{
|
||||
const getHermesJobId = (job: HermesJob): string => job.id || String(job.job_id || job.name || Math.random());
|
||||
|
||||
const getHermesJobStatus = (job: HermesJob): string => {
|
||||
if (job.state) return String(job.state);
|
||||
if (job.status) return String(job.status);
|
||||
if (job.enabled === false) return 'paused';
|
||||
if (job.last_status) return String(job.last_status);
|
||||
return 'scheduled';
|
||||
};
|
||||
|
||||
const formatHermesSchedule = (schedule: HermesJob['schedule']): string => {
|
||||
if (!schedule) return 'non planifie';
|
||||
if (typeof schedule === 'string') return schedule;
|
||||
return schedule.display || schedule.expr || schedule.kind || 'schedule';
|
||||
};
|
||||
|
||||
const getHermesJobResult = (job: HermesJob): string => (
|
||||
job.result || job.last_result || job.output || job.last_output || job.error || job.last_error || ''
|
||||
);
|
||||
|
||||
const getHermesJobTime = (job: HermesJob): string => (
|
||||
job.completed_at || job.last_run_at || job.updated_at || job.created_at || new Date().toISOString()
|
||||
);
|
||||
|
||||
const buildRunRecordFromJob = (job: HermesJob): HermesRunRecord | null => {
|
||||
const result = getHermesJobResult(job);
|
||||
const status = getHermesJobStatus(job).toLowerCase();
|
||||
if (!result || (!['completed', 'failed', 'ok'].includes(status) && !job.completed_at && !job.last_run_at)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const at = getHermesJobTime(job);
|
||||
const jobId = getHermesJobId(job);
|
||||
return {
|
||||
id: `${jobId}:${at}:${result.slice(0, 24)}`,
|
||||
jobId,
|
||||
jobName: job.name || jobId,
|
||||
status: status === 'failed' || job.error || job.last_error ? 'failed' : 'succeeded',
|
||||
result,
|
||||
at,
|
||||
read: false
|
||||
};
|
||||
};
|
||||
|
||||
const previewText = (value: string, max = 96): string => (
|
||||
value.length > max ? `${value.slice(0, max - 1)}...` : value
|
||||
);
|
||||
|
||||
const createDefaultCronDraft = (): HermesJobDraft => ({
|
||||
name: '',
|
||||
schedule: 'every 1h',
|
||||
prompt: '',
|
||||
deliver: 'local'
|
||||
});
|
||||
|
||||
const HermesCockpitWindow: React.FC<{
|
||||
events: ToolEvent[];
|
||||
isWorking: boolean;
|
||||
}> = ({ events, isWorking }) => {
|
||||
jobs: HermesJob[];
|
||||
runs: HermesRunRecord[];
|
||||
syncState: HermesSyncState;
|
||||
syncDetail?: string;
|
||||
lastSyncAt?: string;
|
||||
draft: HermesJobDraft;
|
||||
editingJobId: string | null;
|
||||
isSaving: boolean;
|
||||
onDraftChange: (draft: HermesJobDraft) => void;
|
||||
onCreate: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onStartEdit: (job: HermesJob) => void;
|
||||
onUpdate: () => void;
|
||||
onRefresh: () => void;
|
||||
onPause: (job: HermesJob) => void;
|
||||
onResume: (job: HermesJob) => void;
|
||||
onRun: (job: HermesJob) => void;
|
||||
onDelete: (job: HermesJob) => void;
|
||||
onOpenRun: (run: HermesRunRecord) => void;
|
||||
}> = ({
|
||||
events,
|
||||
isWorking,
|
||||
jobs,
|
||||
runs,
|
||||
syncState,
|
||||
syncDetail,
|
||||
lastSyncAt,
|
||||
draft,
|
||||
editingJobId,
|
||||
isSaving,
|
||||
onDraftChange,
|
||||
onCreate,
|
||||
onCancelEdit,
|
||||
onStartEdit,
|
||||
onUpdate,
|
||||
onRefresh,
|
||||
onPause,
|
||||
onResume,
|
||||
onRun,
|
||||
onDelete,
|
||||
onOpenRun
|
||||
}) => {
|
||||
const latestEvents = events.slice(-8).reverse();
|
||||
const [activeTab, setActiveTab] = useState<HermesCockpitTab>('tools');
|
||||
const unreadRuns = runs.filter(run => !run.read).length;
|
||||
const hasDraft = draft.name.trim() && draft.schedule.trim() && draft.prompt.trim();
|
||||
const canSaveDraft = !!hasDraft && !isSaving;
|
||||
|
||||
const renderTools = () => (
|
||||
<div className="tool-screen-body hermes-cockpit-scroll">
|
||||
{latestEvents.length === 0 ? (
|
||||
<div className="tool-screen-empty">Aucun outil en cours</div>
|
||||
) : (
|
||||
latestEvents.map((event) => (
|
||||
<div key={event.id} className={`tool-screen-line ${event.status}`}>
|
||||
<span className="tool-screen-status">{event.status.toUpperCase()}</span>
|
||||
<span className="tool-screen-text">{event.label}</span>
|
||||
{event.detail && <span className="tool-screen-detail">{event.detail}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCrons = () => (
|
||||
<div className="hermes-cron-panel hermes-cockpit-scroll">
|
||||
<div className="hermes-cron-editor">
|
||||
<input
|
||||
className="hermes-cockpit-input"
|
||||
value={draft.name}
|
||||
onChange={(e) => onDraftChange({ ...draft, name: e.target.value })}
|
||||
placeholder="Nom du cron"
|
||||
/>
|
||||
<input
|
||||
className="hermes-cockpit-input"
|
||||
value={draft.schedule}
|
||||
onChange={(e) => onDraftChange({ ...draft, schedule: e.target.value })}
|
||||
placeholder="every 1h / 0 9 * * *"
|
||||
/>
|
||||
<textarea
|
||||
className="hermes-cockpit-textarea"
|
||||
value={draft.prompt}
|
||||
onChange={(e) => onDraftChange({ ...draft, prompt: e.target.value })}
|
||||
placeholder="Mission Hermes planifiee"
|
||||
/>
|
||||
<div className="hermes-cron-editor-actions">
|
||||
{editingJobId && (
|
||||
<button className="hermes-icon-btn" onClick={onCancelEdit} title="Annuler l'edition">
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="hermes-icon-btn primary"
|
||||
onClick={editingJobId ? onUpdate : onCreate}
|
||||
disabled={!canSaveDraft}
|
||||
title={editingJobId ? 'Sauvegarder le cron' : 'Creer le cron'}
|
||||
>
|
||||
{editingJobId ? <Save size={13} /> : <Plus size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hermes-jobs-list">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="tool-screen-empty">Aucun cron Hermes synchronise</div>
|
||||
) : jobs.map((job) => {
|
||||
const jobId = getHermesJobId(job);
|
||||
const status = getHermesJobStatus(job);
|
||||
const paused = status === 'paused' || job.enabled === false;
|
||||
const result = getHermesJobResult(job);
|
||||
|
||||
return (
|
||||
<div key={jobId} className={`hermes-job-row ${status.toLowerCase()}`}>
|
||||
<div className="hermes-job-main">
|
||||
<span className="hermes-job-name">{job.name || jobId}</span>
|
||||
<span className="hermes-job-meta">
|
||||
{status.toUpperCase()} // {formatHermesSchedule(job.schedule)}
|
||||
</span>
|
||||
<span className="hermes-job-meta">
|
||||
NEXT {job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'inconnu'}
|
||||
</span>
|
||||
{result && <span className="hermes-job-result">{previewText(result)}</span>}
|
||||
</div>
|
||||
<div className="hermes-job-actions">
|
||||
<button className="hermes-icon-btn" onClick={() => onRun(job)} title="Lancer maintenant"><Play size={12} /></button>
|
||||
<button className="hermes-icon-btn" onClick={() => paused ? onResume(job) : onPause(job)} title={paused ? 'Reprendre' : 'Mettre en pause'}>
|
||||
{paused ? <Play size={12} /> : <Pause size={12} />}
|
||||
</button>
|
||||
<button className="hermes-icon-btn" onClick={() => onStartEdit(job)} title="Editer"><Edit3 size={12} /></button>
|
||||
<button className="hermes-icon-btn danger" onClick={() => onDelete(job)} title="Supprimer"><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderHistory = () => (
|
||||
<div className="hermes-history-list hermes-cockpit-scroll">
|
||||
{runs.length === 0 ? (
|
||||
<div className="tool-screen-empty">Aucun resultat de cron en cache</div>
|
||||
) : runs.slice(0, 24).map((run) => (
|
||||
<button key={run.id} className={`hermes-run-row ${run.status} ${run.read ? 'read' : 'unread'}`} onClick={() => onOpenRun(run)}>
|
||||
<span className="hermes-run-status">{run.status === 'failed' ? 'ERR' : 'OK'}</span>
|
||||
<span className="hermes-run-content">
|
||||
<strong>{run.jobName}</strong>
|
||||
<span>{previewText(run.result, 120)}</span>
|
||||
</span>
|
||||
<span className="hermes-run-time">{new Date(run.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`tool-screen-window ${isWorking ? 'active' : ''}`}>
|
||||
<div className={`tool-screen-window hermes-cockpit-window ${isWorking || syncState === 'syncing' ? 'active' : ''}`}>
|
||||
<div className="tool-screen-header">
|
||||
<span className="tool-screen-led" />
|
||||
<span>OUTILS HERMES</span>
|
||||
<span>{isWorking ? 'ACTIF' : 'VEILLE'}</span>
|
||||
<span className={`tool-screen-led ${syncState}`} />
|
||||
<span>COCKPIT HERMES</span>
|
||||
<button className="hermes-icon-btn" onClick={onRefresh} title="Synchroniser Hermes">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="tool-screen-body">
|
||||
{latestEvents.length === 0 ? (
|
||||
<div className="tool-screen-empty">Aucun outil en cours</div>
|
||||
) : (
|
||||
latestEvents.map((event) => (
|
||||
<div key={event.id} className={`tool-screen-line ${event.status}`}>
|
||||
<span className="tool-screen-status">{event.status.toUpperCase()}</span>
|
||||
<span className="tool-screen-text">{event.label}</span>
|
||||
{event.detail && <span className="tool-screen-detail">{event.detail}</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="hermes-cockpit-status">
|
||||
{syncState === 'offline' ? <WifiOff size={12} /> : <CheckCircle2 size={12} />}
|
||||
<span>{syncState === 'offline' ? 'HERMES OFFLINE' : syncState === 'syncing' ? 'SYNC...' : 'HERMES ONLINE'}</span>
|
||||
{lastSyncAt && <span>{new Date(lastSyncAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>}
|
||||
</div>
|
||||
{syncState === 'offline' && syncDetail && <div className="hermes-cockpit-error">{previewText(syncDetail, 120)}</div>}
|
||||
<div className="hermes-cockpit-tabs">
|
||||
<button className={activeTab === 'tools' ? 'active' : ''} onClick={() => setActiveTab('tools')} title="Outils Hermes"><Wrench size={13} /></button>
|
||||
<button className={activeTab === 'crons' ? 'active' : ''} onClick={() => setActiveTab('crons')} title="Crons Hermes"><CalendarClock size={13} /></button>
|
||||
<button className={activeTab === 'history' ? 'active' : ''} onClick={() => setActiveTab('history')} title="Historique des crons">
|
||||
<History size={13} />
|
||||
{unreadRuns > 0 && <span className="hermes-tab-badge">{unreadRuns}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'tools' && renderTools()}
|
||||
{activeTab === 'crons' && renderCrons()}
|
||||
{activeTab === 'history' && renderHistory()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -524,6 +758,14 @@ export const App: React.FC = () => {
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]);
|
||||
const [hermesJobs, setHermesJobs] = useState<HermesJob[]>([]);
|
||||
const [hermesRunHistory, setHermesRunHistory] = useState<HermesRunRecord[]>([]);
|
||||
const [hermesLastSyncAt, setHermesLastSyncAt] = useState<string>('');
|
||||
const [hermesSyncState, setHermesSyncState] = useState<HermesSyncState>('idle');
|
||||
const [hermesSyncDetail, setHermesSyncDetail] = useState<string>('');
|
||||
const [hermesCronDraft, setHermesCronDraft] = useState<HermesJobDraft>(() => createDefaultCronDraft());
|
||||
const [editingHermesJobId, setEditingHermesJobId] = useState<string | null>(null);
|
||||
const [isSavingHermesCron, setIsSavingHermesCron] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [currentlyPlayingMsgId, setCurrentlyPlayingMsgId] = useState<string | null>(null);
|
||||
|
||||
@@ -658,6 +900,151 @@ export const App: React.FC = () => {
|
||||
|
||||
|
||||
// Session Hermes persistante — garantit la continuité mémoire/skills entre redémarrages
|
||||
const persistHermesCronCache = (cache: HermesCronCache) => {
|
||||
persistWrite('eveflow_hermes_cron_cache', JSON.stringify(cache));
|
||||
};
|
||||
|
||||
const mergeHermesJobs = (jobs: HermesJob[], syncedAt: string) => {
|
||||
setHermesJobs(jobs);
|
||||
setHermesLastSyncAt(syncedAt);
|
||||
setHermesRunHistory(prev => {
|
||||
const previousById = new Map(prev.map(run => [run.id, run]));
|
||||
const incoming = jobs
|
||||
.map(buildRunRecordFromJob)
|
||||
.filter((run): run is HermesRunRecord => !!run)
|
||||
.map(run => ({ ...run, read: previousById.get(run.id)?.read ?? false }));
|
||||
const merged = [...incoming, ...prev.filter(run => !incoming.some(next => next.id === run.id))]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 80);
|
||||
|
||||
persistHermesCronCache({ jobs, runs: merged, lastSyncAt: syncedAt });
|
||||
|
||||
const newRuns = incoming.filter(run => !previousById.has(run.id));
|
||||
if (newRuns.length > 0) {
|
||||
const newest = newRuns[0];
|
||||
setMessages(messagesPrev => [...messagesPrev, {
|
||||
id: newest.id,
|
||||
role: 'assistant',
|
||||
content: newest.result,
|
||||
source: 'cron',
|
||||
timestamp: new Date(newest.at)
|
||||
}]);
|
||||
setCurrentEmotion(newest.status === 'failed' ? 'sad' : 'happy');
|
||||
if (!isMuted && audioServiceRef.current && newest.status === 'succeeded') {
|
||||
audioServiceRef.current.speak(newest.result, selectedVoice, ttsRate, 1.1, ttsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshHermesJobs = async () => {
|
||||
if (!pollingServiceRef.current || agentConfig.provider !== 'hermes') return;
|
||||
try {
|
||||
setHermesSyncState('syncing');
|
||||
const snapshot = await pollingServiceRef.current.refresh();
|
||||
mergeHermesJobs(snapshot.jobs, snapshot.syncedAt);
|
||||
setHermesSyncState('online');
|
||||
setHermesSyncDetail('');
|
||||
} catch (e: any) {
|
||||
setHermesSyncState('offline');
|
||||
setHermesSyncDetail(e?.message || String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const finishHermesCronMutation = async (successLabel: string) => {
|
||||
pushToolEvent({ label: successLabel, status: 'done' });
|
||||
await refreshHermesJobs();
|
||||
};
|
||||
|
||||
const handleCreateHermesCron = async () => {
|
||||
if (!pollingServiceRef.current) return;
|
||||
setIsSavingHermesCron(true);
|
||||
pushToolEvent({ label: 'Creation cron Hermes', status: 'running', detail: hermesCronDraft.schedule });
|
||||
try {
|
||||
await pollingServiceRef.current.createJob(hermesCronDraft);
|
||||
setHermesCronDraft(createDefaultCronDraft());
|
||||
await finishHermesCronMutation('Cron Hermes cree');
|
||||
} catch (e: any) {
|
||||
const message = e?.message || String(e);
|
||||
setErrorMessage(`Cron Hermes: ${message}`);
|
||||
pushToolEvent({ label: 'Creation cron Hermes', status: 'error', detail: message });
|
||||
} finally {
|
||||
setIsSavingHermesCron(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEditHermesCron = (job: HermesJob) => {
|
||||
setEditingHermesJobId(getHermesJobId(job));
|
||||
setHermesCronDraft({
|
||||
name: job.name || getHermesJobId(job),
|
||||
schedule: formatHermesSchedule(job.schedule),
|
||||
prompt: job.prompt || '',
|
||||
deliver: typeof job.deliver === 'string' ? job.deliver : 'local'
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEditHermesCron = () => {
|
||||
setEditingHermesJobId(null);
|
||||
setHermesCronDraft(createDefaultCronDraft());
|
||||
};
|
||||
|
||||
const handleUpdateHermesCron = async () => {
|
||||
if (!pollingServiceRef.current || !editingHermesJobId) return;
|
||||
setIsSavingHermesCron(true);
|
||||
pushToolEvent({ label: 'Mise a jour cron Hermes', status: 'running', detail: editingHermesJobId });
|
||||
try {
|
||||
await pollingServiceRef.current.updateJob(editingHermesJobId, hermesCronDraft);
|
||||
handleCancelEditHermesCron();
|
||||
await finishHermesCronMutation('Cron Hermes mis a jour');
|
||||
} catch (e: any) {
|
||||
const message = e?.message || String(e);
|
||||
setErrorMessage(`Cron Hermes: ${message}`);
|
||||
pushToolEvent({ label: 'Mise a jour cron Hermes', status: 'error', detail: message });
|
||||
} finally {
|
||||
setIsSavingHermesCron(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runHermesCronAction = async (job: HermesJob, action: 'pause' | 'resume' | 'run' | 'delete') => {
|
||||
if (!pollingServiceRef.current) return;
|
||||
const jobId = getHermesJobId(job);
|
||||
const labels = {
|
||||
pause: 'Pause cron Hermes',
|
||||
resume: 'Reprise cron Hermes',
|
||||
run: 'Execution immediate Hermes',
|
||||
delete: 'Suppression cron Hermes'
|
||||
};
|
||||
pushToolEvent({ label: labels[action], status: 'running', detail: job.name || jobId });
|
||||
try {
|
||||
if (action === 'pause') await pollingServiceRef.current.pauseJob(jobId);
|
||||
if (action === 'resume') await pollingServiceRef.current.resumeJob(jobId);
|
||||
if (action === 'run') await pollingServiceRef.current.runJob(jobId);
|
||||
if (action === 'delete') await pollingServiceRef.current.deleteJob(jobId);
|
||||
await finishHermesCronMutation(`${labels[action]} OK`);
|
||||
} catch (e: any) {
|
||||
const message = e?.message || String(e);
|
||||
setErrorMessage(`Cron Hermes: ${message}`);
|
||||
pushToolEvent({ label: labels[action], status: 'error', detail: message });
|
||||
}
|
||||
};
|
||||
|
||||
const openHermesRunInChat = (run: HermesRunRecord) => {
|
||||
setHermesRunHistory(prev => {
|
||||
const updated = prev.map(item => item.id === run.id ? { ...item, read: true } : item);
|
||||
persistHermesCronCache({ jobs: hermesJobs, runs: updated, lastSyncAt: hermesLastSyncAt });
|
||||
return updated;
|
||||
});
|
||||
setMessages(prev => [...prev, {
|
||||
id: `cron-open-${run.id}`,
|
||||
role: 'assistant',
|
||||
content: run.result,
|
||||
source: 'cron',
|
||||
timestamp: new Date(run.at)
|
||||
}]);
|
||||
};
|
||||
|
||||
const [hermesSessionId, setHermesSessionId] = useState<string>('');
|
||||
|
||||
// Références de services
|
||||
@@ -779,6 +1166,18 @@ export const App: React.FC = () => {
|
||||
});
|
||||
|
||||
// Charger les voix système
|
||||
persistRead('eveflow_hermes_cron_cache').then(savedCache => {
|
||||
if (!savedCache) return;
|
||||
try {
|
||||
const parsed = JSON.parse(savedCache) as HermesCronCache;
|
||||
if (Array.isArray(parsed.jobs)) setHermesJobs(parsed.jobs);
|
||||
if (Array.isArray(parsed.runs)) setHermesRunHistory(parsed.runs);
|
||||
if (parsed.lastSyncAt) setHermesLastSyncAt(parsed.lastSyncAt);
|
||||
} catch (e) {
|
||||
console.warn('Cache Hermes crons illisible', e);
|
||||
}
|
||||
});
|
||||
|
||||
const loadVoices = () => {
|
||||
const voices = service.getVoices();
|
||||
setAvailableVoices(voices);
|
||||
@@ -818,6 +1217,7 @@ export const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (agentConfig.provider !== 'hermes') {
|
||||
pollingServiceRef.current?.stop();
|
||||
setHermesSyncState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -825,21 +1225,18 @@ export const App: React.FC = () => {
|
||||
pollingServiceRef.current = new PollingService(30000);
|
||||
}
|
||||
|
||||
pollingServiceRef.current.start(agentConfig.hermesUrl, agentConfig.hermesApiKey, (job) => {
|
||||
if (job.result) {
|
||||
setMessages(prev => [...prev, {
|
||||
id: Math.random().toString(),
|
||||
role: 'assistant',
|
||||
content: job.result!,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
// Trigger generic emotion for a pushed job
|
||||
setCurrentEmotion('happy');
|
||||
if (!isMuted && audioServiceRef.current) {
|
||||
audioServiceRef.current.speak(job.result, selectedVoice, ttsRate, 1.1, ttsProvider);
|
||||
}
|
||||
pollingServiceRef.current.start(
|
||||
agentConfig.hermesUrl,
|
||||
agentConfig.hermesApiKey,
|
||||
(snapshot) => {
|
||||
mergeHermesJobs(snapshot.jobs, snapshot.syncedAt);
|
||||
setHermesSyncDetail('');
|
||||
},
|
||||
(status, detail) => {
|
||||
setHermesSyncState(status);
|
||||
setHermesSyncDetail(detail || '');
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
return () => pollingServiceRef.current?.stop();
|
||||
}, [agentConfig.provider, agentConfig.hermesUrl, agentConfig.hermesApiKey, isMuted, selectedVoice, ttsRate, ttsProvider]);
|
||||
@@ -1817,9 +2214,28 @@ export const App: React.FC = () => {
|
||||
avatarId={avatarId}
|
||||
/>
|
||||
{agentConfig.provider === 'hermes' && (
|
||||
<ToolMonitorWindow
|
||||
<HermesCockpitWindow
|
||||
events={toolEvents}
|
||||
isWorking={isSending}
|
||||
jobs={hermesJobs}
|
||||
runs={hermesRunHistory}
|
||||
syncState={hermesSyncState}
|
||||
syncDetail={hermesSyncDetail}
|
||||
lastSyncAt={hermesLastSyncAt}
|
||||
draft={hermesCronDraft}
|
||||
editingJobId={editingHermesJobId}
|
||||
isSaving={isSavingHermesCron}
|
||||
onDraftChange={setHermesCronDraft}
|
||||
onCreate={handleCreateHermesCron}
|
||||
onCancelEdit={handleCancelEditHermesCron}
|
||||
onStartEdit={handleStartEditHermesCron}
|
||||
onUpdate={handleUpdateHermesCron}
|
||||
onRefresh={refreshHermesJobs}
|
||||
onPause={(job) => runHermesCronAction(job, 'pause')}
|
||||
onResume={(job) => runHermesCronAction(job, 'resume')}
|
||||
onRun={(job) => runHermesCronAction(job, 'run')}
|
||||
onDelete={(job) => runHermesCronAction(job, 'delete')}
|
||||
onOpenRun={openHermesRunInChat}
|
||||
/>
|
||||
)}
|
||||
{avatarId !== 'eve' && activeAvatar.assetPath && (
|
||||
|
||||
+314
-5
@@ -241,10 +241,38 @@ body {
|
||||
animation: ambient-breathe 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.avatar-ambient-field::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 34%, rgba(138, 247, 255, 0.42) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 38% 28%, rgba(0, 229, 255, 0.32) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 62% 30%, rgba(0, 229, 255, 0.32) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 32% 48%, rgba(138, 247, 255, 0.28) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 68% 50%, rgba(138, 247, 255, 0.28) 0 2px, transparent 3px),
|
||||
linear-gradient(32deg, transparent 36%, rgba(0, 229, 255, 0.24) 36.4%, transparent 37.2%),
|
||||
linear-gradient(-31deg, transparent 38%, rgba(0, 229, 255, 0.22) 38.4%, transparent 39.2%),
|
||||
linear-gradient(90deg, transparent 41%, rgba(138, 247, 255, 0.2) 41.3%, transparent 42%);
|
||||
filter: drop-shadow(0 0 10px rgba(0, 229, 255, 0.34));
|
||||
transform: scale(0.94);
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.avatar-ambient-field.thinking::before {
|
||||
background:
|
||||
radial-gradient(circle at 50% 32%, rgba(138, 247, 255, 0.26), transparent 24%),
|
||||
radial-gradient(circle at 50% 42%, rgba(0, 229, 255, 0.18), transparent 48%);
|
||||
animation-duration: 3.4s;
|
||||
}
|
||||
|
||||
.avatar-ambient-field.thinking::after {
|
||||
opacity: 0.86;
|
||||
transform: scale(1);
|
||||
animation: neural-grid-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-scanline {
|
||||
position: absolute;
|
||||
left: -15%;
|
||||
@@ -256,6 +284,8 @@ body {
|
||||
}
|
||||
|
||||
.avatar-ambient-field.thinking .ambient-scanline {
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 195, 255, 0.84), transparent);
|
||||
box-shadow: 0 0 20px rgba(0, 195, 255, 0.58);
|
||||
opacity: 0.55;
|
||||
height: 4px;
|
||||
}
|
||||
@@ -335,6 +365,21 @@ body {
|
||||
50% { transform: scale(1.06); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@keyframes neural-grid-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.58;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 229, 255, 0.28));
|
||||
}
|
||||
45% {
|
||||
opacity: 0.95;
|
||||
filter: drop-shadow(0 0 18px rgba(138, 247, 255, 0.52));
|
||||
}
|
||||
70% {
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 12px rgba(0, 229, 255, 0.38));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanline-drift-a {
|
||||
0%, 100% { transform: translateY(-35px); }
|
||||
50% { transform: translateY(72px); }
|
||||
@@ -368,7 +413,7 @@ body {
|
||||
left: clamp(14px, 12%, 90px);
|
||||
right: clamp(14px, 12%, 90px);
|
||||
bottom: clamp(50px, 11vh, 118px);
|
||||
height: clamp(58px, 20vh, 220px);
|
||||
height: clamp(120px, 36vh, 390px);
|
||||
min-width: 0;
|
||||
padding: clamp(5px, 1vw, 10px);
|
||||
border: 1px solid rgba(0, 229, 255, 0.62);
|
||||
@@ -469,6 +514,270 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hermes-cockpit-window {
|
||||
height: clamp(240px, 52vh, 430px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hermes-cockpit-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 22px;
|
||||
padding: 5px 0;
|
||||
font-size: clamp(7px, 1.1vw, 10px);
|
||||
font-weight: 800;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid rgba(0, 229, 255, 0.18);
|
||||
}
|
||||
|
||||
.hermes-cockpit-status span:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tool-screen-led.offline {
|
||||
background: #ff6b6b;
|
||||
box-shadow: 0 0 12px #ff6b6b;
|
||||
}
|
||||
|
||||
.tool-screen-led.syncing {
|
||||
animation: pulse-led 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hermes-cockpit-error {
|
||||
padding: 4px 0;
|
||||
font-size: clamp(7px, 1vw, 9px);
|
||||
color: #ff8f8f;
|
||||
border-bottom: 1px solid rgba(255, 107, 107, 0.22);
|
||||
}
|
||||
|
||||
.hermes-cockpit-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.hermes-cockpit-tabs button,
|
||||
.hermes-icon-btn {
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.26);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 212, 245, 0.07);
|
||||
color: var(--text-neon);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hermes-cockpit-tabs button:hover,
|
||||
.hermes-cockpit-tabs button.active,
|
||||
.hermes-icon-btn:hover,
|
||||
.hermes-icon-btn.primary {
|
||||
border-color: rgba(138, 247, 255, 0.8);
|
||||
background: rgba(0, 212, 245, 0.18);
|
||||
box-shadow: 0 0 12px rgba(0, 212, 245, 0.18);
|
||||
}
|
||||
|
||||
.hermes-icon-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hermes-icon-btn.danger {
|
||||
color: #ff8f8f;
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
|
||||
.hermes-tab-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
font-size: 8px;
|
||||
line-height: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.hermes-cockpit-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 229, 255, 0.38) transparent;
|
||||
}
|
||||
|
||||
.hermes-cron-panel,
|
||||
.hermes-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
height: calc(100% - 86px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hermes-cron-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 6px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid rgba(0, 229, 255, 0.22);
|
||||
}
|
||||
|
||||
.hermes-cockpit-input,
|
||||
.hermes-cockpit-textarea {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(0, 229, 255, 0.24);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(8px, 1.1vw, 10px);
|
||||
padding: 6px 7px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hermes-cockpit-input:focus,
|
||||
.hermes-cockpit-textarea:focus {
|
||||
border-color: rgba(138, 247, 255, 0.75);
|
||||
box-shadow: 0 0 10px rgba(0, 212, 245, 0.14);
|
||||
}
|
||||
|
||||
.hermes-cockpit-textarea {
|
||||
grid-column: 1 / 3;
|
||||
min-height: 38px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.hermes-cron-editor-actions {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hermes-jobs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.hermes-job-row,
|
||||
.hermes-run-row {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 212, 245, 0.055);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hermes-job-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 7px;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.hermes-job-row.failed,
|
||||
.hermes-run-row.failed {
|
||||
border-color: rgba(255, 107, 107, 0.38);
|
||||
}
|
||||
|
||||
.hermes-job-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.hermes-job-name {
|
||||
font-weight: 900;
|
||||
color: var(--text-neon);
|
||||
font-size: clamp(8px, 1.1vw, 10px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hermes-job-meta,
|
||||
.hermes-job-result {
|
||||
font-size: clamp(7px, 1vw, 9px);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hermes-job-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 24px);
|
||||
gap: 5px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.hermes-run-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr auto;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hermes-run-row.unread {
|
||||
border-color: rgba(112, 255, 168, 0.42);
|
||||
background: rgba(112, 255, 168, 0.055);
|
||||
}
|
||||
|
||||
.hermes-run-status {
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
color: #70ffa8;
|
||||
}
|
||||
|
||||
.hermes-run-row.failed .hermes-run-status {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.hermes-run-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.hermes-run-content strong,
|
||||
.hermes-run-content span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hermes-run-content strong {
|
||||
color: var(--text-neon);
|
||||
font-size: clamp(8px, 1.1vw, 10px);
|
||||
}
|
||||
|
||||
.hermes-run-content span,
|
||||
.hermes-run-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: clamp(7px, 1vw, 9px);
|
||||
}
|
||||
|
||||
/* Message bubbles: Capsule-shaped pilot styling */
|
||||
.message-row {
|
||||
display: flex;
|
||||
@@ -851,7 +1160,7 @@ select.glow-input {
|
||||
left: clamp(10px, 18%, 120px);
|
||||
right: clamp(10px, 18%, 120px);
|
||||
bottom: clamp(46px, 9vh, 64px);
|
||||
height: clamp(54px, 12vh, 104px);
|
||||
height: clamp(96px, 22vh, 210px);
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
@@ -948,7 +1257,7 @@ select.glow-input {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: clamp(42px, 8vh, 56px);
|
||||
height: clamp(52px, 11vh, 72px);
|
||||
height: clamp(88px, 20vh, 150px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,14 +1271,14 @@ select.glow-input {
|
||||
@media (max-height: 780px) {
|
||||
.tool-screen-window {
|
||||
bottom: clamp(44px, 9vh, 62px);
|
||||
height: clamp(58px, 14vh, 112px);
|
||||
height: clamp(96px, 24vh, 210px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 660px) {
|
||||
.tool-screen-window {
|
||||
bottom: clamp(38px, 8vh, 52px);
|
||||
height: clamp(48px, 13vh, 76px);
|
||||
height: clamp(82px, 21vh, 150px);
|
||||
}
|
||||
|
||||
.tool-screen-header {
|
||||
|
||||
+163
-64
@@ -1,122 +1,221 @@
|
||||
/**
|
||||
* PollingService — Periodically polls Hermes Agent /api/jobs endpoint
|
||||
* to receive proactive notifications from the AI (scheduled tasks, alerts).
|
||||
* PollingService — synchronizes Hermes /api/jobs for scheduled work.
|
||||
*
|
||||
* Runs as a background timer. Calls the provided callback whenever a
|
||||
* newly completed job is detected.
|
||||
* Hermes remains the source of truth. EveFlow polls as a client, keeps a local
|
||||
* display cache, and uses the same service for CRUD actions on remote jobs.
|
||||
*/
|
||||
|
||||
import { LogService } from './logService';
|
||||
|
||||
export type HermesJobStatus =
|
||||
| 'pending'
|
||||
| 'scheduled'
|
||||
| 'paused'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'ok'
|
||||
| string;
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string | { kind?: string; expr?: string; display?: string };
|
||||
status?: HermesJobStatus;
|
||||
state?: HermesJobStatus;
|
||||
enabled?: boolean;
|
||||
result?: string;
|
||||
output?: string;
|
||||
last_result?: string;
|
||||
last_output?: string;
|
||||
error?: string;
|
||||
last_error?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
next_run_at?: string;
|
||||
last_run_at?: string;
|
||||
completed_at?: string;
|
||||
last_status?: string;
|
||||
repeat?: { times?: number | null; completed?: number };
|
||||
skills?: string[];
|
||||
deliver?: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type JobHandler = (job: HermesJob) => void;
|
||||
export interface HermesJobDraft {
|
||||
name: string;
|
||||
schedule: string;
|
||||
prompt: string;
|
||||
deliver?: string;
|
||||
}
|
||||
|
||||
export interface HermesSyncSnapshot {
|
||||
jobs: HermesJob[];
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export type JobsSyncHandler = (snapshot: HermesSyncSnapshot) => void;
|
||||
export type SyncStatusHandler = (status: 'syncing' | 'online' | 'offline', detail?: string) => void;
|
||||
|
||||
const normalizeBaseUrl = (hermesUrl: string): string => (
|
||||
hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '')
|
||||
);
|
||||
|
||||
const parseJobsPayload = (payload: unknown): HermesJob[] => {
|
||||
if (Array.isArray(payload)) return payload as HermesJob[];
|
||||
if (payload && typeof payload === 'object') {
|
||||
const maybeJobs = (payload as { jobs?: unknown }).jobs;
|
||||
if (Array.isArray(maybeJobs)) return maybeJobs as HermesJob[];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export class PollingService {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private seenJobIds: Set<string> = new Set();
|
||||
private hermesUrl: string = '';
|
||||
private hermesApiKey: string = '';
|
||||
private onJobCompleted: JobHandler | null = null;
|
||||
private onSync: JobsSyncHandler | null = null;
|
||||
private onStatus: SyncStatusHandler | null = null;
|
||||
private intervalMs: number;
|
||||
|
||||
constructor(intervalMs: number = 30_000) {
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling Hermes Agent for completed jobs.
|
||||
*
|
||||
* @param hermesUrl - Base URL of the Hermes server
|
||||
* @param apiKey - Bearer token for authentication
|
||||
* @param onCompleted - Callback fired when a new completed job is detected
|
||||
*/
|
||||
public start(hermesUrl: string, apiKey: string, onCompleted: JobHandler): void {
|
||||
this.stop(); // clear any existing interval
|
||||
public start(
|
||||
hermesUrl: string,
|
||||
apiKey: string,
|
||||
onSync: JobsSyncHandler,
|
||||
onStatus?: SyncStatusHandler
|
||||
): void {
|
||||
this.stop();
|
||||
this.hermesUrl = hermesUrl;
|
||||
this.hermesApiKey = apiKey;
|
||||
this.onJobCompleted = onCompleted;
|
||||
this.onSync = onSync;
|
||||
this.onStatus = onStatus || null;
|
||||
|
||||
LogService.info('PollingService', `Démarrage polling Hermes toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
|
||||
LogService.info('PollingService', `Synchronisation Hermes jobs toutes les ${this.intervalMs / 1000}s`, { hermesUrl });
|
||||
|
||||
// Fire once immediately, then on interval
|
||||
this.poll();
|
||||
this.intervalId = setInterval(() => this.poll(), this.intervalMs);
|
||||
this.sync();
|
||||
this.intervalId = setInterval(() => this.sync(), this.intervalMs);
|
||||
}
|
||||
|
||||
/** Stop the polling loop. */
|
||||
public stop(): void {
|
||||
if (this.intervalId !== null) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
LogService.info('PollingService', 'Polling Hermes arrêté.');
|
||||
LogService.info('PollingService', 'Synchronisation Hermes jobs arrêtée.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if currently polling. */
|
||||
public get isActive(): boolean {
|
||||
return this.intervalId !== null;
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.hermesUrl || !this.onJobCompleted) return;
|
||||
public async refresh(): Promise<HermesSyncSnapshot> {
|
||||
return this.fetchJobs();
|
||||
}
|
||||
|
||||
const base = this.hermesUrl.replace(/\/$/, '').replace(/\/v1(\/.*)?$/, '');
|
||||
const endpoint = `${base}/api/jobs`;
|
||||
public async createJob(draft: HermesJobDraft): Promise<HermesJob> {
|
||||
return this.requestJob('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: draft.name,
|
||||
schedule: draft.schedule,
|
||||
prompt: draft.prompt,
|
||||
...(draft.deliver ? { deliver: draft.deliver } : {})
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public async updateJob(jobId: string, patch: Partial<HermesJobDraft>): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteJob(jobId: string): Promise<void> {
|
||||
await this.requestRaw(`/${encodeURIComponent(jobId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
public async pauseJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
public async resumeJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/resume`, { method: 'POST' });
|
||||
}
|
||||
|
||||
public async runJob(jobId: string): Promise<HermesJob> {
|
||||
return this.requestJob(`/${encodeURIComponent(jobId)}/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
private async sync(): Promise<void> {
|
||||
if (!this.hermesUrl || !this.onSync) return;
|
||||
|
||||
try {
|
||||
this.onStatus?.('syncing');
|
||||
const snapshot = await this.fetchJobs();
|
||||
this.onSync(snapshot);
|
||||
this.onStatus?.('online');
|
||||
} catch (e: any) {
|
||||
const message = e?.message || String(e);
|
||||
LogService.warn('PollingService', `Synchronisation Hermes jobs impossible: ${message}`);
|
||||
this.onStatus?.('offline', message);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJobs(): Promise<HermesSyncSnapshot> {
|
||||
const payload = await this.requestRaw('');
|
||||
return {
|
||||
jobs: parseJobsPayload(payload),
|
||||
syncedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async requestJob(path: string, init: RequestInit): Promise<HermesJob> {
|
||||
const payload = await this.requestRaw(path, init);
|
||||
if (payload && typeof payload === 'object' && 'job' in payload) {
|
||||
return (payload as { job: HermesJob }).job;
|
||||
}
|
||||
return payload as HermesJob;
|
||||
}
|
||||
|
||||
private async requestRaw(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
if (!this.hermesUrl) throw new Error('URL Hermes manquante');
|
||||
|
||||
const base = normalizeBaseUrl(this.hermesUrl);
|
||||
const endpoint = `${base}/api/jobs${path}`;
|
||||
const abortCtrl = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 10_000); // 10s max
|
||||
const timeoutId = setTimeout(() => abortCtrl.abort(), 12_000);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.hermesApiKey) headers['Authorization'] = `Bearer ${this.hermesApiKey.trim()}`;
|
||||
if (this.hermesApiKey) headers.Authorization = `Bearer ${this.hermesApiKey.trim()}`;
|
||||
|
||||
const response = await fetch(endpoint, { method: 'GET', headers, signal: abortCtrl.signal });
|
||||
const response = await fetch(endpoint, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers || {}) },
|
||||
signal: abortCtrl.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
LogService.warn('PollingService', `Erreur HTTP /api/jobs: ${response.status}`);
|
||||
return;
|
||||
const raw = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}${raw ? `: ${raw.slice(0, 180)}` : ''}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const jobs: HermesJob[] = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray(payload?.jobs)
|
||||
? payload.jobs
|
||||
: [];
|
||||
|
||||
if (!Array.isArray(jobs)) {
|
||||
LogService.warn('PollingService', 'Format /api/jobs inattendu', { preview: JSON.stringify(payload).slice(0, 200) });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === 'completed' && job.id && !this.seenJobIds.has(job.id)) {
|
||||
this.seenJobIds.add(job.id);
|
||||
LogService.info('PollingService', `Nouveau job complété: ${job.id}`, { result: job.result?.slice(0, 200) });
|
||||
this.onJobCompleted(job);
|
||||
}
|
||||
}
|
||||
|
||||
// Évite une fuite mémoire sur les sessions longues
|
||||
if (this.seenJobIds.size > 500) {
|
||||
const arr = [...this.seenJobIds];
|
||||
this.seenJobIds = new Set(arr.slice(-250));
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch (e: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((e as any).name === 'AbortError') {
|
||||
LogService.warn('PollingService', 'Poll timeout (10s) — serveur Hermes trop lent ou injoignable');
|
||||
return;
|
||||
}
|
||||
LogService.warn('PollingService', `Exception lors du poll: ${e.message}`);
|
||||
if (e?.name === 'AbortError') throw new Error('Timeout Hermes /api/jobs');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user