/* ============================================================ 文件记录窗口 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: ` `, }, { 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 = ''; 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); });