166 lines
40 KiB
HTML
166 lines
40 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>KimiFleet</title>
|
||
<style>
|
||
:root{color-scheme:dark;--bg:#090d16;--surface:#101622;--surface2:#151d2c;--surface3:#1b2537;--line:#263247;--line2:#34435d;--text:#edf2fb;--muted:#8b9ab2;--subtle:#66748b;--blue:#6ea8fe;--cyan:#65d6d0;--green:#52d39a;--yellow:#f4bf64;--red:#ff737d;--violet:#a78bfa;--shadow:0 18px 55px #0005}
|
||
*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(900px 500px at 50% -200px,#213353 0,#0d1320 50%,var(--bg) 100%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow-x:hidden}button,input,textarea{font:inherit}button{color:inherit}.hidden{display:none!important}
|
||
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:18px;min-height:72px;padding:14px 26px;background:#0b101bd9;border-bottom:1px solid var(--line);backdrop-filter:blur(18px)}.brand{display:flex;align-items:center;gap:11px;min-width:210px}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid #41618f;border-radius:10px;background:linear-gradient(145deg,#223c63,#111a2a);color:#bcd6ff;font-weight:900;box-shadow:inset 0 1px #ffffff14,0 7px 22px #0005}.brand h1{margin:0;font-size:17px;letter-spacing:-.3px}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.fleet-stats{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.stat{display:flex;align-items:center;gap:6px;padding:6px 9px;border:1px solid var(--line);border-radius:999px;background:#101725}.stat-dot{width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green)}.toolbar{display:flex;align-items:center;gap:8px;margin-left:auto}.search-wrap{position:relative}.search-wrap svg{position:absolute;left:11px;top:10px;color:var(--subtle)}.search{width:min(28vw,320px);height:36px;padding:0 12px 0 34px;border:1px solid var(--line);border-radius:9px;outline:none;background:#0e1522;color:var(--text)}.search:focus{border-color:#4c6f9f;box-shadow:0 0 0 3px #4e7fbd1d}.icon-btn,.primary-btn,.quiet-btn{height:36px;border:1px solid var(--line);border-radius:9px;background:#131b29;cursor:pointer;transition:.16s ease}.icon-btn{display:grid;place-items:center;width:36px;padding:0}.icon-btn:hover,.quiet-btn:hover{border-color:var(--line2);background:#1a2434}.primary-btn{display:flex;align-items:center;gap:7px;padding:0 13px;border-color:#456fa8;background:#28598f;color:white;font-weight:700}.primary-btn:hover{background:#326ba7}.quiet-btn{padding:0 11px;color:#c6d2e5}
|
||
.fleet{padding:20px 26px 32px}.agent-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(520px,100%),1fr));gap:16px;align-items:start}.agent-grid.dense{grid-template-columns:repeat(auto-fit,minmax(min(400px,100%),1fr))}.agent-grid.focused{display:block;max-width:1180px;margin:0 auto}.empty-fleet{display:grid;place-items:center;min-height:55vh;border:1px dashed var(--line2);border-radius:18px;color:var(--muted);text-align:center}.empty-fleet strong{display:block;margin-bottom:6px;color:var(--text);font-size:17px}
|
||
.agent-card{display:flex;flex-direction:column;min-height:650px;max-height:calc(100vh - 112px);overflow:hidden;border:1px solid var(--line);border-radius:15px;background:linear-gradient(180deg,#121a28,#0e1521);box-shadow:var(--shadow)}.dense .agent-card{min-height:520px}.focused .agent-card{height:calc(100vh - 112px);max-height:none}.agent-head{padding:13px 14px 11px;border-bottom:1px solid var(--line);background:#141c2a}.head-row{display:flex;align-items:center;gap:9px}.agent-avatar{display:grid;place-items:center;width:31px;height:31px;border:1px solid #36527a;border-radius:9px;background:#172943;color:#9ec8ff;font-weight:900}.agent-identity{min-width:0}.agent-name{font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-meta{margin-top:2px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-actions{display:flex;gap:5px;margin-left:auto}.mini-btn{display:grid;place-items:center;width:29px;height:29px;padding:0;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}.mini-btn:hover{border-color:var(--line);background:var(--surface3);color:var(--text)}.state-pill{display:flex;align-items:center;gap:6px;margin-left:5px;padding:4px 8px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.state-pill i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.state-pill.working{color:var(--yellow)}.state-pill.working i{background:var(--yellow);box-shadow:0 0 8px var(--yellow)}.state-pill.ready{color:var(--green)}.state-pill.ready i{background:var(--green)}.state-pill.error{color:var(--red)}.state-pill.error i{background:var(--red)}
|
||
.activity-line{display:flex;align-items:center;gap:8px;margin-top:10px;color:#cbd8ea;font-size:12px}.activity-line .pulse{width:6px;height:6px;border-radius:50%;background:var(--yellow);box-shadow:0 0 9px var(--yellow)}.activity-line.ready .pulse{background:var(--green);box-shadow:0 0 9px var(--green)}.activity-line.error .pulse,.activity-line.stopped .pulse{background:var(--red);box-shadow:0 0 9px var(--red)}.activity-text{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue{margin-left:auto;color:var(--violet);font-size:10px}.context-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:10px}.context-track{height:3px;overflow:hidden;border-radius:4px;background:#253044}.context-fill{height:100%;border-radius:4px;background:var(--cyan);transition:width .3s}.context-fill.warn{background:var(--yellow)}.context-fill.danger{background:var(--red)}.context-label{color:var(--muted);font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
|
||
.chat{flex:1;min-height:0;overflow:auto;padding:15px 15px 20px;scrollbar-color:#35445d transparent;scrollbar-width:thin}.chat-empty{display:grid;place-items:center;height:100%;color:var(--subtle);text-align:center}.chat-empty .orb{display:grid;place-items:center;width:42px;height:42px;margin:0 auto 10px;border:1px solid #315078;border-radius:13px;background:#16243a;color:#8ab9f4}.message{margin:0 0 14px}.message-label{margin:0 0 5px;color:var(--muted);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.06em}.message-body{max-width:92%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#151e2d;color:#dce5f3;line-height:1.48;white-space:pre-wrap;overflow-wrap:anywhere}.message.user{display:flex;flex-direction:column;align-items:flex-end}.message.user .message-label{text-align:right}.message.user .message-body{border-color:#315989;background:#173254;color:#eef6ff}.message.assistant .message-body{border-color:#29384e;background:#131b28}.message.error .message-body{border-color:#6a3540;background:#321b24;color:#ffc6cb}.system-message{margin:11px auto;padding:5px 9px;width:max-content;max-width:90%;border:1px solid var(--line);border-radius:999px;background:#121a27;color:var(--muted);font-size:10px;text-align:center}.thought{margin:0 0 12px;border-left:2px solid #556987;padding-left:10px;color:var(--muted)}.thought summary{cursor:pointer;font-size:11px;font-weight:700;user-select:none}.thought-text{margin-top:7px;max-height:180px;overflow:auto;color:#8fa0b8;font-size:11px;line-height:1.45;white-space:pre-wrap}.tool-call{margin:0 0 9px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:#101824}.tool-main{display:flex;align-items:center;gap:8px}.tool-icon{display:grid;place-items:center;width:22px;height:22px;border-radius:6px;background:#1d2a3e;color:#9bbbe8}.tool-title{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#c8d5e7;font:11px ui-monospace,SFMono-Regular,Consolas,monospace}.tool-status{color:var(--muted);font-size:10px}.tool-status.in_progress{color:var(--yellow)}.tool-status.completed{color:var(--green)}.tool-status.failed{color:var(--red)}.tool-call details{margin-top:7px}.tool-call summary{color:var(--subtle);cursor:pointer;font-size:10px}.tool-detail{max-height:180px;margin:7px 0 0;padding:8px;overflow:auto;border-radius:6px;background:#0a101a;color:#8fa4c2;font:10px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
|
||
.composer{padding:11px;border-top:1px solid var(--line);background:#111925}.composer-box{display:flex;align-items:flex-end;gap:8px;padding:8px 8px 8px 11px;border:1px solid var(--line2);border-radius:11px;background:#0c121d;transition:.15s}.composer-box:focus-within{border-color:#4d709e;box-shadow:0 0 0 3px #416caa18}.composer textarea{min-height:24px;max-height:110px;flex:1;resize:none;border:0;outline:0;background:transparent;color:var(--text);line-height:1.45}.composer textarea::placeholder{color:#65748a}.send-btn{display:grid;place-items:center;width:30px;height:30px;flex:none;border:0;border-radius:8px;background:#2c66a4;color:white;cursor:pointer}.send-btn:hover{background:#3979bd}.send-btn:disabled{cursor:not-allowed;opacity:.4}.composer-foot{display:flex;align-items:center;gap:6px;margin-top:7px;color:var(--subtle);font-size:9px}.scope-chip{padding:2px 5px;border-radius:4px;background:#192438;color:#8fa4c1;font:9px ui-monospace,SFMono-Regular,Consolas,monospace}.composer-foot .spacer{flex:1}
|
||
dialog{width:min(520px,calc(100vw - 30px));padding:0;border:1px solid var(--line2);border-radius:15px;background:#111826;color:var(--text);box-shadow:0 35px 100px #000b}dialog::backdrop{background:#050812b8;backdrop-filter:blur(5px)}.dialog-head{display:flex;align-items:center;padding:17px 18px;border-bottom:1px solid var(--line)}.dialog-head h2{margin:0;font-size:16px}.dialog-body{display:grid;gap:13px;padding:18px}.field label{display:block;margin-bottom:6px;color:var(--muted);font-size:11px;font-weight:700}.field input,.field textarea{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;outline:none;background:#0b111c;color:var(--text)}.field textarea{min-height:76px;resize:vertical}.field input:focus,.field textarea:focus{border-color:#5278a8}.field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.dialog-foot{display:flex;justify-content:flex-end;gap:8px;padding:13px 18px;border-top:1px solid var(--line)}.toast-stack{position:fixed;right:18px;bottom:18px;z-index:50;display:grid;gap:8px}.toast{max-width:380px;padding:10px 12px;border:1px solid var(--line2);border-radius:9px;background:#172131;box-shadow:0 12px 35px #0008;color:#dce5f2;font-size:12px}.toast.error{border-color:#7b3b48;color:#ffc6cc}
|
||
.count-badge{display:inline-block;min-width:17px;margin-left:6px;padding:1px 6px;border-radius:999px;background:#28598f;color:#fff;font-size:10px;font-weight:800;text-align:center}
|
||
.handoff-entry{margin:0 0 14px;max-width:92%;padding:9px 12px;border:1px solid #3d3658;border-left:3px solid var(--violet);border-radius:10px;background:#151a2c}.handoff-head{display:flex;align-items:center;gap:7px;margin-bottom:4px;color:var(--violet);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.handoff-source{color:var(--muted);font-weight:600;text-transform:none;letter-spacing:0}.handoff-title{margin:0 0 4px;color:var(--text);font-size:12.5px;font-weight:700}.handoff-text{color:#d5dcee;font-size:13px;line-height:1.48;white-space:pre-wrap;overflow-wrap:anywhere}
|
||
.segmented{display:flex;gap:4px;padding:3px;border:1px solid var(--line);border-radius:9px;background:#0b111c}.segmented button{flex:1;padding:6px 4px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer;font-size:11px;font-weight:700;transition:.14s}.segmented button:hover{color:var(--text)}.segmented button.active{border-color:#456fa8;background:#1d3252;color:#eaf2ff}
|
||
.recipient-grid{display:flex;flex-wrap:wrap;gap:6px}.recipient-chip{display:flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;background:#0e1522;color:#c6d2e5;font-size:11.5px;cursor:pointer;user-select:none}.recipient-chip input{margin:0;accent-color:#6ea8fe}.recipient-chip:has(input:checked){border-color:#456fa8;background:#1a2c47;color:#eaf2ff}.recipient-chip:has(input:disabled){opacity:.75;cursor:default}
|
||
.hint{color:var(--subtle);font-size:11px;line-height:1.5}.label-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.label-row label{margin-bottom:0}.link-btn{padding:0;border:0;background:none;color:var(--blue);cursor:pointer;font-size:10.5px;font-weight:700}.link-btn:hover{text-decoration:underline}.field-error{min-height:15px;color:var(--red);font-size:11px}
|
||
dialog.drawer{width:min(600px,96vw);max-width:96vw;height:100%;max-height:100%;margin:0 0 0 auto;border:0;border-left:1px solid var(--line2);border-radius:0}dialog.drawer[open]{animation:drawer-in .18s ease}@keyframes drawer-in{from{transform:translateX(50px);opacity:0}}.drawer .dialog-head{position:sticky;top:0;z-index:2;background:#111826}
|
||
.drawer-list{display:grid;gap:9px;padding:16px 18px;overflow:auto}.handoff-record{padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:#0e1522}.hr-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.hr-route{min-width:0;color:#dce5f3;font-size:12px;overflow:hidden;text-overflow:ellipsis}.hr-route strong{color:var(--text)}.hr-arrow{margin:0 5px;color:var(--violet)}.kind-chip{padding:2px 7px;border:1px solid var(--line2);border-radius:999px;color:#c6d2e5;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.05em}.h-status{padding:2px 8px;border:1px solid;border-radius:999px;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.h-status.preparing,.h-status.delivering{color:var(--yellow);border-color:#6b5a2e;background:#2a2314}.h-status.delivered{color:var(--green);border-color:#2e5c46;background:#12291e}.h-status.partial{color:var(--violet);border-color:#4b3f6e;background:#1c1830}.h-status.failed{color:var(--red);border-color:#6a3540;background:#2a151a}.hr-time{margin-left:auto;color:var(--subtle);font-size:10px;white-space:nowrap}.hr-title{margin-top:5px;color:var(--text);font-size:12px;font-weight:700}.hr-details{margin-top:7px}.hr-details summary{cursor:pointer;color:var(--muted);font-size:11px;font-weight:700;user-select:none}.hr-content{margin:6px 0 0;max-height:220px;overflow:auto;padding:8px;border:1px solid var(--line);border-radius:8px;background:#0b111c;color:#c9d5e6;font:11px ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.hr-error{margin-top:6px;color:var(--red);font-size:11px}.hr-deliveries{display:grid;gap:3px;margin-top:6px;color:var(--muted);font-size:11px}.hr-deliveries .dl-ok{color:var(--green)}.hr-deliveries .dl-wait{color:var(--yellow)}.hr-deliveries .dl-fail{color:var(--red)}
|
||
@media(max-width:800px){.topbar{flex-wrap:wrap;padding:12px 14px}.brand{min-width:auto}.fleet-stats{order:3;width:100%}.toolbar{gap:6px}.search{width:180px}.fleet{padding:13px}.agent-card{max-height:none;height:calc(100vh - 150px);min-height:560px}.field-row{grid-template-columns:1fr}dialog.drawer{width:100vw;max-width:100vw}.segmented{flex-wrap:wrap}.segmented button{flex:1 1 31%}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="topbar">
|
||
<div class="brand"><div class="brand-mark">K</div><div><h1>KimiFleet</h1><small>K3 context cockpit</small></div></div>
|
||
<div id="fleetStats" class="fleet-stats"></div>
|
||
<div class="toolbar">
|
||
<label class="search-wrap"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><input id="search" class="search" placeholder="Search agents"></label>
|
||
<button id="densityBtn" class="icon-btn" title="Toggle compact grid">▦</button>
|
||
<button id="handoffsBtn" class="quiet-btn" title="Handoff history">⇄ Handoffs<span id="handoffCount" class="count-badge hidden">0</span></button>
|
||
<button id="newAgentBtn" class="primary-btn"><span>+</span> New agent</button>
|
||
</div>
|
||
</header>
|
||
<main class="fleet"><section id="agentGrid" class="agent-grid"></section></main>
|
||
|
||
<dialog id="agentDialog">
|
||
<form id="agentForm" method="dialog">
|
||
<div class="dialog-head"><h2>Start Kimi K3 instance</h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto">✕</button></div>
|
||
<div class="dialog-body">
|
||
<div class="field-row"><div class="field"><label for="agentName">Name</label><input id="agentName" name="name" required pattern="[A-Za-z0-9_-]+" placeholder="renderer-2"></div><div class="field"><label for="agentRole">Role</label><input id="agentRole" name="role" placeholder="reviewer"></div></div>
|
||
<div class="field"><label for="agentWorkspace">Workspace</label><input id="agentWorkspace" name="workspace" required placeholder="/path/to/project"></div>
|
||
<div class="field"><label for="agentScopes">Exclusive scopes · one per line</label><textarea id="agentScopes" name="scopes" placeholder="src/Engine.AI tests/Engine.Tests"></textarea></div>
|
||
</div>
|
||
<div class="dialog-foot"><button type="button" class="quiet-btn" data-close-dialog>Cancel</button><button type="submit" class="primary-btn">Start instance</button></div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="handoffDialog">
|
||
<form id="handoffForm" method="dialog">
|
||
<div class="dialog-head"><h2>Hand off · <span id="handoffFromName">agent</span></h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto">✕</button></div>
|
||
<div class="dialog-body">
|
||
<div class="field"><div class="label-row"><label>Recipients</label><button type="button" id="handoffSelectAll" class="link-btn">Select all</button></div><div id="handoffRecipients" class="recipient-grid"></div></div>
|
||
<div class="field"><label>Kind</label><div id="handoffKinds" class="segmented"><button type="button" data-kind="message" class="active">Message</button><button type="button" data-kind="context">Context</button><button type="button" data-kind="delegate">Delegate</button><button type="button" data-kind="review">Review</button><button type="button" data-kind="broadcast">Broadcast</button></div><div id="handoffKindHint" class="hint" style="margin-top:6px"></div></div>
|
||
<div class="field"><label for="handoffTitle">Title · optional</label><input id="handoffTitle" maxlength="120" placeholder="Short summary, e.g. Auth refactor context"></div>
|
||
<div class="field" id="handoffMessageField"><label for="handoffMessage">Message</label><textarea id="handoffMessage" placeholder="Direct note delivered as-is to each recipient…"></textarea></div>
|
||
<div class="field hidden" id="handoffInstructionsField"><label for="handoffInstructions">Handoff instructions</label><textarea id="handoffInstructions" placeholder="What the handoff should contain, e.g. decisions made, files touched, what to do next…"></textarea></div>
|
||
<div id="handoffError" class="field-error"></div>
|
||
</div>
|
||
<div class="dialog-foot"><button type="button" class="quiet-btn" data-close-dialog>Cancel</button><button type="submit" id="handoffSend" class="primary-btn">Send handoff</button></div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="handoffHistory" class="drawer">
|
||
<div class="dialog-head"><h2>Handoffs</h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto">✕</button></div>
|
||
<div id="handoffHistoryList" class="drawer-list"></div>
|
||
</dialog>
|
||
|
||
<div id="toasts" class="toast-stack"></div>
|
||
|
||
<script>
|
||
const grid=document.querySelector('#agentGrid'),stats=document.querySelector('#fleetStats'),search=document.querySelector('#search'),dialog=document.querySelector('#agentDialog'),form=document.querySelector('#agentForm'),toasts=document.querySelector('#toasts');
|
||
const handoffDialog=document.querySelector('#handoffDialog'),handoffForm=document.querySelector('#handoffForm'),historyDialog=document.querySelector('#handoffHistory'),historyList=document.querySelector('#handoffHistoryList'),handoffCount=document.querySelector('#handoffCount');
|
||
const handoffKindInfo={message:'Delivered as-is into each recipient’s context — nothing is generated.',context:'The source agent generates a focused context brief from your instructions, so recipients see what matters.',delegate:'Hands off a task: your instructions become an actionable assignment for recipients to execute.',review:'Asks recipients to review the source agent’s recent work — instructions focus what they should check.',broadcast:'Goes to every running agent at once — all other agents are selected automatically.'};
|
||
const hStatusClass=status=>['preparing','delivering','delivered','partial','failed'].includes(status)?status:'preparing';
|
||
const state={agents:[],chats:new Map(),drafts:new Map(),focused:null,dense:localStorage.getItem('kimiFleetDensity')==='dense',refreshing:false,pendingAgents:new Set(),refreshTimer:null,handoffs:[],handoffFrom:null,handoffKind:'message',handoffSending:false,handoffTouched:false};
|
||
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||
const fmtTokens=value=>value>=1000?`${(value/1000).toFixed(value>=100000?0:1)}k`:String(value||0);
|
||
const icon=name=>({read:'◇',execute:'›_',edit:'✎',search:'⌕',delete:'−',fetch:'↓',think:'◌',move:'↝'}[name]||'·');
|
||
|
||
function toast(message,error=false){const node=document.createElement('div');node.className=`toast${error?' error':''}`;node.textContent=message;toasts.append(node);setTimeout(()=>node.remove(),4200)}
|
||
async function api(path,options={}){const response=await fetch(path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});let body=null;try{body=await response.json()}catch{}if(!response.ok)throw new Error(body?.error||`${response.status} ${response.statusText}`);return body}
|
||
function rememberEditor(){const active=document.activeElement;if(!active?.dataset?.composer)return null;return {name:active.dataset.composer,start:active.selectionStart,end:active.selectionEnd}}
|
||
function restoreEditor(saved){if(!saved)return;const editor=document.querySelector(`[data-composer="${CSS.escape(saved.name)}"]`);if(editor){editor.focus();editor.setSelectionRange(saved.start,saved.end)}}
|
||
function nearBottom(element){return element.scrollHeight-element.scrollTop-element.clientHeight<90}
|
||
|
||
function render(){
|
||
const editor=rememberEditor(),scrolls=new Map([...document.querySelectorAll('.chat')].map(node=>[node.dataset.chat,{top:node.scrollTop,bottom:nearBottom(node)}])),openEntries=new Set([...document.querySelectorAll('details[open][data-entry]')].map(node=>node.dataset.entry));
|
||
const query=search.value.trim().toLowerCase();let agents=state.agents.filter(agent=>!query||`${agent.name} ${agent.role} ${agent.workspace} ${(agent.scopes||[]).join(' ')}`.toLowerCase().includes(query));
|
||
agents.sort((a,b)=>Number(b.state==='working')-Number(a.state==='working')||a.name.localeCompare(b.name));if(state.focused)agents=agents.filter(agent=>agent.name===state.focused);
|
||
grid.className=`agent-grid${state.dense?' dense':''}${state.focused?' focused':''}`;
|
||
stats.innerHTML=`<span class="stat"><span class="stat-dot"></span>${state.agents.length} instance${state.agents.length===1?'':'s'}</span><span class="stat">${state.agents.filter(a=>a.state==='working').length} working</span>`;
|
||
grid.innerHTML=agents.length?agents.map(card).join(''):`<div class="empty-fleet"><div><strong>${state.agents.length?'No matching agents':'No Kimi instances yet'}</strong>${state.agents.length?'Try another search.':'Start a scoped K3 agent to begin.'}</div></div>`;
|
||
bindAutoResize();restoreEditor(editor);document.querySelectorAll('details[data-entry]').forEach(node=>node.open=openEntries.has(node.dataset.entry));
|
||
document.querySelectorAll('.chat').forEach(node=>{const saved=scrolls.get(node.dataset.chat);if(saved?.bottom||!saved)node.scrollTop=node.scrollHeight;else node.scrollTop=saved.top});
|
||
}
|
||
|
||
function card(agent){
|
||
const chat=state.chats.get(agent.name)||[],percent=Math.min(100,Number(agent.contextPercent)||0),contextClass=percent>=90?'danger':percent>=70?'warn':'',draft=state.drafts.get(agent.name)||'';
|
||
return `<article class="agent-card" data-agent="${esc(agent.name)}">
|
||
<header class="agent-head"><div class="head-row"><div class="agent-avatar">K</div><div class="agent-identity"><div class="agent-name">${esc(agent.name)}</div><div class="agent-meta">${esc(agent.role)} · ${esc(agent.workspace)}</div></div><span class="state-pill ${esc(agent.state)}"><i></i>${esc(agent.state)}</span><div class="agent-actions">${agent.state==='working'?`<button class="mini-btn" data-action="cancel" data-agent="${esc(agent.name)}" title="Cancel current task">■</button>`:''}<button class="mini-btn" data-action="compact" data-agent="${esc(agent.name)}" title="Compact context">↯</button><button class="mini-btn" data-action="reset" data-agent="${esc(agent.name)}" title="Fresh context">↺</button><button class="mini-btn" data-action="handoff" data-agent="${esc(agent.name)}" title="Hand off to other agents">⇄</button><button class="mini-btn" data-action="focus" data-agent="${esc(agent.name)}" title="${state.focused===agent.name?'Exit focus':'Focus'}">${state.focused===agent.name?'⊙':'⛶'}</button><button class="mini-btn" data-action="stop" data-agent="${esc(agent.name)}" title="Stop agent">×</button></div></div>
|
||
<div class="activity-line ${esc(agent.state)}"><span class="pulse"></span><span class="activity-text" title="${esc(agent.activity)}">${esc(agent.activity||'Waiting')}</span>${agent.queueDepth?`<span class="queue">${agent.queueDepth} queued</span>`:''}</div>
|
||
<div class="context-row" title="Estimated context usage"><div class="context-track"><div class="context-fill ${contextClass}" style="width:${percent}%"></div></div><span class="context-label">~${fmtTokens(agent.contextTokens)} / ${fmtTokens(agent.maxContextTokens)} · ${percent.toFixed(1)}%</span></div></header>
|
||
<section class="chat" data-chat="${esc(agent.name)}">${chat.length?chat.map(message).join(''):`<div class="chat-empty"><div><div class="orb">K</div>No messages in this context yet</div></div>`}</section>
|
||
<footer class="composer"><div class="composer-box"><textarea rows="1" data-composer="${esc(agent.name)}" placeholder="Message ${esc(agent.name)}…">${esc(draft)}</textarea><button class="send-btn" data-action="send" data-agent="${esc(agent.name)}" ${draft.trim()?'':'disabled'} title="Send task">↑</button></div><div class="composer-foot"><span>Enter to send · Shift+Enter newline</span><span class="spacer"></span>${(agent.scopes||[]).slice(0,2).map(scope=>`<span class="scope-chip">${esc(scope)}</span>`).join('')}</div></footer>
|
||
</article>`;
|
||
}
|
||
|
||
function message(item){
|
||
if(item.type==='system')return `<div class="system-message">${esc(item.text)}</div>`;
|
||
if(item.type==='thought')return `<details class="thought" data-entry="${esc(item.id)}"><summary>Thinking</summary><div class="thought-text">${esc(item.text)}</div></details>`;
|
||
if(item.type==='tool')return `<div class="tool-call"><div class="tool-main"><span class="tool-icon">${icon(item.toolKind)}</span><span class="tool-title" title="${esc(item.title)}">${esc(item.title||item.toolKind||'Tool call')}</span><span class="tool-status ${esc(item.status)}">${esc((item.status||'pending').replace('_',' '))}</span></div>${item.text?`<details data-entry="${esc(item.id)}"><summary>Details</summary><pre class="tool-detail">${esc(item.text)}</pre></details>`:''}</div>`;
|
||
if(item.type==='handoff')return `<div class="handoff-entry"><div class="handoff-head"><span>⇄</span><span>${esc(item.kind||'handoff')}</span>${item.source?`<span class="handoff-source">from ${esc(item.source)}</span>`:''}</div>${item.title?`<div class="handoff-title">${esc(item.title)}</div>`:''}<div class="handoff-text">${esc(item.text)}</div></div>`;
|
||
const role=item.type==='user'?'You':item.type==='error'?'Error':'Kimi';return `<div class="message ${esc(item.type)}"><div class="message-label">${role}</div><div class="message-body">${esc(item.text)}</div></div>`;
|
||
}
|
||
|
||
async function refresh(requestedAgents=null){
|
||
if(state.refreshing){(requestedAgents||[]).forEach(name=>state.pendingAgents.add(name));return}
|
||
state.refreshing=true;
|
||
try{
|
||
const agents=await api('/agents'),activeNames=new Set(agents.map(agent=>agent.name)),knownNames=new Set(state.agents.map(agent=>agent.name));
|
||
const targets=requestedAgents===null?agents.map(agent=>agent.name):[...new Set([...(requestedAgents||[]),...agents.filter(agent=>!knownNames.has(agent.name)).map(agent=>agent.name)])];
|
||
const chats=new Map(state.chats);for(const name of [...chats.keys()])if(!activeNames.has(name))chats.delete(name);
|
||
await Promise.all(targets.filter(name=>activeNames.has(name)).map(async name=>chats.set(name,await api(`/agents/${encodeURIComponent(name)}/chat`))));
|
||
state.agents=agents;state.chats=chats;render();
|
||
}catch(error){toast(error.message,true)}finally{state.refreshing=false;if(state.pendingAgents.size)scheduleRefresh()}
|
||
}
|
||
function scheduleRefresh(agent){if(agent&&agent!=='fleet')state.pendingAgents.add(agent);if(state.refreshTimer)return;state.refreshTimer=setTimeout(()=>{const agents=[...state.pendingAgents];state.pendingAgents.clear();state.refreshTimer=null;refresh(agents)},450)}
|
||
function bindAutoResize(){document.querySelectorAll('.composer textarea').forEach(area=>{area.style.height='auto';area.style.height=`${Math.min(110,area.scrollHeight)}px`})}
|
||
|
||
grid.addEventListener('input',event=>{if(!event.target.matches('[data-composer]'))return;state.drafts.set(event.target.dataset.composer,event.target.value);event.target.style.height='auto';event.target.style.height=`${Math.min(110,event.target.scrollHeight)}px`;const button=event.target.closest('.composer').querySelector('[data-action="send"]');button.disabled=!event.target.value.trim()});
|
||
grid.addEventListener('keydown',event=>{if(event.target.matches('[data-composer]')&&event.key==='Enter'&&!event.shiftKey){event.preventDefault();send(event.target.dataset.composer)}});
|
||
grid.addEventListener('click',async event=>{const button=event.target.closest('[data-action]');if(!button)return;const {action,agent}=button.dataset;try{if(action==='send')await send(agent);else if(action==='handoff')openHandoffDialog(agent);else if(action==='focus'){state.focused=state.focused===agent?null:agent;render()}else if(action==='cancel'){await api(`/agents/${encodeURIComponent(agent)}/cancel`,{method:'POST',body:'{}'});toast(`${agent}: cancellation requested`)}else if(action==='compact'){await api(`/agents/${encodeURIComponent(agent)}/context/compact`,{method:'POST',body:JSON.stringify({instructions:'Preserve decisions, constraints, current task, changed files, and pending verification.'})});toast(`${agent}: context compaction queued`)}else if(action==='reset'){if(confirm(`Create a fresh context for ${agent}? Files are not changed.`)){await api(`/agents/${encodeURIComponent(agent)}/context/reset`,{method:'POST',body:'{}'});toast(`${agent}: fresh context created`);scheduleRefresh()}}else if(action==='stop'){if(confirm(`Stop ${agent} and release its scopes?`)){await api(`/agents/${encodeURIComponent(agent)}/stop`,{method:'POST',body:'{}'});if(state.focused===agent)state.focused=null;toast(`${agent} stopped`);scheduleRefresh()}}}catch(error){toast(error.message,true)}});
|
||
async function send(agent){const prompt=(state.drafts.get(agent)||'').trim();if(!prompt)return;state.drafts.set(agent,'');render();try{await api(`/agents/${encodeURIComponent(agent)}/prompt`,{method:'POST',body:JSON.stringify({prompt})});scheduleRefresh()}catch(error){state.drafts.set(agent,prompt);render();toast(error.message,true)}}
|
||
|
||
document.querySelector('#newAgentBtn').addEventListener('click',()=>{document.querySelector('#agentWorkspace').value=localStorage.getItem('kimiFleetWorkspace')||state.agents[0]?.workspace||'';dialog.showModal()});document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>button.closest('dialog')?.close()));
|
||
form.addEventListener('submit',async event=>{event.preventDefault();const data=new FormData(form),payload={name:data.get('name').trim(),workspace:data.get('workspace').trim(),role:data.get('role').trim()||'worker',scopes:data.get('scopes').split(/[,\n]/).map(value=>value.trim()).filter(Boolean)};try{await api('/agents',{method:'POST',body:JSON.stringify(payload)});localStorage.setItem('kimiFleetWorkspace',payload.workspace);dialog.close();form.reset();toast(`${payload.name} started`);scheduleRefresh()}catch(error){toast(error.message,true)}});
|
||
document.querySelector('#densityBtn').addEventListener('click',()=>{state.dense=!state.dense;localStorage.setItem('kimiFleetDensity',state.dense?'dense':'comfortable');render()});search.addEventListener('input',render);
|
||
|
||
function fmtTime(value){const date=new Date(value);return Number.isNaN(date.getTime())?'':date.toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||
function handoffRecipients(){return[...document.querySelectorAll('#handoffRecipients input:checked')].map(box=>box.value)}
|
||
function validateHandoff(){const isMessage=state.handoffKind==='message';let error='';if(!handoffRecipients().length)error='Select at least one recipient.';else if(isMessage&&!document.querySelector('#handoffMessage').value.trim())error='Write a message to send.';else if(!isMessage&&!document.querySelector('#handoffInstructions').value.trim())error='Describe what the handoff should contain.';document.querySelector('#handoffError').textContent=state.handoffTouched?error:'';document.querySelector('#handoffSend').disabled=Boolean(error)||state.handoffSending;return!error}
|
||
function setHandoffSending(sending){state.handoffSending=sending;document.querySelector('#handoffSend').textContent=sending?'Sending…':'Send handoff';validateHandoff()}
|
||
function syncBroadcast(){const broadcast=state.handoffKind==='broadcast';document.querySelectorAll('#handoffRecipients input').forEach(box=>{if(broadcast)box.checked=true;box.disabled=broadcast});document.querySelector('#handoffSelectAll').classList.toggle('hidden',broadcast)}
|
||
function syncKindUI(){document.querySelectorAll('#handoffKinds button').forEach(button=>button.classList.toggle('active',button.dataset.kind===state.handoffKind));document.querySelector('#handoffKindHint').textContent=handoffKindInfo[state.handoffKind];const isMessage=state.handoffKind==='message';document.querySelector('#handoffMessageField').classList.toggle('hidden',!isMessage);document.querySelector('#handoffInstructionsField').classList.toggle('hidden',isMessage);syncBroadcast();validateHandoff()}
|
||
function openHandoffDialog(from){state.handoffFrom=from;state.handoffKind='message';state.handoffSending=false;state.handoffTouched=false;handoffForm.reset();document.querySelector('#handoffFromName').textContent=from;document.querySelector('#handoffError').textContent='';document.querySelector('#handoffSend').textContent='Send handoff';const others=state.agents.filter(agent=>agent.name!==from&&!['starting','stopped'].includes(agent.state));document.querySelector('#handoffRecipients').innerHTML=others.length?others.map(agent=>`<label class="recipient-chip"><input type="checkbox" value="${esc(agent.name)}">${esc(agent.name)}</label>`).join(''):'<span class="hint">No other agents running yet.</span>';syncKindUI();handoffDialog.showModal()}
|
||
async function fetchHandoffs(){try{const records=await api('/handoffs');if(Array.isArray(records)){state.handoffs=records;renderHandoffBadge();if(historyDialog.open)renderHandoffHistory()}}catch{}}
|
||
function scheduleHandoffRefresh(){clearTimeout(scheduleHandoffRefresh.timer);scheduleHandoffRefresh.timer=setTimeout(fetchHandoffs,450)}
|
||
function renderHandoffBadge(){const count=state.handoffs.length;handoffCount.textContent=count>99?'99+':String(count);handoffCount.classList.toggle('hidden',count===0)}
|
||
function deliveryLines(deliveries){if(!deliveries)return[];const entries=Array.isArray(deliveries)?deliveries.map(d=>[d?.recipient??d?.name??d?.agent??'?',d]):Object.entries(deliveries);return entries.map(([name,d])=>{const error=typeof d==='string'?null:d?.error||null,status=typeof d==='string'?d:d?.status||d?.state||'pending';return{name,error,status,failed:Boolean(error||status==='failed')}})}
|
||
function renderHandoffHistory(){const open=new Set([...historyList.querySelectorAll('details[open][data-h]')].map(node=>node.dataset.h));historyList.innerHTML=state.handoffs.length?state.handoffs.map(h=>{const status=h.status||'preparing',deliveries=deliveryLines(h.deliveries),issues=h.error||deliveries.some(d=>d.failed);return `<div class="handoff-record"><div class="hr-top"><span class="hr-route"><strong>${esc(h.from)}</strong><span class="hr-arrow">→</span>${esc((h.recipients||[]).join(', '))}</span><span class="kind-chip">${esc(h.kind||'message')}</span><span class="h-status ${hStatusClass(status)}">${esc(status)}</span><span class="hr-time">${esc(fmtTime(h.createdAt))}</span></div>${h.title?`<div class="hr-title">${esc(h.title)}</div>`:''}<details class="hr-details" data-h="${esc(h.id)}"${open.has(String(h.id))?' open':''}><summary>Content${issues?' · issues':''}</summary><pre class="hr-content">${esc(h.content||'(no content yet)')}</pre>${h.error?`<div class="hr-error">⚠ ${esc(h.error)}</div>`:''}${deliveries.length?`<div class="hr-deliveries">${deliveries.map(d=>{const done=d.status==='delivered';return`<div class="${d.failed?'dl-fail':done?'dl-ok':'dl-wait'}">${d.failed?'✗':done?'✓':'·'} ${esc(d.name)} · ${esc(d.status)}${d.error?` — ${esc(d.error)}`:''}</div>`}).join('')}</div>`:''}</details></div>`}).join(''):'<div class="hint">No handoffs yet — use the ⇄ action on any agent card.</div>'}
|
||
document.querySelector('#handoffsBtn').addEventListener('click',()=>{renderHandoffHistory();historyDialog.showModal();fetchHandoffs()});
|
||
document.querySelector('#handoffKinds').addEventListener('click',event=>{const button=event.target.closest('button[data-kind]');if(!button)return;state.handoffTouched=true;state.handoffKind=button.dataset.kind;syncKindUI()});
|
||
document.querySelector('#handoffRecipients').addEventListener('change',()=>{state.handoffTouched=true;validateHandoff()});
|
||
document.querySelector('#handoffSelectAll').addEventListener('click',()=>{state.handoffTouched=true;document.querySelectorAll('#handoffRecipients input').forEach(box=>box.checked=true);validateHandoff()});
|
||
document.querySelector('#handoffMessage').addEventListener('input',()=>{state.handoffTouched=true;validateHandoff()});
|
||
document.querySelector('#handoffInstructions').addEventListener('input',()=>{state.handoffTouched=true;validateHandoff()});
|
||
handoffForm.addEventListener('submit',async event=>{event.preventDefault();state.handoffTouched=true;if(!validateHandoff()||state.handoffSending)return;const recipients=handoffRecipients(),title=document.querySelector('#handoffTitle').value.trim(),payload={recipients,kind:state.handoffKind};if(title)payload.title=title;if(state.handoffKind==='message')payload.message=document.querySelector('#handoffMessage').value.trim();else payload.instructions=document.querySelector('#handoffInstructions').value.trim();setHandoffSending(true);try{await api(`/agents/${encodeURIComponent(state.handoffFrom)}/handoff`,{method:'POST',body:JSON.stringify(payload)});handoffDialog.close();toast(`Handoff sent to ${recipients.join(', ')}`);scheduleRefresh();fetchHandoffs()}catch(error){toast(error.message,true);document.querySelector('#handoffError').textContent=error.message;setHandoffSending(false)}});
|
||
const source=new EventSource('/events');source.onmessage=event=>{try{const update=JSON.parse(event.data);scheduleRefresh(update.agent);if(update.kind==='handoff')scheduleHandoffRefresh()}catch{scheduleRefresh()}};source.onerror=()=>setTimeout(()=>scheduleRefresh(),1000);refresh();fetchHandoffs();setInterval(()=>refresh([]),4000);
|
||
</script>
|
||
</body>
|
||
</html>
|