Files
lsdefine--genericagent/frontends/conductor.html
T
2026-07-13 13:04:19 +08:00

514 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Conductor</title>
<style>
:root {
--bg: #f5f7fb;
--panel: rgba(255,255,255,0.78);
--panel-2: rgba(255,255,255,0.92);
--border: rgba(31,42,68,0.10);
--text: #172033;
--muted: #7a8497;
--soft: #43506a;
--accent: #3b82f6;
--accent-2: #8b5cf6;
--green: #10b981;
--amber: #f59e0b;
--red: #ef4444;
--gray: #9ca3af;
--shadow: 0 18px 55px rgba(30, 45, 80, .11);
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 100vh;
overflow: hidden;
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(900px 600px at 12% 8%, rgba(59, 130, 246, .14), transparent 54%),
radial-gradient(800px 650px at 86% 18%, rgba(139, 92, 246, .12), transparent 58%),
radial-gradient(650px 520px at 61% 94%, rgba(16, 185, 129, .10), transparent 60%),
linear-gradient(180deg, #fbfcff, var(--bg));
}
.app {
height: 100vh;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
.app-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(340px, 40%) 1fr;
gap: 18px;
}
.pane {
min-height: 0;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.66));
backdrop-filter: blur(18px);
border-radius: 26px;
box-shadow: var(--shadow);
overflow: hidden;
}
.left-col { min-height: 0; display: flex; flex-direction: column; gap: 18px; }
.left-col > .pane.left { flex: 1; min-height: 0; }
.left, .right { display: flex; flex-direction: column; min-height: 0; }
.right { background: transparent; border: 0; box-shadow: none; backdrop-filter: none; gap: 18px; }
.head {
padding: 18px 20px 13px;
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid rgba(31,42,68,.075);
background: rgba(255,255,255,.34);
}
.title { font-size: 17px; letter-spacing: .2px; font-weight: 700; }
.hint { color: var(--muted); font-size: 12px; }
.cards {
flex: 1;
min-height: 0;
padding: 14px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.cards::-webkit-scrollbar, .messages::-webkit-scrollbar,
.console-body::-webkit-scrollbar, .approvals-body::-webkit-scrollbar { width: 8px; }
.cards::-webkit-scrollbar-thumb, .messages::-webkit-scrollbar-thumb,
.console-body::-webkit-scrollbar-thumb, .approvals-body::-webkit-scrollbar-thumb {
background: rgba(31,42,68,.16);
border-radius: 10px;
}
.approvals-body::-webkit-scrollbar { width: 10px; }
.approvals-body::-webkit-scrollbar-track { background: rgba(31,42,68,.08); border-radius: 10px; }
.approvals-body::-webkit-scrollbar-thumb { background: rgba(31,42,68,.34); border-radius: 10px; border: 2px solid rgba(255,255,255,.65); }
.empty { color: var(--muted); font-size: 13px; padding: 8px 4px; }
/* ---------- subagent 卡片(可折叠) ---------- */
.card {
position: relative;
padding: 13px 14px 12px 16px;
border-radius: 18px;
background: rgba(255,255,255,.76);
border: 1px solid rgba(31,42,68,.10);
box-shadow: 0 8px 24px rgba(30,45,80,.055);
cursor: pointer;
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.card:hover {
transform: translateY(-1px);
background: rgba(255,255,255,.95);
border-color: rgba(59,130,246,.24);
box-shadow: 0 12px 30px rgba(30,45,80,.08);
}
.card::before {
content: "";
position: absolute;
left: 0; top: 14px; bottom: 14px;
width: 4px;
border-radius: 99px;
background: var(--state);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 84%);
}
.card.running { --state: var(--green); }
.card.waiting { --state: var(--amber); }
.card.stopped { --state: var(--gray); }
.card.failed { --state: var(--red); }
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 7px;
}
.dot-text { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--state);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 86%); }
.chev { color: var(--muted); font-size: 11px; transition: transform .2s ease; }
.card.expanded .chev { transform: rotate(180deg); }
.prompt {
color: #24314d; font-size: 13px; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
}
.card.expanded .prompt { -webkit-line-clamp: unset; margin-bottom: 11px; }
.card .reply { display: none; }
.card.expanded .reply {
display: block;
color: var(--soft); font-size: 13px; line-height: 1.4;
background: rgba(245,247,251,.78); border: 1px solid rgba(31,42,68,.07);
border-radius: 14px; padding: 11px 12px; max-height: 220px; overflow-y: auto; word-wrap: break-word;
}
.reply p, .reply ul, .reply ol, .reply pre { margin: 0 0 .25em 0; }
.reply p:last-child { margin-bottom: 0; }
.reply code { font-size: 12px; background: rgba(0,0,0,.05); padding: 1px 4px; border-radius: 4px; }
.reply pre { white-space: pre-wrap; }
/* ---------- 任务奏折栏(无事则隐) ---------- */
.approvals { display: none; min-height: 0; overflow: hidden; }
.approvals.show { display: flex; flex: 0 0 420px; height: 420px; max-height: 42vh; flex-direction: column; }
.approvals .head {
background: linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.05));
border-bottom: 1px solid rgba(245,158,11,.18);
}
.approvals .title { display: inline-flex; align-items: center; gap: 8px; }
.badge {
font-size: 11px; font-weight: 800; color: #fff;
background: var(--amber); border-radius: 99px; padding: 2px 9px;
box-shadow: 0 4px 12px rgba(245,158,11,.35);
}
.approvals-body { flex: 1; min-height: 0; padding: 13px; display: flex; flex-direction: column; gap: 11px; overflow-y: scroll; overflow-x: hidden; scrollbar-gutter: stable; }
.approval {
flex: 0 0 auto;
border-radius: 16px; background: rgba(255,255,255,.8);
border: 1px solid rgba(245,158,11,.25);
box-shadow: 0 8px 22px rgba(245,158,11,.10);
overflow: hidden;
}
.approval-bar { padding: 12px 14px; cursor: pointer; display: flex; gap: 9px; align-items: center; }
.approval-tag { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--amber); font-weight: 800; }
.approval-sum { flex: 1; font-size: 13px; color: #24314d; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.approval .body { display: none; padding: 0 14px 14px; }
.approval.expanded .body { display: block; }
.approval .src { font-size: 12px; color: var(--muted); margin-bottom: 8px; line-height: 1.4; }
.approval textarea {
width: 100%; min-height: 88px; resize: vertical;
border: 1px solid rgba(31,42,68,.14); border-radius: 12px;
padding: 10px 12px; font: inherit; font-size: 13px; line-height: 1.45;
background: rgba(255,255,255,.95); color: var(--text); outline: none;
}
.approval textarea:focus { border-color: rgba(245,158,11,.5); box-shadow: 0 0 0 4px rgba(245,158,11,.10); }
.approval-acts { display: flex; gap: 10px; margin-top: 11px; }
.approval-acts button { height: 40px; min-width: 0; flex: 1; border-radius: 13px; font-size: 14px; font-weight: 750; }
.btn-ok { background: linear-gradient(135deg, #10b981, #059669); box-shadow: 0 8px 20px rgba(16,185,129,.25); }
.btn-no { background: linear-gradient(135deg, #f87171, #ef4444); box-shadow: 0 8px 20px rgba(239,68,68,.22); }
/* ---------- 聊天框 ---------- */
.chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.messages { flex: 1; overflow: auto; padding: 20px; display: flex; flex-direction: column; gap: 13px; }
.msg { max-width: 80%; padding: 13px 15px; border-radius: 18px; line-height: 1.45; font-size: 14px;
word-wrap: break-word; box-shadow: 0 8px 22px rgba(30,45,80,.055); }
.msg p { margin: 0 0 .3em 0; } .msg p:last-child { margin-bottom: 0; }
.msg.me { align-self: flex-end; color: white;
background: linear-gradient(135deg, #3b82f6, #7c3aed); border: 1px solid rgba(59,130,246,.20);
border-bottom-right-radius: 7px; }
.msg.me.unread { box-shadow: 0 0 0 2px #f59e0b, 0 8px 22px rgba(30,45,80,.055); }
.msg.me.unread::after { content: '未读'; display: block; margin-top: 6px; font-size: 11px; opacity: .85; color: #fbbf24; }
.msg.ai { align-self: flex-start; background: rgba(255,255,255,.88);
border: 1px solid rgba(31,42,68,.10); border-bottom-left-radius: 7px; }
.msg.system { align-self: center; max-width: 88%; color: var(--muted); font-size: 12px;
padding: 7px 11px; border-radius: 99px; background: rgba(255,255,255,.70);
border: 1px solid rgba(31,42,68,.08); box-shadow: none; }
.composer { padding: 14px; border-top: 1px solid rgba(31,42,68,.075); display: flex; gap: 12px;
align-items: end; background: rgba(255,255,255,.40); }
textarea#input { flex: 1; height: 52px; max-height: 140px; resize: none; outline: none; color: var(--text);
background: rgba(255,255,255,.92); border: 1px solid rgba(31,42,68,.12); border-radius: 18px;
padding: 14px 15px; font: inherit; line-height: 1.35; }
textarea#input:focus { border-color: rgba(59,130,246,.44); box-shadow: 0 0 0 4px rgba(59,130,246,.10); }
button { height: 52px; min-width: 92px; border: 0; border-radius: 18px; color: white; font-weight: 750;
cursor: pointer; background: linear-gradient(135deg, #3b82f6, #10b981);
box-shadow: 0 10px 26px rgba(59,130,246,.22); }
/* ---------- conductor 输出(横跨底部的独立卡片,默认收起) ---------- */
.console {
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: 20px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.66));
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.console-bar {
padding: 13px 20px; cursor: pointer; display: flex; align-items: center; justify-content: space-between;
background: rgba(255,255,255,.34); user-select: none;
}
.console-bar:hover { background: rgba(255,255,255,.6); }
.console-bar .t { font-size: 13px; font-weight: 700; color: var(--soft); display: inline-flex; align-items: center; gap: 8px; }
.console-bar .chev2 { color: var(--muted); font-size: 12px; transition: transform .2s ease; }
.console.open .console-bar .chev2 { transform: rotate(180deg); }
.console-body {
display: none;
max-height: 42vh; overflow-y: auto; overflow-x: hidden;
background: #0f1626; color: #cdd6e6;
padding: 14px 16px; font-family: "SF Mono", Consolas, monospace; font-size: 12px; line-height: 1.55;
white-space: normal; word-break: break-word; overflow-wrap: anywhere;
}
.console.open .console-body { display: block; }
.log-rec { padding: 8px 0; border-bottom: 1px dashed rgba(255,255,255,.08); min-width: 0; }
.log-rec:last-child { border-bottom: 0; }
.log-rec :is(p, ul, ol, pre, table) { margin: 0 0 .4em 0; max-width: 100%; }
.log-rec :is(h1, h2, h3, h4, h5, h6) {
font-size: 12px; font-weight: 700; color: #93c5fd;
margin: .5em 0 .25em 0; line-height: 1.4; border: 0; padding: 0;
}
.log-rec pre { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere;
background: rgba(255,255,255,.05); border-radius: 8px; padding: 8px 10px; overflow-x: auto; }
.log-rec code { word-break: break-word; overflow-wrap: anywhere; }
.log-rec table { display: block; overflow-x: auto; }
.log-evt { color: #7dd3fc; }
.log-ts { color: #64748b; }
@media (max-width: 900px) {
.app { padding: 12px; }
.app-grid { grid-template-columns: 1fr; grid-template-rows: 38% 1fr; }
.msg { max-width: 92%; }
}
</style>
</head>
<body>
<main class="app">
<div class="app-grid">
<!-- 左列:subagent 卡片 + conductor 输出 -->
<div class="left-col">
<!-- subagent 卡片 -->
<section class="pane left">
<div class="head">
<div class="title">Subagents</div>
<div class="hint">点击卡片展开 · live</div>
</div>
<div class="cards" id="cards">
<div class="empty">暂无 subagent。</div>
</div>
</section>
<!-- conductor 输出:左列底部独立卡片,系统级,默认收起 -->
<section class="console" id="console">
<div class="console-bar" id="consoleBar">
<span class="t">🧠 Conductor 输出 <span class="hint" id="logCount"></span></span>
<span class="chev2"></span>
</div>
<div class="console-body" id="consoleBody"></div>
</section>
</div><!-- /left-col -->
<!-- 右:待批任务 + 聊天 -->
<section class="right">
<!-- 待批任务(无事则隐) -->
<section class="pane approvals" id="approvals">
<div class="head">
<div class="title">待批任务 <span class="badge" id="apprBadge">0</span></div>
<div class="hint">conductor 拟派 subagent · 待你批复</div>
</div>
<div class="approvals-body" id="approvalsBody"></div>
</section>
<!-- 聊天 -->
<section class="pane chat">
<div class="head">
<div class="title">Conversation</div>
<div class="hint" id="wsStatus">connecting...</div>
</div>
<div class="messages" id="messages"></div>
<div class="composer">
<textarea id="input" placeholder="告诉 Conductor 你想做什么……"></textarea>
<button id="send">发送</button>
</div>
</section>
</section>
</div><!-- /app-grid -->
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const cardsEl = document.getElementById('cards');
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const wsStatus = document.getElementById('wsStatus');
const apprEl = document.getElementById('approvals');
const apprBody = document.getElementById('approvalsBody');
const apprBadge = document.getElementById('apprBadge');
const consoleEl = document.getElementById('console');
const consoleBar = document.getElementById('consoleBar');
const consoleBody= document.getElementById('consoleBody');
const logCount = document.getElementById('logCount');
let ws;
function esc(s){ return String(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function renderMd(s){ try { return marked.parse(s||'', { breaks: true, gfm: true }); } catch(e){ return esc(s); } }
/* ---------- subagent 卡片 ---------- */
function renderCards(items){
if(!items || !items.length){ cardsEl.innerHTML = '<div class="empty">暂无 subagent。</div>'; return; }
// 重建前记录展开状态(按卡片顺序索引),避免刷新后用户手动展开的卡片被收起
const expandedIdx = new Set();
cardsEl.querySelectorAll('.card').forEach((c,i)=>{ if(c.classList.contains('expanded')) expandedIdx.add(i); });
cardsEl.innerHTML = items.map((it,i)=>{
const status = it.status || 'stopped';
return `<article class="card ${esc(status)}${expandedIdx.has(i)?' expanded':''}">
<div class="status-row">
<span class="dot-text"><span class="dot"></span>${esc(status)}</span>
<span class="chev">▾</span>
</div>
<div class="prompt">${esc(it.prompt)}</div>
<div class="reply">${renderMd(it.reply || '等待输出……')}</div>
</article>`;
}).join('');
}
cardsEl.addEventListener('click', e=>{
const card = e.target.closest('.card'); if(card) card.classList.toggle('expanded');
});
/* ---------- 任务奏折 ---------- */
function apprHtml(it){ return `
<div class="approval" data-id="${esc(it.id)}">
<div class="approval-bar">
<span class="approval-tag">拟派</span>
<span class="approval-sum">${esc(it.prompt)}</span>
<span class="chev">▾</span>
</div>
<div class="body">
<div class="src">触发:${esc(it.source || '外部事件')}</div>
<textarea class="appr-prompt">${esc(it.prompt)}</textarea>
<div class="approval-acts">
<button class="btn-ok" data-act="ok">同意派发</button>
<button class="btn-no" data-act="no">否决</button>
</div>
</div>
</div>`; }
function addApproval(it){
apprEl.classList.add('show');
apprBody.insertAdjacentHTML('beforeend', apprHtml(it));
apprBadge.textContent = apprBody.querySelectorAll('.approval').length;
}
apprBody.addEventListener('click', e=>{
const bar = e.target.closest('.approval-bar');
if(bar){ bar.parentElement.classList.toggle('expanded'); return; }
const btn = e.target.closest('button[data-act]');
if(btn){
const card = btn.closest('.approval');
const act = btn.dataset.act;
const prompt = card.querySelector('.appr-prompt').value;
if(act==='ok') fetch('/subagent',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt})});
card.remove();
const left = apprBody.querySelectorAll('.approval').length;
apprBadge.textContent = left;
if(!left) apprEl.classList.remove('show');
}
});
/* ---------- 聊天 ---------- */
function addMsg(item){
const role = item.role || 'system';
const cls = role==='user' ? 'me' : (role==='conductor' ? 'ai' : 'system');
const div = document.createElement('div');
div.className = `msg ${cls}` + (role==='user' && !item.read ? ' unread' : '');
if(item.id) div.dataset.id = item.id;
div.innerHTML = renderMd(item.msg || '');
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function markAllRead(){ messagesEl.querySelectorAll('.msg.me.unread').forEach(el=>el.classList.remove('unread')); }
/* ---------- conductor 输出 ---------- */
function fmtTs(ms){ try{ return new Date(ms).toLocaleTimeString('zh-CN',{hour12:false}); }catch(e){ return ''; } }
function logItemHtml(r){
return `<div class="log-rec"><span class="log-ts">[${fmtTs(r.ts)}]</span> <span class="log-evt">${esc(r.event)} · t${r.turn}</span>${renderMd(r.text)}</div>`;
}
function renderLog(items){ // 全量重建(hello 快照)
items = items || [];
logCount.textContent = items.length ? ${items.length} 条` : '';
consoleBody.innerHTML = items.map(logItemHtml).join('');
if(consoleEl.classList.contains('open')) consoleBody.scrollTop = consoleBody.scrollHeight;
}
function appendLog(item){ // 增量追加一轮(完整 markdown)
if(!item) return;
consoleBody.insertAdjacentHTML('beforeend', logItemHtml(item));
logCount.textContent = ${consoleBody.querySelectorAll('.log-rec').length} 条`;
if(consoleEl.classList.contains('open')) consoleBody.scrollTop = consoleBody.scrollHeight;
}
consoleBar.addEventListener('click', ()=>{
consoleEl.classList.toggle('open');
if(consoleEl.classList.contains('open')) consoleBody.scrollTop = consoleBody.scrollHeight;
});
/* ---------- WS ---------- */
function connect(){
const proto = location.protocol==='https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = ()=> wsStatus.textContent = 'connected';
ws.onclose = ()=>{ wsStatus.textContent = 'disconnected · retrying'; setTimeout(connect,1200); };
ws.onerror = ()=> wsStatus.textContent = 'error';
ws.onmessage = ev=>{
const data = JSON.parse(ev.data);
if(data.type==='hello'){
renderCards(data.subagents||[]);
messagesEl.innerHTML=''; (data.chat||[]).forEach(addMsg);
renderLog(data.log||[]);
} else if(data.type==='subagents'){ renderCards(data.items||[]); }
else if(data.type==='chat'){ addMsg(data.item); }
else if(data.type==='chat_read'){ markAllRead(); }
else if(data.type==='log'){ appendLog(data.item); }
else if(data.type==='approval'){ addApproval(data.item); }
};
}
function send(){
const msg = inputEl.value.trim();
if(!msg || !ws || ws.readyState!==WebSocket.OPEN) return;
ws.send(JSON.stringify({msg})); inputEl.value=''; inputEl.focus();
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', e=>{ if(e.key==='Enter' && !e.shiftKey && !e.isComposing && e.keyCode!==229){ e.preventDefault(); send(); } });
// Input length limit (10000 chars)
const MAX_LEN = 10000;
let limitToast = null;
function showLimitToast(msg) {
if (limitToast) return; // already showing, don't spam
limitToast = document.createElement('div');
limitToast.textContent = msg;
limitToast.style.cssText = 'position:fixed;top:24px;left:50%;transform:translateX(-50%);background:#dc2626;color:#fff;padding:10px 24px;border-radius:8px;font-size:14px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:opacity .3s';
document.body.appendChild(limitToast);
setTimeout(() => {
if (limitToast) { limitToast.style.opacity = '0'; }
setTimeout(() => { if (limitToast) { limitToast.remove(); limitToast = null; } }, 300);
}, 3000);
}
function checkLimit() {
if (inputEl.value.length > MAX_LEN) {
inputEl.value = inputEl.value.slice(0, MAX_LEN);
showLimitToast('已达字数上限(' + MAX_LEN + '');
}
}
inputEl.addEventListener('paste', e => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
const remaining = MAX_LEN - inputEl.value.length + (inputEl.selectionEnd - inputEl.selectionStart);
const insert = text.slice(0, Math.max(0, remaining));
const start = inputEl.selectionStart;
const end = inputEl.selectionEnd;
inputEl.value = inputEl.value.slice(0, start) + insert + inputEl.value.slice(end);
inputEl.setSelectionRange(start + insert.length, start + insert.length);
if (text.length > remaining) {
showLimitToast('已达字数上限(' + MAX_LEN + '');
}
});
inputEl.addEventListener('input', checkLimit);
/* 接通后端: hello 推送 cards/chat/log 快照, approval 由 /approval 推送 */
connect();
</script>
</body>
</html>