From 760fae492095528f86ccb6224e0531e40f4aa1b0 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 16 Dec 2025 18:01:33 +0800 Subject: [PATCH] fix: simplify folder icon handling --- .../chat/monitor/MonitorDirector.ts | 801 ++++++++++++++---- static/src/components/chat/monitor/types.ts | 1 + static/src/stores/monitor.ts | 23 +- 3 files changed, 679 insertions(+), 146 deletions(-) diff --git a/static/src/components/chat/monitor/MonitorDirector.ts b/static/src/components/chat/monitor/MonitorDirector.ts index 57e0370..552bc48 100644 --- a/static/src/components/chat/monitor/MonitorDirector.ts +++ b/static/src/components/chat/monitor/MonitorDirector.ts @@ -99,15 +99,24 @@ const EDITOR_MAX_ANIMATION_STEPS = 4000; const EDITOR_TYPING_THRESHOLD = 180; const EDITOR_TYPING_INTERVAL = 34; const EDITOR_ERASE_INTERVAL = 26; +const RENAME_ERASE_INTERVAL = 30; +const RENAME_TYPE_INTERVAL = 32; const MONITOR_EDITOR_DEBUG = false; const MONITOR_READER_DEBUG = false; +const MONITOR_RENAME_DEBUG = true; const readerDebug = (...args: any[]) => { if (!MONITOR_READER_DEBUG) { return; } console.debug('[MonitorReader]', ...args); }; +const renameDebug = (...args: any[]) => { + if (!MONITOR_RENAME_DEBUG) { + return; + } + console.info('[MonitorRename]', ...args); +}; const editorDebug = (...args: any[]) => { if (!MONITOR_EDITOR_DEBUG) { return; @@ -206,6 +215,7 @@ export class MonitorDirector implements MonitorDriver { private editorSpeedBoost = 1; private pythonRunToken = 0; private pendingDesktopFolders = new Set(); + private pendingCreateEntries = new Set(); private fileIcons = new Map(); private browserResultMap = new Map(); private folderEntries = new Map(); @@ -238,6 +248,8 @@ export class MonitorDirector implements MonitorDriver { private progressBubbleBase: string | null = null; private progressSceneName: string | null = null; private latestMemoryScroll = 0; + private desktopRenderLocked = false; + private pendingDesktopRoots: string[] | null = null; private thinkingBubbleTimer: number | null = null; private thinkingBubblePhase = 0; private waitingBubbleTimer: number | null = null; @@ -329,6 +341,8 @@ export class MonitorDirector implements MonitorDriver { this.resetManualPositions(); this.manualPositions.clear(); this.hideAllWindows(); + this.activeFolder = null; + this.refreshFolderIconStates(); } else { this.windowOrder = this.windowOrder.filter(win => win && win.classList.contains('visible')); } @@ -376,6 +390,11 @@ export class MonitorDirector implements MonitorDriver { } setDesktopRoots(roots: string[], options?: { immediate?: boolean }) { + if (this.desktopRenderLocked) { + this.pendingDesktopRoots = Array.isArray(roots) ? [...roots] : []; + monitorLifecycleDebug('setDesktopRoots:locked', { pending: this.pendingDesktopRoots.length }); + return; + } const nextRoots = Array.isArray(roots) ? [...roots] : []; const previousRoots = [...this.desktopRoots]; this.desktopRoots = nextRoots; @@ -416,6 +435,7 @@ export class MonitorDirector implements MonitorDriver { }); } this.renderDesktopFolders(); + this.refreshFolderIconStates(); } setManualInteractionEnabled(enabled: boolean) { @@ -512,6 +532,47 @@ export class MonitorDirector implements MonitorDriver { const progressLabel = getSceneProgressLabel(name); const hasPreviewForScene = !!this.progressBubbleBase && this.progressSceneName === name; const isPlaybackPhase = runtime?.statusPhase === 'playback'; + // 预先缓存 wait 结果,避免重复请求并让错误尽早暴露 + const waitFn = runtime.waitForResult || (() => Promise.resolve(null)); + let waitedOnce = false; + let cachedResult: any = null; + const innerWait = async (id?: string | number | null) => { + if (waitedOnce) { + return cachedResult; + } + waitedOnce = true; + cachedResult = await waitFn(id); + return cachedResult; + }; + + // 结果已明确失败时,直接提示错误,避免播放后续动画 + const preStatus = String(payload?.status || payload?.result?.status || '').toLowerCase(); + const preFailed = + ['failed', 'error'].includes(preStatus) || + payload?.error || + payload?.success === false || + payload?.result?.success === false || + (typeof payload?.result?.error === 'string' && payload.result.error); + monitorLifecycleDebug('playScene:prefail-check', { + scene: name, + preStatus, + preFailed, + statusPhase: runtime?.statusPhase, + hasPreviewForScene + }); + if (preFailed) { + const message = + payload?.result?.error || + payload?.error || + payload?.result?.message || + payload?.message || + '工具执行错误'; + this.stopProgressBubble(); + this.showSpeechBubble(message, { variant: 'error', duration: 2600 }); + monitorLifecycleDebug('playScene:skip-on-error', { scene: name, status: preStatus, message }); + return; + } + monitorLifecycleDebug('playScene:start', { scene: name, progressLabel, @@ -551,6 +612,44 @@ export class MonitorDirector implements MonitorDriver { ensureProgressBubble(); } + // 在回放阶段先等待结果,若失败直接返回以避免动画播放 + if (isPlaybackPhase) { + const waitKey = payload?.executionId ?? payload?.id ?? payload?.arguments?.id ?? null; + try { + const preResult = await innerWait(waitKey); + const preResultStatus = String(preResult?.status || preResult?.result?.status || '').toLowerCase(); + const preResultFailed = + ['failed', 'error'].includes(preResultStatus) || + preResult?.success === false || + preResult?.result?.success === false || + preResult?.error || + (typeof preResult?.result?.error === 'string' && preResult.result.error); + monitorLifecycleDebug('playScene:prefetch-result', { + scene: name, + waitKey, + preResultStatus, + preResultFailed + }); + if (preResultFailed) { + const message = + preResult?.result?.error || + preResult?.error || + payload?.result?.error || + payload?.error || + '工具执行错误'; + this.stopProgressBubble(); + this.showSpeechBubble(message, { variant: 'error', duration: 2600 }); + monitorLifecycleDebug('playScene:skip-after-prefetch-error', { scene: name, message }); + return; + } + } catch (error: any) { + monitorLifecycleDebug('playScene:prefetch-error', { scene: name, error: String(error) }); + this.stopProgressBubble(); + this.showSpeechBubble('工具执行错误', { variant: 'error', duration: 2600 }); + return; + } + } + // 若进入播放阶段且有执行结果已完成,压制“正在…”提示 if (isPlaybackPhase) { this.playbackLagging = true; @@ -561,16 +660,39 @@ export class MonitorDirector implements MonitorDriver { const wrappedRuntime: MonitorSceneRuntime = { ...runtime, waitForResult: async (id?: string | number | null) => { - const waitFn = runtime.waitForResult || (() => Promise.resolve(null)); if (!isPlaybackPhase && !this.playbackLagging) { ensureProgressBubble(); } const waitKey = id ?? payload?.executionId ?? payload?.id; progressDebug('playScene:waitForResult:start', { scene: name, id: waitKey }); try { - const result = await waitFn(id); + const result = await innerWait(id); progressDebug('playScene:waitForResult:resolved', { scene: name, id: waitKey }); this.playbackLagging = false; + const resultStatus = String(result?.status || result?.result?.status || '').toLowerCase(); + const failed = + ['failed', 'error'].includes(resultStatus) || + result?.success === false || + result?.result?.success === false || + result?.error || + (typeof result?.result?.error === 'string' && result.result.error); + monitorLifecycleDebug('playScene:waitForResult:status', { + scene: name, + id: waitKey, + resultStatus, + failed + }); + if (failed) { + const message = + result?.result?.error || + result?.error || + payload?.result?.error || + payload?.error || + '工具执行错误'; + this.stopProgressBubble(); + this.showSpeechBubble(message, { variant: 'error', duration: 2600 }); + throw new Error('tool-failed'); + } return result; } finally { clearProgressBubble(); @@ -786,6 +908,102 @@ export class MonitorDirector implements MonitorDriver { return parts.filter(Boolean).join('/'); } + private normalizeEntryPath(path?: string | null) { + return this.composePath(this.normalizePathSegments(path || '')); + } + + private markPendingCreation(path?: string | null) { + const normalized = this.normalizeEntryPath(path || ''); + if (normalized) { + this.pendingCreateEntries.add(normalized); + this.applyPendingCreationState(normalized, true); + const segments = this.normalizePathSegments(normalized); + if (segments.length === 1) { + // 立即刷新桌面,移除占位避免闪现空位 + this.renderDesktopFolders(); + } else if (segments.length > 1) { + const parentKey = this.composePath(segments.slice(0, -1)); + if (this.activeFolder === parentKey) { + this.renderFolderEntries(parentKey, false); + } + } + } + } + + private releasePendingCreation(path?: string | null) { + const normalized = this.normalizeEntryPath(path || ''); + if (!normalized) { + return; + } + if (this.pendingCreateEntries.has(normalized)) { + this.pendingCreateEntries.delete(normalized); + } + this.applyPendingCreationState(normalized, false); + const segments = this.normalizePathSegments(normalized); + if (segments.length === 1) { + // 根目录图标可能尚未渲染,重新绘制桌面 + this.renderDesktopFolders(); + } else if (segments.length > 1) { + const parentKey = this.composePath(segments.slice(0, -1)); + if (this.activeFolder === parentKey) { + this.renderFolderEntries(parentKey, false); + } + } + } + + private isCreationPending(path?: string | null) { + const normalized = this.normalizeEntryPath(path || ''); + return normalized ? this.pendingCreateEntries.has(normalized) : false; + } + + private applyPendingCreationState(path: string, active: boolean) { + const segments = this.normalizePathSegments(path); + const targetRoot = segments.length === 1 ? segments[0] : null; + const entryEl = this.findFolderEntryElement(path); + const desktopEl = targetRoot + ? this.folderIcons.get(targetRoot) || this.fileIcons.get(targetRoot) || null + : null; + const apply = (el: HTMLElement | null) => { + if (!el) { + return; + } + if (active) { + el.classList.remove('visible'); + el.classList.add('pending-reveal'); + el.style.opacity = '0'; + } else { + el.classList.remove('pending-reveal'); + el.style.opacity = '1'; + requestAnimationFrame(() => el.classList.add('visible')); + } + }; + apply(entryEl); + apply(desktopEl); + } + + // 提前标记待创建路径,供外部(store enqueue 阶段)调用,避免动画首帧闪现 + preparePendingCreation(path?: string | null) { + this.markPendingCreation(path); + } + + private lockDesktopRender() { + this.desktopRenderLocked = true; + monitorLifecycleDebug('desktopRender:lock'); + } + + private unlockDesktopRender() { + if (!this.desktopRenderLocked) { + return; + } + this.desktopRenderLocked = false; + monitorLifecycleDebug('desktopRender:unlock', { pending: this.pendingDesktopRoots?.length || 0 }); + if (this.pendingDesktopRoots) { + const pending = this.pendingDesktopRoots; + this.pendingDesktopRoots = null; + this.setDesktopRoots(pending, { immediate: true }); + } + } + private ensureFolderKey(key: string) { if (!key) { return; @@ -795,6 +1013,13 @@ export class MonitorDirector implements MonitorDriver { } } + private refreshFolderIconStates() { + // 需求简化:不区分开关状态,统一使用同一图标 + this.folderIcons.forEach((_icon, name) => { + this.setFolderIconState(name, false); + }); + } + private async loadFolderEntries(folderKey: string) { const path = folderKey || ''; try { @@ -808,11 +1033,14 @@ export class MonitorDirector implements MonitorDriver { return; } const items = Array.isArray(data?.data?.items) ? data.data.items : []; - const entries: FolderEntry[] = items.map((item: any) => ({ - name: item?.name || '', - path: item?.path || this.composePath([path, item?.name].filter(Boolean)), - type: item?.type === 'directory' ? 'folder' : 'file' - })); + const entries: FolderEntry[] = items.map((item: any) => { + const rawPath = item?.path || this.composePath([path, item?.name].filter(Boolean)); + return { + name: item?.name || '', + path: this.normalizeEntryPath(rawPath), + type: item?.type === 'directory' ? 'folder' : 'file' + }; + }); this.folderEntries.set(path, entries); } catch (error) { console.warn('[MonitorDirector] loadFolderEntries failed', path, error); @@ -836,11 +1064,22 @@ export class MonitorDirector implements MonitorDriver { icon.appendChild(img); icon.appendChild(span); this.elements.folderBody.appendChild(icon); - if (!animate) { - icon.classList.add('visible'); + const pending = this.isCreationPending(entry.path); + if (pending) { + icon.classList.add('pending-reveal'); + icon.style.opacity = '0'; + } + if (!animate || pending) { + if (!pending) { + icon.classList.add('visible'); + } return; } - requestAnimationFrame(() => icon.classList.add('visible')); + requestAnimationFrame(() => { + if (!pending) { + icon.classList.add('visible'); + } + }); }); } @@ -963,31 +1202,24 @@ export class MonitorDirector implements MonitorDriver { const targetSegments = segments.slice(); let currentKey: string | null = null; let startIndex = 0; + const folderWindowVisible = this.isWindowVisible(this.elements.folderWindow); + const activeSegments = this.activeFolder ? this.normalizePathSegments(this.activeFolder) : []; + const hasActiveMatch = + activeSegments.length && + activeSegments.length <= targetSegments.length && + activeSegments.every((seg, idx) => seg === targetSegments[idx]); - if (this.activeFolder) { - const activeSegments = this.normalizePathSegments(this.activeFolder); - if (activeSegments.length && activeSegments.length <= targetSegments.length) { - let matches = true; - for (let i = 0; i < activeSegments.length; i += 1) { - if (activeSegments[i] !== targetSegments[i]) { - matches = false; - break; - } - } - if (matches) { - currentKey = this.composePath(activeSegments); - startIndex = activeSegments.length; - await this.openFolder(currentKey, currentKey); - await sleep(40); - } - } - } - - if (!currentKey) { + // 若文件夹窗口未开,或当前活跃文件夹不在目标路径前缀,强制从根目录开始双击打开,保证有明显“打开文件夹”动画 + if (!folderWindowVisible || !hasActiveMatch) { const root = targetSegments[0]; await this.doubleClickDesktopFolder(root); currentKey = root; startIndex = 1; + } else { + currentKey = this.composePath(activeSegments); + startIndex = activeSegments.length; + await this.openFolder(currentKey, currentKey); + await sleep(40); } for (let i = startIndex; i < targetSegments.length; i += 1) { @@ -1029,12 +1261,14 @@ export class MonitorDirector implements MonitorDriver { if (!grid) { return; } - const desiredRoots = this.desktopRoots.filter(folder => !this.pendingDesktopFolders.has(folder)); + const desiredRoots = this.desktopRoots.filter( + folder => !this.pendingDesktopFolders.has(folder) && !this.isCreationPending(folder) + ); const knownRoots = new Set(this.desktopRoots); // 清理已不存在的图标 Array.from(this.folderIcons.entries()).forEach(([name, icon]) => { - if (!knownRoots.has(name)) { + if (!knownRoots.has(name) || this.isCreationPending(name)) { if (icon.parentElement === grid) { grid.removeChild(icon); } @@ -1049,7 +1283,7 @@ export class MonitorDirector implements MonitorDriver { icon = this.createDesktopFolderIcon(folder); this.folderIcons.set(folder, icon); } - if (this.pendingDesktopFolders.has(folder)) { + if (this.pendingDesktopFolders.has(folder) || this.isCreationPending(folder)) { icon.classList.remove('visible'); icon.classList.add('pending-reveal'); if (icon.parentElement === grid) { @@ -1091,7 +1325,7 @@ export class MonitorDirector implements MonitorDriver { } else { grid.appendChild(icon); } - if (!icon.classList.contains('visible')) { + if (!icon.classList.contains('visible') && !this.isCreationPending(folder)) { requestAnimationFrame(() => icon.classList.add('visible')); } lastInserted = icon; @@ -2243,6 +2477,9 @@ export class MonitorDirector implements MonitorDriver { await this.typeSearchQuery(query); this.elements.browserStatus.textContent = '正在搜索...'; const completion = await runtime.waitForResult(payload.executionId || payload.id); + if (!this.ensureSuccessOrErrorBubble(completion, payload, '搜索失败')) { + return; + } const results = Array.isArray(completion?.result?.results) ? completion.result.results : []; this.renderSearchResults(results); this.elements.browserStatus.textContent = completion?.status === 'completed' ? '搜索完成,已加载结果' : '搜索未完成'; @@ -2302,6 +2539,9 @@ export class MonitorDirector implements MonitorDriver { } catch (error) { console.warn('[MonitorDirector] webExtract waitForResult error', error); } + if (!this.ensureSuccessOrErrorBubble(completion, payload, '网页提取失败')) { + return; + } const resolvedResult = completion?.result ?? payload?.result ?? null; const { hasContent, hasError } = this.renderExtractionSummary(extractionInstance.summaryEl, resolvedResult); const finalStatus = this.resolveExtractionStatus(completion, payload, resolvedResult); @@ -2318,137 +2558,287 @@ export class MonitorDirector implements MonitorDriver { this.sceneHandlers.createFolder = async (payload, runtime) => { this.applySceneStatus(runtime, 'createFolder', '正在创建文件夹'); + this.lockDesktopRender(); const rawPath = payload?.arguments?.path || payload?.arguments?.target_path || '新建文件夹'; const segments = this.normalizePathSegments(rawPath); const folderName = segments.pop() || '新建文件夹'; - if (segments.length) { - const parentKey = await this.openFolderChain(segments); - if (parentKey) { - await this.movePointerToElement(this.elements.folderBody, { offsetX: 40, offsetY: 40, duration: 720 }); + const parentKey = this.composePath(segments); + const pendingPath = this.composePath([parentKey, folderName].filter(Boolean)); + this.markPendingCreation(pendingPath); + let completion: any = null; + try { + let preExistingEl: HTMLElement | null = null; + if (!segments.length) { + preExistingEl = this.folderIcons.get(folderName) || null; + } else { + const prePath = this.composePath([parentKey, folderName]); + preExistingEl = this.findFolderEntryElement(prePath); + } + if (preExistingEl) { + preExistingEl.style.opacity = '0'; + preExistingEl.classList.add('pending-reveal'); + } + const resultPromise = runtime + .waitForResult(payload.executionId || payload.id) + .catch(error => { + console.warn('[MonitorDirector] createFolder waitForResult error', error); + return null; + }); + if (segments.length) { + const openedParent = await this.openFolderChain(segments); + if (openedParent) { + await this.movePointerToElement(this.elements.folderBody, { offsetX: 40, offsetY: 40, duration: 720 }); + await this.click({ right: true }); + this.showContextMenu('folder'); + await sleep(200); + await this.highlightMenu('folder', 'folder'); + await this.click(); + this.hideContextMenus(); + } + } else { + await this.movePointerToDesktop(); await this.click({ right: true }); - this.showContextMenu('folder'); - await sleep(200); - await this.highlightMenu('folder', 'folder'); + this.showContextMenu('desktop'); + await sleep(240); + await this.highlightMenu('desktop', 'folder'); await this.click(); this.hideContextMenus(); - this.upsertFolderEntry(parentKey, { name: folderName, type: 'folder' }); } - } else { - await this.movePointerToDesktop(); - await this.click({ right: true }); - this.showContextMenu('desktop'); - await sleep(240); - await this.highlightMenu('desktop', 'folder'); - await this.click(); - this.hideContextMenus(); - this.ensureFolderKey(folderName); - if (!this.desktopRoots.includes(folderName)) { - this.desktopRoots.push(folderName); + completion = await resultPromise; + if (!this.ensureSuccessOrErrorBubble(completion, payload, '创建文件夹失败')) { + return; } - const icon = await this.revealDesktopFolderIcon(folderName, { fallbackSpawn: true }); - if (icon) { - await this.movePointerToElement(icon, { duration: 420 }); + const resolvedPath = this.resolveResultPath(completion, rawPath); + this.releasePendingCreation(resolvedPath); + const finalSegments = this.normalizePathSegments(resolvedPath); + const finalName = finalSegments.pop() || folderName; + if (finalSegments.length) { + const finalParentKey = this.composePath(finalSegments); + this.upsertFolderEntry(finalParentKey, { name: finalName, type: 'folder' }); + await this.openFolder(finalParentKey, finalParentKey); + const entryPath = this.composePath([finalParentKey, finalName]); + const entryEl = this.findFolderEntryElement(entryPath); + if (entryEl) { + entryEl.classList.add('visible'); + entryEl.style.opacity = '1'; + } + } else { + this.ensureFolderKey(finalName); + if (!this.desktopRoots.includes(finalName)) { + this.desktopRoots.push(finalName); + } + this.setDesktopRoots(this.desktopRoots, { immediate: true }); + const icon = await this.revealDesktopFolderIcon(finalName, { fallbackSpawn: true }); + if (icon) { + icon.style.opacity = '1'; + await this.movePointerToElement(icon, { duration: 420 }); + } } + await sleep(600); + } finally { + this.releasePendingCreation(pendingPath); + this.unlockDesktopRender(); } - await sleep(600); }; this.sceneHandlers.createFile = async (payload, runtime) => { this.applySceneStatus(runtime, 'createFile', '正在创建文件'); + this.lockDesktopRender(); const rawPath = payload?.arguments?.path || payload?.arguments?.target_path || 'new-file.txt'; const segments = this.normalizePathSegments(rawPath); const filename = segments.pop() || '新建文件'; - const resultPromise = runtime - .waitForResult(payload.executionId || payload.id) - .catch(error => { - console.warn('[MonitorDirector] createFile waitForResult error', error); - return null; - }); - if (segments.length) { - const parentKey = await this.openFolderChain(segments); - if (parentKey) { - await this.movePointerToElement(this.elements.folderBody, { offsetX: 30, offsetY: 20, duration: 720 }); + const parentKey = this.composePath(segments); + const pendingPath = this.composePath([parentKey, filename].filter(Boolean)); + this.markPendingCreation(pendingPath); + let completion: any = null; + try { + let preExistingEl: HTMLElement | null = null; + if (!segments.length) { + preExistingEl = this.fileIcons.get(filename) || null; + } else { + const prePath = this.composePath([parentKey, filename]); + preExistingEl = this.findFolderEntryElement(prePath); + } + if (preExistingEl) { + preExistingEl.style.opacity = '0'; + preExistingEl.classList.add('pending-reveal'); + } + const resultPromise = runtime + .waitForResult(payload.executionId || payload.id) + .catch(error => { + console.warn('[MonitorDirector] createFile waitForResult error', error); + return null; + }); + if (segments.length) { + const openedParent = await this.openFolderChain(segments); + if (openedParent) { + await this.movePointerToElement(this.elements.folderBody, { offsetX: 30, offsetY: 20, duration: 720 }); + await this.click({ right: true }); + this.showContextMenu('folder'); + await sleep(200); + await this.highlightMenu('folder', 'file'); + await this.click(); + this.hideContextMenus(); + } + } else { + await this.movePointerToDesktop(); await this.click({ right: true }); - this.showContextMenu('folder'); + this.showContextMenu('desktop'); await sleep(200); - await this.highlightMenu('folder', 'file'); + await this.highlightMenu('desktop', 'file'); await this.click(); this.hideContextMenus(); } - } else { - await this.movePointerToDesktop(); - await this.click({ right: true }); - this.showContextMenu('desktop'); - await sleep(200); - await this.highlightMenu('desktop', 'file'); - await this.click(); - this.hideContextMenus(); - } - const completion = await resultPromise; - const success = this.toolResultSucceeded(completion ?? payload?.result ?? null); - if (!success) { - return; - } - const resolvedPath = this.resolveResultPath(completion, rawPath); - const finalSegments = this.normalizePathSegments(resolvedPath); - const finalName = finalSegments.pop() || filename; - if (finalSegments.length) { - const parentKey = this.composePath(finalSegments); - this.upsertFolderEntry(parentKey, { name: finalName, type: 'file' }); - const entryPath = this.composePath([parentKey, finalName]); - const entryEl = this.findFolderEntryElement(entryPath); - if (entryEl) { - entryEl.classList.add('visible'); + completion = await resultPromise; + if (!this.ensureSuccessOrErrorBubble(completion, payload, '创建文件失败')) { + return; } - } else { - let fileIcon = this.fileIcons.get(finalName); - if (!fileIcon) { - fileIcon = this.spawnDesktopFile(finalName); - } - if (fileIcon) { - await this.movePointerToElement(fileIcon, { duration: 520 }); + const resolvedPath = this.resolveResultPath(completion, rawPath); + this.releasePendingCreation(resolvedPath); + const finalSegments = this.normalizePathSegments(resolvedPath); + const finalName = finalSegments.pop() || filename; + if (finalSegments.length) { + const finalParentKey = this.composePath(finalSegments); + this.upsertFolderEntry(finalParentKey, { name: finalName, type: 'file' }); + const entryPath = this.composePath([finalParentKey, finalName]); + const entryEl = this.findFolderEntryElement(entryPath); + if (entryEl) { + entryEl.classList.add('visible'); + entryEl.style.opacity = '1'; + } + } else { + let fileIcon = this.fileIcons.get(finalName); + if (!fileIcon) { + fileIcon = this.spawnDesktopFile(finalName); + } + if (fileIcon) { + fileIcon.style.opacity = '1'; + await this.movePointerToElement(fileIcon, { duration: 520 }); + } } + await sleep(400); + } finally { + this.releasePendingCreation(pendingPath); + this.unlockDesktopRender(); } - await sleep(400); }; this.sceneHandlers.renameFile = async (payload, runtime) => { this.applySceneStatus(runtime, 'renameFile', '正在重命名'); - const sourcePath = payload?.arguments?.path || payload?.arguments?.target_path; - const targetPath = payload?.arguments?.new_path || payload?.arguments?.destination_path; + this.lockDesktopRender(); + const sourcePath = + payload?.arguments?.path || + payload?.arguments?.target_path || + payload?.arguments?.old_path || + payload?.arguments?.source_path || + payload?.arguments?.from_path || + payload?.argumentSnapshot?.old_path || + payload?.argumentSnapshot?.path || + payload?.result?.old_path || + payload?.result?.path; + const targetPath = + payload?.arguments?.new_path || + payload?.arguments?.destination_path || + payload?.arguments?.target_path || + payload?.argumentSnapshot?.new_path || + payload?.argumentSnapshot?.path || + payload?.result?.new_path || + payload?.result?.destination_path; + renameDebug('scene:start', { + sourcePath, + targetPath, + status: payload?.status, + resultStatus: payload?.result?.status + }); if (!sourcePath || !targetPath) { + renameDebug('scene:missing-path', { sourcePath, targetPath }); await sleep(400); + this.unlockDesktopRender(); + return; + } + let completion: any = null; + try { + completion = await runtime.waitForResult(payload.executionId || payload.id); + } catch (error) { + console.warn('[MonitorDirector] renameFile waitForResult error', error); + completion = null; + } + if (!this.ensureSuccessOrErrorBubble(completion, payload, '重命名失败')) { + this.unlockDesktopRender(); return; } const sourceSegments = this.normalizePathSegments(sourcePath); const targetSegments = this.normalizePathSegments(targetPath); const fromName = sourceSegments.pop() || ''; const toName = targetSegments.pop() || ''; - if (sourceSegments.length) { - const parentKey = await this.openFolderChain(sourceSegments); - if (parentKey) { - const existing = (this.folderEntries.get(parentKey) || []).find(item => item.name === fromName); - const entryType = existing?.type || 'file'; - this.upsertFolderEntry(parentKey, { name: fromName, type: entryType }, { animate: false }); - await this.openFolder(parentKey, parentKey); - await sleep(40); - const entryPath = this.composePath([parentKey, fromName]); - const entryEl = this.findFolderEntryElement(entryPath); - if (entryEl) { - await this.movePointerToElement(entryEl, { duration: 620 }); + renameDebug('scene:resolved-names', { fromName, toName, sourceSegments: [...sourceSegments], targetSegments: [...targetSegments] }); + const clickRenameInFileMenu = async () => { + this.showContextMenu('file'); + await this.waitForMenuVisible(this.elements.fileMenu, 240); + const highlighted = await this.highlightMenu('file', 'rename'); + if (!highlighted) { + const btn = this.elements.fileMenu.querySelector('button[data-action="rename"]'); + if (btn) { + await this.movePointerToElement(btn, { duration: 300 }); + btn.classList.add('active'); + await sleep(180); + btn.classList.remove('active'); } - this.renameFolderEntry(parentKey, fromName, toName); } - } else { - const icon = this.fileIcons.get(fromName) || this.folderIcons.get(fromName); - if (icon) { - await this.movePointerToElement(icon, { duration: 600 }); - this.renameDesktopEntry(icon, toName); - this.fileIcons.delete(fromName); - this.fileIcons.set(toName, icon); + await this.click(); + this.hideContextMenus(); + }; + try { + if (sourceSegments.length) { + const parentKey = await this.openFolderChain(sourceSegments); + if (parentKey) { + const existing = (this.folderEntries.get(parentKey) || []).find(item => item.name === fromName); + const entryType = existing?.type || 'file'; + this.upsertFolderEntry(parentKey, { name: fromName, type: entryType }, { animate: false }); + await this.openFolder(parentKey, parentKey); + await sleep(40); + const entryPath = this.composePath([parentKey, fromName]); + let entryEl = this.findFolderEntryElement(entryPath); + renameDebug('scene:folder-target', { entryPath, found: !!entryEl }); + if (!entryEl) { + renameDebug('scene:folder-missing-target', { entryPath }); + } else { + await this.movePointerToElement(entryEl, { duration: 620 }); + await this.click({ right: true }); + await clickRenameInFileMenu(); + await this.animateRenameLabel(entryEl, toName); + renameDebug('scene:folder-rename-animated', { entryPath, toName }); + } + this.renameFolderEntry(parentKey, fromName, toName, { skipRender: true }); + } + } else { + const icon = this.fileIcons.get(fromName) || this.folderIcons.get(fromName) || null; + renameDebug('scene:desktop-target', { fromName, found: !!icon }); + if (!icon) { + renameDebug('scene:desktop-missing-target', { fromName }); + } else { + await this.movePointerToElement(icon, { duration: 600 }); + await this.click({ right: true }); + await clickRenameInFileMenu(); + await this.animateRenameLabel(icon, toName); + this.renameDesktopEntry(icon, toName); + renameDebug('scene:desktop-rename-animated', { fromName, toName }); + if (this.fileIcons.has(fromName)) { + this.fileIcons.delete(fromName); + this.fileIcons.set(toName, icon); + } + if (this.folderIcons.has(fromName)) { + this.renameDesktopRoot(fromName, toName, { skipRender: true }); + } + } } + await sleep(400); + } catch (error) { + console.error('[MonitorRename] scene error', error); + renameDebug('scene:error', { error }); + } finally { + this.unlockDesktopRender(); } - await sleep(400); }; this.sceneHandlers.deleteFile = async (payload, runtime) => { @@ -2602,6 +2992,9 @@ export class MonitorDirector implements MonitorDriver { console.warn('[MonitorDirector] appendFile waitForResult error', error); return null; }); + if (!this.ensureSuccessOrErrorBubble(completion, payload, '文件编辑失败')) { + return; + } const payloadAfterLines = this.resolveEditorAfterLines(payload, completion); const snapshotAfterSource = typeof completion?.monitor_snapshot_after?.content === 'string' @@ -2663,6 +3056,9 @@ export class MonitorDirector implements MonitorDriver { } await this.typeCommandText(command); const completion = await runtime.waitForResult(payload.executionId || payload.id); + if (!this.ensureSuccessOrErrorBubble(completion, payload, '命令执行失败')) { + return; + } const output = completion?.result?.output || completion?.result?.stdout || '命令执行完成'; const lines = this.sanitizeTerminalOutput( typeof output === 'string' @@ -2709,6 +3105,9 @@ export class MonitorDirector implements MonitorDriver { } await this.typePythonCode(code, { deletePrevious: true, animate }); const completion = await runtime.waitForResult(payload.executionId || payload.id); + if (!this.ensureSuccessOrErrorBubble(completion, payload, 'Python 执行失败')) { + return; + } if (runToken !== this.pythonRunToken) { // 有新的 Python 运行已启动,本次结果丢弃 return; @@ -2758,6 +3157,9 @@ export class MonitorDirector implements MonitorDriver { console.warn('[MonitorDirector] reader waitForResult error', error); readerDebug('readerScene:waitForResult error', error); } + if (!this.ensureSuccessOrErrorBubble(completion, payload, '阅读失败')) { + return; + } const { source: resultPayload, label: payloadSource } = this.resolveReaderPayload(payload, completion); readerDebug('readerScene:payload resolved', { executionId: payload?.executionId || payload?.id, @@ -2806,6 +3208,9 @@ export class MonitorDirector implements MonitorDriver { console.warn('[MonitorDirector] focus waitForResult error', error); return null; }); + if (!this.ensureSuccessOrErrorBubble(completion, payload, '聚焦文件失败')) { + return; + } this.showWindow(this.elements.readerWindow); this.elements.readerTitle.textContent = targetPath; this.renderReaderMessage('正在加载文件内容...'); @@ -2851,6 +3256,9 @@ export class MonitorDirector implements MonitorDriver { console.warn('[MonitorDirector] unfocus waitForResult error', error); return null; }); + if (!this.ensureSuccessOrErrorBubble(null, payload, '取消聚焦失败')) { + return; + } this.showWindow(this.elements.readerWindow); this.elements.readerTitle.textContent = targetPath; this.elements.readerWindow.classList.remove('focused'); @@ -2862,6 +3270,9 @@ export class MonitorDirector implements MonitorDriver { await this.sceneHandlers.reader(payload, runtime); this.applySceneStatus(runtime, 'ocr', '正在提取'); const completion = await runtime.waitForResult(payload.executionId || payload.id); + if (!this.ensureSuccessOrErrorBubble(completion, payload, 'OCR 失败')) { + return; + } const lines = completion?.result?.text || completion?.result?.lines || []; this.renderReaderOcr(lines); await sleep(400); @@ -3584,6 +3995,9 @@ export class MonitorDirector implements MonitorDriver { this.positionWindow(el, anchor); } this.pushWindowToStack(el); + if (el === this.elements.folderWindow) { + this.refreshFolderIconStates(); + } } private pushWindowToStack(el: HTMLElement) { @@ -3624,11 +4038,18 @@ export class MonitorDirector implements MonitorDriver { if (!el) { return; } + const isFolderWindow = el === this.elements.folderWindow; + if (isFolderWindow) { + this.activeFolder = null; + } this.windowOrder = this.windowOrder.filter(win => win && win !== el); const animate = options.animate ?? false; if (!animate) { el.classList.remove('visible', 'closing'); this.updateWindowZIndices(); + if (isFolderWindow) { + this.refreshFolderIconStates(); + } return; } if (el.classList.contains('closing')) { @@ -3638,6 +4059,9 @@ export class MonitorDirector implements MonitorDriver { setTimeout(() => { el.classList.remove('visible', 'closing'); this.updateWindowZIndices(); + if (isFolderWindow) { + this.refreshFolderIconStates(); + } }, 320); } @@ -3682,6 +4106,7 @@ export class MonitorDirector implements MonitorDriver { const { x, y } = this.clampToScreen(desiredX, desiredY, menu.offsetWidth, menu.offsetHeight); menu.style.left = `${x}px`; menu.style.top = `${y}px`; + monitorLifecycleDebug('contextMenu:show', { type, x, y, desiredX, desiredY }); } private hideContextMenus() { @@ -3729,14 +4154,21 @@ export class MonitorDirector implements MonitorDriver { menu.style.left = `${x}px`; menu.style.top = `${y}px`; this.secondaryMenu = menu; + monitorLifecycleDebug('secondaryMenu:show', { + items: items.map(item => item.action), + anchorRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + pos: { x, y } + }); return menu; } private async chooseSecondaryMenuAction(action: string) { const btn = this.secondaryMenu?.querySelector(`button[data-action="${action}"]`) || null; if (!btn) { + monitorLifecycleDebug('secondaryMenu:missing-btn', { action, hasMenu: !!this.secondaryMenu }); return false; } + monitorLifecycleDebug('secondaryMenu:choose', { action, rect: btn.getBoundingClientRect().toJSON ? btn.getBoundingClientRect().toJSON() : btn.getBoundingClientRect() }); await this.movePointerToElement(btn, { duration: 320 }); btn.classList.add('active'); await sleep(200); @@ -3840,6 +4272,7 @@ export class MonitorDirector implements MonitorDriver { while (!menu.classList.contains('visible') && performance.now() - start < timeout) { await sleep(16); } + monitorLifecycleDebug('menu:visible-check', { hasMenu: !!menu, visible: menu.classList.contains('visible') }); return menu.classList.contains('visible'); } @@ -3994,8 +4427,7 @@ export class MonitorDirector implements MonitorDriver { this.showWindow(this.elements.folderWindow); this.elements.folderHeaderText.textContent = label || folderKey || 'workspace'; this.renderFolderEntries(folderKey); - const rootName = folderKey.split('/')[0]; - this.setFolderIconState(rootName, true); + this.refreshFolderIconStates(); } private setFolderIconState(folderName: string, open: boolean) { @@ -4010,7 +4442,8 @@ export class MonitorDirector implements MonitorDriver { if (!img) { return; } - img.src = open ? this.assets.folderOpenIcon : this.assets.folderIcon; + // 简化:统一使用单一图标 + img.src = this.assets.folderIcon; } private removeFolderEntry(folderName: string, entryName: string) { @@ -4033,15 +4466,12 @@ export class MonitorDirector implements MonitorDriver { } private closeFolder() { - if (this.activeFolder) { - const rootName = this.activeFolder.split('/')[0]; - this.setFolderIconState(rootName, false); - } - this.elements.folderWindow.classList.remove('visible'); this.activeFolder = null; + this.elements.folderWindow.classList.remove('visible'); + this.refreshFolderIconStates(); } - private renameFolderEntry(folderName: string, fromName: string, toName: string) { + private renameFolderEntry(folderName: string, fromName: string, toName: string, options: { skipRender?: boolean } = {}) { const entries = this.folderEntries.get(folderName); if (!entries) { return; @@ -4061,7 +4491,7 @@ export class MonitorDirector implements MonitorDriver { } } this.folderEntries.set(folderName, entries); - if (this.activeFolder === folderName) { + if (!options.skipRender && this.activeFolder === folderName) { this.renderFolderEntries(folderName, false); } } @@ -4073,6 +4503,74 @@ export class MonitorDirector implements MonitorDriver { } } + private async animateRenameLabel(targetEl: HTMLElement | null, newName: string) { + if (!targetEl) { + return; + } + const span = targetEl.querySelector('span'); + if (!span) { + return; + } + const current = span.textContent || ''; + if (current === newName) { + return; + } + renameDebug('animate:start', { current, newName }); + for (let i = current.length; i > 0; i -= 1) { + span.textContent = current.slice(0, i - 1); + await sleep(RENAME_ERASE_INTERVAL); + } + for (let i = 0; i < newName.length; i += 1) { + span.textContent = newName.slice(0, i + 1); + await sleep(RENAME_TYPE_INTERVAL); + } + span.textContent = newName; + renameDebug('animate:end', { final: span.textContent }); + } + + private renameDesktopRoot(fromName: string, toName: string, options: { skipRender?: boolean } = {}) { + if (!fromName || !toName || fromName === toName) { + return; + } + const icon = this.folderIcons.get(fromName) || null; + if (icon) { + this.folderIcons.delete(fromName); + this.folderIcons.set(toName, icon); + icon.dataset.folderName = toName; + this.renameDesktopEntry(icon, toName); + } + const updatedEntries = new Map(); + this.folderEntries.forEach((entries, key) => { + if (key === fromName || key.startsWith(`${fromName}/`)) { + const newKey = toName + key.slice(fromName.length); + const mapped = entries.map(item => { + const nextPath = item.path.startsWith(fromName) ? toName + item.path.slice(fromName.length) : item.path; + return { ...item, path: nextPath }; + }); + updatedEntries.set(newKey, mapped); + } else { + updatedEntries.set(key, entries); + } + }); + this.folderEntries = updatedEntries; + const rootIdx = this.desktopRoots.indexOf(fromName); + if (rootIdx >= 0) { + this.desktopRoots[rootIdx] = toName; + } + if (this.activeFolder) { + if (this.activeFolder === fromName) { + this.activeFolder = toName; + } else if (this.activeFolder.startsWith(`${fromName}/`)) { + this.activeFolder = toName + this.activeFolder.slice(fromName.length); + } + } + if (!options.skipRender) { + this.renderDesktopFolders(); + this.refreshFolderIconStates(); + } + } + + private openEditorWindow(filename: string) { this.showWindow(this.elements.editorWindow); this.elements.editorHeaderText.textContent = filename; @@ -4999,18 +5497,14 @@ export class MonitorDirector implements MonitorDriver { return true; } const status = String(payload?.status || payload?.result?.status || '').toLowerCase(); - if (status === 'failed' || status === 'error') { - return false; - } - if (payload?.result?.success === false) { - return false; - } - if (payload?.success === false) { - return false; - } - if (payload?.result?.error) { + const successStatuses = ['completed', 'success', 'succeeded', 'ok', 'done']; + if (status && !successStatuses.includes(status)) { return false; } + if (payload?.result?.success === false) return false; + if (payload?.success === false) return false; + if (payload?.result?.error) return false; + if (payload?.error) return false; return true; } @@ -5029,6 +5523,25 @@ export class MonitorDirector implements MonitorDriver { return resolved || fallback; } + /** + * 工具失败时统一弹出红色气泡并终止后续动画。 + * 返回 true 表示成功可继续,false 表示已提示错误应中断。 + */ + private ensureSuccessOrErrorBubble(completion: any, payload?: any, fallbackMessage = '工具执行错误'): boolean { + const ok = this.toolResultSucceeded(completion ?? payload ?? null); + if (ok) { + return true; + } + const message = + completion?.result?.error || + completion?.error || + payload?.error || + payload?.result?.error || + fallbackMessage; + this.showSpeechBubble(message || fallbackMessage, { variant: 'error', duration: 2600 }); + return false; + } + private normalizeLines(content: any): string[] { if (typeof content === 'string') { const parts = content.split(/\r?\n/).map(line => line.replace(/\t/g, ' ')); diff --git a/static/src/components/chat/monitor/types.ts b/static/src/components/chat/monitor/types.ts index bc05f3b..e694e43 100644 --- a/static/src/components/chat/monitor/types.ts +++ b/static/src/components/chat/monitor/types.ts @@ -31,4 +31,5 @@ export interface MonitorDriver { previewSceneProgress(name: string): void; playScene(name: string, payload: Record, runtime: MonitorSceneRuntime): Promise; destroy(): void; + preparePendingCreation?(path?: string | null): void; } diff --git a/static/src/stores/monitor.ts b/static/src/stores/monitor.ts index 4528793..7f0aab8 100644 --- a/static/src/stores/monitor.ts +++ b/static/src/stores/monitor.ts @@ -149,7 +149,12 @@ export const useMonitorStore = defineStore('monitor', { actions: { registerDriver(driver: MonitorDriver) { this.driver = driver; - this.driver.resetScene({ desktopRoots: this.lastTreeSnapshot, preserveBubble: this.bubbleActive }); + this.driver.resetScene({ + desktopRoots: this.lastTreeSnapshot, + preserveBubble: this.bubbleActive, + preservePointer: false, // 初次挂载重置指针 + preserveWindows: false + }); if (this.speechBuffer) { this.driver.showSpeechBubble(this.speechBuffer, { variant: 'info', duration: 0 }); this.bubbleActive = true; @@ -191,7 +196,7 @@ export const useMonitorStore = defineStore('monitor', { preserveAwaitingTools?: boolean; }) { const preserveBubble = !!options?.preserveBubble; - const preservePointer = !!options?.preservePointer; + const preservePointer = options?.preservePointer !== false; const preserveWindows = !!options?.preserveWindows; const preserveQueue = !!options?.preserveQueue; const preservePendingResults = !!options?.preservePendingResults; @@ -362,6 +367,20 @@ export const useMonitorStore = defineStore('monitor', { this.ensurePendingResult(id); this.queue.push({ id, script, payload: { ...payload, executionId: id } }); this.awaitingTools[id] = payload.name; + + // 提前为创建类工具标记“待创建”路径,避免动画开始前图标闪现 + if (this.driver && typeof this.driver.preparePendingCreation === 'function') { + if (payload.name === 'create_folder' || payload.name === 'create_file') { + const pendingPath = + payload?.arguments?.path || + payload?.arguments?.target_path || + payload?.arguments?.targetPath || + payload?.argumentSnapshot?.path || + payload?.argumentSnapshot?.target_path; + this.driver.preparePendingCreation(pendingPath); + } + } + monitorLifecycleLog('enqueue', { id, tool: payload.name,