/* ============================================================ 子智能体 / 后台指令窗口 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); });