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
+
+
+
+
@@ -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);