Files
openclaw-backups/skills/openclaw-dashboard/agent-dashboard.html

4470 lines
224 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>OpenClaw Dashboard</title>
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="OpenClaw">
<meta name="theme-color" content="#06080e">
<meta name="description" content="Glassmorphic agent management dashboard for OpenClaw with task kanban, document editor, API monitoring & real-time agent tracking.">
<meta name="author" content="Jony Jing">
<meta property="og:title" content="OpenClaw Agent Dashboard">
<meta property="og:description" content="Glassmorphic agent management dashboard for OpenClaw with task kanban, document editor, API monitoring & real-time agent tracking.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://github.com/JonathanJing/openclaw-dashboard">
<meta property="og:image" content="https://raw.githubusercontent.com/JonathanJing/openclaw-dashboard/main/screenshots/tasks-kanban.png">
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#06080e;--bg2:#0a0d14;--card:#0d1117;--border:#1a1f2e;
--text:#e6edf3;--text2:#8b949e;--accent:#7c5cfc;--accent2:#a78bfa;
--green:#2dd4a0;--yellow:#f5a623;--red:#f85149;--blue:#58a6ff;
--glass:rgba(13,17,23,0.7);--glow:rgba(124,92,252,0.15);
--font:'Outfit',system-ui,sans-serif;--mono:'Space Mono',monospace;
}
html{scroll-behavior:smooth}
body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;overflow-x:hidden}
.bg-mesh{position:fixed;inset:0;z-index:0;pointer-events:none;
background:radial-gradient(ellipse 80% 60% at 20% 20%,rgba(124,92,252,0.06),transparent),
radial-gradient(ellipse 60% 50% at 80% 80%,rgba(45,212,160,0.04),transparent),
radial-gradient(ellipse 50% 40% at 50% 50%,rgba(88,166,255,0.03),transparent)}
.container{max-width:1280px;margin:0 auto;padding:20px;position:relative;z-index:1}
/* ─── Header ─── */
header{display:flex;align-items:center;justify-content:space-between;padding:16px 0 24px;border-bottom:1px solid var(--border);margin-bottom:24px}
.logo{font-size:1.5rem;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--accent2),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-size:200% 200%;animation:shimmer 6s ease infinite}
@keyframes shimmer{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
.logo-sub{font-size:.7rem;letter-spacing:3px;text-transform:uppercase;color:var(--text2);margin-top:2px}
.status-dot{width:10px;height:10px;border-radius:50%;display:inline-block;animation:pulse 2s infinite;margin-right:8px}
.status-dot.ok{background:var(--green)}
.status-dot.err{background:var(--red);animation:pulse-red 2s infinite}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(45,212,160,0.4)}50%{box-shadow:0 0 0 6px rgba(45,212,160,0)}}
@keyframes pulse-red{0%,100%{box-shadow:0 0 0 0 rgba(248,81,73,0.4)}50%{box-shadow:0 0 0 6px rgba(248,81,73,0)}}
.header-right{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text2)}
.back-link,.refresh-btn{color:var(--accent);text-decoration:none;font-size:.85rem;border:1px solid var(--border);padding:6px 14px;border-radius:999px;transition:all .2s;background:transparent;cursor:pointer;font-family:var(--font)}
.back-link:hover,.refresh-btn:hover{background:var(--glow);border-color:var(--accent)}
.refresh-btn svg{vertical-align:middle}
/* ─── Tab Bar ─── */
.tab-bar{display:flex;gap:4px;background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;padding:4px;margin-bottom:24px;width:fit-content}
.tab-btn{padding:10px 24px;border-radius:10px;border:none;background:transparent;color:var(--text2);font-family:var(--font);font-size:.9rem;font-weight:500;cursor:pointer;transition:all .25s;position:relative;white-space:nowrap}
.tab-btn:hover{color:var(--text)}
.tab-btn.active{background:var(--accent);color:#fff;box-shadow:0 2px 12px rgba(124,92,252,0.3)}
.tab-btn .badge-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:rgba(255,255,255,0.15);font-size:.7rem;margin-left:6px;font-weight:600}
.tab-btn.active .badge-count{background:rgba(255,255,255,0.25)}
/* ─── Tab Panels ─── */
.tab-panel{display:none;animation:fadeIn .3s ease}
.tab-panel.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
/* ─── Live Indicator ─── */
.live-indicator{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.5px;text-transform:uppercase;cursor:pointer;transition:all .3s;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);user-select:none}
.live-indicator .live-dot{width:7px;height:7px;border-radius:50%;background:var(--text2);transition:all .3s}
.live-indicator.active{border-color:rgba(45,212,160,0.35);color:var(--green)}
.live-indicator.active .live-dot{background:var(--green);box-shadow:0 0 6px rgba(45,212,160,0.5);animation:livePulse 2s infinite}
.live-indicator.flash{border-color:rgba(124,92,252,0.5);box-shadow:0 0 12px rgba(124,92,252,0.15)}
@keyframes livePulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(45,212,160,0.4)}50%{opacity:.6;box-shadow:0 0 0 4px rgba(45,212,160,0)}}
.live-indicator:hover{background:rgba(45,212,160,0.06)}
/* ─── Task card status transition ─── */
.task-card{transition:all .3s ease,border-color .5s ease,box-shadow .5s ease}
/* ─── Cards ─── */
.glass-card{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:16px;padding:20px;position:relative;overflow:hidden;transition:all .3s}
.glass-card:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(124,92,252,0.08)}
.glass-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
/* ─── Badges ─── */
.badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.5px;text-transform:uppercase}
.badge-new{background:rgba(245,166,35,0.15);color:var(--yellow);border:1px solid rgba(245,166,35,0.25)}
.badge-in-progress{background:rgba(88,166,255,0.15);color:var(--blue);border:1px solid rgba(88,166,255,0.25)}
.badge-done{background:rgba(45,212,160,0.15);color:var(--green);border:1px solid rgba(45,212,160,0.25)}
.badge-failed{background:rgba(248,81,73,0.15);color:var(--red);border:1px solid rgba(248,81,73,0.25)}
.badge-priority{background:rgba(124,92,252,0.15);color:var(--accent2);border:1px solid rgba(124,92,252,0.25)}
.badge-priority.high{background:rgba(248,81,73,0.12);color:var(--red);border-color:rgba(248,81,73,0.2)}
.badge-priority.low{background:rgba(139,148,158,0.12);color:var(--text2);border-color:rgba(139,148,158,0.2)}
/* ─── Task Cards ─── */
.tasks-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px}
.filter-group{display:flex;gap:6px;flex-wrap:wrap}
.filter-btn{padding:6px 14px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s}
.filter-btn:hover{border-color:var(--accent);color:var(--text)}
.filter-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.search-input{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:8px 14px;color:var(--text);font-family:var(--font);font-size:.85rem;outline:none;transition:all .25s;width:220px}
.search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,0.15)}
.search-input::placeholder{color:var(--text2)}
.create-btn{padding:8px 20px;border-radius:10px;border:none;background:var(--accent);color:#fff;font-family:var(--font);font-size:.85rem;font-weight:600;cursor:pointer;transition:all .25s;display:flex;align-items:center;gap:6px}
.create-btn:hover{background:#6b4ce6;transform:translateY(-1px);box-shadow:0 4px 16px rgba(124,92,252,0.3)}
.spacer{flex:1}
.task-list{display:flex;flex-direction:column;gap:12px}
.task-card{cursor:pointer;transition:all .3s}
.task-card::before{background:linear-gradient(180deg,var(--accent),var(--blue))}
.task-card.status-done::before{background:linear-gradient(180deg,var(--green),var(--blue))}
.task-card.status-failed::before{background:linear-gradient(180deg,var(--red),var(--yellow))}
.task-card.status-in-progress::before{background:linear-gradient(180deg,var(--blue),var(--accent))}
.task-card.status-new::before{background:linear-gradient(180deg,var(--yellow),var(--accent))}
.task-header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.task-title{font-weight:600;font-size:1rem;flex:1;min-width:150px}
.task-meta{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text2);margin-top:8px;font-family:var(--mono)}
.task-meta svg{width:14px;height:14px;opacity:.5}
.task-body{max-height:0;overflow:hidden;transition:max-height .4s ease,padding .3s ease;padding:0 0}
.task-card.expanded .task-body{max-height:2000px;padding:16px 0 0}
.task-description{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;font-size:.85rem;line-height:1.7;margin-bottom:16px;color:var(--text2)}
.task-description p{margin-bottom:8px}
.task-description code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
.task-description pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
.task-description pre code{background:none;padding:0}
.task-actions{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
.action-btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
.action-btn:hover{border-color:var(--accent);color:var(--text);background:var(--glow)}
.action-btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.action-btn.primary:hover{background:#6b4ce6}
.action-btn.danger{border-color:rgba(248,81,73,0.3);color:var(--red)}
.action-btn.danger:hover{background:rgba(248,81,73,0.1)}
/* ─── Kanban Board ─── */
.view-toggle-tasks{display:flex;background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden;flex-shrink:0}
.view-toggle-tasks button{padding:6px 14px;border:none;background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:5px}
.view-toggle-tasks button:hover{color:var(--text)}
.view-toggle-tasks button.active{background:var(--accent);color:#fff}
.view-toggle-tasks button svg{width:14px;height:14px}
.kanban-board{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;min-height:400px}
.kanban-column{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:16px;display:flex;flex-direction:column;min-height:300px;transition:border-color .2s}
.kanban-column.drag-over{border-color:var(--accent);box-shadow:0 0 20px rgba(124,92,252,0.15)}
.kanban-col-header{padding:16px 16px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-shrink:0}
.kanban-col-title{font-size:.82rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;display:flex;align-items:center;gap:8px}
.kanban-col-title .col-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.kanban-col-title .col-dot.new{background:var(--yellow)}
.kanban-col-title .col-dot.in-progress{background:var(--blue)}
.kanban-col-title .col-dot.done{background:var(--green)}
.kanban-col-title .col-dot.failed{background:var(--red)}
.kanban-col-count{font-size:.72rem;font-family:var(--mono);color:var(--text2);background:var(--card);padding:2px 8px;border-radius:999px;border:1px solid var(--border)}
.kanban-col-body{flex:1;padding:10px;overflow-y:auto;display:flex;flex-direction:column;gap:8px;min-height:60px}
.kanban-col-body.empty-drop{display:flex;align-items:center;justify-content:center}
.kanban-col-body .drop-hint{font-size:.78rem;color:var(--text2);opacity:.4;text-align:center;padding:20px 10px}
.kanban-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:grab;transition:all .2s;position:relative;overflow:hidden}
.kanban-card:active{cursor:grabbing}
.kanban-card:hover{border-color:rgba(124,92,252,0.3);transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.2)}
.kanban-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
.kanban-card.status-new::before{background:linear-gradient(180deg,var(--yellow),var(--accent))}
.kanban-card.status-in-progress::before{background:linear-gradient(180deg,var(--blue),var(--accent))}
.kanban-card.status-done::before{background:linear-gradient(180deg,var(--green),var(--blue))}
.kanban-card.status-failed::before{background:linear-gradient(180deg,var(--red),var(--yellow))}
.kanban-card.dragging{opacity:.4;transform:scale(.96)}
.kanban-card-title{font-size:.85rem;font-weight:600;margin-bottom:8px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.kanban-card-footer{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.kanban-card-footer .badge{font-size:.65rem;padding:2px 7px}
.kanban-card-assignee{font-size:.72rem;color:var(--text2);margin-left:auto;white-space:nowrap}
@media(max-width:768px){
.kanban-board{grid-template-columns:1fr;gap:12px;overflow-x:auto}
.kanban-board.horizontal-scroll{display:flex;grid-template-columns:unset;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;padding-bottom:8px}
.kanban-board.horizontal-scroll .kanban-column{min-width:280px;scroll-snap-align:start;flex-shrink:0}
.view-toggle-tasks{width:100%}
.view-toggle-tasks button{flex:1;justify-content:center}
}
/* ─── Notes Timeline ─── */
.notes-section{margin-top:12px}
.notes-title{font-size:.8rem;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
.notes-list{display:flex;flex-direction:column;gap:10px;margin-bottom:12px}
.note-item{display:flex;gap:12px;padding:0 0 0 16px;border-left:2px solid var(--border);font-size:.82rem}
.note-time{font-family:var(--mono);font-size:.72rem;color:var(--text2);white-space:nowrap;min-width:80px}
.note-text{color:var(--text);line-height:1.5}
.add-note-row{display:flex;gap:8px}
.note-input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--text);font-family:var(--font);font-size:.82rem;outline:none;transition:border-color .2s}
.note-input:focus{border-color:var(--accent)}
/* ─── Empty State ─── */
.empty-state{text-align:center;padding:60px 20px;color:var(--text2)}
.empty-state svg{width:80px;height:80px;opacity:.3;margin-bottom:16px}
.empty-state h3{font-size:1.1rem;font-weight:600;margin-bottom:6px;color:var(--text)}
.empty-state p{font-size:.85rem;max-width:400px;margin:0 auto 20px}
/* ─── Loading / Skeleton ─── */
.skeleton{background:linear-gradient(90deg,var(--card) 25%,var(--border) 50%,var(--card) 75%);background-size:200% 100%;animation:skeletonShine 1.5s infinite;border-radius:8px}
@keyframes skeletonShine{0%{background-position:200% 0}100%{background-position:-200% 0}}
.skeleton-card{height:80px;margin-bottom:12px;border-radius:16px}
.spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* ─── Modal ─── */
.modal-overlay{position:fixed;inset:0;z-index:1000;background:rgba(6,8,14,0.8);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;animation:fadeIn .2s ease}
.modal-overlay.show{display:flex}
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:20px;padding:28px;width:90%;max-width:540px;max-height:90vh;overflow-y:auto;transform:translateY(20px);animation:modalSlideIn .3s ease forwards}
@keyframes modalSlideIn{to{transform:translateY(0)}}
.modal h2{font-size:1.2rem;font-weight:700;margin-bottom:20px;display:flex;align-items:center;gap:8px}
.modal h2::before{content:'';width:4px;height:20px;border-radius:4px;background:linear-gradient(180deg,var(--accent),var(--blue))}
.form-group{margin-bottom:16px}
.form-label{display:block;font-size:.78rem;font-weight:500;color:var(--text2);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
.form-input,.form-textarea,.form-select{width:100%;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-family:var(--font);font-size:.88rem;outline:none;transition:all .2s}
.form-input:focus,.form-textarea:focus,.form-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,0.1)}
.form-textarea{resize:vertical;min-height:100px;font-family:var(--mono);font-size:.82rem;line-height:1.6}
.form-select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
.form-select option{background:var(--bg2);color:var(--text)}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
.modal-close{position:absolute;top:16px;right:16px;background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.4rem;width:36px;height:36px;display:flex;align-items:center;justify-content:center;border-radius:8px;transition:all .2s;z-index:100}
.modal-close:hover{color:var(--text);background:rgba(255,255,255,0.08)}
/* ─── Toast ─── */
.toast-container{position:fixed;top:20px;right:20px;z-index:2000;display:flex;flex-direction:column;gap:8px}
.toast{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:14px 20px;font-size:.85rem;display:flex;align-items:center;gap:10px;animation:toastIn .3s ease,toastOut .3s ease forwards;animation-delay:0s,3s;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
@keyframes toastIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}}
@keyframes toastOut{to{opacity:0;transform:translateX(40px)}}
.toast.success{border-left:3px solid var(--green)}
.toast.error{border-left:3px solid var(--red)}
.toast.info{border-left:3px solid var(--blue)}
.toast-icon{font-size:1rem}
/* ─── Documents Tab ─── */
.docs-layout{display:grid;grid-template-columns:260px 1fr;gap:20px;min-height:500px}
.file-sidebar{display:flex;flex-direction:column;gap:4px}
.file-sidebar-header{font-size:.7rem;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:var(--text2);padding:8px 12px;margin-bottom:4px}
.file-item{display:flex;align-items:center;gap:8px;padding:10px 14px;border-radius:10px;cursor:pointer;transition:all .2s;font-size:.85rem;color:var(--text2);border:1px solid transparent}
.file-item:hover{background:rgba(124,92,252,0.06);color:var(--text)}
.file-item.active{background:rgba(124,92,252,0.1);color:var(--accent2);border-color:rgba(124,92,252,0.2)}
.file-item svg{width:16px;height:16px;opacity:.5;flex-shrink:0}
.file-item.active svg{opacity:.8}
.file-item .file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-item.indent{padding-left:28px;font-size:.8rem}
.file-divider{height:1px;background:var(--border);margin:8px 0}
.editor-area{display:flex;flex-direction:column;gap:12px}
.editor-toolbar{display:flex;align-items:center;gap:10px}
.editor-filename{font-weight:600;font-size:1rem;flex:1}
.editor-filename span{color:var(--text2);font-weight:400;font-size:.82rem;margin-left:8px}
.editor-textarea{width:100%;min-height:400px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;color:var(--text);font-family:var(--mono);font-size:.82rem;line-height:1.7;outline:none;resize:vertical;transition:border-color .2s}
.editor-textarea:focus{border-color:var(--accent)}
.save-btn{padding:8px 20px;border-radius:10px;border:none;background:var(--green);color:#000;font-family:var(--font);font-size:.85rem;font-weight:600;cursor:pointer;transition:all .2s}
.save-btn:hover{background:#25b889;transform:translateY(-1px)}
.save-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.edit-toggle-btn{padding:8px 18px;border-radius:10px;border:1px solid var(--border);background:var(--card);color:var(--text2);font-family:var(--font);font-size:.82rem;font-weight:500;cursor:pointer;transition:all .25s;display:flex;align-items:center;gap:6px}
.edit-toggle-btn:hover{border-color:var(--accent);color:var(--text);background:var(--glow)}
.edit-toggle-btn.editing{background:var(--accent);color:#fff;border-color:var(--accent)}
.edit-toggle-btn.editing:hover{background:#6b4ce6}
.md-preview{width:100%;min-height:400px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px 28px;color:var(--text);font-size:.88rem;line-height:1.8;overflow-y:auto;overflow-x:hidden}
.md-preview h1{font-size:1.6rem;font-weight:700;margin:0 0 16px;padding-bottom:10px;border-bottom:1px solid var(--border);color:var(--text);line-height:1.3}
.md-preview h2{font-size:1.25rem;font-weight:600;margin:24px 0 12px;padding-bottom:6px;border-bottom:1px solid rgba(26,31,46,0.6);color:var(--text);line-height:1.3}
.md-preview h3{font-size:1.05rem;font-weight:600;margin:20px 0 8px;color:var(--text);line-height:1.3}
.md-preview h4{font-size:.92rem;font-weight:600;margin:16px 0 6px;color:var(--accent2)}
.md-preview p{margin:0 0 12px;color:var(--text2)}
.md-preview strong{color:var(--text);font-weight:600}
.md-preview em{color:var(--accent2);font-style:italic}
.md-preview a{color:var(--blue);text-decoration:none;border-bottom:1px solid rgba(88,166,255,0.3);transition:all .2s}
.md-preview a:hover{border-bottom-color:var(--blue);color:var(--accent2)}
.md-preview code{background:rgba(124,92,252,0.1);padding:2px 7px;border-radius:5px;font-family:var(--mono);font-size:.82rem;color:var(--accent2)}
.md-preview pre{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px 18px;overflow-x:auto;margin:12px 0 16px;line-height:1.6}
.md-preview pre code{background:none;padding:0;color:var(--text);font-size:.8rem}
.md-preview ul,.md-preview ol{margin:8px 0 16px 8px;padding-left:20px}
.md-preview li{margin-bottom:6px;color:var(--text2);line-height:1.6}
.md-preview li::marker{color:var(--accent)}
.md-preview blockquote{border-left:3px solid var(--accent);margin:12px 0 16px;padding:10px 16px;background:rgba(124,92,252,0.04);border-radius:0 8px 8px 0;color:var(--text2)}
.md-preview blockquote p{margin-bottom:4px}
.md-preview hr{border:none;height:1px;background:var(--border);margin:24px 0}
.md-preview table{width:100%;border-collapse:collapse;margin:12px 0 16px;font-size:.84rem}
.md-preview th{text-align:left;padding:10px 14px;background:var(--bg);border:1px solid var(--border);font-weight:600;color:var(--text);text-transform:uppercase;font-size:.75rem;letter-spacing:.5px}
.md-preview td{padding:10px 14px;border:1px solid var(--border);color:var(--text2)}
.md-preview tr:hover td{background:rgba(124,92,252,0.03)}
.md-preview img{max-width:100%;border-radius:8px;margin:8px 0}
.md-empty-hint{text-align:center;padding:60px 20px;color:var(--text2)}
.md-empty-hint svg{width:60px;height:60px;opacity:.3;margin-bottom:12px}
.md-empty-hint p{font-size:.88rem;margin-top:8px}
.view-toggle{display:flex;background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.view-toggle button{padding:6px 14px;border:none;background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s}
.view-toggle button.active{background:var(--accent);color:#fff}
/* Skills Grid */
.skills-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;margin-top:12px}
.skill-card{padding:16px;cursor:default}
.skill-card::before{background:linear-gradient(180deg,var(--accent2),var(--blue))}
.skill-card .skill-name{font-weight:600;font-size:.92rem;margin-bottom:6px}
.skill-card .skill-desc{font-size:.8rem;color:var(--text2);line-height:1.5}
.skill-card .skill-meta{font-family:var(--mono);font-size:.7rem;color:var(--text2);margin-top:8px;opacity:.7}
/* ─── Logs Tab ─── */
.logs-toolbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}
.toggle-switch{display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--text2)}
.toggle-track{width:36px;height:20px;background:var(--border);border-radius:10px;cursor:pointer;position:relative;transition:background .2s}
.toggle-track.on{background:var(--accent)}
.toggle-track::after{content:'';position:absolute;width:16px;height:16px;background:#fff;border-radius:50%;top:2px;left:2px;transition:transform .2s}
.toggle-track.on::after{transform:translateX(16px)}
.timeline{position:relative;padding-left:28px}
.timeline::before{content:'';position:absolute;left:8px;top:0;bottom:0;width:2px;background:var(--border)}
.timeline-item{position:relative;margin-bottom:20px;animation:cardIn .4s ease backwards}
.timeline-item::before{content:'';position:absolute;left:-24px;top:6px;width:12px;height:12px;border-radius:50%;background:var(--card);border:2px solid var(--border)}
.timeline-item.type-task::before{border-color:var(--accent);background:rgba(124,92,252,0.2)}
.timeline-item.type-memory::before{border-color:var(--green);background:rgba(45,212,160,0.2)}
.timeline-date{font-family:var(--mono);font-size:.72rem;color:var(--text2);margin-bottom:4px}
.timeline-content{background:var(--glass);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:.84rem;line-height:1.6;color:var(--text2);cursor:pointer;transition:all .2s}
.timeline-content:hover{border-color:rgba(124,92,252,0.3)}
.timeline-content.expanded{color:var(--text);white-space:pre-wrap}
.timeline-content .preview{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.timeline-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
.timeline-tag.memory{background:rgba(45,212,160,0.1);color:var(--green)}
.timeline-tag.task{background:rgba(124,92,252,0.1);color:var(--accent2)}
@keyframes cardIn{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
/* ─── Ops (Cron + Vision) ─── */
.ops-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}
.card-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}
.card-title{font-weight:700;font-size:1rem}
.card-sub{font-size:.82rem;color:var(--text2)}
.pill-row{display:flex;gap:6px;flex-wrap:wrap}
.pill{font-size:.72rem;padding:4px 8px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text2)}
.pill.ok{color:var(--green);border-color:rgba(45,212,160,0.35)}
.pill.fail{color:var(--red);border-color:rgba(248,81,73,0.35)}
.pill.run{color:var(--yellow);border-color:rgba(245,166,35,0.35)}
.timeline-list{display:flex;flex-direction:column;gap:8px}
.timeline-item.ops{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:12px;background:var(--card)}
.timeline-left{display:flex;flex-direction:column;gap:4px;min-width:0}
.timeline-title{font-weight:600;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.timeline-meta{font-size:.75rem;color:var(--text2)}
.timeline-status{display:inline-flex;align-items:center;gap:6px;font-size:.75rem}
.timeline-dot{width:8px;height:8px;border-radius:50%}
.timeline-dot.ok{background:var(--green)}
.timeline-dot.fail{background:var(--red)}
.timeline-dot.run{background:var(--yellow)}
.timeline-right{font-size:.78rem;color:var(--text2);text-align:right;white-space:nowrap}
.vision-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.vision-card{padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--card)}
.vision-label{font-size:.78rem;color:var(--text2);margin-bottom:6px}
.vision-count{font-size:1.4rem;font-weight:700}
.vision-sub{font-size:.72rem;color:var(--text2);margin-top:4px}
/* ─── Ops Channel Usage ─── */
.ops-channel-list{display:flex;flex-direction:column;gap:8px}
.ops-channel-card{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--card)}
.ops-ch-left{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}
.ops-ch-name{font-weight:600;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ops-ch-meta{font-size:.75rem;color:var(--text2);display:flex;gap:8px;flex-wrap:wrap}
.ops-ch-right{text-align:right;flex-shrink:0}
.ops-ch-tokens{font-size:1.1rem;font-weight:700}
.ops-ch-cost{font-size:.78rem;color:var(--text2)}
.ops-model-bar{margin-top:12px}
.ops-bar-track{display:flex;height:8px;border-radius:4px;overflow:hidden;background:var(--border)}
.ops-bar-track>div{height:100%;flex-shrink:0}
.ops-model-legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:.75rem;color:var(--text2)}
.ops-model-legend-item{display:flex;align-items:center;gap:4px}
.ops-model-dot{width:8px;height:8px;border-radius:50%}
/* ─── Ops Management Buttons ─── */
.ops-mgmt-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:0}
.ops-mgmt-btn{display:flex;align-items:center;gap:10px;padding:14px 16px;border-radius:12px;border:1px solid var(--border);background:rgba(13,17,23,0.6);color:var(--text);font-family:var(--font);font-size:.9rem;font-weight:500;cursor:pointer;transition:all .25s;width:100%;text-align:left;position:relative;backdrop-filter:blur(12px)}
.ops-mgmt-btn:hover{border-color:var(--accent);background:rgba(124,92,252,0.08);transform:translateY(-1px);box-shadow:0 4px 16px rgba(124,92,252,0.12)}
.ops-mgmt-btn:active{transform:translateY(0)}
.ops-mgmt-btn .btn-icon{font-size:1.2rem;flex-shrink:0}
.ops-mgmt-btn .btn-label{flex:1}
.ops-mgmt-btn .btn-badge{font-size:1rem;margin-left:auto;transition:all .2s}
.ops-mgmt-btn.loading .btn-badge{animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.ops-cost-card{margin-top:0}
.ops-cost-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:.85rem}
.ops-cost-row:last-child{border-bottom:none}
.ops-cost-label{color:var(--text2)}
.ops-cost-value{color:var(--text);font-family:var(--mono);font-size:.8rem}
/* ─── Config Viewer ─── */
.config-file{margin-bottom:12px;border:1px solid var(--border);border-radius:12px;overflow:hidden}
.config-file-header{padding:10px 14px;background:var(--card);cursor:pointer;display:flex;justify-content:space-between;align-items:center;font-weight:600;font-size:.85rem}
.config-file-header:hover{background:rgba(124,92,252,.06)}
.config-file-meta{font-size:.72rem;color:var(--text2);font-weight:400}
.config-file-body{display:none;padding:12px;background:var(--bg);border-top:1px solid var(--border);max-height:400px;overflow:auto}
.config-file-body.open{display:block}
.config-file-body pre{font-family:'SF Mono',monospace;font-size:.75rem;white-space:pre-wrap;word-break:break-all;margin:0;line-height:1.5;color:var(--text1)}
.config-cat{font-size:.72rem;padding:2px 8px;border-radius:4px;margin-left:8px}
.config-cat.core{background:rgba(124,92,252,.15);color:#c084fc}
.config-cat.keys{background:rgba(248,113,113,.15);color:#f87171}
.config-cat.personality{background:rgba(52,211,153,.15);color:#34d399}
/* ─── Enhanced Cron ─── */
.cron-card{border:1px solid var(--border);border-radius:12px;padding:14px;margin-bottom:10px;background:var(--card)}
.cron-card.disabled{opacity:.5}
.cron-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
.cron-name{font-weight:700;font-size:.9rem}
.cron-schedule{font-size:.78rem;color:var(--accent);font-weight:500}
.cron-desc{font-size:.82rem;color:var(--text2);margin:6px 0;line-height:1.4}
.cron-footer{display:flex;gap:12px;font-size:.72rem;color:var(--text2);flex-wrap:wrap;margin-top:8px}
.cron-status{display:inline-flex;align-items:center;gap:4px}
.cron-status .dot{width:6px;height:6px;border-radius:50%}
.cron-status .dot.ok{background:#34d399}.cron-status .dot.fail{background:#f87171}.cron-status .dot.off{background:#6b7280}
/* ─── Sessions Table ─── */
.sessions-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.sessions-table{width:100%;border-collapse:collapse;font-size:.78rem}
.sessions-table th{padding:8px 6px;text-align:left;color:var(--text2);font-weight:500;white-space:nowrap;position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--border)}
.sess-sort-btn{display:inline-flex;align-items:center;gap:4px;border:none;background:transparent;color:inherit;font:inherit;cursor:pointer;padding:0}
.sess-sort-btn:hover{color:var(--text)}
.sess-sort-btn.active{color:var(--accent2)}
.sess-sort-indicator{font-family:var(--mono);font-size:.65rem;opacity:.85;min-width:12px;text-align:center}
.sessions-table td{padding:7px 6px;border-bottom:1px solid var(--border);white-space:nowrap;vertical-align:middle}
.sessions-table tr:hover{background:rgba(124,92,252,.06)}
.sess-name{font-weight:600;max-width:160px;overflow:hidden;text-overflow:ellipsis}
.sess-status{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}
.sess-status.active{background:#34d399}.sess-status.idle{background:#6b7280}.sess-status.error{background:#f87171}.sess-status.stale{background:#fbbf24}
.sess-model{font-size:.72rem;padding:2px 6px;border-radius:4px;border:1px solid var(--border)}
.model-select{font-size:.7rem;padding:2px 4px;border-radius:4px;border:1px solid var(--border);background:var(--bg2);color:var(--text);cursor:pointer;outline:none;max-width:80px;transition:all .3s}
.model-select:focus{border-color:var(--blue)}
.sess-alert{padding:8px 12px;border-radius:8px;margin-bottom:6px;font-size:.82rem;display:flex;align-items:center;gap:8px}
.sess-alert.error{background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3)}
.sess-alert.waste{background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.3)}
.sess-alert.stale{background:rgba(107,114,128,.1);border:1px solid rgba(107,114,128,.3)}
/* ─── Cost Heatmap ─── */
.cost-heatmap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.cost-heatmap table{width:100%;border-collapse:collapse;font-size:.75rem}
.cost-heatmap th{padding:6px 4px;text-align:center;color:var(--text2);font-weight:500;white-space:nowrap;position:sticky;top:0;background:var(--bg)}
.cost-heatmap th:first-child{text-align:left;min-width:90px}
.cost-heatmap td{padding:5px 4px;text-align:center;border-radius:4px;white-space:nowrap}
.cost-heatmap td:first-child{text-align:left;font-weight:600;color:var(--text1)}
.cost-heatmap .heat-cell{display:inline-block;padding:2px 6px;border-radius:4px;min-width:40px;font-variant-numeric:tabular-nums}
.cost-heatmap .total-row td{border-top:1px solid var(--border);font-weight:700}
/* ─── Responsive ─── */
@media(max-width:768px){
.ops-mgmt-grid{grid-template-columns:1fr}
.ops-grid{grid-template-columns:1fr}
.vision-grid{grid-template-columns:1fr 1fr}
body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}
header{flex-direction:column;gap:8px;text-align:center;padding:calc(12px + env(safe-area-inset-top)) 0 16px}
.header-right{justify-content:center;flex-wrap:wrap;gap:8px}
/* Bottom nav bar on mobile */
.tab-bar{
position:fixed;bottom:0;left:0;right:0;z-index:900;
width:100%;margin:0;border-radius:0;border:none;
border-top:1px solid var(--border);
background:rgba(6,8,14,0.92);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
display:flex;justify-content:space-around;
padding:8px 8px calc(8px + env(safe-area-inset-bottom));
gap:0
}
.tab-btn{
flex:1;display:flex;flex-direction:column;align-items:center;
padding:6px 4px;min-height:44px;font-size:.65rem;gap:2px;
border-radius:10px;white-space:nowrap
}
.tab-btn svg{width:20px;height:20px;margin:0}
.tab-btn .badge-count{margin-left:0;margin-top:1px}
.tab-btn .tab-label{display:block}
.tab-content{display:block;font-size:.65rem}
.tasks-toolbar{flex-direction:column;align-items:stretch}
.search-input{width:100%}
.spacer{display:none}
.docs-layout{grid-template-columns:1fr;min-height:auto}
.file-sidebar{max-height:200px;overflow-y:auto}
.modal{width:95%;padding:20px}
.task-header{flex-direction:column;align-items:flex-start}
.logs-toolbar{flex-direction:column;align-items:flex-start}
.container{padding:0 12px}
.agent-stat-value{font-size:2rem}
}
/* ─── Task Detail Modal ─── */
.detail-modal .modal{max-width:720px;padding:0;overflow:hidden}
.detail-header{padding:24px 28px 16px;padding-right:56px;border-bottom:1px solid var(--border);position:relative}
.detail-header .detail-status-row{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.detail-title{font-size:1.3rem;font-weight:700;line-height:1.3;margin-bottom:4px}
.detail-meta{display:flex;align-items:center;gap:12px;font-size:.78rem;color:var(--text2);font-family:var(--mono);flex-wrap:wrap;margin-top:8px}
.detail-meta span{display:flex;align-items:center;gap:4px}
.detail-body{padding:0 28px 24px;max-height:60vh;overflow-y:auto}
.detail-section{margin-top:20px}
.detail-section-title{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-bottom:10px;display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
.detail-section-title svg{width:14px;height:14px;transition:transform .2s}
.detail-section-title.collapsed svg{transform:rotate(-90deg)}
.detail-section-content{animation:fadeIn .3s ease}
.detail-section-title.collapsed+.detail-section-content{display:none}
.detail-description{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;font-size:.85rem;line-height:1.8;color:var(--text2)}
.detail-description p{margin-bottom:8px}
.detail-description code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
.detail-description pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-x:auto;margin:10px 0;font-size:.8rem;line-height:1.6}
.detail-description pre code{background:none;padding:0;color:var(--text)}
.detail-description h2,.detail-description h3,.detail-description h4{color:var(--text);margin:12px 0 6px;font-weight:600}
.detail-description h2{font-size:1rem}
.detail-description h3{font-size:.92rem}
.detail-description h4{font-size:.86rem}
.detail-description strong{color:var(--text)}
.detail-description em{color:var(--accent2)}
.detail-description li{margin-bottom:4px;list-style-position:inside}
.detail-description ul,.detail-description ol{margin:6px 0 6px 4px}
.detail-output{background:var(--bg);border:1px solid var(--border);border-radius:12px;overflow:hidden}
.detail-output-header{padding:10px 16px;background:var(--card);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;font-size:.78rem}
.detail-output-header span{color:var(--text2);font-weight:600;letter-spacing:.5px;text-transform:uppercase}
.detail-output-copy{padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.72rem;cursor:pointer;transition:all .2s}
.detail-output-copy:hover{border-color:var(--accent);color:var(--text)}
.detail-output-body{padding:16px 18px;font-family:var(--mono);font-size:.8rem;line-height:1.7;color:var(--text);white-space:pre-wrap;word-break:break-word;max-height:400px;overflow-y:auto}
.detail-notes{display:flex;flex-direction:column;gap:10px}
.detail-note{display:flex;gap:12px;padding:10px 14px;background:var(--bg);border-radius:10px;border:1px solid var(--border);transition:border-color .2s}
.detail-note:hover{border-color:rgba(124,92,252,0.2)}
.detail-note.is-output{border-left:3px solid var(--accent);background:rgba(124,92,252,0.03)}
.detail-note.is-status{border-left:3px solid var(--blue);opacity:.7}
.detail-note-time{font-family:var(--mono);font-size:.7rem;color:var(--text2);white-space:nowrap;min-width:90px;padding-top:2px}
.detail-note-text{font-size:.82rem;color:var(--text);line-height:1.6;flex:1;overflow:hidden}
.detail-note-text.truncated{max-height:60px;position:relative}
.detail-note-text.truncated::after{content:'';position:absolute;bottom:0;left:0;right:0;height:24px;background:linear-gradient(transparent,var(--bg))}
.detail-note-expand{font-size:.72rem;color:var(--accent);cursor:pointer;margin-top:4px;flex-shrink:0;align-self:flex-end}
.detail-note-expand:hover{text-decoration:underline}
.detail-add-note{display:flex;gap:8px;margin-top:12px}
.detail-note-input{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-family:var(--font);font-size:.84rem;outline:none;transition:border-color .25s}
.detail-note-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,0.1)}
.detail-note-input::placeholder{color:var(--text2)}
.detail-actions{display:flex;gap:8px;padding:16px 28px;border-top:1px solid var(--border);background:var(--bg2);flex-wrap:wrap}
.detail-actions .action-btn{padding:8px 18px;font-size:.82rem}
/* ─── Connected APIs Grid ─── */
.apis-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-top:12px}
.api-card{padding:20px;cursor:default;transition:all .3s}
.api-card::before{background:linear-gradient(180deg,var(--accent),var(--green))}
.api-card:hover{transform:translateY(-3px);box-shadow:0 12px 40px rgba(124,92,252,0.12)}
.api-card-header{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.api-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;flex-shrink:0;border:1px solid var(--border);background:var(--bg)}
.api-icon.telegram{background:linear-gradient(135deg,#2AABEE,#229ED9);border:none;color:#fff}
.api-icon.google{background:linear-gradient(135deg,#4285F4,#34A853);border:none;color:#fff}
.api-icon.notion{background:#000;border:none;color:#fff}
.api-icon.brevo{background:linear-gradient(135deg,#0092FF,#004DFF);border:none;color:#fff}
.api-icon.email{background:linear-gradient(135deg,#EA4335,#FBBC05);border:none;color:#fff}
.api-icon.whisper{background:linear-gradient(135deg,#10a37f,#1a7f64);border:none;color:#fff}
.api-icon.tavily{background:linear-gradient(135deg,#6366F1,#8B5CF6);border:none;color:#fff}
.api-icon.brave{background:linear-gradient(135deg,#FB542B,#FF7654);border:none;color:#fff}
.api-icon.imagen{background:linear-gradient(135deg,#4285F4,#DB4437,#F4B400,#0F9D58);border:none;color:#fff}
.api-icon.meta{background:linear-gradient(135deg,#0081FB,#0064E0,#0082FB);border:none;color:#fff}
.api-icon.openclaw{background:linear-gradient(135deg,var(--accent),var(--accent2));border:none;color:#fff}
.api-card-info{flex:1;min-width:0}
.api-card-name{font-weight:700;font-size:1rem;margin-bottom:2px;display:flex;align-items:center;gap:8px}
.api-card-name .api-status{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.api-card-name .api-status.connected{background:var(--green);box-shadow:0 0 6px rgba(45,212,160,0.4)}
.api-card-name .api-status.disconnected{background:var(--red);box-shadow:0 0 6px rgba(248,81,73,0.4)}
.api-card-name .api-status.partial{background:var(--yellow);box-shadow:0 0 6px rgba(245,166,35,0.4)}
.api-card-provider{font-size:.78rem;color:var(--text2)}
.api-card-desc{font-size:.82rem;color:var(--text2);line-height:1.5;margin-bottom:14px}
.api-capabilities{display:flex;flex-wrap:wrap;gap:6px}
.api-cap{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:8px;font-size:.7rem;font-weight:500;background:rgba(124,92,252,0.08);color:var(--accent2);border:1px solid rgba(124,92,252,0.12);letter-spacing:.3px}
.api-cap svg{width:12px;height:12px;opacity:.7}
.api-cap.messaging{background:rgba(88,166,255,0.08);color:var(--blue);border-color:rgba(88,166,255,0.15)}
.api-cap.data{background:rgba(45,212,160,0.08);color:var(--green);border-color:rgba(45,212,160,0.15)}
.api-cap.ai{background:rgba(167,139,250,0.08);color:var(--accent2);border-color:rgba(167,139,250,0.15)}
.api-cap.search{background:rgba(248,166,35,0.08);color:var(--yellow);border-color:rgba(248,166,35,0.15)}
.apis-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
.api-stat-card{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;padding:16px 20px;text-align:center}
.api-stat-value{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--green));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.api-stat-label{font-size:.75rem;color:var(--text2);text-transform:uppercase;letter-spacing:1px;margin-top:4px}
/* ─── Agent Monitor Bar ─── */
.agent-monitor{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px}
.agent-stat{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;padding:16px 18px;position:relative;overflow:hidden;transition:all .3s}
.agent-stat:hover{transform:translateY(-2px);box-shadow:0 6px 24px rgba(124,92,252,0.1)}
.agent-stat::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:3px 0 0 3px}
.agent-stat.stat-main::before{background:linear-gradient(180deg,var(--green),var(--accent))}
.agent-stat.stat-subagent::before{background:linear-gradient(180deg,var(--accent),var(--blue))}
.agent-stat.stat-hook::before{background:linear-gradient(180deg,var(--blue),var(--accent2))}
.agent-stat.stat-cron::before{background:linear-gradient(180deg,var(--yellow),var(--accent))}
.agent-stat.stat-total::before{background:linear-gradient(180deg,var(--accent2),var(--green))}
.agent-stat-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.agent-stat-icon{font-size:1.2rem;opacity:.8}
.agent-stat-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;font-size:.65rem;font-weight:600;letter-spacing:.5px}
.agent-stat-badge.active{background:rgba(45,212,160,0.15);color:var(--green);border:1px solid rgba(45,212,160,0.25)}
.agent-stat-badge.idle{background:rgba(139,148,158,0.1);color:var(--text2);border:1px solid rgba(139,148,158,0.2)}
.agent-stat-badge .pulse-dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 2s infinite}
.agent-stat-value{font-size:1.8rem;font-weight:800;line-height:1;margin-bottom:2px}
.agent-stat-value.val-active{background:linear-gradient(135deg,var(--green),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.agent-stat-value.val-accent{background:linear-gradient(135deg,var(--accent),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.agent-stat-value.val-blue{background:linear-gradient(135deg,var(--blue),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.agent-stat-value.val-yellow{background:linear-gradient(135deg,var(--yellow),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.agent-stat-value.val-purple{background:linear-gradient(135deg,var(--accent2),var(--green));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.agent-stat-label{font-size:.72rem;color:var(--text2);text-transform:uppercase;letter-spacing:1px;font-weight:500}
.agent-stat-detail{font-size:.7rem;color:var(--text2);margin-top:6px;font-family:var(--mono);opacity:.7}
.agent-sessions-panel{background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:14px;margin-bottom:24px;overflow:hidden;transition:all .3s}
.agent-sessions-header{padding:12px 18px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:background .2s;user-select:none}
.agent-sessions-header:hover{background:rgba(124,92,252,0.04)}
.agent-sessions-title{font-size:.78rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--text2);display:flex;align-items:center;gap:8px}
.agent-sessions-title svg{width:14px;height:14px;transition:transform .2s}
.agent-sessions-header.collapsed .agent-sessions-title svg{transform:rotate(-90deg)}
.agent-sessions-count{font-size:.7rem;font-family:var(--mono);color:var(--accent2);background:rgba(124,92,252,0.1);padding:2px 8px;border-radius:999px}
.agent-sessions-body{max-height:400px;overflow-y:auto;padding:0;transition:max-height .4s ease}
.agent-sessions-header.collapsed+.agent-sessions-body{max-height:0;overflow:hidden}
.agent-session-row{display:flex;align-items:center;gap:12px;padding:10px 18px;border-top:1px solid var(--border);font-size:.8rem;transition:background .15s}
.agent-session-row:hover{background:rgba(124,92,252,0.04)}
.agent-session-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.agent-session-dot.active{background:var(--green);box-shadow:0 0 6px rgba(45,212,160,0.4);animation:pulse 2s infinite}
.agent-session-dot.idle{background:var(--text2);opacity:.4}
.agent-session-key{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-family:var(--mono);font-size:.75rem}
.agent-session-type{padding:2px 8px;border-radius:999px;font-size:.65rem;font-weight:600;letter-spacing:.5px;text-transform:uppercase;flex-shrink:0}
.agent-session-type.type-main{background:rgba(45,212,160,0.12);color:var(--green)}
.agent-session-type.type-subagent{background:rgba(124,92,252,0.12);color:var(--accent2)}
.agent-session-type.type-hook{background:rgba(88,166,255,0.12);color:var(--blue)}
.agent-session-type.type-cron{background:rgba(245,166,35,0.12);color:var(--yellow)}
.agent-session-type.type-group{background:rgba(139,148,158,0.1);color:var(--text2)}
.agent-session-age{font-family:var(--mono);font-size:.7rem;color:var(--text2);white-space:nowrap;flex-shrink:0}
.agent-session-tokens{font-family:var(--mono);font-size:.68rem;color:var(--text2);white-space:nowrap;flex-shrink:0;opacity:.6}
.agent-session-label{font-size:.72rem;color:var(--accent2);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0}
@media(max-width:768px){
.agent-monitor{grid-template-columns:repeat(2,1fr);gap:8px}
.agent-monitor .agent-stat:last-child{grid-column:1 / -1}
.agent-stat-value{font-size:1.6rem}
.agent-stat{padding:12px 14px}
.agent-session-tokens,.agent-session-label{display:none}
/* Mobile: hide 任务(2), Tokens(5); keep 频道/模型/消息/花费/$/条/匹配 */
.sessions-table th:nth-child(2),.sessions-table td:nth-child(2),
.sessions-table th:nth-child(5),.sessions-table td:nth-child(5){display:none}
.sessions-table{font-size:.72rem}
.sess-name{max-width:100px}
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--text2)}
/* ─── Staggered animations ─── */
.stagger-1{animation-delay:.05s!important}
.stagger-2{animation-delay:.1s!important}
.stagger-3{animation-delay:.15s!important}
.stagger-4{animation-delay:.2s!important}
.stagger-5{animation-delay:.25s!important}
.stagger-6{animation-delay:.3s!important}
.stagger-7{animation-delay:.35s!important}
.stagger-8{animation-delay:.4s!important}
/* ─── Task Markdown Content Field ─── */
.task-content-preview{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin-top:12px;position:relative;overflow:hidden;max-height:120px;cursor:pointer;transition:all .3s}
.task-content-preview::after{content:'';position:absolute;bottom:0;left:0;right:0;height:40px;background:linear-gradient(transparent,var(--bg));pointer-events:none}
.task-content-preview.expanded{max-height:none}
.task-content-preview.expanded::after{display:none}
.task-content-label{display:flex;align-items:center;gap:6px;font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--accent2);margin-bottom:8px}
.task-content-label svg{width:14px;height:14px;opacity:.7}
.task-content-md{font-size:.84rem;line-height:1.7;color:var(--text2)}
.task-content-md h1,.task-content-md h2,.task-content-md h3,.task-content-md h4{color:var(--text);margin:8px 0 4px;font-weight:600}
.task-content-md h1{font-size:1.1rem}.task-content-md h2{font-size:1rem}.task-content-md h3{font-size:.92rem}.task-content-md h4{font-size:.86rem}
.task-content-md p{margin:0 0 8px}
.task-content-md code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
.task-content-md pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
.task-content-md pre code{background:none;padding:0;color:var(--text);font-size:.78rem}
.task-content-md ul,.task-content-md ol{margin:6px 0 10px;padding-left:20px}
.task-content-md li{margin-bottom:4px;color:var(--text2)}
.task-content-md li::marker{color:var(--accent)}
.task-content-md blockquote{border-left:3px solid var(--accent);padding:8px 14px;margin:8px 0;background:rgba(124,92,252,0.04);border-radius:0 8px 8px 0}
.task-content-md strong{color:var(--text)}
.task-content-md em{color:var(--accent2)}
.task-content-md a{color:var(--blue);text-decoration:none;border-bottom:1px solid rgba(88,166,255,0.3)}
.task-content-md a:hover{border-bottom-color:var(--blue)}
.task-content-md table{width:100%;border-collapse:collapse;font-size:.82rem;margin:8px 0}
.task-content-md th{text-align:left;padding:8px 12px;background:var(--bg2);border:1px solid var(--border);font-weight:600;color:var(--text);font-size:.75rem;letter-spacing:.5px;text-transform:uppercase}
.task-content-md td{padding:8px 12px;border:1px solid var(--border);color:var(--text2)}
.task-content-md tr:hover td{background:rgba(124,92,252,0.03)}
.task-content-md hr{border:none;height:1px;background:var(--border);margin:16px 0}
.task-content-md img{max-width:100%;border-radius:6px}
.detail-content-section{margin-top:20px}
.detail-content-area{background:var(--bg);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:border-color .2s}
.detail-content-area:focus-within{border-color:var(--accent)}
.detail-content-header{padding:10px 16px;background:var(--card);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.detail-content-header .content-label{font-size:.75rem;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--accent2);display:flex;align-items:center;gap:6px}
.detail-content-header .content-label svg{width:14px;height:14px}
.detail-content-actions{display:flex;gap:6px}
.content-edit-btn{padding:4px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.72rem;cursor:pointer;transition:all .2s}
.content-edit-btn:hover{border-color:var(--accent);color:var(--text)}
.content-edit-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.content-save-btn{padding:4px 12px;border-radius:6px;border:none;background:var(--green);color:#000;font-family:var(--font);font-size:.72rem;font-weight:600;cursor:pointer;transition:all .2s}
.content-save-btn:hover{background:#25b889}
.detail-content-md{padding:18px 20px;font-size:.85rem;line-height:1.8;color:var(--text2);min-height:80px}
.detail-content-md h1{font-size:1.3rem;font-weight:700;margin:0 0 12px;color:var(--text)}
.detail-content-md h2{font-size:1.1rem;font-weight:600;margin:16px 0 8px;color:var(--text)}
.detail-content-md h3{font-size:.95rem;font-weight:600;margin:12px 0 6px;color:var(--text)}
.detail-content-md h4{font-size:.88rem;font-weight:600;margin:10px 0 4px;color:var(--accent2)}
.detail-content-md p{margin:0 0 10px}
.detail-content-md code{background:rgba(124,92,252,0.1);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:.8rem;color:var(--accent2)}
.detail-content-md pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-x:auto;margin:10px 0;line-height:1.6}
.detail-content-md pre code{background:none;padding:0;color:var(--text);font-size:.78rem}
.detail-content-md ul,.detail-content-md ol{margin:6px 0 12px;padding-left:20px}
.detail-content-md li{margin-bottom:4px;color:var(--text2);line-height:1.6}
.detail-content-md li::marker{color:var(--accent)}
.detail-content-md blockquote{border-left:3px solid var(--accent);padding:10px 16px;margin:10px 0;background:rgba(124,92,252,0.04);border-radius:0 8px 8px 0}
.detail-content-md blockquote p{margin-bottom:4px}
.detail-content-md strong{color:var(--text)}
.detail-content-md em{color:var(--accent2)}
.detail-content-md a{color:var(--blue);text-decoration:none;border-bottom:1px solid rgba(88,166,255,0.3)}
.detail-content-md a:hover{border-bottom-color:var(--blue)}
.detail-content-md table{width:100%;border-collapse:collapse;font-size:.84rem;margin:10px 0}
.detail-content-md th{text-align:left;padding:8px 12px;background:var(--bg2);border:1px solid var(--border);font-weight:600;color:var(--text);font-size:.75rem;letter-spacing:.5px;text-transform:uppercase}
.detail-content-md td{padding:8px 12px;border:1px solid var(--border);color:var(--text2)}
.detail-content-md tr:hover td{background:rgba(124,92,252,0.03)}
.detail-content-md hr{border:none;height:1px;background:var(--border);margin:20px 0}
.detail-content-md img{max-width:100%;border-radius:8px;margin:8px 0}
.detail-content-textarea{width:100%;min-height:200px;background:var(--bg2);border:none;padding:16px 20px;color:var(--text);font-family:var(--mono);font-size:.82rem;line-height:1.7;outline:none;resize:vertical}
.detail-content-empty{text-align:center;padding:30px 20px;color:var(--text2);font-size:.84rem}
.detail-content-empty svg{width:40px;height:40px;opacity:.3;margin-bottom:8px;display:block;margin:0 auto 8px}
/* ─── Parallel Execution Panel ─── */
.parallel-panel{background:var(--glass);backdrop-filter:blur(16px);border:1px solid rgba(124,92,252,0.25);border-radius:16px;margin-bottom:20px;overflow:hidden;animation:fadeIn .3s ease}
.parallel-header{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)}
.parallel-title{font-size:.85rem;font-weight:700;display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,var(--accent),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.parallel-count{font-size:.72rem;font-family:var(--mono);color:var(--green);background:rgba(45,212,160,0.1);padding:3px 10px;border-radius:999px;border:1px solid rgba(45,212,160,0.2)}
.parallel-tasks{padding:8px;max-height:300px;overflow-y:auto}
.parallel-task-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;transition:background .2s;font-size:.82rem}
.parallel-task-row:hover{background:rgba(124,92,252,0.04)}
.parallel-spinner{width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;flex-shrink:0}
.parallel-done-icon{width:16px;height:16px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--green);color:#000;font-size:.6rem;font-weight:700;flex-shrink:0}
.parallel-fail-icon{width:16px;height:16px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--red);color:#fff;font-size:.6rem;font-weight:700;flex-shrink:0}
.parallel-task-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.parallel-task-time{font-family:var(--mono);font-size:.7rem;color:var(--text2);white-space:nowrap}
.parallel-task-status{padding:2px 8px;border-radius:999px;font-size:.65rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.parallel-task-status.running{background:rgba(88,166,255,0.15);color:var(--blue);border:1px solid rgba(88,166,255,0.25)}
.parallel-task-status.done{background:rgba(45,212,160,0.15);color:var(--green);border:1px solid rgba(45,212,160,0.25)}
.parallel-task-status.failed{background:rgba(248,81,73,0.15);color:var(--red);border:1px solid rgba(248,81,73,0.25)}
.parallel-actions{display:flex;gap:8px;padding:10px 18px;border-top:1px solid var(--border)}
.parallel-clear-btn{padding:5px 14px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.75rem;cursor:pointer;transition:all .2s}
.parallel-clear-btn:hover{border-color:var(--accent);color:var(--text)}
/* ─── Task Selection ─── */
.task-checkbox{width:18px;height:18px;border-radius:6px;border:2px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;margin-right:4px}
.task-checkbox:hover{border-color:var(--accent)}
.task-checkbox.checked{background:var(--accent);border-color:var(--accent)}
.task-checkbox.checked::after{content:'✓';color:#fff;font-size:.7rem;font-weight:700}
.batch-bar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:rgba(124,92,252,0.08);border:1px solid rgba(124,92,252,0.2);border-radius:12px;margin-bottom:16px;animation:fadeIn .2s ease}
.batch-bar-count{font-size:.85rem;font-weight:600;color:var(--accent2)}
.batch-run-btn{padding:7px 18px;border-radius:8px;border:none;background:linear-gradient(135deg,var(--accent),var(--blue));color:#fff;font-family:var(--font);font-size:.82rem;font-weight:600;cursor:pointer;transition:all .25s;display:flex;align-items:center;gap:6px}
.batch-run-btn:hover{transform:translateY(-1px);box-shadow:0 4px 16px rgba(124,92,252,0.3)}
.batch-deselect-btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--text2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s}
.batch-deselect-btn:hover{border-color:var(--accent);color:var(--text)}
/* ─── Spawn button in task actions ─── */
.spawn-btn{padding:6px 14px;border-radius:8px;border:1px solid rgba(124,92,252,0.3);background:rgba(124,92,252,0.06);color:var(--accent2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
.spawn-btn:hover{background:rgba(124,92,252,0.15);border-color:var(--accent);transform:translateY(-1px)}
/* ─── Attachments Section ─── */
.attachments-section{margin-top:20px}
.attachments-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-top:10px}
.attachment-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:all .2s;position:relative;cursor:pointer}
.attachment-card:hover{border-color:rgba(124,92,252,0.3);transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,0.2)}
.attachment-card .att-preview{width:100%;height:110px;display:flex;align-items:center;justify-content:center;background:var(--bg2);overflow:hidden;position:relative}
.attachment-card .att-preview img{width:100%;height:100%;object-fit:cover;transition:transform .3s}
.attachment-card:hover .att-preview img{transform:scale(1.05)}
.attachment-card .att-preview .att-icon{font-size:2.2rem;opacity:.5}
.attachment-card .att-info{padding:8px 10px}
.attachment-card .att-name{font-size:.72rem;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3}
.attachment-card .att-size{font-size:.65rem;color:var(--text2);font-family:var(--mono);margin-top:2px}
.attachment-card .att-delete{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;background:rgba(248,81,73,0.85);color:#fff;border:none;font-size:.65rem;cursor:pointer;display:none;align-items:center;justify-content:center;transition:all .15s;z-index:2;line-height:1}
.attachment-card:hover .att-delete{display:flex}
.attachment-card .att-delete:hover{background:var(--red);transform:scale(1.1)}
.att-drop-zone{border:2px dashed var(--border);border-radius:14px;padding:24px;text-align:center;cursor:pointer;transition:all .25s;margin-top:12px;position:relative}
.att-drop-zone:hover,.att-drop-zone.drag-over{border-color:var(--accent);background:rgba(124,92,252,0.04)}
.att-drop-zone.drag-over{box-shadow:0 0 16px rgba(124,92,252,0.12)}
.att-drop-zone-icon{font-size:2rem;opacity:.4;margin-bottom:6px}
.att-drop-zone-text{font-size:.82rem;color:var(--text2)}
.att-drop-zone-hint{font-size:.7rem;color:var(--text2);opacity:.5;margin-top:4px}
.att-drop-zone input[type="file"]{position:absolute;inset:0;opacity:0;cursor:pointer}
.att-upload-progress{margin-top:8px;display:none}
.att-upload-bar{height:4px;background:var(--border);border-radius:2px;overflow:hidden}
.att-upload-bar-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--green));border-radius:2px;transition:width .3s;width:0%}
.att-upload-status{font-size:.72rem;color:var(--text2);margin-top:4px;text-align:center}
/* Create modal attachments */
.create-att-drop-zone{border:2px dashed var(--border);border-radius:14px;padding:18px;text-align:center;cursor:pointer;transition:all .25s;position:relative}
.create-att-drop-zone:hover,.create-att-drop-zone.drag-over{border-color:var(--accent);background:rgba(124,92,252,0.04)}
.create-att-drop-zone.drag-over{box-shadow:0 0 16px rgba(124,92,252,0.12)}
.create-att-drop-zone input[type="file"]{position:absolute;inset:0;opacity:0;cursor:pointer}
.create-att-queue{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.create-att-item{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:6px 10px;font-size:.75rem;max-width:100%;transition:all .2s}
.create-att-item:hover{border-color:rgba(124,92,252,0.3)}
.create-att-item .cai-icon{font-size:1rem;flex-shrink:0}
.create-att-item .cai-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-weight:500}
.create-att-item .cai-size{color:var(--text2);font-family:var(--mono);flex-shrink:0;font-size:.68rem}
.create-att-item .cai-remove{background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem;padding:0 2px;border-radius:4px;transition:color .15s;flex-shrink:0;line-height:1}
.create-att-item .cai-remove:hover{color:var(--red)}
.create-att-item .cai-thumb{width:32px;height:32px;border-radius:6px;object-fit:cover;flex-shrink:0}
/* ─── Image Lightbox ─── */
.lightbox-overlay{position:fixed;inset:0;z-index:2000;background:rgba(6,8,14,0.92);backdrop-filter:blur(12px);display:none;align-items:center;justify-content:center;cursor:zoom-out;animation:fadeIn .2s ease}
.lightbox-overlay.show{display:flex}
.lightbox-img{max-width:90%;max-height:85vh;border-radius:12px;box-shadow:0 16px 64px rgba(0,0,0,0.6);animation:modalSlideIn .3s ease forwards}
.lightbox-close{position:fixed;top:20px;right:24px;width:40px;height:40px;border-radius:50%;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:1.2rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;z-index:2001}
.lightbox-close:hover{background:rgba(255,255,255,0.2);transform:scale(1.1)}
.lightbox-info{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);color:var(--text2);font-size:.82rem;display:flex;align-items:center;gap:12px;background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:12px;padding:8px 18px;z-index:2001}
.lightbox-download{padding:5px 14px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--accent2);font-family:var(--font);font-size:.78rem;cursor:pointer;transition:all .2s;text-decoration:none}
.lightbox-download:hover{border-color:var(--accent);background:var(--glow);color:var(--text)}
.att-empty{text-align:center;padding:20px;color:var(--text2);font-size:.82rem;opacity:.6}
.att-empty svg{width:36px;height:36px;opacity:.25;margin-bottom:6px;display:block;margin:0 auto 6px}
</style>
</head>
<body>
<div class="bg-mesh"></div>
<div class="container">
<!-- Header -->
<header>
<div>
<div class="logo">🦞 OpenClaw Dashboard</div>
<div class="logo-sub">24/7 Online</div>
</div>
<div class="header-right">
<span class="status-dot ok" id="statusDot"></span>
<span id="statusText">Connecting…</span>
<button class="live-indicator active" id="liveIndicator" onclick="toggleLivePolling()" title="Toggle live updates">
<span class="live-dot"></span> LIVE
</button>
<button class="refresh-btn" onclick="refreshCurrentTab()" title="Refresh">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<a href="/" class="back-link">← Control UI</a>
</div>
</header>
<!-- Agent Monitor Bar -->
<div class="agent-monitor" id="agentMonitor">
<div class="agent-stat stat-main">
<div class="agent-stat-header">
<span class="agent-stat-icon">💰</span>
<span class="agent-stat-badge idle" id="mainAgentBadge"><span class="pulse-dot"></span> today</span>
</div>
<div class="agent-stat-value val-active" id="mainAgentValue"></div>
<div class="agent-stat-label">Today Cost</div>
<div class="agent-stat-detail" id="mainAgentDetail">Loading…</div>
</div>
<div class="agent-stat stat-subagent">
<div class="agent-stat-header">
<span class="agent-stat-icon">📊</span>
<span class="agent-stat-badge idle" id="subagentBadge"></span>
</div>
<div class="agent-stat-value val-accent" id="subagentValue"></div>
<div class="agent-stat-label">Today Tokens</div>
<div class="agent-stat-detail" id="subagentDetail"></div>
</div>
<div class="agent-stat stat-cron">
<div class="agent-stat-header">
<span class="agent-stat-icon"></span>
<span class="agent-stat-badge idle" id="cronBadge"></span>
</div>
<div class="agent-stat-value val-yellow" id="cronValue">0</div>
<div class="agent-stat-label">Cron Jobs</div>
<div class="agent-stat-detail" id="cronDetail"></div>
</div>
<div class="agent-stat stat-hook">
<div class="agent-stat-header">
<span class="agent-stat-icon">💬</span>
<span class="agent-stat-badge idle" id="hookBadge"></span>
</div>
<div class="agent-stat-value val-blue" id="hookValue">0</div>
<div class="agent-stat-label">Sessions</div>
<div class="agent-stat-detail" id="hookDetail"></div>
</div>
<div class="agent-stat stat-total">
<div class="agent-stat-header">
<span class="agent-stat-icon">🧠</span>
<span class="agent-stat-badge idle" id="totalBadge"></span>
</div>
<div class="agent-stat-value val-purple" id="totalValue"></div>
<div class="agent-stat-label">Model Mix</div>
<div id="modelMixBars" style="margin-top:8px"></div>
</div>
</div>
<!-- System Info Bar (fixed, always visible) -->
<div id="systemInfoBar" class="glass-card" style="padding:10px 16px;margin-bottom:12px;font-size:.75rem;display:none">
<div style="display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center" id="systemInfoContent"></div>
</div>
<div id="watchdogGlobalAlert" class="glass-card" style="padding:10px 14px;margin-bottom:12px;border-color:var(--red);display:none">
<div style="display:flex;justify-content:space-between;gap:10px;align-items:center">
<div style="font-size:.8rem;color:#ffb4b4" id="watchdogGlobalText">OpenClaw watchdog alert</div>
<button class="btn-secondary" style="padding:6px 10px;font-size:.72rem" onclick="focusOperationsTab()">Open Operations</button>
</div>
</div>
<!-- Today's Cron Runs -->
<div class="agent-sessions-panel" id="cronRunsPanel">
<div class="agent-sessions-header" onclick="this.classList.toggle('collapsed');this.nextElementSibling.style.maxHeight=this.classList.contains('collapsed')?'0':'400px'">
<div class="agent-sessions-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
今日 Cron 执行记录
</div>
<span class="agent-sessions-count" id="cronRunsCount">0</span>
</div>
<div class="agent-sessions-body" id="cronRunsBody" style="max-height:400px;transition:max-height .4s"></div>
</div>
<!-- Tab Bar -->
<div class="tab-bar">
<button class="tab-btn active" data-tab="sessions">Sessions</button>
<button class="tab-btn" data-tab="ops">Cost</button>
<button class="tab-btn" data-tab="operations">Operations</button>
<button class="tab-btn" data-tab="tasks">Cron<span class="badge-count" id="taskCount">0</span></button>
<button class="tab-btn" data-tab="quality">Quality</button>
<button class="tab-btn" data-tab="audit">Audit</button>
<button class="tab-btn" data-tab="config">Config</button>
</div>
<!-- ═══ Sessions Panel ═══ -->
<div class="tab-panel active" id="panel-sessions">
<div id="sessionsAlerts" style="margin-bottom:12px"></div>
<div id="sessionsTable" class="sessions-table-wrap">
<div class="skeleton skeleton-card"></div>
</div>
</div>
<!-- ═══ Cost Panel ═══ -->
<div class="tab-panel" id="panel-ops">
<div class="glass-card" style="padding:16px;margin-bottom:16px">
<div class="card-header">
<div>
<div class="card-title">Today's Usage (PST)</div>
<div class="card-sub" id="opsTotalSub">Loading…</div>
</div>
<div class="pill-row" id="opsTotalPills"></div>
</div>
<div class="ops-model-bar" id="opsModelBar"></div>
</div>
<div class="card-title" style="margin-bottom:12px">Channel Breakdown</div>
<div id="opsChannelList" class="ops-channel-list">
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
</div>
<div class="glass-card" style="padding:16px;margin-top:16px">
<div class="card-header">
<div>
<div class="card-title">All-Time Usage</div>
<div class="card-sub" id="alltimeSub">Loading…</div>
</div>
</div>
<div id="alltimeModels" class="ops-channel-list" style="margin-bottom:12px"></div>
<div class="card-title" style="font-size:.85rem;margin-bottom:8px">Daily Tokens (Last 14 Days)</div>
<canvas id="dailyChart" height="120" style="width:100%;border-radius:8px"></canvas>
<div class="ops-model-legend chart-legend" style="margin-top:6px"></div>
<div class="card-title" style="font-size:.85rem;margin:12px 0 8px">Daily Cost by Model</div>
<canvas id="dailyCostChart" height="120" style="width:100%;border-radius:8px"></canvas>
<div class="ops-model-legend chart-cost-legend" style="margin-top:6px"></div>
<div class="card-title" style="font-size:.85rem;margin:12px 0 8px">Cost Heatmap (Model × Day)</div>
<div id="costHeatmap" class="cost-heatmap"></div>
</div>
<!-- Provider Audit hidden — no useful billing API from Anthropic/Google yet -->
<div class="glass-card" style="padding:16px;margin-top:16px;display:none" id="auditCard">
<div class="card-title">🔍 Provider Audit (Official APIs)</div>
<div class="card-sub" style="margin-bottom:12px">Cross-reference with provider billing data</div>
<div id="auditContent"></div>
</div>
</div>
<!-- ═══ Operations Panel ═══ -->
<div class="tab-panel" id="panel-operations">
<div class="glass-card" style="padding:16px">
<div class="card-title" style="margin-bottom:4px">⚙️ Operations Management</div>
<div class="card-sub" style="margin-bottom:16px">Backup/restore, restart and safe OpenClaw update workflow</div>
<div class="ops-mgmt-grid">
<button class="ops-mgmt-btn" id="btnBackup" onclick="opsAction('backup')">
<span class="btn-icon">💾</span>
<span class="btn-label">Backup + Push Repo</span>
<span class="btn-badge" id="badgeBackup"></span>
</button>
<button class="ops-mgmt-btn" id="btnRestore" onclick="opsAction('restore')">
<span class="btn-icon">📂</span>
<span class="btn-label">Load from Backup</span>
<span class="btn-badge" id="badgeRestore"></span>
</button>
<button class="ops-mgmt-btn" id="btnUpdateOpenClaw" onclick="opsAction('updateOpenClaw')">
<span class="btn-icon">⬆️</span>
<span class="btn-label">Update OpenClaw</span>
<span class="btn-badge" id="badgeUpdateOpenClaw"></span>
</button>
<button class="ops-mgmt-btn" id="btnRestart" onclick="opsAction('restart')">
<span class="btn-icon">🔄</span>
<span class="btn-label">Restart OpenClaw</span>
<span class="btn-badge" id="badgeRestart"></span>
</button>
</div>
<div id="opsMgmtResult" style="margin-top:14px;display:none">
<div class="glass-card" style="padding:12px;background:rgba(0,0,0,0.3);border-color:var(--border)" id="opsMgmtResultInner"></div>
</div>
</div>
<div class="glass-card" style="padding:16px;margin-top:16px">
<div class="card-title" style="margin-bottom:4px">🐶 Watchdog</div>
<div class="card-sub" id="watchdogStatusText">Loading watchdog status…</div>
<div class="ops-ch-meta" id="watchdogStatusMeta" style="margin-top:8px"></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px">
<select id="watchdogWindowSelect" class="form-input" style="max-width:180px;padding:6px 10px;font-size:.75rem" onchange="setWatchdogWindow(this.value)">
<option value="5">Last 5 min</option>
<option value="10">Last 10 min</option>
<option value="15" selected>Last 15 min</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:.75rem;color:var(--text2);cursor:pointer">
<input type="checkbox" id="watchdogCriticalOnly" onchange="setWatchdogCriticalOnly(this.checked)">
Only critical events
</label>
</div>
<div id="watchdogTimeline" style="margin-top:10px"></div>
<div id="watchdogEventsList" class="ops-channel-list" style="margin-top:10px"></div>
</div>
</div>
<!-- ═══ Cron Panel ═══ -->
<!-- ═══ Quality Panel ═══ -->
<div class="tab-panel" id="panel-quality">
<div id="qualityContent"><div class="skeleton skeleton-card"></div></div>
</div>
<!-- ═══ Audit Panel ═══ -->
<div class="tab-panel" id="panel-audit">
<div id="auditContent2"><div class="skeleton skeleton-card"></div></div>
</div>
<!-- ═══ Config Panel ═══ -->
<div class="tab-panel" id="panel-config">
<div id="configContent"><div class="skeleton skeleton-card"></div></div>
</div>
<!-- ═══ Cron Panel ═══ -->
<div class="tab-panel" id="panel-tasks">
<div class="ops-grid">
<div class="glass-card" style="padding:16px">
<div class="card-header">
<div>
<div class="card-title">Vision Ingestion</div>
<div class="card-sub" id="visionStatus">Notion source</div>
</div>
</div>
<div id="visionGrid" class="vision-grid">
<div class="skeleton skeleton-card" style="height:80px"></div>
<div class="skeleton skeleton-card" style="height:80px"></div>
<div class="skeleton skeleton-card" style="height:80px"></div>
<div class="skeleton skeleton-card" style="height:80px"></div>
</div>
</div>
</div>
<!-- Cron Cost Analysis -->
<div class="glass-card" style="padding:16px;margin-top:16px">
<div class="card-title">💰 Cron 成本分析</div>
<div class="card-sub" id="cronCostSummary">Loading...</div>
<div id="cronCostContent" style="margin-top:12px"></div>
</div>
<!-- Fixed vs Variable Trend -->
<div class="glass-card" style="padding:16px;margin-top:12px">
<div class="card-title">📈 固定成本 vs 浮动成本趋势</div>
<div class="card-sub">Cron固定= 每日自动任务 · 交互(浮动)= 人工触发的对话</div>
<canvas id="cronTrendChart" height="160" style="width:100%;margin-top:8px"></canvas>
<div id="cronTrendLegend" style="margin-top:6px;font-size:.72rem;display:flex;gap:16px"></div>
</div>
</div>
<!-- ═══ Documents Panel ═══ -->
<div class="tab-panel" id="panel-documents">
<div class="docs-layout">
<div class="glass-card" style="padding:12px">
<div class="file-sidebar-header">Workspace Files</div>
<div class="file-sidebar" id="fileSidebar"></div>
<div class="file-divider"></div>
<div class="file-sidebar-header">Skills</div>
<div style="padding:0 8px 8px">
<div class="view-toggle" id="viewToggle">
<button class="active" data-view="editor" onclick="setDocView('editor')">Editor</button>
<button data-view="skills" onclick="setDocView('skills')">Skills</button>
</div>
</div>
</div>
<div class="editor-area" id="editorArea">
<div class="editor-toolbar">
<div class="editor-filename" id="editorFilename">Select a file<span>to begin editing</span></div>
<button class="edit-toggle-btn" id="editToggleBtn" onclick="toggleEditMode()" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<span id="editToggleLabel">Edit</span>
</button>
<button class="save-btn" id="saveBtn" onclick="saveFile()" disabled style="display:none">Save</button>
</div>
<div class="md-preview" id="mdPreview">
<div class="md-empty-hint">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<p>Select a file from the sidebar to view its contents</p>
</div>
</div>
<textarea class="editor-textarea" id="editorTextarea" placeholder="Select a file from the sidebar to view and edit its contents…" disabled style="display:none"></textarea>
</div>
<div id="skillsArea" style="display:none">
<input type="text" class="search-input" id="skillSearch" placeholder="Search skills…" style="width:100%;margin-bottom:12px">
<div class="skills-grid" id="skillsGrid">
<div class="skeleton skeleton-card" style="height:120px"></div>
<div class="skeleton skeleton-card" style="height:120px"></div>
<div class="skeleton skeleton-card" style="height:120px"></div>
</div>
</div>
</div>
</div>
<!-- ═══ Connected APIs Panel ═══ -->
<div class="tab-panel" id="panel-apis">
<div class="apis-stats" id="apiStats"></div>
<input type="text" class="search-input" id="apiSearch" placeholder="Search APIs…" style="width:100%;max-width:360px;margin-bottom:16px">
<div class="apis-grid" id="apisGrid">
<div class="skeleton skeleton-card" style="height:180px"></div>
<div class="skeleton skeleton-card" style="height:180px"></div>
<div class="skeleton skeleton-card" style="height:180px"></div>
</div>
</div>
<!-- ═══ Logs Panel ═══ -->
<div class="tab-panel" id="panel-logs">
<div class="logs-toolbar">
<div class="filter-group" id="logDateFilters">
<button class="filter-btn active" data-days="7">7 Days</button>
<button class="filter-btn" data-days="30">30 Days</button>
<button class="filter-btn" data-days="0">All</button>
</div>
<span class="spacer"></span>
<div class="toggle-switch">
<span>Auto-refresh</span>
<div class="toggle-track" id="autoRefreshToggle" onclick="toggleAutoRefresh()"></div>
</div>
</div>
<div class="timeline" id="logsTimeline">
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
<div class="skeleton skeleton-card"></div>
</div>
</div>
</div>
<!-- Task Detail Modal -->
<div class="modal-overlay detail-modal" id="detailModal">
<div class="modal" style="position:relative">
<button class="modal-close" onclick="closeDetailModal()"></button>
<div class="detail-header">
<div class="detail-status-row" id="detailStatusRow"></div>
<div class="detail-title" id="detailTitle"></div>
<div class="detail-meta" id="detailMeta"></div>
</div>
<div class="detail-body" id="detailBody"></div>
<div class="detail-actions" id="detailActions"></div>
</div>
</div>
<!-- Create Task Modal -->
<div class="modal-overlay" id="createModal">
<div class="modal" style="position:relative">
<button class="modal-close" onclick="closeCreateModal()"></button>
<h2>Create Task</h2>
<div class="form-group">
<label class="form-label">Title</label>
<input type="text" class="form-input" id="newTitle" placeholder="Task title…">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="newDesc" placeholder="Short task description…" style="min-height:60px"></textarea>
</div>
<div class="form-group">
<label class="form-label">📝 Content <span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:.72rem;color:var(--text2)">(Markdown supported — use headers, lists, code blocks, tables…)</span></label>
<textarea class="form-textarea" id="newContent" placeholder="# Detailed content here…&#10;&#10;Supports **bold**, *italic*, `code`, lists, tables, and more.&#10;&#10;```js&#10;const example = 'code block';&#10;```" style="min-height:160px"></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label class="form-label">Priority</label>
<select class="form-select" id="newPriority">
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="low">Low</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Assignee</label>
<select class="form-select" id="newAssignee">
<option value="main">Main Agent</option>
<option value="sub-agent">Sub-Agent</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Due Date</label>
<input type="date" class="form-input" id="newDueDate">
</div>
<div class="form-group">
<label class="form-label">📎 Attachments</label>
<div class="create-att-drop-zone" id="createAttDropZone">
<input type="file" id="createAttFileInput" multiple>
<div class="att-drop-zone-icon">📁</div>
<div class="att-drop-zone-text">Drop files here or click to upload</div>
<div class="att-drop-zone-hint">Images, PDFs, documents — up to 20MB each</div>
</div>
<div class="create-att-queue" id="createAttQueue" style="display:none"></div>
</div>
<div class="modal-actions">
<button class="action-btn" onclick="closeCreateModal()">Cancel</button>
<button class="action-btn primary" id="createTaskBtn" onclick="createTask()">Create Task</button>
</div>
</div>
</div>
<!-- Image Lightbox -->
<div class="lightbox-overlay" id="lightboxOverlay" onclick="closeLightbox()">
<button class="lightbox-close" onclick="closeLightbox()"></button>
<img class="lightbox-img" id="lightboxImg" onclick="event.stopPropagation()">
<div class="lightbox-info" onclick="event.stopPropagation()">
<span id="lightboxName"></span>
<span id="lightboxSize" style="font-family:var(--mono);font-size:.72rem"></span>
<a class="lightbox-download" id="lightboxDownload" target="_blank">⬇ Download</a>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
/* ═══════════════════════════════════════════════
Agent Dashboard — Vanilla JS
═══════════════════════════════════════════════ */
// ─── Auth ───
function getToken() {
const p = new URLSearchParams(location.search).get('token');
if (p) {
try {
const clean = `${location.origin}${location.pathname}${location.hash || ''}`;
history.replaceState(null, '', clean);
} catch {}
return p;
}
try { const s = JSON.parse(localStorage.getItem('openclaw.control.settings.v1') || '{}'); if (s.token) return s.token; } catch(e) {}
return localStorage.getItem('openclaw_token') || '';
}
const TOKEN = getToken();
const API = (location.port === '18789' || location.port === '18790')
? `${location.protocol}//${location.hostname}:18790`
: '';
async function apiFetch(path, opts = {}) {
const url = `${API}${path}`;
const authHeaders = TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {};
const res = await fetch(url, {
...opts,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...authHeaders, ...(opts.headers || {}) }
});
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
const ct = res.headers.get('content-type') || '';
if (ct.includes('json')) return res.json();
return res.text();
}
// ─── Connection Check ───
async function checkConnection() {
const dot = document.getElementById('statusDot');
const txt = document.getElementById('statusText');
try {
await apiFetch('/health');
dot.className = 'status-dot ok';
txt.textContent = 'Connected';
} catch(e) {
dot.className = 'status-dot err';
txt.textContent = 'Disconnected';
}
}
// ─── Tabs ───
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`panel-${btn.dataset.tab}`).classList.add('active');
if (btn.dataset.tab === 'sessions') loadSessions();
if (btn.dataset.tab === 'tasks') { loadCronEnhanced(); loadTasks(true); loadCronCosts(); }
if (btn.dataset.tab === 'ops') { loadOpsChannels(); loadOpsAlltime(); loadOpsAudit(); }
if (btn.dataset.tab === 'operations') loadOperationsStatus();
if (btn.dataset.tab === 'quality') loadQuality();
if (btn.dataset.tab === 'audit') loadAudit();
if (btn.dataset.tab === 'config') loadConfig();
});
});
let _watchdogCache = null;
let _watchdogWindowMins = 15;
let _watchdogCriticalOnly = false;
function focusOperationsTab() {
const btn = document.querySelector('.tab-btn[data-tab="operations"]');
if (btn) btn.click();
}
function setWatchdogWindow(v) {
const n = Number(v);
if (Number.isFinite(n)) _watchdogWindowMins = Math.min(Math.max(n, 5), 15);
pollWatchdogStatus();
}
function setWatchdogCriticalOnly(checked) {
_watchdogCriticalOnly = !!checked;
pollWatchdogStatus();
}
async function pollWatchdogStatus() {
try {
const data = await apiFetch(`/ops/watchdog?limit=12&windowMinutes=${_watchdogWindowMins}&criticalOnly=${_watchdogCriticalOnly ? '1' : '0'}`);
_watchdogCache = data;
renderWatchdogStatus(data);
} catch (e) {
renderWatchdogStatus({
effectiveStatus: 'unknown',
runtime: { running: false, checkedAt: new Date().toISOString() },
watchdog: null,
recentEvents: [],
timeline: { points: [], windowMinutes: _watchdogWindowMins, stepSeconds: 30 },
error: e.message,
});
}
}
function renderWatchdogTimeline(timeline) {
const el = document.getElementById('watchdogTimeline');
if (!el) return;
const points = Array.isArray(timeline?.points) ? timeline.points : [];
const windowMinutes = timeline?.windowMinutes || _watchdogWindowMins;
if (!points.length) {
el.innerHTML = '<div class="ops-ch-meta">No timeline data.</div>';
return;
}
const bars = points.map(p => {
const color = p.status === 'down' ? 'var(--red)' : (p.status === 'healthy' ? 'var(--green)' : 'var(--yellow)');
const ts = new Date(p.ts || Date.now()).toLocaleTimeString();
return `<div title="${ts} · ${p.status}" style="height:14px;flex:1;background:${color};opacity:.9;border-radius:2px"></div>`;
}).join('');
el.innerHTML = `
<div class="ops-ch-meta" style="margin-bottom:6px">Status timeline (${windowMinutes} min): green=healthy · red=down</div>
<div style="display:flex;gap:2px;align-items:center">${bars}</div>
`;
}
function renderWatchdogStatus(data) {
const statusEl = document.getElementById('watchdogStatusText');
const metaEl = document.getElementById('watchdogStatusMeta');
const eventsEl = document.getElementById('watchdogEventsList');
const globalAlert = document.getElementById('watchdogGlobalAlert');
const globalText = document.getElementById('watchdogGlobalText');
if (!statusEl || !metaEl || !eventsEl || !globalAlert || !globalText) return;
const effective = data?.effectiveStatus || 'unknown';
const runtimeRunning = !!data?.runtime?.running;
const wd = data?.watchdog || {};
const cfg = data?.configGuard || {};
const failures = wd?.consecutive_failures ?? '—';
const reason = wd?.last_reason || 'unknown';
const updatedAt = wd?.updated_at || data?.runtime?.checkedAt || '';
const configGuardStatus = cfg?.status || 'unknown';
const configDriftDetected = !!cfg?.driftDetected;
const suspectedConfigDrift = !!cfg?.suspectedConfigDrift;
const recent = Array.isArray(data?.recentEvents) ? data.recentEvents : [];
const timeline = data?.timeline || { points: [] };
if (effective === 'down') {
const driftMsg = suspectedConfigDrift
? ' Probable root cause: config drift (`openclaw.json` vs `.good`).'
: '';
statusEl.innerHTML = `<span style="color:var(--red)">OpenClaw is DOWN</span> — watchdog detected runtime outage.${driftMsg}`;
globalAlert.style.display = 'block';
globalText.textContent = `OpenClaw down: ${reason} · failures=${failures}${suspectedConfigDrift ? ' · config_drift=YES' : ''}`;
} else if (effective === 'healthy') {
statusEl.innerHTML = '<span style="color:var(--green)">OpenClaw is healthy</span> — watchdog is monitoring.';
globalAlert.style.display = 'none';
} else {
const driftMsg = configDriftDetected
? ' Config drift is present; investigate before it escalates.'
: '';
statusEl.innerHTML = `<span style="color:var(--yellow)">Watchdog degraded / unknown</span> — check events below.${driftMsg}`;
globalAlert.style.display = 'block';
globalText.textContent = `Watchdog warning: ${reason} · failures=${failures}${configDriftDetected ? ' · config_drift=YES' : ''}`;
}
metaEl.textContent = `runtime=${runtimeRunning ? 'running' : 'stopped'} · watchdog=${wd.status || 'unknown'} · failures=${failures} · last_reason=${reason} · config_guard=${configGuardStatus} · config_drift=${configDriftDetected ? 'yes' : 'no'} · updated=${updatedAt ? new Date(updatedAt).toLocaleString() : '—'} · filter=${_watchdogCriticalOnly ? 'critical only' : 'all'}`;
renderWatchdogTimeline(timeline);
if (!recent.length) {
eventsEl.innerHTML = `<div class="ops-ch-meta">No watchdog events for selected filter in last ${_watchdogWindowMins} minutes.</div>`;
return;
}
eventsEl.innerHTML = recent.map(ev => {
const sev = ev.severity || 'info';
const color = sev === 'critical' ? 'var(--red)' : (sev === 'warning' ? 'var(--yellow)' : 'var(--green)');
const detailsHtml = escHtml(ev.details || '').replace(/\n/g, '<br>');
return `<div class="ops-channel-card" style="padding:8px 10px">
<div class="ops-ch-left">
<div class="ops-ch-name" style="font-size:.8rem;color:${color}">${escHtml(ev.event || 'event')} · ${escHtml(ev.reason || 'n/a')}</div>
<div class="ops-ch-meta">${detailsHtml}</div>
</div>
<div class="ops-ch-right"><div class="ops-ch-cost">${new Date(ev.time || Date.now()).toLocaleTimeString()}</div></div>
</div>`;
}).join('');
}
function loadOperationsStatus() {
if (_watchdogCache) renderWatchdogStatus(_watchdogCache);
pollWatchdogStatus();
}
function refreshCurrentTab() {
const active = document.querySelector('.tab-btn.active');
if (active) active.click();
checkConnection();
loadAgentMonitor();
}
// ─── Toast ───
function toast(message, type = 'info') {
const c = document.getElementById('toastContainer');
const icons = { success: '✓', error: '✕', info: '' };
const t = document.createElement('div');
t.className = `toast ${type}`;
t.innerHTML = `<span class="toast-icon">${icons[type] || icons.info}</span><span>${message}</span>`;
c.appendChild(t);
setTimeout(() => { if (t.parentNode) t.remove(); }, 3500);
}
// ─── Simple Markdown ───
function renderMarkdown(text) {
if (!text) return '';
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
return `<p>${html}</p>`;
}
// ─── Full Markdown (using marked.js library) ───
function renderFullMarkdown(text) {
if (!text) return '';
try {
if (typeof marked !== 'undefined' && marked.parse) {
return marked.parse(text, { breaks: true, gfm: true });
}
} catch(e) { /* fallback */ }
return renderMarkdown(text);
}
// ═══ TASKS ═══
let allTasks = [];
let currentFilter = 'all';
let expandedTaskId = null;
let _tasksHash = '';
let _livePollingId = null;
const LIVE_POLL_MS = 15000; // was 3000, reduced to avoid excessive polling // poll every 3 seconds
function _hashTasks(tasks) {
// Fast hash: JSON of id+status+updatedAt+notes.length for each task
return tasks.map(t => `${t.id}|${t.status}|${t.updatedAt}|${(t.notes||[]).length}`).join(';');
}
async function loadTasks(force) {
const list = document.getElementById('taskList');
try {
const data = await apiFetch('/tasks');
const tasks = Array.isArray(data) ? data : (data.tasks || []);
const newHash = _hashTasks(tasks);
// Skip re-render if nothing changed (unless forced)
if (!force && newHash === _tasksHash) return;
_tasksHash = newHash;
allTasks = tasks;
document.getElementById('taskCount').textContent = allTasks.length;
if (taskView === 'kanban') { setTaskView('kanban'); } else { setTaskView('list'); }
// If detail modal is open and user isn't editing content, refresh it
if (detailTaskId && !isContentEditing) {
const updated = allTasks.find(t => t.id === detailTaskId);
if (updated) openDetailModal(detailTaskId);
}
// Flash the live indicator on data change
_flashLiveIndicator();
} catch(e) {
list.innerHTML = `<div class="empty-state"><svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" fill="none" stroke="currentColor" stroke-width="2"/><path d="M28 28l24 24M52 28L28 52" stroke="currentColor" stroke-width="2"/></svg><h3>Unable to Load Tasks</h3><p>${e.message}</p><button class="action-btn primary" onclick="loadTasks(true)" style="margin:0 auto">Retry</button></div>`;
}
}
function _flashLiveIndicator() {
const el = document.getElementById('liveIndicator');
if (!el) return;
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 600);
}
function startLivePolling() {
if (_livePollingId) return;
_livePollingId = setInterval(() => loadTasks(false), LIVE_POLL_MS);
const el = document.getElementById('liveIndicator');
if (el) el.classList.add('active');
}
function stopLivePolling() {
if (_livePollingId) { clearInterval(_livePollingId); _livePollingId = null; }
const el = document.getElementById('liveIndicator');
if (el) el.classList.remove('active');
}
function toggleLivePolling() {
if (_livePollingId) { stopLivePolling(); toast('Live updates paused', 'info'); }
else { startLivePolling(); toast('Live updates enabled', 'info'); }
}
function renderTasks() {
const list = document.getElementById('taskList');
const search = (document.getElementById('taskSearch').value || '').toLowerCase();
let filtered = allTasks;
if (currentFilter !== 'all') filtered = filtered.filter(t => (t.status || 'new') === currentFilter);
if (search) filtered = filtered.filter(t => (t.title || '').toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
if (filtered.length === 0) {
list.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 80 80"><rect x="16" y="12" width="48" height="56" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="28" y1="28" x2="52" y2="28" stroke="currentColor" stroke-width="1.5"/><line x1="28" y1="38" x2="48" y2="38" stroke="currentColor" stroke-width="1.5"/><line x1="28" y1="48" x2="44" y2="48" stroke="currentColor" stroke-width="1.5"/><circle cx="24" cy="28" r="2" fill="currentColor"/><circle cx="24" cy="38" r="2" fill="currentColor"/><circle cx="24" cy="48" r="2" fill="currentColor"/></svg>
<h3>${currentFilter !== 'all' ? 'No matching tasks' : 'No tasks yet'}</h3>
<p>${currentFilter !== 'all' ? 'Try a different filter or create a new task.' : 'Create your first task to get started with agent task management.'}</p>
<button class="create-btn" onclick="openCreateModal()" style="margin:0 auto">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Task
</button>
</div>`;
return;
}
list.innerHTML = filtered.map((task, i) => {
const status = task.status || 'new';
const priority = task.priority || 'medium';
const isExpanded = expandedTaskId === task.id;
const notes = task.notes || [];
const date = task.createdAt ? new Date(task.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
const dueDate = task.dueDate ? new Date(task.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
return `<div class="glass-card task-card status-${status} ${isExpanded ? 'expanded' : ''}" onclick="toggleTask('${task.id}')" style="animation:cardIn .4s ease backwards;animation-delay:${i * 0.05}s">
<div class="task-header">
${selectionMode ? `<div class="task-checkbox ${selectedTaskIds.has(task.id) ? 'checked' : ''}" data-task-id="${task.id}" onclick="toggleTaskSelection('${task.id}', event)"></div>` : ''}
<span class="task-title">${escHtml(task.title || 'Untitled')}</span>
<span class="badge badge-${status}">${statusLabel(status)}</span>
<span class="badge badge-priority ${priority}">${priority}</span>
</div>
<div class="task-meta">
${task.assignee ? `<span>👤 ${escHtml(task.assignee)}</span>` : ''}
${date ? `<span>📅 ${date}</span>` : ''}
${dueDate ? `<span>⏰ Due ${dueDate}</span>` : ''}
${notes.length ? `<span>📝 ${notes.length} note${notes.length > 1 ? 's' : ''}</span>` : ''}
${task.content ? '<span>📄 Content</span>' : ''}
</div>
<div class="task-body" onclick="event.stopPropagation()">
${task.description ? `<div class="task-description">${renderMarkdown(task.description)}</div>` : ''}
${task.content ? `<div class="task-content-preview" onclick="event.stopPropagation();this.classList.toggle('expanded')">
<div class="task-content-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> Content</div>
<div class="task-content-md">${renderFullMarkdown(task.content)}</div>
</div>` : ''}
<div class="task-actions">
<button class="spawn-btn" onclick="spawnSingleTask('${task.id}')">⚡ Spawn</button>
${status !== 'in-progress' ? `<button class="action-btn primary" onclick="updateTaskStatus('${task.id}','in-progress')">▶ In Progress</button>` : ''}
${status !== 'done' ? `<button class="action-btn" onclick="updateTaskStatus('${task.id}','done')" style="border-color:rgba(45,212,160,0.3);color:var(--green)">✓ Done</button>` : ''}
${status !== 'failed' ? `<button class="action-btn danger" onclick="updateTaskStatus('${task.id}','failed')">✕ Failed</button>` : ''}
${status !== 'new' ? `<button class="action-btn" onclick="updateTaskStatus('${task.id}','new')">↩ Reset</button>` : ''}
</div>
<div class="notes-section">
<div class="notes-title">Notes</div>
<div class="notes-list">
${notes.length === 0 ? '<div style="font-size:.8rem;color:var(--text2);padding:4px 0 4px 16px;border-left:2px solid var(--border)">No notes yet</div>' : notes.map(n => `
<div class="note-item">
<span class="note-time">${n.createdAt ? new Date(n.createdAt).toLocaleString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : ''}</span>
<span class="note-text">${escHtml(n.text || n.content || '')}</span>
</div>
`).join('')}
</div>
<div class="add-note-row">
<input type="text" class="note-input" id="note-${task.id}" placeholder="Add a note…" onkeydown="if(event.key==='Enter')addNote('${task.id}')">
<button class="action-btn" onclick="addNote('${task.id}')">Add</button>
</div>
</div>
</div>
</div>`;
}).join('');
}
function statusLabel(s) {
return { 'new': 'New', 'in-progress': 'In Progress', 'done': 'Done', 'failed': 'Failed' }[s] || s;
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function toggleTask(id) {
openDetailModal(id);
}
async function updateTaskStatus(id, status) {
try {
await apiFetch(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) });
toast(`Task updated to ${statusLabel(status)}`, 'success');
loadTasks(true);
} catch(e) { toast(`Failed: ${e.message}`, 'error'); }
}
async function addNote(taskId) {
const input = document.getElementById(`note-${taskId}`);
const text = input?.value?.trim();
if (!text) return;
try {
await apiFetch(`/tasks/${taskId}/notes`, { method: 'POST', body: JSON.stringify({ text }) });
input.value = '';
toast('Note added', 'success');
loadTasks(true);
} catch(e) { toast(`Failed: ${e.message}`, 'error'); }
}
// Filters (guarded for Ops view)
const _statusFilters = document.getElementById('statusFilters');
if (_statusFilters) {
_statusFilters.addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
document.querySelectorAll('#statusFilters .filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
renderTasks();
});
}
const _taskSearch = document.getElementById('taskSearch');
if (_taskSearch) {
_taskSearch.addEventListener('input', () => {
if (taskView === 'kanban') renderKanban();
else renderTasks();
});
}
// Create Modal — Pending Attachments
let pendingCreateFiles = [];
function openCreateModal() {
document.getElementById('createModal').classList.add('show');
document.getElementById('newTitle').focus();
setupCreateDropZone();
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('show');
['newTitle', 'newDesc', 'newContent', 'newDueDate'].forEach(id => document.getElementById(id).value = '');
document.getElementById('newPriority').value = 'medium';
document.getElementById('newAssignee').value = 'main';
pendingCreateFiles = [];
renderCreateAttQueue();
}
function setupCreateDropZone() {
const zone = document.getElementById('createAttDropZone');
const fileInput = document.getElementById('createAttFileInput');
if (!zone || !fileInput) return;
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
zone.ondragleave = () => zone.classList.remove('drag-over');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
for (let i = 0; i < e.dataTransfer.files.length; i++) {
addPendingFile(e.dataTransfer.files[i]);
}
};
fileInput.onchange = () => {
for (let i = 0; i < fileInput.files.length; i++) {
addPendingFile(fileInput.files[i]);
}
fileInput.value = '';
};
}
function addPendingFile(file) {
if (file.size > 20 * 1024 * 1024) {
toast(`${file.name} is too large (max 20MB)`, 'error');
return;
}
// Prevent duplicates by name+size
if (pendingCreateFiles.some(f => f.name === file.name && f.size === file.size)) return;
pendingCreateFiles.push(file);
renderCreateAttQueue();
toast(`📎 ${file.name} queued`, 'success');
}
function removePendingFile(index) {
pendingCreateFiles.splice(index, 1);
renderCreateAttQueue();
}
function renderCreateAttQueue() {
const container = document.getElementById('createAttQueue');
if (!container) return;
if (pendingCreateFiles.length === 0) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
container.innerHTML = pendingCreateFiles.map((file, i) => {
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase();
const isImage = ['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp'].includes(ext);
const icon = isImage ? '🖼️' : (typeof getFileIcon === 'function' ? getFileIcon(ext) : '📎');
const thumbId = 'cthumb-' + i;
if (isImage) {
// Generate thumbnail async
const reader = new FileReader();
reader.onload = () => {
const el = document.getElementById(thumbId);
if (el) { el.src = reader.result; el.style.display = 'block'; }
const iconEl = document.getElementById(thumbId + '-icon');
if (iconEl) iconEl.style.display = 'none';
};
reader.readAsDataURL(file);
}
return `<div class="create-att-item">
<span class="cai-icon" id="${thumbId}-icon">${icon}</span>
<img class="cai-thumb" id="${thumbId}" style="display:none" alt="">
<span class="cai-name">${escHtml(file.name)}</span>
<span class="cai-size">${formatSize(file.size)}</span>
<button class="cai-remove" onclick="removePendingFile(${i})" title="Remove">✕</button>
</div>`;
}).join('');
}
async function createTask() {
const title = document.getElementById('newTitle').value.trim();
if (!title) { toast('Title is required', 'error'); return; }
const btn = document.getElementById('createTaskBtn');
const hasFiles = pendingCreateFiles.length > 0;
btn.disabled = true;
btn.textContent = hasFiles ? 'Creating & Uploading…' : 'Creating…';
const payload = {
title,
description: document.getElementById('newDesc').value.trim(),
content: document.getElementById('newContent').value.trim(),
priority: document.getElementById('newPriority').value,
assignee: document.getElementById('newAssignee').value,
status: 'new'
};
const due = document.getElementById('newDueDate').value;
if (due) payload.dueDate = due;
try {
const task = await apiFetch('/tasks', { method: 'POST', body: JSON.stringify(payload) });
// Upload pending attachments
if (hasFiles) {
let uploaded = 0;
for (const file of pendingCreateFiles) {
try {
btn.textContent = `Uploading ${++uploaded}/${pendingCreateFiles.length}`;
const base64 = await fileToBase64(file);
await apiFetch(`/tasks/${task.id}/attachments`, {
method: 'POST',
body: JSON.stringify({ filename: file.name, data: base64, source: 'user' }),
});
} catch (e) {
toast(`Failed to upload ${file.name}: ${e.message}`, 'error');
}
}
toast(`Task created with ${uploaded} attachment${uploaded !== 1 ? 's' : ''}!`, 'success');
} else {
toast('Task created!', 'success');
}
closeCreateModal();
loadTasks(true);
} catch(e) {
toast(`Failed: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Create Task';
}
}
// Close modal on overlay click
document.getElementById('createModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeCreateModal();
});
// ═══ KANBAN VIEW ═══
let taskView = localStorage.getItem('taskView') || 'list';
function setTaskView(view) {
taskView = view;
localStorage.setItem('taskView', view);
document.querySelectorAll('#taskViewToggle button').forEach(b => b.classList.toggle('active', b.dataset.view === view));
const listEl = document.getElementById('taskList');
const kanbanEl = document.getElementById('kanbanBoard');
const filtersEl = document.getElementById('statusFilters');
if (view === 'kanban') {
listEl.style.display = 'none';
kanbanEl.style.display = '';
filtersEl.style.display = 'none';
renderKanban();
} else {
listEl.style.display = '';
kanbanEl.style.display = 'none';
filtersEl.style.display = '';
renderTasks();
}
}
const KANBAN_COLUMNS = [
{ status: 'new', label: 'New' },
{ status: 'in-progress', label: 'In Progress' },
{ status: 'done', label: 'Done' },
{ status: 'failed', label: 'Failed' }
];
function renderKanban() {
const board = document.getElementById('kanbanBoard');
const search = (document.getElementById('taskSearch').value || '').toLowerCase();
let filtered = allTasks;
if (search) filtered = filtered.filter(t => (t.title || '').toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
board.innerHTML = KANBAN_COLUMNS.map(col => {
const colTasks = filtered.filter(t => (t.status || 'new') === col.status);
return `<div class="kanban-column" data-status="${col.status}"
ondragover="kanbanDragOver(event)" ondragleave="kanbanDragLeave(event)" ondrop="kanbanDrop(event)">
<div class="kanban-col-header">
<div class="kanban-col-title">
<span class="col-dot ${col.status}"></span>
${col.label}
</div>
<span class="kanban-col-count">${colTasks.length}</span>
</div>
<div class="kanban-col-body${colTasks.length === 0 ? ' empty-drop' : ''}">
${colTasks.length === 0
? '<div class="drop-hint">Drop tasks here</div>'
: colTasks.map(task => renderKanbanCard(task)).join('')}
</div>
</div>`;
}).join('');
// Add mobile horizontal scroll class
if (window.innerWidth <= 768) board.classList.add('horizontal-scroll');
else board.classList.remove('horizontal-scroll');
}
function renderKanbanCard(task) {
const status = task.status || 'new';
const priority = task.priority || 'medium';
return `<div class="kanban-card status-${status}" draggable="true"
data-task-id="${task.id}"
ondragstart="kanbanDragStart(event)" ondragend="kanbanDragEnd(event)"
onclick="kanbanCardClick('${task.id}')">
<div class="kanban-card-title">${escHtml(task.title || 'Untitled')}</div>
<div class="kanban-card-footer">
<span class="badge badge-priority ${priority}">${priority}</span>
${task.notes && task.notes.length ? `<span style="font-size:.68rem;color:var(--text2)">📝${task.notes.length}</span>` : ''}
${task.assignee ? `<span class="kanban-card-assignee">👤 ${escHtml(task.assignee)}</span>` : ''}
</div>
</div>`;
}
// ─── Drag & Drop ───
let draggedTaskId = null;
function kanbanDragStart(e) {
draggedTaskId = e.target.dataset.taskId;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', draggedTaskId);
}
function kanbanDragEnd(e) {
e.target.classList.remove('dragging');
draggedTaskId = null;
document.querySelectorAll('.kanban-column').forEach(c => c.classList.remove('drag-over'));
}
function kanbanDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const col = e.target.closest('.kanban-column');
if (col) col.classList.add('drag-over');
}
function kanbanDragLeave(e) {
const col = e.target.closest('.kanban-column');
if (col && !col.contains(e.relatedTarget)) col.classList.remove('drag-over');
}
async function kanbanDrop(e) {
e.preventDefault();
const col = e.target.closest('.kanban-column');
if (!col) return;
col.classList.remove('drag-over');
const taskId = e.dataTransfer.getData('text/plain') || draggedTaskId;
const newStatus = col.dataset.status;
if (!taskId || !newStatus) return;
const task = allTasks.find(t => t.id === taskId);
if (!task || (task.status || 'new') === newStatus) return;
try {
await apiFetch(`/tasks/${taskId}`, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) });
task.status = newStatus;
toast(`Task moved to ${statusLabel(newStatus)}`, 'success');
renderKanban();
} catch(err) {
toast(`Failed: ${err.message}`, 'error');
}
}
function kanbanCardClick(taskId) {
openDetailModal(taskId);
}
// ═══ TASK DETAIL MODAL ═══
let detailTaskId = null;
function openDetailModal(taskId) {
const task = allTasks.find(t => t.id === taskId);
if (!task) return;
detailTaskId = taskId;
const status = task.status || 'new';
const priority = task.priority || 'medium';
const created = task.createdAt ? new Date(task.createdAt) : null;
const updated = task.updatedAt ? new Date(task.updatedAt) : null;
const due = task.dueDate ? new Date(task.dueDate) : null;
const notes = task.notes || [];
// Separate agent output notes from status/regular notes
const statusNotes = [];
const outputNotes = [];
const regularNotes = [];
notes.forEach(n => {
const txt = n.text || n.content || '';
if (txt.startsWith('Status changed')) statusNotes.push(n);
else if (txt.length > 150) outputNotes.push(n);
else regularNotes.push(n);
});
// Header
document.getElementById('detailStatusRow').innerHTML = `
<span class="badge badge-${status}">${statusLabel(status)}</span>
<span class="badge badge-priority ${priority}">${priority}</span>
${task.source ? `<span class="badge" style="background:rgba(139,148,158,0.1);color:var(--text2);border:1px solid var(--border)">${escHtml(task.source)}</span>` : ''}
`;
document.getElementById('detailTitle').textContent = task.title || 'Untitled';
document.getElementById('detailMeta').innerHTML = `
${task.assignee ? `<span>👤 ${escHtml(task.assignee)}</span>` : ''}
${created ? `<span>📅 Created ${created.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>` : ''}
${updated ? `<span>🔄 Updated ${timeAgo(updated)}</span>` : ''}
${due ? `<span>⏰ Due ${due.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>` : ''}
`;
// Body
let bodyHtml = '';
// Description section
if (task.description) {
bodyHtml += `
<div class="detail-section">
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
Description
</div>
<div class="detail-section-content">
<div class="detail-description">${renderMarkdown(task.description)}</div>
</div>
</div>`;
}
// Content section (rich markdown field)
bodyHtml += `
<div class="detail-content-section">
<div class="detail-content-area" id="detailContentArea">
<div class="detail-content-header">
<span class="content-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Content <span style="font-weight:400;font-size:.68rem;color:var(--text2);letter-spacing:0;text-transform:none;margin-left:4px">Markdown</span>
</span>
<div class="detail-content-actions">
<button class="content-edit-btn" id="contentEditBtn" onclick="toggleContentEdit()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="content-save-btn" id="contentSaveBtn" onclick="saveTaskContent()" style="display:none">Save</button>
</div>
</div>
<div class="detail-content-md" id="detailContentMd">
${task.content ? renderFullMarkdown(task.content) : `<div class="detail-content-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
No content yet — click Edit to add markdown content
</div>`}
</div>
<textarea class="detail-content-textarea" id="detailContentTextarea" placeholder="# Write your content here…&#10;&#10;Supports **bold**, *italic*, \`code\`, lists, tables, and more." style="display:none">${escHtml(task.content || '')}</textarea>
</div>
</div>`;
// Attachments section
bodyHtml += `
<div class="detail-section attachments-section">
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
Attachments <span style="font-weight:400;font-size:.7rem;color:var(--text2)" id="attachmentsCount">…</span>
</div>
<div class="detail-section-content">
<div class="attachments-grid" id="attachmentsGrid">
<div style="grid-column:1/-1;text-align:center;padding:12px;color:var(--text2);font-size:.82rem"><div class="spinner" style="margin:0 auto 8px"></div>Loading…</div>
</div>
<div class="att-drop-zone" id="attDropZone">
<input type="file" id="attFileInput" multiple>
<div class="att-drop-zone-icon">📁</div>
<div class="att-drop-zone-text">Drop files here or click to upload</div>
<div class="att-drop-zone-hint">Images, PDFs, documents — up to 20MB each</div>
</div>
<div class="att-upload-progress" id="attUploadProgress">
<div class="att-upload-bar"><div class="att-upload-bar-fill" id="attUploadBarFill"></div></div>
<div class="att-upload-status" id="attUploadStatus"></div>
</div>
</div>
</div>`;
// Agent Output section (long notes = agent results)
if (outputNotes.length > 0) {
bodyHtml += `
<div class="detail-section">
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
Agent Output <span style="font-weight:400;font-size:.7rem;color:var(--accent2)">${outputNotes.length} result${outputNotes.length > 1 ? 's' : ''}</span>
</div>
<div class="detail-section-content">
${outputNotes.map((n, i) => {
const txt = n.text || n.content || '';
const time = n.timestamp || n.createdAt;
const escaped = txt.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '');
return `<div class="detail-output" style="${i > 0 ? 'margin-top:10px' : ''}">
<div class="detail-output-header">
<span>🤖 Output${time ? ' · ' + new Date(time).toLocaleString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : ''}</span>
<button class="detail-output-copy" onclick="copyOutput(this, '${escaped}')">Copy</button>
</div>
<div class="detail-output-body">${renderMarkdown(txt)}</div>
</div>`;
}).join('')}
</div>
</div>`;
}
// Activity / Comments section (regular + status notes combined)
const activityNotes = [...regularNotes, ...statusNotes].sort((a, b) => {
const ta = new Date(a.timestamp || a.createdAt || 0).getTime();
const tb = new Date(b.timestamp || b.createdAt || 0).getTime();
return tb - ta;
});
bodyHtml += `
<div class="detail-section">
<div class="detail-section-title" onclick="this.classList.toggle('collapsed')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
Activity & Comments <span style="font-weight:400;font-size:.7rem;color:var(--text2)">${activityNotes.length}</span>
</div>
<div class="detail-section-content">
<div class="detail-notes" id="detailNotes">
${activityNotes.length === 0 ? '<div style="font-size:.82rem;color:var(--text2);padding:12px 0">No activity yet</div>' : activityNotes.map(n => {
const txt = n.text || n.content || '';
const time = n.timestamp || n.createdAt;
const isStatus = txt.startsWith('Status changed');
const isLong = txt.length > 200;
const noteId = 'dn-' + Math.random().toString(36).slice(2, 8);
return `<div class="detail-note ${isStatus ? 'is-status' : ''}">
<div class="detail-note-time">${time ? new Date(time).toLocaleString('en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : ''}</div>
<div class="detail-note-text ${isLong ? 'truncated' : ''}" id="${noteId}">${escHtml(txt)}</div>
${isLong ? `<span class="detail-note-expand" onclick="toggleNoteExpand('${noteId}', this)">Show more</span>` : ''}
</div>`;
}).join('')}
</div>
<div class="detail-add-note">
<input type="text" class="detail-note-input" id="detailNoteInput" placeholder="Add a comment…" onkeydown="if(event.key==='Enter')addDetailNote()">
<button class="action-btn primary" onclick="addDetailNote()">Add</button>
</div>
</div>
</div>`;
document.getElementById('detailBody').innerHTML = bodyHtml;
// Actions footer
document.getElementById('detailActions').innerHTML = `
<button class="spawn-btn" onclick="spawnSingleTask('${taskId}')">⚡ Run as Sub-Agent</button>
${status !== 'in-progress' ? '<button class="action-btn primary" onclick="detailUpdateStatus(\'in-progress\')">▶ In Progress</button>' : ''}
${status !== 'done' ? '<button class="action-btn" style="border-color:rgba(45,212,160,0.3);color:var(--green)" onclick="detailUpdateStatus(\'done\')">✓ Done</button>' : ''}
${status !== 'failed' ? '<button class="action-btn danger" onclick="detailUpdateStatus(\'failed\')">✕ Failed</button>' : ''}
${status !== 'new' ? '<button class="action-btn" onclick="detailUpdateStatus(\'new\')">↩ Reset</button>' : ''}
<span style="flex:1"></span>
<button class="action-btn danger" onclick="if(confirm('Delete this task?'))deleteTask('${taskId}')">🗑 Delete</button>
`;
document.getElementById('detailModal').classList.add('show');
// Load attachments and setup drop zone
loadAttachments(taskId);
setTimeout(() => setupDropZone(taskId), 100);
}
function closeDetailModal() {
document.getElementById('detailModal').classList.remove('show');
detailTaskId = null;
isContentEditing = false;
}
async function detailUpdateStatus(newStatus) {
if (!detailTaskId) return;
try {
await apiFetch('/tasks/' + detailTaskId, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) });
toast('Task updated to ' + statusLabel(newStatus), 'success');
await loadTasks(true);
// Detail modal will be auto-refreshed by loadTasks when detailTaskId is set
} catch(e) { toast('Failed: ' + e.message, 'error'); }
}
async function addDetailNote() {
if (!detailTaskId) return;
const input = document.getElementById('detailNoteInput');
const text = input?.value?.trim();
if (!text) return;
try {
await apiFetch('/tasks/' + detailTaskId + '/notes', { method: 'POST', body: JSON.stringify({ text }) });
input.value = '';
toast('Comment added', 'success');
await loadTasks(true);
setTimeout(() => {
const body = document.getElementById('detailBody');
if (body) body.scrollTop = body.scrollHeight;
}, 100);
} catch(e) { toast('Failed: ' + e.message, 'error'); }
}
async function deleteTask(taskId) {
try {
await apiFetch('/tasks/' + taskId, { method: 'DELETE' });
toast('Task deleted', 'success');
closeDetailModal();
loadTasks(true);
} catch(e) { toast('Failed: ' + e.message, 'error'); }
}
function toggleNoteExpand(noteId, btn) {
const el = document.getElementById(noteId);
if (!el) return;
el.classList.toggle('truncated');
btn.textContent = el.classList.contains('truncated') ? 'Show more' : 'Show less';
}
function copyOutput(btn, text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
}).catch(() => toast('Copy failed', 'error'));
}
// ─── Content Edit/Save ───
let isContentEditing = false;
function toggleContentEdit() {
isContentEditing = !isContentEditing;
const mdView = document.getElementById('detailContentMd');
const textarea = document.getElementById('detailContentTextarea');
const editBtn = document.getElementById('contentEditBtn');
const saveBtn = document.getElementById('contentSaveBtn');
if (isContentEditing) {
mdView.style.display = 'none';
textarea.style.display = '';
textarea.focus();
editBtn.classList.add('active');
editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Preview';
saveBtn.style.display = '';
} else {
// Update preview with current textarea content
const content = textarea.value;
mdView.innerHTML = content.trim()
? renderFullMarkdown(content)
: '<div class="detail-content-empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>No content yet — click Edit to add markdown content</div>';
mdView.style.display = '';
textarea.style.display = 'none';
editBtn.classList.remove('active');
editBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px;margin-right:3px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Edit';
saveBtn.style.display = 'none';
}
}
async function saveTaskContent() {
if (!detailTaskId) return;
const textarea = document.getElementById('detailContentTextarea');
const content = textarea.value;
const saveBtn = document.getElementById('contentSaveBtn');
saveBtn.textContent = 'Saving…';
saveBtn.disabled = true;
try {
await apiFetch('/tasks/' + detailTaskId, { method: 'PATCH', body: JSON.stringify({ content }) });
toast('Content saved!', 'success');
// Update local task data
const task = allTasks.find(t => t.id === detailTaskId);
if (task) task.content = content;
// Switch back to preview
if (isContentEditing) toggleContentEdit();
// Refresh the list/kanban behind the modal
if (taskView === 'kanban') renderKanban(); else renderTasks();
} catch(e) {
toast('Save failed: ' + e.message, 'error');
} finally {
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
}
}
function timeAgo(date) {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
document.getElementById('detailModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeDetailModal();
});
// Restore view on load
if (taskView === 'kanban') {
document.querySelectorAll('#taskViewToggle button').forEach(b => b.classList.toggle('active', b.dataset.view === 'kanban'));
}
// ═══ DOCUMENTS ═══
const WORKSPACE_FILES = ['MEMORY.md', 'SOUL.md', 'USER.md', 'AGENTS.md', 'TOOLS.md', 'IDENTITY.md', 'HEARTBEAT.md'];
let currentFile = null;
let docView = 'editor';
let allSkills = [];
let memoryFiles = [];
let isEditMode = false;
let currentFileContent = '';
async function loadFileList() {
const sidebar = document.getElementById('fileSidebar');
// Load memory dir files
try {
const data = await apiFetch('/files?path=memory/&list=true');
memoryFiles = Array.isArray(data) ? data : (data.files || []);
} catch(e) { memoryFiles = []; }
sidebar.innerHTML = WORKSPACE_FILES.map(f => `
<div class="file-item ${currentFile === f ? 'active' : ''}" onclick="selectFile('${f}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span class="file-name">${f}</span>
</div>
`).join('') + (memoryFiles.length ? `
<div class="file-divider"></div>
<div class="file-sidebar-header">memory/</div>
${memoryFiles.map(f => {
const path = typeof f === 'string' ? f : f.name || f.path || '';
const name = path.split('/').pop();
return `<div class="file-item indent ${currentFile === 'memory/' + name ? 'active' : ''}" onclick="selectFile('memory/${name}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span class="file-name">${escHtml(name)}</span>
</div>`;
}).join('')}
` : '');
}
async function selectFile(path) {
currentFile = path;
isEditMode = false;
loadFileList();
const ta = document.getElementById('editorTextarea');
const preview = document.getElementById('mdPreview');
const fname = document.getElementById('editorFilename');
const saveBtn = document.getElementById('saveBtn');
const editBtn = document.getElementById('editToggleBtn');
const editLabel = document.getElementById('editToggleLabel');
fname.innerHTML = `${escHtml(path)}`;
preview.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text2)"><div class="spinner"></div><p style="margin-top:12px;font-size:.84rem">Loading…</p></div>';
// Show preview mode by default
preview.style.display = '';
ta.style.display = 'none';
saveBtn.style.display = 'none';
editBtn.style.display = '';
editBtn.classList.remove('editing');
editLabel.textContent = 'Edit';
try {
const data = await apiFetch(`/files?path=${encodeURIComponent(path)}`);
const content = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
currentFileContent = content;
ta.value = content;
ta.disabled = false;
saveBtn.disabled = false;
renderMarkdownPreview(content);
} catch(e) {
currentFileContent = '';
preview.innerHTML = `<div class="md-empty-hint"><p style="color:var(--red)">Error loading file: ${escHtml(e.message)}</p></div>`;
saveBtn.disabled = true;
editBtn.style.display = 'none';
}
}
function renderMarkdownPreview(content) {
const preview = document.getElementById('mdPreview');
if (!content || !content.trim()) {
preview.innerHTML = '<div class="md-empty-hint"><p>This file is empty</p></div>';
return;
}
try {
if (typeof marked !== 'undefined' && marked.parse) {
preview.innerHTML = marked.parse(content, { breaks: true, gfm: true });
} else {
// Fallback to simple rendering
preview.innerHTML = renderMarkdown(content);
}
} catch(e) {
preview.innerHTML = `<pre style="white-space:pre-wrap;color:var(--text2)">${escHtml(content)}</pre>`;
}
}
function toggleEditMode() {
isEditMode = !isEditMode;
const ta = document.getElementById('editorTextarea');
const preview = document.getElementById('mdPreview');
const saveBtn = document.getElementById('saveBtn');
const editBtn = document.getElementById('editToggleBtn');
const editLabel = document.getElementById('editToggleLabel');
if (isEditMode) {
// Switch to edit mode
ta.value = currentFileContent;
ta.style.display = '';
preview.style.display = 'none';
saveBtn.style.display = '';
editBtn.classList.add('editing');
editLabel.textContent = 'Preview';
ta.focus();
} else {
// Switch back to preview mode — pick up any edits
currentFileContent = ta.value;
ta.style.display = 'none';
preview.style.display = '';
saveBtn.style.display = 'none';
editBtn.classList.remove('editing');
editLabel.textContent = 'Edit';
renderMarkdownPreview(currentFileContent);
}
}
async function saveFile() {
if (!currentFile) return;
const btn = document.getElementById('saveBtn');
const content = document.getElementById('editorTextarea').value;
btn.disabled = true;
btn.textContent = 'Saving…';
try {
await apiFetch(`/files?path=${encodeURIComponent(currentFile)}`, {
method: 'PUT',
body: JSON.stringify({ content })
});
currentFileContent = content;
toast('File saved successfully', 'success');
} catch(e) {
toast(`Save failed: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Save';
}
}
function setDocView(view) {
docView = view;
document.querySelectorAll('#viewToggle button').forEach(b => b.classList.toggle('active', b.dataset.view === view));
document.getElementById('editorArea').style.display = view === 'editor' ? '' : 'none';
document.getElementById('skillsArea').style.display = view === 'skills' ? '' : 'none';
if (view === 'skills') loadSkills();
}
async function loadSkills() {
const grid = document.getElementById('skillsGrid');
try {
const data = await apiFetch('/skills');
allSkills = Array.isArray(data) ? data : (data.skills || []);
renderSkills();
} catch(e) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Unable to load skills</h3><p>${e.message}</p></div>`;
}
}
function renderSkills() {
const grid = document.getElementById('skillsGrid');
const search = (document.getElementById('skillSearch').value || '').toLowerCase();
const filtered = search ? allSkills.filter(s => (s.name || '').toLowerCase().includes(search) || (s.description || '').toLowerCase().includes(search)) : allSkills;
if (filtered.length === 0) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No skills found</h3></div>';
return;
}
grid.innerHTML = filtered.map((s, i) => `
<div class="glass-card skill-card" style="animation:cardIn .4s ease backwards;animation-delay:${i * 0.04}s">
<div class="skill-name">${escHtml(s.name || s.id || 'Unnamed')}</div>
<div class="skill-desc">${escHtml(s.description || 'No description')}</div>
${s.version ? `<div class="skill-meta">v${escHtml(s.version)}</div>` : ''}
</div>
`).join('');
}
document.getElementById('skillSearch')?.addEventListener('input', () => renderSkills());
// ═══ LOGS ═══
let logDays = 7;
let autoRefresh = false;
let autoRefreshInterval = null;
async function loadLogs() {
const timeline = document.getElementById('logsTimeline');
timeline.innerHTML = '<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div>';
let entries = [];
// Fetch memory files for log entries
try {
const data = await apiFetch('/files?path=memory/&list=true');
const files = Array.isArray(data) ? data : (data.files || []);
for (const f of files) {
const name = typeof f === 'string' ? f : (f.name || f.path || '');
const fname = name.split('/').pop();
// Check date filter
const dateMatch = fname.match(/(\d{4}-\d{2}-\d{2})/);
if (dateMatch && logDays > 0) {
const fileDate = new Date(dateMatch[1]);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - logDays);
if (fileDate < cutoff) continue;
}
try {
const content = await apiFetch(`/files?path=memory/${encodeURIComponent(fname)}`);
const text = typeof content === 'string' ? content : (content.content || '');
entries.push({
type: 'memory',
date: dateMatch ? dateMatch[1] : fname,
title: fname,
content: text,
timestamp: dateMatch ? new Date(dateMatch[1]).getTime() : 0
});
} catch(e) { /* skip */ }
}
} catch(e) { /* ok */ }
// Fetch tasks for status history
try {
const data = await apiFetch('/tasks');
const tasks = Array.isArray(data) ? data : (data.tasks || []);
tasks.forEach(t => {
if (t.createdAt) {
entries.push({
type: 'task',
date: new Date(t.createdAt).toISOString().slice(0, 10),
title: `Task: ${t.title || 'Untitled'}`,
content: `Status: ${statusLabel(t.status || 'new')}${t.description ? '\n' + t.description : ''}`,
timestamp: new Date(t.createdAt).getTime()
});
}
// Add notes as log entries
(t.notes || []).forEach(n => {
if (n.createdAt) {
entries.push({
type: 'task',
date: new Date(n.createdAt).toISOString().slice(0, 10),
title: `Note on: ${t.title || 'Untitled'}`,
content: n.text || n.content || '',
timestamp: new Date(n.createdAt).getTime()
});
}
});
});
} catch(e) { /* ok */ }
// Sort by date desc
entries.sort((a, b) => b.timestamp - a.timestamp);
if (entries.length === 0) {
timeline.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 80 80"><circle cx="40" cy="40" r="36" fill="none" stroke="currentColor" stroke-width="1.5"/><polyline points="40 20 40 40 52 46" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<h3>No log entries</h3>
<p>Memory files and task activity will appear here as a timeline.</p>
</div>`;
return;
}
timeline.innerHTML = entries.map((entry, i) => {
const preview = entry.content.length > 200 ? entry.content.slice(0, 200) + '…' : entry.content;
return `<div class="timeline-item type-${entry.type}" style="animation-delay:${i * 0.06}s">
<div class="timeline-date">${entry.date}</div>
<div class="timeline-content" onclick="this.classList.toggle('expanded')">
<span class="timeline-tag ${entry.type}">${entry.type === 'memory' ? '📝 Memory' : '📋 Task'}</span>
<div><strong>${escHtml(entry.title)}</strong></div>
<div class="preview">${escHtml(preview)}</div>
<div style="display:none" class="full-content">${escHtml(entry.content)}</div>
</div>
</div>`;
}).join('');
// Handle expand/collapse to show full content
timeline.querySelectorAll('.timeline-content').forEach(el => {
el.addEventListener('click', function() {
const preview = this.querySelector('.preview');
const full = this.querySelector('.full-content');
if (this.classList.contains('expanded')) {
preview.style.display = 'none';
full.style.display = 'block';
} else {
preview.style.display = '';
full.style.display = 'none';
}
});
});
}
// Log date filters
document.getElementById('logDateFilters').addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
document.querySelectorAll('#logDateFilters .filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
logDays = parseInt(btn.dataset.days) || 0;
loadLogs();
});
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
const toggle = document.getElementById('autoRefreshToggle');
toggle.classList.toggle('on', autoRefresh);
if (autoRefresh) {
autoRefreshInterval = setInterval(() => loadLogs(), 30000);
toast('Auto-refresh enabled (30s)', 'info');
} else {
clearInterval(autoRefreshInterval);
toast('Auto-refresh disabled', 'info');
}
}
// ═══ CONNECTED APIs ═══
const CONNECTED_APIS = [
{
id: 'discord',
name: 'Discord Bot',
provider: 'Discord API',
icon: '🎮',
iconClass: 'discord',
status: 'connected',
description: '主消息频道,用于接收指令、发送报告和互动。支持 Components v2 组件交互。',
capabilities: [
{ label: 'Send Messages', type: 'messaging' },
{ label: 'Read Messages', type: 'messaging' },
{ label: 'React', type: 'messaging' },
{ label: 'Components v2', type: 'messaging' }
]
},
{
id: 'anthropic',
name: 'Anthropic Claude',
provider: 'Anthropic API',
icon: '🧠',
iconClass: 'anthropic',
status: 'connected',
description: '主 LLM 引擎。Claude Sonnet 4.6 / Opus 4.6,用于推理、代码生成和对话。',
capabilities: [
{ label: 'Claude Sonnet 4.6', type: 'ai' },
{ label: 'Claude Opus 4.6', type: 'ai' },
{ label: 'Reasoning', type: 'ai' },
{ label: 'Code Gen', type: 'ai' }
]
},
{
id: 'brave-search',
name: 'Brave Search',
provider: 'Brave API',
icon: '🦁',
iconClass: 'brave',
status: 'connected',
description: '实时网页搜索 API用于信息检索和最新内容获取。',
capabilities: [
{ label: 'Web Search', type: 'search' },
{ label: 'Region Filter', type: 'search' },
{ label: 'Freshness Filter', type: 'search' }
]
},
{
id: 'notion',
name: 'Notion',
provider: 'Notion API',
icon: 'N',
iconClass: 'notion',
status: 'connected',
description: '数据库读写,用于任务管理、项目跟踪和知识库存储。',
capabilities: [
{ label: 'Database Read', type: 'data' },
{ label: 'Database Write', type: 'data' },
{ label: 'Pages', type: 'data' },
{ label: 'Task Queue', type: 'ai' }
]
},
{
id: 'openai',
name: 'OpenAI',
provider: 'OpenAI API',
icon: '⚡',
iconClass: 'openai',
status: 'connected',
description: 'GPT-5.2 / Codex用于代码补全、内容生成和多模态任务。',
capabilities: [
{ label: 'GPT-5.2', type: 'ai' },
{ label: 'Codex', type: 'ai' },
{ label: 'Code Completion', type: 'ai' },
{ label: 'Multimodal', type: 'ai' }
]
},
{
id: 'gemini',
name: 'Google Gemini',
provider: 'Google AI',
icon: 'G',
iconClass: 'google',
status: 'connected',
description: 'Gemini 3 Pro / Flash用于多模态推理、图像理解和长上下文任务。',
capabilities: [
{ label: 'Gemini 3 Pro', type: 'ai' },
{ label: 'Gemini Flash', type: 'ai' },
{ label: 'Image Understanding', type: 'ai' },
{ label: 'Long Context', type: 'ai' }
]
},
{
id: 'x-api',
name: 'X API',
provider: 'Twitter API v2',
icon: '𝕏',
iconClass: 'twitter',
status: 'connected',
description: '推文抓取与监控,追踪 @steipete @openclaw 及相关话题动态。',
capabilities: [
{ label: 'Tweet Fetch', type: 'data' },
{ label: '@steipete', type: 'search' },
{ label: '@openclaw', type: 'search' },
{ label: 'Timeline', type: 'data' }
]
},
{
id: 'web-fetch',
name: 'Web Fetch',
provider: 'Built-in',
icon: '🌐',
iconClass: 'web',
status: 'connected',
description: '网页内容提取工具,用于抓取和解析任意 URL 的页面内容。',
capabilities: [
{ label: 'URL Fetch', type: 'data' },
{ label: 'Content Extract', type: 'data' },
{ label: 'HTML Parse', type: 'data' }
]
},
{
id: 'whisper',
name: 'OpenAI Whisper API',
provider: 'OpenAI API',
icon: '🎙️',
iconClass: 'whisper',
status: 'connected',
description: '语音转文字服务,支持多语言识别,用于音频内容处理。',
capabilities: [
{ label: 'Speech-to-Text', type: 'ai' },
{ label: 'Multi-Language', type: 'ai' },
{ label: 'Audio Transcribe', type: 'ai' }
]
}
];
function renderAPIs() {
const grid = document.getElementById('apisGrid');
const stats = document.getElementById('apiStats');
const search = (document.getElementById('apiSearch')?.value || '').toLowerCase();
const filtered = search
? CONNECTED_APIS.filter(a => a.name.toLowerCase().includes(search) || a.provider.toLowerCase().includes(search) || a.description.toLowerCase().includes(search) || a.capabilities.some(c => c.label.toLowerCase().includes(search)))
: CONNECTED_APIS;
const connected = CONNECTED_APIS.filter(a => a.status === 'connected').length;
const totalCaps = CONNECTED_APIS.reduce((sum, a) => sum + a.capabilities.length, 0);
const categories = new Set();
CONNECTED_APIS.forEach(a => a.capabilities.forEach(c => categories.add(c.type)));
document.getElementById('apiCount').textContent = CONNECTED_APIS.length;
stats.innerHTML = `
<div class="api-stat-card">
<div class="api-stat-value">${CONNECTED_APIS.length}</div>
<div class="api-stat-label">Total APIs</div>
</div>
<div class="api-stat-card">
<div class="api-stat-value">${connected}</div>
<div class="api-stat-label">Connected</div>
</div>
<div class="api-stat-card">
<div class="api-stat-value">${totalCaps}</div>
<div class="api-stat-label">Capabilities</div>
</div>
<div class="api-stat-card">
<div class="api-stat-value">${categories.size}</div>
<div class="api-stat-label">Categories</div>
</div>
`;
if (filtered.length === 0) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>No APIs match your search</h3><p>Try a different search term.</p></div>`;
return;
}
grid.innerHTML = filtered.map((api, i) => `
<div class="glass-card api-card" style="animation:cardIn .4s ease backwards;animation-delay:${i * 0.06}s">
<div class="api-card-header">
<div class="api-icon ${api.iconClass}">${api.icon}</div>
<div class="api-card-info">
<div class="api-card-name">
${escHtml(api.name)}
<span class="api-status ${api.status}" title="${api.status}"></span>
</div>
<div class="api-card-provider">${escHtml(api.provider)}</div>
</div>
</div>
<div class="api-card-desc">${escHtml(api.description)}</div>
<div class="api-capabilities">
${api.capabilities.map(cap => `<span class="api-cap ${cap.type}">${escHtml(cap.label)}</span>`).join('')}
</div>
</div>
`).join('');
}
document.getElementById('apiSearch')?.addEventListener('input', () => renderAPIs());
// ═══ AGENT MONITOR ═══
let agentData = null;
async function loadAgentMonitor() {
try {
agentData = await apiFetch('/agents');
renderAgentMonitor();
} catch(e) {
// Silently fail - monitor is non-critical
console.warn('Agent monitor fetch failed:', e.message);
}
}
function loadSystemInfo() {
apiFetch('/ops/system').then(sys => {
const el = document.getElementById('systemInfoBar');
const c = document.getElementById('systemInfoContent');
if (!el || !c) return;
const memPct = sys.memory?.usePct || '0';
const memColor = +memPct > 85 ? 'var(--red)' : +memPct > 60 ? 'var(--yellow)' : 'var(--green)';
const diskPct = parseInt(sys.disk?.usePct) || 0;
const diskColor = diskPct > 80 ? 'var(--red)' : diskPct > 60 ? 'var(--yellow)' : 'var(--green)';
c.innerHTML = `
<span>🖥️ <strong>${sys.macModel || sys.hostname}</strong></span>
<span>🍎 ${sys.macOS || '—'}</span>
<span>🧮 ${sys.cpus}核 · Load ${sys.loadAvg?.['1m']?.toFixed(1) || '—'}</span>
<span style="color:${memColor}">💾 RAM ${memPct}%</span>
<span style="color:${diskColor}">💿 Disk ${sys.disk?.usePct || '—'}</span>
<span>📦 Node ${sys.nodeVersion || '—'}</span>
<span>🦞 v${sys.clawVersion || '—'}</span>
`;
el.style.display = '';
}).catch(() => {});
}
function renderAgentMonitor() {
if (!agentData) return;
const d = agentData;
loadSystemInfo();
// Fetch today's ops data for header cards — use /ops/sessions for accurate totals (includes all channels)
apiFetch('/ops/sessions').then(sessData => {
let totalCost = 0, totalTokens = 0, totalMsgs = 0, models = {};
(sessData.sessions || []).forEach(s => {
const t = s.today || {};
totalCost += t.cost || 0;
totalTokens += t.totalTokens || 0;
totalMsgs += t.messages || 0;
// Aggregate by actual model used in today's messages
if (t.models) {
for (const [m, tk] of Object.entries(t.models)) {
models[m] = (models[m] || 0) + tk;
}
}
});
// Card 1: Today Cost
const mainBadge = document.getElementById('mainAgentBadge');
const mainValue = document.getElementById('mainAgentValue');
const mainDetail = document.getElementById('mainAgentDetail');
mainBadge.className = 'agent-stat-badge active';
mainBadge.innerHTML = '<span class="pulse-dot"></span> today';
mainValue.textContent = '$' + totalCost.toFixed(2);
mainDetail.textContent = totalMsgs + ' messages';
// Card 2: Today Tokens — show total + top model percentages
const subVal = document.getElementById('subagentValue');
const subBadge = document.getElementById('subagentBadge');
const subDetail = document.getElementById('subagentDetail');
subVal.textContent = fmtTokens(totalTokens);
subBadge.className = 'agent-stat-badge active';
subBadge.innerHTML = 'today';
const topModel = Object.entries(models).filter(([k]) => k !== 'delivery-mirror' && k !== 'unknown').sort((a, b) => b[1] - a[1])[0];
subDetail.textContent = topModel ? 'Top: ' + shortModel(topModel[0]) : '—';
// Card 5: Model Mix — visual bars
const sorted = Object.entries(models).filter(([k]) => k !== 'delivery-mirror' && k !== 'unknown').sort((a, b) => b[1] - a[1]);
const mixEl = document.getElementById('modelMixBars');
const totalVal5 = document.getElementById('totalValue');
const totalBadge5 = document.getElementById('totalBadge');
if (mixEl && sorted.length) {
totalVal5.textContent = sorted.length + ' models';
totalBadge5.className = 'agent-stat-badge active';
totalBadge5.innerHTML = 'today';
// Stacked bar
let barHtml = '<div style="display:flex;height:10px;border-radius:5px;overflow:hidden;margin-bottom:6px">';
const colors = {};
sorted.forEach(([m, tk]) => {
const pct = ((tk / (totalTokens || 1)) * 100);
const c = getModelColor(m);
colors[m] = c;
barHtml += `<div style="width:${pct}%;background:${c};min-width:2px" title="${shortModel(m)} ${pct.toFixed(0)}%"></div>`;
});
barHtml += '</div>';
// Legend
barHtml += '<div style="display:flex;flex-wrap:wrap;gap:4px 10px;font-size:.7rem">';
sorted.forEach(([m, tk]) => {
const pct = ((tk / (totalTokens || 1)) * 100).toFixed(0);
barHtml += `<span style="color:${colors[m]}">● ${shortModel(m)} <b>${pct}%</b></span>`;
});
barHtml += '</div>';
mixEl.innerHTML = barHtml;
}
}).catch(() => {});
// Card 3: Cron Jobs (from agent data)
const cronVal = document.getElementById('cronValue');
const cronBadge = document.getElementById('cronBadge');
const cronDetail = document.getElementById('cronDetail');
cronVal.textContent = d.crons?.total || 0;
cronBadge.className = `agent-stat-badge ${d.crons?.active > 0 ? 'active' : 'idle'}`;
cronBadge.innerHTML = d.crons?.active > 0 ? `<span class="pulse-dot"></span> ${d.crons.active} running` : `${d.crons?.total || 0} total`;
cronDetail.textContent = d.crons?.active > 0 ? d.crons.active + ' running' : 'all idle';
// Card 4: Sessions
const hookVal = document.getElementById('hookValue');
const hookBadge = document.getElementById('hookBadge');
const hookDetail = document.getElementById('hookDetail');
hookVal.textContent = d.activeSessions || 0;
hookBadge.className = `agent-stat-badge ${d.activeSessions > 0 ? 'active' : 'idle'}`;
hookBadge.innerHTML = d.activeSessions + ' active';
hookDetail.textContent = (d.totalSessions || 0) + ' total';
// Card 5: Model Mix — rendered by /ops/sessions fetch above
// Render sessions panel
renderSessionsPanel();
}
function renderSessionsPanel() {
// Load today's cron runs instead of active sessions
loadCronRuns();
}
async function loadCronRuns() {
const body = document.getElementById('cronRunsBody');
const count = document.getElementById('cronRunsCount');
if (!body) return;
try {
const data = await apiFetch('/cron/today');
const runs = data.todayJobs || [];
count.textContent = runs.length;
if (runs.length === 0) {
body.innerHTML = '<div style="padding:12px 18px;font-size:.8rem;color:var(--text2)">今日暂无 Cron 执行记录</div>';
return;
}
// Sort by last run time descending
runs.sort((a, b) => (b.last?.endedAt || b.last?.startedAt || 0) - (a.last?.endedAt || a.last?.startedAt || 0));
body.innerHTML = runs.map(r => {
const last = r.last || {};
const name = r.name || r.id?.slice(0, 8) || '—';
const status = last.status === 'ok' ? '✅' : last.status === 'error' ? '❌' : '⏳';
const time = last.endedAt ? new Date(last.endedAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'America/Los_Angeles' }) : (last.startedAt ? new Date(last.startedAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'America/Los_Angeles' }) : '—');
const dur = (last.endedAt && last.startedAt) ? ((last.endedAt - last.startedAt) / 1000).toFixed(1) + 's' : '';
const model = last.model ? shortModel(last.model) : '';
const tokens = Number.isFinite(last.tokens) ? (fmtTokens(last.tokens) + ' tok') : '';
const cost = Number.isFinite(last.costUsd) ? fmtUsd(last.costUsd, 3) : '';
const detail = [model, tokens, cost, dur].filter(Boolean).join(' · ');
return `<div class="agent-session-row">
<span style="font-size:.9rem">${status}</span>
<span class="agent-session-key" style="flex:1;font-family:inherit;font-size:.78rem">${escHtml(name)}</span>
<span class="agent-session-tokens">${escHtml(detail)}</span>
<span class="agent-session-age">${time}</span>
</div>`;
}).join('');
} catch (e) {
body.innerHTML = `<div style="padding:12px 18px;font-size:.8rem;color:var(--text2)">${e.message}</div>`;
}
}
function formatAge(minutes) {
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
return `${Math.floor(minutes / 1440)}d ago`;
}
function toggleSessionsPanel() {
// Panel toggle handled by CSS via collapsed class
}
// ═══ PARALLEL EXECUTION ═══
let selectionMode = false;
let selectedTaskIds = new Set();
let parallelRuns = new Map(); // taskId -> { status, startTime, title }
function toggleSelectionMode() {
selectionMode = !selectionMode;
const btn = document.getElementById('selectionModeBtn');
btn.style.background = selectionMode ? 'var(--accent)' : '';
btn.style.color = selectionMode ? '#fff' : '';
btn.style.borderColor = selectionMode ? 'var(--accent)' : '';
selectedTaskIds.clear();
updateBatchBar();
// Re-render to show/hide checkboxes
if (taskView === 'kanban') renderKanban(); else renderTasks();
}
function toggleTaskSelection(taskId, event) {
if (event) event.stopPropagation();
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId);
} else {
selectedTaskIds.add(taskId);
}
updateBatchBar();
// Update checkbox visual
const cb = document.querySelector(`.task-checkbox[data-task-id="${taskId}"]`);
if (cb) cb.classList.toggle('checked', selectedTaskIds.has(taskId));
}
function selectAllNew() {
allTasks.filter(t => t.status === 'new').forEach(t => selectedTaskIds.add(t.id));
updateBatchBar();
if (taskView === 'kanban') renderKanban(); else renderTasks();
}
function clearSelection() {
selectedTaskIds.clear();
updateBatchBar();
if (taskView === 'kanban') renderKanban(); else renderTasks();
}
function updateBatchBar() {
const bar = document.getElementById('batchBar');
const count = document.getElementById('batchCount');
if (selectionMode && selectedTaskIds.size > 0) {
bar.style.display = '';
count.textContent = `${selectedTaskIds.size} selected`;
} else {
bar.style.display = 'none';
}
}
async function spawnSingleTask(taskId) {
try {
const task = allTasks.find(t => t.id === taskId);
await apiFetch(`/tasks/${taskId}/spawn`, { method: 'POST' });
parallelRuns.set(taskId, { status: 'running', startTime: Date.now(), title: task?.title || 'Untitled' });
renderParallelPanel();
toast('Task spawned as sub-agent', 'success');
loadTasks(true);
} catch(e) {
toast(`Spawn failed: ${e.message}`, 'error');
}
}
async function spawnBatch() {
const ids = Array.from(selectedTaskIds);
if (ids.length === 0) { toast('No tasks selected', 'error'); return; }
try {
const result = await apiFetch('/tasks/spawn-batch', {
method: 'POST',
body: JSON.stringify({ taskIds: ids })
});
// Track spawned tasks
ids.forEach(id => {
const task = allTasks.find(t => t.id === id);
parallelRuns.set(id, { status: 'running', startTime: Date.now(), title: task?.title || 'Untitled' });
});
renderParallelPanel();
const spawned = result.spawned || ids.length;
const skipped = result.skipped?.length || 0;
toast(`${spawned} tasks spawned in parallel${skipped ? ` (${skipped} skipped)` : ''}`, 'success');
selectedTaskIds.clear();
updateBatchBar();
loadTasks(true);
} catch(e) {
toast(`Batch spawn failed: ${e.message}`, 'error');
}
}
function renderParallelPanel() {
const panel = document.getElementById('parallelPanel');
const container = document.getElementById('parallelTasks');
const countEl = document.getElementById('parallelCount');
// Update statuses from task data
parallelRuns.forEach((run, taskId) => {
const task = allTasks.find(t => t.id === taskId);
if (task) {
if (task.status === 'done') run.status = 'done';
else if (task.status === 'failed') run.status = 'failed';
else if (task.status === 'in-progress') run.status = 'running';
}
});
if (parallelRuns.size === 0) {
panel.style.display = 'none';
return;
}
panel.style.display = '';
const running = Array.from(parallelRuns.values()).filter(r => r.status === 'running').length;
countEl.textContent = `${running} running / ${parallelRuns.size} total`;
container.innerHTML = Array.from(parallelRuns.entries()).map(([taskId, run]) => {
const elapsed = Math.round((Date.now() - run.startTime) / 1000);
const elapsedStr = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed/60)}m ${elapsed%60}s`;
let icon = '<div class="parallel-spinner"></div>';
if (run.status === 'done') icon = '<div class="parallel-done-icon">✓</div>';
if (run.status === 'failed') icon = '<div class="parallel-fail-icon">✕</div>';
return `<div class="parallel-task-row">
${icon}
<span class="parallel-task-name" title="${escHtml(run.title)}">${escHtml(run.title)}</span>
<span class="parallel-task-status ${run.status}">${run.status}</span>
<span class="parallel-task-time">${elapsedStr}</span>
</div>`;
}).join('');
}
function clearCompletedParallel() {
parallelRuns.forEach((run, taskId) => {
if (run.status === 'done' || run.status === 'failed') parallelRuns.delete(taskId);
});
renderParallelPanel();
}
function hideParallelPanel() {
document.getElementById('parallelPanel').style.display = 'none';
}
// ─── Ops Dashboard (Cron + Vision) ───
async function loadOps() {
const visionEl = document.getElementById('visionGrid');
const visionStatus = document.getElementById('visionStatus');
if (!visionEl) return;
try {
const vision = await apiFetch('/vision/stats');
renderVisionStats(vision, visionEl, visionStatus);
} catch (e) {
visionEl.innerHTML = `<div class="empty-state"><h3>Unable to Load Vision Stats</h3><p>${e.message}</p></div>`;
}
}
function renderVisionStats(data, visionEl, statusEl) {
const cats = data?.categories || {};
if (statusEl) statusEl.textContent = data?.status === 'not_configured' ? 'Notion not configured' : 'Notion source';
const entries = Object.entries(cats);
if (entries.length === 0) {
visionEl.innerHTML = `<div class="empty-state"><h3>No vision stats</h3><p>Configure Notion databases to see ingestion counts.</p></div>`;
return;
}
visionEl.innerHTML = entries.map(([k, v]) => {
return `<div class="vision-card">
<div class="vision-label">${k}</div>
<div class="vision-count">${v.count ?? 0}</div>
<div class="vision-sub">Today</div>
</div>`;
}).join('');
}
// Override tasks loader to cron loader
loadTasks = async function() {
await loadOps();
};
// ─── Sessions Panel ───
// ─── Model Selector ───
const MODEL_OPTIONS = [
{ value: 'default', label: '默认', short: 'default' },
{ value: 'opus', label: 'Opus', full: 'claude-opus-4-6' },
{ value: 'sonnet', label: 'Sonnet', full: 'claude-sonnet-4-6' },
{ value: 'flash', label: 'Flash', full: 'gemini-3.0-flash' },
{ value: 'pro', label: 'Pro', full: 'gemini-3.1-pro' },
{ value: 'codex', label: 'Codex', full: 'gpt-5.3-codex' },
];
function buildModelSelect(currentModel, id, type) {
// type: 'session' (channelId) or 'cron' (jobId)
const opts = MODEL_OPTIONS.map(o => {
const isCurrent = o.full ? currentModel.includes(o.full) : (!currentModel || currentModel === 'unknown');
return `<option value="${o.value}" ${isCurrent ? 'selected' : ''}>${o.label}</option>`;
}).join('');
const color = getModelColor(currentModel);
return `<select class="model-select" style="border-color:${color};color:${color}"
onchange="changeModel('${type}','${id}',this.value,this)">${opts}</select>`;
}
async function changeModel(type, id, model, el) {
el.disabled = true;
el.style.opacity = '0.5';
const endpoint = type === 'session' ? '/ops/session-model' : '/ops/cron-model';
const body = type === 'session' ? { channelId: id, model } : { jobId: id, model };
try {
await apiFetch(endpoint, { method: 'POST', body: JSON.stringify(body) });
el.style.borderColor = 'var(--green)';
el.style.color = 'var(--green)';
setTimeout(() => {
el.style.opacity = '1';
if (type === 'session') loadSessions();
else loadCronEnhanced();
}, 800);
} catch (e) {
el.style.borderColor = '#f87171';
el.style.color = '#f87171';
el.disabled = false;
el.style.opacity = '1';
alert('模型切换失败: ' + e.message);
}
}
const SESSION_SORT_DEFAULT_DIR = {
model: 'asc',
messages: 'desc',
tokens: 'desc',
cost: 'desc',
costPerMsg: 'desc',
fit: 'desc',
};
const sessionSortState = {
key: null,
dir: 'desc',
};
function toggleSessionSort(key) {
if (sessionSortState.key === key) {
sessionSortState.dir = sessionSortState.dir === 'asc' ? 'desc' : 'asc';
} else {
sessionSortState.key = key;
sessionSortState.dir = SESSION_SORT_DEFAULT_DIR[key] || 'desc';
}
loadSessions();
}
function sessionSortIndicator(key) {
if (sessionSortState.key !== key) return '&harr;';
return sessionSortState.dir === 'asc' ? '&uarr;' : '&darr;';
}
async function loadSessions() {
const alertsEl = document.getElementById('sessionsAlerts');
const tableEl = document.getElementById('sessionsTable');
if (!tableEl) return;
try {
const data = await apiFetch('/ops/sessions');
const sessions = data.sessions || [];
const alerts = data.alerts || [];
const summary = data.summary || {};
// Update header cards from session data
const mainBadge = document.getElementById('mainAgentBadge');
const mainValue = document.getElementById('mainAgentValue');
const mainDetail = document.getElementById('mainAgentDetail');
if (mainValue) {
mainBadge.className = 'agent-stat-badge ' + (alerts.filter(a=>a.type==='error').length > 0 ? 'error' : 'active');
mainBadge.innerHTML = alerts.length > 0 ? `⚠️ ${alerts.length} alerts` : '✅ healthy';
mainValue.textContent = '$' + (summary.todayCost || 0).toFixed(2);
mainDetail.textContent = (summary.todayMessages || 0) + ' messages today';
}
const subVal = document.getElementById('subagentValue');
if (subVal) {
subVal.textContent = fmtTokens(sessions.reduce((s, r) => s + r.today.totalTokens, 0));
document.getElementById('subagentDetail').textContent = shortModel(summary.topModel);
}
const hookVal = document.getElementById('hookValue');
if (hookVal) {
hookVal.textContent = summary.active || 0;
document.getElementById('hookDetail').textContent = summary.total + ' total';
}
// Alerts
if (alertsEl) {
alertsEl.innerHTML = alerts.map(a =>
`<div class="sess-alert ${a.type}"><span>${a.type === 'error' ? '🔴' : a.type === 'waste' ? '🟡' : '⚪'}</span><strong>${escHtml(a.session)}</strong> ${escHtml(a.msg)}</div>`
).join('');
}
// Table
if (sessions.length === 0) {
tableEl.innerHTML = '<div class="empty-state"><h3>No sessions</h3></div>';
return;
}
// Chinese names + task type for Discord channels
const channelMeta = {
'#dev_build': { cn: '开发构建', task: '🔧 深度开发', tier: 'hard' },
'#ai-learning': { cn: 'AI学习', task: '🧠 架构讨论', tier: 'hard' },
'#ops-report': { cn: '运维报告', task: '📊 汇报转发', tier: 'easy' },
'#general': { cn: '综合频道', task: '💬 闲聊', tier: 'easy' },
'#openclaw-watch': { cn: '生态监控', task: '🔍 监控播报', tier: 'medium' },
'#jobs-intel': { cn: '求职情报', task: '💼 搜索整理', tier: 'medium' },
'#x-ai-socal-radar': { cn: 'X/AI雷达', task: '🐦 内容创作', tier: 'hard' },
'#tech-news': { cn: '科技新闻', task: '📰 摘要生成', tier: 'medium' },
'#podcast_video_article': { cn: '播客/视频', task: '📝 内容摘要', tier: 'medium' },
'#meta-vision-ingest': { cn: '视觉入口', task: '👁️ 图片路由', tier: 'easy' },
'#socal-ai-events': { cn: '南加AI活动', task: '🎯 活动搜索', tier: 'medium' },
'#event-planning': { cn: '活动策划', task: '📅 规划', tier: 'easy' },
'#networking-log': { cn: '人脉记录', task: '👤 信息录入', tier: 'easy' },
'#工作搭子碎碎念': { cn: '工作搭子', task: '💬 闲聊', tier: 'easy' },
'#饮酒': { cn: '饮酒', task: '🍷 品鉴记录', tier: 'easy' },
'#灰茄': { cn: '灰茄', task: '🚬 品鉴记录', tier: 'easy' },
'#品茶': { cn: '品茶', task: '🍵 品鉴记录', tier: 'easy' },
'#养花': { cn: '养花', task: '🌱 记录', tier: 'easy' },
'#灵修': { cn: '灵修', task: '📖 灵修提醒', tier: 'easy' },
};
// Model tier: what complexity level is this model suited for
const modelTier = m => {
if (!m) return 'easy';
if (m.includes('opus')) return 'hard';
if (m.includes('sonnet') || m.includes('codex') || m.includes('pro')) return 'medium';
return 'easy'; // flash, etc.
};
// Fit assessment
function fitLabel(s) {
const meta = channelMeta[s.displayName];
const taskTier = meta?.tier || 'medium';
const mTier = modelTier(s.model);
const tiers = { easy: 0, medium: 1, hard: 2 };
const diff = tiers[mTier] - tiers[taskTier];
if (diff >= 2) return { emoji: '🔴', text: '过高', tip: '模型远超任务需求,建议降级' };
if (diff === 1) return { emoji: '🟡', text: '偏高', tip: '可考虑降级节省成本' };
if (diff === 0) return { emoji: '🟢', text: '匹配', tip: '模型与任务复杂度匹配' };
if (diff === -1) return { emoji: '🔵', text: '偏低', tip: '任务较复杂,可考虑升级' };
return { emoji: '⚪', text: '—', tip: '' };
}
const fitSortRank = s => {
const text = fitLabel(s).text;
if (text === '过高') return 4;
if (text === '偏高') return 3;
if (text === '匹配') return 2;
if (text === '偏低') return 1;
return 0;
};
const getSortValue = (s, key) => {
const eff = s.today.effectiveMessages || 0;
if (key === 'model') return shortModel(s.model || '').toLowerCase();
if (key === 'messages') return s.today.messages || 0;
if (key === 'tokens') return s.today.totalTokens || 0;
if (key === 'cost') return s.today.cost || 0;
if (key === 'costPerMsg') return eff > 0 ? (s.today.cost || 0) / eff : null;
if (key === 'fit') return fitSortRank(s);
return null;
};
const sortedSessions = [...sessions];
if (sessionSortState.key) {
const dir = sessionSortState.dir === 'asc' ? 1 : -1;
sortedSessions.sort((a, b) => {
const av = getSortValue(a, sessionSortState.key);
const bv = getSortValue(b, sessionSortState.key);
if (av == null && bv == null) return a.displayName.localeCompare(b.displayName, 'zh-Hans');
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'string' || typeof bv === 'string') {
const cmp = String(av).localeCompare(String(bv), 'zh-Hans');
if (cmp !== 0) return cmp * dir;
return a.displayName.localeCompare(b.displayName, 'zh-Hans');
}
if (av === bv) return a.displayName.localeCompare(b.displayName, 'zh-Hans');
return (av - bv) * dir;
});
}
let html = `<table class="sessions-table">
<thead><tr>
<th>频道</th>
<th>任务</th>
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'model' ? 'active' : ''}" onclick="toggleSessionSort('model')">模型 <span class="sess-sort-indicator">${sessionSortIndicator('model')}</span></button></th>
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'messages' ? 'active' : ''}" onclick="toggleSessionSort('messages')">消息 <span class="sess-sort-indicator">${sessionSortIndicator('messages')}</span></button></th>
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'tokens' ? 'active' : ''}" onclick="toggleSessionSort('tokens')">Tokens <span class="sess-sort-indicator">${sessionSortIndicator('tokens')}</span></button></th>
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'cost' ? 'active' : ''}" onclick="toggleSessionSort('cost')">花费 <span class="sess-sort-indicator">${sessionSortIndicator('cost')}</span></button></th>
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'costPerMsg' ? 'active' : ''}" onclick="toggleSessionSort('costPerMsg')">$/条 <span class="sess-sort-indicator">${sessionSortIndicator('costPerMsg')}</span></button></th>
<th><button type="button" class="sess-sort-btn ${sessionSortState.key === 'fit' ? 'active' : ''}" onclick="toggleSessionSort('fit')">匹配 <span class="sess-sort-indicator">${sessionSortIndicator('fit')}</span></button></th>
</tr></thead><tbody>`;
for (const s of sortedSessions) {
const meta = channelMeta[s.displayName] || {};
const cn = meta.cn || '';
const taskTag = meta.task || '';
const eff = s.today.effectiveMessages || 0;
const costPerMsg = eff > 0 ? (s.today.cost / eff) : 0;
const costColor = costPerMsg > 1.5 ? '#f87171' : costPerMsg > 0.5 ? '#fbbf24' : 'var(--green)';
const fit = fitLabel(s);
const nameHtml = cn
? `<span class="sess-name">${escHtml(s.displayName)}</span><br><span style="color:var(--text2);font-size:.65rem">${cn}</span>`
: `<span class="sess-name">${escHtml(s.displayName)}</span>`;
// Model selector dropdown
const modelSelect = s.channelId ? buildModelSelect(s.model, s.channelId, 'session') : `<span class="sess-model" style="border-color:${getModelColor(s.model)};color:${getModelColor(s.model)}">${shortModel(s.model)}</span>`;
html += `<tr>
<td><span class="sess-status ${s.status}"></span>${nameHtml}</td>
<td style="font-size:.7rem">${taskTag}</td>
<td>${modelSelect}</td>
<td>${s.today.messages}<span style="color:var(--text2);font-size:.65rem"><br>${eff} 有效</span></td>
<td>${fmtTokens(s.today.totalTokens)}</td>
<td style="font-weight:600">$${s.today.cost.toFixed(2)}</td>
<td style="color:${costColor};font-weight:600">${eff > 0 ? '$' + costPerMsg.toFixed(2) : '—'}</td>
<td title="${fit.tip}">${fit.emoji} <span style="font-size:.65rem">${fit.text}</span></td>
</tr>`;
}
html += '</tbody></table>';
tableEl.innerHTML = html;
} catch (e) {
tableEl.innerHTML = `<div class="empty-state"><p>${e.message}</p></div>`;
}
}
async function loadQuality() {
const el = document.getElementById('qualityContent');
if (!el) return;
try {
const data = await apiFetch('/ops/sessions');
const sessions = (data.sessions || []).filter(s => s.today.messages > 0);
// Sort by idle rate desc
sessions.sort((a, b) => b.today.noReplyRate - a.today.noReplyRate);
let html = '<div class="glass-card" style="padding:16px;margin-bottom:12px"><div class="card-title">Session Quality (Today)</div><div class="card-sub">静默率 = (NO_REPLY 无回复 + HEARTBEAT_OK 心跳) / 总消息数。高静默率说明该频道大量消息不需要回复,可考虑降级模型节省成本。</div></div>';
html += '<div class="ops-channel-list">';
for (const s of sessions) {
const barWidth = Math.min(s.today.noReplyRate, 100);
const color = s.today.noReplyRate > 60 ? '#f87171' : s.today.noReplyRate > 30 ? '#fbbf24' : '#34d399';
html += `<div class="ops-channel-card">
<div class="ops-ch-left" style="flex:1">
<div class="ops-ch-name">${escHtml(s.displayName)}</div>
<div style="height:6px;background:var(--border);border-radius:3px;margin-top:4px">
<div style="width:${barWidth}%;height:100%;background:${color};border-radius:3px"></div>
</div>
<div class="ops-ch-meta"><span>${s.today.messages} msgs</span><span>${s.today.effectiveMessages} effective</span><span>${s.today.noReply} silent</span><span>${s.today.heartbeat} heartbeat</span></div>
</div>
<div class="ops-ch-right"><div class="ops-ch-tokens" style="color:${color}">${s.today.noReplyRate}%</div><div class="ops-ch-cost">静默率</div></div>
</div>`;
}
html += '</div>';
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p>${e.message}</p>`; }
}
async function loadAudit() {
const el = document.getElementById('auditContent2');
if (!el) return;
try {
const data = await apiFetch('/ops/sessions');
const sessions = (data.sessions || []).filter(s => s.today.messages > 0);
const recommendations = [];
for (const s of sessions) {
// Expensive model with high idle rate
if (s.model?.includes('opus') && s.today.noReplyRate > 40 && s.today.messages > 3) {
recommendations.push({ severity: 'high', session: s.displayName, msg: `Using Opus but ${s.today.noReplyRate}% idle → switch to Sonnet (save ~$${(s.today.cost * 0.8).toFixed(0)}/day)`, model: s.model });
}
// Opus for simple channel
if (s.model?.includes('opus') && s.today.effectiveMessages < 5 && s.today.cost > 1) {
recommendations.push({ severity: 'medium', session: s.displayName, msg: `Opus overkill — only ${s.today.effectiveMessages} effective msgs, costing $${s.today.cost.toFixed(2)}`, model: s.model });
}
// No thinking level set
if (s.thinkingLevel === '—' && s.today.messages > 0) {
recommendations.push({ severity: 'low', session: s.displayName, msg: 'No thinking level set — consider setting to "low" to save tokens', model: s.model });
}
}
let html = '<div class="glass-card" style="padding:16px;margin-bottom:12px"><div class="card-title">Config Audit</div><div class="card-sub">' + recommendations.length + ' recommendations</div></div>';
if (recommendations.length === 0) {
html += '<div class="empty-state"><h3>✅ All Good</h3><p>No optimization opportunities detected.</p></div>';
} else {
html += '<div class="ops-channel-list">';
const sevColors = { high: '#f87171', medium: '#fbbf24', low: '#6b7280' };
for (const r of recommendations) {
html += `<div class="sess-alert" style="background:${sevColors[r.severity]}15;border:1px solid ${sevColors[r.severity]}40">
<span style="font-size:1.1rem">${r.severity === 'high' ? '🔴' : r.severity === 'medium' ? '🟡' : '⚪'}</span>
<div><strong>${escHtml(r.session)}</strong><br><span style="font-size:.8rem;color:var(--text2)">${escHtml(r.msg)}</span></div>
</div>`;
}
html += '</div>';
}
// Provider audit (from existing)
try {
const audit = await apiFetch('/ops/audit');
html += '<div class="glass-card" style="padding:16px;margin-top:12px"><div class="card-title">Provider Verification</div>';
const oi = audit.openai;
if (oi?.status === 'ok') {
html += `<div style="margin:8px 0"><strong>OpenAI</strong> <span class="pill" style="border-color:#34d399;color:#34d399">✓</span> 7d: ${oi.totals.requests} reqs</div>`;
}
const ac = audit.anthropic;
if (ac?.org) {
html += `<div><strong>Anthropic</strong> <span class="pill" style="border-color:#c084fc;color:#c084fc">org ✓</span> ${ac.org.name} · ${ac.activeKeys?.length} keys</div>`;
}
html += '</div>';
} catch {}
// System info
try {
const sys = await apiFetch('/ops/system');
const memPct = sys.memory?.usePct || '—';
const memUsed = ((sys.memory?.used || 0) / 1073741824).toFixed(1);
const memTotal = ((sys.memory?.total || 0) / 1073741824).toFixed(1);
const load = sys.loadAvg?.['1m']?.toFixed(2) || '—';
const uptimeH = Math.floor((sys.dashboardUptime || 0) / 3600);
const uptimeM = Math.floor(((sys.dashboardUptime || 0) % 3600) / 60);
html += `<div class="glass-card" style="padding:16px;margin-top:12px">
<div class="card-title">🖥️ System (${escHtml(sys.hostname || '')})</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;font-size:.82rem">
<div>💻 <strong>${escHtml(sys.macModel || sys.platform)}</strong></div>
<div>🍎 macOS ${escHtml(sys.macOS || '—')}</div>
<div>🧮 CPU: ${sys.cpus} cores · Load: ${load}</div>
<div>💾 RAM: ${memUsed}/${memTotal} GB (${memPct}%)</div>
<div>💿 Disk: ${sys.disk?.used || '—'} / ${sys.disk?.total || '—'} (${sys.disk?.usePct || '—'})</div>
<div>⏱️ Dashboard: ${uptimeH}h ${uptimeM}m</div>
<div>📦 Node: ${escHtml(sys.nodeVersion || '—')}</div>
<div>🦞 OpenClaw: ${escHtml(sys.clawVersion || '—')}</div>
</div>
<div style="margin-top:8px">
<div style="font-size:.72rem;color:var(--text2);margin-bottom:2px">Memory ${memPct}%</div>
<div style="height:8px;border-radius:4px;background:rgba(255,255,255,.1);overflow:hidden">
<div style="height:100%;width:${memPct}%;background:${+memPct>80?'var(--red)':+memPct>60?'var(--yellow)':'var(--green)'};border-radius:4px;transition:width .5s"></div>
</div>
<div style="font-size:.72rem;color:var(--text2);margin:4px 0 2px">Disk ${sys.disk?.usePct || '—'}</div>
<div style="height:8px;border-radius:4px;background:rgba(255,255,255,.1);overflow:hidden">
<div style="height:100%;width:${sys.disk?.usePct || '0%'};background:${parseInt(sys.disk?.usePct)>80?'var(--red)':parseInt(sys.disk?.usePct)>60?'var(--yellow)':'var(--green)'};border-radius:4px;transition:width .5s"></div>
</div>
</div>
</div>`;
} catch {}
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p>${e.message}</p>`; }
}
function timeSince(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
// ─── Config Viewer ───
async function loadConfig() {
const el = document.getElementById('configContent');
if (!el) return;
try {
const data = await apiFetch('/ops/config');
const files = data.files || [];
const cats = { core: '⚙️ Core Config', keys: '🔑 API Keys', personality: '🎭 Personality & Agents' };
const grouped = {};
files.forEach(f => {
const cat = f.category || 'other';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(f);
});
let html = '';
for (const [cat, label] of Object.entries(cats)) {
if (!grouped[cat]) continue;
html += `<div class="card-title" style="margin:12px 0 8px">${label}</div>`;
for (const f of grouped[cat]) {
const id = 'cfg-' + f.label.replace(/[^a-z0-9]/gi, '-');
const sizeKb = (f.size / 1024).toFixed(1);
const modified = new Date(f.modified).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
html += `<div class="config-file">
<div class="config-file-header" onclick="document.getElementById('${id}').classList.toggle('open')">
<span>${escHtml(f.label)}<span class="config-cat ${cat}">${cat}</span></span>
<span class="config-file-meta">${sizeKb}KB · ${modified}</span>
</div>
<div class="config-file-body" id="${id}"><pre>${escHtml(f.content)}</pre></div>
</div>`;
}
}
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p>${e.message}</p>`; }
}
// ─── Enhanced Cron ───
async function loadCronEnhanced() {
const panel = document.getElementById('panel-tasks');
if (!panel) return;
const opsGrid = panel.querySelector('.ops-grid');
if (!opsGrid) return;
let jobsContainer = document.getElementById('cronJobsContainer');
if (!jobsContainer) {
jobsContainer = document.createElement('div');
jobsContainer.id = 'cronJobsContainer';
jobsContainer.style.marginBottom = '12px';
panel.insertBefore(jobsContainer, opsGrid);
}
try {
const data = await apiFetch('/ops/cron');
const jobs = data.jobs || [];
let html = `<div class="glass-card" style="padding:14px;margin-bottom:12px">
<div class="card-title">Cron Jobs</div>
<div class="card-sub">${data.total} 个任务 · ${data.enabled} 启用 · ${data.disabled} 停用</div>
</div>`;
for (const j of jobs) {
const statusDot = !j.enabled ? 'off' : (j.lastRun?.status === 'finished' ? 'ok' : (j.lastRun?.status ? 'fail' : 'ok'));
const lastRunText = j.lastRun ? (() => {
const ago = timeSince(j.lastRun.ts || Date.now());
const dur = j.lastRun.durationMs ? (j.lastRun.durationMs / 1000).toFixed(0) + 's' : '';
const tokens = j.lastRun.tokens ? fmtTokens(j.lastRun.tokens) : '';
return [ago, dur, tokens, j.lastRun.model ? shortModel(j.lastRun.model) : ''].filter(Boolean).join(' · ');
})() : '尚未运行';
html += `<div class="cron-card ${j.enabled ? '' : 'disabled'}">
<div class="cron-header">
<div>
<div class="cron-name">${escHtml(j.name)}</div>
<div class="cron-schedule">🕐 ${escHtml(j.schedule)}</div>
</div>
<span class="cron-status"><span class="dot ${statusDot}"></span>${j.enabled ? '启用' : '停用'}</span>
</div>
<div class="cron-desc">${escHtml(j.description)}</div>
<div class="cron-footer">
<span>📋 ${j.payloadKind || '—'}</span>
<span>🧠 ${buildModelSelect(j.model || '', j.id, 'cron')}</span>
<span>⏱ 上次: ${lastRunText}</span>
</div>
</div>`;
}
jobsContainer.innerHTML = html;
} catch (e) {
jobsContainer.innerHTML = `<div class="glass-card" style="padding:14px;margin-bottom:12px"><p>${escHtml(e.message)}</p></div>`;
}
}
// ─── Ops Channel Usage Panel ───
const MODEL_COLORS = {
'claude-opus-4-6': '#c084fc', 'claude-sonnet-4-6': '#818cf8',
'gpt-5.2-codex': '#34d399', 'gpt-5.2': '#6ee7b7',
'gemini-3-pro': '#fbbf24', 'gemini-3-flash': '#fcd34d',
'gemini-3-pro-preview': '#fbbf24', 'gemini-3-flash-preview': '#fcd34d',
};
function getModelColor(model) {
const key = Object.keys(MODEL_COLORS).find(k => (model || '').includes(k));
return key ? MODEL_COLORS[key] : '#6b7280';
}
function shortModel(m) {
return (m || 'unknown')
.replace(/-preview$/, '')
.replace('claude-', '')
.replace('gemini-3-pro', 'Gemini-3-Pro')
.replace('gemini-3-flash', 'Gemini-3-Flash')
.replace('gpt-5.3-', 'gpt-')
.replace('gpt-5.2-', 'gpt-');
}
function fmtTokens(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'k';
return String(n);
}
function fmtUsd(n, digits = 2) {
return '$' + (Number(n || 0)).toFixed(digits);
}
async function loadCronCosts() {
const summaryEl = document.getElementById('cronCostSummary');
const contentEl = document.getElementById('cronCostContent');
const canvas = document.getElementById('cronTrendChart');
const legendEl = document.getElementById('cronTrendLegend');
if (!contentEl) return;
try {
const data = await apiFetch('/ops/cron-costs');
const s = data.summary || {};
const today = s.today || {};
summaryEl.textContent = `累计 ${s.totalRuns || 0} 次执行 · 总固定成本 ${fmtUsd(s.totalCronCost, 2)}${fmtTokens(s.totalCronTokens || 0)} tokens · 日均固定成本 ${fmtUsd(s.avgDailyCronCost, 2)} · 基线固定 ${fmtUsd(s.avgFixedBaselineCost, 2)} / 任务量波动 ${fmtUsd(s.avgWorkloadVariableCost, 2)} / 交互波动 ${fmtUsd(s.avgInteractiveVariableCost, 2)} · 今日固定 ${fmtUsd(today.cronCost, 2)}${fmtTokens(today.cronTokens || 0)} · 今日浮动 ${fmtUsd(today.interactiveCost, 2)} · ${s.days || 0}`;
// Per-job cost table (each run + each day)
const jobs = data.jobs || [];
const review = data.review || {};
const rc = review.cron || {};
const ri = review.interactive || {};
const cov = review.coverage || {};
let html = `<div class="glass-card" style="padding:10px;margin-bottom:10px">
<div style="font-size:.8rem;font-weight:600;margin-bottom:6px">🔎 数据质量 Review</div>
<div style="font-size:.74rem;color:var(--text2);display:flex;gap:14px;flex-wrap:wrap">
<span>Cron finished: <b>${rc.finishedRuns || 0}</b></span>
<span>无 usage: <b style="color:${(rc.runsWithoutUsage || 0) > 0 ? '#fbbf24' : 'var(--green)'}">${rc.runsWithoutUsage || 0}</b></span>
<span>零 tokens: <b style="color:${(rc.runsWithZeroTokens || 0) > 0 ? '#fbbf24' : 'var(--green)'}">${rc.runsWithZeroTokens || 0}</b></span>
<span>交互覆盖天数: <b>${cov.daysWithInteractive || 0}/${cov.daysWithCron || 0}</b>${cov.interactiveCoveragePct || 0}%</span>
<span>交互 usage消息: <b>${ri.messagesWithUsage || 0}</b></span>
</div>
${(review.notes || []).length > 0 ? `<div style="margin-top:6px;font-size:.72rem;color:#fbbf24">${(review.notes || []).map(n => `${escHtml(n)}`).join('<br>')}</div>` : ''}
</div>`;
html += '<table class="sessions-table"><thead><tr><th>Cron 任务</th><th>总次数</th><th>平均时长/次</th><th>Tokens/次</th><th>$/次</th><th>今日tokens / $</th><th>日均 $</th><th>总花费</th></tr></thead><tbody>';
for (const j of jobs) {
html += `<tr>
<td style="font-weight:600;font-size:.78rem">
${escHtml(j.name)}
<div style="margin-top:4px"><span class="sess-model" style="border-color:${getModelColor(j.model)};color:${getModelColor(j.model)}">${shortModel(j.model)}</span></div>
</td>
<td>${j.runs}</td>
<td>${j.avgDurationSec ? `${j.avgDurationSec.toFixed(1)}s` : '—'}</td>
<td>${fmtTokens(j.tokensPerRun || 0)}</td>
<td style="color:${j.costPerRun > 0.2 ? '#fbbf24' : 'var(--green)'}">${fmtUsd(j.costPerRun, 3)}</td>
<td>${fmtTokens(j.today?.tokens || 0)} / ${fmtUsd(j.today?.cost, 3)}</td>
<td>${fmtUsd(j.avgDailyCost, 3)}</td>
<td style="font-weight:600">${fmtUsd(j.totalCost, 2)}</td>
</tr>`;
}
html += '</tbody></table>';
// Daily breakdown per cron (last 7 days)
if (jobs.length > 0) {
html += '<div style="margin-top:12px;display:grid;gap:8px">';
for (const j of jobs) {
const daily = (j.daily || []).slice(-7).reverse();
html += `<details class="glass-card" style="padding:10px">
<summary style="cursor:pointer;font-size:.78rem;font-weight:600">${escHtml(j.name)} · 最近 ${daily.length} 天每日成本</summary>
<div style="margin-top:8px;overflow:auto">
<table class="sessions-table" style="font-size:.74rem">
<thead><tr><th>日期</th><th>次数</th><th>Tokens</th><th>Tokens/次</th><th>$/次</th><th>当日总$</th></tr></thead>
<tbody>
${daily.map(d => `<tr>
<td>${d.date}</td>
<td>${d.runs}</td>
<td>${fmtTokens(d.tokens || 0)}</td>
<td>${fmtTokens(d.tokensPerRun || 0)}</td>
<td>${fmtUsd(d.costPerRun, 3)}</td>
<td style="font-weight:600">${fmtUsd(d.cost, 3)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
</details>`;
}
html += '</div>';
}
contentEl.innerHTML = html;
// Trend chart: stacked bar (cron fixed + interactive variable)
const trend = data.dailyTrend || [];
if (canvas && trend.length > 1) {
const ctx = canvas.getContext('2d');
const W = canvas.parentElement.clientWidth - 32;
const H = 160;
canvas.width = W * 2; canvas.height = H * 2;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
ctx.scale(2, 2);
const maxCost = Math.max(...trend.map(d => d.totalCost), 1);
const barW = Math.min(40, (W - 40) / trend.length - 4);
const startX = 36;
const chartH = H - 30;
ctx.clearRect(0, 0, W, H);
// Y axis labels
ctx.fillStyle = '#8b949e'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const y = 10 + chartH - (i / 4) * chartH;
ctx.fillText('$' + (maxCost * i / 4).toFixed(0), 30, y + 3);
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(W, y); ctx.stroke();
}
trend.forEach((d, i) => {
const x = startX + i * ((W - startX) / trend.length) + 2;
const fixedH = ((d.fixedBaselineCost || 0) / maxCost) * chartH;
const cronVarH = ((d.workloadVariableCost || 0) / maxCost) * chartH;
const interH = (d.interactiveCost / maxCost) * chartH;
const baseY = 10 + chartH;
// Interactive variable — bottom
ctx.fillStyle = 'rgba(124,92,252,0.6)';
ctx.fillRect(x, baseY - interH, barW, interH);
// Cron volume variable — middle
ctx.fillStyle = 'rgba(251,191,36,0.75)';
ctx.fillRect(x, baseY - interH - cronVarH, barW, cronVarH);
// Cron fixed baseline — top
ctx.fillStyle = 'rgba(45,212,160,0.8)';
ctx.fillRect(x, baseY - interH - cronVarH - fixedH, barW, fixedH);
// Date label
ctx.fillStyle = '#8b949e'; ctx.font = '9px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(d.date.slice(5), x + barW / 2, baseY + 12);
// Total label
ctx.fillStyle = '#e6edf3'; ctx.font = 'bold 9px sans-serif';
ctx.fillText('$' + d.totalCost.toFixed(2), x + barW / 2, baseY - interH - cronVarH - fixedH - 3);
});
const avgFixed = trend.reduce((sum, d) => sum + (d.fixedCostSharePct || 0), 0) / trend.length;
legendEl.innerHTML = `<span style="color:#2dd4a0">■ 固定基线Cron</span><span style="color:#fbbf24">■ 任务量波动Cron</span><span style="color:#7c5cfc">■ 交互浮动成本</span><span style="color:#8b949e">固定占比均值 ${avgFixed.toFixed(0)}%</span>`;
} else if (legendEl) {
legendEl.textContent = '趋势数据不足(至少需要 2 天)';
}
} catch (e) {
contentEl.innerHTML = `<p>${e.message}</p>`;
}
}
async function loadOpsChannels() {
const listEl = document.getElementById('opsChannelList');
const subEl = document.getElementById('opsTotalSub');
const pillsEl = document.getElementById('opsTotalPills');
const barEl = document.getElementById('opsModelBar');
if (!listEl) return;
try {
const data = await apiFetch('/ops/channels');
const totals = data.totals || {};
const channels = data.channels || [];
// Total summary
if (subEl) subEl.textContent = `${fmtTokens(totals.totalTokens)} tokens · ${totals.messages || 0} messages · $${(totals.cost || 0).toFixed(2)}`;
if (pillsEl) {
const modelEntries = Object.entries(totals.models || {}).filter(([,t]) => t > 0).sort((a, b) => b[1] - a[1]);
pillsEl.innerHTML = modelEntries.slice(0, 4).map(([m, t]) =>
`<span class="pill" style="border-color:${getModelColor(m)};color:${getModelColor(m)}">${shortModel(m)} ${fmtTokens(t)}</span>`
).join('');
}
// Model distribution bar
if (barEl) {
const modelEntries = Object.entries(totals.models || {}).filter(([,t]) => t > 0).sort((a, b) => b[1] - a[1]);
const total = totals.totalTokens || 1;
// Bar segments (flex container) + legend below (separate div)
barEl.innerHTML =
'<div class="ops-bar-track">' +
modelEntries.map(([m, t]) =>
`<div style="width:${(t/total*100).toFixed(2)}%;background:${getModelColor(m)}" title="${shortModel(m)}: ${fmtTokens(t)}"></div>`
).join('') +
'</div>' +
'<div class="ops-model-legend">' + modelEntries.map(([m, t]) =>
`<span class="ops-model-legend-item"><span class="ops-model-dot" style="background:${getModelColor(m)}"></span>${shortModel(m)} ${((t/total)*100).toFixed(0)}%</span>`
).join('') + '</div>';
}
// Channel list
if (channels.length === 0) {
listEl.innerHTML = '<div class="empty-state"><h3>No activity today</h3><p>Channel usage will appear here once messages flow.</p></div>';
return;
}
listEl.innerHTML = channels.map(ch => {
const modelList = Object.entries(ch.today.models || {}).sort((a, b) => b[1] - a[1]);
const topModel = modelList[0] ? shortModel(modelList[0][0]) : '—';
const icon = ch.channel === 'discord' ? '🎮' : (ch.channel === 'whatsapp' ? '📱' : '💬');
const name = (ch.displayName || '').replace(/^discord:\d+#/, '#');
return `<div class="ops-channel-card">
<div class="ops-ch-left">
<div class="ops-ch-name">${icon} ${escHtml(name)}</div>
<div class="ops-ch-meta">
<span>${ch.today.messages} msgs</span>
<span>${topModel}</span>
<span>${ch.status}</span>
</div>
</div>
<div class="ops-ch-right">
<div class="ops-ch-tokens">${fmtTokens(ch.today.totalTokens)}</div>
<div class="ops-ch-cost">$${(ch.today.cost || 0).toFixed(2)}</div>
</div>
</div>`;
}).join('');
} catch (e) {
listEl.innerHTML = `<div class="empty-state"><h3>Unable to load</h3><p>${e.message}</p></div>`;
}
}
async function loadOpsAlltime() {
const modelsEl = document.getElementById('alltimeModels');
const subEl = document.getElementById('alltimeSub');
const auditEl = document.getElementById('auditStatus');
const canvas = document.getElementById('dailyChart');
if (!modelsEl) return;
try {
const data = await apiFetch('/ops/alltime');
const t = data.totals || {};
if (subEl) subEl.textContent = `${fmtTokens(t.tokens)} tokens · ${t.messages || 0} messages · $${(t.cost || 0).toFixed(2)} est. · ${data.sessionFiles || 0} sessions`;
// Model breakdown
const models = data.models || [];
modelsEl.innerHTML = models.map(m => {
const pct = t.tokens > 0 ? ((m.tokens / t.tokens) * 100).toFixed(1) : '0';
return `<div class="ops-channel-card">
<div class="ops-ch-left">
<div class="ops-ch-name" style="font-size:.85rem"><span class="ops-model-dot" style="background:${getModelColor(m.name)};display:inline-block;margin-right:6px"></span>${shortModel(m.name)}</div>
<div class="ops-ch-meta"><span>${m.messages} msgs</span><span>${pct}%</span></div>
</div>
<div class="ops-ch-right">
<div class="ops-ch-tokens">${fmtTokens(m.tokens)}</div>
<div class="ops-ch-cost">$${(m.cost || 0).toFixed(2)}</div>
</div>
</div>`;
}).join('');
// Daily charts
const daily = data.recentDaily || [];
if (canvas && daily.length > 0) {
drawDailyChart(canvas, daily);
}
const costCanvas = document.getElementById('dailyCostChart');
if (costCanvas && daily.length > 0) {
drawDailyCostChart(costCanvas, daily);
renderCostHeatmap(daily);
}
// Audit status
if (auditEl && data.audit) {
const a = data.audit;
auditEl.innerHTML = `🔍 <strong>Third-party Audit:</strong>
OpenAI <span class="pill">${a.openai?.status}</span>
Anthropic <span class="pill">${a.anthropic?.status}</span>
Google <span class="pill">${a.google?.status}</span>`;
}
} catch (e) {
modelsEl.innerHTML = `<div class="empty-state"><p>${e.message}</p></div>`;
}
}
async function loadOpsAudit() {
const el = document.getElementById('auditContent');
if (!el) return;
try {
const data = await apiFetch('/ops/audit');
let html = '';
// OpenAI
const oi = data.openai;
if (oi?.status === 'ok') {
const t = oi.totals;
const modelRows = Object.entries(oi.models || {}).sort((a, b) => b[1].input - a[1].input).map(([m, d]) =>
`<div class="ops-channel-card" style="padding:8px 12px">
<div class="ops-ch-left"><div class="ops-ch-name" style="font-size:.82rem">🟢 ${escHtml(m)}</div>
<div class="ops-ch-meta"><span>${d.requests} reqs</span><span>cached: ${fmtTokens(d.cached)}</span></div></div>
<div class="ops-ch-right"><div class="ops-ch-tokens">${fmtTokens(d.input + d.output)}</div></div></div>`
).join('');
html += `<div style="margin-bottom:12px">
<div style="font-weight:600;margin-bottom:6px">OpenAI <span class="pill" style="border-color:#34d399;color:#34d399">✓ verified</span></div>
<div class="ops-ch-meta" style="margin-bottom:8px">7d: ${fmtTokens(t.input)} in + ${fmtTokens(t.output)} out · ${t.requests} reqs · ${fmtTokens(t.cached)} cached</div>
<div class="ops-channel-list">${modelRows}</div>
${Object.keys(oi.days||{}).length > 0 ? `<div class="ops-ch-meta" style="margin-top:6px">Days: ${Object.entries(oi.days).sort().map(([d,v])=>d.slice(5)+':'+fmtTokens(v.input+v.output)).join(' · ')}</div>` : ''}
</div>`;
} else {
html += `<div style="margin-bottom:8px">OpenAI <span class="pill">${oi?.status || 'unknown'}</span> ${oi?.error || ''}</div>`;
}
// Anthropic
const ac = data.anthropic;
if (ac?.status === 'org_only') {
html += `<div style="margin-bottom:8px">
<div style="font-weight:600;margin-bottom:4px">Anthropic <span class="pill" style="border-color:#c084fc;color:#c084fc">org verified</span></div>
<div class="ops-ch-meta">Org: ${escHtml(ac.org?.name)} · ${ac.activeKeys?.length || 0} active keys</div>
<div class="ops-ch-meta" style="margin-top:2px;font-style:italic">${ac.note}</div>
</div>`;
} else {
html += `<div style="margin-bottom:8px">Anthropic <span class="pill">${ac?.status || 'unknown'}</span></div>`;
}
// Google
html += `<div>Google <span class="pill">${data.google?.status || 'no_api'}</span> <span class="ops-ch-meta">${data.google?.note || ''}</span></div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="ops-ch-meta">Failed: ${e.message}</div>`;
}
}
// ─── Ops Management Actions ───
async function opsAction(action) {
const btnMap = { backup: 'btnBackup', restore: 'btnRestore', updateOpenClaw: 'btnUpdateOpenClaw', restart: 'btnRestart' };
const badgeMap = { backup: 'badgeBackup', restore: 'badgeRestore', updateOpenClaw: 'badgeUpdateOpenClaw', restart: 'badgeRestart' };
const btn = document.getElementById(btnMap[action]);
const badge = document.getElementById(badgeMap[action]);
const resultBox = document.getElementById('opsMgmtResult');
const resultInner = document.getElementById('opsMgmtResultInner');
if (badge) badge.textContent = '⏳';
if (btn) btn.classList.add('loading');
if (resultBox) resultBox.style.display = 'none';
try {
let html = '';
if (action === 'restart') {
const r = await fetch('http://127.0.0.1:18789/hooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restart', token: TOKEN })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
if (badge) badge.textContent = '✅';
html = '<span style="color:var(--green)">✅ Restart signal sent to OpenClaw gateway.</span>';
} else if (action === 'backup') {
const data = await apiFetch('/backup', { method: 'POST' });
if (!data.ok) throw new Error(data.error || data.push?.error || 'backup failed');
if (badge) badge.textContent = '✅';
html = `<span style="color:var(--green)">✅ Backup + push completed.</span>
<div class="ops-cost-row"><span class="ops-cost-label">Remote</span><span class="ops-cost-value">${escHtml(data.push?.remote || 'n/a')}</span></div>
<div class="ops-cost-row"><span class="ops-cost-label">Branch</span><span class="ops-cost-value">${escHtml(data.push?.branch || 'n/a')}</span></div>
<pre style="margin-top:8px;font-size:.75rem;color:var(--text2);white-space:pre-wrap;word-break:break-all">${escHtml(data.output || '')}</pre>`;
} else if (action === 'restore') {
const ok = confirm('Load latest auto-backup now? This will overwrite current workspace changes.');
if (!ok) {
if (badge) badge.textContent = '';
html = '<span style="color:var(--text2)">Canceled.</span>';
} else {
const data = await apiFetch('/backup/load', { method: 'POST' });
if (badge) badge.textContent = '✅';
html = `<span style="color:var(--green)">✅ Backup loaded.</span>
<div class="ops-cost-row"><span class="ops-cost-label">Commit</span><span class="ops-cost-value">${escHtml((data.restoredCommit || '').slice(0, 12) || 'n/a')}</span></div>
<pre style="margin-top:8px;font-size:.75rem;color:var(--text2);white-space:pre-wrap;word-break:break-all">${escHtml(data.output || '')}</pre>`;
}
} else if (action === 'updateOpenClaw') {
const ok = confirm('Run OpenClaw update now? This will write memory, backup, push, then update.');
if (!ok) {
if (badge) badge.textContent = '';
html = '<span style="color:var(--text2)">Canceled.</span>';
} else {
const data = await apiFetch('/ops/update-openclaw', { method: 'POST' });
if (!data.ok) throw new Error('Update flow failed. See details below.');
if (badge) badge.textContent = '✅';
const stepRows = (data.steps || []).map(s =>
`<div class="ops-cost-row"><span class="ops-cost-label">${escHtml(s.step || 'step')}</span><span class="ops-cost-value">${s.ok ? '✅ ok' : '❌ failed'}</span></div>`
).join('');
const updateStep = (data.steps || []).find(s => s.step === 'update_openclaw') || {};
html = `<span style="color:var(--green)">✅ OpenClaw updated successfully.</span>
${stepRows}
<div class="ops-cost-row"><span class="ops-cost-label">Before</span><span class="ops-cost-value">${escHtml(updateStep.beforeVersion || 'n/a')}</span></div>
<div class="ops-cost-row"><span class="ops-cost-label">After</span><span class="ops-cost-value">${escHtml(updateStep.afterVersion || 'n/a')}</span></div>
<pre style="margin-top:8px;font-size:.75rem;color:var(--text2);white-space:pre-wrap;word-break:break-all">${escHtml(updateStep.output || '')}</pre>`;
}
}
if (resultInner) resultInner.innerHTML = html;
if (resultBox) resultBox.style.display = 'block';
} catch (e) {
if (badge) badge.textContent = '❌';
if (resultInner) resultInner.innerHTML = `<span style="color:var(--red)">❌ ${escHtml(e.message)}</span>`;
if (resultBox) resultBox.style.display = 'block';
} finally {
if (btn) btn.classList.remove('loading');
pollWatchdogStatus();
}
}
function drawDailyChart(canvas, daily) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const W = rect.width, H = rect.height;
const pad = { top: 10, right: 10, bottom: 24, left: 50 };
const cW = W - pad.left - pad.right;
const cH = H - pad.top - pad.bottom;
const maxTokens = Math.max(...daily.map(d => d.tokens), 1);
const barW = Math.max(4, (cW / daily.length) - 2);
// Collect all models across days
const allModels = new Set();
daily.forEach(d => Object.keys(d.models || {}).forEach(m => { if ((d.models[m] || 0) > 0) allModels.add(m); }));
const modelList = [...allModels].sort((a, b) => {
const ta = daily.reduce((s, d) => s + (d.models?.[a] || 0), 0);
const tb = daily.reduce((s, d) => s + (d.models?.[b] || 0), 0);
return tb - ta;
});
ctx.clearRect(0, 0, W, H);
// Grid lines
ctx.strokeStyle = '#1a1f2e';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (cH * i / 4);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
}
// Stacked bars by model
daily.forEach((d, i) => {
const x = pad.left + (i * cW / daily.length) + 1;
let yOffset = pad.top + cH; // bottom
modelList.forEach(m => {
const t = d.models?.[m] || 0;
if (t <= 0) return;
const h = (t / maxTokens) * cH;
yOffset -= h;
ctx.fillStyle = getModelColor(m);
ctx.beginPath();
ctx.roundRect(x, yOffset, barW, h, 1);
ctx.fill();
});
// Token count above bar
if (d.tokens > 0) {
ctx.fillStyle = '#c9d1d9';
ctx.font = 'bold 9px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(fmtTokens(d.tokens), x + barW / 2, yOffset - 3);
}
// Date label
ctx.fillStyle = '#8b949e';
ctx.font = '9px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(d.date.slice(5), x + barW / 2, pad.top + cH + 14);
});
// Y-axis labels
ctx.fillStyle = '#8b949e';
ctx.font = '9px -apple-system, sans-serif';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const val = maxTokens * (4 - i) / 4;
const y = pad.top + (cH * i / 4) + 3;
ctx.fillText(fmtTokens(val), pad.left - 6, y);
}
// Legend below chart
const legendEl = canvas.parentElement?.querySelector('.chart-legend');
if (legendEl) {
legendEl.innerHTML = modelList.map(m =>
`<span class="ops-model-legend-item"><span class="ops-model-dot" style="background:${getModelColor(m)}"></span>${shortModel(m)}</span>`
).join('');
}
}
function renderCostHeatmap(daily) {
const el = document.getElementById('costHeatmap');
if (!el || !daily.length) return;
// Collect all models with cost > 0
const allModels = new Set();
daily.forEach(d => Object.entries(d.modelCosts || {}).forEach(([m, c]) => { if (c > 0.01) allModels.add(m); }));
const models = [...allModels].sort((a, b) => {
const ta = daily.reduce((s, d) => s + (d.modelCosts?.[a] || 0), 0);
const tb = daily.reduce((s, d) => s + (d.modelCosts?.[b] || 0), 0);
return tb - ta;
});
// Find max cell value for heat coloring
let maxCell = 0;
daily.forEach(d => models.forEach(m => { maxCell = Math.max(maxCell, d.modelCosts?.[m] || 0); }));
function heatBg(val) {
if (val < 0.01) return 'transparent';
const intensity = Math.min(val / maxCell, 1);
// From dark to bright: low=dim, high=vivid
const alpha = 0.15 + intensity * 0.65;
return `rgba(124, 92, 252, ${alpha.toFixed(2)})`;
}
function fmtCost(v) {
if (v < 0.01) return '—';
if (v < 1) return '$' + v.toFixed(2);
return '$' + v.toFixed(0);
}
// Header: Model | Day1 | Day2 | ... | Total
let html = '<table><thead><tr><th></th>';
daily.forEach(d => { html += `<th>${d.date.slice(5)}</th>`; });
html += '<th>Total</th></tr></thead><tbody>';
// Rows: one per model
models.forEach(m => {
html += `<tr><td><span class="ops-model-dot" style="background:${getModelColor(m)};display:inline-block;margin-right:4px;vertical-align:middle"></span>${shortModel(m)}</td>`;
let rowTotal = 0;
daily.forEach(d => {
const v = d.modelCosts?.[m] || 0;
rowTotal += v;
html += `<td><span class="heat-cell" style="background:${heatBg(v)}">${fmtCost(v)}</span></td>`;
});
html += `<td><strong>${fmtCost(rowTotal)}</strong></td></tr>`;
});
// Total row
html += '<tr class="total-row"><td>Total</td>';
let grandTotal = 0;
daily.forEach(d => {
const dayTotal = d.cost || 0;
grandTotal += dayTotal;
html += `<td><strong>${fmtCost(dayTotal)}</strong></td>`;
});
html += `<td><strong>${fmtCost(grandTotal)}</strong></td></tr>`;
html += '</tbody></table>';
el.innerHTML = html;
}
function drawDailyCostChart(canvas, daily) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const W = rect.width, H = rect.height;
const pad = { top: 14, right: 10, bottom: 24, left: 50 };
const cW = W - pad.left - pad.right;
const cH = H - pad.top - pad.bottom;
// Collect models sorted by total cost
const allModels = new Set();
daily.forEach(d => Object.keys(d.modelCosts || {}).forEach(m => { if ((d.modelCosts[m] || 0) > 0) allModels.add(m); }));
const modelList = [...allModels].sort((a, b) => {
const ta = daily.reduce((s, d) => s + (d.modelCosts?.[a] || 0), 0);
const tb = daily.reduce((s, d) => s + (d.modelCosts?.[b] || 0), 0);
return tb - ta;
});
const maxCost = Math.max(...daily.map(d => d.cost || 0), 1);
const barW = Math.max(4, (cW / daily.length) - 2);
ctx.clearRect(0, 0, W, H);
// Grid
ctx.strokeStyle = '#1a1f2e';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (cH * i / 4);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W - pad.right, y); ctx.stroke();
}
// Stacked cost bars
daily.forEach((d, i) => {
const x = pad.left + (i * cW / daily.length) + 1;
let yOffset = pad.top + cH;
modelList.forEach(m => {
const c = d.modelCosts?.[m] || 0;
if (c <= 0) return;
const h = (c / maxCost) * cH;
yOffset -= h;
ctx.fillStyle = getModelColor(m);
ctx.beginPath();
ctx.roundRect(x, yOffset, barW, h, 1);
ctx.fill();
});
// Total cost label above bar
if ((d.cost || 0) > 0) {
ctx.fillStyle = '#c9d1d9';
ctx.font = 'bold 9px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('$' + (d.cost).toFixed(0), x + barW / 2, yOffset - 3);
}
// Date label
ctx.fillStyle = '#8b949e';
ctx.font = '9px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(d.date.slice(5), x + barW / 2, pad.top + cH + 14);
});
// Y-axis (dollar)
ctx.fillStyle = '#8b949e';
ctx.font = '9px -apple-system, sans-serif';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const val = maxCost * (4 - i) / 4;
const y = pad.top + (cH * i / 4) + 3;
ctx.fillText('$' + val.toFixed(0), pad.left - 6, y);
}
// Legend
const legendEl = canvas.parentElement?.querySelector('.chart-cost-legend');
if (legendEl) {
legendEl.innerHTML = modelList.map(m =>
`<span class="ops-model-legend-item"><span class="ops-model-dot" style="background:${getModelColor(m)}"></span>${shortModel(m)}</span>`
).join('');
}
}
// ═══ ATTACHMENTS ═══
let currentAttachments = [];
async function loadAttachments(taskId) {
try {
currentAttachments = await apiFetch(`/tasks/${taskId}/attachments`);
if (!Array.isArray(currentAttachments)) currentAttachments = [];
} catch (e) {
currentAttachments = [];
}
renderAttachments(taskId);
}
function renderAttachments(taskId) {
const container = document.getElementById('attachmentsGrid');
const countEl = document.getElementById('attachmentsCount');
if (!container) return;
countEl.textContent = currentAttachments.length;
if (currentAttachments.length === 0) {
container.innerHTML = `<div class="att-empty" style="grid-column:1/-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
No attachments yet — drag & drop or click below to upload
</div>`;
return;
}
container.innerHTML = currentAttachments.map(file => {
const fileUrl = `${API}/tasks/${taskId}/attachments/${encodeURIComponent(file.name)}`;
const fileIcon = getFileIcon(file.ext);
return `<div class="attachment-card" onclick="${file.isImage ? `openLightbox('${escAttr(fileUrl)}', '${escAttr(file.name)}', '${formatSize(file.size)}')` : `window.open('${escAttr(fileUrl)}&download=1','_blank')`}">
<button class="att-delete" onclick="event.stopPropagation();deleteAttachment('${taskId}','${escAttr(file.name)}')" title="Delete">✕</button>
<div class="att-preview">
${file.isImage
? `<img src="${fileUrl}" alt="${escHtml(file.name)}" loading="lazy">`
: `<span class="att-icon">${fileIcon}</span>`}
</div>
<div class="att-info">
<div class="att-name" title="${escHtml(file.name)}">${escHtml(file.name)}</div>
<div class="att-size">${formatSize(file.size)}</div>
</div>
</div>`;
}).join('');
}
function getFileIcon(ext) {
const icons = {
'.pdf': '📄', '.doc': '📝', '.docx': '📝', '.txt': '📃', '.md': '📃',
'.xls': '📊', '.xlsx': '📊', '.csv': '📊',
'.pptx': '📽️', '.ppt': '📽️',
'.zip': '📦', '.rar': '📦', '.tar': '📦', '.gz': '📦',
'.json': '{ }', '.xml': '🏷️', '.html': '🌐',
'.mp4': '🎬', '.mov': '🎬', '.avi': '🎬',
'.mp3': '🎵', '.wav': '🎵', '.ogg': '🎵',
'.py': '🐍', '.js': '📜', '.ts': '📜',
};
return icons[ext] || '📎';
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function escAttr(s) {
return s.replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
async function uploadAttachment(taskId, file, source) {
const progressEl = document.getElementById('attUploadProgress');
const barFill = document.getElementById('attUploadBarFill');
const statusEl = document.getElementById('attUploadStatus');
if (file.size > 20 * 1024 * 1024) {
toast('File too large (max 20MB)', 'error');
return;
}
progressEl.style.display = '';
barFill.style.width = '20%';
statusEl.textContent = `Uploading ${file.name}`;
try {
const base64 = await fileToBase64(file);
barFill.style.width = '60%';
await apiFetch(`/tasks/${taskId}/attachments`, {
method: 'POST',
body: JSON.stringify({
filename: file.name,
data: base64,
source: source || 'user',
}),
});
barFill.style.width = '100%';
statusEl.textContent = 'Upload complete!';
toast(`📎 ${file.name} attached`, 'success');
setTimeout(() => {
progressEl.style.display = 'none';
barFill.style.width = '0%';
}, 1500);
loadAttachments(taskId);
loadTasks(false); // refresh notes
} catch (e) {
progressEl.style.display = 'none';
barFill.style.width = '0%';
toast(`Upload failed: ${e.message}`, 'error');
}
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function deleteAttachment(taskId, filename) {
if (!confirm(`Delete "${filename}"?`)) return;
try {
await apiFetch(`/tasks/${taskId}/attachments/${encodeURIComponent(filename)}`, { method: 'DELETE' });
toast('Attachment deleted', 'success');
loadAttachments(taskId);
loadTasks(false);
} catch (e) {
toast(`Delete failed: ${e.message}`, 'error');
}
}
function setupDropZone(taskId) {
const zone = document.getElementById('attDropZone');
const fileInput = document.getElementById('attFileInput');
if (!zone || !fileInput) return;
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
zone.ondragleave = () => zone.classList.remove('drag-over');
zone.ondrop = (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
const files = e.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
uploadAttachment(taskId, files[i], 'user');
}
};
fileInput.onchange = () => {
for (let i = 0; i < fileInput.files.length; i++) {
uploadAttachment(taskId, fileInput.files[i], 'user');
}
fileInput.value = '';
};
}
// ─── Lightbox ───
function openLightbox(url, name, size) {
const overlay = document.getElementById('lightboxOverlay');
const img = document.getElementById('lightboxImg');
const nameEl = document.getElementById('lightboxName');
const sizeEl = document.getElementById('lightboxSize');
const downloadEl = document.getElementById('lightboxDownload');
img.src = url;
nameEl.textContent = name;
sizeEl.textContent = size;
downloadEl.href = url + '&download=1';
overlay.classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
const overlay = document.getElementById('lightboxOverlay');
overlay.classList.remove('show');
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('lightboxOverlay').classList.contains('show')) {
closeLightbox();
}
});
// ═══ INIT ═══
checkConnection();
loadSessions(); // Load sessions tab (default)
loadAgentMonitor();
pollWatchdogStatus();
startLivePolling(); // Auto-poll tasks every 3s for live updates
setInterval(checkConnection, 30000);
setInterval(loadAgentMonitor, 60000); // was 15s, reduced to 60s // Refresh agent monitor every 15s
setInterval(pollWatchdogStatus, 10000);
// Kanban mobile resize handler
window.addEventListener('resize', () => {
if (taskView === 'kanban') {
const board = document.getElementById('kanbanBoard');
if (window.innerWidth <= 768) board.classList.add('horizontal-scroll');
else board.classList.remove('horizontal-scroll');
}
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeDetailModal(); closeCreateModal(); }
if (e.key === 'n' && e.ctrlKey && e.shiftKey) { e.preventDefault(); openCreateModal(); }
});
</script>
<footer style="text-align:center;padding:24px 0 16px;color:#8b949e;font-size:0.85rem;font-family:'Outfit',sans-serif;">
Built by <a href="https://github.com/JonathanJing" target="_blank" style="color:#7c5cfc;text-decoration:none;">Jony Jing</a> ·
<a href="https://github.com/JonathanJing/openclaw-dashboard" target="_blank" style="color:#8b949e;text-decoration:none;">GitHub</a>
</footer>
</body>
</html>