/* ============================================================
子智能体 / 后台指令窗口 Demo 逻辑
- 新条目出现动画与待办一致(todo-in keyframes,从左向右进入)
- 运行中条目用定时器模拟流式输出(工具调用 / 终端行)
- 点击条目在左侧展开详情面板:新进度淡入 + 自动向下滚动
- ⋯ 菜单:强制关闭(完成后置灰);无删除
============================================================ */
const agentWindowEl = document.getElementById('agentWindow');
const cmdWindowEl = document.getElementById('cmdWindow');
const agentListEl = document.getElementById('agentList');
const cmdListEl = document.getElementById('cmdList');
const agentCounterEl = document.getElementById('agentCounter');
const cmdCounterEl = document.getElementById('cmdCounter');
const detailPanel = document.getElementById('detailPanel');
const detailDot = document.getElementById('detailDot');
const detailTitle = document.getElementById('detailTitle');
const detailBadge = document.getElementById('detailBadge');
const detailBody = document.getElementById('detailBody');
const detailCloseBtn = document.getElementById('detailClose');
const itemMenu = document.getElementById('itemMenu');
const menuKill = document.getElementById('menuKill');
const runners = new Map();
let runnerSeq = 0;
let agentSeq = 0;
let cmdSeq = 0;
const detailState = { id: null };
let menuRunnerId = null;
/* ---------------- 演示脚本 ---------------- */
const AGENT_ROLES = ['UI Operator', 'Code Reviewer', 'Researcher'];
const AGENT_SCRIPTS = [
[
{ type: 'text', text: '收到,先看一下 MonitorDirector 的当前实现…' },
{ type: 'tool', name: 'read_file', param: 'static/src/components/chat/monitor/MonitorDirector.ts', result: '342 行', duration: 1400 },
{ type: 'tool', name: 'read_file', param: 'static/src/stores/monitor.ts', result: '186 行', duration: 1000 },
{ type: 'text', text: '定位到事件队列逻辑,开始修改…' },
{ type: 'tool', name: 'edit_file', param: 'MonitorDirector.ts · 3 处替换', result: '✓ 成功', duration: 1800 },
{ type: 'tool', name: 'run_command', param: 'npm run build', result: '✓ 2.31s', duration: 2600 },
{ type: 'text', text: '构建通过,改动已完成。' },
],
[
{ type: 'text', text: '开始审查本次改动的 diff…' },
{ type: 'tool', name: 'run_command', param: 'git diff HEAD~1..HEAD', result: '+86 −24', duration: 1500 },
{ type: 'text', text: '发现两处潜在问题,进一步确认上下文…' },
{ type: 'tool', name: 'read_file', param: 'server/chat_flow_tool_loop.py · 1320-1380 行', result: '已读取', duration: 1200 },
{ type: 'text', text: '审查完成:2 个建议,0 个阻塞问题,已同步给 Team Leader。' },
],
[
{ type: 'text', text: '正在检索相关资料…' },
{ type: 'tool', name: 'web_search', param: 'vue3 transition-group FLIP 列表动画', result: '8 条结果', duration: 1600 },
{ type: 'tool', name: 'extract_webpage', param: 'vuejs.org/guide/built-ins/transition-group', result: '4.2k 字', duration: 1300 },
{ type: 'text', text: '整理完成:推荐用 FLIP + 交错延迟实现列表重排动画。' },
],
];
const CMD_POOL = [
{
name: 'npm run build',
script: [
{ text: '$ npm run build', isCommand: true },
{ text: '> vite v5.4.19 building for production...' },
{ text: 'transforming (1823) src/main.ts' },
{ text: '✓ 1823 modules transformed.' },
{ text: 'dist/index.html 0.46 kB │ gzip: 0.31 kB' },
{ text: 'dist/assets/index-C8f3k2m.js 412.18 kB │ gzip: 132.40 kB' },
{ text: '✓ built in 2.31s' },
],
},
{
name: 'npm run lint',
script: [
{ text: '$ npm run lint', isCommand: true },
{ text: '> eslint static/src --ext .ts,.vue' },
{ text: 'checked 214 files' },
{ text: '✓ no problems found' },
],
},
{
name: 'python3 -m unittest test.test_server_refactor_smoke',
script: [
{ text: '$ python3 -m unittest test.test_server_refactor_smoke', isCommand: true },
{ text: 'test_chat_smoke ... ok' },
{ text: 'test_status_smoke ... ok' },
{ text: 'test_tasks_smoke ... ok' },
{ text: 'Ran 3 tests in 0.42s' },
{ text: 'OK' },
],
},
];
/* ---------------- 工具 ---------------- */
function showFloatWindow(el) {
if (!el.hidden) return;
el.hidden = false;
el.classList.add('window-enter');
el.addEventListener('animationend', () => el.classList.remove('window-enter'), { once: true });
}
function updateRunCounter(kind) {
const all = [...runners.values()].filter((r) => r.kind === kind);
const running = all.filter((r) => r.status === 'running').length;
(kind === 'agent' ? agentCounterEl : cmdCounterEl).textContent = `${running}/${all.length}`;
}
/* ---------------- 创建条目 ---------------- */
function createRunner(kind) {
const id = ++runnerSeq;
let name; let script;
if (kind === 'agent') {
name = `${AGENT_ROLES[agentSeq % AGENT_ROLES.length]}_${Math.floor(agentSeq / AGENT_ROLES.length) + 1}`;
script = AGENT_SCRIPTS[agentSeq % AGENT_SCRIPTS.length];
agentSeq += 1;
} else {
const pool = CMD_POOL[cmdSeq % CMD_POOL.length];
name = pool.name;
script = pool.script;
cmdSeq += 1;
}
const runner = {
id,
kind,
name,
status: 'running', // running | done | killed
feed: [],
script,
cursor: 0,
timer: null,
rowEl: null,
listEl: kind === 'agent' ? agentListEl : cmdListEl,
};
runners.set(id, runner);
showFloatWindow(kind === 'agent' ? agentWindowEl : cmdWindowEl);
renderRunnerRow(runner);
updateRunCounter(kind);
playScript(runner);
}
/* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */
function scrollRowIntoView(listEl, li) {
return new Promise((resolve) => {
const listRect = listEl.getBoundingClientRect();
const liRect = li.getBoundingClientRect();
const fullyVisible = liRect.top >= listRect.top && liRect.bottom <= listRect.bottom;
if (fullyVisible) { resolve(); return; }
let done = false;
let timer;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
listEl.removeEventListener('scroll', onScroll);
resolve();
};
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(finish, 90);
};
listEl.addEventListener('scroll', onScroll);
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(finish, 800);
});
}
function renderRunnerRow(runner) {
const li = document.createElement('li');
li.className = 'run-item is-running';
li.dataset.kind = runner.kind;
li.dataset.id = runner.id;
const statusEl = document.createElement('span');
statusEl.className = 'item-status';
const nameEl = document.createElement('span');
nameEl.className = 'item-name';
nameEl.textContent = runner.name;
const menuBtn = document.createElement('button');
menuBtn.className = 'item-menu-btn';
menuBtn.title = '更多';
menuBtn.innerHTML = '';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
showItemMenu(runner, menuBtn);
});
li.append(statusEl, nameEl, menuBtn);
li.addEventListener('click', () => openDetail(runner.id));
runner.rowEl = li;
runner.listEl.appendChild(li);
/* 先隐身插入;若在可视区域外先滚动出来,再播进入动画 */
li.style.opacity = '0';
scrollRowIntoView(runner.listEl, li).then(() => {
li.classList.add('is-entering');
li.addEventListener('animationend', () => {
li.classList.remove('is-entering');
li.style.opacity = '';
}, { once: true });
});
}
/* ---------------- 模拟流式输出 ---------------- */
function playScript(runner) {
const step = () => {
if (runner.status !== 'running') return;
if (runner.cursor >= runner.script.length) {
finishRunner(runner, 'done');
return;
}
const item = runner.script[runner.cursor];
runner.cursor += 1;
if (item.type === 'tool') {
// 工具行先以 running 态出现,duration 后落定结果
const feedItem = { ...item, status: 'running' };
pushFeed(runner, feedItem);
runner.timer = setTimeout(() => {
feedItem.status = 'done';
updateFeedRowEl(feedItem);
runner.timer = setTimeout(step, 380);
}, item.duration);
} else if (item.type === 'text') {
pushFeed(runner, { ...item });
runner.timer = setTimeout(step, 950);
} else {
// 终端行
pushFeed(runner, { type: 'term', ...item });
runner.timer = setTimeout(step, 620);
}
};
runner.timer = setTimeout(step, 650);
}
function finishRunner(runner, status) {
if (runner.status !== 'running') return;
runner.status = status;
clearTimeout(runner.timer);
runner.rowEl.classList.remove('is-running');
runner.rowEl.classList.add('is-done');
updateRunCounter(runner.kind);
if (status === 'killed') {
pushFeed(runner, { type: 'text', killed: true, text: '⛔ 已被强制关闭' });
}
if (detailState.id === runner.id) updateDetailHeader(runner);
}
/* ---------------- feed 行渲染 ---------------- */
function rowClassFor(item) {
if (item.type === 'tool') return 'feed-row feed-tool';
if (item.type === 'term') return 'feed-row feed-term' + (item.isCommand ? ' is-command' : '');
return 'feed-row feed-text' + (item.killed ? ' feed-killed' : '');
}
function rowInnerFor(item) {
if (item.type === 'tool') {
const icon = item.status === 'done'
? '✓'
: '';
const result = item.status === 'done'
? `${item.result}`
: '';
return `${icon}${item.name}${item.param}${result}`;
}
return escapeText(item.text);
}
function escapeText(t) {
const div = document.createElement('div');
div.textContent = t;
return div.innerHTML;
}
function pushFeed(runner, item) {
runner.feed.push(item);
if (detailState.id === runner.id) appendFeedRow(item);
}
/* 追加一行到详情面板:新行淡入,贴近底部时自动向下平滑滚动 */
function appendFeedRow(item) {
const el = detailBody;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
const div = document.createElement('div');
div.className = rowClassFor(item);
div.innerHTML = rowInnerFor(item);
el.appendChild(div);
item.el = div;
if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
function updateFeedRowEl(item) {
if (!item.el) return;
item.el.className = rowClassFor(item);
item.el.innerHTML = rowInnerFor(item);
const el = detailBody;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}
/* ---------------- 详情面板 ---------------- */
function updateDetailHeader(runner) {
detailDot.className = 'detail-status-dot' + (runner.status === 'running' ? ' is-running' : '');
detailBadge.textContent = runner.status === 'running' ? '运行中' : runner.status === 'done' ? '已完成' : '已终止';
detailBadge.classList.toggle('is-done', runner.status !== 'running');
}
function openDetail(id) {
if (detailState.id === id) { closeDetail(); return; } // 再点同一行收起
const runner = runners.get(id);
detailState.id = id;
detailTitle.textContent = runner.name;
updateDetailHeader(runner);
detailBody.innerHTML = '';
runner.feed.forEach((item) => appendFeedRow(item));
detailBody.scrollTop = detailBody.scrollHeight; // 打开时直接定位到底部
document.querySelectorAll('.run-item.is-active').forEach((el) => el.classList.remove('is-active'));
runner.rowEl.classList.add('is-active');
if (detailPanel.hidden) {
detailPanel.hidden = false;
detailPanel.classList.remove('panel-leave');
detailPanel.classList.add('panel-enter');
setTimeout(() => detailPanel.classList.remove('panel-enter'), 260);
} else {
// 切换条目时内容区快速淡入
detailBody.classList.remove('body-fade');
void detailBody.offsetWidth;
detailBody.classList.add('body-fade');
}
}
function closeDetail() {
if (detailPanel.hidden) return;
detailState.id = null;
document.querySelectorAll('.run-item.is-active').forEach((el) => el.classList.remove('is-active'));
detailPanel.classList.remove('panel-enter');
detailPanel.classList.add('panel-leave');
/* 用 setTimeout 而非 animationend:feed 行动画的 animationend 会冒泡到面板造成干扰 */
setTimeout(() => {
detailPanel.classList.remove('panel-leave');
detailPanel.hidden = true;
}, 190);
}
/* ---------------- 条目 ⋯ 菜单 ---------------- */
function showItemMenu(runner, btn) {
if (!itemMenu.hidden && menuRunnerId === runner.id) { hideItemMenu(); return; }
menuRunnerId = runner.id;
const fm = document.getElementById('fileMenu'); // 与文件菜单互斥
if (fm) fm.hidden = true;
const rect = btn.getBoundingClientRect();
/* ⋯ 按钮在行的最右侧,菜单与按钮右对齐、向下展开 */
itemMenu.style.left = 'auto';
itemMenu.style.right = `${window.innerWidth - rect.right}px`;
itemMenu.style.top = `${rect.bottom + 6}px`;
menuKill.disabled = runner.status !== 'running';
itemMenu.hidden = false;
itemMenu.classList.add('menu-enter');
itemMenu.addEventListener('animationend', () => itemMenu.classList.remove('menu-enter'), { once: true });
}
function hideItemMenu() {
itemMenu.hidden = true;
menuRunnerId = null;
}
menuKill.addEventListener('click', () => {
const runner = runners.get(menuRunnerId);
if (runner) finishRunner(runner, 'killed');
hideItemMenu();
});
document.addEventListener('click', (e) => {
if (!itemMenu.hidden && !e.target.closest('.item-menu') && !e.target.closest('.item-menu-btn')) {
hideItemMenu();
}
});
detailCloseBtn.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!itemMenu.hidden) { hideItemMenu(); e.preventDefault(); }
else if (!detailPanel.hidden) { closeDetail(); e.preventDefault(); }
}
});
/* ---------------- 演示按钮 & 自动演示 ---------------- */
document.getElementById('btnNewAgent').addEventListener('click', () => createRunner('agent'));
document.getElementById('btnNewCmd').addEventListener('click', () => createRunner('cmd'));
window.addEventListener('load', () => {
setTimeout(() => createRunner('agent'), 1200);
setTimeout(() => createRunner('cmd'), 2100);
});