agent-Specialization/demo/todo_float_window/runner.js
JOJO 171833e4d1 feat(quickdock): 新增对话区右侧快捷窗口(待办/子智能体/后台指令/文件记录)
- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」
- 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content
- 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类
- 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
2026-07-22 00:52:36 +08:00

422 lines
15 KiB
JavaScript
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.

/* ============================================================
子智能体 / 后台指令窗口 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 而非 animationendfeed 行动画的 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);
});