mirror of
https://github.com/hanzoskill/llm-council.git
synced 2026-10-30 21:14:09 +00:00
461 lines
14 KiB
JavaScript
461 lines
14 KiB
JavaScript
(() => {
|
|
const byId = (id) => document.getElementById(id);
|
|
|
|
const elements = {
|
|
runId: byId('runId'),
|
|
phase: byId('phase'),
|
|
taskBrief: byId('taskBrief'),
|
|
plannerSummary: byId('plannerSummary'),
|
|
plannerSelect: byId('plannerSelect'),
|
|
plannerId: byId('plannerId'),
|
|
plannerStatus: byId('plannerStatus'),
|
|
plannerSummaryText: byId('plannerSummaryText'),
|
|
plannerErrors: byId('plannerErrors'),
|
|
judgeStatus: byId('judgeStatus'),
|
|
judgeSummary: byId('judgeSummary'),
|
|
judgeErrors: byId('judgeErrors'),
|
|
finalPlanStatus: byId('finalPlanStatus'),
|
|
finalPlanEditor: byId('finalPlanEditor'),
|
|
finalPlanPreview: byId('finalPlanPreview'),
|
|
previewPane: byId('previewPane'),
|
|
editToggle: byId('editToggle'),
|
|
previewToggle: byId('previewToggle'),
|
|
resetLatest: byId('resetLatest'),
|
|
acceptBtn: byId('acceptBtn'),
|
|
saveBtn: byId('saveBtn'),
|
|
saveStatus: byId('saveStatus'),
|
|
refineContext: byId('refineContext'),
|
|
refineBtn: byId('refineBtn'),
|
|
refineStatus: byId('refineStatus'),
|
|
connectionStatus: byId('connectionStatus'),
|
|
lastUpdated: byId('lastUpdated'),
|
|
sessionCountdown: byId('sessionCountdown'),
|
|
keepOpenToggle: byId('keepOpenToggle'),
|
|
keepOpenStatus: byId('keepOpenStatus')
|
|
};
|
|
|
|
const token = new URLSearchParams(window.location.search).get('token');
|
|
const stateEndpoint = '/api/state';
|
|
const eventsEndpoint = token ? `/events?token=${encodeURIComponent(token)}` : '/events';
|
|
|
|
let currentState = {
|
|
run_id: '',
|
|
task_brief: '',
|
|
phase: '',
|
|
planners: [],
|
|
judge: { status: '', summary: '', errors: [] },
|
|
final_plan: '',
|
|
errors: [],
|
|
timestamps: {}
|
|
};
|
|
|
|
let latestFinalPlan = '';
|
|
let editorDirty = false;
|
|
let editorLocked = false;
|
|
let previewVisible = true;
|
|
let selectedPlannerId = '';
|
|
let sessionDeadline = null;
|
|
let sessionKeepOpen = false;
|
|
let sessionTimer = null;
|
|
|
|
const setText = (el, value, fallback = '—') => {
|
|
if (!el) {
|
|
return;
|
|
}
|
|
const text = typeof value === 'string' && value.trim() ? value : fallback;
|
|
el.textContent = text;
|
|
};
|
|
|
|
const setStatus = (el, message, tone) => {
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.textContent = message;
|
|
if (tone) {
|
|
el.dataset.tone = tone;
|
|
} else {
|
|
delete el.dataset.tone;
|
|
}
|
|
};
|
|
|
|
const attemptCloseUi = () => {
|
|
try {
|
|
window.close();
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
setTimeout(() => {
|
|
if (!window.closed) {
|
|
setStatus(elements.saveStatus, 'accepted — you can close this tab');
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const formatTimestamp = (value) => {
|
|
if (!value) {
|
|
return '—';
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
return date.toLocaleTimeString();
|
|
};
|
|
|
|
const updateLastUpdated = (value) => {
|
|
setText(elements.lastUpdated, `last update: ${formatTimestamp(value)}`);
|
|
};
|
|
|
|
const formatRemaining = (ms) => {
|
|
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
const pad = (value) => String(value).padStart(2, '0');
|
|
if (hours > 0) {
|
|
return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
|
}
|
|
return `${minutes}:${pad(seconds)}`;
|
|
};
|
|
|
|
const updateSessionCountdown = () => {
|
|
if (!elements.sessionCountdown) {
|
|
return;
|
|
}
|
|
if (sessionKeepOpen) {
|
|
setText(elements.sessionCountdown, 'session: keep open');
|
|
return;
|
|
}
|
|
if (!sessionDeadline) {
|
|
setText(elements.sessionCountdown, 'session: —');
|
|
return;
|
|
}
|
|
const remaining = sessionDeadline - Date.now();
|
|
if (remaining <= 0) {
|
|
setText(elements.sessionCountdown, 'session: closing…');
|
|
return;
|
|
}
|
|
setText(elements.sessionCountdown, `session: ${formatRemaining(remaining)} left`);
|
|
};
|
|
|
|
const setSessionState = (state) => {
|
|
sessionKeepOpen = Boolean(state?.keep_open);
|
|
sessionDeadline = state?.ui_deadline ? new Date(state.ui_deadline).getTime() : null;
|
|
if (elements.keepOpenToggle) {
|
|
elements.keepOpenToggle.checked = sessionKeepOpen;
|
|
}
|
|
if (!sessionTimer) {
|
|
sessionTimer = window.setInterval(updateSessionCountdown, 1000);
|
|
}
|
|
updateSessionCountdown();
|
|
};
|
|
|
|
const setStatusLink = (el, message, url) => {
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.textContent = '';
|
|
const text = message || '';
|
|
if (text) {
|
|
el.appendChild(document.createTextNode(text + ' '));
|
|
}
|
|
if (url) {
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.textContent = 'Open new run';
|
|
link.target = '_blank';
|
|
link.rel = 'noopener';
|
|
el.appendChild(link);
|
|
}
|
|
};
|
|
|
|
const updatePlannerList = (planners) => {
|
|
if (!elements.plannerSelect) {
|
|
return;
|
|
}
|
|
elements.plannerSelect.textContent = '';
|
|
if (!Array.isArray(planners) || planners.length === 0) {
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = 'Waiting for planner output…';
|
|
elements.plannerSelect.appendChild(option);
|
|
updatePlannerDetail(null);
|
|
return;
|
|
}
|
|
|
|
planners.forEach((planner) => {
|
|
const option = document.createElement('option');
|
|
option.value = planner.id || '';
|
|
option.textContent = planner.id || 'planner';
|
|
elements.plannerSelect.appendChild(option);
|
|
});
|
|
|
|
if (!selectedPlannerId || !planners.some((p) => p.id === selectedPlannerId)) {
|
|
selectedPlannerId = planners[0].id || '';
|
|
}
|
|
elements.plannerSelect.value = selectedPlannerId;
|
|
const active = planners.find((planner) => planner.id === selectedPlannerId) || planners[0];
|
|
updatePlannerDetail(active);
|
|
};
|
|
|
|
const updatePlannerDetail = (planner) => {
|
|
if (!planner) {
|
|
setText(elements.plannerId, '—');
|
|
setText(elements.plannerStatus, 'pending');
|
|
setText(elements.plannerSummaryText, '—');
|
|
if (elements.plannerErrors) {
|
|
elements.plannerErrors.textContent = '';
|
|
}
|
|
return;
|
|
}
|
|
setText(elements.plannerId, planner.id || 'planner');
|
|
setText(elements.plannerStatus, planner.status || 'pending');
|
|
setText(elements.plannerSummaryText, planner.summary || '—');
|
|
if (elements.plannerErrors) {
|
|
elements.plannerErrors.textContent = '';
|
|
if (Array.isArray(planner.errors) && planner.errors.length > 0) {
|
|
planner.errors.forEach((err) => {
|
|
const item = document.createElement('li');
|
|
item.textContent = err;
|
|
elements.plannerErrors.appendChild(item);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateJudge = (judge) => {
|
|
setText(elements.judgeStatus, judge?.status || 'pending');
|
|
setText(elements.judgeSummary, judge?.summary || '—');
|
|
elements.judgeErrors.textContent = '';
|
|
|
|
if (Array.isArray(judge?.errors) && judge.errors.length > 0) {
|
|
judge.errors.forEach((err) => {
|
|
const item = document.createElement('li');
|
|
item.textContent = err;
|
|
elements.judgeErrors.appendChild(item);
|
|
});
|
|
}
|
|
};
|
|
|
|
const updateFinalPlan = (planText) => {
|
|
latestFinalPlan = planText || '';
|
|
if (!editorDirty) {
|
|
elements.finalPlanEditor.value = latestFinalPlan;
|
|
}
|
|
elements.finalPlanPreview.textContent = latestFinalPlan || '—';
|
|
const status = editorDirty ? 'edited locally' : 'synced';
|
|
setText(elements.finalPlanStatus, status);
|
|
};
|
|
|
|
const applyState = (state) => {
|
|
currentState = state;
|
|
setText(elements.runId, `run: ${state.run_id || '—'}`);
|
|
setText(elements.phase, `phase: ${state.phase || '—'}`);
|
|
setText(elements.taskBrief, state.task_brief || '—');
|
|
|
|
const planners = Array.isArray(state.planners) ? state.planners : [];
|
|
elements.plannerSummary.textContent = `${planners.length} planners`;
|
|
updatePlannerList(planners);
|
|
|
|
updateJudge(state.judge || { status: 'pending', summary: '', errors: [] });
|
|
updateFinalPlan(state.final_plan || '');
|
|
|
|
updateLastUpdated(state.timestamps?.updated_at || new Date().toISOString());
|
|
setSessionState(state);
|
|
};
|
|
|
|
const fetchState = async () => {
|
|
try {
|
|
const response = await fetch(stateEndpoint, { cache: 'no-store' });
|
|
if (!response.ok) {
|
|
throw new Error('failed');
|
|
}
|
|
const payload = await response.json();
|
|
applyState(payload);
|
|
} catch (error) {
|
|
setStatus(elements.connectionStatus, 'state fetch failed');
|
|
}
|
|
};
|
|
|
|
const handleEvent = (message) => {
|
|
if (!message || typeof message !== 'object') {
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'phase_change') {
|
|
currentState.phase = message.payload?.phase || currentState.phase;
|
|
applyState(currentState);
|
|
updateLastUpdated(message.payload?.timestamp);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'planner_update') {
|
|
const planner = message.payload?.planner;
|
|
if (planner && planner.id) {
|
|
const planners = Array.isArray(currentState.planners) ? [...currentState.planners] : [];
|
|
const index = planners.findIndex((item) => item.id === planner.id);
|
|
if (index >= 0) {
|
|
planners[index] = planner;
|
|
} else {
|
|
planners.push(planner);
|
|
}
|
|
currentState.planners = planners;
|
|
}
|
|
applyState(currentState);
|
|
updateLastUpdated(message.payload?.timestamp);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'judge_update') {
|
|
if (message.payload?.judge) {
|
|
currentState.judge = message.payload.judge;
|
|
}
|
|
applyState(currentState);
|
|
updateLastUpdated(message.payload?.timestamp);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'final_plan') {
|
|
currentState.final_plan = message.payload?.final_plan || currentState.final_plan;
|
|
applyState(currentState);
|
|
updateLastUpdated(message.payload?.timestamp);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'session_update') {
|
|
currentState.keep_open = message.payload?.keep_open;
|
|
currentState.ui_deadline = message.payload?.ui_deadline;
|
|
setSessionState(currentState);
|
|
updateLastUpdated(message.payload?.timestamp);
|
|
return;
|
|
}
|
|
|
|
if (message.type === 'action_result') {
|
|
const action = message.payload?.action;
|
|
const status = message.payload?.message || message.payload?.status || 'updated';
|
|
if (action === 'save') {
|
|
setStatus(elements.saveStatus, status);
|
|
} else if (action === 'accept') {
|
|
setStatus(elements.saveStatus, status);
|
|
attemptCloseUi();
|
|
} else if (action === 'refine') {
|
|
setStatus(elements.refineStatus, status);
|
|
} else if (action === 'keepalive') {
|
|
setStatus(elements.keepOpenStatus, status);
|
|
}
|
|
updateLastUpdated(message.payload?.timestamp);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const connectEvents = () => {
|
|
if (!window.EventSource) {
|
|
setStatus(elements.connectionStatus, 'SSE not supported');
|
|
return;
|
|
}
|
|
|
|
const source = new EventSource(eventsEndpoint);
|
|
setStatus(elements.connectionStatus, 'connecting…');
|
|
|
|
source.onopen = () => {
|
|
setStatus(elements.connectionStatus, 'connected');
|
|
fetchState();
|
|
};
|
|
|
|
source.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
handleEvent(data);
|
|
} catch (error) {
|
|
setStatus(elements.connectionStatus, 'event parse error');
|
|
}
|
|
};
|
|
|
|
source.onerror = () => {
|
|
setStatus(elements.connectionStatus, 'reconnecting…');
|
|
};
|
|
};
|
|
|
|
const postAction = async (path, payload, statusEl) => {
|
|
if (!token) {
|
|
setStatus(statusEl, 'missing token', 'warning');
|
|
return;
|
|
}
|
|
const suppressQueued = path === '/api/save';
|
|
setStatus(statusEl, suppressQueued ? 'saving…' : 'sending…');
|
|
try {
|
|
const response = await fetch(path, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-UI-Token': token
|
|
},
|
|
body: JSON.stringify(payload || {})
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('action failed');
|
|
}
|
|
if (!suppressQueued) {
|
|
setStatus(statusEl, 'queued');
|
|
}
|
|
} catch (error) {
|
|
setStatus(statusEl, 'failed');
|
|
}
|
|
};
|
|
|
|
if (elements.finalPlanEditor) {
|
|
elements.finalPlanEditor.addEventListener('input', () => {
|
|
if (editorLocked) {
|
|
return;
|
|
}
|
|
editorDirty = elements.finalPlanEditor.value !== latestFinalPlan;
|
|
elements.finalPlanPreview.textContent = elements.finalPlanEditor.value || '—';
|
|
const status = editorDirty ? 'edited locally' : 'synced';
|
|
setText(elements.finalPlanStatus, status);
|
|
});
|
|
}
|
|
|
|
elements.acceptBtn.addEventListener('click', () => {
|
|
postAction('/api/accept', { final_plan: elements.finalPlanEditor.value }, elements.saveStatus);
|
|
});
|
|
|
|
elements.saveBtn.addEventListener('click', () => {
|
|
postAction('/api/save', { final_plan: elements.finalPlanEditor.value }, elements.saveStatus);
|
|
});
|
|
|
|
if (elements.refineBtn) {
|
|
elements.refineBtn.addEventListener('click', () => {
|
|
postAction(
|
|
'/api/refine',
|
|
{
|
|
context: elements.refineContext.value,
|
|
final_plan: elements.finalPlanEditor.value
|
|
},
|
|
elements.refineStatus
|
|
);
|
|
});
|
|
}
|
|
|
|
if (elements.plannerSelect) {
|
|
elements.plannerSelect.addEventListener('change', () => {
|
|
selectedPlannerId = elements.plannerSelect.value;
|
|
const planners = Array.isArray(currentState.planners) ? currentState.planners : [];
|
|
const active = planners.find((planner) => planner.id === selectedPlannerId) || planners[0];
|
|
updatePlannerDetail(active || null);
|
|
});
|
|
}
|
|
|
|
if (elements.keepOpenToggle) {
|
|
elements.keepOpenToggle.addEventListener('change', () => {
|
|
postAction(
|
|
'/api/keepalive',
|
|
{ keep_open: elements.keepOpenToggle.checked },
|
|
elements.keepOpenStatus
|
|
);
|
|
});
|
|
}
|
|
|
|
fetchState();
|
|
connectEvents();
|
|
})();
|