- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」 - 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content - 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类 - 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
356 lines
11 KiB
JavaScript
356 lines
11 KiB
JavaScript
/* ============================================================
|
||
文件记录窗口 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);
|
||
});
|