1862 lines
88 KiB
JavaScript
1862 lines
88 KiB
JavaScript
/**
|
|
* Flynn Live Ops Dashboard
|
|
*
|
|
* Shows core counters, model performance, event stream, active requests,
|
|
* and channel status. Fast metrics refresh every 3s, slow health every 10s.
|
|
*/
|
|
|
|
let _fastTimer = null;
|
|
let _slowTimer = null;
|
|
let _dashboardClient = null;
|
|
let _lastPlaybookRollbackPatches = null;
|
|
let _lastBriefingTestAt = null;
|
|
let _assistantSaveState = null;
|
|
let _lastAssistantConfig = null;
|
|
let _assistantManualOverrides = new Set();
|
|
let _assistantModelDefaultsDraft = null;
|
|
let _assistantDraftState = new Map();
|
|
let _assistantDraftTouchedAt = 0;
|
|
let _lastCouncilTask = '';
|
|
let _lastCouncilResult = null;
|
|
let _lastCouncilError = null;
|
|
let _lastServices = [];
|
|
let _serviceConfigState = {
|
|
open: false,
|
|
serviceName: null,
|
|
status: null,
|
|
tone: 'neutral',
|
|
advancedPatch: '',
|
|
};
|
|
|
|
const MODEL_DEFAULT_TASK_KEYS = ['compaction', 'memory_extraction', 'classification', 'tool_summarisation', 'complex_reasoning'];
|
|
const MODEL_DEFAULT_TIER_KEYS = ['default', 'fast', 'complex', 'local'];
|
|
const HEARTBEAT_CHECK_KEYS = ['gateway', 'model', 'channels', 'memory', 'disk', 'process_memory', 'backup', 'provider_errors'];
|
|
const SERVICE_TOGGLE_PATCH_PATHS = {
|
|
heartbeat: 'automation.heartbeat.enabled',
|
|
daily_briefing: 'automation.daily_briefing.enabled',
|
|
gmail: 'automation.gmail.enabled',
|
|
gcal: 'automation.gcal.enabled',
|
|
gdocs: 'automation.gdocs.enabled',
|
|
gdrive: 'automation.gdrive.enabled',
|
|
gtasks: 'automation.gtasks.enabled',
|
|
backup: 'backup.enabled',
|
|
audio_transcription: 'audio.enabled',
|
|
sandbox: 'sandbox.enabled',
|
|
};
|
|
const ASSISTANT_DRAFT_TTL_MS = 2 * 60 * 1000;
|
|
|
|
function formatUptime(seconds) {
|
|
const d = Math.floor(seconds / 86400);
|
|
const h = Math.floor((seconds % 86400) / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
const parts = [];
|
|
if (d > 0) {parts.push(`${d}d`);}
|
|
if (h > 0) {parts.push(`${h}h`);}
|
|
if (m > 0) {parts.push(`${m}m`);}
|
|
parts.push(`${s}s`);
|
|
return parts.join(' ');
|
|
}
|
|
|
|
function timeAgo(timestamp) {
|
|
const secs = Math.floor((Date.now() - timestamp) / 1000);
|
|
if (secs < 60) {return `${secs}s ago`;}
|
|
if (secs < 3600) {return `${Math.floor(secs / 60)}m ago`;}
|
|
return `${Math.floor(secs / 3600)}h ago`;
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
const d = new Date(timestamp);
|
|
return d.toLocaleTimeString('en-GB', { hour12: false });
|
|
}
|
|
|
|
function formatDay(day) {
|
|
const parsed = new Date(`${day}T00:00:00`);
|
|
if (Number.isNaN(parsed.getTime())) {return day;}
|
|
return parsed.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
|
|
}
|
|
|
|
function formatNumber(value) {
|
|
return (value ?? 0).toLocaleString();
|
|
}
|
|
|
|
function formatSessionDurationFromMessages(avgMessagesPerSession) {
|
|
if (!avgMessagesPerSession || avgMessagesPerSession <= 0) {return '—';}
|
|
return `${avgMessagesPerSession.toFixed(1)} msgs/session`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function getAssistantStateSnapshot(configData) {
|
|
const automation = configData?.automation ?? {};
|
|
const memory = configData?.memory ?? {};
|
|
const tts = configData?.tts ?? {};
|
|
const queue = configData?.server?.queue ?? {};
|
|
return {
|
|
announce: (automation.delivery_mode ?? 'shared_session') === 'announce',
|
|
dailyBriefing: Boolean(automation.daily_briefing?.enabled),
|
|
memoryDaily: Boolean(memory.daily_log?.enabled),
|
|
memoryProactive: Boolean(memory.proactive_extract?.enabled),
|
|
memoryMinToolCalls: Number(memory.proactive_extract?.min_tool_calls ?? 1),
|
|
ttsEnabled: Boolean(tts.enabled),
|
|
ttsChannels: Array.isArray(tts.enabled_channels) ? tts.enabled_channels : [],
|
|
queueMode: queue.mode ?? 'collect',
|
|
};
|
|
}
|
|
|
|
function buildPlaybookPatches(playbook) {
|
|
if (playbook === 'executive') {
|
|
const patches = {
|
|
'automation.delivery_mode': 'announce',
|
|
'automation.daily_briefing.enabled': true,
|
|
'memory.daily_log.enabled': true,
|
|
'memory.proactive_extract.enabled': true,
|
|
'memory.proactive_extract.min_tool_calls': 1,
|
|
'tts.enabled': true,
|
|
'tts.enabled_channels': [],
|
|
'server.queue.mode': 'interrupt',
|
|
};
|
|
for (const key of _assistantManualOverrides) {
|
|
delete patches[key];
|
|
}
|
|
return patches;
|
|
}
|
|
if (playbook === 'operator') {
|
|
const patches = {
|
|
'automation.delivery_mode': 'announce',
|
|
'automation.daily_briefing.enabled': true,
|
|
'memory.daily_log.enabled': true,
|
|
'memory.proactive_extract.enabled': true,
|
|
'memory.proactive_extract.min_tool_calls': 2,
|
|
'tts.enabled': false,
|
|
'server.queue.mode': 'steer_backlog',
|
|
};
|
|
for (const key of _assistantManualOverrides) {
|
|
delete patches[key];
|
|
}
|
|
return patches;
|
|
}
|
|
const patches = {
|
|
'automation.delivery_mode': 'shared_session',
|
|
'automation.daily_briefing.enabled': false,
|
|
'memory.daily_log.enabled': false,
|
|
'memory.proactive_extract.enabled': false,
|
|
'memory.proactive_extract.min_tool_calls': 3,
|
|
'tts.enabled': false,
|
|
'server.queue.mode': 'collect',
|
|
};
|
|
for (const key of _assistantManualOverrides) {
|
|
delete patches[key];
|
|
}
|
|
return patches;
|
|
}
|
|
|
|
function buildRollbackPatchesFromSnapshot(snapshot) {
|
|
return {
|
|
'automation.delivery_mode': snapshot.announce ? 'announce' : 'shared_session',
|
|
'automation.daily_briefing.enabled': snapshot.dailyBriefing,
|
|
'memory.daily_log.enabled': snapshot.memoryDaily,
|
|
'memory.proactive_extract.enabled': snapshot.memoryProactive,
|
|
'memory.proactive_extract.min_tool_calls': Number.isFinite(snapshot.memoryMinToolCalls) ? snapshot.memoryMinToolCalls : 1,
|
|
'tts.enabled': snapshot.ttsEnabled,
|
|
'tts.enabled_channels': snapshot.ttsChannels,
|
|
'server.queue.mode': snapshot.queueMode,
|
|
};
|
|
}
|
|
|
|
function getByPath(obj, dottedPath) {
|
|
if (!obj || typeof obj !== 'object') {return undefined;}
|
|
const parts = dottedPath.split('.');
|
|
let cursor = obj;
|
|
for (const part of parts) {
|
|
if (!cursor || typeof cursor !== 'object' || !(part in cursor)) {
|
|
return undefined;
|
|
}
|
|
cursor = cursor[part];
|
|
}
|
|
return cursor;
|
|
}
|
|
|
|
function valuesMatch(expected, actual) {
|
|
if (Array.isArray(expected) && Array.isArray(actual)) {
|
|
if (expected.length !== actual.length) {return false;}
|
|
for (let i = 0; i < expected.length; i++) {
|
|
if (expected[i] !== actual[i]) {return false;}
|
|
}
|
|
return true;
|
|
}
|
|
return expected === actual;
|
|
}
|
|
|
|
function valuesMatchForPath(path, expected, actual) {
|
|
if (valuesMatch(expected, actual)) {
|
|
return true;
|
|
}
|
|
|
|
// Some optional string paths are normalized by config handlers:
|
|
// writing "" is persisted as "unset" (undefined).
|
|
if (typeof expected === 'string' && expected.trim().length === 0 && (actual === undefined || actual === null)) {
|
|
const optionalStringSuffixes = ['scaffold_path'];
|
|
if (optionalStringSuffixes.some((suffix) => path.endsWith(suffix))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function setAssistantSaveState(message, tone = 'neutral') {
|
|
_assistantSaveState = {
|
|
message,
|
|
tone,
|
|
at: Date.now(),
|
|
};
|
|
}
|
|
|
|
function writeAssistantDraftValue(control) {
|
|
if (!control || !control.id) {return;}
|
|
const isCheckbox = control.tagName === 'INPUT' && control.type === 'checkbox';
|
|
if (isCheckbox) {
|
|
_assistantDraftState.set(control.id, { kind: 'checkbox', value: Boolean(control.checked) });
|
|
} else if (control.tagName === 'SELECT') {
|
|
const selectedOption = Array.from(control.options ?? []).find((option) => option.selected);
|
|
_assistantDraftState.set(control.id, { kind: 'value', value: selectedOption?.value ?? '' });
|
|
} else {
|
|
_assistantDraftState.set(control.id, { kind: 'value', value: control.value ?? '' });
|
|
}
|
|
_assistantDraftTouchedAt = Date.now();
|
|
}
|
|
|
|
function bindAssistantDraftTracking(rootEl) {
|
|
const controls = rootEl.querySelectorAll('input[id], select[id], textarea[id]');
|
|
controls.forEach((control) => {
|
|
control.addEventListener('input', () => writeAssistantDraftValue(control));
|
|
control.addEventListener('change', () => writeAssistantDraftValue(control));
|
|
});
|
|
}
|
|
|
|
function applyAssistantDraftState(rootEl) {
|
|
if (_assistantDraftState.size === 0) {return;}
|
|
const now = Date.now();
|
|
if (_assistantDraftTouchedAt > 0 && (now - _assistantDraftTouchedAt) > ASSISTANT_DRAFT_TTL_MS) {
|
|
_assistantDraftState = new Map();
|
|
_assistantDraftTouchedAt = 0;
|
|
return;
|
|
}
|
|
|
|
for (const [id, draft] of _assistantDraftState.entries()) {
|
|
const control = rootEl.querySelector(`#${id}`);
|
|
if (!control) {continue;}
|
|
const isCheckbox = control.tagName === 'INPUT' && control.type === 'checkbox';
|
|
if (draft.kind === 'checkbox' && isCheckbox) {
|
|
control.checked = Boolean(draft.value);
|
|
} else if (control.tagName === 'SELECT') {
|
|
const options = Array.from(control.options ?? []);
|
|
const desired = String(draft.value ?? '');
|
|
let matchedIndex = -1;
|
|
for (let i = 0; i < options.length; i++) {
|
|
const option = options[i];
|
|
const isSelected = option.value === desired;
|
|
option.selected = isSelected;
|
|
if (isSelected) {
|
|
matchedIndex = i;
|
|
}
|
|
}
|
|
if (options.length > 0) {
|
|
const fallbackIndex = matchedIndex >= 0 ? matchedIndex : 0;
|
|
options[fallbackIndex].selected = true;
|
|
if ('selectedIndex' in control) {
|
|
control.selectedIndex = fallbackIndex;
|
|
}
|
|
}
|
|
try {
|
|
control.value = desired;
|
|
} catch {
|
|
// Some DOM shims expose readonly value on select nodes.
|
|
}
|
|
} else if ('value' in control) {
|
|
try {
|
|
control.value = String(draft.value ?? '');
|
|
} catch {
|
|
// Ignore rare non-writable value surfaces from non-browser DOM shims.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderAssistantSaveState() {
|
|
if (!_assistantSaveState) {
|
|
return '<div id="ops-assistant-status" class="text-sm text-zinc-500 mt-4">No recent save action.</div>';
|
|
}
|
|
|
|
const toneClass = _assistantSaveState.tone === 'success'
|
|
? 'text-green-500'
|
|
: _assistantSaveState.tone === 'warning'
|
|
? 'text-amber-500'
|
|
: _assistantSaveState.tone === 'error'
|
|
? 'text-red-500'
|
|
: 'text-zinc-500';
|
|
|
|
const at = new Date(_assistantSaveState.at).toLocaleTimeString(undefined, {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
});
|
|
|
|
return `<div id="ops-assistant-status" class="text-sm ${toneClass} mt-4">${escapeHtml(_assistantSaveState.message)} <span class="text-zinc-500">(at ${escapeHtml(at)})</span></div>`;
|
|
}
|
|
|
|
function extractCouncilResultFromOutput(output) {
|
|
if (typeof output !== 'string' || output.trim().length === 0) {
|
|
return null;
|
|
}
|
|
const marker = '{"pipeline_version"';
|
|
const idx = output.lastIndexOf(marker);
|
|
if (idx < 0) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(output.slice(idx));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Initial full render ─────────────────────────────────────────
|
|
|
|
function renderSkeleton(el) {
|
|
el.innerHTML = `
|
|
<h1 class="text-2xl font-semibold text-zinc-50 mb-6">Live Ops Dashboard</h1>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Core Counters</h2>
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8" id="ops-counters">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors"><div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Loading...</div><div class="text-2xl font-bold font-mono text-zinc-50">—</div></div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Model Performance</h2>
|
|
<div id="ops-model-table">
|
|
<div class="text-sm text-zinc-500">Loading...</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Session Analytics</h2>
|
|
<div id="ops-session-analytics">
|
|
<div class="text-sm text-zinc-500">Loading...</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Context Health</h2>
|
|
<div id="ops-context-health">
|
|
<div class="text-sm text-zinc-500">Loading...</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Assistant Health</h2>
|
|
<div id="ops-assistant-health">
|
|
<div class="text-sm text-zinc-500">Loading...</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Event Stream</h2>
|
|
<div class="max-h-72 overflow-y-auto bg-zinc-900 border border-zinc-800 rounded-lg p-2 font-mono text-xs" id="ops-events">
|
|
<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words text-zinc-400">Loading events...</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Active Requests</h2>
|
|
<div id="ops-requests">
|
|
<div class="text-sm text-zinc-500">Loading...</div>
|
|
</div>
|
|
|
|
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Services</h2>
|
|
<div class="text-xs text-zinc-500 mb-2">Click a service card to configure.</div>
|
|
<div id="ops-services" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
<div class="text-sm text-zinc-500">Loading...</div>
|
|
</div>
|
|
<div id="ops-service-config-modal-root"></div>
|
|
`;
|
|
}
|
|
|
|
// ── Section updaters (targeted DOM updates) ─────────────────────
|
|
|
|
function updateCounters(metrics, health) {
|
|
const el = document.getElementById('ops-counters');
|
|
if (!el) {return;}
|
|
|
|
const sessions = health?.sessions ?? 0;
|
|
const errCount = metrics?.errors ?? 0;
|
|
|
|
const cards = [
|
|
{ label: 'Messages Processed', value: String(metrics?.messagesProcessed ?? 0), cls: '' },
|
|
{ label: 'Active Sessions', value: String(sessions), cls: '' },
|
|
{ label: 'Queue Depth', value: String(metrics?.queueDepth ?? 0), cls: '' },
|
|
{ label: 'Uptime', value: formatUptime(metrics?.uptime ?? 0), cls: '' },
|
|
{ label: 'Active Requests', value: String(metrics?.activeRequests ?? 0), cls: '' },
|
|
{ label: 'Errors', value: String(errCount), cls: errCount > 0 ? 'text-red-500' : '' },
|
|
];
|
|
|
|
el.innerHTML = cards.map(c =>
|
|
`<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">${c.label}</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50 ${c.cls}">${c.value}</div>
|
|
</div>`,
|
|
).join('');
|
|
}
|
|
|
|
function updateModelTable(metrics) {
|
|
const el = document.getElementById('ops-model-table');
|
|
if (!el) {return;}
|
|
|
|
const mc = metrics?.modelCalls;
|
|
const calls = mc?.recentCalls ?? [];
|
|
|
|
if (calls.length === 0) {
|
|
el.innerHTML = '<div class="text-sm text-zinc-500">No model calls recorded yet</div>';
|
|
return;
|
|
}
|
|
|
|
const totalCalls = mc.total ?? 0;
|
|
const avgLatency = mc.avgLatency ?? 0;
|
|
const errorRate = mc.errorRate ?? 0;
|
|
|
|
const summaryHtml = `
|
|
<div class="flex flex-wrap gap-4 md:gap-6 mb-3 text-sm text-zinc-400">
|
|
<div><span>Total Calls:</span> <span class="font-mono text-zinc-50">${totalCalls}</span></div>
|
|
<div><span>Avg Latency:</span> <span class="font-mono text-zinc-50">${avgLatency}ms</span></div>
|
|
<div><span>Error Rate:</span> <span class="font-mono text-zinc-50">${(errorRate * 100).toFixed(2)}%</span></div>
|
|
</div>
|
|
`;
|
|
|
|
// Show newest first
|
|
const rows = [...calls].reverse().map(c => {
|
|
const status = c.error ? '<span class="text-red-500">✗</span>' : '<span class="text-green-500">✓</span>';
|
|
return `<tr class="hover:bg-zinc-800/50">
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${timeAgo(c.timestamp)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(c.provider)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${c.latency}ms</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${c.tokensPerSec.toFixed(1)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${c.inputTokens}/${c.outputTokens}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${status}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
el.innerHTML = `
|
|
${summaryHtml}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Time</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Provider</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Latency</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Tokens/sec</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">In/Out</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateEvents(eventsData) {
|
|
const el = document.getElementById('ops-events');
|
|
if (!el) {return;}
|
|
|
|
const events = eventsData?.events ?? [];
|
|
|
|
if (events.length === 0) {
|
|
el.innerHTML = '<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words text-zinc-400">No events recorded yet</div>';
|
|
return;
|
|
}
|
|
|
|
// Events come newest-first from the API; show newest at bottom for log feel
|
|
const reversed = [...events].reverse();
|
|
|
|
el.innerHTML = reversed.map(e => {
|
|
const time = formatTime(e.timestamp);
|
|
const level = (e.level || 'info').toUpperCase();
|
|
const levelColor = e.level === 'error' ? 'text-red-500' : e.level === 'warn' ? 'text-amber-500' : 'text-zinc-400';
|
|
return `<div class="px-2 py-1 border-b border-zinc-800/50 last:border-0 break-words ${levelColor}">[${time}] [${level}] ${escapeHtml(e.source)}: ${escapeHtml(e.message)}</div>`;
|
|
}).join('');
|
|
|
|
// Auto-scroll to bottom
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
function updateActiveRequests(requestsData) {
|
|
const el = document.getElementById('ops-requests');
|
|
if (!el) {return;}
|
|
|
|
const requests = requestsData?.requests ?? [];
|
|
|
|
if (requests.length === 0) {
|
|
el.innerHTML = '<div class="text-sm text-zinc-500">No active requests</div>';
|
|
return;
|
|
}
|
|
|
|
const rows = requests.map(r => {
|
|
const duration = r.durationMs < 1000
|
|
? `${r.durationMs}ms`
|
|
: `${(r.durationMs / 1000).toFixed(1)}s`;
|
|
const started = formatTime(r.startedAt);
|
|
return `<tr class="hover:bg-zinc-800/50">
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${escapeHtml(r.sessionId)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${escapeHtml(r.channel)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${duration}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${started}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
el.innerHTML = `
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Channel</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Duration</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Started</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateSessionAnalytics(analyticsData) {
|
|
const el = document.getElementById('ops-session-analytics');
|
|
if (!el) {return;}
|
|
|
|
const daily = analyticsData?.daily ?? [];
|
|
const topSessions = analyticsData?.topSessions ?? [];
|
|
const topTools = analyticsData?.topTools ?? [];
|
|
const topTopics = analyticsData?.topTopics ?? [];
|
|
|
|
const totalSessions = analyticsData?.totalSessions ?? 0;
|
|
const totalMessages = analyticsData?.totalMessages ?? 0;
|
|
const avgMessagesPerSession = analyticsData?.averageMessagesPerSession ?? 0;
|
|
|
|
const summaryHtml = `
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Sessions (Window)</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalSessions)}</div>
|
|
</div>
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Messages (Window)</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50">${formatNumber(totalMessages)}</div>
|
|
</div>
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Avg Session</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50">${formatSessionDurationFromMessages(avgMessagesPerSession)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const topToolsHtml = topTools.length > 0
|
|
? `<div>${topTools.map((tool) =>
|
|
`<div class="py-1.5 border-b border-zinc-800/50 last:border-0 text-sm"><span class="text-blue-500">${escapeHtml(tool.toolName)}</span> <span class="text-zinc-500">(${formatNumber(tool.executions)})</span></div>`,
|
|
).join('')}</div>`
|
|
: '<div class="text-sm text-zinc-500">No tool usage captured in this window</div>';
|
|
|
|
const topTopicsHtml = topTopics.length > 0
|
|
? `<div>${topTopics.map((topic) =>
|
|
`<div class="py-1.5 border-b border-zinc-800/50 last:border-0 text-sm"><span class="text-blue-500">${escapeHtml(topic.topic)}</span> <span class="text-zinc-500">(${formatNumber(topic.occurrences)})</span></div>`,
|
|
).join('')}</div>`
|
|
: '<div class="text-sm text-zinc-500">No indexed topics captured in this window</div>';
|
|
|
|
const topSessionsHtml = topSessions.length > 0
|
|
? `<div class="overflow-x-auto"><table class="w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Messages</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Last Active</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${topSessions.map((session) => `
|
|
<tr class="hover:bg-zinc-800/50">
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${escapeHtml(session.sessionId)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(session.messages)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${timeAgo(session.lastActivity * 1000)}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table></div>`
|
|
: '<div class="text-sm text-zinc-500">No session activity in this window</div>';
|
|
|
|
const dailyHtml = daily.length > 0
|
|
? `<div class="overflow-x-auto"><table class="w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Day</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Sessions</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Messages</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${daily.slice(0, 7).map((row) => `
|
|
<tr class="hover:bg-zinc-800/50">
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${formatDay(row.day)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(row.sessions)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(row.messages)}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table></div>`
|
|
: '<div class="text-sm text-zinc-500">No daily activity in this window</div>';
|
|
|
|
el.innerHTML = `
|
|
${summaryHtml}
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Top Tools</div>
|
|
${topToolsHtml}
|
|
</div>
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Top Topics</div>
|
|
${topTopicsHtml}
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Top Sessions</div>
|
|
${topSessionsHtml}
|
|
</div>
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Daily Trend (Last 7 Rows)</div>
|
|
${dailyHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateContextHealth(contextData) {
|
|
const el = document.getElementById('ops-context-health');
|
|
if (!el) {return;}
|
|
|
|
const sessions = contextData?.sessions ?? [];
|
|
if (sessions.length === 0) {
|
|
el.innerHTML = '<div class="text-sm text-zinc-500">No active context usage snapshots</div>';
|
|
return;
|
|
}
|
|
|
|
const sorted = [...sessions].sort((a, b) => (b.budget?.usagePct ?? 0) - (a.budget?.usagePct ?? 0));
|
|
const top = sorted.slice(0, 8);
|
|
const highest = top[0]?.budget?.usagePct ?? 0;
|
|
const overThreshold = sessions.filter(s => (s.budget?.shouldCompact ?? false)).length;
|
|
|
|
const summary = `
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Highest Usage</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50 ${highest >= 90 ? 'text-red-500' : ''}">${highest.toFixed(1)}%</div>
|
|
</div>
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Sessions Near Limit</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50 ${overThreshold > 0 ? 'text-red-500' : ''}">${overThreshold}</div>
|
|
</div>
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-4 hover:border-zinc-600 transition-colors">
|
|
<div class="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">Active Snapshots</div>
|
|
<div class="text-2xl font-bold font-mono text-zinc-50">${sessions.length}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const rows = top.map((entry) => {
|
|
const budget = entry.budget ?? {};
|
|
const usage = budget.usagePct ?? 0;
|
|
const cls = usage >= 95 ? 'text-red-500' : usage >= 85 ? 'text-amber-500' : '';
|
|
return `<tr class="hover:bg-zinc-800/50">
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${escapeHtml(entry.sessionId)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono ${cls}">${usage.toFixed(1)}%</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50 font-mono">${formatNumber(budget.estimatedTokens ?? 0)} / ${formatNumber(budget.contextWindow ?? 0)}</td>
|
|
<td class="px-3 py-2 text-zinc-50 border-b border-zinc-800/50">${budget.shouldCompact ? 'yes' : 'no'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
el.innerHTML = `
|
|
${summary}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Session</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Usage</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Estimated Tokens</th>
|
|
<th class="text-left px-3 py-2 text-xs font-medium text-zinc-400 uppercase tracking-wide border-b border-zinc-800">Should Compact</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function applyAssistantPatch(patches, statusEl) {
|
|
if (!_dashboardClient) {
|
|
setAssistantSaveState('Save skipped: dashboard client unavailable.', 'error');
|
|
return;
|
|
}
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Saving...';
|
|
statusEl.className = 'text-sm text-zinc-500';
|
|
}
|
|
try {
|
|
const result = await _dashboardClient.call('config.patch', { patches });
|
|
const rejected = result?.rejected ?? [];
|
|
const persistError = result?.persistError;
|
|
const applied = result?.applied ?? [];
|
|
const persisted = result?.persisted === true;
|
|
|
|
let message = '';
|
|
let tone = 'neutral';
|
|
if (statusEl) {
|
|
if (persistError) {
|
|
message = `Save failed: ${persistError}`;
|
|
tone = 'error';
|
|
} else if (rejected.length > 0) {
|
|
message = `Rejected: ${rejected.join(', ')}`;
|
|
tone = 'error';
|
|
} else if (!persisted) {
|
|
message = `Runtime-only save (${applied.length} updated, file persistence unavailable)`;
|
|
tone = 'warning';
|
|
} else {
|
|
// Verify read-after-write so UI cannot claim persistence when value did not stick.
|
|
try {
|
|
const fresh = await _dashboardClient.call('config.get');
|
|
const mismatches = [];
|
|
for (const [key, value] of Object.entries(patches)) {
|
|
const actual = getByPath(fresh, key);
|
|
if (!valuesMatchForPath(key, value, actual)) {
|
|
mismatches.push(`${key} expected=${JSON.stringify(value)} actual=${JSON.stringify(actual)}`);
|
|
}
|
|
}
|
|
|
|
if (mismatches.length > 0) {
|
|
message = `Saved response received but read-back mismatch: ${mismatches.join('; ')}`;
|
|
tone = 'error';
|
|
} else {
|
|
message = `Saved to runtime + config file (${applied.length} updated)`;
|
|
tone = 'success';
|
|
}
|
|
} catch (verifyError) {
|
|
message = `Saved response received, but verification failed: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`;
|
|
tone = 'warning';
|
|
}
|
|
}
|
|
setAssistantSaveState(message, tone);
|
|
statusEl.textContent = message;
|
|
statusEl.className = `text-sm ${tone === 'success' ? 'text-green-500' : tone === 'warning' ? 'text-amber-500' : tone === 'error' ? 'text-red-500' : 'text-zinc-500'}`;
|
|
}
|
|
return { persisted, applied, rejected, persistError };
|
|
} catch (error) {
|
|
const msg = `Save error: ${error instanceof Error ? error.message : String(error)}`;
|
|
setAssistantSaveState(msg, 'error');
|
|
if (statusEl) {
|
|
statusEl.textContent = msg;
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return { persisted: false, applied: [], rejected: [], persistError: msg };
|
|
}
|
|
}
|
|
|
|
async function triggerDailyBriefingTest(jobName, statusEl) {
|
|
if (!_dashboardClient) {return;}
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Triggering test briefing...';
|
|
statusEl.className = 'text-sm text-zinc-500';
|
|
}
|
|
try {
|
|
const result = await _dashboardClient.call('tools.invoke', {
|
|
tool: 'cron.trigger',
|
|
args: { name: jobName },
|
|
});
|
|
|
|
if (result?.success) {
|
|
const output = typeof result.output === 'string' ? result.output : 'Triggered.';
|
|
if (statusEl) {
|
|
statusEl.textContent = output;
|
|
statusEl.className = 'text-sm text-green-500';
|
|
}
|
|
_lastBriefingTestAt = Date.now();
|
|
return true;
|
|
}
|
|
|
|
if (statusEl) {
|
|
statusEl.textContent = result?.error ?? 'Failed to trigger briefing.';
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
if (statusEl) {
|
|
statusEl.textContent = `Trigger error: ${error instanceof Error ? error.message : String(error)}`;
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function triggerCouncilRun(task, statusEl) {
|
|
if (!_dashboardClient) {return false;}
|
|
if (!task) {
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Task is required.';
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return false;
|
|
}
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Running council...';
|
|
statusEl.className = 'text-sm text-zinc-500';
|
|
}
|
|
try {
|
|
const result = await _dashboardClient.call('tools.invoke', {
|
|
tool: 'council.run',
|
|
args: { task },
|
|
});
|
|
if (!result?.success) {
|
|
_lastCouncilError = result?.error ?? 'Council run failed.';
|
|
_lastCouncilResult = null;
|
|
if (statusEl) {
|
|
statusEl.textContent = _lastCouncilError;
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const parsed = extractCouncilResultFromOutput(result.output);
|
|
if (!parsed) {
|
|
_lastCouncilError = 'Council run succeeded but output could not be parsed.';
|
|
_lastCouncilResult = null;
|
|
if (statusEl) {
|
|
statusEl.textContent = _lastCouncilError;
|
|
statusEl.className = 'text-sm text-amber-500';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_lastCouncilTask = task;
|
|
_lastCouncilResult = parsed;
|
|
_lastCouncilError = null;
|
|
if (statusEl) {
|
|
statusEl.textContent = `Council run complete: ${parsed.stop_snapshot?.stop_reason ?? 'ok'}`;
|
|
statusEl.className = 'text-sm text-green-500';
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
_lastCouncilError = `Council run error: ${error instanceof Error ? error.message : String(error)}`;
|
|
_lastCouncilResult = null;
|
|
if (statusEl) {
|
|
statusEl.textContent = _lastCouncilError;
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function updateAssistantHealth(configData) {
|
|
const el = document.getElementById('ops-assistant-health');
|
|
if (!el) {return;}
|
|
_assistantModelDefaultsDraft = readAssistantModelDefaultsDraft(el) ?? _assistantModelDefaultsDraft;
|
|
|
|
const snapshot = getAssistantStateSnapshot(configData);
|
|
|
|
const automation = configData?.automation ?? {};
|
|
const memory = configData?.memory ?? {};
|
|
const tts = configData?.tts ?? {};
|
|
|
|
const deliveryMode = automation.delivery_mode ?? 'shared_session';
|
|
const announce = deliveryMode === 'announce';
|
|
const dailyBriefing = Boolean(automation.daily_briefing?.enabled);
|
|
const memoryDaily = Boolean(memory.daily_log?.enabled);
|
|
const memoryProactive = Boolean(memory.proactive_extract?.enabled);
|
|
const proactiveThreshold = Number(memory.proactive_extract?.min_tool_calls ?? 1);
|
|
const ttsEnabled = Boolean(tts.enabled);
|
|
const briefing = automation.daily_briefing ?? {};
|
|
const briefingName = briefing.name ?? 'daily-briefing';
|
|
const briefingSchedule = briefing.schedule ?? '0 8 * * *';
|
|
const briefingPrompt = briefing.prompt ?? '';
|
|
const briefingOutput = briefing.output ?? null;
|
|
const briefingModelTier = briefing.model_tier ?? 'default';
|
|
const briefingTimezone = briefing.timezone ?? 'system';
|
|
const briefingOutputLabel = briefingOutput?.channel && briefingOutput?.peer
|
|
? `${briefingOutput.channel}/${briefingOutput.peer}`
|
|
: 'not configured';
|
|
const briefingReady = dailyBriefing && Boolean(briefingOutput?.channel && briefingOutput?.peer);
|
|
const playbookLikeReady = announce || (memoryDaily && memoryProactive);
|
|
const modelTier = _assistantModelDefaultsDraft?.primaryTier ?? configData?.agents?.primary_tier ?? 'default';
|
|
const delegation = configData?.agents?.delegation ?? {};
|
|
const backgroundModels = configData?.agents?.background_models ?? {};
|
|
const councils = configData?.councils ?? {};
|
|
const councilsDefaults = councils.defaults ?? {};
|
|
const councilsGroups = councils.groups ?? {};
|
|
const councilsD = councilsGroups.D ?? {};
|
|
const councilsP = councilsGroups.P ?? {};
|
|
const tiers = configData?.models ?? {};
|
|
const modelCatalog = configData?.__modelCatalog ?? [];
|
|
const providerList = modelCatalog.length > 0
|
|
? modelCatalog.map((entry) => entry.provider)
|
|
: ['anthropic', 'openai', 'gemini', 'openrouter', 'github', 'xai', 'ollama', 'llamacpp', 'bedrock', 'zhipuai', 'minimax', 'moonshot', 'synthetic'];
|
|
const modelOptionsByProvider = Object.fromEntries(modelCatalog.map((entry) => [entry.provider, entry.models ?? []]));
|
|
const checklistRows = [
|
|
{ label: 'Set briefing output channel + peer', done: Boolean(briefingOutput?.channel && briefingOutput?.peer) },
|
|
{ label: 'Enable assistant behavior profile', done: playbookLikeReady },
|
|
{ label: 'Send a test morning briefing', done: _lastBriefingTestAt !== null },
|
|
];
|
|
|
|
const chip = (label, value) => `
|
|
<div class="flex justify-between items-center px-3 py-2.5 bg-zinc-900 border border-zinc-800 rounded-lg text-sm">
|
|
<span class="text-zinc-400">${escapeHtml(label)}</span>
|
|
<span class="font-bold ${value ? 'text-green-500' : 'text-zinc-500'}">${value ? 'ON' : 'OFF'}</span>
|
|
</div>
|
|
`;
|
|
const tierOption = (selected) => ['fast', 'default', 'complex', 'local']
|
|
.map((tier) => `<option value="${tier}" ${selected === tier ? 'selected' : ''}>${tier}</option>`)
|
|
.join('');
|
|
const providerOption = (selected) => providerList
|
|
.map((provider) => `<option value="${provider}" ${selected === provider ? 'selected' : ''}>${provider}</option>`)
|
|
.join('');
|
|
const modelDataList = (id, provider, selected) => {
|
|
const options = modelOptionsByProvider[provider] ?? [];
|
|
return `
|
|
<input id="${id}" list="${id}-list" value="${escapeHtml(selected ?? '')}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Enter a model ID for the selected provider or pick one from the suggestion list." />
|
|
<datalist id="${id}-list">
|
|
${options.map((model) => `<option value="${escapeHtml(model)}"></option>`).join('')}
|
|
</datalist>
|
|
`;
|
|
};
|
|
const taskRows = [
|
|
{ key: 'compaction', label: 'Compaction' },
|
|
{ key: 'memory_extraction', label: 'Memory extraction' },
|
|
{ key: 'classification', label: 'Classification' },
|
|
{ key: 'tool_summarisation', label: 'Tool summarisation' },
|
|
{ key: 'complex_reasoning', label: 'Complex reasoning' },
|
|
];
|
|
const councilConversations = Array.isArray(_lastCouncilResult?.conversations) ? _lastCouncilResult.conversations : [];
|
|
const councilSummary = _lastCouncilResult?.stop_snapshot
|
|
? `Last run: ${_lastCouncilResult.stop_snapshot.stop_reason} (round ${_lastCouncilResult.stop_snapshot.round_reached})`
|
|
: (_lastCouncilError ? `Last run failed: ${_lastCouncilError}` : 'No council run yet in this dashboard session.');
|
|
|
|
el.innerHTML = `
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2 mb-4">
|
|
${chip('Announce Mode', announce)}
|
|
${chip('Daily Briefing', dailyBriefing)}
|
|
${chip('Memory Daily Log', memoryDaily)}
|
|
${chip('Proactive Extract', memoryProactive)}
|
|
${chip('TTS Replies', ttsEnabled)}
|
|
<div class="flex justify-between items-center px-3 py-2.5 bg-zinc-900 border border-zinc-800 rounded-lg text-sm">
|
|
<span class="text-zinc-400">Extract Threshold</span>
|
|
<span class="font-bold">${Number.isFinite(proactiveThreshold) ? proactiveThreshold : 1}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-announce">
|
|
${announce ? 'Disable Announce Mode' : 'Enable Announce Mode'}
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-daily-briefing">
|
|
${dailyBriefing ? 'Disable Daily Briefing' : 'Enable Daily Briefing'}
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-memory-daily">
|
|
${memoryDaily ? 'Disable Daily Log' : 'Enable Daily Log'}
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-memory-proactive">
|
|
${memoryProactive ? 'Disable Proactive Extract' : 'Enable Proactive Extract'}
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="toggle-tts">
|
|
${ttsEnabled ? 'Disable TTS' : 'Enable TTS'}
|
|
</button>
|
|
</div>
|
|
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Assistant Playbooks</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 my-3">
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-executive">
|
|
Executive
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-operator">
|
|
Operator
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-focus">
|
|
Focus
|
|
</button>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="playbook-undo" ${_lastPlaybookRollbackPatches ? '' : 'disabled'}>
|
|
Undo Last Playbook
|
|
</button>
|
|
</div>
|
|
<div class="text-sm text-zinc-500">Executive: announce + voice + aggressive interrupt. Operator: announce + memory-first + steer backlog. Focus: reactive, quieter mode.</div>
|
|
</div>
|
|
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Model Tier Defaults</div>
|
|
<div class="text-sm text-zinc-500 mb-3">Tier provider/model definitions</div>
|
|
<div class="space-y-3 mb-4">
|
|
${MODEL_DEFAULT_TIER_KEYS.map((tier) => {
|
|
const cfg = tiers?.[tier] ?? {};
|
|
const provider = _assistantModelDefaultsDraft?.tiers?.[tier]?.provider ?? cfg.provider ?? tiers?.default?.provider ?? 'openai';
|
|
const model = _assistantModelDefaultsDraft?.tiers?.[tier]?.model ?? cfg.model ?? '';
|
|
return `
|
|
<div class="p-3 border border-zinc-800 rounded-md bg-zinc-950/60">
|
|
<div class="text-sm text-zinc-50 mb-2">${escapeHtml(tier)} tier</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<label class="flex flex-col gap-1" title="Provider used for this model tier.">
|
|
<span class="text-xs text-zinc-500">Provider</span>
|
|
<select id="assist-tier-${tier}-provider" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Select a provider for the ${escapeHtml(tier)} tier.">
|
|
${providerOption(provider)}
|
|
</select>
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Default model name for this tier.">
|
|
<span class="text-xs text-zinc-500">Model</span>
|
|
${modelDataList(`assist-tier-${tier}-model`, provider, model)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
|
<label class="flex flex-col gap-1.5" title="Main tier used when no task-specific routing rule applies.">
|
|
<span class="text-sm text-zinc-400">Primary tier</span>
|
|
<select id="assist-primary-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Choose the default model tier for general assistant responses.">
|
|
${tierOption(modelTier)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="text-sm text-zinc-400 mb-2">Delegation tiers + background model overrides</div>
|
|
<div class="space-y-3">
|
|
${taskRows.map((task) => {
|
|
const background = backgroundModels?.[task.key] ?? {};
|
|
const draftTask = _assistantModelDefaultsDraft?.tasks?.[task.key] ?? {};
|
|
const delegationTier = draftTask.delegationTier ?? delegation?.[task.key] ?? 'fast';
|
|
const backgroundEnabled = draftTask.backgroundEnabled ?? Boolean(backgroundModels?.[task.key] && background?.enabled !== false);
|
|
return `
|
|
<div class="p-3 border border-zinc-800 rounded-md bg-zinc-950/60">
|
|
<div class="text-sm text-zinc-50 mb-2">${escapeHtml(task.label)}</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-2">
|
|
<label class="flex flex-col gap-1" title="Tier used when this task is delegated.">
|
|
<span class="text-xs text-zinc-500">Delegation tier</span>
|
|
<select id="assist-delegation-${task.key}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Select the tier used for ${escapeHtml(task.label.toLowerCase())} delegation.">
|
|
${tierOption(delegationTier)}
|
|
</select>
|
|
</label>
|
|
<label class="flex items-center gap-2 mt-5 md:mt-0" title="When enabled, this task can use a custom provider/model override.">
|
|
<input id="assist-bg-${task.key}-enabled" type="checkbox" ${backgroundEnabled ? 'checked' : ''} title="Toggle background provider/model override for this task." />
|
|
<span class="text-xs text-zinc-400">Enable provider/model override</span>
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Provider used when override is enabled.">
|
|
<span class="text-xs text-zinc-500">Provider</span>
|
|
<select id="assist-bg-${task.key}-provider" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Pick the override provider for this task.">
|
|
${providerOption(draftTask.provider ?? background?.provider ?? tiers?.default?.provider ?? 'openai')}
|
|
</select>
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Model used when override is enabled.">
|
|
<span class="text-xs text-zinc-500">Model</span>
|
|
${modelDataList(
|
|
`assist-bg-${task.key}-model`,
|
|
draftTask.provider ?? background?.provider ?? tiers?.default?.provider ?? 'openai',
|
|
draftTask.model ?? background?.model ?? '',
|
|
)}
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Tier to fall back to if the override model fails.">
|
|
<span class="text-xs text-zinc-500">Fallback tier</span>
|
|
<select id="assist-bg-${task.key}-fallback" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Choose the fallback tier for this task override.">
|
|
${tierOption(draftTask.fallbackTier ?? background?.fallback_tier ?? 'fast')}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 mt-3">
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-model-defaults">
|
|
Save Model Defaults
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Councils</div>
|
|
<div class="text-sm text-zinc-500 mb-3">On-demand council orchestration settings and council role model tiers.</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
|
<label class="flex items-center gap-2 mt-5 md:mt-0" title="Enable on-demand council orchestration flows.">
|
|
<input id="assist-councils-enabled" type="checkbox" ${councils.enabled ? 'checked' : ''} title="Toggle council orchestration features." />
|
|
<span class="text-xs text-zinc-400">Enable councils</span>
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Model tier for D-council agents.">
|
|
<span class="text-sm text-zinc-400">D model tier</span>
|
|
<select id="assist-council-d-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Set the model tier for D-council roles.">
|
|
${tierOption(councilsD.model_tier ?? 'complex')}
|
|
</select>
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Model tier for P-council agents.">
|
|
<span class="text-sm text-zinc-400">P model tier</span>
|
|
<select id="assist-council-p-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Set the model tier for P-council roles.">
|
|
${tierOption(councilsP.model_tier ?? 'complex')}
|
|
</select>
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Model tier used for council meta synthesis.">
|
|
<span class="text-sm text-zinc-400">Meta model tier</span>
|
|
<select id="assist-council-meta-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Set the model tier for the council meta arbiter.">
|
|
${tierOption(councils.meta_model_tier ?? 'complex')}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
|
<label class="flex flex-col gap-1.5" title="Agent ID for the D arbiter role.">
|
|
<span class="text-sm text-zinc-400">D arbiter agent</span>
|
|
<input id="assist-council-d-arbiter" type="text" value="${escapeHtml(councilsD.arbiter_agent ?? 'council_d_arbiter')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Configured agent key for the D arbiter role." />
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Agent ID for the D freethinker role.">
|
|
<span class="text-sm text-zinc-400">D freethinker agent</span>
|
|
<input id="assist-council-d-freethinker" type="text" value="${escapeHtml(councilsD.freethinker_agent ?? 'council_d_freethinker')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Configured agent key for the D freethinker role." />
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Agent ID for the P arbiter role.">
|
|
<span class="text-sm text-zinc-400">P arbiter agent</span>
|
|
<input id="assist-council-p-arbiter" type="text" value="${escapeHtml(councilsP.arbiter_agent ?? 'council_p_arbiter')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Configured agent key for the P arbiter role." />
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Agent ID for the P freethinker role.">
|
|
<span class="text-sm text-zinc-400">P freethinker agent</span>
|
|
<input id="assist-council-p-freethinker" type="text" value="${escapeHtml(councilsP.freethinker_agent ?? 'council_p_freethinker')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Configured agent key for the P freethinker role." />
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Agent ID for the meta arbiter role.">
|
|
<span class="text-sm text-zinc-400">Meta arbiter agent</span>
|
|
<input id="assist-council-meta-arbiter" type="text" value="${escapeHtml(councils.meta_arbiter_agent ?? 'council_meta_arbiter')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Configured agent key for the council meta arbiter role." />
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Optional JSON scaffold used as council prompt template.">
|
|
<span class="text-sm text-zinc-400">Scaffold path (optional)</span>
|
|
<input id="assist-council-scaffold" type="text" value="${escapeHtml(councils.scaffold_path ?? '')}" placeholder="docs/councils/ai-council-production-scaffold.json" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Path to a scaffold JSON file under the project directory." />
|
|
</label>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
|
<label class="flex flex-col gap-1.5" title="Upper bound on iterative council rounds per run.">
|
|
<span class="text-sm text-zinc-400">Max rounds</span>
|
|
<input id="assist-council-max-rounds" type="number" min="1" max="6" value="${escapeHtml(String(councilsDefaults.max_rounds ?? 2))}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Use 1-6 rounds; higher values increase cost and latency." />
|
|
</label>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-councils">
|
|
Save Councils
|
|
</button>
|
|
</div>
|
|
<div class="mt-4 p-3 border border-zinc-800 rounded-md bg-zinc-950/60">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-2">Council Conversations</div>
|
|
<div class="text-xs text-zinc-500 mb-3">${escapeHtml(councilSummary)}</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-2 mb-3">
|
|
<input id="assist-council-task" type="text" value="${escapeHtml(_lastCouncilTask)}" placeholder="Run councils on demand: e.g. design a 2-week experiment plan..." class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Prompt for an ad-hoc council run; use a concrete decision or planning question." />
|
|
<button type="button" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="run-council">
|
|
Run Council
|
|
</button>
|
|
</div>
|
|
<div id="assist-council-status" class="text-sm text-zinc-500 mb-3"></div>
|
|
<div class="max-h-72 overflow-y-auto space-y-2">
|
|
${councilConversations.length === 0 ? '<div class="text-sm text-zinc-500">No conversation log yet.</div>' : councilConversations.map((turn, idx) => `
|
|
<details class="border border-zinc-800 rounded-md bg-zinc-900/70 p-2">
|
|
<summary class="cursor-pointer text-sm text-zinc-100">
|
|
#${idx + 1} ${escapeHtml(turn.call_id)} · ${escapeHtml(turn.agent)} @ ${escapeHtml(turn.tier)}
|
|
</summary>
|
|
<div class="mt-2 text-xs text-zinc-400">Prompt payload</div>
|
|
<pre class="text-xs text-zinc-300 whitespace-pre-wrap bg-zinc-950 border border-zinc-800 rounded p-2 mt-1">${escapeHtml(JSON.stringify(turn.prompt_payload, null, 2))}</pre>
|
|
<div class="mt-2 text-xs text-zinc-400">Response</div>
|
|
<pre class="text-xs text-zinc-300 whitespace-pre-wrap bg-zinc-950 border border-zinc-800 rounded p-2 mt-1">${escapeHtml(String(turn.response ?? ''))}</pre>
|
|
</details>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
|
<div class="text-sm font-semibold text-zinc-50 mb-3">Assistant Activation Checklist</div>
|
|
<div class="space-y-1 mb-4">
|
|
${checklistRows.map((row) => `
|
|
<div class="flex items-center gap-2 py-1 text-sm ${row.done ? 'text-zinc-50' : 'text-zinc-500'}">
|
|
<span class="w-5 text-center font-bold">${row.done ? '✓' : '○'}</span>
|
|
<span>${escapeHtml(row.label)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<label class="flex flex-col gap-1.5" title="Channel used for scheduled briefing messages.">
|
|
<span class="text-sm text-zinc-400">Briefing output channel</span>
|
|
<input id="assist-brief-channel" type="text" value="${escapeHtml(briefingOutput?.channel ?? '')}" placeholder="telegram" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Set the output adapter name for daily briefing delivery." />
|
|
</label>
|
|
<label class="flex flex-col gap-1.5" title="Destination peer/chat ID for scheduled briefings.">
|
|
<span class="text-sm text-zinc-400">Briefing output peer/chat id</span>
|
|
<input id="assist-brief-peer" type="text" value="${escapeHtml(briefingOutput?.peer ?? '')}" placeholder="123456789" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" title="Set the recipient peer/chat ID for briefing delivery." />
|
|
</label>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-briefing-output">
|
|
Save Briefing Output
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="text-sm font-semibold text-zinc-50">Morning Brief Preview</div>
|
|
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="test-daily-briefing" ${briefingReady ? '' : 'disabled'}>
|
|
Send Test Briefing
|
|
</button>
|
|
</div>
|
|
<div class="flex flex-wrap gap-3 text-sm text-zinc-500 mb-3">
|
|
<span>name: <code class="font-mono">${escapeHtml(briefingName)}</code></span>
|
|
<span>schedule: <code class="font-mono">${escapeHtml(briefingSchedule)}</code></span>
|
|
<span>timezone: <code class="font-mono">${escapeHtml(briefingTimezone)}</code></span>
|
|
<span>tier: <code class="font-mono">${escapeHtml(briefingModelTier)}</code></span>
|
|
<span>output: <code class="font-mono">${escapeHtml(briefingOutputLabel)}</code></span>
|
|
</div>
|
|
<div class="max-h-44 overflow-y-auto bg-zinc-950 border border-zinc-800 rounded-md p-3"><code class="text-sm text-zinc-400 font-mono whitespace-pre-wrap">${escapeHtml(briefingPrompt || 'No daily briefing prompt configured.')}</code></div>
|
|
${briefingReady ? '' : '<div class="text-sm text-zinc-500 mt-2">Enable daily briefing and set output channel/peer to test-send.</div>'}
|
|
</div>
|
|
${renderAssistantSaveState()}
|
|
`;
|
|
|
|
// Preserve local unsaved form edits across periodic dashboard refreshes.
|
|
applyAssistantDraftState(el);
|
|
|
|
const updateModelOptions = (inputId, provider) => {
|
|
const input = el.querySelector(`#${inputId}`);
|
|
const list = el.querySelector(`#${inputId}-list`);
|
|
if (!input || !list) {return;}
|
|
const options = modelOptionsByProvider[provider] ?? [];
|
|
list.innerHTML = options.map((model) => `<option value="${escapeHtml(model)}"></option>`).join('');
|
|
};
|
|
|
|
const tierRows = ['default', 'fast', 'complex', 'local'];
|
|
for (const tier of tierRows) {
|
|
const providerSelect = el.querySelector(`#assist-tier-${tier}-provider`);
|
|
if (!providerSelect) {continue;}
|
|
providerSelect.addEventListener('change', () => {
|
|
updateModelOptions(`assist-tier-${tier}-model`, providerSelect.value);
|
|
});
|
|
}
|
|
|
|
const taskRowsForModels = MODEL_DEFAULT_TASK_KEYS;
|
|
for (const task of taskRowsForModels) {
|
|
const providerSelect = el.querySelector(`#assist-bg-${task}-provider`);
|
|
if (!providerSelect) {continue;}
|
|
providerSelect.addEventListener('change', () => {
|
|
updateModelOptions(`assist-bg-${task}-model`, providerSelect.value);
|
|
});
|
|
}
|
|
|
|
// Refresh datalist options after draft re-application in case provider selects were restored.
|
|
for (const tier of tierRows) {
|
|
const providerSelect = el.querySelector(`#assist-tier-${tier}-provider`);
|
|
if (!providerSelect) {continue;}
|
|
updateModelOptions(`assist-tier-${tier}-model`, providerSelect.value);
|
|
}
|
|
for (const task of taskRowsForModels) {
|
|
const providerSelect = el.querySelector(`#assist-bg-${task}-provider`);
|
|
if (!providerSelect) {continue;}
|
|
updateModelOptions(`assist-bg-${task}-model`, providerSelect.value);
|
|
}
|
|
|
|
bindAssistantDraftTracking(el);
|
|
|
|
const statusEl = el.querySelector('#ops-assistant-status');
|
|
const buttons = el.querySelectorAll('.assistant-action-btn');
|
|
buttons.forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
const rawAction = button.getAttribute('data-action');
|
|
const action = rawAction === 'save-council' ? 'save-councils' : rawAction;
|
|
let patches = null;
|
|
if (action === 'toggle-announce') {
|
|
patches = { 'automation.delivery_mode': announce ? 'shared_session' : 'announce' };
|
|
_assistantManualOverrides.add('automation.delivery_mode');
|
|
} else if (action === 'toggle-daily-briefing') {
|
|
patches = { 'automation.daily_briefing.enabled': !dailyBriefing };
|
|
_assistantManualOverrides.add('automation.daily_briefing.enabled');
|
|
} else if (action === 'toggle-memory-daily') {
|
|
patches = { 'memory.daily_log.enabled': !memoryDaily };
|
|
_assistantManualOverrides.add('memory.daily_log.enabled');
|
|
} else if (action === 'toggle-memory-proactive') {
|
|
patches = { 'memory.proactive_extract.enabled': !memoryProactive };
|
|
_assistantManualOverrides.add('memory.proactive_extract.enabled');
|
|
} else if (action === 'toggle-tts') {
|
|
patches = { 'tts.enabled': !ttsEnabled };
|
|
_assistantManualOverrides.add('tts.enabled');
|
|
} else if (action === 'test-daily-briefing') {
|
|
await triggerDailyBriefingTest(briefingName, statusEl);
|
|
} else if (action === 'playbook-executive') {
|
|
_lastPlaybookRollbackPatches = buildRollbackPatchesFromSnapshot(snapshot);
|
|
patches = buildPlaybookPatches('executive');
|
|
} else if (action === 'playbook-operator') {
|
|
_lastPlaybookRollbackPatches = buildRollbackPatchesFromSnapshot(snapshot);
|
|
patches = buildPlaybookPatches('operator');
|
|
} else if (action === 'playbook-focus') {
|
|
_lastPlaybookRollbackPatches = buildRollbackPatchesFromSnapshot(snapshot);
|
|
patches = buildPlaybookPatches('focus');
|
|
} else if (action === 'playbook-undo') {
|
|
if (!_lastPlaybookRollbackPatches) {
|
|
if (statusEl) {
|
|
statusEl.textContent = 'No playbook changes to undo.';
|
|
statusEl.className = 'text-sm text-zinc-500';
|
|
}
|
|
return;
|
|
}
|
|
patches = _lastPlaybookRollbackPatches;
|
|
_lastPlaybookRollbackPatches = null;
|
|
} else if (action === 'save-briefing-output') {
|
|
const channel = (el.querySelector('#assist-brief-channel')?.value ?? '').trim();
|
|
const peer = (el.querySelector('#assist-brief-peer')?.value ?? '').trim();
|
|
if (!channel || !peer) {
|
|
if (statusEl) {
|
|
statusEl.textContent = 'Briefing output channel and peer are required.';
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return;
|
|
}
|
|
patches = {
|
|
'automation.daily_briefing.output.channel': channel,
|
|
'automation.daily_briefing.output.peer': peer,
|
|
'automation.daily_briefing.enabled': true,
|
|
};
|
|
_assistantManualOverrides.add('automation.daily_briefing.enabled');
|
|
} else if (action === 'save-model-defaults') {
|
|
const tasks = MODEL_DEFAULT_TASK_KEYS;
|
|
patches = {
|
|
'agents.primary_tier': (el.querySelector('#assist-primary-tier')?.value ?? 'default'),
|
|
};
|
|
const tiers = MODEL_DEFAULT_TIER_KEYS;
|
|
for (const tier of tiers) {
|
|
const provider = (el.querySelector(`#assist-tier-${tier}-provider`)?.value ?? '').trim();
|
|
const model = (el.querySelector(`#assist-tier-${tier}-model`)?.value ?? '').trim();
|
|
if (!provider || (!model && tier !== 'default')) {
|
|
continue;
|
|
}
|
|
if (provider) {
|
|
patches[`models.${tier}.provider`] = provider;
|
|
}
|
|
if (model) {
|
|
patches[`models.${tier}.model`] = model;
|
|
}
|
|
}
|
|
for (const task of tasks) {
|
|
const delegationTier = el.querySelector(`#assist-delegation-${task}`)?.value ?? 'fast';
|
|
const enabled = Boolean(el.querySelector(`#assist-bg-${task}-enabled`)?.checked);
|
|
const provider = (el.querySelector(`#assist-bg-${task}-provider`)?.value ?? '').trim();
|
|
const model = (el.querySelector(`#assist-bg-${task}-model`)?.value ?? '').trim();
|
|
const fallbackTier = el.querySelector(`#assist-bg-${task}-fallback`)?.value ?? 'fast';
|
|
|
|
patches[`agents.delegation.${task}`] = delegationTier;
|
|
patches[`agents.background_models.${task}.enabled`] = enabled;
|
|
if (provider) {
|
|
patches[`agents.background_models.${task}.provider`] = provider;
|
|
}
|
|
if (model) {
|
|
patches[`agents.background_models.${task}.model`] = model;
|
|
}
|
|
patches[`agents.background_models.${task}.fallback_tier`] = fallbackTier;
|
|
}
|
|
} else if (action === 'save-councils') {
|
|
const enabled = Boolean(el.querySelector('#assist-councils-enabled')?.checked);
|
|
const dTier = el.querySelector('#assist-council-d-tier')?.value ?? 'complex';
|
|
const pTier = el.querySelector('#assist-council-p-tier')?.value ?? 'complex';
|
|
const metaTier = el.querySelector('#assist-council-meta-tier')?.value ?? 'complex';
|
|
const dArbiter = (el.querySelector('#assist-council-d-arbiter')?.value ?? '').trim();
|
|
const dFreethinker = (el.querySelector('#assist-council-d-freethinker')?.value ?? '').trim();
|
|
const pArbiter = (el.querySelector('#assist-council-p-arbiter')?.value ?? '').trim();
|
|
const pFreethinker = (el.querySelector('#assist-council-p-freethinker')?.value ?? '').trim();
|
|
const metaArbiter = (el.querySelector('#assist-council-meta-arbiter')?.value ?? '').trim();
|
|
const scaffoldPath = (el.querySelector('#assist-council-scaffold')?.value ?? '').trim();
|
|
const maxRoundsRaw = Number(el.querySelector('#assist-council-max-rounds')?.value ?? 2);
|
|
const maxRounds = Number.isFinite(maxRoundsRaw) ? Math.max(1, Math.min(6, Math.floor(maxRoundsRaw))) : 2;
|
|
|
|
if (!dArbiter || !dFreethinker || !pArbiter || !pFreethinker || !metaArbiter) {
|
|
if (statusEl) {
|
|
statusEl.textContent = 'All council agent names are required.';
|
|
statusEl.className = 'text-sm text-red-500';
|
|
}
|
|
return;
|
|
}
|
|
|
|
patches = {
|
|
'councils.enabled': enabled,
|
|
'councils.defaults.max_rounds': maxRounds,
|
|
'councils.groups.D.model_tier': dTier,
|
|
'councils.groups.P.model_tier': pTier,
|
|
'councils.meta_model_tier': metaTier,
|
|
'councils.groups.D.arbiter_agent': dArbiter,
|
|
'councils.groups.D.freethinker_agent': dFreethinker,
|
|
'councils.groups.P.arbiter_agent': pArbiter,
|
|
'councils.groups.P.freethinker_agent': pFreethinker,
|
|
'councils.meta_arbiter_agent': metaArbiter,
|
|
'councils.scaffold_path': scaffoldPath,
|
|
};
|
|
} else if (action === 'run-council') {
|
|
const councilTask = (el.querySelector('#assist-council-task')?.value ?? '').trim();
|
|
const councilStatusEl = el.querySelector('#assist-council-status');
|
|
const ok = await triggerCouncilRun(councilTask, councilStatusEl);
|
|
if (ok && _lastAssistantConfig) {
|
|
updateAssistantHealth(_lastAssistantConfig);
|
|
}
|
|
return;
|
|
}
|
|
if (!patches) {return;}
|
|
const patchResult = await applyAssistantPatch(patches, statusEl);
|
|
if (action === 'save-model-defaults' && patchResult.applied.length > 0 && patchResult.rejected.length === 0) {
|
|
_assistantModelDefaultsDraft = null;
|
|
} else if (action === 'save-model-defaults') {
|
|
_assistantModelDefaultsDraft = readAssistantModelDefaultsDraft(el) ?? _assistantModelDefaultsDraft;
|
|
}
|
|
// Force immediate refresh of slow sections after applying.
|
|
const refreshed = await fetchSlow(_dashboardClient);
|
|
if (refreshed) {
|
|
if (refreshed.config) {
|
|
_lastAssistantConfig = refreshed.config;
|
|
}
|
|
updateServices(refreshed.services);
|
|
updateSessionAnalytics(refreshed.sessionAnalytics);
|
|
updateContextHealth(refreshed.contextUsage);
|
|
// Only re-render assistant controls from a confirmed config snapshot.
|
|
if (refreshed.config) {
|
|
updateAssistantHealth(_lastAssistantConfig);
|
|
} else if (_lastAssistantConfig) {
|
|
updateAssistantHealth(_lastAssistantConfig);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function readAssistantModelDefaultsDraft(rootEl) {
|
|
const primaryTier = rootEl.querySelector('#assist-primary-tier')?.value;
|
|
if (!primaryTier) {
|
|
return null;
|
|
}
|
|
|
|
const tiers = {};
|
|
for (const tier of MODEL_DEFAULT_TIER_KEYS) {
|
|
const provider = (rootEl.querySelector(`#assist-tier-${tier}-provider`)?.value ?? '').trim();
|
|
const model = (rootEl.querySelector(`#assist-tier-${tier}-model`)?.value ?? '').trim();
|
|
if (!provider && !model) {continue;}
|
|
tiers[tier] = { provider, model };
|
|
}
|
|
|
|
const tasks = {};
|
|
for (const task of MODEL_DEFAULT_TASK_KEYS) {
|
|
tasks[task] = {
|
|
delegationTier: rootEl.querySelector(`#assist-delegation-${task}`)?.value ?? 'fast',
|
|
backgroundEnabled: Boolean(rootEl.querySelector(`#assist-bg-${task}-enabled`)?.checked),
|
|
provider: (rootEl.querySelector(`#assist-bg-${task}-provider`)?.value ?? '').trim(),
|
|
model: (rootEl.querySelector(`#assist-bg-${task}-model`)?.value ?? '').trim(),
|
|
fallbackTier: rootEl.querySelector(`#assist-bg-${task}-fallback`)?.value ?? 'fast',
|
|
};
|
|
}
|
|
|
|
return {
|
|
primaryTier,
|
|
tiers,
|
|
tasks,
|
|
};
|
|
}
|
|
|
|
function _updateChannels(channelsData) {
|
|
const el = document.getElementById('ops-channels');
|
|
if (!el) {return;}
|
|
|
|
const channels = channelsData?.channels ?? [];
|
|
|
|
if (channels.length === 0) {
|
|
el.innerHTML = '<div class="text-sm text-zinc-500">No channels registered</div>';
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = channels.map(ch =>
|
|
`<div class="channel-card">
|
|
<span class="channel-dot ${ch.status}"></span>
|
|
<span class="channel-name">${escapeHtml(ch.name)}</span>
|
|
</div>`,
|
|
).join('');
|
|
}
|
|
|
|
function updateServices(servicesData) {
|
|
const el = document.getElementById('ops-services');
|
|
if (!el) {return;}
|
|
|
|
const services = servicesData?.services ?? [];
|
|
_lastServices = services;
|
|
|
|
if (services.length === 0) {
|
|
el.innerHTML = '<div class="text-sm text-zinc-500">No services configured</div>';
|
|
renderServiceConfigModal();
|
|
return;
|
|
}
|
|
|
|
el.innerHTML = services.map(svc => {
|
|
const typeIcon = svc.type === 'channel' ? '📡' : svc.type === 'automation' ? '⚙️' : '🔧';
|
|
const borderColor = svc.status === 'connected'
|
|
? 'border-l-green-500'
|
|
: svc.status === 'configured'
|
|
? 'border-l-blue-500'
|
|
: svc.status === 'error'
|
|
? 'border-l-red-500'
|
|
: 'border-l-zinc-600 opacity-60';
|
|
const statusColor = svc.status === 'connected'
|
|
? 'text-green-500'
|
|
: svc.status === 'configured'
|
|
? 'text-blue-500'
|
|
: svc.status === 'error'
|
|
? 'text-red-500'
|
|
: 'text-zinc-500';
|
|
const itemCount = svc.itemCount ? ` (${svc.itemCount})` : '';
|
|
return `<button class="service-card text-left bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-1 border-l-4 ${borderColor} hover:border-zinc-700 transition-colors" data-service-name="${escapeHtml(svc.name)}">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm shrink-0">${typeIcon}</span>
|
|
<span class="text-sm font-semibold text-zinc-50">${escapeHtml(svc.name)}${itemCount}</span>
|
|
</div>
|
|
<span class="text-xs uppercase ${statusColor}">${escapeHtml(svc.status)}</span>
|
|
<span class="text-xs text-zinc-500">${escapeHtml(svc.description)}</span>
|
|
<span class="text-xs text-zinc-600">Click to configure</span>
|
|
${svc.error ? `<span class="text-xs text-red-400">Error: ${escapeHtml(String(svc.error))}</span>` : ''}
|
|
</button>`;
|
|
}).join('');
|
|
|
|
el.querySelectorAll('.service-card').forEach((card) => {
|
|
card.addEventListener('click', () => {
|
|
const serviceName = card.getAttribute('data-service-name');
|
|
if (!serviceName) {return;}
|
|
_serviceConfigState.open = true;
|
|
_serviceConfigState.serviceName = serviceName;
|
|
_serviceConfigState.status = null;
|
|
_serviceConfigState.tone = 'neutral';
|
|
renderServiceConfigModal();
|
|
});
|
|
});
|
|
|
|
renderServiceConfigModal();
|
|
}
|
|
|
|
function getConfigValue(path, fallbackValue) {
|
|
const value = getByPath(_lastAssistantConfig, path);
|
|
return value === undefined ? fallbackValue : value;
|
|
}
|
|
|
|
function renderServiceConfigModal() {
|
|
const root = document.getElementById('ops-service-config-modal-root');
|
|
if (!root) {return;}
|
|
if (!_serviceConfigState.open || !_serviceConfigState.serviceName) {
|
|
root.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const service = _lastServices.find((svc) => svc.name === _serviceConfigState.serviceName);
|
|
if (!service) {
|
|
_serviceConfigState.open = false;
|
|
root.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const quickTogglePath = SERVICE_TOGGLE_PATCH_PATHS[service.name];
|
|
const hasQuickToggle = Boolean(quickTogglePath);
|
|
const quickToggleValue = hasQuickToggle ? Boolean(getConfigValue(quickTogglePath, false)) : false;
|
|
const heartbeatSection = service.name === 'heartbeat'
|
|
? `
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<label class="flex flex-col gap-1" title="Heartbeat check interval (examples: 1m, 5m, 30s).">
|
|
<span class="text-xs text-zinc-500">Interval (e.g. 5m)</span>
|
|
<input id="svc-heartbeat-interval" type="text" value="${escapeHtml(String(getConfigValue('automation.heartbeat.interval', '5m')))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="How often heartbeat checks are executed." />
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Minimum wait between repeated failure notifications.">
|
|
<span class="text-xs text-zinc-500">Notify cooldown</span>
|
|
<input id="svc-heartbeat-notify-cooldown" type="text" value="${escapeHtml(String(getConfigValue('automation.heartbeat.notify_cooldown', '30m')))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Cooldown duration before sending another alert for the same issue." />
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Consecutive failed checks required before a service is marked unhealthy.">
|
|
<span class="text-xs text-zinc-500">Failure threshold</span>
|
|
<input id="svc-heartbeat-failure-threshold" type="number" min="1" max="10" value="${escapeHtml(String(getConfigValue('automation.heartbeat.failure_threshold', 2)))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Use 1 for aggressive alerting, higher values to reduce false positives." />
|
|
</label>
|
|
<label class="flex flex-col gap-1" title="Free disk space alert threshold in megabytes.">
|
|
<span class="text-xs text-zinc-500">Disk threshold (MB)</span>
|
|
<input id="svc-heartbeat-disk-threshold" type="number" min="10" value="${escapeHtml(String(getConfigValue('automation.heartbeat.disk_threshold_mb', 100)))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" title="Alert when free disk falls below this value." />
|
|
</label>
|
|
</div>
|
|
<div class="mt-2">
|
|
<div class="text-xs text-zinc-500 mb-1">Checks</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-1">
|
|
${HEARTBEAT_CHECK_KEYS.map((check) => {
|
|
const selected = Array.isArray(getConfigValue('automation.heartbeat.checks', HEARTBEAT_CHECK_KEYS))
|
|
&& getConfigValue('automation.heartbeat.checks', HEARTBEAT_CHECK_KEYS).includes(check);
|
|
return `
|
|
<label class="flex items-center gap-2 text-xs text-zinc-300" title="Enable or disable the ${escapeHtml(check)} heartbeat check.">
|
|
<input type="checkbox" data-heartbeat-check="${check}" ${selected ? 'checked' : ''} title="Toggle ${escapeHtml(check)} check." />
|
|
<span>${escapeHtml(check)}</span>
|
|
</label>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
`
|
|
: '';
|
|
|
|
const toneClass = _serviceConfigState.tone === 'success'
|
|
? 'text-green-500'
|
|
: _serviceConfigState.tone === 'error'
|
|
? 'text-red-500'
|
|
: 'text-zinc-500';
|
|
|
|
root.innerHTML = `
|
|
<div id="svc-config-overlay" class="fixed inset-0 bg-black/60 z-40"></div>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div class="w-full max-w-3xl bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-xl">
|
|
<div class="flex items-start justify-between gap-4 mb-3">
|
|
<div>
|
|
<div class="text-lg font-semibold text-zinc-50">Configure ${escapeHtml(service.name)}</div>
|
|
<div class="text-xs text-zinc-400">${escapeHtml(service.description ?? '')}</div>
|
|
</div>
|
|
<button id="svc-config-close" class="px-2 py-1 text-xs border border-zinc-700 rounded text-zinc-300 hover:bg-zinc-800">Close</button>
|
|
</div>
|
|
<div class="mb-3 p-3 border border-zinc-800 rounded bg-zinc-950/60">
|
|
<div class="text-xs uppercase text-zinc-500 mb-2">Quick Settings</div>
|
|
${hasQuickToggle ? `
|
|
<label class="flex items-center gap-2 mb-2" title="Master on/off for this service (when available).">
|
|
<input id="svc-quick-enabled" type="checkbox" ${quickToggleValue ? 'checked' : ''} title="Enable or disable this service." />
|
|
<span class="text-sm text-zinc-200">Enabled</span>
|
|
</label>
|
|
` : '<div class="text-xs text-zinc-500 mb-2">No quick toggle available for this service.</div>'}
|
|
${heartbeatSection}
|
|
</div>
|
|
<div class="mb-3 p-3 border border-zinc-800 rounded bg-zinc-950/60">
|
|
<div class="text-xs uppercase text-zinc-500 mb-2">Advanced Patch (optional JSON)</div>
|
|
<textarea id="svc-advanced-patch" rows="5" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm font-mono focus:border-blue-500 outline-none" placeholder='{"automation.heartbeat.enabled": true}' title="Optional JSON object of config path/value pairs to patch on save.">${escapeHtml(_serviceConfigState.advancedPatch ?? '')}</textarea>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div id="svc-config-status" class="text-xs ${toneClass}">${escapeHtml(_serviceConfigState.status ?? '')}</div>
|
|
<div class="flex items-center gap-2">
|
|
<button id="svc-config-cancel" class="px-3 py-1.5 text-sm border border-zinc-700 rounded text-zinc-300 hover:bg-zinc-800">Cancel</button>
|
|
<button id="svc-config-save" class="px-3 py-1.5 text-sm border border-zinc-700 rounded bg-zinc-800 text-zinc-100 hover:bg-zinc-700">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const closeModal = () => {
|
|
_serviceConfigState.open = false;
|
|
renderServiceConfigModal();
|
|
};
|
|
root.querySelector('#svc-config-close')?.addEventListener('click', closeModal);
|
|
root.querySelector('#svc-config-cancel')?.addEventListener('click', closeModal);
|
|
root.querySelector('#svc-config-overlay')?.addEventListener('click', closeModal);
|
|
|
|
root.querySelector('#svc-config-save')?.addEventListener('click', async () => {
|
|
if (!_dashboardClient || !_serviceConfigState.serviceName) {return;}
|
|
const patches = {};
|
|
|
|
if (quickTogglePath) {
|
|
patches[quickTogglePath] = Boolean(root.querySelector('#svc-quick-enabled')?.checked);
|
|
}
|
|
if (_serviceConfigState.serviceName === 'heartbeat') {
|
|
patches['automation.heartbeat.interval'] = (root.querySelector('#svc-heartbeat-interval')?.value ?? '5m').trim();
|
|
patches['automation.heartbeat.notify_cooldown'] = (root.querySelector('#svc-heartbeat-notify-cooldown')?.value ?? '30m').trim();
|
|
patches['automation.heartbeat.failure_threshold'] = Number(root.querySelector('#svc-heartbeat-failure-threshold')?.value ?? 2);
|
|
patches['automation.heartbeat.disk_threshold_mb'] = Number(root.querySelector('#svc-heartbeat-disk-threshold')?.value ?? 100);
|
|
patches['automation.heartbeat.checks'] = HEARTBEAT_CHECK_KEYS.filter(
|
|
(check) => Boolean(root.querySelector(`[data-heartbeat-check="${check}"]`)?.checked),
|
|
);
|
|
}
|
|
|
|
const advancedRaw = (root.querySelector('#svc-advanced-patch')?.value ?? '').trim();
|
|
_serviceConfigState.advancedPatch = advancedRaw;
|
|
if (advancedRaw.length > 0) {
|
|
try {
|
|
const parsed = JSON.parse(advancedRaw);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('Advanced patch must be a JSON object');
|
|
}
|
|
Object.assign(patches, parsed);
|
|
} catch (error) {
|
|
_serviceConfigState.status = error instanceof Error ? error.message : String(error);
|
|
_serviceConfigState.tone = 'error';
|
|
renderServiceConfigModal();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(patches).length === 0) {
|
|
_serviceConfigState.status = 'No changes to save.';
|
|
_serviceConfigState.tone = 'neutral';
|
|
renderServiceConfigModal();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await _dashboardClient.call('config.patch', { patches });
|
|
const applied = Array.isArray(result?.applied) ? result.applied : [];
|
|
const rejected = Array.isArray(result?.rejected) ? result.rejected : [];
|
|
if (applied.length === 0) {
|
|
_serviceConfigState.status = rejected.length > 0
|
|
? `No changes applied. Rejected: ${rejected.join(', ')}`
|
|
: 'No changes were applied.';
|
|
_serviceConfigState.tone = 'error';
|
|
renderServiceConfigModal();
|
|
return;
|
|
}
|
|
_serviceConfigState.status = `Saved ${applied.length} setting(s).${rejected.length > 0 ? ` Rejected: ${rejected.join(', ')}` : ''}`;
|
|
_serviceConfigState.tone = rejected.length > 0 ? 'error' : 'success';
|
|
|
|
const refreshed = await fetchSlow(_dashboardClient);
|
|
if (refreshed) {
|
|
updateServices(refreshed.services);
|
|
updateSessionAnalytics(refreshed.sessionAnalytics);
|
|
updateContextHealth(refreshed.contextUsage);
|
|
if (refreshed.config) {
|
|
_lastAssistantConfig = refreshed.config;
|
|
updateAssistantHealth(_lastAssistantConfig);
|
|
}
|
|
}
|
|
renderServiceConfigModal();
|
|
} catch (error) {
|
|
_serviceConfigState.status = `Save failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
_serviceConfigState.tone = 'error';
|
|
renderServiceConfigModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Data fetching ───────────────────────────────────────────────
|
|
|
|
async function fetchFast(client) {
|
|
try {
|
|
const [metrics, eventsData, requestsData] = await Promise.all([
|
|
client.call('system.metrics'),
|
|
client.call('system.events', { limit: 50 }),
|
|
client.call('system.activeRequests'),
|
|
]);
|
|
return { metrics, eventsData, requestsData };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchSlow(client) {
|
|
const [health, services, sessionAnalytics, contextUsage, config, modelCatalog] = await Promise.allSettled([
|
|
client.call('system.health'),
|
|
client.call('system.services'),
|
|
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
|
client.call('system.contextUsage'),
|
|
client.call('config.get'),
|
|
client.call('system.modelCatalog'),
|
|
]);
|
|
|
|
const unwrap = (result) => (result.status === 'fulfilled' ? result.value : null);
|
|
|
|
const configValue = unwrap(config);
|
|
const modelCatalogValue = unwrap(modelCatalog);
|
|
if (configValue && typeof configValue === 'object') {
|
|
configValue.__modelCatalog = Array.isArray(modelCatalogValue?.providers) ? modelCatalogValue.providers : [];
|
|
}
|
|
|
|
return {
|
|
health: unwrap(health),
|
|
services: unwrap(services),
|
|
sessionAnalytics: unwrap(sessionAnalytics),
|
|
contextUsage: unwrap(contextUsage),
|
|
config: configValue,
|
|
};
|
|
}
|
|
|
|
// ── Main load function ──────────────────────────────────────────
|
|
|
|
let _lastHealth = null;
|
|
let _lastMetrics = null;
|
|
|
|
async function loadDashboard(el, client) {
|
|
_dashboardClient = client;
|
|
renderSkeleton(el);
|
|
|
|
// Fetch everything initially
|
|
const [fast, slow] = await Promise.all([
|
|
fetchFast(client),
|
|
fetchSlow(client),
|
|
]);
|
|
|
|
_lastHealth = slow?.health ?? null;
|
|
_lastMetrics = fast?.metrics ?? null;
|
|
|
|
if (fast) {
|
|
updateCounters(fast.metrics, _lastHealth);
|
|
updateModelTable(fast.metrics);
|
|
updateEvents(fast.eventsData);
|
|
updateActiveRequests(fast.requestsData);
|
|
}
|
|
if (slow?.config) {
|
|
_lastAssistantConfig = slow.config;
|
|
}
|
|
if (slow?.services) {
|
|
updateServices(slow.services);
|
|
}
|
|
if (slow?.sessionAnalytics) {
|
|
updateSessionAnalytics(slow.sessionAnalytics);
|
|
}
|
|
if (slow?.contextUsage) {
|
|
updateContextHealth(slow.contextUsage);
|
|
}
|
|
if (slow?.config) {
|
|
updateAssistantHealth(_lastAssistantConfig);
|
|
}
|
|
|
|
// Fast refresh: 3 seconds for metrics, events, requests
|
|
_fastTimer = setInterval(async () => {
|
|
const data = await fetchFast(client);
|
|
if (data) {
|
|
_lastMetrics = data.metrics;
|
|
updateCounters(data.metrics, _lastHealth);
|
|
updateModelTable(data.metrics);
|
|
updateEvents(data.eventsData);
|
|
updateActiveRequests(data.requestsData);
|
|
}
|
|
}, 3000);
|
|
|
|
// Slow refresh: 10 seconds for health, services
|
|
_slowTimer = setInterval(async () => {
|
|
const data = await fetchSlow(client);
|
|
if (data.health) {
|
|
_lastHealth = data.health;
|
|
updateCounters(_lastMetrics, data.health);
|
|
}
|
|
if (data.config) {
|
|
_lastAssistantConfig = data.config;
|
|
}
|
|
if (data.services) {
|
|
updateServices(data.services);
|
|
}
|
|
if (data.sessionAnalytics) {
|
|
updateSessionAnalytics(data.sessionAnalytics);
|
|
}
|
|
if (data.contextUsage) {
|
|
updateContextHealth(data.contextUsage);
|
|
}
|
|
if (data.config) {
|
|
updateAssistantHealth(_lastAssistantConfig);
|
|
}
|
|
}, 10000);
|
|
}
|
|
|
|
export const DashboardPage = {
|
|
async render(el, client) {
|
|
await loadDashboard(el, client);
|
|
},
|
|
|
|
teardown() {
|
|
if (_fastTimer) {
|
|
clearInterval(_fastTimer);
|
|
_fastTimer = null;
|
|
}
|
|
if (_slowTimer) {
|
|
clearInterval(_slowTimer);
|
|
_slowTimer = null;
|
|
}
|
|
_lastHealth = null;
|
|
_lastMetrics = null;
|
|
_dashboardClient = null;
|
|
_lastPlaybookRollbackPatches = null;
|
|
_lastBriefingTestAt = null;
|
|
_assistantSaveState = null;
|
|
_lastAssistantConfig = null;
|
|
_assistantManualOverrides = new Set();
|
|
_assistantModelDefaultsDraft = null;
|
|
_assistantDraftState = new Map();
|
|
_assistantDraftTouchedAt = 0;
|
|
},
|
|
};
|