312 lines
14 KiB
HTML
312 lines
14 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>herdctl Agent Editor</title>
|
||
|
|
<style>
|
||
|
|
:root {
|
||
|
|
--bg:#0f1117;--surface:#1a1d27;--surface2:#222535;--border:#2e3250;
|
||
|
|
--accent:#5b7cf6;--accent2:#7c3aed;--green:#22c55e;--red:#ef4444;
|
||
|
|
--yellow:#f59e0b;--text:#e2e8f0;--muted:#64748b;
|
||
|
|
--mono:'JetBrains Mono','Fira Code',monospace;
|
||
|
|
}
|
||
|
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||
|
|
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;}
|
||
|
|
header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;gap:16px;flex-shrink:0;}
|
||
|
|
.logo{display:flex;align-items:center;gap:10px;}
|
||
|
|
.logo-icon{width:28px;height:28px;background:linear-gradient(135deg,var(--accent),var(--accent2));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;}
|
||
|
|
.logo-text{font-weight:700;font-size:16px;}
|
||
|
|
.logo-sub{font-size:11px;color:var(--muted);}
|
||
|
|
.spacer{flex:1;}
|
||
|
|
.status-bar{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);}
|
||
|
|
.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);}
|
||
|
|
.layout{display:flex;flex:1;overflow:hidden;}
|
||
|
|
.sidebar{width:260px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;}
|
||
|
|
.sidebar-header{padding:14px 16px 10px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;display:flex;justify-content:space-between;align-items:center;}
|
||
|
|
.btn-new{background:var(--accent);color:#fff;border:none;border-radius:5px;padding:3px 10px;font-size:11px;cursor:pointer;font-weight:600;}
|
||
|
|
.agent-list{flex:1;overflow-y:auto;padding:4px 8px 8px;}
|
||
|
|
.agent-item{padding:10px 12px;border-radius:8px;cursor:pointer;border:1px solid transparent;transition:all .15s;margin-bottom:4px;}
|
||
|
|
.agent-item:hover{background:var(--surface2);border-color:var(--border);}
|
||
|
|
.agent-item.active{background:var(--surface2);border-color:var(--accent);}
|
||
|
|
.agent-name{font-size:13px;font-weight:600;margin-bottom:3px;}
|
||
|
|
.agent-desc{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
|
|
.agent-tags{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap;}
|
||
|
|
.tag{font-size:10px;background:var(--border);color:var(--muted);border-radius:3px;padding:1px 6px;}
|
||
|
|
.agent-status{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--muted);margin-top:4px;}
|
||
|
|
.sdot{width:5px;height:5px;border-radius:50%;background:var(--green);}
|
||
|
|
.sdot.running{background:var(--yellow);animation:pulse 1s infinite;}
|
||
|
|
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;}
|
||
|
|
.editor-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 20px;display:flex;align-items:center;gap:10px;flex-shrink:0;}
|
||
|
|
.editor-filename{font-family:var(--mono);font-size:13px;font-weight:600;}
|
||
|
|
.btn{border:none;border-radius:6px;padding:6px 14px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:6px;}
|
||
|
|
.btn:disabled{opacity:.4;cursor:not-allowed;}
|
||
|
|
.btn-save{background:var(--accent);color:#fff;}
|
||
|
|
.btn-save:hover:not(:disabled){background:#4a6cf0;}
|
||
|
|
.btn-trigger{background:var(--green);color:#000;}
|
||
|
|
.btn-trigger:hover:not(:disabled){opacity:.85;}
|
||
|
|
.btn-discard{background:var(--surface2);color:var(--text);border:1px solid var(--border);}
|
||
|
|
.btn-discard:hover:not(:disabled){border-color:var(--accent);}
|
||
|
|
.editor-wrap{flex:1;display:flex;overflow:hidden;}
|
||
|
|
textarea#editor{flex:1;background:var(--bg);color:var(--text);font-family:var(--mono);font-size:13px;line-height:1.7;border:none;outline:none;resize:none;padding:20px 24px;tab-size:2;white-space:pre;overflow-wrap:normal;overflow-x:auto;}
|
||
|
|
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;}
|
||
|
|
.empty-icon{font-size:48px;opacity:.3;}
|
||
|
|
.empty-text{font-size:14px;}
|
||
|
|
.toast{position:fixed;bottom:24px;right:24px;padding:12px 18px;border-radius:8px;font-size:13px;font-weight:500;z-index:999;transition:all .3s;transform:translateY(80px);opacity:0;max-width:380px;}
|
||
|
|
.toast.show{transform:translateY(0);opacity:1;}
|
||
|
|
.toast.success{background:#14532d;border:1px solid var(--green);color:#bbf7d0;}
|
||
|
|
.toast.error{background:#450a0a;border:1px solid var(--red);color:#fecaca;}
|
||
|
|
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center;}
|
||
|
|
.overlay.show{display:flex;}
|
||
|
|
.modal{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;width:480px;}
|
||
|
|
.modal h3{font-size:15px;margin-bottom:16px;}
|
||
|
|
.form-group{margin-bottom:14px;}
|
||
|
|
.form-group label{display:block;font-size:11px;color:var(--muted);margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px;}
|
||
|
|
.form-group input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;color:var(--text);font-size:13px;outline:none;}
|
||
|
|
.form-group input:focus{border-color:var(--accent);}
|
||
|
|
.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px;}
|
||
|
|
.dirty{color:var(--yellow);font-size:11px;}
|
||
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||
|
|
::-webkit-scrollbar{width:6px;height:6px;}
|
||
|
|
::-webkit-scrollbar-track{background:transparent;}
|
||
|
|
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px;}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<header>
|
||
|
|
<div class="logo">
|
||
|
|
<div class="logo-icon">⚙</div>
|
||
|
|
<div>
|
||
|
|
<div class="logo-text">herdctl editor</div>
|
||
|
|
<div class="logo-sub">Wild Dragon Agent Fleet</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="spacer"></div>
|
||
|
|
<div class="status-bar">
|
||
|
|
<div class="dot" id="herdctlDot"></div>
|
||
|
|
<span id="herdctlStatus">connecting...</span>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
<div class="layout">
|
||
|
|
<div class="sidebar">
|
||
|
|
<div class="sidebar-header">
|
||
|
|
Agents
|
||
|
|
<button class="btn-new" onclick="showNewModal()">+ New</button>
|
||
|
|
</div>
|
||
|
|
<div class="agent-list" id="agentList">
|
||
|
|
<div style="padding:20px;text-align:center;color:var(--muted);font-size:12px;">Loading...</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="main">
|
||
|
|
<div id="editorToolbar" class="editor-toolbar" style="display:none">
|
||
|
|
<span class="editor-filename" id="editorFilename">—</span>
|
||
|
|
<span class="dirty" id="dirtyIndicator"></span>
|
||
|
|
<div class="spacer"></div>
|
||
|
|
<button class="btn btn-discard" onclick="discardChanges()">↩ Discard</button>
|
||
|
|
<button class="btn btn-trigger" id="btnTrigger" onclick="triggerAgent()">▶ Run Now</button>
|
||
|
|
<button class="btn btn-save" id="btnSave" onclick="saveAgent()">💾 Save & Restart</button>
|
||
|
|
</div>
|
||
|
|
<div class="editor-wrap">
|
||
|
|
<div class="empty-state" id="emptyState">
|
||
|
|
<div class="empty-icon">📋</div>
|
||
|
|
<div class="empty-text">Select an agent to edit</div>
|
||
|
|
</div>
|
||
|
|
<textarea id="editor" spellcheck="false" style="display:none" oninput="markDirty()"></textarea>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="toast" id="toast"></div>
|
||
|
|
<div class="overlay" id="newModal">
|
||
|
|
<div class="modal">
|
||
|
|
<h3>Create New Agent</h3>
|
||
|
|
<div class="form-group">
|
||
|
|
<label>Agent Name</label>
|
||
|
|
<input id="newAgentName" type="text" placeholder="e.g. weekly-report" oninput="updateNewFilename()">
|
||
|
|
</div>
|
||
|
|
<div class="form-group">
|
||
|
|
<label>Filename</label>
|
||
|
|
<input id="newAgentFilename" type="text" readonly style="opacity:.6">
|
||
|
|
</div>
|
||
|
|
<div class="modal-actions">
|
||
|
|
<button class="btn btn-discard" onclick="hideNewModal()">Cancel</button>
|
||
|
|
<button class="btn btn-save" onclick="createAgent()">Create</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<script>
|
||
|
|
let currentFile = null, originalContent = null, agentStatuses = {};
|
||
|
|
|
||
|
|
const TEMPLATE = n => `name: ${n}
|
||
|
|
description: "Describe what this agent does"
|
||
|
|
working_directory: /workspace
|
||
|
|
|
||
|
|
identity:
|
||
|
|
name: "${n} Agent"
|
||
|
|
role: "Assistant"
|
||
|
|
personality: "Helpful, concise"
|
||
|
|
|
||
|
|
system_prompt: |
|
||
|
|
You are a helpful agent for Wild Dragon / BMG.
|
||
|
|
Describe your agent role and behavior here.
|
||
|
|
|
||
|
|
schedules:
|
||
|
|
daily:
|
||
|
|
type: cron
|
||
|
|
expression: "0 9 * * *"
|
||
|
|
prompt: |
|
||
|
|
Describe what the agent should do when triggered.
|
||
|
|
|
||
|
|
runtime: cli
|
||
|
|
model: claude-sonnet-4-20250514
|
||
|
|
max_turns: 50
|
||
|
|
permission_mode: bypassPermissions
|
||
|
|
|
||
|
|
allowed_tools:
|
||
|
|
- Read
|
||
|
|
- Write
|
||
|
|
- Edit
|
||
|
|
- Bash
|
||
|
|
- mcp__mcp-server__*
|
||
|
|
`;
|
||
|
|
|
||
|
|
async function loadAgents() {
|
||
|
|
try {
|
||
|
|
const [ar, sr] = await Promise.all([
|
||
|
|
fetch('/api/agents'),
|
||
|
|
fetch('/api/status').catch(() => null)
|
||
|
|
]);
|
||
|
|
const agents = await ar.json();
|
||
|
|
const statuses = sr ? await sr.json().catch(() => []) : [];
|
||
|
|
agentStatuses = {};
|
||
|
|
statuses.forEach(s => { agentStatuses[s.name] = s; });
|
||
|
|
|
||
|
|
const list = document.getElementById('agentList');
|
||
|
|
if (!agents.length) {
|
||
|
|
list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--muted);font-size:12px;">No agents found</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
list.innerHTML = agents.map(a => {
|
||
|
|
const st = agentStatuses[a.name];
|
||
|
|
const running = st?.status === 'running';
|
||
|
|
const next = st?.schedules?.[0]?.nextRunAt;
|
||
|
|
const nextStr = next ? new Date(next).toLocaleString([],{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '';
|
||
|
|
return `<div class="agent-item${currentFile===a.filename?' active':''}" onclick="loadAgent('${a.filename}')">
|
||
|
|
<div class="agent-name">${a.name}</div>
|
||
|
|
<div class="agent-desc">${a.description||'No description'}</div>
|
||
|
|
<div class="agent-tags">${a.schedules.map(s=>`<span class="tag">⏰ ${s}</span>`).join('')}</div>
|
||
|
|
<div class="agent-status"><div class="sdot${running?' running':''}"></div>${running?'Running':nextStr?`Next: ${nextStr}`:'Idle'}</div>
|
||
|
|
</div>`;
|
||
|
|
}).join('');
|
||
|
|
|
||
|
|
document.getElementById('herdctlDot').style.background = 'var(--green)';
|
||
|
|
document.getElementById('herdctlStatus').textContent = `herdctl • ${agents.length} agent${agents.length!==1?'s':''}`;
|
||
|
|
} catch(e) {
|
||
|
|
document.getElementById('herdctlDot').style.background = 'var(--red)';
|
||
|
|
document.getElementById('herdctlStatus').textContent = 'herdctl offline';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadAgent(filename) {
|
||
|
|
if (isDirty() && !confirm('Discard unsaved changes?')) return;
|
||
|
|
try {
|
||
|
|
const r = await fetch(`/api/agents/${filename}`);
|
||
|
|
const d = await r.json();
|
||
|
|
currentFile = filename;
|
||
|
|
originalContent = d.content;
|
||
|
|
document.getElementById('editor').value = d.content;
|
||
|
|
document.getElementById('editorFilename').textContent = filename;
|
||
|
|
document.getElementById('editor').style.display = 'block';
|
||
|
|
document.getElementById('emptyState').style.display = 'none';
|
||
|
|
document.getElementById('editorToolbar').style.display = 'flex';
|
||
|
|
document.getElementById('dirtyIndicator').textContent = '';
|
||
|
|
loadAgents();
|
||
|
|
} catch(e) { showToast('Failed to load agent','error'); }
|
||
|
|
}
|
||
|
|
|
||
|
|
function isDirty() { return currentFile && document.getElementById('editor').value !== originalContent; }
|
||
|
|
function markDirty() { document.getElementById('dirtyIndicator').textContent = isDirty() ? '● unsaved' : ''; }
|
||
|
|
function discardChanges() {
|
||
|
|
if (isDirty() && !confirm('Discard unsaved changes?')) return;
|
||
|
|
document.getElementById('editor').value = originalContent;
|
||
|
|
document.getElementById('dirtyIndicator').textContent = '';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveAgent() {
|
||
|
|
if (!currentFile) return;
|
||
|
|
const content = document.getElementById('editor').value;
|
||
|
|
const btn = document.getElementById('btnSave');
|
||
|
|
btn.disabled = true; btn.textContent = 'Saving...';
|
||
|
|
try {
|
||
|
|
const r = await fetch(`/api/agents/${currentFile}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({content})});
|
||
|
|
const d = await r.json();
|
||
|
|
if (!r.ok) throw new Error(d.error);
|
||
|
|
originalContent = content;
|
||
|
|
document.getElementById('dirtyIndicator').textContent = '';
|
||
|
|
showToast('✓ '+d.message,'success');
|
||
|
|
setTimeout(loadAgents,2000);
|
||
|
|
} catch(e) { showToast('Error: '+e.message,'error'); }
|
||
|
|
finally { btn.disabled=false; btn.innerHTML='💾 Save & Restart'; }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function triggerAgent() {
|
||
|
|
if (!currentFile || !confirm('Run this agent now?')) return;
|
||
|
|
const btn = document.getElementById('btnTrigger');
|
||
|
|
btn.disabled=true; btn.textContent='Triggering...';
|
||
|
|
try {
|
||
|
|
const r = await fetch(`/api/agents/${currentFile}/trigger`,{method:'POST'});
|
||
|
|
const d = await r.json();
|
||
|
|
if (!r.ok) throw new Error(d.error);
|
||
|
|
showToast('▶ '+d.message,'success');
|
||
|
|
setTimeout(loadAgents,1500);
|
||
|
|
} catch(e) { showToast('Error: '+e.message,'error'); }
|
||
|
|
finally { btn.disabled=false; btn.textContent='▶ Run Now'; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function showNewModal() {
|
||
|
|
document.getElementById('newModal').classList.add('show');
|
||
|
|
document.getElementById('newAgentName').value='';
|
||
|
|
document.getElementById('newAgentFilename').value='';
|
||
|
|
setTimeout(()=>document.getElementById('newAgentName').focus(),50);
|
||
|
|
}
|
||
|
|
function hideNewModal() { document.getElementById('newModal').classList.remove('show'); }
|
||
|
|
function updateNewFilename() {
|
||
|
|
const n=document.getElementById('newAgentName').value.trim().toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,'');
|
||
|
|
document.getElementById('newAgentFilename').value = n ? n+'.yaml' : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function createAgent() {
|
||
|
|
const name=document.getElementById('newAgentName').value.trim();
|
||
|
|
const filename=document.getElementById('newAgentFilename').value;
|
||
|
|
if (!name||!filename){showToast('Enter an agent name','error');return;}
|
||
|
|
try {
|
||
|
|
const r=await fetch('/api/agents',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename,content:TEMPLATE(name)})});
|
||
|
|
const d=await r.json();
|
||
|
|
if (!r.ok) throw new Error(d.error);
|
||
|
|
hideNewModal(); await loadAgents(); await loadAgent(filename);
|
||
|
|
showToast('✓ Agent created','success');
|
||
|
|
} catch(e){showToast('Error: '+e.message,'error');}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('editor').addEventListener('keydown',e=>{
|
||
|
|
if(e.key==='Tab'){
|
||
|
|
e.preventDefault();
|
||
|
|
const t=e.target,s=t.selectionStart,en=t.selectionEnd;
|
||
|
|
t.value=t.value.substring(0,s)+' '+t.value.substring(en);
|
||
|
|
t.selectionStart=t.selectionEnd=s+2; markDirty();
|
||
|
|
}
|
||
|
|
if((e.ctrlKey||e.metaKey)&&e.key==='s'){e.preventDefault();saveAgent();}
|
||
|
|
});
|
||
|
|
|
||
|
|
function showToast(msg,type='success'){
|
||
|
|
const t=document.getElementById('toast');
|
||
|
|
t.textContent=msg; t.className=`toast ${type} show`;
|
||
|
|
setTimeout(()=>t.classList.remove('show'),4000);
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('newModal').addEventListener('click',e=>{if(e.target===e.currentTarget)hideNewModal();});
|
||
|
|
loadAgents();
|
||
|
|
setInterval(loadAgents,15000);
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|