agent-Specialization/demo/todo_float_window/file.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

356 lines
11 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 逻辑
- 条目:[⋯] 文件名(无状态点);点击条目从右侧滑出预览侧边栏
- ⋯ 菜单:下载 / 在文件管理器中打开 / 复制路径(工作区相对路径)
- 显示逻辑(正式版):文件被编辑过 + 文件仍存在
- 复用 runner.js 的 showFloatWindow / scrollRowIntoView
============================================================ */
const fileWindowEl = document.getElementById('fileWindow');
const fileListEl = document.getElementById('fileList');
const fileCounterEl = document.getElementById('fileCounter');
const fileMenuEl = document.getElementById('fileMenu');
const menuDownload = document.getElementById('menuDownload');
const menuReveal = document.getElementById('menuReveal');
const menuCopyPath = document.getElementById('menuCopyPath');
const previewEl = document.getElementById('filePreview');
const previewNameEl = document.getElementById('previewName');
const previewPathEl = document.getElementById('previewPath');
const previewBodyEl = document.getElementById('previewBody');
const previewCloseBtn = document.getElementById('previewClose');
const toastEl = document.getElementById('demoToast');
const fileState = { files: [], poolIdx: 0 };
const previewState = { path: null };
let menuFilePath = null;
let toastTimer = null;
/* ---------------- 演示文件数据 ---------------- */
const FILES_POOL = [
{
path: 'static/src/components/chat/ChatArea.vue',
content: `<template>
<div class="chat-area" ref="chatAreaRef">
<MessageList
:messages="visibleMessages"
:loading="isLoading"
@retry="handleRetry"
/>
<InputBar
v-model="draft"
:disabled="!connected"
@send="handleSend"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const draft = ref('')
const visibleMessages = computed(() => chatStore.activeMessages)
function handleSend() {
if (!draft.value.trim()) return
chatStore.sendUserMessage(draft.value)
draft.value = ''
}
</script>`,
},
{
path: 'server/chat_flow_tool_loop.py',
content: `async def execute_tool_calls(self, tool_calls, messages):
"""执行一轮工具调用,并把结果回填到消息列表。"""
results = []
for call in tool_calls:
tool_name = call.get("name")
tool_args = call.get("arguments", {})
handler = self.tool_handlers.get(tool_name)
if handler is None:
results.append({"success": False, "error": f"未知工具: {tool_name}"})
continue
try:
result = await handler(**tool_args)
results.append(result)
except Exception as exc:
logger.exception("tool %s failed", tool_name)
results.append({"success": False, "error": str(exc)})
for call, result in zip(tool_calls, results):
messages.append({
"role": "tool",
"tool_call_id": call["id"],
"content": format_tool_result_for_context(call["name"], result),
})
return results`,
},
{
path: 'docs/float_window_design.md',
content: `# 悬浮窗口设计
## 布局
- 位置:对话显示区域右上角
- 排列:多个窗口上下排布,右对齐
- 尺寸:宽 270px列表区固定 5 行
## 窗口列表
1. 待办事项:任务创建 / 完成 / 替换动画
2. 文件记录:本次对话编辑过的文件
3. 子智能体:运行状态 + 详情面板
4. 后台指令:运行状态 + 终端输出
## 动画规范
- 进入:从左向右位移 14px + 淡入,逐行 60ms 交错
- 完成:横线从左往右划过 + 文字变灰
- 移出:从右向左 + 高度收拢
- 显示区域外的条目:先滚动露出,再播动画`,
},
{
path: 'config/limits.py',
content: `"""运行限制相关配置。"""
MAX_TODO_ITEMS = 8
"""单次待办列表最多条目数。"""
FLOAT_WINDOW_ROWS = 5
"""悬浮窗口列表区固定行数。"""
FLOAT_WINDOW_WIDTH = 270
"""悬浮窗口宽度(最大宽度的 90%)。"""
SUB_AGENT_MAX_ROUNDS = 12
"""子智能体单轮任务最大对话轮数。"""
LOG_ROTATE_MAX_BYTES = 20 * 1024 * 1024
"""单日志文件最大体积,超过触发轮转。"""`,
},
];
/* ---------------- 工具 ---------------- */
function basename(p) { return p.split('/').pop(); }
function dirname(p) { return p.split('/').slice(0, -1).join('/') + '/'; }
function showToast(text) {
toastEl.textContent = text;
toastEl.classList.add('is-show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('is-show'), 1800);
}
function updateFileCounter() {
fileCounterEl.textContent = String(fileState.files.length);
}
/* ---------------- 文件条目 ---------------- */
function simulateEditFile() {
// 正式版逻辑:同一文件只出现一次;被删除的文件从列表移除
if (fileState.poolIdx >= FILES_POOL.length) {
showToast('演示文件已全部在列表中');
return;
}
const file = FILES_POOL[fileState.poolIdx];
fileState.poolIdx += 1;
fileState.files.push(file);
showFloatWindow(fileWindowEl);
renderFileRow(file);
updateFileCounter();
}
function renderFileRow(file) {
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.path = file.path;
li.title = file.path;
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();
showFileMenu(file, menuBtn);
});
const nameEl = document.createElement('span');
nameEl.className = 'file-name';
nameEl.textContent = basename(file.path);
li.append(menuBtn, nameEl);
li.addEventListener('click', () => openPreview(file));
fileListEl.appendChild(li);
/* 先隐身插入;若在可视区域外先滚动出来,再播进入动画 */
li.style.opacity = '0';
scrollRowIntoView(fileListEl, li).then(() => {
li.classList.add('is-entering');
li.addEventListener('animationend', () => {
li.classList.remove('is-entering');
li.style.opacity = '';
}, { once: true });
});
}
/* ---------------- ⋯ 菜单:下载 / 在文件管理器中打开 / 复制路径 ---------------- */
function showFileMenu(file, btn) {
if (!fileMenuEl.hidden && menuFilePath === file.path) { hideFileMenu(); return; }
menuFilePath = file.path;
const rm = document.getElementById('itemMenu'); // 与子智能体菜单互斥
if (rm) rm.hidden = true;
const rect = btn.getBoundingClientRect();
/* ⋯ 在条目最左侧,菜单与按钮左对齐、向下展开 */
fileMenuEl.style.left = `${rect.left}px`;
fileMenuEl.style.right = 'auto';
fileMenuEl.style.top = `${rect.bottom + 6}px`;
fileMenuEl.hidden = false;
fileMenuEl.classList.add('menu-enter');
fileMenuEl.addEventListener('animationend', () => fileMenuEl.classList.remove('menu-enter'), { once: true });
}
function hideFileMenu() {
fileMenuEl.hidden = true;
menuFilePath = null;
}
function currentMenuFile() {
return fileState.files.find((f) => f.path === menuFilePath);
}
menuDownload.addEventListener('click', () => {
const file = currentMenuFile();
if (file) {
const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = basename(file.path);
a.click();
URL.revokeObjectURL(url);
showToast(`已下载 ${a.download}`);
}
hideFileMenu();
});
menuReveal.addEventListener('click', () => {
// 正式版:复用 git 状态侧边栏逻辑用默认应用打开docker 模式下无此选项
showToast('已在文件管理器中打开demo 示意)');
hideFileMenu();
});
menuCopyPath.addEventListener('click', () => {
const file = currentMenuFile();
if (file) copyText(file.path);
hideFileMenu();
});
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => showToast('已复制相对路径'),
() => fallbackCopy(text),
);
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
showToast('已复制相对路径');
} catch (err) {
showToast('复制失败');
}
ta.remove();
}
document.addEventListener('click', (e) => {
if (!fileMenuEl.hidden && !e.target.closest('.file-menu') && !e.target.closest('.item-menu-btn')) {
hideFileMenu();
}
});
/* ---------------- 右侧预览侧边栏 ---------------- */
function openPreview(file) {
if (previewState.path === file.path) { closePreview(); return; } // 再点同一文件收起
previewState.path = file.path;
previewNameEl.textContent = basename(file.path);
previewPathEl.textContent = dirname(file.path);
previewBodyEl.innerHTML = '';
file.content.split('\n').forEach((line, i) => {
const div = document.createElement('div');
div.className = 'code-line';
const no = document.createElement('span');
no.className = 'line-no';
no.textContent = String(i + 1);
const text = document.createElement('span');
text.className = 'line-text';
text.textContent = line;
div.append(no, text);
previewBodyEl.appendChild(div);
});
previewBodyEl.scrollTop = 0;
document.querySelectorAll('.file-item.is-active').forEach((el) => el.classList.remove('is-active'));
const row = fileListEl.querySelector(`[data-path="${CSS.escape(file.path)}"]`);
if (row) row.classList.add('is-active');
document.body.classList.add('preview-open');
previewEl.classList.add('is-open');
}
function closePreview() {
previewState.path = null;
document.body.classList.remove('preview-open');
previewEl.classList.remove('is-open');
document.querySelectorAll('.file-item.is-active').forEach((el) => el.classList.remove('is-active'));
}
previewCloseBtn.addEventListener('click', closePreview);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !e.defaultPrevented) {
if (!fileMenuEl.hidden) hideFileMenu();
else if (previewState.path) closePreview();
}
});
/* 窗口栈整体滚动时,关闭所有已打开的 ⋯ 菜单(菜单是 fixed 定位,不随栈滚动) */
document.querySelector('.float-stack').addEventListener('scroll', () => {
hideItemMenu();
hideFileMenu();
});
/* ---------------- 演示按钮 & 自动演示 ---------------- */
document.getElementById('btnEditFile').addEventListener('click', simulateEditFile);
window.addEventListener('load', () => {
setTimeout(simulateEditFile, 2900);
setTimeout(simulateEditFile, 3600);
});