<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- System font stack — no external Google Fonts dependency -->
<title>Ironclad Dashboard</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230a0a0f'/%3E%3Crect x='10' y='5' width='12' height='18' rx='0.8' fill='none' stroke='%2300ff88' stroke-width='2'/%3E%3Ccircle cx='13.6' cy='11.5' r='1.35' fill='%2300ff88'/%3E%3Ccircle cx='18.4' cy='11.5' r='1.35' fill='%2300ff88'/%3E%3Crect x='13.6' y='16.8' width='4.8' height='1.35' fill='%2300ff88'/%3E%3Crect x='15' y='23' width='2' height='4' fill='%2300ff88'/%3E%3C/svg%3E">
<style>
:root, [data-theme="ai-purple"] {
--bg: #0a0b0f;
--surface: #12131a;
--border: #1e2030;
--text: #e4e4e7;
--muted: #71717a;
--accent: #6366f1;
--accent-dim: rgba(99, 102, 241, 0.15);
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
--radius: 6px;
--shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -2px rgba(0,0,0,0.2);
--font: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", Menlo, Consolas, monospace;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", Menlo, Consolas, monospace;
--crt-scanline: rgba(0,0,0,0.08);
--crt-vignette: rgba(0,0,0,0.35);
--text-glow: rgba(228,228,231,0.12);
--robot-color: #22c55e;
}
[data-theme="crt-orange"] {
--bg: #0c0800; --surface: #1a1200; --border: #2e2200; --text: #ffcc66; --muted: #996633;
--accent: #ff8c00; --accent-dim: rgba(255, 140, 0, 0.15);
--success: #ffaa00; --warning: #ff6600; --error: #ff3300;
--crt-scanline: rgba(0,0,0,0.1); --crt-vignette: rgba(0,0,0,0.4);
--text-glow: rgba(255,140,0,0.15);
}
[data-theme="crt-green"] {
--bg: #000c00; --surface: #001a00; --border: #003300; --text: #33ff33; --muted: #1a9933;
--accent: #00ff41; --accent-dim: rgba(0, 255, 65, 0.12);
--success: #00ff41; --warning: #99ff33; --error: #ff3333;
--crt-scanline: rgba(0,0,0,0.12); --crt-vignette: rgba(0,0,0,0.45);
--text-glow: rgba(0,255,65,0.2);
}
[data-theme="psychedelic"] {
--bg: #0a0012; --surface: #150025; --border: #2a0050; --text: #f0e0ff; --muted: #9966cc;
--accent: #ff00ff; --accent-dim: rgba(255, 0, 255, 0.15);
--success: #00ffcc; --warning: #ffff00; --error: #ff0066;
--crt-scanline: rgba(0,0,0,0.06); --crt-vignette: rgba(0,0,0,0.3);
--text-glow: rgba(255,0,255,0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: var(--font); background: var(--bg); color: var(--text);
overflow: hidden; display: flex;
-webkit-font-smoothing: none; -moz-osx-font-smoothing: unset;
text-shadow: 0 0 1px var(--text-glow, rgba(228,228,231,0.12));
}
.layout { display: flex; width: 100%; height: 100%; }
.sidebar {
width: 252px; min-width: 252px; height: 100%; background: var(--surface);
border-right: 1px solid var(--border); display: flex; flex-direction: column;
flex-shrink: 0; transition: width 0.2s, min-width 0.2s; overflow: hidden;
}
.sidebar.collapsed { width: 56px; min-width: 56px; }
.sidebar-header { padding: 1.25rem; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 0.75rem; white-space: normal; overflow: hidden; transition: padding 0.2s, justify-content 0.2s; }
.sidebar.collapsed .sidebar-header { padding: 0; justify-content: center; align-items: center; gap: 0; height: 56px; min-height: 56px; max-height: 56px; overflow: hidden; }
.sidebar-brand { display: flex; flex-direction: column; min-width: 0; overflow: hidden; transition: width 0.2s, opacity 0.2s; }
.sidebar.collapsed .sidebar-brand { opacity: 0; width: 0; overflow: hidden; margin: 0; padding: 0; }
.sidebar-title { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; letter-spacing: 0.15em; text-transform: uppercase; }
.sidebar-subtitle { font-size: 0.66rem; line-height: 1.25; color: var(--muted); letter-spacing: 0.04em; text-transform: uppercase; white-space: normal; overflow-wrap: anywhere; }
.robot-icon { flex-shrink: 0; margin-top: 2px; transition: width 0.2s, height 0.2s; }
.sidebar.collapsed .robot-icon { width: 20px; height: 20px; }
.agent-badge { display: inline-flex; align-items: center; gap: 0.375rem; margin-top: 0.75rem; padding: 0.375rem 0.75rem; max-width: 100%; background: var(--accent-dim); border-radius: 3px; font-size: 0.8125rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity 0.15s, max-height 0.2s, margin 0.2s, padding 0.2s; max-height: 40px; }
.sidebar.collapsed .agent-badge { opacity: 0; max-height: 0; margin: 0; padding: 0; overflow: hidden; pointer-events: none; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); flex-shrink: 0; animation: statusPulse 2s ease-in-out infinite; }
.status-dot.warning { background: var(--warning); animation: none; }
.status-dot.error { background: var(--error); animation: none; }
@keyframes statusPulse { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34,197,94,0.4); } 50% { opacity: 0.7; box-shadow: 0 0 6px 2px rgba(34,197,94,0.3); } }
.sidebar-nav { flex: 1; overflow-y: auto; padding: 0.75rem 0; }
.sidebar-nav a { display: flex; align-items: center; gap: 0.75rem; padding: 0.625rem 1.25rem; color: var(--muted); text-decoration: none; font-size: 0.875rem; transition: color 0.15s, background 0.15s; cursor: pointer; white-space: nowrap; overflow: hidden; }
.sidebar.collapsed .sidebar-nav a { justify-content: center; padding: 0.5rem 0; }
.sidebar-nav a .nav-label { transition: opacity 0.15s, max-width 0.2s; max-width: 200px; opacity: 1; overflow: hidden; }
.sidebar.collapsed .sidebar-nav a .nav-label { opacity: 0; max-width: 0; margin: 0; padding: 0; }
.nav-icon { width: 16px; height: 16px; flex-shrink: 0; color: currentColor; }
.ui-icon { width: 14px; height: 14px; flex-shrink: 0; color: currentColor; display: inline-block; }
.sidebar-nav a:hover { color: var(--text); background: rgba(255,255,255,0.03); }
.sidebar-nav a.active { color: var(--accent); background: var(--accent-dim); }
.sidebar-nav a:focus-visible,
.mobile-nav a:focus-visible,
.btn:focus-visible,
.tabs button:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sidebar-collapse-btn {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.625rem 1.25rem; border: none; background: none; color: var(--muted);
font-size: 0.75rem; font-family: var(--font); cursor: pointer;
transition: color 0.15s, background 0.15s; border-top: 1px solid var(--border);
white-space: nowrap; overflow: hidden;
}
.sidebar-collapse-btn:hover { color: var(--text); background: rgba(255,255,255,0.03); }
.sidebar-collapse-btn .collapse-icon { width: 16px; height: 16px; flex-shrink: 0; transition: transform 0.2s; }
.sidebar.collapsed .sidebar-collapse-btn .collapse-icon { transform: rotate(180deg); }
.sidebar-collapse-btn .collapse-label { transition: opacity 0.15s, max-width 0.2s; max-width: 100px; opacity: 1; overflow: hidden; }
.sidebar.collapsed .sidebar-collapse-btn .collapse-label { opacity: 0; max-width: 0; margin: 0; padding: 0; }
.sidebar-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border); font-size: 0.75rem; color: var(--muted); white-space: normal; overflow: visible; transition: padding 0.2s, text-align 0.2s; }
.sidebar.collapsed .sidebar-footer { text-align: center; padding: 0.5rem 0.25rem; font-size: 0.625rem; }
.main-wrap { flex: 1; display: flex; flex-direction: column; min-width: 0; height: 100%; overflow: hidden; }
.header-bar { height: 56px; padding: 0 1.5rem; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.breadcrumb { font-size: 0.875rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.connection-status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--muted); }
.connection-status .ws-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); flex-shrink: 0; animation: statusPulse 2s ease-in-out infinite; }
.connection-status .ws-dot.off { background: var(--error); animation: none; }
.content { flex: 1; overflow: auto; padding: 1.5rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; box-shadow: var(--shadow); transition: transform 0.15s, box-shadow 0.15s; }
.card:hover { transform: translateY(-1px); box-shadow: 0 8px 15px -3px rgba(0,0,0,0.35); }
.card-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 0.5rem; }
.card-value { font-size: 1.25rem; font-weight: 600; }
.card-mono { font-family: var(--font-mono); font-size: 0.8125rem; }
.session-nick { transition: color 0.15s; }
.copy-id-btn { display: inline-flex; align-items: center; justify-content: center; width: 1.125rem; height: 1.125rem; padding: 0; margin-left: 0.375rem; background: none; border: 1px solid var(--border); border-radius: 3px; color: var(--muted); cursor: pointer; vertical-align: middle; transition: color 0.15s, border-color 0.15s; flex-shrink: 0; }
.copy-id-btn:hover { color: var(--accent); border-color: var(--accent); }
.copy-id-btn svg { width: 0.6875rem; height: 0.6875rem; }
.badge { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.75rem; font-weight: 500; }
.badge.success { background: rgba(34, 197, 94, 0.2); color: var(--success); }
.badge.error { background: rgba(239, 68, 68, 0.2); color: var(--error); }
.badge.warning { background: rgba(234, 179, 8, 0.2); color: var(--warning); }
.badge.muted { background: rgba(113, 113, 122, 0.2); color: var(--muted); }
.badge.success::before { content: '✓'; font-size: 0.68rem; line-height: 1; }
.badge.warning::before { content: '!'; font-size: 0.68rem; line-height: 1; }
.badge.error::before { content: '×'; font-size: 0.7rem; line-height: 1; }
.overview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; }
.overview-fleet-slot { grid-column: 1 / -1; min-width: 0; }
.composite-card { padding: 0; overflow: hidden; }
.composite-card:hover { transform: translateY(-2px); }
.cc-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 1rem 1.25rem 0; }
.cc-left { display: flex; flex-direction: column; gap: 0.125rem; }
.cc-label { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.cc-value { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
.cc-sub { font-size: 0.75rem; color: var(--muted); margin-top: 0.125rem; font-family: var(--font-mono); }
.cc-right { display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; }
.cc-delta { font-size: 0.75rem; font-weight: 600; display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.15rem 0.5rem; border-radius: 3px; }
.cc-delta.up { color: var(--success); background: rgba(34,197,94,0.12); }
.cc-delta.down { color: var(--error); background: rgba(239,68,68,0.12); }
.cc-delta.flat { color: var(--muted); background: rgba(113,113,122,0.12); }
.cc-chart-wrap { position: relative; }
.cc-chart-axis { position: absolute; right: 0.5rem; font-size: 0.625rem; color: var(--muted); font-family: var(--font-mono); line-height: 1; pointer-events: none; user-select: none; }
.cc-chart-axis.top { top: 0.35rem; }
.cc-chart-axis.bottom { bottom: 0.2rem; }
.cc-chart { width: 100%; height: 64px; display: block; margin-top: 0.5rem; }
.cc-footer { display: flex; gap: 1rem; padding: 0.625rem 1.25rem; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); }
.cc-stat { display: flex; flex-direction: column; }
.cc-stat-label { font-size: 0.625rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
.cc-stat-value { font-size: 0.8125rem; font-weight: 600; font-family: var(--font-mono); }
.overview-status { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem; margin-bottom:0.75rem; }
.overview-meta { display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; }
.overview-attention { margin-bottom:0.75rem; }
.overview-onboarding { margin-bottom:0.75rem; }
.cc-agents-row { display: flex; gap: 0.5rem; flex-wrap: wrap; padding: 0.75rem 1.25rem 1rem; }
.cc-agent-pill { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.3rem 0.625rem; border-radius: 3px; font-size: 0.75rem; background: rgba(255,255,255,0.04); border: 1px solid var(--border); }
.cc-agent-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.skeleton { background: linear-gradient(90deg, var(--border) 25%, var(--surface) 50%, var(--border) 75%); background-size: 200% 100%; animation: skeleton 1.2s ease-in-out infinite; border-radius: 4px; }
@keyframes skeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.table-wrap { overflow-x: auto; border-radius: var(--radius); border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
th { background: var(--surface); color: var(--muted); font-weight: 500; position: sticky; top: 0; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
.tabs { display: flex; gap: 0.25rem; margin-bottom: 1rem; flex-wrap: wrap; }
.tabs button { padding: 0.5rem 1rem; background: transparent; border: 1px solid var(--border); border-radius: 4px; color: var(--muted); font-size: 0.875rem; cursor: pointer; transition: all 0.15s; font-family: var(--font); }
.tabs button:hover { color: var(--text); }
.tabs button.active { background: var(--accent-dim); color: var(--accent); border-color: var(--accent); }
.btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; background: var(--accent); color: white; border: none; border-radius: 4px; font-size: 0.875rem; font-family: var(--font); cursor: pointer; transition: background 0.15s, transform 0.1s; text-transform: uppercase; letter-spacing: 0.04em; }
.btn:hover { background: #5558e3; transform: translateY(-1px); }
.btn.secondary { background: var(--surface); color: var(--text); border: 1px solid var(--border); }
.btn.secondary:hover { background: var(--border); }
.skills-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.skill-card { cursor: default; }
.skill-card .card-value { font-size: 1rem; }
.skill-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
.skill-info { flex: 1; min-width: 0; }
.toggle { position: relative; width: 40px; height: 22px; flex-shrink: 0; margin-top: 2px; }
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track { position: absolute; inset: 0; background: var(--border); border-radius: 11px; cursor: pointer; transition: background 0.2s; }
.toggle-track::after { content: ''; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; background: var(--muted); border-radius: 50%; transition: transform 0.2s, background 0.2s; }
.toggle input:checked + .toggle-track { background: rgba(34,197,94,0.3); }
.toggle input:checked + .toggle-track::after { transform: translateX(18px); background: var(--success); }
.toggle input:disabled + .toggle-track { background: rgba(148,163,184,0.2); cursor: not-allowed; }
.toggle input:disabled + .toggle-track::after { background: var(--muted); }
.toggle input:checked:disabled + .toggle-track { background: rgba(148,163,184,0.35); }
.toggle input:checked:disabled + .toggle-track::after { transform: translateX(18px); background: rgba(203,213,225,0.9); }
.message-thread { flex: 1; overflow-y: auto; min-height: 0; }
.message { margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 3px; max-width: 85%; }
.message.user { margin-left: auto; background: var(--accent-dim); border: 1px solid rgba(99,102,241,0.3); }
.message.assistant { margin-right: auto; background: var(--surface); border: 1px solid var(--border); }
.message .md-table { width: auto; margin: 0.5rem 0; font-size: 0.8125rem; }
.message .md-table th, .message .md-table td { padding: 0.375rem 0.75rem; border: 1px solid var(--border); }
.message .md-table th { background: rgba(255,255,255,0.04); }
.message-role { font-size: 0.7rem; text-transform: uppercase; color: var(--muted); margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.5rem; }
.ctx-expand-btn { background: none; border: 1px solid var(--border); border-radius: 3px; color: var(--muted); cursor: pointer; padding: 1px 5px; font-size: 0.625rem; line-height: 1.2; transition: all 0.15s; font-family: var(--font); margin-left: auto; }
.ctx-expand-btn:hover { color: var(--accent); border-color: var(--accent); }
.ctx-detail { margin-top: 0.5rem; padding: 0.625rem; background: rgba(0,0,0,0.2); border-radius: 3px; border: 1px solid var(--border); font-size: 0.75rem; }
.ctx-detail .ctx-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin: 0.375rem 0; gap: 1px; }
.ctx-detail .ctx-bar span { display: block; height: 100%; transition: width 0.3s; }
.ctx-detail .ctx-bar .sys { background: var(--accent); }
.ctx-detail .ctx-bar .mem { background: var(--success); }
.ctx-detail .ctx-bar .hist { background: var(--warning); }
.ctx-detail .ctx-bar .free { background: rgba(255,255,255,0.05); }
.ctx-detail .ctx-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
.ctx-detail .ctx-legend span { display: flex; align-items: center; gap: 0.25rem; font-size: 0.6875rem; color: var(--muted); }
.ctx-detail .ctx-legend span::before { content: ''; width: 8px; height: 8px; border-radius: 2px; }
.ctx-detail .ctx-legend .l-sys::before { background: var(--accent); }
.ctx-detail .ctx-legend .l-mem::before { background: var(--success); }
.ctx-detail .ctx-legend .l-hist::before { background: var(--warning); }
.ctx-section { margin-top: 0.5rem; }
.ctx-section summary { cursor: pointer; color: var(--muted); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; }
.ctx-section pre { margin: 0.25rem 0 0; font-size: 0.75rem; white-space: pre-wrap; word-break: break-all; color: var(--text); max-height: 200px; overflow-y: auto; }
.ctx-explorer-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; min-height: 500px; }
.ctx-timeline { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow-y: auto; max-height: 600px; }
.ctx-timeline-item { padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.15s; font-size: 0.8125rem; }
.ctx-timeline-item:hover { background: rgba(255,255,255,0.03); }
.ctx-timeline-item.active { background: var(--accent-dim); border-left: 3px solid var(--accent); }
.ctx-turn-detail { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; }
.ctx-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
.ctx-stat { background: rgba(0,0,0,0.15); border-radius: 4px; padding: 0.625rem; text-align: center; }
.ctx-stat .val { font-size: 1.25rem; font-weight: 700; color: var(--text); }
.ctx-stat .lbl { font-size: 0.6875rem; color: var(--muted); text-transform: uppercase; margin-top: 0.125rem; }
.ctx-tips { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.375rem; }
.ctx-tip { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.625rem; line-height: 1.3; cursor: default; }
.ctx-tip.info { background: rgba(59,130,246,0.15); color: #93bbfd; border: 1px solid rgba(59,130,246,0.3); }
.ctx-tip.warning { background: rgba(245,158,11,0.15); color: #fbbf24; border: 1px solid rgba(245,158,11,0.3); }
.ctx-tip.critical { background: rgba(239,68,68,0.15); color: #fca5a5; border: 1px solid rgba(239,68,68,0.3); }
.ctx-tip .tip-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.ctx-tip.info .tip-dot { background: #3b82f6; }
.ctx-tip.warning .tip-dot { background: #f59e0b; }
.ctx-tip.critical .tip-dot { background: #ef4444; }
.ctx-tip-detail { margin-top: 0.375rem; padding: 0.375rem 0.5rem; background: rgba(0,0,0,0.15); border-radius: 3px; font-size: 0.6875rem; color: var(--muted); }
.ctx-tip-detail strong { color: var(--text); font-weight: 600; }
.hint-banner {
margin-bottom: 0.75rem;
border: 1px solid rgba(59, 130, 246, 0.45);
background: rgba(59, 130, 246, 0.14);
color: #bfdbfe;
border-radius: var(--radius);
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.8125rem;
line-height: 1.35;
}
.hint-banner .hint-main { display: flex; align-items: flex-start; gap: 0.5rem; min-width: 0; }
.hint-banner .hint-icon { font-size: 0.95rem; line-height: 1; margin-top: 1px; flex-shrink: 0; }
.hint-banner .hint-text { color: #dbeafe; }
.hint-banner .hint-dismiss {
border: 1px solid rgba(191, 219, 254, 0.35);
background: rgba(15, 23, 42, 0.45);
color: #dbeafe;
border-radius: 4px;
width: 22px;
height: 22px;
line-height: 1;
cursor: pointer;
font-family: var(--font);
flex-shrink: 0;
}
.hint-banner .hint-dismiss:hover { border-color: #dbeafe; color: #fff; }
.hint-pref-overlay {
position: fixed;
inset: 0;
z-index: 1200;
background: rgba(0, 0, 0, 0.62);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.hint-pref-modal {
width: min(430px, 92vw);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem;
}
.hint-pref-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.45rem; }
.hint-pref-copy { font-size: 0.8125rem; color: var(--muted); margin-bottom: 0.85rem; }
.hint-pref-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.ctx-analyze-btn { margin-top: 0.5rem; font-size: 0.6875rem; padding: 0.25rem 0.625rem; }
.ctx-insights { margin-top: 1rem; }
.streaming-content { white-space: pre-wrap; word-break: break-word; }
.streaming-content::after { content: '\2588'; animation: cursorBlink 0.8s step-end infinite; color: var(--accent); }
@keyframes cursorBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.thinking-indicator { margin-right: auto; padding: 0.75rem 1rem; display: flex; align-items: center; gap: 0.625rem; }
.thinking-brain { display: inline-block; font-size: 1.25rem; animation: brainBounce 1.2s ease-in-out infinite; }
.thinking-dots span { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--muted); margin: 0 2px; animation: dotPulse 1.4s ease-in-out infinite; }
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes brainBounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-6px) rotate(-8deg); }
50% { transform: translateY(0) rotate(0deg); }
75% { transform: translateY(-6px) rotate(8deg); }
}
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1.2); }
}
.config-block { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1rem; font-family: var(--font-mono); font-size: 0.8125rem; overflow-x: auto; margin-bottom: 0.5rem; white-space: pre-wrap; word-break: break-word; }
.config-section { margin-bottom: 1rem; }
.config-section summary { cursor: pointer; padding: 0.5rem 0; color: var(--accent); }
.chart-bars { display: flex; align-items: flex-end; gap: 4px; height: 120px; margin-top: 0.5rem; }
.chart-bar { flex: 1; min-width: 8px; background: linear-gradient(180deg, var(--accent) 0%, rgba(99,102,241,0.4) 100%); border-radius: 4px 4px 0 0; transition: height 0.3s; }
.toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 1000; display: flex; flex-direction: column; gap: 0.5rem; }
.toast { padding: 0.75rem 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; box-shadow: var(--shadow); font-size: 0.875rem; animation: toastIn 0.25s ease; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--surface); border-top: 1px solid var(--border); padding: 0.5rem; z-index: 100; }
.mobile-nav a { flex: 1; display: flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0.5rem; font-size: 0.75rem; text-decoration: none; color: var(--muted); cursor: pointer; }
.mobile-nav a .nav-icon { width: 12px; height: 12px; }
.settings-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
.settings-actions .btn { min-width: 80px; justify-content: center; }
.settings-editor { position: relative; }
.settings-editor textarea {
width: 100%; min-height: 360px; padding: 1rem; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; font-family: var(--font-mono); font-size: 0.8125rem;
line-height: 1.6; resize: vertical; tab-size: 2; outline: none; transition: border-color 0.15s;
}
.settings-editor textarea:focus { border-color: var(--accent); }
.settings-editor textarea.has-error { border-color: var(--error); }
.settings-lint { font-size: 0.75rem; margin-top: 0.375rem; min-height: 1.25rem; font-family: var(--font-mono); }
.settings-lint.ok { color: var(--success); }
.settings-lint.err { color: var(--error); }
.settings-form { display: flex; flex-direction: column; gap: 1.25rem; }
.settings-section { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 1rem; }
.settings-section-title { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); margin-bottom: 0.75rem; font-weight: 600; }
.settings-row { display: grid; grid-template-columns: minmax(140px, max-content) 1fr; gap: 0.75rem; align-items: center; margin-bottom: 0.5rem; }
.settings-row:last-child { margin-bottom: 0; }
.settings-label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.settings-input {
padding: 0.375rem 0.625rem; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 3px; font-family: var(--font-mono); font-size: 0.8125rem;
outline: none; transition: border-color 0.15s; width: 100%;
}
.settings-input:focus { border-color: var(--accent); }
.settings-input[type="number"] { max-width: 120px; }
.settings-toggle-wrap { display: flex; align-items: center; gap: 0.5rem; }
.settings-toggle-label { font-size: 0.75rem; color: var(--muted); }
.settings-nested { margin-left: 0.75rem; padding-left: 0.75rem; border-left: 2px solid var(--border); }
.settings-dirty-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--warning); margin-left: 0.5rem; vertical-align: middle; }
.settings-input::placeholder { color: var(--muted); opacity: 0.45; font-style: italic; }
.settings-input.warn { border-color: var(--error); background: rgba(239,68,68,0.06); }
.settings-label.warn { color: var(--error); }
.settings-warn-icon { color: var(--error); font-size: 0.75rem; margin-left: 0.25rem; }
.model-order-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; }
.model-order-item { display: grid; grid-template-columns: 16px minmax(160px,1fr) auto auto auto; gap: 0.5rem; align-items: center; padding: 0.45rem 0.5rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); }
.model-order-item.dragging { opacity: 0.55; border-style: dashed; }
.model-order-item.drop-target { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-dim) inset; }
.model-order-handle { color: var(--muted); cursor: grab; user-select: none; font-size: 0.75rem; letter-spacing: -1px; text-align: center; }
.model-order-name { font-family: var(--font-mono); font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.model-order-actions { display: flex; gap: 0.35rem; align-items: center; }
.model-order-btn { font-size: 0.65rem; padding: 0.2rem 0.45rem; border: 1px solid var(--border); border-radius: 3px; background: transparent; color: var(--muted); cursor: pointer; }
.model-order-btn:hover { color: var(--text); border-color: var(--accent); }
.model-order-add { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center; }
.settings-provider-card { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0.75rem; margin-bottom: 0.75rem; }
.settings-provider-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; padding-bottom: 0.375rem; border-bottom: 1px solid var(--border); }
.settings-provider-name { font-size: 0.8125rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.settings-provider-add { display: flex; align-items: center; gap: 0.375rem; padding: 0.5rem 0.75rem; background: none; border: 1px dashed var(--border); border-radius: 4px; color: var(--muted); font-family: var(--font); font-size: 0.75rem; cursor: pointer; width: 100%; justify-content: center; transition: all 0.15s; }
.settings-provider-add:hover { border-color: var(--accent); color: var(--accent); }
.key-manage-row { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed var(--border); }
.key-manage-row input { flex: 1; font-family: var(--font); font-size: 0.75rem; padding: 0.3rem 0.5rem; background: var(--panel); border: 1px solid var(--border); border-radius: 3px; color: var(--fg); }
.key-manage-row input:focus { outline: none; border-color: var(--accent); }
.key-manage-btn { font-family: var(--font); font-size: 0.675rem; padding: 0.25rem 0.625rem; border-radius: 3px; cursor: pointer; border: 1px solid; transition: all 0.15s; white-space: nowrap; }
.key-manage-btn.save { background: var(--success); border-color: var(--success); color: #fff; }
.key-manage-btn.save:hover { opacity: 0.85; }
.key-manage-btn.remove { background: none; border-color: var(--error); color: var(--error); }
.key-manage-btn.remove:hover { background: var(--error); color: #fff; }
.key-manage-msg { font-size: 0.675rem; margin-left: 0.25rem; }
.grade-stars { display: flex; gap: 0.125rem; margin-top: 0.375rem; align-items: center; }
.grade-stars .star { font-size: 1rem; cursor: pointer; color: var(--border); transition: color 0.15s; user-select: none; line-height: 1; }
.grade-stars .star:hover, .grade-stars .star.hovered { color: var(--warning); }
.grade-stars .star.filled { color: var(--warning); }
.grade-stars .grade-comment-toggle { font-size: 0.625rem; color: var(--muted); cursor: pointer; margin-left: 0.375rem; border: none; background: none; font-family: var(--font); }
.grade-stars .grade-comment-toggle:hover { color: var(--text); }
.grade-comment-row { display: flex; gap: 0.375rem; align-items: center; margin-top: 0.25rem; }
.grade-comment-row input { flex: 1; font-family: var(--font); font-size: 0.6875rem; padding: 0.25rem 0.5rem; background: var(--surface); border: 1px solid var(--border); border-radius: 3px; color: var(--text); outline: none; }
.grade-comment-row input:focus { border-color: var(--accent); }
.grade-comment-row .grade-save-btn { font-size: 0.625rem; padding: 0.2rem 0.5rem; background: var(--accent); border: none; border-radius: 3px; color: #fff; cursor: pointer; font-family: var(--font); }
.grade-badge { display: inline-flex; align-items: center; gap: 0.125rem; font-size: 0.5625rem; color: var(--warning); margin-left: 0.25rem; }
.session-chat-wrap { display: flex; flex-direction: column; height: calc(100vh - 56px - 3rem); min-height: 0; }
.session-chat-header { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border); margin-bottom: 0.75rem; flex-shrink: 0; }
.session-chat-input { display: flex; gap: 0.5rem; flex-shrink: 0; padding-top: 0.75rem; border-top: 1px solid var(--border); margin-top: auto; }
.session-chat-input input { flex: 1; padding: 0.625rem 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: var(--font); font-size: 0.875rem; outline: none; }
.session-chat-input input:focus { border-color: var(--accent); }
.session-chat-input input:disabled { opacity: 0.4; pointer-events: none; }
.session-chat-input .btn:disabled { opacity: 0.4; pointer-events: none; }
.chain-preset-btn { padding: 0.25rem 0.5rem; background: var(--surface); border: 1px solid var(--border); border-radius: 3px; color: var(--muted); font-size: 0.625rem; font-family: var(--font); cursor: pointer; transition: all 0.15s; }
.chain-preset-btn:hover { color: var(--text); border-color: var(--accent); }
.chain-preset-btn.active { color: var(--accent); border-color: var(--accent); background: var(--accent-dim); }
.metrics-legend { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.75rem; }
.metrics-legend-item { display: flex; align-items: center; gap: 0.375rem; font-size: 0.75rem; }
.metrics-legend-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
.metrics-legend-val { color: var(--muted); font-family: var(--font-mono); }
.metrics-summary-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
.metrics-stat { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0.625rem 0.75rem; }
.metrics-stat-label { font-size: 0.625rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.metrics-stat-value { font-size: 1rem; font-weight: 700; margin-top: 0.125rem; font-family: var(--font-mono); }
.routing-profile-grid { display: grid; grid-template-columns: minmax(220px, 260px) minmax(260px, 1fr); gap: 1rem; align-items: start; }
.routing-slider-row { margin-bottom: 0.75rem; }
.routing-slider-row label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.06em; }
.routing-slider-row input[type="range"] { width: 100%; accent-color: var(--accent); }
.routing-profile-actions { display: flex; gap: 0.5rem; margin-top: 0.85rem; flex-wrap: wrap; }
.model-graph-wrap { position: relative; height: 320px; border: 1px solid var(--border); border-radius: 6px; background: linear-gradient(180deg, rgba(99,102,241,0.08), rgba(99,102,241,0.02)); overflow: hidden; }
.model-graph-svg { width: 100%; height: 100%; display: block; }
.model-graph-node { fill: var(--surface); stroke: var(--accent); stroke-width: 1.6; cursor: pointer; }
.model-graph-node-unusable { fill: rgba(255,255,255,0.06); stroke: var(--muted); stroke-dasharray: 3 2; }
.model-graph-node-label { fill: var(--text); font-size: 10px; font-family: var(--font-mono); text-anchor: middle; pointer-events: none; }
.model-graph-edge { stroke: var(--border); stroke-opacity: 0.75; cursor: pointer; }
.model-graph-edge.active { stroke: var(--accent); stroke-opacity: 0.95; }
.model-graph-node.active { fill: rgba(99,102,241,0.18); }
.model-graph-faded { opacity: 0.3; }
.model-graph-detail { margin-top: 0.65rem; font-size: 0.75rem; color: var(--muted); }
@media (max-width: 960px) { .routing-profile-grid { grid-template-columns: 1fr; } .model-graph-wrap { height: 250px; } }
.cal-wrap { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.cal-header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; flex-shrink: 0; }
.cal-header-left { display: flex; align-items: center; gap: 1rem; }
.cal-title { font-size: 1rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; }
.cal-nav { display: flex; gap: 0.25rem; }
.cal-nav button { width: 28px; height: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: 3px; color: var(--muted); font-size: 0.875rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: color 0.15s, border-color 0.15s; font-family: var(--font); }
.cal-nav button:hover { color: var(--text); border-color: var(--accent); }
.cal-header-right { display: flex; align-items: center; gap: 0.5rem; }
.cal-body { display: grid; grid-template-columns: 1fr 240px; flex: 1; min-height: 0; overflow: hidden; }
@media (max-width: 900px) { .cal-body { grid-template-columns: 1fr; } .cal-sidebar { display: none; } }
.cal-grid-wrap { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); }
.cal-dow-row { display: grid; grid-template-columns: repeat(7, 1fr); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.cal-dow { padding: 0.375rem 0.25rem; text-align: center; font-size: 0.5625rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); background: var(--surface); }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); grid-auto-rows: 1fr; flex: 1; min-height: 0; }
.cal-day { background: var(--bg); padding: 0.25rem 0.375rem; cursor: pointer; transition: background 0.15s; position: relative; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; }
.cal-day:nth-child(7n) { border-right: none; }
.cal-day:hover { background: rgba(99,102,241,0.04); }
.cal-day.other-month { opacity: 0.3; }
.cal-day.today { background: rgba(99,102,241,0.06); }
.cal-day.today::after { content: ''; position: absolute; top: 2px; right: 2px; width: 6px; height: 6px; background: var(--accent); border-radius: 50%; }
.cal-day.selected { background: var(--accent-dim); outline: 2px solid var(--accent); outline-offset: -2px; z-index: 1; }
.cal-day-num { font-size: 0.6875rem; color: var(--muted); margin-bottom: 0.125rem; line-height: 1; flex-shrink: 0; }
.cal-day.today .cal-day-num { color: var(--accent); font-weight: 700; }
.cal-events { display: flex; flex-direction: column; gap: 1px; flex: 1; min-height: 0; overflow: hidden; }
.cal-evt { font-size: 0.5625rem; padding: 1px 4px; border-radius: 2px; color: #fff; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.4; flex-shrink: 0; transition: filter 0.15s; }
.cal-evt:hover { filter: brightness(1.2); }
.cal-evt.fail { box-shadow: inset 0 0 0 1px rgba(239,68,68,0.6); }
.cal-evt.past { opacity: 0.75; cursor: default; }
.cal-evt.past:hover { filter: none; }
.cal-edit-info { font-size: 0.6875rem; color: var(--muted); background: rgba(99,102,241,0.08); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.625rem; line-height: 1.4; }
.cal-edit-info strong { color: var(--text); font-weight: 600; }
.cal-legend-btn.disabled { opacity: 0.3; pointer-events: none; cursor: not-allowed; }
.cal-sidebar { display: flex; flex-direction: column; overflow-y: auto; background: var(--surface); }
.cal-sidebar-section { padding: 0.75rem; border-bottom: 1px solid var(--border); }
.cal-sidebar-title { font-size: 0.5625rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 0.5rem; }
.cal-legend-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.75rem; cursor: pointer; transition: opacity 0.15s; }
.cal-legend-item:hover { opacity: 0.8; }
.cal-legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
.cal-legend-info { flex: 1; min-width: 0; }
.cal-legend-name { font-weight: 600; font-size: 0.75rem; }
.cal-legend-sched { font-size: 0.625rem; color: var(--muted); font-family: var(--font-mono); }
.cal-legend-actions { display: flex; gap: 0.25rem; }
.cal-legend-btn { width: 20px; height: 20px; background: none; border: 1px solid var(--border); border-radius: 3px; color: var(--muted); font-size: 0.625rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; font-family: var(--font); }
.cal-legend-btn:hover { color: var(--text); border-color: var(--accent); }
.cal-legend-btn.danger:hover { color: var(--error); border-color: var(--error); }
.cal-detail { padding: 0.75rem; }
.cal-detail-title { font-size: 0.5625rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.5rem; }
.cal-detail-run { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 0.6875rem; border-bottom: 1px solid var(--border); }
.cal-detail-run:last-child { border-bottom: none; }
.cal-detail-dot { width: 6px; height: 6px; border-radius: 1px; flex-shrink: 0; }
.cal-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 10000; display: flex; align-items: center; justify-content: center; }
.cal-modal { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; width: 380px; max-width: 90vw; box-shadow: 0 16px 48px rgba(0,0,0,0.4); }
.cal-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
.cal-modal-header h3 { font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.06em; }
.cal-modal-close { background: none; border: none; color: var(--muted); font-size: 1.25rem; cursor: pointer; line-height: 1; font-family: var(--font); }
.cal-modal-close:hover { color: var(--text); }
.cal-modal-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.cal-modal-row { display: flex; flex-direction: column; gap: 0.25rem; }
.cal-modal-label { font-size: 0.625rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.cal-modal-input { padding: 0.375rem 0.625rem; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 3px; font-family: var(--font-mono); font-size: 0.8125rem; outline: none; width: 100%; }
.cal-modal-input:focus { border-color: var(--accent); }
.cal-modal-select { padding: 0.375rem 0.625rem; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 3px; font-family: var(--font-mono); font-size: 0.8125rem; outline: none; width: 100%; appearance: none; }
.cal-modal-footer { display: flex; gap: 0.5rem; justify-content: flex-end; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
.cal-modal-footer .btn { font-size: 0.75rem; padding: 0.375rem 0.875rem; }
.cal-color-row { display: flex; gap: 0.375rem; }
.cal-color-swatch { width: 24px; height: 24px; border-radius: 3px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
.cal-color-swatch.active { border-color: var(--text); }
.cal-color-swatch:hover { border-color: var(--muted); }
.cal-sched-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.cal-sched-row .cal-modal-select { width: auto; min-width: 100px; }
.cal-sched-inline { display: inline-flex; align-items: center; gap: 0.375rem; font-size: 0.75rem; color: var(--text); }
.cal-sched-input-sm { width: 52px; padding: 0.3rem 0.4rem; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 3px; font-family: var(--font-mono); font-size: 0.8125rem; text-align: center; outline: none; }
.cal-sched-input-sm:focus { border-color: var(--accent); }
.cal-days-row { display: flex; gap: 0.25rem; }
.cal-day-btn { width: 30px; height: 26px; background: var(--surface); border: 1px solid var(--border); border-radius: 3px; color: var(--muted); font-size: 0.625rem; font-family: var(--font); cursor: pointer; transition: all 0.15s; text-transform: uppercase; letter-spacing: 0.04em; }
.cal-day-btn.active { background: var(--accent-dim); color: var(--accent); border-color: var(--accent); }
.cal-day-btn:hover { border-color: var(--accent); color: var(--text); }
.cal-sched-summary { font-size: 0.6875rem; color: var(--muted); font-family: var(--font-mono); padding: 0.5rem 0.625rem; background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 3px; margin-top: 0.25rem; }
.theme-picker { position: relative; display: flex; align-items: center; gap: 0.5rem; }
.theme-btn { background: none; border: 1px solid var(--border); border-radius: 3px; padding: 0.3rem 0.625rem; color: var(--muted); font-size: 0.6875rem; font-family: var(--font); cursor: pointer; display: flex; align-items: center; gap: 0.375rem; transition: color 0.15s, border-color 0.15s; text-transform: uppercase; letter-spacing: 0.06em; }
.theme-btn:hover { color: var(--text); border-color: var(--accent); }
.theme-swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--accent); flex-shrink: 0; }
.theme-dropdown { display: none; position: absolute; right: 0; top: 100%; margin-top: 0.375rem; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; box-shadow: var(--shadow); z-index: 500; min-width: 180px; overflow: hidden; }
.theme-dropdown.open { display: block; }
.theme-option { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; font-size: 0.75rem; font-family: var(--font); color: var(--muted); cursor: pointer; transition: background 0.1s, color 0.1s; border: none; background: none; width: 100%; text-align: left; }
.theme-option:hover { background: rgba(255,255,255,0.04); color: var(--text); }
.theme-option.active { color: var(--accent); }
.theme-option-swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.1); }
[data-theme="psychedelic"] { animation: psychBg 6s linear infinite; }
@keyframes psychBg { 0% { --accent: #ff00ff; } 25% { --accent: #00ffcc; } 50% { --accent: #ffff00; } 75% { --accent: #ff0066; } 100% { --accent: #ff00ff; } }
@property --accent { syntax: '<color>'; inherits: true; initial-value: #6366f1; }
.crt-overlay { position: fixed; inset: 0; pointer-events: none; z-index: 9999; }
.crt-overlay::before {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(0deg, transparent, transparent 1px, var(--crt-scanline, rgba(0,0,0,0.08)) 1px, var(--crt-scanline, rgba(0,0,0,0.08)) 2px);
animation: crtFlicker 8s linear infinite;
}
.crt-overlay::after {
content: ''; position: absolute; inset: 0;
background: radial-gradient(ellipse at center, transparent 55%, var(--crt-vignette, rgba(0,0,0,0.35)) 100%);
}
@keyframes crtFlicker { 0% { opacity: 0.95; } 5% { opacity: 1; } 10% { opacity: 0.96; } 14% { opacity: 1; } 20% { opacity: 0.98; } 55% { opacity: 1; } 62% { opacity: 0.97; } 67% { opacity: 1; } 80% { opacity: 0.96; } 85% { opacity: 1; } 92% { opacity: 0.98; } 100% { opacity: 1; } }
@media (max-width: 768px) {
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; z-index: 200; margin-left: -252px; }
.sidebar.open { margin-left: 0; }
.mobile-nav { display: flex; }
.content { padding-bottom: 4rem; }
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<svg class="robot-icon" viewBox="0 0 32 32" width="32" height="32" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="4" fill="var(--bg, #0a0b0f)"/>
<rect x="10" y="5" width="12" height="18" rx="0.8" fill="none" stroke="var(--robot-color, #22c55e)" stroke-width="2"/>
<circle cx="13.6" cy="11.5" r="1.35" fill="var(--robot-color, #22c55e)"/>
<circle cx="18.4" cy="11.5" r="1.35" fill="var(--robot-color, #22c55e)"/>
<rect x="13.6" y="16.8" width="4.8" height="1.35" fill="var(--robot-color, #22c55e)"/>
<rect x="15" y="23" width="2" height="4" fill="var(--robot-color, #22c55e)"/>
</svg>
<div class="sidebar-brand">
<div class="sidebar-title">Ironclad</div>
<div class="sidebar-subtitle">Autonomous Agent Runtime</div>
<div class="agent-badge" id="agent-badge">
<div class="status-dot" id="agent-dot"></div>
<span id="agent-state">—</span>
</div>
</div>
</div>
<nav class="sidebar-nav">
<a href="#overview" data-page="overview" class="active"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor"/><rect x="9" y="1" width="6" height="3" rx="1" fill="currentColor"/><rect x="1" y="9" width="6" height="3" rx="1" fill="currentColor"/><rect x="9" y="6" width="6" height="9" rx="1" fill="currentColor"/><rect x="1" y="14" width="6" height="1" rx=".5" fill="currentColor"/></svg><span class="nav-label">Overview</span></a>
<a href="#sessions" data-page="sessions"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M2 1h12a1 1 0 011 1v8a1 1 0 01-1 1H5.5L2 14V2a1 1 0 010-1z" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="5" y1="5" x2="11" y2="5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="5" y1="8" x2="9" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg><span class="nav-label">Sessions</span></a>
<a href="#context" data-page="context"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M8 1.5l5.5 2v5c0 3.1-2.2 5.6-5.5 6.6-3.3-1-5.5-3.5-5.5-6.6v-5z" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8" cy="7" r="1.4" fill="currentColor"/><path d="M5.5 10c.7-1 1.5-1.5 2.5-1.5s1.8.5 2.5 1.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg><span class="nav-label">Context</span></a>
<a href="#memory" data-page="memory"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="3" y="2" width="10" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="1" y1="5" x2="3" y2="5" stroke="currentColor" stroke-width="1.2"/><line x1="1" y1="8" x2="3" y2="8" stroke="currentColor" stroke-width="1.2"/><line x1="1" y1="11" x2="3" y2="11" stroke="currentColor" stroke-width="1.2"/><line x1="13" y1="5" x2="15" y2="5" stroke="currentColor" stroke-width="1.2"/><line x1="13" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.2"/><line x1="13" y1="11" x2="15" y2="11" stroke="currentColor" stroke-width="1.2"/><circle cx="8" cy="8" r="2" fill="currentColor"/></svg><span class="nav-label">Memory</span></a>
<a href="#skills" data-page="skills"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M8 1l1.8 3.6L14 5.3l-3 2.9.7 4.1L8 10.5l-3.7 1.8.7-4.1-3-2.9 4.2-.7z" fill="currentColor"/></svg><span class="nav-label">Skills</span></a>
<a href="#agents" data-page="agents"><svg class="nav-icon" viewBox="0 0 16 16"><circle cx="8" cy="4" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="13" cy="6" r="2" fill="currentColor" opacity=".5"/><circle cx="3" cy="6" r="2" fill="currentColor" opacity=".5"/></svg><span class="nav-label">Agents</span></a>
<a href="#scheduler" data-page="scheduler"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="1.5" y1="6" x2="14.5" y2="6" stroke="currentColor" stroke-width="1.5"/><line x1="5" y1="1" x2="5" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="11" y1="1" x2="11" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="4" y="8.5" width="2" height="2" rx=".5" fill="currentColor"/><rect x="7" y="8.5" width="2" height="2" rx=".5" fill="currentColor"/><rect x="10" y="8.5" width="2" height="2" rx=".5" fill="currentColor"/><rect x="4" y="11.5" width="2" height="1.5" rx=".5" fill="currentColor"/></svg><span class="nav-label">Scheduler</span></a>
<a href="#metrics" data-page="metrics"><svg class="nav-icon" viewBox="0 0 16 16"><polyline points="1,12 4,7 7,9 10,3 14,6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><line x1="1" y1="14.5" x2="15" y2="14.5" stroke="currentColor" stroke-width="1.2"/></svg><span class="nav-label">Metrics</span></a>
<a href="#efficiency" data-page="efficiency"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M8 1v6l4.2 2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1" fill="currentColor"/></svg><span class="nav-label">Prompt Performance</span></a>
<a href="#wallet" data-page="wallet"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1" y="3" width="14" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 3V2.5A1.5 1.5 0 012.5 1h9L14 3" fill="none" stroke="currentColor" stroke-width="1.3"/><rect x="10" y="7" width="5" height="3" rx="1" fill="currentColor"/><circle cx="12.5" cy="8.5" r=".8" fill="var(--bg)"/></svg><span class="nav-label">Wallet</span></a>
<a href="#workspace" data-page="workspace"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5" cy="5.5" r="1.5" fill="currentColor"/><circle cx="11" cy="5.5" r="1.5" fill="currentColor"/><circle cx="8" cy="11" r="1.5" fill="currentColor"/><line x1="5" y1="7" x2="8" y2="9.5" stroke="currentColor" stroke-width="1" opacity=".5"/><line x1="11" y1="7" x2="8" y2="9.5" stroke="currentColor" stroke-width="1" opacity=".5"/></svg><span class="nav-label">Workspace</span></a>
<a href="#settings" data-page="settings"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M6.5 1h3l.3 2.1a5.5 5.5 0 011.3.7L13 2.7l1.5 2.6-1.8 1.2a5.5 5.5 0 010 1.5l1.8 1.2-1.5 2.6-1.9-1.1a5.5 5.5 0 01-1.3.7L9.5 13.5h-3l-.3-2.1a5.5 5.5 0 01-1.3-.7L3 11.8l-1.5-2.6 1.8-1.2a5.5 5.5 0 010-1.5L1.5 5.3 3 2.7l1.9 1.1a5.5 5.5 0 011.3-.7L6.5 1zM8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z" fill="currentColor"/></svg><span class="nav-label">Settings</span></a>
</nav>
<button class="sidebar-collapse-btn" id="sidebar-collapse" title="Collapse sidebar">
<svg class="collapse-icon" viewBox="0 0 16 16"><path d="M10.3 2.3L4.6 8l5.7 5.7 1.1-1.1L6.8 8l4.6-4.6z" fill="currentColor"/></svg>
<span class="collapse-label">Collapse</span>
</button>
<div class="sidebar-footer">v<span id="version">—</span></div>
</aside>
<div class="main-wrap">
<header class="header-bar">
<span class="breadcrumb" id="breadcrumb">Overview</span>
<div style="display:flex;align-items:center;gap:0.75rem">
<div class="theme-picker">
<span style="font-size:0.6875rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em">Theme</span>
<button class="theme-btn" id="theme-toggle"><div class="theme-swatch"></div><span id="theme-name">AI Black & Purple (Default)</span></button>
<div class="theme-dropdown" id="theme-dropdown">
<button class="theme-option active" data-theme-set="ai-purple"><div class="theme-option-swatch" style="background:#6366f1"></div>AI Black & Purple (Default)</button>
<button class="theme-option" data-theme-set="crt-orange"><div class="theme-option-swatch" style="background:#ff8c00"></div>CRT Orange</button>
<button class="theme-option" data-theme-set="crt-green"><div class="theme-option-swatch" style="background:#00ff41"></div>CRT Green</button>
<button class="theme-option" data-theme-set="psychedelic"><div class="theme-option-swatch" style="background:linear-gradient(135deg,#ff00ff,#00ffcc,#ffff00)"></div>Psychedelic Freakout</button>
</div>
</div>
<div class="connection-status">
<div class="ws-dot off" id="ws-dot"></div>
<span id="ws-label">Connecting…</span>
</div>
</div>
</header>
<main class="content" id="content"></main>
</div>
</div>
<div class="crt-overlay"></div>
<div class="toast-container" id="toasts"></div>
<nav class="mobile-nav">
<a href="#overview" data-page="overview"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor"/><rect x="9" y="1" width="6" height="3" rx="1" fill="currentColor"/><rect x="1" y="9" width="6" height="3" rx="1" fill="currentColor"/><rect x="9" y="6" width="6" height="9" rx="1" fill="currentColor"/><rect x="1" y="14" width="6" height="1" rx=".5" fill="currentColor"/></svg>Overview</a>
<a href="#sessions" data-page="sessions"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M2 1h12a1 1 0 011 1v8a1 1 0 01-1 1H5.5L2 14V2a1 1 0 010-1z" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="5" y1="5" x2="11" y2="5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="5" y1="8" x2="9" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>Sessions</a>
<a href="#context" data-page="context"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M8 1.5l5.5 2v5c0 3.1-2.2 5.6-5.5 6.6-3.3-1-5.5-3.5-5.5-6.6v-5z" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8" cy="7" r="1.4" fill="currentColor"/><path d="M5.5 10c.7-1 1.5-1.5 2.5-1.5s1.8.5 2.5 1.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>Context</a>
<a href="#memory" data-page="memory"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="3" y="2" width="10" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="1" y1="5" x2="3" y2="5" stroke="currentColor" stroke-width="1.2"/><line x1="1" y1="8" x2="3" y2="8" stroke="currentColor" stroke-width="1.2"/><line x1="1" y1="11" x2="3" y2="11" stroke="currentColor" stroke-width="1.2"/><circle cx="8" cy="8" r="2" fill="currentColor"/></svg>Memory</a>
<a href="#skills" data-page="skills"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M8 1l1.8 3.6L14 5.3l-3 2.9.7 4.1L8 10.5l-3.7 1.8.7-4.1-3-2.9 4.2-.7z" fill="currentColor"/></svg>Skills</a>
<a href="#agents" data-page="agents"><svg class="nav-icon" viewBox="0 0 16 16"><circle cx="8" cy="4" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>Agents</a>
<a href="#scheduler" data-page="scheduler"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1.5" y="2.5" width="13" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="1.5" y1="6" x2="14.5" y2="6" stroke="currentColor" stroke-width="1.5"/><line x1="5" y1="1" x2="5" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="11" y1="1" x2="11" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>Scheduler</a>
<a href="#metrics" data-page="metrics"><svg class="nav-icon" viewBox="0 0 16 16"><polyline points="1,12 4,7 7,9 10,3 14,6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><line x1="1" y1="14.5" x2="15" y2="14.5" stroke="currentColor" stroke-width="1.2"/></svg>Metrics</a>
<a href="#efficiency" data-page="efficiency"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M8 1v6l4.2 2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1" fill="currentColor"/></svg>Prompt Performance</a>
<a href="#wallet" data-page="wallet"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1" y="3" width="14" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 3V2.5A1.5 1.5 0 012.5 1h9L14 3" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>Wallet</a>
<a href="#workspace" data-page="workspace"><svg class="nav-icon" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5" cy="5.5" r="1.5" fill="currentColor"/><circle cx="11" cy="5.5" r="1.5" fill="currentColor"/><circle cx="8" cy="11" r="1.5" fill="currentColor"/></svg>Workspace</a>
<a href="#settings" data-page="settings"><svg class="nav-icon" viewBox="0 0 16 16"><path d="M6.5 1h3l.3 2.1a5.5 5.5 0 011.3.7L13 2.7l1.5 2.6-1.8 1.2a5.5 5.5 0 010 1.5l1.8 1.2-1.5 2.6-1.9-1.1a5.5 5.5 0 01-1.3.7L9.5 13.5h-3l-.3-2.1a5.5 5.5 0 01-1.3-.7L3 11.8l-1.5-2.6 1.8-1.2a5.5 5.5 0 010-1.5L1.5 5.3 3 2.7l1.9 1.1a5.5 5.5 0 011.3-.7L6.5 1zM8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z" fill="currentColor"/></svg>Settings</a>
</nav>
<script>
(function() {
var setHtml = function(el, html) { if (el) el.innerHTML = html; };
var BASE = '';
var pages = ['overview','sessions','context','memory','skills','agents','scheduler','metrics','efficiency','recommendations','wallet','settings','workspace'];
var titles = { overview: 'Overview', sessions: 'Sessions', context: 'Context', memory: 'Memory', skills: 'Skills', agents: 'Agents', scheduler: 'Scheduler', metrics: 'Metrics', efficiency: 'Prompt Performance', wallet: 'Wallet', settings: 'Settings', workspace: 'Workspace' };
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function uiIcon(name) {
switch (name) {
case 'refresh':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M13 5V2h-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 2A6 6 0 1 0 14 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
case 'download':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2v7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M5.5 7.5L8 10l2.5-2.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.5 12.5h11" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
case 'spark':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 1.5l1.7 3.4 3.8.6-2.7 2.6.6 3.7L8 10.3l-3.4 1.8.6-3.7L2.5 5.5l3.8-.6L8 1.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
case 'power':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 1.8v5.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M11.5 3.7a5 5 0 1 1-7 0" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
case 'trash':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2.5 4h11" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M6 4V2.8h4V4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M4.2 4l.8 9h6l.8-9" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
case 'plus':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
case 'send':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8l12-5-3.5 10-2.7-4.2L2 8z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
case 'search':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.3"/><path d="M10.4 10.4L14 14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
case 'back':
return '<svg class="ui-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M7 3L2.5 8 7 13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 8h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
default:
return '';
}
}
function uiBtnLabel(iconName, label) { return uiIcon(iconName) + '<span>' + label + '</span>'; }
function copyIdBtn(id) { return '<button class="copy-id-btn" data-copy-id="' + esc(id) + '" title="Copy session ID"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.3"/><path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg></button>'; }
function truncate(s, n) { return (s && s.length > n) ? s.slice(0, n) + '\u2026' : (s || ''); }
function clamp01(v) {
var n = Number(v);
if (!isFinite(n)) return 0;
if (n < 0) return 0;
if (n > 1) return 1;
return n;
}
function round2(v) {
return Math.round(Number(v || 0) * 100) / 100;
}
function normalizeRoutingProfile(profile, preferredKey) {
var p = {
correctness: clamp01(profile && profile.correctness),
cost: clamp01(profile && profile.cost),
speed: clamp01(profile && profile.speed)
};
var keys = ['correctness', 'cost', 'speed'];
var total = p.correctness + p.cost + p.speed;
if (total <= 1.0 + 1e-9) {
return { correctness: round2(p.correctness), cost: round2(p.cost), speed: round2(p.speed) };
}
if (preferredKey && keys.indexOf(preferredKey) !== -1) {
var otherSum = 0;
keys.forEach(function(k) { if (k !== preferredKey) otherSum += p[k]; });
p[preferredKey] = Math.max(0, Math.min(p[preferredKey], 1 - otherSum));
return { correctness: round2(p.correctness), cost: round2(p.cost), speed: round2(p.speed) };
}
if (total > 0) {
var scale = 1 / total;
p.correctness *= scale;
p.cost *= scale;
p.speed *= scale;
}
return { correctness: round2(p.correctness), cost: round2(p.cost), speed: round2(p.speed) };
}
function routingProfileTotal(profile) {
return round2(clamp01(profile && profile.correctness) + clamp01(profile && profile.cost) + clamp01(profile && profile.speed));
}
function deriveRoutingProfile(cfg) {
var correctness = clamp01(cfg && cfg.accuracy_floor);
var costWeight = cfg && cfg.cost_weight;
var cost = clamp01(costWeight == null ? (cfg && cfg.cost_aware ? 0.5 : 0.0) : costWeight);
var confidence = clamp01(cfg && cfg.confidence_threshold != null ? cfg.confidence_threshold : 0.9);
var speed = clamp01((0.95 - confidence) / 0.35);
return normalizeRoutingProfile({
correctness: round2(correctness),
cost: round2(cost),
speed: round2(speed)
});
}
function projectRoutingPatchFromProfile(profile) {
var p = {
correctness: clamp01(profile && profile.correctness),
cost: clamp01(profile && profile.cost),
speed: clamp01(profile && profile.speed)
};
return {
models: {
routing: {
accuracy_floor: round2(p.correctness),
cost_aware: p.cost > 0.01,
cost_weight: round2(p.cost),
confidence_threshold: round2(0.95 - (0.35 * p.speed)),
estimated_output_tokens: Math.max(200, Math.min(1200, Math.round(1200 - (800 * p.speed))))
}
}
};
}
function renderRoutingSpiderSvg(profile) {
var values = [clamp01(profile.correctness), clamp01(profile.cost), clamp01(profile.speed)];
var labels = ['Correctness', 'Cost', 'Speed'];
var cx = 110, cy = 110, r = 78;
var grid = '';
[0.25, 0.5, 0.75, 1.0].forEach(function(level) {
var pts = [];
for (var i = 0; i < 3; i++) {
var angle = (-Math.PI / 2) + (i * (2 * Math.PI / 3));
pts.push((cx + Math.cos(angle) * r * level).toFixed(1) + ',' + (cy + Math.sin(angle) * r * level).toFixed(1));
}
grid += '<polygon points="' + pts.join(' ') + '" fill="none" stroke="var(--border)" stroke-width="1"/>';
});
var axes = '';
var labelEls = '';
var polyPts = [];
for (var i = 0; i < 3; i++) {
var ang = (-Math.PI / 2) + (i * (2 * Math.PI / 3));
var ox = cx + Math.cos(ang) * r;
var oy = cy + Math.sin(ang) * r;
axes += '<line x1="' + cx + '" y1="' + cy + '" x2="' + ox.toFixed(1) + '" y2="' + oy.toFixed(1) + '" stroke="var(--border)"/>';
var lx = cx + Math.cos(ang) * (r + 18);
var ly = cy + Math.sin(ang) * (r + 18);
labelEls += '<text x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '" text-anchor="middle" fill="var(--muted)" style="font-size:10px;font-family:var(--font)">' + labels[i] + '</text>';
var px = cx + Math.cos(ang) * r * values[i];
var py = cy + Math.sin(ang) * r * values[i];
polyPts.push(px.toFixed(1) + ',' + py.toFixed(1));
}
return '<svg viewBox="0 0 220 220" width="220" height="220" aria-label="routing profile spider graph">'
+ grid + axes
+ '<polygon points="' + polyPts.join(' ') + '" fill="rgba(99,102,241,0.25)" stroke="var(--accent)" stroke-width="2"/>'
+ labelEls
+ '</svg>';
}
function renderModelDecisionGraph(events, focusTurnId, focusModel, focusEdge) {
var MAX_GRAPH_CANDIDATES = 18;
var rows = Array.isArray(events) ? events.slice() : [];
if (!rows.length) {
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Task Model Decision Graph</div><div style="color:var(--muted)">No model selection events yet.</div></div>';
}
var byTurn = {};
rows.forEach(function(ev) {
var tid = String((ev && ev.turn_id) || '').trim();
if (!tid) return;
byTurn[tid] = ev;
});
var selectedTurn = focusTurnId && byTurn[focusTurnId] ? focusTurnId : String(rows[0].turn_id || '');
var selectedEvent = byTurn[selectedTurn] || rows[0] || {};
var selectedModel = String(selectedEvent.selected_model || '').trim();
var candidates = Array.isArray(selectedEvent.candidates) ? selectedEvent.candidates : [];
var nodeMap = {};
candidates.forEach(function(c) {
var model = String((c && c.model) || '').trim();
if (!model) return;
var score = typeof c.metascore === 'number' ? c.metascore : null;
nodeMap[model] = {
model: model,
score: score,
usable: c && c.usable !== false
};
});
if (selectedModel && !nodeMap[selectedModel]) {
nodeMap[selectedModel] = { model: selectedModel, score: null, usable: true };
}
var sortedNodes = Object.keys(nodeMap).sort(function(a, b) {
if (a === selectedModel) return -1;
if (b === selectedModel) return 1;
var sa = nodeMap[a] && typeof nodeMap[a].score === 'number' ? nodeMap[a].score : -Infinity;
var sb = nodeMap[b] && typeof nodeMap[b].score === 'number' ? nodeMap[b].score : -Infinity;
if (sa !== sb) return sb - sa;
return a.localeCompare(b);
});
var hiddenCount = Math.max(0, sortedNodes.length - MAX_GRAPH_CANDIDATES);
var nodes = sortedNodes.slice(0, MAX_GRAPH_CANDIDATES);
if (selectedModel && nodes.indexOf(selectedModel) === -1) {
nodes[nodes.length - 1] = selectedModel;
}
if (!nodes.length) return '';
var width = 680, height = 320;
var selectedX = 530, selectedY = height / 2;
var candidateX = 220;
var pos = {};
var candidateModels = nodes.filter(function(model) { return model !== selectedModel; });
if (selectedModel) pos[selectedModel] = { x: selectedX, y: selectedY };
candidateModels.forEach(function(model, idx) {
var y;
if (candidateModels.length === 1) {
y = selectedY;
} else {
var pad = 28;
var span = Math.max(10, (height - (pad * 2)));
y = pad + ((span * idx) / (candidateModels.length - 1));
}
pos[model] = { x: candidateX, y: y };
});
var edges = {};
candidateModels.forEach(function(model) {
if (!selectedModel || model === selectedModel) return;
var key = model + '→' + selectedModel;
edges[key] = { key: key, from: model, to: selectedModel, count: 1 };
});
var edgeKeys = Object.keys(edges);
var edgeEls = edgeKeys.map(function(k) {
var e = edges[k];
var a = pos[e.from], b = pos[e.to];
if (!a || !b) return '';
var active = focusEdge && focusEdge === e.key;
var faded = (focusModel && e.from !== focusModel && e.to !== focusModel) || (focusEdge && !active);
var cls = 'model-graph-edge' + (active ? ' active' : '') + (faded ? ' model-graph-faded' : '');
return '<line class="' + cls + '" data-edge-key="' + esc(e.key) + '" x1="' + a.x.toFixed(1) + '" y1="' + a.y.toFixed(1) + '" x2="' + b.x.toFixed(1) + '" y2="' + b.y.toFixed(1) + '" stroke-width="1.4"><title>' + esc(e.key) + '</title></line>';
}).join('');
var laneGuides = '';
if (candidateModels.length > 1) {
var yMin = pos[candidateModels[0]].y;
var yMax = pos[candidateModels[candidateModels.length - 1]].y;
laneGuides = '<line x1="' + candidateX + '" y1="' + yMin.toFixed(1) + '" x2="' + candidateX + '" y2="' + yMax.toFixed(1) + '" stroke="var(--border)" stroke-opacity="0.45" stroke-dasharray="2 3" />';
}
var nodeEls = nodes.map(function(model) {
var p = pos[model];
if (!p) return '';
var active = (focusModel && focusModel === model) || (!focusModel && model === selectedModel);
var faded = focusModel && !active;
var unusable = nodeMap[model] && nodeMap[model].usable === false;
var cls = 'model-graph-node' + (active ? ' active' : '') + (faded ? ' model-graph-faded' : '') + (unusable ? ' model-graph-node-unusable' : '');
var short = truncate(model.split('/').pop() || model, 22);
var score = nodeMap[model].score;
var scoreTxt = typeof score === 'number' ? ' · score ' + score.toFixed(3) : '';
var selectedTxt = model === selectedModel ? ' · selected' : '';
var r = model === selectedModel ? 14 : 10;
var labelX = model === selectedModel ? p.x : (p.x - 14);
var labelY = model === selectedModel ? (p.y + 28) : (p.y + 3);
var anchor = model === selectedModel ? 'middle' : 'end';
return '<g data-node-model="' + esc(model) + '" style="cursor:pointer">'
+ '<circle class="' + cls + '" cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="' + r + '"><title>' + esc(model + selectedTxt + scoreTxt) + '</title></circle>'
+ '<text class="model-graph-node-label" text-anchor="' + anchor + '" x="' + labelX.toFixed(1) + '" y="' + labelY.toFixed(1) + '">' + esc(short) + '</text>'
+ '</g>';
}).join('');
var options = rows.slice(0, 80).map(function(ev) {
var tid = String(ev.turn_id || '');
var stamp = String(ev.created_at || '').replace('T', ' ').replace('Z', '');
var excerpt = truncate(String(ev.user_excerpt || ''), 44);
var label = truncate(tid, 8) + (excerpt ? ' · ' + excerpt : '') + (stamp ? ' · ' + stamp : '');
return '<option value="' + esc(tid) + '"' + (tid === selectedTurn ? ' selected' : '') + '>' + esc(label) + '</option>';
}).join('');
var detail = 'Task turn: ' + selectedTurn + '. Selected model: ' + (selectedModel || 'unknown') + '. Displaying top ' + nodes.length + ' candidate models' + (hiddenCount > 0 ? ' (' + hiddenCount + ' hidden).' : '.');
if (focusEdge && edges[focusEdge]) {
detail = 'Task turn: ' + selectedTurn + '. Candidate edge: ' + edges[focusEdge].from + ' → ' + edges[focusEdge].to + '.';
} else if (focusModel) {
var n = nodeMap[focusModel];
if (n) detail = 'Task turn: ' + selectedTurn + '. Focused model: ' + focusModel + (typeof n.score === 'number' ? ' (metascore ' + n.score.toFixed(3) + ').' : '.');
}
return '<div class="card" style="margin-bottom:1rem;padding-bottom:0.75rem">'
+ '<div class="card-title">Task Model Decision Graph</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.5rem">Candidate models and chosen model for a single task. Pick a task turn to inspect that decision.</div>'
+ '<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;margin-bottom:0.55rem"><label for="model-graph-task-select" style="font-size:0.75rem;color:var(--muted)">Task turn</label><select id="model-graph-task-select" class="select" style="min-width:320px;max-width:100%">' + options + '</select></div>'
+ '<div class="model-graph-wrap">'
+ '<svg class="model-graph-svg" viewBox="0 0 680 320" preserveAspectRatio="xMidYMid meet">'
+ '<text x="36" y="22" fill="var(--muted)" style="font-size:10px;font-family:var(--font-mono)">Candidates (ranked)</text>'
+ '<text x="487" y="22" fill="var(--muted)" style="font-size:10px;font-family:var(--font-mono)">Selected</text>'
+ laneGuides + edgeEls + nodeEls
+ '</svg></div>'
+ '<div id="model-graph-detail" class="model-graph-detail">' + esc(detail) + '</div>'
+ '<div style="margin-top:0.5rem"><button class="btn secondary" id="model-graph-clear-focus" style="font-size:0.6875rem;padding:0.22rem 0.55rem">Clear focus</button></div>'
+ '</div>';
}
var HINTS_ENABLED_KEY = 'ic_hints_enabled';
var HINTS_PROMPTED_KEY = 'ic_hints_prompted';
var HINTS_DISMISSED_KEY = 'ic_hints_dismissed';
function hintsEnabled() {
try {
var v = window.localStorage.getItem(HINTS_ENABLED_KEY);
return v !== '0';
} catch (_) {
return true;
}
}
function setHintsEnabled(enabled) {
try { window.localStorage.setItem(HINTS_ENABLED_KEY, enabled ? '1' : '0'); } catch (_) {}
}
function loadDismissedHints() {
try {
var raw = window.localStorage.getItem(HINTS_DISMISSED_KEY);
if (!raw) return {};
var parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function saveDismissedHints(map) {
try { window.localStorage.setItem(HINTS_DISMISSED_KEY, JSON.stringify(map || {})); } catch (_) {}
}
function dismissHint(id) {
if (!id) return;
var map = loadDismissedHints();
map[id] = true;
saveDismissedHints(map);
}
function isHintDismissed(id) {
if (!id) return false;
return !!loadDismissedHints()[id];
}
function clearHintDismissals() {
saveDismissedHints({});
}
function disableAndClearHints() {
setHintsEnabled(false);
clearHintDismissals();
try { window.localStorage.setItem('ic_sessions_helper_dismissed', '1'); } catch (_) {}
try { window.localStorage.setItem('ic_dash_onboarding_dismissed', '1'); } catch (_) {}
}
function renderHintBanner(id, text) {
return '<div class="hint-banner" data-hint-id="' + esc(id) + '">'
+ '<div class="hint-main"><span class="hint-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.3"/><circle cx="8" cy="4.4" r="0.9" fill="currentColor"/><path d="M8 7v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg></span><div class="hint-text">' + esc(text) + '</div></div>'
+ '<button class="hint-dismiss" data-dismiss-hint="' + esc(id) + '" title="Dismiss hint" aria-label="Dismiss hint">x</button>'
+ '</div>';
}
function maybePromptHintPreference() {
var prompted = false;
try { prompted = window.localStorage.getItem(HINTS_PROMPTED_KEY) === '1'; } catch (_) {}
if (prompted) return;
if (document.getElementById('hint-pref-overlay')) return;
var overlay = document.createElement('div');
overlay.id = 'hint-pref-overlay';
overlay.className = 'hint-pref-overlay';
overlay.innerHTML = '<div class="hint-pref-modal">'
+ '<div class="hint-pref-title">Show hints?</div>'
+ '<div class="hint-pref-copy">Hints call out useful next steps. You can change this later in Settings.</div>'
+ '<div class="hint-pref-actions">'
+ '<button class="btn secondary" id="hint-pref-no">No</button>'
+ '<button class="btn" id="hint-pref-yes">Yes</button>'
+ '</div></div>';
document.body.appendChild(overlay);
function finalize(enableHints) {
try { window.localStorage.setItem(HINTS_PROMPTED_KEY, '1'); } catch (_) {}
if (enableHints) setHintsEnabled(true); else disableAndClearHints();
overlay.remove();
if (App && App.page) App.navigate(App.page);
}
var noBtn = overlay.querySelector('#hint-pref-no');
var yesBtn = overlay.querySelector('#hint-pref-yes');
if (noBtn) noBtn.addEventListener('click', function() { finalize(false); });
if (yesBtn) yesBtn.addEventListener('click', function() { finalize(true); });
}
try {
if (window.localStorage.getItem('ic_sessions_helper_dismissed') === '1') dismissHint('sessions-helper');
} catch (_) {}
var AGENT_DISPLAY_NAME = '';
function setAgentDisplayName(name) {
var value = String(name || '').trim();
if (value) AGENT_DISPLAY_NAME = value;
}
function sessionAssistantLabel(session, fallback) {
var fallbackLabel = fallback || 'assistant';
if (!session) return AGENT_DISPLAY_NAME || fallbackLabel;
var candidate = (session.agent_name || session.agent_id || '').toString().trim();
if (candidate && candidate.toLowerCase() !== 'default') return candidate;
if (AGENT_DISPLAY_NAME) return AGENT_DISPLAY_NAME;
return fallbackLabel;
}
function sanitizeMarkdownUrl(url) {
var raw = String(url || '').trim();
if (!raw) return null;
var lowered = raw.toLowerCase();
if (lowered.indexOf('javascript:') === 0 || lowered.indexOf('data:') === 0 || lowered.indexOf('vbscript:') === 0) return null;
if (lowered.indexOf('http://') === 0 || lowered.indexOf('https://') === 0 || lowered.indexOf('mailto:') === 0) return raw;
if (raw.charAt(0) === '/' || raw.charAt(0) === '#') return raw;
return null;
}
function renderInlineMarkdown(text) {
var src = String(text || '');
if (!src) return '';
var html = esc(src);
var inlineCodes = [];
html = html.replace(/`([^`]+)`/g, function(_, code) {
var token = '\x01IC' + inlineCodes.length + '\x01';
inlineCodes.push('<code>' + code + '</code>');
return token;
});
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, label, url) {
var safe = sanitizeMarkdownUrl(url);
var safeLabel = renderInlineMarkdown(label);
if (!safe) return safeLabel;
return '<a href="' + esc(safe) + '" target="_blank" rel="noopener noreferrer">' + safeLabel + '</a>';
});
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
html = html.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
html = html.replace(/_([^_\n]+)_/g, '<em>$1</em>');
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
html = html.replace(/~([^~\n]+)~/g, '<del>$1</del>');
html = html.replace(/\x01IC(\d+)\x01/g, function(_, idx) {
var n = Number(idx);
return inlineCodes[n] || '';
});
return html;
}
function renderSafeMarkdown(input) {
var src = String(input || '');
if (!src) return '';
var codeBlocks = [];
src = src.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, function(_, lang, code) {
var token = '__MD_CODE_BLOCK_' + codeBlocks.length + '__';
var language = String(lang || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
var classAttr = language ? ' class="language-' + language + '"' : '';
codeBlocks.push('<pre><code' + classAttr + '>' + esc(code) + '</code></pre>');
return token;
});
var lines = src.replace(/\r\n/g, '\n').split('\n');
var htmlParts = [];
var paragraph = [];
var inUl = false;
var inOl = false;
var inQuote = false;
function flushParagraph() {
if (!paragraph.length) return;
htmlParts.push('<p>' + paragraph.map(renderInlineMarkdown).join('<br>') + '</p>');
paragraph = [];
}
function closeLists() {
if (inUl) { htmlParts.push('</ul>'); inUl = false; }
if (inOl) { htmlParts.push('</ol>'); inOl = false; }
}
function closeQuote() {
if (inQuote) { htmlParts.push('</blockquote>'); inQuote = false; }
}
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
var trimmed = raw.trim();
var tokenOnly = /^__MD_CODE_BLOCK_\d+__$/.test(trimmed);
if (!trimmed) {
flushParagraph();
closeLists();
closeQuote();
continue;
}
if (tokenOnly) {
flushParagraph();
closeLists();
closeQuote();
htmlParts.push(trimmed);
continue;
}
var headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushParagraph();
closeLists();
closeQuote();
var level = headingMatch[1].length;
htmlParts.push('<h' + level + '>' + renderInlineMarkdown(headingMatch[2]) + '</h' + level + '>');
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
flushParagraph();
closeLists();
closeQuote();
htmlParts.push('<hr>');
continue;
}
// Table: | col | col | with separator line following
if (trimmed.charAt(0) === '|' && i + 1 < lines.length && /^\|[\s\-:|]+\|$/.test(lines[i + 1].trim())) {
flushParagraph();
closeLists();
closeQuote();
var headerCells = trimmed.split('|').slice(1, -1).map(function(c) { return c.trim(); });
var tbl = '<table class="md-table"><thead><tr>';
for (var h = 0; h < headerCells.length; h++) {
tbl += '<th>' + renderInlineMarkdown(headerCells[h]) + '</th>';
}
tbl += '</tr></thead><tbody>';
i += 2; // skip header + separator
while (i < lines.length && lines[i].trim().charAt(0) === '|') {
var cells = lines[i].trim().split('|').slice(1, -1).map(function(c) { return c.trim(); });
tbl += '<tr>';
for (var c = 0; c < cells.length; c++) {
tbl += '<td>' + renderInlineMarkdown(cells[c]) + '</td>';
}
tbl += '</tr>';
i++;
}
tbl += '</tbody></table>';
htmlParts.push(tbl);
i--; // compensate for the for-loop i++
continue;
}
var quoteMatch = trimmed.match(/^>\s?(.*)$/);
if (quoteMatch) {
flushParagraph();
closeLists();
if (!inQuote) { htmlParts.push('<blockquote>'); inQuote = true; }
htmlParts.push('<p>' + renderInlineMarkdown(quoteMatch[1]) + '</p>');
continue;
}
closeQuote();
var ulMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (ulMatch) {
flushParagraph();
if (inOl) { htmlParts.push('</ol>'); inOl = false; }
if (!inUl) { htmlParts.push('<ul>'); inUl = true; }
htmlParts.push('<li>' + renderInlineMarkdown(ulMatch[1]) + '</li>');
continue;
}
var olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
flushParagraph();
if (inUl) { htmlParts.push('</ul>'); inUl = false; }
if (!inOl) { htmlParts.push('<ol>'); inOl = true; }
htmlParts.push('<li>' + renderInlineMarkdown(olMatch[1]) + '</li>');
continue;
}
closeLists();
paragraph.push(trimmed);
}
flushParagraph();
closeLists();
closeQuote();
var html = htmlParts.join('\n');
html = html.replace(/__MD_CODE_BLOCK_(\d+)__/g, function(_, idx) {
var n = Number(idx);
return codeBlocks[n] || '';
});
return html;
}
function toast(msg) {
var c = document.getElementById('toasts'); if (!c) return;
var el = document.createElement('div'); el.className = 'toast'; el.textContent = msg;
c.appendChild(el); setTimeout(function() { el.remove(); }, 4000);
}
var AUTH_CHECKED = false;
var AUTH_REQUIRED = false;
var API_KEY = null;
function authHeaders(extra) {
var h = extra || { 'Accept': 'application/json' };
if (API_KEY) h['Authorization'] = 'Bearer ' + API_KEY;
return h;
}
function ensureAuth() {
if (AUTH_CHECKED) return Promise.resolve();
return fetch(BASE + '/api/health', { headers: { 'Accept': 'application/json' } })
.then(function() {
AUTH_CHECKED = true;
AUTH_REQUIRED = false;
return fetch(BASE + '/api/config', { headers: { 'Accept': 'application/json' } });
})
.then(function(r) {
if (r.status === 401) {
AUTH_REQUIRED = true;
API_KEY = prompt('Enter API key:');
if (API_KEY === '' || API_KEY === null) API_KEY = null;
}
AUTH_CHECKED = true;
})
.catch(function() { AUTH_CHECKED = true; });
}
function api(path, opts) {
opts = opts || {};
var url = BASE + path;
var defaultHeaders = { 'Accept': 'application/json' };
if (opts.body) defaultHeaders['Content-Type'] = 'application/json';
var headers = opts.headers ? authHeaders(opts.headers) : authHeaders(defaultHeaders);
return fetch(url, { headers: headers, method: opts.method || 'GET', body: opts.body }).then(function(r) {
if (!r.ok) return r.text().then(function(t) { throw new Error(t || r.statusText); });
var ct = r.headers.get('content-type');
if (ct && ct.indexOf('application/json') !== -1) return r.json();
return r.text();
});
}
function fetchWithFallback(url, fallback, label) {
return api(url)
.then(function(data) { return { ok: true, data: data, label: label }; })
.catch(function(err) {
return {
ok: false,
data: fallback,
label: label,
error: (err && err.message) ? err.message : String(err || 'unknown error')
};
});
}
function applySidebarIdentity(agentStatus, health) {
var agent = agentStatus || {};
var healthData = health || {};
if (agent.name) setAgentDisplayName(agent.name);
else if (healthData.agent) setAgentDisplayName(healthData.agent);
var state = String(agent.state || healthData.status || 'unknown').toLowerCase();
var dotEl = document.getElementById('agent-dot');
if (dotEl) {
dotEl.className = 'status-dot' + (state === 'running' ? '' : state === 'sleeping' ? ' warning' : ' error');
}
var stateEl = document.getElementById('agent-state');
if (stateEl) stateEl.textContent = state || 'unknown';
var ve = document.getElementById('version');
if (ve) ve.textContent = healthData.version || 'unknown';
}
function refreshSidebarIdentity() {
return Promise.all([
api('/api/agent/status').catch(function() { return {}; }),
api('/api/health').catch(function() { return {}; })
]).then(function(arr) {
applySidebarIdentity(arr[0], arr[1]);
}).catch(function() {});
}
function repeatedSeries(count, value) {
var pts = []; for (var i = 0; i < count; i++) pts.push(value); return pts;
}
var PROVIDER_COLORS = { anthropic: '#f59e0b', openai: '#22c55e', google: '#06b6d4' };
var PROVIDERS = ['anthropic', 'openai', 'google'];
var sparklineId = 0;
function renderSparkCanvas(series, opts) {
opts = opts || {};
var id = 'spark-' + (++sparklineId);
var color = opts.color || '#6366f1';
var fillFrom = opts.fillFrom != null ? opts.fillFrom : 0.25;
var lineWidth = opts.lineWidth || 2;
var height = opts.height || 64;
var axisTopLabel = opts.axisTopLabel || null;
var axisBottomLabel = opts.axisBottomLabel || null;
setTimeout(function() {
var canvas = document.getElementById(id); if (!canvas) return;
var dpr = window.devicePixelRatio || 1;
var rect = canvas.parentElement ? canvas.parentElement.getBoundingClientRect() : { width: 340 };
var W = rect.width, H = height;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
var ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var min = opts.min != null ? Number(opts.min) : Math.min.apply(null, series);
var max = opts.max != null ? Number(opts.max) : Math.max.apply(null, series);
if (!isFinite(min)) min = 0;
if (!isFinite(max)) max = 1;
if (max < min) { var t = max; max = min; min = t; }
var range = max - min || 1; var padTop = 6, padBot = 4, usableH = H - padTop - padBot;
function toX(i) { return (i / (series.length - 1)) * W; }
function toY(v) {
var n = (v - min) / range;
if (n < 0) n = 0;
if (n > 1) n = 1;
return padTop + usableH - n * usableH;
}
var grad = ctx.createLinearGradient(0, padTop, 0, H);
grad.addColorStop(0, color + Math.round(fillFrom * 255).toString(16).padStart(2, '0'));
grad.addColorStop(1, color + '00');
ctx.beginPath(); ctx.moveTo(toX(0), H);
for (var i = 0; i < series.length; i++) {
if (i === 0) ctx.lineTo(toX(0), toY(series[0]));
else { var cx1 = (toX(i-1)+toX(i))/2; ctx.bezierCurveTo(cx1, toY(series[i-1]), cx1, toY(series[i]), toX(i), toY(series[i])); }
}
ctx.lineTo(W, H); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
ctx.beginPath();
for (var i = 0; i < series.length; i++) {
if (i === 0) ctx.moveTo(toX(0), toY(series[0]));
else { var cx1 = (toX(i-1)+toX(i))/2; ctx.bezierCurveTo(cx1, toY(series[i-1]), cx1, toY(series[i]), toX(i), toY(series[i])); }
}
ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.stroke();
var lastX = toX(series.length - 1), lastY = toY(series[series.length - 1]);
ctx.fillStyle = color; ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#0a0b0f'; ctx.beginPath(); ctx.arc(lastX, lastY, 1.5, 0, Math.PI * 2); ctx.fill();
}, 0);
if (axisTopLabel || axisBottomLabel) {
var top = axisTopLabel ? '<span class="cc-chart-axis top">' + esc(axisTopLabel) + '</span>' : '';
var bottom = axisBottomLabel ? '<span class="cc-chart-axis bottom">' + esc(axisBottomLabel) + '</span>' : '';
return '<div class="cc-chart-wrap"><canvas id="' + id + '" class="cc-chart"></canvas>' + top + bottom + '</div>';
}
return '<canvas id="' + id + '" class="cc-chart"></canvas>';
}
var stackedId = 0;
function renderStackedArea(seriesMap, keys, colors, opts) {
opts = opts || {};
var id = 'stacked-' + (++stackedId);
var height = opts.height || 180;
var showLabels = opts.labels !== false;
setTimeout(function() {
var canvas = document.getElementById(id); if (!canvas) return;
var dpr = window.devicePixelRatio || 1;
var rect = canvas.parentElement ? canvas.parentElement.getBoundingClientRect() : { width: 600 };
var W = rect.width, H = height;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
var ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var len = seriesMap[keys[0]].length;
var padTop = 8, padBot = showLabels ? 22 : 6, padLeft = opts.yAxis ? 48 : 6, padRight = 20;
var chartW = W - padLeft - padRight, chartH = H - padTop - padBot;
var stacked = [];
for (var i = 0; i < len; i++) {
var cum = 0, layers = [];
for (var k = 0; k < keys.length; k++) { var val = seriesMap[keys[k]][i]; layers.push({ bottom: cum, top: cum + val, key: keys[k] }); cum += val; }
stacked.push({ layers: layers, total: cum });
}
var maxTotal = opts.fixedMax != null ? opts.fixedMax : Math.max.apply(null, stacked.map(function(s) { return s.total; }));
if (maxTotal === 0) maxTotal = 1;
function toX(i) { return padLeft + (i / (len - 1)) * chartW; }
function toY(v) { return padTop + chartH - (v / maxTotal) * chartH; }
for (var k = keys.length - 1; k >= 0; k--) {
var color = colors[keys[k]] || '#71717a';
var grad = ctx.createLinearGradient(0, padTop, 0, H - padBot);
grad.addColorStop(0, color + '55'); grad.addColorStop(1, color + '08');
ctx.beginPath(); ctx.moveTo(toX(0), toY(0));
for (var i = 0; i < len; i++) {
var topVal = stacked[i].layers[k].top;
if (i === 0) ctx.lineTo(toX(0), toY(topVal));
else { var cx1 = (toX(i-1)+toX(i))/2; ctx.bezierCurveTo(cx1, toY(stacked[i-1].layers[k].top), cx1, toY(topVal), toX(i), toY(topVal)); }
}
ctx.lineTo(toX(len - 1), toY(0)); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
ctx.beginPath();
for (var i = 0; i < len; i++) {
var topVal = stacked[i].layers[k].top;
if (i === 0) ctx.moveTo(toX(0), toY(topVal));
else { var cx1 = (toX(i-1)+toX(i))/2; ctx.bezierCurveTo(cx1, toY(stacked[i-1].layers[k].top), cx1, toY(topVal), toX(i), toY(topVal)); }
}
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
}
if (opts.yAxis) {
ctx.fillStyle = '#71717a'; ctx.font = '9px ui-monospace, monospace'; ctx.textAlign = 'right';
var steps = 4;
for (var s = 0; s <= steps; s++) {
var val = (maxTotal / steps) * s, y = toY(val);
ctx.fillText(opts.yFormat ? opts.yFormat(val) : val.toFixed(2), padLeft - 6, y + 3);
if (s > 0 && s < steps) { ctx.strokeStyle = 'rgba(113,113,122,0.1)'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(padLeft, y); ctx.lineTo(W - padRight, y); ctx.stroke(); }
}
}
if (showLabels) {
ctx.fillStyle = '#71717a'; ctx.font = '9px ui-monospace, monospace'; ctx.textAlign = 'center';
var labelStep = Math.max(1, Math.floor(len / 6));
for (var i = 0; i < len; i += labelStep) { var label = opts.xLabels ? opts.xLabels[i] : (i + 'h'); ctx.fillText(label, toX(i), H - 4); }
}
}, 0);
return '<canvas id="' + id + '" style="width:100%;height:' + height + 'px;display:block"></canvas>';
}
function deltaHtml(current, previous) {
if (previous === 0) return '<span class="cc-delta flat">—</span>';
var pct = ((current - previous) / Math.abs(previous) * 100);
var dir = pct > 1 ? 'up' : pct < -1 ? 'down' : 'flat';
var arrow = dir === 'up' ? '\u2191' : dir === 'down' ? '\u2193' : '\u2192';
return '<span class="cc-delta ' + dir + '">' + arrow + ' ' + Math.abs(pct).toFixed(1) + '%</span>';
}
function formatUptime(sec) {
if (sec == null) return '\u2014';
sec = Math.floor(sec);
var d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function seriesLast(s) { return s[s.length - 1]; }
function seriesPrev(s) { return s[s.length - 2] || s[0]; }
function seriesAvg(s) { return s.reduce(function(a, b) { return a + b; }, 0) / s.length; }
function seriesMax(s) { return Math.max.apply(null, s); }
var overviewRefreshTimer = null;
var modelsRefreshTimer = null;
var FLEET_HISTORY_MAX_POINTS = 40;
var _fleetHistory = { labels: [], byAgent: {} };
function pushFleetSnapshot(agents, activityByAgent) {
var now = new Date();
var hh = String(now.getHours()).padStart(2, '0');
var mm = String(now.getMinutes()).padStart(2, '0');
var label = hh + ':' + mm;
_fleetHistory.labels.push(label);
if (_fleetHistory.labels.length > FLEET_HISTORY_MAX_POINTS) _fleetHistory.labels.shift();
var activeKeys = {};
agents.forEach(function(a) {
var k = a.name || a.id;
activeKeys[k] = true;
if (!_fleetHistory.byAgent[k]) _fleetHistory.byAgent[k] = [];
_fleetHistory.byAgent[k].push(activityByAgent[k] || 0);
if (_fleetHistory.byAgent[k].length > FLEET_HISTORY_MAX_POINTS) _fleetHistory.byAgent[k].shift();
});
Object.keys(_fleetHistory.byAgent).forEach(function(k) {
if (!activeKeys[k]) delete _fleetHistory.byAgent[k];
});
var len = _fleetHistory.labels.length;
Object.keys(_fleetHistory.byAgent).forEach(function(k) {
while (_fleetHistory.byAgent[k].length < len) _fleetHistory.byAgent[k].unshift(0);
});
}
function startOverviewRefresh(app) {
if (overviewRefreshTimer) clearInterval(overviewRefreshTimer);
overviewRefreshTimer = setInterval(function() {
if (!app || app.page !== 'overview') return;
app.refreshOverview();
}, 7000);
}
function stopModelsBackgroundRefresh() {
if (modelsRefreshTimer) {
clearInterval(modelsRefreshTimer);
modelsRefreshTimer = null;
}
}
function startModelsBackgroundRefresh(app) {
stopModelsBackgroundRefresh();
// Keep model cache warm using metadata-only discovery (no inference calls).
modelsRefreshTimer = setInterval(function() {
if (!app || !app._loadAvailableModels) return;
app._loadAvailableModels({
forceRefresh: true,
cacheTtlMs: 0,
timeoutMs: 700,
nonBlocking: true,
validationLevel: 'zero'
}).catch(function() {});
}, 180000);
}
var workspaceRefreshTimer = null;
function stopWorkspaceRefresh() {
if (workspaceRefreshTimer) {
clearInterval(workspaceRefreshTimer);
workspaceRefreshTimer = null;
}
}
function startWorkspaceRefresh(app) {
stopWorkspaceRefresh();
workspaceRefreshTimer = setInterval(function() {
if (app.page !== 'workspace' || !workspace || !workspace.applySnapshot) return;
api('/api/workspace/state')
.then(function(data) {
_cachedWorkspace = data;
workspace.applySnapshot(data);
})
.catch(function() {});
}, 3000);
}
var _cachedConfig = null;
var _cachedCronJobs = [];
var _cachedWorkspace = null;
var App = {
page: 'overview',
_activeAgentId: null,
_memoryTab: 'episodic',
_skillsTab: 'installed',
_memorySessionId: '',
_overviewShowDetails: false,
_routingProfileDraft: null,
_routingProfileDefaults: null,
_modelGraphFocusTurn: null,
_modelGraphFocusModel: null,
_modelGraphFocusEdge: null,
_effTab: 'performance',
ws: null,
_resolveActiveAgentId: function() {
var self = this;
if (self._activeAgentId) return Promise.resolve(self._activeAgentId);
return api('/api/agent/status')
.then(function(s) {
if (s && s.name) setAgentDisplayName(s.name);
var aid = (s && s.agent_id) ? String(s.agent_id) : 'default';
self._activeAgentId = aid;
return aid;
})
.catch(function() { return 'default'; });
},
setPage: function(p) {
this.page = p;
document.querySelectorAll('.sidebar-nav a, .mobile-nav a').forEach(function(a) {
a.classList.toggle('active', a.getAttribute('data-page') === p);
});
var bc = document.getElementById('breadcrumb'); if (bc) bc.textContent = titles[p] || p;
},
refreshOverview: function() {
if (this.page !== 'overview') return;
var content = document.getElementById('content');
if (!content) return;
var self = this;
this.renderOverview().then(function(cards) {
self._applyOverviewCards(cards);
}).catch(function(e) {
toast(e.message || 'Overview refresh failed');
});
},
_renderOverviewShell: function() {
return ''
+ '<div class="card overview-status">'
+ ' <div class="overview-meta" id="ov-meta"></div>'
+ ' <div style="display:flex;gap:0.5rem;align-items:center">'
+ ' <button class="btn secondary" id="ov-toggle-details">Show details</button>'
+ ' </div>'
+ '</div>'
+ '<div class="card overview-attention" id="ov-attention" style="display:none"></div>'
+ '<div class="card overview-onboarding" id="ov-onboarding" style="display:none"></div>'
+ '<div class="overview-grid" id="overview-grid">'
+ ' <div id="ov-card-cost"></div>'
+ ' <div id="ov-card-tokens"></div>'
+ ' <div id="ov-card-cache"></div>'
+ ' <div id="ov-card-wallet"></div>'
+ ' <div id="ov-card-latency"></div>'
+ ' <div id="ov-card-sessions"></div>'
+ ' <div id="ov-card-system"></div>'
+ ' <div id="ov-card-fleet" class="overview-fleet-slot" style="display:none"></div>'
+ '</div>';
},
_applyOverviewCards: function(cards) {
var self = this;
var slotMap = {
cost: 'ov-card-cost',
tokens: 'ov-card-tokens',
cache: 'ov-card-cache',
wallet: 'ov-card-wallet',
latency: 'ov-card-latency',
sessions: 'ov-card-sessions',
system: 'ov-card-system',
fleet: 'ov-card-fleet'
};
var meta = cards && cards.__meta ? cards.__meta : {};
var attention = cards && cards.__attention ? cards.__attention : [];
var failures = cards && cards.__failures ? cards.__failures : [];
var ovMeta = document.getElementById('ov-meta');
if (ovMeta) {
var staleBadge = failures.length > 0
? '<span class="badge warning">Partial data (' + failures.length + ' source' + (failures.length > 1 ? 's' : '') + ' unavailable)</span>'
: '<span class="badge success">All sources healthy</span>';
var updated = meta.updated_at ? esc(meta.updated_at) : 'unknown';
setHtml(ovMeta, staleBadge + '<span class="badge muted">Last updated: ' + updated + '</span>');
}
var ovAttention = document.getElementById('ov-attention');
if (ovAttention) {
if (attention.length > 0) {
var items = attention.map(function(item) {
return '<li>' + esc(item) + '</li>';
}).join('');
setHtml(ovAttention, '<strong>Attention needed</strong><ul style="margin:0.5rem 0 0 1rem">' + items + '</ul>');
ovAttention.style.display = '';
} else {
setHtml(ovAttention, '<strong>No urgent issues detected.</strong>');
ovAttention.style.display = '';
}
}
var ovOnboarding = document.getElementById('ov-onboarding');
if (ovOnboarding && hintsEnabled() && !window.localStorage.getItem('ic_dash_onboarding_dismissed')) {
setHtml(
ovOnboarding,
'<div style="display:flex;justify-content:space-between;gap:1rem;align-items:flex-start">'
+ '<div><strong>Quick Start</strong><div style="margin-top:0.35rem;color:var(--muted);font-size:0.85rem">1) Open <em>Sessions</em> and start a conversation. 2) Use <em>Context</em> to inspect token and reasoning traces. 3) Use <em>Prompt Performance</em> to tune latency/cost.</div></div>'
+ '<button class="btn secondary" id="dismiss-onboarding" title="Dismiss hint" aria-label="Dismiss hint">x</button>'
+ '</div>'
);
ovOnboarding.style.display = '';
} else if (ovOnboarding) {
ovOnboarding.style.display = 'none';
}
var toggleBtn = document.getElementById('ov-toggle-details');
if (toggleBtn) toggleBtn.textContent = self._overviewShowDetails ? 'Hide details' : 'Show details';
Object.keys(slotMap).forEach(function(key) {
var el = document.getElementById(slotMap[key]);
if (!el) return;
var html = cards && cards[key] ? cards[key] : '';
if (html) {
setHtml(el, html);
var isSecondary = ['wallet', 'latency', 'sessions', 'fleet'].indexOf(key) !== -1;
el.style.display = (isSecondary && !self._overviewShowDetails) ? 'none' : '';
} else {
setHtml(el, '');
if (key === 'fleet') el.style.display = 'none';
}
});
},
navigate: function(page) {
if (page === 'recommendations') {
this._effTab = 'recommendations';
page = 'efficiency';
}
this.setPage(page);
if (page !== 'overview') {
if (overviewRefreshTimer) clearInterval(overviewRefreshTimer);
}
if (page !== 'workspace') stopWorkspaceRefresh();
var content = document.getElementById('content');
if (content) {
if (page === 'workspace' || page === 'scheduler') { content.style.overflow = 'hidden'; content.style.padding = '0'; }
else { content.style.overflow = 'auto'; content.style.padding = '1.5rem'; }
setHtml(content, '<div class="skeleton" style="height:200px"></div>');
}
var self = this;
var renderName = 'render' + page.charAt(0).toUpperCase() + page.slice(1);
if (this[renderName]) {
this[renderName]().then(function(html) {
if (page === 'overview') {
if (content) setHtml(content, self._renderOverviewShell());
self._applyOverviewCards(html);
startOverviewRefresh(self);
return;
}
if (content) {
var helperDefs = {
sessions: { id: 'sessions-helper', text: 'Review and continue conversations. Click a row to open details.' },
context: { id: 'context-helper', text: 'Inspect context budgets, token allocation, and reasoning traces.' },
memory: { id: 'memory-helper', text: 'Browse memory layers or search by query.' },
skills: { id: 'skills-helper', text: 'Toggle and manage enabled runtime skills.' },
agents: { id: 'agents-helper', text: 'Monitor subagents and their current state.' },
scheduler: { id: 'scheduler-helper', text: 'Manage recurring jobs and inspect run outcomes.' },
metrics: { id: 'metrics-helper', text: 'Track costs, throughput, and provider capacity.' },
efficiency: { id: 'efficiency-helper', text: 'Find prompt and token optimization opportunities.' },
recommendations: { id: 'recommendations-helper', text: 'Generate and review improvement suggestions.' },
wallet: { id: 'wallet-helper', text: 'Inspect treasury balances and token positions.' },
workspace: { id: 'workspace-helper', text: 'See live runtime activity across systems.' },
settings: { id: 'settings-helper', text: 'Edit runtime configuration safely.' }
};
var helperDef = helperDefs[page];
var showHelper = !!helperDef && hintsEnabled() && !isHintDismissed(helperDef.id);
if (page === 'sessions') {
showHelper = showHelper && !self._activeSession;
}
var helper = showHelper ? renderHintBanner(helperDef.id, helperDef.text) : '';
setHtml(content, helper + html);
}
if (page === 'workspace') setTimeout(function() { startWorkspaceEngine(_cachedWorkspace || { agents: [], workstations: [] }); startWorkspaceRefresh(self); }, 0);
if (page === 'overview') startOverviewRefresh(self);
}).catch(function(e) {
if (content) setHtml(content, '<div class="card"><p style="color:var(--error)">' + esc(e.message || 'Failed to load') + '</p></div>');
});
}
},
refreshSkills: function() {
if (this.page !== 'skills') return;
var content = document.getElementById('content');
if (!content) return;
setHtml(content, '<div class="skeleton" style="height:200px"></div>');
this.renderSkills().then(function(html) {
setHtml(content, html);
}).catch(function(e) {
setHtml(content, '<div class="card"><p style="color:var(--error)">' + esc(e.message || 'Failed to load skills') + '</p></div>');
});
},
renderOverview: function() {
return Promise.all([
fetchWithFallback('/api/agent/status', {}, 'agent status'),
fetchWithFallback('/api/health', {}, 'health'),
fetchWithFallback('/api/sessions', { sessions: [] }, 'sessions'),
fetchWithFallback('/api/skills', { skills: [] }, 'skills'),
fetchWithFallback('/api/cron/jobs', { jobs: [] }, 'cron jobs'),
fetchWithFallback('/api/stats/cache', { hit_rate: 0 }, 'cache stats'),
fetchWithFallback('/api/stats/timeseries?hours=24', { series: {}, labels: [] }, 'timeseries'),
fetchWithFallback('/api/wallet/balance', {}, 'wallet'),
fetchWithFallback('/api/breaker/status', {}, 'breaker'),
fetchWithFallback('/api/workspace/state', { agents: [] }, 'workspace'),
fetchWithFallback('/api/stats/costs', { costs: [] }, 'cost stats')
]).then(function(arr) {
sparklineId = 0; stackedId = 0;
var failures = arr.filter(function(r) { return !r.ok; });
var agent = arr[0].data, health = arr[1].data, sessions = arr[2].data, skills = arr[3].data;
var cron = arr[4].data, cache = arr[5].data, timeseries = arr[6].data, wallet = arr[7].data, breaker = arr[8].data;
var wsState = arr[9].data, costData = arr[10].data;
_cachedWorkspace = wsState;
var state = (agent.state || 'unknown').toLowerCase();
applySidebarIdentity(agent, health);
if (agent.agent_id) App._activeAgentId = String(agent.agent_id);
var sessionCount = (sessions.sessions || []).length;
var skillList = skills.skills || [];
var skillCount = skillList.length;
var enabledSkills = skillList.filter(function(s) { return s.enabled; }).length;
var jobCount = (cron.jobs || []).length;
var hitRate = cache.hit_rate != null ? cache.hit_rate : 0;
var balance = Number(wallet.balance) || 0;
var costs = costData.costs || [];
var totalCost = costs.reduce(function(s, c) { return s + (Number(c.cost) || 0); }, 0);
var totalTokens = costs.reduce(function(s, c) { return s + (Number(c.tokens_in) || 0) + (Number(c.tokens_out) || 0); }, 0);
var cronErrors = (cron.jobs || []).reduce(function(s, j) { return s + (j.consecutive_errors || 0); }, 0);
var agents = wsState.agents || [];
function agentActivityScore(a) {
var activity = (a.activity || '').toString().toLowerCase();
var state = (a.state || '').toString().toLowerCase();
if (activity === 'idle' || activity === 'standby') return 0;
if (activity === 'inference' || activity === 'working' || activity === 'tool_execution' || activity === 'tooling') return 1;
if (activity === 'walking' || activity === 'moving' || activity === 'talking') return 0.5;
if (state === 'running' && activity) return 0.6;
return 0;
}
var agentRunning = agents.filter(function(a) { return (a.state || '').toLowerCase() === 'running'; });
var agentActivity = {};
agents.forEach(function(a) { agentActivity[a.name || a.id] = agentActivityScore(a); });
var agentActiveNow = Object.keys(agentActivity).filter(function(k) { return (agentActivity[k] || 0) > 0; }).length;
var costPerHr = totalCost > 0 ? totalCost / 24 : 0;
var tokPerHr = totalTokens > 0 ? totalTokens / 24 : 0;
var agentLoadVal = agents.length > 0
? agents.reduce(function(sum, a) { return sum + agentActivityScore(a); }, 0) / agents.length
: 0;
var cronSuccessVal = cronErrors > 0 ? 0.9 : (jobCount > 0 ? 1.0 : 0);
var s = (timeseries && timeseries.series) || {};
var buckets = (timeseries && timeseries.labels && timeseries.labels.length) ? timeseries.labels.length : 24;
var TS = {
costPerHour: (s.cost_per_hour || []).map(Number),
tokensPerHour: (s.tokens_per_hour || []).map(Number),
cacheHitRate: repeatedSeries(buckets, hitRate || 0),
walletBalance: repeatedSeries(buckets, balance || 0),
requestLatency: (s.latency_p50_ms || []).map(Number),
sessionsPerHour: (s.sessions_per_hour || []).map(Number),
memoryEntries: repeatedSeries(buckets, 0),
cronSuccess: (s.cron_success_rate || []).map(Number),
breakerFailures: repeatedSeries(buckets, 0),
agentLoad: repeatedSeries(buckets, agentLoadVal),
};
if (TS.costPerHour.length === 0) TS.costPerHour = repeatedSeries(buckets, costPerHr);
if (TS.tokensPerHour.length === 0) TS.tokensPerHour = repeatedSeries(buckets, tokPerHr);
if (TS.requestLatency.length === 0) TS.requestLatency = repeatedSeries(buckets, 0);
if (TS.sessionsPerHour.length === 0) TS.sessionsPerHour = repeatedSeries(buckets, sessionCount);
if (TS.cronSuccess.length === 0) TS.cronSuccess = repeatedSeries(buckets, cronSuccessVal);
pushFleetSnapshot(agents, agentActivity);
TS.agentLoadByAgent = {};
var agentCount = agents.length;
agents.forEach(function(a) {
var k = a.name || a.id;
var history = (_fleetHistory.byAgent[k] || []).slice();
if (history.length === 0) history = [agentActivity[k] || 0];
// Normalize: each agent contributes at most 1/agentCount to the
// stacked total so that 100% = all agents active simultaneously.
TS.agentLoadByAgent[k] = history.map(function(v) { return v / agentCount; });
});
TS.agentLoadLabels = _fleetHistory.labels.slice();
var costLast = seriesLast(TS.costPerHour), costPrev = seriesPrev(TS.costPerHour);
var tokLast = seriesLast(TS.tokensPerHour), tokPrev = seriesPrev(TS.tokensPerHour);
var hitLast = seriesLast(TS.cacheHitRate), hitPrev = seriesPrev(TS.cacheHitRate);
var balLast = seriesLast(TS.walletBalance), balPrev = seriesPrev(TS.walletBalance);
var latLast = seriesLast(TS.requestLatency), latPrev = seriesPrev(TS.requestLatency);
var memLast = seriesLast(TS.memoryEntries);
var cardCost = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Inference Cost (24h)</div><div class="cc-value">$' + totalCost.toFixed(2) + '</div><div class="cc-sub">$' + costLast.toFixed(4) + '/hr current</div></div><div class="cc-right">' + deltaHtml(costLast, costPrev) + '</div></div>' + renderSparkCanvas(TS.costPerHour, { color: '#6366f1' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">Avg / hr</div><div class="cc-stat-value">$' + seriesAvg(TS.costPerHour).toFixed(4) + '</div></div><div class="cc-stat"><div class="cc-stat-label">Peak</div><div class="cc-stat-value">$' + seriesMax(TS.costPerHour).toFixed(4) + '</div></div><div class="cc-stat"><div class="cc-stat-label">Requests</div><div class="cc-stat-value">' + costs.length + '</div></div></div></div>';
var cardTokens = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Token Throughput</div><div class="cc-value">' + Math.round(tokLast).toLocaleString() + '</div><div class="cc-sub">tokens/hr current</div></div><div class="cc-right">' + deltaHtml(tokLast, tokPrev) + '</div></div>' + renderSparkCanvas(TS.tokensPerHour, { color: '#8b5cf6' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">Avg / hr</div><div class="cc-stat-value">' + Math.round(seriesAvg(TS.tokensPerHour)).toLocaleString() + '</div></div><div class="cc-stat"><div class="cc-stat-label">Peak</div><div class="cc-stat-value">' + Math.round(seriesMax(TS.tokensPerHour)).toLocaleString() + '</div></div><div class="cc-stat"><div class="cc-stat-label">Total (24h)</div><div class="cc-stat-value">' + totalTokens.toLocaleString() + '</div></div></div></div>';
var cardCache = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Cache Hit Rate</div><div class="cc-value">' + (hitLast * 100).toFixed(1) + '%</div><div class="cc-sub">' + (seriesAvg(TS.cacheHitRate) * 100).toFixed(1) + '% avg over 24h</div></div><div class="cc-right">' + deltaHtml(hitLast, hitPrev) + '</div></div>' + renderSparkCanvas(TS.cacheHitRate, { color: '#22c55e' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">Floor</div><div class="cc-stat-value">' + (Math.min.apply(null, TS.cacheHitRate) * 100).toFixed(1) + '%</div></div><div class="cc-stat"><div class="cc-stat-label">Ceiling</div><div class="cc-stat-value">' + (seriesMax(TS.cacheHitRate) * 100).toFixed(1) + '%</div></div></div></div>';
var cardWallet = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Wallet Balance</div><div class="cc-value">' + balLast.toFixed(3) + ' ' + esc(wallet.currency || 'SOL') + '</div><div class="cc-sub">managed wallet</div></div><div class="cc-right">' + deltaHtml(balLast, balPrev) + '</div></div>' + renderSparkCanvas(TS.walletBalance, { color: '#f59e0b' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">24h Low</div><div class="cc-stat-value">' + Math.min.apply(null, TS.walletBalance).toFixed(3) + '</div></div><div class="cc-stat"><div class="cc-stat-label">24h High</div><div class="cc-stat-value">' + seriesMax(TS.walletBalance).toFixed(3) + '</div></div></div></div>';
var cardLatency = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Request Latency</div><div class="cc-value">' + Math.round(latLast) + 'ms</div><div class="cc-sub">p50 current</div></div><div class="cc-right">' + deltaHtml(latPrev, latLast) + '</div></div>' + renderSparkCanvas(TS.requestLatency, { color: '#06b6d4' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">Avg</div><div class="cc-stat-value">' + Math.round(seriesAvg(TS.requestLatency)) + 'ms</div></div><div class="cc-stat"><div class="cc-stat-label">p99</div><div class="cc-stat-value">' + Math.round(seriesMax(TS.requestLatency)) + 'ms</div></div></div></div>';
var cardSessions = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Sessions</div><div class="cc-value">' + sessionCount + ' active</div></div><div class="cc-right"><span class="cc-delta flat">' + sessionCount + ' open</span></div></div>' + renderSparkCanvas(TS.sessionsPerHour, { color: '#ec4899' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">Skills</div><div class="cc-stat-value">' + enabledSkills + '/' + skillCount + '</div></div><div class="cc-stat"><div class="cc-stat-label">Cron jobs</div><div class="cc-stat-value">' + jobCount + '</div></div></div></div>';
var healthStatus = String(health.status || 'unknown').toLowerCase();
var healthClass = healthStatus === 'ok' || healthStatus === 'healthy' ? 'success' : (healthStatus === 'warning' ? 'warning' : 'error');
var breakerState = String(breaker.note || 'closed').toLowerCase();
var breakerClass = breakerState.indexOf('open') >= 0 ? 'error' : (breakerState.indexOf('half') >= 0 ? 'warning' : 'success');
var cardSystem = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">System Health</div><div class="cc-value"><span class="badge ' + healthClass + '">' + esc(health.status || 'unknown') + '</span></div><div class="cc-sub">uptime ' + formatUptime(health.uptime_seconds) + '</div></div><div class="cc-right"><span class="cc-delta up">' + (seriesLast(TS.cronSuccess) * 100).toFixed(0) + '% cron</span></div></div>' + renderSparkCanvas(TS.cronSuccess, { color: '#22c55e' }) + '<div class="cc-footer"><div class="cc-stat"><div class="cc-stat-label">Breaker</div><div class="cc-stat-value"><span class="badge ' + breakerClass + '" style="font-size:0.6875rem">' + esc(breaker.note || 'closed') + '</span></div></div><div class="cc-stat"><div class="cc-stat-label">Cron errors</div><div class="cc-stat-value">' + cronErrors + '</div></div></div></div>';
var cardFleet = '';
if (agents.length > 0) {
var fleetColors = {};
var fleetLegend = agents.map(function(a) {
var k = a.name || a.id;
var stDot = (a.state || '').toLowerCase() === 'running' ? 'var(--success)' : 'var(--warning)';
// Use raw per-agent score (0–1) for the legend bar, not the
// normalized fleet-fraction used in the stacked chart.
var lastVal = agentActivity[k] || 0;
var stateLabel = (a.activity || a.state || 'idle').toString().toLowerCase();
var color = a.color || '#6366f1';
fleetColors[k] = color;
return '<div class="metrics-legend-item" style="display:grid;grid-template-columns:minmax(120px,1fr) 1fr auto auto;align-items:center;gap:0.5rem">' +
'<div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(k) + '</div>' +
'<div style="height:6px;background:rgba(255,255,255,0.09);border-radius:999px;overflow:hidden"><div style="height:100%;width:' + Math.max(0, Math.min(100, lastVal * 100)).toFixed(0) + '%;background:' + color + ';border-radius:999px"></div></div>' +
'<span class="metrics-legend-val" style="margin-left:0.25rem">' + (lastVal * 100).toFixed(0) + '%</span>' +
'<div title="' + esc(stateLabel) + '" style="width:6px;height:6px;border-radius:50%;background:' + stDot + ';flex-shrink:0"></div>' +
'</div>';
}).join('');
var fleetKeys = agents.map(function(a) { return a.name || a.id; });
var fleetChart = renderStackedArea(
TS.agentLoadByAgent,
fleetKeys,
fleetColors,
{
height: 190,
labels: TS.agentLoadLabels.length > 1,
xLabels: TS.agentLoadLabels,
yAxis: true,
fixedMax: 1.0,
yFormat: function(v) { return (v * 100).toFixed(0) + '%'; }
}
);
cardFleet = '<div class="card composite-card"><div class="cc-header"><div class="cc-left"><div class="cc-label">Agent Fleet Activity</div><div class="cc-value">' + (seriesLast(TS.agentLoad) * 100).toFixed(0) + '% active load</div><div class="cc-sub">' + agentActiveNow + ' / ' + agents.length + ' agents active now</div></div><div class="cc-right">' + deltaHtml(seriesLast(TS.agentLoad), seriesPrev(TS.agentLoad)) + '</div></div>' + fleetChart + '<div class="metrics-legend" style="padding:0.5rem 1rem 0.75rem">' + fleetLegend + '</div></div>';
}
var attention = [];
if (failures.length > 0) attention.push(failures.length + ' data source(s) unavailable: ' + failures.map(function(f) { return f.label; }).join(', '));
if (cronErrors > 0) attention.push('Cron has ' + cronErrors + ' consecutive error(s)');
if (breakerClass !== 'success') attention.push('Circuit breaker is ' + (breaker.note || 'not closed'));
if (healthClass !== 'success') attention.push('System health reports "' + (health.status || 'unknown') + '"');
return {
cost: cardCost,
tokens: cardTokens,
cache: cardCache,
wallet: cardWallet,
latency: cardLatency,
sessions: cardSessions,
system: cardSystem,
fleet: cardFleet,
__attention: attention,
__failures: failures,
__meta: { updated_at: new Date().toLocaleTimeString() }
};
});
},
_activeSession: null,
_sessionMessages: [],
renderSessions: function() {
var self = this;
return Promise.all([
api('/api/sessions'),
api('/api/agent/status').catch(function() { return {}; })
]).then(function(results) {
var data = results[0] || {};
var agentStatus = results[1] || {};
if (agentStatus.name) setAgentDisplayName(agentStatus.name);
var sessions = data.sessions || [];
if (sessions.length > 0) {
dismissHint('sessions-helper');
try { window.localStorage.setItem('ic_sessions_helper_dismissed', '1'); } catch (_) {}
}
if (self._activeSession) {
return Promise.all([
api('/api/sessions/' + encodeURIComponent(self._activeSession.id) + '/messages').catch(function() { return { messages: [] }; }),
api('/api/sessions/' + encodeURIComponent(self._activeSession.id) + '/turns').catch(function() { return { turns: [] }; }),
api('/api/sessions/' + encodeURIComponent(self._activeSession.id) + '/feedback').catch(function() { return { feedback: [] }; })
]).then(function(results) {
var r = results[0], turnsData = results[1], fbData = results[2];
self._sessionMessages = r.messages || [];
var turns = turnsData.turns || [];
var feedbackByTurn = {};
(fbData.feedback || []).forEach(function(fb) { feedbackByTurn[fb.turn_id] = fb; });
var asstCount = 0;
var msgs = self._sessionMessages.map(function(m, idx) {
var side = m.role === 'user' ? 'user' : 'assistant';
var roleLabel = side === 'assistant'
? sessionAssistantLabel(self._activeSession, m.role || 'assistant')
: (m.role || 'user');
var expandBtn = side === 'assistant' ? '<button class="ctx-expand-btn" data-msg-idx="' + idx + '" data-session-id="' + esc(self._activeSession.id) + '">View context</button>' : '';
var gradeHtml = '';
if (side === 'assistant') {
var turnForMsg = turns[asstCount] || null;
asstCount++;
if (turnForMsg) {
var existingFb = feedbackByTurn[turnForMsg.id];
var existingGrade = existingFb ? existingFb.grade : 0;
gradeHtml = '<div class="grade-stars" data-turn-id="' + esc(turnForMsg.id) + '">';
for (var g = 1; g <= 5; g++) {
gradeHtml += '<span class="star' + (g <= existingGrade ? ' filled' : '') + '" data-grade="' + g + '">\u2605</span>';
}
gradeHtml += '<button class="grade-comment-toggle" data-turn-id="' + esc(turnForMsg.id) + '">comment</button>';
gradeHtml += '</div>';
if (existingFb && existingFb.comment) {
gradeHtml += '<div style="font-size:0.625rem;color:var(--muted);margin-top:0.125rem">\u201c' + esc(existingFb.comment) + '\u201d</div>';
}
}
}
return '<div class="message ' + side + '" id="msg-' + idx + '"><div class="message-role">' + esc(roleLabel) + expandBtn + '</div><div>' + renderSafeMarkdown(m.content || '') + '</div>' + gradeHtml + '<div class="ctx-detail" id="ctx-detail-' + idx + '" style="display:none"></div></div>';
}).join('') || '<p style="color:var(--muted);padding:1rem">Send a message to begin the conversation.</p>';
var chatLabel = self._activeSession.nickname || truncate(self._activeSession.id, 16);
var chatAgentLabel = sessionAssistantLabel(self._activeSession, self._activeSession.agent_id || 'default');
return '<div class="session-chat-wrap"><div class="session-chat-header"><button class="btn secondary" style="font-size:0.75rem;padding:0.3rem 0.75rem" id="btn-back-sessions">' + uiBtnLabel('back', 'Back') + '</button><span style="font-weight:600">' + esc(chatAgentLabel) + '</span><span class="session-nick" title="' + esc(self._activeSession.id) + '" style="color:var(--muted);font-size:0.75rem">' + esc(chatLabel) + '</span>' + copyIdBtn(self._activeSession.id) + '</div><div class="message-thread">' + msgs + '</div><div class="session-chat-input"><input type="text" id="session-msg-input" placeholder="Type a message\u2026" autocomplete="off"><button class="btn" id="btn-send-msg">' + uiBtnLabel('send', 'Send') + '</button></div></div>';
});
}
var PAGE_SIZE = 25;
var page = self._sessionsPage || 0;
var totalPages = Math.ceil(sessions.length / PAGE_SIZE) || 1;
if (page >= totalPages) page = totalPages - 1;
var paged = sessions.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
var rows = paged.map(function(s) {
var label = s.nickname || truncate(s.id, 16);
var st = s.status || 'active';
var stCls = st === 'active' ? 'success' : (st === 'closed' ? '' : 'warning');
return '<tr data-id="' + esc(s.id || '') + '" style="cursor:pointer" title="' + esc(s.id || '') + '"><td><span class="session-nick">' + esc(label) + '</span>' + copyIdBtn(s.id || '') + '</td><td>' + esc(s.agent_id || '') + '</td><td><span class="badge ' + stCls + '" style="font-size:0.5625rem">' + esc(st) + '</span></td><td>' + esc(s.created_at || '') + '</td><td>' + esc(s.updated_at || '') + '</td><td style="display:flex;gap:0.25rem"><button class="btn secondary" style="font-size:0.65rem;padding:0.2rem 0.5rem">Open</button><button class="btn secondary danger session-delete-btn" data-delete-session="' + esc(s.id || '') + '" style="font-size:0.65rem;padding:0.2rem 0.5rem" title="Delete">\u2715</button></td></tr>';
}).join('');
var emptyHint = sessions.length === 0 ? '<div class="card" style="margin-top:1rem;color:var(--muted)">No sessions yet. Click <strong>New session</strong> to start one.</div>' : '';
var pager = totalPages > 1 ? '<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.75rem;justify-content:center"><button class="btn secondary" id="sess-prev" style="font-size:0.75rem;padding:0.2rem 0.6rem"' + (page === 0 ? ' disabled' : '') + '>\u2190 Prev</button><span style="font-size:0.75rem;color:var(--muted)">Page ' + (page + 1) + ' of ' + totalPages + '</span><button class="btn secondary" id="sess-next" style="font-size:0.75rem;padding:0.2rem 0.6rem"' + (page >= totalPages - 1 ? ' disabled' : '') + '>Next \u2192</button></div>' : '';
return '<div style="display:flex;gap:0.75rem;align-items:center"><button class="btn" id="btn-new-session">' + uiBtnLabel('plus', 'New session') + '</button><span style="font-size:0.8125rem;color:var(--muted)">' + sessions.length + ' session' + (sessions.length !== 1 ? 's' : '') + '</span></div><div class="table-wrap" style="margin-top:1rem;max-height:calc(100vh - 56px - 8rem);overflow-y:auto"><table><thead><tr><th>Topic</th><th>Agent</th><th>Status</th><th>Created</th><th>Updated</th><th>Action</th></tr></thead><tbody>' + rows + '</tbody></table></div>' + pager + emptyHint;
});
},
_memoryCategory: '',
_memoryFilter: '',
_memoryPage: 0,
renderMemory: function() {
var tab = this._memoryTab;
var sessionId = this._memorySessionId || '';
var cat = this._memoryCategory || '';
var promises;
if (tab === 'search') {
promises = Promise.resolve({ entries: [], _mode: 'search' });
} else if (tab === 'working') {
promises = api('/api/memory/working' + (sessionId ? '/' + encodeURIComponent(sessionId) : ''))
.catch(function() { return { entries: [] }; })
.then(function(d) { d._mode = 'working'; return d; });
} else if (tab === 'semantic') {
var catPromise = api('/api/memory/semantic/categories').catch(function() { return { categories: [] }; });
var entriesUrl = cat ? '/api/memory/semantic/' + encodeURIComponent(cat) : '/api/memory/semantic?limit=100';
var dataPromise = api(entriesUrl).catch(function() { return { entries: [] }; });
promises = Promise.all([catPromise, dataPromise]).then(function(r) {
return { categories: r[0].categories || [], entries: r[1].entries || [], _mode: 'semantic' };
});
} else {
promises = api('/api/memory/episodic').catch(function() { return { entries: [] }; })
.then(function(d) { d._mode = 'episodic'; return d; });
}
var self = this;
return promises.then(function(data) {
var allEntries = data.entries || data.results || [];
var mode = data._mode || tab;
var MEM_PAGE_SIZE = 25;
var filterQ = (self._memoryFilter || '').toLowerCase();
var entries = filterQ ? allEntries.filter(function(e) { var txt = (e.content || e.value || e.key || '').toLowerCase(); return txt.indexOf(filterQ) >= 0; }) : allEntries;
var memPage = self._memoryPage || 0;
var memTotalPages = Math.ceil(entries.length / MEM_PAGE_SIZE) || 1;
if (memPage >= memTotalPages) memPage = 0;
var memPaged = entries.slice(memPage * MEM_PAGE_SIZE, (memPage + 1) * MEM_PAGE_SIZE);
function memFilterBar() { return '<div style="margin-bottom:0.75rem"><input type="text" id="mem-filter-input" placeholder="Filter entries\u2026" value="' + esc(self._memoryFilter || '') + '" style="padding:0.4rem 0.75rem;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);width:100%;max-width:300px;font-family:var(--font);font-size:0.8125rem"></div>'; }
function memPager() { if (memTotalPages <= 1) return ''; return '<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.75rem;justify-content:center"><button class="btn secondary" id="mem-prev" style="font-size:0.75rem;padding:0.2rem 0.6rem"' + (memPage === 0 ? ' disabled' : '') + '>\u2190 Prev</button><span style="font-size:0.75rem;color:var(--muted)">Page ' + (memPage + 1) + ' of ' + memTotalPages + ' (' + entries.length + ' entries)</span><button class="btn secondary" id="mem-next" style="font-size:0.75rem;padding:0.2rem 0.6rem"' + (memPage >= memTotalPages - 1 ? ' disabled' : '') + '>Next \u2192</button></div>'; }
var tabButtons = '<div class="tabs">'
+ '<button class="' + (tab === 'working' ? 'active' : '') + '" data-tab="working">Working</button>'
+ '<button class="' + (tab === 'episodic' ? 'active' : '') + '" data-tab="episodic">Episodic</button>'
+ '<button class="' + (tab === 'semantic' ? 'active' : '') + '" data-tab="semantic">Semantic</button>'
+ '<button class="' + (tab === 'search' ? 'active' : '') + '" data-tab="search">Search</button>'
+ '</div>';
var filterHtml = '';
if (mode === 'semantic' && data.categories && data.categories.length > 0) {
var cats = data.categories;
var totalEntries = cats.reduce(function(s, c) { return s + c.count; }, 0);
filterHtml = '<div style="margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.4rem;align-items:center">'
+ '<button class="badge ' + (!cat ? 'active' : 'muted') + '" data-mem-cat="" style="cursor:pointer">All (' + totalEntries + ')</button>';
cats.forEach(function(c) {
filterHtml += '<button class="badge ' + (cat === c.category ? 'active' : 'muted') + '" data-mem-cat="' + esc(c.category) + '" style="cursor:pointer">' + esc(c.category) + ' (' + c.count + ')</button>';
});
filterHtml += '</div>';
}
if (mode === 'semantic') {
var list = memPaged.map(function(e) {
return '<div class="card" style="margin-bottom:0.5rem">'
+ '<div style="display:flex;justify-content:space-between;align-items:baseline">'
+ '<strong style="color:var(--text);font-size:0.875rem">' + esc(e.key || '') + '</strong>'
+ '<span class="badge muted" style="font-size:0.7rem">' + esc(e.category || '') + '</span>'
+ '</div>'
+ '<div class="card-mono" style="margin-top:4px;font-size:0.8125rem">' + esc(e.value || e.content || '') + '</div>'
+ '<div style="margin-top:6px"><span class="badge">conf: ' + esc(String(e.confidence != null ? e.confidence : '—')) + '</span></div>'
+ '</div>';
}).join('') || '<p style="color:var(--muted)">No entries' + (cat ? ' in "' + esc(cat) + '"' : '') + '. Try a different category filter.</p>';
return tabButtons + filterHtml + memFilterBar() + '<div id="memory-list">' + list + '</div>' + memPager();
}
if (mode === 'working') {
var sessionIds = [];
entries.forEach(function(e) { if (e.session_id && sessionIds.indexOf(e.session_id) === -1) sessionIds.push(e.session_id); });
if (sessionIds.length > 1 || (sessionIds.length === 1 && !sessionId)) {
filterHtml = '<div style="margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.4rem;align-items:center">'
+ '<button class="badge ' + (!sessionId ? 'active' : 'muted') + '" data-mem-session="" style="cursor:pointer">All sessions</button>';
sessionIds.forEach(function(sid) {
var short = sid.substring(0, 8);
filterHtml += '<button class="badge ' + (sessionId === sid ? 'active' : 'muted') + '" data-mem-session="' + esc(sid) + '" style="cursor:pointer">' + esc(short) + '\u2026</button>';
});
filterHtml += '</div>';
}
var list = memPaged.map(function(e) {
return '<div class="card" style="margin-bottom:0.5rem">'
+ '<div class="card-mono">' + esc(e.content || '') + '</div>'
+ '<div style="margin-top:6px">'
+ '<span class="badge muted">' + esc(e.entry_type || '—') + '</span> '
+ '<span class="badge">imp: ' + esc(String(e.importance != null ? e.importance : '—')) + '</span> '
+ '<span style="font-size:0.7rem;color:var(--muted)">' + esc((e.session_id || '').substring(0, 8)) + '</span>'
+ '</div></div>';
}).join('') || '<p style="color:var(--muted)">No working memory entries. Open a session and send a message to populate this view.</p>';
return tabButtons + filterHtml + memFilterBar() + '<div id="memory-list">' + list + '</div>' + memPager();
}
if (mode === 'search') {
var searchHtml = '<div style="margin-bottom:1rem"><input type="text" id="memory-search-q" placeholder="Search across all memory\u2026" style="padding:0.5rem 1rem;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);width:100%;max-width:400px;font-family:var(--font)"><button class="btn" style="margin-left:8px" id="memory-search-btn">' + uiBtnLabel('search', 'Search') + '</button></div>';
return tabButtons + searchHtml + '<div id="memory-search-results"><p style="color:var(--muted)">Enter a query and click Search (example: <code>token budget</code>).</p></div>';
}
var list = memPaged.map(function(e) {
var type = e.entry_type || e.classification || e.category || '\u2014';
var imp = e.importance != null ? e.importance : (e.confidence != null ? e.confidence : '\u2014');
var content = e.content || e.value || JSON.stringify(e);
return '<div class="card" style="margin-bottom:0.5rem"><div class="card-mono">' + esc(content) + '</div><div style="margin-top:6px"><span class="badge muted">' + esc(type) + '</span> <span class="badge">imp: ' + esc(String(imp)) + '</span></div></div>';
}).join('') || '<p style="color:var(--muted)">No entries found for this memory view yet.</p>';
return tabButtons + memFilterBar() + '<div id="memory-list">' + list + '</div>' + memPager();
});
},
renderSkills: function() {
var self = this;
var tab = self._skillsTab || 'installed';
if (tab !== 'installed' && tab !== 'catalog') tab = 'installed';
var tabs = '<div class="tabs" style="margin-bottom:1rem">'
+ '<button class="' + (tab === 'installed' ? 'active' : '') + '" data-skills-tab="installed">Installed</button>'
+ '<button class="' + (tab === 'catalog' ? 'active' : '') + '" data-skills-tab="catalog">Catalog</button>'
+ '</div>';
return Promise.all([
api('/api/skills'),
fetchWithFallback('/api/skills/catalog', { items: [] }, 'skills-catalog')
]).then(function(results) {
var data = results[0] || {};
var catalogData = (results[1] && results[1].data) ? results[1].data : { items: [] };
var skills = data.skills || [];
var catalogItems = (catalogData.items || []).filter(function(i) { return (i.source || '') === 'registry'; });
var installedSkillSet = {};
skills.forEach(function(s) {
var skillName = (s && s.name) ? String(s.name).trim().toLowerCase() : '';
if (skillName) installedSkillSet[skillName] = true;
});
var catalogHtml = '';
if (catalogItems.length > 0) {
var catRows = catalogItems.map(function(item) {
var name = item.name || item.filename || '';
var description = String(item.description || '').trim();
var summary = description || 'No description provided for this catalog entry yet.';
var metaParts = [];
if (item.kind) metaParts.push(String(item.kind));
if (item.version) metaParts.push('v' + String(item.version));
if (item.tags && item.tags.length) metaParts.push(String(item.tags.slice(0, 4).join(', ')));
var metaLine = metaParts.length ? metaParts.join(' • ') : '';
var searchText = [
name,
summary,
item.kind || '',
item.version || '',
(item.tags && item.tags.length) ? item.tags.join(' ') : ''
].join(' ').toLowerCase();
var isInstalled = !!installedSkillSet[String(name).trim().toLowerCase()];
var installedMark = isInstalled
? '<span class="badge success" style="font-size:0.62rem;padding:0.08rem 0.35rem">installed</span>'
: '';
return '<label data-catalog-row="1" data-catalog-search="' + esc(searchText) + '" style="display:flex;align-items:flex-start;gap:0.6rem;padding:0.6rem 0.65rem;border:1px solid var(--border-soft);border-radius:6px;background:rgba(255,255,255,0.01)">'
+ '<input type="checkbox" class="cat-skill-check" value="' + esc(name) + '"' + (isInstalled ? ' checked' : '') + ' data-installed="' + (isInstalled ? '1' : '0') + '">'
+ '<div style="min-width:0;flex:1">'
+ '<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap">'
+ '<span style="font-family:var(--font-mono);font-size:0.82rem;color:var(--text)">' + esc(name) + '</span>'
+ installedMark
+ '</div>'
+ '<div style="margin-top:0.18rem;font-size:0.75rem;color:var(--muted);line-height:1.35">' + esc(summary) + '</div>'
+ (metaLine ? '<div style="margin-top:0.2rem;font-size:0.68rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.04em">' + esc(metaLine) + '</div>' : '')
+ '</div>'
+ '</label>';
}).join('');
catalogHtml = ''
+ '<div class="card" style="margin-bottom:1rem">'
+ ' <div style="display:flex;justify-content:space-between;align-items:center;gap:0.75rem;flex-wrap:wrap">'
+ ' <div><div class="card-title">catalog</div><div style="font-size:0.75rem;color:var(--muted)">registry skills (' + catalogItems.length + ')</div></div>'
+ ' <div style="display:flex;gap:0.5rem;flex-wrap:wrap">'
+ ' <button class="btn secondary" id="btn-catalog-refresh">' + uiBtnLabel('refresh', 'Refresh Catalog') + '</button>'
+ ' <button class="btn secondary" id="btn-catalog-install">' + uiBtnLabel('download', 'Install selected') + '</button>'
+ ' <button class="btn secondary" id="btn-catalog-activate">' + uiBtnLabel('power', 'Activate selected') + '</button>'
+ ' <button class="btn" id="btn-catalog-install-activate">' + uiBtnLabel('spark', 'Install + Activate') + '</button>'
+ ' </div>'
+ ' </div>'
+ ' <div style="margin-top:0.75rem">'
+ ' <input type="text" id="catalog-filter-input" placeholder="Search catalog by name, description, tags..."'
+ ' style="width:100%;padding:0.5rem 0.65rem;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--font)">'
+ ' </div>'
+ ' <div style="margin-top:0.75rem;max-height:min(62vh,680px);overflow:auto;padding-right:0.25rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:0.55rem">' + catRows + '</div>'
+ ' <div id="catalog-filter-empty" style="display:none;margin-top:0.5rem;font-size:0.78rem;color:var(--muted)">No catalog skills match this filter.</div>'
+ '</div>';
} else {
catalogHtml = ''
+ '<div class="card" style="margin-bottom:1rem">'
+ ' <div style="display:flex;justify-content:space-between;align-items:center;gap:0.75rem;flex-wrap:wrap">'
+ ' <div><div class="card-title">catalog</div><div style="font-size:0.75rem;color:var(--muted)">registry skills (0)</div></div>'
+ ' <div style="display:flex;gap:0.5rem;flex-wrap:wrap">'
+ ' <button class="btn secondary" id="btn-catalog-refresh">' + uiBtnLabel('refresh', 'Refresh Catalog') + '</button>'
+ ' </div>'
+ ' </div>'
+ ' <div style="margin-top:0.75rem;font-size:0.8125rem;color:var(--muted)">No registry catalog skills available.</div>'
+ '</div>';
}
if (tab === 'catalog') {
return tabs + catalogHtml;
}
if (skills.length === 0) {
return tabs + '<div style="display:flex;align-items:center;gap:1rem"><button class="btn" id="btn-reload-skills">' + uiBtnLabel('refresh', 'Reload skills') + '</button><span style="font-size:0.8125rem;color:var(--muted)">0 / 0 enabled</span></div><div class="card" style="margin-top:1rem;color:var(--muted)">No skills registered. Add skills on disk and click Reload skills.</div>';
}
var enabledCount = skills.filter(function(s) { return s.enabled; }).length;
var sortedSkills = skills.slice().sort(function(a, b) {
var aBuiltIn = !!a.built_in || String(a.kind || '').toLowerCase() === 'builtin';
var bBuiltIn = !!b.built_in || String(b.kind || '').toLowerCase() === 'builtin';
if (aBuiltIn !== bBuiltIn) return aBuiltIn ? 1 : -1;
return String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' });
});
var cards = sortedSkills.map(function(s) {
var kindColor = s.kind === 'tool' ? 'var(--accent)' : s.kind === 'cognitive' ? 'var(--success)' : s.kind === 'multimodal' ? 'var(--warning)' : 'var(--muted)';
var actions = '';
var builtIn = !!s.built_in || String(s.kind || '').toLowerCase() === 'builtin';
var kindLabel = builtIn ? 'builtin' : (s.kind || '');
var builtInBadge = '';
var builtInNameTag = '';
var toggleDisabled = '';
var toggleTitle = '';
var toggleClass = 'toggle';
if (s.id && !builtIn) {
actions = '<div style="display:flex;justify-content:flex-end;margin-top:0.75rem"><button class="btn secondary" data-skill-delete="' + esc(s.id || '') + '" data-skill-name="' + esc(s.name || '') + '" style="border-color:var(--error);color:var(--error);font-size:0.75rem;padding:0.25rem 0.6rem">' + uiBtnLabel('trash', 'Delete') + '</button></div>';
}
var toggleHtml = builtIn
? '<span class="badge" style="background:var(--success);color:#fff;font-size:0.7rem;padding:0.15rem 0.5rem;border-radius:0.25rem">ALWAYS ON</span>'
: '<label class="' + toggleClass + '"><input type="checkbox" data-skill-toggle="' + esc(s.id || '') + '" data-skill-name="' + esc(s.name || '') + '"' + (s.enabled ? ' checked' : '') + toggleDisabled + toggleTitle + '><span class="toggle-track"></span></label>';
return '<div class="card skill-card" data-id="' + esc(s.id || '') + '"><div class="skill-header"><div class="skill-info"><div class="card-title" style="color:' + kindColor + '">' + esc(kindLabel) + '</div><div class="card-value">' + esc(s.name || '') + '</div></div>' + toggleHtml + '</div><p style="font-size:0.8125rem;color:var(--muted);margin-top:0.5rem">' + esc(s.description || '') + '</p>' + actions + '</div>';
}).join('');
return tabs + '<div style="display:flex;align-items:center;gap:1rem"><button class="btn" id="btn-reload-skills">' + uiBtnLabel('refresh', 'Reload skills') + '</button><span style="font-size:0.8125rem;color:var(--muted)">' + enabledCount + ' / ' + skills.length + ' enabled</span></div><div class="skills-grid" style="margin-top:1rem">' + cards + '</div>';
});
},
_closeSkillDeleteModal: function() {
var modal = document.getElementById('skill-delete-modal');
if (modal) modal.remove();
},
_openSkillDeleteModal: function(skillId, skillName) {
var self = this;
self._closeSkillDeleteModal();
var modal = document.createElement('div');
modal.id = 'skill-delete-modal';
modal.style.position = 'fixed';
modal.style.inset = '0';
modal.style.zIndex = '1000';
modal.style.background = 'rgba(0,0,0,0.65)';
modal.style.display = 'flex';
modal.style.alignItems = 'center';
modal.style.justifyContent = 'center';
modal.style.padding = '1.5rem';
var html = ''
+ '<div style="width:min(520px, 100%);background:var(--bg);border:1px solid var(--border);border-radius:8px;box-shadow:0 20px 40px rgba(0,0,0,0.45)">'
+ ' <div style="padding:1rem 1.25rem;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:1rem">'
+ ' <div style="font-weight:700">Delete Skill</div>'
+ ' <button class="btn secondary" data-skill-delete-cancel style="padding:0.25rem 0.6rem">Close</button>'
+ ' </div>'
+ ' <div style="padding:1rem 1.25rem;color:var(--muted);font-size:0.9rem;line-height:1.45">'
+ ' This action permanently removes the skill record from the runtime database.'
+ ' </div>'
+ ' <div style="padding:0 1.25rem 1rem 1.25rem">'
+ ' <div style="margin-bottom:0.45rem;font-size:0.75rem;text-transform:uppercase;color:var(--muted);letter-spacing:0.05em">Confirm skill name</div>'
+ ' <div style="margin-bottom:0.6rem;font-family:var(--font-mono);font-size:0.9rem;color:var(--text)">' + esc(skillName) + '</div>'
+ ' <input id="skill-delete-confirm-input" type="text" placeholder="Type exact skill name"'
+ ' style="width:100%;padding:0.55rem 0.7rem;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--font-mono)">'
+ ' <div id="skill-delete-confirm-hint" style="margin-top:0.5rem;font-size:0.75rem;color:var(--muted)">Type the exact name to enable deletion.</div>'
+ ' </div>'
+ ' <div style="padding:0.9rem 1.25rem;border-top:1px solid var(--border);display:flex;gap:0.6rem;justify-content:flex-end">'
+ ' <button class="btn secondary" data-skill-delete-cancel>Cancel</button>'
+ ' <button class="btn" id="skill-delete-confirm-btn" disabled style="border-color:var(--error);color:var(--error);opacity:0.45;pointer-events:none">Delete Skill</button>'
+ ' </div>'
+ '</div>';
setHtml(modal, html);
document.body.appendChild(modal);
var input = document.getElementById('skill-delete-confirm-input');
var btn = document.getElementById('skill-delete-confirm-btn');
var hint = document.getElementById('skill-delete-confirm-hint');
if (input) input.focus();
function syncState() {
if (!input || !btn) return;
var matches = input.value === skillName;
btn.disabled = !matches;
btn.style.opacity = matches ? '' : '0.45';
btn.style.pointerEvents = matches ? '' : 'none';
if (hint) {
hint.style.color = matches ? 'var(--success)' : 'var(--muted)';
hint.textContent = matches ? 'Name matches. You can delete this skill.' : 'Type the exact name to enable deletion.';
}
}
if (input) {
input.addEventListener('input', syncState);
input.addEventListener('keydown', function(ev) {
if (ev.key === 'Enter' && btn && !btn.disabled) btn.click();
});
}
modal.addEventListener('click', function(ev) {
if (ev.target === modal || ev.target.closest('[data-skill-delete-cancel]')) {
self._closeSkillDeleteModal();
return;
}
if (ev.target.closest('#skill-delete-confirm-btn')) {
api('/api/skills/' + encodeURIComponent(skillId), { method: 'DELETE' })
.then(function(resp) {
self._closeSkillDeleteModal();
toast('Deleted skill: ' + (resp.name || skillName));
App.navigate('skills');
})
.catch(function(err) {
toast(err.message || 'Failed to delete skill');
});
}
});
},
_calMonth: new Date().getMonth(),
_calYear: new Date().getFullYear(),
_calSelected: null,
_calEditJob: null,
_calModalMode: null,
_JOB_COLORS: ['#6366f1','#22c55e','#06b6d4','#f59e0b','#ec4899','#8b5cf6','#ef4444','#14b8a6','#f97316','#a855f7'],
_colorForJob: function(jobId) {
if (!jobId) return this._JOB_COLORS[0];
var h = 0;
for (var i = 0; i < jobId.length; i++) h = ((h << 5) - h) + jobId.charCodeAt(i);
return this._JOB_COLORS[Math.abs(h) % this._JOB_COLORS.length];
},
_parseCronToSched: function(kind, expr) {
var s = { freq: 'daily', interval: 5, intervalUnit: 'minutes', hour: '02', minute: '00', days: [1,2,3,4,5,6,0] };
if (kind === 'interval' || kind === 'every') {
var m = expr.match(/^(\d+)(s|m|h)$/);
if (m) { s.freq = 'interval'; s.interval = parseInt(m[1]); s.intervalUnit = m[2] === 's' ? 'seconds' : m[2] === 'h' ? 'hours' : 'minutes'; }
return s;
}
var parts = (expr || '* * * * *').split(/\s+/);
var min = parts[0] || '*', hr = parts[1] || '*';
if (min.indexOf('/') !== -1) { s.freq = 'interval'; s.intervalUnit = 'minutes'; s.interval = parseInt(min.split('/')[1]) || 5; }
else if (hr.indexOf('/') !== -1) { s.freq = 'interval'; s.intervalUnit = 'hours'; s.interval = parseInt(hr.split('/')[1]) || 1; }
else if (hr !== '*' && min !== '*') { s.hour = String(parseInt(hr)).padStart(2, '0'); s.minute = String(parseInt(min)).padStart(2, '0'); var dow = parts[4] || '*'; if (dow === '*') { s.freq = 'daily'; s.days = [0,1,2,3,4,5,6]; } else { s.freq = 'weekly'; s.days = dow.split(',').map(function(d) { return parseInt(d); }); } }
else if (min !== '*') { s.freq = 'hourly'; s.minute = String(parseInt(min)).padStart(2, '0'); }
else { s.freq = 'interval'; s.interval = 1; s.intervalUnit = 'minutes'; }
return s;
},
_schedToCron: function(s) {
if (s.freq === 'interval') { if (s.intervalUnit === 'seconds') return { kind: 'interval', expr: s.interval + 's' }; if (s.intervalUnit === 'hours') return { kind: 'cron', expr: '0 */' + s.interval + ' * * *' }; return { kind: 'cron', expr: '*/' + s.interval + ' * * * *' }; }
if (s.freq === 'hourly') return { kind: 'cron', expr: s.minute + ' * * * *' };
if (s.freq === 'weekly') return { kind: 'cron', expr: s.minute + ' ' + s.hour + ' * * ' + s.days.join(',') };
return { kind: 'cron', expr: s.minute + ' ' + s.hour + ' * * *' };
},
_readSchedFromUI: function() {
var freqEl = document.getElementById('cal-sched-freq'); var freq = freqEl ? freqEl.value : 'daily';
var s = { freq: freq, interval: 5, intervalUnit: 'minutes', hour: '02', minute: '00', days: [0,1,2,3,4,5,6] };
if (freq === 'interval') { var intEl = document.getElementById('cal-sched-interval'); var unitEl = document.getElementById('cal-sched-interval-unit'); s.interval = intEl ? parseInt(intEl.value) || 1 : 5; s.intervalUnit = unitEl ? unitEl.value : 'minutes'; }
else if (freq === 'hourly') { var minEl = document.getElementById('cal-sched-minute'); s.minute = String(minEl ? parseInt(minEl.value) || 0 : 0).padStart(2, '0'); }
else { var hrEl = document.getElementById('cal-sched-hour'); var minEl = document.getElementById('cal-sched-minute'); s.hour = String(hrEl ? parseInt(hrEl.value) || 0 : 0).padStart(2, '0'); s.minute = String(minEl ? parseInt(minEl.value) || 0 : 0).padStart(2, '0'); if (freq === 'weekly') { s.days = []; document.querySelectorAll('.cal-day-btn.active').forEach(function(b) { s.days.push(parseInt(b.getAttribute('data-cal-day'))); }); if (s.days.length === 0) s.days = [1]; } }
return s;
},
_schedSummary: function(s) {
if (s.freq === 'interval') return 'Every ' + s.interval + ' ' + s.intervalUnit;
if (s.freq === 'hourly') return 'Every hour at :' + s.minute;
var dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; var time = s.hour + ':' + s.minute;
if (s.freq === 'weekly') return 'Weekly on ' + s.days.map(function(i) { return dayNames[i]; }).join(', ') + ' at ' + time;
return 'Daily at ' + time;
},
_cronIntentFromJob: function(job) {
if (!job || !job.payload_json) return '';
try {
var payload = JSON.parse(job.payload_json);
return String(payload.task || payload.prompt || payload.message || '').trim();
} catch (_) {
return '';
}
},
_projectCronDaysInMonth: function(job, year, month) {
var days = [];
if (!job || !job.schedule_kind || !job.schedule_expr) return days;
var maxDay = new Date(year, month + 1, 0).getDate();
var sched = this._parseCronToSched(job.schedule_kind, job.schedule_expr);
// Interval/hourly jobs are frequent: show as "scheduled" every day.
if (sched.freq === 'interval' || sched.freq === 'hourly') {
for (var d = 1; d <= maxDay; d++) {
days.push(year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'));
}
return days;
}
if (sched.freq === 'daily') {
for (var d2 = 1; d2 <= maxDay; d2++) {
days.push(year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d2).padStart(2, '0'));
}
return days;
}
if (sched.freq === 'weekly') {
var allowed = {};
(sched.days || []).forEach(function(x) { allowed[String(x)] = true; });
for (var d3 = 1; d3 <= maxDay; d3++) {
var jsDay = new Date(year, month, d3).getDay(); // 0=Sun
if (allowed[String(jsDay)]) {
days.push(year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d3).padStart(2, '0'));
}
}
}
return days;
},
renderScheduler: function() {
var self = this;
var year = self._calYear, month = self._calMonth;
var monthStart = year + '-' + String(month + 1).padStart(2, '0') + '-01 00:00:00';
var monthEnd = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(new Date(year, month + 1, 0).getDate()).padStart(2, '0') + ' 23:59:59';
return Promise.all([
api('/api/cron/jobs'),
api('/api/cron/runs?from=' + encodeURIComponent(monthStart) + '&to=' + encodeURIComponent(monthEnd) + '&limit=5000').catch(function() { return { runs: [] }; })
]).then(function(arr) {
var jobs = (arr[0] && arr[0].jobs) || [];
var runs = (arr[1] && arr[1].runs) || [];
jobs.forEach(function(j) { j.color = self._colorForJob(j.id || j.name || 'job'); });
_cachedCronJobs = jobs;
var now = new Date();
var todayKey = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
var months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
var dows = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
var firstDay = new Date(year, month, 1);
var startDow = (firstDay.getDay() + 6) % 7;
var daysInMonth = new Date(year, month + 1, 0).getDate();
var prevDays = new Date(year, month, 0).getDate();
var today = now.getDate();
var cronRuns = {};
runs.forEach(function(r) {
var day = r.day || (r.created_at ? r.created_at.split(' ')[0] : '');
if (!day) return;
if (!cronRuns[day]) cronRuns[day] = [];
cronRuns[day].push({
job: r.job_name,
job_id: r.job_id,
status: (r.status || 'unknown').toLowerCase()
});
});
Object.keys(cronRuns).forEach(function(day) {
var grouped = {};
cronRuns[day].forEach(function(r) {
var key = r.job_id || r.job;
if (!grouped[key]) grouped[key] = { job: r.job, job_id: r.job_id, count: 0, success_count: 0, error_count: 0, status: 'success', scheduled_only: false };
grouped[key].count += 1;
if (r.status === 'error') grouped[key].error_count += 1;
else grouped[key].success_count += 1;
});
Object.keys(grouped).forEach(function(k) {
var g = grouped[k];
if (g.error_count > 0 && g.success_count > 0) g.status = 'mixed';
else if (g.error_count > 0) g.status = 'error';
else g.status = 'success';
});
cronRuns[day] = Object.keys(grouped).map(function(k) { return grouped[k]; });
});
// Project scheduled occurrences into the month grid so newly created jobs
// are visible before first execution.
jobs.forEach(function(j) {
var projected = self._projectCronDaysInMonth(j, year, month);
projected.forEach(function(day) {
if (!cronRuns[day]) cronRuns[day] = [];
var exists = cronRuns[day].some(function(r) {
return (r.job_id && j.id && r.job_id === j.id) || r.job === j.name;
});
if (!exists) {
cronRuns[day].push({
job: j.name,
job_id: j.id,
count: 0,
success_count: 0,
error_count: 0,
status: 'scheduled',
scheduled_only: true
});
}
});
});
var jobMap = {}; jobs.forEach(function(j) { jobMap[j.name] = j; if (j.id) jobMap[j.id] = j; });
var calCells = '';
var totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7;
for (var i = 0; i < totalCells; i++) {
var dayNum, isOther = false, dateKey;
if (i < startDow) { dayNum = prevDays - startDow + i + 1; isOther = true; var pm = month === 0 ? 12 : month; var py = month === 0 ? year - 1 : year; dateKey = py + '-' + String(pm).padStart(2, '0') + '-' + String(dayNum).padStart(2, '0'); }
else if (i >= startDow + daysInMonth) { dayNum = i - startDow - daysInMonth + 1; isOther = true; var nm = month + 2 > 12 ? 1 : month + 2; var ny = month + 2 > 12 ? year + 1 : year; dateKey = ny + '-' + String(nm).padStart(2, '0') + '-' + String(dayNum).padStart(2, '0'); }
else { dayNum = i - startDow + 1; dateKey = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(dayNum).padStart(2, '0'); }
var isToday = dateKey === todayKey; var isSelected = dateKey === self._calSelected;
var cls = 'cal-day' + (isOther ? ' other-month' : '') + (isToday ? ' today' : '') + (isSelected ? ' selected' : '');
var dayRuns = cronRuns[dateKey] || [];
var isPast = dateKey < todayKey;
var evtHtml = '';
if (dayRuns.length > 0) {
evtHtml = '<div class="cal-events">';
dayRuns.forEach(function(r) { var job = jobMap[r.job_id || r.job]; var color = job ? job.color : '#71717a'; var failCls = r.status === 'error' ? ' fail' : ''; var scheduledCls = r.status === 'scheduled' ? ' past' : ''; var pastCls = isPast ? ' past' : ''; var pastAttr = isPast ? ' data-past="true"' : ''; var statusText = r.status === 'scheduled' ? 'scheduled' : r.status; evtHtml += '<div class="cal-evt' + failCls + scheduledCls + pastCls + '" style="background:' + color + '" title="' + esc(r.job) + ' (' + r.count + ' runs, status: ' + statusText + ')' + '"' + pastAttr + ' data-evt-job="' + esc(r.job) + '">' + esc(r.job) + '</div>'; });
evtHtml += '</div>';
}
calCells += '<div class="' + cls + '" data-date="' + dateKey + '"><div class="cal-day-num">' + dayNum + '</div>' + evtHtml + '</div>';
}
var dowHeaders = dows.map(function(d) { return '<div class="cal-dow">' + d + '</div>'; }).join('');
var legend = jobs.map(function(j, idx) {
var ls = (j.last_status || '').toLowerCase(); var stBadge = ls === 'success' ? '<span class="badge success" style="font-size:0.5625rem">ok</span>' : (ls === 'error' ? '<span class="badge error" style="font-size:0.5625rem">err</span>' : '<span class="badge" style="font-size:0.5625rem;background:rgba(113,113,122,0.2);color:#a1a1aa">idle</span>');
return '<div class="cal-legend-item"><div class="cal-legend-dot" style="background:' + j.color + '"></div><div class="cal-legend-info"><div class="cal-legend-name">' + esc(j.name) + '</div><div class="cal-legend-sched">' + esc(j.schedule_expr && j.schedule_expr.toLowerCase().indexOf((j.schedule_kind || '').toLowerCase()) === 0 ? j.schedule_expr : ((j.schedule_kind || '') + ' ' + (j.schedule_expr || '')).trim()) + '</div></div><div class="cal-legend-actions">' + stBadge + '<button class="cal-legend-btn" data-cal-edit="' + idx + '" title="Edit">\u270e</button><button class="cal-legend-btn danger" data-cal-delete="' + idx + '" title="Delete">\u2715</button></div></div>';
}).join('');
var detail = '';
if (self._calSelected) {
var selIsPast = self._calSelected < todayKey;
var selRuns = cronRuns[self._calSelected] || [];
if (selRuns.length > 0) {
var histLabel = selIsPast ? '<div style="font-size:0.625rem;color:var(--muted);margin-bottom:0.375rem;text-transform:uppercase;letter-spacing:0.06em">Historical runs</div>' : '';
var runItems = selRuns.map(function(r) { var job = jobMap[r.job_id || r.job]; var color = job ? job.color : '#71717a'; var stCls = r.status === 'error' ? 'error' : (r.status === 'scheduled' ? 'muted' : 'success'); var countLabel = r.status === 'scheduled' ? 'planned' : (r.count + 'x'); return '<div class="cal-detail-run"><div class="cal-detail-dot" style="background:' + color + '"></div><span style="flex:1">' + esc(r.job) + '</span><span class="card-mono" style="color:var(--muted)">' + countLabel + '</span><span class="badge ' + stCls + '" style="font-size:0.5625rem">' + r.status + '</span></div>'; }).join('');
detail = '<div class="cal-detail"><div class="cal-detail-title">' + esc(self._calSelected) + '</div>' + histLabel + runItems + '</div>';
} else { detail = '<div class="cal-detail"><div class="cal-detail-title">' + esc(self._calSelected) + '</div><div style="color:var(--muted);font-size:0.75rem">No runs on this day</div></div>'; }
}
var modal = '';
if (self._calModalMode) {
var job = self._calEditJob || { name: '', description: '', schedule_kind: 'cron', schedule_expr: '' };
var titleText = self._calModalMode === 'add' ? 'New Scheduled Job' : 'Edit Job';
var sched = self._parseCronToSched(job.schedule_kind, job.schedule_expr);
var freqOpts = [['interval','Repeating'],['hourly','Hourly'],['daily','Daily'],['weekly','Weekly']];
var freqSelect = '<select class="cal-modal-select" id="cal-sched-freq">' + freqOpts.map(function(f) { return '<option value="' + f[0] + '"' + (sched.freq === f[0] ? ' selected' : '') + '>' + f[1] + '</option>'; }).join('') + '</select>';
var intervalRow = '';
if (sched.freq === 'interval') { var unitOpts = [['seconds','Seconds'],['minutes','Minutes'],['hours','Hours']]; intervalRow = '<div class="cal-sched-row"><span class="cal-sched-inline">Every</span><input class="cal-sched-input-sm" type="number" id="cal-sched-interval" value="' + sched.interval + '" min="1" max="999"><select class="cal-modal-select" id="cal-sched-interval-unit" style="min-width:80px">' + unitOpts.map(function(u) { return '<option value="' + u[0] + '"' + (sched.intervalUnit === u[0] ? ' selected' : '') + '>' + u[1] + '</option>'; }).join('') + '</select></div>'; }
var timeRow = '';
if (sched.freq === 'hourly') { timeRow = '<div class="cal-sched-row"><span class="cal-sched-inline">At minute</span><input class="cal-sched-input-sm" type="number" id="cal-sched-minute" value="' + parseInt(sched.minute) + '" min="0" max="59"><span class="cal-sched-inline">of every hour</span></div>'; }
else if (sched.freq === 'daily' || sched.freq === 'weekly') { timeRow = '<div class="cal-sched-row"><span class="cal-sched-inline">At</span><input class="cal-sched-input-sm" type="number" id="cal-sched-hour" value="' + parseInt(sched.hour) + '" min="0" max="23"><span class="cal-sched-inline">:</span><input class="cal-sched-input-sm" type="number" id="cal-sched-minute" value="' + parseInt(sched.minute) + '" min="0" max="59"></div>'; }
var daysRow = '';
if (sched.freq === 'weekly') { var dayLabels = ['Su','Mo','Tu','We','Th','Fr','Sa']; daysRow = '<div class="cal-modal-row"><label class="cal-modal-label">Days</label><div class="cal-days-row">' + dayLabels.map(function(d, i) { return '<button class="cal-day-btn' + (sched.days.indexOf(i) !== -1 ? ' active' : '') + '" data-cal-day="' + i + '">' + d + '</button>'; }).join('') + '</div></div>'; }
var summary = self._schedSummary(sched);
var editNotice = '';
if (self._calModalMode === 'edit') { editNotice = '<div class="cal-edit-info"><strong>Note:</strong> Changes to the schedule will only apply to <strong>future</strong> executions. Historical run records are preserved as-is and cannot be modified.</div>'; }
modal = '<div class="cal-modal-overlay" data-cal-overlay><div class="cal-modal"><div class="cal-modal-header"><h3>' + titleText + '</h3><button class="cal-modal-close" data-cal-modal-close>\u00d7</button></div><div class="cal-modal-body">' + editNotice + '<div class="cal-modal-row"><label class="cal-modal-label">Name</label><input class="cal-modal-input" id="cal-job-name" value="' + esc(job.name) + '" placeholder="e.g. health-check"></div><div class="cal-modal-row"><label class="cal-modal-label">Intent</label><input class="cal-modal-input" id="cal-job-description" value="' + esc(job.description || '') + '" placeholder="e.g. summarize overnight events and pending tasks"></div><div class="cal-modal-row"><label class="cal-modal-label">Recurrence</label>' + freqSelect + '</div>' + intervalRow + timeRow + daysRow + '<div class="cal-sched-summary" id="cal-sched-summary">' + esc(summary) + '</div></div><div class="cal-modal-footer"><button class="btn secondary" data-cal-modal-close>Cancel</button><button class="btn" id="cal-modal-save">' + (self._calModalMode === 'add' ? 'Add Job' : 'Save') + '</button></div></div></div>';
}
return '<div class="cal-wrap"><div class="cal-header"><div class="cal-header-left"><div class="cal-title">' + months[month] + ' ' + year + '</div><div class="cal-nav"><button data-cal-nav="prev">\u25c0</button><button data-cal-nav="today">\u25cf</button><button data-cal-nav="next">\u25b6</button></div></div><div class="cal-header-right"><button class="btn" style="font-size:0.75rem;padding:0.3rem 0.75rem" data-cal-add>+ Add Job</button></div></div><div class="cal-body"><div class="cal-grid-wrap"><div class="cal-dow-row">' + dowHeaders + '</div><div class="cal-grid">' + calCells + '</div></div><div class="cal-sidebar"><div class="cal-sidebar-section"><div class="cal-sidebar-title">Scheduled Jobs (' + jobs.length + ')</div>' + legend + '</div>' + (detail ? '<div class="cal-sidebar-section">' + detail + '</div>' : '') + '</div></div>' + modal + '</div>';
});
},
renderMetrics: function() {
return Promise.all([
api('/api/stats/costs'),
api('/api/stats/transactions?hours=24'),
api('/api/stats/capacity').catch(function() { return { providers: {} }; }),
api('/api/models/selections?limit=50').catch(function() { return { events: [] }; })
]).then(function(arr) {
stackedId = 0;
var costs = arr[0].costs || []; var txs = arr[1].transactions || []; var capacity = arr[2].providers || {};
var modelSelections = arr[3].events || [];
var providerCosts = {}; var providerTokens = {};
PROVIDERS.forEach(function(p) { providerCosts[p] = []; providerTokens[p] = []; });
var now = new Date(); var xLabels = [];
for (var i = 0; i < 24; i++) { var h = (now.getHours() - 23 + i + 24) % 24; xLabels.push(String(h).padStart(2, '0') + ':00'); }
PROVIDERS.forEach(function(p) { for (var i = 0; i < 24; i++) { providerCosts[p].push(0); providerTokens[p].push(0); } });
costs.forEach(function(c) {
var p = (c.provider || '').toLowerCase();
if (PROVIDERS.indexOf(p) === -1) p = 'anthropic';
var costVal = Number(c.cost) || 0;
var tokVal = (Number(c.tokens_in) || 0) + (Number(c.tokens_out) || 0);
var bucket = 0;
if (c.created_at) {
var d = new Date(c.created_at);
if (!isNaN(d.getTime())) {
var hoursAgo = (now.getTime() - d.getTime()) / 3600000;
bucket = Math.max(0, Math.min(23, 23 - Math.floor(hoursAgo)));
}
}
providerCosts[p][bucket] += costVal;
providerTokens[p][bucket] += tokVal;
});
var providerTotals = {}; var providerTotalTokens = {};
PROVIDERS.forEach(function(p) {
providerTotals[p] = providerCosts[p].reduce(function(a, b) { return a + b; }, 0);
providerTotalTokens[p] = providerTokens[p].reduce(function(a, b) { return a + b; }, 0);
});
var totalCost = PROVIDERS.reduce(function(s, p) { return s + providerTotals[p]; }, 0);
var totalTokens = PROVIDERS.reduce(function(s, p) { return s + providerTotalTokens[p]; }, 0);
var costChart = '<div class="card" style="margin-bottom:1rem;padding-bottom:0.75rem"><div class="card-title">Cost by Provider (24h)</div><div style="display:flex;align-items:baseline;gap:0.75rem;margin-bottom:0.25rem"><div class="card-value">$' + totalCost.toFixed(2) + '</div><span style="font-size:0.75rem;color:var(--muted)">' + costs.length + ' requests</span></div>' + renderStackedArea(providerCosts, PROVIDERS, PROVIDER_COLORS, { height: 180, yAxis: true, xLabels: xLabels, yFormat: function(v) { return '$' + v.toFixed(3); } }) + '<div class="metrics-legend">';
PROVIDERS.forEach(function(p) { var pct = totalCost > 0 ? (providerTotals[p] / totalCost * 100).toFixed(0) : '0'; costChart += '<div class="metrics-legend-item"><div class="metrics-legend-dot" style="background:' + PROVIDER_COLORS[p] + '"></div>' + p + '<span class="metrics-legend-val">$' + providerTotals[p].toFixed(3) + ' (' + pct + '%)</span></div>'; });
costChart += '</div></div>';
var tokenChart = '<div class="card" style="margin-bottom:1rem;padding-bottom:0.75rem"><div class="card-title">Token Volume by Provider (24h)</div><div style="display:flex;align-items:baseline;gap:0.75rem;margin-bottom:0.25rem"><div class="card-value">' + Math.round(totalTokens).toLocaleString() + '</div><span style="font-size:0.75rem;color:var(--muted)">tokens total</span></div>' + renderStackedArea(providerTokens, PROVIDERS, PROVIDER_COLORS, { height: 140, yAxis: true, xLabels: xLabels, yFormat: function(v) { return v >= 1000 ? (v / 1000).toFixed(1) + 'k' : Math.round(v).toString(); } }) + '<div class="metrics-legend">';
PROVIDERS.forEach(function(p) { tokenChart += '<div class="metrics-legend-item"><div class="metrics-legend-dot" style="background:' + PROVIDER_COLORS[p] + '"></div>' + p + '<span class="metrics-legend-val">' + Math.round(providerTotalTokens[p]).toLocaleString() + '</span></div>'; });
tokenChart += '</div></div>';
var avgCost = costs.length ? (totalCost / costs.length).toFixed(4) : '0';
var statGrid = '<div class="metrics-summary-grid"><div class="metrics-stat"><div class="metrics-stat-label">Avg / request</div><div class="metrics-stat-value">$' + avgCost + '</div></div>';
PROVIDERS.forEach(function(p) { statGrid += '<div class="metrics-stat"><div class="metrics-stat-label">' + p + ' share</div><div class="metrics-stat-value" style="color:' + PROVIDER_COLORS[p] + '">' + (totalCost > 0 ? (providerTotals[p] / totalCost * 100).toFixed(0) : '0') + '%</div></div>'; });
statGrid += '</div>';
var txRows = txs.map(function(t) { return '<tr><td class="card-mono">' + esc(t.tx_type || '') + '</td><td>' + esc((t.amount || '') + ' ' + (t.currency || '')) + '</td><td>' + esc(t.counterparty || '') + '</td><td>' + esc(t.created_at || '') + '</td></tr>'; }).join('');
var costRows = costs.slice(0, 20).map(function(c) { var pColor = PROVIDER_COLORS[(c.provider || '').toLowerCase()] || '#71717a'; return '<tr><td class="card-mono">' + esc(truncate(c.id, 10)) + '</td><td>' + esc(c.model || '') + '</td><td><span style="color:' + pColor + '">' + esc(c.provider || '') + '</span></td><td>' + (c.tokens_in || 0) + '</td><td>' + (c.tokens_out || 0) + '</td><td>$' + Number(c.cost || 0).toFixed(6) + '</td><td>' + esc(c.created_at || '') + '</td></tr>'; }).join('');
var capNames = Object.keys(capacity).sort();
var capRows = capNames.map(function(name) {
var p = capacity[name] || {};
var headroom = Number(p.headroom || 0);
var pressure = p.sustained_hot ? '<span class="badge error">hot</span>' : (p.near_capacity ? '<span class="badge warning">near cap</span>' : '<span class="badge success">healthy</span>');
var tpm = p.tpm_limit ? (Math.round((p.token_utilization || 0) * 100) + '% (' + (p.tokens_used || 0).toLocaleString() + '/' + (p.tpm_limit || 0).toLocaleString() + ')') : 'n/a';
var rpm = p.rpm_limit ? (Math.round((p.request_utilization || 0) * 100) + '% (' + (p.requests_used || 0).toLocaleString() + '/' + (p.rpm_limit || 0).toLocaleString() + ')') : 'n/a';
return '<tr><td class="card-mono">' + esc(name) + '</td><td>' + Math.round(headroom * 100) + '%</td><td>' + pressure + '</td><td>' + esc(tpm) + '</td><td>' + esc(rpm) + '</td></tr>';
}).join('');
var capacityTable = '<p style="margin:1.25rem 0 0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Capacity & Headroom</p>'
+ '<div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Provider</th><th>Headroom</th><th>State</th><th>TPM Utilization</th><th>RPM Utilization</th></tr></thead><tbody>'
+ (capRows || '<tr><td colspan="5" style="color:var(--muted)">No capacity limits configured.</td></tr>')
+ '</tbody></table></div>';
var selectionRows = modelSelections.map(function(ms) {
var strategy = ms.strategy || 'unknown';
var cands = (ms.candidates || []).length;
return '<tr>'
+ '<td class="card-mono">' + esc(truncate(ms.turn_id || '', 10)) + '</td>'
+ '<td class="card-mono">' + esc(ms.selected_model || '') + '</td>'
+ '<td>' + esc(strategy) + '</td>'
+ '<td>' + cands + '</td>'
+ '<td>' + esc(ms.complexity || '') + '</td>'
+ '<td>' + esc(ms.created_at || '') + '</td>'
+ '</tr>';
}).join('');
var selectionTable = '<p style="margin:1.25rem 0 0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Live Model Selection Log</p>'
+ '<div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Turn</th><th>Selected Model</th><th>Strategy</th><th>Candidates</th><th>Complexity</th><th>Time</th></tr></thead><tbody>'
+ (selectionRows || '<tr><td colspan="6" style="color:var(--muted)">No model selection traces yet.</td></tr>')
+ '</tbody></table></div>';
return costChart + tokenChart + statGrid + capacityTable + selectionTable +
'<p style="margin:1.25rem 0 0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Transactions</p><div class="table-wrap" style="margin-bottom:1.25rem"><table><thead><tr><th>Type</th><th>Amount</th><th>Counterparty</th><th>Time</th></tr></thead><tbody>' + txRows + '</tbody></table></div>' +
'<p style="margin-bottom:0.5rem;color:var(--muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em">Request log</p><div class="table-wrap"><table><thead><tr><th>ID</th><th>Model</th><th>Provider</th><th>In</th><th>Out</th><th>Cost</th><th>Time</th></tr></thead><tbody>' + costRows + '</tbody></table></div>';
});
},
_effPeriod: '7d',
renderEfficiency: function() {
var self = this;
var period = this._effPeriod || '7d';
var effTab = this._effTab || 'performance';
var tabBar = '<div class="tabs" style="margin-bottom:1rem">'
+ '<button data-eff-tab="performance"' + (effTab === 'performance' ? ' class="active"' : '') + '>Performance</button>'
+ '<button data-eff-tab="recommendations"' + (effTab === 'recommendations' ? ' class="active"' : '') + '>Recommendations</button>'
+ '</div>';
if (effTab === 'recommendations') {
return this.renderRecommendations().then(function(recommendationsHtml) {
return tabBar + recommendationsHtml;
});
}
return Promise.all([
api('/api/stats/efficiency?period=' + encodeURIComponent(period)),
api('/api/models/selections?limit=80').catch(function() { return { events: [] }; }),
api('/api/models/routing-diagnostics').catch(function() { return { config: {} }; })
]).then(function(arr) {
var report = arr[0] || {};
var modelSelections = (arr[1] && arr[1].events) ? arr[1].events : [];
var routingConfig = (arr[2] && arr[2].config) ? arr[2].config : {};
var routingProfile = deriveRoutingProfile(routingConfig);
App._routingProfileDefaults = normalizeRoutingProfile(routingProfile);
if (!App._routingProfileDraft) App._routingProfileDraft = normalizeRoutingProfile({ correctness: routingProfile.correctness, cost: routingProfile.cost, speed: routingProfile.speed });
App._routingProfileDraft = normalizeRoutingProfile(App._routingProfileDraft);
var models = report.models || {};
var ts = report.time_series || [];
var totals = report.totals || {};
var modelNames = Object.keys(models).sort(function(a, b) {
return (models[b].cost.total || 0) - (models[a].cost.total || 0);
});
var spider = renderRoutingSpiderSvg(App._routingProfileDraft || routingProfile);
var persistedLabel = '';
if (App._routingProfilePersistedAt) {
var savedAt = new Date(App._routingProfilePersistedAt);
var savedText = Number.isNaN(savedAt.getTime()) ? App._routingProfilePersistedAt : savedAt.toLocaleString();
persistedLabel = '<div style="margin-top:0.45rem;font-size:0.6875rem;color:var(--muted)">'
+ (App._routingProfilePersisted ? 'Saved to config: ' : 'Applied: ')
+ esc(savedText)
+ '</div>';
}
var profileCard = '<div class="card" style="margin-bottom:1rem;padding-bottom:0.75rem"><div class="card-title">Routing Weights (Correctness / Cost / Speed)</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.65rem">Operator profile mapped onto routing controls. Adjust and apply to persist to runtime config.</div>'
+ '<div class="routing-profile-grid">'
+ '<div id="routing-profile-spider-host">' + spider + '</div>'
+ '<div>'
+ '<div class="routing-slider-row"><label for="routing-slider-correctness"><span>Correctness</span><span id="routing-slider-correctness-val">' + round2((App._routingProfileDraft || routingProfile).correctness).toFixed(2) + '</span></label><input id="routing-slider-correctness" data-routing-slider="correctness" type="range" min="0" max="1" step="0.01" value="' + round2((App._routingProfileDraft || routingProfile).correctness).toFixed(2) + '"></div>'
+ '<div class="routing-slider-row"><label for="routing-slider-cost"><span>Cost</span><span id="routing-slider-cost-val">' + round2((App._routingProfileDraft || routingProfile).cost).toFixed(2) + '</span></label><input id="routing-slider-cost" data-routing-slider="cost" type="range" min="0" max="1" step="0.01" value="' + round2((App._routingProfileDraft || routingProfile).cost).toFixed(2) + '"></div>'
+ '<div class="routing-slider-row"><label for="routing-slider-speed"><span>Speed</span><span id="routing-slider-speed-val">' + round2((App._routingProfileDraft || routingProfile).speed).toFixed(2) + '</span></label><input id="routing-slider-speed" data-routing-slider="speed" type="range" min="0" max="1" step="0.01" value="' + round2((App._routingProfileDraft || routingProfile).speed).toFixed(2) + '"></div>'
+ '<div style="font-size:0.72rem;color:var(--muted);margin-top:0.35rem">Total: <span id="routing-slider-total">' + routingProfileTotal((App._routingProfileDraft || routingProfile)).toFixed(2) + '</span> / 1.00</div>'
+ '<div class="routing-profile-actions"><button class="btn" id="routing-profile-apply" style="font-size:0.6875rem;padding:0.3rem 0.7rem">Apply profile</button><button class="btn secondary" id="routing-profile-reset" style="font-size:0.6875rem;padding:0.3rem 0.7rem">Reset</button></div>'
+ persistedLabel
+ '</div></div></div>';
var decisionGraph = renderModelDecisionGraph(modelSelections, App._modelGraphFocusTurn, App._modelGraphFocusModel, App._modelGraphFocusEdge);
// Period selector
var periods = ['1h', '24h', '7d', '30d', 'all'];
var periodBar = '<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap">';
periods.forEach(function(p) {
var cls = p === period ? 'background:var(--accent);color:var(--bg)' : 'background:var(--surface);color:var(--muted);border:1px solid var(--border)';
periodBar += '<button data-eff-period="' + p + '" style="padding:0.375rem 0.875rem;border-radius:4px;font-family:var(--font);font-size:0.8125rem;cursor:pointer;border:none;' + cls + '">' + p.toUpperCase() + '</button>';
});
periodBar += '</div>';
// Cost overview banner
var banner = '<div class="card" style="margin-bottom:1rem;background:linear-gradient(135deg,var(--surface) 0%,rgba(99,102,241,0.08) 100%)">'
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem">'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Total Cost</div><div style="font-size:1.5rem;font-weight:700;color:var(--accent)">$' + (totals.total_cost || 0).toFixed(4) + '</div></div>'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Cache Savings</div><div style="font-size:1.5rem;font-weight:700;color:var(--success)">$' + (totals.total_cache_savings || 0).toFixed(4) + '</div></div>'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Total Turns</div><div style="font-size:1.5rem;font-weight:700">' + (totals.total_turns || 0).toLocaleString() + '</div></div>'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Models Active</div><div style="font-size:1.5rem;font-weight:700">' + modelNames.length + '</div></div>'
+ '</div></div>';
// Model comparison cards
var trendArrow = function(t) {
if (t === 'increasing') return '<span style="color:var(--error)">▲</span>';
if (t === 'decreasing') return '<span style="color:var(--success)">▼</span>';
return '<span style="color:var(--muted)">▬</span>';
};
var trendArrowGood = function(t) {
if (t === 'increasing') return '<span style="color:var(--success)">▲</span>';
if (t === 'decreasing') return '<span style="color:var(--error)">▼</span>';
return '<span style="color:var(--muted)">▬</span>';
};
var modelColors = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6'];
var cards = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1rem;margin-bottom:1.25rem">';
modelNames.forEach(function(name, idx) {
var m = models[name];
var c = m.cost || {};
var t = m.trend || {};
var color = modelColors[idx % modelColors.length];
var isMostExpensive = totals.most_expensive_model === name;
var isMostEfficient = totals.most_efficient_model === name;
var badges = '';
if (isMostExpensive) badges += '<span style="background:rgba(239,68,68,0.15);color:var(--error);padding:0.125rem 0.5rem;border-radius:3px;font-size:0.6875rem;margin-right:0.25rem">HIGHEST COST</span>';
if (isMostEfficient) badges += '<span style="background:rgba(34,197,94,0.15);color:var(--success);padding:0.125rem 0.5rem;border-radius:3px;font-size:0.6875rem">MOST EFFICIENT</span>';
cards += '<div class="card" style="border-left:3px solid ' + color + '">'
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem"><div style="font-weight:600;font-size:0.9375rem">' + esc(name) + '</div><div>' + badges + '</div></div>'
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.8125rem">'
+ '<div><span style="color:var(--muted)">Turns</span><div style="font-weight:600">' + m.total_turns.toLocaleString() + '</div></div>'
+ '<div><span style="color:var(--muted)">Total Cost</span><div style="font-weight:600;color:' + color + '">$' + c.total.toFixed(4) + '</div></div>'
+ '<div><span style="color:var(--muted)">Output Density</span><div style="font-weight:600">' + m.avg_output_density.toFixed(3) + ' ' + trendArrowGood(t.output_density) + '</div></div>'
+ '<div><span style="color:var(--muted)">Cost/Turn</span><div style="font-weight:600">$' + c.effective_per_turn.toFixed(4) + ' ' + trendArrow(t.cost_per_turn) + '</div></div>'
+ '<div><span style="color:var(--muted)">Cache Hit Rate</span><div style="font-weight:600">' + (m.cache_hit_rate * 100).toFixed(1) + '% ' + trendArrowGood(t.cache_hit_rate) + '</div></div>'
+ '<div><span style="color:var(--muted)">Cache Savings</span><div style="font-weight:600;color:var(--success)">$' + c.cache_savings.toFixed(4) + '</div></div>'
+ '<div><span style="color:var(--muted)">$/Output Token</span><div style="font-weight:600">' + (c.per_output_token * 1000).toFixed(4) + '/1k</div></div>'
+ '<div><span style="color:var(--muted)">Cost Trend</span><div style="font-weight:600">' + trendArrow(c.cumulative_trend) + ' ' + esc(c.cumulative_trend) + '</div></div>'
+ '</div>';
// Cost attribution bar
var attr = c.attribution || {};
var attrBar = '<div style="margin-top:0.75rem"><div style="font-size:0.6875rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.375rem">Input Cost Attribution</div>';
var sys = attr.system_prompt || {};
var mem = attr.memories || {};
var hist = attr.history || {};
if (sys.pct > 0 || mem.pct > 0 || hist.pct > 0) {
attrBar += '<div style="display:flex;height:8px;border-radius:4px;overflow:hidden;margin-bottom:0.375rem">';
if (sys.pct > 0) attrBar += '<div style="width:' + sys.pct + '%;background:#6366f1" title="System prompt: ' + sys.pct.toFixed(1) + '%"></div>';
if (mem.pct > 0) attrBar += '<div style="width:' + mem.pct + '%;background:#22c55e" title="Memories: ' + mem.pct.toFixed(1) + '%"></div>';
if (hist.pct > 0) attrBar += '<div style="width:' + hist.pct + '%;background:#f59e0b" title="History: ' + hist.pct.toFixed(1) + '%"></div>';
attrBar += '</div>';
attrBar += '<div style="display:flex;gap:0.75rem;font-size:0.6875rem;color:var(--muted)">';
if (sys.pct > 0) attrBar += '<span style="color:#6366f1">\u25cf System ' + sys.pct.toFixed(0) + '%</span>';
if (mem.pct > 0) attrBar += '<span style="color:#22c55e">\u25cf Memory ' + mem.pct.toFixed(0) + '%</span>';
if (hist.pct > 0) attrBar += '<span style="color:#f59e0b">\u25cf History ' + hist.pct.toFixed(0) + '%</span>';
attrBar += '</div>';
} else {
attrBar += '<div style="font-size:0.75rem;color:var(--muted);font-style:italic">No context snapshot data available</div>';
}
attrBar += '</div>';
cards += attrBar + '</div>';
});
cards += '</div>';
if (modelNames.length === 0) {
cards = '<div class="card" style="text-align:center;padding:2rem;color:var(--muted)"><div style="font-size:1.5rem;margin-bottom:0.5rem">No inference data</div><div style="font-size:0.875rem">Run some inference requests to see efficiency metrics here.</div></div>';
}
// Time series chart (CSS bar chart)
var tsChart = '';
if (ts.length > 0) {
var maxCost = Math.max.apply(null, ts.map(function(p) { return p.cost; }));
if (maxCost === 0) maxCost = 1;
tsChart = '<div class="card" style="margin-bottom:1.25rem"><div style="font-weight:600;margin-bottom:0.75rem">Cost Over Time</div>';
tsChart += '<div style="display:flex;align-items:flex-end;gap:2px;height:120px;padding-bottom:1.5rem;position:relative">';
var buckets = [];
ts.forEach(function(p) { if (buckets.indexOf(p.bucket) === -1) buckets.push(p.bucket); });
var bucketTotals = {};
ts.forEach(function(p) { bucketTotals[p.bucket] = (bucketTotals[p.bucket] || 0) + p.cost; });
var maxBucketCost = Math.max.apply(null, Object.keys(bucketTotals).map(function(k) { return bucketTotals[k]; }));
if (maxBucketCost === 0) maxBucketCost = 1;
buckets.forEach(function(b, i) {
var pct = (bucketTotals[b] / maxBucketCost) * 100;
var shortDate = b.slice(5);
var colorIdx = i % modelColors.length;
tsChart += '<div style="flex:1;display:flex;flex-direction:column;align-items:center;min-width:0">'
+ '<div style="width:100%;background:' + modelColors[colorIdx] + ';border-radius:2px 2px 0 0;height:' + Math.max(pct, 2) + '%;opacity:0.85;transition:height 0.3s" title="' + b + ': $' + bucketTotals[b].toFixed(4) + '"></div>'
+ '<div style="font-size:0.5625rem;color:var(--muted);margin-top:0.25rem;transform:rotate(-45deg);white-space:nowrap">' + shortDate + '</div>'
+ '</div>';
});
tsChart += '</div></div>';
}
// Model comparison table (sortable)
var table = '';
if (modelNames.length > 0) {
table = '<div class="card" style="margin-bottom:1.25rem"><div style="font-weight:600;margin-bottom:0.75rem">Model Comparison</div>'
+ '<div class="table-wrap"><table><thead><tr>'
+ '<th>Model</th><th>Turns</th><th>Output Density</th><th>Cache Hit %</th>'
+ '<th>Cost/Turn</th><th>Total Cost</th><th>Cache Savings</th><th>Cost Trend</th>'
+ '</tr></thead><tbody>';
modelNames.forEach(function(name) {
var m = models[name];
var c = m.cost || {};
var t = m.trend || {};
table += '<tr>'
+ '<td class="card-mono">' + esc(name) + '</td>'
+ '<td>' + m.total_turns.toLocaleString() + '</td>'
+ '<td>' + m.avg_output_density.toFixed(3) + '</td>'
+ '<td>' + (m.cache_hit_rate * 100).toFixed(1) + '%</td>'
+ '<td>$' + c.effective_per_turn.toFixed(4) + '</td>'
+ '<td>$' + c.total.toFixed(4) + '</td>'
+ '<td style="color:var(--success)">$' + c.cache_savings.toFixed(4) + '</td>'
+ '<td>' + trendArrow(c.cumulative_trend) + ' ' + esc(c.cumulative_trend) + '</td>'
+ '</tr>';
});
table += '</tbody></table></div></div>';
}
var assignment = report.subagent_assignment || {};
var assignmentSection = '';
if (assignment.overall && assignment.by_subagent) {
var overall = assignment.overall || {};
var bySubagent = assignment.by_subagent || {};
var assignments = assignment.assignments || {};
var names = Object.keys(bySubagent).sort(function(a, b) {
return (bySubagent[b].total || 0) - (bySubagent[a].total || 0);
});
assignmentSection = '<div class="card" style="margin-bottom:1.25rem"><div style="font-weight:600;margin-bottom:0.75rem">Subagent Assignment Efficacy</div>';
assignmentSection += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:0.75rem;margin-bottom:0.75rem;font-size:0.8125rem">';
assignmentSection += '<div><span style="color:var(--muted)">Delegations</span><div style="font-weight:600">' + (overall.total || 0) + '</div></div>';
assignmentSection += '<div><span style="color:var(--muted)">Success Rate</span><div style="font-weight:600">' + (((overall.success_rate || 0) * 100).toFixed(1)) + '%</div></div>';
assignmentSection += '<div><span style="color:var(--muted)">Timeout-like</span><div style="font-weight:600;color:var(--warning)">' + (overall.timeout_like || 0) + '</div></div>';
assignmentSection += '<div><span style="color:var(--muted)">P95 Duration</span><div style="font-weight:600">' + (overall.p95_duration_ms || 0).toFixed(0) + ' ms</div></div>';
assignmentSection += '</div>';
assignmentSection += '<div class="table-wrap"><table><thead><tr><th>Subagent</th><th>Assigned Model</th><th>Fallbacks</th><th>Success %</th><th>Timeouts</th><th>Avg ms</th><th>P95 ms</th></tr></thead><tbody>';
names.forEach(function(name) {
var stat = bySubagent[name] || {};
var cfg = assignments[name] || {};
var fallbackList = cfg.fallback_models || [];
assignmentSection += '<tr>'
+ '<td>' + esc(name) + '</td>'
+ '<td class="card-mono">' + esc(cfg.configured_model || '—') + '</td>'
+ '<td>' + esc((fallbackList && fallbackList.length) ? fallbackList.join(', ') : '—') + '</td>'
+ '<td>' + (((stat.success_rate || 0) * 100).toFixed(1)) + '%</td>'
+ '<td>' + (stat.timeout_like || 0) + '</td>'
+ '<td>' + (stat.avg_duration_ms || 0).toFixed(0) + '</td>'
+ '<td>' + (stat.p95_duration_ms || 0).toFixed(0) + '</td>'
+ '</tr>';
});
assignmentSection += '</tbody></table></div></div>';
}
// Cost optimization tips
var tips = '<div class="card"><div style="font-weight:600;margin-bottom:0.75rem">\u26a1 Optimization Tips</div><div style="font-size:0.8125rem;color:var(--muted)">';
var tipList = [];
modelNames.forEach(function(name) {
var m = models[name];
if (m.cache_hit_rate < 0.3 && m.total_turns > 5) {
tipList.push(
'<div style="padding:0.375rem 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;gap:0.75rem;align-items:center;flex-wrap:wrap">'
+ '<div><span style="color:var(--warning)">\u25cf</span> <strong>' + esc(name) + '</strong> has a low cache hit rate (' + (m.cache_hit_rate * 100).toFixed(0) + '%). Consider enabling semantic caching for repeated queries.</div>'
+ '<button class="btn secondary" data-action="enable-semantic-cache" data-model="' + esc(name) + '" style="font-size:0.6875rem;padding:0.25rem 0.625rem;white-space:nowrap">Enable Caching</button>'
+ '</div>'
);
}
if (m.cost.effective_per_turn > 0.05) {
tipList.push('<div style="padding:0.375rem 0;border-bottom:1px solid var(--border)"><span style="color:var(--error)">\u25cf</span> <strong>' + esc(name) + '</strong> costs $' + m.cost.effective_per_turn.toFixed(4) + '/turn. Consider switching to a lighter model for simpler tasks.</div>');
}
if (m.avg_output_density < 0.1 && m.total_turns > 5) {
tipList.push('<div style="padding:0.375rem 0;border-bottom:1px solid var(--border)"><span style="color:var(--warning)">\u25cf</span> <strong>' + esc(name) + '</strong> has low output density (' + m.avg_output_density.toFixed(3) + '). Large inputs produce little output \u2014 check if context window is over-stuffed.</div>');
}
if (m.trend.cost_per_turn === 'increasing') {
tipList.push('<div style="padding:0.375rem 0;border-bottom:1px solid var(--border)"><span style="color:var(--error)">\u25cf</span> <strong>' + esc(name) + '</strong> cost per turn is trending up. Monitor for context window growth or pricing changes.</div>');
}
});
if (tipList.length === 0) {
tipList.push('<div style="padding:0.375rem 0;color:var(--success)">\u2714 All models are operating efficiently. No optimizations needed right now.</div>');
}
tips += tipList.join('') + '</div></div>';
return tabBar + profileCard + decisionGraph + periodBar + banner + cards + tsChart + table + assignmentSection + tips;
});
},
_recPeriod: '30d',
renderRecommendations: function() {
var self = this;
var period = this._recPeriod || '30d';
return api('/api/recommendations?period=' + encodeURIComponent(period)).then(function(data) {
var recs = data.recommendations || [];
var profile = data.profile || {};
var count = data.count || 0;
var periods = ['7d', '30d', 'all'];
var periodBar = '<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap;align-items:center">';
periodBar += '<span style="font-size:0.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-right:0.5rem">Period</span>';
periods.forEach(function(p) {
var cls = p === period ? 'background:var(--accent);color:var(--bg)' : 'background:var(--surface);color:var(--muted);border:1px solid var(--border)';
periodBar += '<button data-rec-period="' + p + '" style="padding:0.375rem 0.875rem;border-radius:4px;font-family:var(--font);font-size:0.8125rem;cursor:pointer;border:none;' + cls + '">' + p.toUpperCase() + '</button>';
});
periodBar += '<button id="btn-deep-analysis" style="margin-left:auto;padding:0.375rem 1rem;border-radius:4px;font-family:var(--font);font-size:0.8125rem;cursor:pointer;border:1px solid var(--accent);background:var(--accent-dim);color:var(--accent)">Generate Deep Analysis</button>';
periodBar += '</div>';
var banner = '<div class="card" style="margin-bottom:1rem;background:linear-gradient(135deg,var(--surface) 0%,rgba(99,102,241,0.08) 100%)">'
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem">'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Recommendations</div><div style="font-size:1.5rem;font-weight:700;color:var(--accent)">' + count + '</div></div>'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Total Turns</div><div style="font-size:1.5rem;font-weight:700">' + (profile.total_turns || 0).toLocaleString() + '</div></div>'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Total Cost</div><div style="font-size:1.5rem;font-weight:700;color:var(--accent)">$' + (profile.total_cost || 0).toFixed(4) + '</div></div>'
+ '<div><div style="font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.25rem">Cache Hit Rate</div><div style="font-size:1.5rem;font-weight:700">' + ((profile.cache_hit_rate || 0) * 100).toFixed(1) + '%</div></div>'
+ '</div></div>';
var priorityColor = function(p) {
if (p === 'High') return 'var(--error)';
if (p === 'Medium') return 'var(--warning)';
return '#6366f1';
};
var priorityBg = function(p) {
if (p === 'High') return 'rgba(239,68,68,0.1)';
if (p === 'Medium') return 'rgba(234,179,8,0.1)';
return 'rgba(99,102,241,0.1)';
};
var categoryIcon = function(cat) {
var icons = {
QueryCrafting: '\u270e',
ModelSelection: '\u2699',
SessionManagement: '\u{1f4ac}',
MemoryLeverage: '\u{1f9e0}',
CostOptimization: '\u{1f4b0}',
ToolUsage: '\u{1f527}',
Configuration: '\u2699'
};
return icons[cat] || '\u2139';
};
var cards = '';
if (recs.length === 0) {
cards = '<div class="card" style="text-align:center;padding:2rem;color:var(--muted)">'
+ '<div style="font-size:1.5rem;margin-bottom:0.5rem">No recommendations</div>'
+ '<div style="font-size:0.875rem">Not enough data to generate recommendations yet. Run more inference requests.</div></div>';
} else {
recs.forEach(function(rec, idx) {
var color = priorityColor(rec.priority);
var bg = priorityBg(rec.priority);
var icon = categoryIcon(rec.category);
var evidenceId = 'rec-evidence-' + idx;
cards += '<div class="card" style="border-left:3px solid ' + color + ';margin-bottom:0.75rem">'
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.5rem">'
+ '<div style="display:flex;align-items:center;gap:0.5rem">'
+ '<span style="font-size:1.125rem">' + icon + '</span>'
+ '<div style="font-weight:600;font-size:0.9375rem">' + esc(rec.title) + '</div>'
+ '</div>'
+ '<span style="background:' + bg + ';color:' + color + ';padding:0.125rem 0.625rem;border-radius:3px;font-size:0.6875rem;font-weight:600;text-transform:uppercase;flex-shrink:0">' + esc(rec.priority) + '</span>'
+ '</div>';
cards += '<div style="font-size:0.8125rem;color:var(--muted);margin-bottom:0.5rem">' + esc(rec.explanation) + '</div>';
cards += '<div style="font-size:0.8125rem;padding:0.5rem 0.75rem;background:rgba(99,102,241,0.06);border-radius:4px;margin-bottom:0.5rem"><strong style="color:var(--accent)">Action:</strong> ' + esc(rec.action) + '</div>';
if (rec.estimated_impact) {
var imp = rec.estimated_impact;
var impactParts = [];
if (imp.monthly_savings) impactParts.push('<span style="color:var(--success)">Est. savings: $' + imp.monthly_savings.toFixed(4) + '</span>');
if (imp.quality_change) impactParts.push('<span style="color:var(--accent)">' + esc(imp.quality_change) + '</span>');
if (impactParts.length > 0) {
cards += '<div style="font-size:0.75rem;display:flex;gap:1rem;margin-bottom:0.5rem">' + impactParts.join('') + '</div>';
}
}
if (rec.evidence && rec.evidence.length > 0) {
cards += '<div style="margin-top:0.25rem">'
+ '<button onclick="var el=document.getElementById(\'' + evidenceId + '\');el.style.display=el.style.display===\'none\'?\'block\':\'none\'" style="background:none;border:none;color:var(--muted);font-family:var(--font);font-size:0.75rem;cursor:pointer;padding:0">\u25b6 Evidence (' + rec.evidence.length + ')</button>'
+ '<div id="' + evidenceId + '" style="display:none;margin-top:0.375rem">';
rec.evidence.forEach(function(ev) {
cards += '<div style="font-size:0.75rem;padding:0.25rem 0;border-bottom:1px solid var(--border)">'
+ '<span style="color:var(--accent)">' + esc(ev.metric) + '</span>: '
+ '<span style="font-weight:600">' + esc(ev.value) + '</span>'
+ ' <span style="color:var(--muted)">(' + esc(ev.context) + ')</span></div>';
});
cards += '</div></div>';
}
cards += '</div>';
});
}
var deepAnalysisContainer = '<div id="deep-analysis-result" style="display:none;margin-top:1rem"></div>';
setTimeout(function() {
document.querySelectorAll('[data-rec-period]').forEach(function(btn) {
btn.addEventListener('click', function() {
self._recPeriod = btn.getAttribute('data-rec-period');
self._effTab = 'recommendations';
self.navigate('efficiency');
});
});
var deepBtn = document.getElementById('btn-deep-analysis');
if (deepBtn) {
deepBtn.addEventListener('click', function() {
deepBtn.disabled = true;
deepBtn.textContent = 'Generating...';
api('/api/recommendations/generate?period=' + encodeURIComponent(self._recPeriod || '30d'), { method: 'POST' }).then(function(res) {
var container = document.getElementById('deep-analysis-result');
if (container) {
container.style.display = 'block';
var actions = (res.actions || []).map(function(a) { return '<li style="margin:0.25rem 0">' + esc(a) + '</li>'; }).join('');
var savings = (res.summary && res.summary.estimated_monthly_savings != null) ? '$' + Number(res.summary.estimated_monthly_savings).toFixed(4) : '—';
var html = '<div class="card"><div style="font-weight:600;margin-bottom:0.75rem">Deep Analysis</div>'
+ '<div style="font-size:0.8125rem;color:var(--muted);margin-bottom:0.5rem">' + esc(res.message || 'Analysis complete') + '</div>'
+ '<div style="font-size:0.75rem;color:var(--text);margin-bottom:0.75rem"><strong>Estimated monthly savings:</strong> ' + esc(savings) + '</div>'
+ (actions ? '<div style="font-size:0.75rem;margin-bottom:0.375rem;color:var(--muted)">Prioritized actions</div><ol style="margin:0;padding-left:1rem;font-size:0.75rem">' + actions + '</ol>' : '<div style="font-size:0.75rem;color:var(--muted)">No prioritized actions returned.</div>')
+ '</div>';
setHtml(container, html);
}
deepBtn.disabled = false;
deepBtn.textContent = 'Generate Deep Analysis';
}).catch(function() {
deepBtn.disabled = false;
deepBtn.textContent = 'Generate Deep Analysis';
});
});
}
}, 0);
return periodBar + banner + cards + deepAnalysisContainer;
});
},
_TOKEN_ICONS: { ETH: '\u039e', MATIC: '\u25c6', USDC: '\u0024', USDT: '\u0024', DAI: '\u25c7', WETH: '\u039e', WBTC: '\u20bf', cbBTC: '\u20bf' },
_renderRevenueSwapSummaryCard: function(revenueSwap) {
var cfg = revenueSwap || {};
var enabled = cfg.enabled !== false;
var target = cfg.target_symbol || 'PALM_USD';
var chain = cfg.default_chain || 'ETH';
var chains = Array.isArray(cfg.chains) ? cfg.chains : [];
var chainRows = chains.map(function(entry) {
var swapContract = entry.swap_contract_address
? '<div style="font-size:0.625rem;color:var(--muted);margin-top:0.2rem">Swap contract: <span class="card-mono">' + esc(entry.swap_contract_address) + '</span></div>'
: '<div style="font-size:0.625rem;color:var(--muted);margin-top:0.2rem">Swap contract: <span class="card-mono">custom/no override</span></div>';
return '<div class="settings-nested" style="margin-top:0.6rem">'
+ '<div class="settings-row"><div class="settings-label">' + esc(entry.chain || 'UNSPECIFIED') + '</div><div class="card-mono" style="font-size:0.7rem;word-break:break-all">' + esc(entry.target_contract_address || '\u2014') + '</div></div>'
+ swapContract
+ '</div>';
}).join('');
if (!chainRows) chainRows = '<div style="font-size:0.75rem;color:var(--muted);padding-top:0.35rem">No chain targets configured.</div>';
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Revenue Swap Policy</div>'
+ '<div class="settings-row"><div class="settings-label">Auto-swap</div><div style="font-family:var(--font-mono)">' + (enabled ? 'enabled' : 'disabled') + '</div></div>'
+ '<div class="settings-row"><div class="settings-label">Default target</div><div style="font-family:var(--font-mono)">' + esc(target) + '</div></div>'
+ '<div class="settings-row"><div class="settings-label">Default chain</div><div style="font-family:var(--font-mono)">' + esc(chain) + '</div></div>'
+ chainRows
+ '</div>';
},
_renderRevenueStrategySummaryCard: function(rows) {
var items = Array.isArray(rows) ? rows : [];
var body = items.length
? items.map(function(row) {
return '<div class="settings-row">'
+ '<div><div style="font-weight:600;font-size:0.8125rem">' + esc(row.strategy || 'unknown') + '</div><div style="font-size:0.625rem;color:var(--muted)">' + esc(String(row.settled_jobs || 0)) + ' settled / ' + esc(String(row.total_jobs || 0)) + ' total</div></div>'
+ '<div style="text-align:right;font-family:var(--font-mono)"><div>$' + Number(row.net_profit_usdc || 0).toFixed(2) + '</div><div style="font-size:0.625rem;color:var(--muted)">priority ' + Number(row.avg_priority_score || 0).toFixed(1) + '</div></div>'
+ '</div>';
}).join('')
: '<div style="font-size:0.75rem;color:var(--muted)">No settled strategy data yet.</div>';
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Revenue Strategy Summary</div>' + body + '</div>';
},
_renderRevenueFeedbackSummaryCard: function(rows) {
var items = Array.isArray(rows) ? rows : [];
var body = items.length
? items.map(function(row) {
return '<div class="settings-row">'
+ '<div><div style="font-weight:600;font-size:0.8125rem">' + esc(row.strategy || 'unknown') + '</div><div style="font-size:0.625rem;color:var(--muted)">' + esc(String(row.feedback_count || 0)) + ' feedback signals</div></div>'
+ '<div style="text-align:right;font-family:var(--font-mono)"><div>' + Number(row.avg_grade || 0).toFixed(2) + ' / 5.00</div><div style="font-size:0.625rem;color:var(--muted)">' + esc((row.latest_feedback_at || '').replace('T', ' ').slice(0, 19) || '\u2014') + '</div></div>'
+ '</div>';
}).join('')
: '<div style="font-size:0.75rem;color:var(--muted)">No feedback recorded yet.</div>';
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Revenue Feedback Summary</div>' + body + '</div>';
},
_renderSeedExerciseReadinessCard: function(readiness) {
var r = readiness || {};
var ready = !!r.meets_seed_target;
var reserve = !!r.minimum_reserve_configured;
var swapEnabled = !!r.swap_enabled;
var targetOk = !!r.default_chain_has_target_contract;
var swapOk = !!r.default_chain_has_swap_contract;
var target = Number(r.seed_target_usdc || 50);
var stable = Number(r.stable_balance_usdc || 0);
var defaultChain = r.default_chain || 'ETH';
var checks = [
['Stable balance', stable.toFixed(2) + ' / ' + target.toFixed(2) + ' USDC', ready],
['Minimum reserve', reserve ? 'configured' : 'missing', reserve],
['Auto-swap', swapEnabled ? 'enabled' : 'disabled', swapEnabled],
['Target contract', targetOk ? 'configured' : 'missing', targetOk],
['Swap contract', swapOk ? 'configured' : 'missing', swapOk]
];
var rows = checks.map(function(check) {
return '<div class="settings-row">'
+ '<div class="settings-label">' + esc(check[0]) + '</div>'
+ '<div style="display:flex;align-items:center;gap:0.5rem"><span class="badge ' + (check[2] ? 'success' : 'warn') + '">' + esc(check[1]) + '</span></div>'
+ '</div>';
}).join('');
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">$50 Seed Readiness</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">Default chain: <span class="card-mono">' + esc(defaultChain) + '</span></div>'
+ rows
+ '</div>';
},
_renderSeedExerciseProgressCard: function(progress) {
var p = progress || {};
var phases = [
['Phase 1: seeded + visible', !!p.phase_1_seeded_and_visible],
['Phase 1: target met', !!p.phase_1_meets_target],
['Phase 2: revenue cycle complete', !!p.phase_2_revenue_cycle_complete],
['Phase 3: swap submitted', !!p.phase_3_swap_submitted],
['Phase 3: swap reconciled', !!p.phase_3_swap_reconciled],
['Phase 3: tax submitted', !!p.phase_3_tax_submitted],
['Phase 3: tax reconciled', !!p.phase_3_tax_reconciled],
['Phase 4: mechanic clear', !!p.phase_4_mechanic_clear]
];
var rows = phases.map(function(phase) {
return '<div class="settings-row">'
+ '<div class="settings-label">' + esc(phase[0]) + '</div>'
+ '<div><span class="badge ' + (phase[1] ? 'success' : 'warn') + '">' + (phase[1] ? 'done' : 'pending') + '</span></div>'
+ '</div>';
}).join('');
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Seed Exercise Progress</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">Next action: ' + esc(p.next_action || 'no action available') + '</div>'
+ rows
+ '</div>';
},
_renderSeedExercisePlanCard: function(plan) {
var p = plan || {};
var phases = Array.isArray(p.phases) ? p.phases : [];
var phaseRows = phases.length
? phases.map(function(phase) {
return '<div class="settings-nested" style="margin-top:0.6rem">'
+ '<div class="settings-row"><div class="settings-label">' + esc(phase.label || phase.id || 'phase') + '</div><div><span class="badge">' + esc(String(Number(phase.max_spend_usdc || 0).toFixed(2))) + ' USDC cap</span></div></div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-top:0.35rem">' + esc(phase.goal || '') + '</div>'
+ '<div style="font-size:0.6875rem;color:var(--muted);margin-top:0.35rem">Success signal: ' + esc(phase.success_signal || '') + '</div>'
+ '</div>';
}).join('')
: '<div style="font-size:0.75rem;color:var(--muted)">No seed exercise plan is available.</div>';
var aborts = Array.isArray(p.abort_conditions) ? p.abort_conditions : [];
var guidance = Array.isArray(p.operator_guidance) ? p.operator_guidance : [];
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">$50 Seed Exercise Plan</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">Constrained live-funds validation plan for treasury, settlement, routing, and repair.</div>'
+ phaseRows
+ '<div style="margin-top:0.9rem;font-size:0.75rem;color:var(--muted)">Abort conditions</div>'
+ (aborts.length ? '<ul style="margin:0.35rem 0 0 1rem;padding:0;color:var(--text)">' + aborts.map(function(item) { return '<li style="margin:0.3rem 0">' + esc(item) + '</li>'; }).join('') + '</ul>' : '<div style="font-size:0.75rem;color:var(--muted)">No abort conditions defined.</div>')
+ '<div style="margin-top:0.9rem;font-size:0.75rem;color:var(--muted)">Operator guidance</div>'
+ (guidance.length ? '<ul style="margin:0.35rem 0 0 1rem;padding:0;color:var(--text)">' + guidance.map(function(item) { return '<li style="margin:0.3rem 0">' + esc(item) + '</li>'; }).join('') + '</ul>' : '<div style="font-size:0.75rem;color:var(--muted)">No operator guidance defined.</div>')
+ '</div>';
},
_renderRevenueSwapTasksCard: function(rows) {
var items = Array.isArray(rows) ? rows : [];
var body = items.length
? '<div class="table-wrap"><table><thead><tr><th>Opportunity</th><th>Status</th><th>Target</th><th>Tx</th><th>Updated</th><th>Actions</th></tr></thead><tbody>'
+ items.map(function(row) {
var source = row.source || {};
var target = (source.target_asset || 'unknown') + ' on ' + (source.target_chain || 'unknown');
var txHash = source.swap_tx_hash
? '<span class="card-mono" style="font-size:0.625rem">' + esc(String(source.swap_tx_hash).slice(0, 18)) + '\u2026</span>'
: '<span style="color:var(--muted)">\u2014</span>';
var updated = row.updated_at ? String(row.updated_at).replace('T', ' ').slice(0, 19) : '\u2014';
var actions = [];
if (row.status === 'pending') {
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="swap" data-revenue-task-action="start" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Start</button>');
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="swap" data-revenue-task-action="submit" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Submit</button>');
} else if (row.status === 'in_progress') {
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="swap" data-revenue-task-action="reconcile" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Reconcile</button>');
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="swap" data-revenue-task-action="confirm" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Confirm</button>');
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="swap" data-revenue-task-action="fail" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Fail</button>');
}
return '<tr>'
+ '<td><div style="font-weight:600">' + esc(row.opportunity_id || row.id || 'unknown') + '</div><div style="font-size:0.625rem;color:var(--muted)">' + esc(row.title || '') + '</div></td>'
+ '<td>' + esc(row.status || 'unknown') + '</td>'
+ '<td>' + esc(target) + '</td>'
+ '<td>' + txHash + '</td>'
+ '<td style="color:var(--muted)">' + esc(updated) + '</td>'
+ '<td><div style="display:flex;flex-wrap:wrap;gap:0.25rem">' + (actions.join('') || '<span style="color:var(--muted)">—</span>') + '</div></td>'
+ '</tr>';
}).join('')
+ '</tbody></table></div>'
: '<div style="font-size:0.75rem;color:var(--muted)">No revenue swap tasks yet.</div>';
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Revenue Swap Tasks</div>' + body + '</div>';
},
_renderRevenueTaxTasksCard: function(rows) {
var items = Array.isArray(rows) ? rows : [];
var body = items.length
? '<div class="table-wrap"><table><thead><tr><th>Opportunity</th><th>Status</th><th>Destination</th><th>Tx</th><th>Updated</th><th>Actions</th></tr></thead><tbody>'
+ items.map(function(row) {
var source = row.source || {};
var dest = (source.destination_wallet || 'unknown') + ' on ' + (source.target_chain || 'unknown');
var txHash = source.tax_tx_hash
? '<span class="card-mono" style="font-size:0.625rem">' + esc(String(source.tax_tx_hash).slice(0, 18)) + '…</span>'
: '<span style="color:var(--muted)">—</span>';
var updated = row.updated_at ? String(row.updated_at).replace('T', ' ').slice(0, 19) : '—';
var actions = [];
if (row.status === 'pending') {
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="tax" data-revenue-task-action="start" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Start</button>');
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="tax" data-revenue-task-action="submit" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Submit</button>');
} else if (row.status === 'in_progress') {
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="tax" data-revenue-task-action="reconcile" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Reconcile</button>');
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="tax" data-revenue-task-action="confirm" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Confirm</button>');
actions.push('<button class="btn secondary" style="font-size:0.625rem;padding:0.18rem 0.45rem" data-revenue-task-kind="tax" data-revenue-task-action="fail" data-opportunity-id="' + esc(row.opportunity_id || '') + '">Fail</button>');
}
return '<tr>'
+ '<td><div style="font-weight:600">' + esc(row.opportunity_id || row.id || 'unknown') + '</div><div style="font-size:0.625rem;color:var(--muted)">' + esc(row.title || '') + '</div></td>'
+ '<td>' + esc(row.status || 'unknown') + '</td>'
+ '<td>' + esc(dest) + '</td>'
+ '<td>' + txHash + '</td>'
+ '<td style="color:var(--muted)">' + esc(updated) + '</td>'
+ '<td><div style="display:flex;flex-wrap:wrap;gap:0.25rem">' + (actions.join('') || '<span style="color:var(--muted)">—</span>') + '</div></td>'
+ '</tr>';
}).join('')
+ '</tbody></table></div>'
: '<div style="font-size:0.75rem;color:var(--muted)">No revenue tax tasks yet.</div>';
return '<div class="card" style="margin-bottom:1rem"><div class="card-title">Revenue Tax Tasks</div>' + body + '</div>';
},
_renderRevenueSwapSettings: function(revenueSwap) {
var cfg = revenueSwap || {};
var chains = Array.isArray(cfg.chains) ? cfg.chains : [];
var rows = chains.map(function(entry, idx) {
return '<div class="settings-nested" style="margin-top:0.6rem">'
+ '<div class="settings-row"><div class="settings-label">Chain</div><input class="settings-input" type="text" data-revenue-swap-chain-field="chain" data-revenue-swap-chain-index="' + idx + '" value="' + esc(entry.chain || '') + '" placeholder="ETH"></div>'
+ '<div class="settings-row"><div class="settings-label">Target contract</div><input class="settings-input" type="text" data-revenue-swap-chain-field="target_contract_address" data-revenue-swap-chain-index="' + idx + '" value="' + esc(entry.target_contract_address || '') + '" placeholder="0x... or program id"></div>'
+ '<div class="settings-row"><div class="settings-label">Swap contract</div><input class="settings-input" type="text" data-revenue-swap-chain-field="swap_contract_address" data-revenue-swap-chain-index="' + idx + '" value="' + esc(entry.swap_contract_address || '') + '" placeholder="optional"></div>'
+ '<div style="display:flex;justify-content:flex-end"><button class="btn secondary" data-revenue-swap-remove-chain="' + idx + '">Remove chain</button></div>'
+ '</div>';
}).join('');
if (!rows) rows = '<div style="font-size:0.75rem;color:var(--muted)">No chain targets configured yet. Add one below.</div>';
return '<div class="settings-section"><div class="settings-section-title">Revenue Swap</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">Default post-settlement conversion policy. Users can disable auto-swap, change the target asset, or configure any chain so long as the contract addresses are supplied.</div>'
+ '<div class="settings-row"><div class="settings-label">Auto-swap by default</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="treasury.revenue_swap.enabled"' + (cfg.enabled !== false ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (cfg.enabled !== false ? 'On' : 'Off') + '</span></div></div>'
+ '<div class="settings-row"><div class="settings-label">Target symbol</div><input class="settings-input" type="text" data-settings-path="treasury.revenue_swap.target_symbol" value="' + esc(cfg.target_symbol || 'PALM_USD') + '" placeholder="PALM_USD"></div>'
+ '<div class="settings-row"><div class="settings-label">Default chain</div><input class="settings-input" type="text" data-settings-path="treasury.revenue_swap.default_chain" value="' + esc(cfg.default_chain || 'ETH') + '" placeholder="ETH"></div>'
+ '<div class="settings-row"><div class="settings-label">Configured chains</div><div style="width:100%">' + rows + '</div></div>'
+ '<div class="model-order-add" style="margin-top:0.5rem"><input id="revenue-swap-chain-add" class="settings-input" type="text" placeholder="Add chain (e.g. ETH, SOLANA, BSC, ARBITRUM)"><button class="btn secondary" id="revenue-swap-chain-add-btn">Add chain</button></div>'
+ '</div>';
},
renderWallet: function() {
var self = this;
return Promise.all([
api('/api/wallet/balance').catch(function() { return { balance: '0', currency: 'ETH', tokens: [], network: 'Unknown' }; }),
api('/api/wallet/address').catch(function() { return { address: '', chain_id: 1, network: 'Unknown' }; }),
api('/api/stats/transactions?hours=168').catch(function() { return { transactions: [] }; }),
api('/api/services/swaps?limit=10').catch(function() { return { swap_tasks: [] }; }),
api('/api/services/tax-payouts?limit=10').catch(function() { return { tax_tasks: [] }; })
]).then(function(arr) {
var bal = arr[0], addr = arr[1], txs = (arr[2].transactions || []), swaps = (arr[3].swap_tasks || []), taxTasks = (arr[4].tax_tasks || []);
var addrStr = (addr.address != null && addr.address !== '') ? addr.address : (addr.note || '\u2014');
var network = bal.network || addr.network || 'Unknown';
var chainId = addr.chain_id || bal.chain_id || 1;
var tokens = bal.tokens || [];
txs = txs.filter(function(t) { return t.amount && t.amount !== 0; });
var txRows = txs.map(function(t) {
var meta = {};
try { if (t.metadata_json) meta = JSON.parse(t.metadata_json); } catch(e) {}
var label = t.tx_type || '';
if (meta.category) label += ' \u00b7 ' + meta.category;
var amtStr = (typeof t.amount === 'number' ? t.amount.toFixed(2) : t.amount) + ' ' + (t.currency || '');
var dateStr = t.created_at || '';
if (dateStr.length > 19) dateStr = dateStr.substring(0, 19).replace('T', ' ');
return '<tr><td>' + esc(label) + '</td><td style="font-family:var(--mono)">' + esc(amtStr) + '</td><td style="color:var(--muted)">' + esc(dateStr) + '</td></tr>';
}).join('');
// Network & address card
var networkBadge = '<span class="badge success" style="font-size:0.625rem;vertical-align:middle">' + esc(network) + '</span>';
var addrSection = '<div class="card" style="margin-bottom:1rem"><div class="card-title">Wallet ' + networkBadge + '</div><div style="font-size:0.6875rem;color:var(--muted);margin-bottom:0.5rem">Chain ID: ' + chainId + '</div><div class="card-mono" style="word-break:break-all" id="wallet-addr">' + esc(addrStr) + '</div><div style="display:flex;gap:0.5rem;margin-top:8px"><button class="btn secondary" id="copy-address" style="font-size:0.75rem;padding:0.3rem 0.75rem">Copy</button></div><p style="font-size:0.6875rem;color:var(--muted);margin-top:0.5rem">Wallet settings are immutable at runtime. Update `ironclad.toml` and restart to change address or chain.</p></div>';
// Token balances card
var tokenRows = '';
if (tokens.length > 0) {
tokens.forEach(function(t) {
var icon = self._TOKEN_ICONS[t.symbol] || '\u25cb';
var bal = t.balance || 0;
var formatted = t.formatted || (bal === 0 ? '0' : String(bal));
var nativeBadge = t.is_native ? ' <span class="badge" style="font-size:0.5rem;padding:1px 4px;opacity:0.6">gas</span>' : '';
var contractLink = '';
if (t.contract) {
var explorerUrl = self._explorerUrl(chainId, t.contract);
contractLink = ' <a href="' + explorerUrl + '" target="_blank" rel="noopener" style="font-size:0.5625rem;color:var(--accent);text-decoration:none" title="' + esc(t.contract) + '">\u2197</a>';
}
var dimClass = bal === 0 ? ' style="opacity:0.4"' : '';
tokenRows += '<div class="settings-row"' + dimClass + '><div style="display:flex;align-items:center;gap:0.5rem;min-width:120px"><span style="font-size:1.1rem;width:1.5rem;text-align:center">' + icon + '</span><div><div style="font-weight:600;font-size:0.8125rem">' + esc(t.symbol) + nativeBadge + contractLink + '</div><div style="font-size:0.625rem;color:var(--muted)">' + esc(t.name) + '</div></div></div><div style="text-align:right;font-family:var(--font-mono);font-size:0.875rem;font-weight:500">' + esc(formatted) + '</div></div>';
});
} else {
tokenRows = '<div style="color:var(--muted);font-size:0.75rem;padding:0.5rem 0">No token data available</div>';
}
var balancesCard = '<div class="card" style="margin-bottom:1rem"><div class="card-title">Balances</div>' + tokenRows + '</div>';
// Treasury card
var treasury = bal.treasury || {};
var treasuryCard = '<div class="card" style="margin-bottom:1rem"><div class="card-title">Treasury Policy</div>' +
'<div class="settings-row"><div class="settings-label">Per-payment cap</div><div style="font-family:var(--font-mono)">$' + (treasury.per_payment_cap || 0).toFixed(2) + '</div></div>' +
'<div class="settings-row"><div class="settings-label">Daily inference budget</div><div style="font-family:var(--font-mono)">$' + (treasury.daily_inference_budget || 0).toFixed(2) + '</div></div>' +
'<div class="settings-row"><div class="settings-label">Minimum reserve</div><div style="font-family:var(--font-mono)">$' + (treasury.minimum_reserve || 0).toFixed(2) + '</div></div>' +
'</div>';
var revenueSwapCard = self._renderRevenueSwapSummaryCard(treasury.revenue_swap || {});
var seedReadinessCard = self._renderSeedExerciseReadinessCard(bal.seed_exercise_readiness || {});
var seedProgressCard = self._renderSeedExerciseProgressCard(bal.seed_exercise_progress || {});
var seedPlanCard = self._renderSeedExercisePlanCard(bal.seed_exercise_plan || {});
var strategySummaryCard = self._renderRevenueStrategySummaryCard(bal.revenue_strategy_summary || []);
var feedbackSummaryCard = self._renderRevenueFeedbackSummaryCard(bal.revenue_feedback_summary || []);
var swapTasksCard = self._renderRevenueSwapTasksCard(swaps);
var taxTasksCard = self._renderRevenueTaxTasksCard(taxTasks);
return addrSection + balancesCard + treasuryCard + revenueSwapCard + seedReadinessCard + seedProgressCard + seedPlanCard + strategySummaryCard + feedbackSummaryCard + swapTasksCard + taxTasksCard +
'<div class="card-title">Transaction history</div><div class="table-wrap"><table><thead><tr><th>Type</th><th>Amount</th><th>Date</th></tr></thead><tbody>' + txRows + '</tbody></table></div>';
});
},
_explorerUrl: function(chainId, contract) {
var base = { 1: 'https://etherscan.io', 8453: 'https://basescan.org', 42161: 'https://arbiscan.io', 10: 'https://optimistic.etherscan.io', 137: 'https://polygonscan.com' };
return (base[chainId] || 'https://basescan.org') + '/token/' + contract;
},
_settingsMode: 'form',
_settingsDraft: null,
_configCapabilities: null,
_settingsModelOrder: null,
_settingsDragModelIndex: null,
_settingsJsonText: null,
_settingsDirty: false,
_getSettingsDraft: function() {
if (!this._settingsDraft && _cachedConfig) this._settingsDraft = JSON.parse(JSON.stringify(_cachedConfig));
return this._settingsDraft || {};
},
_buildMutableConfigPatch: function(draft) {
var caps = this._configCapabilities || {};
var immutable = {};
(caps.immutable_sections || ['server', 'a2a', 'wallet']).forEach(function(k) { immutable[k] = true; });
var patch = {};
Object.keys(draft || {}).forEach(function(k) {
if (!immutable[k]) patch[k] = draft[k];
});
return patch;
},
_initModelOrderFromDraft: function() {
var draft = this._getSettingsDraft();
if (!draft.models || typeof draft.models !== 'object') draft.models = {};
var order = [];
function pushUnique(v) {
var s = String(v || '').trim();
if (!s) return;
if (order.indexOf(s) === -1) order.push(s);
}
pushUnique(draft.models.primary);
var fb = Array.isArray(draft.models.fallbacks) ? draft.models.fallbacks : [];
fb.forEach(pushUnique);
this._settingsModelOrder = order;
},
_syncModelOrderToDraft: function() {
var draft = this._getSettingsDraft();
if (!draft.models || typeof draft.models !== 'object') draft.models = {};
var order = (this._settingsModelOrder || []).map(function(v) { return String(v || '').trim(); }).filter(Boolean);
draft.models.primary = order[0] || '';
draft.models.fallbacks = order.slice(1);
this._settingsDirty = JSON.stringify(draft) !== JSON.stringify(_cachedConfig);
this._settingsJsonText = null;
},
_KNOWN_MODELS: ['anthropic/claude-sonnet-4-20250514','openai/gpt-4o','openai/gpt-4o-mini','google/gemini-2.0-flash','sglang/apertus-8b-instruct:latest','ollama/apertus-8b-instruct:latest','ollama/qwen3:8b'],
_MODEL_CATALOG: [
'anthropic/claude-opus-4-1-20250805','anthropic/claude-opus-4-20250514','anthropic/claude-sonnet-4-20250514','anthropic/claude-3-7-sonnet-20250219','anthropic/claude-3-5-sonnet-20241022','anthropic/claude-3-5-haiku-20241022',
'openai/gpt-5','openai/gpt-5-mini','openai/gpt-4.1','openai/gpt-4.1-mini','openai/gpt-4o','openai/gpt-4o-mini','openai/o3','openai/o3-mini',
'google/gemini-2.5-pro','google/gemini-2.5-flash','google/gemini-2.0-flash','google/gemini-1.5-pro','google/gemini-1.5-flash',
'xai/grok-4','xai/grok-3-beta',
'mistral/magistral-medium','mistral/mistral-large','mistral/mistral-medium','mistral/codestral-latest',
'deepseek/deepseek-chat','deepseek/deepseek-reasoner',
'moonshot/kimi-k2.5','moonshot/kimi-k2-instruct',
'meta/llama-3.3-70b-instruct','meta/llama-3.1-70b-instruct','meta/llama-3.1-8b-instruct',
'qwen/qwen3-235b-a22b','qwen/qwen2.5-72b-instruct','qwen/qwen2.5-coder-32b-instruct',
'cohere/command-a','cohere/command-r-plus',
'perplexity/sonar-pro','perplexity/sonar',
'openrouter/anthropic/claude-sonnet-4','openrouter/openai/gpt-4o','openrouter/google/gemini-2.5-pro',
'sglang/apertus-8b-instruct:latest','sglang/apertus-70b-instruct:latest','vllm/apertus-8b-instruct:latest','vllm/apertus-70b-instruct:latest','docker-model-runner/apertus-8b-instruct:latest','docker-model-runner/apertus-70b-instruct:latest','ollama/apertus-8b-instruct:latest','ollama/apertus-70b-instruct:latest','ollama/qwen3:8b','ollama/qwen2.5-coder:14b','ollama/llama3.1:8b','ollama/llama3.1:70b','ollama/deepseek-r1:14b','ollama/mistral-small:24b'
],
_buildModelOptionEntries: function(opts) {
opts = opts || {};
var configuredSet = {};
var identifiedSet = {};
var modelSet = {};
var self = this;
function mark(set, v) {
var s = String(v || '').trim();
if (!s) return;
set[s] = true;
}
function add(v) {
var s = String(v || '').trim();
if (!s) return;
modelSet[s] = true;
}
(self._MODEL_CATALOG || []).forEach(add);
(self._KNOWN_MODELS || []).forEach(add);
(self._availableModelsCache || []).forEach(function(m) { add(m); mark(identifiedSet, m); });
(opts.discoveredModels || []).forEach(function(m) { add(m); mark(identifiedSet, m); });
var cfg = opts.config || _cachedConfig || {};
if (cfg.models) {
add(cfg.models.primary); mark(configuredSet, cfg.models.primary);
(cfg.models.fallbacks || []).forEach(function(m) { add(m); mark(configuredSet, m); });
}
var roster = opts.roster || [];
roster.forEach(function(a) {
if (!a) return;
add(a.model); mark(identifiedSet, a.model);
add(a.resolved_model); mark(identifiedSet, a.resolved_model);
});
(opts.selected || []).forEach(function(m) { add(m); mark(configuredSet, m); });
var models = Object.keys(modelSet);
models.sort(function(a, b) {
var ap = configuredSet[a] ? 0 : (identifiedSet[a] ? 1 : 2);
var bp = configuredSet[b] ? 0 : (identifiedSet[b] ? 1 : 2);
if (ap !== bp) return ap - bp;
return a.localeCompare(b);
});
var entries = models.map(function(m) {
var configured = !!configuredSet[m];
var identified = !!identifiedSet[m];
var tag = configured ? 'configured' : (identified ? 'detected' : 'catalog');
var prefix = configured ? '★ ' : (identified ? '• ' : '');
return { value: m, configured: configured, identified: identified, tag: tag, label: prefix + m + ' [' + tag + ']' };
});
if (opts.includeControlModes) {
entries.push({ value: 'auto', configured: false, identified: false, tag: 'mode', label: 'auto [mode]' });
entries.push({ value: 'orchestrator', configured: false, identified: false, tag: 'mode', label: 'orchestrator [mode]' });
}
return entries;
},
_renderModelOptionsHtml: function(opts) {
return this._buildModelOptionEntries(opts).map(function(entry) {
return '<option value="' + esc(entry.value) + '">' + esc(entry.label) + '</option>';
}).join('');
},
_renderProviderDiscoveryAlerts: function() {
var reports = this._availableModelsProvidersReport || {};
var proxy = this._availableModelsProxyRuntime || {};
var alerts = [];
if (proxy.mode === 'in_process') {
alerts.push('<div class="card" style="border-color:var(--border);margin-bottom:0.6rem"><div style="font-size:0.72rem;color:var(--muted);font-weight:600">Provider routing mode: in-process</div><div style="font-size:0.72rem;color:var(--muted);margin-top:0.2rem">Dashboard discovery is server-mediated. Providers are contacted only by Ironclad backend APIs.</div></div>');
}
if (proxy.legacy_loopback_support === 'removed_v0_8') {
alerts.push('<div class="card" style="border-color:var(--warning);margin-bottom:0.6rem"><div style="font-size:0.72rem;color:var(--warning);font-weight:600">Legacy loopback provider URLs are unsupported</div><div style="font-size:0.72rem;color:var(--muted);margin-top:0.2rem">127.0.0.1:8788 provider URLs are not supported in v0.8.0+. Update providers.*.url to direct upstream provider base URLs.</div></div>');
}
Object.keys(reports).forEach(function(name) {
var rep = reports[name] || {};
if (rep.status === 'proxy_unreachable' || rep.status === 'proxy_misconfigured' || rep.status === 'legacy_proxy_unsupported') {
var hint = rep.hint || ('Local proxy for ' + name + ' is unavailable.');
var title = rep.status === 'proxy_misconfigured'
? ('Proxy misconfigured: ' + name)
: (rep.status === 'legacy_proxy_unsupported'
? ('Legacy loopback URL unsupported: ' + name)
: ('Proxy unreachable: ' + name));
alerts.push('<div class="card" style="border-color:var(--warning);margin-bottom:0.6rem"><div style="font-size:0.72rem;color:var(--warning);font-weight:600">' + esc(title) + '</div><div style="font-size:0.72rem;color:var(--muted);margin-top:0.2rem">' + esc(hint) + '</div></div>');
}
});
return alerts.join('');
},
_CHAIN_PRESETS: {
'Ethereum': { chain_id: 1, rpc_url: 'https://eth.llamarpc.com' },
'Sepolia': { chain_id: 11155111, rpc_url: 'https://rpc.sepolia.org' },
'Polygon': { chain_id: 137, rpc_url: 'https://polygon-rpc.com' },
'Arbitrum': { chain_id: 42161, rpc_url: 'https://arb1.arbitrum.io/rpc' },
'Optimism': { chain_id: 10, rpc_url: 'https://mainnet.optimism.io' },
'Base': { chain_id: 8453, rpc_url: 'https://mainnet.base.org' },
'Solana': { chain_id: 0, rpc_url: 'https://api.mainnet-beta.solana.com' }
},
_agentsTab: 'roster',
_availableModelsCache: [],
_availableModelsProvidersReport: {},
_availableModelsProxyRuntime: {},
_availableModelsFetchedAt: 0,
_availableModelsInFlight: null,
_loadAvailableModels: function(opts) {
opts = opts || {};
var self = this;
var now = Date.now();
var cacheTtlMs = opts.cacheTtlMs != null ? opts.cacheTtlMs : 300000;
var timeoutMs = opts.timeoutMs != null ? opts.timeoutMs : 1200;
var skipFetch = !!opts.skipFetch;
var validationLevel = (opts.validationLevel || 'zero').toString().toLowerCase();
var cached = Array.isArray(self._availableModelsCache) ? self._availableModelsCache : [];
var cacheFresh = self._availableModelsFetchedAt && ((now - self._availableModelsFetchedAt) < cacheTtlMs);
if (cacheFresh && !opts.forceRefresh) return Promise.resolve(cached);
if (skipFetch) return Promise.resolve(cached);
if (!self._availableModelsInFlight) {
var modelsPath = '/api/models/available?validation_level=' + encodeURIComponent(validationLevel);
self._availableModelsInFlight = api(modelsPath)
.then(function(payload) {
var models = (payload && payload.models) || [];
self._availableModelsCache = Array.isArray(models) ? models : [];
self._availableModelsProvidersReport = (payload && payload.providers) || {};
self._availableModelsProxyRuntime = (payload && payload.proxy) || {};
self._availableModelsFetchedAt = Date.now();
return self._availableModelsCache;
})
.catch(function() {
return cached;
})
.finally(function() {
self._availableModelsInFlight = null;
});
}
// For UI paths, prefer responsiveness over waiting on slow provider scans.
if (opts.nonBlocking) return Promise.resolve(cached);
return Promise.race([
self._availableModelsInFlight,
new Promise(function(resolve) { setTimeout(function() { resolve(cached); }, timeoutMs); })
]);
},
renderAgents: function() {
var self = this;
var tab = self._agentsTab || 'roster';
var tabs = '<div class="tabs" style="margin-bottom:1rem">'
+ '<button class="' + (tab === 'roster' ? 'active' : '') + '" data-agents-tab="roster">Roster</button>'
+ '<button class="' + (tab === 'list' ? 'active' : '') + '" data-agents-tab="list">List</button>'
+ '</div>';
var render = tab === 'list' ? self._renderListTab() : self._renderRosterTab();
return render.then(function(body) { return tabs + body; });
},
_renderRosterTab: function() {
return api('/api/roster').then(function(data) {
var roster = data.roster || [];
if (roster.length === 0) return '<p style="color:var(--muted)">No taskable agents found.</p>';
var grid = roster.map(function(agent) {
var isCmd = agent.role === 'orchestrator';
var borderColor = agent.color || 'var(--accent)';
var stateColor = agent.state === 'Running' ? '#22c55e' : (agent.state === 'Error' ? '#ef4444' : (agent.state === 'Disabled' ? '#71717a' : '#eab308'));
var stateDot = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + stateColor + ';margin-right:6px"></span>';
var displayName = agent.display_name || agent.name;
var model = agent.model || '—';
var modelShort = model.length > 28 ? model.substring(model.lastIndexOf('/') + 1) : model;
var skillCount = agent.skills == null ? '\u2014' : agent.skills.length;
var sessionCount = agent.session_count != null ? agent.session_count : '\u2014';
var roleLabel = isCmd ? 'ORCHESTRATOR' : 'SUBAGENT';
var roleBg = isCmd ? 'rgba(234,179,8,0.15)' : 'rgba(99,102,241,0.15)';
var roleColor = isCmd ? '#eab308' : borderColor;
var card = '<div class="card roster-card" data-roster-id="' + esc(agent.name || agent.id) + '" style="cursor:pointer;border-left:3px solid ' + borderColor + ';padding:1rem;transition:transform 0.15s,box-shadow 0.15s' + (!agent.enabled ? ';opacity:0.5' : '') + '">'
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem">'
+ '<div style="display:flex;align-items:center;gap:0.5rem">'
+ stateDot
+ '<span style="font-weight:700;font-size:1rem;color:var(--text)">' + esc(displayName) + '</span>'
+ '</div>'
+ '<span style="font-size:0.65rem;font-weight:600;letter-spacing:0.05em;padding:2px 8px;border-radius:3px;background:' + roleBg + ';color:' + roleColor + '">' + roleLabel + '</span>'
+ '</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.4rem;font-family:var(--mono)">' + esc(modelShort) + '</div>'
+ '<div style="display:flex;gap:1rem;font-size:0.75rem;color:var(--muted)">'
+ '<span>' + skillCount + ' skills</span>'
+ '<span>' + sessionCount + ' sessions</span>'
+ '</div>'
+ '</div>';
return card;
}).join('');
var cmdCount = roster.filter(function(a) { return a.role === 'orchestrator'; }).length;
var specCount = roster.filter(function(a) { return a.role === 'subagent'; }).length;
var header = '<div style="display:flex;align-items:baseline;gap:1rem;margin-bottom:1rem;flex-wrap:wrap">'
+ '<span style="font-size:0.8125rem;color:var(--muted)">' + roster.length + ' taskable agents</span>'
+ '<span class="badge">' + cmdCount + ' orchestrator</span>'
+ '<span class="badge">' + specCount + ' subagents</span>'
+ '</div>';
return header
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0.75rem">' + grid + '</div>'
+ '<div id="roster-modal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);padding:2rem;overflow-y:auto"></div>';
});
},
_rosterCache: null,
_renderListTab: function() {
var self = this;
return Promise.all([
api('/api/subagents'),
self._loadAvailableModels({ nonBlocking: true, skipFetch: true }),
fetchWithFallback('/api/skills', { skills: [] }, 'skills')
]).then(function(arr) {
var data = arr[0] || {};
var discoveredModels = arr[1] || [];
var skillsData = (arr[2] && arr[2].data) ? arr[2].data : { skills: [] };
var agents = data.agents || [];
var skillRecords = (skillsData.skills || []).filter(function(s) { return s && s.name; });
var skillNameSet = {};
var allSkillNames = [];
skillRecords.forEach(function(s) {
var key = String(s.name).toLowerCase();
if (!skillNameSet[key]) {
skillNameSet[key] = true;
allSkillNames.push(String(s.name));
}
});
allSkillNames.sort();
var enabledSkillNames = skillRecords
.filter(function(s) { return !!s.enabled; })
.map(function(s) { return String(s.name); });
var enabledSkillCount = enabledSkillNames.length;
var enabledSkillHint = enabledSkillNames.length
? enabledSkillNames.slice(0, 10).join(', ') + (enabledSkillNames.length > 10 ? ' ...' : '')
: 'No enabled skills found. Enable skills from the Skills page first.';
var skillOptions = allSkillNames
.map(function(name) { return '<option value="' + esc(name) + '"></option>'; })
.join('');
var specialists = agents.filter(function(a) { return a.role === 'subagent' || a.role === 'specialist'; });
var proxies = agents.filter(function(a) { return a.role === 'model-proxy'; });
var enabledCount = specialists.filter(function(a) { return a.enabled; }).length;
var modelOptions = self._renderModelOptionsHtml({
discoveredModels: discoveredModels,
roster: specialists,
config: _cachedConfig,
includeControlModes: true
});
var html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem"><div>';
html += '<span style="font-size:1.5rem;font-weight:700;color:var(--text)">' + specialists.length + ' Sub-Agents</span>';
html += '<span style="margin-left:0.75rem;font-size:0.8125rem;color:var(--muted)">' + enabledCount + ' enabled</span>';
html += '<span style="margin-left:0.75rem;font-size:0.8125rem;color:var(--muted)">' + enabledSkillCount + ' shared runtime skills</span>';
html += '</div>';
html += '<button class="btn" id="sa-add-btn" style="font-size:0.75rem;padding:0.4rem 1rem">+ Add Agent</button>';
html += '</div>';
if (proxies.length > 0) {
html += '<div class="card" style="margin-bottom:0.75rem;padding:0.75rem 1rem;font-size:0.75rem;color:var(--muted)">'
+ proxies.length + ' model prox' + (proxies.length === 1 ? 'y is' : 'ies are')
+ ' hidden from this taskable sub-agent list.'
+ '</div>';
}
function agentCard(a) {
var c = '';
var skills = a.skills || [];
var fixedSkillCount = skills.length;
var statusBadge = a.enabled
? '<span class="badge success">enabled</span>'
: '<span class="badge" style="background:var(--surface);color:var(--muted)">disabled</span>';
var roleBadge = '<span class="badge" style="background:rgba(99,102,241,0.15);color:#818cf8;margin-left:0.25rem">' + esc(a.role) + '</span>';
c += '<div class="card" style="margin-bottom:0.75rem;opacity:' + (a.enabled ? '1' : '0.6') + '">';
c += '<div style="display:flex;align-items:center;justify-content:space-between">';
c += '<div style="display:flex;align-items:center;gap:0.5rem">';
c += '<span style="font-size:1.25rem">\uD83E\uDD16</span>';
c += '<div>';
c += '<div style="font-weight:600;color:var(--text)">' + esc(a.display_name || a.name) + '</div>';
c += '<div style="font-size:0.6875rem;color:var(--muted);font-family:var(--font-mono)">' + esc(a.name) + '</div>';
c += '</div>';
c += '</div>';
c += '<div style="display:flex;align-items:center;gap:0.5rem">' + statusBadge + roleBadge + '</div>';
c += '</div>';
c += '<div style="display:flex;gap:1rem;margin-top:0.75rem;font-size:0.75rem;color:var(--muted)">';
if (a.model) c += '<div><span style="color:var(--text-dim)">Model:</span> ' + esc(a.model) + '</div>';
if (a.fallback_models && a.fallback_models.length) c += '<div><span style="color:var(--text-dim)">Fallbacks:</span> ' + esc(a.fallback_models.join(', ')) + '</div>';
if (a.model_mode && a.model_mode !== 'fixed') c += '<div><span style="color:var(--text-dim)">Mode:</span> ' + esc(a.model_mode) + '</div>';
if (a.resolved_model) c += '<div><span style="color:var(--text-dim)">Resolved:</span> ' + esc(a.resolved_model) + '</div>';
c += '<div><span style="color:var(--text-dim)">Fixed skills:</span> '
+ '<button class="sa-edit-skills"'
+ ' data-name="' + esc(a.name) + '"'
+ ' data-display="' + esc(a.display_name || '') + '"'
+ ' data-model="' + esc(a.model || '') + '"'
+ ' data-fallbacks="' + esc((a.fallback_models || []).join(",")) + '"'
+ ' data-role="' + esc(a.role) + '"'
+ ' data-skills="' + esc((a.skills || []).join(",")) + '"'
+ ' data-desc="' + esc(a.description || '') + '"'
+ ' style="margin-left:0.25rem;background:transparent;border:1px solid var(--border);border-radius:4px;padding:0.05rem 0.35rem;color:var(--text);cursor:pointer;font-size:0.7rem">'
+ fixedSkillCount
+ '</button></div>';
c += '<div><span style="color:var(--text-dim)">Shared skills:</span> ' + enabledSkillCount + '</div>';
if (a.session_count > 0) c += '<div><span style="color:var(--text-dim)">Sessions:</span> ' + a.session_count + '</div>';
if (a.description) c += '<div style="flex:1">' + esc(a.description) + '</div>';
c += '</div>';
c += '<div style="display:flex;gap:0.5rem;margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.6rem">';
c += '<button class="btn secondary sa-toggle" data-name="' + esc(a.name) + '" style="font-size:0.6875rem;padding:0.25rem 0.6rem">' + (a.enabled ? 'Disable' : 'Enable') + '</button>';
c += '<button class="btn secondary sa-edit" data-name="' + esc(a.name) + '" data-display="' + esc(a.display_name || '') + '" data-model="' + esc(a.model || '') + '" data-fallbacks="' + esc((a.fallback_models || []).join(",")) + '" data-role="' + esc(a.role) + '" data-skills="' + esc((a.skills || []).join(",")) + '" data-desc="' + esc(a.description || '') + '" style="font-size:0.6875rem;padding:0.25rem 0.6rem">Edit</button>';
c += '<button class="btn secondary sa-delete" data-name="' + esc(a.name) + '" style="font-size:0.6875rem;padding:0.25rem 0.6rem;color:var(--error)">Delete</button>';
c += '</div>';
c += '</div>';
return c;
}
if (specialists.length > 0) {
html += '<div style="font-size:0.8125rem;font-weight:600;color:var(--text);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em">Subagents</div>';
specialists.forEach(function(a) { html += agentCard(a); });
}
if (specialists.length === 0) {
html += '<div class="card" style="text-align:center;padding:3rem"><div style="font-size:2rem;margin-bottom:0.5rem">\uD83E\uDD16</div>';
html += '<div style="color:var(--muted)">No taskable sub-agents configured</div>';
html += '<div style="font-size:0.75rem;color:var(--muted);margin-top:0.5rem">Import a prior agent export with <code style="background:var(--surface);padding:0.1rem 0.4rem;border-radius:4px">ironclad migrate import <export-root> --areas agents</code> or add one below</div>';
html += '</div>';
}
html += '<div id="sa-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;display:none;align-items:center;justify-content:center">';
html += '<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.5rem;width:min(440px,90vw)">';
html += '<div style="font-weight:600;font-size:1rem;margin-bottom:1rem" id="sa-modal-title">Add Sub-Agent</div>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Name (kebab-case)</label>';
html += '<input class="settings-input" id="sa-m-name" placeholder="e.g. data-analyst" style="margin-bottom:0.75rem;font-family:var(--font-mono)">';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Display Name</label>';
html += '<input class="settings-input" id="sa-m-display" placeholder="e.g. Data Analyst" style="margin-bottom:0.75rem">';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Model</label>';
html += '<input class="settings-input" id="sa-m-model" list="sa-model-options" placeholder="e.g. ollama/qwen3:8b | auto | orchestrator" style="margin-bottom:0.75rem;font-family:var(--font-mono)">';
html += '<datalist id="sa-model-options">' + modelOptions + '</datalist>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Fallback Models (comma-separated)</label>';
html += '<input class="settings-input" id="sa-m-fallbacks" list="sa-model-options" placeholder="e.g. openrouter/openai/gpt-4o, moonshot/kimi-k2-turbo-preview" style="margin-bottom:0.75rem;font-family:var(--font-mono)">';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Role</label>';
html += '<select class="settings-input" id="sa-m-role" style="margin-bottom:0.75rem"><option value="subagent">subagent</option><option value="model-proxy">model-proxy</option></select>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Fixed Skills (comma-separated)</label>';
html += '<input class="settings-input" id="sa-m-skills" list="sa-skill-options" placeholder="e.g. research,summarization" style="margin-bottom:0.35rem">';
html += '<datalist id="sa-skill-options">' + skillOptions + '</datalist>';
html += '<div id="sa-m-skills-help" style="font-size:0.6875rem;color:var(--muted);margin-bottom:0.75rem">Available: ' + esc(enabledSkillHint) + '</div>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Description</label>';
html += '<textarea class="settings-input" id="sa-m-desc" rows="2" placeholder="What does this agent do?" style="margin-bottom:1rem;resize:vertical"></textarea>';
html += '<div style="display:flex;gap:0.5rem;justify-content:flex-end">';
html += '<button class="btn secondary" id="sa-m-cancel" style="font-size:0.75rem;padding:0.35rem 1rem">Cancel</button>';
html += '<button class="btn" id="sa-m-save" style="font-size:0.75rem;padding:0.35rem 1rem">Save</button>';
html += '</div></div></div>';
setTimeout(function() {
var modal = document.getElementById('sa-modal');
var editingName = null;
var skillsHelpDefault = 'Available: ' + enabledSkillHint;
function syncRoleSkillsState() {
var role = ((document.getElementById('sa-m-role') || {}).value || 'subagent').toLowerCase();
var skillsInput = document.getElementById('sa-m-skills');
var help = document.getElementById('sa-m-skills-help');
if (!skillsInput || !help) return;
if (role === 'model-proxy') {
skillsInput.disabled = true;
skillsInput.value = '';
help.textContent = 'Model proxies cannot own fixed skills. Set role to subagent to assign skills.';
} else {
skillsInput.disabled = false;
help.textContent = skillsHelpDefault;
}
}
function openModal(title, vals) {
if (!modal) return;
modal.style.display = 'flex';
var t = document.getElementById('sa-modal-title'); if (t) t.textContent = title;
var nameInp = document.getElementById('sa-m-name');
if (nameInp) { nameInp.value = vals.name || ''; nameInp.disabled = !!vals.editing; }
var d = document.getElementById('sa-m-display'); if (d) d.value = vals.display || '';
var m = document.getElementById('sa-m-model'); if (m) m.value = vals.model || '';
var fb = document.getElementById('sa-m-fallbacks'); if (fb) fb.value = vals.fallbacks || '';
var r = document.getElementById('sa-m-role'); if (r) r.value = vals.role || 'subagent';
var sk = document.getElementById('sa-m-skills'); if (sk) sk.value = vals.skills || '';
var desc = document.getElementById('sa-m-desc'); if (desc) desc.value = vals.desc || '';
editingName = vals.editing || null;
syncRoleSkillsState();
}
function closeModal() { if (modal) modal.style.display = 'none'; editingName = null; }
var addBtn = document.getElementById('sa-add-btn');
if (addBtn) addBtn.onclick = function() { openModal('Add Sub-Agent', {}); };
var cancelBtn = document.getElementById('sa-m-cancel');
if (cancelBtn) cancelBtn.onclick = closeModal;
if (modal) modal.onclick = function(e) { if (e.target === modal) closeModal(); };
var roleSelect = document.getElementById('sa-m-role');
if (roleSelect) roleSelect.onchange = syncRoleSkillsState;
var saveBtn = document.getElementById('sa-m-save');
if (saveBtn) saveBtn.onclick = function() {
var nameVal = (document.getElementById('sa-m-name') || {}).value || '';
var displayVal = (document.getElementById('sa-m-display') || {}).value || '';
var modelVal = (document.getElementById('sa-m-model') || {}).value || '';
var fallbackVals = (document.getElementById('sa-m-fallbacks') || {}).value || '';
var roleVal = (document.getElementById('sa-m-role') || {}).value || 'subagent';
var skillsVal = (document.getElementById('sa-m-skills') || {}).value || '';
var skills = skillsVal.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
var fallbacks = fallbackVals.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
var descVal = (document.getElementById('sa-m-desc') || {}).value || '';
if (!nameVal && !editingName) { toast('Name is required'); return; }
if (editingName) {
api('/api/subagents/' + encodeURIComponent(editingName), {
method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
body: JSON.stringify({ display_name: displayVal || null, model: modelVal || null, fallback_models: fallbacks, role: roleVal, skills: skills, description: descVal || null })
}).then(function() { toast('Agent updated'); closeModal(); self.navigate('agents'); });
} else {
api('/api/subagents', {
method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
body: JSON.stringify({ name: nameVal, display_name: displayVal || null, model: modelVal, fallback_models: fallbacks, role: roleVal, skills: skills, description: descVal || null })
}).then(function() { toast('Agent created'); closeModal(); self.navigate('agents'); });
}
};
document.querySelectorAll('.sa-toggle').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
api('/api/subagents/' + encodeURIComponent(n) + '/toggle', {
method: 'PUT', headers: authHeaders({ 'Accept': 'application/json' })
}).then(function() { toast('Agent toggled'); self.navigate('agents'); });
};
});
document.querySelectorAll('.sa-edit').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
openModal('Edit: ' + n, {
name: n, editing: n,
display: this.getAttribute('data-display'),
model: this.getAttribute('data-model'),
fallbacks: this.getAttribute('data-fallbacks'),
role: this.getAttribute('data-role'),
skills: this.getAttribute('data-skills'),
desc: this.getAttribute('data-desc')
});
};
});
document.querySelectorAll('.sa-edit-skills').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
openModal('Edit skills: ' + n, {
name: n, editing: n,
display: this.getAttribute('data-display'),
model: this.getAttribute('data-model'),
fallbacks: this.getAttribute('data-fallbacks'),
role: this.getAttribute('data-role'),
skills: this.getAttribute('data-skills'),
desc: this.getAttribute('data-desc')
});
var input = document.getElementById('sa-m-skills');
if (input && !input.disabled) input.focus();
};
});
document.querySelectorAll('.sa-delete').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
if (!confirm('Delete sub-agent "' + n + '"? This cannot be undone.')) return;
api('/api/subagents/' + encodeURIComponent(n), {
method: 'DELETE', headers: authHeaders({ 'Accept': 'application/json' })
}).then(function() { toast('Agent deleted'); self.navigate('agents'); });
};
});
}, 0);
return html;
});
},
_ctxSession: null,
_ctxTurns: [],
_ctxActiveTurn: null,
_liveStreamTurn: null,
renderContext: function() {
var self = this;
return api('/api/sessions').then(function(data) {
var sessions = (data.sessions || []).filter(function(s) { return s.status === 'active'; });
var liveBanner = '';
if (self._liveStreamTurn && self._liveStreamTurn.turn_id) {
liveBanner = '<div class="card" style="margin-bottom:0.75rem;padding:0.625rem 0.75rem;background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3)">'
+ '<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;flex-wrap:wrap">'
+ '<div style="font-size:0.75rem;color:var(--muted)">'
+ '<span class="badge" style="background:var(--accent);color:#fff;font-size:0.55rem;margin-right:0.35rem">LIVE</span>'
+ 'Active stream turn <span class="card-mono" style="font-size:0.7rem">' + esc(truncate(self._liveStreamTurn.turn_id, 16)) + '</span>'
+ (self._liveStreamTurn.model ? ' · model <span class="card-mono" style="font-size:0.7rem">' + esc(self._liveStreamTurn.model) + '</span>' : '')
+ '</div>'
+ '<div style="display:flex;align-items:center;gap:0.375rem">'
+ '<button class="btn secondary" id="ctx-copy-live-turn" style="font-size:0.6875rem;padding:0.25rem 0.6rem" data-turn-id="' + esc(self._liveStreamTurn.turn_id) + '">Copy turn ID</button>'
+ '<button class="btn secondary" id="ctx-open-live-turn" style="font-size:0.6875rem;padding:0.25rem 0.6rem" data-turn-id="' + esc(self._liveStreamTurn.turn_id) + '" data-session-id="' + esc(self._liveStreamTurn.session_id || '') + '">Open forensic detail</button>'
+ '</div>'
+ '</div></div>';
}
if (self._ctxSession && self._ctxActiveTurn) {
return Promise.all([
api('/api/turns/' + encodeURIComponent(self._ctxActiveTurn.id)).catch(function() { return null; }),
api('/api/turns/' + encodeURIComponent(self._ctxActiveTurn.id) + '/context').catch(function() { return null; }),
api('/api/turns/' + encodeURIComponent(self._ctxActiveTurn.id) + '/tools').catch(function() { return { tool_calls: [] }; }),
api('/api/turns/' + encodeURIComponent(self._ctxActiveTurn.id) + '/tips').catch(function() { return { tips: [] }; }),
api('/api/turns/' + encodeURIComponent(self._ctxActiveTurn.id) + '/model-selection').catch(function() { return null; })
]).then(function(results) {
var turn = results[0], ctx = results[1], tools = results[2], tipsData = results[3], modelSel = results[4];
var detailHtml = '';
if (ctx) {
var budget = ctx.token_budget || 1;
var sysPct = Math.round(((ctx.system_prompt_tokens || 0) / budget) * 100);
var memPct = Math.round(((ctx.memory_tokens || 0) / budget) * 100);
var histPct = Math.round(((ctx.history_tokens || 0) / budget) * 100);
var freePct = Math.max(0, 100 - sysPct - memPct - histPct);
detailHtml += '<div class="ctx-stats">';
detailHtml += '<div class="ctx-stat"><div class="val">' + esc(ctx.complexity_level) + '</div><div class="lbl">Level</div></div>';
detailHtml += '<div class="ctx-stat"><div class="val">' + (ctx.token_budget || 0).toLocaleString() + '</div><div class="lbl">Budget</div></div>';
detailHtml += '<div class="ctx-stat"><div class="val">' + (ctx.history_depth || 0) + '</div><div class="lbl">History Depth</div></div>';
if (turn) {
detailHtml += '<div class="ctx-stat"><div class="val">' + ((turn.tokens_in || 0) + (turn.tokens_out || 0)).toLocaleString() + '</div><div class="lbl">Tokens</div></div>';
detailHtml += '<div class="ctx-stat"><div class="val">$' + (turn.cost || 0).toFixed(4) + '</div><div class="lbl">Cost</div></div>';
}
detailHtml += '</div>';
detailHtml += '<h4 style="margin:0 0 0.25rem;font-size:0.75rem;color:var(--muted)">TOKEN ALLOCATION</h4>';
detailHtml += '<div class="ctx-bar"><span class="sys" style="width:' + sysPct + '%"></span><span class="mem" style="width:' + memPct + '%"></span><span class="hist" style="width:' + histPct + '%"></span><span class="free" style="width:' + freePct + '%"></span></div>';
detailHtml += '<div class="ctx-legend"><span class="l-sys">System ' + (ctx.system_prompt_tokens || 0) + '</span><span class="l-mem">Memory ' + (ctx.memory_tokens || 0) + '</span><span class="l-hist">History ' + (ctx.history_tokens || 0) + '</span></div>';
if (ctx.model) detailHtml += '<p style="margin:0.75rem 0 0;font-size:0.75rem;color:var(--muted)">Model: <span style="color:var(--text)">' + esc(ctx.model) + '</span></p>';
} else {
detailHtml += '<p style="color:var(--muted)">No context snapshot recorded for this turn.</p>';
}
if (turn && turn.thinking) {
detailHtml += '<details class="ctx-section"><summary>Reasoning Trace</summary><div>' + renderSafeMarkdown(turn.thinking) + '</div></details>';
}
var toolCalls = (tools && tools.tool_calls) || [];
if (toolCalls.length > 0) {
detailHtml += '<details class="ctx-section"><summary>Tool Calls (' + toolCalls.length + ')</summary>';
toolCalls.forEach(function(tc) {
detailHtml += '<div style="margin:0.375rem 0;padding:0.375rem;background:rgba(0,0,0,0.15);border-radius:3px;font-size:0.75rem">';
detailHtml += '<span class="badge ' + (tc.status === 'success' ? 'success' : 'error') + '" style="font-size:0.5625rem;margin-right:0.25rem">' + esc(tc.status) + '</span>';
detailHtml += '<strong>' + esc(tc.tool_name) + '</strong>';
if (tc.duration_ms != null) detailHtml += ' <span style="color:var(--muted)">' + tc.duration_ms + 'ms</span>';
detailHtml += '</div>';
});
detailHtml += '</details>';
}
var turnTips = (tipsData && tipsData.tips) || [];
if (turnTips.length > 0) {
detailHtml += '<h4 style="margin:0.75rem 0 0.25rem;font-size:0.75rem;color:var(--muted)">ANALYSIS TIPS</h4>';
detailHtml += '<div class="ctx-tips">';
turnTips.forEach(function(tip) {
detailHtml += '<span class="ctx-tip ' + esc(tip.severity) + '" title="' + esc(tip.suggestion) + '"><span class="tip-dot"></span>' + esc(tip.message) + '</span>';
});
detailHtml += '</div>';
}
if (modelSel) {
var candidates = modelSel.candidates || [];
detailHtml += '<details class="ctx-section" open><summary>Model Selection Forensics</summary>';
detailHtml += '<div style="font-size:0.75rem;color:var(--muted);margin:0.35rem 0 0.5rem">'
+ 'Selected <span style="color:var(--text);font-family:var(--mono)">' + esc(modelSel.selected_model || '—') + '</span>'
+ ' via <span class="badge muted" style="font-size:0.6rem">' + esc(modelSel.strategy || 'unknown') + '</span>'
+ (modelSel.complexity ? ' · complexity ' + esc(modelSel.complexity) : '')
+ '</div>';
if (modelSel.user_excerpt) {
detailHtml += '<div style="font-size:0.7rem;color:var(--muted);margin-bottom:0.5rem">Task excerpt: <span style="color:var(--text)">' + esc(modelSel.user_excerpt) + '</span></div>';
}
if (candidates.length > 0) {
detailHtml += '<div class="table-wrap"><table><thead><tr><th>Candidate</th><th>Source</th><th>Provider</th><th>Breaker</th><th>Usable</th><th>Reason</th></tr></thead><tbody>';
candidates.forEach(function(c) {
detailHtml += '<tr>'
+ '<td class="card-mono">' + esc(c.model || '') + '</td>'
+ '<td>' + esc(c.source || '') + '</td>'
+ '<td>' + (c.provider_available ? '<span class="badge success">yes</span>' : '<span class="badge error">no</span>') + '</td>'
+ '<td>' + (c.breaker_blocked ? '<span class="badge error">open</span>' : '<span class="badge success">closed</span>') + '</td>'
+ '<td>' + (c.usable ? '<span class="badge success">yes</span>' : '<span class="badge error">no</span>') + '</td>'
+ '<td>' + esc(c.note || '') + '</td>'
+ '</tr>';
});
detailHtml += '</tbody></table></div>';
}
detailHtml += '</details>';
}
detailHtml += '<button class="btn secondary ctx-analyze-btn" data-analyze-turn="' + esc(self._ctxActiveTurn.id) + '" style="margin-top:0.75rem">Analyze with AI</button>';
var timelineHtml = self._ctxTurns.map(function(t) {
var isActive = t.id === self._ctxActiveTurn.id;
var cost = t.cost != null ? '$' + t.cost.toFixed(4) : '';
var turnLabel = sessionAssistantLabel(self._ctxSession, 'assistant');
return '<div class="ctx-timeline-item' + (isActive ? ' active' : '') + '" data-turn-id="' + esc(t.id) + '"><div style="font-size:0.6875rem;color:var(--muted)">' + esc(t.created_at || '') + '</div><div style="display:flex;justify-content:space-between;align-items:center;gap:0.25rem"><span class="badge muted" style="font-size:0.5625rem">' + esc(turnLabel) + '</span><span class="badge muted" style="font-size:0.5625rem">' + esc(t.model || '') + '</span><span style="font-size:0.6875rem;color:var(--success);margin-left:auto">' + cost + '</span><span class="badge muted" style="font-size:0.5625rem">View details \u203a</span></div></div>';
}).join('');
return liveBanner + '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem"><button class="btn secondary" style="font-size:0.75rem;padding:0.3rem 0.75rem" id="ctx-back-btn">\u25c0 Back</button><h3 style="margin:0">Turn Detail</h3></div><div class="ctx-explorer-grid"><div class="ctx-timeline">' + timelineHtml + '</div><div class="ctx-turn-detail">' + detailHtml + '</div></div>';
});
}
if (self._ctxSession) {
return Promise.all([
api('/api/sessions/' + encodeURIComponent(self._ctxSession.id) + '/turns').catch(function() { return { turns: [] }; }),
api('/api/sessions/' + encodeURIComponent(self._ctxSession.id) + '/insights').catch(function() { return { insights: [] }; }),
api('/api/sessions/' + encodeURIComponent(self._ctxSession.id) + '/feedback').catch(function() { return { feedback: [] }; })
]).then(function(results) {
var r = results[0], insightsData = results[1], fbData = results[2];
self._ctxTurns = r.turns || [];
var ctxFbMap = {};
(fbData.feedback || []).forEach(function(fb) { ctxFbMap[fb.turn_id] = fb; });
if (self._ctxTurns.length === 0) {
return liveBanner + '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem"><button class="btn secondary" style="font-size:0.75rem;padding:0.3rem 0.75rem" id="ctx-back-btn">\u25c0 Back</button><h3 style="margin:0">' + esc(self._ctxSession.nickname || self._ctxSession.id) + '</h3></div><div class="card"><p style="color:var(--muted)">No turns recorded for this session yet.</p><button class="btn secondary" data-page-nav="sessions">Open Sessions</button></div>';
}
var totalCost = 0, totalTokens = 0;
self._ctxTurns.forEach(function(t) { totalCost += t.cost || 0; totalTokens += (t.tokens_in || 0) + (t.tokens_out || 0); });
var gradedTurns = Object.keys(ctxFbMap).length;
var avgGrade = gradedTurns > 0 ? (Object.values(ctxFbMap).reduce(function(s, f) { return s + f.grade; }, 0) / gradedTurns).toFixed(1) : '\u2014';
var summaryHtml = '<div class="ctx-stats"><div class="ctx-stat"><div class="val">' + self._ctxTurns.length + '</div><div class="lbl">Turns</div></div><div class="ctx-stat"><div class="val">' + totalTokens.toLocaleString() + '</div><div class="lbl">Total Tokens</div></div><div class="ctx-stat"><div class="val">$' + totalCost.toFixed(4) + '</div><div class="lbl">Total Cost</div></div><div class="ctx-stat"><div class="val">' + avgGrade + '</div><div class="lbl">Avg Grade</div></div></div>';
var insights = (insightsData && insightsData.insights) || [];
var insightsHtml = '';
if (insights.length > 0) {
insightsHtml += '<div class="ctx-insights"><h4 style="margin:0 0 0.5rem;font-size:0.75rem;color:var(--muted)">SESSION INSIGHTS</h4><div class="ctx-tips">';
insights.forEach(function(tip) {
insightsHtml += '<span class="ctx-tip ' + esc(tip.severity) + '" title="' + esc(tip.suggestion) + '"><span class="tip-dot"></span>' + esc(tip.message) + '</span>';
});
insightsHtml += '</div></div>';
}
var timelineHtml = self._ctxTurns.map(function(t) {
var cost = t.cost != null ? '$' + t.cost.toFixed(4) : '';
var tokens = ((t.tokens_in || 0) + (t.tokens_out || 0)).toLocaleString();
var fb = ctxFbMap[t.id];
var gradeBadge = fb ? '<span class="grade-badge">' + '\u2605'.repeat(fb.grade) + '</span>' : '';
var turnLabel = sessionAssistantLabel(self._ctxSession, 'assistant');
return '<div class="ctx-timeline-item" data-turn-id="' + esc(t.id) + '" style="cursor:pointer"><div style="font-size:0.6875rem;color:var(--muted)">' + esc(t.created_at || '') + '</div><div style="display:flex;justify-content:space-between;align-items:center;gap:0.25rem"><span class="badge muted" style="font-size:0.5625rem">' + esc(turnLabel) + '</span><span class="badge muted" style="font-size:0.5625rem">' + esc(t.model || '') + '</span>' + gradeBadge + '<span style="font-size:0.6875rem">' + tokens + ' tokens</span><span style="font-size:0.6875rem;color:var(--success)">' + cost + '</span><span class="badge muted" style="font-size:0.5625rem">View details \u203a</span></div></div>';
}).join('');
return liveBanner + '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem"><button class="btn secondary" style="font-size:0.75rem;padding:0.3rem 0.75rem" id="ctx-back-btn">\u25c0 Back</button><h3 style="margin:0">' + esc(self._ctxSession.nickname || self._ctxSession.id) + '</h3></div>' + summaryHtml + insightsHtml + '<div class="ctx-timeline">' + timelineHtml + '</div>';
});
}
var visibleSessions = sessions.filter(function(s) { return (s.turn_count || 0) > 0; });
var rows = visibleSessions.map(function(s) {
var nick = s.nickname || truncate(s.id, 16);
var agentLabel = sessionAssistantLabel(s, s.agent_id || 'default');
return '<tr data-ctx-session=\'' + esc(JSON.stringify(s)) + '\' style="cursor:pointer"><td>' + esc(nick) + '</td><td><span class="badge muted" style="font-size:0.5625rem">' + esc(agentLabel) + '</span></td><td style="font-size:0.75rem;color:var(--muted)">' + esc(s.created_at || '') + '</td><td><button class="btn secondary" style="font-size:0.65rem;padding:0.2rem 0.5rem">Open</button></td></tr>';
}).join('');
if (!rows) {
return liveBanner + '<h3>Context</h3><p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">Select a session to inspect turn-by-turn context allocation, token budgets, and reasoning traces.</p><div class="card"><p style="color:var(--muted)">No sessions with recorded turns yet.</p><button class="btn secondary" data-page-nav="sessions">Go to Sessions</button></div>';
}
return liveBanner + '<h3>Context</h3><p style="color:var(--muted);font-size:0.875rem;margin-bottom:1rem">Select a session to inspect turn-by-turn context allocation, token budgets, and reasoning traces.</p><div class="table-wrap"><table><thead><tr><th>Session</th><th>Agent</th><th>Created</th><th>Action</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
});
},
renderSettings: function() {
var self = this;
return Promise.all([
api('/api/config'),
api('/api/config/capabilities').catch(function() { return { immutable_sections: ['server', 'a2a', 'wallet'] }; })
]).then(function(arr) {
var cfg = arr[0] || {};
var caps = arr[1] || {};
self._configCapabilities = caps;
var immutableSet = {};
(caps.immutable_sections || []).forEach(function(s) { immutableSet[s] = true; });
_cachedConfig = cfg;
if (!self._settingsDraft) self._settingsDraft = JSON.parse(JSON.stringify(cfg));
var mode = self._settingsMode;
var draft = self._getSettingsDraft();
var dirty = self._settingsDirty;
var dirtyDot = dirty ? '<span class="settings-dirty-dot" title="Unsaved changes"></span>' : '';
var hintsOn = hintsEnabled();
var hintSettings = '<div class="settings-section"><div class="settings-section-title">Dashboard Hints</div>'
+ '<div class="settings-row"><div class="settings-label">Hints</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" id="dashboard-hints-toggle"' + (hintsOn ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (hintsOn ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.75rem;color:var(--muted)">Turn contextual hints on/off. Dismissed hints stay hidden unless reset.</div>'
+ '<div style="margin-top:0.6rem"><button class="btn secondary" id="hints-reset-dismissed" style="font-size:0.7rem;padding:0.25rem 0.55rem">Reset dismissed hints</button></div>'
+ '</div>';
var tabs = '<div class="tabs" style="margin-bottom:1rem"><button class="' + (mode === 'form' ? 'active' : '') + '" data-settings-mode="form">Form</button><button class="' + (mode === 'models' ? 'active' : '') + '" data-settings-mode="models">Model Order</button><button class="' + (mode === 'access' ? 'active' : '') + '" data-settings-mode="access">Access Control</button><button class="' + (mode === 'json' ? 'active' : '') + '" data-settings-mode="json">JSON</button></div>';
var actions = '<div class="settings-actions"><button class="btn" id="settings-save" ' + (dirty ? '' : 'disabled style="opacity:0.4;pointer-events:none"') + '>Save</button><button class="btn secondary" id="settings-apply" ' + (dirty ? '' : 'disabled style="opacity:0.4;pointer-events:none"') + '>Apply</button><button class="btn secondary" id="settings-cancel" ' + (dirty ? '' : 'disabled style="opacity:0.4;pointer-events:none"') + '>Cancel</button>' + dirtyDot + '</div>';
if (mode === 'json') {
var jsonText = self._settingsJsonText != null ? self._settingsJsonText : JSON.stringify(draft, null, 2);
var lintMsg = '', lintCls = 'ok';
try { JSON.parse(jsonText); lintMsg = '\u2713 Valid JSON'; } catch (e) { lintMsg = '\u2717 ' + e.message; lintCls = 'err'; }
return tabs + hintSettings + '<div class="settings-editor"><textarea id="settings-json-editor" spellcheck="false">' + esc(jsonText) + '</textarea><div class="settings-lint ' + lintCls + '">' + esc(lintMsg) + '</div></div>' + actions;
}
if (mode === 'models') {
if (!self._settingsModelOrder) self._initModelOrderFromDraft();
var modelOrder = (self._settingsModelOrder || []).slice();
var modelsImmutable = !!immutableSet.models;
var modelRows = modelOrder.map(function(name, idx) {
var roleBadge = idx === 0
? '<span class="badge success" style="font-size:0.6rem">Primary</span>'
: '<span class="badge muted" style="font-size:0.6rem">Fallback ' + idx + '</span>';
var makePrimaryBtn = idx === 0 ? '' : '<button class="model-order-btn" data-model-order-make-primary="' + idx + '">Make primary</button>';
var removeBtn = '<button class="model-order-btn" data-model-order-remove="' + idx + '">Remove</button>';
var dragAttrs = modelsImmutable ? '' : (' draggable="true" data-model-order-item="' + idx + '"');
return '<div class="model-order-item"' + dragAttrs + '>'
+ '<div class="model-order-handle" title="' + (modelsImmutable ? 'Read only' : 'Drag to reorder') + '">\u22ee\u22ee</div>'
+ '<div class="model-order-name" title="' + esc(name) + '">' + esc(name) + '</div>'
+ roleBadge
+ '<div class="model-order-actions">' + makePrimaryBtn + '</div>'
+ (modelsImmutable ? '' : removeBtn)
+ '</div>';
}).join('');
if (!modelRows) modelRows = '<div class="card" style="padding:0.75rem;color:var(--muted)">No models configured. Add one below to set a primary model.</div>';
var knownModelOptions = self._renderModelOptionsHtml({
config: draft,
includeControlModes: false
});
var immutableNote = modelsImmutable
? '<div style="font-size:0.75rem;color:var(--muted);margin:0.35rem 0 0.85rem">Model order is immutable at runtime for this instance. Edit `ironclad.toml` and restart.</div>'
: '<div style="font-size:0.75rem;color:var(--muted);margin:0.35rem 0 0.85rem">Drag rows to reorder preference. Top row is primary; all others are fallback order.</div>';
var addRow = modelsImmutable ? '' : ('<div class="model-order-add"><input id="model-order-add-input" class="settings-input" list="model-order-known-list" type="text" placeholder="Add model (provider/name)"><datalist id="model-order-known-list">' + knownModelOptions + '</datalist><button class="btn secondary" id="model-order-add-btn">Add model</button></div>');
var providerAlerts = self._renderProviderDiscoveryAlerts();
return tabs
+ hintSettings
+ '<div class="settings-form"><div class="settings-section"><div class="settings-section-title">Model Order</div>'
+ providerAlerts
+ immutableNote
+ '<div class="model-order-list" id="model-order-list">' + modelRows + '</div>'
+ addRow
+ '</div></div>'
+ actions;
}
if (mode === 'access') {
var sec = draft.security || {};
var authLevels = [['External','External \u2014 safe tools only'],['Peer','Peer \u2014 safe + caution'],['SelfGenerated','SelfGenerated \u2014 safe + caution + dangerous'],['Creator','Creator \u2014 full access']];
var ceilLevels = [['External','External \u2014 safe tools only'],['Peer','Peer \u2014 safe + caution'],['SelfGenerated','SelfGenerated \u2014 safe + caution + dangerous']];
var mkOpts = function(levels, cur) { return levels.map(function(l) { return '<option value="' + l[0] + '"' + (cur === l[0] ? ' selected' : '') + '>' + l[1] + '</option>'; }).join(''); };
var accHtml = '';
accHtml += '<div class="settings-section"><div class="settings-section-title">Authority Matrix</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">What each authority level can do:</div>'
+ '<table style="width:100%;font-size:0.72rem;border-collapse:collapse">'
+ '<thead><tr style="border-bottom:1px solid var(--border)"><th style="text-align:left;padding:0.35rem 0.5rem">Level</th><th style="text-align:center;padding:0.35rem">Safe</th><th style="text-align:center;padding:0.35rem">Caution</th><th style="text-align:center;padding:0.35rem">Dangerous</th><th style="text-align:center;padding:0.35rem">Forbidden</th></tr></thead>'
+ '<tbody>'
+ '<tr style="border-bottom:1px solid var(--border)"><td style="padding:0.35rem 0.5rem"><strong>Creator</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td></tr>'
+ '<tr style="border-bottom:1px solid var(--border)"><td style="padding:0.35rem 0.5rem"><strong>SelfGenerated</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td></tr>'
+ '<tr style="border-bottom:1px solid var(--border)"><td style="padding:0.35rem 0.5rem"><strong>Peer</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td><td style="text-align:center">\u274c</td></tr>'
+ '<tr><td style="padding:0.35rem 0.5rem"><strong>External</strong></td><td style="text-align:center">\u2705</td><td style="text-align:center">\u274c</td><td style="text-align:center">\u274c</td><td style="text-align:center">\u274c</td></tr>'
+ '</tbody></table>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin-top:0.5rem">'
+ '<strong>Safe:</strong> conversation, summarization, web search. '
+ '<strong>Caution:</strong> read_file, write_file, delegation. '
+ '<strong>Dangerous:</strong> run_script, shell execution.'
+ '</div></div>';
accHtml += '<div class="settings-section"><div class="settings-section-title">Security Policy</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">'
+ 'Authority resolution: effective = min(max(positive grants\u2026), min(negative ceilings\u2026))'
+ '</div>';
var denyEmpty = sec.deny_on_empty_allowlist !== false;
accHtml += '<div class="settings-row"><div class="settings-label">Deny on empty allow-list</div>'
+ '<div class="settings-toggle-wrap"><label class="toggle">'
+ '<input type="checkbox" data-settings-path="security.deny_on_empty_allowlist"' + (denyEmpty ? ' checked' : '') + '>'
+ '<span class="toggle-track"></span></label>'
+ '<span class="settings-toggle-label">' + (denyEmpty ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'When enabled, channels with no allow-list entries reject all messages (secure default). When disabled, empty allow-lists permit all senders.'
+ '</div>';
var alAuth = sec.allowlist_authority || 'Peer';
accHtml += '<div class="settings-row"><div class="settings-label">Allow-list authority</div>'
+ '<select class="settings-input" data-settings-path="security.allowlist_authority" style="max-width:280px">'
+ mkOpts(authLevels, alAuth) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Authority granted to senders who pass a channel\u2019s allow-list check.'
+ '</div>';
var trAuth = sec.trusted_authority || 'Creator';
accHtml += '<div class="settings-row"><div class="settings-label">Trusted sender authority</div>'
+ '<select class="settings-input" data-settings-path="security.trusted_authority" style="max-width:280px">'
+ mkOpts(authLevels, trAuth) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Authority granted to senders listed in channels.trusted_sender_ids.'
+ '</div>';
var apiAuth = sec.api_authority || 'Creator';
accHtml += '<div class="settings-row"><div class="settings-label">API authority</div>'
+ '<select class="settings-input" data-settings-path="security.api_authority" style="max-width:280px">'
+ mkOpts(authLevels, apiAuth) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Authority for HTTP API and WebSocket requests.'
+ '</div>';
var threatCeil = sec.threat_caution_ceiling || 'External';
accHtml += '<div class="settings-row"><div class="settings-label">Threat scanner ceiling</div>'
+ '<select class="settings-input" data-settings-path="security.threat_caution_ceiling" style="max-width:280px">'
+ mkOpts(ceilLevels, threatCeil) + '</select></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Maximum authority when the threat scanner flags input as Caution. Must be below Creator.'
+ '</div>';
accHtml += '</div>';
// ── Filesystem Security section ────────────────────────────
var fsSec = (sec.filesystem || {});
var wsOnly = fsSec.workspace_only !== false;
var scriptConf = fsSec.script_fs_confinement !== false;
var protectedPaths = (fsSec.protected_paths || []).join('\n');
var extraProtected = (fsSec.extra_protected_paths || []).join('\n');
var scriptAllowed = (fsSec.script_allowed_paths || []).join('\n');
accHtml += '<div class="settings-section"><div class="settings-section-title">Filesystem Security</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.75rem">'
+ 'Controls filesystem access for agent tools and skill scripts.'
+ '</div>';
accHtml += '<div class="settings-row"><div class="settings-label">Workspace-only mode</div>'
+ '<div class="settings-toggle-wrap"><label class="toggle">'
+ '<input type="checkbox" data-settings-path="security.filesystem.workspace_only"' + (wsOnly ? ' checked' : '') + '>'
+ '<span class="toggle-track"></span></label>'
+ '<span class="settings-toggle-label">' + (wsOnly ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Restrict agent file tools to the workspace directory. Absolute paths outside workspace are denied.'
+ '</div>';
accHtml += '<div class="settings-row"><div class="settings-label">Script FS confinement</div>'
+ '<div class="settings-toggle-wrap"><label class="toggle">'
+ '<input type="checkbox" data-settings-path="security.filesystem.script_fs_confinement"' + (scriptConf ? ' checked' : '') + '>'
+ '<span class="toggle-track"></span></label>'
+ '<span class="settings-toggle-label">' + (scriptConf ? 'On' : 'Off') + '</span></div></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'OS-level sandbox for skill scripts (macOS sandbox-exec). Confines writes to workspace + /tmp.'
+ '</div>';
accHtml += '<div class="settings-row" style="align-items:start"><div class="settings-label" style="padding-top:0.3rem">Protected paths</div>'
+ '<textarea class="settings-input" data-settings-path="security.filesystem.protected_paths" data-settings-array="1" '
+ 'rows="6" style="font-family:var(--font-mono);font-size:0.7rem;resize:vertical"'
+ ' placeholder="One pattern per line">' + esc(protectedPaths) + '</textarea></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Blacklisted path patterns (case-insensitive substring match). One per line.'
+ '</div>';
accHtml += '<div class="settings-row" style="align-items:start"><div class="settings-label" style="padding-top:0.3rem">Extra protected paths</div>'
+ '<textarea class="settings-input" data-settings-path="security.filesystem.extra_protected_paths" data-settings-array="1" '
+ 'rows="3" style="font-family:var(--font-mono);font-size:0.7rem;resize:vertical"'
+ ' placeholder="Your custom patterns (merged with defaults)">' + esc(extraProtected) + '</textarea></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Additional patterns merged with the default list above.'
+ '</div>';
accHtml += '<div class="settings-row" style="align-items:start"><div class="settings-label" style="padding-top:0.3rem">Script allowed paths</div>'
+ '<textarea class="settings-input" data-settings-path="security.filesystem.script_allowed_paths" data-settings-array="1" '
+ 'rows="2" style="font-family:var(--font-mono);font-size:0.7rem;resize:vertical"'
+ ' placeholder="Absolute paths scripts may write to (one per line)">' + esc(scriptAllowed) + '</textarea></div>'
+ '<div style="font-size:0.68rem;color:var(--muted);margin:-0.25rem 0 0.6rem 0;padding-left:0.5rem">'
+ 'Additional absolute paths that sandboxed scripts may access for writing. One per line.'
+ '</div>';
accHtml += '</div>';
accHtml += '<div class="settings-section"><div class="settings-section-title">How Claims Are Resolved</div>'
+ '<div style="font-size:0.72rem;line-height:1.6;color:var(--text)">'
+ '<strong>Positive grants</strong> OR across layers \u2014 any layer can grant authority (best grant wins):<br>'
+ '<span style="color:var(--muted)">\u2022 Channel allow-list \u2192 allow-list authority \u2022 trusted_sender_ids \u2192 trusted authority \u2022 API key \u2192 API authority</span><br><br>'
+ '<strong>Negative ceilings</strong> AND across layers \u2014 strictest restriction wins:<br>'
+ '<span style="color:var(--muted)">\u2022 Threat scanner (Caution) \u2192 threat ceiling</span><br><br>'
+ '<strong>Final authority</strong> = min(best grant, strictest ceiling)'
+ '</div></div>';
return tabs + hintSettings + '<div class="settings-form">' + accHtml + '</div>' + actions;
}
var html = tabs + hintSettings + '<div class="settings-form">';
var specialSections = ['models', 'providers', 'wallet'];
var sections = Object.keys(draft);
sections.forEach(function(section) {
var sectionData = draft[section];
if (typeof sectionData !== 'object' || sectionData === null) return;
if (section === 'security') return; // rendered in Access Control tab
var isImmutableSection = !!immutableSet[section];
if (isImmutableSection) {
html += '<div class="settings-section"><div class="settings-section-title">' + esc(section) + ' <span class="badge muted" style="font-size:0.6rem">restart required</span></div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.5rem">This section is immutable at runtime. Edit `ironclad.toml` and restart.</div>'
+ '<pre class="card-mono" style="white-space:pre-wrap">' + esc(JSON.stringify(sectionData, null, 2)) + '</pre></div>';
return;
}
if (section === 'models') {
html += '<div class="settings-section"><div class="settings-section-title">Models</div>';
html += self._renderProviderDiscoveryAlerts();
var modeVal = sectionData.mode || sectionData.routing || 'standard';
html += '<div class="settings-row"><div class="settings-label">Mode</div><select class="settings-input" data-settings-path="models.mode" style="max-width:200px"><option value="standard"' + (modeVal === 'standard' ? ' selected' : '') + '>Standard</option><option value="creative"' + (modeVal === 'creative' ? ' selected' : '') + '>Creative</option></select></div>';
var primaryVal = sectionData.primary || '';
var modelOpts = self._renderModelOptionsHtml({
config: draft,
includeControlModes: false
});
html += '<div class="settings-row"><div class="settings-label">Primary</div><div style="position:relative;width:100%"><input class="settings-input" list="model-list" type="text" data-settings-path="models.primary" value="' + esc(primaryVal) + '" placeholder="Select or type a model"><datalist id="model-list">' + modelOpts + '</datalist></div></div>';
Object.keys(sectionData).forEach(function(key) {
if (key === 'mode' || key === 'routing' || key === 'primary') return;
var val = sectionData[key]; var path = 'models.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val !== 'object') {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
return;
}
if (section === 'providers') {
html += '<div class="settings-section"><div class="settings-section-title">Providers</div>';
var providerKeys = Object.keys(sectionData);
var knownProviders = ['anthropic', 'openai', 'google', 'ollama', 'mistral', 'cohere', 'deepseek', 'groq', 'xai', 'together', 'openrouter'];
providerKeys.forEach(function(pName) {
var pData = sectionData[pName];
if (typeof pData !== 'object' || pData === null) return;
var keyStatus = pData._key_status || 'missing';
var keySource = pData._key_source || 'unknown';
var keyBadge = '';
if (keyStatus === 'configured') {
keyBadge = '<span class="badge success" style="font-size:0.6rem;padding:0.1rem 0.4rem">Key: ' + esc(keySource) + '</span>';
} else if (keyStatus === 'not_required') {
keyBadge = '<span class="badge muted" style="font-size:0.6rem;padding:0.1rem 0.4rem">Local</span>';
} else {
keyBadge = '<span class="badge error" style="font-size:0.6rem;padding:0.1rem 0.4rem">Key missing</span>';
}
html += '<div class="settings-provider-card"><div class="settings-provider-header"><span class="settings-provider-name">' + esc(pName) + '</span>' + keyBadge + '</div>';
Object.keys(pData).forEach(function(pKey) {
if (pKey.charAt(0) === '_') return;
var pVal = pData[pKey]; var pPath = 'providers.' + pName + '.' + pKey;
if (typeof pVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(pKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(pPath) + '"' + (pVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (pVal ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof pVal === 'object' && pVal !== null) {
html += '<div class="settings-row"><div class="settings-label">' + esc(pKey) + '</div><span class="badge muted" style="font-size:0.6rem">' + Object.keys(pVal).length + ' entries</span></div>';
} else {
var type = typeof pVal === 'number' ? 'number' : 'text';
var sv = (pVal == null || pVal === '') ? '' : esc(String(pVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(pKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(pPath) + '" value="' + sv + '" placeholder="none"></div>';
}
});
if (keyStatus === 'missing') {
html += '<div class="key-manage-row" data-provider="' + esc(pName) + '">'
+ '<input type="password" placeholder="Paste API key\u2026" class="key-input" autocomplete="off">'
+ '<button class="key-manage-btn save" data-action="save-key">Save to keystore</button>'
+ '<span class="key-manage-msg"></span></div>';
} else if (keyStatus === 'configured' && keySource === 'keystore') {
html += '<div class="key-manage-row" data-provider="' + esc(pName) + '">'
+ '<span style="font-size:0.7rem;color:var(--muted)">Key stored in encrypted keystore</span>'
+ '<button class="key-manage-btn remove" data-action="remove-key">Remove</button>'
+ '<span class="key-manage-msg"></span></div>';
}
html += '</div>';
});
var providerSuggestions = {};
knownProviders.forEach(function(name) { providerSuggestions[name] = true; });
providerKeys.forEach(function(name) { providerSuggestions[name] = true; });
var providerOptions = Object.keys(providerSuggestions).sort().map(function(name) {
return '<option value="' + esc(name) + '"></option>';
}).join('');
html += '<div class="model-order-add" style="margin-top:0.5rem">'
+ '<input id="add-provider-input" class="settings-input" list="provider-known-list" type="text" placeholder="Add provider (e.g. mistral, cohere, deepseek)">'
+ '<datalist id="provider-known-list">' + providerOptions + '</datalist>'
+ '<button class="btn secondary" id="add-provider">Add Provider</button>'
+ '</div></div>';
return;
}
if (section === 'channels') {
html += '<div class="settings-section"><div class="settings-section-title">channels</div>';
var handledChannelKeys = {};
var renderListEditor = function(opts) {
var uniqMap = {};
(Array.isArray(opts.values) ? opts.values : []).forEach(function(v) {
var asStr = String(v == null ? '' : v).trim();
if (asStr) uniqMap[asStr] = true;
});
var values = Object.keys(uniqMap);
var suggMap = {};
(Array.isArray(opts.suggestions) ? opts.suggestions : []).forEach(function(v) {
var asStr = String(v == null ? '' : v).trim();
if (asStr) suggMap[asStr] = true;
});
values.forEach(function(v) { suggMap[v] = true; });
var optionHtml = Object.keys(suggMap).sort().map(function(v) { return '<option value="' + esc(v) + '"></option>'; }).join('');
var chipsHtml = values.map(function(v) {
return '<span class="badge muted" style="display:inline-flex;align-items:center;gap:0.3rem;margin:0.15rem 0.25rem 0.15rem 0">'
+ '<span>' + esc(v) + '</span>'
+ '<button class="btn secondary ' + esc(opts.removeClass) + '" ' + esc(opts.dataAttr) + '="' + esc(v) + '" style="font-size:0.6rem;padding:0.05rem 0.25rem;line-height:1">x</button>'
+ '</span>';
}).join('');
return '<div class="settings-row"><div class="settings-label">' + esc(opts.label) + '</div><div>'
+ '<div class="model-order-add" style="margin-bottom:0.35rem">'
+ '<input id="' + esc(opts.inputId) + '" class="settings-input" list="' + esc(opts.listId) + '" type="text" placeholder="' + esc(opts.placeholder || 'Add item') + '">'
+ '<datalist id="' + esc(opts.listId) + '">' + optionHtml + '</datalist>'
+ '<button class="btn secondary" id="' + esc(opts.addId) + '">Add</button>'
+ '</div>'
+ '<div>' + (chipsHtml || '<span style="font-size:0.72rem;color:var(--muted)">No values configured.</span>') + '</div>'
+ '</div></div>';
};
var startupRaw = sectionData.startup_announcements;
var startupSelected = [];
if (Array.isArray(startupRaw)) startupSelected = startupRaw.map(function(v) { return String(v || '').trim().toLowerCase(); }).filter(Boolean);
else if (typeof startupRaw === 'string') startupSelected = startupRaw.split(',').map(function(v) { return String(v || '').trim().toLowerCase(); }).filter(Boolean);
var startupSet = {};
startupSelected.forEach(function(v) { startupSet[v] = true; });
var startupKnown = ['telegram', 'whatsapp', 'signal', 'discord', 'email', 'voice'];
var startupChecks = startupKnown.map(function(ch) {
return '<label style="display:inline-flex;align-items:center;gap:0.35rem;margin:0.2rem 0.5rem 0.2rem 0"><input type="checkbox" class="startup-ann-check" data-startup-channel="' + esc(ch) + '"' + (startupSet[ch] ? ' checked' : '') + '> <span style="font-size:0.75rem">' + esc(ch) + '</span></label>';
}).join('');
html += '<div class="settings-row"><div class="settings-label">startup_announcements</div><div>'
+ '<div style="display:flex;flex-wrap:wrap">' + startupChecks + '</div>'
+ '<div style="font-size:0.7rem;color:var(--muted);margin-top:0.35rem">Select channels to announce on startup. Leave all unchecked to disable announcements.</div>'
+ '</div></div>';
var trustedList = Array.isArray(sectionData.trusted_sender_ids) ? sectionData.trusted_sender_ids.map(function(v) { return String(v || '').trim(); }).filter(Boolean) : [];
var suggestionSet = {};
var tg = sectionData.telegram || {};
var wa = sectionData.whatsapp || {};
var sig = sectionData.signal || {};
(Array.isArray(tg.allowed_chat_ids) ? tg.allowed_chat_ids : []).forEach(function(v) { suggestionSet[String(v)] = true; });
(Array.isArray(wa.allowed_numbers) ? wa.allowed_numbers : []).forEach(function(v) { suggestionSet[String(v)] = true; });
(Array.isArray(sig.allowed_numbers) ? sig.allowed_numbers : []).forEach(function(v) { suggestionSet[String(v)] = true; });
var suggestionOptions = Object.keys(suggestionSet).sort().map(function(v) { return '<option value="' + esc(v) + '"></option>'; }).join('');
var trustedBadges = trustedList.map(function(v) {
return '<span class="badge muted" style="display:inline-flex;align-items:center;gap:0.3rem;margin:0.15rem 0.25rem 0.15rem 0">'
+ '<span>' + esc(v) + '</span>'
+ '<button class="btn secondary trusted-sender-remove" data-trusted-sender="' + esc(v) + '" style="font-size:0.6rem;padding:0.05rem 0.25rem;line-height:1">x</button>'
+ '</span>';
}).join('');
html += '<div class="settings-row"><div class="settings-label">trusted_sender_ids</div><div>'
+ '<div class="model-order-add" style="margin-bottom:0.35rem">'
+ '<input id="trusted-sender-input" class="settings-input" list="trusted-sender-known-list" type="text" placeholder="Add sender id and click Add">'
+ '<datalist id="trusted-sender-known-list">' + suggestionOptions + '</datalist>'
+ '<button class="btn secondary" id="trusted-sender-add">Add</button>'
+ '</div>'
+ '<div id="trusted-sender-chiplist">' + (trustedBadges || '<span style="font-size:0.72rem;color:var(--muted)">No trusted senders configured.</span>') + '</div>'
+ '</div></div>';
var phoneSuggestions = trustedList.filter(function(v) { return /^\+?[0-9][0-9\-\s()]+$/.test(v); });
var chatIdSuggestions = trustedList.filter(function(v) { return /^-?[0-9]+$/.test(v); });
var emailSuggestions = trustedList.filter(function(v) { return String(v).indexOf('@') !== -1; });
if (sectionData.telegram && typeof sectionData.telegram === 'object') {
handledChannelKeys.telegram = true;
var tg = sectionData.telegram;
var tgVals = Array.isArray(tg.allowed_chat_ids) ? tg.allowed_chat_ids : [];
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">telegram</div>';
html += renderListEditor({
label: 'allowed_chat_ids',
values: tgVals,
suggestions: chatIdSuggestions.concat(tgVals.map(function(v) { return String(v); })),
inputId: 'telegram-allowed-chat-input',
listId: 'telegram-allowed-chat-list',
addId: 'telegram-allowed-chat-add',
removeClass: 'telegram-allowed-chat-remove',
dataAttr: 'data-telegram-chat-id',
placeholder: 'Add allowed chat id (e.g. 123456789)'
});
Object.keys(tg).forEach(function(subKey) {
if (subKey === 'allowed_chat_ids') return;
var subVal = tg[subKey]; var subPath = 'channels.telegram.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
}
if (sectionData.whatsapp && typeof sectionData.whatsapp === 'object') {
handledChannelKeys.whatsapp = true;
var waCfg = sectionData.whatsapp;
var waVals = Array.isArray(waCfg.allowed_numbers) ? waCfg.allowed_numbers : [];
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">whatsapp</div>';
html += renderListEditor({
label: 'allowed_numbers',
values: waVals,
suggestions: phoneSuggestions.concat(waVals),
inputId: 'whatsapp-allowed-number-input',
listId: 'whatsapp-allowed-number-list',
addId: 'whatsapp-allowed-number-add',
removeClass: 'whatsapp-allowed-number-remove',
dataAttr: 'data-whatsapp-number',
placeholder: 'Add allowed number (e.g. +15551234567)'
});
Object.keys(waCfg).forEach(function(subKey) {
if (subKey === 'allowed_numbers') return;
var subVal = waCfg[subKey]; var subPath = 'channels.whatsapp.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
}
if (sectionData.signal && typeof sectionData.signal === 'object') {
handledChannelKeys.signal = true;
var sigCfg = sectionData.signal;
var sigVals = Array.isArray(sigCfg.allowed_numbers) ? sigCfg.allowed_numbers : [];
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">signal</div>';
html += renderListEditor({
label: 'allowed_numbers',
values: sigVals,
suggestions: phoneSuggestions.concat(sigVals),
inputId: 'signal-allowed-number-input',
listId: 'signal-allowed-number-list',
addId: 'signal-allowed-number-add',
removeClass: 'signal-allowed-number-remove',
dataAttr: 'data-signal-number',
placeholder: 'Add allowed number (e.g. +15551234567)'
});
Object.keys(sigCfg).forEach(function(subKey) {
if (subKey === 'allowed_numbers') return;
var subVal = sigCfg[subKey]; var subPath = 'channels.signal.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
}
if (sectionData.discord && typeof sectionData.discord === 'object') {
handledChannelKeys.discord = true;
var dcCfg = sectionData.discord;
var dcVals = Array.isArray(dcCfg.allowed_guild_ids) ? dcCfg.allowed_guild_ids : [];
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">discord</div>';
html += renderListEditor({
label: 'allowed_guild_ids',
values: dcVals,
suggestions: dcVals,
inputId: 'discord-allowed-guild-input',
listId: 'discord-allowed-guild-list',
addId: 'discord-allowed-guild-add',
removeClass: 'discord-allowed-guild-remove',
dataAttr: 'data-discord-guild-id',
placeholder: 'Add allowed guild id'
});
Object.keys(dcCfg).forEach(function(subKey) {
if (subKey === 'allowed_guild_ids') return;
var subVal = dcCfg[subKey]; var subPath = 'channels.discord.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
}
if (sectionData.email && typeof sectionData.email === 'object') {
handledChannelKeys.email = true;
var emailCfg = sectionData.email;
var senderVals = Array.isArray(emailCfg.allowed_senders) ? emailCfg.allowed_senders : [];
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">email</div>';
html += renderListEditor({
label: 'allowed_senders',
values: senderVals,
suggestions: emailSuggestions.concat(senderVals),
inputId: 'email-allowed-sender-input',
listId: 'email-allowed-sender-list',
addId: 'email-allowed-sender-add',
removeClass: 'email-allowed-sender-remove',
dataAttr: 'data-email-sender',
placeholder: 'Add allowed sender email'
});
Object.keys(emailCfg).forEach(function(subKey) {
if (subKey === 'allowed_senders') return;
var subVal = emailCfg[subKey]; var subPath = 'channels.email.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
}
Object.keys(sectionData).forEach(function(key) {
if (key === 'startup_announcements' || key === 'trusted_sender_ids' || handledChannelKeys[key]) return;
var val = sectionData[key];
var path = section + '.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val === 'object' && val !== null) {
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">' + esc(key) + '</div>';
Object.keys(val).forEach(function(subKey) {
var subVal = val[subKey]; var subPath = path + '.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
} else {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
return;
}
if (section === 'wallet') {
html += '<div class="settings-section"><div class="settings-section-title">Wallet</div>';
var chainPresetBtns = '<div style="display:flex;gap:0.375rem;flex-wrap:wrap;margin-bottom:0.75rem">';
Object.keys(self._CHAIN_PRESETS).forEach(function(name) {
var preset = self._CHAIN_PRESETS[name];
var isActive = String(sectionData.chain_id) === String(preset.chain_id);
chainPresetBtns += '<button class="chain-preset-btn' + (isActive ? ' active' : '') + '" data-chain-preset="' + esc(name) + '">' + esc(name) + '</button>';
});
chainPresetBtns += '</div>';
html += '<div class="settings-row"><div class="settings-label">Chain</div><div style="width:100%">' + chainPresetBtns + '</div></div>';
Object.keys(sectionData).forEach(function(key) {
var val = sectionData[key]; var path = 'wallet.' + key;
if (key === 'private_key' || key === 'mnemonic') return;
var isRpc = key.toLowerCase().indexOf('rpc') !== -1;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
var ph = isRpc ? 'Auto-populated from chain preset' : 'none';
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="' + ph + '"></div>';
}
});
html += '</div>';
return;
}
if (section === 'treasury') {
html += '<div class="settings-section"><div class="settings-section-title">Treasury</div>';
Object.keys(sectionData).forEach(function(key) {
if (key === 'revenue_swap') return;
var val = sectionData[key];
var path = section + '.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val !== 'object') {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
html += self._renderRevenueSwapSettings(sectionData.revenue_swap || {});
return;
}
html += '<div class="settings-section"><div class="settings-section-title">' + esc(section) + '</div>';
Object.keys(sectionData).forEach(function(key) {
var val = sectionData[key];
var path = section + '.' + key;
if (typeof val === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(path) + '"' + (val ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (val ? 'On' : 'Off') + '</span></div></div>';
} else if (typeof val === 'object' && val !== null) {
html += '<div class="settings-nested"><div style="font-size:0.625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);margin-bottom:0.5rem">' + esc(key) + '</div>';
Object.keys(val).forEach(function(subKey) {
var subVal = val[subKey]; var subPath = path + '.' + subKey;
var type = typeof subVal === 'number' ? 'number' : 'text';
if (typeof subVal === 'boolean') {
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><div class="settings-toggle-wrap"><label class="toggle"><input type="checkbox" data-settings-path="' + esc(subPath) + '"' + (subVal ? ' checked' : '') + '><span class="toggle-track"></span></label><span class="settings-toggle-label">' + (subVal ? 'On' : 'Off') + '</span></div></div>';
} else {
var ssv = (subVal == null || subVal === '') ? '' : esc(String(subVal));
html += '<div class="settings-row"><div class="settings-label">' + esc(subKey) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(subPath) + '" value="' + ssv + '" placeholder="none"></div>';
}
});
html += '</div>';
} else {
var type = typeof val === 'number' ? 'number' : 'text';
var sv = (val == null || val === '') ? '' : esc(String(val));
html += '<div class="settings-row"><div class="settings-label">' + esc(key) + '</div><input class="settings-input" type="' + type + '" data-settings-path="' + esc(path) + '" value="' + sv + '" placeholder="none"></div>';
}
});
html += '</div>';
});
html += '</div>' + actions;
return html;
});
},
_setNestedValue: function(obj, path, value) { var parts = path.split('.'); var cur = obj; for (var i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]]; } cur[parts[parts.length - 1]] = value; },
_getNestedValue: function(obj, path) { var parts = path.split('.'); var cur = obj; for (var i = 0; i < parts.length; i++) { if (cur == null) return undefined; cur = cur[parts[i]]; } return cur; },
renderWorkspace: function() {
return api('/api/workspace/state').then(function(data) {
_cachedWorkspace = data;
return '<div id="ws-wrap" style="position:relative;width:100%;height:100%;min-height:0;overflow:hidden"><canvas id="ws-canvas" style="display:block;border-radius:var(--radius)"></canvas></div>';
});
}
};
function onHash() {
var hash = (window.location.hash || '#overview').slice(1) || 'overview';
if (hash === 'roster') { hash = 'agents'; App._agentsTab = 'roster'; window.location.hash = 'agents'; return; }
if (pages.indexOf(hash) === -1) hash = 'overview';
ensureAuth().then(function() {
maybePromptHintPreference();
refreshSidebarIdentity();
App.navigate(hash);
});
}
document.querySelectorAll('.sidebar-nav a, .mobile-nav a').forEach(function(a) {
a.addEventListener('click', function(ev) {
ev.preventDefault();
var p = a.getAttribute('data-page');
if (p === 'efficiency') App._effTab = 'performance';
if (p) window.location.hash = p;
var sb = document.getElementById('sidebar');
if (sb && window.innerWidth <= 768) sb.classList.remove('open');
});
});
var themeNames = { 'ai-purple': 'AI Black & Purple (Default)', 'crt-orange': 'CRT Orange', 'crt-green': 'CRT Green', 'psychedelic': 'Psychedelic Freakout' };
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
document.querySelectorAll('.theme-option').forEach(function(o) { o.classList.toggle('active', o.getAttribute('data-theme-set') === theme); });
var nameEl = document.getElementById('theme-name'); if (nameEl) nameEl.textContent = themeNames[theme] || theme;
}
var savedTheme = localStorage.getItem('ironclad-theme');
if (savedTheme && themeNames[savedTheme]) applyTheme(savedTheme);
var themeToggle = document.getElementById('theme-toggle');
var themeDropdown = document.getElementById('theme-dropdown');
if (themeToggle && themeDropdown) {
themeToggle.addEventListener('click', function(e) { e.stopPropagation(); themeDropdown.classList.toggle('open'); });
document.addEventListener('click', function() { themeDropdown.classList.remove('open'); });
themeDropdown.addEventListener('click', function(e) {
var opt = e.target.closest('[data-theme-set]'); if (!opt) return;
var theme = opt.getAttribute('data-theme-set');
applyTheme(theme);
localStorage.setItem('ironclad-theme', theme);
themeDropdown.classList.remove('open');
if (App.page === 'overview') App.navigate('overview');
});
}
var collapseBtn = document.getElementById('sidebar-collapse');
if (collapseBtn) {
collapseBtn.addEventListener('click', function() {
var sb = document.getElementById('sidebar');
if (sb) { sb.classList.toggle('collapsed'); collapseBtn.title = sb.classList.contains('collapsed') ? 'Expand sidebar' : 'Collapse sidebar'; }
});
}
window.addEventListener('hashchange', onHash);
var content = document.getElementById('content');
if (content) content.addEventListener('click', function(e) {
var enableCacheBtn = e.target.closest('[data-action="enable-semantic-cache"]');
if (enableCacheBtn) {
if (enableCacheBtn.disabled) return;
var modelName = enableCacheBtn.getAttribute('data-model') || 'model';
var oldText = enableCacheBtn.textContent;
enableCacheBtn.disabled = true;
enableCacheBtn.textContent = 'Applying...';
api('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cache: { enabled: true } })
}).then(function() {
if (!_cachedConfig) _cachedConfig = {};
if (!_cachedConfig.cache) _cachedConfig.cache = {};
_cachedConfig.cache.enabled = true;
toast('Semantic caching enabled for runtime (' + modelName + ').');
enableCacheBtn.textContent = 'Enabled';
}).catch(function(err) {
enableCacheBtn.disabled = false;
enableCacheBtn.textContent = oldText;
toast(err.message || 'Failed to enable semantic caching');
});
return;
}
var navBtn = e.target.closest('[data-page-nav]');
if (navBtn) {
var nextPage = navBtn.getAttribute('data-page-nav');
if (nextPage === 'efficiency') App._effTab = 'performance';
App.navigate(nextPage);
return;
}
var graphNode = e.target.closest('[data-node-model]');
if (graphNode) {
App._modelGraphFocusModel = graphNode.getAttribute('data-node-model');
App._modelGraphFocusEdge = null;
App.navigate('efficiency');
return;
}
var graphEdge = e.target.closest('[data-edge-key]');
if (graphEdge) {
App._modelGraphFocusEdge = graphEdge.getAttribute('data-edge-key');
App._modelGraphFocusModel = null;
App.navigate('efficiency');
return;
}
if (e.target.closest('#model-graph-clear-focus')) {
App._modelGraphFocusModel = null;
App._modelGraphFocusEdge = null;
App.navigate('efficiency');
return;
}
if (e.target.closest('#routing-profile-reset')) {
App._routingProfileDraft = normalizeRoutingProfile(App._routingProfileDefaults
? { correctness: App._routingProfileDefaults.correctness, cost: App._routingProfileDefaults.cost, speed: App._routingProfileDefaults.speed }
: { correctness: 0.0, cost: 0.0, speed: 0.0 });
App.navigate('efficiency');
return;
}
if (e.target.closest('#routing-profile-apply')) {
var applyBtn = e.target.closest('#routing-profile-apply');
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Applying...'; }
var patch = projectRoutingPatchFromProfile(App._routingProfileDraft || App._routingProfileDefaults || { correctness: 0, cost: 0, speed: 0 });
api('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch)
}).then(function(resp) {
App._routingProfilePersistedAt = new Date().toISOString();
App._routingProfilePersisted = !!(resp && resp.persisted);
toast(App._routingProfilePersisted ? 'Routing profile applied and persisted.' : 'Routing profile applied.');
App.navigate('efficiency');
}).catch(function(err) {
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Apply profile'; }
toast(err.message || 'Failed to apply routing profile');
});
return;
}
if (e.target.closest('#ov-toggle-details')) {
App._overviewShowDetails = !App._overviewShowDetails;
var toggle = document.getElementById('ov-toggle-details');
if (toggle) toggle.textContent = App._overviewShowDetails ? 'Hide details' : 'Show details';
App.refreshOverview();
return;
}
if (e.target.closest('#dismiss-onboarding')) {
window.localStorage.setItem('ic_dash_onboarding_dismissed', '1');
var onboarding = document.getElementById('ov-onboarding');
if (onboarding) onboarding.style.display = 'none';
return;
}
var dismissHintBtn = e.target.closest('[data-dismiss-hint]');
if (dismissHintBtn) {
var hintId = dismissHintBtn.getAttribute('data-dismiss-hint');
dismissHint(hintId);
var hintNode = dismissHintBtn.closest('.hint-banner');
if (hintNode) hintNode.remove();
return;
}
if (e.target.closest('#btn-back-sessions')) {
App._activeSession = null; App.navigate('sessions'); return;
}
if (e.target.closest('#sess-prev')) { App._sessionsPage = Math.max(0, (App._sessionsPage || 0) - 1); App.navigate('sessions'); return; }
if (e.target.closest('#sess-next')) { App._sessionsPage = (App._sessionsPage || 0) + 1; App.navigate('sessions'); return; }
if (e.target.closest('#mem-prev')) { App._memoryPage = Math.max(0, (App._memoryPage || 0) - 1); App.navigate('memory'); return; }
if (e.target.closest('#mem-next')) { App._memoryPage = (App._memoryPage || 0) + 1; App.navigate('memory'); return; }
if (e.target.closest('.session-delete-btn')) {
var delId = e.target.closest('.session-delete-btn').getAttribute('data-delete-session');
if (delId && confirm('Delete session ' + delId.substring(0, 8) + '…?')) {
api('/api/sessions/' + encodeURIComponent(delId), { method: 'DELETE' }).then(function() { toast('Session deleted'); App.navigate('sessions'); }).catch(function(err) { toast(err.message || 'Failed'); });
}
return;
}
if (e.target.closest('#btn-new-session')) {
App._resolveActiveAgentId().then(function(agentId) {
return api('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent_id: agentId }) }).then(function(r) {
var sid = r.session_id || r.id;
App._activeSession = { id: sid, agent_id: agentId, agent_name: AGENT_DISPLAY_NAME || null };
dismissHint('sessions-helper');
try { window.localStorage.setItem('ic_sessions_helper_dismissed', '1'); } catch (_) {}
toast('Session created');
App.navigate('sessions');
});
}).catch(function(err) { toast(err.message || 'Failed'); });
return;
}
if (e.target.closest('#btn-send-msg')) {
if (App._sendingMessage) return;
var input = document.getElementById('session-msg-input');
var msg = input ? input.value.trim() : '';
if (!msg || !App._activeSession) return;
App._sendingMessage = true;
input.value = '';
input.disabled = true;
var sendBtn = document.getElementById('btn-send-msg');
if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = 'Thinking\u2026'; }
var thread = document.querySelector('.message-thread');
if (thread) {
var userBubble = document.createElement('div');
userBubble.className = 'message user';
setHtml(userBubble, '<div class="message-role">user</div><div>' + renderSafeMarkdown(msg) + '</div>');
thread.appendChild(userBubble);
var thinkEl = document.createElement('div');
thinkEl.className = 'thinking-indicator';
thinkEl.id = 'thinking-bubble';
setHtml(thinkEl, '<span class="thinking-brain">\uD83E\uDDE0</span><span class="thinking-dots"><span></span><span></span><span></span></span>');
thread.appendChild(thinkEl);
thread.scrollTop = thread.scrollHeight;
}
function unlockChat() {
App._sendingMessage = false;
var tb = document.getElementById('thinking-bubble');
if (tb) tb.remove();
if (input) { input.disabled = false; input.focus(); }
if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = 'Send'; }
}
(async function streamMessage() {
var asstBubble = null;
var contentEl = null;
var streamModel = '';
var accumulated = '';
try {
var resp = await fetch('/api/agent/message/stream', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ content: msg, session_id: App._activeSession.id })
});
if (!resp.ok) {
var errBody = await resp.text();
try { var p = JSON.parse(errBody); throw new Error(p.error || errBody); } catch(ex) { if (ex.message) throw ex; throw new Error(errBody); }
}
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var sseBuffer = '';
while (true) {
var result = await reader.read();
if (result.done) break;
sseBuffer += decoder.decode(result.value, { stream: true });
var lines = sseBuffer.split('\n');
sseBuffer = lines.pop() || '';
for (var li = 0; li < lines.length; li++) {
var line = lines[li].trim();
if (!line.startsWith('data:')) continue;
var payload = line.substring(5).trim();
if (!payload || payload === '[DONE]') continue;
try { var d = JSON.parse(payload); } catch(ex) { continue; }
if (d.type === 'stream_start') {
if (d.session_id) App._activeSession.id = d.session_id;
App._liveStreamTurn = {
turn_id: d.turn_id || '',
session_id: d.session_id || App._activeSession.id || '',
model: d.model || ''
};
if (App.page === 'context') App.navigate('context');
streamModel = d.model || '';
var tb2 = document.getElementById('thinking-bubble');
if (tb2) tb2.remove();
if (thread) {
asstBubble = document.createElement('div');
asstBubble.className = 'message assistant';
var assistantLabel = sessionAssistantLabel(App._activeSession, 'assistant');
var roleHtml = '<div class="message-role">' + esc(assistantLabel);
if (streamModel) roleHtml += ' <span class="badge muted" style="font-size:0.5625rem;margin-right:0.375rem">' + esc(streamModel) + '</span>';
roleHtml += ' <span class="badge" style="font-size:0.5rem;background:var(--accent);color:#fff">streaming</span>';
roleHtml += '</div>';
contentEl = document.createElement('div');
contentEl.className = 'streaming-content';
setHtml(asstBubble, roleHtml);
asstBubble.appendChild(contentEl);
thread.appendChild(asstBubble);
}
} else if (d.type === 'chunk' && d.delta) {
accumulated += d.delta;
if (contentEl) { setHtml(contentEl, renderSafeMarkdown(accumulated)); thread.scrollTop = thread.scrollHeight; }
if (sendBtn) sendBtn.textContent = 'Streaming\u2026';
} else if (d.type === 'stream_end') {
if (!d.turn_id || (App._liveStreamTurn && App._liveStreamTurn.turn_id === d.turn_id)) {
App._liveStreamTurn = null;
if (App.page === 'context') App.navigate('context');
}
if (asstBubble) {
var endModel = d.model || streamModel;
var finalMeta = '';
if (endModel) finalMeta += '<span class="badge muted" style="font-size:0.5625rem;margin-right:0.375rem">' + esc(endModel) + '</span>';
if (d.tokens_in || d.tokens_out) finalMeta += '<span class="badge muted" style="font-size:0.5rem;margin-right:0.375rem">' + (d.tokens_in||0) + '/' + (d.tokens_out||0) + ' tokens</span>';
var assistantLabelFinal = sessionAssistantLabel(App._activeSession, 'assistant');
setHtml(asstBubble, '<div class="message-role">' + esc(assistantLabelFinal) + (finalMeta ? ' ' + finalMeta : '') + '</div><div>' + renderSafeMarkdown(accumulated) + '</div>');
}
} else if (d.type === 'error') {
toast(d.error || 'Stream error');
}
}
}
} catch(err) {
var errMsg2 = err.message || 'Agent failed to respond';
toast(errMsg2);
}
unlockChat();
})();
return;
}
var rosterCard = e.target.closest('.roster-card[data-roster-id]');
if (rosterCard) {
var agentId = rosterCard.getAttribute('data-roster-id');
Promise.all([
api('/api/roster'),
App._loadAvailableModels({ nonBlocking: true, skipFetch: true }),
api('/api/config').catch(function() { return { models: {} }; })
]).then(function(arr) {
var data = arr[0] || { roster: [] };
var availableModels = arr[1] || [];
var cfgModels = (arr[2] && arr[2].models) || {};
var agent = (data.roster || []).find(function(a) { return (a.name || a.id) === agentId; });
if (!agent) return;
var modal = document.getElementById('roster-modal');
if (!modal) return;
var isCmd = agent.role === 'orchestrator';
var color = agent.color || 'var(--accent)';
var displayName = agent.display_name || agent.name;
var stateColor = agent.state === 'Running' ? '#22c55e' : (agent.state === 'Error' ? '#ef4444' : (agent.state === 'Disabled' ? '#71717a' : '#eab308'));
var roleLabel = isCmd ? 'ORCHESTRATOR' : (agent.role === 'model-proxy' ? 'MODEL PROXY' : 'SUBAGENT');
var orchestratorOrder = [];
var orchestratorOrderState = [];
var rosterDragModelIndex = null;
function pushOrchestratorModel(v) {
var s = String(v || '').trim();
if (!s) return;
if (orchestratorOrder.indexOf(s) === -1) orchestratorOrder.push(s);
}
if (isCmd) {
pushOrchestratorModel(cfgModels.primary || agent.model);
(cfgModels.fallbacks || []).forEach(pushOrchestratorModel);
if (!orchestratorOrder.length) pushOrchestratorModel(agent.model);
orchestratorOrderState = orchestratorOrder.slice();
}
function renderOrchestratorOrderRows(order) {
if (!order.length) {
return '<div class="card" style="padding:0.6rem;color:var(--muted)">No models added yet.</div>';
}
return order.map(function(name, idx) {
var roleBadge = idx === 0
? '<span class="badge success" style="font-size:0.6rem">Primary</span>'
: '<span class="badge muted" style="font-size:0.6rem">Fallback ' + idx + '</span>';
var makePrimaryBtn = idx === 0 ? '' : '<button class="model-order-btn" data-roster-model-order-primary="' + idx + '">Make primary</button>';
var removeBtn = '<button class="model-order-btn" data-roster-model-order-remove="' + idx + '">Remove</button>';
return '<div class="model-order-item" draggable="true" data-roster-model-order-item="' + idx + '">'
+ '<div class="model-order-handle" title="Drag to reorder">\u22ee\u22ee</div>'
+ '<div class="model-order-name" title="' + esc(name) + '">' + esc(name) + '</div>'
+ roleBadge
+ '<div class="model-order-actions">' + makePrimaryBtn + '</div>'
+ removeBtn
+ '</div>';
}).join('');
}
function rerenderOrchestratorOrderList() {
var listEl = document.getElementById('roster-model-order-list');
if (listEl) setHtml(listEl, renderOrchestratorOrderRows(orchestratorOrderState));
}
var voice = agent.voice || {};
var voiceHtml = '';
if (voice.formality || voice.proactiveness) {
var attrs = [
{ label: 'Formality', val: voice.formality },
{ label: 'Proactive', val: voice.proactiveness },
{ label: 'Verbosity', val: voice.verbosity },
{ label: 'Humor', val: voice.humor },
{ label: 'Domain', val: voice.domain }
];
voiceHtml = '<div style="margin-top:1rem"><div style="font-size:0.75rem;font-weight:600;color:var(--muted);letter-spacing:0.05em;margin-bottom:0.5rem">VOICE PROFILE</div>';
attrs.forEach(function(a) {
if (!a.val) return;
var barVal = { balanced: 50, formal: 85, casual: 20, concise: 30, verbose: 80, suggest: 50, wait: 15, initiative: 90, dry: 25, robotic: 40, witty: 75 };
var pct = barVal[a.val] || 50;
voiceHtml += '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:4px;font-size:0.75rem">'
+ '<span style="width:75px;color:var(--muted)">' + a.label + '</span>'
+ '<div style="flex:1;height:6px;background:var(--surface);border-radius:3px;overflow:hidden"><div style="width:' + pct + '%;height:100%;background:' + color + ';border-radius:3px"></div></div>'
+ '<span style="width:60px;text-align:right;color:var(--text)">' + esc(a.val) + '</span></div>';
});
voiceHtml += '</div>';
}
var missionsHtml = '';
if (agent.missions && agent.missions.length > 0) {
missionsHtml = '<div style="margin-top:1rem"><div style="font-size:0.75rem;font-weight:600;color:var(--muted);letter-spacing:0.05em;margin-bottom:0.5rem">MISSIONS</div>';
agent.missions.forEach(function(m) {
var prioColor = m.priority === 'high' ? '#ef4444' : (m.priority === 'medium' ? '#eab308' : '#22c55e');
missionsHtml += '<div style="display:flex;align-items:baseline;gap:0.5rem;margin-bottom:0.4rem;font-size:0.8125rem">'
+ '<span style="width:6px;height:6px;border-radius:50%;background:' + prioColor + ';flex-shrink:0;margin-top:4px"></span>'
+ '<div><strong style="color:var(--text)">' + esc(m.name) + '</strong>'
+ (m.timeframe ? ' <span style="color:var(--muted);font-size:0.7rem">(' + esc(m.timeframe) + ')</span>' : '')
+ '<div style="color:var(--muted);font-size:0.75rem">' + esc(m.description || '') + '</div></div></div>';
});
missionsHtml += '</div>';
}
var skillsHtml = '';
var skills = agent.skills || [];
if (skills.length > 0) {
var breakdown = agent.skill_breakdown || {};
skillsHtml = '<div style="margin-top:1rem"><div style="font-size:0.75rem;font-weight:600;color:var(--muted);letter-spacing:0.05em;margin-bottom:0.5rem">SKILLS (' + skills.length + ')</div>';
var kindColors = { tool: 'var(--accent)', cognitive: '#22c55e', format: '#06b6d4', multimodal: '#f59e0b', agent: '#8b5cf6' };
Object.keys(breakdown).forEach(function(kind) {
var items = breakdown[kind];
var kc = kindColors[kind] || 'var(--muted)';
skillsHtml += '<div style="margin-bottom:0.5rem"><span style="font-size:0.7rem;font-weight:600;color:' + kc + ';letter-spacing:0.03em">' + esc(kind.toUpperCase()) + '</span>'
+ '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:3px">';
items.forEach(function(s) {
skillsHtml += '<span style="font-size:0.7rem;padding:1px 6px;border-radius:3px;background:rgba(255,255,255,0.05);color:var(--text);border:1px solid rgba(255,255,255,0.08)">' + esc(s) + '</span>';
});
skillsHtml += '</div></div>';
});
skillsHtml += '</div>';
}
var rulesHtml = '';
if (agent.firmware_rules && agent.firmware_rules.length > 0) {
rulesHtml = '<div style="margin-top:1rem"><div style="font-size:0.75rem;font-weight:600;color:var(--muted);letter-spacing:0.05em;margin-bottom:0.5rem">FIRMWARE RULES</div>';
agent.firmware_rules.forEach(function(r) {
var rColor = r.type === 'must' ? '#22c55e' : '#ef4444';
var rLabel = r.type === 'must' ? 'MUST' : 'MUST NOT';
rulesHtml += '<div style="display:flex;gap:0.5rem;margin-bottom:0.3rem;font-size:0.75rem;align-items:baseline">'
+ '<span style="font-weight:700;color:' + rColor + ';font-size:0.65rem;flex-shrink:0">' + rLabel + '</span>'
+ '<span style="color:var(--text)">' + esc(r.rule) + '</span></div>';
});
rulesHtml += '</div>';
}
var statsHtml = '';
if (agent.stats) {
var s = agent.stats;
statsHtml = '<div style="margin-top:1rem;display:grid;grid-template-columns:repeat(2,1fr);gap:0.5rem">';
var statItems = [
{ label: 'Subagents', val: s.subordinate_count },
{ label: 'Running', val: s.running_subordinates },
{ label: 'Total Skills', val: s.total_skills },
{ label: 'Enabled Skills', val: s.enabled_skills }
];
statItems.forEach(function(si) {
if (si.val == null) return;
statsHtml += '<div style="text-align:center;padding:0.5rem;background:var(--surface);border-radius:var(--radius)">'
+ '<div style="font-size:1.25rem;font-weight:700;color:' + color + '">' + si.val + '</div>'
+ '<div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em">' + si.label + '</div></div>';
});
statsHtml += '</div>';
}
if (agent.session_count != null && !agent.stats) {
statsHtml = '<div style="margin-top:1rem;display:flex;gap:1rem">'
+ '<div style="text-align:center;padding:0.5rem;background:var(--surface);border-radius:var(--radius);flex:1">'
+ '<div style="font-size:1.25rem;font-weight:700;color:' + color + '">' + agent.session_count + '</div>'
+ '<div style="font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em">Sessions</div></div></div>';
}
var subordinates = agent.subordinates || [];
var proxyLookup = {};
(data.model_proxies || []).forEach(function(a) {
if (!a) return;
if (a.id) proxyLookup[String(a.id)] = true;
if (a.name) proxyLookup[String(a.name)] = true;
});
subordinates = subordinates.filter(function(sid) { return !proxyLookup[String(sid)]; });
var subsHtml = '';
if (subordinates.length > 0) {
subsHtml = '<div style="margin-top:1rem"><div style="font-size:0.75rem;font-weight:600;color:var(--muted);letter-spacing:0.05em;margin-bottom:0.5rem">SUBAGENTS (' + subordinates.length + ')</div>'
+ '<div style="display:flex;flex-wrap:wrap;gap:4px">';
subordinates.forEach(function(sid) {
subsHtml += '<span class="badge" style="cursor:pointer" data-roster-sub="' + esc(sid) + '">' + esc(sid) + '</span>';
});
subsHtml += '</div></div>';
}
var html = '<div style="max-width:520px;margin:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden">'
+ '<div style="background:linear-gradient(135deg,' + color + '22,' + color + '08);padding:1.5rem;border-bottom:1px solid var(--border);position:relative">'
+ '<button id="roster-modal-close" style="position:absolute;top:12px;right:12px;background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.2rem">\u2715</button>'
+ '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">'
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + stateColor + '"></span>'
+ '<span style="font-size:0.65rem;font-weight:600;letter-spacing:0.05em;color:' + color + '">' + roleLabel + '</span>'
+ '</div>'
+ '<div style="font-size:1.5rem;font-weight:800;color:var(--text);margin-bottom:0.25rem">' + esc(displayName) + '</div>'
+ '<div style="font-family:var(--mono);font-size:0.75rem;color:var(--muted)">' + esc(agent.name || '') + '</div>'
+ '<div id="roster-model-display" style="display:flex;align-items:center;gap:0.5rem;margin-top:0.5rem">'
+ '<span style="font-family:var(--mono);font-size:0.75rem;color:' + color + '">' + esc(agent.model || '—') + '</span>'
+ '<button id="roster-change-model-btn" style="font-size:0.6rem;padding:1px 6px;border-radius:3px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);color:var(--muted);cursor:pointer" title="Change model">change</button>'
+ '</div>'
+ '<div id="roster-model-editor" style="display:none;margin-top:0.5rem">'
+ (function() {
var allowControlModes = !isCmd && String(agent.role || '').toLowerCase() !== 'model-proxy';
var opts = App._renderModelOptionsHtml({
discoveredModels: availableModels,
roster: data.roster || [],
config: { models: cfgModels },
selected: orchestratorOrderState.concat([agent.model || '']),
includeControlModes: allowControlModes
});
if (isCmd) {
return '<div style="font-size:0.6875rem;color:var(--muted);margin-bottom:0.375rem">Model order (top = primary, next = fallback sequence)</div>'
+ '<div class="model-order-list" id="roster-model-order-list">' + renderOrchestratorOrderRows(orchestratorOrderState) + '</div>'
+ '<div style="display:flex;gap:0.4rem;align-items:center;margin-top:6px">'
+ '<select id="roster-model-select" style="flex:1;font-size:0.75rem;font-family:var(--mono);padding:4px 6px;border-radius:4px;background:var(--surface);color:var(--text);border:1px solid var(--border)">'
+ '<option value="">-- quick add known model --</option>' + opts + '</select>'
+ '<button id="roster-model-order-add" class="btn secondary roster-model-add-btn" style="font-size:0.7rem;padding:3px 8px">Add</button>'
+ '</div>'
+ '<div style="display:flex;gap:0.4rem;align-items:center;margin-top:4px">'
+ '<input id="roster-model-custom" type="text" placeholder="or type provider/model and click Add" style="flex:1;font-size:0.75rem;font-family:var(--mono);padding:4px 6px;border-radius:4px;background:var(--surface);color:var(--text);border:1px solid var(--border)">'
+ '<button class="btn secondary roster-model-add-btn" style="font-size:0.7rem;padding:3px 8px">Add</button>'
+ '</div>'
+ '<div style="display:flex;gap:0.4rem;margin-top:6px">'
+ '<button id="roster-model-save" class="btn" style="font-size:0.7rem;padding:3px 10px">Apply</button>'
+ '<button id="roster-model-cancel" class="btn secondary" style="font-size:0.7rem;padding:3px 10px">Cancel</button>'
+ '</div>';
}
var selectedModel = String(agent.model || '');
var singleOpts = App._buildModelOptionEntries({
discoveredModels: availableModels,
roster: data.roster || [],
config: { models: cfgModels },
selected: [selectedModel],
includeControlModes: allowControlModes
}).map(function(entry) {
var sel = entry.value === selectedModel ? ' selected' : '';
return '<option value="' + esc(entry.value) + '"' + sel + '>' + esc(entry.label) + '</option>';
}).join('');
return '<div style="display:flex;gap:0.4rem;align-items:center">'
+ '<select id="roster-model-select" style="flex:1;font-size:0.75rem;font-family:var(--mono);padding:4px 6px;border-radius:4px;background:var(--surface);color:var(--text);border:1px solid var(--border)">'
+ '<option value="">-- select model --</option>' + singleOpts + '</select>'
+ '</div>'
+ '<div style="display:flex;gap:0.4rem;align-items:center;margin-top:4px">'
+ '<input id="roster-model-custom" type="text" placeholder="or type provider/model" style="flex:1;font-size:0.75rem;font-family:var(--mono);padding:4px 6px;border-radius:4px;background:var(--surface);color:var(--text);border:1px solid var(--border)">'
+ '</div>'
+ '<div style="display:flex;gap:0.4rem;margin-top:6px">'
+ '<button id="roster-model-save" class="btn" style="font-size:0.7rem;padding:3px 10px">Apply</button>'
+ '<button id="roster-model-cancel" class="btn secondary" style="font-size:0.7rem;padding:3px 10px">Cancel</button>'
+ '</div>';
})()
+ '</div>'
+ (agent.description ? '<div style="font-size:0.8125rem;color:var(--text);margin-top:0.75rem;line-height:1.4;max-height:4.5em;overflow:hidden;text-overflow:ellipsis">' + esc(agent.description) + '</div>' : '')
+ '</div>'
+ '<div style="padding:1.25rem">'
+ voiceHtml + missionsHtml + skillsHtml + rulesHtml + statsHtml + subsHtml
+ (agent.supervisor ? '<div style="margin-top:1rem;font-size:0.75rem;color:var(--muted)">Reports to: <strong style="color:var(--text)">' + esc(agent.supervisor) + '</strong></div>' : '')
+ '</div></div>';
modal.style.display = 'flex';
modal.style.alignItems = 'flex-start';
modal.style.justifyContent = 'center';
setHtml(modal, html);
if (isCmd) rerenderOrchestratorOrderList();
modal.onclick = function(ev) {
if (ev.target === modal || ev.target.closest('#roster-modal-close')) {
modal.style.display = 'none';
}
var subLink = ev.target.closest('[data-roster-sub]');
if (subLink) {
var subId = subLink.getAttribute('data-roster-sub');
modal.style.display = 'none';
var subCard = document.querySelector('.roster-card[data-roster-id="' + subId + '"]');
if (subCard) subCard.click();
}
if (ev.target.closest('#roster-change-model-btn')) {
var disp = document.getElementById('roster-model-display');
var ed = document.getElementById('roster-model-editor');
if (disp) disp.style.display = 'none';
if (ed) ed.style.display = 'block';
}
if (ev.target.closest('#roster-model-cancel')) {
var disp2 = document.getElementById('roster-model-display');
var ed2 = document.getElementById('roster-model-editor');
if (disp2) disp2.style.display = 'flex';
if (ed2) ed2.style.display = 'none';
}
if (ev.target.closest('.roster-model-add-btn')) {
var selAdd = document.getElementById('roster-model-select');
var customAdd = document.getElementById('roster-model-custom');
var toAdd = (customAdd && customAdd.value.trim()) || (selAdd && selAdd.value) || '';
if (!toAdd) { toast('Select or enter a model'); return; }
if (orchestratorOrderState.indexOf(toAdd) !== -1) { toast('Model already listed'); return; }
orchestratorOrderState.push(toAdd);
rerenderOrchestratorOrderList();
if (customAdd) customAdd.value = '';
if (selAdd) selAdd.value = '';
}
var removeOrderBtn = ev.target.closest('[data-roster-model-order-remove]');
if (removeOrderBtn) {
var removeIdx = Number(removeOrderBtn.getAttribute('data-roster-model-order-remove'));
if (!isNaN(removeIdx) && removeIdx >= 0 && removeIdx < orchestratorOrderState.length) {
orchestratorOrderState.splice(removeIdx, 1);
rerenderOrchestratorOrderList();
}
}
var primaryOrderBtn = ev.target.closest('[data-roster-model-order-primary]');
if (primaryOrderBtn) {
var promoteIdx = Number(primaryOrderBtn.getAttribute('data-roster-model-order-primary'));
if (!isNaN(promoteIdx) && promoteIdx > 0 && promoteIdx < orchestratorOrderState.length) {
var promoted = orchestratorOrderState.splice(promoteIdx, 1)[0];
orchestratorOrderState.unshift(promoted);
rerenderOrchestratorOrderList();
}
}
if (ev.target.closest('#roster-model-save')) {
var sel = document.getElementById('roster-model-select');
var custom = document.getElementById('roster-model-custom');
var agentKey = agent.name || agent.id;
var payload;
if (isCmd) {
if (!orchestratorOrderState.length) { toast('Add at least one model in order list'); return; }
payload = { model: orchestratorOrderState[0], fallbacks: orchestratorOrderState.slice(1) };
} else {
var newModel = (custom && custom.value.trim()) || (sel && sel.value) || '';
if (!newModel) { toast('Select or enter a model'); return; }
payload = { model: newModel };
}
api('/api/roster/' + encodeURIComponent(agentKey) + '/model', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function(resp) {
if (isCmd && Array.isArray(resp.model_order)) {
toast('Model order updated (' + resp.model_order.length + ' total)');
} else {
toast('Model changed: ' + (resp.old_model || '?') + ' \u2192 ' + resp.new_model);
}
modal.style.display = 'none';
App.navigate('agents');
}).catch(function(err) {
toast(err.message || 'Failed to change model');
});
}
};
modal.ondragstart = function(ev) {
if (!isCmd) return;
var item = ev.target.closest('[data-roster-model-order-item]');
if (!item) return;
rosterDragModelIndex = Number(item.getAttribute('data-roster-model-order-item'));
item.classList.add('dragging');
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', String(rosterDragModelIndex));
}
};
modal.ondragover = function(ev) {
if (!isCmd) return;
var item = ev.target.closest('[data-roster-model-order-item]');
if (!item) return;
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
modal.querySelectorAll('.model-order-item.drop-target').forEach(function(el) { el.classList.remove('drop-target'); });
item.classList.add('drop-target');
};
modal.ondrop = function(ev) {
if (!isCmd) return;
var item = ev.target.closest('[data-roster-model-order-item]');
if (!item) return;
ev.preventDefault();
var toIdx = Number(item.getAttribute('data-roster-model-order-item'));
modal.querySelectorAll('.model-order-item.drop-target,.model-order-item.dragging').forEach(function(el) { el.classList.remove('drop-target'); el.classList.remove('dragging'); });
var fromIdx = rosterDragModelIndex;
rosterDragModelIndex = null;
if (isNaN(fromIdx) || isNaN(toIdx) || fromIdx === toIdx || fromIdx < 0 || toIdx < 0 || fromIdx >= orchestratorOrderState.length || toIdx >= orchestratorOrderState.length) return;
var moved = orchestratorOrderState.splice(fromIdx, 1)[0];
orchestratorOrderState.splice(toIdx, 0, moved);
rerenderOrchestratorOrderList();
};
modal.ondragend = function() {
if (!isCmd) return;
modal.querySelectorAll('.model-order-item.drop-target,.model-order-item.dragging').forEach(function(el) { el.classList.remove('drop-target'); el.classList.remove('dragging'); });
rosterDragModelIndex = null;
};
});
return;
}
if (e.target.closest('#btn-reload-skills')) {
api('/api/skills/reload', { method: 'POST' }).then(function() { toast('Skills reloaded'); App.refreshSkills(); }).catch(function(err) { toast(err.message || 'Failed'); });
return;
}
if (e.target.closest('#btn-catalog-refresh')) {
App.refreshSkills();
toast('Catalog refreshed');
return;
}
var catalogInstallBtn = e.target.closest('#btn-catalog-install');
var catalogActivateBtn = e.target.closest('#btn-catalog-activate');
var catalogInstallActivateBtn = e.target.closest('#btn-catalog-install-activate');
if (catalogInstallBtn || catalogActivateBtn || catalogInstallActivateBtn) {
var selectedRows = Array.prototype.slice.call(document.querySelectorAll('.cat-skill-check:checked'))
.map(function(el) {
return {
name: (el.value || '').trim(),
installed: el.getAttribute('data-installed') === '1'
};
})
.filter(function(v) { return !!v.name; });
if (selectedRows.length === 0) {
toast('Select at least one catalog skill');
return;
}
if (catalogActivateBtn) {
var selectedForActivate = selectedRows.map(function(row) { return row.name; });
api('/api/skills/catalog/activate', {
method: 'POST',
body: { skills: selectedForActivate }
}).then(function() {
toast('Activated selected catalog skills');
App.refreshSkills();
}).catch(function(err) {
toast(err.message || 'Catalog activation failed');
});
return;
}
var activateOnInstall = !!catalogInstallActivateBtn;
var selectedForInstall = selectedRows.filter(function(row) { return !row.installed; }).map(function(row) { return row.name; });
if (selectedForInstall.length === 0) {
toast('All selected catalog skills are already installed');
return;
}
var payload = { skills: selectedForInstall, activate: activateOnInstall };
api('/api/skills/catalog/install', { method: 'POST', body: payload })
.then(function(resp) {
var installed = (resp && resp.installed) ? resp.installed.length : selectedForInstall.length;
toast('Installed ' + installed + ' skill(s)' + (activateOnInstall ? ' and reloaded' : ''));
if (!activateOnInstall) {
return api('/api/skills/reload', { method: 'POST' }).then(function() {
toast('Skills reloaded');
});
}
})
.then(function() {
App.refreshSkills();
})
.catch(function(err) {
toast(err.message || 'Catalog install failed');
});
return;
}
var skillDelete = e.target.closest('[data-skill-delete]');
if (skillDelete) {
var skillId = skillDelete.getAttribute('data-skill-delete');
var skillName = skillDelete.getAttribute('data-skill-name') || '';
if (!skillId || !skillName) { toast('Missing skill metadata'); return; }
App._openSkillDeleteModal(skillId, skillName);
return;
}
var skillToggle = e.target.closest('[data-skill-toggle]');
if (skillToggle) {
var skillIdToggle = skillToggle.getAttribute('data-skill-toggle') || '';
var skillNameToggle = skillToggle.getAttribute('data-skill-name') || skillIdToggle;
var nextChecked = !!skillToggle.checked;
var prevChecked = !nextChecked;
if (!skillIdToggle) {
skillToggle.checked = prevChecked;
toast('Missing skill id');
return;
}
if (skillToggle.getAttribute('data-skill-pending') === '1') return;
skillToggle.setAttribute('data-skill-pending', '1');
skillToggle.disabled = true;
api('/api/skills/' + encodeURIComponent(skillIdToggle) + '/toggle', { method: 'PUT' })
.then(function(resp) {
skillToggle.checked = !!resp.enabled;
toast((resp.enabled ? 'Skill enabled: ' : 'Skill disabled: ') + skillNameToggle);
App.navigate('skills');
})
.catch(function(err) {
skillToggle.checked = prevChecked;
toast(err.message || 'Failed to toggle skill');
})
.finally(function() {
skillToggle.removeAttribute('data-skill-pending');
skillToggle.disabled = false;
});
return;
}
if (e.target.closest('#copy-address')) {
var pre = document.getElementById('wallet-addr');
if (pre && pre.textContent) navigator.clipboard.writeText(pre.textContent).then(function() { toast('Address copied'); });
return;
}
var revenueTaskBtn = e.target.closest('[data-revenue-task-action][data-opportunity-id][data-revenue-task-kind]');
if (revenueTaskBtn) {
var taskKind = revenueTaskBtn.getAttribute('data-revenue-task-kind');
var taskAction = revenueTaskBtn.getAttribute('data-revenue-task-action');
var opportunityId = revenueTaskBtn.getAttribute('data-opportunity-id');
if (!taskKind || !taskAction || !opportunityId) { toast('Missing revenue task metadata'); return; }
var basePath = taskKind === 'tax' ? '/api/services/tax-payouts/' : '/api/services/swaps/';
var request = { method: 'POST' };
if (taskAction === 'confirm') {
var txHash = window.prompt('Enter the transaction hash to confirm:', '');
if (!txHash || !txHash.trim()) return;
request.headers = { 'Content-Type': 'application/json' };
request.body = JSON.stringify({ tx_hash: txHash.trim() });
} else if (taskAction === 'fail') {
var reason = window.prompt('Enter the failure reason:', '');
if (!reason || !reason.trim()) return;
request.headers = { 'Content-Type': 'application/json' };
request.body = JSON.stringify({ reason: reason.trim() });
} else if (taskAction === 'submit') {
var calldata = window.prompt('Enter calldata (0x...) for submission:', '');
if (!calldata || !calldata.trim()) return;
var contractAddress = window.prompt('Optional contract address override (leave blank to use configured task source):', '') || '';
request.headers = { 'Content-Type': 'application/json' };
request.body = JSON.stringify({
calldata: calldata.trim(),
contract_address: contractAddress.trim() || null
});
}
api(basePath + encodeURIComponent(opportunityId) + '/' + taskAction, request)
.then(function(resp) {
var msg = taskKind === 'tax' ? 'Tax payout ' : 'Swap ';
msg += taskAction + ' completed';
if (resp && resp.receipt_status) msg += ' — receipt ' + resp.receipt_status;
if (resp && resp.tx_hash) msg += ' — ' + String(resp.tx_hash).slice(0, 18) + '…';
toast(msg);
App.navigate('wallet');
})
.catch(function(err) {
toast(err.message || ('Revenue task ' + taskAction + ' failed'));
});
return;
}
if (e.target.closest('#memory-search-btn')) {
var q = document.getElementById('memory-search-q'); var query = (q && q.value) || '';
if (!query.trim()) { toast('Enter a search query'); return; }
api('/api/memory/search?q=' + encodeURIComponent(query)).then(function(r) {
var res = document.getElementById('memory-search-results'); if (!res) return;
var results = r.results || [];
setHtml(res, results.map(function(e) { return '<div class="card" style="margin-bottom:0.5rem"><div class="card-mono">' + esc(e.content || JSON.stringify(e)) + '</div><span class="badge muted">' + esc(e.type || '') + '</span></div>'; }).join('') || '<p style="color:var(--muted)">No results</p>');
}).catch(function(err) { var res = document.getElementById('memory-search-results'); if (res) setHtml(res, '<p style="color:var(--error)">' + esc(err.message || 'Search failed') + '</p>'); });
return;
}
var tabBtn = e.target.closest('.tabs button[data-tab]');
if (tabBtn) { App._memoryTab = tabBtn.getAttribute('data-tab'); App._memoryCategory = ''; App._memorySessionId = ''; App.navigate('memory'); return; }
var memCatBtn = e.target.closest('[data-mem-cat]');
if (memCatBtn) { App._memoryCategory = memCatBtn.getAttribute('data-mem-cat'); App.navigate('memory'); return; }
var memSessBtn = e.target.closest('[data-mem-session]');
if (memSessBtn) { App._memorySessionId = memSessBtn.getAttribute('data-mem-session'); App.navigate('memory'); return; }
var effTabBtn = e.target.closest('[data-eff-tab]');
if (effTabBtn) { App._effTab = effTabBtn.getAttribute('data-eff-tab') || 'performance'; App.navigate('efficiency'); return; }
var effPeriodBtn = e.target.closest('[data-eff-period]');
if (effPeriodBtn) { App._effPeriod = effPeriodBtn.getAttribute('data-eff-period'); App.navigate('efficiency'); return; }
var agentsTab = e.target.closest('[data-agents-tab]');
if (agentsTab) { App._agentsTab = agentsTab.getAttribute('data-agents-tab'); App.navigate('agents'); return; }
var skillsTab = e.target.closest('[data-skills-tab]');
if (skillsTab) { App._skillsTab = skillsTab.getAttribute('data-skills-tab'); App.navigate('skills'); return; }
var settingsMode = e.target.closest('[data-settings-mode]');
if (settingsMode) {
var newMode = settingsMode.getAttribute('data-settings-mode');
if (newMode === 'json' && App._settingsMode !== 'json') App._settingsJsonText = JSON.stringify(App._getSettingsDraft(), null, 2);
if (App._settingsMode === 'json' && newMode !== 'json' && App._settingsJsonText) { try { App._settingsDraft = JSON.parse(App._settingsJsonText); } catch(ex) {} }
if (newMode === 'models') App._initModelOrderFromDraft();
App._settingsMode = newMode; App.navigate('settings'); return;
}
if (e.target.closest('#hints-reset-dismissed')) {
clearHintDismissals();
setHintsEnabled(true);
try { window.localStorage.setItem(HINTS_PROMPTED_KEY, '1'); } catch (_) {}
toast('Dismissed hints reset');
App.navigate('settings');
return;
}
if (e.target.closest('#trusted-sender-add')) {
var senderInput = document.getElementById('trusted-sender-input');
var sender = (senderInput && senderInput.value || '').trim();
if (!sender) { toast('Enter a sender id first'); return; }
var draftTs = App._getSettingsDraft();
if (!draftTs.channels) draftTs.channels = {};
var curTs = Array.isArray(draftTs.channels.trusted_sender_ids) ? draftTs.channels.trusted_sender_ids.slice() : [];
if (curTs.indexOf(sender) !== -1) { toast('Sender already trusted'); return; }
curTs.push(sender);
draftTs.channels.trusted_sender_ids = curTs;
if (senderInput) senderInput.value = '';
App._settingsDirty = JSON.stringify(draftTs) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var removeTrustedSenderBtn = e.target.closest('.trusted-sender-remove[data-trusted-sender]');
if (removeTrustedSenderBtn) {
var senderVal = removeTrustedSenderBtn.getAttribute('data-trusted-sender');
var draftTr = App._getSettingsDraft();
if (!draftTr.channels) draftTr.channels = {};
var curTr = Array.isArray(draftTr.channels.trusted_sender_ids) ? draftTr.channels.trusted_sender_ids.slice() : [];
draftTr.channels.trusted_sender_ids = curTr.filter(function(v) { return String(v) !== String(senderVal); });
App._settingsDirty = JSON.stringify(draftTr) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
if (e.target.closest('#telegram-allowed-chat-add')) {
var tgInput = document.getElementById('telegram-allowed-chat-input');
var tgRaw = (tgInput && tgInput.value || '').trim();
if (!tgRaw) { toast('Enter a chat id first'); return; }
if (!/^-?[0-9]+$/.test(tgRaw)) { toast('Chat id must be an integer'); return; }
var tgId = Number(tgRaw);
var draftTg = App._getSettingsDraft();
if (!draftTg.channels) draftTg.channels = {};
if (!draftTg.channels.telegram || typeof draftTg.channels.telegram !== 'object') draftTg.channels.telegram = {};
var tgList = Array.isArray(draftTg.channels.telegram.allowed_chat_ids) ? draftTg.channels.telegram.allowed_chat_ids.slice() : [];
if (tgList.indexOf(tgId) !== -1) { toast('Chat id already allowed'); return; }
tgList.push(tgId);
draftTg.channels.telegram.allowed_chat_ids = tgList;
if (tgInput) tgInput.value = '';
App._settingsDirty = JSON.stringify(draftTg) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var tgRemoveBtn = e.target.closest('.telegram-allowed-chat-remove[data-telegram-chat-id]');
if (tgRemoveBtn) {
var tgRemoveVal = Number(tgRemoveBtn.getAttribute('data-telegram-chat-id'));
var draftTgR = App._getSettingsDraft();
if (!draftTgR.channels) draftTgR.channels = {};
if (!draftTgR.channels.telegram || typeof draftTgR.channels.telegram !== 'object') draftTgR.channels.telegram = {};
var tgCur = Array.isArray(draftTgR.channels.telegram.allowed_chat_ids) ? draftTgR.channels.telegram.allowed_chat_ids.slice() : [];
draftTgR.channels.telegram.allowed_chat_ids = tgCur.filter(function(v) { return Number(v) !== tgRemoveVal; });
App._settingsDirty = JSON.stringify(draftTgR) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
if (e.target.closest('#whatsapp-allowed-number-add')) {
var waInput = document.getElementById('whatsapp-allowed-number-input');
var waVal = (waInput && waInput.value || '').trim();
if (!waVal) { toast('Enter a number first'); return; }
var draftWa = App._getSettingsDraft();
if (!draftWa.channels) draftWa.channels = {};
if (!draftWa.channels.whatsapp || typeof draftWa.channels.whatsapp !== 'object') draftWa.channels.whatsapp = {};
var waList = Array.isArray(draftWa.channels.whatsapp.allowed_numbers) ? draftWa.channels.whatsapp.allowed_numbers.slice() : [];
if (waList.indexOf(waVal) !== -1) { toast('Number already allowed'); return; }
waList.push(waVal);
draftWa.channels.whatsapp.allowed_numbers = waList;
if (waInput) waInput.value = '';
App._settingsDirty = JSON.stringify(draftWa) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var waRemoveBtn = e.target.closest('.whatsapp-allowed-number-remove[data-whatsapp-number]');
if (waRemoveBtn) {
var waRemoveVal = waRemoveBtn.getAttribute('data-whatsapp-number');
var draftWaR = App._getSettingsDraft();
if (!draftWaR.channels) draftWaR.channels = {};
if (!draftWaR.channels.whatsapp || typeof draftWaR.channels.whatsapp !== 'object') draftWaR.channels.whatsapp = {};
var waCur = Array.isArray(draftWaR.channels.whatsapp.allowed_numbers) ? draftWaR.channels.whatsapp.allowed_numbers.slice() : [];
draftWaR.channels.whatsapp.allowed_numbers = waCur.filter(function(v) { return String(v) !== String(waRemoveVal); });
App._settingsDirty = JSON.stringify(draftWaR) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
if (e.target.closest('#signal-allowed-number-add')) {
var sigInput = document.getElementById('signal-allowed-number-input');
var sigVal = (sigInput && sigInput.value || '').trim();
if (!sigVal) { toast('Enter a number first'); return; }
var draftSig = App._getSettingsDraft();
if (!draftSig.channels) draftSig.channels = {};
if (!draftSig.channels.signal || typeof draftSig.channels.signal !== 'object') draftSig.channels.signal = {};
var sigList = Array.isArray(draftSig.channels.signal.allowed_numbers) ? draftSig.channels.signal.allowed_numbers.slice() : [];
if (sigList.indexOf(sigVal) !== -1) { toast('Number already allowed'); return; }
sigList.push(sigVal);
draftSig.channels.signal.allowed_numbers = sigList;
if (sigInput) sigInput.value = '';
App._settingsDirty = JSON.stringify(draftSig) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var sigRemoveBtn = e.target.closest('.signal-allowed-number-remove[data-signal-number]');
if (sigRemoveBtn) {
var sigRemoveVal = sigRemoveBtn.getAttribute('data-signal-number');
var draftSigR = App._getSettingsDraft();
if (!draftSigR.channels) draftSigR.channels = {};
if (!draftSigR.channels.signal || typeof draftSigR.channels.signal !== 'object') draftSigR.channels.signal = {};
var sigCur = Array.isArray(draftSigR.channels.signal.allowed_numbers) ? draftSigR.channels.signal.allowed_numbers.slice() : [];
draftSigR.channels.signal.allowed_numbers = sigCur.filter(function(v) { return String(v) !== String(sigRemoveVal); });
App._settingsDirty = JSON.stringify(draftSigR) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
if (e.target.closest('#discord-allowed-guild-add')) {
var dcInput = document.getElementById('discord-allowed-guild-input');
var dcVal = (dcInput && dcInput.value || '').trim();
if (!dcVal) { toast('Enter a guild id first'); return; }
var draftDc = App._getSettingsDraft();
if (!draftDc.channels) draftDc.channels = {};
if (!draftDc.channels.discord || typeof draftDc.channels.discord !== 'object') draftDc.channels.discord = {};
var dcList = Array.isArray(draftDc.channels.discord.allowed_guild_ids) ? draftDc.channels.discord.allowed_guild_ids.slice() : [];
if (dcList.indexOf(dcVal) !== -1) { toast('Guild id already allowed'); return; }
dcList.push(dcVal);
draftDc.channels.discord.allowed_guild_ids = dcList;
if (dcInput) dcInput.value = '';
App._settingsDirty = JSON.stringify(draftDc) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var dcRemoveBtn = e.target.closest('.discord-allowed-guild-remove[data-discord-guild-id]');
if (dcRemoveBtn) {
var dcRemoveVal = dcRemoveBtn.getAttribute('data-discord-guild-id');
var draftDcR = App._getSettingsDraft();
if (!draftDcR.channels) draftDcR.channels = {};
if (!draftDcR.channels.discord || typeof draftDcR.channels.discord !== 'object') draftDcR.channels.discord = {};
var dcCur = Array.isArray(draftDcR.channels.discord.allowed_guild_ids) ? draftDcR.channels.discord.allowed_guild_ids.slice() : [];
draftDcR.channels.discord.allowed_guild_ids = dcCur.filter(function(v) { return String(v) !== String(dcRemoveVal); });
App._settingsDirty = JSON.stringify(draftDcR) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
if (e.target.closest('#email-allowed-sender-add')) {
var emInput = document.getElementById('email-allowed-sender-input');
var emVal = (emInput && emInput.value || '').trim();
if (!emVal) { toast('Enter an email sender first'); return; }
var draftEm = App._getSettingsDraft();
if (!draftEm.channels) draftEm.channels = {};
if (!draftEm.channels.email || typeof draftEm.channels.email !== 'object') draftEm.channels.email = {};
var emList = Array.isArray(draftEm.channels.email.allowed_senders) ? draftEm.channels.email.allowed_senders.slice() : [];
if (emList.indexOf(emVal) !== -1) { toast('Sender already allowed'); return; }
emList.push(emVal);
draftEm.channels.email.allowed_senders = emList;
if (emInput) emInput.value = '';
App._settingsDirty = JSON.stringify(draftEm) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var emRemoveBtn = e.target.closest('.email-allowed-sender-remove[data-email-sender]');
if (emRemoveBtn) {
var emRemoveVal = emRemoveBtn.getAttribute('data-email-sender');
var draftEmR = App._getSettingsDraft();
if (!draftEmR.channels) draftEmR.channels = {};
if (!draftEmR.channels.email || typeof draftEmR.channels.email !== 'object') draftEmR.channels.email = {};
var emCur = Array.isArray(draftEmR.channels.email.allowed_senders) ? draftEmR.channels.email.allowed_senders.slice() : [];
draftEmR.channels.email.allowed_senders = emCur.filter(function(v) { return String(v) !== String(emRemoveVal); });
App._settingsDirty = JSON.stringify(draftEmR) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
if (e.target.closest('#model-order-add-btn')) {
var addInput = document.getElementById('model-order-add-input');
var modelName = (addInput && addInput.value || '').trim();
if (!modelName) { toast('Enter a model name'); return; }
if (!App._settingsModelOrder) App._initModelOrderFromDraft();
if (App._settingsModelOrder.indexOf(modelName) !== -1) { toast('Model already in order list'); return; }
App._settingsModelOrder.push(modelName);
App._syncModelOrderToDraft();
App.navigate('settings');
return;
}
if (e.target.closest('#revenue-swap-chain-add-btn')) {
var chainInput = document.getElementById('revenue-swap-chain-add');
var chainName = (chainInput && chainInput.value || '').trim().toUpperCase();
if (!chainName) { toast('Enter a chain name'); return; }
var draftSwapAdd = App._getSettingsDraft();
if (!draftSwapAdd.treasury) draftSwapAdd.treasury = {};
if (!draftSwapAdd.treasury.revenue_swap) draftSwapAdd.treasury.revenue_swap = {};
if (!Array.isArray(draftSwapAdd.treasury.revenue_swap.chains)) draftSwapAdd.treasury.revenue_swap.chains = [];
var exists = draftSwapAdd.treasury.revenue_swap.chains.some(function(entry) {
return String((entry && entry.chain) || '').trim().toUpperCase() === chainName;
});
if (exists) { toast('Chain already configured'); return; }
draftSwapAdd.treasury.revenue_swap.chains.push({
chain: chainName,
target_contract_address: '',
swap_contract_address: ''
});
if (chainInput) chainInput.value = '';
App._settingsDirty = JSON.stringify(draftSwapAdd) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var revenueSwapRemoveBtn = e.target.closest('[data-revenue-swap-remove-chain]');
if (revenueSwapRemoveBtn) {
var removeIdx = Number(revenueSwapRemoveBtn.getAttribute('data-revenue-swap-remove-chain'));
var draftSwapRemove = App._getSettingsDraft();
var swapChains = draftSwapRemove && draftSwapRemove.treasury && draftSwapRemove.treasury.revenue_swap && Array.isArray(draftSwapRemove.treasury.revenue_swap.chains)
? draftSwapRemove.treasury.revenue_swap.chains
: null;
if (!swapChains || removeIdx < 0 || removeIdx >= swapChains.length) return;
swapChains.splice(removeIdx, 1);
App._settingsDirty = JSON.stringify(draftSwapRemove) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
App.navigate('settings');
return;
}
var modelPrimaryBtn = e.target.closest('[data-model-order-make-primary]');
if (modelPrimaryBtn) {
var pIdx = Number(modelPrimaryBtn.getAttribute('data-model-order-make-primary'));
if (!App._settingsModelOrder || pIdx <= 0 || pIdx >= App._settingsModelOrder.length) return;
var primaryName = App._settingsModelOrder.splice(pIdx, 1)[0];
App._settingsModelOrder.unshift(primaryName);
App._syncModelOrderToDraft();
App.navigate('settings');
return;
}
var modelRemoveBtn = e.target.closest('[data-model-order-remove]');
if (modelRemoveBtn) {
var rIdx = Number(modelRemoveBtn.getAttribute('data-model-order-remove'));
if (!App._settingsModelOrder || rIdx < 0 || rIdx >= App._settingsModelOrder.length) return;
App._settingsModelOrder.splice(rIdx, 1);
App._syncModelOrderToDraft();
App.navigate('settings');
return;
}
if (e.target.closest('#settings-save') || e.target.closest('#settings-apply')) {
var isApply = !!e.target.closest('#settings-apply');
if (App._settingsMode === 'json') { try { App._settingsDraft = JSON.parse(App._settingsJsonText || '{}'); } catch(err) { toast('Fix JSON errors first'); return; } }
var draft = App._getSettingsDraft();
var patch = App._buildMutableConfigPatch(draft);
api('/api/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }).then(function() {
var merged = JSON.parse(JSON.stringify(_cachedConfig || {}));
Object.keys(patch || {}).forEach(function(k) { merged[k] = patch[k]; });
_cachedConfig = merged;
App._settingsDirty = false; App._settingsJsonText = null;
toast(isApply ? 'Configuration applied' : 'Configuration saved');
App.navigate('settings');
}).catch(function(err) { toast(err.message || 'Save failed'); });
return;
}
if (e.target.closest('#settings-cancel')) {
App._settingsDraft = null; App._settingsJsonText = null; App._settingsDirty = false;
toast('Changes discarded'); App.navigate('settings'); return;
}
var chainBtn = e.target.closest('[data-chain-preset]');
if (chainBtn) {
var name = chainBtn.getAttribute('data-chain-preset');
var preset = App._CHAIN_PRESETS[name];
if (preset) {
var draft = App._getSettingsDraft();
App._setNestedValue(draft, 'wallet.chain_id', preset.chain_id);
App._setNestedValue(draft, 'wallet.rpc_url', preset.rpc_url);
App._settingsDirty = JSON.stringify(draft) !== JSON.stringify(_cachedConfig);
App._settingsJsonText = null;
App.navigate('settings');
}
return;
}
if (e.target.closest('#add-provider')) {
var providerInput = document.getElementById('add-provider-input');
var name = (providerInput && providerInput.value || '');
if (!name || !name.trim()) return;
name = name.trim().toLowerCase();
var draft = App._getSettingsDraft();
if (!draft.providers) draft.providers = {};
if (draft.providers[name]) { toast('Provider "' + name + '" already exists'); return; }
draft.providers[name] = { url: '', tier: 'T2', format: 'openai', chat_path: '/v1/chat/completions' };
if (providerInput) providerInput.value = '';
App._settingsDirty = true; App._settingsJsonText = null;
App.navigate('settings');
return;
}
var keyBtn = e.target.closest('[data-action="save-key"]') || e.target.closest('[data-action="remove-key"]');
if (keyBtn) {
var row = keyBtn.closest('.key-manage-row');
var provName = row && row.getAttribute('data-provider');
var msgEl = row && row.querySelector('.key-manage-msg');
if (!provName) return;
var action = keyBtn.getAttribute('data-action');
if (action === 'save-key') {
var inp = row.querySelector('.key-input');
var val = inp && inp.value.trim();
if (!val) { toast('Paste an API key first'); return; }
keyBtn.disabled = true; keyBtn.textContent = 'Saving\u2026';
api('/api/providers/' + encodeURIComponent(provName) + '/key', {
method: 'PUT', body: JSON.stringify({ api_key: val })
}).then(function() {
toast('Key saved for ' + provName);
App._settingsCache = null; App.navigate('settings');
}).catch(function(err) {
if (msgEl) { msgEl.textContent = err.message || 'Save failed'; msgEl.style.color = 'var(--error)'; }
keyBtn.disabled = false; keyBtn.textContent = 'Save to keystore';
});
} else if (action === 'remove-key') {
if (!confirm('Remove ' + provName + ' API key from keystore?')) return;
keyBtn.disabled = true; keyBtn.textContent = 'Removing\u2026';
api('/api/providers/' + encodeURIComponent(provName) + '/key', {
method: 'DELETE'
}).then(function() {
toast('Key removed for ' + provName);
App._settingsCache = null; App.navigate('settings');
}).catch(function(err) {
if (msgEl) { msgEl.textContent = err.message || 'Remove failed'; msgEl.style.color = 'var(--error)'; }
keyBtn.disabled = false; keyBtn.textContent = 'Remove';
});
}
return;
}
var calNav = e.target.closest('[data-cal-nav]');
if (calNav) {
var dir = calNav.getAttribute('data-cal-nav');
if (dir === 'prev') { App._calMonth--; if (App._calMonth < 0) { App._calMonth = 11; App._calYear--; } }
else if (dir === 'next') { App._calMonth++; if (App._calMonth > 11) { App._calMonth = 0; App._calYear++; } }
else { App._calMonth = new Date().getMonth(); App._calYear = new Date().getFullYear(); }
App._calSelected = null; App.navigate('scheduler'); return;
}
if (e.target.closest('[data-cal-add]')) { App._calModalMode = 'add'; App._calEditJob = null; App.navigate('scheduler'); return; }
var editBtn = e.target.closest('[data-cal-edit]');
if (editBtn) {
var idx = Number(editBtn.getAttribute('data-cal-edit')); var j = _cachedCronJobs[idx];
if (j) { App._calModalMode = 'edit'; App._calEditJob = { name: j.name, description: (j.description || App._cronIntentFromJob(j) || ''), schedule_kind: j.schedule_kind, schedule_expr: j.schedule_expr, _idx: idx, _origName: j.name }; App.navigate('scheduler'); }
return;
}
var delBtn = e.target.closest('[data-cal-delete]');
var runBtn = e.target.closest('[data-cal-run]');
if (runBtn) {
var runIdx = Number(runBtn.getAttribute('data-cal-run')); var rj = _cachedCronJobs[runIdx];
if (rj && rj.id) {
api('/api/cron/jobs/' + encodeURIComponent(rj.id) + '/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(function(resp) {
var msg = (resp.status === 'success' ? 'Ran ' : 'Run failed: ') + rj.name + (resp.error ? ' — ' + resp.error : '');
if (resp.output_text) msg += ' — ' + String(resp.output_text).replace(/\s+/g, ' ').trim().slice(0, 160);
toast(msg);
App.navigate('scheduler');
})
.catch(function(err) { toast(err.message || 'Run failed'); });
}
return;
}
if (delBtn) {
var idx = Number(delBtn.getAttribute('data-cal-delete')); var j = _cachedCronJobs[idx];
if (j) {
if (!confirm('Delete scheduled job "' + j.name + '" and its run history?')) return;
api('/api/cron/jobs/' + encodeURIComponent(j.id), { method: 'DELETE' }).then(function() { toast('Deleted "' + j.name + '"'); App.navigate('scheduler'); }).catch(function(err) { toast(err.message || 'Delete failed'); App.navigate('scheduler'); });
}
return;
}
if (e.target.closest('[data-cal-modal-close]') || (e.target.closest('[data-cal-overlay]') && e.target.hasAttribute('data-cal-overlay'))) { App._calModalMode = null; App._calEditJob = null; App.navigate('scheduler'); return; }
var dayBtn = e.target.closest('[data-cal-day]');
if (dayBtn) { dayBtn.classList.toggle('active'); var sched = App._readSchedFromUI(); var sumEl = document.getElementById('cal-sched-summary'); if (sumEl) sumEl.textContent = App._schedSummary(sched); return; }
if (e.target.closest('#cal-modal-save')) {
var nameEl = document.getElementById('cal-job-name'); var name = (nameEl && nameEl.value || '').trim();
if (!name) { toast('Name is required'); return; }
var sched = App._readSchedFromUI(); var cron = App._schedToCron(sched);
var descEl = document.getElementById('cal-job-description'); var intent = (descEl && descEl.value || '').trim();
var payloadObj = intent ? { action: 'agent_task', task: intent } : { action: 'log', message: ('scheduled job: ' + name) };
var jobData = { name: name, description: intent || null, schedule_kind: cron.kind, schedule_expr: cron.expr, payload_json: JSON.stringify(payloadObj), agent_id: App._activeAgentId || 'ironclad' };
if (App._calModalMode === 'add') {
api('/api/cron/jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(jobData) }).then(function() { toast('Added "' + name + '"'); App._calModalMode = null; App._calEditJob = null; App.navigate('scheduler'); }).catch(function(err) { toast(err.message || 'Add failed'); });
} else {
var editIdx = App._calEditJob && App._calEditJob._idx;
var editJob = editIdx != null ? _cachedCronJobs[editIdx] : null;
if (editJob && editJob.id) {
api('/api/cron/jobs/' + encodeURIComponent(editJob.id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(jobData) }).then(function() { toast('Updated "' + name + '"'); App._calModalMode = null; App._calEditJob = null; App.navigate('scheduler'); }).catch(function(err) { toast(err.message || 'Update failed'); });
} else { toast('Cannot update: job ID missing'); }
}
return;
}
var evtChip = e.target.closest('[data-evt-job]');
if (evtChip) {
if (evtChip.hasAttribute('data-past')) {
var parentDay = evtChip.closest('.cal-day[data-date]');
if (parentDay) { var date = parentDay.getAttribute('data-date'); App._calSelected = date; App.navigate('scheduler'); }
return;
}
var jobName = evtChip.getAttribute('data-evt-job');
var idx = _cachedCronJobs.findIndex(function(j) { return j.name === jobName; });
if (idx >= 0) { var j = _cachedCronJobs[idx]; App._calModalMode = 'edit'; App._calEditJob = { name: j.name, description: (j.description || App._cronIntentFromJob(j) || ''), schedule_kind: j.schedule_kind, schedule_expr: j.schedule_expr, _idx: idx, _origName: j.name }; App.navigate('scheduler'); }
return;
}
var calDay = e.target.closest('.cal-day[data-date]');
if (calDay) { var date = calDay.getAttribute('data-date'); App._calSelected = App._calSelected === date ? null : date; App.navigate('scheduler'); return; }
var copyBtn = e.target.closest('.copy-id-btn[data-copy-id]');
if (copyBtn) {
e.stopPropagation();
var copyId = copyBtn.getAttribute('data-copy-id');
var svgEl = copyBtn.querySelector('svg');
navigator.clipboard.writeText(copyId).then(function() {
toast('Session ID copied');
if (svgEl) { svgEl.innerHTML = '<polyline points="4 8 7 11 12 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'; setTimeout(function() { svgEl.innerHTML = '<rect x="5" y="5" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.3"/><path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>'; }, 1500); }
}).catch(function() { toast('Copy failed \u2014 try HTTPS'); });
return;
}
var row = e.target.closest('tbody tr[data-id]');
if (row) {
var id = row.getAttribute('data-id');
var tds = row.querySelectorAll('td');
var nickTd = tds[0]; var agentTd = tds[1];
var nick = nickTd ? nickTd.textContent.trim() : '';
dismissHint('sessions-helper');
try { window.localStorage.setItem('ic_sessions_helper_dismissed', '1'); } catch (_) {}
App._activeSession = {
id: id,
agent_id: agentTd ? agentTd.textContent : 'default',
agent_name: AGENT_DISPLAY_NAME || null,
nickname: nick !== id ? nick : null
};
App.navigate('sessions');
return;
}
// Grade stars: click to submit grade
var starEl = e.target.closest('.grade-stars .star');
if (starEl) {
var gradeRow = starEl.closest('.grade-stars');
var turnId = gradeRow.getAttribute('data-turn-id');
var grade = parseInt(starEl.getAttribute('data-grade'));
var allStars = gradeRow.querySelectorAll('.star');
allStars.forEach(function(s) {
var g = parseInt(s.getAttribute('data-grade'));
if (g <= grade) { s.classList.add('filled'); } else { s.classList.remove('filled'); }
});
api('/api/turns/' + encodeURIComponent(turnId) + '/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade: grade })
}).catch(function() {
api('/api/turns/' + encodeURIComponent(turnId) + '/feedback', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade: grade })
}).catch(function() {});
});
return;
}
// Grade comment toggle
var commentToggle = e.target.closest('.grade-comment-toggle');
if (commentToggle) {
var gradeRow = commentToggle.closest('.grade-stars');
var turnId = gradeRow.getAttribute('data-turn-id');
var existing = gradeRow.parentElement.querySelector('.grade-comment-row');
if (existing) { existing.remove(); return; }
var row = document.createElement('div');
row.className = 'grade-comment-row';
var inp = document.createElement('input');
inp.type = 'text';
inp.placeholder = 'Add a comment\u2026';
inp.className = 'grade-comment-input';
inp.setAttribute('data-turn-id', turnId);
var btn = document.createElement('button');
btn.className = 'grade-save-btn';
btn.setAttribute('data-turn-id', turnId);
btn.textContent = 'Save';
row.appendChild(inp);
row.appendChild(btn);
gradeRow.parentElement.insertBefore(row, gradeRow.nextSibling);
inp.focus();
return;
}
// Grade comment save
var commentSave = e.target.closest('.grade-save-btn');
if (commentSave) {
var turnId = commentSave.getAttribute('data-turn-id');
var input = commentSave.parentElement.querySelector('.grade-comment-input');
var comment = input ? input.value.trim() : '';
var gradeRow = commentSave.parentElement.previousElementSibling;
var filledStars = gradeRow ? gradeRow.querySelectorAll('.star.filled').length : 0;
var grade = filledStars > 0 ? filledStars : 3;
api('/api/turns/' + encodeURIComponent(turnId) + '/feedback', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grade: grade, comment: comment || null })
}).then(function() {
commentSave.parentElement.remove();
toast('Comment saved');
}).catch(function() { toast('Failed to save comment'); });
return;
}
// Context Explorer: session selection
var ctxSessionRow = e.target.closest('tr[data-ctx-session]');
if (ctxSessionRow) {
try { App._ctxSession = JSON.parse(ctxSessionRow.getAttribute('data-ctx-session')); } catch(ex) {}
App._ctxActiveTurn = null;
App.navigate('context');
return;
}
// Context Explorer: turn selection
var ctxTurnItem = e.target.closest('.ctx-timeline-item[data-turn-id]');
if (ctxTurnItem) {
var turnId = ctxTurnItem.getAttribute('data-turn-id');
var found = App._ctxTurns.find(function(t) { return t.id === turnId; });
if (found) { App._ctxActiveTurn = found; App.navigate('context'); }
return;
}
var ctxLiveBtn = e.target.closest('#ctx-open-live-turn');
if (ctxLiveBtn) {
var liveTurnId = ctxLiveBtn.getAttribute('data-turn-id');
var liveSessionId = ctxLiveBtn.getAttribute('data-session-id');
if (liveSessionId) {
App._ctxSession = { id: liveSessionId, nickname: liveSessionId };
}
if (liveTurnId) {
App._ctxActiveTurn = { id: liveTurnId };
}
App.navigate('context');
return;
}
var ctxCopyLiveBtn = e.target.closest('#ctx-copy-live-turn');
if (ctxCopyLiveBtn) {
var copyTurnId = ctxCopyLiveBtn.getAttribute('data-turn-id') || '';
if (!copyTurnId) {
toast('No live turn ID available');
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(copyTurnId).then(function() {
toast('Turn ID copied');
}).catch(function() {
toast('Copy failed');
});
} else {
try {
var tmp = document.createElement('textarea');
tmp.value = copyTurnId;
tmp.setAttribute('readonly', '');
tmp.style.position = 'absolute';
tmp.style.left = '-9999px';
document.body.appendChild(tmp);
tmp.select();
document.execCommand('copy');
document.body.removeChild(tmp);
toast('Turn ID copied');
} catch (ex) {
toast('Copy failed');
}
}
return;
}
// Context Explorer: back button
if (e.target.closest('#ctx-back-btn')) {
if (App._ctxActiveTurn) { App._ctxActiveTurn = null; }
else { App._ctxSession = null; }
App.navigate('context');
return;
}
// Per-message context expand button
var ctxBtn = e.target.closest('.ctx-expand-btn');
if (ctxBtn) {
var msgIdx = ctxBtn.getAttribute('data-msg-idx');
var detailEl = document.getElementById('ctx-detail-' + msgIdx);
if (!detailEl) return;
if (detailEl.style.display !== 'none') { detailEl.style.display = 'none'; return; }
var sessionId = ctxBtn.getAttribute('data-session-id');
setHtml(detailEl, '<span style="color:var(--muted);font-size:0.75rem">Loading context\u2026</span>');
detailEl.style.display = 'block';
api('/api/sessions/' + encodeURIComponent(sessionId) + '/turns').then(function(r) {
var turns = r.turns || [];
var asstIdx = 0;
var allMsgs = App._sessionMessages || [];
for (var mi = 0; mi < allMsgs.length && mi <= parseInt(msgIdx); mi++) {
if (allMsgs[mi].role === 'assistant') asstIdx++;
}
var turn = turns[asstIdx - 1];
if (!turn) { setHtml(detailEl, '<span style="color:var(--muted);font-size:0.75rem">No turn data found.</span>'); return; }
return Promise.all([
api('/api/turns/' + encodeURIComponent(turn.id) + '/context').catch(function() { return null; }),
api('/api/turns/' + encodeURIComponent(turn.id) + '/tips').catch(function() { return { tips: [] }; })
]).then(function(results) {
var ctx = results[0], tipsData = results[1];
var html = '';
if (ctx) {
var budget = ctx.token_budget || 1;
var sysPct = Math.round(((ctx.system_prompt_tokens || 0) / budget) * 100);
var memPct = Math.round(((ctx.memory_tokens || 0) / budget) * 100);
var histPct = Math.round(((ctx.history_tokens || 0) / budget) * 100);
var freePct = Math.max(0, 100 - sysPct - memPct - histPct);
html += '<div style="font-size:0.6875rem;color:var(--muted);margin-bottom:0.25rem">' + esc(ctx.complexity_level) + ' \u2022 budget: ' + (ctx.token_budget || 0).toLocaleString() + ' tokens \u2022 depth: ' + (ctx.history_depth || 0) + '</div>';
html += '<div class="ctx-bar"><span class="sys" style="width:' + sysPct + '%"></span><span class="mem" style="width:' + memPct + '%"></span><span class="hist" style="width:' + histPct + '%"></span><span class="free" style="width:' + freePct + '%"></span></div>';
html += '<div class="ctx-legend"><span class="l-sys">System ' + (ctx.system_prompt_tokens || 0) + '</span><span class="l-mem">Memory ' + (ctx.memory_tokens || 0) + '</span><span class="l-hist">History ' + (ctx.history_tokens || 0) + '</span></div>';
if (ctx.model) html += '<div style="margin-top:0.25rem;font-size:0.6875rem;color:var(--muted)">Model: ' + esc(ctx.model) + '</div>';
} else {
html += '<span style="color:var(--muted)">No context snapshot.</span>';
}
if (turn.cost != null) html += '<div style="margin-top:0.25rem;font-size:0.6875rem;color:var(--success)">$' + turn.cost.toFixed(4) + ' \u2022 ' + ((turn.tokens_in || 0) + (turn.tokens_out || 0)).toLocaleString() + ' tokens</div>';
var tips = (tipsData && tipsData.tips) || [];
if (tips.length > 0) {
html += '<div class="ctx-tips">';
tips.forEach(function(tip) {
html += '<span class="ctx-tip ' + esc(tip.severity) + '" title="' + esc(tip.suggestion) + '"><span class="tip-dot"></span>' + esc(tip.message) + '</span>';
});
html += '</div>';
}
html += '<button class="btn secondary ctx-analyze-btn" data-analyze-turn="' + esc(turn.id) + '">Analyze with AI</button>';
if (turn.thinking) html += '<details class="ctx-section"><summary>Reasoning</summary><div>' + renderSafeMarkdown(turn.thinking) + '</div></details>';
setHtml(detailEl, html);
});
}).catch(function() { setHtml(detailEl, '<span style="color:var(--error);font-size:0.75rem">Failed to load context.</span>'); });
return;
}
var analyzeBtn = e.target.closest('[data-analyze-turn]');
if (analyzeBtn) {
var turnId = analyzeBtn.getAttribute('data-analyze-turn');
analyzeBtn.disabled = true; analyzeBtn.textContent = 'Analyzing\u2026';
api('/api/turns/' + encodeURIComponent(turnId) + '/analyze', { method: 'POST' }).then(function(r) {
analyzeBtn.textContent = 'Analyze with AI'; analyzeBtn.disabled = false;
var parent = analyzeBtn.parentElement;
var existing = parent.querySelector('.ctx-analyze-result');
if (existing) existing.remove();
var div = document.createElement('div');
div.className = 'ctx-analyze-result';
div.style.cssText = 'margin-top:0.375rem;padding:0.5rem;background:rgba(0,0,0,0.15);border-radius:3px;font-size:0.6875rem;border:1px solid var(--border)';
var inner = '<div style="color:var(--muted);margin-bottom:0.25rem">Turn analysis</div>';
inner += '<div style="margin-bottom:0.35rem;color:var(--text)">' + esc(r.summary || 'No summary') + '</div>';
var tips = r.tips || [];
if (tips.length > 0) {
inner += '<ul style="margin:0;padding-left:1rem">';
tips.forEach(function(t) {
inner += '<li style="margin:0.18rem 0"><strong>' + esc(t.rule_name || 'tip') + ':</strong> ' + esc(t.suggestion || t.message || '') + '</li>';
});
inner += '</ul>';
} else {
inner += '<div style="color:var(--muted)">No additional recommendations.</div>';
}
setHtml(div, inner);
parent.insertBefore(div, analyzeBtn.nextSibling);
}).catch(function(err) {
analyzeBtn.textContent = 'Analyze with AI'; analyzeBtn.disabled = false;
toast(err.message || 'Analysis failed');
});
return;
}
});
if (content) content.addEventListener('change', function(e) {
if (!e.target || e.target.id !== 'model-graph-task-select') return;
App._modelGraphFocusTurn = e.target.value || null;
App._modelGraphFocusModel = null;
App._modelGraphFocusEdge = null;
App.navigate('efficiency');
});
if (content) content.addEventListener('input', function(e) {
var slider = e.target.closest('[data-routing-slider]');
if (!slider) return;
var key = slider.getAttribute('data-routing-slider');
if (!key) return;
if (!App._routingProfileDraft) {
App._routingProfileDraft = App._routingProfileDefaults
? { correctness: App._routingProfileDefaults.correctness, cost: App._routingProfileDefaults.cost, speed: App._routingProfileDefaults.speed }
: { correctness: 0.0, cost: 0.0, speed: 0.0 };
}
App._routingProfileDraft[key] = clamp01(slider.value);
App._routingProfileDraft = normalizeRoutingProfile(App._routingProfileDraft, key);
['correctness', 'cost', 'speed'].forEach(function(k) {
var valEl = document.getElementById('routing-slider-' + k + '-val');
if (valEl) valEl.textContent = App._routingProfileDraft[k].toFixed(2);
var sliderEl = document.getElementById('routing-slider-' + k);
if (sliderEl) sliderEl.value = App._routingProfileDraft[k].toFixed(2);
});
var totalEl = document.getElementById('routing-slider-total');
if (totalEl) totalEl.textContent = routingProfileTotal(App._routingProfileDraft).toFixed(2);
var spiderHost = document.getElementById('routing-profile-spider-host');
if (spiderHost) spiderHost.innerHTML = renderRoutingSpiderSvg(App._routingProfileDraft);
});
if (content) content.addEventListener('input', function(e) {
if (!e.target || e.target.id !== 'catalog-filter-input') return;
var needle = String(e.target.value || '').trim().toLowerCase();
var rows = Array.prototype.slice.call(document.querySelectorAll('[data-catalog-row="1"]'));
var visible = 0;
rows.forEach(function(row) {
var hay = String(row.getAttribute('data-catalog-search') || '').toLowerCase();
var match = !needle || hay.indexOf(needle) !== -1;
row.style.display = match ? '' : 'none';
if (match) visible += 1;
});
var empty = document.getElementById('catalog-filter-empty');
if (empty) empty.style.display = visible === 0 ? '' : 'none';
});
if (content) content.addEventListener('input', function(e) {
if (e.target.id === 'mem-filter-input') { App._memoryFilter = e.target.value; App._memoryPage = 0; App.navigate('memory'); return; }
});
if (content) content.addEventListener('input', function(e) {
if (e.target.id === 'cal-sched-interval' || e.target.id === 'cal-sched-hour' || e.target.id === 'cal-sched-minute') { var sched = App._readSchedFromUI(); var sumEl = document.getElementById('cal-sched-summary'); if (sumEl) sumEl.textContent = App._schedSummary(sched); return; }
var jsonEditor = e.target.closest('#settings-json-editor');
if (jsonEditor) {
App._settingsJsonText = jsonEditor.value; App._settingsDirty = true;
var lint = document.querySelector('.settings-lint');
if (lint) { try { JSON.parse(jsonEditor.value); lint.textContent = '\u2713 Valid JSON'; lint.className = 'settings-lint ok'; jsonEditor.classList.remove('has-error'); } catch (err) { lint.textContent = '\u2717 ' + err.message; lint.className = 'settings-lint err'; jsonEditor.classList.add('has-error'); } }
document.querySelectorAll('#settings-save,#settings-apply,#settings-cancel').forEach(function(b) { b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = ''; });
return;
}
var formInput = e.target.closest('[data-settings-path]');
if (formInput) {
var path = formInput.getAttribute('data-settings-path');
var draft = App._getSettingsDraft();
var rawVal = formInput.value;
var newVal;
if (formInput.type === 'checkbox') { newVal = formInput.checked; }
else if (formInput.type === 'number') { newVal = Number(rawVal); }
else if (formInput.hasAttribute('data-settings-array')) { newVal = rawVal.split('\n').map(function(s){return s.trim()}).filter(function(s){return s.length > 0}); }
else { newVal = rawVal === '' ? null : rawVal; }
App._setNestedValue(draft, path, newVal);
App._settingsDirty = JSON.stringify(draft) !== JSON.stringify(_cachedConfig);
App._settingsJsonText = null;
if (formInput.type === 'checkbox') { var lbl = formInput.closest('.settings-toggle-wrap'); if (lbl) { var span = lbl.querySelector('.settings-toggle-label'); if (span) span.textContent = newVal ? 'On' : 'Off'; } }
document.querySelectorAll('#settings-save,#settings-apply,#settings-cancel').forEach(function(b) { if (App._settingsDirty) { b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = ''; } else { b.disabled = true; b.style.opacity = '0.4'; b.style.pointerEvents = 'none'; } });
return;
}
var revenueSwapChainInput = e.target.closest('[data-revenue-swap-chain-field]');
if (revenueSwapChainInput) {
var revenueField = revenueSwapChainInput.getAttribute('data-revenue-swap-chain-field');
var revenueIdx = Number(revenueSwapChainInput.getAttribute('data-revenue-swap-chain-index'));
var draftSwapField = App._getSettingsDraft();
if (!draftSwapField.treasury) draftSwapField.treasury = {};
if (!draftSwapField.treasury.revenue_swap) draftSwapField.treasury.revenue_swap = {};
if (!Array.isArray(draftSwapField.treasury.revenue_swap.chains)) draftSwapField.treasury.revenue_swap.chains = [];
if (!draftSwapField.treasury.revenue_swap.chains[revenueIdx]) draftSwapField.treasury.revenue_swap.chains[revenueIdx] = { chain: '', target_contract_address: '', swap_contract_address: '' };
var revenueVal = revenueSwapChainInput.value;
if (revenueField === 'chain') revenueVal = String(revenueVal || '').toUpperCase();
draftSwapField.treasury.revenue_swap.chains[revenueIdx][revenueField] = revenueVal;
App._settingsDirty = JSON.stringify(draftSwapField) !== JSON.stringify(_cachedConfig);
App._settingsJsonText = null;
document.querySelectorAll('#settings-save,#settings-apply,#settings-cancel').forEach(function(b) { if (App._settingsDirty) { b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = ''; } else { b.disabled = true; b.style.opacity = '0.4'; b.style.pointerEvents = 'none'; } });
return;
}
});
if (content) content.addEventListener('dragstart', function(e) {
var item = e.target.closest('[data-model-order-item]');
if (!item || App._settingsMode !== 'models') return;
App._settingsDragModelIndex = Number(item.getAttribute('data-model-order-item'));
item.classList.add('dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(App._settingsDragModelIndex));
}
});
if (content) content.addEventListener('dragover', function(e) {
var item = e.target.closest('[data-model-order-item]');
if (!item || App._settingsMode !== 'models') return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.model-order-item.drop-target').forEach(function(el) { el.classList.remove('drop-target'); });
item.classList.add('drop-target');
});
if (content) content.addEventListener('dragleave', function(e) {
var item = e.target.closest('[data-model-order-item]');
if (!item) return;
item.classList.remove('drop-target');
});
if (content) content.addEventListener('drop', function(e) {
var item = e.target.closest('[data-model-order-item]');
if (!item || App._settingsMode !== 'models') return;
e.preventDefault();
var fromIdx = App._settingsDragModelIndex;
var toIdx = Number(item.getAttribute('data-model-order-item'));
document.querySelectorAll('.model-order-item.drop-target,.model-order-item.dragging').forEach(function(el) { el.classList.remove('drop-target'); el.classList.remove('dragging'); });
App._settingsDragModelIndex = null;
if (!App._settingsModelOrder || fromIdx == null || fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
var moved = App._settingsModelOrder.splice(fromIdx, 1)[0];
App._settingsModelOrder.splice(toIdx, 0, moved);
App._syncModelOrderToDraft();
App.navigate('settings');
});
if (content) content.addEventListener('dragend', function() {
document.querySelectorAll('.model-order-item.drop-target,.model-order-item.dragging').forEach(function(el) { el.classList.remove('drop-target'); el.classList.remove('dragging'); });
App._settingsDragModelIndex = null;
});
if (content) content.addEventListener('keydown', function(e) {
if (e.target.id === 'session-msg-input' && e.key === 'Enter') { e.preventDefault(); if (App._sendingMessage) return; var btn = document.getElementById('btn-send-msg'); if (btn && !btn.disabled) btn.click(); return; }
if (e.target.id === 'model-order-add-input' && e.key === 'Enter') { e.preventDefault(); var addBtn = document.getElementById('model-order-add-btn'); if (addBtn) addBtn.click(); return; }
if (e.target.id === 'revenue-swap-chain-add' && e.key === 'Enter') { e.preventDefault(); var addRevenueSwapChainBtn = document.getElementById('revenue-swap-chain-add-btn'); if (addRevenueSwapChainBtn) addRevenueSwapChainBtn.click(); return; }
if (e.target.id === 'add-provider-input' && e.key === 'Enter') { e.preventDefault(); var addProviderBtn = document.getElementById('add-provider'); if (addProviderBtn) addProviderBtn.click(); return; }
if (e.target.id === 'trusted-sender-input' && e.key === 'Enter') { e.preventDefault(); var addSenderBtn = document.getElementById('trusted-sender-add'); if (addSenderBtn) addSenderBtn.click(); return; }
if (e.target.id === 'telegram-allowed-chat-input' && e.key === 'Enter') { e.preventDefault(); var addTgBtn = document.getElementById('telegram-allowed-chat-add'); if (addTgBtn) addTgBtn.click(); return; }
if (e.target.id === 'whatsapp-allowed-number-input' && e.key === 'Enter') { e.preventDefault(); var addWaBtn = document.getElementById('whatsapp-allowed-number-add'); if (addWaBtn) addWaBtn.click(); return; }
if (e.target.id === 'signal-allowed-number-input' && e.key === 'Enter') { e.preventDefault(); var addSigBtn = document.getElementById('signal-allowed-number-add'); if (addSigBtn) addSigBtn.click(); return; }
if (e.target.id === 'discord-allowed-guild-input' && e.key === 'Enter') { e.preventDefault(); var addDcBtn = document.getElementById('discord-allowed-guild-add'); if (addDcBtn) addDcBtn.click(); return; }
if (e.target.id === 'email-allowed-sender-input' && e.key === 'Enter') { e.preventDefault(); var addEmBtn = document.getElementById('email-allowed-sender-add'); if (addEmBtn) addEmBtn.click(); return; }
if (e.target.id === 'roster-model-custom' && e.key === 'Enter') { e.preventDefault(); var rosterAddBtn = document.getElementById('roster-model-order-add'); var rosterSaveBtn = document.getElementById('roster-model-save'); if (rosterAddBtn) rosterAddBtn.click(); else if (rosterSaveBtn) rosterSaveBtn.click(); return; }
if (e.target.id === 'settings-json-editor' && e.key === 'Tab') { e.preventDefault(); var ta = e.target; var start = ta.selectionStart, end = ta.selectionEnd; ta.value = ta.value.substring(0, start) + ' ' + ta.value.substring(end); ta.selectionStart = ta.selectionEnd = start + 2; ta.dispatchEvent(new Event('input', { bubbles: true })); }
});
if (content) content.addEventListener('change', function(e) {
if (e.target.classList && e.target.classList.contains('startup-ann-check')) {
var draftSa = App._getSettingsDraft();
if (!draftSa.channels) draftSa.channels = {};
var checks = Array.prototype.slice.call(document.querySelectorAll('.startup-ann-check:checked')).map(function(el) { return String(el.getAttribute('data-startup-channel') || '').trim(); }).filter(Boolean);
draftSa.channels.startup_announcements = checks.length ? checks : null;
App._settingsDirty = JSON.stringify(draftSa) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
document.querySelectorAll('#settings-save,#settings-apply,#settings-cancel').forEach(function(b) { if (App._settingsDirty) { b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = ''; } else { b.disabled = true; b.style.opacity = '0.4'; b.style.pointerEvents = 'none'; } });
return;
}
if (e.target.id === 'dashboard-hints-toggle') {
var enabled = !!e.target.checked;
if (enabled) {
setHintsEnabled(true);
} else {
disableAndClearHints();
}
try { window.localStorage.setItem(HINTS_PROMPTED_KEY, '1'); } catch (_) {}
var toggleWrap = e.target.closest('.settings-toggle-wrap');
if (toggleWrap) {
var toggleLbl = toggleWrap.querySelector('.settings-toggle-label');
if (toggleLbl) toggleLbl.textContent = enabled ? 'On' : 'Off';
}
toast(enabled ? 'Hints enabled' : 'Hints disabled');
return;
}
if (e.target.id === 'cal-sched-freq') {
var job = App._calEditJob || { name: '', schedule_kind: 'cron', schedule_expr: '' };
var nameEl = document.getElementById('cal-job-name'); if (nameEl) job.name = nameEl.value;
var freq = e.target.value;
var defaults = { interval: { kind: 'cron', expr: '*/5 * * * *' }, hourly: { kind: 'cron', expr: '0 * * * *' }, daily: { kind: 'cron', expr: '0 2 * * *' }, weekly: { kind: 'cron', expr: '0 9 * * 1,2,3,4,5' } };
var d = defaults[freq] || defaults.daily; job.schedule_kind = d.kind; job.schedule_expr = d.expr; App._calEditJob = job; App.navigate('scheduler'); return;
}
if (e.target.id === 'cal-sched-interval' || e.target.id === 'cal-sched-interval-unit' || e.target.id === 'cal-sched-hour' || e.target.id === 'cal-sched-minute') { var sched = App._readSchedFromUI(); var sumEl = document.getElementById('cal-sched-summary'); if (sumEl) sumEl.textContent = App._schedSummary(sched); return; }
var formInput = e.target.closest('[data-settings-path]');
if (formInput) {
var path = formInput.getAttribute('data-settings-path'); var draft = App._getSettingsDraft();
var newVal;
if (formInput.type === 'checkbox') { newVal = formInput.checked; }
else if (formInput.tagName === 'SELECT') { newVal = formInput.value; }
else if (formInput.type === 'number') { newVal = Number(formInput.value); }
else if (formInput.hasAttribute('data-settings-array')) { newVal = formInput.value.split('\n').map(function(s){return s.trim()}).filter(function(s){return s.length > 0}); }
else { newVal = formInput.value === '' ? null : formInput.value; }
App._setNestedValue(draft, path, newVal);
App._settingsDirty = JSON.stringify(draft) !== JSON.stringify(_cachedConfig); App._settingsJsonText = null;
if (formInput.type === 'checkbox') { var lbl = formInput.closest('.settings-toggle-wrap'); if (lbl) { var span = lbl.querySelector('.settings-toggle-label'); if (span) span.textContent = newVal ? 'On' : 'Off'; } }
document.querySelectorAll('#settings-save,#settings-apply,#settings-cancel').forEach(function(b) { if (App._settingsDirty) { b.disabled = false; b.style.opacity = ''; b.style.pointerEvents = ''; } else { b.disabled = true; b.style.opacity = '0.4'; b.style.pointerEvents = 'none'; } });
}
var revenueSwapChainInput = e.target.closest('[data-revenue-swap-chain-field]');
if (revenueSwapChainInput && revenueSwapChainInput.getAttribute('data-revenue-swap-chain-field') === 'chain') {
revenueSwapChainInput.value = String(revenueSwapChainInput.value || '').toUpperCase();
}
});
var workspace = null;
function startWorkspaceEngine(data) { if (workspace) { workspace.stop(); workspace = null; } var canvas = document.getElementById('ws-canvas'); if (!canvas) return; workspace = new WorkspaceEngine(canvas, data); workspace.start(); }
function WorkspaceEngine(canvas, data) {
this.canvas = canvas; this.ctx = canvas.getContext('2d');
this.running = false; this.animId = 0; this.time = 0;
this.bots = []; this.stations = []; this.tethers = []; this.interactions = []; this.dataStreams = [];
this.selectedBotId = null;
this.dpr = window.devicePixelRatio || 1;
var self = this;
this._onCanvasClick = function(e) { self.onCanvasClick(e); };
this._onCanvasMove = function(e) { self.onCanvasMove(e); };
this.canvas.addEventListener('click', this._onCanvasClick);
this.canvas.addEventListener('mousemove', this._onCanvasMove);
this._resizeObs = new ResizeObserver(function() { self.resize(); });
this._resizeObs.observe(canvas.parentElement);
this.initData(data);
}
WorkspaceEngine.prototype._stationById = function(id) {
if (!id) return null;
return this.stations.find(function(s) { return s.id === id; }) || null;
};
WorkspaceEngine.prototype._idleWarehouseStation = function() {
return this.stations.find(function(s) {
var k = (s.kind || '').toLowerCase();
return s.id === 'shelter' || s.id === 'standby' || k === 'shelter' || k === 'standby';
}) || null;
};
WorkspaceEngine.prototype._isRuntimeActive = function(activity) {
var a = (activity || '').toLowerCase();
return a === 'inference' || a === 'working' || a === 'tool_execution' || a === 'tooling' || a === 'moving' || a === 'walking' || a === 'talking';
};
WorkspaceEngine.prototype._buildBotFromAgent = function(a, idx, total) {
var cx = 0.5, cy = 0.5;
if (total > 1) {
var angle = (idx / total) * Math.PI * 2 - Math.PI / 2;
var radius = Math.min(0.22, 0.08 + total * 0.012);
cx = 0.5 + Math.cos(angle) * radius;
cy = 0.5 + Math.sin(angle) * radius;
}
return {
id: a.id, name: a.name, role: a.role || 'subagent',
model: a.model || '', state: (a.state || 'Idle').toString(), color: a.color || '#6366f1',
activity: (a.activity || '').toString().toLowerCase(),
subordinates: a.subordinates || [], supervisor: a.supervisor || null,
skills: a.skills || [], activeSkill: a.active_skill || null, skillFade: 0,
x: cx, y: cy,
targetX: cx, targetY: cy, vx: 0, vy: 0,
animState: (a.activity && a.activity !== 'idle') ? 'working' : 'idle', animFrame: 0, animTimer: 0,
headAngle: 0, headTarget: 0, headTimer: 2 + idx * 0.15,
lights: [0.6, 0.6, 0.8], lightTimer: 0.5,
blinkTimer: 3 + idx * 0.2, blinking: false,
workTimer: 0, currentStation: a.current_workstation || null
};
};
WorkspaceEngine.prototype.initData = function(data) {
var agents = data.agents || [];
var systems = data.systems || data.workstations || [];
this.stations = systems.map(function(s) { return { id: s.id, name: s.name, kind: s.kind, x: s.x, y: s.y }; });
if (this.stations.length === 0) {
this.stations = [
{ id: 'llm', name: 'LLM Inference', kind: 'Inference', x: 0.18, y: 0.22 },
{ id: 'memory', name: 'Memory', kind: 'Storage', x: 0.82, y: 0.22 },
{ id: 'exec', name: 'Code Execution', kind: 'Execution', x: 0.18, y: 0.78 },
{ id: 'blockchain', name: 'Blockchain', kind: 'Blockchain', x: 0.82, y: 0.78 },
{ id: 'web', name: 'Web / APIs', kind: 'Tool', x: 0.50, y: 0.12 },
{ id: 'files', name: 'File System', kind: 'Tool', x: 0.50, y: 0.88 },
{ id: 'shelter', name: 'Idle Agents', kind: 'Shelter', x: 0.035, y: 0.50 }
];
}
var self = this;
this.bots = agents.map(function(a, idx) { return self._buildBotFromAgent(a, idx, agents.length); });
this.bots.forEach(function(bot) {
if (!bot.currentStation) return;
var st = self.stations.find(function(s) { return s.id === bot.currentStation; });
if (st) {
bot.targetX = st.x;
bot.targetY = st.y + 0.10;
}
});
this.layoutStandby();
this.tethers = [];
for (var i = 0; i < this.bots.length; i++) {
var bot = this.bots[i];
if (bot.subordinates && bot.subordinates.length > 0) {
for (var j = 0; j < bot.subordinates.length; j++) {
var sub = this.bots.find(function(b) { return b.id === bot.subordinates[j]; });
if (sub) this.tethers.push({ from: bot, to: sub, color: bot.color });
}
}
}
};
WorkspaceEngine.prototype.applySnapshot = function(data) {
if (!data) return;
var agents = data.agents || [];
var systems = data.systems || data.workstations || [];
if (systems.length > 0) {
this.stations = systems.map(function(s) { return { id: s.id, name: s.name, kind: s.kind, x: s.x, y: s.y }; });
}
var byId = {};
this.bots.forEach(function(b) { byId[b.id] = b; });
var nextBots = [];
for (var i = 0; i < agents.length; i++) {
var incoming = agents[i];
var existing = byId[incoming.id];
if (!existing) {
existing = this._buildBotFromAgent(incoming, i, agents.length);
} else {
existing.name = incoming.name || existing.name;
existing.role = incoming.role || existing.role;
existing.model = incoming.model || existing.model;
existing.state = (incoming.state || existing.state || 'Idle').toString();
existing.color = incoming.color || existing.color;
existing.activity = (incoming.activity || '').toString().toLowerCase();
existing.subordinates = incoming.subordinates || existing.subordinates || [];
existing.supervisor = incoming.supervisor || null;
existing.skills = incoming.skills || existing.skills || [];
existing.activeSkill = incoming.active_skill || null;
existing.currentStation = incoming.current_workstation || existing.currentStation || null;
var runtimeActive = this._isRuntimeActive(existing.activity);
var stationedActive = !!existing.currentStation && existing.currentStation !== 'standby' && existing.currentStation !== 'shelter';
if (existing.animState !== 'walking') existing.animState = (existing.activeSkill || runtimeActive || stationedActive) ? 'working' : 'idle';
}
var st = this._stationById(existing.currentStation);
if (st) {
existing.targetX = st.x;
existing.targetY = st.y + 0.10;
}
nextBots.push(existing);
}
this.bots = nextBots;
this.tethers = [];
for (var t = 0; t < this.bots.length; t++) {
var bot = this.bots[t];
if (bot.subordinates && bot.subordinates.length > 0) {
for (var j = 0; j < bot.subordinates.length; j++) {
var sub = this.bots.find(function(b) { return b.id === bot.subordinates[j]; });
if (sub) this.tethers.push({ from: bot, to: sub, color: bot.color });
}
}
}
this.layoutStandby();
};
WorkspaceEngine.prototype.layoutStandby = function() {
var standby = this._idleWarehouseStation();
if (!standby) return;
var idleBots = this.bots.filter(function(b) {
return !b.activeSkill && (b.animState === 'idle' || b.animState === 'walking');
});
// Move idle bots to the shelter station coords. drawBot() skips rendering
// them entirely — only the station badge count is visible.
for (var i = 0; i < idleBots.length; i++) {
var b = idleBots[i];
if (b.currentStation && b.currentStation !== 'standby' && b.currentStation !== 'shelter') continue;
b.targetX = standby.x;
b.targetY = standby.y;
b.currentStation = 'shelter';
}
};
WorkspaceEngine.prototype.resize = function() {
var parent = this.canvas.parentElement; if (!parent) return;
var w = parent.clientWidth, h = parent.clientHeight; if (w < 1 || h < 1) return;
this.canvas.width = w * this.dpr; this.canvas.height = h * this.dpr;
this.canvas.style.width = w + 'px'; this.canvas.style.height = h + 'px';
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
this.W = w; this.H = h;
};
WorkspaceEngine.prototype.start = function() {
this.running = true; this.resize(); this.lastTime = performance.now();
var self = this;
(function loop(ts) {
if (!self.running) return;
var dt = Math.min((ts - self.lastTime) / 1000, 0.05);
self.lastTime = ts; self.time += dt;
self.update(dt); self.render();
self.animId = requestAnimationFrame(loop);
})(performance.now());
};
WorkspaceEngine.prototype.stop = function() {
this.running = false; cancelAnimationFrame(this.animId);
if (this._onCanvasClick) this.canvas.removeEventListener('click', this._onCanvasClick);
if (this._onCanvasMove) this.canvas.removeEventListener('mousemove', this._onCanvasMove);
if (this._resizeObs) this._resizeObs.disconnect();
};
WorkspaceEngine.prototype._botAtScreenPoint = function(x, y) {
var sorted = this.bots.slice().sort(function(a, b) { return b.y - a.y; });
for (var i = 0; i < sorted.length; i++) {
var bot = sorted[i];
var isAgent = bot.role === 'agent';
var radius = isAgent ? 34 : 28;
var dx = x - (bot.x * this.W);
var dy = y - (bot.y * this.H);
if ((dx * dx + dy * dy) <= radius * radius) return bot;
}
return null;
};
WorkspaceEngine.prototype.onCanvasClick = function(evt) {
if (!this.W || !this.H) return;
var rect = this.canvas.getBoundingClientRect();
var x = evt.clientX - rect.left;
var y = evt.clientY - rect.top;
var hit = this._botAtScreenPoint(x, y);
this.selectedBotId = hit ? hit.id : null;
};
WorkspaceEngine.prototype.onCanvasMove = function(evt) {
if (!this.W || !this.H) return;
var rect = this.canvas.getBoundingClientRect();
var x = evt.clientX - rect.left;
var y = evt.clientY - rect.top;
var hit = this._botAtScreenPoint(x, y);
this.canvas.style.cursor = hit ? 'pointer' : 'default';
};
WorkspaceEngine.prototype.describeBotTask = function(bot) {
if (!bot) return '';
if (bot.animState === 'working' && bot.activeSkill) return 'Working on ' + bot.activeSkill + '.';
if (bot.animState === 'working' && bot.currentStation) {
var ws = this.stations.find(function(s) { return s.id === bot.currentStation; });
return ws ? ('Working at ' + ws.name + '.') : 'Working on a task.';
}
if (bot.currentStation === 'standby' || bot.currentStation === 'shelter') return 'Idle, waiting for the next task.';
if (bot.animState === 'walking') return 'Moving to the next workstation.';
return 'Idle and waiting for the next task.';
};
WorkspaceEngine.prototype.drawTaskBubble = function(ctx, bot, W, H) {
var tc = this._themeColors || { surface: '#1e2030', text: '#e4e4e7', aR: 99, aG: 102, aB: 241 };
var text = this.describeBotTask(bot);
if (!text) return;
var px = bot.x * W;
var py = bot.y * H - 56;
ctx.save();
ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
var maxW = 240;
var words = text.split(' ');
var lines = [];
var current = '';
for (var i = 0; i < words.length; i++) {
var testLine = current ? (current + ' ' + words[i]) : words[i];
if (ctx.measureText(testLine).width > maxW && current) {
lines.push(current);
current = words[i];
} else {
current = testLine;
}
}
if (current) lines.push(current);
var bubbleW = 0;
for (var li = 0; li < lines.length; li++) bubbleW = Math.max(bubbleW, ctx.measureText(lines[li]).width);
bubbleW += 14;
var lineH = 12;
var bubbleH = lines.length * lineH + 10;
var bx = Math.max(10, Math.min(W - bubbleW - 10, px - bubbleW / 2));
var by = Math.max(8, py - bubbleH);
ctx.fillStyle = tc.surface;
ctx.globalAlpha = 0.96;
ctx.beginPath(); ctx.roundRect(bx, by, bubbleW, bubbleH, 8); ctx.fill();
ctx.strokeStyle = 'rgba(' + tc.aR + ',' + tc.aG + ',' + tc.aB + ',0.35)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bubbleW, bubbleH, 8); ctx.stroke();
ctx.beginPath();
ctx.moveTo(px - 5, by + bubbleH);
ctx.lineTo(px, by + bubbleH + 8);
ctx.lineTo(px + 5, by + bubbleH);
ctx.closePath();
ctx.fillStyle = tc.surface;
ctx.fill();
ctx.stroke();
ctx.fillStyle = tc.text;
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
for (var t = 0; t < lines.length; t++) ctx.fillText(lines[t], bx + 7, by + 14 + t * lineH);
ctx.restore();
};
WorkspaceEngine.prototype.update = function(dt) {
if (this.selectedBotId && !this.bots.some(function(b) { return b.id === this.selectedBotId; }, this)) {
this.selectedBotId = null;
}
var self = this;
this.bots.forEach(function(bot) {
var dx = bot.targetX - bot.x, dy = bot.targetY - bot.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.005) {
var step = Math.min(0.35 * dt, dist);
bot.x += (dx / dist) * step; bot.y += (dy / dist) * step;
bot.animState = 'walking';
} else {
bot.x = bot.targetX; bot.y = bot.targetY;
if (bot.animState === 'walking') bot.animState = bot.activeSkill ? 'working' : 'idle';
}
bot.headTimer -= dt;
if (bot.headTimer <= 0) { bot.headTarget = Math.sin(bot.animFrame * 0.9) * 0.35; bot.headTimer = 2.8; }
bot.headAngle += (bot.headTarget - bot.headAngle) * 2 * dt;
bot.lightTimer -= dt;
if (bot.lightTimer <= 0) { bot.lights = bot.activeSkill ? [0.9, 0.6, 0.2] : [0.25, 0.45, 0.7]; bot.lightTimer = 0.5; }
bot.blinkTimer -= dt;
if (bot.blinkTimer <= 0) { bot.blinking = !bot.blinking; bot.blinkTimer = bot.blinking ? 0.12 : 3.2; }
bot.animTimer += dt;
if (bot.animTimer > 0.18) { bot.animFrame = (bot.animFrame + 1) % 4; bot.animTimer = 0; }
if (bot.activeSkill) { bot.skillFade = Math.min(1, bot.skillFade + dt * 3); }
else { bot.skillFade = Math.max(0, bot.skillFade - dt * 2); }
if (bot.animState !== 'walking') {
var runtimeActive = self._isRuntimeActive(bot.activity);
var stationedActive = !!bot.currentStation && bot.currentStation !== 'standby' && bot.currentStation !== 'shelter';
bot.animState = (bot.activeSkill || runtimeActive || stationedActive) ? 'working' : 'idle';
}
});
for (var i = this.dataStreams.length - 1; i >= 0; i--) {
var ds = this.dataStreams[i];
ds.progress += dt * 1.5;
ds.timer -= dt;
if (ds.timer <= 0) this.dataStreams.splice(i, 1);
}
for (var i = this.interactions.length - 1; i >= 0; i--) {
var ix = this.interactions[i];
ix.timer -= dt; ix.phase += dt;
ix.bubbleTimer += dt;
if (ix.timer <= 0) {
if (ix.botA._savedTarget) { ix.botA.targetX = ix.botA._savedTarget.x; ix.botA.targetY = ix.botA._savedTarget.y; delete ix.botA._savedTarget; }
if (ix.botB._savedTarget) { ix.botB.targetX = ix.botB._savedTarget.x; ix.botB.targetY = ix.botB._savedTarget.y; delete ix.botB._savedTarget; }
ix.botA.animState = 'idle'; ix.botB.animState = 'idle';
this.interactions.splice(i, 1);
}
}
this.layoutStandby();
};
function parseThemeColors() {
var cs = getComputedStyle(document.documentElement);
var bg = cs.getPropertyValue('--bg').trim() || '#0a0b0f';
var surface = cs.getPropertyValue('--surface').trim() || '#1e2030';
var accent = cs.getPropertyValue('--accent').trim() || '#6366f1';
var muted = cs.getPropertyValue('--muted').trim() || '#71717a';
var el = document.createElement('canvas'); el.width = el.height = 1;
var c2 = el.getContext('2d'); c2.fillStyle = accent; c2.fillRect(0, 0, 1, 1);
var d = c2.getImageData(0, 0, 1, 1).data;
var text = cs.getPropertyValue('--text').trim() || '#e4e4e7';
return { bg: bg, surface: surface, accent: accent, muted: muted, text: text, aR: d[0], aG: d[1], aB: d[2] };
}
WorkspaceEngine.prototype.render = function() {
var ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
var tc = parseThemeColors(); var aR = tc.aR, aG = tc.aG, aB = tc.aB;
this._themeColors = tc;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = tc.bg; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.04)';
for (var gx = 30; gx < W; gx += 40) for (var gy = 30; gy < H; gy += 40) {
ctx.beginPath(); ctx.arc(gx, gy, 0.8, 0, Math.PI * 2); ctx.fill();
}
var self = this;
this.stations.forEach(function(s) {
var sx = s.x * W, sy = s.y * H;
ctx.save(); ctx.translate(sx, sy);
var pulse = 0.6 + 0.4 * Math.sin(self.time * 1.5 + sx * 0.01);
var nearBot = self.bots.some(function(b) { return b.currentStation === s.id && (b.animState === 'working' || b.animState === 'walking'); });
var glowAlpha = nearBot ? 0.08 : 0.03;
var ringAlpha = nearBot ? (0.2 + 0.1 * pulse) : (0.08 + 0.04 * pulse);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + glowAlpha + ')';
ctx.beginPath(); ctx.arc(0, 8, 42, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + ringAlpha + ')';
ctx.lineWidth = nearBot ? 1.5 : 0.8;
ctx.beginPath(); ctx.arc(0, 8, 42, 0, Math.PI * 2); ctx.stroke();
var kind = (s.kind || '').toLowerCase();
if (kind === 'inference') {
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.3)'; ctx.lineWidth = 1;
var nodes = [[-12,-18],[12,-18],[0,-6],[-18,-4],[18,-4],[-8,6],[8,6],[0,16]];
var edges = [[0,2],[1,2],[0,3],[1,4],[2,5],[2,6],[3,5],[4,6],[5,7],[6,7]];
edges.forEach(function(e) { ctx.beginPath(); ctx.moveTo(nodes[e[0]][0], nodes[e[0]][1]); ctx.lineTo(nodes[e[1]][0], nodes[e[1]][1]); ctx.stroke(); });
nodes.forEach(function(n, i) { var r = i === 2 || i === 7 ? 4.5 : 3.5; ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.arc(n[0], n[1], r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.5 + 0.3 * pulse) + ')'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.3 + 0.4 * ((Math.sin(self.time * 3 + i * 1.2) + 1) / 2)) + ')'; ctx.beginPath(); ctx.arc(n[0], n[1], r - 1, 0, Math.PI * 2); ctx.fill(); });
} else if (kind === 'storage') {
for (var ci = 0; ci < 3; ci++) { var cy = -14 + ci * 11; ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.ellipse(0, cy + 8, 18, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillRect(-18, cy, 36, 8); ctx.beginPath(); ctx.ellipse(0, cy, 18, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.3 + 0.1 * ci) + ')'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.ellipse(0, cy, 18, 4, 0, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-18, cy); ctx.lineTo(-18, cy + 8); ctx.stroke(); ctx.beginPath(); ctx.moveTo(18, cy); ctx.lineTo(18, cy + 8); ctx.stroke(); ctx.beginPath(); ctx.ellipse(0, cy + 8, 18, 4, 0, Math.PI, Math.PI * 2); ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.08 + 0.06 * Math.sin(self.time * 2 + ci)) + ')'; ctx.fillRect(-16, cy + 1, 32, 6); }
} else if (kind === 'execution') {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.35)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.roundRect(-22, -18, 44, 34, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.12)'; ctx.fillRect(-22, -18, 44, 8); ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(-16, -14, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#eab308'; ctx.beginPath(); ctx.arc(-10, -14, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#22c55e'; ctx.beginPath(); ctx.arc(-4, -14, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.6)'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left'; ctx.fillText('$_', -17, -2); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.25)'; ctx.fillRect(-8, -6, 20 * pulse, 2); ctx.fillRect(-17, 3, 26, 1.5); ctx.fillRect(-17, 8, 18, 1.5); if (Math.sin(self.time * 4) > 0) { ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.8)'; ctx.fillRect(-10, -6, 6, 8); }
} else if (kind === 'blockchain') {
ctx.save(); var hexR = 16; ctx.beginPath(); for (var hi = 0; hi < 6; hi++) { var angle = Math.PI / 6 + hi * Math.PI / 3; var hx = Math.cos(angle) * hexR, hy = Math.sin(angle) * hexR; if (hi === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); } ctx.closePath(); ctx.fillStyle = tc.surface; ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.35 + 0.15 * pulse) + ')'; ctx.lineWidth = 1.5; ctx.stroke(); var innerR = 10; ctx.beginPath(); for (var hi = 0; hi < 6; hi++) { var angle = Math.PI / 6 + hi * Math.PI / 3; ctx.lineTo(Math.cos(angle) * innerR, Math.sin(angle) * innerR); } ctx.closePath(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.2)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.5 + 0.3 * pulse) + ')'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('\u25c8', 0, 0); ctx.restore();
} else if (kind === 'standby' || kind === 'shelter') {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.35)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.roundRect(-24, -12, 48, 24, 6); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.16)';
ctx.fillRect(-22, -10, 44, 7);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.45)';
ctx.beginPath(); ctx.arc(-10, 2, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(0, 2, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(10, 2, 3, 0, Math.PI * 2); ctx.fill();
var sheltered = self.bots.filter(function(b) {
var idle = !b.activeSkill && (b.animState === 'idle' || b.animState === 'walking');
return idle && (b.currentStation === 'shelter' || b.currentStation === 'standby');
}).length;
var badge = String(sheltered);
ctx.font = 'bold 9px ' + getComputedStyle(document.body).fontFamily;
var badgeW = Math.max(14, ctx.measureText(badge).width + 8);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.22)';
ctx.beginPath(); ctx.roundRect(14, -22, badgeW, 12, 6); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.45)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(14, -22, badgeW, 12, 6); ctx.stroke();
ctx.fillStyle = tc.text;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(badge, 14 + badgeW / 2, -16);
} else {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.3)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.roundRect(-22, -18, 44, 34, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.1)'; ctx.fillRect(-22, -18, 44, 9); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.25)'; ctx.beginPath(); ctx.arc(-17, -13.5, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(-12, -13.5, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.3 + 0.15 * pulse) + ')'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 3, 9, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.ellipse(0, 3, 4, 9, 0, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-9, 3); ctx.lineTo(9, 3); ctx.stroke();
}
ctx.fillStyle = tc.muted; ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(s.name, 0, 32);
if (s.kind === 'Inference') {
var activeModels = [];
self.bots.forEach(function(b) {
if (b.currentStation === s.id && b.animState === 'working' && b.model) {
if (activeModels.indexOf(b.model) === -1) activeModels.push(b.model);
}
});
if (activeModels.length > 0) {
ctx.font = '8px ' + getComputedStyle(document.body).fontFamily;
activeModels.forEach(function(m, mi) {
var my = 44 + mi * 12;
var mw = ctx.measureText(m).width + 8;
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.1)';
ctx.beginPath(); ctx.roundRect(-mw / 2, my - 5, mw, 11, 3); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.2)'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.roundRect(-mw / 2, my - 5, mw, 11, 3); ctx.stroke();
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.7)';
ctx.fillText(m, 0, my + 3);
});
}
}
ctx.restore();
});
this.dataStreams.forEach(function(ds) {
var p = Math.min(1, ds.progress);
var x1 = ds.fromX * W, y1 = ds.fromY * H, x2 = ds.toX * W, y2 = ds.toY * H;
var cx = x1 + (x2 - x1) * p, cy = y1 + (y2 - y1) * p;
var alpha = ds.timer > 0.3 ? 0.6 : ds.timer / 0.3 * 0.6;
ctx.save();
ctx.setLineDash([3, 6]); ctx.lineDashOffset = -self.time * 40;
ctx.strokeStyle = ds.color + '30'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ds.color; ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2);
ctx.fillStyle = ds.color + '20'; ctx.fill();
if (ds.label) {
var midX = (x1 + x2) / 2, midY = (y1 + y2) / 2 - 10;
ctx.globalAlpha = alpha * 0.95;
ctx.font = 'bold 8px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'center';
var lw = ctx.measureText(ds.label).width + 8;
ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.roundRect(midX - lw / 2, midY - 6, lw, 12, 4); ctx.fill();
ctx.strokeStyle = ds.color + '55'; ctx.lineWidth = 0.5; ctx.stroke();
ctx.fillStyle = ds.color; ctx.fillText(ds.label, midX, midY + 2.5);
}
ctx.globalAlpha = 1;
ctx.restore();
});
this.tethers.forEach(function(t) {
var subordinateActive = !!t.to && (
!!t.to.activeSkill ||
self._isRuntimeActive(t.to.activity) ||
t.to.animState === 'working' ||
t.to.animState === 'walking' ||
!!(t.to.currentStation && t.to.currentStation !== 'standby' && t.to.currentStation !== 'shelter')
);
if (!subordinateActive) return;
var fromX = t.from.x * W;
var fromY = t.from.y * H;
var toX = t.to.x * W;
var toY = t.to.y * H;
ctx.save(); ctx.setLineDash([4, 6]); ctx.lineDashOffset = -self.time * 15;
ctx.strokeStyle = t.color + '25'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
});
this.interactions.forEach(function(ix) {
var ax = ix.botA.x * W, ay = ix.botA.y * H;
var bx = ix.botB.x * W, by = ix.botB.y * H;
var midX = (ax + bx) / 2, midY = (ay + by) / 2;
ctx.save();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.2)';
ctx.lineWidth = 2; ctx.setLineDash([2, 4]); ctx.lineDashOffset = -self.time * 30;
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]);
var speakerA = Math.sin(ix.phase * 2.5) > 0;
var spkX = speakerA ? ax : bx, spkY = (speakerA ? ay : by) - 45;
var bubW = 28, bubH = 16;
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.12)';
ctx.beginPath(); ctx.roundRect(spkX - bubW / 2, spkY - bubH / 2, bubW, bubH, 6); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(spkX - bubW / 2, spkY - bubH / 2, bubW, bubH, 6); ctx.stroke();
ctx.beginPath(); ctx.moveTo(spkX - 3, spkY + bubH / 2);
ctx.lineTo(spkX, spkY + bubH / 2 + 5); ctx.lineTo(spkX + 3, spkY + bubH / 2);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.12)'; ctx.fill();
var dotPhase = ix.bubbleTimer * 4;
for (var di = 0; di < 3; di++) {
var dotA = 0.3 + 0.7 * Math.max(0, Math.sin(dotPhase - di * 0.8));
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + dotA + ')';
ctx.beginPath(); ctx.arc(spkX - 6 + di * 6, spkY, 2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
});
var sortedBots = this.bots.slice().sort(function(a, b) { return a.y - b.y; });
sortedBots.forEach(function(bot) { self.drawBot(ctx, bot, W, H); });
if (this.selectedBotId) {
var selected = this.bots.find(function(b) { return b.id === self.selectedBotId; });
if (selected) this.drawTaskBubble(ctx, selected, W, H);
}
};
WorkspaceEngine.prototype.drawBot = function(ctx, bot, W, H) {
// Sheltered bots are hidden — the station badge shows the count.
// They reappear when assigned a skill/task.
var isSheltered = (bot.currentStation === 'shelter' || bot.currentStation === 'standby') && !bot.activeSkill;
if (isSheltered) return;
var tc = this._themeColors || { surface: '#1e2030', muted: '#71717a', text: '#e4e4e7', aR: 99, aG: 102, aB: 241 };
var px = bot.x * W, py = bot.y * H, t = this.time, frame = bot.animFrame;
var isAgent = (bot.role === 'agent');
var scale = isAgent ? 1.0 : 0.7;
var bodyW = isAgent ? 20 : 14, bodyH = isAgent ? 24 : 18;
var walkBob = (bot.animState === 'walking') ? Math.sin(t * 12) * 2 : 0;
ctx.save(); ctx.translate(px, py + walkBob); ctx.scale(scale, scale);
if (this.selectedBotId === bot.id) {
ctx.strokeStyle = 'rgba(' + tc.aR + ',' + tc.aG + ',' + tc.aB + ',0.65)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, bodyH + 4, bodyW * 0.95, 6, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath(); ctx.ellipse(0, bodyH + 4, bodyW * 0.6, 3.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = bot.color; ctx.strokeStyle = bot.color + 'cc'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.roundRect(-bodyW, -bodyH * 0.3, bodyW * 2, bodyH, isAgent ? 4 : 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fillRect(-bodyW + 3, -bodyH * 0.3 + 3, bodyW * 2 - 6, bodyH * 0.35);
var lightColors = ['#22c55e', '#eab308', '#ef4444'];
for (var li = 0; li < 3; li++) { var lx = -bodyW + 6 + li * 7, ly = -bodyH * 0.3 + 7; ctx.fillStyle = lightColors[li]; ctx.globalAlpha = 0.3 + 0.7 * bot.lights[li]; ctx.beginPath(); ctx.arc(lx, ly, 2, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = 1;
if (isAgent) {
var legOffset = (bot.animState === 'walking') ? Math.sin(t * 10) * 4 : 0;
ctx.fillStyle = bot.color + 'bb';
ctx.fillRect(-bodyW + 4, bodyH * 0.7 - 2, 7, 10 + (legOffset > 0 ? legOffset : 0));
ctx.fillRect(bodyW - 11, bodyH * 0.7 - 2, 7, 10 + (legOffset < 0 ? -legOffset : 0));
ctx.fillStyle = tc.surface;
ctx.fillRect(-bodyW + 2, bodyH * 0.7 + 7 + Math.max(0, legOffset), 11, 4);
ctx.fillRect(bodyW - 13, bodyH * 0.7 + 7 + Math.max(0, -legOffset), 11, 4);
} else {
var treadOffset = (bot.animState === 'walking') ? (frame * 3) % 12 : 0;
ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.roundRect(-bodyW - 2, bodyH * 0.5, bodyW * 2 + 4, 8, 4); ctx.fill();
ctx.strokeStyle = bot.color + '88'; ctx.lineWidth = 1; ctx.stroke();
ctx.strokeStyle = bot.color + '44';
for (var ti = 0; ti < 5; ti++) { var tx = -bodyW + 2 + (ti * 9 + treadOffset) % (bodyW * 2); ctx.beginPath(); ctx.moveTo(tx, bodyH * 0.5 + 1); ctx.lineTo(tx, bodyH * 0.5 + 7); ctx.stroke(); }
}
if (bot.animState === 'working') {
var armAngle = Math.sin(t * 4) * 0.3;
ctx.save(); ctx.translate(bodyW + 2, 0); ctx.rotate(-0.5 + armAngle);
ctx.fillStyle = bot.color + 'cc'; ctx.fillRect(0, -2, 14, 4);
ctx.fillStyle = '#eab308'; ctx.beginPath(); ctx.arc(14, 0, 3, 0, Math.PI * 2); ctx.fill();
if (frame % 2 === 0) {
ctx.fillStyle = '#eab308';
ctx.globalAlpha = 0.7;
for (var si = 0; si < 3; si++) {
var sa = ((frame + si * 5) * 0.9) % (Math.PI * 2);
ctx.beginPath();
ctx.arc(14 + Math.cos(sa) * 5, Math.sin(sa) * 5, 1.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
ctx.save(); ctx.translate(0, -bodyH * 0.3); ctx.rotate(bot.headAngle);
var headW = isAgent ? 18 : 14, headH = isAgent ? 16 : 12;
ctx.fillStyle = bot.color + 'aa'; ctx.fillRect(-3, -4, 6, 5);
ctx.fillStyle = bot.color; ctx.strokeStyle = bot.color + 'cc'; ctx.lineWidth = 1.5;
ctx.beginPath();
if (isAgent) { ctx.roundRect(-headW, -headH - 4, headW * 2, headH, 5); }
else { ctx.arc(0, -headH * 0.5 - 2, headW * 0.85, 0, Math.PI * 2); }
ctx.fill(); ctx.stroke();
if (isAgent) {
ctx.strokeStyle = bot.color + 'cc'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, -headH - 4); ctx.lineTo(0, -headH - 14); ctx.stroke();
var antGlow = 0.5 + 0.5 * Math.sin(t * 3);
ctx.fillStyle = bot.color; ctx.globalAlpha = 0.5 + 0.5 * antGlow;
ctx.beginPath(); ctx.arc(0, -headH - 15, 3, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
var eyeY = -headH * 0.5 - 2;
if (bot.blinking) { ctx.fillStyle = tc.surface; ctx.fillRect(-headW + 4, eyeY - 1, headW - 6, 2); ctx.fillRect(3, eyeY - 1, headW - 6, 2); }
else { ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.arc(-headW * 0.4, eyeY, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(headW * 0.4, eyeY, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = tc.text; ctx.beginPath(); ctx.arc(-headW * 0.4 + bot.headAngle * 3, eyeY, 2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(headW * 0.4 + bot.headAngle * 3, eyeY, 2, 0, Math.PI * 2); ctx.fill(); }
} else {
var visorY = -headH * 0.5 - 2, visorGlow = 0.6 + 0.4 * Math.sin(t * 2.5 + px * 0.01);
ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.roundRect(-headW * 0.6, visorY - 3, headW * 1.2, 6, 3); ctx.fill();
ctx.fillStyle = bot.color; ctx.globalAlpha = visorGlow; ctx.beginPath(); ctx.roundRect(-headW * 0.5, visorY - 2, headW, 4, 2); ctx.fill(); ctx.globalAlpha = 1;
var scanX = Math.sin(t * 3 + px * 0.01) * headW * 0.35;
ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.arc(scanX, visorY, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
}
ctx.restore();
if (isAgent && bot.subordinates && bot.subordinates.length > 0) {
ctx.save(); ctx.translate(0, -bodyH * 0.3 - (isAgent ? 24 : 18));
ctx.strokeStyle = bot.color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(-6, -18); ctx.lineTo(0, -22); ctx.lineTo(6, -18); ctx.stroke();
ctx.restore();
}
ctx.save();
var tagY = -bodyH * 0.3 - (isAgent ? 30 : 20) - (isAgent && bot.subordinates && bot.subordinates.length > 0 ? 10 : 0);
ctx.font = 'bold 10px ' + getComputedStyle(document.body).fontFamily; ctx.textAlign = 'center';
var nameW = ctx.measureText(bot.name).width + 12;
ctx.globalAlpha = 0.88; ctx.fillStyle = tc.surface;
ctx.beginPath(); ctx.roundRect(-nameW / 2, tagY - 7, nameW, 14, 7); ctx.fill();
ctx.globalAlpha = 1; ctx.strokeStyle = bot.color + '66'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = tc.text; ctx.fillText(bot.name, 0, tagY + 3);
var dotColor = bot.state === 'Running' ? '#22c55e' : (bot.state === 'Error' ? '#ef4444' : '#eab308');
ctx.fillStyle = dotColor; ctx.beginPath(); ctx.arc(nameW / 2 - 4, tagY, 3, 0, Math.PI * 2); ctx.fill();
ctx.restore();
if (bot.activeSkill && bot.skillFade > 0) {
ctx.save();
var skillY = bodyH + (isAgent ? 16 : 12);
ctx.globalAlpha = bot.skillFade * 0.85;
ctx.font = '8px ' + getComputedStyle(document.body).fontFamily; ctx.textAlign = 'center';
var sw = ctx.measureText(bot.activeSkill).width + 10;
ctx.fillStyle = bot.color + '20';
ctx.beginPath(); ctx.roundRect(-sw / 2, skillY - 5, sw, 12, 4); ctx.fill();
ctx.strokeStyle = bot.color + '50'; ctx.lineWidth = 0.5; ctx.stroke();
ctx.fillStyle = bot.color; ctx.globalAlpha = bot.skillFade * 0.9;
ctx.fillText(bot.activeSkill, 0, skillY + 3);
ctx.globalAlpha = 1; ctx.restore();
}
ctx.restore();
};
WorkspaceEngine.prototype.handleEvent = function(ev) {
if (!ev || !ev.type) return;
var self = this;
function primaryBot() {
return self.bots.find(function(b) { return b.role === 'agent'; }) || self.bots[0] || null;
}
function llmStation() {
return self.stations.find(function(s) { return s.id === 'llm'; }) || null;
}
if (ev.type === 'agent_started' || ev.type === 'agent_stopped' || ev.type === 'agent_error') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) { bot.state = ev.type === 'agent_started' ? 'Running' : (ev.type === 'agent_stopped' ? 'Stopped' : 'Error'); }
}
if (ev.type === 'agent_moved') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
var station = this.stations.find(function(s) { return s.id === ev.workstation; });
if (bot && station) { bot.targetX = station.x; bot.targetY = station.y + 0.10; bot.currentStation = station.id; }
}
if (ev.type === 'agent_working') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) {
if (ev.workstation) {
var st = this.stations.find(function(s) { return s.id === ev.workstation; });
if (st) { bot.targetX = st.x; bot.targetY = st.y + 0.10; bot.currentStation = st.id; }
}
bot.animState = 'working';
if (ev.skill) bot.activeSkill = ev.skill;
}
}
if (ev.type === 'agent_idle') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) { bot.animState = 'idle'; bot.activeSkill = null; bot.currentStation = 'standby'; }
this.layoutStandby();
}
if (ev.type === 'skill_activated') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) { bot.activeSkill = ev.skill || ev.skill_name; bot.animState = 'working'; }
}
if (ev.type === 'a2a_interaction') {
var botA = this.bots.find(function(b) { return b.id === ev.agent_a; });
var botB = this.bots.find(function(b) { return b.id === ev.agent_b; });
if (botA && botB) {
var meetX = (botA.x + botB.x) / 2, meetY = (botA.y + botB.y) / 2;
botA._savedTarget = { x: botA.targetX, y: botA.targetY };
botB._savedTarget = { x: botB.targetX, y: botB.targetY };
botA.targetX = meetX - 0.03; botA.targetY = meetY;
botB.targetX = meetX + 0.03; botB.targetY = meetY;
botA.animState = 'talking'; botB.animState = 'talking';
self.interactions.push({ botA: botA, botB: botB, timer: ev.duration || 4, phase: 0, bubbleTimer: 0 });
}
}
if (ev.type === 'stream_start') {
var bot = primaryBot();
var st = llmStation();
if (bot) {
if (st) { bot.targetX = st.x; bot.targetY = st.y + 0.10; bot.currentStation = st.id; }
bot.animState = 'working';
bot.activeSkill = ev.model || 'inference';
}
}
if (ev.type === 'stream_chunk') {
var bot = primaryBot();
var st = llmStation();
if (bot && st) {
self.dataStreams.push({
fromX: bot.x, fromY: bot.y, toX: st.x, toY: st.y,
color: bot.color, timer: 0.8, progress: 0, label: ''
});
}
if (ev.done) {
var b = primaryBot();
if (b) { b.animState = 'idle'; b.activeSkill = null; b.currentStation = 'standby'; }
this.layoutStandby();
}
}
if (ev.type === 'stream_end') {
var bot = primaryBot();
if (bot) { bot.animState = 'idle'; bot.activeSkill = null; bot.currentStation = 'standby'; }
this.layoutStandby();
}
};
// BUG-002: WebSocket connection with exponential backoff reconnection.
// S-HIGH-2: Uses short-lived tickets instead of persistent API key in URL.
App._wsReconnectDelay = 1000;
App._wsReconnectTimer = null;
App._bindWsEvents = function(ws) {
ws.onopen = function() {
App._wsReconnectDelay = 1000;
var d = document.getElementById('ws-dot'); if (d) d.classList.remove('off');
var l = document.getElementById('ws-label'); if (l) l.textContent = 'Connected';
refreshSidebarIdentity();
};
ws.onclose = function() {
var d = document.getElementById('ws-dot'); if (d) d.classList.add('off');
var l = document.getElementById('ws-label'); if (l) l.textContent = 'Reconnecting...';
clearTimeout(App._wsReconnectTimer);
App._wsReconnectTimer = setTimeout(function() { App._connectWs(); }, App._wsReconnectDelay);
App._wsReconnectDelay = Math.min(App._wsReconnectDelay * 2, 30000);
};
ws.onerror = function() {
var d = document.getElementById('ws-dot'); if (d) d.classList.add('off');
};
ws.onmessage = function(ev) {
try {
var event = JSON.parse(ev.data);
if (workspace && workspace.handleEvent) workspace.handleEvent(event);
if (event && event.type === 'stream_start') {
App._liveStreamTurn = {
turn_id: event.turn_id || '',
session_id: event.session_id || '',
model: event.model || ''
};
if (App.page === 'context') {
App.navigate('context');
}
} else if (event && event.type === 'stream_end') {
if (!event.turn_id || (App._liveStreamTurn && App._liveStreamTurn.turn_id === event.turn_id)) {
App._liveStreamTurn = null;
if (App.page === 'context') {
App.navigate('context');
}
}
}
if (event && (event.type === 'model_selection' || event.type === 'model_shift')) {
if (App.page === 'efficiency') {
App.navigate('efficiency');
} else if (App.page === 'context' && App._ctxActiveTurn && event.turn_id === App._ctxActiveTurn.id) {
App.navigate('context');
}
}
} catch(e) {}
};
};
App._connectWs = function() {
try {
if (App.ws && (App.ws.readyState === WebSocket.CONNECTING || App.ws.readyState === WebSocket.OPEN)) return;
var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = proto + '//' + window.location.host + '/ws';
if (!API_KEY) {
// No auth configured — connect directly
App.ws = new WebSocket(wsUrl);
App._bindWsEvents(App.ws);
return;
}
// Fetch a short-lived ticket, then connect with it
fetch(BASE + '/api/ws-ticket', {
method: 'POST',
headers: authHeaders({ 'Accept': 'application/json' })
}).then(function(r) {
if (!r.ok) throw new Error('ticket fetch failed: ' + r.status);
return r.json();
}).then(function(data) {
App.ws = new WebSocket(wsUrl + '?ticket=' + encodeURIComponent(data.ticket));
App._bindWsEvents(App.ws);
}).catch(function() {
var d = document.getElementById('ws-dot'); if (d) d.classList.add('off');
var l = document.getElementById('ws-label'); if (l) l.textContent = 'Reconnecting...';
clearTimeout(App._wsReconnectTimer);
App._wsReconnectTimer = setTimeout(function() { App._connectWs(); }, App._wsReconnectDelay);
App._wsReconnectDelay = Math.min(App._wsReconnectDelay * 2, 30000);
});
} catch (err) { var d = document.getElementById('ws-dot'); if (d) d.classList.add('off'); var l = document.getElementById('ws-label'); if (l) l.textContent = 'No WebSocket'; }
};
App._connectWs();
setInterval(refreshSidebarIdentity, 30000);
startModelsBackgroundRefresh(App);
App._loadAvailableModels({ nonBlocking: true, validationLevel: 'zero' }).catch(function() {});
onHash();
})();
</script>
</body>
</html>