Add Hermes cron synchronization cockpit

This commit is contained in:
Michael committed 2026-05-21 10:03:41 +02:00
1 parent 1caa738dfb
commit 17e573911d
3 files changed
+929 -105

No files matched your search

+452 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}