Archived
1915 lines
100 KiB
HTML
1915 lines
100 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Neuron Code</title>
|
|
<style>
|
|
/* ── Reset & base ─────────────────────────────────────────────── */
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
:root{
|
|
--bg:#0a0a0f;--surface:#111118;--surface2:#1a1a28;--border:#2a2a3a;
|
|
--accent:#6366f1;--accent2:#818cf8;--text:#e2e8f0;--muted:#64748b;
|
|
--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--blue:#3b82f6;--purple:#a855f7;
|
|
}
|
|
html,body{height:100%;background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;overflow:hidden}
|
|
button{cursor:pointer;border:none;background:none;color:inherit;font:inherit}
|
|
input,textarea,select{font:inherit;color:inherit;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:6px 10px;outline:none}
|
|
input:focus,textarea:focus,select:focus{border-color:var(--accent)}
|
|
a{color:var(--accent2);text-decoration:none}
|
|
|
|
/* ── Layout ───────────────────────────────────────────────────── */
|
|
#app{display:flex;flex-direction:column;height:100vh}
|
|
#topbar{display:flex;align-items:center;gap:12px;padding:0 16px;height:48px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;z-index:10}
|
|
#main-area{display:flex;flex:1;overflow:hidden}
|
|
#sidebar{width:200px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
|
#content{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
#ticker{height:28px;background:#0d0d1a;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 12px;font-size:11px;color:var(--muted);overflow:hidden;flex-shrink:0}
|
|
#pane-area{flex:1;overflow:hidden;display:flex}
|
|
|
|
/* ── Top bar ──────────────────────────────────────────────────── */
|
|
#logo{font-size:15px;font-weight:700;color:var(--accent2);letter-spacing:-.5px;margin-right:8px}
|
|
#logo span{color:var(--text)}
|
|
#server-status{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green)}
|
|
#server-status.offline{background:var(--red);box-shadow:0 0 6px var(--red)}
|
|
#topbar-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
|
#repo-selector{font-size:12px;padding:4px 8px;max-width:180px}
|
|
#settings-btn{opacity:.6;font-size:16px;padding:4px 8px;border-radius:4px}
|
|
#settings-btn:hover{opacity:1;background:var(--surface2)}
|
|
#fullscreen-btn{opacity:.6;font-size:14px;padding:4px 8px;border-radius:4px}
|
|
#fullscreen-btn:hover{opacity:1;background:var(--surface2)}
|
|
|
|
/* ── Sidebar nav ──────────────────────────────────────────────── */
|
|
#nav-list{padding:8px 0;flex:1}
|
|
.nav-item{display:flex;align-items:center;gap:8px;padding:8px 16px;font-size:13px;cursor:pointer;border-radius:0;color:var(--muted);transition:all .15s}
|
|
.nav-item:hover{color:var(--text);background:var(--surface2)}
|
|
.nav-item.active{color:var(--accent2);background:#1e1e30;border-right:2px solid var(--accent)}
|
|
.nav-item .icon{width:16px;text-align:center;font-size:14px}
|
|
.nav-item .badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;padding:1px 5px;border-radius:10px}
|
|
|
|
/* ── Event ticker ─────────────────────────────────────────────── */
|
|
#ticker-inner{white-space:nowrap;transition:opacity .3s}
|
|
.tick-item{display:inline-block;margin-right:32px}
|
|
.tick-item .evtype{color:var(--accent2);margin-right:6px}
|
|
|
|
/* ── Panes ────────────────────────────────────────────────────── */
|
|
.pane{display:none;flex:1;flex-direction:column;overflow:hidden}
|
|
.pane.active{display:flex}
|
|
.pane-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0}
|
|
.pane-header h2{font-size:15px;font-weight:600}
|
|
.pane-body{flex:1;overflow-y:auto;padding:16px 20px}
|
|
.btn{padding:6px 14px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:none;transition:all .15s}
|
|
.btn-primary{background:var(--accent);color:white}
|
|
.btn-primary:hover{background:#5558e8}
|
|
.btn-secondary{background:var(--surface2);color:var(--text);border:1px solid var(--border)}
|
|
.btn-secondary:hover{border-color:var(--accent);color:var(--accent2)}
|
|
.btn-sm{padding:3px 10px;font-size:11px}
|
|
.btn-danger{background:#3b1a1a;color:var(--red);border:1px solid #5c2020}
|
|
.btn-danger:hover{background:#4a1f1f}
|
|
|
|
/* ── Cards & lists ────────────────────────────────────────────── */
|
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:10px;transition:border-color .15s}
|
|
.card:hover{border-color:var(--accent)}
|
|
.card-title{font-size:13px;font-weight:600;margin-bottom:4px}
|
|
.card-meta{font-size:11px;color:var(--muted)}
|
|
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
|
|
.badge-open{background:#0d3321;color:var(--green)}
|
|
.badge-closed{background:#2a1a1a;color:var(--muted)}
|
|
.badge-merged{background:#2a1a3a;color:var(--purple)}
|
|
.badge-high{background:#3b1a1a;color:var(--red)}
|
|
.badge-medium{background:#3b2a0a;color:var(--amber)}
|
|
.badge-low{background:#0d1a3b;color:var(--blue)}
|
|
.chip{display:inline-block;padding:1px 7px;border-radius:4px;font-size:10px;background:var(--surface2);color:var(--accent2);margin-right:4px;border:1px solid var(--border)}
|
|
.label-chip{background:#1a1a30;border-color:#3333aa;color:#818cf8}
|
|
|
|
/* ── Chat pane ────────────────────────────────────────────────── */
|
|
#chat-pane{display:none;flex:1;flex-direction:column;overflow:hidden}
|
|
#chat-pane.active{display:flex}
|
|
#chat-container{display:flex;flex:1;overflow:hidden}
|
|
#avatar-column{width:200px;display:flex;flex-direction:column;align-items:center;padding:20px 12px;background:var(--surface);border-right:1px solid var(--border);flex-shrink:0}
|
|
#avatar-wrap{position:relative;width:160px;height:160px}
|
|
#avatar{width:160px;height:160px;cursor:default;transition:transform .5s}
|
|
#avatar.curious{transform:rotate(-3deg)}
|
|
#avatar.thinking{animation:think-bob 2s ease-in-out infinite}
|
|
@keyframes think-bob{0%,100%{transform:rotate(0deg)}25%{transform:rotate(-1.5deg)}75%{transform:rotate(1.5deg)}}
|
|
.glow-ring{animation:pulse 3s ease-in-out infinite}
|
|
@keyframes pulse{0%,100%{opacity:.4;stroke-width:2}50%{opacity:.9;stroke-width:3}}
|
|
#avatar.speaking .glow-ring{animation:speaking-pulse 0.4s ease-in-out infinite}
|
|
@keyframes speaking-pulse{0%,100%{opacity:.5}50%{opacity:1}}
|
|
#avatar.happy .glow-ring{stroke:#22c55e;animation:pulse 2s ease-in-out infinite}
|
|
#avatar.concerned .glow-ring{stroke:#f59e0b;animation:pulse 1.5s ease-in-out infinite}
|
|
#avatar.curious .glow-ring{stroke:#a855f7}
|
|
#avatar-name{font-size:12px;color:var(--accent2);font-weight:600;margin-top:8px;text-align:center}
|
|
#emotion-history{display:flex;gap:4px;margin-top:8px;align-items:center}
|
|
.emotion-dot{width:8px;height:8px;border-radius:50%;opacity:.7;transition:all .3s}
|
|
.emotion-dot.idle{background:var(--muted)}
|
|
.emotion-dot.thinking{background:var(--blue)}
|
|
.emotion-dot.speaking{background:var(--accent)}
|
|
.emotion-dot.happy{background:var(--green)}
|
|
.emotion-dot.concerned{background:var(--amber)}
|
|
.emotion-dot.curious{background:var(--purple)}
|
|
#voice-controls{width:100%;margin-top:14px;font-size:11px}
|
|
#voice-controls summary{cursor:pointer;color:var(--muted);font-size:11px;margin-bottom:6px;list-style:none}
|
|
#voice-controls summary::-webkit-details-marker{display:none}
|
|
#voice-controls summary::before{content:'▶ ';font-size:9px}
|
|
details[open] summary::before{content:'▼ '}
|
|
.vc-row{display:flex;align-items:center;gap:6px;margin-bottom:6px}
|
|
.vc-row label{color:var(--muted);width:46px;flex-shrink:0}
|
|
.vc-row input[type=range]{flex:1;height:3px;accent-color:var(--accent)}
|
|
.vc-row .val{color:var(--accent2);width:28px;text-align:right;font-size:10px}
|
|
#voice-select{width:100%;font-size:11px;margin-bottom:6px}
|
|
#mute-btn{width:100%;padding:4px;font-size:11px;border-radius:4px;background:var(--surface2);border:1px solid var(--border)}
|
|
#mute-btn.muted{border-color:var(--amber);color:var(--amber)}
|
|
.preset-row{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px}
|
|
.preset-btn{flex:1;min-width:60px;padding:3px;font-size:10px;border-radius:4px;background:var(--surface2);border:1px solid var(--border);color:var(--muted)}
|
|
.preset-btn:hover{color:var(--accent2);border-color:var(--accent)}
|
|
.preset-btn.active{background:#1a1a30;border-color:var(--accent);color:var(--accent2)}
|
|
#replay-btn{width:100%;margin-top:6px;padding:4px;font-size:11px;border-radius:4px;background:var(--surface2);border:1px solid var(--border);color:var(--muted)}
|
|
#replay-btn:hover{color:var(--accent2);border-color:var(--accent)}
|
|
#memory-indicator{margin-top:10px;font-size:10px;color:var(--muted);text-align:center}
|
|
#memory-count{color:var(--accent2)}
|
|
#chat-column{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
#messages{flex:1;overflow-y:auto;padding:16px}
|
|
#messages::-webkit-scrollbar{width:4px}
|
|
#messages::-webkit-scrollbar-track{background:transparent}
|
|
#messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
|
.msg{display:flex;gap:10px;margin-bottom:16px;align-items:flex-start}
|
|
.msg.user{flex-direction:row-reverse}
|
|
.msg-bubble{max-width:75%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.6}
|
|
.msg.neuron .msg-bubble{background:var(--surface2);border:1px solid var(--border);border-top-left-radius:3px;color:var(--text)}
|
|
.msg.user .msg-bubble{background:#1e1e40;border:1px solid #3333aa;border-top-right-radius:3px;color:var(--text)}
|
|
.msg-avatar{width:28px;height:28px;border-radius:50%;background:var(--surface2);border:1px solid var(--accent);display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;margin-top:2px}
|
|
.msg-time{font-size:10px;color:var(--muted);margin-top:4px}
|
|
#chat-input-bar{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:flex-end;background:var(--surface);flex-shrink:0}
|
|
#chat-input{flex:1;resize:none;height:40px;max-height:120px;padding:10px 12px;font-size:13px;border-radius:8px;background:var(--surface2);border:1px solid var(--border);line-height:1.4;overflow:hidden}
|
|
#chat-input:focus{border-color:var(--accent);height:auto}
|
|
#send-btn{padding:9px 16px;border-radius:8px;background:var(--accent);color:white;font-size:13px;font-weight:500}
|
|
#send-btn:hover{background:#5558e8}
|
|
#mic-btn{padding:9px 12px;border-radius:8px;background:var(--surface2);border:1px solid var(--border);font-size:16px}
|
|
#mic-btn.listening{background:#3b0d0d;border-color:var(--red);animation:mic-pulse .8s ease-in-out infinite}
|
|
@keyframes mic-pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
|
|
|
/* ── Rendered results ─────────────────────────────────────────── */
|
|
.commit-row{display:flex;gap:10px;align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--border)}
|
|
.commit-hash{font-family:monospace;font-size:11px;color:var(--accent2);width:60px;flex-shrink:0;padding-top:1px}
|
|
.commit-msg{font-size:12px;flex:1}
|
|
.commit-meta{font-size:10px;color:var(--muted)}
|
|
.issue-row{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)}
|
|
.issue-num{font-size:11px;color:var(--muted);width:36px}
|
|
.issue-title{font-size:12px;flex:1}
|
|
.pr-row{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)}
|
|
.pr-branches{font-size:11px;color:var(--muted);font-family:monospace}
|
|
.stat-card{background:var(--surface2);border-radius:8px;padding:12px;display:inline-block;margin-right:10px;margin-bottom:8px}
|
|
.stat-val{font-size:22px;font-weight:700;color:var(--accent2)}
|
|
.stat-label{font-size:10px;color:var(--muted);margin-top:2px}
|
|
pre.json{font-size:11px;line-height:1.5;overflow-x:auto;padding:10px;background:var(--surface2);border-radius:6px;border:1px solid var(--border)}
|
|
.json-key{color:#93c5fd}
|
|
.json-str{color:#86efac}
|
|
.json-num{color:#fcd34d}
|
|
.json-bool{color:#f9a8d4}
|
|
|
|
/* ── File tree ────────────────────────────────────────────────── */
|
|
.tree-node{padding:3px 0 3px 16px;font-size:12px;cursor:pointer;border-radius:4px}
|
|
.tree-node:hover{background:var(--surface2)}
|
|
.tree-node .icon{margin-right:6px;font-size:11px}
|
|
.tree-dir .icon{color:var(--amber)}
|
|
.tree-file .icon{color:var(--muted)}
|
|
.file-viewer{flex:1;overflow:auto;padding:12px;font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin-top:10px}
|
|
|
|
/* ── Commit graph ─────────────────────────────────────────────── */
|
|
.cg-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid #1a1a2a}
|
|
.cg-dot{width:10px;height:10px;border-radius:50%;background:var(--accent);border:2px solid var(--accent2);flex-shrink:0}
|
|
.cg-hash{font-family:monospace;font-size:11px;color:var(--accent2)}
|
|
.cg-msg{font-size:12px;flex:1}
|
|
.cg-meta{font-size:10px;color:var(--muted)}
|
|
.branch-chip{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;background:#1a2040;border:1px solid #2a3070;color:#818cf8;margin-left:4px}
|
|
|
|
/* ── Kanban ───────────────────────────────────────────────────── */
|
|
#kanban{display:flex;gap:14px;padding:16px;overflow-x:auto;flex:1}
|
|
.kanban-col{min-width:240px;background:var(--surface);border:1px solid var(--border);border-radius:8px;display:flex;flex-direction:column}
|
|
.kanban-col-hdr{padding:10px 14px;font-size:12px;font-weight:600;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px}
|
|
.kanban-col-hdr .count{background:var(--surface2);border-radius:10px;padding:1px 7px;font-size:10px;color:var(--muted)}
|
|
.kanban-cards{flex:1;padding:10px;overflow-y:auto;min-height:100px}
|
|
.kanban-card{background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:10px;margin-bottom:8px;cursor:grab;font-size:12px}
|
|
.kanban-card:hover{border-color:var(--accent)}
|
|
.kanban-card.dragging{opacity:.5}
|
|
.kanban-col.drag-over .kanban-cards{background:#1a1a28}
|
|
|
|
/* ── Pipeline live view ───────────────────────────────────────── */
|
|
.pl-step{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;gap:10px}
|
|
.pl-step-status{width:16px;height:16px;border-radius:50%;flex-shrink:0;margin-top:2px}
|
|
.pl-status-pending{background:var(--border)}
|
|
.pl-status-running{background:var(--blue);animation:run-pulse .8s ease-in-out infinite}
|
|
.pl-status-pass{background:var(--green)}
|
|
.pl-status-fail{background:var(--red)}
|
|
@keyframes run-pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
.pl-step-body{flex:1}
|
|
.pl-step-name{font-size:12px;font-weight:600}
|
|
.pl-step-out{font-size:11px;font-family:monospace;color:var(--muted);margin-top:4px;white-space:pre-wrap}
|
|
.pl-duration{font-size:10px;color:var(--muted);margin-left:auto;flex-shrink:0}
|
|
.pl-banner{padding:14px;text-align:center;font-weight:700;font-size:14px;border-radius:6px;margin:12px}
|
|
.pl-banner.pass{background:#0d3321;color:var(--green)}
|
|
.pl-banner.fail{background:#3b1a1a;color:var(--red)}
|
|
|
|
/* ── Security pane ────────────────────────────────────────────── */
|
|
.sec-finding{padding:10px 14px;border-bottom:1px solid var(--border)}
|
|
.sec-finding .path{font-family:monospace;font-size:11px;color:var(--accent2)}
|
|
.sec-finding .desc{font-size:12px;margin-top:2px}
|
|
.sec-finding input[type=checkbox]{margin-right:6px;accent-color:var(--accent)}
|
|
|
|
/* ── Wiki ─────────────────────────────────────────────────────── */
|
|
.wiki-card{padding:10px 14px;border-bottom:1px solid var(--border);cursor:pointer}
|
|
.wiki-card:hover{background:var(--surface2)}
|
|
.wiki-title{font-size:13px;font-weight:600}
|
|
.wiki-meta{font-size:10px;color:var(--muted);margin-top:2px}
|
|
.wiki-body{font-size:13px;line-height:1.7;white-space:pre-wrap;padding:14px;background:var(--surface2);border-radius:6px;border:1px solid var(--border)}
|
|
|
|
/* ── Settings modal ───────────────────────────────────────────── */
|
|
#settings-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center}
|
|
#settings-modal.open{display:flex}
|
|
#settings-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;width:400px;max-width:95vw}
|
|
#settings-box h3{font-size:15px;font-weight:700;margin-bottom:16px;color:var(--accent2)}
|
|
.setting-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;font-size:13px}
|
|
.setting-row label{color:var(--muted);width:140px;flex-shrink:0}
|
|
.setting-row input,.setting-row select{flex:1}
|
|
.color-presets{display:flex;gap:6px}
|
|
.color-swatch{width:22px;height:22px;border-radius:50%;cursor:pointer;border:2px solid transparent}
|
|
.color-swatch.active{border-color:white}
|
|
|
|
/* ── Fullscreen avatar ────────────────────────────────────────── */
|
|
#fullscreen-overlay{display:none;position:fixed;inset:0;background:#060608;z-index:200;flex-direction:column;align-items:center;justify-content:center}
|
|
#fullscreen-overlay.open{display:flex}
|
|
#fs-neural-bg{position:absolute;inset:0;overflow:hidden;opacity:.15}
|
|
#fs-avatar{width:min(60vw,400px);height:auto;z-index:1}
|
|
#fs-chat-bar{position:absolute;bottom:0;left:0;right:0;padding:20px;display:flex;gap:10px;background:linear-gradient(transparent,rgba(6,6,8,.95))}
|
|
#fs-input{flex:1;background:rgba(255,255,255,.05);border:1px solid var(--border);border-radius:8px;padding:12px 16px;font-size:14px;color:var(--text)}
|
|
#fs-send{padding:12px 20px;border-radius:8px;background:var(--accent);color:white}
|
|
#fs-close{position:absolute;top:16px;right:16px;opacity:.5;font-size:20px;cursor:pointer}
|
|
#fs-close:hover{opacity:1}
|
|
#fs-whisper{position:absolute;top:16px;left:16px;opacity:.5;font-size:12px;cursor:pointer;padding:4px 10px;border:1px solid var(--border);border-radius:4px}
|
|
#fs-whisper:hover{opacity:1}
|
|
|
|
/* ── Toast ────────────────────────────────────────────────────── */
|
|
#toast-area{position:fixed;bottom:80px;right:20px;z-index:300;display:flex;flex-direction:column;gap:6px}
|
|
.toast{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:12px;max-width:320px;animation:slide-in .2s ease;box-shadow:0 4px 20px rgba(0,0,0,.4)}
|
|
@keyframes slide-in{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
|
.toast.cli{border-left:3px solid var(--accent)}
|
|
.toast .toast-cmd{font-family:monospace;color:var(--accent2);margin-bottom:3px}
|
|
.toast .toast-res{color:var(--muted);font-size:11px}
|
|
|
|
/* ── Welcome screen ───────────────────────────────────────────── */
|
|
#welcome{display:none;flex:1;align-items:center;justify-content:center;flex-direction:column;gap:20px;text-align:center;padding:40px}
|
|
#welcome.show{display:flex}
|
|
#welcome h2{font-size:22px;font-weight:700;color:var(--text)}
|
|
#welcome p{font-size:14px;color:var(--muted);max-width:400px;line-height:1.7}
|
|
#welcome-input{width:320px;padding:10px 14px;font-size:13px;border-radius:8px}
|
|
#welcome-register{padding:10px 24px;border-radius:8px;background:var(--accent);color:white;font-size:14px;font-weight:600;margin-top:8px}
|
|
|
|
/* ── Scrollbar global ─────────────────────────────────────────── */
|
|
::-webkit-scrollbar{width:4px;height:4px}
|
|
::-webkit-scrollbar-track{background:transparent}
|
|
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
|
|
|
/* ── Shortcut hints ───────────────────────────────────────────── */
|
|
[data-tip]{position:relative}
|
|
[data-tip]:hover::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#1a1a28;border:1px solid var(--border);color:var(--muted);font-size:10px;padding:3px 7px;border-radius:4px;white-space:nowrap;pointer-events:none;z-index:50}
|
|
|
|
/* ── Input search/filter ──────────────────────────────────────── */
|
|
.filter-bar{display:flex;gap:8px;margin-bottom:12px}
|
|
.filter-bar input{flex:1}
|
|
.view-toggle{display:flex;border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
|
.view-toggle button{padding:4px 10px;font-size:11px;background:none;color:var(--muted);border-right:1px solid var(--border)}
|
|
.view-toggle button:last-child{border-right:none}
|
|
.view-toggle button.active{background:var(--accent);color:white}
|
|
|
|
/* ── Diff / code view ─────────────────────────────────────────── */
|
|
.diff-add{color:var(--green)}
|
|
.diff-del{color:var(--red)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ── App shell ─────────────────────────────────────────────── -->
|
|
<div id="app">
|
|
|
|
<!-- Top bar -->
|
|
<div id="topbar">
|
|
<div id="logo">Neuron <span>Code</span></div>
|
|
<div id="server-status" data-tip="Server connected"></div>
|
|
<div id="ticker-inner" style="font-size:11px;color:var(--muted);margin-left:8px">Connecting to event stream…</div>
|
|
<div id="topbar-right">
|
|
<select id="repo-selector" title="Active repo"><option value="">— select repo —</option></select>
|
|
<button id="fullscreen-btn" data-tip="F — fullscreen avatar">⬡</button>
|
|
<button id="settings-btn" data-tip="Settings">⚙</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="main-area">
|
|
|
|
<!-- Sidebar -->
|
|
<div id="sidebar">
|
|
<div id="nav-list">
|
|
<div class="nav-item active" data-pane="chat" data-tip="⌘1">
|
|
<span class="icon">💬</span> Chat
|
|
</div>
|
|
<div class="nav-item" data-pane="repos" data-tip="⌘1">
|
|
<span class="icon">📁</span> Repos
|
|
</div>
|
|
<div class="nav-item" data-pane="issues" data-tip="⌘2">
|
|
<span class="icon">◎</span> Issues
|
|
<span class="badge" id="issues-badge" style="display:none">0</span>
|
|
</div>
|
|
<div class="nav-item" data-pane="prs" data-tip="⌘3">
|
|
<span class="icon">⇄</span> Pull Requests
|
|
</div>
|
|
<div class="nav-item" data-pane="pipeline" data-tip="⌘4">
|
|
<span class="icon">▶</span> Pipeline
|
|
</div>
|
|
<div class="nav-item" data-pane="files" data-tip="⌘5">
|
|
<span class="icon">🗂</span> Files
|
|
</div>
|
|
<div class="nav-item" data-pane="search" data-tip="⌘5">
|
|
<span class="icon">⌕</span> Search
|
|
</div>
|
|
<div class="nav-item" data-pane="wiki" data-tip="⌘6">
|
|
<span class="icon">📖</span> Wiki
|
|
</div>
|
|
<div class="nav-item" data-pane="security" data-tip="">
|
|
<span class="icon">🛡</span> Security
|
|
</div>
|
|
<div class="nav-item" data-pane="events" data-tip="">
|
|
<span class="icon">⚡</span> Events
|
|
</div>
|
|
<div class="nav-item" data-pane="insights" data-tip="">
|
|
<span class="icon">📊</span> Insights
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content area -->
|
|
<div id="content">
|
|
<div id="ticker" style="display:none">
|
|
<span id="ticker-scroll"></span>
|
|
</div>
|
|
|
|
<!-- Welcome screen -->
|
|
<div id="welcome">
|
|
<svg width="120" height="120" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="100" cy="100" r="95" fill="none" stroke="#6366f1" stroke-width="2" opacity="0.6"/>
|
|
<circle cx="100" cy="100" r="88" fill="#1a1a2e"/>
|
|
<ellipse cx="72" cy="88" rx="10" ry="12" fill="#818cf8"/>
|
|
<ellipse cx="128" cy="88" rx="10" ry="12" fill="#818cf8"/>
|
|
<circle cx="72" cy="88" r="5" fill="#1a1a2e"/>
|
|
<circle cx="128" cy="88" r="5" fill="#1a1a2e"/>
|
|
<circle cx="74" cy="85" r="2" fill="white" opacity="0.8"/>
|
|
<circle cx="130" cy="85" r="2" fill="white" opacity="0.8"/>
|
|
<path d="M 76 116 Q 100 134 124 116" stroke="#818cf8" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
|
</svg>
|
|
<h2>Hello. I'm Neuron.</h2>
|
|
<p>Your AI-native development partner. Tell me where your code lives and I'll start understanding it.</p>
|
|
<input id="welcome-input" placeholder="Path to your project (e.g. /Users/you/my-app)…" type="text"/>
|
|
<button id="welcome-register" onclick="welcomeRegister()">Register Repo</button>
|
|
</div>
|
|
|
|
<!-- Pane area -->
|
|
<div id="pane-area">
|
|
|
|
<!-- Chat pane -->
|
|
<div id="chat-pane" class="pane">
|
|
<div id="chat-container">
|
|
<!-- Avatar column -->
|
|
<div id="avatar-column">
|
|
<div id="avatar-wrap">
|
|
<svg id="avatar" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- Outer glow ring -->
|
|
<circle cx="100" cy="100" r="95" fill="none" stroke="#6366f1" stroke-width="2" opacity="0.6" class="glow-ring"/>
|
|
<!-- Face -->
|
|
<circle cx="100" cy="100" r="88" fill="#1a1a2e"/>
|
|
<!-- Neural dots (forehead) -->
|
|
<circle class="neural-dot" cx="100" cy="58" r="2.5" fill="#6366f1" opacity="0.4"/>
|
|
<circle class="neural-dot" cx="86" cy="63" r="2" fill="#6366f1" opacity="0.3"/>
|
|
<circle class="neural-dot" cx="114" cy="63" r="2" fill="#6366f1" opacity="0.3"/>
|
|
<circle class="neural-dot" cx="74" cy="71" r="1.5" fill="#6366f1" opacity="0.2"/>
|
|
<circle class="neural-dot" cx="126" cy="71" r="1.5" fill="#6366f1" opacity="0.2"/>
|
|
<!-- Neural lines -->
|
|
<line x1="100" y1="58" x2="86" y2="63" stroke="#6366f1" stroke-width=".5" opacity="0.2"/>
|
|
<line x1="100" y1="58" x2="114" y2="63" stroke="#6366f1" stroke-width=".5" opacity="0.2"/>
|
|
<line x1="86" y1="63" x2="74" y2="71" stroke="#6366f1" stroke-width=".5" opacity="0.15"/>
|
|
<line x1="114" y1="63" x2="126" y2="71" stroke="#6366f1" stroke-width=".5" opacity="0.15"/>
|
|
<!-- Eyes -->
|
|
<g id="eyes">
|
|
<ellipse id="eye-left" cx="72" cy="88" rx="10" ry="12" fill="#818cf8"/>
|
|
<ellipse id="eye-right" cx="128" cy="88" rx="10" ry="12" fill="#818cf8"/>
|
|
<circle id="pupil-left" cx="72" cy="88" r="5" fill="#1a1a2e"/>
|
|
<circle id="pupil-right" cx="128" cy="88" r="5" fill="#1a1a2e"/>
|
|
<circle cx="74" cy="85" r="2" fill="white" opacity="0.8"/>
|
|
<circle cx="130" cy="85" r="2" fill="white" opacity="0.8"/>
|
|
</g>
|
|
<!-- Mouth -->
|
|
<path id="mouth" d="M 80 118 Q 100 128 120 118" stroke="#818cf8" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
|
</svg>
|
|
</div>
|
|
<div id="avatar-name">Neuron</div>
|
|
<div id="emotion-history"></div>
|
|
|
|
<!-- Voice controls -->
|
|
<details id="voice-controls">
|
|
<summary>Voice</summary>
|
|
<div class="preset-row">
|
|
<button class="preset-btn active" onclick="applyPreset('default')" data-preset="default">Default</button>
|
|
<button class="preset-btn" onclick="applyPreset('enthusiastic')" data-preset="enthusiastic">Hype</button>
|
|
<button class="preset-btn" onclick="applyPreset('deep')" data-preset="deep">Deep</button>
|
|
<button class="preset-btn" onclick="applyPreset('rapid')" data-preset="rapid">Rapid</button>
|
|
</div>
|
|
<select id="voice-select"></select>
|
|
<div class="vc-row">
|
|
<label>Pitch</label>
|
|
<input type="range" id="pitch-slider" min="0.5" max="2.0" step="0.05" value="1.0" oninput="updateVal(this,'pitch-val')">
|
|
<span class="val" id="pitch-val">1.0</span>
|
|
</div>
|
|
<div class="vc-row">
|
|
<label>Speed</label>
|
|
<input type="range" id="rate-slider" min="0.5" max="2.0" step="0.05" value="0.95" oninput="updateVal(this,'rate-val')">
|
|
<span class="val" id="rate-val">0.95</span>
|
|
</div>
|
|
<div class="vc-row">
|
|
<label>Volume</label>
|
|
<input type="range" id="vol-slider" min="0" max="1" step="0.05" value="1.0" oninput="updateVal(this,'vol-val')">
|
|
<span class="val" id="vol-val">1.0</span>
|
|
</div>
|
|
<button id="mute-btn" onclick="toggleMute()">🔊 Mute</button>
|
|
<button id="replay-btn" onclick="replayLast()">↺ Replay last</button>
|
|
</details>
|
|
<div id="memory-indicator">Memory: <span id="memory-count">—</span></div>
|
|
</div>
|
|
|
|
<!-- Chat messages column -->
|
|
<div id="chat-column">
|
|
<div id="messages"></div>
|
|
<div id="chat-input-bar">
|
|
<textarea id="chat-input" placeholder="Ask Neuron anything… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
|
|
<button id="mic-btn" data-tip="⌘/ — voice input" onclick="toggleMic()">🎤</button>
|
|
<button id="send-btn" onclick="sendChat()">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Repos pane -->
|
|
<div id="repos-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Repositories</h2>
|
|
<button class="btn btn-primary" style="margin-left:auto" onclick="showRegisterForm()">+ Register Repo</button>
|
|
</div>
|
|
<div class="pane-body">
|
|
<div id="register-form" style="display:none;margin-bottom:16px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:14px">
|
|
<div style="font-size:13px;font-weight:600;margin-bottom:10px">Register New Repository</div>
|
|
<div style="display:flex;gap:8px;flex-direction:column">
|
|
<input id="reg-path" placeholder="Path (e.g. /Users/you/my-app)" type="text"/>
|
|
<input id="reg-name" placeholder="Name (optional)" type="text"/>
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn btn-primary" onclick="registerRepo()">Register</button>
|
|
<button class="btn btn-secondary" onclick="document.getElementById('register-form').style.display='none'">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="repos-list"></div>
|
|
<div id="commit-graph" style="display:none;margin-top:16px">
|
|
<div style="font-size:13px;font-weight:600;margin-bottom:8px">Commit History</div>
|
|
<div id="commit-graph-inner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Issues pane -->
|
|
<div id="issues-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Issues</h2>
|
|
<div class="view-toggle" style="margin-left:auto">
|
|
<button class="active" onclick="setIssueView('list',this)">List</button>
|
|
<button onclick="setIssueView('kanban',this)">Kanban</button>
|
|
</div>
|
|
<button class="btn btn-primary" style="margin-left:10px" onclick="showNewIssueModal()">+ New Issue</button>
|
|
</div>
|
|
<div id="issues-list-view" style="flex:1;overflow:auto;padding:16px">
|
|
<div id="issues-list"></div>
|
|
</div>
|
|
<div id="kanban" style="display:none">
|
|
<div class="kanban-col" id="kcol-open" ondragover="dragOver(event)" ondrop="drop(event,'open')">
|
|
<div class="kanban-col-hdr"><span style="color:var(--green)">●</span> Open <span class="count" id="kcnt-open">0</span></div>
|
|
<div class="kanban-cards" id="kcard-open"></div>
|
|
</div>
|
|
<div class="kanban-col" id="kcol-in_progress" ondragover="dragOver(event)" ondrop="drop(event,'in_progress')">
|
|
<div class="kanban-col-hdr"><span style="color:var(--blue)">●</span> In Progress <span class="count" id="kcnt-in_progress">0</span></div>
|
|
<div class="kanban-cards" id="kcard-in_progress"></div>
|
|
</div>
|
|
<div class="kanban-col" id="kcol-closed" ondragover="dragOver(event)" ondrop="drop(event,'closed')">
|
|
<div class="kanban-col-hdr"><span style="color:var(--muted)">●</span> Closed <span class="count" id="kcnt-closed">0</span></div>
|
|
<div class="kanban-cards" id="kcard-closed"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PR pane -->
|
|
<div id="prs-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Pull Requests</h2>
|
|
<button class="btn btn-primary" style="margin-left:auto" onclick="showNewPRModal()">+ New PR</button>
|
|
</div>
|
|
<div class="pane-body" id="prs-list"></div>
|
|
</div>
|
|
|
|
<!-- Pipeline pane -->
|
|
<div id="pipeline-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Pipeline</h2>
|
|
<button class="btn btn-secondary" style="margin-left:auto" onclick="rerunPipeline()">↺ Re-run</button>
|
|
</div>
|
|
<div class="pane-body">
|
|
<div style="display:flex;gap:8px;margin-bottom:14px">
|
|
<input id="pipeline-intent" placeholder="Intent (e.g. run tests, build docker image)" style="flex:1" type="text"/>
|
|
<button class="btn btn-primary" onclick="runPipeline()">Run</button>
|
|
</div>
|
|
<div id="pipeline-steps"></div>
|
|
<div id="pipeline-banner" style="display:none"></div>
|
|
<div style="font-size:13px;font-weight:600;margin-top:16px;margin-bottom:8px;color:var(--muted)">History</div>
|
|
<div id="pipeline-history"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Files pane -->
|
|
<div id="files-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Files</h2>
|
|
<span id="file-path-breadcrumb" style="font-size:11px;color:var(--muted);margin-left:8px"></span>
|
|
<div style="margin-left:auto;display:flex;gap:6px">
|
|
<button class="btn btn-sm btn-secondary" id="copy-file-btn" style="display:none" onclick="copyFileContent()">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;flex:1;overflow:hidden">
|
|
<div id="file-tree" style="width:220px;overflow-y:auto;border-right:1px solid var(--border);padding:8px"></div>
|
|
<div style="flex:1;overflow:auto;padding:14px">
|
|
<div id="file-content-area" style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre;color:var(--text)"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search pane -->
|
|
<div id="search-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Search</h2>
|
|
</div>
|
|
<div class="pane-body">
|
|
<div class="filter-bar">
|
|
<input id="search-input" placeholder="Search…" type="text" onkeydown="if(event.key==='Enter')doSearch()"/>
|
|
<div class="view-toggle">
|
|
<button class="active" onclick="setSearchType('code',this)">Code</button>
|
|
<button onclick="setSearchType('issues',this)">Issues</button>
|
|
<button onclick="setSearchType('semantic',this)">Semantic</button>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="doSearch()">Search</button>
|
|
</div>
|
|
<div id="search-results"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Wiki pane -->
|
|
<div id="wiki-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Wiki</h2>
|
|
<button class="btn btn-primary" style="margin-left:auto" onclick="showNewWikiModal()">+ New Page</button>
|
|
</div>
|
|
<div style="display:flex;flex:1;overflow:hidden">
|
|
<div id="wiki-list" style="width:220px;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
|
<div id="wiki-content" style="flex:1;overflow-y:auto;padding:16px">
|
|
<div style="color:var(--muted);font-size:13px">Select a page to read it.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Security pane -->
|
|
<div id="security-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Security Scan</h2>
|
|
<div id="sec-badges" style="margin-left:8px;display:flex;gap:6px"></div>
|
|
<button class="btn btn-secondary" style="margin-left:auto" onclick="runSecurityScan()">Scan</button>
|
|
<span id="sec-last-scan" style="font-size:10px;color:var(--muted);margin-left:10px"></span>
|
|
</div>
|
|
<div class="pane-body" id="security-results">
|
|
<div style="color:var(--muted);font-size:13px">Click Scan to run a security analysis on the active repo.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Events pane -->
|
|
<div id="events-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Live Events</h2>
|
|
<span id="event-count" style="font-size:11px;color:var(--muted);margin-left:auto">0 events</span>
|
|
</div>
|
|
<div class="pane-body" id="events-list"></div>
|
|
</div>
|
|
|
|
<!-- Insights pane -->
|
|
<div id="insights-pane" class="pane">
|
|
<div class="pane-header">
|
|
<h2>Insights</h2>
|
|
<button class="btn btn-secondary" style="margin-left:auto" onclick="loadInsights()">Refresh</button>
|
|
</div>
|
|
<div class="pane-body" id="insights-content">
|
|
<div style="color:var(--muted);font-size:13px">Select an active repo and click Refresh.</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /pane-area -->
|
|
</div><!-- /content -->
|
|
</div><!-- /main-area -->
|
|
</div><!-- /app -->
|
|
|
|
<!-- ── Modals ──────────────────────────────────────────────────── -->
|
|
<div id="settings-modal">
|
|
<div id="settings-box">
|
|
<h3>⚙ Settings</h3>
|
|
<div class="setting-row">
|
|
<label>Avatar name</label>
|
|
<input id="set-name" value="Neuron" oninput="document.getElementById('avatar-name').textContent=this.value;localStorage.setItem('avatar-name',this.value)"/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Color theme</label>
|
|
<div class="color-presets">
|
|
<div class="color-swatch active" style="background:#6366f1" onclick="setTheme('indigo',this)" title="Indigo"></div>
|
|
<div class="color-swatch" style="background:#10b981" onclick="setTheme('emerald',this)" title="Emerald"></div>
|
|
<div class="color-swatch" style="background:#f43f5e" onclick="setTheme('rose',this)" title="Rose"></div>
|
|
<div class="color-swatch" style="background:#f59e0b" onclick="setTheme('amber',this)" title="Amber"></div>
|
|
</div>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Avatar size</label>
|
|
<select id="set-size" onchange="setAvatarSize(this.value)">
|
|
<option value="120">Small</option>
|
|
<option value="160" selected>Medium</option>
|
|
<option value="200">Large</option>
|
|
</select>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Auto-speak</label>
|
|
<input type="checkbox" id="set-autospeak" checked onchange="localStorage.setItem('autospeak',this.checked)"/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Show emotions</label>
|
|
<input type="checkbox" id="set-emotions" checked onchange="document.getElementById('emotion-history').style.display=this.checked?'flex':'none';localStorage.setItem('show-emotions',this.checked)"/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Wake word</label>
|
|
<input id="set-wake" value="hey neuron" oninput="localStorage.setItem('wake-word',this.value)"/>
|
|
</div>
|
|
<div style="margin-top:16px;display:flex;justify-content:flex-end">
|
|
<button class="btn btn-primary" onclick="document.getElementById('settings-modal').classList.remove('open')">Done</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fullscreen overlay -->
|
|
<div id="fullscreen-overlay">
|
|
<svg id="fs-neural-bg" viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice">
|
|
<g id="neural-net"></g>
|
|
</svg>
|
|
<svg id="fs-avatar" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="100" cy="100" r="95" fill="none" stroke="#6366f1" stroke-width="2" opacity="0.6" class="glow-ring"/>
|
|
<circle cx="100" cy="100" r="88" fill="#1a1a2e"/>
|
|
<circle class="neural-dot" cx="100" cy="58" r="2.5" fill="#6366f1" opacity="0.4"/>
|
|
<circle class="neural-dot" cx="86" cy="63" r="2" fill="#6366f1" opacity="0.3"/>
|
|
<circle class="neural-dot" cx="114" cy="63" r="2" fill="#6366f1" opacity="0.3"/>
|
|
<ellipse id="fs-eye-left" cx="72" cy="88" rx="10" ry="12" fill="#818cf8"/>
|
|
<ellipse id="fs-eye-right" cx="128" cy="88" rx="10" ry="12" fill="#818cf8"/>
|
|
<circle id="fs-pupil-left" cx="72" cy="88" r="5" fill="#1a1a2e"/>
|
|
<circle id="fs-pupil-right" cx="128" cy="88" r="5" fill="#1a1a2e"/>
|
|
<circle cx="74" cy="85" r="2" fill="white" opacity="0.8"/>
|
|
<circle cx="130" cy="85" r="2" fill="white" opacity="0.8"/>
|
|
<path id="fs-mouth" d="M 80 118 Q 100 128 120 118" stroke="#818cf8" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
|
</svg>
|
|
<span id="fs-close" onclick="exitFullscreen()">✕</span>
|
|
<span id="fs-whisper" onclick="toggleWhisper()">🔇 Whisper</span>
|
|
<div id="fs-chat-bar">
|
|
<input id="fs-input" placeholder="Ask Neuron…" type="text" onkeydown="if(event.key==='Enter')fsChat()"/>
|
|
<button id="fs-send" onclick="fsChat()">Send</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast area -->
|
|
<div id="toast-area"></div>
|
|
|
|
<script>
|
|
// ══════════════════════════════════════════════════════════════
|
|
// STATE
|
|
// ══════════════════════════════════════════════════════════════
|
|
const BASE = 'http://localhost:7890';
|
|
let currentRepo = localStorage.getItem('current-repo') || '';
|
|
let isMuted = false;
|
|
let isWhisper = false;
|
|
let lastUtterance = '';
|
|
let voicesLoaded = false;
|
|
let thinkingInterval = null;
|
|
let emotionHistory = [];
|
|
let eventCount = 0;
|
|
let lastPipelineIntent = '';
|
|
let stateKeyCount = 0;
|
|
let draggedIssue = null;
|
|
let searchType = 'code';
|
|
let issueView = 'list';
|
|
let allIssues = [];
|
|
let sseSource = null;
|
|
let lastActivityLog = [];
|
|
|
|
// ── Theme colors per preset ────────────────────────────────────
|
|
const THEMES = {
|
|
indigo: {accent:'#6366f1',accent2:'#818cf8'},
|
|
emerald:{accent:'#10b981',accent2:'#34d399'},
|
|
rose: {accent:'#f43f5e',accent2:'#fb7185'},
|
|
amber: {accent:'#f59e0b',accent2:'#fcd34d'},
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// INIT
|
|
// ══════════════════════════════════════════════════════════════
|
|
window.addEventListener('load', () => {
|
|
loadSettings();
|
|
populateVoices();
|
|
window.speechSynthesis.onvoiceschanged = populateVoices;
|
|
scheduleBlink();
|
|
connectSSE();
|
|
renderNeuralBg();
|
|
setupKeyboard();
|
|
loadRepoSelector();
|
|
checkWelcome();
|
|
document.getElementById('chat-input').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
|
|
});
|
|
document.addEventListener('mousemove', trackPupils);
|
|
setTimeout(greetOnLoad, 1500);
|
|
});
|
|
|
|
function loadSettings() {
|
|
const name = localStorage.getItem('avatar-name') || 'Neuron';
|
|
document.getElementById('avatar-name').textContent = name;
|
|
document.getElementById('set-name').value = name;
|
|
const theme = localStorage.getItem('theme') || 'indigo';
|
|
applyThemeColors(theme);
|
|
const size = localStorage.getItem('avatar-size') || '160';
|
|
setAvatarSize(size, true);
|
|
document.getElementById('set-size').value = size;
|
|
const autospeak = localStorage.getItem('autospeak');
|
|
if (autospeak === 'false') document.getElementById('set-autospeak').checked = false;
|
|
const preset = localStorage.getItem('voice-preset') || 'default';
|
|
applyPreset(preset, true);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// NAVIGATION
|
|
// ══════════════════════════════════════════════════════════════
|
|
document.querySelectorAll('.nav-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const pane = item.dataset.pane;
|
|
navigateTo(pane);
|
|
});
|
|
});
|
|
|
|
function navigateTo(pane) {
|
|
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
|
const ni = document.querySelector(`.nav-item[data-pane="${pane}"]`);
|
|
if (ni) ni.classList.add('active');
|
|
|
|
// Hide all panes
|
|
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
|
|
document.getElementById('chat-pane').classList.remove('active');
|
|
|
|
if (pane === 'chat') {
|
|
document.getElementById('chat-pane').classList.add('active');
|
|
return;
|
|
}
|
|
|
|
const el = document.getElementById(pane + '-pane');
|
|
if (el) {
|
|
el.classList.add('active');
|
|
loadPane(pane);
|
|
}
|
|
}
|
|
|
|
function loadPane(pane) {
|
|
switch(pane) {
|
|
case 'repos': loadRepos(); break;
|
|
case 'issues': loadIssues(); break;
|
|
case 'prs': loadPRs(); break;
|
|
case 'wiki': loadWiki(); break;
|
|
case 'files': loadFileTree(); break;
|
|
case 'insights': loadInsights(); break;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// AXON API
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function axon(tool, params = {}) {
|
|
const body = {
|
|
id: `web-${Date.now()}`,
|
|
method: 'tool_call',
|
|
params: { tool, params }
|
|
};
|
|
try {
|
|
const r = await fetch(BASE + '/axon/message', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const j = await r.json();
|
|
stateKeyCount++;
|
|
pulseMemory();
|
|
document.getElementById('memory-count').textContent = stateKeyCount;
|
|
return j.result || j;
|
|
} catch(e) {
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// AVATAR
|
|
// ══════════════════════════════════════════════════════════════
|
|
const MOUTHS = {
|
|
idle: 'M 80 118 Q 100 128 120 118',
|
|
thinking: 'M 82 122 Q 100 122 118 122',
|
|
speaking: 'M 80 118 Q 100 132 120 118',
|
|
happy: 'M 76 116 Q 100 134 124 116',
|
|
concerned:'M 82 124 Q 100 118 118 124',
|
|
curious: 'M 80 120 Q 100 128 120 116',
|
|
};
|
|
|
|
function setAvatarState(state) {
|
|
const avatar = document.getElementById('avatar');
|
|
avatar.className = state;
|
|
const mouth = document.getElementById('mouth');
|
|
const fsMouth = document.getElementById('fs-mouth');
|
|
const path = MOUTHS[state] || MOUTHS.idle;
|
|
if (mouth) mouth.setAttribute('d', path);
|
|
if (fsMouth) fsMouth.setAttribute('d', path);
|
|
trackEmotion(state);
|
|
if (state === 'thinking') startThinkingAnim();
|
|
else stopThinkingAnim();
|
|
if (isWhisper) {
|
|
document.getElementById('avatar').style.filter = 'brightness(0.6)';
|
|
} else {
|
|
document.getElementById('avatar').style.filter = '';
|
|
}
|
|
}
|
|
|
|
function detectExpression(text) {
|
|
const t = text.toLowerCase();
|
|
if (t.includes('error') || t.includes('failed') || t.includes('cannot') || t.includes('fail')) return 'concerned';
|
|
if (t.includes('complete') || t.includes('success') || t.includes('done') || t.includes('✓') || t.includes('merged') || t.includes('committed')) return 'happy';
|
|
if (t.includes('?') || t.includes('what') || t.includes('which') || t.includes('how') || t.includes('where')) return 'curious';
|
|
if (text.length > 400) return 'thinking';
|
|
return 'idle';
|
|
}
|
|
|
|
function trackEmotion(state) {
|
|
emotionHistory.push(state);
|
|
if (emotionHistory.length > 5) emotionHistory.shift();
|
|
renderEmotionHistory();
|
|
}
|
|
|
|
function renderEmotionHistory() {
|
|
const el = document.getElementById('emotion-history');
|
|
el.innerHTML = emotionHistory.map(e =>
|
|
`<div class="emotion-dot ${e}" title="${e}"></div>`
|
|
).join('');
|
|
}
|
|
|
|
// ── Blinking ───────────────────────────────────────────────────
|
|
function scheduleBlink() {
|
|
const el = document.getElementById('eye-left');
|
|
const er = document.getElementById('eye-right');
|
|
function blink() {
|
|
if (el && er) {
|
|
el.animate([{ry:12},{ry:1},{ry:12}], {duration:150, easing:'ease-in-out'});
|
|
setTimeout(() => er.animate([{ry:12},{ry:1},{ry:12}], {duration:150}), 40);
|
|
}
|
|
setTimeout(blink, 2000 + Math.random() * 4000);
|
|
}
|
|
setTimeout(blink, 1000 + Math.random() * 2000);
|
|
}
|
|
|
|
// ── Pupil tracking ─────────────────────────────────────────────
|
|
function trackPupils(e) {
|
|
const avatar = document.getElementById('avatar');
|
|
if (!avatar) return;
|
|
const rect = avatar.getBoundingClientRect();
|
|
const cx = rect.left + rect.width / 2;
|
|
const cy = rect.top + rect.height / 2;
|
|
const angle = Math.atan2(e.clientY - cy, e.clientX - cx);
|
|
const dist = Math.min(3, Math.hypot(e.clientX - cx, e.clientY - cy) / 50);
|
|
const pl = document.getElementById('pupil-left');
|
|
const pr = document.getElementById('pupil-right');
|
|
const fpl = document.getElementById('fs-pupil-left');
|
|
const fpr = document.getElementById('fs-pupil-right');
|
|
const dx = Math.cos(angle) * dist;
|
|
const dy = Math.sin(angle) * dist;
|
|
if (pl) { pl.setAttribute('cx', 72 + dx); pl.setAttribute('cy', 88 + dy); }
|
|
if (pr) { pr.setAttribute('cx', 128 + dx); pr.setAttribute('cy', 88 + dy); }
|
|
if (fpl) { fpl.setAttribute('cx', 72 + dx); fpl.setAttribute('cy', 88 + dy); }
|
|
if (fpr) { fpr.setAttribute('cx', 128 + dx); fpr.setAttribute('cy', 88 + dy); }
|
|
}
|
|
|
|
// ── Thinking animation ─────────────────────────────────────────
|
|
function startThinkingAnim() {
|
|
const dots = document.querySelectorAll('.neural-dot');
|
|
let i = 0;
|
|
stopThinkingAnim();
|
|
thinkingInterval = setInterval(() => {
|
|
dots.forEach((d, idx) => {
|
|
d.style.opacity = idx === i % dots.length ? '1' : '0.2';
|
|
});
|
|
i++;
|
|
}, 200);
|
|
}
|
|
|
|
function stopThinkingAnim() {
|
|
if (thinkingInterval) { clearInterval(thinkingInterval); thinkingInterval = null; }
|
|
document.querySelectorAll('.neural-dot').forEach(d => { d.style.opacity = ''; });
|
|
}
|
|
|
|
// ── Memory pulse ───────────────────────────────────────────────
|
|
function pulseMemory() {
|
|
const el = document.getElementById('memory-indicator');
|
|
el.style.color = 'var(--accent2)';
|
|
setTimeout(() => { el.style.color = ''; }, 300);
|
|
const dots = document.querySelectorAll('.neural-dot');
|
|
dots.forEach(d => {
|
|
d.style.opacity = '1';
|
|
setTimeout(() => { d.style.opacity = ''; }, 400);
|
|
});
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// VOICE
|
|
// ══════════════════════════════════════════════════════════════
|
|
function populateVoices() {
|
|
const voices = speechSynthesis.getVoices().filter(v => v.lang.startsWith('en'));
|
|
const sel = document.getElementById('voice-select');
|
|
if (!voices.length) return;
|
|
sel.innerHTML = voices.map(v =>
|
|
`<option value="${v.name}" ${v.name.includes('Samantha') || v.name.includes('Google US') ? 'selected' : ''}>${v.name}</option>`
|
|
).join('');
|
|
voicesLoaded = true;
|
|
}
|
|
|
|
function getSelectedVoice() {
|
|
const sel = document.getElementById('voice-select').value;
|
|
return speechSynthesis.getVoices().find(v => v.name === sel) || null;
|
|
}
|
|
|
|
function speak(text, onStart, onEnd) {
|
|
if (isMuted) { onStart?.(); onEnd?.(); return; }
|
|
const autospeak = document.getElementById('set-autospeak').checked;
|
|
if (!autospeak) return;
|
|
speechSynthesis.cancel();
|
|
const utt = new SpeechSynthesisUtterance(text);
|
|
utt.voice = getSelectedVoice();
|
|
utt.pitch = parseFloat(document.getElementById('pitch-slider').value);
|
|
utt.rate = isWhisper ? 0.85 : parseFloat(document.getElementById('rate-slider').value);
|
|
utt.volume = isWhisper ? 0.3 : parseFloat(document.getElementById('vol-slider').value);
|
|
utt.onstart = () => { setAvatarState('speaking'); onStart?.(); };
|
|
utt.onend = () => { setAvatarState('idle'); onEnd?.(); };
|
|
utt.onboundary = (e) => animateMouth(e);
|
|
lastUtterance = text;
|
|
speechSynthesis.speak(utt);
|
|
}
|
|
|
|
function animateMouth(event) {
|
|
const mouth = document.getElementById('mouth');
|
|
const fsMouth = document.getElementById('fs-mouth');
|
|
const isOpen = event.charIndex % 2 === 0;
|
|
const path = isOpen ? 'M 80 118 Q 100 135 120 118' : 'M 80 118 Q 100 124 120 118';
|
|
if (mouth) mouth.setAttribute('d', path);
|
|
if (fsMouth) fsMouth.setAttribute('d', path);
|
|
}
|
|
|
|
function toggleMute() {
|
|
isMuted = !isMuted;
|
|
const btn = document.getElementById('mute-btn');
|
|
btn.textContent = isMuted ? '🔇 Unmute' : '🔊 Mute';
|
|
btn.classList.toggle('muted', isMuted);
|
|
if (isMuted) speechSynthesis.cancel();
|
|
}
|
|
|
|
function toggleWhisper() {
|
|
isWhisper = !isWhisper;
|
|
const btn = document.getElementById('fs-whisper');
|
|
btn.textContent = isWhisper ? '🔊 Normal' : '🔇 Whisper';
|
|
const avatar = document.getElementById('avatar');
|
|
avatar.style.filter = isWhisper ? 'brightness(0.6)' : '';
|
|
}
|
|
|
|
function replayLast() {
|
|
if (lastUtterance) speak(lastUtterance);
|
|
}
|
|
|
|
function updateVal(input, valId) {
|
|
document.getElementById(valId).textContent = parseFloat(input.value).toFixed(2);
|
|
}
|
|
|
|
const PRESETS = {
|
|
default: { rate: 0.95, pitch: 1.0 },
|
|
enthusiastic:{ rate: 1.2, pitch: 1.15 },
|
|
deep: { rate: 0.85, pitch: 0.75 },
|
|
rapid: { rate: 1.5, pitch: 1.0 },
|
|
};
|
|
|
|
function applyPreset(name, silent) {
|
|
const p = PRESETS[name];
|
|
if (!p) return;
|
|
document.getElementById('rate-slider').value = p.rate;
|
|
document.getElementById('pitch-slider').value = p.pitch;
|
|
document.getElementById('rate-val').textContent = p.rate.toFixed(2);
|
|
document.getElementById('pitch-val').textContent = p.pitch.toFixed(2);
|
|
document.querySelectorAll('.preset-btn').forEach(b => b.classList.toggle('active', b.dataset.preset === name));
|
|
if (!silent) localStorage.setItem('voice-preset', name);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// GREETING
|
|
// ══════════════════════════════════════════════════════════════
|
|
function greetOnLoad() {
|
|
const hour = new Date().getHours();
|
|
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening';
|
|
const msgs = [
|
|
`${greeting}. I'm ready.`,
|
|
`${greeting}. What are we building today?`,
|
|
`${greeting}. Systems are up. What's first?`,
|
|
];
|
|
const msg = msgs[Math.floor(Math.random() * msgs.length)];
|
|
setAvatarState('happy');
|
|
appendMessage('neuron', msg);
|
|
speak(msg);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// CHAT
|
|
// ══════════════════════════════════════════════════════════════
|
|
function appendMessage(role, content, rendered) {
|
|
const msgs = document.getElementById('messages');
|
|
const div = document.createElement('div');
|
|
div.className = 'msg ' + role;
|
|
const time = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
|
const avatarHtml = role === 'neuron'
|
|
? `<div class="msg-avatar" style="border-color:var(--accent)">N</div>`
|
|
: `<div class="msg-avatar" style="border-color:var(--muted);color:var(--muted)">W</div>`;
|
|
div.innerHTML = `
|
|
${avatarHtml}
|
|
<div>
|
|
<div class="msg-bubble">${rendered || escapeHtml(content)}</div>
|
|
<div class="msg-time">${time}</div>
|
|
</div>`;
|
|
msgs.appendChild(div);
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
lastActivityLog.push({ role, content, time });
|
|
}
|
|
|
|
function escapeHtml(t) {
|
|
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>');
|
|
}
|
|
|
|
async function sendChat() {
|
|
const inp = document.getElementById('chat-input');
|
|
const msg = inp.value.trim();
|
|
if (!msg) return;
|
|
inp.value = '';
|
|
appendMessage('user', msg);
|
|
setAvatarState('thinking');
|
|
|
|
const t = msg.toLowerCase();
|
|
if (t === 'recap' || t.includes('what did we do') || t.includes('session recap')) {
|
|
const recap = generateRecap();
|
|
appendMessage('neuron', recap);
|
|
setAvatarState('happy');
|
|
speak(recap);
|
|
return;
|
|
}
|
|
|
|
let result;
|
|
// Try intent first
|
|
result = await axon('intent', { repo_id: currentRepo || 'local', natural_language_query: msg });
|
|
|
|
const expr = detectExpression(JSON.stringify(result));
|
|
setAvatarState(expr);
|
|
|
|
const rendered = renderResult(result);
|
|
appendMessage('neuron', JSON.stringify(result), rendered);
|
|
const spoken = buildSpokenSummary(result, msg);
|
|
speak(spoken);
|
|
}
|
|
|
|
function buildSpokenSummary(result, query) {
|
|
if (result.error) return `There was an error: ${result.error}`;
|
|
if (result.commits) return `Found ${result.commits.length} commits.`;
|
|
if (result.issues) return `Found ${result.issues.length} issues.`;
|
|
if (result.prs) return `Found ${result.prs.length} pull requests.`;
|
|
if (result.file_count) return `Repo has ${result.file_count} files.`;
|
|
if (result.resolved_tool) return `I ran ${result.resolved_tool}.`;
|
|
return 'Done.';
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// RESULT RENDERING
|
|
// ══════════════════════════════════════════════════════════════
|
|
function renderResult(result) {
|
|
if (!result || typeof result !== 'object') return escapeHtml(String(result));
|
|
if (result.error) return `<div style="color:var(--red);font-size:13px">⚠ ${escapeHtml(result.error)}</div>`;
|
|
if (result.commits && Array.isArray(result.commits)) return renderCommitLog(result.commits);
|
|
if (result.issues && Array.isArray(result.issues) && result.issues[0]?.number) return renderIssueList(result.issues);
|
|
if (result.prs && Array.isArray(result.prs)) return renderPRList(result.prs);
|
|
if (result.file_count !== undefined) return renderRepoStats(result);
|
|
if (result.detected_style || result.architecture_style) return renderArchitecture(result);
|
|
if (result.arbor_source) return renderDiagram(result);
|
|
if (result.content && typeof result.content === 'string' && result.slug) return renderWikiPage(result);
|
|
if (result.findings) return renderSecurityFindings(result.findings);
|
|
return `<pre class="json">${syntaxHighlight(JSON.stringify(result, null, 2))}</pre>`;
|
|
}
|
|
|
|
function renderCommitLog(commits) {
|
|
return commits.map(c => `
|
|
<div class="commit-row">
|
|
<div class="commit-hash">${(c.hash || c.id || '').slice(0,7)}</div>
|
|
<div>
|
|
<div class="commit-msg">${escapeHtml(c.message || '')}</div>
|
|
<div class="commit-meta">${escapeHtml(c.author || '')} · ${c.timestamp || c.time || ''}</div>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderIssueList(issues) {
|
|
return issues.map(i => `
|
|
<div class="issue-row">
|
|
<div class="issue-num">#${i.number}</div>
|
|
<div class="issue-title">${escapeHtml(i.title || '')}</div>
|
|
<span class="badge badge-${i.status === 'closed' ? 'closed' : 'open'}">${i.status || 'open'}</span>
|
|
<div class="commit-meta" style="margin-left:8px">${escapeHtml(i.author || '')}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderPRList(prs) {
|
|
return prs.map(pr => `
|
|
<div class="pr-row">
|
|
<div class="issue-num">#${pr.number}</div>
|
|
<div class="issue-title">${escapeHtml(pr.title || '')}</div>
|
|
<div class="pr-branches">${escapeHtml(pr.from_branch||'')} → ${escapeHtml(pr.to_branch||'main')}</div>
|
|
<span class="badge badge-${pr.status === 'merged' ? 'merged' : pr.status === 'closed' ? 'closed' : 'open'}">${pr.status || 'open'}</span>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderRepoStats(r) {
|
|
return `
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px">
|
|
${r.file_count !== undefined ? `<div class="stat-card"><div class="stat-val">${r.file_count}</div><div class="stat-label">Files</div></div>` : ''}
|
|
${r.commit_count !== undefined ? `<div class="stat-card"><div class="stat-val">${r.commit_count}</div><div class="stat-label">Commits</div></div>` : ''}
|
|
${r.component_count !== undefined ? `<div class="stat-card"><div class="stat-val">${r.component_count}</div><div class="stat-label">Components</div></div>` : ''}
|
|
</div>
|
|
${r.description ? `<div style="font-size:13px;color:var(--muted)">${escapeHtml(r.description)}</div>` : ''}`;
|
|
}
|
|
|
|
function renderArchitecture(r) {
|
|
return `
|
|
<div class="card">
|
|
<div class="card-title">${r.architecture_style || r.detected_style || 'Architecture'}</div>
|
|
<div class="card-meta">${r.description || ''}</div>
|
|
</div>
|
|
${r.components ? r.components.map(c => `<div class="chip">${escapeHtml(c.name)}</div>`).join('') : ''}`;
|
|
}
|
|
|
|
function renderDiagram(r) {
|
|
return `
|
|
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:12px">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
|
|
<span style="font-size:12px;font-weight:600">Architecture Diagram</span>
|
|
<button class="btn btn-sm btn-secondary" onclick="copyText(\`${r.arbor_source.replace(/`/g,'\\`')}\`)">Copy</button>
|
|
</div>
|
|
<pre style="font-size:11px;line-height:1.5;overflow-x:auto">${escapeHtml(r.arbor_source)}</pre>
|
|
</div>`;
|
|
}
|
|
|
|
function renderWikiPage(r) {
|
|
return `<div class="wiki-body">${escapeHtml(r.content)}</div>`;
|
|
}
|
|
|
|
function renderSecurityFindings(findings) {
|
|
return findings.map(f => `
|
|
<div class="sec-finding">
|
|
<span class="badge badge-${f.severity?.toLowerCase() || 'low'}">${f.severity || 'LOW'}</span>
|
|
<span class="path" style="margin-left:8px">${escapeHtml(f.file || '')}</span>
|
|
<div class="desc">${escapeHtml(f.description || f.message || '')}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function syntaxHighlight(json) {
|
|
return json
|
|
.replace(/("[\w\s]+")\s*:/g, '<span class="json-key">$1</span>:')
|
|
.replace(/:\s*(".*?")/g, ': <span class="json-str">$1</span>')
|
|
.replace(/:\s*(\d+\.?\d*)/g, ': <span class="json-num">$1</span>')
|
|
.replace(/:\s*(true|false|null)/g, ': <span class="json-bool">$1</span>');
|
|
}
|
|
|
|
function copyText(t) {
|
|
navigator.clipboard.writeText(t);
|
|
showToast('Copied to clipboard', '', 1500);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// REPOS PANE
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function loadRepos() {
|
|
const result = await axon('list_repos', {});
|
|
const el = document.getElementById('repos-list');
|
|
const repos = result.repos || result || [];
|
|
if (!Array.isArray(repos) || repos.length === 0) {
|
|
el.innerHTML = '<div style="color:var(--muted);font-size:13px">No repos registered. Register one above.</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = repos.map(r => `
|
|
<div class="card" onclick="selectRepo('${r.repo_id || r.id}','${escapeHtml(r.name||r.repo_id||r.id)}')">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div class="card-title" style="flex:1">📁 ${escapeHtml(r.name || r.repo_id || r.id || 'Unknown')}</div>
|
|
${currentRepo === (r.repo_id || r.id) ? '<span class="badge" style="background:var(--accent)">active</span>' : ''}
|
|
</div>
|
|
<div class="card-meta">${escapeHtml(r.path || '')} · ${r.file_count||0} files · ${r.architecture_style||''}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
async function loadRepoSelector() {
|
|
const result = await axon('list_repos', {});
|
|
const repos = result.repos || result || [];
|
|
const sel = document.getElementById('repo-selector');
|
|
const existing = Array.from(sel.options).map(o => o.value);
|
|
(Array.isArray(repos) ? repos : []).forEach(r => {
|
|
const id = r.repo_id || r.id;
|
|
if (!existing.includes(id)) {
|
|
const opt = document.createElement('option');
|
|
opt.value = id; opt.textContent = r.name || id;
|
|
sel.appendChild(opt);
|
|
}
|
|
});
|
|
if (currentRepo) sel.value = currentRepo;
|
|
sel.addEventListener('change', () => {
|
|
currentRepo = sel.value;
|
|
localStorage.setItem('current-repo', currentRepo);
|
|
});
|
|
}
|
|
|
|
function selectRepo(id, name) {
|
|
currentRepo = id;
|
|
localStorage.setItem('current-repo', id);
|
|
document.getElementById('repo-selector').value = id;
|
|
loadRepos();
|
|
loadCommitGraph(id);
|
|
setAvatarState('happy');
|
|
speak(`Switched to ${name}.`);
|
|
}
|
|
|
|
async function loadCommitGraph(repoId) {
|
|
const result = await axon('vcs_log', { repo_id: repoId });
|
|
const commits = result.commits || result || [];
|
|
if (!Array.isArray(commits) || commits.length === 0) return;
|
|
document.getElementById('commit-graph').style.display = 'block';
|
|
document.getElementById('commit-graph-inner').innerHTML = commits.map(c => `
|
|
<div class="cg-row">
|
|
<div class="cg-dot"></div>
|
|
<div class="cg-hash">${(c.hash||c.id||'').slice(0,7)}</div>
|
|
<div class="cg-msg">${escapeHtml(c.message||'')}</div>
|
|
<div class="cg-meta">${escapeHtml(c.author||'')} · ${c.timestamp||''}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function showRegisterForm() {
|
|
document.getElementById('register-form').style.display = 'block';
|
|
}
|
|
|
|
async function registerRepo() {
|
|
const path = document.getElementById('reg-path').value.trim();
|
|
const name = document.getElementById('reg-name').value.trim();
|
|
if (!path) return;
|
|
setAvatarState('thinking');
|
|
const result = await axon('index_repo', { path, repo_id: name || path.split('/').pop() });
|
|
document.getElementById('register-form').style.display = 'none';
|
|
loadRepos();
|
|
loadRepoSelector();
|
|
currentRepo = result.repo_id || name;
|
|
localStorage.setItem('current-repo', currentRepo);
|
|
setAvatarState('happy');
|
|
const msg = result.description
|
|
? `I've found your project. It looks like a ${result.architecture_style}. ${result.file_count} files in total. What would you like to build today?`
|
|
: `Repo registered.`;
|
|
appendMessage('neuron', msg);
|
|
speak(msg);
|
|
checkWelcome();
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// WELCOME
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function checkWelcome() {
|
|
const result = await axon('list_repos', {});
|
|
const repos = result.repos || result || [];
|
|
const hasRepos = Array.isArray(repos) && repos.length > 0;
|
|
document.getElementById('welcome').classList.toggle('show', !hasRepos);
|
|
document.getElementById('pane-area').style.display = hasRepos ? 'flex' : 'none';
|
|
if (!hasRepos) {
|
|
document.getElementById('chat-pane').classList.remove('active');
|
|
}
|
|
}
|
|
|
|
async function welcomeRegister() {
|
|
const path = document.getElementById('welcome-input').value.trim();
|
|
if (!path) return;
|
|
const btn = document.getElementById('welcome-register');
|
|
btn.textContent = 'Registering…';
|
|
const result = await axon('index_repo', { path, repo_id: path.split('/').pop() });
|
|
currentRepo = result.repo_id || path.split('/').pop();
|
|
localStorage.setItem('current-repo', currentRepo);
|
|
await loadRepoSelector();
|
|
document.getElementById('welcome').classList.remove('show');
|
|
document.getElementById('pane-area').style.display = 'flex';
|
|
navigateTo('chat');
|
|
const msg = result.architecture_style
|
|
? `I've found your project. It looks like a ${result.architecture_style}. ${result.file_count||0} files. What would you like to build today?`
|
|
: `Repo registered. What would you like to build today?`;
|
|
setAvatarState('happy');
|
|
appendMessage('neuron', msg);
|
|
speak(msg);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ISSUES
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function loadIssues() {
|
|
const result = await axon('list_issues', { repo_id: currentRepo || 'local' });
|
|
allIssues = result.issues || result || [];
|
|
if (!Array.isArray(allIssues)) allIssues = [];
|
|
const badge = document.getElementById('issues-badge');
|
|
const open = allIssues.filter(i => i.status !== 'closed').length;
|
|
badge.textContent = open; badge.style.display = open > 0 ? '' : 'none';
|
|
if (issueView === 'list') renderIssueListView();
|
|
else renderKanban();
|
|
}
|
|
|
|
function renderIssueListView() {
|
|
const el = document.getElementById('issues-list');
|
|
if (!allIssues.length) { el.innerHTML = '<div style="color:var(--muted);font-size:13px">No issues found.</div>'; return; }
|
|
el.innerHTML = allIssues.map(i => `
|
|
<div class="card" onclick="expandIssue(${i.number})">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span class="issue-num" style="width:auto">#${i.number}</span>
|
|
<div class="card-title" style="flex:1">${escapeHtml(i.title||'')}</div>
|
|
<span class="badge badge-${i.status === 'closed' ? 'closed' : 'open'}">${i.status||'open'}</span>
|
|
</div>
|
|
<div class="card-meta" style="margin-top:4px">${escapeHtml(i.author||'')} · ${i.created_at||''}
|
|
${(i.labels||[]).map(l=>`<span class="label-chip">${escapeHtml(l)}</span>`).join('')}
|
|
</div>
|
|
<div id="issue-expand-${i.number}" style="display:none;margin-top:10px">
|
|
<div style="font-size:12px;line-height:1.6;color:var(--muted)">${escapeHtml(i.body||'')}</div>
|
|
<div style="margin-top:8px;display:flex;gap:6px">
|
|
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();closeIssue(${i.number})">Close</button>
|
|
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation();commentPrompt(${i.number})">Comment</button>
|
|
</div>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function expandIssue(num) {
|
|
const el = document.getElementById(`issue-expand-${num}`);
|
|
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
}
|
|
|
|
function renderKanban() {
|
|
['open','in_progress','closed'].forEach(status => {
|
|
const cards = allIssues.filter(i => {
|
|
if (status === 'open') return i.status === 'open' || !i.status;
|
|
return i.status === status;
|
|
});
|
|
const el = document.getElementById(`kcard-${status}`);
|
|
const cnt = document.getElementById(`kcnt-${status}`);
|
|
if (cnt) cnt.textContent = cards.length;
|
|
if (el) el.innerHTML = cards.map(i => `
|
|
<div class="kanban-card" draggable="true" ondragstart="dragStart(event,${i.number})" data-issue="${i.number}">
|
|
<div style="display:flex;justify-content:space-between">
|
|
<span style="font-size:10px;color:var(--muted)">#${i.number}</span>
|
|
<span class="badge badge-${i.status === 'closed' ? 'closed' : 'open'}" style="font-size:9px">${i.status||'open'}</span>
|
|
</div>
|
|
<div style="margin-top:4px">${escapeHtml(i.title||'')}</div>
|
|
${(i.labels||[]).map(l=>`<span class="label-chip" style="margin-top:4px">${escapeHtml(l)}</span>`).join('')}
|
|
</div>`).join('');
|
|
});
|
|
}
|
|
|
|
function setIssueView(v, btn) {
|
|
issueView = v;
|
|
document.querySelectorAll('#issues-pane .view-toggle button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('issues-list-view').style.display = v === 'list' ? 'block' : 'none';
|
|
document.getElementById('kanban').style.display = v === 'kanban' ? 'flex' : 'none';
|
|
if (v === 'kanban') renderKanban();
|
|
else renderIssueListView();
|
|
}
|
|
|
|
function dragStart(e, num) { draggedIssue = num; e.currentTarget.classList.add('dragging'); }
|
|
function dragOver(e) { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
|
|
async function drop(e, status) {
|
|
e.preventDefault();
|
|
e.currentTarget.classList.remove('drag-over');
|
|
if (!draggedIssue) return;
|
|
await axon('update_issue', { repo_id: currentRepo||'local', number: draggedIssue, status });
|
|
draggedIssue = null;
|
|
loadIssues();
|
|
}
|
|
|
|
function showNewIssueModal() {
|
|
const title = prompt('Issue title:');
|
|
if (!title) return;
|
|
const body = prompt('Issue body (optional):') || '';
|
|
axon('create_issue', { repo_id: currentRepo||'local', title, body, author: 'developer' }).then(() => loadIssues());
|
|
}
|
|
|
|
async function closeIssue(num) {
|
|
await axon('close_issue', { repo_id: currentRepo||'local', number: num });
|
|
loadIssues();
|
|
}
|
|
|
|
function commentPrompt(num) {
|
|
const body = prompt('Comment:');
|
|
if (!body) return;
|
|
axon('comment_issue', { repo_id: currentRepo||'local', number: num, author: 'developer', body });
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// PULL REQUESTS
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function loadPRs() {
|
|
const result = await axon('list_prs', { repo_id: currentRepo || 'local' });
|
|
const prs = result.prs || result || [];
|
|
const el = document.getElementById('prs-list');
|
|
if (!Array.isArray(prs) || prs.length === 0) {
|
|
el.innerHTML = '<div style="color:var(--muted);font-size:13px">No pull requests found.</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = prs.map(pr => `
|
|
<div class="card">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span style="font-size:11px;color:var(--muted)">#${pr.number}</span>
|
|
<div class="card-title" style="flex:1">${escapeHtml(pr.title||'')}</div>
|
|
<span class="badge badge-${pr.status === 'merged' ? 'merged' : pr.status === 'closed' ? 'closed' : 'open'}">${pr.status||'open'}</span>
|
|
</div>
|
|
<div class="card-meta">
|
|
<code style="color:var(--accent2)">${escapeHtml(pr.from_branch||'')} → ${escapeHtml(pr.to_branch||'main')}</code>
|
|
· ${escapeHtml(pr.author||'')} · ${pr.created_at||''}
|
|
</div>
|
|
<div style="margin-top:8px;display:flex;gap:6px">
|
|
${pr.status === 'open' ? `<button class="btn btn-sm btn-primary" onclick="mergePR(${pr.number})">Merge</button>` : ''}
|
|
<button class="btn btn-sm btn-secondary" onclick="reviewPR(${pr.number})">AI Review</button>
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
async function mergePR(num) {
|
|
await axon('merge_pr', { repo_id: currentRepo||'local', number: num, merge_strategy: 'merge' });
|
|
setAvatarState('happy'); speak('Merged.');
|
|
loadPRs();
|
|
}
|
|
|
|
async function reviewPR(num) {
|
|
const result = await axon('pr_copilot', { repo_id: currentRepo||'local', number: num });
|
|
showToast('PR Review', result.review || JSON.stringify(result), 5000);
|
|
}
|
|
|
|
function showNewPRModal() {
|
|
const title = prompt('PR title:');
|
|
if (!title) return;
|
|
const from = prompt('From branch:') || 'feature/new';
|
|
const to = prompt('To branch:') || 'main';
|
|
axon('create_pr', { repo_id: currentRepo||'local', title, body:'', author:'developer', from_branch:from, to_branch:to }).then(() => loadPRs());
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// PIPELINE
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function runPipeline() {
|
|
const intent = document.getElementById('pipeline-intent').value.trim();
|
|
if (!intent) return;
|
|
lastPipelineIntent = intent;
|
|
document.getElementById('pipeline-steps').innerHTML = '<div style="color:var(--muted);font-size:13px">Running…</div>';
|
|
document.getElementById('pipeline-banner').style.display = 'none';
|
|
setAvatarState('thinking');
|
|
const result = await axon('run_pipeline', { repo_id: currentRepo||'local', intent, path: '' });
|
|
renderPipelineResult(result);
|
|
}
|
|
|
|
async function rerunPipeline() {
|
|
if (!lastPipelineIntent) return;
|
|
document.getElementById('pipeline-intent').value = lastPipelineIntent;
|
|
runPipeline();
|
|
}
|
|
|
|
function renderPipelineResult(result) {
|
|
const steps = result.steps || [];
|
|
const el = document.getElementById('pipeline-steps');
|
|
if (steps.length) {
|
|
el.innerHTML = steps.map(s => `
|
|
<div class="pl-step">
|
|
<div class="pl-step-status pl-status-${s.status === 'pass' ? 'pass' : s.status === 'fail' ? 'fail' : 'pass'}"></div>
|
|
<div class="pl-step-body">
|
|
<div class="pl-step-name">${escapeHtml(s.name||s.command||'')}</div>
|
|
<div class="pl-step-out">${escapeHtml(s.output||'')}</div>
|
|
</div>
|
|
${s.duration_ms ? `<div class="pl-duration">${s.duration_ms}ms</div>` : ''}
|
|
</div>`).join('');
|
|
} else {
|
|
el.innerHTML = `<pre class="json">${syntaxHighlight(JSON.stringify(result,null,2))}</pre>`;
|
|
}
|
|
const banner = document.getElementById('pipeline-banner');
|
|
const passed = result.exit_code === 0 || result.success === true;
|
|
banner.style.display = 'block';
|
|
banner.className = `pl-banner ${passed ? 'pass' : 'fail'}`;
|
|
banner.textContent = passed ? '✓ PASSED' : '✗ FAILED';
|
|
setAvatarState(passed ? 'happy' : 'concerned');
|
|
speak(passed ? 'Pipeline passed.' : `Pipeline failed.`);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// FILE BROWSER
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function loadFileTree(path) {
|
|
const p = path || '.';
|
|
const result = await axon('list_tree', { repo_id: currentRepo||'local', path: p });
|
|
const tree = result.entries || result.tree || result || [];
|
|
const el = document.getElementById('file-tree');
|
|
el.innerHTML = (Array.isArray(tree) ? tree : []).map(entry => `
|
|
<div class="tree-node ${entry.kind === 'dir' ? 'tree-dir' : 'tree-file'}"
|
|
onclick="${entry.kind === 'dir' ? `loadFileTree('${escapeHtml(entry.path)}')` : `loadFile('${escapeHtml(entry.path)}')`}">
|
|
<span class="icon">${entry.kind === 'dir' ? '📂' : '📄'}</span>${escapeHtml(entry.name || entry.path)}
|
|
</div>`).join('');
|
|
}
|
|
|
|
async function loadFile(path) {
|
|
document.getElementById('file-path-breadcrumb').textContent = path;
|
|
document.getElementById('copy-file-btn').style.display = '';
|
|
const result = await axon('get_file', { repo_id: currentRepo||'local', path });
|
|
const content = result.content || result.source || JSON.stringify(result, null, 2);
|
|
document.getElementById('file-content-area').textContent = content;
|
|
window._lastFileContent = content;
|
|
}
|
|
|
|
function copyFileContent() {
|
|
if (window._lastFileContent) copyText(window._lastFileContent);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SEARCH
|
|
// ══════════════════════════════════════════════════════════════
|
|
function setSearchType(t, btn) {
|
|
searchType = t;
|
|
document.querySelectorAll('#search-pane .view-toggle button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
}
|
|
|
|
async function doSearch() {
|
|
const q = document.getElementById('search-input').value.trim();
|
|
if (!q) return;
|
|
const el = document.getElementById('search-results');
|
|
el.innerHTML = '<div style="color:var(--muted)">Searching…</div>';
|
|
let result;
|
|
if (searchType === 'code') result = await axon('search_code', { repo_id: currentRepo||'local', query: q });
|
|
else if (searchType === 'issues') result = await axon('search_issues', { repo_id: currentRepo||'local', query: q });
|
|
else result = await axon('semantic_search', { repo_id: currentRepo||'local', query: q });
|
|
el.innerHTML = renderResult(result);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// WIKI
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function loadWiki() {
|
|
const result = await axon('list_wiki', { repo_id: currentRepo||'local' });
|
|
const pages = result.pages || result || [];
|
|
const el = document.getElementById('wiki-list');
|
|
el.innerHTML = (Array.isArray(pages) ? pages : []).map(p => `
|
|
<div class="wiki-card" onclick="loadWikiPage('${escapeHtml(p.slug)}')">
|
|
<div class="wiki-title">${escapeHtml(p.title||p.slug)}</div>
|
|
<div class="wiki-meta">${p.updated_at||''}</div>
|
|
</div>`).join('') || '<div style="padding:14px;color:var(--muted);font-size:13px">No pages.</div>';
|
|
}
|
|
|
|
async function loadWikiPage(slug) {
|
|
const result = await axon('get_wiki_page', { repo_id: currentRepo||'local', slug });
|
|
document.getElementById('wiki-content').innerHTML = `
|
|
<div style="font-size:15px;font-weight:700;margin-bottom:12px">${escapeHtml(result.title||slug)}</div>
|
|
<div class="wiki-body">${escapeHtml(result.body||result.content||'')}</div>`;
|
|
}
|
|
|
|
function showNewWikiModal() {
|
|
const slug = prompt('Page slug (e.g. getting-started):');
|
|
if (!slug) return;
|
|
const title = prompt('Page title:') || slug;
|
|
axon('create_wiki_page', { repo_id: currentRepo||'local', slug, title, body:`# ${title}`, author:'developer' }).then(() => loadWiki());
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SECURITY
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function runSecurityScan() {
|
|
document.getElementById('security-results').innerHTML = '<div style="color:var(--muted)">Scanning…</div>';
|
|
setAvatarState('thinking');
|
|
const result = await axon('security_scan', { repo_id: currentRepo||'local' });
|
|
const findings = result.findings || result.issues || [];
|
|
const counts = { HIGH:0, MEDIUM:0, LOW:0 };
|
|
findings.forEach(f => { const k = (f.severity||'LOW').toUpperCase(); if (counts[k]!==undefined) counts[k]++; });
|
|
const badges = document.getElementById('sec-badges');
|
|
badges.innerHTML = Object.entries(counts).map(([k,v]) =>
|
|
v > 0 ? `<span class="badge badge-${k.toLowerCase()}">${k}: ${v}</span>` : ''
|
|
).join('');
|
|
document.getElementById('sec-last-scan').textContent = 'Last scanned: ' + new Date().toLocaleTimeString();
|
|
const el = document.getElementById('security-results');
|
|
const reviewed = JSON.parse(localStorage.getItem('sec-reviewed') || '{}');
|
|
el.innerHTML = findings.length
|
|
? findings.map((f, i) => `
|
|
<div class="sec-finding" style="${reviewed[i] ? 'opacity:.4' : ''}">
|
|
<label style="cursor:pointer">
|
|
<input type="checkbox" ${reviewed[i]?'checked':''} onchange="markReviewed(${i},this.checked)"/>
|
|
<span class="badge badge-${(f.severity||'low').toLowerCase()}">${f.severity||'LOW'}</span>
|
|
<span class="path" style="margin-left:8px">${escapeHtml(f.file||f.path||'')}</span>
|
|
</label>
|
|
<div class="desc">${escapeHtml(f.description||f.message||f.check||'')}</div>
|
|
</div>`).join('')
|
|
: '<div style="color:var(--green);font-size:13px">No findings.</div>';
|
|
if (findings.length > 0) { setAvatarState('concerned'); speak(`Found ${findings.length} security findings.`); }
|
|
else { setAvatarState('happy'); speak('No security issues found.'); }
|
|
}
|
|
|
|
function markReviewed(i, v) {
|
|
const r = JSON.parse(localStorage.getItem('sec-reviewed') || '{}');
|
|
r[i] = v;
|
|
localStorage.setItem('sec-reviewed', JSON.stringify(r));
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// INSIGHTS
|
|
// ══════════════════════════════════════════════════════════════
|
|
async function loadInsights() {
|
|
const el = document.getElementById('insights-content');
|
|
el.innerHTML = '<div style="color:var(--muted)">Loading…</div>';
|
|
const result = await axon('repo_insights', { repo_id: currentRepo||'local' });
|
|
el.innerHTML = `<pre class="json">${syntaxHighlight(JSON.stringify(result, null, 2))}</pre>`;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SSE EVENT STREAM
|
|
// ══════════════════════════════════════════════════════════════
|
|
function connectSSE() {
|
|
if (sseSource) sseSource.close();
|
|
sseSource = new EventSource(BASE + '/axon/sse');
|
|
sseSource.onopen = () => {
|
|
document.getElementById('server-status').classList.remove('offline');
|
|
document.getElementById('ticker-inner').textContent = '● Connected';
|
|
};
|
|
sseSource.onerror = () => {
|
|
document.getElementById('server-status').classList.add('offline');
|
|
document.getElementById('ticker-inner').textContent = '○ Disconnected — retrying…';
|
|
setTimeout(connectSSE, 3000);
|
|
};
|
|
sseSource.onmessage = (e) => {
|
|
let event;
|
|
try { event = JSON.parse(e.data); } catch { return; }
|
|
handleSSEEvent(event);
|
|
};
|
|
}
|
|
|
|
function handleSSEEvent(event) {
|
|
eventCount++;
|
|
document.getElementById('event-count').textContent = `${eventCount} events`;
|
|
addToEventFeed(event);
|
|
updateTicker(event);
|
|
|
|
const et = event.event_type || '';
|
|
if (et === 'vcs.commit') { loadCommitGraph(currentRepo); setAvatarState('happy'); speak('Committed.'); }
|
|
if (et === 'pr.merged') { loadPRs(); setAvatarState('happy'); speak('Merged.'); }
|
|
if (et === 'issue.created') loadIssues();
|
|
if (et === 'pipeline.output') appendPipelineOutput(event.payload);
|
|
if (et === 'pipeline.complete') markPipelineComplete(event.payload);
|
|
if (et.includes('error') || et.includes('fail')) {
|
|
setAvatarState('concerned');
|
|
const msg = event.payload ? `Something failed: ${JSON.stringify(event.payload).slice(0,60)}` : 'An error occurred.';
|
|
speak(msg);
|
|
}
|
|
if (et === 'cli.command') {
|
|
showToast(`> ${event.payload?.tool || event.payload?.command || ''}`, event.payload?.summary || '', 3000, 'cli');
|
|
}
|
|
if (et === 'security.found') setAvatarState('concerned');
|
|
}
|
|
|
|
function addToEventFeed(event) {
|
|
const el = document.getElementById('events-list');
|
|
const div = document.createElement('div');
|
|
div.className = 'card';
|
|
div.style.marginBottom = '6px';
|
|
div.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<span class="chip">${escapeHtml(event.event_type||'event')}</span>
|
|
<span style="font-size:11px;color:var(--muted);margin-left:auto">${new Date().toLocaleTimeString()}</span>
|
|
</div>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:4px;font-family:monospace">${escapeHtml(JSON.stringify(event.payload||'').slice(0,120))}</div>`;
|
|
el.insertBefore(div, el.firstChild);
|
|
if (el.children.length > 50) el.removeChild(el.lastChild);
|
|
}
|
|
|
|
function updateTicker(event) {
|
|
const ticker = document.getElementById('ticker-inner');
|
|
const et = event.event_type || 'event';
|
|
ticker.textContent = `⚡ ${et}`;
|
|
ticker.style.color = 'var(--accent2)';
|
|
setTimeout(() => { ticker.style.color = ''; ticker.textContent = '● Live'; }, 2000);
|
|
}
|
|
|
|
function appendPipelineOutput(payload) {
|
|
const el = document.getElementById('pipeline-steps');
|
|
const div = document.createElement('div');
|
|
div.className = 'pl-step';
|
|
div.innerHTML = `<div class="pl-step-body"><div class="pl-step-out">${escapeHtml(String(payload?.line||payload||''))}</div></div>`;
|
|
el.appendChild(div);
|
|
}
|
|
|
|
function markPipelineComplete(payload) {
|
|
const banner = document.getElementById('pipeline-banner');
|
|
const passed = payload?.exit_code === 0 || payload?.success;
|
|
banner.style.display = 'block';
|
|
banner.className = `pl-banner ${passed ? 'pass' : 'fail'}`;
|
|
banner.textContent = passed ? '✓ PASSED' : '✗ FAILED';
|
|
setAvatarState(passed ? 'happy' : 'concerned');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// VOICE INPUT (MIC)
|
|
// ══════════════════════════════════════════════════════════════
|
|
let recognition = null;
|
|
function toggleMic() {
|
|
if (recognition) { recognition.stop(); recognition = null; document.getElementById('mic-btn').classList.remove('listening'); return; }
|
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if (!SR) { showToast('Voice input not supported in this browser', '', 2000); return; }
|
|
recognition = new SR();
|
|
recognition.continuous = false;
|
|
recognition.interimResults = true;
|
|
recognition.lang = 'en-US';
|
|
recognition.onresult = (e) => {
|
|
const t = Array.from(e.results).map(r => r[0].transcript).join('');
|
|
document.getElementById('chat-input').value = t;
|
|
if (e.results[0].isFinal) { recognition.stop(); recognition = null; sendChat(); }
|
|
};
|
|
recognition.onend = () => { document.getElementById('mic-btn').classList.remove('listening'); recognition = null; };
|
|
recognition.start();
|
|
setAvatarState('curious');
|
|
document.getElementById('mic-btn').classList.add('listening');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// FULLSCREEN AVATAR
|
|
// ══════════════════════════════════════════════════════════════
|
|
function enterFullscreen() {
|
|
document.getElementById('fullscreen-overlay').classList.add('open');
|
|
document.getElementById('fs-input').focus();
|
|
}
|
|
|
|
function exitFullscreen() {
|
|
document.getElementById('fullscreen-overlay').classList.remove('open');
|
|
}
|
|
|
|
async function fsChat() {
|
|
const inp = document.getElementById('fs-input');
|
|
const msg = inp.value.trim();
|
|
if (!msg) return;
|
|
inp.value = '';
|
|
setAvatarState('thinking');
|
|
const result = await axon('intent', { repo_id: currentRepo||'local', natural_language_query: msg });
|
|
const expr = detectExpression(JSON.stringify(result));
|
|
setAvatarState(expr);
|
|
speak(buildSpokenSummary(result, msg));
|
|
}
|
|
|
|
// ── Neural background animation ────────────────────────────────
|
|
function renderNeuralBg() {
|
|
const svg = document.getElementById('neural-net');
|
|
if (!svg) return;
|
|
const nodes = Array.from({length:20}, () => ({ x: Math.random()*800, y: Math.random()*600 }));
|
|
svg.innerHTML = nodes.map(n => `<circle cx="${n.x}" cy="${n.y}" r="${1+Math.random()*3}" fill="#6366f1" opacity="${.3+Math.random()*.5}">
|
|
<animate attributeName="opacity" values="${.2+Math.random()*.5};${.7+Math.random()*.3};${.2+Math.random()*.5}" dur="${2+Math.random()*3}s" repeatCount="indefinite"/>
|
|
</circle>`).join('') +
|
|
nodes.flatMap((n,i) => nodes.slice(i+1).filter(()=>Math.random()<.25).map(m =>
|
|
`<line x1="${n.x}" y1="${n.y}" x2="${m.x}" y2="${m.y}" stroke="#6366f1" stroke-width=".5" opacity=".2">
|
|
<animate attributeName="opacity" values=".1;.4;.1" dur="${3+Math.random()*4}s" repeatCount="indefinite"/>
|
|
</line>`
|
|
)).join('');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// KEYBOARD SHORTCUTS
|
|
// ══════════════════════════════════════════════════════════════
|
|
function setupKeyboard() {
|
|
document.addEventListener('keydown', e => {
|
|
const isInput = ['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName);
|
|
if (e.key === 'Escape') {
|
|
exitFullscreen();
|
|
speechSynthesis.cancel();
|
|
return;
|
|
}
|
|
if (e.key === 'f' && !isInput) { enterFullscreen(); return; }
|
|
if (e.key === 'r' && !isInput) { replayLast(); return; }
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); document.getElementById('chat-input').focus(); return; }
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); toggleMic(); return; }
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'm') { e.preventDefault(); toggleMute(); return; }
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && isInput) { e.preventDefault(); sendChat(); return; }
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '1') navigateTo('chat');
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '2') navigateTo('issues');
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '3') navigateTo('prs');
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '4') navigateTo('pipeline');
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '5') navigateTo('search');
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '6') navigateTo('wiki');
|
|
});
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SETTINGS
|
|
// ══════════════════════════════════════════════════════════════
|
|
document.getElementById('settings-btn').onclick = () => {
|
|
document.getElementById('settings-modal').classList.add('open');
|
|
};
|
|
document.getElementById('settings-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('settings-modal'))
|
|
document.getElementById('settings-modal').classList.remove('open');
|
|
});
|
|
document.getElementById('fullscreen-btn').onclick = enterFullscreen;
|
|
|
|
function setTheme(name, swatch) {
|
|
document.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
|
|
swatch.classList.add('active');
|
|
applyThemeColors(name);
|
|
localStorage.setItem('theme', name);
|
|
}
|
|
|
|
function applyThemeColors(name) {
|
|
const t = THEMES[name] || THEMES.indigo;
|
|
document.documentElement.style.setProperty('--accent', t.accent);
|
|
document.documentElement.style.setProperty('--accent2', t.accent2);
|
|
document.querySelectorAll('.glow-ring').forEach(r => r.setAttribute('stroke', t.accent));
|
|
document.querySelectorAll('#eye-left,#eye-right,#fs-eye-left,#fs-eye-right').forEach(e => e.setAttribute('fill', t.accent2));
|
|
}
|
|
|
|
function setAvatarSize(size, silent) {
|
|
const px = parseInt(size);
|
|
document.getElementById('avatar').style.width = px + 'px';
|
|
document.getElementById('avatar').style.height = px + 'px';
|
|
document.getElementById('avatar-wrap').style.width = px + 'px';
|
|
document.getElementById('avatar-wrap').style.height = px + 'px';
|
|
if (!silent) localStorage.setItem('avatar-size', size);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// TOAST
|
|
// ══════════════════════════════════════════════════════════════
|
|
function showToast(title, body, duration = 3000, cls = '') {
|
|
const area = document.getElementById('toast-area');
|
|
const div = document.createElement('div');
|
|
div.className = 'toast ' + cls;
|
|
div.innerHTML = `<div class="toast-cmd">${escapeHtml(title)}</div>${body ? `<div class="toast-res">${escapeHtml(String(body).slice(0,120))}</div>` : ''}`;
|
|
area.appendChild(div);
|
|
setTimeout(() => div.remove(), duration);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SESSION RECAP
|
|
// ══════════════════════════════════════════════════════════════
|
|
function generateRecap() {
|
|
const log = lastActivityLog;
|
|
const commands = log.filter(e => e.role === 'user').map(e => e.content).slice(0,5);
|
|
return `This session: I answered ${log.filter(e=>e.role==='neuron').length} messages, handled ${commands.length} requests. Latest: ${commands.slice(-3).join(', ')}.`;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// CONTEXTUAL REACTIONS (keystroke rate)
|
|
// ══════════════════════════════════════════════════════════════
|
|
let keyTimes = [];
|
|
document.addEventListener('keydown', () => {
|
|
const now = Date.now();
|
|
keyTimes.push(now);
|
|
keyTimes = keyTimes.filter(t => now - t < 1000);
|
|
if (keyTimes.length > 5) {
|
|
const av = document.getElementById('avatar');
|
|
if (av && av.className === 'idle') setAvatarState('curious');
|
|
}
|
|
});
|
|
|
|
// Initial pane
|
|
navigateTo('chat');
|
|
</script>
|
|
</body>
|
|
</html>
|