feat(quickdock): 新增对话区右侧快捷窗口(待办/子智能体/后台指令/文件记录)
- 四窗口固定顺序占位布局,空时收起到 0 宽;动画区分「运行中新出现」与「切换/加载」 - 文件记录:edit/write 埋点写入对话 metadata.edited_files,预览走既有 /api/file/content - 详情面板:lastDetail/lastStatus 快照、首次填充静态+瞬间到底、终态四色分类 - 数据通道全部走 REST 轮询(带 conversation_id 对话级隔离),含 demo 与设计文档
This commit is contained in:
parent
1901aeb82f
commit
171833e4d1
192
demo/todo_float_window/app.js
Normal file
192
demo/todo_float_window/app.js
Normal file
@ -0,0 +1,192 @@
|
||||
/* ============================================================
|
||||
待办事项悬浮窗 Demo 交互逻辑
|
||||
三组动画:
|
||||
1. 创建:逐行从左向右进入(stagger)
|
||||
2. 完成:横线从左往右划过 + 文字变灰
|
||||
3. 更新:旧行逐行向左移出(高度收拢)→ 新行逐行进入
|
||||
============================================================ */
|
||||
|
||||
const ENTER_STAGGER = 60; // 进入动画每行间隔 ms
|
||||
const LEAVE_STAGGER = 45; // 移出动画每行间隔 ms
|
||||
const LEAVE_DURATION = 260; // 单行移出动画时长 ms
|
||||
|
||||
const listEl = document.getElementById('todoList');
|
||||
const windowEl = document.getElementById('todoWindow');
|
||||
const counterEl = document.getElementById('todoCounter');
|
||||
|
||||
const state = { items: [], busy: false };
|
||||
|
||||
/* ---------------- 演示数据 ---------------- */
|
||||
|
||||
const SCENARIOS = {
|
||||
initial: [
|
||||
{ text: '梳理对话区布局,确定悬浮窗挂载位置' },
|
||||
{ text: '实现悬浮窗容器:圆角、最小 / 最大宽度' },
|
||||
{ text: '待办行直接铺在窗口上,去掉嵌套卡片' },
|
||||
{ text: '编写创建 / 完成 / 更新三组动画' },
|
||||
{ text: '联调后端 todo 事件,接入真实数据' },
|
||||
],
|
||||
updated: [
|
||||
{ text: '复核悬浮窗最大 / 最小宽度表现', done: true },
|
||||
{ text: '把划线动画时序再调顺手一点' },
|
||||
{ text: '超长任务名省略号验证(这一条故意写得特别特别长来测试溢出)' },
|
||||
{ text: '设计文件编辑记录窗口的数据结构' },
|
||||
{ text: '子智能体与后台指令窗口渲染方案' },
|
||||
{ text: '三种主题下核对一遍配色' },
|
||||
],
|
||||
long: [
|
||||
{ text: '拉取最新代码并跑一遍冒烟测试' },
|
||||
{ text: '检查待办窗口在窄屏下的最小宽度' },
|
||||
{ text: '把完成动画的横线改成从左向右' },
|
||||
{ text: '补五条以上时的内部滚动' },
|
||||
{ text: '写一条特别长的任务用来验证文字溢出时省略号效果到底行不行' },
|
||||
{ text: '同步更新 AGENTS.md 的布局说明' },
|
||||
{ text: '给悬浮窗预留多窗口上下排布的间距' },
|
||||
{ text: '收拾 demo 控制台,准备演示' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ---------------- 工具 ---------------- */
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function updateCounter() {
|
||||
const done = state.items.filter((t) => t.done).length;
|
||||
counterEl.textContent = `${done}/${state.items.length}`;
|
||||
}
|
||||
|
||||
function ensureWindowVisible() {
|
||||
if (!windowEl.hidden) return;
|
||||
windowEl.hidden = false;
|
||||
windowEl.classList.add('window-enter');
|
||||
windowEl.addEventListener('animationend', () => windowEl.classList.remove('window-enter'), { once: true });
|
||||
}
|
||||
|
||||
/* ---------------- 渲染 ---------------- */
|
||||
|
||||
function renderTodos(items) {
|
||||
listEl.innerHTML = '';
|
||||
items.forEach((item, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'todo-item is-entering' + (item.done ? ' is-done' : '');
|
||||
li.style.animationDelay = `${i * ENTER_STAGGER}ms`;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'todo-dot';
|
||||
const text = document.createElement('span');
|
||||
text.className = 'todo-text';
|
||||
text.textContent = item.text;
|
||||
|
||||
li.append(dot, text);
|
||||
li.addEventListener('animationend', () => {
|
||||
li.classList.remove('is-entering');
|
||||
li.style.animationDelay = '';
|
||||
}, { once: true });
|
||||
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
/* 让当前列表逐行向左移出,返回动画总耗时 */
|
||||
async function playLeaveAnimation() {
|
||||
const rows = [...listEl.children];
|
||||
if (!rows.length) return;
|
||||
rows.forEach((li, i) => {
|
||||
li.style.animationDelay = `${i * LEAVE_STAGGER}ms`;
|
||||
li.classList.add('is-leaving');
|
||||
});
|
||||
await wait(rows.length * LEAVE_STAGGER + LEAVE_DURATION + 20);
|
||||
}
|
||||
|
||||
/* ---------------- 三个核心动作 ---------------- */
|
||||
|
||||
/* 1. 创建 / 整表替换(收到新 todo 列表) */
|
||||
async function replaceTodos(newItems) {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
|
||||
await playLeaveAnimation(); // 旧列表逐行移出
|
||||
state.items = newItems.map((t) => ({ text: t.text, done: !!t.done }));
|
||||
ensureWindowVisible();
|
||||
renderTodos(state.items); // 新列表逐行进入
|
||||
|
||||
state.busy = false;
|
||||
}
|
||||
|
||||
/* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */
|
||||
function scrollRowIntoView(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); // 滚动停止 90ms 后认为到位
|
||||
};
|
||||
listEl.addEventListener('scroll', onScroll);
|
||||
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setTimeout(finish, 800); // 兜底,避免意外卡死
|
||||
});
|
||||
}
|
||||
|
||||
/* 2. 完成下一项(先滚动到可见 → 再划线动画) */
|
||||
async function completeNext() {
|
||||
if (state.busy) return;
|
||||
const idx = state.items.findIndex((t) => !t.done);
|
||||
if (idx === -1) return;
|
||||
|
||||
state.busy = true;
|
||||
const li = listEl.children[idx];
|
||||
if (li) await scrollRowIntoView(li); // 未完全显示的先滚出来
|
||||
|
||||
state.items[idx].done = true;
|
||||
if (li) {
|
||||
li.classList.add('is-done', 'just-done');
|
||||
setTimeout(() => li.classList.remove('just-done'), 400);
|
||||
}
|
||||
updateCounter();
|
||||
state.busy = false;
|
||||
}
|
||||
|
||||
/* 3. 清空(移出后隐藏窗口) */
|
||||
async function clearTodos() {
|
||||
if (state.busy || !state.items.length) return;
|
||||
state.busy = true;
|
||||
|
||||
await playLeaveAnimation();
|
||||
state.items = [];
|
||||
renderTodos(state.items);
|
||||
|
||||
windowEl.classList.add('window-leave');
|
||||
windowEl.addEventListener('animationend', () => {
|
||||
windowEl.classList.remove('window-leave');
|
||||
windowEl.hidden = true;
|
||||
}, { once: true });
|
||||
|
||||
state.busy = false;
|
||||
}
|
||||
|
||||
/* ---------------- 绑定演示按钮 ---------------- */
|
||||
|
||||
document.getElementById('btnCreate').addEventListener('click', () => replaceTodos(SCENARIOS.initial));
|
||||
document.getElementById('btnComplete').addEventListener('click', completeNext);
|
||||
document.getElementById('btnUpdate').addEventListener('click', () => replaceTodos(SCENARIOS.updated));
|
||||
document.getElementById('btnLong').addEventListener('click', () => replaceTodos(SCENARIOS.long));
|
||||
document.getElementById('btnClear').addEventListener('click', clearTodos);
|
||||
|
||||
/* 页面加载后自动演示一次创建 */
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => replaceTodos(SCENARIOS.initial), 500);
|
||||
});
|
||||
194
demo/todo_float_window/file.css
Normal file
194
demo/todo_float_window/file.css
Normal file
@ -0,0 +1,194 @@
|
||||
/* ============================================================
|
||||
文件记录窗口 + 右侧预览侧边栏 + toast 样式
|
||||
复用 style.css 变量;⋯ 按钮复用 runner.css 的 .item-menu-btn
|
||||
============================================================ */
|
||||
|
||||
/* ---------------- 文件条目:[⋯] 左 + 文件名,无状态点 ---------------- */
|
||||
|
||||
.file-item {
|
||||
height: var(--row-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 10px 0 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.file-item:hover { background: var(--hover-wash); }
|
||||
|
||||
/* 当前正在预览的文件保持高亮 */
|
||||
.file-item.is-active { background: var(--surface-wash); }
|
||||
|
||||
.file-item.is-entering {
|
||||
opacity: 0;
|
||||
animation: todo-in 0.34s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------------- 右侧预览侧边栏 ---------------- */
|
||||
|
||||
/* 打开时悬浮窗栈向左让位 */
|
||||
.float-stack { transition: right 0.3s var(--ease-out); }
|
||||
body.preview-open .float-stack { right: calc(20px + 440px + 12px); }
|
||||
|
||||
/* 详情面板同样让位 */
|
||||
.detail-panel { transition: right 0.3s var(--ease-out); }
|
||||
body.preview-open .detail-panel {
|
||||
right: calc(20px + 440px + 12px + var(--window-max-w) * 0.9 + 12px);
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 440px;
|
||||
max-width: calc(100vw - 80px);
|
||||
background: var(--surface-float);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 95;
|
||||
/* 初始藏在屏幕右缘外;用 visibility 配合 transform 做滑入滑出 */
|
||||
transform: translateX(calc(100% + 24px));
|
||||
visibility: hidden;
|
||||
transition: transform 0.3s var(--ease-out), visibility 0s linear 0.3s;
|
||||
}
|
||||
|
||||
.file-preview.is-open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition: transform 0.3s var(--ease-out), visibility 0s;
|
||||
}
|
||||
|
||||
/* 预览头部:文件名 + 目录 + 关闭 */
|
||||
.preview-header {
|
||||
flex: none;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.preview-header svg {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.preview-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11.5px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-close {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.preview-close svg { width: 13px; height: 13px; color: inherit; }
|
||||
|
||||
.preview-close:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); }
|
||||
|
||||
/* 预览内容:等宽 + 行号 */
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 0 12px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.preview-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.code-line:hover { background: var(--hover-wash); }
|
||||
|
||||
.line-no {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
padding-right: 12px;
|
||||
text-align: right;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-text {
|
||||
flex: 1;
|
||||
padding-right: 14px;
|
||||
white-space: pre;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---------------- 文件菜单(普通项,非危险色) ---------------- */
|
||||
|
||||
.file-menu .menu-item { color: var(--text-primary); }
|
||||
.file-menu .menu-item:hover { background: var(--surface-wash); }
|
||||
|
||||
/* ---------------- demo toast 反馈 ---------------- */
|
||||
|
||||
.demo-toast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: #26241f;
|
||||
color: #faf9f5;
|
||||
font-size: 12.5px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 400;
|
||||
}
|
||||
|
||||
.demo-toast.is-show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
355
demo/todo_float_window/file.js
Normal file
355
demo/todo_float_window/file.js
Normal file
@ -0,0 +1,355 @@
|
||||
/* ============================================================
|
||||
文件记录窗口 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);
|
||||
});
|
||||
146
demo/todo_float_window/index.html
Normal file
146
demo/todo_float_window/index.html
Normal file
@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>悬浮窗口栈 Demo · 待办 / 子智能体 / 后台指令</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="runner.css">
|
||||
<link rel="stylesheet" href="file.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 模拟对话显示区域 -->
|
||||
<main class="mock-chat">
|
||||
<div class="mock-bubble mock-bubble--user">把待办、子智能体、后台指令都做成右上角的悬浮窗口</div>
|
||||
<div class="mock-bubble mock-bubble--ai">好的,三个窗口上下排布在对话区右上角。子智能体和后台指令会持续产生输出,点击条目可以在左侧展开详情面板。</div>
|
||||
<div class="mock-bubble mock-bubble--ai">条目左侧的 ⋯ 可以展开菜单(强制关闭);运行中的条目状态点会呼吸,完成后变灰。</div>
|
||||
</main>
|
||||
|
||||
<!-- 右上角悬浮窗口栈 -->
|
||||
<div class="float-stack">
|
||||
|
||||
<!-- 待办事项 -->
|
||||
<section class="float-window todo-window" id="todoWindow" hidden>
|
||||
<header class="float-window__header">
|
||||
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 4.5 3.5 6 6 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<path d="M2 11 3.5 12.5 6 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 11.5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="float-window__title">待办事项</span>
|
||||
<span class="todo-counter" id="todoCounter">0/0</span>
|
||||
</header>
|
||||
<ul class="todo-list" id="todoList"></ul>
|
||||
</section>
|
||||
|
||||
<!-- 子智能体 -->
|
||||
<section class="float-window run-window" id="agentWindow" hidden>
|
||||
<header class="float-window__header">
|
||||
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4"/>
|
||||
<path d="M3 13c.7-2.6 2.7-4 5-4s4.3 1.4 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="float-window__title">子智能体</span>
|
||||
<span class="todo-counter" id="agentCounter">0/0</span>
|
||||
</header>
|
||||
<ul class="run-list" id="agentList"></ul>
|
||||
</section>
|
||||
|
||||
<!-- 后台指令 -->
|
||||
<section class="float-window run-window" id="cmdWindow" hidden>
|
||||
<header class="float-window__header">
|
||||
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2.5 4.5 6 8l-3.5 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 12h5.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="float-window__title">后台指令</span>
|
||||
<span class="todo-counter" id="cmdCounter">0/0</span>
|
||||
</header>
|
||||
<ul class="run-list" id="cmdList"></ul>
|
||||
</section>
|
||||
|
||||
<!-- 文件记录 -->
|
||||
<section class="float-window" id="fileWindow" hidden>
|
||||
<header class="float-window__header">
|
||||
<svg class="float-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 2.5h5.5L12 5v8.5H4V2.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="float-window__title">文件</span>
|
||||
<span class="todo-counter" id="fileCounter">0</span>
|
||||
</header>
|
||||
<ul class="run-list" id="fileList"></ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 详情面板(悬浮窗左侧展开) -->
|
||||
<section class="detail-panel" id="detailPanel" hidden>
|
||||
<header class="detail-header">
|
||||
<span class="detail-status-dot" id="detailDot"></span>
|
||||
<span class="detail-title" id="detailTitle">—</span>
|
||||
<span class="detail-badge" id="detailBadge">运行中</span>
|
||||
<button class="detail-close" id="detailClose" title="关闭">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="detail-body" id="detailBody"></div>
|
||||
</section>
|
||||
|
||||
<!-- 条目 ⋯ 菜单(子智能体/后台指令,全局单例) -->
|
||||
<div class="item-menu" id="itemMenu" hidden>
|
||||
<button class="menu-item menu-item--danger" id="menuKill">强制关闭</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件 ⋯ 菜单(全局单例) -->
|
||||
<div class="item-menu file-menu" id="fileMenu" hidden>
|
||||
<button class="menu-item" id="menuDownload">下载</button>
|
||||
<button class="menu-item" id="menuReveal">在文件管理器中打开</button>
|
||||
<button class="menu-item" id="menuCopyPath">复制路径</button>
|
||||
</div>
|
||||
|
||||
<!-- 文件预览侧边栏(右侧滑入) -->
|
||||
<section class="file-preview" id="filePreview">
|
||||
<header class="preview-header">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 2.5h5.5L12 5v8.5H4V2.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="preview-name" id="previewName">—</span>
|
||||
<span class="preview-path" id="previewPath"></span>
|
||||
<button class="preview-close" id="previewClose" title="关闭">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="preview-body" id="previewBody"></div>
|
||||
</section>
|
||||
|
||||
<!-- demo toast -->
|
||||
<div class="demo-toast" id="demoToast"></div>
|
||||
|
||||
<!-- demo 控制台 -->
|
||||
<div class="demo-panel">
|
||||
<p class="demo-panel__hint">悬浮窗口栈 · 动画演示</p>
|
||||
<div class="demo-panel__buttons">
|
||||
<button class="demo-btn demo-btn--primary" id="btnCreate">创建待办(进入动画)</button>
|
||||
<button class="demo-btn" id="btnComplete">完成下一项(划线动画)</button>
|
||||
<button class="demo-btn" id="btnUpdate">收到新待办(替换动画)</button>
|
||||
<button class="demo-btn" id="btnLong">长列表(8 条 · 滚动)</button>
|
||||
<button class="demo-btn demo-btn--ghost" id="btnClear">清空</button>
|
||||
<div class="demo-divider"></div>
|
||||
<button class="demo-btn demo-btn--primary" id="btnNewAgent">+ 新子智能体</button>
|
||||
<button class="demo-btn demo-btn--primary" id="btnNewCmd">+ 新后台指令</button>
|
||||
<button class="demo-btn demo-btn--primary" id="btnEditFile">✎ 模拟编辑文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="runner.js"></script>
|
||||
<script src="file.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
362
demo/todo_float_window/runner.css
Normal file
362
demo/todo_float_window/runner.css
Normal file
@ -0,0 +1,362 @@
|
||||
/* ============================================================
|
||||
子智能体 / 后台指令窗口 + 详情面板 + 条目菜单 样式
|
||||
复用 style.css 的变量与窗口基础样式
|
||||
============================================================ */
|
||||
|
||||
/* ---------------- 条目列表(与待办同规格:固定 5 行) ---------------- */
|
||||
|
||||
.run-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
height: calc(var(--row-h) * 5);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.run-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
|
||||
/* 条目:[状态点] 左 + 名称 + [⋯] 右,整行可点击 */
|
||||
.run-item {
|
||||
height: var(--row-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px 0 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.run-item:hover { background: var(--hover-wash); }
|
||||
|
||||
/* 当前打开详情的条目保持高亮 */
|
||||
.run-item.is-active { background: var(--surface-wash); }
|
||||
|
||||
/* ⋯ 按钮 */
|
||||
.item-menu-btn {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.item-menu-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.item-menu-btn:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); }
|
||||
|
||||
/* 名称 */
|
||||
.item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 后台指令名称用等宽字体 */
|
||||
.run-item[data-kind="cmd"] .item-name {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 完成后名称变灰 */
|
||||
.run-item.is-done .item-name { color: var(--text-tertiary); }
|
||||
|
||||
/* 状态点:运行中呼吸,完成/终止变灰 */
|
||||
.item-status {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.run-item.is-running .item-status {
|
||||
background: var(--accent);
|
||||
animation: status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.run-item.is-done .item-status { background: var(--text-tertiary); }
|
||||
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* 进入动画复用待办的 keyframes(todo-in 定义在 style.css,全局可用) */
|
||||
.run-item.is-entering {
|
||||
opacity: 0;
|
||||
animation: todo-in 0.34s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* ---------------- 详情面板 ---------------- */
|
||||
|
||||
/* display:flex 会覆盖 hidden 属性的 display:none,必须显式补回 */
|
||||
.detail-panel[hidden] { display: none; }
|
||||
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: calc(20px + var(--window-max-w) * 0.9 + 12px); /* 悬浮窗左侧 */
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 360px);
|
||||
height: 460px;
|
||||
max-height: calc(100vh - 40px);
|
||||
background: var(--surface-float);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.detail-panel.panel-enter { animation: panel-in 0.24s var(--ease-out); }
|
||||
@keyframes panel-in {
|
||||
from { opacity: 0; transform: translateX(12px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.detail-panel.panel-leave { animation: panel-out 0.18s ease-in forwards; }
|
||||
@keyframes panel-out {
|
||||
to { opacity: 0; transform: translateX(8px); }
|
||||
}
|
||||
|
||||
/* 详情头部 */
|
||||
.detail-header {
|
||||
flex: none;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detail-status-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.detail-status-dot.is-running {
|
||||
background: var(--accent);
|
||||
animation: status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-badge {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 99px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-badge.is-done { background: var(--surface-wash); color: var(--text-tertiary); }
|
||||
|
||||
.detail-close {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.detail-close svg { width: 13px; height: 13px; }
|
||||
|
||||
.detail-close:hover { background: rgba(38, 36, 31, 0.07); color: var(--text-secondary); }
|
||||
|
||||
/* 详情内容区 */
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0 10px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.detail-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
|
||||
.detail-body.body-fade { animation: body-fade-in 0.18s ease-out; }
|
||||
@keyframes body-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---------------- feed 行(工具 + 输出,扁平行式,无嵌套卡片) ---------------- */
|
||||
|
||||
.feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
min-height: 28px;
|
||||
font-size: 12.5px;
|
||||
animation: feed-in 0.25s var(--ease-out);
|
||||
}
|
||||
|
||||
/* 新进度出现动画:淡入 + 轻微上浮 */
|
||||
@keyframes feed-in {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 智能体文本输出 */
|
||||
.feed-row.feed-text {
|
||||
align-items: flex-start;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 工具调用行 */
|
||||
.feed-tool .tool-name {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.feed-tool .tool-param {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feed-tool .tool-result {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 工具运行中 spinner / 完成 ✓ */
|
||||
.tool-spinner {
|
||||
flex: none;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
animation: tool-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tool-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tool-done {
|
||||
flex: none;
|
||||
width: 11px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 终端输出行(后台指令) */
|
||||
.feed-row.feed-term {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.feed-row.feed-term.is-command {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 终止提示行 */
|
||||
.feed-row.feed-killed { color: #c0453a; }
|
||||
|
||||
/* ---------------- 条目 ⋯ 菜单 ---------------- */
|
||||
|
||||
.item-menu {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
min-width: 116px;
|
||||
padding: 4px;
|
||||
background: var(--surface-float);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.item-menu.menu-enter { animation: menu-in 0.16s ease-out; }
|
||||
@keyframes menu-in {
|
||||
from { opacity: 0; transform: translateY(-3px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-item--danger { color: #c0453a; }
|
||||
.menu-item--danger:hover:not(:disabled) { background: rgba(192, 69, 58, 0.08); }
|
||||
.menu-item--danger:disabled { color: var(--text-tertiary); cursor: default; }
|
||||
|
||||
/* ---------------- demo 控制台补充 ---------------- */
|
||||
|
||||
.demo-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 5px 0;
|
||||
}
|
||||
421
demo/todo_float_window/runner.js
Normal file
421
demo/todo_float_window/runner.js
Normal file
@ -0,0 +1,421 @@
|
||||
/* ============================================================
|
||||
子智能体 / 后台指令窗口 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 而非 animationend:feed 行动画的 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);
|
||||
});
|
||||
276
demo/todo_float_window/style.css
Normal file
276
demo/todo_float_window/style.css
Normal file
@ -0,0 +1,276 @@
|
||||
/* ============================================================
|
||||
待办事项悬浮窗 Demo 样式
|
||||
配色暂用经典主题暖色系,正式接入时替换为语义 token
|
||||
============================================================ */
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--surface-base: #faf9f5;
|
||||
--surface-float: #ffffff;
|
||||
--surface-wash: #f5f0e8;
|
||||
--border-subtle: #ece5d8;
|
||||
--border-strong: #e2d9c8;
|
||||
--text-primary: #26241f;
|
||||
--text-secondary: #6f6a5e;
|
||||
--text-tertiary: #a9a294;
|
||||
--accent: #cc785c;
|
||||
--accent-hover: #c06a4e;
|
||||
--hover-wash: rgba(38, 36, 31, 0.04);
|
||||
--shadow-float: 0 1px 2px rgba(38, 36, 31, 0.05), 0 8px 24px rgba(38, 36, 31, 0.10);
|
||||
--row-h: 36px;
|
||||
--window-max-w: 300px;
|
||||
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
--ease-strike: cubic-bezier(0.55, 0.06, 0.35, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---------------- 模拟对话区 ---------------- */
|
||||
|
||||
.mock-chat {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 64px 24px 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mock-bubble {
|
||||
max-width: 78%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mock-bubble--user { align-self: flex-end; background: #efe9de; }
|
||||
.mock-bubble--ai { align-self: flex-start; background: #ffffff; border: 1px solid var(--border-subtle); }
|
||||
|
||||
/* ---------------- 悬浮窗口栈 ---------------- */
|
||||
|
||||
.float-stack {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end; /* 多个窗口右对齐,上下排布 */
|
||||
gap: 12px;
|
||||
/* 整体超出屏幕高度时可滚动,滚动条隐藏 */
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
/* padding + 负 margin:扩大滚动盒以容纳窗口阴影,避免被 overflow 裁剪 */
|
||||
padding: 24px;
|
||||
margin: -24px;
|
||||
}
|
||||
|
||||
.float-stack::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
|
||||
/* 窗口:圆角矩形 + 固定宽度(最大宽度的 90%) + 不透明背景 + 克制阴影 */
|
||||
.float-window {
|
||||
flex: none; /* 禁止被栈 flex 压缩,保持固定高度 */
|
||||
width: calc(var(--window-max-w) * 0.9); /* 300 * 0.9 = 270px */
|
||||
background: var(--surface-float);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 窗口自身出现 / 消失(高度变化由行展开动画带动,这里只做淡入) */
|
||||
.float-window.window-enter { animation: window-in 0.3s ease-out; }
|
||||
@keyframes window-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.float-window.window-leave { animation: window-out 0.22s ease-in forwards; }
|
||||
@keyframes window-out {
|
||||
to { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||
}
|
||||
|
||||
/* 窗口标题栏 */
|
||||
.float-window__header {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.float-window__icon { width: 15px; height: 15px; color: var(--accent); flex: none; }
|
||||
|
||||
.float-window__title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.todo-counter {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------------- 待办列表 ---------------- */
|
||||
|
||||
/* 窗口高度固定为 5 行,不足留空、超出内部滚动;滚动条完全隐藏。
|
||||
无上下 padding:首行 hover 紧贴标题栏分隔线,末行紧贴窗口底边 */
|
||||
.todo-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
height: calc(var(--row-h) * 5);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* 旧 Edge */
|
||||
}
|
||||
|
||||
.todo-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
|
||||
/* 每一行直接铺在窗口上:无背景、无边框、无嵌套卡片 */
|
||||
/* 待办行不可点击,无 hover 效果 */
|
||||
.todo-item {
|
||||
height: var(--row-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden; /* 配合移出动画的高度收拢 */
|
||||
}
|
||||
|
||||
/* 状态点 */
|
||||
.todo-dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-tertiary);
|
||||
background: transparent;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.todo-item.is-done .todo-dot { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* 任务文字:固定一行,超长省略 */
|
||||
.todo-text {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线 */
|
||||
}
|
||||
|
||||
/* 删除线(默认收起) */
|
||||
.todo-text::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
top: 50%;
|
||||
height: 1.5px;
|
||||
margin-top: -0.75px;
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 1px;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 完成态(静态,用于整表渲染时已完成行) */
|
||||
.todo-item.is-done .todo-text { color: var(--text-tertiary); }
|
||||
.todo-item.is-done .todo-text::after { transform: scaleX(1); }
|
||||
|
||||
/* 完成动作瞬间:横线从左往右划过 + 状态点弹跳 */
|
||||
.todo-item.just-done .todo-text::after {
|
||||
animation: strike-in 0.28s var(--ease-strike) forwards;
|
||||
}
|
||||
@keyframes strike-in {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
.todo-item.just-done .todo-dot { animation: dot-pop 0.32s var(--ease-out); }
|
||||
@keyframes dot-pop {
|
||||
0% { transform: scale(1); }
|
||||
45% { transform: scale(1.6); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* 进入动画:从左向右位移 + 淡入(stagger 由 JS 写 animation-delay) */
|
||||
.todo-item.is-entering {
|
||||
opacity: 0;
|
||||
animation: todo-in 0.34s var(--ease-out) forwards;
|
||||
}
|
||||
@keyframes todo-in {
|
||||
from { opacity: 0; transform: translateX(-14px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 移出动画:从右向左位移 + 淡出,随后高度收拢让下方行平滑上移 */
|
||||
.todo-item.is-leaving {
|
||||
animation: todo-out 0.26s ease-in forwards;
|
||||
}
|
||||
@keyframes todo-out {
|
||||
0% { opacity: 1; transform: translateX(0); height: var(--row-h); }
|
||||
55% { opacity: 0; transform: translateX(-14px); height: var(--row-h); }
|
||||
100% { opacity: 0; transform: translateX(-14px); height: 0; }
|
||||
}
|
||||
|
||||
/* ---------------- demo 控制台 ---------------- */
|
||||
|
||||
.demo-panel {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.demo-panel__hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-panel__buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-float);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.demo-btn:hover { background: var(--surface-wash); }
|
||||
|
||||
.demo-btn--primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
|
||||
.demo-btn--primary:hover { background: var(--accent-hover); }
|
||||
|
||||
.demo-btn--ghost { border-style: dashed; color: var(--text-tertiary); }
|
||||
132
docs/float_window_design.md
Normal file
132
docs/float_window_design.md
Normal file
@ -0,0 +1,132 @@
|
||||
# 对话区快捷窗口栈 · 设计定稿(2026-07-21)
|
||||
|
||||
> 状态:demo 已验收,正式接入 `static/src` 中。
|
||||
> Demo 位置:`demo/todo_float_window/`(目录名为历史遗留,内含全部 4 个窗口;浏览器直接打开 `index.html`)。
|
||||
> 本文档是唯一设计依据;压缩对话后凭本文档 + demo 继续。
|
||||
|
||||
## 0.1 正式接入变更(2026-07-21 用户补充,覆盖 demo 的悬浮定位)
|
||||
|
||||
1. **命名**:该功能正式名称为「**快捷窗口**」(quick dock / quick window)。
|
||||
2. **布局:占位而非悬浮**:快捷窗口**不是** `position: fixed` 悬浮,而是在**对话区域右侧空白区域占位显示**的一列,常驻时会**把对话区域向左挤压**(与 git/终端 right-panels 同为 `chat-container` 的 flex 兄弟节点,位于 `</main>` 之后、right-panels 之前)。有内容时才显示该列。
|
||||
3. **设置项**:个人空间(PersonalizationDrawer)新增「**隐藏快捷窗口**」开关,**默认关闭**(即默认显示)。字段 `hide_quick_dock: boolean` 存 personalization form。
|
||||
4. 详情面板(子智能体/后台指令)保持 fixed 悬浮,从快捷窗口列左侧滑出;文件预览侧边栏在快捷窗口列右侧(git/终端面板左侧)占位/滑出。
|
||||
5. 移动端(isMobileViewport)第一版先隐藏。
|
||||
|
||||
## 0. Demo 文件地图
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `index.html` | 页面结构:模拟对话区 + 悬浮栈(4 窗口)+ 详情面板 + 预览侧边栏 + 菜单 + toast |
|
||||
| `style.css` | 变量(配色/尺寸/缓动)+ 窗口基础 + 待办样式与全部 keyframes |
|
||||
| `app.js` | 待办逻辑(创建/完成/替换/滚动划线) |
|
||||
| `runner.css` / `runner.js` | 子智能体 + 后台指令窗口、左侧详情面板、⋯ 菜单(强制关闭) |
|
||||
| `file.css` / `file.js` | 文件记录窗口、右侧预览侧边栏、⋯ 菜单(下载/打开/复制路径)、toast |
|
||||
|
||||
## 1. 总体布局
|
||||
|
||||
- 位置:对话区域右侧**占位列**(非悬浮):`chat-container` 的 flex 兄弟节点,宽 294px(270 + 左右 padding 12),`flex: none`,整列上下排布。对话区被自然向左挤压。
|
||||
- 窗口顺序(从上到下,固定):**待办事项 → 子智能体 → 后台指令 → 文件**。
|
||||
- 窗口上下排布,右对齐,`gap: 12px`。
|
||||
- **整列可滚动**:列高 100%,`overflow-y: auto`,滚动条隐藏。
|
||||
- 防阴影被 overflow 裁剪:栈 `padding: 24px; margin: -24px`。
|
||||
- 栈滚动时关闭所有打开的 ⋯ 菜单(菜单 fixed 定位不随栈滚动)。
|
||||
- 各窗口初始隐藏,有内容时淡入出现(fade + translateY(-6px),0.3s)。
|
||||
|
||||
## 2. 窗口通用规格
|
||||
|
||||
- **宽度固定 270px**(= 最大宽度 300px × 90%,变量 `--window-max-w` 联动)。
|
||||
- **列表区高度固定 5 行**(行高 36px → 180px),不足留空,超出内部滚动,滚动条隐藏。
|
||||
- 窗口 `flex: none`(防止被栈 flex 压缩变矮,已踩坑)。
|
||||
- 圆角 12px;背景不透明(实体面板禁半透明);中性阴影(无发光);1px 细边框。
|
||||
- 标题栏 38px:SVG 图标(accent 色)+ 标题(12.5px 次要色)+ 右侧计数(11.5px 三级色)。
|
||||
- **列表无上下 padding**:首行 hover 紧贴标题栏分隔线,末行紧贴窗口底边。
|
||||
- 行:固定 36px 高,直接铺在窗口上(无嵌套卡片/背景/边框),超长文字省略号。
|
||||
- 正式接入时配色全部换成三主题语义 token(demo 为经典主题暖色写死)。
|
||||
|
||||
## 3. 各窗口设计
|
||||
|
||||
### 3.1 待办事项
|
||||
|
||||
- 行结构:`[状态点] 任务文字`;**无 hover、不可点击**。
|
||||
- 计数:`done/total`。
|
||||
- 动画:
|
||||
- **创建**:逐行从左向右(位移 14px + 淡入,0.34s,stagger 60ms)。
|
||||
- **完成**:横线从左往右划过文字(260ms,`strike-in` scaleX 0→1,origin left)→ 文字延迟 120ms 变灰 + 状态点变色弹跳(`dot-pop`)。静态完成态(整表渲染时)不播划线,直接呈现。
|
||||
- **整表替换**(收到新 todo):旧行从上到下逐行从右向左移出(0.26s,stagger 45ms,**带高度收拢**让下方行平滑上移)→ 新行逐行进入。
|
||||
- **区域外条目**:要完成的行未完全显示时,先 `scrollIntoView({behavior:'smooth', block:'nearest'})` 滚出,滚动停稳 90ms 后再播划线(800ms 兜底)。
|
||||
|
||||
### 3.2 子智能体 / 3.3 后台指令(两者同构,仅详情内容不同)
|
||||
|
||||
- 行结构:`[●状态点] 名称 [⋯]`;**有 hover,整行可点击**;后台指令名称用等宽字体。
|
||||
- 状态点:运行中 = accent 色呼吸(`status-pulse` 1.6s);完成/终止 = 灰,名称同步变灰。
|
||||
- 计数:`running/total`。
|
||||
- 新条目:进入动画同待办;**区域外先滚动露出再播进入动画**(先隐身插入,滚动到位后播)。
|
||||
- **无删除**(条目永久保留)。
|
||||
- ⋯ 菜单:仅「强制关闭」(警示红色,运行完/已终止置灰);菜单与按钮**右对齐**向下展开;两菜单(本菜单与文件菜单)互斥;Esc / 点空白关闭。
|
||||
- **点击行 → 左侧展开详情面板**(480×460,`right: calc(20px + 270px + 12px)`,滑入 0.24s):
|
||||
- 子智能体:工具调用 + 文本输出流,**扁平行式无嵌套卡片**;工具行:运行中 spinner(旋转)→ 完成变 ✓ + 右侧落定结果;文本行次要色。
|
||||
- 后台指令:等宽终端行,首行 `$ 命令` 加粗。
|
||||
- 新进度出现动画:淡入 + translateY(5px) 上浮(0.25s)。
|
||||
- **自动滚动**:距底 <80px 时新内容平滑滚到底;用户上翻则不打断。
|
||||
- 再点同一行 / ✕ / Esc 关闭;点其他行切换(内容区快速淡入)。
|
||||
- ⚠️ **实际 API 颗粒度提醒**:子智能体进度 API 没有细化的"运行中流式状态",**每个步骤完成后才推送一次进度**。demo 里的流式效果仅为演示,正式接入时的显示颗粒度届时再定。
|
||||
|
||||
### 3.4 文件
|
||||
|
||||
- 行结构:`[⋯] 文件名`;**无状态点**;有 hover,整行可点击。
|
||||
- 计数:文件总数。
|
||||
- 显示逻辑(正式版):**文件被编辑/创建过 + 文件仍存在**才显示;同一文件按 path 去重只出现一次。
|
||||
- ⋯ 菜单(与按钮**左对齐**向下展开):
|
||||
1. **下载**
|
||||
2. **在文件管理器中打开**——复用 git 状态侧边栏已有逻辑,用默认应用打开;**docker 模式下无此选项**
|
||||
3. **复制路径**——工作区内相对路径
|
||||
- **点击行 → 右侧滑出预览侧边栏**(非左侧详情面板):
|
||||
- 440px 宽、全高(top/bottom 20px),`translateX(100%+24px)` ↔ `0`,0.3s。
|
||||
- 打开时**悬浮窗栈与详情面板同步左移让位**(栈 `right: 20+440+12`,均带 0.3s transition)。
|
||||
- 头部:文件图标 + 文件名(等宽加粗)+ 目录(灰小字)+ 关闭按钮。
|
||||
- 内容:等宽 12px + 行号(44px 灰),行 hover 有底色,滚动条隐藏。
|
||||
- 再点同一文件 / ✕ / Esc 关闭;点其他文件直接切换。
|
||||
- 仅支持特定种类 UTF-8 文本文件(正式版按扩展名白名单)。
|
||||
|
||||
## 4. 后端配合点(正式接入)
|
||||
|
||||
1. **文件记录**:每次 `write_file` / `edit_file` 成功后,把文件**相对路径**(含时间戳、操作类型)写入**对话文件(对话 JSON)**;同一 path 更新而非追加重复项。
|
||||
2. **前端读取**:前端从对话数据中读取文件记录列表渲染文件窗口。
|
||||
3. **文件内容**:预览用**现有文件内容 API**(用户确认已有,无需新做)。
|
||||
4. 待办 / 子智能体 / 后台指令:接各自现有数据流与事件推送。
|
||||
|
||||
## 5. 已踩过的实现坑(接入时避开)
|
||||
|
||||
1. `display: flex` 会覆盖 `hidden` 属性的 `display: none` → 必须补 `.panel[hidden] { display: none; }`。
|
||||
2. flex 容器会压缩子元素(窗口变矮)→ 窗口加 `flex: none`。
|
||||
3. `animationend` 会冒泡(feed 行 → 详情面板),用 `{once:true}` 监听会被提前消费 → 时序控制改用 `setTimeout`。
|
||||
4. `overflow` 裁剪窗口阴影 → 滚动容器 `padding + 负 margin` 扩大滚动盒。
|
||||
5. 菜单 fixed 定位不随栈滚动 → 栈滚动时主动关闭菜单。
|
||||
6. 两个 ⋯ 菜单 + Esc 多层浮层:菜单互斥;Esc 按 菜单 → 详情面板 → 预览栏 顺序逐个关(用 `e.preventDefault()` + `e.defaultPrevented` 串联)。
|
||||
|
||||
## 6. 正式接入任务清单(2026-07-21 已全部完成)
|
||||
|
||||
- [x] 后端:编辑文件路径记录写入对话 JSON(write/edit 钩子)+ 对话数据带出该列表
|
||||
- [x] 前端:`static/src` 快捷窗口占位列容器(非悬浮,挤压对话区)
|
||||
- [x] 前端:4 个窗口组件 + 详情面板 + 文件预览侧边栏(配色走语义 token 三主题)
|
||||
- [x] 前端:接入待办 / 子智能体 / 后台指令 / 文件记录的真实数据与事件推送
|
||||
- [x] 前端:文件预览内容接现有文件内容 API(扩展名白名单)
|
||||
- [x] 动画参数按 demo 定稿迁移(位移 14px、stagger 60/45ms、划线 260ms 等)
|
||||
|
||||
### 接入实现位置(2026-07-21)
|
||||
|
||||
**后端**
|
||||
- `core/main_terminal_parts/tools_execution.py`:`_record_edited_file` / `_remove_edited_file` / `_rename_edited_file` + `_mutate_edited_files`(写入对话 `metadata.edited_files`,同 path 去重;广播 `edited_files_updated`)
|
||||
- `server/conversation.py`:`GET /api/conversations/<id>/messages` 返回 `edited_files`(过滤不存在文件)
|
||||
- `server/app_legacy.py`:`terminal_broadcast` 白名单加 `edited_files_updated`
|
||||
- `modules/personalization_manager.py`:`hide_quick_dock` 字段(默认 False)
|
||||
|
||||
**前端**
|
||||
- `static/src/components/chat/quickdock/`:`QuickDock.vue`(占位列容器+菜单+Esc 分层+轮询)、`TodoWindow.vue`、`RunnerWindow.vue`(子智能体/后台指令通用)、`RunnerDetailPanel.vue`、`FileWindow.vue`、`FilePreviewPanel.vue`、`quickdock.css`(全部 keyframes 与语义 token 配色)
|
||||
- `static/src/stores/quickDock.ts`:editedFiles / detail / previewPath / menu 状态
|
||||
- `static/src/composables/useLegacySocket.ts`:监听 `edited_files_updated`(按 conversation_id 过滤)
|
||||
- `static/src/app/methods/history.ts`:messages 加载后回填 editedFiles
|
||||
- `static/src/App.vue`:`</main>` 后挂载 `QuickDock` + `FilePreviewPanel`(移动端与 hide_quick_dock 时不渲染)
|
||||
- `static/src/components/personalization/PersonalizationDrawer.vue`:「隐藏快捷窗口」设置行(默认隐藏工作区之后)
|
||||
|
||||
**验证状态**:py_compile ✓ / 6 项后端冒烟 ✓ / tsc+stylelint+vite build ✓ / 新文件 eslint 0 问题,存量文件 lint 与基线持平。8091 服务重启后的实际运行验证待手动进行。
|
||||
@ -105,6 +105,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
||||
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
|
||||
"allow_root_file_creation": False, # 允许在根目录创建文件开关
|
||||
"default_hide_workspace": False, # 默认隐藏工作区
|
||||
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
|
||||
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
|
||||
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
|
||||
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
|
||||
@ -528,6 +529,12 @@ def sanitize_personalization_payload(
|
||||
else:
|
||||
base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False))
|
||||
|
||||
# 隐藏快捷窗口
|
||||
if "hide_quick_dock" in data:
|
||||
base["hide_quick_dock"] = bool(data.get("hide_quick_dock"))
|
||||
else:
|
||||
base["hide_quick_dock"] = bool(base.get("hide_quick_dock", False))
|
||||
|
||||
# 侧边栏按工作区/项目分组显示对话
|
||||
if "group_sidebar_by_workspace" in data:
|
||||
base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace"))
|
||||
|
||||
@ -1196,7 +1196,7 @@ def terminal_broadcast(event_type, data):
|
||||
"""广播终端事件到所有订阅者"""
|
||||
try:
|
||||
# 对于全局事件,发送给所有连接的客户端
|
||||
if event_type in ('token_update', 'todo_updated'):
|
||||
if event_type in ('token_update', 'todo_updated', 'edited_files_updated'):
|
||||
socketio.emit(event_type, data) # 全局广播,不限制房间
|
||||
debug_log(f"全局广播{event_type}: {data}")
|
||||
else:
|
||||
|
||||
@ -1018,13 +1018,36 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace:
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit:
|
||||
messages = messages[-limit:] # 获取最后N条消息
|
||||
|
||||
|
||||
# 快捷窗口:本次对话编辑/创建过的文件记录(过滤不存在的文件)
|
||||
edited_files: list = []
|
||||
raw_edited = (conversation_data.get("metadata") or {}).get("edited_files")
|
||||
if isinstance(raw_edited, list):
|
||||
file_manager = getattr(terminal, "file_manager", None)
|
||||
for item in raw_edited:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rel_path = str(item.get("path") or "").strip()
|
||||
if not rel_path:
|
||||
continue
|
||||
try:
|
||||
valid, _err, full_path = file_manager._validate_path(rel_path) if file_manager else (False, None, None)
|
||||
except Exception:
|
||||
valid, full_path = False, None
|
||||
if valid and full_path is not None and full_path.exists() and full_path.is_file():
|
||||
edited_files.append({
|
||||
"path": rel_path,
|
||||
"op": item.get("op") or "edit",
|
||||
"ts": item.get("ts") or "",
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"messages": messages,
|
||||
"total_count": len(conversation_data.get("messages", []))
|
||||
"total_count": len(conversation_data.get("messages", [])),
|
||||
"edited_files": edited_files
|
||||
}
|
||||
})
|
||||
else:
|
||||
|
||||
@ -205,6 +205,28 @@ def bootstrap_conversation(conversation_id: str):
|
||||
"is_truly_active": is_main_running or any(bg_status.values()),
|
||||
}
|
||||
|
||||
# 快捷窗口:本次对话编辑/创建过的文件记录(过滤不存在的文件)
|
||||
edited_files: list = []
|
||||
raw_edited = raw_meta.get("edited_files")
|
||||
if isinstance(raw_edited, list):
|
||||
file_manager = getattr(ws_terminal, "file_manager", None)
|
||||
for item in raw_edited:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rel_path = str(item.get("path") or "").strip()
|
||||
if not rel_path:
|
||||
continue
|
||||
try:
|
||||
valid, _err, full_path = file_manager._validate_path(rel_path) if file_manager else (False, None, None)
|
||||
except Exception:
|
||||
valid, full_path = False, None
|
||||
if valid and full_path is not None and full_path.exists() and full_path.is_file():
|
||||
edited_files.append({
|
||||
"path": rel_path,
|
||||
"op": item.get("op") or "edit",
|
||||
"ts": item.get("ts") or "",
|
||||
})
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"conversation_id": normalized_id,
|
||||
"meta": {
|
||||
@ -221,6 +243,7 @@ def bootstrap_conversation(conversation_id: str):
|
||||
},
|
||||
"messages": messages,
|
||||
"running": running,
|
||||
"edited_files": edited_files,
|
||||
}
|
||||
|
||||
if is_main_running:
|
||||
|
||||
@ -385,6 +385,13 @@
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<QuickDock
|
||||
v-if="!isMobileViewport && !personalizationStore?.form?.hide_quick_dock"
|
||||
:host-mode="versioningHostMode"
|
||||
/>
|
||||
<FilePreviewPanel
|
||||
v-if="!isMobileViewport && !personalizationStore?.form?.hide_quick_dock"
|
||||
/>
|
||||
<div
|
||||
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
||||
class="resize-handle resize-handle--right-panels"
|
||||
@ -888,6 +895,8 @@
|
||||
import { defineAsyncComponent, onMounted, ref } from 'vue';
|
||||
import appOptions from './app';
|
||||
import VideoPicker from './components/overlay/VideoPicker.vue';
|
||||
import QuickDock from './components/chat/quickdock/QuickDock.vue';
|
||||
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
|
||||
import { useTutorialStore } from './stores/tutorial';
|
||||
import { usePersonalizationStore } from './stores/personalization';
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog, traceLog } from '../common';
|
||||
import { useQuickDockStore } from '../../../stores/quickDock';
|
||||
|
||||
/**
|
||||
* 统一加载协议(方案 B):进入对话的单一入口。
|
||||
@ -110,6 +111,15 @@ export const bootstrapMethods = {
|
||||
this.lastHistoryLoadedConversationId = normalizedId;
|
||||
this.refreshBlankHeroState();
|
||||
|
||||
// 4.5 快捷窗口:回填本次对话编辑/创建文件记录。
|
||||
// 必须先等 currentConversationId 的级联 watcher(app watcher 清空 → store 同步 →
|
||||
// QuickDock 内清空)全部执行完,否则回填数据会被 watcher 覆盖。
|
||||
// setTimeout(0) 走宏任务,比 $nextTick 的 microtask 更保险。
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
useQuickDockStore().setEditedFiles(
|
||||
Array.isArray(data.edited_files) ? data.edited_files : []
|
||||
);
|
||||
|
||||
// 5. 运行中任务快速恢复(任务/事件/判据已由 bootstrap 聚合,
|
||||
// 免去 GET /api/tasks + 历史死等 + GET /api/tasks/{id} 三次请求)
|
||||
const running = data.running || {};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog } from './common';
|
||||
import { useQuickDockStore } from '../../stores/quickDock';
|
||||
const jsonDebug = (...args: any[]) => {
|
||||
console.log('[JSONDEBUG]', ...args);
|
||||
};
|
||||
@ -157,6 +158,10 @@ export const historyMethods = {
|
||||
const rawMessages = Array.isArray(messagesData?.data?.messages)
|
||||
? messagesData.data.messages
|
||||
: [];
|
||||
// 快捷窗口:回填本次对话编辑/创建文件记录(后端已过滤不存在文件)
|
||||
useQuickDockStore().setEditedFiles(
|
||||
Array.isArray(messagesData?.data?.edited_files) ? messagesData.data.edited_files : []
|
||||
);
|
||||
const lastAssistantRaw = [...rawMessages]
|
||||
.reverse()
|
||||
.find((m: any) => m?.role === 'assistant');
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog, goalModeDebugLog } from '../common';
|
||||
import { useTaskStore } from '../../../stores/task';
|
||||
import { useQuickDockStore } from '../../../stores/quickDock';
|
||||
import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility';
|
||||
import {
|
||||
debugNotifyLog,
|
||||
@ -293,6 +294,24 @@ export const lifecycleMethods = {
|
||||
case 'conversation_resolved':
|
||||
this.handleConversationResolved(eventData, eventIdx);
|
||||
break;
|
||||
|
||||
case 'todo_updated':
|
||||
// 任务期广播的待办快照(创建/勾选/清空都会触发)。live=true 让窗口播动画。
|
||||
// 快照直接可用;缺失时退化为 REST 拉取(fetchTodoList 带 conversation_id)。
|
||||
if (eventData && 'todo_list' in eventData) {
|
||||
this.fileSetTodoList(eventData.todo_list || null, true);
|
||||
} else if (typeof this.scheduleTodoListRefresh === 'function') {
|
||||
this.scheduleTodoListRefresh(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'edited_files_updated':
|
||||
// 快捷窗口文件记录:edit/write/delete/rename 后广播,payload 携带最新列表
|
||||
useQuickDockStore().setEditedFiles(
|
||||
Array.isArray(eventData?.edited_files) ? eventData.edited_files : [],
|
||||
true
|
||||
);
|
||||
break;
|
||||
case 'compression_state':
|
||||
// 重放历史事件时不处理管理类事件,避免触发副作用(如 toast 闪烁 / 状态回退)。
|
||||
if (this._rebuildingFromScratch) break;
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog, traceLog } from './methods/common';
|
||||
import { useConversationStore } from '../stores/conversation';
|
||||
import { useQuickDockStore } from '../stores/quickDock';
|
||||
import { useFileStore } from '../stores/file';
|
||||
|
||||
export const watchers = {
|
||||
multiAgentMode(newValue) {
|
||||
useConversationStore().$patch({ multiAgentMode: !!newValue });
|
||||
},
|
||||
currentConversationId(newId, oldId) {
|
||||
if (newId === oldId) {
|
||||
return;
|
||||
}
|
||||
// 同步到 conversationStore(QuickDock 等组件监听 store 侧的 id)
|
||||
useConversationStore().setCurrentConversationId(newId || null);
|
||||
const quickDock = useQuickDockStore();
|
||||
// 关闭详情/预览/菜单等瞬态(必须立即)
|
||||
quickDock.resetTransient();
|
||||
if (!newId) {
|
||||
// /new 等无对话场景:不会有 bootstrap 回填,立即清空让列折叠
|
||||
quickDock.setEditedFiles([]);
|
||||
useFileStore().setTodoList(null);
|
||||
}
|
||||
// 切到另一对话:不清列表 —— 旧内容短暂保留,由 bootstrap/fetch 回填自然覆盖;
|
||||
// 新对话无内容时窗口在回填后才消失,避免列「先收起再展开」的闪烁。
|
||||
},
|
||||
inputMessage() {
|
||||
this.autoResizeInput();
|
||||
if (typeof this.scheduleComposerDraftPersist === 'function') {
|
||||
|
||||
175
static/src/components/chat/quickdock/FilePreviewPanel.vue
Normal file
175
static/src/components/chat/quickdock/FilePreviewPanel.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<transition name="qd-preview-slide">
|
||||
<aside v-if="previewPath" class="qd-preview">
|
||||
<section class="qd-preview__panel">
|
||||
<header class="qd-preview__header">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 2.5V5H12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
|
||||
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
|
||||
<button class="qd-preview__close" title="关闭" @click="close">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="qd-preview__body">
|
||||
<div v-if="loading" class="qd-preview__loading">加载中…</div>
|
||||
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
|
||||
<template v-else>
|
||||
<div v-for="(line, i) in lines" :key="i" class="qd-code-line">
|
||||
<span class="line-no">{{ i + 1 }}</span>
|
||||
<span class="line-text">{{ line }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
|
||||
/**
|
||||
* 文件预览侧边栏(占位列,位于快捷窗口列右侧、git/终端面板左侧)
|
||||
* 内容走现有 /api/file/content;仅支持白名单内的 UTF-8 文本类文件。
|
||||
*/
|
||||
|
||||
const PREVIEWABLE_EXTS = new Set([
|
||||
'.txt',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.csv',
|
||||
'.json',
|
||||
'.json5',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.vue',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.go',
|
||||
'.rs',
|
||||
'.java',
|
||||
'.c',
|
||||
'.h',
|
||||
'.cpp',
|
||||
'.hpp',
|
||||
'.cs',
|
||||
'.php',
|
||||
'.swift',
|
||||
'.kt',
|
||||
'.sh',
|
||||
'.bash',
|
||||
'.zsh',
|
||||
'.ps1',
|
||||
'.bat',
|
||||
'.cmd',
|
||||
'.html',
|
||||
'.htm',
|
||||
'.css',
|
||||
'.scss',
|
||||
'.sass',
|
||||
'.less',
|
||||
'.xml',
|
||||
'.svg',
|
||||
'.toml',
|
||||
'.ini',
|
||||
'.cfg',
|
||||
'.conf',
|
||||
'.log',
|
||||
'.sql'
|
||||
]);
|
||||
|
||||
const quickDock = useQuickDockStore();
|
||||
const { previewPath } = storeToRefs(quickDock);
|
||||
|
||||
const lines = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const fileName = computed(() => {
|
||||
const p = previewPath.value || '';
|
||||
return p.split('/').pop() || p;
|
||||
});
|
||||
|
||||
const dirName = computed(() => {
|
||||
const p = previewPath.value || '';
|
||||
const parts = p.split('/');
|
||||
return parts.length > 1 ? `${parts.slice(0, -1).join('/')}/` : '';
|
||||
});
|
||||
|
||||
function extOf(path: string): string {
|
||||
const name = path.split('/').pop() || '';
|
||||
const idx = name.lastIndexOf('.');
|
||||
return idx >= 0 ? name.slice(idx).toLowerCase() : '';
|
||||
}
|
||||
|
||||
let loadSeq = 0;
|
||||
|
||||
watch(
|
||||
previewPath,
|
||||
async (path) => {
|
||||
const mySeq = ++loadSeq;
|
||||
lines.value = [];
|
||||
error.value = '';
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
if (!PREVIEWABLE_EXTS.has(extOf(path))) {
|
||||
error.value = '该文件类型不支持预览';
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`);
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
throw new Error(payload?.error || `加载失败(HTTP ${resp.status})`);
|
||||
}
|
||||
const text = await resp.text();
|
||||
if (mySeq !== loadSeq) {
|
||||
return;
|
||||
}
|
||||
lines.value = text.split('\n');
|
||||
} catch (err: any) {
|
||||
if (mySeq !== loadSeq) {
|
||||
return;
|
||||
}
|
||||
error.value = err?.message || '加载失败';
|
||||
} finally {
|
||||
if (mySeq === loadSeq) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function close() {
|
||||
quickDock.closePreview();
|
||||
}
|
||||
</script>
|
||||
177
static/src/components/chat/quickdock/FileWindow.vue
Normal file
177
static/src/components/chat/quickdock/FileWindow.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<section v-if="rows.length" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
|
||||
<header class="qd-window__header">
|
||||
<svg class="qd-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M9.5 2.5V5H12" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span class="qd-window__title">文件</span>
|
||||
<span class="qd-window__counter">{{ rows.length }}</span>
|
||||
</header>
|
||||
<ul ref="listRef" class="qd-list">
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row.path"
|
||||
class="qd-file-item"
|
||||
:class="{ 'is-active': previewPath === row.path, 'qd-row-enter': row.entering }"
|
||||
:style="row.pendingEnter ? { opacity: '0' } : undefined"
|
||||
:title="row.path"
|
||||
@click="openRow(row)"
|
||||
>
|
||||
<button class="qd-row-menu-btn" title="更多" @click.stop="openMenu($event, row)">
|
||||
<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>
|
||||
</button>
|
||||
<span class="qd-file-name">{{ basename(row.path) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
|
||||
/**
|
||||
* 文件记录窗口
|
||||
* 本次对话中编辑/创建过且仍存在的文件;同一 path 只出现一次。
|
||||
* 行结构:[⋯] 文件名;点击行 → 右侧预览侧边栏;⋯ → 菜单(下载/打开/复制路径)
|
||||
*/
|
||||
|
||||
interface Row {
|
||||
path: string;
|
||||
entering: boolean;
|
||||
pendingEnter: boolean;
|
||||
}
|
||||
|
||||
const quickDock = useQuickDockStore();
|
||||
const { editedFiles, previewPath } = storeToRefs(quickDock);
|
||||
|
||||
const rows = ref<Row[]>([]);
|
||||
const windowEntering = ref(false);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
let windowEnterShown = false;
|
||||
|
||||
function basename(p: string): string {
|
||||
return p.split('/').pop() || p;
|
||||
}
|
||||
|
||||
function openRow(row: Row) {
|
||||
quickDock.closeMenu();
|
||||
quickDock.openPreview(row.path);
|
||||
}
|
||||
|
||||
function openMenu(e: MouseEvent, row: Row) {
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
quickDock.openMenu({
|
||||
type: 'file',
|
||||
key: row.path,
|
||||
left: rect.left, // 与按钮左对齐、向下展开
|
||||
top: rect.bottom + 6,
|
||||
alignRight: false
|
||||
});
|
||||
}
|
||||
|
||||
function scrollElIntoView(el: HTMLElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const listEl = listRef.value;
|
||||
if (!listEl) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const listRect = listEl.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const fullyVisible = elRect.top >= listRect.top && elRect.bottom <= listRect.bottom;
|
||||
if (fullyVisible) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let done = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
listEl.removeEventListener('scroll', onScroll);
|
||||
resolve();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(finish, 90);
|
||||
};
|
||||
listEl.addEventListener('scroll', onScroll);
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
timer = setTimeout(finish, 800);
|
||||
});
|
||||
}
|
||||
|
||||
async function playEnter(row: Row) {
|
||||
await nextTick();
|
||||
const listEl = listRef.value;
|
||||
const index = rows.value.indexOf(row);
|
||||
const el = listEl?.children[index] as HTMLElement | undefined;
|
||||
if (!el) {
|
||||
row.pendingEnter = false;
|
||||
row.entering = true;
|
||||
setTimeout(() => {
|
||||
row.entering = false;
|
||||
}, 380);
|
||||
return;
|
||||
}
|
||||
await scrollElIntoView(el);
|
||||
row.pendingEnter = false;
|
||||
row.entering = true;
|
||||
setTimeout(() => {
|
||||
row.entering = false;
|
||||
}, 380);
|
||||
}
|
||||
|
||||
watch(
|
||||
editedFiles,
|
||||
(list) => {
|
||||
// live=true 来自任务期实时事件(播动画);false 来自加载/bootstrap(静态呈现)
|
||||
const live = quickDock.editedFilesLive;
|
||||
const byPath = new Map(list.map((item) => [item.path, item]));
|
||||
|
||||
// 移除已消失的行(文件被删除 / 对话切换清空)
|
||||
rows.value = rows.value.filter((row) => byPath.has(row.path));
|
||||
|
||||
// 新增行:实时事件隐身插入 → 滚动露出 → 播进入动画;加载来源直接静态插入
|
||||
const existing = new Set(rows.value.map((row) => row.path));
|
||||
const added = list.filter((item) => !existing.has(item.path));
|
||||
if (added.length) {
|
||||
if (live && !windowEnterShown && !rows.value.length) {
|
||||
windowEnterShown = true;
|
||||
windowEntering.value = true;
|
||||
setTimeout(() => {
|
||||
windowEntering.value = false;
|
||||
}, 320);
|
||||
}
|
||||
added.forEach((item) => {
|
||||
const row: Row = { path: item.path, entering: false, pendingEnter: live };
|
||||
rows.value.push(row);
|
||||
if (live) {
|
||||
void playEnter(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
289
static/src/components/chat/quickdock/QuickDock.vue
Normal file
289
static/src/components/chat/quickdock/QuickDock.vue
Normal file
@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<aside class="quick-dock" :class="{ 'quick-dock--empty': !hasContent }">
|
||||
<div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll">
|
||||
<TodoWindow />
|
||||
<RunnerWindow kind="agent" />
|
||||
<RunnerWindow kind="cmd" />
|
||||
<FileWindow />
|
||||
</div>
|
||||
|
||||
<!-- 详情面板(fixed 浮在列左侧) -->
|
||||
<RunnerDetailPanel />
|
||||
|
||||
<!-- 全局 ⋯ 菜单(fixed 单例) -->
|
||||
<div v-if="menu" class="qd-menu menu-enter" :style="menuStyle" @click.stop>
|
||||
<template v-if="menu.type === 'runner'">
|
||||
<button
|
||||
class="qd-menu__item qd-menu__item--danger"
|
||||
:disabled="!menuTargetRunning"
|
||||
@click="killRunner"
|
||||
>
|
||||
强制关闭
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="qd-menu__item" @click="downloadFile">下载</button>
|
||||
<button v-if="hostMode" class="qd-menu__item" @click="revealInManager">
|
||||
在文件管理器中打开
|
||||
</button>
|
||||
<button class="qd-menu__item" @click="copyPath">复制路径</button>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
import { useSubAgentStore } from '@/stores/subAgent';
|
||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||
import { useConversationStore } from '@/stores/conversation';
|
||||
import { useFileStore } from '@/stores/file';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import TodoWindow from './TodoWindow.vue';
|
||||
import RunnerWindow from './RunnerWindow.vue';
|
||||
import RunnerDetailPanel from './RunnerDetailPanel.vue';
|
||||
import FileWindow from './FileWindow.vue';
|
||||
|
||||
/**
|
||||
* 快捷窗口(Quick Dock)容器
|
||||
* 对话区右侧占位列:待办 / 子智能体 / 后台指令 / 文件 四个窗口上下排布。
|
||||
* 同时负责:全局 ⋯ 菜单、Esc 分层关闭、列表轮询、对话切换重置。
|
||||
*/
|
||||
|
||||
defineProps<{ hostMode: boolean }>();
|
||||
|
||||
const quickDock = useQuickDockStore();
|
||||
const subAgentStore = useSubAgentStore();
|
||||
const bgStore = useBackgroundCommandStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const fileStore = useFileStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const { menu } = storeToRefs(quickDock);
|
||||
const scrollRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/** 四个窗口任一有内容时才占位显示(空列不挤压对话区) */
|
||||
const hasContent = computed(() => {
|
||||
const todoCount = fileStore.todoList?.tasks?.length || 0;
|
||||
return (
|
||||
todoCount > 0 ||
|
||||
subAgentStore.subAgents.length > 0 ||
|
||||
bgStore.commands.length > 0 ||
|
||||
quickDock.editedFiles.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);
|
||||
const CMD_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
|
||||
|
||||
/* ---------------- 菜单定位与目标状态 ---------------- */
|
||||
|
||||
const MENU_ESTIMATED_HEIGHT = 128;
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
const m = menu.value;
|
||||
if (!m) {
|
||||
return {};
|
||||
}
|
||||
const top = Math.min(m.top, window.innerHeight - MENU_ESTIMATED_HEIGHT - 8);
|
||||
if (m.alignRight) {
|
||||
return { right: `${window.innerWidth - m.left}px`, top: `${top}px` };
|
||||
}
|
||||
return { left: `${m.left}px`, top: `${top}px` };
|
||||
});
|
||||
|
||||
const menuTargetRunning = computed(() => {
|
||||
const m = menu.value;
|
||||
if (!m || m.type !== 'runner') {
|
||||
return false;
|
||||
}
|
||||
if (m.kind === 'agent') {
|
||||
const agent = subAgentStore.subAgents.find((a) => a.task_id === m.key);
|
||||
const status = (agent?.status || '').toString().toLowerCase();
|
||||
return !!agent && !AGENT_TERMINAL.has(status);
|
||||
}
|
||||
const cmd = bgStore.commands.find((c) => c.command_id === m.key);
|
||||
const status = (cmd?.status || '').toString().toLowerCase();
|
||||
return !!cmd && !CMD_TERMINAL.has(status);
|
||||
});
|
||||
|
||||
/* ---------------- 菜单动作 ---------------- */
|
||||
|
||||
async function killRunner() {
|
||||
const m = menu.value;
|
||||
if (!m || m.type !== 'runner') {
|
||||
return;
|
||||
}
|
||||
quickDock.closeMenu();
|
||||
const result =
|
||||
m.kind === 'agent'
|
||||
? await subAgentStore.terminateSubAgent(m.key)
|
||||
: await bgStore.cancelCommand(m.key);
|
||||
if (!result?.success) {
|
||||
uiStore.pushToast({ message: result?.error || '强制关闭失败', type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
function basename(p: string): string {
|
||||
return p.split('/').pop() || p;
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
const m = menu.value;
|
||||
if (!m || m.type !== 'file') {
|
||||
return;
|
||||
}
|
||||
quickDock.closeMenu();
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/download/file?path=${encodeURIComponent(m.key)}`;
|
||||
a.download = basename(m.key);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
/** 用系统默认应用打开(候选列表第一个即系统默认 handler) */
|
||||
async function revealInManager() {
|
||||
const m = menu.value;
|
||||
if (!m || m.type !== 'file') {
|
||||
return;
|
||||
}
|
||||
const path = m.key;
|
||||
quickDock.closeMenu();
|
||||
try {
|
||||
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !payload?.success) {
|
||||
throw new Error(payload?.error || '检测可用应用失败');
|
||||
}
|
||||
const apps = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
|
||||
if (!apps.length) {
|
||||
throw new Error('未找到可用应用');
|
||||
}
|
||||
const openResp = await fetch('/api/project/open-file-with-app', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, app_id: apps[0].id })
|
||||
});
|
||||
const openPayload = await openResp.json().catch(() => ({}));
|
||||
if (!openResp.ok || !openPayload?.success) {
|
||||
throw new Error(openPayload?.error || '打开文件失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
uiStore.pushToast({ message: err?.message || '打开文件失败', type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPath() {
|
||||
const m = menu.value;
|
||||
if (!m || m.type !== 'file') {
|
||||
return;
|
||||
}
|
||||
const path = m.key;
|
||||
quickDock.closeMenu();
|
||||
const toast = (message: string, type = 'info') =>
|
||||
uiStore.pushToast({ message, type, duration: 2000 });
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(path);
|
||||
toast('已复制相对路径', 'success');
|
||||
return;
|
||||
} catch {
|
||||
// 走 fallback
|
||||
}
|
||||
}
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = path;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
toast('已复制相对路径', 'success');
|
||||
} catch {
|
||||
toast('复制失败', 'error');
|
||||
}
|
||||
ta.remove();
|
||||
}
|
||||
|
||||
/* ---------------- 全局事件:Esc 分层关闭 / 点空白关菜单 / 栈滚动关菜单 ---------------- */
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (menu.value) {
|
||||
quickDock.closeMenu();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (quickDock.detail) {
|
||||
quickDock.closeDetail();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (quickDock.previewPath) {
|
||||
quickDock.closePreview();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick(e: MouseEvent) {
|
||||
if (!menu.value) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('.qd-menu') || target?.closest('.qd-row-menu-btn')) {
|
||||
return;
|
||||
}
|
||||
quickDock.closeMenu();
|
||||
}
|
||||
|
||||
function handleStackScroll() {
|
||||
// 菜单是 fixed 定位不随栈滚动,栈滚动时主动关闭
|
||||
if (menu.value) {
|
||||
quickDock.closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 轮询(常驻,5s)与对话切换 ---------------- */
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function refreshAll() {
|
||||
void subAgentStore.fetchSubAgents();
|
||||
void bgStore.fetchCommands();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll();
|
||||
pollTimer = setInterval(refreshAll, 5000);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
quickDock.resetTransient();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => conversationStore.currentConversationId,
|
||||
() => {
|
||||
// 只关瞬态面板;列表数据保留,由 bootstrap(edited_files)与 fetchTodoList 回填覆盖,
|
||||
// 避免切换对话时列「先收起再展开」闪烁(/new 场景由 app watcher 负责清空)。
|
||||
quickDock.resetTransient();
|
||||
refreshAll();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style src="./quickdock.css"></style>
|
||||
507
static/src/components/chat/quickdock/RunnerDetailPanel.vue
Normal file
507
static/src/components/chat/quickdock/RunnerDetailPanel.vue
Normal file
@ -0,0 +1,507 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="renderVisible"
|
||||
class="qd-detail"
|
||||
:class="{ 'panel-enter': entering, 'panel-leave': leaving }"
|
||||
>
|
||||
<header class="qd-detail__header">
|
||||
<span class="qd-detail__dot" :class="`is-${stateClass}`"></span>
|
||||
<span class="qd-detail__title" :title="title">{{ title }}</span>
|
||||
<span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span>
|
||||
<button class="qd-detail__close" title="关闭" @click="close">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }">
|
||||
<!-- 子智能体:工具调用 + 文本输出时间线 -->
|
||||
<template v-if="effectiveDetail?.kind === 'agent'">
|
||||
<div v-if="!timelineItems.length" class="qd-detail__empty">
|
||||
{{ activityLoading ? '加载中…' : '暂无进度' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="item in timelineItems"
|
||||
:key="item.key"
|
||||
class="qd-feed-row"
|
||||
:class="[item.kind === 'tool' ? 'feed-tool' : 'feed-text', { 'is-new': animatedKeys.has(item.key) }]"
|
||||
>
|
||||
<template v-if="item.kind === 'tool'">
|
||||
<span v-if="item.state === 'running'" class="qd-tool-spinner"></span>
|
||||
<span v-else class="qd-tool-done">{{ item.state === 'failed' ? '✕' : '✓' }}</span>
|
||||
<span class="tool-name">{{ item.toolName }}</span>
|
||||
<span class="tool-param" :title="item.text">{{ item.text }}</span>
|
||||
<span class="tool-result" :class="{ 'is-error': item.state === 'failed' }">
|
||||
{{ item.stateLabel }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ item.content }}</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 后台指令:终端输出行 -->
|
||||
<template v-else>
|
||||
<div v-if="!outputLines.length" class="qd-detail__empty">
|
||||
{{ detailLoading ? '加载中…' : '暂无输出' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(line, i) in outputLines"
|
||||
:key="i"
|
||||
class="qd-feed-row feed-term"
|
||||
:class="{ 'is-command': i === 0, 'is-new': animatedKeys.has(`term-${i}`) }"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
import { useSubAgentStore } from '@/stores/subAgent';
|
||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||
|
||||
/**
|
||||
* 详情面板(fixed 浮在快捷窗口列左侧)
|
||||
* - 子智能体:activity entries 时间线(工具行 + 文本行)
|
||||
* - 后台指令:终端输出行(首行 $ 命令加粗)
|
||||
* - 新行自动播 feed-in 动画;距底 <80px 时新内容平滑滚到底(用户上翻不打断)
|
||||
*/
|
||||
|
||||
const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);
|
||||
const CMD_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
|
||||
|
||||
const quickDock = useQuickDockStore();
|
||||
const subAgentStore = useSubAgentStore();
|
||||
const bgStore = useBackgroundCommandStore();
|
||||
|
||||
const { detail } = storeToRefs(quickDock);
|
||||
const { activityEntries, activityLoading } = storeToRefs(subAgentStore);
|
||||
const { activeDetail, detailLoading } = storeToRefs(bgStore);
|
||||
|
||||
/** 最后一个非 null 的 detail:关闭时 detail 立即变 null,但离开动画仍播放 190ms,
|
||||
* 期间所有状态显示改用此快照,避免角标/标题闪变为「已结束」/空。 */
|
||||
const lastDetail = ref<{ kind: 'agent' | 'cmd'; id: string } | null>(null);
|
||||
watch(
|
||||
detail,
|
||||
(val) => {
|
||||
if (val) {
|
||||
lastDetail.value = { kind: val.kind, id: val.id };
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const effectiveDetail = computed(() => detail.value || lastDetail.value);
|
||||
|
||||
/** 最后一次非空实时状态:切换对话/关闭面板时 store 列表被替换,实时状态取空,
|
||||
* 离开动画期间回退到此快照,避免空状态被误判为「运行中」。 */
|
||||
const lastStatus = ref('');
|
||||
|
||||
/** 实时状态(面板打开期间随 store 刷新) */
|
||||
const liveStatus = computed(() => {
|
||||
const target = effectiveDetail.value;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.kind === 'agent') {
|
||||
const agent = subAgentStore.subAgents.find((a) => a.task_id === target.id);
|
||||
const active =
|
||||
subAgentStore.activeAgent?.task_id === target.id ? subAgentStore.activeAgent : null;
|
||||
return String(active?.status || agent?.status || '');
|
||||
}
|
||||
const cmd = bgStore.commands.find((c) => c.command_id === target.id);
|
||||
const active = bgStore.activeCommand?.command_id === target.id ? bgStore.activeCommand : null;
|
||||
return String(active?.status || cmd?.status || '');
|
||||
});
|
||||
watch(
|
||||
liveStatus,
|
||||
(val) => {
|
||||
if (val) {
|
||||
lastStatus.value = val;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const renderVisible = ref(false);
|
||||
const entering = ref(false);
|
||||
const leaving = ref(false);
|
||||
const bodyFading = ref(false);
|
||||
const bodyRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/** 当前条目状态与标题(关闭离开动画期间沿用最后快照) */
|
||||
const currentStatus = computed(() => {
|
||||
return liveStatus.value || lastStatus.value || '';
|
||||
});
|
||||
|
||||
const isRunning = computed(() => {
|
||||
const status = currentStatus.value.toLowerCase();
|
||||
if (!effectiveDetail.value || !status) {
|
||||
// 状态未知(快照也没有)时不应显示「运行中」
|
||||
return false;
|
||||
}
|
||||
// idle(空闲)不是运行中:子智能体等待唤醒,不参与运行态展示
|
||||
if (status === 'idle') {
|
||||
return false;
|
||||
}
|
||||
return !(effectiveDetail.value.kind === 'agent' ? AGENT_TERMINAL : CMD_TERMINAL).has(status);
|
||||
});
|
||||
|
||||
/** 状态分类:running=运行中 / idle=空闲 / done=完成 / ended=失败·超时·终止 */
|
||||
const stateClass = computed(() => {
|
||||
const status = currentStatus.value.toLowerCase();
|
||||
if (status === 'idle') {
|
||||
return 'idle';
|
||||
}
|
||||
if (isRunning.value) {
|
||||
return 'running';
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return 'done';
|
||||
}
|
||||
return 'ended';
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
if (!effectiveDetail.value) {
|
||||
return '';
|
||||
}
|
||||
if (effectiveDetail.value.kind === 'agent') {
|
||||
const agent = subAgentStore.subAgents.find((a) => a.task_id === effectiveDetail.value?.id);
|
||||
return (
|
||||
agent?.display_name || agent?.summary || subAgentStore.activeAgent?.display_name || '子智能体'
|
||||
);
|
||||
}
|
||||
const cmd = bgStore.commands.find((c) => c.command_id === effectiveDetail.value?.id);
|
||||
return cmd?.command || bgStore.activeCommand?.command || '后台指令';
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
const status = currentStatus.value.toLowerCase();
|
||||
if (status === 'idle') {
|
||||
return '空闲';
|
||||
}
|
||||
if (isRunning.value) {
|
||||
return '运行中';
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return '已完成';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '已失败';
|
||||
}
|
||||
if (status === 'timeout') {
|
||||
return '已超时';
|
||||
}
|
||||
if (status === 'terminated' || status === 'cancelled') {
|
||||
return '已终止';
|
||||
}
|
||||
return '已结束';
|
||||
});
|
||||
|
||||
/* ---------------- 子智能体时间线(逻辑借鉴 SubAgentActivityDialog) ---------------- */
|
||||
|
||||
function normalizeStatus(status?: string) {
|
||||
if (status === 'running' || status === 'in_progress') return 'running';
|
||||
if (status === 'completed' || status === 'done' || status === 'success') return 'completed';
|
||||
if (status === 'failed' || status === 'error') return 'failed';
|
||||
return status || 'running';
|
||||
}
|
||||
|
||||
function isEntryTerminal(status?: string) {
|
||||
const normalized = (status || '').toString().toLowerCase();
|
||||
return [
|
||||
'completed',
|
||||
'failed',
|
||||
'timeout',
|
||||
'terminated',
|
||||
'cancelled',
|
||||
'done',
|
||||
'success',
|
||||
'error'
|
||||
].includes(normalized);
|
||||
}
|
||||
|
||||
function buildText(entry: any): string {
|
||||
const tool = entry.tool || '';
|
||||
const args = entry.args || {};
|
||||
if (tool === 'read_file') return `阅读 ${args.path || args.file_path || ''}`;
|
||||
if (tool === 'write_file') return `写入文件 ${args.file_path || args.path || ''}`;
|
||||
if (tool === 'read_skill') return `阅读技能 ${args.skill_name || ''}`;
|
||||
if (tool === 'web_search') return `搜索 ${args.query || args.q || ''}`;
|
||||
if (tool === 'extract_webpage') return `提取 ${args.url || ''}`;
|
||||
if (tool === 'run_command') return `运行命令 ${args.command || ''}`;
|
||||
if (tool === 'edit_file') return `编辑 ${args.path || args.file_path || ''}`;
|
||||
if (tool === 'read_mediafile') return `读取媒体文件 ${args.path || args.file_path || ''}`;
|
||||
return tool || '工具';
|
||||
}
|
||||
|
||||
interface ToolTimelineItem {
|
||||
kind: 'tool';
|
||||
key: string;
|
||||
state: string;
|
||||
stateLabel: string;
|
||||
toolName: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OutputTimelineItem {
|
||||
kind: 'output';
|
||||
key: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const timelineItems = computed<(ToolTimelineItem | OutputTimelineItem)[]>(() => {
|
||||
const entries = activityEntries.value || [];
|
||||
const rawItems: ({ kind: 'tool'; key: string; entry: any } | OutputTimelineItem)[] = [];
|
||||
let currentToolGroup: { kind: 'tool'; key: string; entry: any } | null = null;
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (currentToolGroup) {
|
||||
rawItems.push(currentToolGroup);
|
||||
currentToolGroup = null;
|
||||
}
|
||||
};
|
||||
|
||||
entries.forEach((entry: any, index: number) => {
|
||||
if (
|
||||
entry?.type === 'progress' &&
|
||||
entry?.subtype === 'output' &&
|
||||
typeof entry.content === 'string'
|
||||
) {
|
||||
flushToolGroup();
|
||||
rawItems.push({
|
||||
kind: 'output',
|
||||
key: `output-${entry.ts || index}`,
|
||||
content: entry.content
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry || entry.type !== 'progress' || !entry.tool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = entry.id || `${entry.tool}-${entry.ts || index}`;
|
||||
if (
|
||||
currentToolGroup &&
|
||||
(currentToolGroup.entry.id === entry.id || currentToolGroup.key === baseKey) &&
|
||||
!isEntryTerminal(currentToolGroup.entry.status)
|
||||
) {
|
||||
currentToolGroup.entry = { ...currentToolGroup.entry, ...entry };
|
||||
return;
|
||||
}
|
||||
|
||||
flushToolGroup();
|
||||
let key = baseKey;
|
||||
let suffix = 0;
|
||||
while (rawItems.some((item) => item.kind === 'tool' && item.key === key)) {
|
||||
suffix += 1;
|
||||
key = `${baseKey}--${suffix}`;
|
||||
}
|
||||
currentToolGroup = { kind: 'tool', key, entry: { ...entry } };
|
||||
});
|
||||
|
||||
flushToolGroup();
|
||||
|
||||
return rawItems.map((item) => {
|
||||
if (item.kind === 'output') {
|
||||
return item;
|
||||
}
|
||||
const state = normalizeStatus(item.entry.status);
|
||||
return {
|
||||
kind: 'tool' as const,
|
||||
key: item.key,
|
||||
state,
|
||||
stateLabel: state === 'completed' ? '完成' : state === 'failed' ? '失败' : '进行中',
|
||||
toolName: item.entry.tool || '工具',
|
||||
text: buildText(item.entry)
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------------- 后台指令输出 ---------------- */
|
||||
|
||||
const outputLines = computed<string[]>(() => {
|
||||
if (effectiveDetail.value?.kind !== 'cmd') {
|
||||
return [];
|
||||
}
|
||||
const output = activeDetail.value?.output;
|
||||
if (typeof output !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return output.split('\n');
|
||||
});
|
||||
|
||||
/* ---------------- feed 行进入动画:仅面板打开期间新出现的行播放 ---------------- */
|
||||
/** 打开/切换详情后首次填充的行直接静态显示;之后新出现的行才带 is-new 播动画 */
|
||||
const animatedKeys = ref<Set<string>>(new Set());
|
||||
let seenFeedKeys = new Set<string>();
|
||||
let feedHydrated = false;
|
||||
|
||||
function resetFeedAnimState() {
|
||||
seenFeedKeys = new Set();
|
||||
feedHydrated = false;
|
||||
animatedKeys.value = new Set();
|
||||
}
|
||||
|
||||
watch(
|
||||
[timelineItems, outputLines],
|
||||
() => {
|
||||
const keys =
|
||||
effectiveDetail.value?.kind === 'agent'
|
||||
? timelineItems.value.map((t) => t.key)
|
||||
: outputLines.value.map((_, i) => `term-${i}`);
|
||||
if (!feedHydrated) {
|
||||
// 空列表不算首次填充:打开详情时 entries 会先被清空再拉取,
|
||||
// 若清空即消费首次机会,真实内容到达时会被滚动 watch 误判为
|
||||
// 「后续批次 + 距底<80」而发起平滑滚动(先上后下的滚动动画)。
|
||||
if (!keys.length) {
|
||||
return;
|
||||
}
|
||||
// 真实内容的首次填充:全部标记为已见,静态显示,
|
||||
// 并瞬间定位到底部(无滚动动画)
|
||||
feedHydrated = true;
|
||||
keys.forEach((k) => seenFeedKeys.add(k));
|
||||
nextTick(() => scrollToBottom(false));
|
||||
return;
|
||||
}
|
||||
const newKeys = keys.filter((k) => !seenFeedKeys.has(k));
|
||||
if (!newKeys.length) {
|
||||
return;
|
||||
}
|
||||
newKeys.forEach((k) => seenFeedKeys.add(k));
|
||||
animatedKeys.value = new Set(newKeys);
|
||||
// 动画播完后清理,避免后续 DOM 重建时重播
|
||||
setTimeout(() => {
|
||||
animatedKeys.value = new Set();
|
||||
}, 400);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
/* ---------------- 数据联动:打开/切换/关闭详情 ---------------- */
|
||||
|
||||
watch(
|
||||
detail,
|
||||
(target, prev) => {
|
||||
if (!target) {
|
||||
// 关闭:播离开动画后隐藏;数据清理延迟到动画结束,
|
||||
// 避免离开期间内容区闪成「暂无进度」、角标闪变「已结束」
|
||||
const cleanup = () => {
|
||||
if (prev?.kind === 'agent') {
|
||||
subAgentStore.closeSubAgent();
|
||||
}
|
||||
if (prev?.kind === 'cmd') {
|
||||
bgStore.closeCommand();
|
||||
}
|
||||
};
|
||||
if (renderVisible.value) {
|
||||
entering.value = false;
|
||||
leaving.value = true;
|
||||
// setTimeout 而非 animationend:feed 行动画的 animationend 会冒泡干扰
|
||||
setTimeout(() => {
|
||||
leaving.value = false;
|
||||
renderVisible.value = false;
|
||||
cleanup();
|
||||
}, 190);
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换条目:立即清理上一个条目的数据(避免旧内容留给新条目)
|
||||
if (prev?.kind === 'agent') {
|
||||
subAgentStore.closeSubAgent();
|
||||
}
|
||||
if (prev?.kind === 'cmd') {
|
||||
bgStore.closeCommand();
|
||||
}
|
||||
// 新条目的 feed 动画状态重置:首次填充静态显示
|
||||
resetFeedAnimState();
|
||||
|
||||
// 加载详情数据(store 自带轮询,终态自动停止)。
|
||||
// silent:不设 activeAgent/activeCommand,避免触发旧版详情弹窗
|
||||
//(SubAgentActivityDialog / BackgroundCommandDialog 以 activeXxx 为显示条件)
|
||||
if (target.kind === 'agent') {
|
||||
const agent = subAgentStore.subAgents.find((a) => a.task_id === target.id);
|
||||
if (agent) {
|
||||
subAgentStore.openSubAgent(agent, { silent: true });
|
||||
}
|
||||
} else {
|
||||
const cmd = bgStore.commands.find((c) => c.command_id === target.id);
|
||||
if (cmd) {
|
||||
bgStore.openCommand(cmd, { silent: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!renderVisible.value) {
|
||||
renderVisible.value = true;
|
||||
entering.value = true;
|
||||
setTimeout(() => {
|
||||
entering.value = false;
|
||||
}, 260);
|
||||
nextTick(() => {
|
||||
scrollToBottom(false);
|
||||
});
|
||||
} else {
|
||||
// 切换条目:内容区快速淡入
|
||||
bodyFading.value = false;
|
||||
nextTick(() => {
|
||||
bodyFading.value = true;
|
||||
scrollToBottom(false);
|
||||
setTimeout(() => {
|
||||
bodyFading.value = false;
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/* ---------------- 自动滚动:距底 <80px 时新内容滚到底 ---------------- */
|
||||
|
||||
function scrollToBottom(smooth: boolean) {
|
||||
const el = bodyRef.value;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
||||
}
|
||||
|
||||
function maybeAutoScroll() {
|
||||
const el = bodyRef.value;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
// 首次填充由 feed watch 瞬间定位到底,不走平滑滚动;
|
||||
// 平滑滚动只用于「已在底部时新步骤出现」的场景
|
||||
if (!feedHydrated) {
|
||||
return;
|
||||
}
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (nearBottom) {
|
||||
nextTick(() => scrollToBottom(true));
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [timelineItems.value.length, outputLines.value.length],
|
||||
() => {
|
||||
maybeAutoScroll();
|
||||
}
|
||||
);
|
||||
|
||||
function close() {
|
||||
quickDock.closeDetail();
|
||||
}
|
||||
</script>
|
||||
295
static/src/components/chat/quickdock/RunnerWindow.vue
Normal file
295
static/src/components/chat/quickdock/RunnerWindow.vue
Normal file
@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<section v-if="rows.length" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
|
||||
<header class="qd-window__header">
|
||||
<svg v-if="kind === 'agent'" class="qd-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="5.5" r="2.5" stroke="currentColor" stroke-width="1.4" />
|
||||
<path
|
||||
d="M3 13c.7-2.6 2.7-4 5-4s4.3 1.4 5 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="qd-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l-3.5 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M8 12h5.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span class="qd-window__title">{{ kind === 'agent' ? '子智能体' : '后台指令' }}</span>
|
||||
<span class="qd-window__counter">{{ runningCount }}/{{ rows.length }}</span>
|
||||
</header>
|
||||
<ul ref="listRef" class="qd-list">
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="qd-run-item"
|
||||
:data-kind="kind"
|
||||
:class="[
|
||||
`is-${row.state}`,
|
||||
{
|
||||
'is-active': isActive(row),
|
||||
'qd-row-enter': row.entering
|
||||
}
|
||||
]"
|
||||
:style="row.pendingEnter ? { opacity: '0' } : undefined"
|
||||
@click="openRow(row)"
|
||||
>
|
||||
<span class="qd-run-status"></span>
|
||||
<span class="qd-run-name" :title="row.name">{{ row.name }}</span>
|
||||
<button class="qd-row-menu-btn" title="更多" @click.stop="openMenu($event, row)">
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useSubAgentStore } from '@/stores/subAgent';
|
||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||
import { useConversationStore } from '@/stores/conversation';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
|
||||
/**
|
||||
* 子智能体 / 后台指令窗口(两者同构)
|
||||
* - 行结构:[●状态点] 名称 [⋯];运行中状态点呼吸,终态变灰
|
||||
* - 新条目:先隐身插入,若在可视区域外先平滑滚动露出,再播进入动画
|
||||
* - 点击行 → 左侧详情面板;⋯ → 菜单(强制关闭)
|
||||
*/
|
||||
|
||||
/** 行状态:running=运行中(蓝) / idle=空闲(白) / done=完成(绿) / ended=失败·超时·终止(红)
|
||||
* 配色对齐旧工作区面板的 .sub-agent-status 文字色。 */
|
||||
type RowState = 'running' | 'idle' | 'done' | 'ended';
|
||||
|
||||
function agentStateOf(status: string): RowState {
|
||||
if (status === 'idle') return 'idle';
|
||||
if (status === 'completed') return 'done';
|
||||
if (status === 'failed' || status === 'timeout' || status === 'terminated' || status === 'cancelled') {
|
||||
return 'ended';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function cmdStateOf(status: string): RowState {
|
||||
if (status === 'completed') return 'done';
|
||||
if (status === 'failed' || status === 'timeout' || status === 'cancelled') {
|
||||
return 'ended';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
state: RowState;
|
||||
entering: boolean;
|
||||
pendingEnter: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{ kind: 'agent' | 'cmd' }>();
|
||||
|
||||
const subAgentStore = useSubAgentStore();
|
||||
const bgStore = useBackgroundCommandStore();
|
||||
const quickDock = useQuickDockStore();
|
||||
const convStore = useConversationStore();
|
||||
const { detail } = storeToRefs(quickDock);
|
||||
|
||||
const rows = ref<Row[]>([]);
|
||||
const windowEntering = ref(false);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
let windowEnterShown = false;
|
||||
|
||||
/** 切换对话/页面加载后的第一次列表更新全部静态显示(不播进入动画);
|
||||
* 之后运行期间新出现的条目才播。初始 true 保证首帧加载即静态。 */
|
||||
const staticNext = ref(true);
|
||||
watch(
|
||||
() => convStore.currentConversationId,
|
||||
() => {
|
||||
staticNext.value = true;
|
||||
}
|
||||
);
|
||||
|
||||
const runningCount = computed(() => rows.value.filter((r) => r.state === 'running').length);
|
||||
|
||||
interface SourceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
state: RowState;
|
||||
}
|
||||
|
||||
const sourceItems = computed<SourceItem[]>(() => {
|
||||
if (props.kind === 'agent') {
|
||||
return subAgentStore.subAgents
|
||||
.map((a) => {
|
||||
const id = (a.task_id || '').toString();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const status = (a.status || '').toString().toLowerCase();
|
||||
return {
|
||||
id,
|
||||
name: a.display_name || a.summary || `子智能体 ${a.agent_id ?? id}`,
|
||||
state: agentStateOf(status)
|
||||
};
|
||||
})
|
||||
.filter((x): x is SourceItem => x !== null);
|
||||
}
|
||||
return bgStore.commands
|
||||
.map((c) => {
|
||||
const id = (c.command_id || '').toString();
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const status = (c.status || '').toString().toLowerCase();
|
||||
return {
|
||||
id,
|
||||
name: c.command || id,
|
||||
state: cmdStateOf(status)
|
||||
};
|
||||
})
|
||||
.filter((x): x is SourceItem => x !== null);
|
||||
});
|
||||
|
||||
function isActive(row: Row) {
|
||||
return detail.value?.kind === props.kind && detail.value?.id === row.id;
|
||||
}
|
||||
|
||||
function openRow(row: Row) {
|
||||
quickDock.closeMenu();
|
||||
quickDock.openDetail(props.kind, row.id);
|
||||
}
|
||||
|
||||
function openMenu(e: MouseEvent, row: Row) {
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
quickDock.openMenu({
|
||||
type: 'runner',
|
||||
kind: props.kind,
|
||||
key: row.id,
|
||||
left: rect.right, // alignRight:菜单右缘对齐按钮右缘
|
||||
top: rect.bottom + 6,
|
||||
alignRight: true
|
||||
});
|
||||
}
|
||||
|
||||
/** 若目标行未完全显示,先平滑滚动到可见位置 */
|
||||
function scrollElIntoView(el: HTMLElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const listEl = listRef.value;
|
||||
if (!listEl) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const listRect = listEl.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const fullyVisible = elRect.top >= listRect.top && elRect.bottom <= listRect.bottom;
|
||||
if (fullyVisible) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let done = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
listEl.removeEventListener('scroll', onScroll);
|
||||
resolve();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(finish, 90);
|
||||
};
|
||||
listEl.addEventListener('scroll', onScroll);
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
timer = setTimeout(finish, 800);
|
||||
});
|
||||
}
|
||||
|
||||
async function playEnter(row: Row) {
|
||||
await nextTick();
|
||||
const listEl = listRef.value;
|
||||
const index = rows.value.indexOf(row);
|
||||
const el = listEl?.children[index] as HTMLElement | undefined;
|
||||
if (!el) {
|
||||
row.pendingEnter = false;
|
||||
row.entering = true;
|
||||
setTimeout(() => {
|
||||
row.entering = false;
|
||||
}, 380);
|
||||
return;
|
||||
}
|
||||
await scrollElIntoView(el);
|
||||
row.pendingEnter = false;
|
||||
row.entering = true;
|
||||
setTimeout(() => {
|
||||
row.entering = false;
|
||||
}, 380);
|
||||
}
|
||||
|
||||
watch(
|
||||
sourceItems,
|
||||
(list) => {
|
||||
// 消费「下次静态」标记:无论本次有无新增都消费,
|
||||
// 保证切换对话后的第一次更新(包括空表)直接静态
|
||||
const isStatic = staticNext.value;
|
||||
staticNext.value = false;
|
||||
|
||||
const byId = new Map(list.map((item) => [item.id, item]));
|
||||
|
||||
// 更新现有行状态;移除已消失的行
|
||||
rows.value = rows.value.filter((row) => byId.has(row.id));
|
||||
rows.value.forEach((row) => {
|
||||
const item = byId.get(row.id);
|
||||
if (item) {
|
||||
row.name = item.name;
|
||||
row.state = item.state;
|
||||
}
|
||||
});
|
||||
|
||||
// 新增行:静态模式直接插入;否则隐身插入 → 滚动露出 → 播进入动画
|
||||
const existingIds = new Set(rows.value.map((row) => row.id));
|
||||
const added = list.filter((item) => !existingIds.has(item.id));
|
||||
if (added.length) {
|
||||
if (!isStatic && !windowEnterShown && !rows.value.length) {
|
||||
windowEnterShown = true;
|
||||
windowEntering.value = true;
|
||||
setTimeout(() => {
|
||||
windowEntering.value = false;
|
||||
}, 320);
|
||||
}
|
||||
added.forEach((item) => {
|
||||
const row: Row = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
state: item.state,
|
||||
entering: false,
|
||||
pendingEnter: !isStatic
|
||||
};
|
||||
rows.value.push(row);
|
||||
if (!isStatic) {
|
||||
void playEnter(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
309
static/src/components/chat/quickdock/TodoWindow.vue
Normal file
309
static/src/components/chat/quickdock/TodoWindow.vue
Normal file
@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<section v-if="visible" class="qd-window" :class="{ 'qd-window-enter': windowEntering }">
|
||||
<header class="qd-window__header">
|
||||
<svg class="qd-window__icon" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M2 4.5 3.5 6 6 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M8.5 5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
<path
|
||||
d="M2 11 3.5 12.5 6 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M8.5 11.5H14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span class="qd-window__title">待办事项</span>
|
||||
<span class="qd-window__counter">{{ doneCount }}/{{ rows.length }}</span>
|
||||
</header>
|
||||
<ul ref="listRef" class="qd-list">
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
class="qd-todo-item"
|
||||
:class="{
|
||||
'is-done': row.done,
|
||||
'just-done': row.justDone,
|
||||
'qd-row-enter': row.entering,
|
||||
'qd-row-leave': row.leaving
|
||||
}"
|
||||
:style="rowStyle(row)"
|
||||
>
|
||||
<span class="qd-todo-dot"></span>
|
||||
<span class="qd-todo-text">{{ row.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useFileStore } from '@/stores/file';
|
||||
|
||||
/**
|
||||
* 待办事项窗口
|
||||
* 三组动画(参数与 demo 定稿一致):
|
||||
* 1. 创建/整表替换:旧行逐行向左移出(stagger 45ms + 高度收拢)→ 新行逐行从左向右进入(stagger 60ms)
|
||||
* 2. 完成:未完全显示的行先平滑滚动露出 → 横线从左往右划过 + 文字变灰 + 状态点弹跳
|
||||
* 3. 清空:移出后隐藏窗口
|
||||
*/
|
||||
|
||||
const ENTER_STAGGER = 60;
|
||||
const LEAVE_STAGGER = 45;
|
||||
const LEAVE_DURATION = 260;
|
||||
|
||||
interface TodoTask {
|
||||
index: number;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
title: string;
|
||||
done: boolean;
|
||||
entering: boolean;
|
||||
leaving: boolean;
|
||||
justDone: boolean;
|
||||
enterDelay: number;
|
||||
leaveDelay: number;
|
||||
}
|
||||
|
||||
const fileStore = useFileStore();
|
||||
const todoList = computed(() => fileStore.todoList);
|
||||
|
||||
const rows = ref<Row[]>([]);
|
||||
const visible = ref(false);
|
||||
const windowEntering = ref(false);
|
||||
const busy = ref(false);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/** 代际标记:动画流程中途来了新数据时,旧流程不再写状态 */
|
||||
let gen = 0;
|
||||
|
||||
const doneCount = computed(() => rows.value.filter((r) => r.done).length);
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function tasksOf(val: any): TodoTask[] {
|
||||
return Array.isArray(val?.tasks) ? val.tasks : [];
|
||||
}
|
||||
|
||||
function signature(tasks: TodoTask[]): string {
|
||||
return tasks.map((t) => `${t.index}:${t.title}`).join('|');
|
||||
}
|
||||
|
||||
function isDoneStatus(status: any): boolean {
|
||||
return status === 'done' || status === 'completed';
|
||||
}
|
||||
|
||||
function rowStyle(row: Row) {
|
||||
if (row.entering && row.enterDelay) {
|
||||
return { animationDelay: `${row.enterDelay}ms` };
|
||||
}
|
||||
if (row.leaving && row.leaveDelay) {
|
||||
return { animationDelay: `${row.leaveDelay}ms` };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function playLeave() {
|
||||
const current = rows.value;
|
||||
if (!current.length) {
|
||||
return;
|
||||
}
|
||||
current.forEach((row, i) => {
|
||||
row.leaveDelay = i * LEAVE_STAGGER;
|
||||
row.leaving = true;
|
||||
});
|
||||
await wait(current.length * LEAVE_STAGGER + LEAVE_DURATION + 20);
|
||||
}
|
||||
|
||||
function renderEntering(tasks: TodoTask[]) {
|
||||
rows.value = tasks.map((t, i) => ({
|
||||
key: `${t.index}:${t.title}`,
|
||||
title: t.title,
|
||||
done: isDoneStatus(t.status),
|
||||
entering: true,
|
||||
leaving: false,
|
||||
justDone: false,
|
||||
enterDelay: i * ENTER_STAGGER,
|
||||
leaveDelay: 0
|
||||
}));
|
||||
// 用 setTimeout 而非 animationend:动画事件会冒泡造成干扰
|
||||
const total = tasks.length * ENTER_STAGGER + 340 + 30;
|
||||
const myGen = gen;
|
||||
setTimeout(() => {
|
||||
if (myGen !== gen) {
|
||||
return;
|
||||
}
|
||||
rows.value.forEach((row) => {
|
||||
row.entering = false;
|
||||
row.enterDelay = 0;
|
||||
});
|
||||
}, total);
|
||||
}
|
||||
|
||||
/** 若目标行未完全显示,先平滑滚动到可见位置 */
|
||||
function scrollRowIntoView(index: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
nextTick(() => {
|
||||
const listEl = listRef.value;
|
||||
const li = listEl?.children[index] as HTMLElement | undefined;
|
||||
if (!listEl || !li) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
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: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = () => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
listEl.removeEventListener('scroll', onScroll);
|
||||
resolve();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(finish, 90); // 滚动停止 90ms 后认为到位
|
||||
};
|
||||
listEl.addEventListener('scroll', onScroll);
|
||||
li.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
timer = setTimeout(finish, 800); // 兜底
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatic(tasks: TodoTask[]) {
|
||||
rows.value = tasks.map((t) => ({
|
||||
key: `${t.index}:${t.title}`,
|
||||
title: t.title,
|
||||
done: isDoneStatus(t.status),
|
||||
entering: false,
|
||||
leaving: false,
|
||||
justDone: false,
|
||||
enterDelay: 0,
|
||||
leaveDelay: 0
|
||||
}));
|
||||
}
|
||||
|
||||
watch(
|
||||
todoList,
|
||||
async (newVal, oldVal) => {
|
||||
const myGen = ++gen;
|
||||
const newTasks = tasksOf(newVal);
|
||||
const oldTasks = tasksOf(oldVal);
|
||||
// live=true 来自任务期实时事件(播动画);false 来自加载/切换/fetch(静态呈现)
|
||||
const live = fileStore.todoListLive;
|
||||
|
||||
// 有 → 空:移出后隐藏窗口(加载来源直接消失)
|
||||
if (!newTasks.length) {
|
||||
if (rows.value.length && live) {
|
||||
busy.value = true;
|
||||
await playLeave();
|
||||
if (myGen !== gen) {
|
||||
return;
|
||||
}
|
||||
busy.value = false;
|
||||
}
|
||||
rows.value = [];
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 空 → 有:实时事件播窗口 + 行进入动画;加载回填静态呈现
|
||||
if (!rows.value.length) {
|
||||
visible.value = true;
|
||||
if (live) {
|
||||
windowEntering.value = true;
|
||||
setTimeout(() => {
|
||||
windowEntering.value = false;
|
||||
}, 320);
|
||||
renderEntering(newTasks);
|
||||
} else {
|
||||
renderStatic(newTasks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 有 → 有:title 序列相同 = 状态更新;不同 = 整表替换
|
||||
if (signature(oldTasks) === signature(newTasks)) {
|
||||
// 加载来源:静态同步状态,不播划线动画
|
||||
if (!live) {
|
||||
newTasks.forEach((t, i) => {
|
||||
const row = rows.value[i];
|
||||
if (row) {
|
||||
row.done = isDoneStatus(t.status);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < newTasks.length; i += 1) {
|
||||
const row = rows.value[i];
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
const done = isDoneStatus(newTasks[i].status);
|
||||
if (row.done === done) {
|
||||
continue;
|
||||
}
|
||||
// 等待进行中的整表动画结束,避免状态互相覆盖
|
||||
while (busy.value) {
|
||||
await wait(50);
|
||||
if (myGen !== gen) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (done) {
|
||||
await scrollRowIntoView(i); // 区域外先滚动露出
|
||||
if (myGen !== gen) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
row.done = done;
|
||||
if (done) {
|
||||
row.justDone = true;
|
||||
const target = row;
|
||||
setTimeout(() => {
|
||||
target.justDone = false;
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 整表替换:实时事件播移出+进入;加载来源直接静态替换
|
||||
if (!live) {
|
||||
renderStatic(newTasks);
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
await playLeave();
|
||||
if (myGen !== gen) {
|
||||
return;
|
||||
}
|
||||
renderEntering(newTasks);
|
||||
busy.value = false;
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
</script>
|
||||
924
static/src/components/chat/quickdock/quickdock.css
Normal file
924
static/src/components/chat/quickdock/quickdock.css
Normal file
@ -0,0 +1,924 @@
|
||||
/* ============================================================
|
||||
快捷窗口(Quick Dock)共享样式
|
||||
配色全部走三主题语义 token;动画参数迁移自 demo/todo_float_window
|
||||
============================================================ */
|
||||
|
||||
.quick-dock,
|
||||
.qd-detail,
|
||||
.qd-preview,
|
||||
.qd-menu {
|
||||
--qd-row-h: 36px;
|
||||
--qd-width: 270px;
|
||||
--qd-ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
--qd-ease-strike: cubic-bezier(0.55, 0.06, 0.35, 1);
|
||||
}
|
||||
|
||||
/* ---------------- 占位列容器 ---------------- */
|
||||
|
||||
.quick-dock {
|
||||
flex: none;
|
||||
width: 294px; /* 270 + 左右 12 padding */
|
||||
padding: 20px 12px 20px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
padding 0.3s ease,
|
||||
opacity 0.25s ease,
|
||||
transform 0.3s var(--qd-ease-out);
|
||||
}
|
||||
|
||||
/* 四个窗口都无内容时收起到 0 宽,不挤压对话区 */
|
||||
.quick-dock--empty {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.quick-dock__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
/* padding + 负 margin:扩大滚动盒以容纳窗口阴影,避免被 overflow 裁剪 */
|
||||
padding: 24px;
|
||||
margin: -24px;
|
||||
}
|
||||
|
||||
.quick-dock__scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ---------------- 窗口通用 ---------------- */
|
||||
|
||||
.qd-window {
|
||||
flex: none; /* 禁止被列 flex 压缩变矮 */
|
||||
width: var(--qd-width);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qd-window.qd-window-enter {
|
||||
animation: qd-window-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes qd-window-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-window__header {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.qd-window__icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.qd-window__title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.qd-window__counter {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 列表:固定 5 行,无上下 padding(首行紧贴标题分隔线),滚动条隐藏 */
|
||||
.qd-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: calc(var(--qd-row-h) * 5);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.qd-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ---------------- 行进入 / 移出动画(待办 / 子智能体 / 后台指令 / 文件共用) ---------------- */
|
||||
|
||||
.qd-row-enter {
|
||||
opacity: 0;
|
||||
animation: qd-row-in 0.34s var(--qd-ease-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes qd-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-row-leave {
|
||||
animation: qd-row-out 0.26s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes qd-row-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
height: var(--qd-row-h);
|
||||
}
|
||||
55% {
|
||||
opacity: 0;
|
||||
transform: translateX(-14px);
|
||||
height: var(--qd-row-h);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-14px);
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 待办行 ---------------- */
|
||||
|
||||
.qd-todo-item {
|
||||
height: var(--qd-row-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden; /* 配合移出动画的高度收拢 */
|
||||
}
|
||||
|
||||
.qd-todo-dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-tertiary);
|
||||
background: transparent;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.qd-todo-item.is-done .qd-todo-dot {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.qd-todo-text {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.22s ease 0.12s; /* 变灰略滞后于划线 */
|
||||
}
|
||||
|
||||
.qd-todo-text::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
top: 50%;
|
||||
height: 1.5px;
|
||||
margin-top: -0.75px;
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 1px;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 完成静态态(整表渲染时已完成行直接呈现,不播划线) */
|
||||
.qd-todo-item.is-done .qd-todo-text {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.qd-todo-item.is-done .qd-todo-text::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* 完成动作瞬间:横线从左往右划过 + 状态点弹跳 */
|
||||
.qd-todo-item.just-done .qd-todo-text::after {
|
||||
animation: qd-strike-in 0.28s var(--qd-ease-strike) forwards;
|
||||
}
|
||||
|
||||
@keyframes qd-strike-in {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-todo-item.just-done .qd-todo-dot {
|
||||
animation: qd-dot-pop 0.32s var(--qd-ease-out);
|
||||
}
|
||||
|
||||
@keyframes qd-dot-pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
45% {
|
||||
transform: scale(1.6);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 子智能体 / 后台指令行 ---------------- */
|
||||
|
||||
.qd-run-item {
|
||||
height: var(--qd-row-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px 0 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.qd-run-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.qd-run-item.is-active {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.qd-run-status {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 状态配色对齐旧工作区面板 .sub-agent-status:
|
||||
运行中=info 蓝 / 空闲=次要文字白 / 完成=success 绿 / 失败·超时·终止=danger 红 */
|
||||
.qd-run-item.is-running .qd-run-status {
|
||||
background: var(--state-info);
|
||||
animation: qd-status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.qd-run-item.is-idle .qd-run-status {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qd-run-item.is-done .qd-run-status {
|
||||
background: var(--state-success);
|
||||
}
|
||||
|
||||
.qd-run-item.is-ended .qd-run-status {
|
||||
background: var(--state-danger);
|
||||
}
|
||||
|
||||
@keyframes qd-status-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.qd-run-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.qd-run-item[data-kind='cmd'] .qd-run-name {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.qd-run-item.is-done .qd-run-name,
|
||||
.qd-run-item.is-ended .qd-run-name {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ⋯ 按钮(行内) */
|
||||
.qd-row-menu-btn {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
.qd-row-menu-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.qd-row-menu-btn:hover {
|
||||
background: var(--tab-active);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---------------- 文件行 ---------------- */
|
||||
|
||||
.qd-file-item {
|
||||
height: var(--qd-row-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 10px 0 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.qd-file-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.qd-file-item.is-active {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.qd-file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------------- 详情面板(fixed 浮在列左侧) ---------------- */
|
||||
|
||||
.qd-detail {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: calc(294px + 12px); /* 快捷窗口列宽 + gap */
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 360px);
|
||||
height: 460px;
|
||||
max-height: calc(100vh - 40px);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.qd-detail.panel-enter {
|
||||
animation: qd-panel-in 0.24s var(--qd-ease-out);
|
||||
}
|
||||
|
||||
@keyframes qd-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-detail.panel-leave {
|
||||
animation: qd-panel-out 0.18s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes qd-panel-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-detail__header {
|
||||
flex: none;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.qd-detail__dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.qd-detail__dot.is-running {
|
||||
background: var(--state-info);
|
||||
animation: qd-status-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.qd-detail__dot.is-idle {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qd-detail__dot.is-done {
|
||||
background: var(--state-success);
|
||||
}
|
||||
|
||||
.qd-detail__dot.is-ended {
|
||||
background: var(--state-danger);
|
||||
}
|
||||
|
||||
.qd-detail__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qd-detail__badge {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.qd-detail__badge.is-running {
|
||||
background: color-mix(in srgb, var(--state-info) 14%, transparent);
|
||||
color: var(--state-info);
|
||||
}
|
||||
|
||||
.qd-detail__badge.is-idle {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qd-detail__badge.is-done {
|
||||
background: color-mix(in srgb, var(--state-success) 14%, transparent);
|
||||
color: var(--state-success);
|
||||
}
|
||||
|
||||
.qd-detail__badge.is-ended {
|
||||
background: color-mix(in srgb, var(--state-danger) 14%, transparent);
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.qd-detail__close {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
.qd-detail__close svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.qd-detail__close:hover {
|
||||
background: var(--tab-active);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qd-detail__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0 10px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.qd-detail__body::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.qd-detail__body.body-fade {
|
||||
animation: qd-body-fade-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes qd-body-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.qd-detail__empty {
|
||||
padding: 18px 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ---------------- feed 行(扁平行式,无嵌套卡片) ---------------- */
|
||||
|
||||
.qd-feed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
min-height: 28px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 仅「面板打开期间新出现」的行播进入动画;打开时已有的行由组件静态渲染,
|
||||
避免每次打开详情全部行重播一遍出现动画 */
|
||||
.qd-feed-row.is-new {
|
||||
animation: qd-feed-in 0.25s var(--qd-ease-out);
|
||||
}
|
||||
|
||||
@keyframes qd-feed-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-text {
|
||||
align-items: flex-start;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-tool .tool-name {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-tool .tool-param {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-tool .tool-result {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-tool .tool-result.is-error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.qd-tool-spinner {
|
||||
flex: none;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
animation: qd-tool-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes qd-tool-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-tool-done {
|
||||
flex: none;
|
||||
width: 11px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-term {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-term.is-command {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qd-feed-row.feed-killed {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
/* ---------------- 文件预览侧边栏(占位列) ---------------- */
|
||||
|
||||
.qd-preview {
|
||||
flex: none;
|
||||
width: 452px; /* 440 + 右 12 padding */
|
||||
max-width: calc(100vw - 200px);
|
||||
padding: 20px 12px 20px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.qd-preview__panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 预览侧边栏滑入滑出(对齐 git/终端面板 right-panels-slide 的参数) */
|
||||
.qd-preview-slide-enter-active,
|
||||
.qd-preview-slide-leave-active {
|
||||
transition:
|
||||
width 0.24s ease,
|
||||
padding 0.24s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.24s var(--qd-ease-out);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qd-preview-slide-enter-from,
|
||||
.qd-preview-slide-leave-to {
|
||||
width: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.qd-preview__header {
|
||||
flex: none;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.qd-preview__header > svg {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.qd-preview__name {
|
||||
flex: none;
|
||||
max-width: 45%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.qd-preview__path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11.5px;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.qd-preview__close {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
.qd-preview__close svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.qd-preview__close:hover {
|
||||
background: var(--tab-active);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qd-preview__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 0 12px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.qd-preview__body::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.qd-preview__loading,
|
||||
.qd-preview__error {
|
||||
padding: 18px 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.qd-preview__error {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.qd-code-line {
|
||||
display: flex;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.qd-code-line:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.qd-code-line .line-no {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
padding-right: 12px;
|
||||
text-align: right;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.qd-code-line .line-text {
|
||||
flex: 1;
|
||||
padding-right: 14px;
|
||||
white-space: pre;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---------------- 全局 ⋯ 菜单(fixed 单例) ---------------- */
|
||||
|
||||
.qd-menu {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
min-width: 116px;
|
||||
padding: 4px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.qd-menu.menu-enter {
|
||||
animation: qd-menu-in 0.16s ease-out;
|
||||
}
|
||||
|
||||
@keyframes qd-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.qd-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.qd-menu__item:hover:not(:disabled) {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.qd-menu__item--danger {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.qd-menu__item--danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--state-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
.qd-menu__item:disabled {
|
||||
color: var(--text-tertiary);
|
||||
cursor: default;
|
||||
}
|
||||
@ -637,6 +637,28 @@
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">隐藏快捷窗口</span
|
||||
><span class="settings-row-desc"
|
||||
>关闭对话区右侧的待办 / 子智能体 / 后台指令 / 文件窗口列</span
|
||||
></span
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="form.hide_quick_dock"
|
||||
@change="
|
||||
personalization.updateField({
|
||||
key: 'hide_quick_dock',
|
||||
value: $event.target.checked
|
||||
})
|
||||
" /><span class="fancy-check" aria-hidden="true"
|
||||
><svg viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
pathLength="575.0541381835938"
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">按项目分组对话</span
|
||||
@ -654,7 +676,7 @@
|
||||
" /><span class="fancy-check" aria-hidden="true"
|
||||
><svg viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
pathLength="575.0541381835938"
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
|
||||
@ -20,6 +20,7 @@ import { io as createSocketClient } from 'socket.io-client';
|
||||
import { renderLatexInRealtime } from './useMarkdownRenderer';
|
||||
import { getMessageVisibility, messageStartsWork } from '../utils/messageVisibility';
|
||||
import { goalModeDebugLog } from '../app/methods/common';
|
||||
import { useQuickDockStore } from '../stores/quickDock';
|
||||
|
||||
export async function initializeLegacySocket(ctx: any) {
|
||||
try {
|
||||
@ -733,6 +734,23 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
ctx.fileSetTodoList((data && data.todo_list) || null);
|
||||
});
|
||||
|
||||
// 快捷窗口:本次对话编辑/创建文件记录更新
|
||||
ctx.socket.on('edited_files_updated', (data) => {
|
||||
socketLog('收到编辑文件记录更新事件:', data);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
// 全局广播带 conversation_id,只响应当前对话的更新
|
||||
if (
|
||||
data.conversation_id &&
|
||||
ctx.currentConversationId &&
|
||||
data.conversation_id !== ctx.currentConversationId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
useQuickDockStore().setEditedFiles(data.edited_files || []);
|
||||
});
|
||||
|
||||
// 系统就绪
|
||||
ctx.socket.on('system_ready', (data) => {
|
||||
ctx.projectPath = data.project_path || '';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useUiStore } from './ui';
|
||||
import { useConversationStore } from './conversation';
|
||||
|
||||
interface BackgroundCommand {
|
||||
command_id: string;
|
||||
@ -23,12 +24,21 @@ interface BackgroundCommandState {
|
||||
pollTimer: ReturnType<typeof setInterval> | null;
|
||||
detailPollTimer: ReturnType<typeof setInterval> | null;
|
||||
activeCommand: BackgroundCommand | null;
|
||||
/** QuickDock 静默查看的 command_id:拉详情/轮询但不设 activeCommand(避免弹出旧详情窗口) */
|
||||
silentDetailCommandId: string | null;
|
||||
stoppingCommandIds: Record<string, boolean>;
|
||||
activeDetail: BackgroundCommandDetail | null;
|
||||
detailLoading: boolean;
|
||||
detailError: string | null;
|
||||
}
|
||||
|
||||
/** 必须携带 conversation_id:with_terminal 无会话参数时会落到工作区级服务 terminal,
|
||||
* 其 manager / current_conversation_id 与对话级 terminal 不一致,会查不到数据。 */
|
||||
function convQuery(): string {
|
||||
const convId = useConversationStore().currentConversationId;
|
||||
return convId ? `conversation_id=${encodeURIComponent(convId)}` : '';
|
||||
}
|
||||
|
||||
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'timeout', 'cancelled']);
|
||||
|
||||
export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
@ -37,6 +47,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
pollTimer: null,
|
||||
detailPollTimer: null,
|
||||
activeCommand: null,
|
||||
silentDetailCommandId: null,
|
||||
stoppingCommandIds: {},
|
||||
activeDetail: null,
|
||||
detailLoading: false,
|
||||
@ -45,7 +56,16 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
actions: {
|
||||
async fetchCommands() {
|
||||
try {
|
||||
const resp = await fetch('/api/background_commands?limit=200');
|
||||
// /new 等无对话场景:不请求后端,直接置空(服务 terminal 可能命中其他对话的记录)
|
||||
const convId = useConversationStore().currentConversationId;
|
||||
if (!convId) {
|
||||
this.commands = [];
|
||||
return;
|
||||
}
|
||||
const query = convQuery();
|
||||
const resp = await fetch(
|
||||
`/api/background_commands?limit=200${query ? `&${query}` : ''}`
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
@ -81,11 +101,16 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
this.pollTimer = null;
|
||||
}
|
||||
},
|
||||
openCommand(command: BackgroundCommand) {
|
||||
openCommand(command: BackgroundCommand, options?: { silent?: boolean }) {
|
||||
if (!command || !command.command_id) {
|
||||
return;
|
||||
}
|
||||
this.activeCommand = command;
|
||||
if (options?.silent) {
|
||||
this.silentDetailCommandId = command.command_id;
|
||||
} else {
|
||||
this.activeCommand = command;
|
||||
this.silentDetailCommandId = null;
|
||||
}
|
||||
this.activeDetail = null;
|
||||
this.detailError = null;
|
||||
this.fetchCommandDetail(command.command_id);
|
||||
@ -94,6 +119,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
closeCommand() {
|
||||
this.stopDetailPolling();
|
||||
this.activeCommand = null;
|
||||
this.silentDetailCommandId = null;
|
||||
this.activeDetail = null;
|
||||
this.detailError = null;
|
||||
this.detailLoading = false;
|
||||
@ -103,7 +129,7 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
return;
|
||||
}
|
||||
this.detailPollTimer = setInterval(() => {
|
||||
const commandId = this.activeCommand?.command_id;
|
||||
const commandId = this.activeCommand?.command_id || this.silentDetailCommandId;
|
||||
if (commandId) {
|
||||
this.fetchCommandDetail(commandId);
|
||||
}
|
||||
@ -119,7 +145,10 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
if (!commandId) return;
|
||||
this.detailLoading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/background_commands/${encodeURIComponent(commandId)}`);
|
||||
const query = convQuery();
|
||||
const resp = await fetch(
|
||||
`/api/background_commands/${encodeURIComponent(commandId)}${query ? `?${query}` : ''}`
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
@ -158,8 +187,9 @@ export const useBackgroundCommandStore = defineStore('backgroundCommand', {
|
||||
[normalizedId]: true
|
||||
};
|
||||
try {
|
||||
const query = convQuery();
|
||||
const resp = await fetch(
|
||||
`/api/background_commands/${encodeURIComponent(normalizedId)}/cancel`,
|
||||
`/api/background_commands/${encodeURIComponent(normalizedId)}/cancel${query ? `?${query}` : ''}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
|
||||
@ -75,6 +75,10 @@ export const useConversationStore = defineStore('conversation', {
|
||||
multiAgentMode: false
|
||||
}),
|
||||
actions: {
|
||||
/** 当前对话 id 同步(由 app watcher 在 this.currentConversationId 变化时写入) */
|
||||
setCurrentConversationId(id: string | null) {
|
||||
this.currentConversationId = id;
|
||||
},
|
||||
resetConversations() {
|
||||
this.conversations = [];
|
||||
this.searchResults = [];
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useConversationStore } from './conversation';
|
||||
|
||||
const FILE_STORE_DEBUG_LOGS = false;
|
||||
function fileDebugLog(...args: unknown[]) {
|
||||
@ -38,6 +39,8 @@ interface FileState {
|
||||
fileTree: FileNode[];
|
||||
expandedFolders: Record<string, boolean>;
|
||||
todoList: TodoList | null;
|
||||
/** true = 来自任务期实时事件(播动画);false = 来自加载/fetch(静态呈现) */
|
||||
todoListLive: boolean;
|
||||
fileTreeUnavailable: boolean;
|
||||
fileTreeMessage: string;
|
||||
contextMenu: ContextMenuState;
|
||||
@ -78,6 +81,7 @@ export const useFileStore = defineStore('file', {
|
||||
fileTree: [],
|
||||
expandedFolders: {},
|
||||
todoList: null,
|
||||
todoListLive: false,
|
||||
fileTreeUnavailable: false,
|
||||
fileTreeMessage: '',
|
||||
contextMenu: {
|
||||
@ -163,17 +167,25 @@ export const useFileStore = defineStore('file', {
|
||||
},
|
||||
async fetchTodoList() {
|
||||
try {
|
||||
const response = await fetch('/api/todo-list');
|
||||
// 必须携带 conversation_id:todo 数据保存在对话级 terminal 的内存中,
|
||||
// 不带 id 时后端返回工作区级服务 terminal(永远为空)。
|
||||
const convId = useConversationStore().currentConversationId;
|
||||
const url = convId
|
||||
? `/api/todo-list?conversation_id=${encodeURIComponent(convId)}`
|
||||
: '/api/todo-list';
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (data && data.success) {
|
||||
this.todoList = data.data || null;
|
||||
this.todoListLive = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取待办列表失败:', error);
|
||||
}
|
||||
},
|
||||
setTodoList(payload: TodoList | null) {
|
||||
setTodoList(payload: TodoList | null, live = false) {
|
||||
this.todoList = payload;
|
||||
this.todoListLive = live;
|
||||
},
|
||||
showContextMenu(payload: { node: FileNode; event: MouseEvent }) {
|
||||
if (!payload || !payload.node) {
|
||||
|
||||
@ -63,6 +63,7 @@ interface PersonalForm {
|
||||
agents_md_auto_inject: boolean;
|
||||
allow_root_file_creation: boolean;
|
||||
default_hide_workspace: boolean;
|
||||
hide_quick_dock: boolean;
|
||||
group_sidebar_by_workspace: boolean;
|
||||
sidebar_pinned_workspaces: string[];
|
||||
sidebar_workspace_order: string[];
|
||||
@ -225,6 +226,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
agents_md_auto_inject: false,
|
||||
allow_root_file_creation: false,
|
||||
default_hide_workspace: false,
|
||||
hide_quick_dock: false,
|
||||
group_sidebar_by_workspace: false,
|
||||
sidebar_pinned_workspaces: [],
|
||||
sidebar_workspace_order: [],
|
||||
@ -465,6 +467,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
agents_md_auto_inject: !!data.agents_md_auto_inject,
|
||||
allow_root_file_creation: !!data.allow_root_file_creation,
|
||||
default_hide_workspace: !!data.default_hide_workspace,
|
||||
hide_quick_dock: !!data.hide_quick_dock,
|
||||
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
|
||||
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)
|
||||
? data.sidebar_pinned_workspaces.filter((item: unknown) => typeof item === 'string')
|
||||
|
||||
122
static/src/stores/quickDock.ts
Normal file
122
static/src/stores/quickDock.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
/**
|
||||
* 快捷窗口(Quick Dock)状态
|
||||
* 对话区右侧占位列:待办 / 子智能体 / 后台指令 / 文件记录 四个窗口。
|
||||
* 本 store 管理:文件记录列表、详情面板目标、文件预览目标、全局 ⋯ 菜单。
|
||||
*/
|
||||
|
||||
export interface EditedFileEntry {
|
||||
path: string;
|
||||
op?: string;
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
export interface QuickDockDetailTarget {
|
||||
kind: 'agent' | 'cmd';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface QuickDockMenuState {
|
||||
/** runner = 子智能体/后台指令条目菜单(强制关闭);file = 文件条目菜单 */
|
||||
type: 'runner' | 'file';
|
||||
/** 仅 runner:区分子智能体 / 后台指令 */
|
||||
kind?: 'agent' | 'cmd';
|
||||
/** runner: task_id / command_id;file: 相对路径 */
|
||||
key: string;
|
||||
/** fixed 定位坐标 */
|
||||
left: number;
|
||||
top: number;
|
||||
/** 菜单与触发按钮右对齐(runner 的 ⋯ 在行右侧) */
|
||||
alignRight: boolean;
|
||||
}
|
||||
|
||||
interface QuickDockState {
|
||||
editedFiles: EditedFileEntry[];
|
||||
/** true = 来自任务期实时事件(播动画);false = 来自加载/bootstrap(静态呈现) */
|
||||
editedFilesLive: boolean;
|
||||
detail: QuickDockDetailTarget | null;
|
||||
previewPath: string | null;
|
||||
menu: QuickDockMenuState | null;
|
||||
}
|
||||
|
||||
export const useQuickDockStore = defineStore('quickDock', {
|
||||
state: (): QuickDockState => ({
|
||||
editedFiles: [],
|
||||
editedFilesLive: false,
|
||||
detail: null,
|
||||
previewPath: null,
|
||||
menu: null
|
||||
}),
|
||||
actions: {
|
||||
setEditedFiles(list: EditedFileEntry[] | null | undefined, live = false) {
|
||||
this.editedFilesLive = live;
|
||||
if (!Array.isArray(list)) {
|
||||
this.editedFiles = [];
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const normalized: EditedFileEntry[] = [];
|
||||
for (const item of list) {
|
||||
if (!item || typeof item.path !== 'string' || !item.path) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(item.path)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(item.path);
|
||||
normalized.push({ path: item.path, op: item.op, ts: item.ts });
|
||||
}
|
||||
this.editedFiles = normalized;
|
||||
// 列表移除正在预览的文件时,同步关闭预览
|
||||
if (this.previewPath && !seen.has(this.previewPath)) {
|
||||
this.previewPath = null;
|
||||
}
|
||||
},
|
||||
openDetail(kind: 'agent' | 'cmd', id: string) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
// 再点同一条目 = 收起
|
||||
if (this.detail && this.detail.kind === kind && this.detail.id === id) {
|
||||
this.detail = null;
|
||||
return;
|
||||
}
|
||||
this.detail = { kind, id };
|
||||
},
|
||||
closeDetail() {
|
||||
this.detail = null;
|
||||
},
|
||||
openPreview(path: string) {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
// 再点同一文件 = 收起
|
||||
if (this.previewPath === path) {
|
||||
this.previewPath = null;
|
||||
return;
|
||||
}
|
||||
this.previewPath = path;
|
||||
},
|
||||
closePreview() {
|
||||
this.previewPath = null;
|
||||
},
|
||||
openMenu(menu: QuickDockMenuState) {
|
||||
// 同一按钮再点 = 收起
|
||||
if (this.menu && this.menu.type === menu.type && this.menu.key === menu.key) {
|
||||
this.menu = null;
|
||||
return;
|
||||
}
|
||||
this.menu = menu;
|
||||
},
|
||||
closeMenu() {
|
||||
this.menu = null;
|
||||
},
|
||||
/** 切换对话 / 隐藏快捷窗口时重置全部瞬态 */
|
||||
resetTransient() {
|
||||
this.detail = null;
|
||||
this.previewPath = null;
|
||||
this.menu = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -28,6 +28,8 @@ interface SubAgentState {
|
||||
pollTimer: ReturnType<typeof setInterval> | null;
|
||||
activityTimer: ReturnType<typeof setInterval> | null;
|
||||
activeAgent: SubAgent | null;
|
||||
/** QuickDock 静默查看的 task_id(不设 activeAgent,避免弹出旧进度窗口) */
|
||||
silentActivityTaskId: string | null;
|
||||
stoppingTaskIds: Record<string, boolean>;
|
||||
activityEntries: SubAgentActivityEntry[];
|
||||
activityLoading: boolean;
|
||||
@ -42,6 +44,8 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
pollTimer: null,
|
||||
activityTimer: null,
|
||||
activeAgent: null,
|
||||
/** QuickDock 静默查看的 task_id:拉 activity/轮询但不设 activeAgent(避免弹出旧进度窗口) */
|
||||
silentActivityTaskId: null,
|
||||
stoppingTaskIds: {},
|
||||
activityEntries: [],
|
||||
activityLoading: false,
|
||||
@ -51,11 +55,21 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
async fetchSubAgents() {
|
||||
try {
|
||||
const conversationStore = useConversationStore();
|
||||
const convId = conversationStore.currentConversationId;
|
||||
// /new 等无对话场景:不请求后端,直接置空。
|
||||
// 否则后端服务 terminal 的「当前对话」可能是最近加载的对话(或命中全局
|
||||
// running 兜底),把其他对话的子智能体显示到新对话页。
|
||||
if (!convId) {
|
||||
this.subAgents = [];
|
||||
return;
|
||||
}
|
||||
let resp;
|
||||
if (conversationStore.multiAgentMode && conversationStore.currentConversationId) {
|
||||
resp = await fetch(`/api/multiagent/active_sub_agents?conversation_id=${encodeURIComponent(conversationStore.currentConversationId)}`);
|
||||
if (conversationStore.multiAgentMode) {
|
||||
resp = await fetch(`/api/multiagent/active_sub_agents?conversation_id=${encodeURIComponent(convId)}`);
|
||||
} else {
|
||||
resp = await fetch('/api/sub_agents');
|
||||
// 必须携带 conversation_id:后端按 terminal 的当前对话过滤,无参数时落到
|
||||
// 工作区级服务 terminal,其 current_conversation_id 与用户查看的对话不一致。
|
||||
resp = await fetch(`/api/sub_agents?conversation_id=${encodeURIComponent(convId)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
@ -95,11 +109,16 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
this.pollTimer = null;
|
||||
}
|
||||
},
|
||||
openSubAgent(agent: SubAgent) {
|
||||
openSubAgent(agent: SubAgent, options?: { silent?: boolean }) {
|
||||
if (!agent || !agent.task_id) {
|
||||
return;
|
||||
}
|
||||
this.activeAgent = agent;
|
||||
if (options?.silent) {
|
||||
this.silentActivityTaskId = agent.task_id;
|
||||
} else {
|
||||
this.activeAgent = agent;
|
||||
this.silentActivityTaskId = null;
|
||||
}
|
||||
this.activityEntries = [];
|
||||
this.activityError = null;
|
||||
this.fetchSubAgentActivity(agent.task_id);
|
||||
@ -108,6 +127,7 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
closeSubAgent() {
|
||||
this.stopActivityPolling();
|
||||
this.activeAgent = null;
|
||||
this.silentActivityTaskId = null;
|
||||
this.activityEntries = [];
|
||||
this.activityError = null;
|
||||
this.activityLoading = false;
|
||||
@ -117,7 +137,7 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
return;
|
||||
}
|
||||
this.activityTimer = setInterval(() => {
|
||||
const taskId = this.activeAgent?.task_id;
|
||||
const taskId = this.activeAgent?.task_id || this.silentActivityTaskId;
|
||||
if (taskId) {
|
||||
this.fetchSubAgentActivity(taskId);
|
||||
}
|
||||
@ -133,7 +153,11 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
if (!taskId) return;
|
||||
this.activityLoading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/sub_agents/${taskId}/activity?limit=100000`);
|
||||
// 携带 conversation_id:子智能体跑在对话级 terminal,服务 terminal 的
|
||||
// sub_agent_manager.tasks 里查不到该任务(404 → 前端永远「暂无进度」)。
|
||||
const convId = useConversationStore().currentConversationId;
|
||||
const query = convId ? `&conversation_id=${encodeURIComponent(convId)}` : '';
|
||||
const resp = await fetch(`/api/sub_agents/${taskId}/activity?limit=100000${query}`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
@ -147,6 +171,11 @@ export const useSubAgentStore = defineStore('subAgent', {
|
||||
status: data.data.status || this.activeAgent.status
|
||||
};
|
||||
}
|
||||
// 静默查看时同步列表项状态,保证 QuickDock 详情面板的状态角标及时更新
|
||||
const listItem = this.subAgents.find((item) => item.task_id === taskId);
|
||||
if (listItem && data.data.status) {
|
||||
listItem.status = data.data.status;
|
||||
}
|
||||
const status = (data.data.status || '').toString();
|
||||
if (TERMINAL_STATUSES.has(status)) {
|
||||
this.stopActivityPolling();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user