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