From 9750b0b8f1de3ea4222764502f3da2e25dc8fedb Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 14 Dec 2025 23:28:22 +0800 Subject: [PATCH] fix: sync wait overlay with runtime --- .../components/chat/VirtualMonitorSurface.vue | 36 +++ .../chat/monitor/MonitorDirector.ts | 247 +++++++++++++++--- .../components/chat/monitor/progressMap.ts | 4 +- .../components/chat/_virtual-monitor.scss | 135 ++++++++++ 4 files changed, 389 insertions(+), 33 deletions(-) diff --git a/static/src/components/chat/VirtualMonitorSurface.vue b/static/src/components/chat/VirtualMonitorSurface.vue index f60c726..dace265 100644 --- a/static/src/components/chat/VirtualMonitorSurface.vue +++ b/static/src/components/chat/VirtualMonitorSurface.vue @@ -158,9 +158,28 @@ +
+
+ + + + 等待 +
+
+
+
00
+
00
+
:
+
00
+
00
+
+
+
+
+
@@ -169,6 +188,7 @@
+
@@ -191,6 +211,14 @@
pointer
+ +
+
Z
+
Z
+
Z
+
Z
+
5s
+
@@ -253,6 +281,10 @@ const memoryTime = ref(null); const todoWindow = ref(null); const todoSummary = ref(null); const todoList = ref(null); +const waitWindow = ref(null); +const waitDisplay = ref(null); +const waitOverlay = ref(null); +const waitCountdown = ref(null); const desktopMenu = ref(null); const folderMenu = ref(null); const fileMenu = ref(null); @@ -334,6 +366,10 @@ const mountDirector = async () => { todoWindow: todoWindow.value!, todoSummary: todoSummary.value!, todoList: todoList.value!, + waitWindow: waitWindow.value!, + waitDisplay: waitDisplay.value!, + waitOverlay: waitOverlay.value!, + waitCountdown: waitCountdown.value!, desktopMenu: desktopMenu.value!, folderMenu: folderMenu.value!, fileMenu: fileMenu.value!, diff --git a/static/src/components/chat/monitor/MonitorDirector.ts b/static/src/components/chat/monitor/MonitorDirector.ts index d18781c..118f2a1 100644 --- a/static/src/components/chat/monitor/MonitorDirector.ts +++ b/static/src/components/chat/monitor/MonitorDirector.ts @@ -65,6 +65,10 @@ export interface MonitorElements { todoWindow: HTMLElement; todoSummary: HTMLElement; todoList: HTMLElement; + waitWindow: HTMLElement; + waitDisplay: HTMLElement; + waitOverlay: HTMLElement; + waitCountdown: HTMLElement; desktopMenu: HTMLElement; folderMenu: HTMLElement; fileMenu: HTMLElement; @@ -237,6 +241,9 @@ export class MonitorDirector implements MonitorDriver { private waitingBubbleTimer: number | null = null; private waitingBubbleBase: string | null = null; private progressBubbleActive = false; + private secondaryMenu: HTMLElement | null = null; + private waitDigits: HTMLElement[] = []; + private waitOverlayTimer: number | null = null; // 当实际执行进度快于动画播放时,用于压制“正在 xxx”提示 private playbackLagging = false; // 记录最近一次 Python 执行的ID,用于丢弃过期动画/结果 @@ -2087,7 +2094,8 @@ export class MonitorDirector implements MonitorDriver { this.elements.terminalWindow, this.elements.readerWindow, this.elements.memoryWindow, - this.elements.todoWindow + this.elements.todoWindow, + this.elements.waitWindow ].forEach(win => this.closeWindow(win, { animate: false })); this.clearExtractionWindows(); this.clearTerminalSessions(); @@ -2106,10 +2114,12 @@ export class MonitorDirector implements MonitorDriver { this.windowAnchors.set(this.elements.readerWindow, { x: 0.42, y: 0.05 }); this.windowAnchors.set(this.elements.memoryWindow, { x: 0.28, y: 0.32 }); this.windowAnchors.set(this.elements.todoWindow, { x: 0.5, y: 0.32 }); + this.windowAnchors.set(this.elements.waitWindow, { x: 0.74, y: 0.24 }); } private layoutFloatingWindows() { this.windowAnchors.forEach((anchor, el) => this.positionWindow(el, anchor)); + this.hideWaitOverlay(); } private positionWindow(el: HTMLElement, anchor: { x: number; y: number }) { @@ -2380,6 +2390,25 @@ export class MonitorDirector implements MonitorDriver { await sleep(300); return; } + const openDeleteMenu = async (targetEl: HTMLElement | null, menuType: ContextMenuType = 'file') => { + if (!targetEl) { + return false; + } + await this.movePointerToElement(targetEl, { duration: 600 }); + await this.click({ right: true }); + this.showContextMenu('file'); + await sleep(160); + const highlighted = await this.highlightMenu('file', 'delete'); + if (!highlighted) { + const btn = this.elements.fileMenu.querySelector('button[data-action="delete"]'); + if (btn) { + await this.movePointerToElement(btn, { duration: 320 }); + } + } + await this.click(); + this.hideContextMenus(); + return true; + }; if (segments.length) { const parentKey = await this.openFolderChain(segments); if (parentKey) { @@ -2390,20 +2419,18 @@ export class MonitorDirector implements MonitorDriver { await sleep(40); const entryPath = this.composePath([parentKey, name]); const entryEl = this.findFolderEntryElement(entryPath); - if (entryEl) { - await this.movePointerToElement(entryEl, { duration: 600 }); - entryEl.classList.add('removing'); - setTimeout(() => { - this.removeFolderEntry(parentKey, name); - }, 260); - } else { + const menuShown = await openDeleteMenu(entryEl, 'file'); + if (!menuShown) { this.removeFolderEntry(parentKey, name); + return; } + entryEl?.classList.add('removing'); + setTimeout(() => this.removeFolderEntry(parentKey, name), 260); } } else { const icon = this.fileIcons.get(name) || this.folderIcons.get(name); if (icon) { - await this.movePointerToElement(icon, { duration: 500 }); + await openDeleteMenu(icon, 'file'); icon.classList.add('removing'); setTimeout(() => icon.remove(), 320); this.fileIcons.delete(name); @@ -2420,6 +2447,28 @@ export class MonitorDirector implements MonitorDriver { await sleep(400); }; + this.sceneHandlers.wait = async (payload, runtime) => { + this.applySceneStatus(runtime, 'wait', '正在等待'); + const duration = + Number(payload?.arguments?.duration || payload?.arguments?.seconds || payload?.seconds || payload?.duration) || 5; + await this.movePointerToDesktop(); + await this.click({ right: true }); + this.showContextMenu('desktop'); + await sleep(200); + const highlighted = await this.highlightMenu('desktop', 'wait'); + if (!highlighted) { + const btn = this.elements.desktopMenu.querySelector('button[data-action="wait"]'); + if (btn) { + await this.movePointerToElement(btn, { duration: 320 }); + } + } + await this.click(); + this.hideContextMenus(); + await sleep(140); // 等菜单完全收起后再出现等待提示 + const waitPromise = runtime.waitForResult(payload.executionId || payload.id).catch(() => null); + await this.playWaitCountdown(duration, waitPromise); + }; + this.sceneHandlers.appendFile = async (payload, runtime) => { this.applySceneStatus(runtime, 'appendFile', '正在编辑'); const rawPath = payload?.arguments?.path || payload?.argumentSnapshot?.path || 'file.txt'; @@ -2956,20 +3005,29 @@ export class MonitorDirector implements MonitorDriver { }; this.sceneHandlers.terminalSleep = async (payload, runtime) => { - this.applySceneStatus(runtime, 'terminalSleep', '正在关闭终端'); - const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: false, activate: false }); - terminalMenuDebug('scene:terminalSleep:start', { sessionId }); - await this.openTerminalContextMenu(sessionId); - terminalMenuDebug('scene:terminalSleep:menu-opened', { sessionId }); - await this.chooseTerminalMenuAction('close'); - this.closeTerminalSession(sessionId); - // 如果已无会话,收起终端窗口以呈现“窗口被关闭”效果 - if (!this.terminalSessions.size) { - this.closeWindow(this.elements.terminalWindow, { animate: true }); + this.applySceneStatus(runtime, 'terminalSleep', '正在等待'); + const duration = + Number(payload?.arguments?.duration || payload?.arguments?.seconds || payload?.seconds || payload?.duration) || 5; + await this.movePointerToDesktop(); + await this.click({ right: true }); + this.showContextMenu('desktop'); + await sleep(200); + const highlighted = await this.highlightMenu('desktop', 'wait'); + if (!highlighted) { + const btn = this.elements.desktopMenu.querySelector('button[data-action="wait"]'); + if (btn) { + await this.movePointerToElement(btn, { duration: 320 }); + } } - await sleep(320); + await this.click(); + this.hideContextMenus(); + await sleep(140); // 等菜单完全收起后再出现等待提示 + const waitPromise = runtime.waitForResult(payload.executionId || payload.id).catch(() => null); + await this.playWaitCountdown(duration, waitPromise); }; + this.sceneHandlers.sleep = this.sceneHandlers.wait; + this.sceneHandlers.webSave = async (_payload, runtime) => { this.applySceneStatus(runtime, 'webSave', '正在保存网页'); const targetUrl = @@ -3521,9 +3579,57 @@ export class MonitorDirector implements MonitorDriver { menu.classList.remove('visible'); menu.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); }); + this.hideSecondaryMenu(); + this.hideWaitOverlay(); this.terminalContextSessionId = null; } + private hideSecondaryMenu() { + if (this.secondaryMenu && this.secondaryMenu.parentElement) { + this.secondaryMenu.parentElement.removeChild(this.secondaryMenu); + } + this.secondaryMenu = null; + } + + private showSecondaryMenu(anchorMenu: HTMLElement | null, items: Array<{ label: string; action: string }>) { + this.hideSecondaryMenu(); + if (!anchorMenu || !items.length) { + return null; + } + const menu = document.createElement('div'); + menu.className = 'context-menu secondary-menu visible'; + items.forEach(item => { + const btn = document.createElement('button'); + btn.textContent = item.label; + btn.dataset.action = item.action; + menu.appendChild(btn); + }); + this.elements.screen.appendChild(menu); + const rect = anchorMenu.getBoundingClientRect(); + const left = rect.right - this.screenRect.left + 10; + const top = rect.top - this.screenRect.top + 4; + const { x, y } = this.clampToScreen(left, top, menu.offsetWidth || 180, menu.offsetHeight || 60); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + this.secondaryMenu = menu; + return menu; + } + + private async chooseSecondaryMenuAction(action: string) { + const btn = this.secondaryMenu?.querySelector(`button[data-action="${action}"]`) || null; + if (!btn) { + return false; + } + await this.movePointerToElement(btn, { duration: 320 }); + btn.classList.add('active'); + await sleep(200); + btn.classList.remove('active'); + await this.click(); + await sleep(140); + this.hideSecondaryMenu(); + return true; + } + private async highlightMenu(type: ContextMenuType, action: string): Promise { const map: Record = { desktop: this.elements.desktopMenu, @@ -3620,6 +3726,59 @@ export class MonitorDirector implements MonitorDriver { return menu.classList.contains('visible'); } + private hideWaitOverlay() { + if (!this.elements.waitOverlay) return; + this.elements.waitOverlay.classList.remove('active'); + if (this.waitOverlayTimer) { + clearInterval(this.waitOverlayTimer); + this.waitOverlayTimer = null; + } + } + + private formatWaitText(seconds: number) { + if (seconds >= 60) { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, '0'); + const s = (seconds % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + } + return `${seconds}s`; + } + + private async playWaitCountdown(durationSeconds = 5, until?: Promise) { + const total = Math.max(1, Math.round(durationSeconds)); + if (!this.elements.waitOverlay || !this.elements.waitCountdown) { + return; + } + const tip = this.getPointerTip(); + this.elements.waitOverlay.style.left = `${tip.x + 16}px`; + this.elements.waitOverlay.style.top = `${tip.y - 28}px`; + const start = performance.now(); + this.elements.waitCountdown.textContent = this.formatWaitText(total); + this.elements.waitOverlay.classList.add('active'); + if (this.waitOverlayTimer) { + clearInterval(this.waitOverlayTimer); + this.waitOverlayTimer = null; + } + const update = () => { + const elapsed = (performance.now() - start) / 1000; + const remain = Math.max(0, total - elapsed); + if (this.elements.waitCountdown) { + this.elements.waitCountdown.textContent = this.formatWaitText(Math.ceil(remain)); + } + }; + this.waitOverlayTimer = window.setInterval(update, 200); + const done = until || Promise.resolve(); + try { + await done; + } catch (_err) { + // 忽略错误,仍然结束动画 + } + update(); + this.hideWaitOverlay(); + } + /** * 将指针瞬时对准目标元素的中心,防止动画收尾时产生偏移 */ @@ -5134,25 +5293,49 @@ export class MonitorDirector implements MonitorDriver { await sleep(180); } - private async scrollTodoItemIntoView(card: HTMLElement | null) { + private async scrollTodoItemIntoView(card: HTMLElement | null, options: { waitMs?: number } = {}) { if (!card || !this.elements.todoList) return; const body = this.elements.todoList; - const cardTop = card.offsetTop; - const cardBottom = cardTop + card.offsetHeight; - const viewTop = body.scrollTop; - const viewBottom = viewTop + body.clientHeight; + const margin = 8; + const wait = typeof options.waitMs === 'number' ? options.waitMs : 120; - // 如果完整可见,直接返回 - if (cardTop >= viewTop && cardBottom <= viewBottom) { + const measure = () => { + const bodyRect = body.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + const relTop = cardRect.top - bodyRect.top + body.scrollTop; + const relBottom = relTop + cardRect.height; + const viewTop = body.scrollTop; + const viewBottom = viewTop + body.clientHeight; + return { relTop, relBottom, viewTop, viewBottom }; + }; + + let { relTop, relBottom, viewTop, viewBottom } = measure(); + + // 已完整可见(含少量缓冲),直接返回 + if (relTop >= viewTop + margin && relBottom <= viewBottom - margin) { return; } - // 让整个卡片进入视口:若底部被遮挡,滚到卡片底部露出;否则滚到卡片顶部 - const targetTop = - cardBottom > viewBottom ? cardBottom - body.clientHeight + 4 : Math.max(0, cardTop - 4); + // 计算需要滚动的位置(基于相对坐标,避免 offsetParent 不一致的问题) + const needsScrollDown = relBottom > viewBottom - margin; + const targetTop = needsScrollDown ? relBottom - body.clientHeight + margin : Math.max(0, relTop - margin); + const clampedTop = Math.max(0, targetTop); - body.scrollTop = Math.max(0, targetTop); - await sleep(30); + body.scrollTo({ top: clampedTop, behavior: 'smooth' }); + await this.waitForScrollSettled(body, clampedTop); + if (wait > 0) { + await sleep(wait); + } + + // 二次校验,确保整块已进入可视区域 + ({ relTop, relBottom, viewTop, viewBottom } = measure()); + if (relTop < viewTop + margin) { + body.scrollTop = Math.max(0, relTop - margin); + await sleep(30); + } else if (relBottom > viewBottom - margin) { + body.scrollTop = relBottom - body.clientHeight + margin; + await sleep(30); + } } private async toggleTodoItem(text?: string | null, done?: boolean, index?: number | null) { diff --git a/static/src/components/chat/monitor/progressMap.ts b/static/src/components/chat/monitor/progressMap.ts index a721e8d..70ddfaa 100644 --- a/static/src/components/chat/monitor/progressMap.ts +++ b/static/src/components/chat/monitor/progressMap.ts @@ -21,10 +21,12 @@ export const SCENE_PROGRESS_LABELS: Record = { todoFinish: '正在完成任务', todoFinishConfirm: '正在确认任务', todoDelete: '正在移除任务', + wait: '正在等待', + sleep: '正在等待', createFolder: '正在创建文件夹', renameFile: '正在重命名', terminalReset: '正在重置终端', - terminalSleep: '正在暂停终端', + terminalSleep: '准备等待', terminalRun: '正在执行', ocr: '正在提取', memory: '正在同步记忆', diff --git a/static/src/styles/components/chat/_virtual-monitor.scss b/static/src/styles/components/chat/_virtual-monitor.scss index ebe7ff4..fe2d442 100644 --- a/static/src/styles/components/chat/_virtual-monitor.scss +++ b/static/src/styles/components/chat/_virtual-monitor.scss @@ -1253,6 +1253,87 @@ border: 1px solid rgba(39, 201, 119, 0.4); } +.virtual-monitor-surface .wait-window { + width: 260px; + height: 180px; + top: 120px; + left: 820px; + display: flex; + flex-direction: column; +} + +.virtual-monitor-surface .wait-body { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(circle at 20% 20%, rgba(118, 148, 255, 0.18), transparent 45%), radial-gradient(circle at 80% 30%, rgba(92, 209, 183, 0.16), transparent 40%), rgba(248, 251, 255, 0.9); + border-radius: 0 0 14px 14px; + padding: 18px; +} + +.virtual-monitor-surface .flip-clock { + display: flex; + align-items: center; + gap: 8px; + perspective: 900px; + font-variant-numeric: tabular-nums; +} + +.virtual-monitor-surface .flip-separator { + font-size: 26px; + color: #2d3748; + margin: 0 4px; + font-weight: 600; +} + +.virtual-monitor-surface .flip-digit { + position: relative; + width: 44px; + height: 56px; + border-radius: 10px; + background: linear-gradient(180deg, #101827, #0b1222); + color: #f5fbff; + overflow: hidden; + box-shadow: 0 12px 24px rgba(14, 32, 68, 0.35); + transition: transform 0.2s ease; +} + +.virtual-monitor-surface .flip-digit .top, +.virtual-monitor-surface .flip-digit .bottom { + position: absolute; + left: 0; + right: 0; + height: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 26px; + font-weight: 700; + backface-visibility: hidden; +} + +.virtual-monitor-surface .flip-digit .top { + top: 0; + transform-origin: bottom; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)); +} + +.virtual-monitor-surface .flip-digit .bottom { + bottom: 0; + transform-origin: top; +} + +.virtual-monitor-surface .flip-digit.flipping { + animation: flipCard 0.38s ease forwards; +} + +@keyframes flipCard { + 0% { transform: rotateX(0deg); } + 50% { transform: rotateX(-82deg); } + 100% { transform: rotateX(0deg); } +} + .virtual-monitor-surface .context-menu { position: absolute; background: rgba(9, 16, 32, 0.96); @@ -1278,6 +1359,11 @@ transform: translateY(0); } +.virtual-monitor-surface .context-menu.secondary-menu { + z-index: 45; + min-width: 130px; +} + .virtual-monitor-surface .context-menu button { border: none; background: transparent; @@ -1317,6 +1403,55 @@ --arrow-size: 18px; } +.virtual-monitor-surface .wait-overlay { + position: absolute; + pointer-events: none; + opacity: 0; + transition: opacity 0.35s ease; + z-index: 48; + filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.25)); +} + +.virtual-monitor-surface .wait-overlay.active { + opacity: 1; +} + +.virtual-monitor-surface .wait-overlay .z { + position: absolute; + font-size: 28px; + font-weight: 700; + color: #111; + opacity: 0; + animation: swayUpToRight 2s ease-out infinite; +} + +.virtual-monitor-surface .wait-overlay.active .z { + opacity: 1; +} + +.virtual-monitor-surface .wait-overlay .z-2 { animation-delay: 0.25s; } +.virtual-monitor-surface .wait-overlay .z-3 { animation-delay: 0.5s; } +.virtual-monitor-surface .wait-overlay .z-4 { animation-delay: 0.75s; } + +.virtual-monitor-surface .wait-overlay .wait-countdown { + position: absolute; + left: 34px; + top: 8px; + padding: 0; + color: #111; + background: transparent; + border: none; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.5px; + box-shadow: none; +} + +@keyframes swayUpToRight { + 0% { transform: translate(0, 0) rotate(0deg); opacity: 1; } + 100% { transform: translate(80px, -100px) rotate(30deg); opacity: 0; } +} + .virtual-monitor-surface .speech-bubble.visible { opacity: 1; transform: scale(1);