- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」 - 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content - 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类 - 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
422 lines
15 KiB
JavaScript
422 lines
15 KiB
JavaScript
/* ============================================================
|
||
子智能体 / 后台指令窗口 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 = '<svg viewBox="0 0 16 16"><circle cx="3.5" cy="8" r="1.3" fill="currentColor"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><circle cx="12.5" cy="8" r="1.3" fill="currentColor"/></svg>';
|
||
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'
|
||
? '<span class="tool-done">✓</span>'
|
||
: '<span class="tool-spinner"></span>';
|
||
const result = item.status === 'done'
|
||
? `<span class="tool-result">${item.result}</span>`
|
||
: '';
|
||
return `${icon}<span class="tool-name">${item.name}</span><span class="tool-param">${item.param}</span>${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);
|
||
});
|