From 8be1c37b6a0400db65117e42571de6d1ce0eb1f5 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 10 Apr 2026 14:34:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E9=80=BB=E8=BE=91=E5=B1=82=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/src/app.ts | 328 +- static/src/app/bootstrap.ts | 212 +- static/src/app/components.ts | 56 +- static/src/app/computed.ts | 319 +- static/src/app/lifecycle.ts | 202 +- static/src/app/methods/common.ts | 24 +- static/src/app/methods/conversation.ts | 1277 +++---- static/src/app/methods/history.ts | 793 ++-- static/src/app/methods/message.ts | 1230 +++--- static/src/app/methods/monitor.ts | 32 +- static/src/app/methods/resources.ts | 560 +-- static/src/app/methods/search.ts | 329 +- static/src/app/methods/taskPolling.ts | 2995 +++++++-------- static/src/app/methods/tooling.ts | 514 ++- static/src/app/methods/ui.ts | 2849 +++++++------- static/src/app/methods/upload.ts | 794 ++-- static/src/app/state.ts | 200 +- static/src/app/watchers.ts | 121 +- static/src/composables/useLegacySocket.ts | 3306 +++++++++-------- static/src/composables/useMarkdownRenderer.ts | 39 +- static/src/composables/usePanelResize.ts | 6 +- static/src/composables/useScrollControl.ts | 9 +- static/src/stores/chat.ts | 5 +- static/src/stores/file.ts | 6 +- static/src/stores/input.ts | 4 +- static/src/stores/model.ts | 9 +- static/src/stores/monitor.ts | 116 +- static/src/stores/personalization.ts | 67 +- static/src/stores/policy.ts | 11 +- static/src/stores/resource.ts | 18 +- static/src/stores/task.ts | 762 ++-- static/src/stores/tool.ts | 10 +- static/src/stores/tutorial.ts | 598 ++- static/src/stores/ui.ts | 8 +- static/src/stores/upload.ts | 6 +- static/src/utils/chatDisplay.ts | 10 +- static/src/utils/formatters.ts | 8 +- 37 files changed, 9267 insertions(+), 8566 deletions(-) diff --git a/static/src/app.ts b/static/src/app.ts index 265772b..244bfb2 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -33,171 +33,171 @@ import { monitorMethods } from './app/methods/monitor'; // 其他初始化逻辑已迁移到 app/bootstrap.ts const appOptions = { - data: dataState, - created, - mounted, - beforeUnmount, - computed, - watch: watchers, + data: dataState, + created, + mounted, + beforeUnmount, + computed, + watch: watchers, + + methods: { + ...conversationMethods, + ...historyMethods, + ...searchMethods, + ...messageMethods, + ...uploadMethods, + ...resourceMethods, + ...toolingMethods, + ...uiMethods, + ...monitorMethods, + ...taskPollingMethods, + ...mapActions(useUiStore, { + uiToggleSidebar: 'toggleSidebar', + uiSetSidebarCollapsed: 'setSidebarCollapsed', + uiSetWorkspaceCollapsed: 'setWorkspaceCollapsed', + uiToggleWorkspaceCollapsed: 'toggleWorkspaceCollapsed', + uiSetChatDisplayMode: 'setChatDisplayMode', + uiSetPanelMode: 'setPanelMode', + uiSetPanelMenuOpen: 'setPanelMenuOpen', + uiTogglePanelMenu: 'togglePanelMenu', + uiSetMobileViewport: 'setIsMobileViewport', + uiSetMobileOverlayMenuOpen: 'setMobileOverlayMenuOpen', + uiToggleMobileOverlayMenu: 'toggleMobileOverlayMenu', + uiSetActiveMobileOverlay: 'setActiveMobileOverlay', + uiCloseMobileOverlay: 'closeMobileOverlay', + uiPushToast: 'pushToast', + uiUpdateToast: 'updateToast', + uiDismissToast: 'dismissToast', + uiShowQuotaToastMessage: 'showQuotaToastMessage', + uiDismissQuotaToast: 'dismissQuotaToast', + uiRequestConfirm: 'requestConfirm', + uiResolveConfirm: 'resolveConfirm' + }), + ...mapActions(useModelStore, { + modelSet: 'setModel' + }), + ...mapActions(useChatStore, { + chatExpandBlock: 'expandBlock', + chatCollapseBlock: 'collapseBlock', + chatClearExpandedBlocks: 'clearExpandedBlocks', + chatSetThinkingLock: 'setThinkingLock', + chatClearThinkingLocks: 'clearThinkingLocks', + chatSetScrollState: 'setScrollState', + chatEnableAutoScroll: 'enableAutoScroll', + chatDisableAutoScroll: 'disableAutoScroll', + chatToggleScrollLockState: 'toggleScrollLockState', + chatAddUserMessage: 'addUserMessage', + chatStartAssistantMessage: 'startAssistantMessage', + chatStartThinkingAction: 'startThinkingAction', + chatAppendThinkingChunk: 'appendThinkingChunk', + chatCompleteThinkingAction: 'completeThinking', + chatStartTextAction: 'startTextAction', + chatAppendTextChunk: 'appendTextChunk', + chatCompleteTextAction: 'completeText', + chatAddSystemMessage: 'addSystemMessage', + chatEnsureAssistantMessage: 'ensureAssistantMessage' + }), + ...mapActions(useInputStore, { + inputSetFocused: 'setInputFocused', + inputToggleQuickMenu: 'toggleQuickMenu', + inputCloseMenus: 'closeMenus', + inputOpenQuickMenu: 'openQuickMenu', + inputSetQuickMenuOpen: 'setQuickMenuOpen', + inputToggleToolMenu: 'toggleToolMenu', + inputSetToolMenuOpen: 'setToolMenuOpen', + inputToggleSettingsMenu: 'toggleSettingsMenu', + inputSetSettingsOpen: 'setSettingsOpen', + inputSetMessage: 'setInputMessage', + inputClearMessage: 'clearInputMessage', + inputSetLineCount: 'setInputLineCount', + inputSetMultiline: 'setInputMultiline', + inputSetImagePickerOpen: 'setImagePickerOpen', + inputSetSelectedImages: 'setSelectedImages', + inputAddSelectedImage: 'addSelectedImage', + inputClearSelectedImages: 'clearSelectedImages', + inputRemoveSelectedImage: 'removeSelectedImage', + inputSetVideoPickerOpen: 'setVideoPickerOpen', + inputSetSelectedVideos: 'setSelectedVideos', + inputAddSelectedVideo: 'addSelectedVideo', + inputClearSelectedVideos: 'clearSelectedVideos', + inputRemoveSelectedVideo: 'removeSelectedVideo' + }), + ...mapActions(useToolStore, { + toolRegisterAction: 'registerToolAction', + toolUnregisterAction: 'unregisterToolAction', + toolFindAction: 'findToolAction', + toolTrackAction: 'trackToolAction', + toolReleaseAction: 'releaseToolAction', + toolGetLatestAction: 'getLatestActiveToolAction', + toolResetTracking: 'resetToolTracking', + toolSetSettings: 'setToolSettings', + toolSetSettingsLoading: 'setToolSettingsLoading' + }), + ...mapActions(useResourceStore, { + resourceUpdateCurrentContextTokens: 'updateCurrentContextTokens', + resourceFetchConversationTokenStatistics: 'fetchConversationTokenStatistics', + resourceSetCurrentContextTokens: 'setCurrentContextTokens', + resourceToggleTokenPanel: 'toggleTokenPanel', + resourceApplyStatusSnapshot: 'applyStatusSnapshot', + resourceUpdateContainerStatus: 'updateContainerStatus', + resourceStartContainerStatsPolling: 'startContainerStatsPolling', + resourceStopContainerStatsPolling: 'stopContainerStatsPolling', + resourceStartProjectStoragePolling: 'startProjectStoragePolling', + resourceStopProjectStoragePolling: 'stopProjectStoragePolling', + resourceStartUsageQuotaPolling: 'startUsageQuotaPolling', + resourceStopUsageQuotaPolling: 'stopUsageQuotaPolling', + resourcePollContainerStats: 'pollContainerStats', + resourceBindContainerVisibilityWatcher: 'bindContainerVisibilityWatcher', + resourcePollProjectStorage: 'pollProjectStorage', + resourceFetchUsageQuota: 'fetchUsageQuota', + resourceResetTokenStatistics: 'resetTokenStatistics', + resourceSetUsageQuota: 'setUsageQuota' + }), + ...mapActions(useUploadStore, { + uploadHandleSelected: 'handleSelectedFiles', + uploadBatchFiles: 'uploadFiles' + }), + ...mapActions(useFileStore, { + fileFetchTree: 'fetchFileTree', + fileSetTreeFromResponse: 'setFileTreeFromResponse', + fileFetchTodoList: 'fetchTodoList', + fileSetTodoList: 'setTodoList', + fileHideContextMenu: 'hideContextMenu', + fileMarkTreeUnavailable: 'markFileTreeUnavailable' + }), + ...mapActions(useMonitorStore, { + monitorSyncDesktop: 'syncDesktopFromTree', + monitorResetVisual: 'resetVisualState', + monitorResetSpeech: 'resetSpeechBuffer', + monitorShowSpeech: 'enqueueModelSpeech', + monitorShowThinking: 'enqueueModelThinking', + monitorEndModelOutput: 'endModelOutput', + monitorShowPendingReply: 'showPendingReply', + monitorPreviewTool: 'previewToolIntent', + monitorQueueTool: 'enqueueToolEvent', + monitorResolveTool: 'resolveToolResult', + forceUnlockMonitor: 'resetVisualState' + }), + ...mapActions(useSubAgentStore, { + subAgentFetch: 'fetchSubAgents', + subAgentStartPolling: 'startPolling', + subAgentStopPolling: 'stopPolling' + }), + ...mapActions(useBackgroundCommandStore, { + backgroundCommandFetch: 'fetchCommands', + backgroundCommandStartPolling: 'startPolling', + backgroundCommandStopPolling: 'stopPolling' + }), + ...mapActions(useFocusStore, { + focusFetchFiles: 'fetchFocusedFiles', + focusSetFiles: 'setFocusedFiles' + }), + ...mapActions(usePersonalizationStore, { + personalizationOpenDrawer: 'openDrawer' + }) + } +}; - methods: { - ...conversationMethods, - ...historyMethods, - ...searchMethods, - ...messageMethods, - ...uploadMethods, - ...resourceMethods, - ...toolingMethods, - ...uiMethods, - ...monitorMethods, - ...taskPollingMethods, - ...mapActions(useUiStore, { - uiToggleSidebar: 'toggleSidebar', - uiSetSidebarCollapsed: 'setSidebarCollapsed', - uiSetWorkspaceCollapsed: 'setWorkspaceCollapsed', - uiToggleWorkspaceCollapsed: 'toggleWorkspaceCollapsed', - uiSetChatDisplayMode: 'setChatDisplayMode', - uiSetPanelMode: 'setPanelMode', - uiSetPanelMenuOpen: 'setPanelMenuOpen', - uiTogglePanelMenu: 'togglePanelMenu', - uiSetMobileViewport: 'setIsMobileViewport', - uiSetMobileOverlayMenuOpen: 'setMobileOverlayMenuOpen', - uiToggleMobileOverlayMenu: 'toggleMobileOverlayMenu', - uiSetActiveMobileOverlay: 'setActiveMobileOverlay', - uiCloseMobileOverlay: 'closeMobileOverlay', - uiPushToast: 'pushToast', - uiUpdateToast: 'updateToast', - uiDismissToast: 'dismissToast', - uiShowQuotaToastMessage: 'showQuotaToastMessage', - uiDismissQuotaToast: 'dismissQuotaToast', - uiRequestConfirm: 'requestConfirm', - uiResolveConfirm: 'resolveConfirm' - }), - ...mapActions(useModelStore, { - modelSet: 'setModel' - }), - ...mapActions(useChatStore, { - chatExpandBlock: 'expandBlock', - chatCollapseBlock: 'collapseBlock', - chatClearExpandedBlocks: 'clearExpandedBlocks', - chatSetThinkingLock: 'setThinkingLock', - chatClearThinkingLocks: 'clearThinkingLocks', - chatSetScrollState: 'setScrollState', - chatEnableAutoScroll: 'enableAutoScroll', - chatDisableAutoScroll: 'disableAutoScroll', - chatToggleScrollLockState: 'toggleScrollLockState', - chatAddUserMessage: 'addUserMessage', - chatStartAssistantMessage: 'startAssistantMessage', - chatStartThinkingAction: 'startThinkingAction', - chatAppendThinkingChunk: 'appendThinkingChunk', - chatCompleteThinkingAction: 'completeThinking', - chatStartTextAction: 'startTextAction', - chatAppendTextChunk: 'appendTextChunk', - chatCompleteTextAction: 'completeText', - chatAddSystemMessage: 'addSystemMessage', - chatEnsureAssistantMessage: 'ensureAssistantMessage' - }), - ...mapActions(useInputStore, { - inputSetFocused: 'setInputFocused', - inputToggleQuickMenu: 'toggleQuickMenu', - inputCloseMenus: 'closeMenus', - inputOpenQuickMenu: 'openQuickMenu', - inputSetQuickMenuOpen: 'setQuickMenuOpen', - inputToggleToolMenu: 'toggleToolMenu', - inputSetToolMenuOpen: 'setToolMenuOpen', - inputToggleSettingsMenu: 'toggleSettingsMenu', - inputSetSettingsOpen: 'setSettingsOpen', - inputSetMessage: 'setInputMessage', - inputClearMessage: 'clearInputMessage', - inputSetLineCount: 'setInputLineCount', - inputSetMultiline: 'setInputMultiline', - inputSetImagePickerOpen: 'setImagePickerOpen', - inputSetSelectedImages: 'setSelectedImages', - inputAddSelectedImage: 'addSelectedImage', - inputClearSelectedImages: 'clearSelectedImages', - inputRemoveSelectedImage: 'removeSelectedImage', - inputSetVideoPickerOpen: 'setVideoPickerOpen', - inputSetSelectedVideos: 'setSelectedVideos', - inputAddSelectedVideo: 'addSelectedVideo', - inputClearSelectedVideos: 'clearSelectedVideos', - inputRemoveSelectedVideo: 'removeSelectedVideo' - }), - ...mapActions(useToolStore, { - toolRegisterAction: 'registerToolAction', - toolUnregisterAction: 'unregisterToolAction', - toolFindAction: 'findToolAction', - toolTrackAction: 'trackToolAction', - toolReleaseAction: 'releaseToolAction', - toolGetLatestAction: 'getLatestActiveToolAction', - toolResetTracking: 'resetToolTracking', - toolSetSettings: 'setToolSettings', - toolSetSettingsLoading: 'setToolSettingsLoading' - }), - ...mapActions(useResourceStore, { - resourceUpdateCurrentContextTokens: 'updateCurrentContextTokens', - resourceFetchConversationTokenStatistics: 'fetchConversationTokenStatistics', - resourceSetCurrentContextTokens: 'setCurrentContextTokens', - resourceToggleTokenPanel: 'toggleTokenPanel', - resourceApplyStatusSnapshot: 'applyStatusSnapshot', - resourceUpdateContainerStatus: 'updateContainerStatus', - resourceStartContainerStatsPolling: 'startContainerStatsPolling', - resourceStopContainerStatsPolling: 'stopContainerStatsPolling', - resourceStartProjectStoragePolling: 'startProjectStoragePolling', - resourceStopProjectStoragePolling: 'stopProjectStoragePolling', - resourceStartUsageQuotaPolling: 'startUsageQuotaPolling', - resourceStopUsageQuotaPolling: 'stopUsageQuotaPolling', - resourcePollContainerStats: 'pollContainerStats', - resourceBindContainerVisibilityWatcher: 'bindContainerVisibilityWatcher', - resourcePollProjectStorage: 'pollProjectStorage', - resourceFetchUsageQuota: 'fetchUsageQuota', - resourceResetTokenStatistics: 'resetTokenStatistics', - resourceSetUsageQuota: 'setUsageQuota' - }), - ...mapActions(useUploadStore, { - uploadHandleSelected: 'handleSelectedFiles', - uploadBatchFiles: 'uploadFiles' - }), - ...mapActions(useFileStore, { - fileFetchTree: 'fetchFileTree', - fileSetTreeFromResponse: 'setFileTreeFromResponse', - fileFetchTodoList: 'fetchTodoList', - fileSetTodoList: 'setTodoList', - fileHideContextMenu: 'hideContextMenu', - fileMarkTreeUnavailable: 'markFileTreeUnavailable' - }), - ...mapActions(useMonitorStore, { - monitorSyncDesktop: 'syncDesktopFromTree', - monitorResetVisual: 'resetVisualState', - monitorResetSpeech: 'resetSpeechBuffer', - monitorShowSpeech: 'enqueueModelSpeech', - monitorShowThinking: 'enqueueModelThinking', - monitorEndModelOutput: 'endModelOutput', - monitorShowPendingReply: 'showPendingReply', - monitorPreviewTool: 'previewToolIntent', - monitorQueueTool: 'enqueueToolEvent', - monitorResolveTool: 'resolveToolResult', - forceUnlockMonitor: 'resetVisualState' - }), - ...mapActions(useSubAgentStore, { - subAgentFetch: 'fetchSubAgents', - subAgentStartPolling: 'startPolling', - subAgentStopPolling: 'stopPolling' - }), - ...mapActions(useBackgroundCommandStore, { - backgroundCommandFetch: 'fetchCommands', - backgroundCommandStartPolling: 'startPolling', - backgroundCommandStopPolling: 'stopPolling' - }), - ...mapActions(useFocusStore, { - focusFetchFiles: 'fetchFocusedFiles', - focusSetFiles: 'setFocusedFiles' - }), - ...mapActions(usePersonalizationStore, { - personalizationOpenDrawer: 'openDrawer' - }) - } - }; - (appOptions as any).components = appComponents; export default appOptions; diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index fac1da7..cd1c675 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -2,138 +2,136 @@ import katex from 'katex'; function normalizeShowImageSrc(src: string) { - if (!src) return ''; - const trimmed = src.trim(); - if (/^https?:\/\//i.test(trimmed)) return trimmed; - if (trimmed.startsWith('/user_upload/')) return trimmed; - // 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx - const idx = trimmed.toLowerCase().indexOf('/user_upload/'); - if (idx >= 0) { - return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length); - } - if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) { - return trimmed; - } - return ''; -} - -function isSafeImageSrc(src: string) { - return !!normalizeShowImageSrc(src); + if (!src) return ''; + const trimmed = src.trim(); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (trimmed.startsWith('/user_upload/')) return trimmed; + // 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx + const idx = trimmed.toLowerCase().indexOf('/user_upload/'); + if (idx >= 0) { + return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length); + } + if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) { + return trimmed; + } + return ''; } function escapeHtml(input: string) { - return input - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } function renderShowImages(root: ParentNode | null = document) { - if (!root) return; - // 处理因自闭合解析导致的嵌套:把子 show_image 平铺到父后面 - const nested = Array.from(root.querySelectorAll('show_image show_image')).reverse(); - nested.forEach(child => { - const parent = child.parentElement; - if (parent && parent !== root) { - parent.after(child); - } - }); - const nodes = Array.from(root.querySelectorAll('show_image:not([data-rendered])')).reverse(); - nodes.forEach(node => { - // 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序 - if (node.parentNode && node.firstChild) { - const parent = node.parentNode; - const ref = node.nextSibling; // 可能为 null,insertBefore 会当 append - const children = Array.from(node.childNodes); - children.forEach(child => parent.insertBefore(child, ref)); - } + if (!root) return; + // 处理因自闭合解析导致的嵌套:把子 show_image 平铺到父后面 + const nested = Array.from(root.querySelectorAll('show_image show_image')).reverse(); + nested.forEach((child) => { + const parent = child.parentElement; + if (parent && parent !== root) { + parent.after(child); + } + }); + const nodes = Array.from(root.querySelectorAll('show_image:not([data-rendered])')).reverse(); + nodes.forEach((node) => { + // 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序 + if (node.parentNode && node.firstChild) { + const parent = node.parentNode; + const ref = node.nextSibling; // 可能为 null,insertBefore 会当 append + const children = Array.from(node.childNodes); + children.forEach((child) => parent.insertBefore(child, ref)); + } - const rawSrc = node.getAttribute('src') || ''; - const mappedSrc = normalizeShowImageSrc(rawSrc); - if (!mappedSrc) { - node.setAttribute('data-rendered', '1'); - node.setAttribute('data-rendered-error', 'invalid-src'); - return; - } - const alt = node.getAttribute('alt') || ''; - const safeAlt = escapeHtml(alt.trim()); - const figure = document.createElement('figure'); - figure.className = 'chat-inline-image'; + const rawSrc = node.getAttribute('src') || ''; + const mappedSrc = normalizeShowImageSrc(rawSrc); + if (!mappedSrc) { + node.setAttribute('data-rendered', '1'); + node.setAttribute('data-rendered-error', 'invalid-src'); + return; + } + const alt = node.getAttribute('alt') || ''; + const safeAlt = escapeHtml(alt.trim()); + const figure = document.createElement('figure'); + figure.className = 'chat-inline-image'; - const img = document.createElement('img'); - img.loading = 'lazy'; - img.src = mappedSrc; - img.alt = safeAlt; - img.onerror = () => { - figure.classList.add('chat-inline-image--error'); - const tip = document.createElement('div'); - tip.className = 'chat-inline-image__error'; - tip.textContent = '图片加载失败'; - figure.appendChild(tip); - }; - figure.appendChild(img); + const img = document.createElement('img'); + img.loading = 'lazy'; + img.src = mappedSrc; + img.alt = safeAlt; + img.onerror = () => { + figure.classList.add('chat-inline-image--error'); + const tip = document.createElement('div'); + tip.className = 'chat-inline-image__error'; + tip.textContent = '图片加载失败'; + figure.appendChild(tip); + }; + figure.appendChild(img); - if (safeAlt) { - const caption = document.createElement('figcaption'); - caption.innerHTML = safeAlt; - figure.appendChild(caption); - } + if (safeAlt) { + const caption = document.createElement('figcaption'); + caption.innerHTML = safeAlt; + figure.appendChild(caption); + } - node.replaceChildren(figure); - node.setAttribute('data-rendered', '1'); - }); + node.replaceChildren(figure); + node.setAttribute('data-rendered', '1'); + }); } let showImageObserver: MutationObserver | null = null; export function setupShowImageObserver() { - if (showImageObserver) return; - const container = document.querySelector('.messages-area') || document.body; - if (!container) return; - renderShowImages(container); - showImageObserver = new MutationObserver(() => renderShowImages(container)); - showImageObserver.observe(container, { childList: true, subtree: true }); + if (showImageObserver) return; + const container = document.querySelector('.messages-area') || document.body; + if (!container) return; + renderShowImages(container); + showImageObserver = new MutationObserver(() => renderShowImages(container)); + showImageObserver.observe(container, { childList: true, subtree: true }); } export function teardownShowImageObserver() { - if (showImageObserver) { - showImageObserver.disconnect(); - showImageObserver = null; - } + if (showImageObserver) { + showImageObserver.disconnect(); + showImageObserver = null; + } } function updateViewportHeightVar() { - const docEl = document.documentElement; - const visualViewport = window.visualViewport; + const docEl = document.documentElement; + const visualViewport = window.visualViewport; - if (visualViewport) { - const vh = visualViewport.height; - const bottomInset = Math.max( - 0, - (window.innerHeight || docEl.clientHeight || vh) - visualViewport.height - visualViewport.offsetTop - ); - docEl.style.setProperty('--app-viewport', `${vh}px`); - docEl.style.setProperty('--app-bottom-inset', `${bottomInset}px`); - } else { - const height = window.innerHeight || docEl.clientHeight; - if (height) { - docEl.style.setProperty('--app-viewport', `${height}px`); - } - docEl.style.setProperty('--app-bottom-inset', 'env(safe-area-inset-bottom, 0px)'); + if (visualViewport) { + const vh = visualViewport.height; + const bottomInset = Math.max( + 0, + (window.innerHeight || docEl.clientHeight || vh) - + visualViewport.height - + visualViewport.offsetTop + ); + docEl.style.setProperty('--app-viewport', `${vh}px`); + docEl.style.setProperty('--app-bottom-inset', `${bottomInset}px`); + } else { + const height = window.innerHeight || docEl.clientHeight; + if (height) { + docEl.style.setProperty('--app-viewport', `${height}px`); } + docEl.style.setProperty('--app-bottom-inset', 'env(safe-area-inset-bottom, 0px)'); + } } if (typeof window !== 'undefined') { - window.katex = katex; + window.katex = katex; - updateViewportHeightVar(); - window.addEventListener('resize', updateViewportHeightVar); - window.addEventListener('orientationchange', updateViewportHeightVar); - window.addEventListener('pageshow', updateViewportHeightVar); - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', updateViewportHeightVar); - window.visualViewport.addEventListener('scroll', updateViewportHeightVar); - } + updateViewportHeightVar(); + window.addEventListener('resize', updateViewportHeightVar); + window.addEventListener('orientationchange', updateViewportHeightVar); + window.addEventListener('pageshow', updateViewportHeightVar); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', updateViewportHeightVar); + window.visualViewport.addEventListener('scroll', updateViewportHeightVar); + } } diff --git a/static/src/app/components.ts b/static/src/app/components.ts index c4630ea..54e2e8e 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -1,31 +1,45 @@ +import { defineAsyncComponent } from 'vue'; import ChatArea from '../components/chat/ChatArea.vue'; import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue'; import LeftPanel from '../components/panels/LeftPanel.vue'; import TokenDrawer from '../components/token/TokenDrawer.vue'; -import PersonalizationDrawer from '../components/personalization/PersonalizationDrawer.vue'; import QuickMenu from '../components/input/QuickMenu.vue'; import InputComposer from '../components/input/InputComposer.vue'; import AppShell from '../components/shell/AppShell.vue'; -import ImagePicker from '../components/overlay/ImagePicker.vue'; -import ConversationReviewDialog from '../components/overlay/ConversationReviewDialog.vue'; -import SubAgentActivityDialog from '../components/overlay/SubAgentActivityDialog.vue'; -import BackgroundCommandDialog from '../components/overlay/BackgroundCommandDialog.vue'; -import TutorialOverlay from '../components/overlay/TutorialOverlay.vue'; -import NewUserTutorialPrompt from '../components/overlay/NewUserTutorialPrompt.vue'; + +const PersonalizationDrawer = defineAsyncComponent( + () => import('../components/personalization/PersonalizationDrawer.vue') +); +const ImagePicker = defineAsyncComponent(() => import('../components/overlay/ImagePicker.vue')); +const ConversationReviewDialog = defineAsyncComponent( + () => import('../components/overlay/ConversationReviewDialog.vue') +); +const SubAgentActivityDialog = defineAsyncComponent( + () => import('../components/overlay/SubAgentActivityDialog.vue') +); +const BackgroundCommandDialog = defineAsyncComponent( + () => import('../components/overlay/BackgroundCommandDialog.vue') +); +const TutorialOverlay = defineAsyncComponent( + () => import('../components/overlay/TutorialOverlay.vue') +); +const NewUserTutorialPrompt = defineAsyncComponent( + () => import('../components/overlay/NewUserTutorialPrompt.vue') +); export const appComponents = { - ChatArea, - ConversationSidebar, - LeftPanel, - TokenDrawer, - PersonalizationDrawer, - QuickMenu, - InputComposer, - AppShell, - ImagePicker, - ConversationReviewDialog, - SubAgentActivityDialog, - BackgroundCommandDialog, - TutorialOverlay, - NewUserTutorialPrompt + ChatArea, + ConversationSidebar, + LeftPanel, + TokenDrawer, + PersonalizationDrawer, + QuickMenu, + InputComposer, + AppShell, + ImagePicker, + ConversationReviewDialog, + SubAgentActivityDialog, + BackgroundCommandDialog, + TutorialOverlay, + NewUserTutorialPrompt }; diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index e62a71e..597eb00 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -15,161 +15,168 @@ import { useMonitorStore } from '../stores/monitor'; import { usePolicyStore } from '../stores/policy'; export const computed = { - ...mapWritableState(useConnectionStore, [ - 'isConnected', - 'socket', - 'stopRequested', - 'projectPath', - 'agentVersion', - 'thinkingMode', - 'runMode' - ]), - ...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']), - ...mapWritableState(useUiStore, [ - 'sidebarCollapsed', - 'workspaceCollapsed', - 'chatDisplayMode', - 'panelMode', - 'panelMenuOpen', - 'leftWidth', - 'rightWidth', - 'rightCollapsed', - 'isResizing', - 'resizingPanel', - 'minPanelWidth', - 'maxPanelWidth', - 'quotaToast', - 'toastQueue', - 'confirmDialog', - 'easterEgg', - 'isMobileViewport', - 'mobileOverlayMenuOpen', - 'activeMobileOverlay' - ]), - ...mapWritableState(useConversationStore, [ - 'conversations', - 'conversationsLoading', - 'hasMoreConversations', - 'loadingMoreConversations', - 'currentConversationId', - 'currentConversationTitle', - 'searchQuery', - 'searchTimer', - 'searchResults', - 'searchActive', - 'searchInProgress', - 'searchMoreAvailable', - 'searchOffset', - 'searchTotal', - 'conversationsOffset', - 'conversationsLimit' - ]), - ...mapWritableState(useModelStore, ['currentModelKey']), - ...mapState(useModelStore, ['models']), - ...mapWritableState(useChatStore, [ - 'messages', - 'currentMessageIndex', - 'streamingMessage', - 'expandedBlocks', - 'autoScrollEnabled', - 'userScrolling', - 'thinkingScrollLocks' - ]), - ...mapWritableState(useInputStore, [ - 'inputMessage', - 'inputLineCount', - 'inputIsMultiline', - 'inputIsFocused', - 'quickMenuOpen', - 'toolMenuOpen', - 'settingsOpen', - 'imagePickerOpen', - 'videoPickerOpen', - 'selectedImages', - 'selectedVideos' - ]), - resolvedRunMode() { - const allowed = ['fast', 'thinking', 'deep']; - if (allowed.includes(this.runMode)) { - return this.runMode; - } - return this.thinkingMode ? 'thinking' : 'fast'; - }, - headerRunModeOptions() { - return [ - { value: 'fast', label: '快速模式', desc: '低思考,响应更快' }, - { value: 'thinking', label: '思考模式', desc: '更长思考,综合回答' }, - { value: 'deep', label: '深度思考', desc: '持续推理,适合复杂任务' } - ]; - }, - headerRunModeLabel() { - const current = this.headerRunModeOptions.find((o) => o.value === this.resolvedRunMode); - return current ? current.label : '快速模式'; - }, - currentModelLabel() { - const modelStore = useModelStore(); - return modelStore.currentModel?.label || 'Kimi-k2.5'; - }, - policyUiBlocks() { - const store = usePolicyStore(); - return store.uiBlocks || {}; - }, - adminDisabledModels() { - const store = usePolicyStore(); - return store.disabledModelSet; - }, - modelOptions() { - const disabledSet = this.adminDisabledModels || new Set(); - const options = this.models || []; - return options.map((opt) => ({ - ...opt, - disabled: disabledSet.has(opt.key) - })).filter((opt) => true); - }, - titleRibbonVisible() { - return !this.isMobileViewport && this.chatDisplayMode === 'chat'; - }, - ...mapWritableState(useToolStore, [ - 'preparingTools', - 'activeTools', - 'toolActionIndex', - 'toolStacks', - 'toolSettings', - 'toolSettingsLoading' - ]), - ...mapWritableState(useResourceStore, [ - 'tokenPanelCollapsed', - 'currentContextTokens', - 'currentConversationTokens', - 'projectStorage', - 'containerStatus', - 'containerNetRate', - 'usageQuota' - ]), - ...mapWritableState(useFocusStore, ['focusedFiles']), - ...mapWritableState(useUploadStore, ['uploading', 'mediaUploading']), - ...mapState(useMonitorStore, { - monitorIsLocked: (store) => store.isLocked - }), - displayModeSwitchDisabled() { - return !!this.policyUiBlocks.block_virtual_monitor; - }, - displayLockEngaged() { - return !!this.compressionInProgress || !!this.compressing; - }, - streamingUi() { - return this.streamingMessage || this.hasPendingToolActions(); - }, - composerBusy() { - const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; - return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested || this.compressionInProgress || this.compressing; - }, - composerHeroActive() { - return (this.blankHeroActive || this.blankHeroExiting) && !this.composerInteractionActive; - }, - composerInteractionActive() { - const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0); - const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0; - return this.quickMenuOpen || hasText || hasImages; + ...mapWritableState(useConnectionStore, [ + 'isConnected', + 'socket', + 'stopRequested', + 'projectPath', + 'agentVersion', + 'thinkingMode', + 'runMode' + ]), + ...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']), + ...mapWritableState(useUiStore, [ + 'sidebarCollapsed', + 'workspaceCollapsed', + 'chatDisplayMode', + 'panelMode', + 'panelMenuOpen', + 'leftWidth', + 'rightWidth', + 'rightCollapsed', + 'isResizing', + 'resizingPanel', + 'minPanelWidth', + 'maxPanelWidth', + 'quotaToast', + 'toastQueue', + 'confirmDialog', + 'easterEgg', + 'isMobileViewport', + 'mobileOverlayMenuOpen', + 'activeMobileOverlay' + ]), + ...mapWritableState(useConversationStore, [ + 'conversations', + 'conversationsLoading', + 'hasMoreConversations', + 'loadingMoreConversations', + 'currentConversationId', + 'currentConversationTitle', + 'searchQuery', + 'searchTimer', + 'searchResults', + 'searchActive', + 'searchInProgress', + 'searchMoreAvailable', + 'searchOffset', + 'searchTotal', + 'conversationsOffset', + 'conversationsLimit' + ]), + ...mapWritableState(useModelStore, ['currentModelKey']), + ...mapState(useModelStore, ['models']), + ...mapWritableState(useChatStore, [ + 'messages', + 'currentMessageIndex', + 'streamingMessage', + 'expandedBlocks', + 'autoScrollEnabled', + 'userScrolling', + 'thinkingScrollLocks' + ]), + ...mapWritableState(useInputStore, [ + 'inputMessage', + 'inputLineCount', + 'inputIsMultiline', + 'inputIsFocused', + 'quickMenuOpen', + 'toolMenuOpen', + 'settingsOpen', + 'imagePickerOpen', + 'videoPickerOpen', + 'selectedImages', + 'selectedVideos' + ]), + resolvedRunMode() { + const allowed = ['fast', 'thinking', 'deep']; + if (allowed.includes(this.runMode)) { + return this.runMode; } + return this.thinkingMode ? 'thinking' : 'fast'; + }, + headerRunModeOptions() { + return [ + { value: 'fast', label: '快速模式', desc: '低思考,响应更快' }, + { value: 'thinking', label: '思考模式', desc: '更长思考,综合回答' }, + { value: 'deep', label: '深度思考', desc: '持续推理,适合复杂任务' } + ]; + }, + headerRunModeLabel() { + const current = this.headerRunModeOptions.find((o) => o.value === this.resolvedRunMode); + return current ? current.label : '快速模式'; + }, + currentModelLabel() { + const modelStore = useModelStore(); + return modelStore.currentModel?.label || 'Kimi-k2.5'; + }, + policyUiBlocks() { + const store = usePolicyStore(); + return store.uiBlocks || {}; + }, + adminDisabledModels() { + const store = usePolicyStore(); + return store.disabledModelSet; + }, + modelOptions() { + const disabledSet = this.adminDisabledModels || new Set(); + const options = this.models || []; + return options.map((opt) => ({ + ...opt, + disabled: disabledSet.has(opt.key) + })); + }, + titleRibbonVisible() { + return !this.isMobileViewport && this.chatDisplayMode === 'chat'; + }, + ...mapWritableState(useToolStore, [ + 'preparingTools', + 'activeTools', + 'toolActionIndex', + 'toolStacks', + 'toolSettings', + 'toolSettingsLoading' + ]), + ...mapWritableState(useResourceStore, [ + 'tokenPanelCollapsed', + 'currentContextTokens', + 'currentConversationTokens', + 'projectStorage', + 'containerStatus', + 'containerNetRate', + 'usageQuota' + ]), + ...mapWritableState(useFocusStore, ['focusedFiles']), + ...mapWritableState(useUploadStore, ['uploading', 'mediaUploading']), + ...mapState(useMonitorStore, { + monitorIsLocked: (store) => store.isLocked + }), + displayModeSwitchDisabled() { + return !!this.policyUiBlocks.block_virtual_monitor; + }, + displayLockEngaged() { + return !!this.compressionInProgress || !!this.compressing; + }, + streamingUi() { + return this.streamingMessage || this.hasPendingToolActions(); + }, + composerBusy() { + const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; + return ( + this.streamingUi || + this.taskInProgress || + monitorLock || + this.stopRequested || + this.compressionInProgress || + this.compressing + ); + }, + composerHeroActive() { + return (this.blankHeroActive || this.blankHeroExiting) && !this.composerInteractionActive; + }, + composerInteractionActive() { + const hasText = !!(this.inputMessage && this.inputMessage.trim().length > 0); + const hasImages = Array.isArray(this.selectedImages) && this.selectedImages.length > 0; + return this.quickMenuOpen || hasText || hasImages; + } }; diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 856d4e7..609fb44 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -5,119 +5,119 @@ import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap'; import { debugLog } from './methods/common'; export function created() { - const actionStore = useChatActionStore(); - actionStore.registerDependencies({ - pushToast: (payload) => this.uiPushToast(payload), - autoResizeInput: () => this.autoResizeInput(), - focusComposer: () => { - const inputEl = this.getComposerElement('stadiumInput'); - if (inputEl && typeof inputEl.focus === 'function') { - inputEl.focus(); - } - }, - isConnected: () => this.isConnected, - executeCommand: async (command) => this.executeSystemCommand(command, { showToast: false }), - downloadResource: (url, filename) => this.downloadResource(url, filename) - }); + const actionStore = useChatActionStore(); + actionStore.registerDependencies({ + pushToast: (payload) => this.uiPushToast(payload), + autoResizeInput: () => this.autoResizeInput(), + focusComposer: () => { + const inputEl = this.getComposerElement('stadiumInput'); + if (inputEl && typeof inputEl.focus === 'function') { + inputEl.focus(); + } + }, + isConnected: () => this.isConnected, + executeCommand: async (command) => this.executeSystemCommand(command, { showToast: false }), + downloadResource: (url, filename) => this.downloadResource(url, filename) + }); } export async function mounted() { - debugLog('Vue应用已挂载'); - console.log('[DEBUG_SYSTEM][boot] 前端调试版本已加载 2026-04-05'); - if (window.ensureCsrfToken) { - window.ensureCsrfToken().catch((err) => { - console.warn('CSRF token 初始化失败:', err); - }); - } - // 并行启动路由解析与初始化数据,网页端不再依赖 WebSocket 初始化 - const routePromise = this.bootstrapRoute(); - await routePromise; - this.$nextTick(() => { - this.ensureScrollListener(); - // 刷新后若无输出,自动解锁滚动锁定 - normalizeScrollLock(this); + debugLog('Vue应用已挂载'); + console.log('[DEBUG_SYSTEM][boot] 前端调试版本已加载 2026-04-05'); + if (window.ensureCsrfToken) { + window.ensureCsrfToken().catch((err) => { + console.warn('CSRF token 初始化失败:', err); }); - setupShowImageObserver(); + } + // 并行启动路由解析与初始化数据,网页端不再依赖 WebSocket 初始化 + const routePromise = this.bootstrapRoute(); + await routePromise; + this.$nextTick(() => { + this.ensureScrollListener(); + // 刷新后若无输出,自动解锁滚动锁定 + normalizeScrollLock(this); + }); + setupShowImageObserver(); - // 立即加载初始数据(并行获取状态,优先同步运行模式) - const initialDataPromise = this.loadInitialData(); - this.startConnectionHeartbeat(); - this.checkTutorialPrompt(); - if (initialDataPromise && typeof initialDataPromise.then === 'function') { - initialDataPromise - .then(() => { - // 避免在初始加载阶段被覆盖,加载完成后再兜底检查一次 - this.checkTutorialPrompt(); - }) - .catch(() => {}); + // 立即加载初始数据(并行获取状态,优先同步运行模式) + const initialDataPromise = this.loadInitialData(); + this.startConnectionHeartbeat(); + this.checkTutorialPrompt(); + if (initialDataPromise && typeof initialDataPromise.then === 'function') { + initialDataPromise + .then(() => { + // 避免在初始加载阶段被覆盖,加载完成后再兜底检查一次 + this.checkTutorialPrompt(); + }) + .catch(() => {}); + } + + // 注册全局事件处理器(用于任务轮询) + (window as any).__taskEventHandler = (event: any) => { + if (typeof this.handleTaskEvent === 'function') { + this.handleTaskEvent(event); } + }; - // 注册全局事件处理器(用于任务轮询) - (window as any).__taskEventHandler = (event: any) => { - if (typeof this.handleTaskEvent === 'function') { - this.handleTaskEvent(event); - } - }; + // 立即尝试恢复运行中的任务(不延迟) + if (typeof this.restoreTaskState === 'function') { + this.restoreTaskState(); + } - // 立即尝试恢复运行中的任务(不延迟) - if (typeof this.restoreTaskState === 'function') { - this.restoreTaskState(); - } + document.addEventListener('click', this.handleClickOutsideQuickMenu); + document.addEventListener('click', this.handleClickOutsidePanelMenu); + document.addEventListener('click', this.handleClickOutsideHeaderMenu); + document.addEventListener('click', this.handleClickOutsideMobileMenu); + document.addEventListener('click', this.handleCopyCodeClick); + window.addEventListener('popstate', this.handlePopState); + window.addEventListener('keydown', this.handleMobileOverlayEscape); + this.setupMobileViewportWatcher(); - document.addEventListener('click', this.handleClickOutsideQuickMenu); - document.addEventListener('click', this.handleClickOutsidePanelMenu); - document.addEventListener('click', this.handleClickOutsideHeaderMenu); - document.addEventListener('click', this.handleClickOutsideMobileMenu); - document.addEventListener('click', this.handleCopyCodeClick); - window.addEventListener('popstate', this.handlePopState); - window.addEventListener('keydown', this.handleMobileOverlayEscape); - this.setupMobileViewportWatcher(); + this.subAgentFetch(); + this.subAgentStartPolling(); + this.backgroundCommandFetch(); + this.backgroundCommandStartPolling(); - this.subAgentFetch(); - this.subAgentStartPolling(); - this.backgroundCommandFetch(); - this.backgroundCommandStartPolling(); - - this.$nextTick(() => { - this.autoResizeInput(); - }); - this.resourceBindContainerVisibilityWatcher(); - this.resourceStartContainerStatsPolling(); - this.resourceStartProjectStoragePolling(); - this.resourceStartUsageQuotaPolling(); + this.$nextTick(() => { + this.autoResizeInput(); + }); + this.resourceBindContainerVisibilityWatcher(); + this.resourceStartContainerStatsPolling(); + this.resourceStartProjectStoragePolling(); + this.resourceStartUsageQuotaPolling(); } export function beforeUnmount() { - // 停止任务轮询 - try { - const { useTaskStore } = require('../stores/task'); - const taskStore = useTaskStore(); - taskStore.stopPolling(); - } catch (error) { - // ignore - } + // 停止任务轮询 + try { + const { useTaskStore } = require('../stores/task'); + const taskStore = useTaskStore(); + taskStore.stopPolling(); + } catch (error) { + // ignore + } - document.removeEventListener('click', this.handleClickOutsideQuickMenu); - document.removeEventListener('click', this.handleClickOutsidePanelMenu); - document.removeEventListener('click', this.handleClickOutsideHeaderMenu); - document.removeEventListener('click', this.handleClickOutsideMobileMenu); - document.removeEventListener('click', this.handleCopyCodeClick); - window.removeEventListener('popstate', this.handlePopState); - window.removeEventListener('keydown', this.handleMobileOverlayEscape); - this.teardownMobileViewportWatcher(); - this.subAgentStopPolling(); - this.backgroundCommandStopPolling(); - this.resourceStopContainerStatsPolling(); - this.resourceStopProjectStoragePolling(); - this.resourceStopUsageQuotaPolling(); - this.stopConnectionHeartbeat(); - teardownShowImageObserver(); - if (this.titleTypingTimer) { - clearInterval(this.titleTypingTimer); - this.titleTypingTimer = null; - } - const cleanup = this.destroyEasterEggEffect(true); - if (cleanup && typeof cleanup.catch === 'function') { - cleanup.catch(() => {}); - } + document.removeEventListener('click', this.handleClickOutsideQuickMenu); + document.removeEventListener('click', this.handleClickOutsidePanelMenu); + document.removeEventListener('click', this.handleClickOutsideHeaderMenu); + document.removeEventListener('click', this.handleClickOutsideMobileMenu); + document.removeEventListener('click', this.handleCopyCodeClick); + window.removeEventListener('popstate', this.handlePopState); + window.removeEventListener('keydown', this.handleMobileOverlayEscape); + this.teardownMobileViewportWatcher(); + this.subAgentStopPolling(); + this.backgroundCommandStopPolling(); + this.resourceStopContainerStatsPolling(); + this.resourceStopProjectStoragePolling(); + this.resourceStopUsageQuotaPolling(); + this.stopConnectionHeartbeat(); + teardownShowImageObserver(); + if (this.titleTypingTimer) { + clearInterval(this.titleTypingTimer); + this.titleTypingTimer = null; + } + const cleanup = this.destroyEasterEggEffect(true); + if (cleanup && typeof cleanup.catch === 'function') { + cleanup.catch(() => {}); + } } diff --git a/static/src/app/methods/common.ts b/static/src/app/methods/common.ts index 77e1686..7209314 100644 --- a/static/src/app/methods/common.ts +++ b/static/src/app/methods/common.ts @@ -3,19 +3,19 @@ const ENABLE_APP_DEBUG_LOGS = true; const TRACE_CONV = true; export function debugLog(...args) { - if (!ENABLE_APP_DEBUG_LOGS) return; - try { - console.log('[app]', ...args); - } catch (e) { - /* ignore logging errors */ - } + if (!ENABLE_APP_DEBUG_LOGS) return; + try { + console.log('[app]', ...args); + } catch (e) { + /* ignore logging errors */ + } } export const traceLog = (...args) => { - if (!TRACE_CONV) return; - try { - console.log('[conv-trace]', ...args); - } catch (e) { - // ignore - } + if (!TRACE_CONV) return; + try { + console.log('[conv-trace]', ...args); + } catch (e) { + // ignore + } }; diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 9840939..990e2ff 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -1,669 +1,692 @@ // @ts-nocheck import { debugLog, traceLog } from './common'; +import { usePersonalizationStore } from '../../stores/personalization'; export const conversationMethods = { - // 完整重置所有状态 - resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { - console.log('[DEBUG_AWAITING] ===== resetAllStates =====', { reason }); + // 完整重置所有状态 + resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { + console.log('[DEBUG_AWAITING] ===== resetAllStates =====', { reason }); - // 如果正在等待子智能体完成,不重置任务状态 - if (this.waitingForSubAgent) { - debugLog('跳过状态重置:正在等待子智能体完成', { reason }); - return; + // 如果正在等待子智能体完成,不重置任务状态 + if (this.waitingForSubAgent) { + debugLog('跳过状态重置:正在等待子智能体完成', { reason }); + return; + } + + debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId }); + this.logMessageState('resetAllStates:before-cleanup', { reason }); + this.fileHideContextMenu(); + this.monitorResetVisual({ + preserveBubble: true, + preservePointer: true, + preserveWindows: !!options?.preserveMonitorWindows, + preserveQueue: !!options?.preserveMonitorWindows + }); + + // 重置消息和流状态 + this.streamingMessage = false; + this.currentMessageIndex = -1; + this.stopRequested = false; + this.taskInProgress = false; + this.dropToolEvents = false; + + // 清理工具状态 + this.toolResetTracking(); + + // 新增:将所有未完成的工具标记为已完成,并清理awaitingFirstContent状态 + const assistantMsgsBefore = this.messages + .filter((m) => m.role === 'assistant') + .map((m) => ({ + awaitingFirstContent: m.awaitingFirstContent, + generatingLabel: m.generatingLabel + })); + + this.messages.forEach((msg) => { + if (msg.role === 'assistant') { + // 清理等待动画状态 + if (msg.awaitingFirstContent) { + msg.awaitingFirstContent = false; + } + if (msg.generatingLabel) { + msg.generatingLabel = ''; } - - debugLog('重置所有前端状态', { reason, conversationId: this.currentConversationId }); - this.logMessageState('resetAllStates:before-cleanup', { reason }); - this.fileHideContextMenu(); - this.monitorResetVisual({ - preserveBubble: true, - preservePointer: true, - preserveWindows: !!options?.preserveMonitorWindows, - preserveQueue: !!options?.preserveMonitorWindows - }); - - // 重置消息和流状态 - this.streamingMessage = false; - this.currentMessageIndex = -1; - this.stopRequested = false; - this.taskInProgress = false; - this.dropToolEvents = false; // 清理工具状态 - this.toolResetTracking(); - - // 新增:将所有未完成的工具标记为已完成,并清理awaitingFirstContent状态 - const assistantMsgsBefore = this.messages.filter(m => m.role === 'assistant').map(m => ({ - awaitingFirstContent: m.awaitingFirstContent, - generatingLabel: m.generatingLabel - })); - - this.messages.forEach((msg) => { - if (msg.role === 'assistant') { - // 清理等待动画状态 - if (msg.awaitingFirstContent) { - msg.awaitingFirstContent = false; - } - if (msg.generatingLabel) { - msg.generatingLabel = ''; - } - - // 清理工具状态 - if (msg.actions) { - msg.actions.forEach(action => { - if (action.type === 'tool' && - (action.tool.status === 'preparing' || action.tool.status === 'running')) { - action.tool.status = 'completed'; - } - }); - } + if (msg.actions) { + msg.actions.forEach((action) => { + if ( + action.type === 'tool' && + (action.tool.status === 'preparing' || action.tool.status === 'running') + ) { + action.tool.status = 'completed'; } - }); - - const assistantMsgsAfter = this.messages.filter(m => m.role === 'assistant').map(m => ({ - awaitingFirstContent: m.awaitingFirstContent, - generatingLabel: m.generatingLabel - })); - - console.log('[DEBUG_AWAITING] resetAllStates 清理完成', { - before: assistantMsgsBefore, - after: assistantMsgsAfter - }); - - // 清理Markdown缓存 - if (this.markdownCache) { - this.markdownCache.clear(); + }); } - this.chatClearThinkingLocks(); + } + }); - // 强制更新视图 - this.$forceUpdate(); + const assistantMsgsAfter = this.messages + .filter((m) => m.role === 'assistant') + .map((m) => ({ + awaitingFirstContent: m.awaitingFirstContent, + generatingLabel: m.generatingLabel + })); - this.inputSetSettingsOpen(false); - this.inputSetToolMenuOpen(false); - this.inputSetQuickMenuOpen(false); - this.modeMenuOpen = false; - this.inputSetLineCount(1); - this.inputSetMultiline(false); - this.inputClearMessage(); - this.inputClearSelectedImages(); - this.inputSetImagePickerOpen(false); - this.imageEntries = []; - this.imageLoading = false; - this.conversationHasImages = false; - this.toolSetSettingsLoading(false); - this.toolSetSettings([]); + console.log('[DEBUG_AWAITING] resetAllStates 清理完成', { + before: assistantMsgsBefore, + after: assistantMsgsAfter + }); - debugLog('前端状态重置完成'); - this._scrollListenerReady = false; - this.$nextTick(() => { - this.ensureScrollListener(); - }); + // 清理Markdown缓存 + if (this.markdownCache) { + this.markdownCache.clear(); + } + this.chatClearThinkingLocks(); - // 重置已加载对话标记,便于后续重新加载新对话历史 - this.lastHistoryLoadedConversationId = null; + // 强制更新视图 + this.$forceUpdate(); - this.logMessageState('resetAllStates:after-cleanup', { reason }); - }, + this.inputSetSettingsOpen(false); + this.inputSetToolMenuOpen(false); + this.inputSetQuickMenuOpen(false); + this.modeMenuOpen = false; + this.inputSetLineCount(1); + this.inputSetMultiline(false); + this.inputClearMessage(); + this.inputClearSelectedImages(); + this.inputSetImagePickerOpen(false); + this.imageEntries = []; + this.imageLoading = false; + this.conversationHasImages = false; + this.toolSetSettingsLoading(false); + this.toolSetSettings([]); - scheduleResetAfterTask(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { - const start = Date.now(); - const maxWait = 4000; - const interval = 200; - const tryReset = () => { - if (!this.monitorIsLocked || Date.now() - start >= maxWait) { - this.resetAllStates(reason, options); - return; - } - setTimeout(tryReset, interval); - }; - tryReset(); - }, + debugLog('前端状态重置完成'); + this._scrollListenerReady = false; + this.$nextTick(() => { + this.ensureScrollListener(); + }); - resetTokenStatistics() { - this.resourceResetTokenStatistics(); - }, + // 重置已加载对话标记,便于后续重新加载新对话历史 + this.lastHistoryLoadedConversationId = null; - // ========================================== - // 对话管理核心功能 - // ========================================== + this.logMessageState('resetAllStates:after-cleanup', { reason }); + }, - async loadConversationsList() { - const queryOffset = this.conversationsOffset; - const queryLimit = this.conversationsLimit; - const refreshToken = queryOffset === 0 ? ++this.conversationListRefreshToken : this.conversationListRefreshToken; - const requestSeq = ++this.conversationListRequestSeq; - this.conversationsLoading = true; - try { - const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}`); - const data = await response.json(); + scheduleResetAfterTask( + reason = 'unspecified', + options: { preserveMonitorWindows?: boolean } = {} + ) { + const start = Date.now(); + const maxWait = 4000; + const interval = 200; + const tryReset = () => { + if (!this.monitorIsLocked || Date.now() - start >= maxWait) { + this.resetAllStates(reason, options); + return; + } + setTimeout(tryReset, interval); + }; + tryReset(); + }, - if (data.success) { - if (refreshToken !== this.conversationListRefreshToken) { - debugLog('忽略已过期的对话列表响应', { - requestSeq, - responseOffset: queryOffset - }); - return; - } + resetTokenStatistics() { + this.resourceResetTokenStatistics(); + }, - if (queryOffset === 0) { - this.conversations = data.data.conversations; - } else { - this.conversations.push(...data.data.conversations); - } - if (this.currentConversationId) { - this.promoteConversationToTop(this.currentConversationId); - } - this.hasMoreConversations = data.data.has_more; - debugLog(`已加载 ${this.conversations.length} 个对话`); + // ========================================== + // 对话管理核心功能 + // ========================================== - if ( - this.conversationsOffset === 0 && - !this.currentConversationId && - this.conversations.length > 0 && - !this.isExplicitNewConversationRoute() - ) { - // 只有在初始化完成后,才自动加载第一个对话 - // 避免与 bootstrapRoute 冲突 - if (this.initialRouteResolved) { - const latestConversation = this.conversations[0]; - if (latestConversation && latestConversation.id) { - await this.loadConversation(latestConversation.id); - } - } - } - } else { - console.error('加载对话列表失败:', data.error); - } - } catch (error) { - console.error('加载对话列表异常:', error); - } finally { - if (refreshToken === this.conversationListRefreshToken) { - this.conversationsLoading = false; - } - } - }, + async loadConversationsList() { + const queryOffset = this.conversationsOffset; + const queryLimit = this.conversationsLimit; + const refreshToken = + queryOffset === 0 ? ++this.conversationListRefreshToken : this.conversationListRefreshToken; + const requestSeq = ++this.conversationListRequestSeq; + this.conversationsLoading = true; + try { + const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}`); + const data = await response.json(); - async loadMoreConversations() { - if (this.loadingMoreConversations || !this.hasMoreConversations) return; - - this.loadingMoreConversations = true; - this.conversationsOffset += this.conversationsLimit; - await this.loadConversationsList(); - this.loadingMoreConversations = false; - }, - - async loadConversation(conversationId, options = {}) { - const force = Boolean(options.force); - debugLog('加载对话:', conversationId); - traceLog('loadConversation:start', { conversationId, currentConversationId: this.currentConversationId, force }); - this.logMessageState('loadConversation:start', { conversationId, force }); - this.suppressTitleTyping = true; - this.titleReady = false; - this.currentConversationTitle = ''; - this.titleTypingText = ''; - - if (!force && conversationId === this.currentConversationId) { - debugLog('已是当前对话,跳过加载'); - traceLog('loadConversation:skip-same', { conversationId }); - this.suppressTitleTyping = false; - this.titleReady = true; - return; + if (data.success) { + if (refreshToken !== this.conversationListRefreshToken) { + debugLog('忽略已过期的对话列表响应', { + requestSeq, + responseOffset: queryOffset + }); + return; } - if ((this.compressionInProgress || this.compressing) && !force) { - const confirmed = await this.confirmAction({ - title: '压缩进行中', - message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?', - confirmText: '确认', - cancelText: '取消' - }); - if (!confirmed) { - this.suppressTitleTyping = false; - this.titleReady = true; - return; - } - try { - if (this.currentConversationId) { - await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, { method: 'POST' }); - } - } catch (e) { - console.warn('取消压缩失败:', e); - } - this.compressionInProgress = false; - this.compressing = false; - this.compressionMode = ''; - this.compressionStage = ''; - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; + if (queryOffset === 0) { + this.conversations = data.data.conversations; + } else { + this.conversations.push(...data.data.conversations); + } + if (this.currentConversationId) { + this.promoteConversationToTop(this.currentConversationId); + } + this.hasMoreConversations = data.data.has_more; + debugLog(`已加载 ${this.conversations.length} 个对话`); + + if ( + this.conversationsOffset === 0 && + !this.currentConversationId && + this.conversations.length > 0 && + !this.isExplicitNewConversationRoute() + ) { + // 只有在初始化完成后,才自动加载第一个对话 + // 避免与 bootstrapRoute 冲突 + if (this.initialRouteResolved) { + const latestConversation = this.conversations[0]; + if (latestConversation && latestConversation.id) { + await this.loadConversation(latestConversation.id); } + } + } + } else { + console.error('加载对话列表失败:', data.error); + } + } catch (error) { + console.error('加载对话列表异常:', error); + } finally { + if (refreshToken === this.conversationListRefreshToken) { + this.conversationsLoading = false; + } + } + }, + + async loadMoreConversations() { + if (this.loadingMoreConversations || !this.hasMoreConversations) return; + + this.loadingMoreConversations = true; + this.conversationsOffset += this.conversationsLimit; + await this.loadConversationsList(); + this.loadingMoreConversations = false; + }, + + async loadConversation(conversationId, options = {}) { + const force = Boolean(options.force); + debugLog('加载对话:', conversationId); + traceLog('loadConversation:start', { + conversationId, + currentConversationId: this.currentConversationId, + force + }); + this.logMessageState('loadConversation:start', { conversationId, force }); + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; + + if (!force && conversationId === this.currentConversationId) { + debugLog('已是当前对话,跳过加载'); + traceLog('loadConversation:skip-same', { conversationId }); + this.suppressTitleTyping = false; + this.titleReady = true; + return; + } + + if ((this.compressionInProgress || this.compressing) && !force) { + const confirmed = await this.confirmAction({ + title: '压缩进行中', + message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?', + confirmText: '确认', + cancelText: '取消' + }); + if (!confirmed) { + this.suppressTitleTyping = false; + this.titleReady = true; + return; + } + try { + if (this.currentConversationId) { + await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, { + method: 'POST' + }); + } + } catch (e) { + console.warn('取消压缩失败:', e); + } + this.compressionInProgress = false; + this.compressing = false; + this.compressionMode = ''; + this.compressionStage = ''; + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + } + + // 应用个性化设置中的默认模型和思考模式 + try { + const personalizationStore = usePersonalizationStore(); + + if (personalizationStore.loaded) { + const defaultRunMode = personalizationStore.form.default_run_mode; + const defaultModel = personalizationStore.form.default_model; + + if (defaultRunMode) { + this.runMode = defaultRunMode; + debugLog('应用默认运行模式:', defaultRunMode); } - // 应用个性化设置中的默认模型和思考模式 - try { - const { usePersonalizationStore } = await import('../../stores/personalization'); - const personalizationStore = usePersonalizationStore(); - - if (personalizationStore.loaded) { - const defaultRunMode = personalizationStore.form.default_run_mode; - const defaultModel = personalizationStore.form.default_model; - - if (defaultRunMode) { - this.runMode = defaultRunMode; - debugLog('应用默认运行模式:', defaultRunMode); - } - - if (defaultModel) { - this.currentModelKey = defaultModel; - debugLog('应用默认模型:', defaultModel); - } - - // 根据默认运行模式设置思考模式 - if (defaultRunMode === 'thinking') { - this.thinkingMode = true; - } else if (defaultRunMode === 'fast') { - this.thinkingMode = false; - } - } - } catch (error) { - console.warn('应用个性化默认设置失败:', error); + if (defaultModel) { + this.currentModelKey = defaultModel; + debugLog('应用默认模型:', defaultModel); } - // 有任务或后台子智能体运行时,提示用户确认切换 - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - const hasActiveTask = taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent; - if (hasActiveTask) { - const waitingLabel = this.waitingForBackgroundCommand ? '后台指令' : '后台子智能体'; - const confirmed = await this.confirmAction({ - title: '切换对话', - message: this.waitingForSubAgent - ? `${waitingLabel}正在运行,切换对话后将不会自动接收完成提示。确定要切换吗?` - : '当前有任务正在执行,切换对话后任务会停止。确定要切换吗?', - confirmText: '切换', - cancelText: '取消' - }); - if (!confirmed) { - this.suppressTitleTyping = false; - this.titleReady = true; - return; - } - - if (taskStore.hasActiveTask) { - await taskStore.cancelTask(); - taskStore.clearTask(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - } - - if (this.waitingForSubAgent && !taskStore.hasActiveTask) { - this.waitingForSubAgent = false; - this.waitingForBackgroundCommand = false; - } - - this.streamingMessage = false; - this.taskInProgress = false; - this.stopRequested = false; - } - } catch (error) { - console.error('[切换对话] 检查/停止任务失败:', error); + // 根据默认运行模式设置思考模式 + if (defaultRunMode === 'thinking') { + this.thinkingMode = true; + } else if (defaultRunMode === 'fast') { + this.thinkingMode = false; } + } + } catch (error) { + console.warn('应用个性化默认设置失败:', error); + } - try { - // 1. 调用加载API - const response = await fetch(`/api/conversations/${conversationId}/load`, { - method: 'PUT' - }); - const result = await response.json(); - - if (result.success) { - debugLog('对话加载API成功:', result); - traceLog('loadConversation:api-success', { conversationId, title: result.title }); - - // 2. 更新当前对话信息 - this.skipConversationHistoryReload = true; - this.currentConversationId = conversationId; - this.currentConversationTitle = result.title; - this.titleReady = true; - this.suppressTitleTyping = false; - this.startTitleTyping(this.currentConversationTitle, { animate: false }); - this.promoteConversationToTop(conversationId); - history.pushState({ conversationId }, '', `/${this.stripConversationPrefix(conversationId)}`); - this.skipConversationLoadedEvent = true; - - // 3. 重置UI状态 - this.resetAllStates(`loadConversation:${conversationId}`); - this.subAgentFetch(); - this.fetchTodoList(); - - // 4. 立即加载历史和统计,确保列表切换后界面同步更新 - await this.fetchAndDisplayHistory(); - this.fetchConversationTokenStatistics(); - this.updateCurrentContextTokens(); - traceLog('loadConversation:after-history', { - conversationId, - messagesLen: Array.isArray(this.messages) ? this.messages.length : 'n/a' - }); - - } else { - console.error('对话加载失败:', result.message); - this.suppressTitleTyping = false; - this.titleReady = true; - this.uiPushToast({ - title: '加载对话失败', - message: result.message || '服务器未返回成功状态', - type: 'error' - }); - } - } catch (error) { - console.error('加载对话异常:', error); - traceLog('loadConversation:error', { conversationId, error: error?.message || String(error) }); - this.suppressTitleTyping = false; - this.titleReady = true; - this.uiPushToast({ - title: '加载对话异常', - message: error.message || String(error), - type: 'error' - }); - } - }, - - promoteConversationToTop(conversationId) { - if (!Array.isArray(this.conversations) || !conversationId) { - return; - } - const index = this.conversations.findIndex(conv => conv && conv.id === conversationId); - if (index > 0) { - const [selected] = this.conversations.splice(index, 1); - this.conversations.unshift(selected); - } - }, - - async createNewConversation() { - console.log('[DEBUG_AWAITING] ===== createNewConversation ====='); - if (this.compressionInProgress || this.compressing) { - const confirmed = await this.confirmAction({ - title: '压缩进行中', - message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?', - confirmText: '确认', - cancelText: '取消' - }); - if (!confirmed) { - return; - } - try { - if (this.currentConversationId) { - await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, { method: 'POST' }); - } - } catch (e) { - console.warn('取消压缩失败:', e); - } - this.compressionInProgress = false; - this.compressing = false; - this.compressionMode = ''; - this.compressionStage = ''; - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; - } - } - - debugLog('创建新对话...'); - traceLog('createNewConversation:start', { - currentConversationId: this.currentConversationId, - convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' - }); - this.logMessageState('createNewConversation:start'); - - // 应用个性化设置中的默认模型和思考模式 - try { - const { usePersonalizationStore } = await import('../../stores/personalization'); - const personalizationStore = usePersonalizationStore(); - - if (personalizationStore.loaded) { - const defaultRunMode = personalizationStore.form.default_run_mode; - const defaultModel = personalizationStore.form.default_model; - - if (defaultRunMode) { - this.runMode = defaultRunMode; - debugLog('应用默认运行模式:', defaultRunMode); - } - - if (defaultModel) { - this.currentModelKey = defaultModel; - debugLog('应用默认模型:', defaultModel); - } - - // 根据默认运行模式设置思考模式 - if (defaultRunMode === 'thinking') { - this.thinkingMode = true; - } else if (defaultRunMode === 'fast') { - this.thinkingMode = false; - } - } - } catch (error) { - console.warn('应用个性化默认设置失败:', error); - } - - // 检查是否有运行中的任务,如果有则提示用户 - let hasActiveTask = false; - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - - if (taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent) { - hasActiveTask = true; - const waitingLabel = this.waitingForBackgroundCommand ? '后台指令' : '后台子智能体'; - - // 显示提示 - const confirmed = await this.confirmAction({ - title: '创建新对话', - message: this.waitingForSubAgent - ? `${waitingLabel}正在运行,创建新对话后将不会自动接收完成提示。确定要创建吗?` - : '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?', - confirmText: '创建', - cancelText: '取消' - }); - - if (!confirmed) { - // 用户取消创建 - return; - } - - // 用户确认,停止任务 - debugLog('[创建新对话] 用户确认,正在停止任务...'); - if (taskStore.hasActiveTask) { - await taskStore.cancelTask(); - taskStore.clearTask(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - } - - // 重置任务相关状态 - this.streamingMessage = false; - this.taskInProgress = false; - this.stopRequested = false; - if (this.waitingForSubAgent && !taskStore.hasActiveTask) { - this.waitingForSubAgent = false; - this.waitingForBackgroundCommand = false; - } - - debugLog('[创建新对话] 任务已停止'); - } - } catch (error) { - console.error('[创建新对话] 检查/停止任务失败:', error); - } - - try { - const response = await fetch('/api/conversations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - thinking_mode: this.thinkingMode, - mode: this.runMode - }) - }); - - const result = await response.json(); - - if (result.success) { - const newConversationId = result.conversation_id; - debugLog('新对话创建成功:', newConversationId); - traceLog('createNewConversation:created', { newConversationId }); - - // 在本地列表插入占位,避免等待刷新 - const placeholder = { - id: newConversationId, - title: '新对话', - updated_at: new Date().toISOString(), - total_messages: 0, - total_tools: 0 - }; - this.conversations = [ - placeholder, - ...this.conversations.filter(conv => conv && conv.id !== newConversationId) - ]; - - // 直接加载新对话,确保状态一致 - // 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态 - await this.loadConversation(newConversationId, { force: true }); - traceLog('createNewConversation:after-load', { - newConversationId, - currentConversationId: this.currentConversationId - }); - - // 刷新对话列表获取最新统计 - this.conversationsOffset = 0; - await this.loadConversationsList(); - traceLog('createNewConversation:after-refresh', { - newConversationId, - conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' - }); - - // 如果停止了任务,显示提示 - if (hasActiveTask) { - this.uiPushToast({ - title: '任务已停止', - message: '创建新对话后,之前的任务已停止', - type: 'info', - duration: 3000 - }); - } - } else { - console.error('创建对话失败:', result.message); - this.uiPushToast({ - title: '创建对话失败', - message: result.message || '服务器未返回成功状态', - type: 'error' - }); - } - } catch (error) { - console.error('创建对话异常:', error); - this.uiPushToast({ - title: '创建对话异常', - message: error.message || String(error), - type: 'error' - }); - } - }, - - async deleteConversation(conversationId) { + // 有任务或后台子智能体运行时,提示用户确认切换 + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const hasActiveTask = + taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent; + if (hasActiveTask) { + const waitingLabel = this.waitingForBackgroundCommand ? '后台指令' : '后台子智能体'; const confirmed = await this.confirmAction({ - title: '删除对话', - message: '确定要删除这个对话吗?删除后无法恢复。', - confirmText: '删除', - cancelText: '取消' + title: '切换对话', + message: this.waitingForSubAgent + ? `${waitingLabel}正在运行,切换对话后将不会自动接收完成提示。确定要切换吗?` + : '当前有任务正在执行,切换对话后任务会停止。确定要切换吗?', + confirmText: '切换', + cancelText: '取消' }); if (!confirmed) { - return; + this.suppressTitleTyping = false; + this.titleReady = true; + return; } - debugLog('删除对话:', conversationId); - this.logMessageState('deleteConversation:start', { conversationId }); - - try { - const response = await fetch(`/api/conversations/${conversationId}`, { - method: 'DELETE' - }); - - const result = await response.json(); - - if (result.success) { - debugLog('对话删除成功'); - - // 如果删除的是当前对话,清空界面 - if (conversationId === this.currentConversationId) { - this.logMessageState('deleteConversation:before-clear', { conversationId }); - this.messages = []; - this.logMessageState('deleteConversation:after-clear', { conversationId }); - this.currentConversationId = null; - this.currentConversationTitle = ''; - this.resetAllStates(`deleteConversation:${conversationId}`); - this.resetTokenStatistics(); - history.replaceState({}, '', '/new'); - } - - // 刷新对话列表 - this.conversationsOffset = 0; - await this.loadConversationsList(); - - } else { - console.error('删除对话失败:', result.message); - this.uiPushToast({ - title: '删除对话失败', - message: result.message || '服务器未返回成功状态', - type: 'error' - }); - } - } catch (error) { - console.error('删除对话异常:', error); - this.uiPushToast({ - title: '删除对话异常', - message: error.message || String(error), - type: 'error' - }); + if (taskStore.hasActiveTask) { + await taskStore.cancelTask(); + taskStore.clearTask(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } } - }, - async duplicateConversation(conversationId) { - debugLog('复制对话:', conversationId); - try { - const response = await fetch(`/api/conversations/${conversationId}/duplicate`, { - method: 'POST' - }); - - const result = await response.json(); - - if (response.ok && result.success) { - const newId = result.duplicate_conversation_id; - if (newId) { - await this.loadConversation(newId, { force: true }); - } - - this.conversationsOffset = 0; - await this.loadConversationsList(); - } else { - const message = result.message || result.error || '复制失败'; - this.uiPushToast({ - title: '复制对话失败', - message, - type: 'error' - }); - } - } catch (error) { - console.error('复制对话异常:', error); - this.uiPushToast({ - title: '复制对话异常', - message: error.message || String(error), - type: 'error' - }); + if (this.waitingForSubAgent && !taskStore.hasActiveTask) { + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; } + + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + } + } catch (error) { + console.error('[切换对话] 检查/停止任务失败:', error); } + + try { + // 1. 调用加载API + const response = await fetch(`/api/conversations/${conversationId}/load`, { + method: 'PUT' + }); + const result = await response.json(); + + if (result.success) { + debugLog('对话加载API成功:', result); + traceLog('loadConversation:api-success', { conversationId, title: result.title }); + + // 2. 更新当前对话信息 + this.skipConversationHistoryReload = true; + this.currentConversationId = conversationId; + this.currentConversationTitle = result.title; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping(this.currentConversationTitle, { animate: false }); + this.promoteConversationToTop(conversationId); + history.pushState( + { conversationId }, + '', + `/${this.stripConversationPrefix(conversationId)}` + ); + this.skipConversationLoadedEvent = true; + + // 3. 重置UI状态 + this.resetAllStates(`loadConversation:${conversationId}`); + this.subAgentFetch(); + this.fetchTodoList(); + + // 4. 立即加载历史和统计,确保列表切换后界面同步更新 + await this.fetchAndDisplayHistory(); + this.fetchConversationTokenStatistics(); + this.updateCurrentContextTokens(); + traceLog('loadConversation:after-history', { + conversationId, + messagesLen: Array.isArray(this.messages) ? this.messages.length : 'n/a' + }); + } else { + console.error('对话加载失败:', result.message); + this.suppressTitleTyping = false; + this.titleReady = true; + this.uiPushToast({ + title: '加载对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); + } + } catch (error) { + console.error('加载对话异常:', error); + traceLog('loadConversation:error', { + conversationId, + error: error?.message || String(error) + }); + this.suppressTitleTyping = false; + this.titleReady = true; + this.uiPushToast({ + title: '加载对话异常', + message: error.message || String(error), + type: 'error' + }); + } + }, + + promoteConversationToTop(conversationId) { + if (!Array.isArray(this.conversations) || !conversationId) { + return; + } + const index = this.conversations.findIndex((conv) => conv && conv.id === conversationId); + if (index > 0) { + const [selected] = this.conversations.splice(index, 1); + this.conversations.unshift(selected); + } + }, + + async createNewConversation() { + console.log('[DEBUG_AWAITING] ===== createNewConversation ====='); + if (this.compressionInProgress || this.compressing) { + const confirmed = await this.confirmAction({ + title: '压缩进行中', + message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?', + confirmText: '确认', + cancelText: '取消' + }); + if (!confirmed) { + return; + } + try { + if (this.currentConversationId) { + await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, { + method: 'POST' + }); + } + } catch (e) { + console.warn('取消压缩失败:', e); + } + this.compressionInProgress = false; + this.compressing = false; + this.compressionMode = ''; + this.compressionStage = ''; + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + } + + debugLog('创建新对话...'); + traceLog('createNewConversation:start', { + currentConversationId: this.currentConversationId, + convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' + }); + this.logMessageState('createNewConversation:start'); + + // 应用个性化设置中的默认模型和思考模式 + try { + const personalizationStore = usePersonalizationStore(); + + if (personalizationStore.loaded) { + const defaultRunMode = personalizationStore.form.default_run_mode; + const defaultModel = personalizationStore.form.default_model; + + if (defaultRunMode) { + this.runMode = defaultRunMode; + debugLog('应用默认运行模式:', defaultRunMode); + } + + if (defaultModel) { + this.currentModelKey = defaultModel; + debugLog('应用默认模型:', defaultModel); + } + + // 根据默认运行模式设置思考模式 + if (defaultRunMode === 'thinking') { + this.thinkingMode = true; + } else if (defaultRunMode === 'fast') { + this.thinkingMode = false; + } + } + } catch (error) { + console.warn('应用个性化默认设置失败:', error); + } + + // 检查是否有运行中的任务,如果有则提示用户 + let hasActiveTask = false; + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + + if (taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent) { + hasActiveTask = true; + const waitingLabel = this.waitingForBackgroundCommand ? '后台指令' : '后台子智能体'; + + // 显示提示 + const confirmed = await this.confirmAction({ + title: '创建新对话', + message: this.waitingForSubAgent + ? `${waitingLabel}正在运行,创建新对话后将不会自动接收完成提示。确定要创建吗?` + : '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?', + confirmText: '创建', + cancelText: '取消' + }); + + if (!confirmed) { + // 用户取消创建 + return; + } + + // 用户确认,停止任务 + debugLog('[创建新对话] 用户确认,正在停止任务...'); + if (taskStore.hasActiveTask) { + await taskStore.cancelTask(); + taskStore.clearTask(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + } + + // 重置任务相关状态 + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + if (this.waitingForSubAgent && !taskStore.hasActiveTask) { + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + } + + debugLog('[创建新对话] 任务已停止'); + } + } catch (error) { + console.error('[创建新对话] 检查/停止任务失败:', error); + } + + try { + const response = await fetch('/api/conversations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + thinking_mode: this.thinkingMode, + mode: this.runMode + }) + }); + + const result = await response.json(); + + if (result.success) { + const newConversationId = result.conversation_id; + debugLog('新对话创建成功:', newConversationId); + traceLog('createNewConversation:created', { newConversationId }); + + // 在本地列表插入占位,避免等待刷新 + const placeholder = { + id: newConversationId, + title: '新对话', + updated_at: new Date().toISOString(), + total_messages: 0, + total_tools: 0 + }; + this.conversations = [ + placeholder, + ...this.conversations.filter((conv) => conv && conv.id !== newConversationId) + ]; + + // 直接加载新对话,确保状态一致 + // 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态 + await this.loadConversation(newConversationId, { force: true }); + traceLog('createNewConversation:after-load', { + newConversationId, + currentConversationId: this.currentConversationId + }); + + // 刷新对话列表获取最新统计 + this.conversationsOffset = 0; + await this.loadConversationsList(); + traceLog('createNewConversation:after-refresh', { + newConversationId, + conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' + }); + + // 如果停止了任务,显示提示 + if (hasActiveTask) { + this.uiPushToast({ + title: '任务已停止', + message: '创建新对话后,之前的任务已停止', + type: 'info', + duration: 3000 + }); + } + } else { + console.error('创建对话失败:', result.message); + this.uiPushToast({ + title: '创建对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); + } + } catch (error) { + console.error('创建对话异常:', error); + this.uiPushToast({ + title: '创建对话异常', + message: error.message || String(error), + type: 'error' + }); + } + }, + + async deleteConversation(conversationId) { + const confirmed = await this.confirmAction({ + title: '删除对话', + message: '确定要删除这个对话吗?删除后无法恢复。', + confirmText: '删除', + cancelText: '取消' + }); + if (!confirmed) { + return; + } + + debugLog('删除对话:', conversationId); + this.logMessageState('deleteConversation:start', { conversationId }); + + try { + const response = await fetch(`/api/conversations/${conversationId}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (result.success) { + debugLog('对话删除成功'); + + // 如果删除的是当前对话,清空界面 + if (conversationId === this.currentConversationId) { + this.logMessageState('deleteConversation:before-clear', { conversationId }); + this.messages = []; + this.logMessageState('deleteConversation:after-clear', { conversationId }); + this.currentConversationId = null; + this.currentConversationTitle = ''; + this.resetAllStates(`deleteConversation:${conversationId}`); + this.resetTokenStatistics(); + history.replaceState({}, '', '/new'); + } + + // 刷新对话列表 + this.conversationsOffset = 0; + await this.loadConversationsList(); + } else { + console.error('删除对话失败:', result.message); + this.uiPushToast({ + title: '删除对话失败', + message: result.message || '服务器未返回成功状态', + type: 'error' + }); + } + } catch (error) { + console.error('删除对话异常:', error); + this.uiPushToast({ + title: '删除对话异常', + message: error.message || String(error), + type: 'error' + }); + } + }, + + async duplicateConversation(conversationId) { + debugLog('复制对话:', conversationId); + try { + const response = await fetch(`/api/conversations/${conversationId}/duplicate`, { + method: 'POST' + }); + + const result = await response.json(); + + if (response.ok && result.success) { + const newId = result.duplicate_conversation_id; + if (newId) { + await this.loadConversation(newId, { force: true }); + } + + this.conversationsOffset = 0; + await this.loadConversationsList(); + } else { + const message = result.message || result.error || '复制失败'; + this.uiPushToast({ + title: '复制对话失败', + message, + type: 'error' + }); + } + } catch (error) { + console.error('复制对话异常:', error); + this.uiPushToast({ + title: '复制对话异常', + message: error.message || String(error), + type: 'error' + }); + } + } }; diff --git a/static/src/app/methods/history.ts b/static/src/app/methods/history.ts index 5547a43..5f24de3 100644 --- a/static/src/app/methods/history.ts +++ b/static/src/app/methods/history.ts @@ -5,409 +5,412 @@ const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务 const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/; function parseSubAgentDoneLabel(rawContent: any): string | null { - const content = (rawContent || '').toString().trim(); - if (!content) return null; - const match = content.match(SUB_AGENT_DONE_PREFIX_RE); - if (!match || !/已完成/.test(content)) return null; - return `子智能体${match[1]} 任务完成`; + const content = (rawContent || '').toString().trim(); + if (!content) return null; + const match = content.match(SUB_AGENT_DONE_PREFIX_RE); + if (!match || !/已完成/.test(content)) return null; + return `子智能体${match[1]} 任务完成`; } function parseBackgroundRunCommandDoneLabel(rawContent: any): string | null { - const content = (rawContent || '').toString().trim(); - if (!content) return null; - if (!BG_RUN_COMMAND_DONE_PREFIX_RE.test(content)) return null; - return '后台 run_command 完成'; + const content = (rawContent || '').toString().trim(); + if (!content) return null; + if (!BG_RUN_COMMAND_DONE_PREFIX_RE.test(content)) return null; + return '后台 run_command 完成'; } function parseSystemNoticeLabel(rawContent: any): string | null { - return parseSubAgentDoneLabel(rawContent) || parseBackgroundRunCommandDoneLabel(rawContent); + return parseSubAgentDoneLabel(rawContent) || parseBackgroundRunCommandDoneLabel(rawContent); } export const historyMethods = { - // ========================================== - // 关键功能:获取并显示历史对话内容 - // ========================================== - async fetchAndDisplayHistory(options = {}) { - const { force = false } = options as { force?: boolean }; - const targetConversationId = this.currentConversationId; - if (!targetConversationId || targetConversationId.startsWith('temp_')) { - debugLog('没有当前对话ID,跳过历史加载'); - this.refreshBlankHeroState(); - return; - } - - // 若同一对话正在加载,直接复用;若是切换对话则允许并发但后来的请求会赢 - if (this.historyLoading && this.historyLoadingFor === targetConversationId) { - debugLog('同一对话历史正在加载,跳过重复请求'); - return; - } - - // 已经有完整历史且非强制刷新时,避免重复加载导致动画播放两次 - const alreadyHydrated = - !force && - this.lastHistoryLoadedConversationId === targetConversationId && - Array.isArray(this.messages) && - this.messages.length > 0; - if (alreadyHydrated) { - debugLog('历史已加载,跳过重复请求'); - this.logMessageState('fetchAndDisplayHistory:skip-duplicate', { - conversationId: targetConversationId - }); - return; - } - - const loadSeq = ++this.historyLoadSeq; - this.historyLoading = true; - this.historyLoadingFor = targetConversationId; - try { - debugLog('开始获取历史对话内容...'); - this.logMessageState('fetchAndDisplayHistory:start', { conversationId: this.currentConversationId }); - - try { - // 使用专门的API获取对话消息历史 - const messagesResponse = await fetch(`/api/conversations/${targetConversationId}/messages`); - - if (!messagesResponse.ok) { - console.warn('无法获取消息历史,尝试备用方法'); - // 备用方案:通过状态API获取 - const statusResponse = await fetch('/api/status'); - const status = await statusResponse.json(); - debugLog('系统状态:', status); - this.applyStatusSnapshot(status); - - // 如果状态中有对话历史字段 - if (status.conversation_history && Array.isArray(status.conversation_history)) { - this.renderHistoryMessages(status.conversation_history); - return; - } - - debugLog('备用方案也无法获取历史消息'); - return; - } - - // 如果在等待期间用户已切换到其他对话,则丢弃结果 - if (loadSeq !== this.historyLoadSeq || this.currentConversationId !== targetConversationId) { - debugLog('检测到对话已切换,丢弃过期的历史加载结果'); - return; - } - - const messagesData = await messagesResponse.json(); - debugLog('获取到消息数据:', messagesData); - - if (messagesData.success && messagesData.data && messagesData.data.messages) { - const messages = messagesData.data.messages; - debugLog(`发现 ${messages.length} 条历史消息`); - - if (messages.length > 0) { - // 清空当前显示的消息 - this.logMessageState('fetchAndDisplayHistory:before-clear-existing'); - this.messages = []; - this.logMessageState('fetchAndDisplayHistory:after-clear-existing'); - - // 渲染历史消息 - 这是关键功能 - this.renderHistoryMessages(messages); - - // 滚动到底部 - this.$nextTick(() => { - this.scrollToBottom(); - }); - - debugLog('历史对话内容显示完成'); - } else { - debugLog('对话存在但没有历史消息'); - this.logMessageState('fetchAndDisplayHistory:no-history-clear'); - this.messages = []; - this.logMessageState('fetchAndDisplayHistory:no-history-cleared'); - } - } else { - debugLog('消息数据格式不正确:', messagesData); - this.logMessageState('fetchAndDisplayHistory:invalid-data-clear'); - this.messages = []; - this.logMessageState('fetchAndDisplayHistory:invalid-data-cleared'); - } - - } catch (error) { - console.error('获取历史对话失败:', error); - debugLog('尝试不显示错误弹窗,仅在控制台记录'); - // 不显示alert,避免打断用户体验 - this.logMessageState('fetchAndDisplayHistory:error-clear', { error: error?.message || String(error) }); - this.messages = []; - this.logMessageState('fetchAndDisplayHistory:error-cleared'); - } - } finally { - // 仅在本次加载仍是最新请求时清除 loading 状态 - if (loadSeq === this.historyLoadSeq) { - this.historyLoading = false; - this.historyLoadingFor = null; - } - this.refreshBlankHeroState(); - } - }, - - // ========================================== - // 关键功能:渲染历史消息 - // ========================================== - renderHistoryMessages(historyMessages) { - debugLog('开始渲染历史消息...', historyMessages); - debugLog('历史消息数量:', historyMessages.length); - this.logMessageState('renderHistoryMessages:start', { historyCount: historyMessages.length }); - - if (!Array.isArray(historyMessages)) { - console.error('历史消息不是数组格式'); - return; - } - - let currentAssistantMessage = null; - let historyHasImages = false; - let historyHasVideos = false; - - historyMessages.forEach((message, index) => { - debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message); - const meta = message.metadata || {}; - if (message.role === 'user' && (meta.system_injected_image || meta.system_injected_video)) { - debugLog('跳过系统代发的图片/视频消息(仅用于模型查看,不在前端展示)'); - return; - } - - if (message.role === 'user') { - // 用户消息 - 先结束之前的assistant消息 - if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { - this.messages.push(currentAssistantMessage); - currentAssistantMessage = null; - } - const images = message.images || (message.metadata && message.metadata.images) || []; - const videos = message.videos || (message.metadata && message.metadata.videos) || []; - if (Array.isArray(images) && images.length) { - historyHasImages = true; - } - if (Array.isArray(videos) && videos.length) { - historyHasVideos = true; - } - this.messages.push({ - role: 'user', - content: message.content || '', - images, - videos, - metadata: message.metadata || {} - }); - debugLog('添加用户消息:', message.content?.substring(0, 50) + '...'); - - } else if (message.role === 'assistant') { - // AI消息 - 如果没有当前assistant消息,创建一个 - if (!currentAssistantMessage) { - currentAssistantMessage = { - role: 'assistant', - actions: [], - streamingThinking: '', - streamingText: '', - currentStreamingType: null, - activeThinkingId: null, - awaitingFirstContent: false, // 历史消息不应该显示等待动画 - generatingLabel: '' - }; - } - - const content = message.content || ''; - const reasoningText = (message.reasoning_content || '').trim(); - - if (reasoningText) { - const blockId = `history-thinking-${Date.now()}-${Math.random().toString(36).slice(2)}`; - currentAssistantMessage.actions.push({ - id: `history-think-${Date.now()}-${Math.random()}`, - type: 'thinking', - content: reasoningText, - streaming: false, - collapsed: true, - timestamp: Date.now(), - blockId - }); - debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...'); - } - - // 处理普通文本内容(移除思考标签后的内容) - const textContent = content.trim(); - if (textContent) { - currentAssistantMessage.actions.push({ - id: `history-text-${Date.now()}-${Math.random()}`, - type: 'text', - content: textContent, - streaming: false, - timestamp: Date.now() - }); - debugLog('添加文本内容:', textContent.substring(0, 50) + '...'); - } - - // 处理工具调用 - if (message.tool_calls && Array.isArray(message.tool_calls)) { - message.tool_calls.forEach((toolCall, tcIndex) => { - let arguments_obj = {}; - try { - arguments_obj = typeof toolCall.function.arguments === 'string' - ? JSON.parse(toolCall.function.arguments || '{}') - : (toolCall.function.arguments || {}); - } catch (e) { - console.warn('解析工具参数失败:', e); - arguments_obj = {}; - } - - const action = { - id: `history-tool-${toolCall.id || Date.now()}-${tcIndex}`, - type: 'tool', - tool: { - id: toolCall.id, - name: toolCall.function.name, - arguments: arguments_obj, - argumentSnapshot: this.cloneToolArguments(arguments_obj), - argumentLabel: this.buildToolLabel(arguments_obj), - intent_full: arguments_obj.intent || '', - intent_rendered: arguments_obj.intent || '', - status: 'preparing', - result: null - }, - timestamp: Date.now() - }; - // 如果是历史加载的动作且状态仍为进行中,标记为 stale,避免刷新后按钮卡死 - if (['preparing', 'running', 'awaiting_content'].includes(action.tool.status)) { - action.tool.status = 'stale'; - action.tool.awaiting_content = false; - action.streaming = false; - } - currentAssistantMessage.actions.push(action); - debugLog('添加工具调用:', toolCall.function.name); - }); - } - - } else if (message.role === 'tool') { - // 工具结果 - 更新当前assistant消息中对应的工具 - if (currentAssistantMessage) { - // 查找对应的工具action - 使用更灵活的匹配 - let toolAction = null; - - // 优先按tool_call_id匹配 - if (message.tool_call_id) { - toolAction = currentAssistantMessage.actions.find(action => - action.type === 'tool' && - action.tool.id === message.tool_call_id - ); - } - - // 如果找不到,按name匹配最后一个同名工具 - if (!toolAction && message.name) { - const sameNameTools = currentAssistantMessage.actions.filter(action => - action.type === 'tool' && - action.tool.name === message.name - ); - toolAction = sameNameTools[sameNameTools.length - 1]; // 取最后一个 - } - - if (toolAction) { - // 解析工具结果(优先使用JSON,其次使用元数据的 tool_payload,以保证搜索结果在刷新后仍可展示) - let result; - try { - result = JSON.parse(message.content); - } catch (e) { - if (message.metadata && message.metadata.tool_payload) { - result = message.metadata.tool_payload; - } else { - result = { - output: message.content, - success: true - }; - } - } - - toolAction.tool.status = 'completed'; - toolAction.tool.result = result; - if (result && typeof result === 'object') { - if (result.error) { - toolAction.tool.message = result.error; - } else if (result.message && !toolAction.tool.message) { - toolAction.tool.message = result.message; - } - } - debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`); - } else { - console.warn('找不到对应的工具调用:', message.name, message.tool_call_id); - } - } - - } else { - // 其他类型消息(如system)- 先结束当前assistant消息 - if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { - this.messages.push(currentAssistantMessage); - currentAssistantMessage = null; - } - if (message.role === 'system') { - const rawContent = message.content || ''; - const label = parseSystemNoticeLabel(rawContent); - console.log('[DEBUG_SYSTEM][history] 命中 role=system 历史消息', { - rawContent, - matchedDoneNotice: !!label - }); - if (label) { - // 历史中的 system 通知转换为 assistant system action,避免被 role=system 过滤掉 - this.messages.push({ - role: 'assistant', - actions: [ - { - id: `history-system-${Date.now()}-${Math.random()}`, - type: 'system', - content: label, - variant: 'sub_agent_done', - streaming: false, - timestamp: Date.now() - } - ], - streamingThinking: '', - streamingText: '', - currentStreamingType: null, - activeThinkingId: null, - awaitingFirstContent: false, - generatingLabel: '' - }); - } else { - console.log('[DEBUG_SYSTEM][history] role=system 被历史加载拦截(非子智能体完成)', { - rawContent - }); - } - return; - } - - debugLog('处理其他类型消息:', message.role); - this.messages.push({ - role: message.role, - content: message.content || '' - }); - } - }); - - // 处理最后一个assistant消息 - if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { - this.messages.push(currentAssistantMessage); - } - - this.conversationHasImages = historyHasImages; - this.conversationHasVideos = historyHasVideos; - - debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`); - this.logMessageState('renderHistoryMessages:after-render'); - this.lastHistoryLoadedConversationId = this.currentConversationId || null; - - // 强制更新视图 - this.$forceUpdate(); - - // 确保滚动到底部 - this.$nextTick(() => { - this.scrollToBottom(); - setTimeout(() => { - const blockCount = this.$el && this.$el.querySelectorAll - ? this.$el.querySelectorAll('.message-block').length - : 'N/A'; - debugLog('[Messages] DOM 渲染统计', { - blocks: blockCount, - conversationId: this.currentConversationId - }); - }, 0); - }); + // ========================================== + // 关键功能:获取并显示历史对话内容 + // ========================================== + async fetchAndDisplayHistory(options = {}) { + const { force = false } = options as { force?: boolean }; + const targetConversationId = this.currentConversationId; + if (!targetConversationId || targetConversationId.startsWith('temp_')) { + debugLog('没有当前对话ID,跳过历史加载'); + this.refreshBlankHeroState(); + return; } + + // 若同一对话正在加载,直接复用;若是切换对话则允许并发但后来的请求会赢 + if (this.historyLoading && this.historyLoadingFor === targetConversationId) { + debugLog('同一对话历史正在加载,跳过重复请求'); + return; + } + + // 已经有完整历史且非强制刷新时,避免重复加载导致动画播放两次 + const alreadyHydrated = + !force && + this.lastHistoryLoadedConversationId === targetConversationId && + Array.isArray(this.messages) && + this.messages.length > 0; + if (alreadyHydrated) { + debugLog('历史已加载,跳过重复请求'); + this.logMessageState('fetchAndDisplayHistory:skip-duplicate', { + conversationId: targetConversationId + }); + return; + } + + const loadSeq = ++this.historyLoadSeq; + this.historyLoading = true; + this.historyLoadingFor = targetConversationId; + try { + debugLog('开始获取历史对话内容...'); + this.logMessageState('fetchAndDisplayHistory:start', { + conversationId: this.currentConversationId + }); + + try { + // 使用专门的API获取对话消息历史 + const messagesResponse = await fetch(`/api/conversations/${targetConversationId}/messages`); + + if (!messagesResponse.ok) { + console.warn('无法获取消息历史,尝试备用方法'); + // 备用方案:通过状态API获取 + const statusResponse = await fetch('/api/status'); + const status = await statusResponse.json(); + debugLog('系统状态:', status); + this.applyStatusSnapshot(status); + + // 如果状态中有对话历史字段 + if (status.conversation_history && Array.isArray(status.conversation_history)) { + this.renderHistoryMessages(status.conversation_history); + return; + } + + debugLog('备用方案也无法获取历史消息'); + return; + } + + // 如果在等待期间用户已切换到其他对话,则丢弃结果 + if ( + loadSeq !== this.historyLoadSeq || + this.currentConversationId !== targetConversationId + ) { + debugLog('检测到对话已切换,丢弃过期的历史加载结果'); + return; + } + + const messagesData = await messagesResponse.json(); + debugLog('获取到消息数据:', messagesData); + + if (messagesData.success && messagesData.data && messagesData.data.messages) { + const messages = messagesData.data.messages; + debugLog(`发现 ${messages.length} 条历史消息`); + + if (messages.length > 0) { + // 清空当前显示的消息 + this.logMessageState('fetchAndDisplayHistory:before-clear-existing'); + this.messages = []; + this.logMessageState('fetchAndDisplayHistory:after-clear-existing'); + + // 渲染历史消息 - 这是关键功能 + this.renderHistoryMessages(messages); + + // 滚动到底部 + this.$nextTick(() => { + this.scrollToBottom(); + }); + + debugLog('历史对话内容显示完成'); + } else { + debugLog('对话存在但没有历史消息'); + this.logMessageState('fetchAndDisplayHistory:no-history-clear'); + this.messages = []; + this.logMessageState('fetchAndDisplayHistory:no-history-cleared'); + } + } else { + debugLog('消息数据格式不正确:', messagesData); + this.logMessageState('fetchAndDisplayHistory:invalid-data-clear'); + this.messages = []; + this.logMessageState('fetchAndDisplayHistory:invalid-data-cleared'); + } + } catch (error) { + console.error('获取历史对话失败:', error); + debugLog('尝试不显示错误弹窗,仅在控制台记录'); + // 不显示alert,避免打断用户体验 + this.logMessageState('fetchAndDisplayHistory:error-clear', { + error: error?.message || String(error) + }); + this.messages = []; + this.logMessageState('fetchAndDisplayHistory:error-cleared'); + } + } finally { + // 仅在本次加载仍是最新请求时清除 loading 状态 + if (loadSeq === this.historyLoadSeq) { + this.historyLoading = false; + this.historyLoadingFor = null; + } + this.refreshBlankHeroState(); + } + }, + + // ========================================== + // 关键功能:渲染历史消息 + // ========================================== + renderHistoryMessages(historyMessages) { + debugLog('开始渲染历史消息...', historyMessages); + debugLog('历史消息数量:', historyMessages.length); + this.logMessageState('renderHistoryMessages:start', { historyCount: historyMessages.length }); + + if (!Array.isArray(historyMessages)) { + console.error('历史消息不是数组格式'); + return; + } + + let currentAssistantMessage = null; + let historyHasImages = false; + let historyHasVideos = false; + + historyMessages.forEach((message, index) => { + debugLog(`处理消息 ${index + 1}/${historyMessages.length}:`, message.role, message); + const meta = message.metadata || {}; + if (message.role === 'user' && (meta.system_injected_image || meta.system_injected_video)) { + debugLog('跳过系统代发的图片/视频消息(仅用于模型查看,不在前端展示)'); + return; + } + + if (message.role === 'user') { + // 用户消息 - 先结束之前的assistant消息 + if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { + this.messages.push(currentAssistantMessage); + currentAssistantMessage = null; + } + const images = message.images || (message.metadata && message.metadata.images) || []; + const videos = message.videos || (message.metadata && message.metadata.videos) || []; + if (Array.isArray(images) && images.length) { + historyHasImages = true; + } + if (Array.isArray(videos) && videos.length) { + historyHasVideos = true; + } + this.messages.push({ + role: 'user', + content: message.content || '', + images, + videos, + metadata: message.metadata || {} + }); + debugLog('添加用户消息:', message.content?.substring(0, 50) + '...'); + } else if (message.role === 'assistant') { + // AI消息 - 如果没有当前assistant消息,创建一个 + if (!currentAssistantMessage) { + currentAssistantMessage = { + role: 'assistant', + actions: [], + streamingThinking: '', + streamingText: '', + currentStreamingType: null, + activeThinkingId: null, + awaitingFirstContent: false, // 历史消息不应该显示等待动画 + generatingLabel: '' + }; + } + + const content = message.content || ''; + const reasoningText = (message.reasoning_content || '').trim(); + + if (reasoningText) { + const blockId = `history-thinking-${Date.now()}-${Math.random().toString(36).slice(2)}`; + currentAssistantMessage.actions.push({ + id: `history-think-${Date.now()}-${Math.random()}`, + type: 'thinking', + content: reasoningText, + streaming: false, + collapsed: true, + timestamp: Date.now(), + blockId + }); + debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...'); + } + + // 处理普通文本内容(移除思考标签后的内容) + const textContent = content.trim(); + if (textContent) { + currentAssistantMessage.actions.push({ + id: `history-text-${Date.now()}-${Math.random()}`, + type: 'text', + content: textContent, + streaming: false, + timestamp: Date.now() + }); + debugLog('添加文本内容:', textContent.substring(0, 50) + '...'); + } + + // 处理工具调用 + if (message.tool_calls && Array.isArray(message.tool_calls)) { + message.tool_calls.forEach((toolCall, tcIndex) => { + let arguments_obj = {}; + try { + arguments_obj = + typeof toolCall.function.arguments === 'string' + ? JSON.parse(toolCall.function.arguments || '{}') + : toolCall.function.arguments || {}; + } catch (e) { + console.warn('解析工具参数失败:', e); + arguments_obj = {}; + } + + const action = { + id: `history-tool-${toolCall.id || Date.now()}-${tcIndex}`, + type: 'tool', + tool: { + id: toolCall.id, + name: toolCall.function.name, + arguments: arguments_obj, + argumentSnapshot: this.cloneToolArguments(arguments_obj), + argumentLabel: this.buildToolLabel(arguments_obj), + intent_full: arguments_obj.intent || '', + intent_rendered: arguments_obj.intent || '', + status: 'preparing', + result: null + }, + timestamp: Date.now() + }; + // 如果是历史加载的动作且状态仍为进行中,标记为 stale,避免刷新后按钮卡死 + if (['preparing', 'running', 'awaiting_content'].includes(action.tool.status)) { + action.tool.status = 'stale'; + action.tool.awaiting_content = false; + action.streaming = false; + } + currentAssistantMessage.actions.push(action); + debugLog('添加工具调用:', toolCall.function.name); + }); + } + } else if (message.role === 'tool') { + // 工具结果 - 更新当前assistant消息中对应的工具 + if (currentAssistantMessage) { + // 查找对应的工具action - 使用更灵活的匹配 + let toolAction = null; + + // 优先按tool_call_id匹配 + if (message.tool_call_id) { + toolAction = currentAssistantMessage.actions.find( + (action) => action.type === 'tool' && action.tool.id === message.tool_call_id + ); + } + + // 如果找不到,按name匹配最后一个同名工具 + if (!toolAction && message.name) { + const sameNameTools = currentAssistantMessage.actions.filter( + (action) => action.type === 'tool' && action.tool.name === message.name + ); + toolAction = sameNameTools[sameNameTools.length - 1]; // 取最后一个 + } + + if (toolAction) { + // 解析工具结果(优先使用JSON,其次使用元数据的 tool_payload,以保证搜索结果在刷新后仍可展示) + let result; + try { + result = JSON.parse(message.content); + } catch (e) { + if (message.metadata && message.metadata.tool_payload) { + result = message.metadata.tool_payload; + } else { + result = { + output: message.content, + success: true + }; + } + } + + toolAction.tool.status = 'completed'; + toolAction.tool.result = result; + if (result && typeof result === 'object') { + if (result.error) { + toolAction.tool.message = result.error; + } else if (result.message && !toolAction.tool.message) { + toolAction.tool.message = result.message; + } + } + debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`); + } else { + console.warn('找不到对应的工具调用:', message.name, message.tool_call_id); + } + } + } else { + // 其他类型消息(如system)- 先结束当前assistant消息 + if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { + this.messages.push(currentAssistantMessage); + currentAssistantMessage = null; + } + if (message.role === 'system') { + const rawContent = message.content || ''; + const label = parseSystemNoticeLabel(rawContent); + console.log('[DEBUG_SYSTEM][history] 命中 role=system 历史消息', { + rawContent, + matchedDoneNotice: !!label + }); + if (label) { + // 历史中的 system 通知转换为 assistant system action,避免被 role=system 过滤掉 + this.messages.push({ + role: 'assistant', + actions: [ + { + id: `history-system-${Date.now()}-${Math.random()}`, + type: 'system', + content: label, + variant: 'sub_agent_done', + streaming: false, + timestamp: Date.now() + } + ], + streamingThinking: '', + streamingText: '', + currentStreamingType: null, + activeThinkingId: null, + awaitingFirstContent: false, + generatingLabel: '' + }); + } else { + console.log('[DEBUG_SYSTEM][history] role=system 被历史加载拦截(非子智能体完成)', { + rawContent + }); + } + return; + } + + debugLog('处理其他类型消息:', message.role); + this.messages.push({ + role: message.role, + content: message.content || '' + }); + } + }); + + // 处理最后一个assistant消息 + if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { + this.messages.push(currentAssistantMessage); + } + + this.conversationHasImages = historyHasImages; + this.conversationHasVideos = historyHasVideos; + + debugLog(`历史消息渲染完成,共 ${this.messages.length} 条消息`); + this.logMessageState('renderHistoryMessages:after-render'); + this.lastHistoryLoadedConversationId = this.currentConversationId || null; + + // 强制更新视图 + this.$forceUpdate(); + + // 确保滚动到底部 + this.$nextTick(() => { + this.scrollToBottom(); + setTimeout(() => { + const blockCount = + this.$el && this.$el.querySelectorAll + ? this.$el.querySelectorAll('.message-block').length + : 'N/A'; + debugLog('[Messages] DOM 渲染统计', { + blocks: blockCount, + conversationId: this.currentConversationId + }); + }, 0); + }); + } }; diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index ba586b9..6dade00 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -3,619 +3,621 @@ import { debugLog } from './common'; import { useModelStore } from '../../stores/model'; export const messageMethods = { - async executeSystemCommand(rawCommand, options = {}) { - const command = (rawCommand || '').toString().trim(); - if (!command) { - return { success: false, message: '命令不能为空' }; - } - - if (!this.isConnected) { - if (options.showToast !== false) { - this.uiPushToast({ - title: '连接不可用', - message: '当前无法执行命令,请稍后重试。', - type: 'error' - }); - } - return { success: false, message: '连接不可用' }; - } - - try { - const response = await fetch('/api/commands', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ command }) - }); - - const payload = await response.json().catch(() => ({})); - const result = { - command: payload.command || command.replace(/^\//, ''), - success: !!payload.success, - message: payload.message, - data: payload.data - }; - this.handleSystemCommandResult(result, options); - return result; - } catch (error) { - const message = error instanceof Error ? error.message : '命令执行失败'; - const result = { - command: command.replace(/^\//, ''), - success: false, - message - }; - this.handleSystemCommandResult(result, options); - return result; - } - }, - - handleSystemCommandResult(data, options = {}) { - const showToast = options.showToast !== false; - if (data.command === 'clear' && data.success) { - this.logMessageState?.('command_result-clear', { data }); - this.messages = []; - this.logMessageState?.('command_result-cleared', { data }); - this.currentMessageIndex = -1; - this.chatClearExpandedBlocks(); - this.resetTokenStatistics(); - if (showToast) { - this.uiPushToast({ - title: '已清除', - message: data.message || '对话已清除', - type: 'success' - }); - } - return; - } - - if (data.command === 'status' && data.success) { - this.addSystemMessage(`系统状态:\n${JSON.stringify(data.data || {}, null, 2)}`); - if (showToast) { - this.uiPushToast({ - title: '状态已更新', - message: '已获取系统状态', - type: 'success' - }); - } - return; - } - - if (!data.success) { - this.addSystemMessage(`命令失败: ${data.message || '未知错误'}`); - if (showToast) { - this.uiPushToast({ - title: '命令执行失败', - message: data.message || '请稍后重试', - type: 'error' - }); - } - return; - } - - if (showToast) { - this.uiPushToast({ - title: '命令已执行', - message: data.command || '完成', - type: 'success' - }); - } - }, - - handleSendOrStop() { - if (this.compressionInProgress) { - this.uiPushToast({ - title: '对话自动压缩中', - message: '当前不可发送/停止,请等待压缩完成', - type: 'warning' - }); - return; - } - if (this.composerBusy) { - this.stopTask(); - } else { - this.sendMessage(); - } - }, - - async sendMessage() { - console.log('[DEBUG_AWAITING] ===== sendMessage ====='); - - if (this.compressionInProgress) { - this.uiPushToast({ - title: '对话自动压缩中', - message: '压缩完成后才能继续发送消息', - type: 'warning' - }); - return; - } - if (this.streamingUi) { - return; - } - if (this.mediaUploading) { - this.uiPushToast({ - title: '上传中', - message: '请等待图片/视频上传完成后再发送', - type: 'info' - }); - return; - } - - const text = (this.inputMessage || '').trim(); - const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : []; - const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : []; - const hasText = text.length > 0; - const hasImages = images.length > 0; - const hasVideos = videos.length > 0; - - if (!hasText && !hasImages && !hasVideos) { - return; - } - - const quotaType = this.thinkingMode ? 'thinking' : 'fast'; - if (this.isQuotaExceeded(quotaType)) { - this.showQuotaToast({ type: quotaType }); - return; - } - - const modelStore = useModelStore(); - const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey); - if (hasImages && !currentModel?.supportsImage) { - this.uiPushToast({ - title: '当前模型不支持图片', - message: '请切换到支持图片输入的模型再发送图片', - type: 'error' - }); - return; - } - - if (hasVideos && !currentModel?.supportsVideo) { - this.uiPushToast({ - title: '当前模型不支持视频', - message: '请切换到支持视频输入的模型后再发送视频', - type: 'error' - }); - return; - } - - if (hasVideos && hasImages) { - this.uiPushToast({ - title: '请勿同时发送', - message: '视频与图片需分开发送,每条仅包含一种媒体', - type: 'warning' - }); - return; - } - - if (hasVideos) { - this.uiPushToast({ - title: '视频处理中', - message: '读取视频需要较长时间,请耐心等待', - type: 'info', - duration: 5000 - }); - } - - const message = text; - const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/'); - if (isCommand) { - await this.executeSystemCommand(message, { showToast: false }); - this.inputClearMessage(); - this.inputClearSelectedImages(); - this.inputClearSelectedVideos(); - this.autoResizeInput(); - return; - } - - const wasBlank = this.isConversationBlank(); - if (wasBlank) { - this.blankHeroExiting = true; - this.blankHeroActive = true; - setTimeout(() => { - this.blankHeroExiting = false; - this.blankHeroActive = false; - }, 320); - } - - let targetConversationId = this.currentConversationId; - if (!targetConversationId) { - try { - const createResp = await fetch('/api/conversations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - thinking_mode: this.thinkingMode, - mode: this.runMode - }) - }); - const createResult = await createResp.json().catch(() => ({})); - if (!createResp.ok || !createResult?.success || !createResult?.conversation_id) { - throw new Error(createResult?.message || createResult?.error || '创建对话失败'); - } - targetConversationId = createResult.conversation_id; - this.skipConversationHistoryReload = true; - this.currentConversationId = targetConversationId; - this.currentConversationTitle = '新对话'; - this.conversations = [ - { - id: targetConversationId, - title: '新对话', - updated_at: new Date().toISOString(), - total_messages: 0, - total_tools: 0 - }, - ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId) - ]; - const pathFragment = this.stripConversationPrefix(targetConversationId); - history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`); - } catch (error) { - this.uiPushToast({ - title: '发送失败', - message: error?.message || '创建新对话失败,请重试', - type: 'error' - }); - return; - } - } - - // 标记任务进行中,直到任务完成或用户手动停止 - this.taskInProgress = true; - this.chatAddUserMessage(message, images, videos); - - // 使用 REST API 创建任务(轮询模式) - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - - await taskStore.createTask(message, images, videos, targetConversationId, { - model_key: this.currentModelKey, - run_mode: this.runMode, - thinking_mode: this.thinkingMode - }); - - debugLog('[Message] 任务已创建,开始轮询'); - } catch (error) { - console.error('[Message] 创建任务失败:', error); - this.uiPushToast({ - title: '发送失败', - message: error.message || '创建任务失败,请重试', - type: 'error' - }); - this.taskInProgress = false; - return; - } - - if (typeof this.monitorShowPendingReply === 'function') { - this.monitorShowPendingReply(); - } - this.inputClearMessage(); - this.inputClearSelectedImages(); - this.inputClearSelectedVideos(); - this.inputSetImagePickerOpen(false); - this.inputSetVideoPickerOpen(false); - this.inputSetLineCount(1); - this.inputSetMultiline(false); - if (hasImages) { - this.conversationHasImages = true; - this.conversationHasVideos = false; - } - if (hasVideos) { - this.conversationHasVideos = true; - this.conversationHasImages = false; - } - if (this.autoScrollEnabled) { - this.scrollToBottom(); - } - this.autoResizeInput(); - - // 发送消息后延迟更新当前上下文Token(关键修复:恢复原逻辑) - setTimeout(() => { - if (this.currentConversationId) { - this.updateCurrentContextTokens(); - } - }, 1000); - }, - - // 新增:停止任务方法 - async stopTask() { - console.log('[DEBUG_AWAITING] ===== stopTask ====='); - if (this.compressionInProgress) { - this.uiPushToast({ - title: '对话自动压缩中', - message: '压缩进行中,当前不可停止任务', - type: 'warning' - }); - return; - } - - const canStop = this.composerBusy && !this.stopRequested; - if (!canStop) { - return; - } - - const shouldDropToolEvents = this.streamingUi; - this.stopRequested = true; - this.dropToolEvents = shouldDropToolEvents; - - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - - if (taskStore.currentTaskId) { - await taskStore.cancelTask(); - } - - // 等待后端确认 - await new Promise(resolve => setTimeout(resolve, 300)); - const shouldKeepBusy = Boolean(taskStore.currentTaskId) && - ['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus)); - - // 清理前端状态 - this.clearPendingTools('user_stop'); - this.streamingMessage = false; - // 若后台已回传停止事件,不要再次把输入区锁回“停止中” - this.taskInProgress = shouldKeepBusy; - this.forceUnlockMonitor('user_stop'); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - - // 清理assistant消息的等待动画状态 - const lastMessage = this.messages[this.messages.length - 1]; - const before = { - hasLastMessage: !!lastMessage, - role: lastMessage?.role, - awaitingFirstContent: lastMessage?.awaitingFirstContent, - generatingLabel: lastMessage?.generatingLabel - }; - - if (lastMessage && lastMessage.role === 'assistant') { - lastMessage.awaitingFirstContent = false; - lastMessage.generatingLabel = ''; - } - - console.log('[DEBUG_AWAITING] stopTask 清理完成', { - before, - after: { - awaitingFirstContent: lastMessage?.awaitingFirstContent, - generatingLabel: lastMessage?.generatingLabel - } - }); - } catch (error) { - console.error('[Message] 取消任务失败:', error); - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - const shouldKeepBusy = Boolean(taskStore.currentTaskId) && - ['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus)); - - // 即使失败也清理状态 - this.clearPendingTools('user_stop'); - this.streamingMessage = false; - // 如果任务其实已结束,允许按钮恢复发送态 - this.taskInProgress = shouldKeepBusy; - this.forceUnlockMonitor('user_stop'); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - - // 清理assistant消息的等待动画状态 - const lastMessage = this.messages[this.messages.length - 1]; - if (lastMessage && lastMessage.role === 'assistant') { - lastMessage.awaitingFirstContent = false; - lastMessage.generatingLabel = ''; - } - - this.uiPushToast({ - title: '停止失败', - message: '任务可能仍在运行,请刷新页面', - type: 'warning' - }); - } finally { - // 确保清除 dropToolEvents 和 stopRequested 标志 - this.dropToolEvents = false; - this.stopRequested = false; - } - }, - - async clearChat() { - const confirmed = await this.confirmAction({ - title: '清除对话', - message: '确定要清除所有对话记录吗?该操作不可撤销。', - confirmText: '清除', - cancelText: '取消' - }); - if (confirmed) { - await this.executeSystemCommand('/clear', { showToast: false }); - } - }, - - async compressConversation() { - if (!this.currentConversationId) { - this.uiPushToast({ - title: '无法压缩', - message: '当前没有可压缩的对话。', - type: 'info' - }); - return; - } - - if (this.compressing) { - return; - } - - const confirmed = await this.confirmAction({ - title: '压缩对话', - message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。', - confirmText: '压缩', - cancelText: '取消' - }); - if (!confirmed) { - return; - } - - this.compressing = true; - this.compressionInProgress = true; - this.compressionMode = 'manual'; - this.compressionStage = 'requesting'; - this.compressionError = ''; - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; - } - this.compressionToastId = this.uiPushToast({ - title: '压缩中', - message: '对话正在压缩,请稍候…', - type: 'info', - duration: null, - closable: false - }); - - try { - const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, { - method: 'POST' - }); - - const result = await response.json(); - - if (response.ok && result.success) { - this.compressionStage = 'switching'; - const newId = result.compressed_conversation_id; - if (newId) { - await this.loadConversation(newId, { force: true }); - } - const guideMessage = (result.guide_message || '').trim(); - const autoTaskStarted = !!result.auto_task_started; - const autoTaskId = result.auto_task_id; - if (autoTaskStarted && autoTaskId) { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - taskStore.resumeTask(autoTaskId, { - status: result.auto_task_status || 'running', - resetOffset: true, - eventHandler: (event) => this.handleTaskEvent(event) - }); - this.taskInProgress = true; - if (typeof this.monitorShowPendingReply === 'function') { - this.monitorShowPendingReply(); - } - } else if (guideMessage) { - await this.sendAutoUserMessage(guideMessage); - } - - this.conversationsOffset = 0; - await this.loadConversationsList(); - - debugLog('对话压缩完成:', result); - this.uiPushToast({ - title: '压缩完成', - message: '已生成压缩后的新对话', - type: 'success', - duration: 2200 - }); - } else { - const message = result.message || result.error || '压缩失败'; - this.compressionError = message; - this.uiPushToast({ - title: '压缩失败', - message, - type: 'error' - }); - } - } catch (error) { - console.error('压缩对话异常:', error); - this.compressionError = error.message || '请稍后重试'; - this.uiPushToast({ - title: '压缩对话异常', - message: error.message || '请稍后重试', - type: 'error' - }); - } finally { - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; - } - this.compressing = false; - this.compressionInProgress = false; - this.compressionMode = ''; - this.compressionStage = ''; - } - }, - - async sendAutoUserMessage(text) { - const message = (text || '').trim(); - if (!message || !this.isConnected) { - return false; - } - const quotaType = this.thinkingMode ? 'thinking' : 'fast'; - if (this.isQuotaExceeded(quotaType)) { - this.showQuotaToast({ type: quotaType }); - return false; - } - this.taskInProgress = true; - this.chatAddUserMessage(message, []); - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - await taskStore.createTask(message, [], [], this.currentConversationId); - } catch (error) { - console.error('[Message] 自动消息创建任务失败:', error); - this.uiPushToast({ - title: '发送失败', - message: error?.message || '创建任务失败,请重试', - type: 'error' - }); - this.taskInProgress = false; - return false; - } - if (typeof this.monitorShowPendingReply === 'function') { - this.monitorShowPendingReply(); - } - if (this.autoScrollEnabled) { - this.scrollToBottom(); - } - this.autoResizeInput(); - setTimeout(() => { - if (this.currentConversationId) { - this.updateCurrentContextTokens(); - } - }, 1000); - return true; - }, - - autoResizeInput() { - this.$nextTick(() => { - const textarea = this.getComposerElement('stadiumInput'); - if (!textarea || !(textarea instanceof HTMLTextAreaElement)) { - return; - } - const previousHeight = textarea.offsetHeight; - textarea.style.height = 'auto'; - const computedStyle = window.getComputedStyle(textarea); - const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20; - const maxHeight = lineHeight * 6; - const targetHeight = Math.min(textarea.scrollHeight, maxHeight); - this.inputSetLineCount(Math.max(1, Math.round(targetHeight / lineHeight))); - this.inputSetMultiline(targetHeight > lineHeight * 1.4); - if (Math.abs(targetHeight - previousHeight) <= 0.5) { - textarea.style.height = `${targetHeight}px`; - return; - } - textarea.style.height = `${previousHeight}px`; - void textarea.offsetHeight; - requestAnimationFrame(() => { - textarea.style.height = `${targetHeight}px`; - }); - }); + async executeSystemCommand(rawCommand, options = {}) { + const command = (rawCommand || '').toString().trim(); + if (!command) { + return { success: false, message: '命令不能为空' }; } + + if (!this.isConnected) { + if (options.showToast !== false) { + this.uiPushToast({ + title: '连接不可用', + message: '当前无法执行命令,请稍后重试。', + type: 'error' + }); + } + return { success: false, message: '连接不可用' }; + } + + try { + const response = await fetch('/api/commands', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ command }) + }); + + const payload = await response.json().catch(() => ({})); + const result = { + command: payload.command || command.replace(/^\//, ''), + success: !!payload.success, + message: payload.message, + data: payload.data + }; + this.handleSystemCommandResult(result, options); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : '命令执行失败'; + const result = { + command: command.replace(/^\//, ''), + success: false, + message + }; + this.handleSystemCommandResult(result, options); + return result; + } + }, + + handleSystemCommandResult(data, options = {}) { + const showToast = options.showToast !== false; + if (data.command === 'clear' && data.success) { + this.logMessageState?.('command_result-clear', { data }); + this.messages = []; + this.logMessageState?.('command_result-cleared', { data }); + this.currentMessageIndex = -1; + this.chatClearExpandedBlocks(); + this.resetTokenStatistics(); + if (showToast) { + this.uiPushToast({ + title: '已清除', + message: data.message || '对话已清除', + type: 'success' + }); + } + return; + } + + if (data.command === 'status' && data.success) { + this.addSystemMessage(`系统状态:\n${JSON.stringify(data.data || {}, null, 2)}`); + if (showToast) { + this.uiPushToast({ + title: '状态已更新', + message: '已获取系统状态', + type: 'success' + }); + } + return; + } + + if (!data.success) { + this.addSystemMessage(`命令失败: ${data.message || '未知错误'}`); + if (showToast) { + this.uiPushToast({ + title: '命令执行失败', + message: data.message || '请稍后重试', + type: 'error' + }); + } + return; + } + + if (showToast) { + this.uiPushToast({ + title: '命令已执行', + message: data.command || '完成', + type: 'success' + }); + } + }, + + handleSendOrStop() { + if (this.compressionInProgress) { + this.uiPushToast({ + title: '对话自动压缩中', + message: '当前不可发送/停止,请等待压缩完成', + type: 'warning' + }); + return; + } + if (this.composerBusy) { + this.stopTask(); + } else { + this.sendMessage(); + } + }, + + async sendMessage() { + console.log('[DEBUG_AWAITING] ===== sendMessage ====='); + + if (this.compressionInProgress) { + this.uiPushToast({ + title: '对话自动压缩中', + message: '压缩完成后才能继续发送消息', + type: 'warning' + }); + return; + } + if (this.streamingUi) { + return; + } + if (this.mediaUploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待图片/视频上传完成后再发送', + type: 'info' + }); + return; + } + + const text = (this.inputMessage || '').trim(); + const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : []; + const videos = Array.isArray(this.selectedVideos) ? this.selectedVideos.slice(0, 1) : []; + const hasText = text.length > 0; + const hasImages = images.length > 0; + const hasVideos = videos.length > 0; + + if (!hasText && !hasImages && !hasVideos) { + return; + } + + const quotaType = this.thinkingMode ? 'thinking' : 'fast'; + if (this.isQuotaExceeded(quotaType)) { + this.showQuotaToast({ type: quotaType }); + return; + } + + const modelStore = useModelStore(); + const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey); + if (hasImages && !currentModel?.supportsImage) { + this.uiPushToast({ + title: '当前模型不支持图片', + message: '请切换到支持图片输入的模型再发送图片', + type: 'error' + }); + return; + } + + if (hasVideos && !currentModel?.supportsVideo) { + this.uiPushToast({ + title: '当前模型不支持视频', + message: '请切换到支持视频输入的模型后再发送视频', + type: 'error' + }); + return; + } + + if (hasVideos && hasImages) { + this.uiPushToast({ + title: '请勿同时发送', + message: '视频与图片需分开发送,每条仅包含一种媒体', + type: 'warning' + }); + return; + } + + if (hasVideos) { + this.uiPushToast({ + title: '视频处理中', + message: '读取视频需要较长时间,请耐心等待', + type: 'info', + duration: 5000 + }); + } + + const message = text; + const isCommand = hasText && !hasImages && !hasVideos && message.startsWith('/'); + if (isCommand) { + await this.executeSystemCommand(message, { showToast: false }); + this.inputClearMessage(); + this.inputClearSelectedImages(); + this.inputClearSelectedVideos(); + this.autoResizeInput(); + return; + } + + const wasBlank = this.isConversationBlank(); + if (wasBlank) { + this.blankHeroExiting = true; + this.blankHeroActive = true; + setTimeout(() => { + this.blankHeroExiting = false; + this.blankHeroActive = false; + }, 320); + } + + let targetConversationId = this.currentConversationId; + if (!targetConversationId) { + try { + const createResp = await fetch('/api/conversations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + thinking_mode: this.thinkingMode, + mode: this.runMode + }) + }); + const createResult = await createResp.json().catch(() => ({})); + if (!createResp.ok || !createResult?.success || !createResult?.conversation_id) { + throw new Error(createResult?.message || createResult?.error || '创建对话失败'); + } + targetConversationId = createResult.conversation_id; + this.skipConversationHistoryReload = true; + this.currentConversationId = targetConversationId; + this.currentConversationTitle = '新对话'; + this.conversations = [ + { + id: targetConversationId, + title: '新对话', + updated_at: new Date().toISOString(), + total_messages: 0, + total_tools: 0 + }, + ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId) + ]; + const pathFragment = this.stripConversationPrefix(targetConversationId); + history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`); + } catch (error) { + this.uiPushToast({ + title: '发送失败', + message: error?.message || '创建新对话失败,请重试', + type: 'error' + }); + return; + } + } + + // 标记任务进行中,直到任务完成或用户手动停止 + this.taskInProgress = true; + this.chatAddUserMessage(message, images, videos); + + // 使用 REST API 创建任务(轮询模式) + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + + await taskStore.createTask(message, images, videos, targetConversationId, { + model_key: this.currentModelKey, + run_mode: this.runMode, + thinking_mode: this.thinkingMode + }); + + debugLog('[Message] 任务已创建,开始轮询'); + } catch (error) { + console.error('[Message] 创建任务失败:', error); + this.uiPushToast({ + title: '发送失败', + message: error.message || '创建任务失败,请重试', + type: 'error' + }); + this.taskInProgress = false; + return; + } + + if (typeof this.monitorShowPendingReply === 'function') { + this.monitorShowPendingReply(); + } + this.inputClearMessage(); + this.inputClearSelectedImages(); + this.inputClearSelectedVideos(); + this.inputSetImagePickerOpen(false); + this.inputSetVideoPickerOpen(false); + this.inputSetLineCount(1); + this.inputSetMultiline(false); + if (hasImages) { + this.conversationHasImages = true; + this.conversationHasVideos = false; + } + if (hasVideos) { + this.conversationHasVideos = true; + this.conversationHasImages = false; + } + if (this.autoScrollEnabled) { + this.scrollToBottom(); + } + this.autoResizeInput(); + + // 发送消息后延迟更新当前上下文Token(关键修复:恢复原逻辑) + setTimeout(() => { + if (this.currentConversationId) { + this.updateCurrentContextTokens(); + } + }, 1000); + }, + + // 新增:停止任务方法 + async stopTask() { + console.log('[DEBUG_AWAITING] ===== stopTask ====='); + if (this.compressionInProgress) { + this.uiPushToast({ + title: '对话自动压缩中', + message: '压缩进行中,当前不可停止任务', + type: 'warning' + }); + return; + } + + const canStop = this.composerBusy && !this.stopRequested; + if (!canStop) { + return; + } + + const shouldDropToolEvents = this.streamingUi; + this.stopRequested = true; + this.dropToolEvents = shouldDropToolEvents; + + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + + if (taskStore.currentTaskId) { + await taskStore.cancelTask(); + } + + // 等待后端确认 + await new Promise((resolve) => setTimeout(resolve, 300)); + const shouldKeepBusy = + Boolean(taskStore.currentTaskId) && + ['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus)); + + // 清理前端状态 + this.clearPendingTools('user_stop'); + this.streamingMessage = false; + // 若后台已回传停止事件,不要再次把输入区锁回“停止中” + this.taskInProgress = shouldKeepBusy; + this.forceUnlockMonitor('user_stop'); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + + // 清理assistant消息的等待动画状态 + const lastMessage = this.messages[this.messages.length - 1]; + const before = { + hasLastMessage: !!lastMessage, + role: lastMessage?.role, + awaitingFirstContent: lastMessage?.awaitingFirstContent, + generatingLabel: lastMessage?.generatingLabel + }; + + if (lastMessage && lastMessage.role === 'assistant') { + lastMessage.awaitingFirstContent = false; + lastMessage.generatingLabel = ''; + } + + console.log('[DEBUG_AWAITING] stopTask 清理完成', { + before, + after: { + awaitingFirstContent: lastMessage?.awaitingFirstContent, + generatingLabel: lastMessage?.generatingLabel + } + }); + } catch (error) { + console.error('[Message] 取消任务失败:', error); + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + const shouldKeepBusy = + Boolean(taskStore.currentTaskId) && + ['running', 'pending', 'cancel_requested'].includes(String(taskStore.taskStatus)); + + // 即使失败也清理状态 + this.clearPendingTools('user_stop'); + this.streamingMessage = false; + // 如果任务其实已结束,允许按钮恢复发送态 + this.taskInProgress = shouldKeepBusy; + this.forceUnlockMonitor('user_stop'); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + + // 清理assistant消息的等待动画状态 + const lastMessage = this.messages[this.messages.length - 1]; + if (lastMessage && lastMessage.role === 'assistant') { + lastMessage.awaitingFirstContent = false; + lastMessage.generatingLabel = ''; + } + + this.uiPushToast({ + title: '停止失败', + message: '任务可能仍在运行,请刷新页面', + type: 'warning' + }); + } finally { + // 确保清除 dropToolEvents 和 stopRequested 标志 + this.dropToolEvents = false; + this.stopRequested = false; + } + }, + + async clearChat() { + const confirmed = await this.confirmAction({ + title: '清除对话', + message: '确定要清除所有对话记录吗?该操作不可撤销。', + confirmText: '清除', + cancelText: '取消' + }); + if (confirmed) { + await this.executeSystemCommand('/clear', { showToast: false }); + } + }, + + async compressConversation() { + if (!this.currentConversationId) { + this.uiPushToast({ + title: '无法压缩', + message: '当前没有可压缩的对话。', + type: 'info' + }); + return; + } + + if (this.compressing) { + return; + } + + const confirmed = await this.confirmAction({ + title: '压缩对话', + message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。', + confirmText: '压缩', + cancelText: '取消' + }); + if (!confirmed) { + return; + } + + this.compressing = true; + this.compressionInProgress = true; + this.compressionMode = 'manual'; + this.compressionStage = 'requesting'; + this.compressionError = ''; + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + this.compressionToastId = this.uiPushToast({ + title: '压缩中', + message: '对话正在压缩,请稍候…', + type: 'info', + duration: null, + closable: false + }); + + try { + const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, { + method: 'POST' + }); + + const result = await response.json(); + + if (response.ok && result.success) { + this.compressionStage = 'switching'; + const newId = result.compressed_conversation_id; + if (newId) { + await this.loadConversation(newId, { force: true }); + } + const guideMessage = (result.guide_message || '').trim(); + const autoTaskStarted = !!result.auto_task_started; + const autoTaskId = result.auto_task_id; + if (autoTaskStarted && autoTaskId) { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + taskStore.resumeTask(autoTaskId, { + status: result.auto_task_status || 'running', + resetOffset: true, + eventHandler: (event) => this.handleTaskEvent(event) + }); + this.taskInProgress = true; + if (typeof this.monitorShowPendingReply === 'function') { + this.monitorShowPendingReply(); + } + } else if (guideMessage) { + await this.sendAutoUserMessage(guideMessage); + } + + this.conversationsOffset = 0; + await this.loadConversationsList(); + + debugLog('对话压缩完成:', result); + this.uiPushToast({ + title: '压缩完成', + message: '已生成压缩后的新对话', + type: 'success', + duration: 2200 + }); + } else { + const message = result.message || result.error || '压缩失败'; + this.compressionError = message; + this.uiPushToast({ + title: '压缩失败', + message, + type: 'error' + }); + } + } catch (error) { + console.error('压缩对话异常:', error); + this.compressionError = error.message || '请稍后重试'; + this.uiPushToast({ + title: '压缩对话异常', + message: error.message || '请稍后重试', + type: 'error' + }); + } finally { + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + this.compressing = false; + this.compressionInProgress = false; + this.compressionMode = ''; + this.compressionStage = ''; + } + }, + + async sendAutoUserMessage(text) { + const message = (text || '').trim(); + if (!message || !this.isConnected) { + return false; + } + const quotaType = this.thinkingMode ? 'thinking' : 'fast'; + if (this.isQuotaExceeded(quotaType)) { + this.showQuotaToast({ type: quotaType }); + return false; + } + this.taskInProgress = true; + this.chatAddUserMessage(message, []); + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + await taskStore.createTask(message, [], [], this.currentConversationId); + } catch (error) { + console.error('[Message] 自动消息创建任务失败:', error); + this.uiPushToast({ + title: '发送失败', + message: error?.message || '创建任务失败,请重试', + type: 'error' + }); + this.taskInProgress = false; + return false; + } + if (typeof this.monitorShowPendingReply === 'function') { + this.monitorShowPendingReply(); + } + if (this.autoScrollEnabled) { + this.scrollToBottom(); + } + this.autoResizeInput(); + setTimeout(() => { + if (this.currentConversationId) { + this.updateCurrentContextTokens(); + } + }, 1000); + return true; + }, + + autoResizeInput() { + this.$nextTick(() => { + const textarea = this.getComposerElement('stadiumInput'); + if (!textarea || !(textarea instanceof HTMLTextAreaElement)) { + return; + } + const previousHeight = textarea.offsetHeight; + textarea.style.height = 'auto'; + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20; + const maxHeight = lineHeight * 6; + const targetHeight = Math.min(textarea.scrollHeight, maxHeight); + this.inputSetLineCount(Math.max(1, Math.round(targetHeight / lineHeight))); + this.inputSetMultiline(targetHeight > lineHeight * 1.4); + if (Math.abs(targetHeight - previousHeight) <= 0.5) { + textarea.style.height = `${targetHeight}px`; + return; + } + textarea.style.height = `${previousHeight}px`; + void textarea.offsetHeight; + requestAnimationFrame(() => { + textarea.style.height = `${targetHeight}px`; + }); + }); + } }; diff --git a/static/src/app/methods/monitor.ts b/static/src/app/methods/monitor.ts index 075f93f..4d53772 100644 --- a/static/src/app/methods/monitor.ts +++ b/static/src/app/methods/monitor.ts @@ -2,23 +2,23 @@ import { useEasterEgg } from '../../composables/useEasterEgg'; export const monitorMethods = { - async handleEasterEggPayload(payload) { - const controller = useEasterEgg(); - await controller.handlePayload(payload, this); - }, + async handleEasterEggPayload(payload) { + const controller = useEasterEgg(); + await controller.handlePayload(payload, this); + }, - async startEasterEggEffect(effectName, payload = {}) { - const controller = useEasterEgg(); - await controller.startEffect(effectName, payload, this); - }, + async startEasterEggEffect(effectName, payload = {}) { + const controller = useEasterEgg(); + await controller.startEffect(effectName, payload, this); + }, - destroyEasterEggEffect(forceImmediate = false) { - const controller = useEasterEgg(); - return controller.destroyEffect(forceImmediate); - }, + destroyEasterEggEffect(forceImmediate = false) { + const controller = useEasterEgg(); + return controller.destroyEffect(forceImmediate); + }, - finishEasterEggCleanup() { - const controller = useEasterEgg(); - controller.finishCleanup(); - } + finishEasterEggCleanup() { + const controller = useEasterEgg(); + controller.finishCleanup(); + } }; diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts index 2946e8f..f7fdb5d 100644 --- a/static/src/app/methods/resources.ts +++ b/static/src/app/methods/resources.ts @@ -1,288 +1,288 @@ // @ts-nocheck import { - formatTokenCount, - formatBytes, - formatPercentage, - formatRate, - formatResetTime, - formatQuotaValue, - quotaTypeLabel, - buildQuotaResetSummary, - isQuotaExceeded as isQuotaExceededUtil, - buildQuotaToastMessage + formatTokenCount, + formatBytes, + formatPercentage, + formatRate, + formatResetTime, + formatQuotaValue, + quotaTypeLabel, + buildQuotaResetSummary, + isQuotaExceeded as isQuotaExceededUtil, + buildQuotaToastMessage } from '../../utils/formatters'; export const resourceMethods = { - hasContainerStats() { - return !!( - this.containerStatus && - this.containerStatus.mode === 'docker' && - this.containerStatus.stats - ); - }, + hasContainerStats() { + return !!( + this.containerStatus && + this.containerStatus.mode === 'docker' && + this.containerStatus.stats + ); + }, - containerStatusClass() { - if (!this.containerStatus) { - return 'status-pill--host'; - } - if (this.containerStatus.mode !== 'docker') { - return 'status-pill--host'; - } - const rawStatus = ( - this.containerStatus.state && - (this.containerStatus.state.status || this.containerStatus.state.Status) - ) || ''; - const status = String(rawStatus).toLowerCase(); - if (status.includes('running')) { - return 'status-pill--running'; - } - if (status.includes('paused')) { - return 'status-pill--stopped'; - } - if (status.includes('exited') || status.includes('dead')) { - return 'status-pill--stopped'; - } - return 'status-pill--running'; - }, - - containerStatusText() { - if (!this.containerStatus) { - return '未知'; - } - if (this.containerStatus.mode !== 'docker') { - return '宿主机模式'; - } - const rawStatus = ( - this.containerStatus.state && - (this.containerStatus.state.status || this.containerStatus.state.Status) - ) || ''; - const status = String(rawStatus).toLowerCase(); - if (status.includes('running')) { - return '运行中'; - } - if (status.includes('paused')) { - return '已暂停'; - } - if (status.includes('exited') || status.includes('dead')) { - return '已停止'; - } - return rawStatus || '容器模式'; - }, - - formatTime(value) { - if (!value) { - return '未知时间'; - } - let date; - if (typeof value === 'number') { - date = new Date(value); - } else if (typeof value === 'string') { - const parsed = Date.parse(value); - if (!Number.isNaN(parsed)) { - date = new Date(parsed); - } else { - const numeric = Number(value); - if (!Number.isNaN(numeric)) { - date = new Date(numeric); - } - } - } else if (value instanceof Date) { - date = value; - } - if (!date || Number.isNaN(date.getTime())) { - return String(value); - } - const now = Date.now(); - const diff = now - date.getTime(); - if (diff < 60000) { - return '刚刚'; - } - if (diff < 3600000) { - const mins = Math.floor(diff / 60000); - return `${mins} 分钟前`; - } - if (diff < 86400000) { - const hours = Math.floor(diff / 3600000); - return `${hours} 小时前`; - } - const formatter = new Intl.DateTimeFormat('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - return formatter.format(date); - }, - - async updateCurrentContextTokens() { - await this.resourceUpdateCurrentContextTokens(this.currentConversationId); - }, - - async fetchConversationTokenStatistics() { - await this.resourceFetchConversationTokenStatistics(this.currentConversationId); - }, - - toggleTokenPanel() { - this.resourceToggleTokenPanel(); - }, - - applyStatusSnapshot(status) { - this.resourceApplyStatusSnapshot(status); - if (status && typeof status.thinking_mode !== 'undefined') { - this.thinkingMode = !!status.thinking_mode; - } - if (status && typeof status.run_mode === 'string') { - this.runMode = status.run_mode; - } else if (status && typeof status.thinking_mode !== 'undefined') { - this.runMode = status.thinking_mode ? 'thinking' : 'fast'; - } - if (status && typeof status.model_key === 'string') { - this.modelSet(status.model_key); - } - if (status && typeof status.has_images !== 'undefined') { - this.conversationHasImages = !!status.has_images; - } - if (status && typeof status.has_videos !== 'undefined') { - this.conversationHasVideos = !!status.has_videos; - } - const compression = status?.conversation?.compression; - if (compression && typeof compression === 'object') { - this.compressionInProgress = !!compression.in_progress; - this.compressionMode = compression.mode || ''; - this.compressionStage = compression.stage || ''; - this.compressionError = compression.error || ''; - } else { - this.compressionInProgress = false; - this.compressionMode = ''; - this.compressionStage = ''; - this.compressionError = ''; - } - }, - - updateContainerStatus(status) { - this.resourceUpdateContainerStatus(status); - }, - - pollContainerStats() { - return this.resourcePollContainerStats(); - }, - - startContainerStatsPolling() { - this.resourceStartContainerStatsPolling(); - }, - - stopContainerStatsPolling() { - this.resourceStopContainerStatsPolling(); - }, - - pollProjectStorage() { - return this.resourcePollProjectStorage(); - }, - - startProjectStoragePolling() { - this.resourceStartProjectStoragePolling(); - }, - - stopProjectStoragePolling() { - this.resourceStopProjectStoragePolling(); - }, - - fetchUsageQuota() { - return this.resourceFetchUsageQuota(); - }, - - startUsageQuotaPolling() { - this.resourceStartUsageQuotaPolling(); - }, - - stopUsageQuotaPolling() { - this.resourceStopUsageQuotaPolling(); - }, - - async downloadFile(path) { - if (!path) { - this.fileHideContextMenu(); - return; - } - const url = `/api/download/file?path=${encodeURIComponent(path)}`; - const name = path.split('/').pop() || 'file'; - await this.downloadResource(url, name); - }, - - async downloadFolder(path) { - if (!path) { - this.fileHideContextMenu(); - return; - } - const url = `/api/download/folder?path=${encodeURIComponent(path)}`; - const segments = path.split('/').filter(Boolean); - const folderName = segments.length ? segments.pop() : 'folder'; - await this.downloadResource(url, `${folderName}.zip`); - }, - - async downloadResource(url, filename) { - try { - const response = await fetch(url); - if (!response.ok) { - let message = response.statusText; - try { - const errorData = await response.json(); - message = errorData.error || errorData.message || message; - } catch (err) { - message = await response.text(); - } - this.uiPushToast({ - title: '下载失败', - message: message || '无法完成下载', - type: 'error' - }); - return; - } - - const blob = await response.blob(); - const downloadName = filename || 'download'; - const link = document.createElement('a'); - const href = URL.createObjectURL(blob); - link.href = href; - link.download = downloadName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(href); - } catch (error) { - console.error('下载失败:', error); - this.uiPushToast({ - title: '下载失败', - message: error.message || String(error), - type: 'error' - }); - } finally { - this.fileHideContextMenu(); - } - }, - - formatTokenCount, - formatBytes, - formatPercentage, - formatRate, - formatResetTime, - formatQuotaValue, - quotaTypeLabel, - - quotaResetSummary() { - return buildQuotaResetSummary(this.usageQuota); - }, - - isQuotaExceeded(type) { - return isQuotaExceededUtil(this.usageQuota, type); - }, - - showQuotaToast(payload) { - if (!payload) { - return; - } - const type = payload.type || 'fast'; - const message = buildQuotaToastMessage(type, this.usageQuota, payload.reset_at); - this.uiShowQuotaToastMessage(message, type); + containerStatusClass() { + if (!this.containerStatus) { + return 'status-pill--host'; } + if (this.containerStatus.mode !== 'docker') { + return 'status-pill--host'; + } + const rawStatus = + (this.containerStatus.state && + (this.containerStatus.state.status || this.containerStatus.state.Status)) || + ''; + const status = String(rawStatus).toLowerCase(); + if (status.includes('running')) { + return 'status-pill--running'; + } + if (status.includes('paused')) { + return 'status-pill--stopped'; + } + if (status.includes('exited') || status.includes('dead')) { + return 'status-pill--stopped'; + } + return 'status-pill--running'; + }, + + containerStatusText() { + if (!this.containerStatus) { + return '未知'; + } + if (this.containerStatus.mode !== 'docker') { + return '宿主机模式'; + } + const rawStatus = + (this.containerStatus.state && + (this.containerStatus.state.status || this.containerStatus.state.Status)) || + ''; + const status = String(rawStatus).toLowerCase(); + if (status.includes('running')) { + return '运行中'; + } + if (status.includes('paused')) { + return '已暂停'; + } + if (status.includes('exited') || status.includes('dead')) { + return '已停止'; + } + return rawStatus || '容器模式'; + }, + + formatTime(value) { + if (!value) { + return '未知时间'; + } + let date; + if (typeof value === 'number') { + date = new Date(value); + } else if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + date = new Date(parsed); + } else { + const numeric = Number(value); + if (!Number.isNaN(numeric)) { + date = new Date(numeric); + } + } + } else if (value instanceof Date) { + date = value; + } + if (!date || Number.isNaN(date.getTime())) { + return String(value); + } + const now = Date.now(); + const diff = now - date.getTime(); + if (diff < 60000) { + return '刚刚'; + } + if (diff < 3600000) { + const mins = Math.floor(diff / 60000); + return `${mins} 分钟前`; + } + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000); + return `${hours} 小时前`; + } + const formatter = new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + return formatter.format(date); + }, + + async updateCurrentContextTokens() { + await this.resourceUpdateCurrentContextTokens(this.currentConversationId); + }, + + async fetchConversationTokenStatistics() { + await this.resourceFetchConversationTokenStatistics(this.currentConversationId); + }, + + toggleTokenPanel() { + this.resourceToggleTokenPanel(); + }, + + applyStatusSnapshot(status) { + this.resourceApplyStatusSnapshot(status); + if (status && typeof status.thinking_mode !== 'undefined') { + this.thinkingMode = !!status.thinking_mode; + } + if (status && typeof status.run_mode === 'string') { + this.runMode = status.run_mode; + } else if (status && typeof status.thinking_mode !== 'undefined') { + this.runMode = status.thinking_mode ? 'thinking' : 'fast'; + } + if (status && typeof status.model_key === 'string') { + this.modelSet(status.model_key); + } + if (status && typeof status.has_images !== 'undefined') { + this.conversationHasImages = !!status.has_images; + } + if (status && typeof status.has_videos !== 'undefined') { + this.conversationHasVideos = !!status.has_videos; + } + const compression = status?.conversation?.compression; + if (compression && typeof compression === 'object') { + this.compressionInProgress = !!compression.in_progress; + this.compressionMode = compression.mode || ''; + this.compressionStage = compression.stage || ''; + this.compressionError = compression.error || ''; + } else { + this.compressionInProgress = false; + this.compressionMode = ''; + this.compressionStage = ''; + this.compressionError = ''; + } + }, + + updateContainerStatus(status) { + this.resourceUpdateContainerStatus(status); + }, + + pollContainerStats() { + return this.resourcePollContainerStats(); + }, + + startContainerStatsPolling() { + this.resourceStartContainerStatsPolling(); + }, + + stopContainerStatsPolling() { + this.resourceStopContainerStatsPolling(); + }, + + pollProjectStorage() { + return this.resourcePollProjectStorage(); + }, + + startProjectStoragePolling() { + this.resourceStartProjectStoragePolling(); + }, + + stopProjectStoragePolling() { + this.resourceStopProjectStoragePolling(); + }, + + fetchUsageQuota() { + return this.resourceFetchUsageQuota(); + }, + + startUsageQuotaPolling() { + this.resourceStartUsageQuotaPolling(); + }, + + stopUsageQuotaPolling() { + this.resourceStopUsageQuotaPolling(); + }, + + async downloadFile(path) { + if (!path) { + this.fileHideContextMenu(); + return; + } + const url = `/api/download/file?path=${encodeURIComponent(path)}`; + const name = path.split('/').pop() || 'file'; + await this.downloadResource(url, name); + }, + + async downloadFolder(path) { + if (!path) { + this.fileHideContextMenu(); + return; + } + const url = `/api/download/folder?path=${encodeURIComponent(path)}`; + const segments = path.split('/').filter(Boolean); + const folderName = segments.length ? segments.pop() : 'folder'; + await this.downloadResource(url, `${folderName}.zip`); + }, + + async downloadResource(url, filename) { + try { + const response = await fetch(url); + if (!response.ok) { + let message = response.statusText; + try { + const errorData = await response.json(); + message = errorData.error || errorData.message || message; + } catch (err) { + message = await response.text(); + } + this.uiPushToast({ + title: '下载失败', + message: message || '无法完成下载', + type: 'error' + }); + return; + } + + const blob = await response.blob(); + const downloadName = filename || 'download'; + const link = document.createElement('a'); + const href = URL.createObjectURL(blob); + link.href = href; + link.download = downloadName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(href); + } catch (error) { + console.error('下载失败:', error); + this.uiPushToast({ + title: '下载失败', + message: error.message || String(error), + type: 'error' + }); + } finally { + this.fileHideContextMenu(); + } + }, + + formatTokenCount, + formatBytes, + formatPercentage, + formatRate, + formatResetTime, + formatQuotaValue, + quotaTypeLabel, + + quotaResetSummary() { + return buildQuotaResetSummary(this.usageQuota); + }, + + isQuotaExceeded(type) { + return isQuotaExceededUtil(this.usageQuota, type); + }, + + showQuotaToast(payload) { + if (!payload) { + return; + } + const type = payload.type || 'fast'; + const message = buildQuotaToastMessage(type, this.usageQuota, payload.reset_at); + this.uiShowQuotaToastMessage(message, type); + } }; diff --git a/static/src/app/methods/search.ts b/static/src/app/methods/search.ts index 1bc62b6..75193c0 100644 --- a/static/src/app/methods/search.ts +++ b/static/src/app/methods/search.ts @@ -4,167 +4,174 @@ const SEARCH_MORE_BATCH = 200; const SEARCH_PREVIEW_LIMIT = 20; export const searchMethods = { - handleSidebarSearchInput(value) { - this.searchQuery = value; - }, + handleSidebarSearchInput(value) { + this.searchQuery = value; + }, - handleSidebarSearchSubmit(value) { - this.searchQuery = value; - const trimmed = String(value || '').trim(); - if (!trimmed) { - this.exitConversationSearch(); - return; - } - this.startConversationSearch(trimmed); - }, - - exitConversationSearch() { - this.searchActive = false; - this.searchInProgress = false; - this.searchMoreAvailable = false; - this.searchOffset = 0; - this.searchTotal = 0; - this.searchResults = []; - this.searchActiveQuery = ''; - this.searchResultIdSet = new Set(); - this.conversationsOffset = 0; - this.loadConversationsList(); - }, - - async startConversationSearch(query) { - const trimmed = String(query || '').trim(); - if (!trimmed) { - return; - } - const requestSeq = ++this.searchRequestSeq; - this.searchActiveQuery = trimmed; - this.searchActive = true; - this.searchInProgress = true; - this.searchMoreAvailable = false; - this.searchOffset = 0; - this.searchTotal = 0; - this.searchResults = []; - this.searchResultIdSet = new Set(); - await this.searchNextConversationBatch(SEARCH_INITIAL_BATCH, requestSeq); - }, - - async loadMoreSearchResults() { - if (!this.searchActive || this.searchInProgress || !this.searchMoreAvailable) { - return; - } - const requestSeq = this.searchRequestSeq; - this.searchInProgress = true; - await this.searchNextConversationBatch(SEARCH_MORE_BATCH, requestSeq); - }, - - async searchNextConversationBatch(batchSize, requestSeq) { - const query = this.searchActiveQuery; - if (!query) { - if (requestSeq === this.searchRequestSeq) { - this.searchInProgress = false; - } - return; - } - try { - const response = await fetch(`/api/conversations?limit=${batchSize}&offset=${this.searchOffset}`); - const payload = await response.json(); - - if (requestSeq !== this.searchRequestSeq) { - return; - } - - if (!payload.success) { - console.error('搜索对话失败:', payload.error || payload.message); - this.searchInProgress = false; - return; - } - - const data = payload.data || {}; - const conversations = data.conversations || []; - if (!this.searchTotal) { - this.searchTotal = data.total || 0; - } - - for (const conv of conversations) { - if (requestSeq !== this.searchRequestSeq) { - return; - } - await this.matchConversation(conv, query, requestSeq); - } - - this.searchOffset += conversations.length; - this.searchMoreAvailable = this.searchOffset < (this.searchTotal || 0); - } catch (error) { - console.error('搜索对话异常:', error); - } finally { - if (requestSeq === this.searchRequestSeq) { - this.searchInProgress = false; - } - } - }, - - async matchConversation(conversation, query, requestSeq) { - if (!conversation || !conversation.id) { - return; - } - if (this.searchResultIdSet && this.searchResultIdSet.has(conversation.id)) { - return; - } - const firstSentence = await this.getConversationFirstUserSentence(conversation.id, requestSeq); - if (requestSeq !== this.searchRequestSeq) { - return; - } - const queryLower = String(query || '').toLowerCase(); - const combined = `${conversation.title || ''} ${firstSentence || ''}`.toLowerCase(); - if (queryLower && combined.includes(queryLower)) { - this.searchResults.push(conversation); - this.searchResultIdSet.add(conversation.id); - } - }, - - async getConversationFirstUserSentence(conversationId, requestSeq) { - if (!conversationId) { - return ''; - } - if (this.searchPreviewCache && Object.prototype.hasOwnProperty.call(this.searchPreviewCache, conversationId)) { - return this.searchPreviewCache[conversationId]; - } - try { - const resp = await fetch(`/api/conversations/${conversationId}/review_preview?limit=${SEARCH_PREVIEW_LIMIT}`); - const payload = await resp.json(); - if (requestSeq !== this.searchRequestSeq) { - return ''; - } - const lines = payload?.data?.preview || []; - let firstUserLine = ''; - for (const line of lines) { - if (typeof line === 'string' && line.startsWith('user:')) { - firstUserLine = line.slice('user:'.length).trim(); - break; - } - } - const firstSentence = this.extractFirstSentence(firstUserLine); - const cached = firstSentence || firstUserLine || ''; - if (!this.searchPreviewCache) { - this.searchPreviewCache = {}; - } - this.searchPreviewCache[conversationId] = cached; - return cached; - } catch (error) { - console.error('获取对话预览失败:', error); - return ''; - } - }, - - extractFirstSentence(text) { - if (!text) { - return ''; - } - const normalized = String(text).replace(/\s+/g, ' ').trim(); - const match = normalized.match(/(.+?[。!?.!?])/); - if (match) { - return match[1]; - } - return normalized; + handleSidebarSearchSubmit(value) { + this.searchQuery = value; + const trimmed = String(value || '').trim(); + if (!trimmed) { + this.exitConversationSearch(); + return; } + this.startConversationSearch(trimmed); + }, + + exitConversationSearch() { + this.searchActive = false; + this.searchInProgress = false; + this.searchMoreAvailable = false; + this.searchOffset = 0; + this.searchTotal = 0; + this.searchResults = []; + this.searchActiveQuery = ''; + this.searchResultIdSet = new Set(); + this.conversationsOffset = 0; + this.loadConversationsList(); + }, + + async startConversationSearch(query) { + const trimmed = String(query || '').trim(); + if (!trimmed) { + return; + } + const requestSeq = ++this.searchRequestSeq; + this.searchActiveQuery = trimmed; + this.searchActive = true; + this.searchInProgress = true; + this.searchMoreAvailable = false; + this.searchOffset = 0; + this.searchTotal = 0; + this.searchResults = []; + this.searchResultIdSet = new Set(); + await this.searchNextConversationBatch(SEARCH_INITIAL_BATCH, requestSeq); + }, + + async loadMoreSearchResults() { + if (!this.searchActive || this.searchInProgress || !this.searchMoreAvailable) { + return; + } + const requestSeq = this.searchRequestSeq; + this.searchInProgress = true; + await this.searchNextConversationBatch(SEARCH_MORE_BATCH, requestSeq); + }, + + async searchNextConversationBatch(batchSize, requestSeq) { + const query = this.searchActiveQuery; + if (!query) { + if (requestSeq === this.searchRequestSeq) { + this.searchInProgress = false; + } + return; + } + try { + const response = await fetch( + `/api/conversations?limit=${batchSize}&offset=${this.searchOffset}` + ); + const payload = await response.json(); + + if (requestSeq !== this.searchRequestSeq) { + return; + } + + if (!payload.success) { + console.error('搜索对话失败:', payload.error || payload.message); + this.searchInProgress = false; + return; + } + + const data = payload.data || {}; + const conversations = data.conversations || []; + if (!this.searchTotal) { + this.searchTotal = data.total || 0; + } + + for (const conv of conversations) { + if (requestSeq !== this.searchRequestSeq) { + return; + } + await this.matchConversation(conv, query, requestSeq); + } + + this.searchOffset += conversations.length; + this.searchMoreAvailable = this.searchOffset < (this.searchTotal || 0); + } catch (error) { + console.error('搜索对话异常:', error); + } finally { + if (requestSeq === this.searchRequestSeq) { + this.searchInProgress = false; + } + } + }, + + async matchConversation(conversation, query, requestSeq) { + if (!conversation || !conversation.id) { + return; + } + if (this.searchResultIdSet && this.searchResultIdSet.has(conversation.id)) { + return; + } + const firstSentence = await this.getConversationFirstUserSentence(conversation.id, requestSeq); + if (requestSeq !== this.searchRequestSeq) { + return; + } + const queryLower = String(query || '').toLowerCase(); + const combined = `${conversation.title || ''} ${firstSentence || ''}`.toLowerCase(); + if (queryLower && combined.includes(queryLower)) { + this.searchResults.push(conversation); + this.searchResultIdSet.add(conversation.id); + } + }, + + async getConversationFirstUserSentence(conversationId, requestSeq) { + if (!conversationId) { + return ''; + } + if ( + this.searchPreviewCache && + Object.prototype.hasOwnProperty.call(this.searchPreviewCache, conversationId) + ) { + return this.searchPreviewCache[conversationId]; + } + try { + const resp = await fetch( + `/api/conversations/${conversationId}/review_preview?limit=${SEARCH_PREVIEW_LIMIT}` + ); + const payload = await resp.json(); + if (requestSeq !== this.searchRequestSeq) { + return ''; + } + const lines = payload?.data?.preview || []; + let firstUserLine = ''; + for (const line of lines) { + if (typeof line === 'string' && line.startsWith('user:')) { + firstUserLine = line.slice('user:'.length).trim(); + break; + } + } + const firstSentence = this.extractFirstSentence(firstUserLine); + const cached = firstSentence || firstUserLine || ''; + if (!this.searchPreviewCache) { + this.searchPreviewCache = {}; + } + this.searchPreviewCache[conversationId] = cached; + return cached; + } catch (error) { + console.error('获取对话预览失败:', error); + return ''; + } + }, + + extractFirstSentence(text) { + if (!text) { + return ''; + } + const normalized = String(text).replace(/\s+/g, ' ').trim(); + const match = normalized.match(/(.+?[。!?.!?])/); + if (match) { + return match[1]; + } + return normalized; + } }; diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 0407c50..91ed587 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -1,9 +1,11 @@ // @ts-nocheck import { debugLog } from './common'; -const debugNotifyLog = (..._args: any[]) => {}; +const debugNotifyLog = (...args: any[]) => { + void args; +}; const keyNotifyLog = (...args: any[]) => { - console.log(...args); + console.log(...args); }; /** @@ -11,1521 +13,1538 @@ const keyNotifyLog = (...args: any[]) => { * 将从 REST API 轮询获取的事件转换为前端状态更新 */ export const taskPollingMethods = { - stopWaitingTaskProbe() { - if (this.waitingTaskProbeTimer) { - debugNotifyLog('[DEBUG_NOTIFY][ui] stopWaitingTaskProbe'); - clearInterval(this.waitingTaskProbeTimer); - this.waitingTaskProbeTimer = null; - } - }, + stopWaitingTaskProbe() { + if (this.waitingTaskProbeTimer) { + debugNotifyLog('[DEBUG_NOTIFY][ui] stopWaitingTaskProbe'); + clearInterval(this.waitingTaskProbeTimer); + this.waitingTaskProbeTimer = null; + } + }, - async tryResumeWaitingNoticeTask() { - if (!this.waitingForSubAgent || !this.currentConversationId) { - debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:skip', { - waitingForSubAgent: this.waitingForSubAgent, - currentConversationId: this.currentConversationId - }); - return false; - } - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - if (taskStore.hasActiveTask) { - debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:already-active', { - taskId: taskStore.currentTaskId, - status: taskStore.taskStatus - }); - return true; - } - - const resp = await fetch('/api/tasks'); - if (!resp.ok) { - debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:tasks-api-not-ok', { - status: resp.status - }); - return false; - } - const result = await resp.json().catch(() => ({})); - const tasks = Array.isArray(result?.data) ? result.data : []; - debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:tasks', { - currentConversationId: this.currentConversationId, - count: tasks.length, - running: tasks - .filter((t: any) => t?.status === 'running') - .map((t: any) => ({ - task_id: t?.task_id, - conversation_id: t?.conversation_id, - status: t?.status - })) - }); - const runningTask = tasks.find((task: any) => - task?.status === 'running' && task?.conversation_id === this.currentConversationId - ); - if (!runningTask?.task_id) { - debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:no-matched-task'); - return false; - } - - debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:resume', { - task_id: runningTask.task_id - }); - keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] tryResumeWaitingNoticeTask:resume', { - task_id: runningTask.task_id, - conversationId: this.currentConversationId - }); - // 关键修复:切到新的通知任务前,清空旧任务事件去重索引。 - // 否则新任务 event idx 与旧任务重叠时会被误判为“重复事件”而丢失 user_message。 - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - taskStore.resumeTask(runningTask.task_id, { - status: 'running', - resetOffset: true, - eventHandler: (event: any) => this.handleTaskEvent(event) - }); - return true; - } catch (error) { - console.warn('[TaskPolling] 接管等待任务失败:', error); - return false; - } - }, - - startWaitingTaskProbe() { - if (this.waitingTaskProbeTimer) { - debugNotifyLog('[DEBUG_NOTIFY][ui] startWaitingTaskProbe:already-started'); - return; - } - debugNotifyLog('[DEBUG_NOTIFY][ui] startWaitingTaskProbe'); - keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] startWaitingTaskProbe', { - conversationId: this.currentConversationId + async tryResumeWaitingNoticeTask() { + if (!this.waitingForSubAgent || !this.currentConversationId) { + debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:skip', { + waitingForSubAgent: this.waitingForSubAgent, + currentConversationId: this.currentConversationId + }); + return false; + } + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + if (taskStore.hasActiveTask) { + debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:already-active', { + taskId: taskStore.currentTaskId, + status: taskStore.taskStatus }); - this.waitingTaskProbeTimer = setInterval(async () => { - if (!this.waitingForSubAgent) { - this.stopWaitingTaskProbe(); - return; - } - const resumed = await this.tryResumeWaitingNoticeTask(); - if (resumed) { - debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop'); - keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop'); - this.stopWaitingTaskProbe(); - } + return true; + } + + const resp = await fetch('/api/tasks'); + if (!resp.ok) { + debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:tasks-api-not-ok', { + status: resp.status + }); + return false; + } + const result = await resp.json().catch(() => ({})); + const tasks = Array.isArray(result?.data) ? result.data : []; + debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:tasks', { + currentConversationId: this.currentConversationId, + count: tasks.length, + running: tasks + .filter((t: any) => t?.status === 'running') + .map((t: any) => ({ + task_id: t?.task_id, + conversation_id: t?.conversation_id, + status: t?.status + })) + }); + const runningTask = tasks.find( + (task: any) => + task?.status === 'running' && task?.conversation_id === this.currentConversationId + ); + if (!runningTask?.task_id) { + debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:no-matched-task'); + return false; + } + + debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:resume', { + task_id: runningTask.task_id + }); + keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] tryResumeWaitingNoticeTask:resume', { + task_id: runningTask.task_id, + conversationId: this.currentConversationId + }); + // 关键修复:切到新的通知任务前,清空旧任务事件去重索引。 + // 否则新任务 event idx 与旧任务重叠时会被误判为“重复事件”而丢失 user_message。 + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + taskStore.resumeTask(runningTask.task_id, { + status: 'running', + resetOffset: true, + eventHandler: (event: any) => this.handleTaskEvent(event) + }); + return true; + } catch (error) { + console.warn('[TaskPolling] 接管等待任务失败:', error); + return false; + } + }, + + startWaitingTaskProbe() { + if (this.waitingTaskProbeTimer) { + debugNotifyLog('[DEBUG_NOTIFY][ui] startWaitingTaskProbe:already-started'); + return; + } + debugNotifyLog('[DEBUG_NOTIFY][ui] startWaitingTaskProbe'); + keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] startWaitingTaskProbe', { + conversationId: this.currentConversationId + }); + this.waitingTaskProbeTimer = setInterval(async () => { + if (!this.waitingForSubAgent) { + this.stopWaitingTaskProbe(); + return; + } + const resumed = await this.tryResumeWaitingNoticeTask(); + if (resumed) { + debugNotifyLog('[DEBUG_NOTIFY][ui] waitingTaskProbe:resumed-and-stop'); + keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] waitingTaskProbe:resumed-and-stop'); + this.stopWaitingTaskProbe(); + } + }, 1000); + }, + + /** + * 处理任务事件(从轮询获取) + */ + handleTaskEvent(event: any) { + if (!event || !event.type) { + return; + } + + const eventType = event.type; + const eventData = event.data || {}; + const eventIdx = event.idx; + if ( + eventType === 'system_message' || + eventType === 'sub_agent_waiting' || + (eventType === 'user_message' && eventData?.sub_agent_notice) + ) { + debugNotifyLog('[DEBUG_NOTIFY][event] 捕获关键事件', { + eventType, + eventIdx, + eventData + }); + keyNotifyLog('[DEBUG_NOTIFY_KEY][event] key-event', { + eventType, + idx: eventIdx, + task_id: eventData?.task_id, + sub_agent_notice: !!eventData?.sub_agent_notice, + has_running_sub_agents: eventData?.has_running_sub_agents, + has_running_background_commands: eventData?.has_running_background_commands + }); + } + + // 事件去重检查 + if (typeof eventIdx === 'number') { + if (!this._processedEventIndices) { + this._processedEventIndices = new Set(); + } + + if (this._processedEventIndices.has(eventIdx)) { + debugLog(`[TaskPolling] 跳过重复事件 #${eventIdx}`); + return; + } + + this._processedEventIndices.add(eventIdx); + + // 限制 Set 大小(保留最近 1000 个) + if (this._processedEventIndices.size > 1000) { + const firstIdx = Math.min(...this._processedEventIndices); + this._processedEventIndices.delete(firstIdx); + } + } + + // 检查事件的 conversation_id 是否匹配当前对话 + // 如果不匹配,忽略该事件(避免切换对话后旧任务的事件显示到新对话中) + const crossConversationAllowed = new Set([ + 'shallow_compression', + 'conversation_resolved', + 'compression_finished' + ]); + if ( + !crossConversationAllowed.has(eventType) && + eventData.conversation_id && + this.currentConversationId + ) { + if (eventData.conversation_id !== this.currentConversationId) { + console.log(`[DEBUG_AWAITING] 忽略不匹配的事件`, { + eventType, + eventIdx, + eventConversationId: eventData.conversation_id, + currentConversationId: this.currentConversationId + }); + debugLog( + `[TaskPolling] 忽略不匹配的事件 #${eventIdx}: ${eventType}, 事件对话=${eventData.conversation_id}, 当前对话=${this.currentConversationId}` + ); + return; + } + } + + debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData); + + console.log(`[DEBUG_AWAITING] 处理事件`, { + eventType, + eventIdx, + eventConversationId: eventData.conversation_id, + currentConversationId: this.currentConversationId + }); + + // 根据事件类型调用对应的处理方法 + switch (eventType) { + case 'ai_message_start': + this.handleAiMessageStart(eventData, eventIdx); + break; + + case 'thinking_start': + this.handleThinkingStart(eventData, eventIdx); + break; + + case 'thinking_chunk': + this.handleThinkingChunk(eventData, eventIdx); + break; + + case 'thinking_end': + this.handleThinkingEnd(eventData, eventIdx); + break; + + case 'text_start': + this.handleTextStart(eventData, eventIdx); + break; + + case 'text_chunk': + this.handleTextChunk(eventData, eventIdx); + break; + + case 'text_end': + this.handleTextEnd(eventData, eventIdx); + break; + + case 'tool_preparing': + this.handleToolPreparing(eventData, eventIdx); + break; + + case 'tool_start': + this.handleToolStart(eventData, eventIdx); + break; + + case 'tool_intent': + this.handleToolIntent(eventData, eventIdx); + break; + + case 'tool_update_action': + case 'update_action': + this.handleToolUpdateAction(eventData, eventIdx); + break; + + case 'append_payload': + this.handleAppendPayload(eventData, eventIdx); + break; + + case 'modify_payload': + this.handleModifyPayload(eventData, eventIdx); + break; + + case 'task_complete': + this.handleTaskComplete(eventData, eventIdx); + break; + + case 'task_stopped': + this.handleTaskStopped(eventData, eventIdx); + break; + + case 'error': + this.handleTaskError(eventData, eventIdx); + break; + + case 'token_update': + this.handleTokenUpdate(eventData, eventIdx); + break; + + case 'conversation_changed': + this.handleConversationChanged(eventData, eventIdx); + break; + + case 'conversation_resolved': + this.handleConversationResolved(eventData, eventIdx); + break; + case 'compression_state': + this.handleCompressionState(eventData, eventIdx); + break; + case 'compression_finished': + this.handleCompressionFinished(eventData, eventIdx); + break; + case 'shallow_compression': + this.handleShallowCompression(eventData, eventIdx); + break; + + case 'user_message': + debugNotifyLog('[DEBUG_NOTIFY][event] dispatch:user_message', { + idx: eventIdx, + data: eventData + }); + this.handleUserMessage(eventData, eventIdx); + break; + + case 'system_message': + this.handleSystemMessage(eventData, eventIdx); + break; + + default: + debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); + } + + // 注意:不要在这里清除 _rebuildingFromScratch 标记 + // 该标记应该在恢复完所有历史事件后才清除 + }, + + handleAiMessageStart(data: any, eventIdx: number) { + const lastMessage = this.messages[this.messages.length - 1]; + console.log('[DEBUG_AWAITING] ===== handleAiMessageStart =====', { + eventIdx, + lastMessageRole: lastMessage?.role, + lastMessageAwaitingFirstContent: lastMessage?.awaitingFirstContent, + lastMessageGeneratingLabel: lastMessage?.generatingLabel + }); + + debugLog('[TaskPolling] AI消息开始, idx:', eventIdx); + console.log('[AiMessageStart] 开始处理', { + eventIdx, + messagesLength: this.messages.length, + lastMessageRole: this.messages[this.messages.length - 1]?.role, + streamingMessage: this.streamingMessage, + taskInProgress: this.taskInProgress, + currentConversationId: this.currentConversationId + }); + + if (this.waitingForSubAgent) { + this.waitingForSubAgent = false; + } + + // 检查是否已经有 assistant 消息 + const hasAssistantMessage = lastMessage && lastMessage.role === 'assistant'; + + console.log('[AiMessageStart] 最后一条消息状态', { + hasAssistantMessage, + lastMessageActionsLength: lastMessage?.actions?.length || 0, + lastMessageAwaitingFirstContent: lastMessage?.awaitingFirstContent, + lastMessageGeneratingLabel: lastMessage?.generatingLabel + }); + + // 判断是否是刷新恢复的情况: + // 1. 有assistant消息 + // 2. 且该消息正在streaming或者没有任何内容(说明是刚创建的) + // 3. 且不是历史加载完成的消息(历史消息的awaitingFirstContent应该是false) + const isRefreshRestore = + hasAssistantMessage && + (this.streamingMessage || !lastMessage.actions || lastMessage.actions.length === 0) && + (lastMessage.awaitingFirstContent === true || this.taskInProgress); + + console.log('[AiMessageStart] 场景判断', { + isRefreshRestore, + streamingMessage: this.streamingMessage, + hasActions: !!(lastMessage?.actions && lastMessage.actions.length > 0), + awaitingFirstContent: lastMessage?.awaitingFirstContent, + taskInProgress: this.taskInProgress + }); + + if (isRefreshRestore) { + console.log('[AiMessageStart] 场景=刷新恢复,复用现有assistant消息'); + debugLog('[TaskPolling] 刷新恢复场景,复用现有 assistant 消息'); + // 只更新状态,不创建新消息 + this.taskInProgress = true; + this.stopRequested = false; + this.streamingMessage = true; + + // 确保 awaitingFirstContent 被正确设置 + const hasContent = lastMessage.actions && lastMessage.actions.length > 0; + if (!hasContent) { + lastMessage.awaitingFirstContent = true; + lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; + console.log('[AiMessageStart] 设置等待动画', { + awaitingFirstContent: true, + generatingLabel: lastMessage.generatingLabel + }); + } else { + // 如果已有内容,确保等待动画不显示 + lastMessage.awaitingFirstContent = false; + lastMessage.generatingLabel = ''; + console.log('[AiMessageStart] 关闭等待动画(已有内容)'); + } + return; + } + + // 其他情况:创建新的 assistant 消息 + console.log('[AiMessageStart] 场景=创建新消息'); + debugLog('[TaskPolling] 创建新的 assistant 消息'); + this.monitorResetSpeech(); + this.cleanupStaleToolActions(); + this.taskInProgress = true; + this.chatStartAssistantMessage(); + this.stopRequested = false; + this.streamingMessage = true; + + const newMessage = this.messages[this.messages.length - 1]; + console.log('[AiMessageStart] 新消息创建完成', { + role: newMessage?.role, + awaitingFirstContent: newMessage?.awaitingFirstContent, + generatingLabel: newMessage?.generatingLabel, + actionsLength: newMessage?.actions?.length || 0, + messagesLength: this.messages.length + }); + + // 如果是从头重建,标记消息为静默恢复 + if (this._rebuildingFromScratch) { + if (newMessage && newMessage.role === 'assistant') { + debugLog('[TaskPolling] 标记消息为静默恢复(从头重建)'); + newMessage.awaitingFirstContent = false; + newMessage.generatingLabel = ''; + console.log('[AiMessageStart] 从头重建,关闭等待动画'); + } + } + + // 强制触发Vue响应式更新 + this.$forceUpdate(); + console.log('[AiMessageStart] 已调用$forceUpdate'); + + this.$nextTick(() => { + console.log('[AiMessageStart] nextTick回调执行'); + this.scrollToBottom(); + }); + }, + + handleThinkingStart(data: any, eventIdx: number) { + console.log('[ThinkingStart] 开始处理', { eventIdx }); + debugLog('[TaskPolling] 思考开始, idx:', eventIdx); + const ignoreThinking = this.runMode === 'fast' || this.thinkingMode === false; + if (ignoreThinking) { + this.monitorEndModelOutput(); + console.log('[ThinkingStart] 忽略思考(fast模式或关闭思考)'); + return; + } + + // 防御性检查:如果没有assistant消息,先创建一个 + const lastMessage = this.messages[this.messages.length - 1]; + if (!lastMessage || lastMessage.role !== 'assistant') { + console.log('[DEBUG_AWAITING] ThinkingStart 检测到缺少assistant消息,自动创建'); + this.chatStartAssistantMessage(); + } + + this.monitorShowThinking(); + + const result = this.chatStartThinkingAction(); + + if (result && result.blockId) { + const blockId = result.blockId; + + const lastMessage = this.messages[this.messages.length - 1]; + + // 有内容了,关闭等待动画 + if (lastMessage && lastMessage.role === 'assistant') { + console.log('[ThinkingStart] 关闭等待动画', { + beforeAwaitingFirstContent: lastMessage.awaitingFirstContent, + beforeGeneratingLabel: lastMessage.generatingLabel + }); + lastMessage.awaitingFirstContent = false; + lastMessage.generatingLabel = ''; + } + + // 只在非历史恢复时展开思考块 + if (!this._rebuildingFromScratch) { + this.chatExpandBlock(blockId); + } + + this.scrollToBottom(); + this.chatSetThinkingLock(blockId, true); + + // 强制触发 actions 数组的响应式更新 + if (lastMessage && lastMessage.actions) { + lastMessage.actions = [...lastMessage.actions]; + } + + this.$forceUpdate(); + } + }, + + handleThinkingChunk(data: any) { + if (this.runMode === 'fast' || this.thinkingMode === false) { + return; + } + const thinkingAction = this.chatAppendThinkingChunk(data.content); + if (thinkingAction) { + this.$forceUpdate(); + this.$nextTick(() => { + if (thinkingAction && thinkingAction.blockId) { + this.scrollThinkingToBottom(thinkingAction.blockId); + } + this.conditionalScrollToBottom(); + }); + } + this.monitorShowThinking(); + }, + + handleThinkingEnd(data: any) { + debugLog('[TaskPolling] 思考结束'); + if (this.runMode === 'fast' || this.thinkingMode === false) { + return; + } + const blockId = this.chatCompleteThinkingAction(data.full_content); + if (blockId) { + // 解锁思考块 + this.chatSetThinkingLock(blockId, false); + + // 只在非历史恢复时延迟折叠思考块 + if (!this._rebuildingFromScratch) { + // 延迟折叠思考块(给用户一点时间看到思考完成) + setTimeout(() => { + this.chatCollapseBlock(blockId); + this.$forceUpdate(); }, 1000); - }, + } else { + // 历史恢复时立即折叠,不需要动画 + this.chatCollapseBlock(blockId); + } - /** - * 处理任务事件(从轮询获取) - */ - handleTaskEvent(event: any) { - if (!event || !event.type) { - return; + this.$nextTick(() => this.scrollThinkingToBottom(blockId)); + } + this.$forceUpdate(); + this.monitorEndModelOutput(); + }, + + handleTextStart() { + console.log('[TextStart] 开始处理'); + debugLog('[TaskPolling] 文本开始'); + + // 防御性检查:如果没有assistant消息,先创建一个 + const lastMessage = this.messages[this.messages.length - 1]; + if (!lastMessage || lastMessage.role !== 'assistant') { + console.log('[DEBUG_AWAITING] TextStart 检测到缺少assistant消息,自动创建'); + this.chatStartAssistantMessage(); + } + + this.chatStartTextAction(); + + // 有内容了,关闭等待动画 + const currentMessage = this.messages[this.messages.length - 1]; + if (currentMessage && currentMessage.role === 'assistant') { + console.log('[TextStart] 关闭等待动画', { + beforeAwaitingFirstContent: currentMessage.awaitingFirstContent, + beforeGeneratingLabel: currentMessage.generatingLabel + }); + currentMessage.awaitingFirstContent = false; + currentMessage.generatingLabel = ''; + } + + this.$forceUpdate(); + }, + + handleTextChunk(data: any) { + if (data && typeof data.content === 'string' && data.content.length) { + this.chatAppendTextChunk(data.content); + this.$forceUpdate(); + this.conditionalScrollToBottom(); + const speech = data.content.replace(/\r/g, ''); + if (speech) { + this.monitorShowSpeech(speech); + } + } + }, + + handleTextEnd(data: any) { + debugLog('[TaskPolling] 文本结束'); + const full = data?.full_content || ''; + this.chatCompleteTextAction(full); + this.$forceUpdate(); + this.monitorEndModelOutput(); + }, + + handleToolPreparing(data: any) { + console.log('[ToolPreparing] 开始处理', { name: data.name, id: data.id }); + debugLog('[TaskPolling] 工具准备中:', data.name); + + if (this.dropToolEvents) { + console.log('[ToolPreparing] dropToolEvents=true,跳过'); + return; + } + + const msg = this.chatEnsureAssistantMessage(); + if (!msg) { + console.log('[ToolPreparing] 无法获取assistant消息'); + return; + } + + if (msg.awaitingFirstContent) { + console.log('[ToolPreparing] 关闭等待动画', { + beforeAwaitingFirstContent: msg.awaitingFirstContent, + beforeGeneratingLabel: msg.generatingLabel + }); + msg.awaitingFirstContent = false; + msg.generatingLabel = ''; + } + + const action = { + id: data.id, + type: 'tool', + tool: { + id: data.id, + name: data.name, + arguments: {}, + argumentSnapshot: null, + argumentLabel: '', + status: 'preparing', + result: null, + message: data.message || `准备调用 ${data.name}...`, + intent_full: data.intent || '', + intent_rendered: data.intent || '' + }, + timestamp: Date.now() + }; + + msg.actions.push(action); + this.preparingTools.set(data.id, action); + this.toolRegisterAction(action, data.id); + this.toolTrackAction(data.name, action); + + this.$forceUpdate(); + this.conditionalScrollToBottom(); + + if (this.monitorPreviewTool) { + this.monitorPreviewTool(data); + } + }, + + handleToolStart(data: any) { + debugLog('[TaskPolling] 工具开始:', data.name); + + if (this.dropToolEvents) { + return; + } + + let action = null; + if (data.preparing_id && this.preparingTools.has(data.preparing_id)) { + action = this.preparingTools.get(data.preparing_id); + this.preparingTools.delete(data.preparing_id); + } else { + action = this.toolFindAction(data.id, data.preparing_id, data.execution_id); + } + + if (!action) { + const msg = this.chatEnsureAssistantMessage(); + if (!msg) { + return; + } + action = { + id: data.id, + type: 'tool', + tool: { + id: data.id, + name: data.name, + arguments: {}, + argumentSnapshot: null, + argumentLabel: '', + status: 'running', + result: null + }, + timestamp: Date.now() + }; + msg.actions.push(action); + } + + action.tool.status = 'running'; + action.tool.arguments = data.arguments; + action.tool.argumentSnapshot = this.cloneToolArguments(data.arguments); + action.tool.argumentLabel = this.buildToolLabel(action.tool.argumentSnapshot); + action.tool.message = null; + action.tool.id = data.id; + action.tool.executionId = data.id; + + this.toolRegisterAction(action, data.id); + this.toolTrackAction(data.name, action); + this.$forceUpdate(); + this.conditionalScrollToBottom(); + + if (this.monitorQueueTool) { + this.monitorQueueTool(data); + } + }, + + handleToolIntent(data: any) { + debugLog('[TaskPolling] 工具意图:', data.name, data.intent); + + if (this.dropToolEvents) { + return; + } + + // 查找对应的工具 action + const action = this.toolFindAction(data.id, data.preparing_id, data.execution_id); + + if (action && action.tool) { + const newIntent = data.intent || ''; + + // 如果 intent 没有变化,跳过 + if (action.tool.intent_full === newIntent) { + return; + } + + // 停止之前的打字机效果 + if (action.tool._intentTyping) { + action.tool._intentTyping = false; + if (action.tool._intentTimer) { + clearTimeout(action.tool._intentTimer); + action.tool._intentTimer = null; } + } - const eventType = event.type; - const eventData = event.data || {}; - const eventIdx = event.idx; - if ( - eventType === 'system_message' || - eventType === 'sub_agent_waiting' || - (eventType === 'user_message' && eventData?.sub_agent_notice) - ) { - debugNotifyLog('[DEBUG_NOTIFY][event] 捕获关键事件', { - eventType, - eventIdx, - eventData - }); - keyNotifyLog('[DEBUG_NOTIFY_KEY][event] key-event', { - eventType, - idx: eventIdx, - task_id: eventData?.task_id, - sub_agent_notice: !!eventData?.sub_agent_notice, - has_running_sub_agents: eventData?.has_running_sub_agents, - has_running_background_commands: eventData?.has_running_background_commands - }); - } + // 更新完整 intent + action.tool.intent_full = newIntent; - // 事件去重检查 - if (typeof eventIdx === 'number') { - if (!this._processedEventIndices) { - this._processedEventIndices = new Set(); - } + // 判断是否是历史恢复:只有从头重建时才直接显示 + const isHistoryRestore = this._rebuildingFromScratch; - if (this._processedEventIndices.has(eventIdx)) { - debugLog(`[TaskPolling] 跳过重复事件 #${eventIdx}`); - return; - } + if (isHistoryRestore) { + // 历史恢复,直接显示完整 intent + action.tool.intent_rendered = newIntent; + debugLog('[TaskPolling] 历史恢复,直接显示 intent'); + } else { + // 新工具块,逐字符显示 + debugLog('[TaskPolling] 新工具块,开始打字机效果'); + action.tool.intent_rendered = ''; + action.tool._intentTyping = true; - this._processedEventIndices.add(eventIdx); - - // 限制 Set 大小(保留最近 1000 个) - if (this._processedEventIndices.size > 1000) { - const firstIdx = Math.min(...this._processedEventIndices); - this._processedEventIndices.delete(firstIdx); - } - } - - // 检查事件的 conversation_id 是否匹配当前对话 - // 如果不匹配,忽略该事件(避免切换对话后旧任务的事件显示到新对话中) - const crossConversationAllowed = new Set([ - 'shallow_compression', - 'conversation_resolved', - 'compression_finished' - ]); - if (!crossConversationAllowed.has(eventType) && eventData.conversation_id && this.currentConversationId) { - if (eventData.conversation_id !== this.currentConversationId) { - console.log(`[DEBUG_AWAITING] 忽略不匹配的事件`, { - eventType, - eventIdx, - eventConversationId: eventData.conversation_id, - currentConversationId: this.currentConversationId - }); - debugLog(`[TaskPolling] 忽略不匹配的事件 #${eventIdx}: ${eventType}, 事件对话=${eventData.conversation_id}, 当前对话=${this.currentConversationId}`); - return; - } - } - - debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData); - - console.log(`[DEBUG_AWAITING] 处理事件`, { - eventType, - eventIdx, - eventConversationId: eventData.conversation_id, - currentConversationId: this.currentConversationId - }); - - // 根据事件类型调用对应的处理方法 - switch (eventType) { - case 'ai_message_start': - this.handleAiMessageStart(eventData, eventIdx); - break; - - case 'thinking_start': - this.handleThinkingStart(eventData, eventIdx); - break; - - case 'thinking_chunk': - this.handleThinkingChunk(eventData, eventIdx); - break; - - case 'thinking_end': - this.handleThinkingEnd(eventData, eventIdx); - break; - - case 'text_start': - this.handleTextStart(eventData, eventIdx); - break; - - case 'text_chunk': - this.handleTextChunk(eventData, eventIdx); - break; - - case 'text_end': - this.handleTextEnd(eventData, eventIdx); - break; - - case 'tool_preparing': - this.handleToolPreparing(eventData, eventIdx); - break; - - case 'tool_start': - this.handleToolStart(eventData, eventIdx); - break; - - case 'tool_intent': - this.handleToolIntent(eventData, eventIdx); - break; - - case 'tool_update_action': - case 'update_action': - this.handleToolUpdateAction(eventData, eventIdx); - break; - - case 'append_payload': - this.handleAppendPayload(eventData, eventIdx); - break; - - case 'modify_payload': - this.handleModifyPayload(eventData, eventIdx); - break; - - case 'task_complete': - this.handleTaskComplete(eventData, eventIdx); - break; - - case 'task_stopped': - this.handleTaskStopped(eventData, eventIdx); - break; - - case 'error': - this.handleTaskError(eventData, eventIdx); - break; - - case 'token_update': - this.handleTokenUpdate(eventData, eventIdx); - break; - - case 'conversation_changed': - this.handleConversationChanged(eventData, eventIdx); - break; - - case 'conversation_resolved': - this.handleConversationResolved(eventData, eventIdx); - break; - case 'compression_state': - this.handleCompressionState(eventData, eventIdx); - break; - case 'compression_finished': - this.handleCompressionFinished(eventData, eventIdx); - break; - case 'shallow_compression': - this.handleShallowCompression(eventData, eventIdx); - break; - - case 'user_message': - debugNotifyLog('[DEBUG_NOTIFY][event] dispatch:user_message', { - idx: eventIdx, - data: eventData - }); - this.handleUserMessage(eventData, eventIdx); - break; - - case 'system_message': - this.handleSystemMessage(eventData, eventIdx); - break; - - default: - debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); - } - - // 注意:不要在这里清除 _rebuildingFromScratch 标记 - // 该标记应该在恢复完所有历史事件后才清除 - }, - - handleAiMessageStart(data: any, eventIdx: number) { - const lastMessage = this.messages[this.messages.length - 1]; - console.log('[DEBUG_AWAITING] ===== handleAiMessageStart =====', { - eventIdx, - lastMessageRole: lastMessage?.role, - lastMessageAwaitingFirstContent: lastMessage?.awaitingFirstContent, - lastMessageGeneratingLabel: lastMessage?.generatingLabel - }); - - debugLog('[TaskPolling] AI消息开始, idx:', eventIdx); - console.log('[AiMessageStart] 开始处理', { - eventIdx, - messagesLength: this.messages.length, - lastMessageRole: this.messages[this.messages.length - 1]?.role, - streamingMessage: this.streamingMessage, - taskInProgress: this.taskInProgress, - currentConversationId: this.currentConversationId - }); - - if (this.waitingForSubAgent) { - this.waitingForSubAgent = false; - } - - // 检查是否已经有 assistant 消息 - const hasAssistantMessage = lastMessage && lastMessage.role === 'assistant'; - - console.log('[AiMessageStart] 最后一条消息状态', { - hasAssistantMessage, - lastMessageActionsLength: lastMessage?.actions?.length || 0, - lastMessageAwaitingFirstContent: lastMessage?.awaitingFirstContent, - lastMessageGeneratingLabel: lastMessage?.generatingLabel - }); - - // 判断是否是刷新恢复的情况: - // 1. 有assistant消息 - // 2. 且该消息正在streaming或者没有任何内容(说明是刚创建的) - // 3. 且不是历史加载完成的消息(历史消息的awaitingFirstContent应该是false) - const isRefreshRestore = hasAssistantMessage && - (this.streamingMessage || !lastMessage.actions || lastMessage.actions.length === 0) && - (lastMessage.awaitingFirstContent === true || this.taskInProgress); - - console.log('[AiMessageStart] 场景判断', { - isRefreshRestore, - streamingMessage: this.streamingMessage, - hasActions: !!(lastMessage?.actions && lastMessage.actions.length > 0), - awaitingFirstContent: lastMessage?.awaitingFirstContent, - taskInProgress: this.taskInProgress - }); - - if (isRefreshRestore) { - console.log('[AiMessageStart] 场景=刷新恢复,复用现有assistant消息'); - debugLog('[TaskPolling] 刷新恢复场景,复用现有 assistant 消息'); - // 只更新状态,不创建新消息 - this.taskInProgress = true; - this.stopRequested = false; - this.streamingMessage = true; - - // 确保 awaitingFirstContent 被正确设置 - const hasContent = lastMessage.actions && lastMessage.actions.length > 0; - if (!hasContent) { - lastMessage.awaitingFirstContent = true; - lastMessage.generatingLabel = lastMessage.generatingLabel || '思考中...'; - console.log('[AiMessageStart] 设置等待动画', { - awaitingFirstContent: true, - generatingLabel: lastMessage.generatingLabel - }); - } else { - // 如果已有内容,确保等待动画不显示 - lastMessage.awaitingFirstContent = false; - lastMessage.generatingLabel = ''; - console.log('[AiMessageStart] 关闭等待动画(已有内容)'); - } - return; - } - - // 其他情况:创建新的 assistant 消息 - console.log('[AiMessageStart] 场景=创建新消息'); - debugLog('[TaskPolling] 创建新的 assistant 消息'); - this.monitorResetSpeech(); - this.cleanupStaleToolActions(); - this.taskInProgress = true; - this.chatStartAssistantMessage(); - this.stopRequested = false; - this.streamingMessage = true; - - const newMessage = this.messages[this.messages.length - 1]; - console.log('[AiMessageStart] 新消息创建完成', { - role: newMessage?.role, - awaitingFirstContent: newMessage?.awaitingFirstContent, - generatingLabel: newMessage?.generatingLabel, - actionsLength: newMessage?.actions?.length || 0, - messagesLength: this.messages.length - }); - - // 如果是从头重建,标记消息为静默恢复 - if (this._rebuildingFromScratch) { - if (newMessage && newMessage.role === 'assistant') { - debugLog('[TaskPolling] 标记消息为静默恢复(从头重建)'); - newMessage.awaitingFirstContent = false; - newMessage.generatingLabel = ''; - console.log('[AiMessageStart] 从头重建,关闭等待动画'); - } - } - - // 强制触发Vue响应式更新 - this.$forceUpdate(); - console.log('[AiMessageStart] 已调用$forceUpdate'); - - this.$nextTick(() => { - console.log('[AiMessageStart] nextTick回调执行'); - this.scrollToBottom(); - }); - }, - - handleThinkingStart(data: any, eventIdx: number) { - console.log('[ThinkingStart] 开始处理', { eventIdx }); - debugLog('[TaskPolling] 思考开始, idx:', eventIdx); - const ignoreThinking = this.runMode === 'fast' || this.thinkingMode === false; - if (ignoreThinking) { - this.monitorEndModelOutput(); - console.log('[ThinkingStart] 忽略思考(fast模式或关闭思考)'); - return; - } - - // 防御性检查:如果没有assistant消息,先创建一个 - const lastMessage = this.messages[this.messages.length - 1]; - if (!lastMessage || lastMessage.role !== 'assistant') { - console.log('[DEBUG_AWAITING] ThinkingStart 检测到缺少assistant消息,自动创建'); - this.chatStartAssistantMessage(); - } - - this.monitorShowThinking(); - - const result = this.chatStartThinkingAction(); - - if (result && result.blockId) { - const blockId = result.blockId; - - const lastMessage = this.messages[this.messages.length - 1]; - - // 有内容了,关闭等待动画 - if (lastMessage && lastMessage.role === 'assistant') { - console.log('[ThinkingStart] 关闭等待动画', { - beforeAwaitingFirstContent: lastMessage.awaitingFirstContent, - beforeGeneratingLabel: lastMessage.generatingLabel - }); - lastMessage.awaitingFirstContent = false; - lastMessage.generatingLabel = ''; - } - - // 只在非历史恢复时展开思考块 - if (!this._rebuildingFromScratch) { - this.chatExpandBlock(blockId); - } - - this.scrollToBottom(); - this.chatSetThinkingLock(blockId, true); - - // 强制触发 actions 数组的响应式更新 - if (lastMessage && lastMessage.actions) { - lastMessage.actions = [...lastMessage.actions]; - } + // 计算每个字符的间隔时间 + // 总时长1秒,但最少0.5秒 + const totalDuration = Math.max(500, Math.min(1000, newIntent.length * 50)); + const charInterval = totalDuration / newIntent.length; + let charIndex = 0; + const typeNextChar = () => { + if (charIndex < newIntent.length && action.tool._intentTyping) { + action.tool.intent_rendered += newIntent[charIndex]; + charIndex++; this.$forceUpdate(); - } - }, - - handleThinkingChunk(data: any, eventIdx: number) { - if (this.runMode === 'fast' || this.thinkingMode === false) { - return; - } - const thinkingAction = this.chatAppendThinkingChunk(data.content); - if (thinkingAction) { + action.tool._intentTimer = setTimeout(typeNextChar, charInterval); + } else { + action.tool._intentTyping = false; + action.tool.intent_rendered = newIntent; // 确保完整 + action.tool._intentTimer = null; this.$forceUpdate(); - this.$nextTick(() => { - if (thinkingAction && thinkingAction.blockId) { - this.scrollThinkingToBottom(thinkingAction.blockId); - } - this.conditionalScrollToBottom(); - }); - } - this.monitorShowThinking(); - }, - - handleThinkingEnd(data: any) { - debugLog('[TaskPolling] 思考结束'); - if (this.runMode === 'fast' || this.thinkingMode === false) { - return; - } - const blockId = this.chatCompleteThinkingAction(data.full_content); - if (blockId) { - // 解锁思考块 - this.chatSetThinkingLock(blockId, false); - - // 只在非历史恢复时延迟折叠思考块 - if (!this._rebuildingFromScratch) { - // 延迟折叠思考块(给用户一点时间看到思考完成) - setTimeout(() => { - this.chatCollapseBlock(blockId); - this.$forceUpdate(); - }, 1000); - } else { - // 历史恢复时立即折叠,不需要动画 - this.chatCollapseBlock(blockId); - } - - this.$nextTick(() => this.scrollThinkingToBottom(blockId)); - } - this.$forceUpdate(); - this.monitorEndModelOutput(); - }, - - handleTextStart(data: any) { - console.log('[TextStart] 开始处理'); - debugLog('[TaskPolling] 文本开始'); - - // 防御性检查:如果没有assistant消息,先创建一个 - const lastMessage = this.messages[this.messages.length - 1]; - if (!lastMessage || lastMessage.role !== 'assistant') { - console.log('[DEBUG_AWAITING] TextStart 检测到缺少assistant消息,自动创建'); - this.chatStartAssistantMessage(); - } - - this.chatStartTextAction(); - - // 有内容了,关闭等待动画 - const currentMessage = this.messages[this.messages.length - 1]; - if (currentMessage && currentMessage.role === 'assistant') { - console.log('[TextStart] 关闭等待动画', { - beforeAwaitingFirstContent: currentMessage.awaitingFirstContent, - beforeGeneratingLabel: currentMessage.generatingLabel - }); - currentMessage.awaitingFirstContent = false; - currentMessage.generatingLabel = ''; - } - - this.$forceUpdate(); - }, - - handleTextChunk(data: any) { - if (data && typeof data.content === 'string' && data.content.length) { - this.chatAppendTextChunk(data.content); - this.$forceUpdate(); - this.conditionalScrollToBottom(); - const speech = data.content.replace(/\r/g, ''); - if (speech) { - this.monitorShowSpeech(speech); - } - } - }, - - handleTextEnd(data: any) { - debugLog('[TaskPolling] 文本结束'); - const full = data?.full_content || ''; - this.chatCompleteTextAction(full); - this.$forceUpdate(); - this.monitorEndModelOutput(); - }, - - handleToolPreparing(data: any) { - console.log('[ToolPreparing] 开始处理', { name: data.name, id: data.id }); - debugLog('[TaskPolling] 工具准备中:', data.name); - - if (this.dropToolEvents) { - console.log('[ToolPreparing] dropToolEvents=true,跳过'); - return; - } - - const msg = this.chatEnsureAssistantMessage(); - if (!msg) { - console.log('[ToolPreparing] 无法获取assistant消息'); - return; - } - - if (msg.awaitingFirstContent) { - console.log('[ToolPreparing] 关闭等待动画', { - beforeAwaitingFirstContent: msg.awaitingFirstContent, - beforeGeneratingLabel: msg.generatingLabel - }); - msg.awaitingFirstContent = false; - msg.generatingLabel = ''; - } - - const action = { - id: data.id, - type: 'tool', - tool: { - id: data.id, - name: data.name, - arguments: {}, - argumentSnapshot: null, - argumentLabel: '', - status: 'preparing', - result: null, - message: data.message || `准备调用 ${data.name}...`, - intent_full: data.intent || '', - intent_rendered: data.intent || '' - }, - timestamp: Date.now() + } }; - msg.actions.push(action); - this.preparingTools.set(data.id, action); - this.toolRegisterAction(action, data.id); - this.toolTrackAction(data.name, action); + action.tool._intentTimer = setTimeout(typeNextChar, 50); // 延迟50ms开始 + } - this.$forceUpdate(); - this.conditionalScrollToBottom(); - - if (this.monitorPreviewTool) { - this.monitorPreviewTool(data); - } - }, - - handleToolStart(data: any) { - debugLog('[TaskPolling] 工具开始:', data.name); - - if (this.dropToolEvents) { - return; - } - - let action = null; - if (data.preparing_id && this.preparingTools.has(data.preparing_id)) { - action = this.preparingTools.get(data.preparing_id); - this.preparingTools.delete(data.preparing_id); - } else { - action = this.toolFindAction(data.id, data.preparing_id, data.execution_id); - } - - if (!action) { - const msg = this.chatEnsureAssistantMessage(); - if (!msg) { - return; - } - action = { - id: data.id, - type: 'tool', - tool: { - id: data.id, - name: data.name, - arguments: {}, - argumentSnapshot: null, - argumentLabel: '', - status: 'running', - result: null - }, - timestamp: Date.now() - }; - msg.actions.push(action); - } - - action.tool.status = 'running'; - action.tool.arguments = data.arguments; - action.tool.argumentSnapshot = this.cloneToolArguments(data.arguments); + // 更新 arguments 和 label + if (action.tool.arguments) { + action.tool.arguments.intent = newIntent; + action.tool.argumentSnapshot = this.cloneToolArguments(action.tool.arguments); action.tool.argumentLabel = this.buildToolLabel(action.tool.argumentSnapshot); - action.tool.message = null; - action.tool.id = data.id; - action.tool.executionId = data.id; + } - this.toolRegisterAction(action, data.id); - this.toolTrackAction(data.name, action); + this.$forceUpdate(); + debugLog('[TaskPolling] 已更新工具意图:', data.name); + } else { + debugLog('[TaskPolling] 未找到对应的工具 action:', data.id); + } + }, + + handleToolUpdateAction(data: any) { + if (this.dropToolEvents) { + return; + } + + debugLog('[TaskPolling] 更新action:', data.id, 'status:', data.status); + + let targetAction = this.toolFindAction(data.id, data.preparing_id, data.execution_id); + if (!targetAction && data.preparing_id && this.preparingTools.has(data.preparing_id)) { + targetAction = this.preparingTools.get(data.preparing_id); + } + + if (!targetAction) { + return; + } + + if (data.status) { + targetAction.tool.status = data.status; + } + if (data.result !== undefined) { + targetAction.tool.result = data.result; + } + if (data.message !== undefined) { + targetAction.tool.message = data.message; + } + if (data.content !== undefined) { + targetAction.tool.content = data.content; + } + + this.$forceUpdate(); + this.conditionalScrollToBottom(); + + if (this.monitorResolveTool && data.status === 'completed') { + this.monitorResolveTool(data); + } + }, + + handleAppendPayload(data: any) { + debugLog('[TaskPolling] 文件追加:', data.path); + this.chatAddAppendPayloadAction(data); + this.$forceUpdate(); + this.conditionalScrollToBottom(); + }, + + handleModifyPayload(data: any) { + debugLog('[TaskPolling] 文件修改:', data.path); + this.chatAddModifyPayloadAction(data); + this.$forceUpdate(); + this.conditionalScrollToBottom(); + }, + + markLatestUserWorkCompleted() { + if (!Array.isArray(this.messages) || this.messages.length === 0) { + return; + } + for (let i = this.messages.length - 1; i >= 0; i -= 1) { + const msg = this.messages[i]; + if (!msg || msg.role !== 'user') { + continue; + } + msg.metadata = msg.metadata || {}; + msg.metadata.work_timer = msg.metadata.work_timer || {}; + const timer = msg.metadata.work_timer; + if (timer.status === 'completed' && typeof timer.duration_ms === 'number') { + return; + } + const nowIso = new Date().toISOString(); + const startedAt = timer.started_at || msg.timestamp || nowIso; + const startMs = Date.parse(startedAt); + const endMs = Date.now(); + const durationMs = Number.isFinite(startMs) ? Math.max(0, endMs - startMs) : 0; + timer.status = 'completed'; + timer.started_at = startedAt; + timer.finished_at = nowIso; + timer.duration_ms = durationMs; + return; + } + }, + + handleTaskComplete(data: any) { + const hasRunningSubAgents = !!data?.has_running_sub_agents; + const hasRunningBackgroundCommands = !!data?.has_running_background_commands; + if (hasRunningSubAgents) { + debugLog('[TaskPolling] 任务完成,但仍有后台子智能体运行'); + } else { + debugLog('[TaskPolling] 任务完成'); + } + + // 同步处理状态更新 + this.streamingMessage = false; + this.stopRequested = false; + if (!hasRunningSubAgents) { + this.markLatestUserWorkCompleted(); + } + + if (hasRunningSubAgents) { + this.taskInProgress = true; + this.waitingForSubAgent = true; + this.waitingForBackgroundCommand = hasRunningBackgroundCommands; + // 关键修复:主任务结束后将切到新的通知任务,先清空旧任务事件索引去重表。 + this.clearProcessedEvents(); + this.startWaitingTaskProbe(); + } else { + this.taskInProgress = false; + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + this.stopWaitingTaskProbe(); + this.clearTaskState(); // 清理任务状态 + } + + this.$forceUpdate(); + + // 只更新统计,不重新加载历史 + if (this.currentConversationId) { + setTimeout(() => { + this.fetchConversationTokenStatistics(); + this.updateCurrentContextTokens(); + }, 500); + } + }, + + handleTaskStopped(data: any, eventIdx: number) { + debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data); + + this.markLatestUserWorkCompleted(); + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + this.stopWaitingTaskProbe(); + + if (typeof this.clearPendingTools === 'function') { + this.clearPendingTools('task_stopped'); + } + this.$forceUpdate(); + + this.clearTaskState(); + }, + + // 新增统一清理方法 + clearTaskState() { + this.stopWaitingTaskProbe(); + (async () => { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + taskStore.clearTask(); // 使用 clearTask 而不是 stopPolling + })(); + + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } + }, + + handleUserMessage(data: any) { + const message = (data?.message || data?.content || '').trim(); + if (!message) { + debugNotifyLog('[DEBUG_NOTIFY][ui] handleUserMessage:empty', { data }); + return; + } + debugLog('[TaskPolling] 收到用户消息事件'); + debugNotifyLog('[DEBUG_NOTIFY][ui] handleUserMessage', { + messagePreview: message.slice(0, 120), + sub_agent_notice: !!data?.sub_agent_notice, + background_command_notice: !!data?.background_command_notice, + task_id: data?.task_id, + has_running_sub_agents: data?.has_running_sub_agents, + has_running_background_commands: data?.has_running_background_commands + }); + keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] handleUserMessage', { + messagePreview: message.slice(0, 80), + task_id: data?.task_id, + sub_agent_notice: !!data?.sub_agent_notice, + background_command_notice: !!data?.background_command_notice + }); + this.chatAddUserMessage(message, data?.images || [], data?.videos || []); + this.taskInProgress = true; + this.streamingMessage = false; + this.stopRequested = false; + if (data?.sub_agent_notice) { + if (typeof data?.has_running_sub_agents === 'boolean') { + this.waitingForSubAgent = data.has_running_sub_agents; + } else if (typeof data?.remaining_count === 'number') { + this.waitingForSubAgent = data.remaining_count > 0; + } + if (typeof data?.has_running_background_commands === 'boolean') { + this.waitingForBackgroundCommand = data.has_running_background_commands; + } else if (data?.background_command_notice) { + this.waitingForBackgroundCommand = this.waitingForSubAgent; + } + if (this.waitingForSubAgent) { + this.startWaitingTaskProbe(); + } else { + this.stopWaitingTaskProbe(); + } + } + this.$forceUpdate(); + this.conditionalScrollToBottom(); + }, + + handleSystemMessage(data: any) { + const content = (data?.content || data?.message || '').trim(); + console.log('[DEBUG_SYSTEM][polling] 收到 system_message 事件', { + raw: data, + normalizedContent: content, + currentConversationId: this.currentConversationId + }); + if (!content) { + console.log('[DEBUG_SYSTEM][polling] system_message 内容为空,跳过'); + return; + } + this.appendSystemAction(content); + }, + + handleTaskError(data: any) { + const shouldRetry = Boolean(data?.retry); + if (shouldRetry) { + const retryIn = Number(data?.retry_in) || 5; + const attempt = Number(data?.attempt) || 1; + const maxAttempts = Number(data?.max_attempts) || attempt; + + debugLog('[TaskPolling] API错误,等待自动重试', { + retryIn, + attempt, + maxAttempts, + message: data?.message + }); + + this.stopRequested = false; + this.taskInProgress = true; + this.streamingMessage = true; + this.$forceUpdate(); + + this.uiPushToast({ + title: '即将重试', + message: `将在 ${retryIn} 秒后重试(第 ${attempt}/${maxAttempts} 次)`, + type: 'info', + duration: Math.max(retryIn, 1) * 1000 + }); + return; + } + + const errorMessage = data.message || '未知错误'; + const errorType = data.error_type || 'unknown'; + + let title = '任务执行失败'; + let message = errorMessage; + + // 根据错误类型提供友好提示 + if (errorType === 'api_error') { + title = 'API 调用失败'; + message = `模型服务异常:${errorMessage}`; + } else if (errorType === 'timeout') { + title = '任务超时'; + message = '任务执行时间过长,已自动停止'; + } else if (errorType === 'quota_exceeded') { + title = '配额不足'; + message = '您的使用配额已用尽'; + } + + console.error('[TaskPolling] 任务错误:', data.message); + this.uiPushToast({ + title, + message, + type: 'error', + duration: 8000 + }); + + // 清理状态 + this.markLatestUserWorkCompleted(); + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + this.$forceUpdate(); + + // 停止轮询 + (async () => { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + taskStore.stopPolling(); + })(); + }, + + handleTokenUpdate(data: any) { + if (data.conversation_id === this.currentConversationId) { + this.currentConversationTokens.cumulative_input_tokens = data.cumulative_input_tokens || 0; + this.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0; + this.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0; + + if (typeof data.current_context_tokens === 'number') { + this.resourceSetCurrentContextTokens(data.current_context_tokens); + } else { + this.updateCurrentContextTokens(); + } + + this.$forceUpdate(); + } + }, + + handleConversationChanged(data: any, eventIdx: number) { + debugLog('[TaskPolling] 对话标题已更新, idx:', eventIdx, data); + + if (data && data.conversation_id === this.currentConversationId) { + // 更新当前对话标题 + if (data.title) { + this.currentConversationTitle = data.title; + debugLog('[TaskPolling] 更新当前对话标题:', data.title); + } + + // 更新对话列表中的标题 + if (data.title && Array.isArray(this.conversations)) { + const conv = this.conversations.find((c) => c && c.id === data.conversation_id); + if (conv) { + conv.title = data.title; + debugLog('[TaskPolling] 更新对话列表中的标题:', data.title); + } + } + + this.$forceUpdate(); + } + }, + + handleConversationResolved(data: any) { + if (data && data.conversation_id) { + this.currentConversationId = data.conversation_id; + if (data.title) { + this.currentConversationTitle = data.title; + } + this.promoteConversationToTop(data.conversation_id); + + const pathFragment = this.stripConversationPrefix(data.conversation_id); + const currentPath = window.location.pathname.replace(/^\/+/, ''); + if (data.created) { + history.pushState({ conversationId: data.conversation_id }, '', `/${pathFragment}`); + } else if (currentPath !== pathFragment) { + history.replaceState({ conversationId: data.conversation_id }, '', `/${pathFragment}`); + } + } + }, + + handleCompressionState(data: any) { + if (!data || typeof data !== 'object') { + return; + } + const wasInProgress = !!this.compressionInProgress; + this.compressionInProgress = !!data.in_progress; + this.compressionMode = data.mode || ''; + this.compressionStage = data.stage || ''; + if (this.compressionInProgress && !wasInProgress) { + const modeLabel = this.compressionMode === 'manual' ? '手动' : '自动'; + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + this.compressionToastId = this.uiPushToast({ + title: '压缩中', + message: `对话正在${modeLabel}压缩,请稍候…`, + type: 'info', + duration: null, + closable: false + }); + } + if (!this.compressionInProgress) { + this.compressionError = data.error || ''; + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + } + }, + + async handleCompressionFinished(data: any) { + if (this.compressionToastId) { + this.uiDismissToast(this.compressionToastId); + this.compressionToastId = null; + } + this.compressionInProgress = false; + this.compressionMode = ''; + this.compressionStage = ''; + this.compressionError = ''; + const newId = data?.conversation_id; + if (newId && newId !== this.currentConversationId) { + await this.loadConversation(newId, { force: true }); + } + this.conversationsOffset = 0; + if (typeof this.loadConversationsList === 'function') { + await this.loadConversationsList(); + } + this.uiPushToast({ + title: '压缩完成', + message: '已自动切换到压缩后的新对话', + type: 'success', + duration: 2400 + }); + }, + + handleShallowCompression(data: any, eventIdx?: number) { + const count = Number(data?.compressed_count || 0); + if (count <= 0) { + return; + } + debugLog('[TaskPolling] 自动浅层压缩触发, idx:', eventIdx, data); + this.uiPushToast({ + title: '自动浅层压缩', + message: `已自动压缩 ${count} 条较早工具结果`, + type: 'info', + duration: 2500 + }); + }, + + /** + * 清理已处理的事件索引 + */ + clearProcessedEvents() { + if (this._processedEventIndices) { + this._processedEventIndices.clear(); + } + }, + + /** + * 恢复任务状态(页面刷新后调用) + */ + async restoreTaskState() { + // 清理已处理的事件索引 + this.clearProcessedEvents(); + + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + + // 如果已经在流式输出中,不重复恢复 + if (this.streamingMessage || this.taskInProgress) { + debugLog('[TaskPolling] 任务已在进行中,跳过恢复', { + streamingMessage: this.streamingMessage, + taskInProgress: this.taskInProgress, + currentConversationId: this.currentConversationId + }); + return; + } + + // 查找运行中的任务 + const runningTask = await taskStore.loadRunningTask(this.currentConversationId); + + if (!runningTask) { + debugLog('[TaskPolling] 没有运行中的任务', { + currentConversationId: this.currentConversationId + }); + await this.restoreSubAgentWaitingState(); + return; + } + + debugLog('[TaskPolling] 发现运行中的任务,开始恢复状态', { + taskId: runningTask?.task_id, + status: runningTask?.status, + conversationId: runningTask?.conversation_id + }); + + // 检查历史是否已加载 + const hasMessages = Array.isArray(this.messages) && this.messages.length > 0; + + if (!hasMessages) { + debugLog('[TaskPolling] 历史未加载,等待历史加载完成'); + setTimeout(() => { + this.restoreTaskState(); + }, 500); + return; + } + + debugLog('[TaskPolling] 历史已加载,开始精细恢复'); + + // 获取任务的所有事件 + const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`); + if (!detailResponse.ok) { + debugLog('[TaskPolling] 获取任务详情失败'); + return; + } + + const detailResult = await detailResponse.json(); + if (!detailResult.success || !detailResult.data.events) { + debugLog('[TaskPolling] 任务详情无效'); + return; + } + + const allEvents = detailResult.data.events; + debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`); + + // 找到最后一条消息 + const lastMessage = this.messages[this.messages.length - 1]; + const isAssistantMessage = lastMessage && lastMessage.role === 'assistant'; + + // console.log('[TaskPolling] 最后一条消息:', { + // exists: !!lastMessage, + // role: lastMessage?.role, + // actionsCount: lastMessage?.actions?.length || 0, + // isAssistant: isAssistantMessage + // }); + + // 检查是否需要从头重建 + // 1. 最后一条不是 assistant 消息 + // 2. 最后一条是空的 assistant 消息 + // 3. 事件数量远大于历史中的 actions 数量(说明历史不完整) + const historyActionsCount = lastMessage?.actions?.length || 0; + const eventCount = allEvents.length; + const historyIncomplete = eventCount > historyActionsCount + 5; // 允许5个事件的误差 + + const needsRebuild = + !isAssistantMessage || + (isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) || + historyIncomplete; + + if (needsRebuild) { + // if (historyIncomplete) { + // console.log('[TaskPolling] 历史不完整,从头重建:', { + // historyActionsCount, + // eventCount, + // diff: eventCount - historyActionsCount + // }); + // } + debugLog('[TaskPolling] 需要从头重建 assistant 响应'); + + // 清空所有消息,准备从头重建 + // 保留用户消息,只删除最后的 assistant 消息 + if (isAssistantMessage) { + debugLog('[TaskPolling] 删除不完整的 assistant 消息'); + this.messages.pop(); + } + + this.streamingMessage = true; + this.taskInProgress = true; this.$forceUpdate(); + + // 重置偏移量为 0,从头获取所有事件来重建 assistant 消息 + taskStore.lastEventIndex = 0; + debugLog('[TaskPolling] 重置偏移量为 0,从头开始轮询'); + + // 标记正在从头重建,用于后续处理 + this._rebuildingFromScratch = true; + this._rebuildingEventCount = allEvents.length; // 记录当前事件总数 + + (window as any).__taskEventHandler = (event: any) => { + this.handleTaskEvent(event); + }; + + taskStore.startPolling((event: any) => { + this.handleTaskEvent(event); + }); + + // 延迟清除重建标记,确保所有历史事件都处理完毕 + setTimeout(() => { + debugLog('[TaskPolling] 历史事件处理完毕,清除重建标记'); + this._rebuildingFromScratch = false; + this._rebuildingEventCount = 0; + }, 2000); + + return; + } + + // 分析事件,找到当前正在进行的操作 + let inThinking = false; + let inText = false; + + for (let i = 0; i < allEvents.length; i++) { + const event = allEvents[i]; + + if (event.type === 'thinking_start') { + inThinking = true; + } else if (event.type === 'thinking_end') { + inThinking = false; + } + + if (event.type === 'text_start') { + inText = true; + } else if (event.type === 'text_end') { + inText = false; + } + } + + // console.log('[TaskPolling] 分析结果:', { + // inThinking, + // inText, + // totalEvents: allEvents.length, + // lastEventType: allEvents[allEvents.length - 1]?.type, + // lastEventIdx: allEvents[allEvents.length - 1]?.idx + // }); + + // 恢复思考块状态 + if (lastMessage.actions) { + // console.log('[TaskPolling] 历史中的 actions 详情:', lastMessage.actions.map((a, idx) => ({ + // index: idx, + // type: a.type, + // id: a.id, + // hasContent: !!a.content, + // contentLength: a.content?.length || 0, + // toolName: a.tool?.name, + // hasBlockId: !!a.blockId, + // blockId: a.blockId, + // collapsed: a.collapsed, + // streaming: a.streaming + // }))); + + const thinkingActions = lastMessage.actions.filter((a) => a.type === 'thinking'); + // console.log('[TaskPolling] 思考块数量:', thinkingActions.length); + + if (inThinking && thinkingActions.length > 0) { + // 正在思考中,检查最后一个思考块是否正在流式输出 + const lastThinking = thinkingActions[thinkingActions.length - 1]; + + // 只有当思考块正在流式输出时才设置锁定状态(但不展开) + if (lastThinking.streaming && lastThinking.blockId) { + // console.log('[TaskPolling] 找到正在流式输出的思考块,设置锁定状态:', lastThinking.blockId); + this.$nextTick(() => { + this.chatSetThinkingLock(lastThinking.blockId, true); + }); + } + } + + // 确保所有思考块都是折叠状态 + for (const thinking of thinkingActions) { + if (thinking.blockId) { + thinking.collapsed = true; + } + } + + // 检查思考块状态(在设置之后)(已禁用) + // thinkingActions.forEach((thinking, idx) => { + // console.log(`[TaskPolling] 思考块 ${idx} (设置后):`, { + // hasBlockId: !!thinking.blockId, + // blockId: thinking.blockId, + // collapsed: thinking.collapsed, + // contentLength: thinking.content?.length || 0 + // }); + // }); + + // 恢复文本块状态 + const textActions = lastMessage.actions.filter((a) => a.type === 'text'); + // console.log('[TaskPolling] 文本块数量:', textActions.length); + + if (inText && textActions.length > 0) { + const lastText = textActions[textActions.length - 1]; + // console.log('[TaskPolling] 标记文本块为流式状态'); + lastText.streaming = true; + } + + // 注册历史中的工具块到 toolActionIndex + // 这样后续的 update_action 事件可以找到对应的块进行状态更新 + const toolActions = lastMessage.actions.filter((a) => a.type === 'tool'); + // console.log('[TaskPolling] 工具块数量:', toolActions.length); + + for (const toolAction of toolActions) { + if (toolAction.tool && toolAction.tool.id) { + // console.log('[TaskPolling] 注册工具块:', { + // id: toolAction.tool.id, + // name: toolAction.tool.name, + // status: toolAction.tool.status + // }); + // 注册到 toolActionIndex + this.toolRegisterAction(toolAction, toolAction.tool.id); + // 如果有 executionId,也注册 + if (toolAction.tool.executionId) { + this.toolRegisterAction(toolAction, toolAction.tool.executionId); + } + // 追踪工具调用 + if (toolAction.tool.name) { + this.toolTrackAction(toolAction.tool.name, toolAction); + } + } + } + } + + // 标记状态为进行中 + this.streamingMessage = true; + this.taskInProgress = true; + + // 设置 currentMessageIndex 指向最后一条 assistant 消息 + // 这样后续添加的 action 会添加到正确的消息中 + const lastMessageIndex = this.messages.length - 1; + if (lastMessage && lastMessage.role === 'assistant') { + this.currentMessageIndex = lastMessageIndex; + // console.log('[TaskPolling] 设置 currentMessageIndex 为:', lastMessageIndex); + } + + // 强制更新界面 + this.$forceUpdate(); + + // 滚动到底部 + this.$nextTick(() => { this.conditionalScrollToBottom(); + }); - if (this.monitorQueueTool) { - this.monitorQueueTool(data); + // 注册事件处理器到全局 + (window as any).__taskEventHandler = (event: any) => { + this.handleTaskEvent(event); + }; + + // 启动轮询(从当前偏移量开始,只处理新事件) + debugLog('[TaskPolling] 启动轮询,起始偏移量:', taskStore.lastEventIndex); + + taskStore.startPolling((event: any) => { + this.handleTaskEvent(event); + }); + + this.uiPushToast({ + title: '任务恢复', + message: '检测到进行中的任务,已恢复连接', + type: 'info', + duration: 3000 + }); + } catch (error) { + console.error('[TaskPolling] 恢复任务状态失败:', error); + } + }, + + /** + * 恢复子智能体等待状态(页面刷新后调用) + */ + async restoreSubAgentWaitingState(retry = 0) { + try { + if (!this.currentConversationId) { + if (retry < 5) { + setTimeout(() => { + this.restoreSubAgentWaitingState(retry + 1); + }, 300); } - }, + return; + } - handleToolIntent(data: any) { - debugLog('[TaskPolling] 工具意图:', data.name, data.intent); + const response = await fetch('/api/sub_agents'); + if (!response.ok) { + debugLog('[TaskPolling] 获取子智能体状态失败'); + return; + } + const result = await response.json(); + if (!result.success) { + debugLog('[TaskPolling] 子智能体状态响应无效'); + return; + } - if (this.dropToolEvents) { - return; + const tasks = Array.isArray(result.data) ? result.data : []; + debugLog('[TaskPolling] 子智能体状态响应', { + total: tasks.length, + currentConversationId: this.currentConversationId, + tasks: tasks.map((task: any) => ({ + task_id: task?.task_id, + status: task?.status, + run_in_background: task?.run_in_background, + notice_pending: task?.notice_pending, + conversation_id: task?.conversation_id + })) + }); + const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated']); + const relevant = tasks.filter((task: any) => { + if (task && task.conversation_id && task.conversation_id !== this.currentConversationId) { + return false; } + return true; + }); - // 查找对应的工具 action - const action = this.toolFindAction(data.id, data.preparing_id, data.execution_id); + const running = relevant.filter( + (task: any) => task?.run_in_background && !terminalStatuses.has(task?.status) + ); + const pendingNotice = relevant.filter((task: any) => task?.notice_pending); - if (action && action.tool) { - const newIntent = data.intent || ''; - - // 如果 intent 没有变化,跳过 - if (action.tool.intent_full === newIntent) { - return; - } - - // 停止之前的打字机效果 - if (action.tool._intentTyping) { - action.tool._intentTyping = false; - if (action.tool._intentTimer) { - clearTimeout(action.tool._intentTimer); - action.tool._intentTimer = null; - } - } - - // 更新完整 intent - action.tool.intent_full = newIntent; - - // 判断是否是历史恢复:只有从头重建时才直接显示 - const isHistoryRestore = this._rebuildingFromScratch; - - if (isHistoryRestore) { - // 历史恢复,直接显示完整 intent - action.tool.intent_rendered = newIntent; - debugLog('[TaskPolling] 历史恢复,直接显示 intent'); - } else { - // 新工具块,逐字符显示 - debugLog('[TaskPolling] 新工具块,开始打字机效果'); - action.tool.intent_rendered = ''; - action.tool._intentTyping = true; - - // 计算每个字符的间隔时间 - // 总时长1秒,但最少0.5秒 - const totalDuration = Math.max(500, Math.min(1000, newIntent.length * 50)); - const charInterval = totalDuration / newIntent.length; - - let charIndex = 0; - const typeNextChar = () => { - if (charIndex < newIntent.length && action.tool._intentTyping) { - action.tool.intent_rendered += newIntent[charIndex]; - charIndex++; - this.$forceUpdate(); - action.tool._intentTimer = setTimeout(typeNextChar, charInterval); - } else { - action.tool._intentTyping = false; - action.tool.intent_rendered = newIntent; // 确保完整 - action.tool._intentTimer = null; - this.$forceUpdate(); - } - }; - - action.tool._intentTimer = setTimeout(typeNextChar, 50); // 延迟50ms开始 - } - - // 更新 arguments 和 label - if (action.tool.arguments) { - action.tool.arguments.intent = newIntent; - action.tool.argumentSnapshot = this.cloneToolArguments(action.tool.arguments); - action.tool.argumentLabel = this.buildToolLabel(action.tool.argumentSnapshot); - } - - this.$forceUpdate(); - debugLog('[TaskPolling] 已更新工具意图:', data.name); - } else { - debugLog('[TaskPolling] 未找到对应的工具 action:', data.id); - } - }, - - handleToolUpdateAction(data: any) { - if (this.dropToolEvents) { - return; - } - - debugLog('[TaskPolling] 更新action:', data.id, 'status:', data.status); - - let targetAction = this.toolFindAction(data.id, data.preparing_id, data.execution_id); - if (!targetAction && data.preparing_id && this.preparingTools.has(data.preparing_id)) { - targetAction = this.preparingTools.get(data.preparing_id); - } - - if (!targetAction) { - return; - } - - if (data.status) { - targetAction.tool.status = data.status; - } - if (data.result !== undefined) { - targetAction.tool.result = data.result; - } - if (data.message !== undefined) { - targetAction.tool.message = data.message; - } - if (data.content !== undefined) { - targetAction.tool.content = data.content; - } - - this.$forceUpdate(); - this.conditionalScrollToBottom(); - - if (this.monitorResolveTool && data.status === 'completed') { - this.monitorResolveTool(data); - } - }, - - handleAppendPayload(data: any) { - debugLog('[TaskPolling] 文件追加:', data.path); - this.chatAddAppendPayloadAction(data); - this.$forceUpdate(); - this.conditionalScrollToBottom(); - }, - - handleModifyPayload(data: any) { - debugLog('[TaskPolling] 文件修改:', data.path); - this.chatAddModifyPayloadAction(data); - this.$forceUpdate(); - this.conditionalScrollToBottom(); - }, - - markLatestUserWorkCompleted() { - if (!Array.isArray(this.messages) || this.messages.length === 0) { - return; - } - for (let i = this.messages.length - 1; i >= 0; i -= 1) { - const msg = this.messages[i]; - if (!msg || msg.role !== 'user') { - continue; - } - msg.metadata = msg.metadata || {}; - msg.metadata.work_timer = msg.metadata.work_timer || {}; - const timer = msg.metadata.work_timer; - if (timer.status === 'completed' && typeof timer.duration_ms === 'number') { - return; - } - const nowIso = new Date().toISOString(); - const startedAt = timer.started_at || msg.timestamp || nowIso; - const startMs = Date.parse(startedAt); - const endMs = Date.now(); - const durationMs = Number.isFinite(startMs) ? Math.max(0, endMs - startMs) : 0; - timer.status = 'completed'; - timer.started_at = startedAt; - timer.finished_at = nowIso; - timer.duration_ms = durationMs; - return; - } - }, - - handleTaskComplete(data: any) { - const hasRunningSubAgents = !!data?.has_running_sub_agents; - const hasRunningBackgroundCommands = !!data?.has_running_background_commands; - if (hasRunningSubAgents) { - debugLog('[TaskPolling] 任务完成,但仍有后台子智能体运行'); - } else { - debugLog('[TaskPolling] 任务完成'); - } - - // 同步处理状态更新 - this.streamingMessage = false; - this.stopRequested = false; - if (!hasRunningSubAgents) { - this.markLatestUserWorkCompleted(); - } - - if (hasRunningSubAgents) { - this.taskInProgress = true; - this.waitingForSubAgent = true; - this.waitingForBackgroundCommand = hasRunningBackgroundCommands; - // 关键修复:主任务结束后将切到新的通知任务,先清空旧任务事件索引去重表。 - this.clearProcessedEvents(); - this.startWaitingTaskProbe(); - } else { - this.taskInProgress = false; - this.waitingForSubAgent = false; - this.waitingForBackgroundCommand = false; - this.stopWaitingTaskProbe(); - this.clearTaskState(); // 清理任务状态 - } - - this.$forceUpdate(); - - // 只更新统计,不重新加载历史 - if (this.currentConversationId) { - setTimeout(() => { - this.fetchConversationTokenStatistics(); - this.updateCurrentContextTokens(); - }, 500); - } - }, - - handleTaskStopped(data: any, eventIdx: number) { - debugLog('[TaskPolling] 任务已停止, idx:', eventIdx, data); - - this.markLatestUserWorkCompleted(); - this.streamingMessage = false; - this.taskInProgress = false; - this.stopRequested = false; - this.waitingForSubAgent = false; + if (running.length || pendingNotice.length) { + debugLog('[TaskPolling] 恢复子智能体等待状态', { + running: running.length, + pendingNotice: pendingNotice.length, + runningTasks: running.map((task: any) => ({ + task_id: task?.task_id, + status: task?.status + })), + pendingTasks: pendingNotice.map((task: any) => ({ + task_id: task?.task_id, + status: task?.status + })) + }); + this.waitingForSubAgent = true; this.waitingForBackgroundCommand = false; - this.stopWaitingTaskProbe(); - - if (typeof this.clearPendingTools === 'function') { - this.clearPendingTools('task_stopped'); - } - this.$forceUpdate(); - - this.clearTaskState(); - }, - - // 新增统一清理方法 - clearTaskState() { - this.stopWaitingTaskProbe(); - (async () => { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - taskStore.clearTask(); // 使用 clearTask 而不是 stopPolling - })(); - - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - }, - - handleUserMessage(data: any) { - const message = (data?.message || data?.content || '').trim(); - if (!message) { - debugNotifyLog('[DEBUG_NOTIFY][ui] handleUserMessage:empty', { data }); - return; - } - debugLog('[TaskPolling] 收到用户消息事件'); - debugNotifyLog('[DEBUG_NOTIFY][ui] handleUserMessage', { - messagePreview: message.slice(0, 120), - sub_agent_notice: !!data?.sub_agent_notice, - background_command_notice: !!data?.background_command_notice, - task_id: data?.task_id, - has_running_sub_agents: data?.has_running_sub_agents, - has_running_background_commands: data?.has_running_background_commands - }); - keyNotifyLog('[DEBUG_NOTIFY_KEY][ui] handleUserMessage', { - messagePreview: message.slice(0, 80), - task_id: data?.task_id, - sub_agent_notice: !!data?.sub_agent_notice, - background_command_notice: !!data?.background_command_notice - }); - this.chatAddUserMessage(message, data?.images || [], data?.videos || []); this.taskInProgress = true; this.streamingMessage = false; this.stopRequested = false; - if (data?.sub_agent_notice) { - if (typeof data?.has_running_sub_agents === 'boolean') { - this.waitingForSubAgent = data.has_running_sub_agents; - } else if (typeof data?.remaining_count === 'number') { - this.waitingForSubAgent = data.remaining_count > 0; - } - if (typeof data?.has_running_background_commands === 'boolean') { - this.waitingForBackgroundCommand = data.has_running_background_commands; - } else if (data?.background_command_notice) { - this.waitingForBackgroundCommand = this.waitingForSubAgent; - } - if (this.waitingForSubAgent) { - this.startWaitingTaskProbe(); - } else { - this.stopWaitingTaskProbe(); - } - } + this.startWaitingTaskProbe(); this.$forceUpdate(); - this.conditionalScrollToBottom(); - }, - - handleSystemMessage(data: any) { - const content = (data?.content || data?.message || '').trim(); - console.log('[DEBUG_SYSTEM][polling] 收到 system_message 事件', { - raw: data, - normalizedContent: content, - currentConversationId: this.currentConversationId - }); - if (!content) { - console.log('[DEBUG_SYSTEM][polling] system_message 内容为空,跳过'); - return; - } - this.appendSystemAction(content); - }, - - handleTaskError(data: any) { - const shouldRetry = Boolean(data?.retry); - if (shouldRetry) { - const retryIn = Number(data?.retry_in) || 5; - const attempt = Number(data?.attempt) || 1; - const maxAttempts = Number(data?.max_attempts) || attempt; - - debugLog('[TaskPolling] API错误,等待自动重试', { - retryIn, - attempt, - maxAttempts, - message: data?.message - }); - - this.stopRequested = false; - this.taskInProgress = true; - this.streamingMessage = true; - this.$forceUpdate(); - - this.uiPushToast({ - title: '即将重试', - message: `将在 ${retryIn} 秒后重试(第 ${attempt}/${maxAttempts} 次)`, - type: 'info', - duration: Math.max(retryIn, 1) * 1000 - }); - return; - } - - const errorMessage = data.message || '未知错误'; - const errorType = data.error_type || 'unknown'; - - let title = '任务执行失败'; - let message = errorMessage; - - // 根据错误类型提供友好提示 - if (errorType === 'api_error') { - title = 'API 调用失败'; - message = `模型服务异常:${errorMessage}`; - } else if (errorType === 'timeout') { - title = '任务超时'; - message = '任务执行时间过长,已自动停止'; - } else if (errorType === 'quota_exceeded') { - title = '配额不足'; - message = '您的使用配额已用尽'; - } - - console.error('[TaskPolling] 任务错误:', data.message); - this.uiPushToast({ - title, - message, - type: 'error', - duration: 8000 - }); - - // 清理状态 - this.markLatestUserWorkCompleted(); - this.streamingMessage = false; - this.taskInProgress = false; - this.stopRequested = false; - this.$forceUpdate(); - - // 停止轮询 - (async () => { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - taskStore.stopPolling(); - })(); - }, - - handleTokenUpdate(data: any) { - if (data.conversation_id === this.currentConversationId) { - this.currentConversationTokens.cumulative_input_tokens = data.cumulative_input_tokens || 0; - this.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0; - this.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0; - - if (typeof data.current_context_tokens === 'number') { - this.resourceSetCurrentContextTokens(data.current_context_tokens); - } else { - this.updateCurrentContextTokens(); - } - - this.$forceUpdate(); - } - }, - - handleConversationChanged(data: any, eventIdx: number) { - debugLog('[TaskPolling] 对话标题已更新, idx:', eventIdx, data); - - if (data && data.conversation_id === this.currentConversationId) { - // 更新当前对话标题 - if (data.title) { - this.currentConversationTitle = data.title; - debugLog('[TaskPolling] 更新当前对话标题:', data.title); - } - - // 更新对话列表中的标题 - if (data.title && Array.isArray(this.conversations)) { - const conv = this.conversations.find(c => c && c.id === data.conversation_id); - if (conv) { - conv.title = data.title; - debugLog('[TaskPolling] 更新对话列表中的标题:', data.title); - } - } - - this.$forceUpdate(); - } - }, - - handleConversationResolved(data: any) { - if (data && data.conversation_id) { - this.currentConversationId = data.conversation_id; - if (data.title) { - this.currentConversationTitle = data.title; - } - this.promoteConversationToTop(data.conversation_id); - - const pathFragment = this.stripConversationPrefix(data.conversation_id); - const currentPath = window.location.pathname.replace(/^\/+/, ''); - if (data.created) { - history.pushState({ conversationId: data.conversation_id }, '', `/${pathFragment}`); - } else if (currentPath !== pathFragment) { - history.replaceState({ conversationId: data.conversation_id }, '', `/${pathFragment}`); - } - } - }, - - handleCompressionState(data: any) { - if (!data || typeof data !== 'object') { - return; - } - const wasInProgress = !!this.compressionInProgress; - this.compressionInProgress = !!data.in_progress; - this.compressionMode = data.mode || ''; - this.compressionStage = data.stage || ''; - if (this.compressionInProgress && !wasInProgress) { - const modeLabel = this.compressionMode === 'manual' ? '手动' : '自动'; - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; - } - this.compressionToastId = this.uiPushToast({ - title: '压缩中', - message: `对话正在${modeLabel}压缩,请稍候…`, - type: 'info', - duration: null, - closable: false - }); - } - if (!this.compressionInProgress) { - this.compressionError = data.error || ''; - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; - } - } - }, - - async handleCompressionFinished(data: any) { - if (this.compressionToastId) { - this.uiDismissToast(this.compressionToastId); - this.compressionToastId = null; - } - this.compressionInProgress = false; - this.compressionMode = ''; - this.compressionStage = ''; - this.compressionError = ''; - const newId = data?.conversation_id; - if (newId && newId !== this.currentConversationId) { - await this.loadConversation(newId, { force: true }); - } - this.conversationsOffset = 0; - if (typeof this.loadConversationsList === 'function') { - await this.loadConversationsList(); - } - this.uiPushToast({ - title: '压缩完成', - message: '已自动切换到压缩后的新对话', - type: 'success', - duration: 2400 - }); - }, - - handleShallowCompression(data: any, eventIdx?: number) { - const count = Number(data?.compressed_count || 0); - if (count <= 0) { - return; - } - debugLog('[TaskPolling] 自动浅层压缩触发, idx:', eventIdx, data); - this.uiPushToast({ - title: '自动浅层压缩', - message: `已自动压缩 ${count} 条较早工具结果`, - type: 'info', - duration: 2500 - }); - }, - - /** - * 清理已处理的事件索引 - */ - clearProcessedEvents() { - if (this._processedEventIndices) { - this._processedEventIndices.clear(); - } - }, - - /** - * 恢复任务状态(页面刷新后调用) - */ - async restoreTaskState() { - // 清理已处理的事件索引 - this.clearProcessedEvents(); - - try { - const { useTaskStore } = await import('../../stores/task'); - const taskStore = useTaskStore(); - - // 如果已经在流式输出中,不重复恢复 - if (this.streamingMessage || this.taskInProgress) { - debugLog('[TaskPolling] 任务已在进行中,跳过恢复', { - streamingMessage: this.streamingMessage, - taskInProgress: this.taskInProgress, - currentConversationId: this.currentConversationId - }); - return; - } - - // 查找运行中的任务 - const runningTask = await taskStore.loadRunningTask(this.currentConversationId); - - if (!runningTask) { - debugLog('[TaskPolling] 没有运行中的任务', { - currentConversationId: this.currentConversationId - }); - await this.restoreSubAgentWaitingState(); - return; - } - - debugLog('[TaskPolling] 发现运行中的任务,开始恢复状态', { - taskId: runningTask?.task_id, - status: runningTask?.status, - conversationId: runningTask?.conversation_id - }); - - // 检查历史是否已加载 - const hasMessages = Array.isArray(this.messages) && this.messages.length > 0; - - if (!hasMessages) { - debugLog('[TaskPolling] 历史未加载,等待历史加载完成'); - setTimeout(() => { - this.restoreTaskState(); - }, 500); - return; - } - - debugLog('[TaskPolling] 历史已加载,开始精细恢复'); - - // 获取任务的所有事件 - const detailResponse = await fetch(`/api/tasks/${taskStore.currentTaskId}`); - if (!detailResponse.ok) { - debugLog('[TaskPolling] 获取任务详情失败'); - return; - } - - const detailResult = await detailResponse.json(); - if (!detailResult.success || !detailResult.data.events) { - debugLog('[TaskPolling] 任务详情无效'); - return; - } - - const allEvents = detailResult.data.events; - debugLog(`[TaskPolling] 获取到 ${allEvents.length} 个事件`); - - // 找到最后一条消息 - const lastMessage = this.messages[this.messages.length - 1]; - const isAssistantMessage = lastMessage && lastMessage.role === 'assistant'; - - // console.log('[TaskPolling] 最后一条消息:', { - // exists: !!lastMessage, - // role: lastMessage?.role, - // actionsCount: lastMessage?.actions?.length || 0, - // isAssistant: isAssistantMessage - // }); - - // 检查是否需要从头重建 - // 1. 最后一条不是 assistant 消息 - // 2. 最后一条是空的 assistant 消息 - // 3. 事件数量远大于历史中的 actions 数量(说明历史不完整) - const historyActionsCount = lastMessage?.actions?.length || 0; - const eventCount = allEvents.length; - const historyIncomplete = eventCount > historyActionsCount + 5; // 允许5个事件的误差 - - const needsRebuild = !isAssistantMessage || - (isAssistantMessage && (!lastMessage.actions || lastMessage.actions.length === 0)) || - historyIncomplete; - - if (needsRebuild) { - // if (historyIncomplete) { - // console.log('[TaskPolling] 历史不完整,从头重建:', { - // historyActionsCount, - // eventCount, - // diff: eventCount - historyActionsCount - // }); - // } - debugLog('[TaskPolling] 需要从头重建 assistant 响应'); - - // 清空所有消息,准备从头重建 - // 保留用户消息,只删除最后的 assistant 消息 - if (isAssistantMessage) { - debugLog('[TaskPolling] 删除不完整的 assistant 消息'); - this.messages.pop(); - } - - this.streamingMessage = true; - this.taskInProgress = true; - this.$forceUpdate(); - - // 重置偏移量为 0,从头获取所有事件来重建 assistant 消息 - taskStore.lastEventIndex = 0; - debugLog('[TaskPolling] 重置偏移量为 0,从头开始轮询'); - - // 标记正在从头重建,用于后续处理 - this._rebuildingFromScratch = true; - this._rebuildingEventCount = allEvents.length; // 记录当前事件总数 - - (window as any).__taskEventHandler = (event: any) => { - this.handleTaskEvent(event); - }; - - taskStore.startPolling((event: any) => { - this.handleTaskEvent(event); - }); - - // 延迟清除重建标记,确保所有历史事件都处理完毕 - setTimeout(() => { - debugLog('[TaskPolling] 历史事件处理完毕,清除重建标记'); - this._rebuildingFromScratch = false; - this._rebuildingEventCount = 0; - }, 2000); - - return; - } - - // 分析事件,找到当前正在进行的操作 - let inThinking = false; - let inText = false; - - for (let i = 0; i < allEvents.length; i++) { - const event = allEvents[i]; - - if (event.type === 'thinking_start') { - inThinking = true; - } else if (event.type === 'thinking_end') { - inThinking = false; - } - - if (event.type === 'text_start') { - inText = true; - } else if (event.type === 'text_end') { - inText = false; - } - } - - // console.log('[TaskPolling] 分析结果:', { - // inThinking, - // inText, - // totalEvents: allEvents.length, - // lastEventType: allEvents[allEvents.length - 1]?.type, - // lastEventIdx: allEvents[allEvents.length - 1]?.idx - // }); - - // 恢复思考块状态 - if (lastMessage.actions) { - // console.log('[TaskPolling] 历史中的 actions 详情:', lastMessage.actions.map((a, idx) => ({ - // index: idx, - // type: a.type, - // id: a.id, - // hasContent: !!a.content, - // contentLength: a.content?.length || 0, - // toolName: a.tool?.name, - // hasBlockId: !!a.blockId, - // blockId: a.blockId, - // collapsed: a.collapsed, - // streaming: a.streaming - // }))); - - const thinkingActions = lastMessage.actions.filter(a => a.type === 'thinking'); - // console.log('[TaskPolling] 思考块数量:', thinkingActions.length); - - if (inThinking && thinkingActions.length > 0) { - // 正在思考中,检查最后一个思考块是否正在流式输出 - const lastThinking = thinkingActions[thinkingActions.length - 1]; - - // 只有当思考块正在流式输出时才设置锁定状态(但不展开) - if (lastThinking.streaming && lastThinking.blockId) { - // console.log('[TaskPolling] 找到正在流式输出的思考块,设置锁定状态:', lastThinking.blockId); - this.$nextTick(() => { - this.chatSetThinkingLock(lastThinking.blockId, true); - }); - } - } - - // 确保所有思考块都是折叠状态 - for (const thinking of thinkingActions) { - if (thinking.blockId) { - thinking.collapsed = true; - } - } - - // 检查思考块状态(在设置之后)(已禁用) - // thinkingActions.forEach((thinking, idx) => { - // console.log(`[TaskPolling] 思考块 ${idx} (设置后):`, { - // hasBlockId: !!thinking.blockId, - // blockId: thinking.blockId, - // collapsed: thinking.collapsed, - // contentLength: thinking.content?.length || 0 - // }); - // }); - - // 恢复文本块状态 - const textActions = lastMessage.actions.filter(a => a.type === 'text'); - // console.log('[TaskPolling] 文本块数量:', textActions.length); - - if (inText && textActions.length > 0) { - const lastText = textActions[textActions.length - 1]; - // console.log('[TaskPolling] 标记文本块为流式状态'); - lastText.streaming = true; - } - - // 注册历史中的工具块到 toolActionIndex - // 这样后续的 update_action 事件可以找到对应的块进行状态更新 - const toolActions = lastMessage.actions.filter(a => a.type === 'tool'); - // console.log('[TaskPolling] 工具块数量:', toolActions.length); - - for (const toolAction of toolActions) { - if (toolAction.tool && toolAction.tool.id) { - // console.log('[TaskPolling] 注册工具块:', { - // id: toolAction.tool.id, - // name: toolAction.tool.name, - // status: toolAction.tool.status - // }); - // 注册到 toolActionIndex - this.toolRegisterAction(toolAction, toolAction.tool.id); - // 如果有 executionId,也注册 - if (toolAction.tool.executionId) { - this.toolRegisterAction(toolAction, toolAction.tool.executionId); - } - // 追踪工具调用 - if (toolAction.tool.name) { - this.toolTrackAction(toolAction.tool.name, toolAction); - } - } - } - } - - // 标记状态为进行中 - this.streamingMessage = true; - this.taskInProgress = true; - - // 设置 currentMessageIndex 指向最后一条 assistant 消息 - // 这样后续添加的 action 会添加到正确的消息中 - const lastMessageIndex = this.messages.length - 1; - if (lastMessage && lastMessage.role === 'assistant') { - this.currentMessageIndex = lastMessageIndex; - // console.log('[TaskPolling] 设置 currentMessageIndex 为:', lastMessageIndex); - } - - // 强制更新界面 - this.$forceUpdate(); - - // 滚动到底部 - this.$nextTick(() => { - this.conditionalScrollToBottom(); - }); - - // 注册事件处理器到全局 - (window as any).__taskEventHandler = (event: any) => { - this.handleTaskEvent(event); - }; - - // 启动轮询(从当前偏移量开始,只处理新事件) - debugLog('[TaskPolling] 启动轮询,起始偏移量:', taskStore.lastEventIndex); - - taskStore.startPolling((event: any) => { - this.handleTaskEvent(event); - }); - - this.uiPushToast({ - title: '任务恢复', - message: '检测到进行中的任务,已恢复连接', - type: 'info', - duration: 3000 - }); - } catch (error) { - console.error('[TaskPolling] 恢复任务状态失败:', error); - } - }, - - /** - * 恢复子智能体等待状态(页面刷新后调用) - */ - async restoreSubAgentWaitingState(retry = 0) { - try { - if (!this.currentConversationId) { - if (retry < 5) { - setTimeout(() => { - this.restoreSubAgentWaitingState(retry + 1); - }, 300); - } - return; - } - - const response = await fetch('/api/sub_agents'); - if (!response.ok) { - debugLog('[TaskPolling] 获取子智能体状态失败'); - return; - } - const result = await response.json(); - if (!result.success) { - debugLog('[TaskPolling] 子智能体状态响应无效'); - return; - } - - const tasks = Array.isArray(result.data) ? result.data : []; - debugLog('[TaskPolling] 子智能体状态响应', { - total: tasks.length, - currentConversationId: this.currentConversationId, - tasks: tasks.map((task: any) => ({ - task_id: task?.task_id, - status: task?.status, - run_in_background: task?.run_in_background, - notice_pending: task?.notice_pending, - conversation_id: task?.conversation_id - })) - }); - const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated']); - const relevant = tasks.filter((task: any) => { - if (task && task.conversation_id && task.conversation_id !== this.currentConversationId) { - return false; - } - return true; - }); - - const running = relevant.filter((task: any) => task?.run_in_background && !terminalStatuses.has(task?.status)); - const pendingNotice = relevant.filter((task: any) => task?.notice_pending); - - if (running.length || pendingNotice.length) { - debugLog('[TaskPolling] 恢复子智能体等待状态', { - running: running.length, - pendingNotice: pendingNotice.length, - runningTasks: running.map((task: any) => ({ task_id: task?.task_id, status: task?.status })), - pendingTasks: pendingNotice.map((task: any) => ({ task_id: task?.task_id, status: task?.status })) - }); - this.waitingForSubAgent = true; - this.waitingForBackgroundCommand = false; - this.taskInProgress = true; - this.streamingMessage = false; - this.stopRequested = false; - this.startWaitingTaskProbe(); - this.$forceUpdate(); - } - } catch (error) { - console.error('[TaskPolling] 恢复子智能体等待状态失败:', error); - } - }, + } + } catch (error) { + console.error('[TaskPolling] 恢复子智能体等待状态失败:', error); + } + } }; diff --git a/static/src/app/methods/tooling.ts b/static/src/app/methods/tooling.ts index da45ad7..0762595 100644 --- a/static/src/app/methods/tooling.ts +++ b/static/src/app/methods/tooling.ts @@ -2,283 +2,277 @@ import { usePersonalizationStore } from '../../stores/personalization'; import { usePolicyStore } from '../../stores/policy'; import { - getToolIcon, - getToolAnimationClass, - getToolStatusText as baseGetToolStatusText, - getToolDescription, - cloneToolArguments, - buildToolLabel, - formatSearchTopic, - formatSearchTime, - formatSearchDomains, - getLanguageClass + getToolIcon, + getToolAnimationClass, + getToolStatusText as baseGetToolStatusText, + getToolDescription, + cloneToolArguments, + buildToolLabel, + formatSearchTopic, + formatSearchTime, + formatSearchDomains, + getLanguageClass } from '../../utils/chatDisplay'; import { debugLog } from './common'; export const toolingMethods = { - toolCategoryIcon(categoryId) { - return this.toolCategoryIcons[categoryId] || 'settings'; - }, + toolCategoryIcon(categoryId) { + return this.toolCategoryIcons[categoryId] || 'settings'; + }, - findMessageByAction(action) { - if (!action) { - return null; - } - for (const message of this.messages) { - if (!message.actions) { - continue; - } - if (message.actions.includes(action)) { - return message; - } - } - return null; - }, + findMessageByAction(action) { + if (!action) { + return null; + } + for (const message of this.messages) { + if (!message.actions) { + continue; + } + if (message.actions.includes(action)) { + return message; + } + } + return null; + }, - cleanupStaleToolActions() { - this.messages.forEach(msg => { - if (!msg.actions) { - return; - } - msg.actions.forEach(action => { - if (action.type !== 'tool' || !action.tool) { - return; - } - if (['running', 'preparing'].includes(action.tool.status)) { - action.tool.status = 'stale'; - action.tool.message = action.tool.message || '已被新的响应中断'; - this.toolUnregisterAction(action); - } - }); - }); - this.preparingTools.clear(); - this.toolActionIndex.clear(); - }, + cleanupStaleToolActions() { + this.messages.forEach((msg) => { + if (!msg.actions) { + return; + } + msg.actions.forEach((action) => { + if (action.type !== 'tool' || !action.tool) { + return; + } + if (['running', 'preparing'].includes(action.tool.status)) { + action.tool.status = 'stale'; + action.tool.message = action.tool.message || '已被新的响应中断'; + this.toolUnregisterAction(action); + } + }); + }); + this.preparingTools.clear(); + this.toolActionIndex.clear(); + }, - clearPendingTools(reason = 'unspecified') { - this.messages.forEach(msg => { - if (!msg.actions) { - return; - } - msg.actions.forEach(action => { - if (action.type !== 'tool' || !action.tool) { - return; - } - const status = - typeof action.tool.status === 'string' - ? action.tool.status.toLowerCase() - : ''; - if (!status || ['preparing', 'running', 'pending', 'queued', 'stale'].includes(status)) { - action.tool.status = 'cancelled'; - action.tool.message = action.tool.message || '已停止'; - } - }); - }); - if (this.preparingTools && this.preparingTools.clear) { - this.preparingTools.clear(); + clearPendingTools(reason = 'unspecified') { + this.messages.forEach((msg) => { + if (!msg.actions) { + return; + } + msg.actions.forEach((action) => { + if (action.type !== 'tool' || !action.tool) { + return; } - if (this.activeTools && this.activeTools.clear) { - this.activeTools.clear(); + const status = + typeof action.tool.status === 'string' ? action.tool.status.toLowerCase() : ''; + if (!status || ['preparing', 'running', 'pending', 'queued', 'stale'].includes(status)) { + action.tool.status = 'cancelled'; + action.tool.message = action.tool.message || '已停止'; } - if (this.toolActionIndex && this.toolActionIndex.clear) { - this.toolActionIndex.clear(); - } - if (this.toolStacks && this.toolStacks.clear) { - this.toolStacks.clear(); - } - if (typeof this.toolResetTracking === 'function') { - this.toolResetTracking(); - } - debugLog('清理待处理工具', { reason }); - }, + }); + }); + if (this.preparingTools && this.preparingTools.clear) { + this.preparingTools.clear(); + } + if (this.activeTools && this.activeTools.clear) { + this.activeTools.clear(); + } + if (this.toolActionIndex && this.toolActionIndex.clear) { + this.toolActionIndex.clear(); + } + if (this.toolStacks && this.toolStacks.clear) { + this.toolStacks.clear(); + } + if (typeof this.toolResetTracking === 'function') { + this.toolResetTracking(); + } + debugLog('清理待处理工具', { reason }); + }, - hasPendingToolActions() { - const mapHasEntries = map => map && typeof map.size === 'number' && map.size > 0; - if (mapHasEntries(this.preparingTools) || mapHasEntries(this.activeTools)) { - return true; + hasPendingToolActions() { + const mapHasEntries = (map) => map && typeof map.size === 'number' && map.size > 0; + if (mapHasEntries(this.preparingTools) || mapHasEntries(this.activeTools)) { + return true; + } + if (!Array.isArray(this.messages)) { + return false; + } + return this.messages.some((msg) => { + if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) { + return false; + } + return msg.actions.some((action) => { + if (!action || action.type !== 'tool' || !action.tool) { + return false; } - if (!Array.isArray(this.messages)) { - return false; + if (action.tool.awaiting_content) { + return true; } - return this.messages.some(msg => { - if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) { - return false; - } - return msg.actions.some(action => { - if (!action || action.type !== 'tool' || !action.tool) { - return false; - } - if (action.tool.awaiting_content) { - return true; - } - const status = - typeof action.tool.status === 'string' - ? action.tool.status.toLowerCase() - : ''; - return !status || ['preparing', 'running', 'pending', 'queued'].includes(status); - }); - }); - }, + const status = + typeof action.tool.status === 'string' ? action.tool.status.toLowerCase() : ''; + return !status || ['preparing', 'running', 'pending', 'queued'].includes(status); + }); + }); + }, - maybeResetStreamingState(reason = 'unspecified') { - if (!this.streamingMessage) { - return false; - } - if (this.hasPendingToolActions()) { - return false; - } - this.streamingMessage = false; - this.stopRequested = false; - debugLog('流式状态已结束', { reason }); - return true; - }, + maybeResetStreamingState(reason = 'unspecified') { + if (!this.streamingMessage) { + return false; + } + if (this.hasPendingToolActions()) { + return false; + } + this.streamingMessage = false; + this.stopRequested = false; + debugLog('流式状态已结束', { reason }); + return true; + }, - applyToolSettingsSnapshot(categories) { - if (!Array.isArray(categories)) { - console.warn('[ToolSettings] Snapshot skipped: categories not array', categories); - return; - } - const normalized = categories.map((item) => ({ - id: item.id, - label: item.label || item.id, - enabled: !!item.enabled, - tools: Array.isArray(item.tools) ? item.tools : [], - locked: !!item.locked, - locked_state: typeof item.locked_state === 'boolean' ? item.locked_state : null - })); - debugLog('[ToolSettings] Snapshot applied', { - received: categories.length, - normalized, - anyEnabled: normalized.some(cat => cat.enabled), - toolExamples: normalized.slice(0, 3) - }); - this.toolSetSettings(normalized); + applyToolSettingsSnapshot(categories) { + if (!Array.isArray(categories)) { + console.warn('[ToolSettings] Snapshot skipped: categories not array', categories); + return; + } + const normalized = categories.map((item) => ({ + id: item.id, + label: item.label || item.id, + enabled: !!item.enabled, + tools: Array.isArray(item.tools) ? item.tools : [], + locked: !!item.locked, + locked_state: typeof item.locked_state === 'boolean' ? item.locked_state : null + })); + debugLog('[ToolSettings] Snapshot applied', { + received: categories.length, + normalized, + anyEnabled: normalized.some((cat) => cat.enabled), + toolExamples: normalized.slice(0, 3) + }); + this.toolSetSettings(normalized); + this.toolSetSettingsLoading(false); + }, + + async loadToolSettings(force = false) { + if (!this.isConnected && !force) { + debugLog('[ToolSettings] Skip load: disconnected & not forced'); + return; + } + if (this.toolSettingsLoading) { + debugLog('[ToolSettings] Skip load: already loading'); + return; + } + if (!force && this.toolSettings.length > 0) { + debugLog('[ToolSettings] Skip load: already have settings'); + return; + } + debugLog('[ToolSettings] Fetch start', { force, hasConnection: this.isConnected }); + this.toolSetSettingsLoading(true); + try { + const response = await fetch('/api/tool-settings'); + const data = await response.json(); + debugLog('[ToolSettings] Fetch response', { status: response.status, data }); + if (response.ok && data.success && Array.isArray(data.categories)) { + this.applyToolSettingsSnapshot(data.categories); + } else { + console.warn('获取工具设置失败:', data); this.toolSetSettingsLoading(false); - }, + } + } catch (error) { + console.error('获取工具设置异常:', error); + this.toolSetSettingsLoading(false); + } + }, - async loadToolSettings(force = false) { - if (!this.isConnected && !force) { - debugLog('[ToolSettings] Skip load: disconnected & not forced'); - return; + async updateToolCategory(categoryId, enabled) { + if (!this.isConnected) { + return; + } + if (this.toolSettingsLoading) { + return; + } + const policyStore = usePolicyStore(); + if (policyStore.isCategoryLocked(categoryId)) { + this.uiPushToast({ + title: '无法修改', + message: '该工具类别被管理员强制设置', + type: 'warning' + }); + return; + } + const previousSnapshot = this.toolSettings.map((item) => ({ ...item })); + const updatedSettings = this.toolSettings.map((item) => { + if (item.id === categoryId) { + return { ...item, enabled }; + } + return item; + }); + this.toolSetSettings(updatedSettings); + try { + const response = await fetch('/api/tool-settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + category: categoryId, + enabled + }) + }); + const data = await response.json(); + if (response.ok && data.success && Array.isArray(data.categories)) { + this.applyToolSettingsSnapshot(data.categories); + } else { + console.warn('更新工具设置失败:', data); + if (data && (data.message || data.error)) { + this.uiPushToast({ + title: '无法切换工具', + message: data.message || data.error, + type: 'warning' + }); } - if (this.toolSettingsLoading) { - debugLog('[ToolSettings] Skip load: already loading'); - return; - } - if (!force && this.toolSettings.length > 0) { - debugLog('[ToolSettings] Skip load: already have settings'); - return; - } - debugLog('[ToolSettings] Fetch start', { force, hasConnection: this.isConnected }); - this.toolSetSettingsLoading(true); - try { - const response = await fetch('/api/tool-settings'); - const data = await response.json(); - debugLog('[ToolSettings] Fetch response', { status: response.status, data }); - if (response.ok && data.success && Array.isArray(data.categories)) { - this.applyToolSettingsSnapshot(data.categories); - } else { - console.warn('获取工具设置失败:', data); - this.toolSetSettingsLoading(false); - } - } catch (error) { - console.error('获取工具设置异常:', error); - this.toolSetSettingsLoading(false); - } - }, + this.toolSetSettings(previousSnapshot); + } + } catch (error) { + console.error('更新工具设置异常:', error); + this.toolSetSettings(previousSnapshot); + } + this.toolSetSettingsLoading(false); + }, - async updateToolCategory(categoryId, enabled) { - if (!this.isConnected) { - return; - } - if (this.toolSettingsLoading) { - return; - } - const policyStore = usePolicyStore(); - if (policyStore.isCategoryLocked(categoryId)) { - this.uiPushToast({ - title: '无法修改', - message: '该工具类别被管理员强制设置', - type: 'warning' - }); - return; - } - const previousSnapshot = this.toolSettings.map((item) => ({ ...item })); - const updatedSettings = this.toolSettings.map((item) => { - if (item.id === categoryId) { - return { ...item, enabled }; - } - return item; - }); - this.toolSetSettings(updatedSettings); - try { - const response = await fetch('/api/tool-settings', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - category: categoryId, - enabled - }) - }); - const data = await response.json(); - if (response.ok && data.success && Array.isArray(data.categories)) { - this.applyToolSettingsSnapshot(data.categories); - } else { - console.warn('更新工具设置失败:', data); - if (data && (data.message || data.error)) { - this.uiPushToast({ - title: '无法切换工具', - message: data.message || data.error, - type: 'warning' - }); - } - this.toolSetSettings(previousSnapshot); - } - } catch (error) { - console.error('更新工具设置异常:', error); - this.toolSetSettings(previousSnapshot); - } - this.toolSetSettingsLoading(false); - }, + toggleToolMenu() { + if (!this.isConnected) { + return; + } + if (this.isPolicyBlocked('block_tool_toggle', '工具启用/禁用已被管理员锁定')) { + return; + } + this.modeMenuOpen = false; + this.modelMenuOpen = false; + const nextState = this.inputToggleToolMenu(); + if (nextState) { + this.inputSetSettingsOpen(false); + if (!this.quickMenuOpen) { + this.inputOpenQuickMenu(); + } + this.loadToolSettings(true); + } else { + this.inputSetToolMenuOpen(false); + } + }, - toggleToolMenu() { - if (!this.isConnected) { - return; - } - if (this.isPolicyBlocked('block_tool_toggle', '工具启用/禁用已被管理员锁定')) { - return; - } - this.modeMenuOpen = false; - this.modelMenuOpen = false; - const nextState = this.inputToggleToolMenu(); - if (nextState) { - this.inputSetSettingsOpen(false); - if (!this.quickMenuOpen) { - this.inputOpenQuickMenu(); - } - this.loadToolSettings(true); - } else { - this.inputSetToolMenuOpen(false); - } - }, - - getToolIcon, - getToolAnimationClass, - getToolStatusText(tool: any) { - const personalization = usePersonalizationStore(); - const intentEnabled = - personalization?.form?.tool_intent_enabled ?? - personalization?.tool_intent_enabled ?? - true; - return baseGetToolStatusText(tool, { intentEnabled }); - }, - getToolDescription, - cloneToolArguments, - buildToolLabel, - formatSearchTopic, - formatSearchTime, - formatSearchDomains, - getLanguageClass + getToolIcon, + getToolAnimationClass, + getToolStatusText(tool: any) { + const personalization = usePersonalizationStore(); + const intentEnabled = + personalization?.form?.tool_intent_enabled ?? personalization?.tool_intent_enabled ?? true; + return baseGetToolStatusText(tool, { intentEnabled }); + }, + getToolDescription, + cloneToolArguments, + buildToolLabel, + formatSearchTopic, + formatSearchTime, + formatSearchDomains, + getLanguageClass }; diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index fda8348..5256283 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -5,15 +5,15 @@ import { usePersonalizationStore } from '../../stores/personalization'; import { useTutorialStore } from '../../stores/tutorial'; import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer'; import { - scrollToBottom as scrollToBottomHelper, - conditionalScrollToBottom as conditionalScrollToBottomHelper, - toggleScrollLock as toggleScrollLockHelper, - scrollThinkingToBottom as scrollThinkingToBottomHelper + scrollToBottom as scrollToBottomHelper, + conditionalScrollToBottom as conditionalScrollToBottomHelper, + toggleScrollLock as toggleScrollLockHelper, + scrollThinkingToBottom as scrollThinkingToBottomHelper } from '../../composables/useScrollControl'; import { - startResize as startPanelResize, - handleResize as handlePanelResize, - stopResize as stopPanelResize + startResize as startPanelResize, + handleResize as handlePanelResize, + stopResize as stopPanelResize } from '../../composables/usePanelResize'; import { debugLog } from './common'; @@ -21,1501 +21,1514 @@ const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务 const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/; function parseSubAgentDoneLabel(rawContent: any): string | null { - const content = (rawContent || '').toString().trim(); - if (!content) { - console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel: 空内容'); - return null; - } - const match = content.match(SUB_AGENT_DONE_PREFIX_RE); - const isCompleted = /已完成/.test(content); - console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel', { - content, - regex: SUB_AGENT_DONE_PREFIX_RE.toString(), - matched: !!match, - matchAgentId: match?.[1], - isCompleted - }); - if (!match || !isCompleted) { - return null; - } - const agentId = match[1]; - return `子智能体${agentId} 任务完成`; + const content = (rawContent || '').toString().trim(); + if (!content) { + console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel: 空内容'); + return null; + } + const match = content.match(SUB_AGENT_DONE_PREFIX_RE); + const isCompleted = /已完成/.test(content); + console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel', { + content, + regex: SUB_AGENT_DONE_PREFIX_RE.toString(), + matched: !!match, + matchAgentId: match?.[1], + isCompleted + }); + if (!match || !isCompleted) { + return null; + } + const agentId = match[1]; + return `子智能体${agentId} 任务完成`; } function parseBackgroundRunCommandDoneLabel(rawContent: any): string | null { - const content = (rawContent || '').toString().trim(); - if (!content) { - return null; - } - if (!BG_RUN_COMMAND_DONE_PREFIX_RE.test(content)) { - return null; - } - return '后台 run_command 完成'; + const content = (rawContent || '').toString().trim(); + if (!content) { + return null; + } + if (!BG_RUN_COMMAND_DONE_PREFIX_RE.test(content)) { + return null; + } + return '后台 run_command 完成'; } function parseSystemNoticeLabel(rawContent: any): string | null { - return parseSubAgentDoneLabel(rawContent) || parseBackgroundRunCommandDoneLabel(rawContent); + return parseSubAgentDoneLabel(rawContent) || parseBackgroundRunCommandDoneLabel(rawContent); } export const uiMethods = { - ensureScrollListener() { - if (this._scrollListenerReady) { - return; - } - const area = this.getMessagesAreaElement(); - if (!area) { - return; - } - this.initScrollListener(); - this._scrollListenerReady = true; - }, + ensureScrollListener() { + if (this._scrollListenerReady) { + return; + } + const area = this.getMessagesAreaElement(); + if (!area) { + return; + } + this.initScrollListener(); + this._scrollListenerReady = true; + }, - setupMobileViewportWatcher() { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - this.updateMobileViewportState(false); - return; - } - const query = window.matchMedia('(max-width: 768px)'); - this.mobileViewportQuery = query; - this.updateMobileViewportState(query.matches); - if (typeof query.addEventListener === 'function') { - query.addEventListener('change', this.handleMobileViewportQueryChange); - } else if (typeof query.addListener === 'function') { - query.addListener(this.handleMobileViewportQueryChange); - } - }, + setupMobileViewportWatcher() { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + this.updateMobileViewportState(false); + return; + } + const query = window.matchMedia('(max-width: 768px)'); + this.mobileViewportQuery = query; + this.updateMobileViewportState(query.matches); + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', this.handleMobileViewportQueryChange); + } else if (typeof query.addListener === 'function') { + query.addListener(this.handleMobileViewportQueryChange); + } + }, - teardownMobileViewportWatcher() { - const query = this.mobileViewportQuery; - if (!query) { - return; - } - if (typeof query.removeEventListener === 'function') { - query.removeEventListener('change', this.handleMobileViewportQueryChange); - } else if (typeof query.removeListener === 'function') { - query.removeListener(this.handleMobileViewportQueryChange); - } - this.mobileViewportQuery = null; - }, + teardownMobileViewportWatcher() { + const query = this.mobileViewportQuery; + if (!query) { + return; + } + if (typeof query.removeEventListener === 'function') { + query.removeEventListener('change', this.handleMobileViewportQueryChange); + } else if (typeof query.removeListener === 'function') { + query.removeListener(this.handleMobileViewportQueryChange); + } + this.mobileViewportQuery = null; + }, - handleMobileViewportQueryChange(event) { - this.updateMobileViewportState(event.matches); - }, + handleMobileViewportQueryChange(event) { + this.updateMobileViewportState(event.matches); + }, - updateMobileViewportState(isMobile) { - this.uiSetMobileViewport(!!isMobile); - if (!isMobile) { - this.uiSetMobileOverlayMenuOpen(false); - this.closeMobileOverlay(); - } - }, + updateMobileViewportState(isMobile) { + this.uiSetMobileViewport(!!isMobile); + if (!isMobile) { + this.uiSetMobileOverlayMenuOpen(false); + this.closeMobileOverlay(); + } + }, - toggleMobileOverlayMenu() { - if (!this.isMobileViewport) { - return; - } - this.uiToggleMobileOverlayMenu(); - }, + toggleMobileOverlayMenu() { + if (!this.isMobileViewport) { + return; + } + this.uiToggleMobileOverlayMenu(); + }, - openMobileOverlay(target) { - if (!this.isMobileViewport) { - return; - } - if (this.activeMobileOverlay === target) { - this.closeMobileOverlay(); - return; - } - if (this.activeMobileOverlay === 'conversation') { - this.uiSetSidebarCollapsed(true); - } - if (target === 'conversation') { - this.uiSetSidebarCollapsed(false); - } - this.uiSetActiveMobileOverlay(target); - this.uiSetMobileOverlayMenuOpen(false); - }, + openMobileOverlay(target) { + if (!this.isMobileViewport) { + return; + } + if (this.activeMobileOverlay === target) { + this.closeMobileOverlay(); + return; + } + if (this.activeMobileOverlay === 'conversation') { + this.uiSetSidebarCollapsed(true); + } + if (target === 'conversation') { + this.uiSetSidebarCollapsed(false); + } + this.uiSetActiveMobileOverlay(target); + this.uiSetMobileOverlayMenuOpen(false); + }, - refreshCurrentPage() { - if (typeof window === 'undefined') { - return; - } - window.location.reload(); - }, + refreshCurrentPage() { + if (typeof window === 'undefined') { + return; + } + window.location.reload(); + }, - handleMobileRefreshClick() { - this.uiSetMobileOverlayMenuOpen(false); - this.refreshCurrentPage(); - }, + handleMobileRefreshClick() { + this.uiSetMobileOverlayMenuOpen(false); + this.refreshCurrentPage(); + }, - closeMobileOverlay() { - if (!this.activeMobileOverlay) { - this.uiCloseMobileOverlay(); - return; - } - if (this.activeMobileOverlay === 'conversation') { - this.uiSetSidebarCollapsed(true); - } - this.uiCloseMobileOverlay(); - }, + closeMobileOverlay() { + if (!this.activeMobileOverlay) { + this.uiCloseMobileOverlay(); + return; + } + if (this.activeMobileOverlay === 'conversation') { + this.uiSetSidebarCollapsed(true); + } + this.uiCloseMobileOverlay(); + }, - applyPolicyUiLocks() { - const policyStore = usePolicyStore(); - const blocks = policyStore.uiBlocks; - if (blocks.collapse_workspace) { - this.uiSetWorkspaceCollapsed(true); - } - if (blocks.block_virtual_monitor && this.chatDisplayMode === 'monitor') { - this.uiSetChatDisplayMode('chat'); - } - }, + applyPolicyUiLocks() { + const policyStore = usePolicyStore(); + const blocks = policyStore.uiBlocks; + if (blocks.collapse_workspace) { + this.uiSetWorkspaceCollapsed(true); + } + if (blocks.block_virtual_monitor && this.chatDisplayMode === 'monitor') { + this.uiSetChatDisplayMode('chat'); + } + }, - isPolicyBlocked(key: string, message?: string) { - const policyStore = usePolicyStore(); - if (policyStore.uiBlocks[key]) { - this.uiPushToast({ - title: '已被管理员禁用', - message: message || '被管理员强制禁用', - type: 'warning' - }); - return true; - } - return false; - }, + isPolicyBlocked(key: string, message?: string) { + const policyStore = usePolicyStore(); + if (policyStore.uiBlocks[key]) { + this.uiPushToast({ + title: '已被管理员禁用', + message: message || '被管理员强制禁用', + type: 'warning' + }); + return true; + } + return false; + }, - handleClickOutsideMobileMenu(event) { - if (!this.isMobileViewport || !this.mobileOverlayMenuOpen) { - return; - } - const trigger = this.$refs.mobilePanelTrigger; - if (trigger && typeof trigger.contains === 'function' && trigger.contains(event.target)) { - return; - } - this.uiSetMobileOverlayMenuOpen(false); - }, + handleClickOutsideMobileMenu(event) { + if (!this.isMobileViewport || !this.mobileOverlayMenuOpen) { + return; + } + const trigger = this.$refs.mobilePanelTrigger; + if (trigger && typeof trigger.contains === 'function' && trigger.contains(event.target)) { + return; + } + this.uiSetMobileOverlayMenuOpen(false); + }, - handleWorkspaceToggle() { - if (this.isMobileViewport) { - return; - } - if (this.isPolicyBlocked('collapse_workspace', '工作区已被管理员强制折叠')) { - this.uiSetWorkspaceCollapsed(true); - return; - } - const nextState = !this.workspaceCollapsed; - this.uiSetWorkspaceCollapsed(nextState); - if (nextState) { - this.uiSetPanelMenuOpen(false); - } - }, + handleWorkspaceToggle() { + if (this.isMobileViewport) { + return; + } + if (this.isPolicyBlocked('collapse_workspace', '工作区已被管理员强制折叠')) { + this.uiSetWorkspaceCollapsed(true); + return; + } + const nextState = !this.workspaceCollapsed; + this.uiSetWorkspaceCollapsed(nextState); + if (nextState) { + this.uiSetPanelMenuOpen(false); + } + }, - handleDisplayModeToggle() { - if (this.displayModeSwitchDisabled) { - // 显式提示管理员禁用 - this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用'); - return; - } - if (this.chatDisplayMode === 'chat' && this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用')) { - return; - } - const next = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat'; - this.uiSetChatDisplayMode(next); - }, + handleDisplayModeToggle() { + if (this.displayModeSwitchDisabled) { + // 显式提示管理员禁用 + this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用'); + return; + } + if ( + this.chatDisplayMode === 'chat' && + this.isPolicyBlocked('block_virtual_monitor', '虚拟显示器已被管理员禁用') + ) { + return; + } + const next = this.chatDisplayMode === 'chat' ? 'monitor' : 'chat'; + this.uiSetChatDisplayMode(next); + }, - handleMobileOverlayEscape(event) { - if (event.key !== 'Escape' || !this.isMobileViewport) { - return; - } - if (this.mobileOverlayMenuOpen) { - this.uiSetMobileOverlayMenuOpen(false); - return; - } - if (this.activeMobileOverlay) { - this.closeMobileOverlay(); - } - }, + handleMobileOverlayEscape(event) { + if (event.key !== 'Escape' || !this.isMobileViewport) { + return; + } + if (this.mobileOverlayMenuOpen) { + this.uiSetMobileOverlayMenuOpen(false); + return; + } + if (this.activeMobileOverlay) { + this.closeMobileOverlay(); + } + }, - handleMobileOverlaySelect(conversationId) { - this.loadConversation(conversationId); - this.closeMobileOverlay(); - }, + handleMobileOverlaySelect(conversationId) { + this.loadConversation(conversationId); + this.closeMobileOverlay(); + }, - handleMobilePersonalClick() { - this.closeMobileOverlay(); - this.uiSetMobileOverlayMenuOpen(false); - this.openPersonalPage(); - }, + handleMobilePersonalClick() { + this.closeMobileOverlay(); + this.uiSetMobileOverlayMenuOpen(false); + this.openPersonalPage(); + }, - toggleSidebar() { - if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') { - this.closeMobileOverlay(); - return; - } - this.uiToggleSidebar(); - }, + toggleSidebar() { + if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') { + this.closeMobileOverlay(); + return; + } + this.uiToggleSidebar(); + }, - togglePanelMenu() { - this.uiTogglePanelMenu(); - }, + togglePanelMenu() { + this.uiTogglePanelMenu(); + }, - selectPanelMode(mode) { - this.uiSetPanelMode(mode); - this.uiSetPanelMenuOpen(false); - if (mode === 'subAgents') { - // 切换到子智能体面板时立即刷新,避免等待轮询间隔 - this.subAgentFetch(); - this.subAgentStartPolling(); - return; - } - if (mode === 'backgroundCommands') { - // 切换到后台指令面板时立即刷新,避免等待轮询间隔 - this.backgroundCommandFetch(); - this.backgroundCommandStartPolling(); - } - }, + selectPanelMode(mode) { + this.uiSetPanelMode(mode); + this.uiSetPanelMenuOpen(false); + if (mode === 'subAgents') { + // 切换到子智能体面板时立即刷新,避免等待轮询间隔 + this.subAgentFetch(); + this.subAgentStartPolling(); + return; + } + if (mode === 'backgroundCommands') { + // 切换到后台指令面板时立即刷新,避免等待轮询间隔 + this.backgroundCommandFetch(); + this.backgroundCommandStartPolling(); + } + }, - openPersonalPage() { - if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) { - return; - } - this.personalizationOpenDrawer(); - }, + openPersonalPage() { + if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) { + return; + } + this.personalizationOpenDrawer(); + }, - async checkTutorialPrompt() { - this.tutorialPromptVisible = false; - this.tutorialPromptUsername = ''; - try { - const resp = await fetch('/api/tutorial-status', { credentials: 'same-origin' }); - const data = await resp.json().catch(() => ({})); - console.info('[tutorial-prompt] status response:', { ok: resp.ok, status: resp.status, data }); - if (!resp.ok || !data?.success) { - return; - } - const payload = data.data || {}; - if (!payload.applicable || !payload.should_prompt) { - return; - } - this.tutorialPromptUsername = payload.username || ''; - this.tutorialPromptVisible = true; - } catch (error) { - console.warn('获取新手教程提示状态失败:', error); - } - }, + async checkTutorialPrompt() { + this.tutorialPromptVisible = false; + this.tutorialPromptUsername = ''; + try { + const resp = await fetch('/api/tutorial-status', { credentials: 'same-origin' }); + const data = await resp.json().catch(() => ({})); + console.info('[tutorial-prompt] status response:', { + ok: resp.ok, + status: resp.status, + data + }); + if (!resp.ok || !data?.success) { + return; + } + const payload = data.data || {}; + if (!payload.applicable || !payload.should_prompt) { + return; + } + this.tutorialPromptUsername = payload.username || ''; + this.tutorialPromptVisible = true; + } catch (error) { + console.warn('获取新手教程提示状态失败:', error); + } + }, - async updateTutorialPromptStatus(completed = true) { - this.tutorialPromptLoading = true; - try { - const resp = await fetch('/api/tutorial-status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify({ tutorial_completed: !!completed }) - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok || !data?.success) { - throw new Error(data?.error || '更新新手教程状态失败'); - } - const tutorialStore = useTutorialStore(); - tutorialStore.markCompleted(); - this.tutorialPromptVisible = false; - return true; - } catch (error) { - console.warn('更新新手教程提示状态失败:', error); - this.uiPushToast({ - title: '提示', - message: '保存新手教程状态失败,请稍后重试', - type: 'warning' - }); - return false; - } finally { - this.tutorialPromptLoading = false; - } - }, + async updateTutorialPromptStatus(completed = true) { + this.tutorialPromptLoading = true; + try { + const resp = await fetch('/api/tutorial-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ tutorial_completed: !!completed }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '更新新手教程状态失败'); + } + const tutorialStore = useTutorialStore(); + tutorialStore.markCompleted(); + this.tutorialPromptVisible = false; + return true; + } catch (error) { + console.warn('更新新手教程提示状态失败:', error); + this.uiPushToast({ + title: '提示', + message: '保存新手教程状态失败,请稍后重试', + type: 'warning' + }); + return false; + } finally { + this.tutorialPromptLoading = false; + } + }, - async handleNewUserTutorialStart() { - const ok = await this.updateTutorialPromptStatus(true); - if (!ok) { - return; - } - const personalStore = usePersonalizationStore(); - personalStore.closeDrawer(); - const tutorialStore = useTutorialStore(); - window.setTimeout(() => { - tutorialStore.startTutorial(); - }, 220); - }, + async handleNewUserTutorialStart() { + const ok = await this.updateTutorialPromptStatus(true); + if (!ok) { + return; + } + const personalStore = usePersonalizationStore(); + personalStore.closeDrawer(); + const tutorialStore = useTutorialStore(); + window.setTimeout(() => { + tutorialStore.startTutorial(); + }, 220); + }, - async handleNewUserTutorialSkip() { - await this.updateTutorialPromptStatus(true); - }, + async handleNewUserTutorialSkip() { + await this.updateTutorialPromptStatus(true); + }, - fetchTodoList() { - return this.fileFetchTodoList(); - }, + fetchTodoList() { + return this.fileFetchTodoList(); + }, - fetchSubAgents() { - return this.subAgentFetch(); - }, + fetchSubAgents() { + return this.subAgentFetch(); + }, - async toggleThinkingMode() { - await this.handleCycleRunMode(); - }, + async toggleThinkingMode() { + await this.handleCycleRunMode(); + }, - handleQuickModeToggle() { - if (!this.isConnected || this.streamingMessage) { - return; - } - this.handleCycleRunMode(); - }, + handleQuickModeToggle() { + if (!this.isConnected || this.streamingMessage) { + return; + } + this.handleCycleRunMode(); + }, - toggleQuickMenu() { - if (!this.isConnected) { - return; - } - const opened = this.inputToggleQuickMenu(); - if (!opened) { - this.modeMenuOpen = false; - this.modelMenuOpen = false; - } - }, + toggleQuickMenu() { + if (!this.isConnected) { + return; + } + const opened = this.inputToggleQuickMenu(); + if (!opened) { + this.modeMenuOpen = false; + this.modelMenuOpen = false; + } + }, - closeQuickMenu() { - this.inputCloseMenus(); - this.modeMenuOpen = false; - this.modelMenuOpen = false; - }, + closeQuickMenu() { + this.inputCloseMenus(); + this.modeMenuOpen = false; + this.modelMenuOpen = false; + }, - toggleModeMenu() { - if (!this.isConnected || this.streamingMessage) { - return; - } - const next = !this.modeMenuOpen; - this.modeMenuOpen = next; - if (next) { - this.modelMenuOpen = false; - } - if (next) { - this.inputSetToolMenuOpen(false); - this.inputSetSettingsOpen(false); - if (!this.quickMenuOpen) { - this.inputOpenQuickMenu(); - } - } - }, + toggleModeMenu() { + if (!this.isConnected || this.streamingMessage) { + return; + } + const next = !this.modeMenuOpen; + this.modeMenuOpen = next; + if (next) { + this.modelMenuOpen = false; + } + if (next) { + this.inputSetToolMenuOpen(false); + this.inputSetSettingsOpen(false); + if (!this.quickMenuOpen) { + this.inputOpenQuickMenu(); + } + } + }, - toggleModelMenu() { - if (!this.isConnected || this.streamingMessage) { - return; - } - const next = !this.modelMenuOpen; - this.modelMenuOpen = next; - if (next) { - this.modeMenuOpen = false; - this.inputSetToolMenuOpen(false); - this.inputSetSettingsOpen(false); - if (!this.quickMenuOpen) { - this.inputOpenQuickMenu(); - } - } - }, + toggleModelMenu() { + if (!this.isConnected || this.streamingMessage) { + return; + } + const next = !this.modelMenuOpen; + this.modelMenuOpen = next; + if (next) { + this.modeMenuOpen = false; + this.inputSetToolMenuOpen(false); + this.inputSetSettingsOpen(false); + if (!this.quickMenuOpen) { + this.inputOpenQuickMenu(); + } + } + }, - toggleHeaderMenu() { - if (!this.isConnected) return; - this.headerMenuOpen = !this.headerMenuOpen; - if (this.headerMenuOpen) { - this.closeQuickMenu(); - this.inputCloseMenus(); - } - }, + toggleHeaderMenu() { + if (!this.isConnected) return; + this.headerMenuOpen = !this.headerMenuOpen; + if (this.headerMenuOpen) { + this.closeQuickMenu(); + this.inputCloseMenus(); + } + }, - async handleModeSelect(mode) { - if (!this.isConnected || this.streamingMessage) { - return; - } - await this.setRunMode(mode); - }, + async handleModeSelect(mode) { + if (!this.isConnected || this.streamingMessage) { + return; + } + await this.setRunMode(mode); + }, - async handleHeaderRunModeSelect(mode) { - await this.handleModeSelect(mode); - this.closeHeaderMenu(); - }, + async handleHeaderRunModeSelect(mode) { + await this.handleModeSelect(mode); + this.closeHeaderMenu(); + }, - async handleModelSelect(key) { - if (!this.isConnected || this.streamingMessage) { - return; + async handleModelSelect(key) { + if (!this.isConnected || this.streamingMessage) { + return; + } + const policyStore = usePolicyStore(); + if (policyStore.isModelDisabled(key)) { + this.uiPushToast({ + title: '模型被禁用', + message: '被管理员强制禁用', + type: 'warning' + }); + return; + } + const modelStore = useModelStore(); + const targetModel = modelStore.models.find((m) => m.key === key); + if (this.conversationHasImages && !targetModel?.supportsImage) { + this.uiPushToast({ + title: '切换失败', + message: '当前对话包含图片,目标模型不支持图片输入', + type: 'error' + }); + return; + } + if (this.conversationHasVideos && !targetModel?.supportsVideo) { + this.uiPushToast({ + title: '切换失败', + message: '当前对话包含视频,目标模型不支持视频输入', + type: 'error' + }); + return; + } + const prev = this.currentModelKey; + try { + const resp = await fetch('/api/model', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_key: key }) + }); + const payload = await resp.json(); + if (!resp.ok || !payload.success) { + throw new Error(payload.error || payload.message || '切换失败'); + } + const data = payload.data || {}; + modelStore.setModel(data.model_key || key); + if (data.run_mode) { + this.runMode = data.run_mode; + this.thinkingMode = data.thinking_mode ?? data.run_mode !== 'fast'; + } else { + // 前端兼容策略:根据模型特性自动调整运行模式 + const currentModel = modelStore.currentModel; + if (currentModel?.deepOnly) { + this.runMode = 'deep'; + this.thinkingMode = true; + } else if (currentModel?.fastOnly) { + this.runMode = 'fast'; + this.thinkingMode = false; + } else { + this.thinkingMode = this.runMode !== 'fast'; } - const policyStore = usePolicyStore(); - if (policyStore.isModelDisabled(key)) { - this.uiPushToast({ - title: '模型被禁用', - message: '被管理员强制禁用', - type: 'warning' - }); - return; - } - const modelStore = useModelStore(); - const targetModel = modelStore.models.find((m) => m.key === key); - if (this.conversationHasImages && !targetModel?.supportsImage) { - this.uiPushToast({ - title: '切换失败', - message: '当前对话包含图片,目标模型不支持图片输入', - type: 'error' - }); - return; - } - if (this.conversationHasVideos && !targetModel?.supportsVideo) { - this.uiPushToast({ - title: '切换失败', - message: '当前对话包含视频,目标模型不支持视频输入', - type: 'error' - }); - return; - } - const prev = this.currentModelKey; - try { - const resp = await fetch('/api/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model_key: key }) - }); - const payload = await resp.json(); - if (!resp.ok || !payload.success) { - throw new Error(payload.error || payload.message || '切换失败'); - } - const data = payload.data || {}; - modelStore.setModel(data.model_key || key); - if (data.run_mode) { - this.runMode = data.run_mode; - this.thinkingMode = data.thinking_mode ?? (data.run_mode !== 'fast'); - } else { - // 前端兼容策略:根据模型特性自动调整运行模式 - const currentModel = modelStore.currentModel; - if (currentModel?.deepOnly) { - this.runMode = 'deep'; - this.thinkingMode = true; - } else if (currentModel?.fastOnly) { - this.runMode = 'fast'; - this.thinkingMode = false; - } else { - this.thinkingMode = this.runMode !== 'fast'; - } - } - this.uiPushToast({ - title: '模型已切换', - message: modelStore.currentModel?.label || key, - type: 'success' - }); - } catch (error) { - modelStore.setModel(prev); - const msg = error instanceof Error ? error.message : String(error || '切换失败'); - this.uiPushToast({ - title: '切换模型失败', - message: msg, - type: 'error' - }); - } finally { - this.modelMenuOpen = false; - this.inputCloseMenus(); - this.inputSetQuickMenuOpen(false); - } - }, + } + this.uiPushToast({ + title: '模型已切换', + message: modelStore.currentModel?.label || key, + type: 'success' + }); + } catch (error) { + modelStore.setModel(prev); + const msg = error instanceof Error ? error.message : String(error || '切换失败'); + this.uiPushToast({ + title: '切换模型失败', + message: msg, + type: 'error' + }); + } finally { + this.modelMenuOpen = false; + this.inputCloseMenus(); + this.inputSetQuickMenuOpen(false); + } + }, - async handleHeaderModelSelect(key, disabled) { - if (disabled) return; - await this.handleModelSelect(key); - this.closeHeaderMenu(); - }, + async handleHeaderModelSelect(key, disabled) { + if (disabled) return; + await this.handleModelSelect(key); + this.closeHeaderMenu(); + }, - async handleCycleRunMode() { - const modes: Array<'fast' | 'thinking' | 'deep'> = ['fast', 'thinking', 'deep']; - const currentMode = this.resolvedRunMode; - const currentIndex = modes.indexOf(currentMode); - const nextMode = modes[(currentIndex + 1) % modes.length]; - await this.setRunMode(nextMode); - }, + async handleCycleRunMode() { + const modes: Array<'fast' | 'thinking' | 'deep'> = ['fast', 'thinking', 'deep']; + const currentMode = this.resolvedRunMode; + const currentIndex = modes.indexOf(currentMode); + const nextMode = modes[(currentIndex + 1) % modes.length]; + await this.setRunMode(nextMode); + }, - async setRunMode(mode, options = {}) { - if (!this.isConnected || this.streamingMessage) { - this.modeMenuOpen = false; - return; - } - const modelStore = useModelStore(); - const fastOnly = modelStore.currentModel?.fastOnly; - const deepOnly = modelStore.currentModel?.deepOnly; - if (fastOnly && mode !== 'fast') { - if (!options.suppressToast) { - this.uiPushToast({ - title: '模式不可用', - message: '当前模型仅支持快速模式', - type: 'warning' - }); - } - this.modeMenuOpen = false; - this.inputCloseMenus(); - return; - } - if (deepOnly && mode !== 'deep') { - if (!options.suppressToast) { - this.uiPushToast({ - title: '模式不可用', - message: '当前模型仅支持深度思考模式', - type: 'warning' - }); - } - this.modeMenuOpen = false; - this.inputCloseMenus(); - return; - } - if (mode === this.resolvedRunMode) { - this.modeMenuOpen = false; - this.closeQuickMenu(); - return; - } - try { - const response = await fetch('/api/thinking-mode', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ mode }) - }); - const payload = await response.json(); - if (!response.ok || !payload.success) { - throw new Error(payload.message || payload.error || '切换失败'); - } - const data = payload.data || {}; - this.thinkingMode = typeof data.thinking_mode === 'boolean' ? data.thinking_mode : mode !== 'fast'; - this.runMode = data.mode || mode; - } catch (error) { - console.error('切换运行模式失败:', error); - const message = error instanceof Error ? error.message : String(error || '未知错误'); - this.uiPushToast({ - title: '切换思考模式失败', - message: message || '请稍后重试', - type: 'error' - }); - } finally { - this.modeMenuOpen = false; - this.inputCloseMenus(); - } - }, - - handleInputChange() { - this.autoResizeInput(); - }, - - handleInputFocus() { - this.inputSetFocused(true); - this.closeQuickMenu(); - }, - - handleInputBlur() { - this.inputSetFocused(false); - }, - - handleRealtimeTerminalClick() { - if (!this.isConnected) { - return; - } - if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) { - return; - } - this.openRealtimeTerminal(); - }, - - handleFocusPanelToggleClick() { - if (!this.isConnected) { - return; - } - if (this.isPolicyBlocked('block_focus_panel', '聚焦面板已被管理员禁用')) { - return; - } - this.toggleFocusPanel(); - }, - - handleTokenPanelToggleClick() { - if (!this.currentConversationId) { - return; - } - if (this.isPolicyBlocked('block_token_panel', '用量统计已被管理员禁用')) { - return; - } - this.toggleTokenPanel(); - }, - - handleCompressConversationClick() { - if (this.compressing || this.streamingMessage || !this.isConnected) { - return; - } - if (this.compressionInProgress) { - this.uiPushToast({ - title: '对话自动压缩中', - message: '当前对话正在压缩,请稍后再试', - type: 'warning' - }); - return; - } - if (this.isPolicyBlocked('block_compress_conversation', '压缩对话已被管理员禁用')) { - return; - } - this.compressConversation(); - }, - - openReviewDialog() { - if (this.isPolicyBlocked('block_conversation_review', '对话引用已被管理员禁用')) { - return; - } - if (!this.isConnected) { - this.uiPushToast({ - title: '无法使用', - message: '当前未连接,无法生成回顾文件', - type: 'warning' - }); - return; - } - if (!this.conversations.length && !this.conversationsLoading) { - this.loadConversationsList(); - } - const fallback = this.conversations.find((c) => c.id !== this.currentConversationId); - if (!fallback) { - this.uiPushToast({ - title: '暂无可用对话', - message: '没有可供回顾的其他对话记录', - type: 'info' - }); - return; - } - this.reviewSelectedConversationId = fallback.id; - this.reviewDialogOpen = true; - this.reviewPreviewLines = []; - this.reviewPreviewError = null; - this.reviewGeneratedPath = null; - this.loadReviewPreview(fallback.id); - this.closeQuickMenu(); - }, - - closeHeaderMenu() { - this.headerMenuOpen = false; - }, - - handleReviewSelect(id) { - if (id === this.currentConversationId) { - this.uiPushToast({ - title: '无法引用当前对话', - message: '请选择其他对话生成回顾', - type: 'warning' - }); - return; - } - this.reviewSelectedConversationId = id; - this.loadReviewPreview(id); - }, - - async handleConfirmReview() { - if (this.reviewSubmitting) return; - if (!this.reviewSelectedConversationId) { - this.uiPushToast({ - title: '请选择对话', - message: '请选择要生成回顾的对话记录', - type: 'info' - }); - return; - } - if (this.reviewSelectedConversationId === this.currentConversationId) { - this.uiPushToast({ - title: '无法引用当前对话', - message: '请选择其他对话生成回顾', - type: 'warning' - }); - return; - } - if (!this.currentConversationId) { - this.uiPushToast({ - title: '无法发送', - message: '当前没有活跃对话,无法自动发送提示消息', - type: 'warning' - }); - return; - } - - this.reviewSubmitting = true; - try { - const { path, char_count } = await this.generateConversationReview(this.reviewSelectedConversationId); - if (!path) { - throw new Error('未获取到生成的文件路径'); - } - const count = typeof char_count === 'number' ? char_count : 0; - this.reviewGeneratedPath = path; - const suggestion = - count && count <= 10000 - ? '建议直接完整阅读。' - : '建议使用 read 工具进行搜索或分段阅读。'; - if (this.reviewSendToModel) { - const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`; - const sent = await this.sendAutoUserMessage(message); - if (sent) { - this.reviewDialogOpen = false; - } - } else { - this.uiPushToast({ - title: '回顾文件已生成', - message: path, - type: 'success' - }); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error || '生成失败'); - this.uiPushToast({ - title: '生成回顾失败', - message: msg, - type: 'error' - }); - } finally { - this.reviewSubmitting = false; - } - }, - - async loadReviewPreview(conversationId) { - this.reviewPreviewLoading = true; - this.reviewPreviewError = null; - this.reviewPreviewLines = []; - try { - const resp = await fetch(`/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}`); - const payload = await resp.json().catch(() => ({})); - if (!resp.ok || !payload?.success) { - const msg = payload?.message || payload?.error || '获取预览失败'; - throw new Error(msg); - } - this.reviewPreviewLines = payload?.data?.preview || []; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error || '获取预览失败'); - this.reviewPreviewError = msg; - } finally { - this.reviewPreviewLoading = false; - } - }, - - async generateConversationReview(conversationId) { - const response = await fetch(`/api/conversations/${conversationId}/review`, { - method: 'POST' + async setRunMode(mode, options = {}) { + if (!this.isConnected || this.streamingMessage) { + this.modeMenuOpen = false; + return; + } + const modelStore = useModelStore(); + const fastOnly = modelStore.currentModel?.fastOnly; + const deepOnly = modelStore.currentModel?.deepOnly; + if (fastOnly && mode !== 'fast') { + if (!options.suppressToast) { + this.uiPushToast({ + title: '模式不可用', + message: '当前模型仅支持快速模式', + type: 'warning' }); - const payload = await response.json().catch(() => ({})); - if (!response.ok || !payload?.success) { - const msg = payload?.message || payload?.error || '生成失败'; - throw new Error(msg); - } - const data = payload.data || payload; - return { - path: data.path || data.file_path || data.relative_path, - char_count: data.char_count ?? data.length ?? data.size ?? 0 - }; - }, + } + this.modeMenuOpen = false; + this.inputCloseMenus(); + return; + } + if (deepOnly && mode !== 'deep') { + if (!options.suppressToast) { + this.uiPushToast({ + title: '模式不可用', + message: '当前模型仅支持深度思考模式', + type: 'warning' + }); + } + this.modeMenuOpen = false; + this.inputCloseMenus(); + return; + } + if (mode === this.resolvedRunMode) { + this.modeMenuOpen = false; + this.closeQuickMenu(); + return; + } + try { + const response = await fetch('/api/thinking-mode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ mode }) + }); + const payload = await response.json(); + if (!response.ok || !payload.success) { + throw new Error(payload.message || payload.error || '切换失败'); + } + const data = payload.data || {}; + this.thinkingMode = + typeof data.thinking_mode === 'boolean' ? data.thinking_mode : mode !== 'fast'; + this.runMode = data.mode || mode; + } catch (error) { + console.error('切换运行模式失败:', error); + const message = error instanceof Error ? error.message : String(error || '未知错误'); + this.uiPushToast({ + title: '切换思考模式失败', + message: message || '请稍后重试', + type: 'error' + }); + } finally { + this.modeMenuOpen = false; + this.inputCloseMenus(); + } + }, - handleClickOutsideQuickMenu(event) { - if (!this.quickMenuOpen) { - return; - } - const shell = this.getComposerElement('stadiumShellOuter') || this.getComposerElement('compactInputShell'); - if (shell && shell.contains(event.target)) { - return; - } - this.closeQuickMenu(); - }, + handleInputChange() { + this.autoResizeInput(); + }, - handleClickOutsideHeaderMenu(event) { - if (!this.headerMenuOpen) return; - const ribbon = this.$refs.titleRibbon as HTMLElement | undefined; - const menu = this.$refs.headerMenu as HTMLElement | undefined; - if ((ribbon && ribbon.contains(event.target)) || (menu && menu.contains(event.target))) { - return; - } - this.closeHeaderMenu(); - }, + handleInputFocus() { + this.inputSetFocused(true); + this.closeQuickMenu(); + }, - handleClickOutsidePanelMenu(event) { - if (!this.panelMenuOpen) { - return; - } - const leftPanel = this.$refs.leftPanel; - const wrapper = leftPanel && leftPanel.panelMenuWrapper ? leftPanel.panelMenuWrapper : null; - if (wrapper && wrapper.contains(event.target)) { - return; - } - this.uiSetPanelMenuOpen(false); - }, + handleInputBlur() { + this.inputSetFocused(false); + }, - isConversationBlank() { - if (!Array.isArray(this.messages) || !this.messages.length) return true; - return !this.messages.some( - (msg) => msg && (msg.role === 'user' || msg.role === 'assistant') + handleRealtimeTerminalClick() { + if (!this.isConnected) { + return; + } + if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) { + return; + } + this.openRealtimeTerminal(); + }, + + handleFocusPanelToggleClick() { + if (!this.isConnected) { + return; + } + if (this.isPolicyBlocked('block_focus_panel', '聚焦面板已被管理员禁用')) { + return; + } + this.toggleFocusPanel(); + }, + + handleTokenPanelToggleClick() { + if (!this.currentConversationId) { + return; + } + if (this.isPolicyBlocked('block_token_panel', '用量统计已被管理员禁用')) { + return; + } + this.toggleTokenPanel(); + }, + + handleCompressConversationClick() { + if (this.compressing || this.streamingMessage || !this.isConnected) { + return; + } + if (this.compressionInProgress) { + this.uiPushToast({ + title: '对话自动压缩中', + message: '当前对话正在压缩,请稍后再试', + type: 'warning' + }); + return; + } + if (this.isPolicyBlocked('block_compress_conversation', '压缩对话已被管理员禁用')) { + return; + } + this.compressConversation(); + }, + + openReviewDialog() { + if (this.isPolicyBlocked('block_conversation_review', '对话引用已被管理员禁用')) { + return; + } + if (!this.isConnected) { + this.uiPushToast({ + title: '无法使用', + message: '当前未连接,无法生成回顾文件', + type: 'warning' + }); + return; + } + if (!this.conversations.length && !this.conversationsLoading) { + this.loadConversationsList(); + } + const fallback = this.conversations.find((c) => c.id !== this.currentConversationId); + if (!fallback) { + this.uiPushToast({ + title: '暂无可用对话', + message: '没有可供回顾的其他对话记录', + type: 'info' + }); + return; + } + this.reviewSelectedConversationId = fallback.id; + this.reviewDialogOpen = true; + this.reviewPreviewLines = []; + this.reviewPreviewError = null; + this.reviewGeneratedPath = null; + this.loadReviewPreview(fallback.id); + this.closeQuickMenu(); + }, + + closeHeaderMenu() { + this.headerMenuOpen = false; + }, + + handleReviewSelect(id) { + if (id === this.currentConversationId) { + this.uiPushToast({ + title: '无法引用当前对话', + message: '请选择其他对话生成回顾', + type: 'warning' + }); + return; + } + this.reviewSelectedConversationId = id; + this.loadReviewPreview(id); + }, + + async handleConfirmReview() { + if (this.reviewSubmitting) return; + if (!this.reviewSelectedConversationId) { + this.uiPushToast({ + title: '请选择对话', + message: '请选择要生成回顾的对话记录', + type: 'info' + }); + return; + } + if (this.reviewSelectedConversationId === this.currentConversationId) { + this.uiPushToast({ + title: '无法引用当前对话', + message: '请选择其他对话生成回顾', + type: 'warning' + }); + return; + } + if (!this.currentConversationId) { + this.uiPushToast({ + title: '无法发送', + message: '当前没有活跃对话,无法自动发送提示消息', + type: 'warning' + }); + return; + } + + this.reviewSubmitting = true; + try { + const { path, char_count } = await this.generateConversationReview( + this.reviewSelectedConversationId + ); + if (!path) { + throw new Error('未获取到生成的文件路径'); + } + const count = typeof char_count === 'number' ? char_count : 0; + this.reviewGeneratedPath = path; + const suggestion = + count && count <= 10000 ? '建议直接完整阅读。' : '建议使用 read 工具进行搜索或分段阅读。'; + if (this.reviewSendToModel) { + const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`; + const sent = await this.sendAutoUserMessage(message); + if (sent) { + this.reviewDialogOpen = false; + } + } else { + this.uiPushToast({ + title: '回顾文件已生成', + message: path, + type: 'success' + }); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error || '生成失败'); + this.uiPushToast({ + title: '生成回顾失败', + message: msg, + type: 'error' + }); + } finally { + this.reviewSubmitting = false; + } + }, + + async loadReviewPreview(conversationId) { + this.reviewPreviewLoading = true; + this.reviewPreviewError = null; + this.reviewPreviewLines = []; + try { + const resp = await fetch( + `/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}` + ); + const payload = await resp.json().catch(() => ({})); + if (!resp.ok || !payload?.success) { + const msg = payload?.message || payload?.error || '获取预览失败'; + throw new Error(msg); + } + this.reviewPreviewLines = payload?.data?.preview || []; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error || '获取预览失败'); + this.reviewPreviewError = msg; + } finally { + this.reviewPreviewLoading = false; + } + }, + + async generateConversationReview(conversationId) { + const response = await fetch(`/api/conversations/${conversationId}/review`, { + method: 'POST' + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + const msg = payload?.message || payload?.error || '生成失败'; + throw new Error(msg); + } + const data = payload.data || payload; + return { + path: data.path || data.file_path || data.relative_path, + char_count: data.char_count ?? data.length ?? data.size ?? 0 + }; + }, + + handleClickOutsideQuickMenu(event) { + if (!this.quickMenuOpen) { + return; + } + const shell = + this.getComposerElement('stadiumShellOuter') || this.getComposerElement('compactInputShell'); + if (shell && shell.contains(event.target)) { + return; + } + this.closeQuickMenu(); + }, + + handleClickOutsideHeaderMenu(event) { + if (!this.headerMenuOpen) return; + const ribbon = this.$refs.titleRibbon as HTMLElement | undefined; + const menu = this.$refs.headerMenu as HTMLElement | undefined; + if ((ribbon && ribbon.contains(event.target)) || (menu && menu.contains(event.target))) { + return; + } + this.closeHeaderMenu(); + }, + + handleClickOutsidePanelMenu(event) { + if (!this.panelMenuOpen) { + return; + } + const leftPanel = this.$refs.leftPanel; + const wrapper = leftPanel && leftPanel.panelMenuWrapper ? leftPanel.panelMenuWrapper : null; + if (wrapper && wrapper.contains(event.target)) { + return; + } + this.uiSetPanelMenuOpen(false); + }, + + isConversationBlank() { + if (!Array.isArray(this.messages) || !this.messages.length) return true; + return !this.messages.some((msg) => msg && (msg.role === 'user' || msg.role === 'assistant')); + }, + + pickWelcomeText() { + const pool = this.blankWelcomePool; + if (!Array.isArray(pool) || !pool.length) { + this.blankWelcomeText = '有什么可以帮忙的?'; + return; + } + const idx = Math.floor(Math.random() * pool.length); + this.blankWelcomeText = pool[idx]; + }, + + refreshBlankHeroState() { + const isBlank = this.isConversationBlank(); + const currentConv = this.currentConversationId || 'temp'; + const needNewWelcome = !this.blankHeroActive || this.lastBlankConversationId !== currentConv; + + if (isBlank) { + if (needNewWelcome && !this.blankHeroExiting) { + this.pickWelcomeText(); + } + this.blankHeroActive = true; + this.lastBlankConversationId = currentConv; + } else { + this.blankHeroActive = false; + this.blankHeroExiting = false; + this.lastBlankConversationId = null; + } + }, + + toggleSettings() { + if (!this.isConnected) { + return; + } + this.modeMenuOpen = false; + this.modelMenuOpen = false; + const nextState = this.inputToggleSettingsMenu(); + if (nextState) { + this.inputSetToolMenuOpen(false); + if (!this.quickMenuOpen) { + this.inputOpenQuickMenu(); + } + } + }, + + toggleFocusPanel() { + this.rightCollapsed = !this.rightCollapsed; + if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { + this.rightWidth = this.minPanelWidth; + } + }, + + addSystemMessage(content) { + console.log('[DEBUG_SYSTEM][ui] addSystemMessage 入参', { content }); + const systemNoticeLabel = parseSystemNoticeLabel(content); + if (!systemNoticeLabel) { + // 其他 system 消息全部隐藏,且不进入渲染链路,避免出现空白块 + console.log('[DEBUG_SYSTEM][ui] addSystemMessage 被拦截(非完成通知)', { content }); + return; + } + console.log('[DEBUG_SYSTEM][ui] addSystemMessage 通过,写入 action', { + original: content, + normalizedLabel: systemNoticeLabel + }); + this.chatAddSystemMessage(systemNoticeLabel, { variant: 'sub_agent_done' }); + this.$forceUpdate(); + this.conditionalScrollToBottom(); + }, + + appendSystemAction(content) { + this.addSystemMessage(content); + }, + + startTitleTyping(title: string, options: { animate?: boolean } = {}) { + if (this.titleTypingTimer) { + clearInterval(this.titleTypingTimer); + this.titleTypingTimer = null; + } + const target = (title || '').trim(); + const animate = options.animate ?? true; + if (!animate) { + this.titleTypingTarget = target; + this.titleTypingText = target; + return; + } + const previous = (this.titleTypingText || '').trim(); + if (previous === target) { + this.titleTypingTarget = target; + this.titleTypingText = target; + return; + } + this.titleTypingTarget = target; + this.titleTypingText = previous; + + const frames: string[] = []; + for (let i = previous.length; i >= 0; i--) { + frames.push(previous.slice(0, i)); + } + for (let j = 1; j <= target.length; j++) { + frames.push(target.slice(0, j)); + } + + let index = 0; + this.titleTypingTimer = window.setInterval(() => { + if (index >= frames.length) { + clearInterval(this.titleTypingTimer!); + this.titleTypingTimer = null; + this.titleTypingText = target; + return; + } + this.titleTypingText = frames[index]; + index += 1; + }, 32); + }, + + toggleBlock(blockId) { + if (!blockId) { + return; + } + if (this.expandedBlocks && this.expandedBlocks.has(blockId)) { + this.chatCollapseBlock(blockId); + } else { + this.chatExpandBlock(blockId); + } + }, + + handleThinkingScroll(blockId, event) { + if (!blockId || !event || !event.target) { + return; + } + const el = event.target; + const threshold = 12; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold; + this.chatSetThinkingLock(blockId, atBottom); + }, + + scrollToBottom() { + scrollToBottomHelper(this); + }, + + conditionalScrollToBottom() { + conditionalScrollToBottomHelper(this); + }, + + toggleScrollLock() { + toggleScrollLockHelper(this); + }, + + scrollThinkingToBottom(blockId) { + scrollThinkingToBottomHelper(this, blockId); + }, + + // 面板调整方法 + startResize(panel, event) { + startPanelResize(this, panel, event); + }, + + handleResize(event) { + handlePanelResize(this, event); + }, + + stopResize() { + stopPanelResize(this); + }, + + getInputComposerRef() { + return this.$refs.inputComposer || null; + }, + + getComposerElement(field) { + const composer = this.getInputComposerRef(); + const unwrap = (value: any) => { + if (!value) { + return null; + } + if (value instanceof HTMLElement) { + return value; + } + if (typeof value === 'object' && 'value' in value && !(value instanceof Window)) { + return value.value; + } + return value; + }; + if (composer && composer[field]) { + return unwrap(composer[field]); + } + if (this.$refs && this.$refs[field]) { + return unwrap(this.$refs[field]); + } + return null; + }, + + getMessagesAreaElement() { + const ref = this.$refs.messagesArea; + if (!ref) { + return null; + } + if (ref instanceof HTMLElement) { + return ref; + } + if (ref.rootEl) { + return ref.rootEl.value || ref.rootEl; + } + if (ref.$el && ref.$el.querySelector) { + const el = ref.$el.querySelector('.messages-area'); + if (el) { + return el; + } + } + return null; + }, + + getThinkingContentElement(blockId) { + const chatArea = this.$refs.messagesArea; + if (chatArea && typeof chatArea.getThinkingRef === 'function') { + const el = chatArea.getThinkingRef(blockId); + if (el) { + return el; + } + } + const refName = `thinkingContent-${blockId}`; + const elRef = this.$refs[refName]; + if (Array.isArray(elRef)) { + return elRef[0] || null; + } + return elRef || null; + }, + + isOutputActive() { + return !!(this.streamingMessage || this.taskInProgress || this.hasPendingToolActions()); + }, + + logMessageState(action, extra = {}) { + const count = Array.isArray(this.messages) ? this.messages.length : 'N/A'; + debugLog('[Messages]', { + action, + count, + conversationId: this.currentConversationId, + streaming: this.streamingMessage, + ...extra + }); + }, + + iconStyle(iconKey, size) { + const iconPath = this.icons ? this.icons[iconKey] : null; + if (!iconPath) { + return {}; + } + const style = { '--icon-src': `url(${iconPath})` }; + if (size) { + style['--icon-size'] = size; + } + return style; + }, + + openGuiFileManager() { + if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) { + return; + } + window.open('/file-manager', '_blank'); + }, + + renderMarkdown(content, isStreaming = false) { + return renderMarkdownHelper(content, isStreaming); + }, + + isExplicitNewConversationRoute() { + const normalizedPath = window.location.pathname.replace(/^\/+|\/+$/g, ''); + return normalizedPath === 'new'; + }, + + async getRunningTaskConversationId() { + try { + const resp = await fetch('/api/tasks'); + if (!resp.ok) { + return null; + } + const payload = await resp.json(); + const tasks = Array.isArray(payload?.data) ? payload.data : []; + const runningTask = tasks.find( + (task: any) => task?.status === 'running' && task?.conversation_id + ); + return runningTask?.conversation_id || null; + } catch (error) { + console.warn('获取运行中任务失败:', error); + return null; + } + }, + + async bootstrapRoute() { + // 在路由解析期间抑制标题动画,避免预置"新对话"闪烁 + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; + const path = window.location.pathname.replace(/^\/+/, ''); + if (!path || this.isExplicitNewConversationRoute()) { + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.logMessageState('bootstrapRoute:clear-messages-for-new'); + this.messages = []; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); + this.initialRouteResolved = true; + this.refreshBlankHeroState(); + return; + } + + const convId = path.startsWith('conv_') ? path : `conv_${path}`; + try { + const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' }); + const result = await resp.json(); + if (result.success) { + this.currentConversationId = convId; + this.currentConversationTitle = result.title || ''; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping(this.currentConversationTitle, { animate: false }); + history.replaceState( + { conversationId: convId }, + '', + `/${this.stripConversationPrefix(convId)}` ); - }, + } else { + history.replaceState({}, '', '/new'); + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); + } + } catch (error) { + console.warn('初始化路由失败:', error); + history.replaceState({}, '', '/new'); + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); + } finally { + this.initialRouteResolved = true; + } + }, - pickWelcomeText() { - const pool = this.blankWelcomePool; - if (!Array.isArray(pool) || !pool.length) { - this.blankWelcomeText = '有什么可以帮忙的?'; - return; - } - const idx = Math.floor(Math.random() * pool.length); - this.blankWelcomeText = pool[idx]; - }, + handlePopState(event) { + const state = event.state || {}; + const convId = state.conversationId; + if (!convId) { + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.logMessageState('handlePopState:clear-messages-no-conversation'); + this.messages = []; + this.logMessageState('handlePopState:after-clear-no-conversation'); + this.resetAllStates('handlePopState:no-conversation'); + this.resetTokenStatistics(); + return; + } + this.loadConversation(convId); + }, - refreshBlankHeroState() { - const isBlank = this.isConversationBlank(); - const currentConv = this.currentConversationId || 'temp'; - const needNewWelcome = - !this.blankHeroActive || - this.lastBlankConversationId !== currentConv; + stripConversationPrefix(conversationId) { + if (!conversationId) return ''; + return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; + }, - if (isBlank) { - if (needNewWelcome && !this.blankHeroExiting) { - this.pickWelcomeText(); - } - this.blankHeroActive = true; - this.lastBlankConversationId = currentConv; - } else { - this.blankHeroActive = false; - this.blankHeroExiting = false; - this.lastBlankConversationId = null; - } - }, + initScrollListener() { + const messagesArea = this.getMessagesAreaElement(); + if (!messagesArea) { + console.warn('消息区域未找到'); + return; + } + this._scrollListenerReady = true; - toggleSettings() { - if (!this.isConnected) { - return; - } - this.modeMenuOpen = false; - this.modelMenuOpen = false; - const nextState = this.inputToggleSettingsMenu(); - if (nextState) { - this.inputSetToolMenuOpen(false); - if (!this.quickMenuOpen) { - this.inputOpenQuickMenu(); - } - } - }, + let isProgrammaticScroll = false; + const bottomThreshold = 12; - toggleFocusPanel() { - this.rightCollapsed = !this.rightCollapsed; - if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { - this.rightWidth = this.minPanelWidth; - } - }, + this._setScrollingFlag = (flag) => { + isProgrammaticScroll = !!flag; + }; - addSystemMessage(content) { - console.log('[DEBUG_SYSTEM][ui] addSystemMessage 入参', { content }); - const systemNoticeLabel = parseSystemNoticeLabel(content); - if (!systemNoticeLabel) { - // 其他 system 消息全部隐藏,且不进入渲染链路,避免出现空白块 - console.log('[DEBUG_SYSTEM][ui] addSystemMessage 被拦截(非完成通知)', { content }); - return; + messagesArea.addEventListener('scroll', () => { + if (isProgrammaticScroll) { + return; + } + + const scrollTop = messagesArea.scrollTop; + const scrollHeight = messagesArea.scrollHeight; + const clientHeight = messagesArea.clientHeight; + const isAtBottom = scrollHeight - scrollTop - clientHeight < bottomThreshold; + + const activeLock = this.autoScrollEnabled && this.isOutputActive(); + + // 锁定且当前有输出时,强制贴底 + if (activeLock) { + if (!isAtBottom) { + if (typeof this._setScrollingFlag === 'function') { + this._setScrollingFlag(true); + } + messagesArea.scrollTop = messagesArea.scrollHeight; + if (typeof this._setScrollingFlag === 'function') { + setTimeout(() => this._setScrollingFlag && this._setScrollingFlag(false), 16); + } } - console.log('[DEBUG_SYSTEM][ui] addSystemMessage 通过,写入 action', { - original: content, - normalizedLabel: systemNoticeLabel + // 保持锁定状态下 userScrolling 为 false + this.chatSetScrollState({ userScrolling: false }); + return; + } + + // 未锁定或无输出:允许自由滚动,仅记录位置 + this.chatSetScrollState({ userScrolling: !isAtBottom }); + }); + }, + + async initSocket() { + // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用 + this.socket = null; + }, + + async checkConnectionHealth() { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null; + try { + const response = await fetch('/api/status', { + method: 'GET', + cache: 'no-store', + signal: controller?.signal + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + this.isConnected = true; + this.connectionHeartbeatFailCount = 0; + } catch (error) { + this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1; + // 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 + this.isConnected = false; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + }, + + startConnectionHeartbeat() { + if (this.connectionHeartbeatTimer) { + return; + } + this.connectionHeartbeatFailCount = 0; + // 先做一次即时探活,再进入定时轮询 + this.checkConnectionHealth(); + this.connectionHeartbeatTimer = window.setInterval(() => { + this.checkConnectionHealth(); + }, this.connectionHeartbeatIntervalMs || 8000); + }, + + stopConnectionHeartbeat() { + if (this.connectionHeartbeatTimer) { + clearInterval(this.connectionHeartbeatTimer); + this.connectionHeartbeatTimer = null; + } + }, + + openRealtimeTerminal() { + const { protocol, hostname, port } = window.location; + const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`; + window.open(target, '_blank'); + }, + + async loadInitialData() { + try { + debugLog('加载初始数据...'); + + const statusResponse = await fetch('/api/status'); + if (!statusResponse.ok) { + throw new Error(`状态接口请求失败: ${statusResponse.status}`); + } + const statusData = await statusResponse.json(); + this.isConnected = true; + this.socket = null; + this.projectPath = statusData.project_path || ''; + this.agentVersion = statusData.version || this.agentVersion; + this.thinkingMode = !!statusData.thinking_mode; + this.applyStatusSnapshot(statusData); + // 立即更新配额和运行模式,避免等待其他慢接口 + this.fetchUsageQuota(); + const modelStore = useModelStore(); + await modelStore.fetchModels().catch((err) => { + console.warn('加载模型列表失败,使用前端默认列表:', err); + }); + if (statusData && typeof statusData.model_key === 'string') { + modelStore.setModel(statusData.model_key); + this.currentModelKey = modelStore.currentModelKey; + } + // 拉取管理员策略 + const policyStore = usePolicyStore(); + await policyStore.fetchPolicy(); + this.applyPolicyUiLocks(); + + // 加载个性化设置 + const personalizationStore = usePersonalizationStore(); + if (!personalizationStore.loaded && !personalizationStore.loading) { + await personalizationStore.fetchPersonalization().catch((err) => { + console.warn('加载个性化设置失败:', err); }); - this.chatAddSystemMessage(systemNoticeLabel, { variant: 'sub_agent_done' }); - this.$forceUpdate(); - this.conditionalScrollToBottom(); - }, + } - appendSystemAction(content) { - this.addSystemMessage(content); - }, + // 应用个性化设置中的默认模型和思考模式 + if (personalizationStore.loaded) { + const defaultRunMode = personalizationStore.form.default_run_mode; + const defaultModel = personalizationStore.form.default_model; - startTitleTyping(title: string, options: { animate?: boolean } = {}) { - if (this.titleTypingTimer) { - clearInterval(this.titleTypingTimer); - this.titleTypingTimer = null; - } - const target = (title || '').trim(); - const animate = options.animate ?? true; - if (!animate) { - this.titleTypingTarget = target; - this.titleTypingText = target; - return; - } - const previous = (this.titleTypingText || '').trim(); - if (previous === target) { - this.titleTypingTarget = target; - this.titleTypingText = target; - return; - } - this.titleTypingTarget = target; - this.titleTypingText = previous; - - const frames: string[] = []; - for (let i = previous.length; i >= 0; i--) { - frames.push(previous.slice(0, i)); - } - for (let j = 1; j <= target.length; j++) { - frames.push(target.slice(0, j)); + if (defaultRunMode) { + this.runMode = defaultRunMode; + debugLog('应用默认运行模式:', defaultRunMode); } - let index = 0; - this.titleTypingTimer = window.setInterval(() => { - if (index >= frames.length) { - clearInterval(this.titleTypingTimer!); - this.titleTypingTimer = null; - this.titleTypingText = target; - return; - } - this.titleTypingText = frames[index]; - index += 1; - }, 32); - }, - - toggleBlock(blockId) { - if (!blockId) { - return; + if (defaultModel) { + modelStore.setModel(defaultModel); + this.currentModelKey = modelStore.currentModelKey; + debugLog('应用默认模型:', defaultModel); } - if (this.expandedBlocks && this.expandedBlocks.has(blockId)) { - this.chatCollapseBlock(blockId); - } else { - this.chatExpandBlock(blockId); + + // 根据默认运行模式设置思考模式 + if (defaultRunMode === 'thinking') { + this.thinkingMode = true; + } else if (defaultRunMode === 'fast') { + this.thinkingMode = false; } - }, + } - handleThinkingScroll(blockId, event) { - if (!blockId || !event || !event.target) { - return; - } - const el = event.target; - const threshold = 12; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold; - this.chatSetThinkingLock(blockId, atBottom); - }, + const focusPromise = this.focusFetchFiles(); + const todoPromise = this.fileFetchTodoList(); + let treePromise: Promise | null = null; + const isHostMode = statusData?.container?.mode === 'host'; + if (isHostMode) { + this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); + } else { + treePromise = this.fileFetchTree(); + } - scrollToBottom() { - scrollToBottomHelper(this); - }, + // 获取当前对话信息 + const isExplicitNewRoute = this.isExplicitNewConversationRoute(); + const runningConversationId = await this.getRunningTaskConversationId(); + const statusConversationId = statusData.conversation && statusData.conversation.current_id; + const resumeConversationId = statusConversationId || runningConversationId; + const shouldResumeOnNewRoute = isExplicitNewRoute && !!runningConversationId; - conditionalScrollToBottom() { - conditionalScrollToBottomHelper(this); - }, - - toggleScrollLock() { - toggleScrollLockHelper(this); - }, - - scrollThinkingToBottom(blockId) { - scrollThinkingToBottomHelper(this, blockId); - }, - - // 面板调整方法 - startResize(panel, event) { - startPanelResize(this, panel, event); - }, - - handleResize(event) { - handlePanelResize(this, event); - }, - - stopResize() { - stopPanelResize(this); - }, - - getInputComposerRef() { - return this.$refs.inputComposer || null; - }, - - getComposerElement(field) { - const composer = this.getInputComposerRef(); - const unwrap = (value: any) => { - if (!value) { - return null; - } - if (value instanceof HTMLElement) { - return value; - } - if (typeof value === 'object' && 'value' in value && !(value instanceof Window)) { - return value.value; - } - return value; - }; - if (composer && composer[field]) { - return unwrap(composer[field]); - } - if (this.$refs && this.$refs[field]) { - return unwrap(this.$refs[field]); - } - return null; - }, - - getMessagesAreaElement() { - const ref = this.$refs.messagesArea; - if (!ref) { - return null; - } - if (ref instanceof HTMLElement) { - return ref; - } - if (ref.rootEl) { - return ref.rootEl.value || ref.rootEl; - } - if (ref.$el && ref.$el.querySelector) { - const el = ref.$el.querySelector('.messages-area'); - if (el) { - return el; - } - } - return null; - }, - - getThinkingContentElement(blockId) { - const chatArea = this.$refs.messagesArea; - if (chatArea && typeof chatArea.getThinkingRef === 'function') { - const el = chatArea.getThinkingRef(blockId); - if (el) { - return el; - } - } - const refName = `thinkingContent-${blockId}`; - const elRef = this.$refs[refName]; - if (Array.isArray(elRef)) { - return elRef[0] || null; - } - return elRef || null; - }, - - isOutputActive() { - return !!(this.streamingMessage || this.taskInProgress || this.hasPendingToolActions()); - }, - - logMessageState(action, extra = {}) { - const count = Array.isArray(this.messages) ? this.messages.length : 'N/A'; - debugLog('[Messages]', { - action, - count, - conversationId: this.currentConversationId, - streaming: this.streamingMessage, - ...extra - }); - }, - - iconStyle(iconKey, size) { - const iconPath = this.icons ? this.icons[iconKey] : null; - if (!iconPath) { - return {}; - } - const style = { '--icon-src': `url(${iconPath})` }; - if (size) { - style['--icon-size'] = size; - } - return style; - }, - - openGuiFileManager() { - if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) { - return; - } - window.open('/file-manager', '_blank'); - }, - - renderMarkdown(content, isStreaming = false) { - return renderMarkdownHelper(content, isStreaming); - }, - - isExplicitNewConversationRoute() { - const normalizedPath = window.location.pathname.replace(/^\/+|\/+$/g, ''); - return normalizedPath === 'new'; - }, - - async getRunningTaskConversationId() { - try { - const resp = await fetch('/api/tasks'); - if (!resp.ok) { - return null; - } - const payload = await resp.json(); - const tasks = Array.isArray(payload?.data) ? payload.data : []; - const runningTask = tasks.find((task: any) => task?.status === 'running' && task?.conversation_id); - return runningTask?.conversation_id || null; - } catch (error) { - console.warn('获取运行中任务失败:', error); - return null; - } - }, - - async bootstrapRoute() { - // 在路由解析期间抑制标题动画,避免预置"新对话"闪烁 + if ( + resumeConversationId && + !this.currentConversationId && + (!isExplicitNewRoute || shouldResumeOnNewRoute) + ) { + this.skipConversationHistoryReload = true; + // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 + this.skipConversationLoadedEvent = true; this.suppressTitleTyping = true; this.titleReady = false; this.currentConversationTitle = ''; this.titleTypingText = ''; - const path = window.location.pathname.replace(/^\/+/, ''); - if (!path || this.isExplicitNewConversationRoute()) { - this.currentConversationId = null; - this.currentConversationTitle = '新对话'; - this.logMessageState('bootstrapRoute:clear-messages-for-new'); - this.messages = []; + this.currentConversationId = resumeConversationId; + const pathFragment = this.stripConversationPrefix(resumeConversationId); + const currentPath = window.location.pathname.replace(/^\/+/, ''); + if (currentPath !== pathFragment) { + history.replaceState({ conversationId: resumeConversationId }, '', `/${pathFragment}`); + } + + // 如果有当前对话,尝试获取标题和历史 + try { + const convResponse = await fetch(`/api/conversations/current`); + const convData = await convResponse.json(); + if (convData.success && convData.data) { + this.currentConversationTitle = convData.data.title; this.titleReady = true; this.suppressTitleTyping = false; - this.startTitleTyping('新对话', { animate: false }); - this.initialRouteResolved = true; - this.refreshBlankHeroState(); - return; - } - - const convId = path.startsWith('conv_') ? path : `conv_${path}`; - try { - const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' }); - const result = await resp.json(); - if (result.success) { - this.currentConversationId = convId; - this.currentConversationTitle = result.title || ''; - this.titleReady = true; - this.suppressTitleTyping = false; - this.startTitleTyping(this.currentConversationTitle, { animate: false }); - history.replaceState({ conversationId: convId }, '', `/${this.stripConversationPrefix(convId)}`); - } else { - history.replaceState({}, '', '/new'); - this.currentConversationId = null; - this.currentConversationTitle = '新对话'; - this.titleReady = true; - this.suppressTitleTyping = false; - this.startTitleTyping('新对话', { animate: false }); - } - } catch (error) { - console.warn('初始化路由失败:', error); - history.replaceState({}, '', '/new'); - this.currentConversationId = null; - this.currentConversationTitle = '新对话'; + this.startTitleTyping(this.currentConversationTitle, { animate: false }); + } else { this.titleReady = true; this.suppressTitleTyping = false; - this.startTitleTyping('新对话', { animate: false }); - } finally { - this.initialRouteResolved = true; + const fallbackTitle = this.currentConversationTitle || '新对话'; + this.currentConversationTitle = fallbackTitle; + this.startTitleTyping(fallbackTitle, { animate: false }); + } + // 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发 + if ( + this.lastHistoryLoadedConversationId !== this.currentConversationId || + !Array.isArray(this.messages) || + this.messages.length === 0 + ) { + await this.fetchAndDisplayHistory(); + } + // 获取当前对话的Token统计 + this.fetchConversationTokenStatistics(); + this.updateCurrentContextTokens(); + } catch (e) { + console.warn('获取当前对话标题失败:', e); + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping(this.currentConversationTitle || '新对话', ''); } - }, + } - handlePopState(event) { - const state = event.state || {}; - const convId = state.conversationId; - if (!convId) { - this.currentConversationId = null; - this.currentConversationTitle = '新对话'; - this.logMessageState('handlePopState:clear-messages-no-conversation'); - this.messages = []; - this.logMessageState('handlePopState:after-clear-no-conversation'); - this.resetAllStates('handlePopState:no-conversation'); - this.resetTokenStatistics(); - return; - } - this.loadConversation(convId); - }, + // 等待其他加载项完成(允许部分失败不阻塞模式切换) + const pendingPromises = [focusPromise, todoPromise]; + if (treePromise) { + pendingPromises.push(treePromise); + } + await Promise.allSettled(pendingPromises); + await this.loadToolSettings(true); - stripConversationPrefix(conversationId) { - if (!conversationId) return ''; - return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; - }, + // 加载对话列表(修复:初始化时未加载对话列表的bug) + this.conversationsOffset = 0; + await this.loadConversationsList(); - initScrollListener() { - const messagesArea = this.getMessagesAreaElement(); - if (!messagesArea) { - console.warn('消息区域未找到'); - return; - } - this._scrollListenerReady = true; - - let isProgrammaticScroll = false; - const bottomThreshold = 12; - - this._setScrollingFlag = (flag) => { - isProgrammaticScroll = !!flag; - }; - - messagesArea.addEventListener('scroll', () => { - if (isProgrammaticScroll) { - return; - } - - const scrollTop = messagesArea.scrollTop; - const scrollHeight = messagesArea.scrollHeight; - const clientHeight = messagesArea.clientHeight; - const isAtBottom = scrollHeight - scrollTop - clientHeight < bottomThreshold; - - const activeLock = this.autoScrollEnabled && this.isOutputActive(); - - // 锁定且当前有输出时,强制贴底 - if (activeLock) { - if (!isAtBottom) { - if (typeof this._setScrollingFlag === 'function') { - this._setScrollingFlag(true); - } - messagesArea.scrollTop = messagesArea.scrollHeight; - if (typeof this._setScrollingFlag === 'function') { - setTimeout(() => this._setScrollingFlag && this._setScrollingFlag(false), 16); - } - } - // 保持锁定状态下 userScrolling 为 false - this.chatSetScrollState({ userScrolling: false }); - return; - } - - // 未锁定或无输出:允许自由滚动,仅记录位置 - this.chatSetScrollState({ userScrolling: !isAtBottom }); - }); - }, - - async initSocket() { - // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用 - this.socket = null; - }, - - async checkConnectionHealth() { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null; - try { - const response = await fetch('/api/status', { - method: 'GET', - cache: 'no-store', - signal: controller?.signal - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - this.isConnected = true; - this.connectionHeartbeatFailCount = 0; - } catch (error) { - this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1; - // 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 - this.isConnected = false; - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - }, - - startConnectionHeartbeat() { - if (this.connectionHeartbeatTimer) { - return; - } - this.connectionHeartbeatFailCount = 0; - // 先做一次即时探活,再进入定时轮询 - this.checkConnectionHealth(); - this.connectionHeartbeatTimer = window.setInterval(() => { - this.checkConnectionHealth(); - }, this.connectionHeartbeatIntervalMs || 8000); - }, - - stopConnectionHeartbeat() { - if (this.connectionHeartbeatTimer) { - clearInterval(this.connectionHeartbeatTimer); - this.connectionHeartbeatTimer = null; - } - }, - - openRealtimeTerminal() { - const { protocol, hostname, port } = window.location; - const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`; - window.open(target, '_blank'); - }, - - async loadInitialData() { - try { - debugLog('加载初始数据...'); - - const statusResponse = await fetch('/api/status'); - if (!statusResponse.ok) { - throw new Error(`状态接口请求失败: ${statusResponse.status}`); - } - const statusData = await statusResponse.json(); - this.isConnected = true; - this.socket = null; - this.projectPath = statusData.project_path || ''; - this.agentVersion = statusData.version || this.agentVersion; - this.thinkingMode = !!statusData.thinking_mode; - this.applyStatusSnapshot(statusData); - // 立即更新配额和运行模式,避免等待其他慢接口 - this.fetchUsageQuota(); - const modelStore = useModelStore(); - await modelStore.fetchModels().catch((err) => { - console.warn('加载模型列表失败,使用前端默认列表:', err); - }); - if (statusData && typeof statusData.model_key === 'string') { - modelStore.setModel(statusData.model_key); - this.currentModelKey = modelStore.currentModelKey; - } - // 拉取管理员策略 - const policyStore = usePolicyStore(); - await policyStore.fetchPolicy(); - this.applyPolicyUiLocks(); - - // 加载个性化设置 - const personalizationStore = usePersonalizationStore(); - if (!personalizationStore.loaded && !personalizationStore.loading) { - await personalizationStore.fetchPersonalization().catch(err => { - console.warn('加载个性化设置失败:', err); - }); - } - - // 应用个性化设置中的默认模型和思考模式 - if (personalizationStore.loaded) { - const defaultRunMode = personalizationStore.form.default_run_mode; - const defaultModel = personalizationStore.form.default_model; - - if (defaultRunMode) { - this.runMode = defaultRunMode; - debugLog('应用默认运行模式:', defaultRunMode); - } - - if (defaultModel) { - modelStore.setModel(defaultModel); - this.currentModelKey = modelStore.currentModelKey; - debugLog('应用默认模型:', defaultModel); - } - - // 根据默认运行模式设置思考模式 - if (defaultRunMode === 'thinking') { - this.thinkingMode = true; - } else if (defaultRunMode === 'fast') { - this.thinkingMode = false; - } - } - - const focusPromise = this.focusFetchFiles(); - const todoPromise = this.fileFetchTodoList(); - let treePromise: Promise | null = null; - const isHostMode = statusData?.container?.mode === 'host'; - if (isHostMode) { - this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); - } else { - treePromise = this.fileFetchTree(); - } - - // 获取当前对话信息 - const isExplicitNewRoute = this.isExplicitNewConversationRoute(); - const runningConversationId = await this.getRunningTaskConversationId(); - const statusConversationId = statusData.conversation && statusData.conversation.current_id; - const resumeConversationId = statusConversationId || runningConversationId; - const shouldResumeOnNewRoute = isExplicitNewRoute && !!runningConversationId; - - if ( - resumeConversationId && - !this.currentConversationId && - (!isExplicitNewRoute || shouldResumeOnNewRoute) - ) { - this.skipConversationHistoryReload = true; - // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 - this.skipConversationLoadedEvent = true; - this.suppressTitleTyping = true; - this.titleReady = false; - this.currentConversationTitle = ''; - this.titleTypingText = ''; - this.currentConversationId = resumeConversationId; - const pathFragment = this.stripConversationPrefix(resumeConversationId); - const currentPath = window.location.pathname.replace(/^\/+/, ''); - if (currentPath !== pathFragment) { - history.replaceState({ conversationId: resumeConversationId }, '', `/${pathFragment}`); - } - - // 如果有当前对话,尝试获取标题和历史 - try { - const convResponse = await fetch(`/api/conversations/current`); - const convData = await convResponse.json(); - if (convData.success && convData.data) { - this.currentConversationTitle = convData.data.title; - this.titleReady = true; - this.suppressTitleTyping = false; - this.startTitleTyping(this.currentConversationTitle, { animate: false }); - } else { - this.titleReady = true; - this.suppressTitleTyping = false; - const fallbackTitle = this.currentConversationTitle || '新对话'; - this.currentConversationTitle = fallbackTitle; - this.startTitleTyping(fallbackTitle, { animate: false }); - } - // 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发 - if ( - this.lastHistoryLoadedConversationId !== this.currentConversationId || - !Array.isArray(this.messages) || - this.messages.length === 0 - ) { - await this.fetchAndDisplayHistory(); - } - // 获取当前对话的Token统计 - this.fetchConversationTokenStatistics(); - this.updateCurrentContextTokens(); - } catch (e) { - console.warn('获取当前对话标题失败:', e); - this.titleReady = true; - this.suppressTitleTyping = false; - this.startTitleTyping(this.currentConversationTitle || '新对话', ''); - } - } - - // 等待其他加载项完成(允许部分失败不阻塞模式切换) - const pendingPromises = [focusPromise, todoPromise]; - if (treePromise) { - pendingPromises.push(treePromise); - } - await Promise.allSettled(pendingPromises); - await this.loadToolSettings(true); - - // 加载对话列表(修复:初始化时未加载对话列表的bug) - this.conversationsOffset = 0; - await this.loadConversationsList(); - - debugLog('初始数据加载完成'); - } catch (error) { - console.error('加载初始数据失败:', error); - this.isConnected = false; - } - }, - - confirmAction(options = {}) { - return this.uiRequestConfirm(options); - }, - - async handleCopyCodeClick(event) { - const target = event.target; - if (!target || !target.classList || !target.classList.contains('copy-code-btn')) { - return; - } - - const blockId = target.getAttribute('data-code'); - if (!blockId) { - return; - } - - // 防止重复点击 - if (target.classList.contains('copied')) { - return; - } - - const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`; - const codeEl = document.querySelector(selector); - if (!codeEl) { - return; - } - - const encoded = codeEl.getAttribute('data-original-code'); - const content = encoded ? this.decodeHtmlEntities(encoded) : codeEl.textContent || ''; - - if (!content.trim()) { - return; - } - - try { - await navigator.clipboard.writeText(content); - - // 添加 copied 类,切换为对勾图标 - target.classList.add('copied'); - - // 5秒后恢复 - setTimeout(() => { - target.classList.remove('copied'); - }, 5000); - } catch (error) { - console.warn('复制失败:', error); - } - }, - - decodeHtmlEntities(text) { - const textarea = document.createElement('textarea'); - textarea.innerHTML = text; - return textarea.value; + debugLog('初始数据加载完成'); + } catch (error) { + console.error('加载初始数据失败:', error); + this.isConnected = false; } + }, + + confirmAction(options = {}) { + return this.uiRequestConfirm(options); + }, + + async handleCopyCodeClick(event) { + const target = event.target; + if (!target || !target.classList || !target.classList.contains('copy-code-btn')) { + return; + } + + const blockId = target.getAttribute('data-code'); + if (!blockId) { + return; + } + + // 防止重复点击 + if (target.classList.contains('copied')) { + return; + } + + const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`; + const codeEl = document.querySelector(selector); + if (!codeEl) { + return; + } + + const encoded = codeEl.getAttribute('data-original-code'); + const content = encoded ? this.decodeHtmlEntities(encoded) : codeEl.textContent || ''; + + if (!content.trim()) { + return; + } + + try { + await navigator.clipboard.writeText(content); + + // 添加 copied 类,切换为对勾图标 + target.classList.add('copied'); + + // 5秒后恢复 + setTimeout(() => { + target.classList.remove('copied'); + }, 5000); + } catch (error) { + console.warn('复制失败:', error); + } + }, + + decodeHtmlEntities(text) { + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + return textarea.value; + } }; diff --git a/static/src/app/methods/upload.ts b/static/src/app/methods/upload.ts index d621b37..3b75a1a 100644 --- a/static/src/app/methods/upload.ts +++ b/static/src/app/methods/upload.ts @@ -3,398 +3,406 @@ import { usePolicyStore } from '../../stores/policy'; import { useModelStore } from '../../stores/model'; export const uploadMethods = { - triggerFileUpload() { - if (this.uploading) { - return; - } - const input = this.getComposerElement('fileUploadInput'); - if (input) { - input.click(); - } - }, - - handleFileSelected(files) { - const policyStore = usePolicyStore(); - if (policyStore.uiBlocks?.block_upload) { - this.uiPushToast({ - title: '上传被禁用', - message: '已被管理员禁用上传功能', - type: 'warning' - }); - return; - } - this.uploadHandleSelected(files); - }, - - normalizeLocalFiles(files) { - if (!files) return []; - const list = Array.isArray(files) ? files : Array.from(files); - return list.filter(Boolean); - }, - - isImageFile(file) { - const name = file?.name || ''; - const type = file?.type || ''; - return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name); - }, - - isVideoFile(file) { - const name = file?.name || ''; - const type = file?.type || ''; - return type.startsWith('video/') || /\.(mp4|mov|m4v|webm|avi|mkv|flv|mpg|mpeg)$/i.test(name); - }, - - upsertImageEntry(path, filename) { - if (!path) return; - const name = filename || path.split('/').pop() || path; - const list = Array.isArray(this.imageEntries) ? this.imageEntries : []; - if (list.some((item) => item.path === path)) { - return; - } - this.imageEntries = [{ name, path }, ...list]; - }, - - upsertVideoEntry(path, filename) { - if (!path) return; - const name = filename || path.split('/').pop() || path; - const list = Array.isArray(this.videoEntries) ? this.videoEntries : []; - if (list.some((item) => item.path === path)) { - return; - } - this.videoEntries = [{ name, path }, ...list]; - }, - - async handleLocalImageFiles(files) { - if (!this.isConnected) { - return; - } - if (this.mediaUploading) { - this.uiPushToast({ - title: '上传中', - message: '请等待当前图片上传完成', - type: 'info' - }); - return; - } - const list = this.normalizeLocalFiles(files); - if (!list.length) { - return; - } - const existingCount = Array.isArray(this.selectedImages) ? this.selectedImages.length : 0; - const remaining = Math.max(0, 9 - existingCount); - if (!remaining) { - this.uiPushToast({ - title: '已达上限', - message: '最多只能选择 9 张图片', - type: 'warning' - }); - return; - } - const valid = list.filter((file) => this.isImageFile(file)); - // 兼容 Android WebView 部分机型:返回的 File 可能缺失 type/扩展名,导致识别失败 - // 若来自图片选择器但未识别出有效类型,回退为“按选择结果直接上传”。 - const candidates = valid.length ? valid : list; - if (valid.length < list.length && valid.length > 0) { - this.uiPushToast({ - title: '已忽略', - message: '已跳过非图片文件', - type: 'info' - }); - } - const limited = candidates.slice(0, remaining); - if (candidates.length > remaining) { - this.uiPushToast({ - title: '已超出数量', - message: `最多还能添加 ${remaining} 张图片,已自动截断`, - type: 'warning' - }); - } - const uploaded = await this.uploadBatchFiles(limited, { - markUploading: true, - markMediaUploading: true - }); - if (!uploaded.length) { - return; - } - uploaded.forEach((item) => { - if (!item?.path) return; - this.inputAddSelectedImage(item.path); - this.upsertImageEntry(item.path, item.filename); - }); - // 上传完成后自动关闭选择窗口 - this.closeImagePicker(); - }, - - async handleLocalVideoFiles(files) { - if (!this.isConnected) { - return; - } - if (this.mediaUploading) { - this.uiPushToast({ - title: '上传中', - message: '请等待当前视频上传完成', - type: 'info' - }); - return; - } - const list = this.normalizeLocalFiles(files); - if (!list.length) { - return; - } - const valid = list.filter((file) => this.isVideoFile(file)); - // 兼容 Android WebView:文件元数据缺失时回退按选择结果直接上传 - const candidates = valid.length ? valid : list; - if (valid.length < list.length && valid.length > 0) { - this.uiPushToast({ - title: '已忽略', - message: '已跳过非视频文件', - type: 'info' - }); - } - if (candidates.length > 1) { - this.uiPushToast({ - title: '视频数量过多', - message: '一次只能选择 1 个视频,已使用第一个', - type: 'warning' - }); - } - const [file] = candidates; - if (!file) { - return; - } - const uploaded = await this.uploadBatchFiles([file], { - markUploading: true, - markMediaUploading: true - }); - const [item] = uploaded; - if (!item?.path) { - return; - } - this.inputSetSelectedVideos([item.path]); - this.inputClearSelectedImages(); - this.upsertVideoEntry(item.path, item.filename); - }, - - async openImagePicker() { - const modelStore = useModelStore(); - const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey); - if (!currentModel?.supportsImage) { - this.uiPushToast({ - title: '当前模型不支持图片', - message: '请选择支持图片输入的模型后再发送图片', - type: 'error' - }); - return; - } - this.closeQuickMenu(); - this.inputSetImagePickerOpen(true); - await this.loadWorkspaceImages(); - }, - - closeImagePicker() { - this.inputSetImagePickerOpen(false); - }, - - async openVideoPicker() { - const modelStore = useModelStore(); - const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey); - if (!currentModel?.supportsVideo) { - this.uiPushToast({ - title: '当前模型不支持视频', - message: '请切换到支持视频输入的模型后再发送视频', - type: 'error' - }); - return; - } - this.closeQuickMenu(); - this.inputSetVideoPickerOpen(true); - await this.loadWorkspaceVideos(); - }, - - closeVideoPicker() { - this.inputSetVideoPickerOpen(false); - }, - - async loadWorkspaceImages() { - this.imageLoading = true; - try { - const entries = await this.fetchAllImageEntries(''); - this.imageEntries = entries; - if (!entries.length) { - this.uiPushToast({ - title: '未找到图片', - message: '工作区内没有可用的图片文件', - type: 'info' - }); - } - } catch (error) { - console.error('加载图片列表失败', error); - this.uiPushToast({ - title: '加载图片失败', - message: error?.message || '请稍后重试', - type: 'error' - }); - } finally { - this.imageLoading = false; - } - }, - - async fetchAllImageEntries(startPath = '') { - const queue: string[] = [startPath || '']; - const visited = new Set(); - const results: Array<{ name: string; path: string }> = []; - const exts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg']); - const maxFolders = 120; - - while (queue.length && visited.size < maxFolders) { - const path = queue.shift() || ''; - if (visited.has(path)) { - continue; - } - visited.add(path); - try { - const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, { - method: 'GET', - credentials: 'include', - headers: { Accept: 'application/json' } - }); - const data = await resp.json().catch(() => null); - if (!data?.success) { - continue; - } - const items = Array.isArray(data?.data?.items) ? data.data.items : []; - for (const item of items) { - const rawPath = - item?.path || - [path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/'); - const type = String(item?.type || '').toLowerCase(); - if (type === 'directory' || type === 'folder') { - queue.push(rawPath); - continue; - } - const ext = - String(item?.extension || '').toLowerCase() || - (rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : ''); - if (exts.has(ext)) { - results.push({ - name: item?.name || rawPath.split('/').pop() || rawPath, - path: rawPath - }); - if (results.length >= 400) { - return results; - } - } - } - } catch (error) { - console.warn('遍历文件夹失败', path, error); - } - } - return results; - }, - - async fetchAllVideoEntries(startPath = '') { - const queue: string[] = [startPath || '']; - const visited = new Set(); - const results: Array<{ name: string; path: string }> = []; - const exts = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm']); - const maxFolders = 120; - - while (queue.length && visited.size < maxFolders) { - const path = queue.shift() || ''; - if (visited.has(path)) { - continue; - } - visited.add(path); - try { - const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, { - method: 'GET', - credentials: 'include', - headers: { Accept: 'application/json' } - }); - const data = await resp.json().catch(() => null); - if (!data?.success) { - continue; - } - const items = Array.isArray(data?.data?.items) ? data.data.items : []; - for (const item of items) { - const rawPath = - item?.path || - [path, item?.name].filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/{2,}/g, '/'); - const type = String(item?.type || '').toLowerCase(); - if (type === 'directory' || type === 'folder') { - queue.push(rawPath); - continue; - } - const ext = - String(item?.extension || '').toLowerCase() || - (rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : ''); - if (exts.has(ext)) { - results.push({ - name: item?.name || rawPath.split('/').pop() || rawPath, - path: rawPath - }); - if (results.length >= 200) { - return results; - } - } - } - } catch (error) { - console.warn('遍历文件夹失败', path, error); - } - } - return results; - }, - - async loadWorkspaceVideos() { - this.videoLoading = true; - try { - const entries = await this.fetchAllVideoEntries(''); - this.videoEntries = entries; - if (!entries.length) { - this.uiPushToast({ - title: '未找到视频', - message: '工作区内没有可用的视频文件', - type: 'info' - }); - } - } catch (error) { - console.error('加载视频列表失败', error); - this.uiPushToast({ - title: '加载视频失败', - message: error?.message || '请稍后重试', - type: 'error' - }); - } finally { - this.videoLoading = false; - } - }, - - handleImagesConfirmed(list) { - this.inputSetSelectedImages(Array.isArray(list) ? list : []); - this.inputSetImagePickerOpen(false); - }, - handleRemoveImage(path) { - this.inputRemoveSelectedImage(path); - }, - handleVideosConfirmed(list) { - const arr = Array.isArray(list) ? list.slice(0, 1) : []; - this.inputSetSelectedVideos(arr); - this.inputSetVideoPickerOpen(false); - if (arr.length) { - this.inputClearSelectedImages(); - } - }, - handleRemoveVideo(path) { - this.inputRemoveSelectedVideo(path); - }, - - handleQuickUpload() { - if (this.uploading || !this.isConnected) { - return; - } - if (this.isPolicyBlocked('block_upload', '上传功能已被管理员禁用')) { - return; - } - this.triggerFileUpload(); + triggerFileUpload() { + if (this.uploading) { + return; } + const input = this.getComposerElement('fileUploadInput'); + if (input) { + input.click(); + } + }, + + handleFileSelected(files) { + const policyStore = usePolicyStore(); + if (policyStore.uiBlocks?.block_upload) { + this.uiPushToast({ + title: '上传被禁用', + message: '已被管理员禁用上传功能', + type: 'warning' + }); + return; + } + this.uploadHandleSelected(files); + }, + + normalizeLocalFiles(files) { + if (!files) return []; + const list = Array.isArray(files) ? files : Array.from(files); + return list.filter(Boolean); + }, + + isImageFile(file) { + const name = file?.name || ''; + const type = file?.type || ''; + return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name); + }, + + isVideoFile(file) { + const name = file?.name || ''; + const type = file?.type || ''; + return type.startsWith('video/') || /\.(mp4|mov|m4v|webm|avi|mkv|flv|mpg|mpeg)$/i.test(name); + }, + + upsertImageEntry(path, filename) { + if (!path) return; + const name = filename || path.split('/').pop() || path; + const list = Array.isArray(this.imageEntries) ? this.imageEntries : []; + if (list.some((item) => item.path === path)) { + return; + } + this.imageEntries = [{ name, path }, ...list]; + }, + + upsertVideoEntry(path, filename) { + if (!path) return; + const name = filename || path.split('/').pop() || path; + const list = Array.isArray(this.videoEntries) ? this.videoEntries : []; + if (list.some((item) => item.path === path)) { + return; + } + this.videoEntries = [{ name, path }, ...list]; + }, + + async handleLocalImageFiles(files) { + if (!this.isConnected) { + return; + } + if (this.mediaUploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待当前图片上传完成', + type: 'info' + }); + return; + } + const list = this.normalizeLocalFiles(files); + if (!list.length) { + return; + } + const existingCount = Array.isArray(this.selectedImages) ? this.selectedImages.length : 0; + const remaining = Math.max(0, 9 - existingCount); + if (!remaining) { + this.uiPushToast({ + title: '已达上限', + message: '最多只能选择 9 张图片', + type: 'warning' + }); + return; + } + const valid = list.filter((file) => this.isImageFile(file)); + // 兼容 Android WebView 部分机型:返回的 File 可能缺失 type/扩展名,导致识别失败 + // 若来自图片选择器但未识别出有效类型,回退为“按选择结果直接上传”。 + const candidates = valid.length ? valid : list; + if (valid.length < list.length && valid.length > 0) { + this.uiPushToast({ + title: '已忽略', + message: '已跳过非图片文件', + type: 'info' + }); + } + const limited = candidates.slice(0, remaining); + if (candidates.length > remaining) { + this.uiPushToast({ + title: '已超出数量', + message: `最多还能添加 ${remaining} 张图片,已自动截断`, + type: 'warning' + }); + } + const uploaded = await this.uploadBatchFiles(limited, { + markUploading: true, + markMediaUploading: true + }); + if (!uploaded.length) { + return; + } + uploaded.forEach((item) => { + if (!item?.path) return; + this.inputAddSelectedImage(item.path); + this.upsertImageEntry(item.path, item.filename); + }); + // 上传完成后自动关闭选择窗口 + this.closeImagePicker(); + }, + + async handleLocalVideoFiles(files) { + if (!this.isConnected) { + return; + } + if (this.mediaUploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待当前视频上传完成', + type: 'info' + }); + return; + } + const list = this.normalizeLocalFiles(files); + if (!list.length) { + return; + } + const valid = list.filter((file) => this.isVideoFile(file)); + // 兼容 Android WebView:文件元数据缺失时回退按选择结果直接上传 + const candidates = valid.length ? valid : list; + if (valid.length < list.length && valid.length > 0) { + this.uiPushToast({ + title: '已忽略', + message: '已跳过非视频文件', + type: 'info' + }); + } + if (candidates.length > 1) { + this.uiPushToast({ + title: '视频数量过多', + message: '一次只能选择 1 个视频,已使用第一个', + type: 'warning' + }); + } + const [file] = candidates; + if (!file) { + return; + } + const uploaded = await this.uploadBatchFiles([file], { + markUploading: true, + markMediaUploading: true + }); + const [item] = uploaded; + if (!item?.path) { + return; + } + this.inputSetSelectedVideos([item.path]); + this.inputClearSelectedImages(); + this.upsertVideoEntry(item.path, item.filename); + }, + + async openImagePicker() { + const modelStore = useModelStore(); + const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey); + if (!currentModel?.supportsImage) { + this.uiPushToast({ + title: '当前模型不支持图片', + message: '请选择支持图片输入的模型后再发送图片', + type: 'error' + }); + return; + } + this.closeQuickMenu(); + this.inputSetImagePickerOpen(true); + await this.loadWorkspaceImages(); + }, + + closeImagePicker() { + this.inputSetImagePickerOpen(false); + }, + + async openVideoPicker() { + const modelStore = useModelStore(); + const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey); + if (!currentModel?.supportsVideo) { + this.uiPushToast({ + title: '当前模型不支持视频', + message: '请切换到支持视频输入的模型后再发送视频', + type: 'error' + }); + return; + } + this.closeQuickMenu(); + this.inputSetVideoPickerOpen(true); + await this.loadWorkspaceVideos(); + }, + + closeVideoPicker() { + this.inputSetVideoPickerOpen(false); + }, + + async loadWorkspaceImages() { + this.imageLoading = true; + try { + const entries = await this.fetchAllImageEntries(''); + this.imageEntries = entries; + if (!entries.length) { + this.uiPushToast({ + title: '未找到图片', + message: '工作区内没有可用的图片文件', + type: 'info' + }); + } + } catch (error) { + console.error('加载图片列表失败', error); + this.uiPushToast({ + title: '加载图片失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + } finally { + this.imageLoading = false; + } + }, + + async fetchAllImageEntries(startPath = '') { + const queue: string[] = [startPath || '']; + const visited = new Set(); + const results: Array<{ name: string; path: string }> = []; + const exts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg']); + const maxFolders = 120; + + while (queue.length && visited.size < maxFolders) { + const path = queue.shift() || ''; + if (visited.has(path)) { + continue; + } + visited.add(path); + try { + const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, { + method: 'GET', + credentials: 'include', + headers: { Accept: 'application/json' } + }); + const data = await resp.json().catch(() => null); + if (!data?.success) { + continue; + } + const items = Array.isArray(data?.data?.items) ? data.data.items : []; + for (const item of items) { + const rawPath = + item?.path || + [path, item?.name] + .filter(Boolean) + .join('/') + .replace(/\\/g, '/') + .replace(/\/{2,}/g, '/'); + const type = String(item?.type || '').toLowerCase(); + if (type === 'directory' || type === 'folder') { + queue.push(rawPath); + continue; + } + const ext = + String(item?.extension || '').toLowerCase() || + (rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : ''); + if (exts.has(ext)) { + results.push({ + name: item?.name || rawPath.split('/').pop() || rawPath, + path: rawPath + }); + if (results.length >= 400) { + return results; + } + } + } + } catch (error) { + console.warn('遍历文件夹失败', path, error); + } + } + return results; + }, + + async fetchAllVideoEntries(startPath = '') { + const queue: string[] = [startPath || '']; + const visited = new Set(); + const results: Array<{ name: string; path: string }> = []; + const exts = new Set(['.mp4', '.mov', '.mkv', '.avi', '.webm']); + const maxFolders = 120; + + while (queue.length && visited.size < maxFolders) { + const path = queue.shift() || ''; + if (visited.has(path)) { + continue; + } + visited.add(path); + try { + const resp = await fetch(`/api/gui/files/entries?path=${encodeURIComponent(path)}`, { + method: 'GET', + credentials: 'include', + headers: { Accept: 'application/json' } + }); + const data = await resp.json().catch(() => null); + if (!data?.success) { + continue; + } + const items = Array.isArray(data?.data?.items) ? data.data.items : []; + for (const item of items) { + const rawPath = + item?.path || + [path, item?.name] + .filter(Boolean) + .join('/') + .replace(/\\/g, '/') + .replace(/\/{2,}/g, '/'); + const type = String(item?.type || '').toLowerCase(); + if (type === 'directory' || type === 'folder') { + queue.push(rawPath); + continue; + } + const ext = + String(item?.extension || '').toLowerCase() || + (rawPath.includes('.') ? `.${rawPath.split('.').pop()?.toLowerCase()}` : ''); + if (exts.has(ext)) { + results.push({ + name: item?.name || rawPath.split('/').pop() || rawPath, + path: rawPath + }); + if (results.length >= 200) { + return results; + } + } + } + } catch (error) { + console.warn('遍历文件夹失败', path, error); + } + } + return results; + }, + + async loadWorkspaceVideos() { + this.videoLoading = true; + try { + const entries = await this.fetchAllVideoEntries(''); + this.videoEntries = entries; + if (!entries.length) { + this.uiPushToast({ + title: '未找到视频', + message: '工作区内没有可用的视频文件', + type: 'info' + }); + } + } catch (error) { + console.error('加载视频列表失败', error); + this.uiPushToast({ + title: '加载视频失败', + message: error?.message || '请稍后重试', + type: 'error' + }); + } finally { + this.videoLoading = false; + } + }, + + handleImagesConfirmed(list) { + this.inputSetSelectedImages(Array.isArray(list) ? list : []); + this.inputSetImagePickerOpen(false); + }, + handleRemoveImage(path) { + this.inputRemoveSelectedImage(path); + }, + handleVideosConfirmed(list) { + const arr = Array.isArray(list) ? list.slice(0, 1) : []; + this.inputSetSelectedVideos(arr); + this.inputSetVideoPickerOpen(false); + if (arr.length) { + this.inputClearSelectedImages(); + } + }, + handleRemoveVideo(path) { + this.inputRemoveSelectedVideo(path); + }, + + handleQuickUpload() { + if (this.uploading || !this.isConnected) { + return; + } + if (this.isPolicyBlocked('block_upload', '上传功能已被管理员禁用')) { + return; + } + this.triggerFileUpload(); + } }; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 9635e5e..e0699b3 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -2,113 +2,113 @@ import { ICONS, TOOL_CATEGORY_ICON_MAP } from '../utils/icons'; export function dataState() { - return { - // 路由相关 - initialRouteResolved: false, - dropToolEvents: false, + return { + // 路由相关 + initialRouteResolved: false, + dropToolEvents: false, - // 轮询模式标志(禁用 WebSocket 事件处理) - usePollingMode: true, - // 后台子智能体等待状态 - waitingForSubAgent: false, - // 后台 run_command 等待状态(用于文案区分) - waitingForBackgroundCommand: false, - // 是否在对话区展示 system 消息 - hideSystemMessages: true, + // 轮询模式标志(禁用 WebSocket 事件处理) + usePollingMode: true, + // 后台子智能体等待状态 + waitingForSubAgent: false, + // 后台 run_command 等待状态(用于文案区分) + waitingForBackgroundCommand: false, + // 是否在对话区展示 system 消息 + hideSystemMessages: true, - // 工具状态跟踪 - preparingTools: new Map(), - activeTools: new Map(), - toolActionIndex: new Map(), - toolStacks: new Map(), - // 当前任务是否仍在进行中(用于保持输入区的"停止"状态) - taskInProgress: false, - // 等待后台通知期间,用于发现并接管新建任务的轮询定时器 - waitingTaskProbeTimer: null, - // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 - lastHistoryLoadedConversationId: null, + // 工具状态跟踪 + preparingTools: new Map(), + activeTools: new Map(), + toolActionIndex: new Map(), + toolStacks: new Map(), + // 当前任务是否仍在进行中(用于保持输入区的"停止"状态) + taskInProgress: false, + // 等待后台通知期间,用于发现并接管新建任务的轮询定时器 + waitingTaskProbeTimer: null, + // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 + lastHistoryLoadedConversationId: null, - // ========================================== - // 对话管理相关状态 - // ========================================== + // ========================================== + // 对话管理相关状态 + // ========================================== - // 搜索功能 - // ========================================== - searchRequestSeq: 0, - searchActiveQuery: '', - searchResultIdSet: new Set(), - searchPreviewCache: {}, + // 搜索功能 + // ========================================== + searchRequestSeq: 0, + searchActiveQuery: '', + searchResultIdSet: new Set(), + searchPreviewCache: {}, - // Token统计相关状态(修复版) - // ========================================== + // Token统计相关状态(修复版) + // ========================================== - // 对话压缩状态 - compressing: false, - compressionInProgress: false, - compressionMode: '', - compressionStage: '', - compressionError: '', - compressionToastId: null, - skipConversationLoadedEvent: false, - skipConversationHistoryReload: false, - _scrollListenerReady: false, - historyLoading: false, - historyLoadingFor: null, - historyLoadSeq: 0, - blankHeroActive: false, - blankHeroExiting: false, - blankWelcomeText: '', - lastBlankConversationId: null, - // 对话标题打字效果 - titleTypingText: '', - titleTypingTarget: '', - titleTypingTimer: null, - titleReady: false, - suppressTitleTyping: false, - headerMenuOpen: false, - blankWelcomePool: [ - '有什么可以帮忙的?', - '想了解些热点吗?', - '要我帮你完成作业吗?', - '整点代码?', - '随便聊点什么?', - '想让我帮你整理一下思路吗?', - '要不要我帮你写个小工具?', - '发我一句话,我来接着做。' - ], - mobileViewportQuery: null, - modeMenuOpen: false, - modelMenuOpen: false, - imageEntries: [], - imageLoading: false, - videoEntries: [], - videoLoading: false, - conversationHasImages: false, - conversationHasVideos: false, - conversationListRequestSeq: 0, - conversationListRefreshToken: 0, - connectionHeartbeatTimer: null, - connectionHeartbeatFailCount: 0, - connectionHeartbeatIntervalMs: 8000, + // 对话压缩状态 + compressing: false, + compressionInProgress: false, + compressionMode: '', + compressionStage: '', + compressionError: '', + compressionToastId: null, + skipConversationLoadedEvent: false, + skipConversationHistoryReload: false, + _scrollListenerReady: false, + historyLoading: false, + historyLoadingFor: null, + historyLoadSeq: 0, + blankHeroActive: false, + blankHeroExiting: false, + blankWelcomeText: '', + lastBlankConversationId: null, + // 对话标题打字效果 + titleTypingText: '', + titleTypingTarget: '', + titleTypingTimer: null, + titleReady: false, + suppressTitleTyping: false, + headerMenuOpen: false, + blankWelcomePool: [ + '有什么可以帮忙的?', + '想了解些热点吗?', + '要我帮你完成作业吗?', + '整点代码?', + '随便聊点什么?', + '想让我帮你整理一下思路吗?', + '要不要我帮你写个小工具?', + '发我一句话,我来接着做。' + ], + mobileViewportQuery: null, + modeMenuOpen: false, + modelMenuOpen: false, + imageEntries: [], + imageLoading: false, + videoEntries: [], + videoLoading: false, + conversationHasImages: false, + conversationHasVideos: false, + conversationListRequestSeq: 0, + conversationListRefreshToken: 0, + connectionHeartbeatTimer: null, + connectionHeartbeatFailCount: 0, + connectionHeartbeatIntervalMs: 8000, - // 工具控制菜单 - icons: ICONS, - toolCategoryIcons: TOOL_CATEGORY_ICON_MAP, + // 工具控制菜单 + icons: ICONS, + toolCategoryIcons: TOOL_CATEGORY_ICON_MAP, - // 对话回顾 - reviewDialogOpen: false, - reviewSelectedConversationId: null, - reviewSubmitting: false, - reviewPreviewLines: [], - reviewPreviewLoading: false, - reviewPreviewError: null, - reviewPreviewLimit: 20, - reviewSendToModel: true, - reviewGeneratedPath: null, + // 对话回顾 + reviewDialogOpen: false, + reviewSelectedConversationId: null, + reviewSubmitting: false, + reviewPreviewLines: [], + reviewPreviewLoading: false, + reviewPreviewError: null, + reviewPreviewLimit: 20, + reviewSendToModel: true, + reviewGeneratedPath: null, - // 新手教程首次引导弹窗 - tutorialPromptVisible: false, - tutorialPromptLoading: false, - tutorialPromptUsername: '' - }; + // 新手教程首次引导弹窗 + tutorialPromptVisible: false, + tutorialPromptLoading: false, + tutorialPromptUsername: '' + }; } diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index e7a8f90..f7f7247 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -2,61 +2,70 @@ import { debugLog, traceLog } from './methods/common'; export const watchers = { - inputMessage() { - this.autoResizeInput(); - }, - messages: { - deep: true, - handler() { - this.refreshBlankHeroState(); - } - }, - currentConversationTitle(newVal, oldVal) { - const target = (newVal && newVal.trim()) || ''; - if (this.suppressTitleTyping) { - this.titleTypingText = target; - this.titleTypingTarget = target; - return; - } - const previous = (oldVal && oldVal.trim()) || (this.titleTypingText && this.titleTypingText.trim()) || ''; - const placeholderPrev = !previous || previous === '新对话'; - const placeholderTarget = !target || target === '新对话'; - const animate = placeholderPrev && !placeholderTarget; // 仅从空/占位切换到真实标题时动画 - this.startTitleTyping(target, { animate }); - }, - currentConversationId: { - immediate: false, - handler(newValue, oldValue) { - debugLog('currentConversationId 变化', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload }); - traceLog('watch:currentConversationId', { - oldValue, - newValue, - skipConversationHistoryReload: this.skipConversationHistoryReload, - historyLoading: this.historyLoading, - historyLoadingFor: this.historyLoadingFor, - historyLoadSeq: this.historyLoadSeq - }); - this.refreshBlankHeroState(); - this.logMessageState('watch:currentConversationId', { oldValue, newValue, skipConversationHistoryReload: this.skipConversationHistoryReload }); - if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) { - return; - } - if (this.skipConversationHistoryReload) { - this.skipConversationHistoryReload = false; - return; - } - if (oldValue && newValue === oldValue) { - return; - } - this.fetchAndDisplayHistory(); - this.fetchConversationTokenStatistics(); - this.updateCurrentContextTokens(); - } - }, - fileTree: { - immediate: true, - handler(newValue) { - this.monitorSyncDesktop(newValue); - } + inputMessage() { + this.autoResizeInput(); + }, + messages: { + deep: true, + handler() { + this.refreshBlankHeroState(); } + }, + currentConversationTitle(newVal, oldVal) { + const target = (newVal && newVal.trim()) || ''; + if (this.suppressTitleTyping) { + this.titleTypingText = target; + this.titleTypingTarget = target; + return; + } + const previous = + (oldVal && oldVal.trim()) || (this.titleTypingText && this.titleTypingText.trim()) || ''; + const placeholderPrev = !previous || previous === '新对话'; + const placeholderTarget = !target || target === '新对话'; + const animate = placeholderPrev && !placeholderTarget; // 仅从空/占位切换到真实标题时动画 + this.startTitleTyping(target, { animate }); + }, + currentConversationId: { + immediate: false, + handler(newValue, oldValue) { + debugLog('currentConversationId 变化', { + oldValue, + newValue, + skipConversationHistoryReload: this.skipConversationHistoryReload + }); + traceLog('watch:currentConversationId', { + oldValue, + newValue, + skipConversationHistoryReload: this.skipConversationHistoryReload, + historyLoading: this.historyLoading, + historyLoadingFor: this.historyLoadingFor, + historyLoadSeq: this.historyLoadSeq + }); + this.refreshBlankHeroState(); + this.logMessageState('watch:currentConversationId', { + oldValue, + newValue, + skipConversationHistoryReload: this.skipConversationHistoryReload + }); + if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) { + return; + } + if (this.skipConversationHistoryReload) { + this.skipConversationHistoryReload = false; + return; + } + if (oldValue && newValue === oldValue) { + return; + } + this.fetchAndDisplayHistory(); + this.fetchConversationTokenStatistics(); + this.updateCurrentContextTokens(); + } + }, + fileTree: { + immediate: true, + handler(newValue) { + this.monitorSyncDesktop(newValue); + } + } }; diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index aaed752..881646f 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -20,1675 +20,1687 @@ import { io as createSocketClient } from 'socket.io-client'; import { renderLatexInRealtime } from './useMarkdownRenderer'; export async function initializeLegacySocket(ctx: any) { - try { - const SOCKET_DEBUG_LOGS_ENABLED = true; - const socketLog = (...args: any[]) => { - if (!SOCKET_DEBUG_LOGS_ENABLED) { - return; - } - console.log(...args); - }; + try { + const SOCKET_DEBUG_LOGS_ENABLED = true; + const socketLog = (...args: any[]) => { + if (!SOCKET_DEBUG_LOGS_ENABLED) { + return; + } + console.log(...args); + }; - socketLog('初始化WebSocket连接...'); + socketLog('初始化WebSocket连接...'); - const socketOptions = { - transports: ['websocket', 'polling'], - autoConnect: false - }; + const socketOptions = { + transports: ['websocket', 'polling'], + autoConnect: false + }; - ctx.socket = createSocketClient('/', socketOptions); + ctx.socket = createSocketClient('/', socketOptions); - // 可开关的逐字打印功能,默认关闭 - const STREAMING_ENABLED = false; - const STREAMING_CHAR_DELAY = 22; - const STREAMING_FINALIZE_DELAY = 1000; - const STREAMING_DEBUG = false; - const STREAMING_DEBUG_HISTORY_LIMIT = 2000; - const streamingState = { - buffer: [] as string[], - timer: null as number | null, - completionTimer: null as number | null, - apiCompleted: false, - pendingCompleteContent: '' as string, - renderedText: '' as string, - activeMessageIndex: null as number | null, - activeTextAction: null as any, - ignoreThinking: false - }; + // 可开关的逐字打印功能,默认关闭 + const STREAMING_ENABLED = false; + const STREAMING_CHAR_DELAY = 22; + const STREAMING_FINALIZE_DELAY = 1000; + const STREAMING_DEBUG = false; + const STREAMING_DEBUG_HISTORY_LIMIT = 2000; + const streamingState = { + buffer: [] as string[], + timer: null as number | null, + completionTimer: null as number | null, + apiCompleted: false, + pendingCompleteContent: '' as string, + renderedText: '' as string, + activeMessageIndex: null as number | null, + activeTextAction: null as any, + ignoreThinking: false + }; - const pendingToolEvents: Array<{ event: string; data: any; handler: () => void }> = []; + const pendingToolEvents: Array<{ event: string; data: any; handler: () => void }> = []; - const startIntentTyping = (action: any, intentText: any) => { - if (!action || !action.tool) { - return; - } - if (typeof intentText !== 'string') { - return; - } - const text = intentText.trim(); - if (!text) { - return; - } - if (action.tool.intent_full === text) { - if (console && console.debug) { - console.debug('[tool_intent] skip (same text)', { - id: action?.id || action?.tool?.id, - name: action?.tool?.name, - text - }); - } - return; - } - if (typeof console !== 'undefined' && console.debug) { - console.debug('[tool_intent] typing start', { - id: action?.id || action?.tool?.id, - name: action?.tool?.name, - text - }); - } - if (action.tool.intent_full === text && action.tool.intent_rendered === text) { - return; - } - // 清理旧定时器 - if (action.tool.intent_typing_timer) { - clearInterval(action.tool.intent_typing_timer); - action.tool.intent_typing_timer = null; - } - action.tool.intent_full = text; - action.tool.intent_rendered = ''; - const duration = 1000; - const step = Math.max(10, Math.floor(duration / Math.max(1, text.length))); - let index = 0; - action.tool.intent_typing_timer = window.setInterval(() => { - action.tool.intent_rendered = text.slice(0, index + 1); - if (index === 0 && console && console.debug) { - console.debug('[tool_intent] typing tick', { - id: action?.id || action?.tool?.id, - name: action?.tool?.name, - next: action.tool.intent_rendered - }); - } - index += 1; - if (index >= text.length) { - clearInterval(action.tool.intent_typing_timer); - action.tool.intent_typing_timer = null; - action.tool.intent_rendered = text; - if (console && console.debug) { - console.debug('[tool_intent] typing done', { - id: action?.id || action?.tool?.id, - name: action?.tool?.name, - full: text - }); - } - } - if (typeof ctx?.$forceUpdate === 'function') { - ctx.$forceUpdate(); - } - }, step); - }; - - const hasPendingStreamingText = () => - STREAMING_ENABLED && - ( !!streamingState.buffer.length || - !!streamingState.pendingCompleteContent || - streamingState.timer !== null || - streamingState.completionTimer !== null); - - const resetPendingToolEvents = () => { - pendingToolEvents.length = 0; - }; - - const flushPendingToolEvents = () => { - if (!pendingToolEvents.length) { - return; - } - const queue = pendingToolEvents.splice(0); - queue.forEach((item) => { - try { - item.handler(); - } catch (error) { - console.warn('延后工具事件处理失败:', item.event, error); - } - }); - }; - - const snapshotStreamingState = () => ({ - bufferLength: streamingState.buffer.length, - timerActive: streamingState.timer !== null, - completionTimerActive: streamingState.completionTimer !== null, - apiCompleted: streamingState.apiCompleted, - pendingLength: (streamingState.pendingCompleteContent || '').length, - renderedLength: streamingState.renderedText.length, - currentMessageIndex: - typeof ctx?.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null, - messagesLength: Array.isArray(ctx?.messages) ? ctx.messages.length : null, - streamingMessage: !!ctx?.streamingMessage - }); - - const streamingDebugHistory: Array = []; - - const sanitizeBubbleText = (text: unknown) => { - if (typeof text !== 'string') { - return ''; - } - return text.replace(/\r/g, ''); - }; - - const getActiveMessage = () => { - if (streamingState.activeMessageIndex === null) { - return null; - } - const messages = ctx?.messages; - if (!Array.isArray(messages)) { - return null; - } - return messages[streamingState.activeMessageIndex] || null; - }; - - const ensureActiveMessageBinding = () => { - if ( - streamingState.activeMessageIndex === null && - typeof ctx?.currentMessageIndex === 'number' && - ctx.currentMessageIndex >= 0 - ) { - streamingState.activeMessageIndex = ctx.currentMessageIndex; - } - if ( - typeof ctx?.currentMessageIndex !== 'number' || - ctx.currentMessageIndex < 0 - ) { - if (streamingState.activeMessageIndex !== null) { - ctx.currentMessageIndex = streamingState.activeMessageIndex; - } - } - if (!ctx.streamingMessage) { - ctx.streamingMessage = true; - } - const msg = getActiveMessage(); - if (!msg && Array.isArray(ctx?.messages) && ctx.messages.length) { - streamingState.activeMessageIndex = ctx.messages.length - 1; - } - return getActiveMessage(); - }; - - const ensureActiveTextAction = () => { - const msg = getActiveMessage(); - if (!msg || !Array.isArray(msg.actions) || !msg.actions.length) { - return null; - } - const known = streamingState.activeTextAction; - if (known && msg.actions.includes(known)) { - return known; - } - for (let i = msg.actions.length - 1; i >= 0; i--) { - const action = msg.actions[i]; - if (action && action.type === 'text') { - streamingState.activeTextAction = action; - return action; - } - } - return null; - }; - - const fallbackAppendToActiveMessage = (text: string) => { - const msg = ensureActiveMessageBinding(); - if (!msg) { - return null; - } - if (typeof msg.streamingText !== 'string') { - msg.streamingText = ''; - } - msg.streamingText += text; - const action = ensureActiveTextAction(); - if (!action) { - return null; - } - if (typeof action.content !== 'string') { - action.content = ''; - } - action.content += text; - action.streaming = action.streaming !== false; - streamingState.activeTextAction = action; - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - renderLatexInRealtime(); - return action; - }; - - const completeActiveTextAction = (fullContent: string) => { - const msg = ensureActiveMessageBinding(); - if (!msg) { - return false; - } - const action = ensureActiveTextAction(); - if (action) { - action.streaming = false; - const fallback = - (typeof fullContent === 'string' && fullContent.length - ? fullContent - : action.content) || ''; - action.content = fallback; - } - msg.streamingText = ''; - msg.currentStreamingType = null; - return !!action; - }; - - const logStreamingDebug = (event: string, detail?: any) => { - if (!STREAMING_DEBUG) { - return; - } - const payload = typeof detail === 'undefined' ? snapshotStreamingState() : detail; - const entry = { - event, - detail: payload, - conversationId: ctx.currentConversationId || null, - timestamp: Date.now() - }; - streamingDebugHistory.push(entry); - if (streamingDebugHistory.length > STREAMING_DEBUG_HISTORY_LIMIT) { - streamingDebugHistory.shift(); - } - try { - window.__streamingDebugLogs = streamingDebugHistory.slice(); - } catch (error) { - // ignore - } - console.log('[streaming-debug]', event, payload); - try { - if (ctx.socket && typeof ctx.socket.emit === 'function') { - ctx.socket.emit('client_stream_debug_log', entry); - } - } catch (error) { - console.warn('上报 streaming debug 日志失败:', error); - } - }; - - const markStreamingIdleIfPossible = (source: string) => { - try { - if (!ctx) { - return; - } - if (typeof ctx.maybeResetStreamingState === 'function') { - const reset = ctx.maybeResetStreamingState(source); - if (reset) { - logStreamingDebug('streaming_idle_reset', { source }); - } - return; - } - if (!ctx.streamingMessage) { - return; - } - const hasPending = - typeof ctx.hasPendingToolActions === 'function' - ? ctx.hasPendingToolActions() - : false; - if (!hasPending) { - ctx.streamingMessage = false; - ctx.stopRequested = false; - logStreamingDebug('streaming_idle_reset:fallback', { source }); - } - } catch (error) { - console.warn('自动结束流式状态失败:', error); - } - }; - - const stopStreamingTimer = () => { - if (streamingState.timer !== null) { - clearTimeout(streamingState.timer); - streamingState.timer = null; - logStreamingDebug('stopStreamingTimer', snapshotStreamingState()); - } - }; - - const stopCompletionTimer = () => { - if (streamingState.completionTimer !== null) { - clearTimeout(streamingState.completionTimer); - streamingState.completionTimer = null; - logStreamingDebug('stopCompletionTimer', snapshotStreamingState()); - } - }; - - const finalizeStreamingText = (options?: { force?: boolean; allowIncomplete?: boolean }) => { - const forceFlush = !!options?.force; - const allowIncomplete = !!options?.allowIncomplete; - logStreamingDebug('finalizeStreamingText:start', { forceFlush, allowIncomplete, snapshot: snapshotStreamingState() }); - if (!forceFlush) { - if (streamingState.buffer.length) { - logStreamingDebug('finalizeStreamingText:blocked-buffer', snapshotStreamingState()); - return false; - } - if (!allowIncomplete && !streamingState.apiCompleted) { - logStreamingDebug('finalizeStreamingText:blocked-api-incomplete', snapshotStreamingState()); - return false; - } - } - stopStreamingTimer(); - stopCompletionTimer(); - if (forceFlush && streamingState.buffer.length) { - const remainder = streamingState.buffer.join(''); - streamingState.buffer.length = 0; - if (remainder) { - applyTextChunk(remainder); - logStreamingDebug('finalizeStreamingText:force-flush-buffer', { flushedChars: remainder.length }); - } - } - const pendingText = streamingState.pendingCompleteContent || ''; - const renderedText = streamingState.renderedText || ''; - let finalText = pendingText || renderedText || ''; - let remainderToAppend = ''; - if (!pendingText) { - finalText = renderedText; - } else if (!renderedText) { - finalText = pendingText; - remainderToAppend = pendingText; - } else if (pendingText.length < renderedText.length) { - // 后端返回的最终内容比已渲染的还短,避免覆盖已展示的字符 - finalText = renderedText; - } else if (pendingText.startsWith(renderedText)) { - finalText = pendingText; - remainderToAppend = pendingText.slice(renderedText.length); - } else { - finalText = pendingText; - } - logStreamingDebug('finalizeStreamingText:resolved-final-text', { - pendingLength: pendingText.length, - renderedLength: renderedText.length, - remainderToAppendLength: remainderToAppend.length, - finalLength: finalText.length - }); - if (remainderToAppend) { - applyTextChunk(remainderToAppend); - } - streamingState.pendingCompleteContent = ''; - streamingState.apiCompleted = false; - streamingState.renderedText = ''; - ctx.chatCompleteTextAction(finalText || ''); - completeActiveTextAction(finalText || ''); - ctx.$forceUpdate(); - // 注意:不在这里重置 streamingMessage,因为可能还有工具调用在进行 - // streamingMessage 只在 task_complete 事件时重置 - logStreamingDebug('finalizeStreamingText:complete', snapshotStreamingState()); - streamingState.activeMessageIndex = null; - streamingState.activeTextAction = null; - markStreamingIdleIfPossible('finalizeStreamingText'); - flushPendingToolEvents(); - return true; - }; - - const scheduleFinalizationAfterDrain = () => { - if (streamingState.buffer.length) { - logStreamingDebug('scheduleFinalizationAfterDrain:buffer-not-empty', snapshotStreamingState()); - return; - } - if (streamingState.completionTimer !== null) { - logStreamingDebug('scheduleFinalizationAfterDrain:already-scheduled', snapshotStreamingState()); - return; - } - logStreamingDebug('scheduleFinalizationAfterDrain:scheduled', snapshotStreamingState()); - streamingState.completionTimer = window.setTimeout(() => { - streamingState.completionTimer = null; - logStreamingDebug('scheduleFinalizationAfterDrain:timer-fired', snapshotStreamingState()); - finalizeStreamingText({ allowIncomplete: true }); - }, STREAMING_FINALIZE_DELAY); - }; - - const resetStreamingBuffer = () => { - stopStreamingTimer(); - stopCompletionTimer(); - streamingState.buffer.length = 0; - streamingState.apiCompleted = false; - streamingState.pendingCompleteContent = ''; - streamingState.renderedText = ''; - streamingState.activeMessageIndex = null; - streamingState.activeTextAction = null; - resetPendingToolEvents(); - logStreamingDebug('resetStreamingBuffer', snapshotStreamingState()); - }; - - const applyTextChunk = (text: string) => { - if (!text) { - return null; - } - ensureActiveMessageBinding(); - let action = ctx.chatAppendTextChunk(text); - if (action) { - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - renderLatexInRealtime(); - } else { - action = fallbackAppendToActiveMessage(text); - } - streamingState.renderedText += text; - const appended = !!action; - logStreamingDebug('applyTextChunk', { - chunkLength: text.length, - appended, - snapshot: snapshotStreamingState() - }); - if (!appended) { - logStreamingDebug('applyTextChunk:missing-target', { - chunkLength: text.length, - currentMessageIndex: - typeof ctx?.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null, - messagesLength: Array.isArray(ctx?.messages) ? ctx.messages.length : null - }); - } - return action; - }; - - const drainStreamingBufferImmediately = () => { - if (!streamingState.buffer.length) { - return; - } - const chunk = streamingState.buffer.join(''); - streamingState.buffer.length = 0; - applyTextChunk(chunk); - }; - - const scheduleStreamingFlush = () => { - const shouldFlushImmediately = typeof document !== 'undefined' && document.hidden === true; - if (shouldFlushImmediately) { - stopStreamingTimer(); - drainStreamingBufferImmediately(); - if (streamingState.apiCompleted) { - finalizeStreamingText({ force: true }); - } else { - scheduleFinalizationAfterDrain(); - } - return; - } - if (streamingState.timer !== null) { - logStreamingDebug('scheduleStreamingFlush:timer-exists', snapshotStreamingState()); - return; - } - if (!streamingState.buffer.length) { - logStreamingDebug('scheduleStreamingFlush:no-buffer', snapshotStreamingState()); - return; - } - logStreamingDebug('scheduleStreamingFlush:start', snapshotStreamingState()); - const process = () => { - streamingState.timer = null; - logStreamingDebug('scheduleStreamingFlush:tick', snapshotStreamingState()); - if (!streamingState.buffer.length) { - logStreamingDebug('scheduleStreamingFlush:buffer-empty', snapshotStreamingState()); - return; - } - const piece = streamingState.buffer.shift(); - if (piece) { - applyTextChunk(piece); - } - if (streamingState.buffer.length) { - scheduleStreamingFlush(); - } else { - logStreamingDebug('scheduleStreamingFlush:buffer-drained', snapshotStreamingState()); - scheduleFinalizationAfterDrain(); - } - }; - streamingState.timer = window.setTimeout(process, STREAMING_CHAR_DELAY); - }; - - const enqueueStreamingContent = (text: string) => { - if (!text) { - return; - } - stopCompletionTimer(); - if (!STREAMING_ENABLED) { - // 关闭逐字模式:整块入缓冲并立即刷新,保持与 chunk 顺序一致 - streamingState.pendingCompleteContent = ''; - streamingState.buffer.length = 0; - streamingState.apiCompleted = false; - applyTextChunk(text); - return; - } - logStreamingDebug('enqueueStreamingContent', { incomingLength: text.length }); - for (const ch of Array.from(text)) { - streamingState.buffer.push(ch); - } - logStreamingDebug('enqueueStreamingContent:buffered', snapshotStreamingState()); - scheduleStreamingFlush(); - }; - - - const scheduleHistoryReload = (delay = 0, options: { force?: boolean } = {}) => { - const { force = false } = options; - if (!ctx || typeof ctx.fetchAndDisplayHistory !== 'function') { - return; - } - setTimeout(() => { - const targetConversationId = ctx.currentConversationId; - if (!targetConversationId || targetConversationId.startsWith('temp_')) { - return; - } - try { - if (!force) { - const loadingSame = - !!ctx.historyLoading && ctx.historyLoadingFor === targetConversationId; - const historyReady = - ctx.lastHistoryLoadedConversationId === targetConversationId && - Array.isArray(ctx.messages) && - ctx.messages.length > 0; - if (loadingSame || historyReady) { - socketLog('跳过重复历史加载(scheduleHistoryReload)'); - return; - } - } - ctx.fetchAndDisplayHistory({ force }); - if (typeof ctx.fetchConversationTokenStatistics === 'function') { - ctx.fetchConversationTokenStatistics(); - } - if (typeof ctx.updateCurrentContextTokens === 'function') { - ctx.updateCurrentContextTokens(); - } - } catch (error) { - console.warn('重新加载对话历史失败:', error); - } - }, delay); - }; - - const assignSocketToken = async () => { - if (typeof window.requestSocketToken !== 'function') { - console.warn('缺少 requestSocketToken(),无法获取实时连接凭证'); - return false; - } - try { - const freshToken = await window.requestSocketToken(); - ctx.socket.auth = { socket_token: freshToken }; - return true; - } catch (error) { - console.error('获取 WebSocket token 失败:', error); - return false; - } - }; - - if (ctx.socket.io && typeof ctx.socket.io.on === 'function') { - ctx.socket.io.on('reconnect_attempt', async () => { - await assignSocketToken(); - }); + const startIntentTyping = (action: any, intentText: any) => { + if (!action || !action.tool) { + return; + } + if (typeof intentText !== 'string') { + return; + } + const text = intentText.trim(); + if (!text) { + return; + } + if (action.tool.intent_full === text) { + if (console && console.debug) { + console.debug('[tool_intent] skip (same text)', { + id: action?.id || action?.tool?.id, + name: action?.tool?.name, + text + }); } - - let hasConnectedOnce = false; - - // 连接事件 - ctx.socket.on('connect', () => { - const isReconnect = hasConnectedOnce; - const historyLoadingSame = - !!ctx.historyLoading && ctx.historyLoadingFor === ctx.currentConversationId; - const historyReady = - ctx.lastHistoryLoadedConversationId === ctx.currentConversationId && - Array.isArray(ctx.messages) && - ctx.messages.length > 0; - - ctx.isConnected = true; - socketLog('WebSocket已连接'); - - // 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画 - if (!isReconnect && (historyLoadingSame || historyReady)) { - socketLog('初次连接已存在历史,跳过重复重置与加载'); - hasConnectedOnce = true; - return; - } - - hasConnectedOnce = true; - ctx.resetAllStates(isReconnect ? 'socket:reconnect' : 'socket:connect'); - scheduleHistoryReload(200, { force: isReconnect }); + return; + } + if (typeof console !== 'undefined' && console.debug) { + console.debug('[tool_intent] typing start', { + id: action?.id || action?.tool?.id, + name: action?.tool?.name, + text }); - - ctx.socket.on('disconnect', () => { - ctx.isConnected = false; - socketLog('WebSocket已断开'); - // 断线时也重置状态,防止状态混乱 - ctx.resetAllStates('socket:disconnect'); - }); - - ctx.socket.on('connect_error', (error) => { - console.error('WebSocket连接错误:', error.message); - }); - - ctx.socket.on('quota_update', (data) => { - if (data && data.quotas) { - ctx.resourceSetUsageQuota({ quotas: data.quotas }); - } else { - ctx.fetchUsageQuota(); - } - }); - - ctx.socket.on('quota_notice', (data) => { - ctx.showQuotaToast(data || {}); - ctx.fetchUsageQuota(); - }); - - ctx.socket.on('quota_exceeded', (data) => { - ctx.showQuotaToast(data || {}); - ctx.fetchUsageQuota(); - }); - - ctx.socket.on('reconnect_attempt', async () => { - await assignSocketToken(); - }); - - const ready = await assignSocketToken(); - if (!ready) { - console.error('无法获取实时连接凭证,WebSocket 初始化中止。'); - return; + } + if (action.tool.intent_full === text && action.tool.intent_rendered === text) { + return; + } + // 清理旧定时器 + if (action.tool.intent_typing_timer) { + clearInterval(action.tool.intent_typing_timer); + action.tool.intent_typing_timer = null; + } + action.tool.intent_full = text; + action.tool.intent_rendered = ''; + const duration = 1000; + const step = Math.max(10, Math.floor(duration / Math.max(1, text.length))); + let index = 0; + action.tool.intent_typing_timer = window.setInterval(() => { + action.tool.intent_rendered = text.slice(0, index + 1); + if (index === 0 && console && console.debug) { + console.debug('[tool_intent] typing tick', { + id: action?.id || action?.tool?.id, + name: action?.tool?.name, + next: action.tool.intent_rendered + }); } - ctx.socket.connect(); + index += 1; + if (index >= text.length) { + clearInterval(action.tool.intent_typing_timer); + action.tool.intent_typing_timer = null; + action.tool.intent_rendered = text; + if (console && console.debug) { + console.debug('[tool_intent] typing done', { + id: action?.id || action?.tool?.id, + name: action?.tool?.name, + full: text + }); + } + } + if (typeof ctx?.$forceUpdate === 'function') { + ctx.$forceUpdate(); + } + }, step); + }; - // ========================================== - // Token统计WebSocket事件处理(修复版) - // ========================================== + const resetPendingToolEvents = () => { + pendingToolEvents.length = 0; + }; - ctx.socket.on('token_update', (data) => { - socketLog('收到token更新事件:', data); + const flushPendingToolEvents = () => { + if (!pendingToolEvents.length) { + return; + } + const queue = pendingToolEvents.splice(0); + queue.forEach((item) => { + try { + item.handler(); + } catch (error) { + console.warn('延后工具事件处理失败:', item.event, error); + } + }); + }; - // 只处理当前对话的token更新 - if (data.conversation_id === ctx.currentConversationId) { - // 更新累计统计(使用后端提供的准确字段名) - ctx.currentConversationTokens.cumulative_input_tokens = data.cumulative_input_tokens || 0; - ctx.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0; - ctx.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0; + const snapshotStreamingState = () => ({ + bufferLength: streamingState.buffer.length, + timerActive: streamingState.timer !== null, + completionTimerActive: streamingState.completionTimer !== null, + apiCompleted: streamingState.apiCompleted, + pendingLength: (streamingState.pendingCompleteContent || '').length, + renderedLength: streamingState.renderedText.length, + currentMessageIndex: + typeof ctx?.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null, + messagesLength: Array.isArray(ctx?.messages) ? ctx.messages.length : null, + streamingMessage: !!ctx?.streamingMessage + }); - socketLog(`累计Token统计更新: 输入=${data.cumulative_input_tokens}, 输出=${data.cumulative_output_tokens}, 总计=${data.cumulative_total_tokens}`); + const streamingDebugHistory: Array = []; - const hasContextTokens = typeof data.current_context_tokens === 'number'; - if (hasContextTokens && typeof ctx.resourceSetCurrentContextTokens === 'function') { - ctx.resourceSetCurrentContextTokens(data.current_context_tokens); - } else { - // 同时更新当前上下文Token(关键修复) - ctx.updateCurrentContextTokens(); - } + const sanitizeBubbleText = (text: unknown) => { + if (typeof text !== 'string') { + return ''; + } + return text.replace(/\r/g, ''); + }; - ctx.$forceUpdate(); - } + const getActiveMessage = () => { + if (streamingState.activeMessageIndex === null) { + return null; + } + const messages = ctx?.messages; + if (!Array.isArray(messages)) { + return null; + } + return messages[streamingState.activeMessageIndex] || null; + }; + + const ensureActiveMessageBinding = () => { + if ( + streamingState.activeMessageIndex === null && + typeof ctx?.currentMessageIndex === 'number' && + ctx.currentMessageIndex >= 0 + ) { + streamingState.activeMessageIndex = ctx.currentMessageIndex; + } + if (typeof ctx?.currentMessageIndex !== 'number' || ctx.currentMessageIndex < 0) { + if (streamingState.activeMessageIndex !== null) { + ctx.currentMessageIndex = streamingState.activeMessageIndex; + } + } + if (!ctx.streamingMessage) { + ctx.streamingMessage = true; + } + const msg = getActiveMessage(); + if (!msg && Array.isArray(ctx?.messages) && ctx.messages.length) { + streamingState.activeMessageIndex = ctx.messages.length - 1; + } + return getActiveMessage(); + }; + + const ensureActiveTextAction = () => { + const msg = getActiveMessage(); + if (!msg || !Array.isArray(msg.actions) || !msg.actions.length) { + return null; + } + const known = streamingState.activeTextAction; + if (known && msg.actions.includes(known)) { + return known; + } + for (let i = msg.actions.length - 1; i >= 0; i--) { + const action = msg.actions[i]; + if (action && action.type === 'text') { + streamingState.activeTextAction = action; + return action; + } + } + return null; + }; + + const fallbackAppendToActiveMessage = (text: string) => { + const msg = ensureActiveMessageBinding(); + if (!msg) { + return null; + } + if (typeof msg.streamingText !== 'string') { + msg.streamingText = ''; + } + msg.streamingText += text; + const action = ensureActiveTextAction(); + if (!action) { + return null; + } + if (typeof action.content !== 'string') { + action.content = ''; + } + action.content += text; + action.streaming = action.streaming !== false; + streamingState.activeTextAction = action; + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + renderLatexInRealtime(); + return action; + }; + + const completeActiveTextAction = (fullContent: string) => { + const msg = ensureActiveMessageBinding(); + if (!msg) { + return false; + } + const action = ensureActiveTextAction(); + if (action) { + action.streaming = false; + const fallback = + (typeof fullContent === 'string' && fullContent.length ? fullContent : action.content) || + ''; + action.content = fallback; + } + msg.streamingText = ''; + msg.currentStreamingType = null; + return !!action; + }; + + const logStreamingDebug = (event: string, detail?: any) => { + if (!STREAMING_DEBUG) { + return; + } + const payload = typeof detail === 'undefined' ? snapshotStreamingState() : detail; + const entry = { + event, + detail: payload, + conversationId: ctx.currentConversationId || null, + timestamp: Date.now() + }; + streamingDebugHistory.push(entry); + if (streamingDebugHistory.length > STREAMING_DEBUG_HISTORY_LIMIT) { + streamingDebugHistory.shift(); + } + try { + window.__streamingDebugLogs = streamingDebugHistory.slice(); + } catch (error) { + // ignore + } + console.log('[streaming-debug]', event, payload); + try { + if (ctx.socket && typeof ctx.socket.emit === 'function') { + ctx.socket.emit('client_stream_debug_log', entry); + } + } catch (error) { + console.warn('上报 streaming debug 日志失败:', error); + } + }; + + const markStreamingIdleIfPossible = (source: string) => { + try { + if (!ctx) { + return; + } + if (typeof ctx.maybeResetStreamingState === 'function') { + const reset = ctx.maybeResetStreamingState(source); + if (reset) { + logStreamingDebug('streaming_idle_reset', { source }); + } + return; + } + if (!ctx.streamingMessage) { + return; + } + const hasPending = + typeof ctx.hasPendingToolActions === 'function' ? ctx.hasPendingToolActions() : false; + if (!hasPending) { + ctx.streamingMessage = false; + ctx.stopRequested = false; + logStreamingDebug('streaming_idle_reset:fallback', { source }); + } + } catch (error) { + console.warn('自动结束流式状态失败:', error); + } + }; + + const stopStreamingTimer = () => { + if (streamingState.timer !== null) { + clearTimeout(streamingState.timer); + streamingState.timer = null; + logStreamingDebug('stopStreamingTimer', snapshotStreamingState()); + } + }; + + const stopCompletionTimer = () => { + if (streamingState.completionTimer !== null) { + clearTimeout(streamingState.completionTimer); + streamingState.completionTimer = null; + logStreamingDebug('stopCompletionTimer', snapshotStreamingState()); + } + }; + + const finalizeStreamingText = (options?: { force?: boolean; allowIncomplete?: boolean }) => { + const forceFlush = !!options?.force; + const allowIncomplete = !!options?.allowIncomplete; + logStreamingDebug('finalizeStreamingText:start', { + forceFlush, + allowIncomplete, + snapshot: snapshotStreamingState() + }); + if (!forceFlush) { + if (streamingState.buffer.length) { + logStreamingDebug('finalizeStreamingText:blocked-buffer', snapshotStreamingState()); + return false; + } + if (!allowIncomplete && !streamingState.apiCompleted) { + logStreamingDebug( + 'finalizeStreamingText:blocked-api-incomplete', + snapshotStreamingState() + ); + return false; + } + } + stopStreamingTimer(); + stopCompletionTimer(); + if (forceFlush && streamingState.buffer.length) { + const remainder = streamingState.buffer.join(''); + streamingState.buffer.length = 0; + if (remainder) { + applyTextChunk(remainder); + logStreamingDebug('finalizeStreamingText:force-flush-buffer', { + flushedChars: remainder.length + }); + } + } + const pendingText = streamingState.pendingCompleteContent || ''; + const renderedText = streamingState.renderedText || ''; + let finalText = pendingText || renderedText || ''; + let remainderToAppend = ''; + if (!pendingText) { + finalText = renderedText; + } else if (!renderedText) { + finalText = pendingText; + remainderToAppend = pendingText; + } else if (pendingText.length < renderedText.length) { + // 后端返回的最终内容比已渲染的还短,避免覆盖已展示的字符 + finalText = renderedText; + } else if (pendingText.startsWith(renderedText)) { + finalText = pendingText; + remainderToAppend = pendingText.slice(renderedText.length); + } else { + finalText = pendingText; + } + logStreamingDebug('finalizeStreamingText:resolved-final-text', { + pendingLength: pendingText.length, + renderedLength: renderedText.length, + remainderToAppendLength: remainderToAppend.length, + finalLength: finalText.length + }); + if (remainderToAppend) { + applyTextChunk(remainderToAppend); + } + streamingState.pendingCompleteContent = ''; + streamingState.apiCompleted = false; + streamingState.renderedText = ''; + ctx.chatCompleteTextAction(finalText || ''); + completeActiveTextAction(finalText || ''); + ctx.$forceUpdate(); + // 注意:不在这里重置 streamingMessage,因为可能还有工具调用在进行 + // streamingMessage 只在 task_complete 事件时重置 + logStreamingDebug('finalizeStreamingText:complete', snapshotStreamingState()); + streamingState.activeMessageIndex = null; + streamingState.activeTextAction = null; + markStreamingIdleIfPossible('finalizeStreamingText'); + flushPendingToolEvents(); + return true; + }; + + const scheduleFinalizationAfterDrain = () => { + if (streamingState.buffer.length) { + logStreamingDebug( + 'scheduleFinalizationAfterDrain:buffer-not-empty', + snapshotStreamingState() + ); + return; + } + if (streamingState.completionTimer !== null) { + logStreamingDebug( + 'scheduleFinalizationAfterDrain:already-scheduled', + snapshotStreamingState() + ); + return; + } + logStreamingDebug('scheduleFinalizationAfterDrain:scheduled', snapshotStreamingState()); + streamingState.completionTimer = window.setTimeout(() => { + streamingState.completionTimer = null; + logStreamingDebug('scheduleFinalizationAfterDrain:timer-fired', snapshotStreamingState()); + finalizeStreamingText({ allowIncomplete: true }); + }, STREAMING_FINALIZE_DELAY); + }; + + const resetStreamingBuffer = () => { + stopStreamingTimer(); + stopCompletionTimer(); + streamingState.buffer.length = 0; + streamingState.apiCompleted = false; + streamingState.pendingCompleteContent = ''; + streamingState.renderedText = ''; + streamingState.activeMessageIndex = null; + streamingState.activeTextAction = null; + resetPendingToolEvents(); + logStreamingDebug('resetStreamingBuffer', snapshotStreamingState()); + }; + + const applyTextChunk = (text: string) => { + if (!text) { + return null; + } + ensureActiveMessageBinding(); + let action = ctx.chatAppendTextChunk(text); + if (action) { + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + renderLatexInRealtime(); + } else { + action = fallbackAppendToActiveMessage(text); + } + streamingState.renderedText += text; + const appended = !!action; + logStreamingDebug('applyTextChunk', { + chunkLength: text.length, + appended, + snapshot: snapshotStreamingState() + }); + if (!appended) { + logStreamingDebug('applyTextChunk:missing-target', { + chunkLength: text.length, + currentMessageIndex: + typeof ctx?.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null, + messagesLength: Array.isArray(ctx?.messages) ? ctx.messages.length : null }); + } + return action; + }; - // 上下文过长提示 - ctx.socket.on('context_warning', (data) => { - const title = data?.title || '上下文过长'; - const message = - data?.message || - '当前对话上下文接近上限,建议使用压缩功能。'; - if (typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title, - message, - type: data?.type || 'warning', - duration: data?.duration || 6000 - }); - } - }); + const drainStreamingBufferImmediately = () => { + if (!streamingState.buffer.length) { + return; + } + const chunk = streamingState.buffer.join(''); + streamingState.buffer.length = 0; + applyTextChunk(chunk); + }; - ctx.socket.on('todo_updated', (data) => { - socketLog('收到todo更新事件:', data); - // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 - if (ctx.initialRouteResolved && data && data.conversation_id) { - ctx.currentConversationId = data.conversation_id; - } - ctx.fileSetTodoList((data && data.todo_list) || null); - }); + const scheduleStreamingFlush = () => { + const shouldFlushImmediately = typeof document !== 'undefined' && document.hidden === true; + if (shouldFlushImmediately) { + stopStreamingTimer(); + drainStreamingBufferImmediately(); + if (streamingState.apiCompleted) { + finalizeStreamingText({ force: true }); + } else { + scheduleFinalizationAfterDrain(); + } + return; + } + if (streamingState.timer !== null) { + logStreamingDebug('scheduleStreamingFlush:timer-exists', snapshotStreamingState()); + return; + } + if (!streamingState.buffer.length) { + logStreamingDebug('scheduleStreamingFlush:no-buffer', snapshotStreamingState()); + return; + } + logStreamingDebug('scheduleStreamingFlush:start', snapshotStreamingState()); + const process = () => { + streamingState.timer = null; + logStreamingDebug('scheduleStreamingFlush:tick', snapshotStreamingState()); + if (!streamingState.buffer.length) { + logStreamingDebug('scheduleStreamingFlush:buffer-empty', snapshotStreamingState()); + return; + } + const piece = streamingState.buffer.shift(); + if (piece) { + applyTextChunk(piece); + } + if (streamingState.buffer.length) { + scheduleStreamingFlush(); + } else { + logStreamingDebug('scheduleStreamingFlush:buffer-drained', snapshotStreamingState()); + scheduleFinalizationAfterDrain(); + } + }; + streamingState.timer = window.setTimeout(process, STREAMING_CHAR_DELAY); + }; - // 系统就绪 - ctx.socket.on('system_ready', (data) => { - ctx.projectPath = data.project_path || ''; - ctx.agentVersion = data.version || ctx.agentVersion; - ctx.thinkingMode = !!data.thinking_mode; - if (data.run_mode) { - ctx.runMode = data.run_mode; - } else { - ctx.runMode = ctx.thinkingMode ? 'thinking' : 'fast'; - } - socketLog('系统就绪:', data); + const enqueueStreamingContent = (text: string) => { + if (!text) { + return; + } + stopCompletionTimer(); + if (!STREAMING_ENABLED) { + // 关闭逐字模式:整块入缓冲并立即刷新,保持与 chunk 顺序一致 + streamingState.pendingCompleteContent = ''; + streamingState.buffer.length = 0; + streamingState.apiCompleted = false; + applyTextChunk(text); + return; + } + logStreamingDebug('enqueueStreamingContent', { incomingLength: text.length }); + for (const ch of Array.from(text)) { + streamingState.buffer.push(ch); + } + logStreamingDebug('enqueueStreamingContent:buffered', snapshotStreamingState()); + scheduleStreamingFlush(); + }; - // 系统就绪后立即加载对话列表 - ctx.loadConversationsList(); - }); - - ctx.socket.on('tool_settings_updated', (data) => { - socketLog('收到工具设置更新:', data); - if (data && Array.isArray(data.categories)) { - ctx.applyToolSettingsSnapshot(data.categories); - } - }); - - // ========================================== - // 对话管理相关Socket事件 - // ========================================== - - // 监听对话变更事件 - ctx.socket.on('conversation_changed', (data) => { - socketLog('对话已切换:', data); - console.log('[conv-trace] socket:conversation_changed', data); - - // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 - if (ctx.initialRouteResolved) { - ctx.currentConversationId = data.conversation_id; - ctx.currentConversationTitle = data.title || ''; - - // 更新对话列表中的标题 - if (data.title && Array.isArray(ctx.conversations)) { - const conv = ctx.conversations.find(c => c && c.id === data.conversation_id); - if (conv) { - conv.title = data.title; - } - } - - ctx.promoteConversationToTop(data.conversation_id); - - if (data.cleared) { - // 对话被清空 - ctx.logMessageState?.('socket:conversation_changed-clearing', { event: data }); - ctx.messages = []; - ctx.logMessageState?.('socket:conversation_changed-cleared', { event: data }); - ctx.currentConversationId = null; - ctx.currentConversationTitle = ''; - // 重置Token统计 - ctx.resetTokenStatistics(); - history.replaceState({}, '', '/new'); - } - - // 刷新对话列表 - ctx.loadConversationsList(); - ctx.fetchTodoList(); - ctx.fetchSubAgents(); - } - }); - - ctx.socket.on('conversation_resolved', (data) => { - if (!data || !data.conversation_id) { - return; - } - const convId = data.conversation_id; - console.log('[conv-trace] socket:conversation_resolved', data); - - // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 - if (ctx.initialRouteResolved) { - ctx.currentConversationId = convId; - if (data.title) { - ctx.currentConversationTitle = data.title; - } - ctx.promoteConversationToTop(convId); - const pathFragment = ctx.stripConversationPrefix(convId); - const currentPath = window.location.pathname.replace(/^\/+/, ''); - if (data.created) { - history.pushState({ conversationId: convId }, '', `/${pathFragment}`); - } else if (currentPath !== pathFragment) { - history.replaceState({ conversationId: convId }, '', `/${pathFragment}`); - } - } - }); - - // 监听对话加载事件 - ctx.socket.on('conversation_loaded', (data) => { - socketLog('对话已加载:', data); - if (ctx.skipConversationLoadedEvent) { - ctx.skipConversationLoadedEvent = false; - socketLog('跳过重复的 conversation_loaded 处理'); - return; - } - const targetConversationId = (data && data.conversation_id) || ctx.currentConversationId; - const historyLoadingSame = - !!targetConversationId && - !!ctx.historyLoading && - ctx.historyLoadingFor === targetConversationId; + const scheduleHistoryReload = (delay = 0, options: { force?: boolean } = {}) => { + const { force = false } = options; + if (!ctx || typeof ctx.fetchAndDisplayHistory !== 'function') { + return; + } + setTimeout(() => { + const targetConversationId = ctx.currentConversationId; + if (!targetConversationId || targetConversationId.startsWith('temp_')) { + return; + } + try { + if (!force) { + const loadingSame = + !!ctx.historyLoading && ctx.historyLoadingFor === targetConversationId; const historyReady = - !!targetConversationId && - ctx.lastHistoryLoadedConversationId === targetConversationId && - Array.isArray(ctx.messages) && - ctx.messages.length > 0; - const forceReload = !!(data && data.force_reload); + ctx.lastHistoryLoadedConversationId === targetConversationId && + Array.isArray(ctx.messages) && + ctx.messages.length > 0; + if (loadingSame || historyReady) { + socketLog('跳过重复历史加载(scheduleHistoryReload)'); + return; + } + } + ctx.fetchAndDisplayHistory({ force }); + if (typeof ctx.fetchConversationTokenStatistics === 'function') { + ctx.fetchConversationTokenStatistics(); + } + if (typeof ctx.updateCurrentContextTokens === 'function') { + ctx.updateCurrentContextTokens(); + } + } catch (error) { + console.warn('重新加载对话历史失败:', error); + } + }, delay); + }; - if (!forceReload && (historyLoadingSame || historyReady)) { - socketLog('conversation_loaded: 历史已加载/加载中,跳过重复刷新'); - } else { - if (data.clear_ui) { - // 清理当前UI状态,准备显示历史内容 - ctx.resetAllStates('socket:conversation_loaded'); - } - // 延迟获取并显示历史对话内容 - setTimeout(() => { - ctx.fetchAndDisplayHistory({ force: forceReload }); - }, 300); - } + const assignSocketToken = async () => { + if (typeof window.requestSocketToken !== 'function') { + console.warn('缺少 requestSocketToken(),无法获取实时连接凭证'); + return false; + } + try { + const freshToken = await window.requestSocketToken(); + ctx.socket.auth = { socket_token: freshToken }; + return true; + } catch (error) { + console.error('获取 WebSocket token 失败:', error); + return false; + } + }; - // 延迟获取Token统计(累计+当前上下文) - setTimeout(() => { - ctx.fetchConversationTokenStatistics(); - ctx.updateCurrentContextTokens(); - ctx.fetchTodoList(); - }, 500); - }); - - // 监听对话列表更新事件 - ctx.socket.on('conversation_list_update', (data) => { - socketLog('对话列表已更新:', data); - // 刷新对话列表 - ctx.conversationsOffset = 0; - ctx.hasMoreConversations = false; - ctx.loadingMoreConversations = false; - ctx.loadConversationsList(); - }); - - // 监听状态更新事件 - ctx.socket.on('status_update', (status) => { - ctx.applyStatusSnapshot(status); - // 只有在初始化完成后,才允许 status_update 修改 currentConversationId - // 避免与 bootstrapRoute 冲突 - if (status.conversation && status.conversation.current_id) { - if (ctx.initialRouteResolved && !ctx.currentConversationId) { - // 初始化完成且当前没有对话ID,才设置 - ctx.currentConversationId = status.conversation.current_id; - } - } - if (typeof status.run_mode === 'string') { - ctx.runMode = status.run_mode; - } else if (typeof status.thinking_mode !== 'undefined') { - ctx.runMode = status.thinking_mode ? 'thinking' : 'fast'; - } - }); - - // 用户消息(后台子智能体完成后自动触发) - ctx.socket.on('user_message', (data) => { - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (!data) { - return; - } - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过user_message(对话不匹配)', data.conversation_id); - return; - } - const message = (data.message || data.content || '').trim(); - if (!message) { - return; - } - ctx.chatAddUserMessage(message, data.images || [], data.videos || []); - ctx.taskInProgress = true; - ctx.streamingMessage = false; - ctx.stopRequested = false; - if (data?.sub_agent_notice && data?.task_id && ctx.usePollingMode) { - if (typeof data?.has_running_sub_agents === 'boolean') { - ctx.waitingForSubAgent = data.has_running_sub_agents; - } else if (typeof data?.remaining_count === 'number') { - ctx.waitingForSubAgent = data.remaining_count > 0; - } - (async () => { - try { - const { useTaskStore } = await import('../stores/task'); - const taskStore = useTaskStore(); - taskStore.resumeTask(data.task_id); - } catch (error) { - console.warn('恢复任务轮询失败', error); - } - })(); - } - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - }); - - - // AI消息开始(已废弃,改用 REST API 轮询) - ctx.socket.on('ai_message_start', () => { - // 只处理子智能体模式 - if (ctx.waitingForSubAgent) { - ctx.waitingForSubAgent = false; - ctx.taskInProgress = false; - } - }); - - // 思考流开始 - ctx.socket.on('thinking_start', () => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('思考开始 (轮询模式,跳过)'); - return; - } - socketLog('思考开始'); - const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false; - streamingState.ignoreThinking = ignoreThinking; - if (ignoreThinking) { - ctx.monitorEndModelOutput(); - return; - } - ctx.monitorShowThinking(); - const result = ctx.chatStartThinkingAction(); - if (result && result.blockId) { - const blockId = result.blockId; - ctx.chatExpandBlock(blockId); - // 开始思考时尝试滚动到底部,但不改变用户锁定选择 - ctx.scrollToBottom(); - ctx.chatSetThinkingLock(blockId, true); - ctx.$forceUpdate(); - } - }); - - // 思考内容块 - ctx.socket.on('thinking_chunk', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (streamingState.ignoreThinking) { - return; - } - const thinkingAction = ctx.chatAppendThinkingChunk(data.content); - if (thinkingAction) { - ctx.$forceUpdate(); - ctx.$nextTick(() => { - if (thinkingAction && thinkingAction.blockId) { - ctx.scrollThinkingToBottom(thinkingAction.blockId); - } - ctx.conditionalScrollToBottom(); - }); - } - ctx.monitorShowThinking(); - }); - - // 思考结束 - ctx.socket.on('thinking_end', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('思考结束 (轮询模式,跳过)'); - return; - } - socketLog('思考结束'); - if (streamingState.ignoreThinking) { - streamingState.ignoreThinking = false; - return; - } - const blockId = ctx.chatCompleteThinkingAction(data.full_content); - if (blockId) { - setTimeout(() => { - ctx.chatCollapseBlock(blockId); - ctx.chatSetThinkingLock(blockId, false); - ctx.$forceUpdate(); - }, 1000); - ctx.$nextTick(() => ctx.scrollThinkingToBottom(blockId)); - } - ctx.$forceUpdate(); - ctx.monitorEndModelOutput(); - }); - - // 文本流开始 - ctx.socket.on('text_start', () => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('文本开始 (轮询模式,跳过)'); - return; - } - console.log('[DEBUG] 收到 text_start 事件:', { - currentConversationId: ctx.currentConversationId, - messagesCount: ctx.messages.length, - currentMessageIndex: ctx.currentMessageIndex - }); - socketLog('文本开始'); - logStreamingDebug('socket:text_start'); - finalizeStreamingText({ force: true }); - resetStreamingBuffer(); - const action = ctx.chatStartTextAction(); - streamingState.activeMessageIndex = - typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null; - streamingState.activeTextAction = action || ensureActiveTextAction(); - ensureActiveMessageBinding(); - ctx.$forceUpdate(); - console.log('[DEBUG] text_start 处理完成'); - }); - - // 文本内容块 - ctx.socket.on('text_chunk', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - console.log('[DEBUG] 收到 text_chunk 事件:', { - conversation_id: data?.conversation_id, - currentConversationId: ctx.currentConversationId, - chunkLength: (data?.content || '').length, - index: data?.index - }); - logStreamingDebug('socket:text_chunk', { - index: data?.index ?? null, - elapsed: data?.elapsed ?? null, - chunkLength: (data?.content || '').length, - snapshot: snapshotStreamingState() - }); - try { - ctx.socket.emit('client_chunk_log', { - conversation_id: ctx.currentConversationId, - index: data?.index ?? null, - elapsed: data?.elapsed ?? null, - length: (data?.content || '').length, - ts: Date.now() - }); - } catch (error) { - console.warn('上报chunk日志失败:', error); - } - if (data && typeof data.content === 'string' && data.content.length) { - if (STREAMING_ENABLED) { - enqueueStreamingContent(data.content); - } else { - // 关闭逐字模式时,直接追加当前chunk - applyTextChunk(data.content); - } - const speech = sanitizeBubbleText(data.content); - if (speech) { - ctx.monitorShowSpeech(speech); - } - } - }); - - // 文本结束 - ctx.socket.on('text_end', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - socketLog('文本结束 (轮询模式,跳过)'); - return; - } - socketLog('文本结束'); - logStreamingDebug('socket:text_end', { - finalLength: (data?.full_content || '').length, - snapshot: snapshotStreamingState() - }); - if (!STREAMING_ENABLED) { - // 已逐块追加,这里保证动作收尾 - const full = data?.full_content || ''; - if (full) { - ctx.chatCompleteTextAction(full); - } else { - ctx.chatCompleteTextAction(''); - } - streamingState.buffer.length = 0; - streamingState.pendingCompleteContent = ''; - streamingState.renderedText = ''; - streamingState.apiCompleted = false; - ctx.monitorEndModelOutput(); - flushPendingToolEvents(); - return; - } - streamingState.apiCompleted = true; - streamingState.pendingCompleteContent = data?.full_content || ''; - const hidden = typeof document !== 'undefined' && document.hidden === true; - if (hidden) { - stopStreamingTimer(); - drainStreamingBufferImmediately(); - finalizeStreamingText({ force: true }); - } else if (!streamingState.buffer.length) { - scheduleFinalizationAfterDrain(); - } else { - scheduleStreamingFlush(); - } - ctx.monitorEndModelOutput(); - flushPendingToolEvents(); - }); - - // 工具提示事件(可选) - ctx.socket.on('tool_hint', (data) => { - socketLog('工具提示:', data.name); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_hint(对话不匹配)', data.conversation_id); - return; - } - // 可以在这里添加提示UI - }); - - // 工具准备中事件 - 实时显示 - ctx.socket.on('tool_preparing', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - console.log('[DEBUG] 收到 tool_preparing 事件:', { - name: data.name, - id: data.id, - conversation_id: data?.conversation_id, - currentConversationId: ctx.currentConversationId, - match: data?.conversation_id === ctx.currentConversationId - }); - socketLog('工具准备中:', data.name); - if (typeof console !== 'undefined' && console.debug) { - console.debug('[tool_intent] preparing', { - id: data?.id, - name: data?.name, - intent: data?.intent, - convo: data?.conversation_id - }); - } - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - console.log('[DEBUG] 跳过 tool_preparing (对话不匹配)'); - socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id); - return; - } - const msg = ctx.chatEnsureAssistantMessage(); - if (!msg) { - console.log('[DEBUG] tool_preparing: 无法获取assistant消息'); - return; - } - if (msg.awaitingFirstContent) { - msg.awaitingFirstContent = false; - msg.generatingLabel = ''; - } - const action = { - id: data.id, - type: 'tool', - tool: { - id: data.id, - name: data.name, - arguments: {}, - argumentSnapshot: null, - argumentLabel: '', - status: 'preparing', - result: null, - message: data.message || `准备调用 ${data.name}...`, - intent_full: data.intent || '', - intent_rendered: data.intent || '' - }, - timestamp: Date.now() - }; - msg.actions.push(action); - ctx.preparingTools.set(data.id, action); - ctx.toolRegisterAction(action, data.id); - ctx.toolTrackAction(data.name, action); - if (data.intent) { - startIntentTyping(action, data.intent); - } - console.log('[DEBUG] tool_preparing 处理完成,action已添加'); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - if (ctx.monitorPreviewTool) { - ctx.monitorPreviewTool(data); - } - }); - - // 工具意图(流式增量)事件 - ctx.socket.on('tool_intent', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - return; - } - if (typeof console !== 'undefined' && console.debug) { - console.debug('[tool_intent] update', { - id: data?.id, - name: data?.name, - intent: data?.intent, - convo: data?.conversation_id - }); - } - const target = - ctx.toolFindAction(data.id, data.preparing_id, data.execution_id) || - ctx.toolGetLatestAction(data.name); - if (target) { - startIntentTyping(target, data.intent); - } - ctx.$forceUpdate(); - }); - - // 工具状态更新事件 - 实时显示详细状态 - ctx.socket.on('tool_status', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('工具状态:', data); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_status(对话不匹配)', data.conversation_id); - return; - } - const target = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); - if (target) { - target.tool.statusDetail = data.detail; - target.tool.statusType = data.status; - if (data.intent) { - startIntentTyping(target, data.intent); - } - ctx.$forceUpdate(); - return; - } - const fallbackAction = ctx.toolGetLatestAction(data.tool); - if (fallbackAction) { - fallbackAction.tool.statusDetail = data.detail; - fallbackAction.tool.statusType = data.status; - ctx.toolRegisterAction(fallbackAction, data.execution_id || data.id || data.preparing_id); - if (data.intent) { - startIntentTyping(fallbackAction, data.intent); - } - ctx.$forceUpdate(); - } - }); - - // 工具开始(从准备转为执行) - ctx.socket.on('tool_start', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('工具开始执行:', data.name); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过tool_start(对话不匹配)', data.conversation_id); - return; - } - const handler = () => { - let action = null; - if (data.preparing_id && ctx.preparingTools.has(data.preparing_id)) { - action = ctx.preparingTools.get(data.preparing_id); - ctx.preparingTools.delete(data.preparing_id); - } else { - action = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); - } - if (!action) { - const msg = ctx.chatEnsureAssistantMessage(); - if (!msg) { - return; - } - action = { - id: data.id, - type: 'tool', - tool: { - id: data.id, - name: data.name, - arguments: {}, - argumentSnapshot: null, - argumentLabel: '', - status: 'running', - result: null - }, - timestamp: Date.now() - }; - msg.actions.push(action); - } - action.tool.status = 'running'; - action.tool.arguments = data.arguments; - action.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments); - action.tool.argumentLabel = ctx.buildToolLabel(action.tool.argumentSnapshot); - action.tool.message = null; - action.tool.id = data.id; - action.tool.executionId = data.id; - if (data.arguments && data.arguments.intent) { - startIntentTyping(action, data.arguments.intent); - } - ctx.toolRegisterAction(action, data.id); - ctx.toolTrackAction(data.name, action); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - ctx.monitorQueueTool(data); - - if (data.name === 'view_video' && typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title: '视频读取中', - message: '读取视频需要较长时间,请耐心等待', - type: 'info', - duration: 5000 - }); - } - }; - - handler(); - }); - - // 更新action(工具完成) - ctx.socket.on('update_action', (data) => { - // 轮询模式下跳过 WebSocket 事件 - if (ctx.usePollingMode && !ctx.waitingForSubAgent) { - return; - } - if (ctx.dropToolEvents) { - return; - } - socketLog('更新action:', data.id, 'status:', data.status); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过update_action(对话不匹配)', data.conversation_id); - return; - } - const handler = () => { - let targetAction = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); - if (!targetAction && data.preparing_id && ctx.preparingTools.has(data.preparing_id)) { - targetAction = ctx.preparingTools.get(data.preparing_id); - } - if (!targetAction) { - outer: for (const message of ctx.messages) { - if (!message.actions) continue; - for (const action of message.actions) { - if (action.type !== 'tool') continue; - const matchByExecution = action.tool.executionId && action.tool.executionId === data.id; - const matchByToolId = action.tool.id === data.id; - const matchByPreparingId = action.id === data.preparing_id; - if (matchByExecution || matchByToolId || matchByPreparingId) { - targetAction = action; - break outer; - } - } - } - } - if (targetAction) { - if (data.status) { - targetAction.tool.status = data.status; - } - if (data.result !== undefined) { - targetAction.tool.result = data.result; - } - if (targetAction.tool && targetAction.tool.name === 'trigger_easter_egg' && data.result !== undefined) { - const eggPromise = ctx.handleEasterEggPayload(data.result); - if (eggPromise && typeof eggPromise.catch === 'function') { - eggPromise.catch((error) => { - console.warn('彩蛋处理异常:', error); - }); - } - } - if (data.message !== undefined) { - targetAction.tool.message = data.message; - } - if (data.arguments && data.arguments.intent) { - startIntentTyping(targetAction, data.arguments.intent); - } - if (data.awaiting_content) { - targetAction.tool.awaiting_content = true; - } else if (data.status === 'completed') { - targetAction.tool.awaiting_content = false; - } - if (!targetAction.tool.executionId && (data.execution_id || data.id)) { - targetAction.tool.executionId = data.execution_id || data.id; - } - if (data.arguments) { - targetAction.tool.arguments = data.arguments; - targetAction.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments); - targetAction.tool.argumentLabel = ctx.buildToolLabel(targetAction.tool.argumentSnapshot); - if (data.arguments.intent) { - startIntentTyping(targetAction, data.arguments.intent); - } - } - ctx.toolRegisterAction(targetAction, data.execution_id || data.id); - if (data.status && ["completed", "failed", "error"].includes(data.status) && !data.awaiting_content) { - ctx.toolUnregisterAction(targetAction); - if (data.id) { - ctx.preparingTools.delete(data.id); - } - if (data.preparing_id) { - ctx.preparingTools.delete(data.preparing_id); - } - } - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - } - - if (data.status === 'completed') { - setTimeout(() => { - ctx.updateCurrentContextTokens(); - }, 500); - try { - ctx.fileFetchTree(); - } catch (error) { - console.warn('刷新文件树失败', error); - } - } - ctx.monitorResolveTool(data); - }; - - handler(); - }); - - ctx.socket.on('append_payload', (data) => { - if (ctx.dropToolEvents) { - return; - } - socketLog('收到append_payload事件:', data); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过append_payload(对话不匹配)', data.conversation_id); - return; - } - ctx.chatAddAppendPayloadAction({ - path: data.path || '未知文件', - forced: !!data.forced, - success: data.success === undefined ? true : !!data.success, - lines: data.lines ?? null, - bytes: data.bytes ?? null - }); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - }); - - ctx.socket.on('modify_payload', (data) => { - if (ctx.dropToolEvents) { - return; - } - socketLog('收到modify_payload事件:', data); - if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { - socketLog('跳过modify_payload(对话不匹配)', data.conversation_id); - return; - } - ctx.chatAddModifyPayloadAction({ - path: data.path || '未知文件', - total: data.total ?? null, - completed: data.completed || [], - failed: data.failed || [], - forced: !!data.forced - }); - ctx.$forceUpdate(); - ctx.conditionalScrollToBottom(); - }); - - // 停止请求确认 - ctx.socket.on('stop_requested', (data) => { - socketLog('停止请求已接收:', data.message); - // 可以显示提示信息 - try { - if (typeof ctx.clearPendingTools === 'function') { - ctx.clearPendingTools('socket:stop_requested'); - } - } catch (error) { - console.warn('清理未完成工具失败', error); - } - }); - - // 任务停止 - ctx.socket.on('task_stopped', (data) => { - socketLog('任务已停止:', data.message); - ctx.taskInProgress = false; - ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true }); - }); - - // 任务完成(重点:更新Token统计) - ctx.socket.on('task_complete', (data) => { - console.log('[DEBUG] 收到 task_complete 事件:', data); - console.log('[DEBUG] 当前状态 (before task_complete):', { - taskInProgress: ctx.taskInProgress, - streamingMessage: ctx.streamingMessage, - has_running_sub_agents: data.has_running_sub_agents - }); - socketLog('任务完成', data); - - // 如果有运行中的子智能体,不重置任务状态 - if (!data.has_running_sub_agents) { - console.log('[DEBUG] 没有运行中的子智能体,重置任务状态'); - if (ctx.waitingForSubAgent) { - ctx.waitingForSubAgent = false; - } - ctx.taskInProgress = false; - ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true }); - } else { - console.log('[DEBUG] 有运行中的子智能体,保持任务状态'); - } - - console.log('[DEBUG] 当前状态 (after task_complete):', { - taskInProgress: ctx.taskInProgress, - streamingMessage: ctx.streamingMessage - }); - - resetPendingToolEvents(); - - // 任务完成后立即更新Token统计(关键修复) - if (ctx.currentConversationId) { - ctx.updateCurrentContextTokens(); - ctx.fetchConversationTokenStatistics(); - } - }); - - // 子智能体等待状态 - ctx.socket.on('sub_agent_waiting', (data) => { - console.log('[DEBUG] 收到 sub_agent_waiting 事件:', data); - console.log('[DEBUG] 当前状态 (before sub_agent_waiting):', { - taskInProgress: ctx.taskInProgress, - streamingMessage: ctx.streamingMessage, - stopRequested: ctx.stopRequested - }); - socketLog('等待子智能体完成:', data); - - // 设置标志:有子智能体在运行,阻止状态重置 - ctx.waitingForSubAgent = true; - - // 保持任务进行中状态 - ctx.taskInProgress = true; - ctx.streamingMessage = false; - ctx.stopRequested = false; - - console.log('[DEBUG] 当前状态 (after sub_agent_waiting):', { - taskInProgress: ctx.taskInProgress, - streamingMessage: ctx.streamingMessage, - stopRequested: ctx.stopRequested, - waitingForSubAgent: ctx.waitingForSubAgent - }); - - // 显示等待提示 - if (typeof ctx.appendSystemAction === 'function') { - const taskList = data.tasks.map((t: any) => `子智能体${t.agent_id} (${t.summary || '无描述'})`).join('、'); - ctx.appendSystemAction(`⏳ 等待 ${data.count} 个后台子智能体完成:${taskList}`); - } - - ctx.$forceUpdate(); - console.log('[DEBUG] sub_agent_waiting 处理完成'); - }); - - // 聚焦文件更新 - ctx.socket.on('focused_files_update', (data) => { - ctx.focusSetFiles(data || {}); - // 聚焦文件变化时更新当前上下文Token(关键修复) - if (ctx.currentConversationId) { - setTimeout(() => { - ctx.updateCurrentContextTokens(); - }, 500); - } - }); - - // 文件树更新 - ctx.socket.on('file_tree_update', (data) => { - ctx.fileSetTreeFromResponse(data); - // 文件树变化时也可能影响上下文 - if (ctx.currentConversationId) { - setTimeout(() => { - ctx.updateCurrentContextTokens(); - }, 500); - } - }); - - // 系统消息 - ctx.socket.on('system_message', (data) => { - if (!data || !data.content) { - return; - } - ctx.appendSystemAction(data.content); - }); - - // 错误处理 - ctx.socket.on('error', (data) => { - const msg = data?.message || '发生未知错误'; - const code = data?.status_code; - const errType = data?.error_type; - const errCode = data?.error_code; - const dumpPath = data?.request_dump; - const baseUrl = data?.base_url; - const modelId = data?.model_id; - const conversationId = data?.conversation_id; - const taskId = data?.task_id; - const shouldRetry = Boolean(data?.retry); - const retryIn = Number(data?.retry_in) || 5; - const retryAttempt = Number(data?.attempt) || 1; - const retryMax = Number(data?.max_attempts) || retryAttempt; - const detailParts = [ - dumpPath ? `请求记录: ${dumpPath}` : '', - baseUrl ? `接口: ${baseUrl}` : '', - modelId ? `模型: ${modelId}` : '', - conversationId ? `对话ID: ${conversationId}` : '', - taskId ? `任务ID: ${taskId}` : '' - ].filter(Boolean); - const detailText = detailParts.length ? `\n${detailParts.join('\n')}` : ''; - if (typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title: code ? `API错误 ${code}` : 'API错误', - message: `${errType ? `${errType}${errCode ? `(${errCode})` : ''}: ${msg}` : msg}${detailText}`, - type: 'error', - duration: 6000 - }); - if (shouldRetry) { - ctx.uiPushToast({ - title: '即将重试', - message: `将在 ${retryIn} 秒后重试(第 ${retryAttempt}/${retryMax} 次)`, - type: 'info', - duration: Math.max(retryIn, 1) * 1000 - }); - } - } - if (shouldRetry) { - // 错误后保持停止按钮态,用户可手动停止或等待自动重试 - ctx.stopRequested = false; - ctx.taskInProgress = true; - ctx.streamingMessage = true; - return; - } - - if (typeof ctx.appendSystemAction === 'function') { - ctx.appendSystemAction( - `${code ? `[API ${code}] ` : '[API] '}${errType ? `${errType}${errCode ? `(${errCode})` : ''}: ` : ''}${msg}${detailText}` - ); - } - - // 最后一次报错:恢复输入状态并清理提示动画 - const msgIndex = typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : -1; - if (msgIndex >= 0 && Array.isArray(ctx.messages)) { - const currentMessage = ctx.messages[msgIndex]; - if (currentMessage && currentMessage.role === 'assistant') { - currentMessage.awaitingFirstContent = false; - currentMessage.generatingLabel = ''; - } - } - if (typeof ctx.chatClearThinkingLocks === 'function') { - ctx.chatClearThinkingLocks(); - } - ctx.streamingMessage = false; - ctx.stopRequested = false; - ctx.taskInProgress = false; - }); - - // 命令结果 - ctx.socket.on('command_result', (data) => { - if (data.command === 'clear' && data.success) { - ctx.logMessageState?.('socket:command_result-clear', { data }); - ctx.messages = []; - ctx.logMessageState?.('socket:command_result-cleared', { data }); - ctx.currentMessageIndex = -1; - ctx.chatClearExpandedBlocks(); - // 清除对话时重置Token统计 - ctx.resetTokenStatistics(); - } else if (data.command === 'status' && data.success) { - ctx.addSystemMessage(`系统状态:\n${JSON.stringify(data.data, null, 2)}`); - } else if (!data.success) { - ctx.addSystemMessage(`命令失败: ${data.message}`); - } - }); - - } catch (error) { - console.error('Socket初始化失败:', error); + if (ctx.socket.io && typeof ctx.socket.io.on === 'function') { + ctx.socket.io.on('reconnect_attempt', async () => { + await assignSocketToken(); + }); } + + let hasConnectedOnce = false; + + // 连接事件 + ctx.socket.on('connect', () => { + const isReconnect = hasConnectedOnce; + const historyLoadingSame = + !!ctx.historyLoading && ctx.historyLoadingFor === ctx.currentConversationId; + const historyReady = + ctx.lastHistoryLoadedConversationId === ctx.currentConversationId && + Array.isArray(ctx.messages) && + ctx.messages.length > 0; + + ctx.isConnected = true; + socketLog('WebSocket已连接'); + + // 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画 + if (!isReconnect && (historyLoadingSame || historyReady)) { + socketLog('初次连接已存在历史,跳过重复重置与加载'); + hasConnectedOnce = true; + return; + } + + hasConnectedOnce = true; + ctx.resetAllStates(isReconnect ? 'socket:reconnect' : 'socket:connect'); + scheduleHistoryReload(200, { force: isReconnect }); + }); + + ctx.socket.on('disconnect', () => { + ctx.isConnected = false; + socketLog('WebSocket已断开'); + // 断线时也重置状态,防止状态混乱 + ctx.resetAllStates('socket:disconnect'); + }); + + ctx.socket.on('connect_error', (error) => { + console.error('WebSocket连接错误:', error.message); + }); + + ctx.socket.on('quota_update', (data) => { + if (data && data.quotas) { + ctx.resourceSetUsageQuota({ quotas: data.quotas }); + } else { + ctx.fetchUsageQuota(); + } + }); + + ctx.socket.on('quota_notice', (data) => { + ctx.showQuotaToast(data || {}); + ctx.fetchUsageQuota(); + }); + + ctx.socket.on('quota_exceeded', (data) => { + ctx.showQuotaToast(data || {}); + ctx.fetchUsageQuota(); + }); + + ctx.socket.on('reconnect_attempt', async () => { + await assignSocketToken(); + }); + + const ready = await assignSocketToken(); + if (!ready) { + console.error('无法获取实时连接凭证,WebSocket 初始化中止。'); + return; + } + ctx.socket.connect(); + + // ========================================== + // Token统计WebSocket事件处理(修复版) + // ========================================== + + ctx.socket.on('token_update', (data) => { + socketLog('收到token更新事件:', data); + + // 只处理当前对话的token更新 + if (data.conversation_id === ctx.currentConversationId) { + // 更新累计统计(使用后端提供的准确字段名) + ctx.currentConversationTokens.cumulative_input_tokens = data.cumulative_input_tokens || 0; + ctx.currentConversationTokens.cumulative_output_tokens = data.cumulative_output_tokens || 0; + ctx.currentConversationTokens.cumulative_total_tokens = data.cumulative_total_tokens || 0; + + socketLog( + `累计Token统计更新: 输入=${data.cumulative_input_tokens}, 输出=${data.cumulative_output_tokens}, 总计=${data.cumulative_total_tokens}` + ); + + const hasContextTokens = typeof data.current_context_tokens === 'number'; + if (hasContextTokens && typeof ctx.resourceSetCurrentContextTokens === 'function') { + ctx.resourceSetCurrentContextTokens(data.current_context_tokens); + } else { + // 同时更新当前上下文Token(关键修复) + ctx.updateCurrentContextTokens(); + } + + ctx.$forceUpdate(); + } + }); + + // 上下文过长提示 + ctx.socket.on('context_warning', (data) => { + const title = data?.title || '上下文过长'; + const message = data?.message || '当前对话上下文接近上限,建议使用压缩功能。'; + if (typeof ctx.uiPushToast === 'function') { + ctx.uiPushToast({ + title, + message, + type: data?.type || 'warning', + duration: data?.duration || 6000 + }); + } + }); + + ctx.socket.on('todo_updated', (data) => { + socketLog('收到todo更新事件:', data); + // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 + if (ctx.initialRouteResolved && data && data.conversation_id) { + ctx.currentConversationId = data.conversation_id; + } + ctx.fileSetTodoList((data && data.todo_list) || null); + }); + + // 系统就绪 + ctx.socket.on('system_ready', (data) => { + ctx.projectPath = data.project_path || ''; + ctx.agentVersion = data.version || ctx.agentVersion; + ctx.thinkingMode = !!data.thinking_mode; + if (data.run_mode) { + ctx.runMode = data.run_mode; + } else { + ctx.runMode = ctx.thinkingMode ? 'thinking' : 'fast'; + } + socketLog('系统就绪:', data); + + // 系统就绪后立即加载对话列表 + ctx.loadConversationsList(); + }); + + ctx.socket.on('tool_settings_updated', (data) => { + socketLog('收到工具设置更新:', data); + if (data && Array.isArray(data.categories)) { + ctx.applyToolSettingsSnapshot(data.categories); + } + }); + + // ========================================== + // 对话管理相关Socket事件 + // ========================================== + + // 监听对话变更事件 + ctx.socket.on('conversation_changed', (data) => { + socketLog('对话已切换:', data); + console.log('[conv-trace] socket:conversation_changed', data); + + // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 + if (ctx.initialRouteResolved) { + ctx.currentConversationId = data.conversation_id; + ctx.currentConversationTitle = data.title || ''; + + // 更新对话列表中的标题 + if (data.title && Array.isArray(ctx.conversations)) { + const conv = ctx.conversations.find((c) => c && c.id === data.conversation_id); + if (conv) { + conv.title = data.title; + } + } + + ctx.promoteConversationToTop(data.conversation_id); + + if (data.cleared) { + // 对话被清空 + ctx.logMessageState?.('socket:conversation_changed-clearing', { event: data }); + ctx.messages = []; + ctx.logMessageState?.('socket:conversation_changed-cleared', { event: data }); + ctx.currentConversationId = null; + ctx.currentConversationTitle = ''; + // 重置Token统计 + ctx.resetTokenStatistics(); + history.replaceState({}, '', '/new'); + } + + // 刷新对话列表 + ctx.loadConversationsList(); + ctx.fetchTodoList(); + ctx.fetchSubAgents(); + } + }); + + ctx.socket.on('conversation_resolved', (data) => { + if (!data || !data.conversation_id) { + return; + } + const convId = data.conversation_id; + console.log('[conv-trace] socket:conversation_resolved', data); + + // 初始化期间不修改 currentConversationId,避免与 bootstrapRoute 冲突 + if (ctx.initialRouteResolved) { + ctx.currentConversationId = convId; + if (data.title) { + ctx.currentConversationTitle = data.title; + } + ctx.promoteConversationToTop(convId); + const pathFragment = ctx.stripConversationPrefix(convId); + const currentPath = window.location.pathname.replace(/^\/+/, ''); + if (data.created) { + history.pushState({ conversationId: convId }, '', `/${pathFragment}`); + } else if (currentPath !== pathFragment) { + history.replaceState({ conversationId: convId }, '', `/${pathFragment}`); + } + } + }); + + // 监听对话加载事件 + ctx.socket.on('conversation_loaded', (data) => { + socketLog('对话已加载:', data); + if (ctx.skipConversationLoadedEvent) { + ctx.skipConversationLoadedEvent = false; + socketLog('跳过重复的 conversation_loaded 处理'); + return; + } + const targetConversationId = (data && data.conversation_id) || ctx.currentConversationId; + const historyLoadingSame = + !!targetConversationId && + !!ctx.historyLoading && + ctx.historyLoadingFor === targetConversationId; + const historyReady = + !!targetConversationId && + ctx.lastHistoryLoadedConversationId === targetConversationId && + Array.isArray(ctx.messages) && + ctx.messages.length > 0; + const forceReload = !!(data && data.force_reload); + + if (!forceReload && (historyLoadingSame || historyReady)) { + socketLog('conversation_loaded: 历史已加载/加载中,跳过重复刷新'); + } else { + if (data.clear_ui) { + // 清理当前UI状态,准备显示历史内容 + ctx.resetAllStates('socket:conversation_loaded'); + } + // 延迟获取并显示历史对话内容 + setTimeout(() => { + ctx.fetchAndDisplayHistory({ force: forceReload }); + }, 300); + } + + // 延迟获取Token统计(累计+当前上下文) + setTimeout(() => { + ctx.fetchConversationTokenStatistics(); + ctx.updateCurrentContextTokens(); + ctx.fetchTodoList(); + }, 500); + }); + + // 监听对话列表更新事件 + ctx.socket.on('conversation_list_update', (data) => { + socketLog('对话列表已更新:', data); + // 刷新对话列表 + ctx.conversationsOffset = 0; + ctx.hasMoreConversations = false; + ctx.loadingMoreConversations = false; + ctx.loadConversationsList(); + }); + + // 监听状态更新事件 + ctx.socket.on('status_update', (status) => { + ctx.applyStatusSnapshot(status); + // 只有在初始化完成后,才允许 status_update 修改 currentConversationId + // 避免与 bootstrapRoute 冲突 + if (status.conversation && status.conversation.current_id) { + if (ctx.initialRouteResolved && !ctx.currentConversationId) { + // 初始化完成且当前没有对话ID,才设置 + ctx.currentConversationId = status.conversation.current_id; + } + } + if (typeof status.run_mode === 'string') { + ctx.runMode = status.run_mode; + } else if (typeof status.thinking_mode !== 'undefined') { + ctx.runMode = status.thinking_mode ? 'thinking' : 'fast'; + } + }); + + // 用户消息(后台子智能体完成后自动触发) + ctx.socket.on('user_message', (data) => { + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (!data) { + return; + } + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过user_message(对话不匹配)', data.conversation_id); + return; + } + const message = (data.message || data.content || '').trim(); + if (!message) { + return; + } + ctx.chatAddUserMessage(message, data.images || [], data.videos || []); + ctx.taskInProgress = true; + ctx.streamingMessage = false; + ctx.stopRequested = false; + if (data?.sub_agent_notice && data?.task_id && ctx.usePollingMode) { + if (typeof data?.has_running_sub_agents === 'boolean') { + ctx.waitingForSubAgent = data.has_running_sub_agents; + } else if (typeof data?.remaining_count === 'number') { + ctx.waitingForSubAgent = data.remaining_count > 0; + } + (async () => { + try { + const { useTaskStore } = await import('../stores/task'); + const taskStore = useTaskStore(); + taskStore.resumeTask(data.task_id); + } catch (error) { + console.warn('恢复任务轮询失败', error); + } + })(); + } + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + }); + + // AI消息开始(已废弃,改用 REST API 轮询) + ctx.socket.on('ai_message_start', () => { + // 只处理子智能体模式 + if (ctx.waitingForSubAgent) { + ctx.waitingForSubAgent = false; + ctx.taskInProgress = false; + } + }); + + // 思考流开始 + ctx.socket.on('thinking_start', () => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + socketLog('思考开始 (轮询模式,跳过)'); + return; + } + socketLog('思考开始'); + const ignoreThinking = ctx.runMode === 'fast' || ctx.thinkingMode === false; + streamingState.ignoreThinking = ignoreThinking; + if (ignoreThinking) { + ctx.monitorEndModelOutput(); + return; + } + ctx.monitorShowThinking(); + const result = ctx.chatStartThinkingAction(); + if (result && result.blockId) { + const blockId = result.blockId; + ctx.chatExpandBlock(blockId); + // 开始思考时尝试滚动到底部,但不改变用户锁定选择 + ctx.scrollToBottom(); + ctx.chatSetThinkingLock(blockId, true); + ctx.$forceUpdate(); + } + }); + + // 思考内容块 + ctx.socket.on('thinking_chunk', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (streamingState.ignoreThinking) { + return; + } + const thinkingAction = ctx.chatAppendThinkingChunk(data.content); + if (thinkingAction) { + ctx.$forceUpdate(); + ctx.$nextTick(() => { + if (thinkingAction && thinkingAction.blockId) { + ctx.scrollThinkingToBottom(thinkingAction.blockId); + } + ctx.conditionalScrollToBottom(); + }); + } + ctx.monitorShowThinking(); + }); + + // 思考结束 + ctx.socket.on('thinking_end', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + socketLog('思考结束 (轮询模式,跳过)'); + return; + } + socketLog('思考结束'); + if (streamingState.ignoreThinking) { + streamingState.ignoreThinking = false; + return; + } + const blockId = ctx.chatCompleteThinkingAction(data.full_content); + if (blockId) { + setTimeout(() => { + ctx.chatCollapseBlock(blockId); + ctx.chatSetThinkingLock(blockId, false); + ctx.$forceUpdate(); + }, 1000); + ctx.$nextTick(() => ctx.scrollThinkingToBottom(blockId)); + } + ctx.$forceUpdate(); + ctx.monitorEndModelOutput(); + }); + + // 文本流开始 + ctx.socket.on('text_start', () => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + socketLog('文本开始 (轮询模式,跳过)'); + return; + } + console.log('[DEBUG] 收到 text_start 事件:', { + currentConversationId: ctx.currentConversationId, + messagesCount: ctx.messages.length, + currentMessageIndex: ctx.currentMessageIndex + }); + socketLog('文本开始'); + logStreamingDebug('socket:text_start'); + finalizeStreamingText({ force: true }); + resetStreamingBuffer(); + const action = ctx.chatStartTextAction(); + streamingState.activeMessageIndex = + typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null; + streamingState.activeTextAction = action || ensureActiveTextAction(); + ensureActiveMessageBinding(); + ctx.$forceUpdate(); + console.log('[DEBUG] text_start 处理完成'); + }); + + // 文本内容块 + ctx.socket.on('text_chunk', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + console.log('[DEBUG] 收到 text_chunk 事件:', { + conversation_id: data?.conversation_id, + currentConversationId: ctx.currentConversationId, + chunkLength: (data?.content || '').length, + index: data?.index + }); + logStreamingDebug('socket:text_chunk', { + index: data?.index ?? null, + elapsed: data?.elapsed ?? null, + chunkLength: (data?.content || '').length, + snapshot: snapshotStreamingState() + }); + try { + ctx.socket.emit('client_chunk_log', { + conversation_id: ctx.currentConversationId, + index: data?.index ?? null, + elapsed: data?.elapsed ?? null, + length: (data?.content || '').length, + ts: Date.now() + }); + } catch (error) { + console.warn('上报chunk日志失败:', error); + } + if (data && typeof data.content === 'string' && data.content.length) { + if (STREAMING_ENABLED) { + enqueueStreamingContent(data.content); + } else { + // 关闭逐字模式时,直接追加当前chunk + applyTextChunk(data.content); + } + const speech = sanitizeBubbleText(data.content); + if (speech) { + ctx.monitorShowSpeech(speech); + } + } + }); + + // 文本结束 + ctx.socket.on('text_end', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + socketLog('文本结束 (轮询模式,跳过)'); + return; + } + socketLog('文本结束'); + logStreamingDebug('socket:text_end', { + finalLength: (data?.full_content || '').length, + snapshot: snapshotStreamingState() + }); + if (!STREAMING_ENABLED) { + // 已逐块追加,这里保证动作收尾 + const full = data?.full_content || ''; + if (full) { + ctx.chatCompleteTextAction(full); + } else { + ctx.chatCompleteTextAction(''); + } + streamingState.buffer.length = 0; + streamingState.pendingCompleteContent = ''; + streamingState.renderedText = ''; + streamingState.apiCompleted = false; + ctx.monitorEndModelOutput(); + flushPendingToolEvents(); + return; + } + streamingState.apiCompleted = true; + streamingState.pendingCompleteContent = data?.full_content || ''; + const hidden = typeof document !== 'undefined' && document.hidden === true; + if (hidden) { + stopStreamingTimer(); + drainStreamingBufferImmediately(); + finalizeStreamingText({ force: true }); + } else if (!streamingState.buffer.length) { + scheduleFinalizationAfterDrain(); + } else { + scheduleStreamingFlush(); + } + ctx.monitorEndModelOutput(); + flushPendingToolEvents(); + }); + + // 工具提示事件(可选) + ctx.socket.on('tool_hint', (data) => { + socketLog('工具提示:', data.name); + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过tool_hint(对话不匹配)', data.conversation_id); + return; + } + // 可以在这里添加提示UI + }); + + // 工具准备中事件 - 实时显示 + ctx.socket.on('tool_preparing', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (ctx.dropToolEvents) { + return; + } + console.log('[DEBUG] 收到 tool_preparing 事件:', { + name: data.name, + id: data.id, + conversation_id: data?.conversation_id, + currentConversationId: ctx.currentConversationId, + match: data?.conversation_id === ctx.currentConversationId + }); + socketLog('工具准备中:', data.name); + if (typeof console !== 'undefined' && console.debug) { + console.debug('[tool_intent] preparing', { + id: data?.id, + name: data?.name, + intent: data?.intent, + convo: data?.conversation_id + }); + } + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + console.log('[DEBUG] 跳过 tool_preparing (对话不匹配)'); + socketLog('跳过tool_preparing(对话不匹配)', data.conversation_id); + return; + } + const msg = ctx.chatEnsureAssistantMessage(); + if (!msg) { + console.log('[DEBUG] tool_preparing: 无法获取assistant消息'); + return; + } + if (msg.awaitingFirstContent) { + msg.awaitingFirstContent = false; + msg.generatingLabel = ''; + } + const action = { + id: data.id, + type: 'tool', + tool: { + id: data.id, + name: data.name, + arguments: {}, + argumentSnapshot: null, + argumentLabel: '', + status: 'preparing', + result: null, + message: data.message || `准备调用 ${data.name}...`, + intent_full: data.intent || '', + intent_rendered: data.intent || '' + }, + timestamp: Date.now() + }; + msg.actions.push(action); + ctx.preparingTools.set(data.id, action); + ctx.toolRegisterAction(action, data.id); + ctx.toolTrackAction(data.name, action); + if (data.intent) { + startIntentTyping(action, data.intent); + } + console.log('[DEBUG] tool_preparing 处理完成,action已添加'); + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + if (ctx.monitorPreviewTool) { + ctx.monitorPreviewTool(data); + } + }); + + // 工具意图(流式增量)事件 + ctx.socket.on('tool_intent', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (ctx.dropToolEvents) { + return; + } + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + return; + } + if (typeof console !== 'undefined' && console.debug) { + console.debug('[tool_intent] update', { + id: data?.id, + name: data?.name, + intent: data?.intent, + convo: data?.conversation_id + }); + } + const target = + ctx.toolFindAction(data.id, data.preparing_id, data.execution_id) || + ctx.toolGetLatestAction(data.name); + if (target) { + startIntentTyping(target, data.intent); + } + ctx.$forceUpdate(); + }); + + // 工具状态更新事件 - 实时显示详细状态 + ctx.socket.on('tool_status', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (ctx.dropToolEvents) { + return; + } + socketLog('工具状态:', data); + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过tool_status(对话不匹配)', data.conversation_id); + return; + } + const target = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); + if (target) { + target.tool.statusDetail = data.detail; + target.tool.statusType = data.status; + if (data.intent) { + startIntentTyping(target, data.intent); + } + ctx.$forceUpdate(); + return; + } + const fallbackAction = ctx.toolGetLatestAction(data.tool); + if (fallbackAction) { + fallbackAction.tool.statusDetail = data.detail; + fallbackAction.tool.statusType = data.status; + ctx.toolRegisterAction(fallbackAction, data.execution_id || data.id || data.preparing_id); + if (data.intent) { + startIntentTyping(fallbackAction, data.intent); + } + ctx.$forceUpdate(); + } + }); + + // 工具开始(从准备转为执行) + ctx.socket.on('tool_start', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (ctx.dropToolEvents) { + return; + } + socketLog('工具开始执行:', data.name); + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过tool_start(对话不匹配)', data.conversation_id); + return; + } + const handler = () => { + let action = null; + if (data.preparing_id && ctx.preparingTools.has(data.preparing_id)) { + action = ctx.preparingTools.get(data.preparing_id); + ctx.preparingTools.delete(data.preparing_id); + } else { + action = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); + } + if (!action) { + const msg = ctx.chatEnsureAssistantMessage(); + if (!msg) { + return; + } + action = { + id: data.id, + type: 'tool', + tool: { + id: data.id, + name: data.name, + arguments: {}, + argumentSnapshot: null, + argumentLabel: '', + status: 'running', + result: null + }, + timestamp: Date.now() + }; + msg.actions.push(action); + } + action.tool.status = 'running'; + action.tool.arguments = data.arguments; + action.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments); + action.tool.argumentLabel = ctx.buildToolLabel(action.tool.argumentSnapshot); + action.tool.message = null; + action.tool.id = data.id; + action.tool.executionId = data.id; + if (data.arguments && data.arguments.intent) { + startIntentTyping(action, data.arguments.intent); + } + ctx.toolRegisterAction(action, data.id); + ctx.toolTrackAction(data.name, action); + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + ctx.monitorQueueTool(data); + + if (data.name === 'view_video' && typeof ctx.uiPushToast === 'function') { + ctx.uiPushToast({ + title: '视频读取中', + message: '读取视频需要较长时间,请耐心等待', + type: 'info', + duration: 5000 + }); + } + }; + + handler(); + }); + + // 更新action(工具完成) + ctx.socket.on('update_action', (data) => { + // 轮询模式下跳过 WebSocket 事件 + if (ctx.usePollingMode && !ctx.waitingForSubAgent) { + return; + } + if (ctx.dropToolEvents) { + return; + } + socketLog('更新action:', data.id, 'status:', data.status); + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过update_action(对话不匹配)', data.conversation_id); + return; + } + const handler = () => { + let targetAction = ctx.toolFindAction(data.id, data.preparing_id, data.execution_id); + if (!targetAction && data.preparing_id && ctx.preparingTools.has(data.preparing_id)) { + targetAction = ctx.preparingTools.get(data.preparing_id); + } + if (!targetAction) { + outer: for (const message of ctx.messages) { + if (!message.actions) continue; + for (const action of message.actions) { + if (action.type !== 'tool') continue; + const matchByExecution = + action.tool.executionId && action.tool.executionId === data.id; + const matchByToolId = action.tool.id === data.id; + const matchByPreparingId = action.id === data.preparing_id; + if (matchByExecution || matchByToolId || matchByPreparingId) { + targetAction = action; + break outer; + } + } + } + } + if (targetAction) { + if (data.status) { + targetAction.tool.status = data.status; + } + if (data.result !== undefined) { + targetAction.tool.result = data.result; + } + if ( + targetAction.tool && + targetAction.tool.name === 'trigger_easter_egg' && + data.result !== undefined + ) { + const eggPromise = ctx.handleEasterEggPayload(data.result); + if (eggPromise && typeof eggPromise.catch === 'function') { + eggPromise.catch((error) => { + console.warn('彩蛋处理异常:', error); + }); + } + } + if (data.message !== undefined) { + targetAction.tool.message = data.message; + } + if (data.arguments && data.arguments.intent) { + startIntentTyping(targetAction, data.arguments.intent); + } + if (data.awaiting_content) { + targetAction.tool.awaiting_content = true; + } else if (data.status === 'completed') { + targetAction.tool.awaiting_content = false; + } + if (!targetAction.tool.executionId && (data.execution_id || data.id)) { + targetAction.tool.executionId = data.execution_id || data.id; + } + if (data.arguments) { + targetAction.tool.arguments = data.arguments; + targetAction.tool.argumentSnapshot = ctx.cloneToolArguments(data.arguments); + targetAction.tool.argumentLabel = ctx.buildToolLabel( + targetAction.tool.argumentSnapshot + ); + if (data.arguments.intent) { + startIntentTyping(targetAction, data.arguments.intent); + } + } + ctx.toolRegisterAction(targetAction, data.execution_id || data.id); + if ( + data.status && + ['completed', 'failed', 'error'].includes(data.status) && + !data.awaiting_content + ) { + ctx.toolUnregisterAction(targetAction); + if (data.id) { + ctx.preparingTools.delete(data.id); + } + if (data.preparing_id) { + ctx.preparingTools.delete(data.preparing_id); + } + } + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + } + + if (data.status === 'completed') { + setTimeout(() => { + ctx.updateCurrentContextTokens(); + }, 500); + try { + ctx.fileFetchTree(); + } catch (error) { + console.warn('刷新文件树失败', error); + } + } + ctx.monitorResolveTool(data); + }; + + handler(); + }); + + ctx.socket.on('append_payload', (data) => { + if (ctx.dropToolEvents) { + return; + } + socketLog('收到append_payload事件:', data); + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过append_payload(对话不匹配)', data.conversation_id); + return; + } + ctx.chatAddAppendPayloadAction({ + path: data.path || '未知文件', + forced: !!data.forced, + success: data.success === undefined ? true : !!data.success, + lines: data.lines ?? null, + bytes: data.bytes ?? null + }); + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + }); + + ctx.socket.on('modify_payload', (data) => { + if (ctx.dropToolEvents) { + return; + } + socketLog('收到modify_payload事件:', data); + if (data?.conversation_id && data.conversation_id !== ctx.currentConversationId) { + socketLog('跳过modify_payload(对话不匹配)', data.conversation_id); + return; + } + ctx.chatAddModifyPayloadAction({ + path: data.path || '未知文件', + total: data.total ?? null, + completed: data.completed || [], + failed: data.failed || [], + forced: !!data.forced + }); + ctx.$forceUpdate(); + ctx.conditionalScrollToBottom(); + }); + + // 停止请求确认 + ctx.socket.on('stop_requested', (data) => { + socketLog('停止请求已接收:', data.message); + // 可以显示提示信息 + try { + if (typeof ctx.clearPendingTools === 'function') { + ctx.clearPendingTools('socket:stop_requested'); + } + } catch (error) { + console.warn('清理未完成工具失败', error); + } + }); + + // 任务停止 + ctx.socket.on('task_stopped', (data) => { + socketLog('任务已停止:', data.message); + ctx.taskInProgress = false; + ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true }); + }); + + // 任务完成(重点:更新Token统计) + ctx.socket.on('task_complete', (data) => { + console.log('[DEBUG] 收到 task_complete 事件:', data); + console.log('[DEBUG] 当前状态 (before task_complete):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + has_running_sub_agents: data.has_running_sub_agents + }); + socketLog('任务完成', data); + + // 如果有运行中的子智能体,不重置任务状态 + if (!data.has_running_sub_agents) { + console.log('[DEBUG] 没有运行中的子智能体,重置任务状态'); + if (ctx.waitingForSubAgent) { + ctx.waitingForSubAgent = false; + } + ctx.taskInProgress = false; + ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true }); + } else { + console.log('[DEBUG] 有运行中的子智能体,保持任务状态'); + } + + console.log('[DEBUG] 当前状态 (after task_complete):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage + }); + + resetPendingToolEvents(); + + // 任务完成后立即更新Token统计(关键修复) + if (ctx.currentConversationId) { + ctx.updateCurrentContextTokens(); + ctx.fetchConversationTokenStatistics(); + } + }); + + // 子智能体等待状态 + ctx.socket.on('sub_agent_waiting', (data) => { + console.log('[DEBUG] 收到 sub_agent_waiting 事件:', data); + console.log('[DEBUG] 当前状态 (before sub_agent_waiting):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + stopRequested: ctx.stopRequested + }); + socketLog('等待子智能体完成:', data); + + // 设置标志:有子智能体在运行,阻止状态重置 + ctx.waitingForSubAgent = true; + + // 保持任务进行中状态 + ctx.taskInProgress = true; + ctx.streamingMessage = false; + ctx.stopRequested = false; + + console.log('[DEBUG] 当前状态 (after sub_agent_waiting):', { + taskInProgress: ctx.taskInProgress, + streamingMessage: ctx.streamingMessage, + stopRequested: ctx.stopRequested, + waitingForSubAgent: ctx.waitingForSubAgent + }); + + // 显示等待提示 + if (typeof ctx.appendSystemAction === 'function') { + const taskList = data.tasks + .map((t: any) => `子智能体${t.agent_id} (${t.summary || '无描述'})`) + .join('、'); + ctx.appendSystemAction(`⏳ 等待 ${data.count} 个后台子智能体完成:${taskList}`); + } + + ctx.$forceUpdate(); + console.log('[DEBUG] sub_agent_waiting 处理完成'); + }); + + // 聚焦文件更新 + ctx.socket.on('focused_files_update', (data) => { + ctx.focusSetFiles(data || {}); + // 聚焦文件变化时更新当前上下文Token(关键修复) + if (ctx.currentConversationId) { + setTimeout(() => { + ctx.updateCurrentContextTokens(); + }, 500); + } + }); + + // 文件树更新 + ctx.socket.on('file_tree_update', (data) => { + ctx.fileSetTreeFromResponse(data); + // 文件树变化时也可能影响上下文 + if (ctx.currentConversationId) { + setTimeout(() => { + ctx.updateCurrentContextTokens(); + }, 500); + } + }); + + // 系统消息 + ctx.socket.on('system_message', (data) => { + if (!data || !data.content) { + return; + } + ctx.appendSystemAction(data.content); + }); + + // 错误处理 + ctx.socket.on('error', (data) => { + const msg = data?.message || '发生未知错误'; + const code = data?.status_code; + const errType = data?.error_type; + const errCode = data?.error_code; + const dumpPath = data?.request_dump; + const baseUrl = data?.base_url; + const modelId = data?.model_id; + const conversationId = data?.conversation_id; + const taskId = data?.task_id; + const shouldRetry = Boolean(data?.retry); + const retryIn = Number(data?.retry_in) || 5; + const retryAttempt = Number(data?.attempt) || 1; + const retryMax = Number(data?.max_attempts) || retryAttempt; + const detailParts = [ + dumpPath ? `请求记录: ${dumpPath}` : '', + baseUrl ? `接口: ${baseUrl}` : '', + modelId ? `模型: ${modelId}` : '', + conversationId ? `对话ID: ${conversationId}` : '', + taskId ? `任务ID: ${taskId}` : '' + ].filter(Boolean); + const detailText = detailParts.length ? `\n${detailParts.join('\n')}` : ''; + if (typeof ctx.uiPushToast === 'function') { + ctx.uiPushToast({ + title: code ? `API错误 ${code}` : 'API错误', + message: `${errType ? `${errType}${errCode ? `(${errCode})` : ''}: ${msg}` : msg}${detailText}`, + type: 'error', + duration: 6000 + }); + if (shouldRetry) { + ctx.uiPushToast({ + title: '即将重试', + message: `将在 ${retryIn} 秒后重试(第 ${retryAttempt}/${retryMax} 次)`, + type: 'info', + duration: Math.max(retryIn, 1) * 1000 + }); + } + } + if (shouldRetry) { + // 错误后保持停止按钮态,用户可手动停止或等待自动重试 + ctx.stopRequested = false; + ctx.taskInProgress = true; + ctx.streamingMessage = true; + return; + } + + if (typeof ctx.appendSystemAction === 'function') { + ctx.appendSystemAction( + `${code ? `[API ${code}] ` : '[API] '}${errType ? `${errType}${errCode ? `(${errCode})` : ''}: ` : ''}${msg}${detailText}` + ); + } + + // 最后一次报错:恢复输入状态并清理提示动画 + const msgIndex = typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : -1; + if (msgIndex >= 0 && Array.isArray(ctx.messages)) { + const currentMessage = ctx.messages[msgIndex]; + if (currentMessage && currentMessage.role === 'assistant') { + currentMessage.awaitingFirstContent = false; + currentMessage.generatingLabel = ''; + } + } + if (typeof ctx.chatClearThinkingLocks === 'function') { + ctx.chatClearThinkingLocks(); + } + ctx.streamingMessage = false; + ctx.stopRequested = false; + ctx.taskInProgress = false; + }); + + // 命令结果 + ctx.socket.on('command_result', (data) => { + if (data.command === 'clear' && data.success) { + ctx.logMessageState?.('socket:command_result-clear', { data }); + ctx.messages = []; + ctx.logMessageState?.('socket:command_result-cleared', { data }); + ctx.currentMessageIndex = -1; + ctx.chatClearExpandedBlocks(); + // 清除对话时重置Token统计 + ctx.resetTokenStatistics(); + } else if (data.command === 'status' && data.success) { + ctx.addSystemMessage(`系统状态:\n${JSON.stringify(data.data, null, 2)}`); + } else if (!data.success) { + ctx.addSystemMessage(`命令失败: ${data.message}`); + } + }); + } catch (error) { + console.error('Socket初始化失败:', error); + } } diff --git a/static/src/composables/useMarkdownRenderer.ts b/static/src/composables/useMarkdownRenderer.ts index 7fce6ba..5b66a05 100644 --- a/static/src/composables/useMarkdownRenderer.ts +++ b/static/src/composables/useMarkdownRenderer.ts @@ -11,17 +11,19 @@ function wrapCodeBlocks(html: string, isStreaming = false) { } let counter = 0; - return html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (match, attributes, content) => {
-    const langMatch = attributes.match(/class="[^"]*language-(\w+)/);
-    const language = langMatch ? langMatch[1] : 'text';
-    const blockId = `code-${Date.now()}-${counter++}`;
-    const escapedContent = content
-      .replace(/&/g, '&')
-      .replace(//g, '>')
-      .replace(/"/g, '"');
+  return html.replace(
+    /
]*)>([\s\S]*?)<\/code><\/pre>/g,
+    (match, attributes, content) => {
+      const langMatch = attributes.match(/class="[^"]*language-(\w+)/);
+      const language = langMatch ? langMatch[1] : 'text';
+      const blockId = `code-${Date.now()}-${counter++}`;
+      const escapedContent = content
+        .replace(/&/g, '&')
+        .replace(//g, '>')
+        .replace(/"/g, '"');
 
-    return `
+      return `
             
${language} @@ -29,7 +31,8 @@ function wrapCodeBlocks(html: string, isStreaming = false) {
${content}
`; - }); + } + ); } export function renderMarkdown(text: string, isStreaming = false) { @@ -71,8 +74,10 @@ export function renderMarkdown(text: string, isStreaming = false) { if (!isStreaming) { setTimeout(() => { if (typeof Prism !== 'undefined') { - const codeBlocks = document.querySelectorAll('.code-block-wrapper pre code:not([data-highlighted])'); - codeBlocks.forEach(block => { + const codeBlocks = document.querySelectorAll( + '.code-block-wrapper pre code:not([data-highlighted])' + ); + codeBlocks.forEach((block) => { try { Prism.highlightElement(block as HTMLElement); block.setAttribute('data-highlighted', 'true'); @@ -83,8 +88,10 @@ export function renderMarkdown(text: string, isStreaming = false) { } if (typeof renderMathInElement !== 'undefined') { - const elements = document.querySelectorAll('.text-output .text-content:not(.streaming-text)'); - elements.forEach(element => { + const elements = document.querySelectorAll( + '.text-output .text-content:not(.streaming-text)' + ); + elements.forEach((element) => { if (element.hasAttribute('data-math-rendered')) return; try { renderMathInElement(element as HTMLElement, { @@ -120,7 +127,7 @@ export function renderLatexInRealtime() { latexRenderTimer = requestAnimationFrame(() => { const elements = document.querySelectorAll('.text-output .streaming-text'); - elements.forEach(element => { + elements.forEach((element) => { try { renderMathInElement(element as HTMLElement, { delimiters: [ diff --git a/static/src/composables/usePanelResize.ts b/static/src/composables/usePanelResize.ts index 0ec41df..99f56ba 100644 --- a/static/src/composables/usePanelResize.ts +++ b/static/src/composables/usePanelResize.ts @@ -23,7 +23,11 @@ export function startResize(ctx: ResizeContext, panel: PanelKey, event: MouseEve if (panel === 'right' && ctx.rightCollapsed) { ctx.rightCollapsed = false; - if (typeof ctx.rightWidth === 'number' && typeof ctx.minPanelWidth === 'number' && ctx.rightWidth < ctx.minPanelWidth) { + if ( + typeof ctx.rightWidth === 'number' && + typeof ctx.minPanelWidth === 'number' && + ctx.rightWidth < ctx.minPanelWidth + ) { ctx.rightWidth = ctx.minPanelWidth; } } diff --git a/static/src/composables/useScrollControl.ts b/static/src/composables/useScrollControl.ts index 374f7f3..dc2f4d4 100644 --- a/static/src/composables/useScrollControl.ts +++ b/static/src/composables/useScrollControl.ts @@ -36,8 +36,7 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) { const attempts = [0, 16, 60, 150, 320, 520]; // 多次尝试覆盖布局抖动/异步伸缩 let cancelled = false; const useSmooth = - options?.behavior === 'smooth' && - typeof (messagesArea as HTMLElement).scrollTo === 'function'; + options?.behavior === 'smooth' && typeof (messagesArea as HTMLElement).scrollTo === 'function'; const perform = (idx: number) => { if (cancelled) return; @@ -97,7 +96,11 @@ export function toggleScrollLock(ctx: ScrollContext) { // 没有模型输出时:允许点击,但不切换锁定,仅单次滚动到底部 if (!active) { - scrollToBottom(ctx, { ignoreUserScrolling: true, resetUserScrolling: true, behavior: 'smooth' }); + scrollToBottom(ctx, { + ignoreUserScrolling: true, + resetUserScrolling: true, + behavior: 'smooth' + }); return ctx.autoScrollEnabled ?? false; } diff --git a/static/src/stores/chat.ts b/static/src/stores/chat.ts index 4afa71c..e0fb26e 100644 --- a/static/src/stores/chat.ts +++ b/static/src/stores/chat.ts @@ -82,7 +82,7 @@ export const useChatStore = defineStore('chat', { thinkingScrollLocks: new Map() }), getters: { - isScrollLocked: state => state.autoScrollEnabled && !state.userScrolling + isScrollLocked: (state) => state.autoScrollEnabled && !state.userScrolling }, actions: { setStreamingMessage(active: boolean) { @@ -315,7 +315,8 @@ export const useChatStore = defineStore('chat', { } if (msg.activeThinkingId) { const found = msg.actions.find( - (action: any) => action && action.id === msg.activeThinkingId && action.type === 'thinking' + (action: any) => + action && action.id === msg.activeThinkingId && action.type === 'thinking' ); if (found) { return found; diff --git a/static/src/stores/file.ts b/static/src/stores/file.ts index 9d828d9..b91d1fe 100644 --- a/static/src/stores/file.ts +++ b/static/src/stores/file.ts @@ -47,7 +47,7 @@ function buildNodes(treeMap: Record | undefined): FileNode[] { if (!treeMap) { return []; } - const entries = Object.keys(treeMap).map(name => { + const entries = Object.keys(treeMap).map((name) => { const node = treeMap[name] || {}; if (node.type === 'folder') { return { @@ -129,7 +129,7 @@ export const useFileStore = defineStore('file', { const validFolderPaths = new Set(); const ensureExpansion = (list: FileNode[]) => { - list.forEach(item => { + list.forEach((item) => { if (item.type === 'folder') { validFolderPaths.add(item.path); if (expanded[item.path] === undefined) { @@ -141,7 +141,7 @@ export const useFileStore = defineStore('file', { }; ensureExpansion(nodes); - Object.keys(expanded).forEach(path => { + Object.keys(expanded).forEach((path) => { if (!validFolderPaths.has(path)) { delete expanded[path]; } diff --git a/static/src/stores/input.ts b/static/src/stores/input.ts index 5923ac4..2eabdf0 100644 --- a/static/src/stores/input.ts +++ b/static/src/stores/input.ts @@ -93,7 +93,7 @@ export const useInputStore = defineStore('input', { this.selectedImages = next.slice(0, 9); }, removeSelectedImage(path: string) { - this.selectedImages = this.selectedImages.filter(item => item !== path); + this.selectedImages = this.selectedImages.filter((item) => item !== path); }, clearSelectedImages() { this.selectedImages = []; @@ -106,7 +106,7 @@ export const useInputStore = defineStore('input', { this.selectedVideos = [path]; }, removeSelectedVideo(path: string) { - this.selectedVideos = this.selectedVideos.filter(item => item !== path); + this.selectedVideos = this.selectedVideos.filter((item) => item !== path); }, clearSelectedVideos() { this.selectedVideos = []; diff --git a/static/src/stores/model.ts b/static/src/stores/model.ts index d367466..3059863 100644 --- a/static/src/stores/model.ts +++ b/static/src/stores/model.ts @@ -72,21 +72,21 @@ export const useModelStore = defineStore('model', { }), getters: { currentModel(state): ModelOption { - return state.models.find(m => m.key === state.currentModelKey) || state.models[0]; + return state.models.find((m) => m.key === state.currentModelKey) || state.models[0]; } }, actions: { setModels(models: ModelOption[]) { if (!Array.isArray(models) || !models.length) return; this.models = models; - const exists = this.models.some(m => m.key === this.currentModelKey); + const exists = this.models.some((m) => m.key === this.currentModelKey); if (!exists && this.models[0]) { this.currentModelKey = this.models[0].key; } }, setModel(key: ModelKey) { if (this.currentModelKey === key) return; - const exists = this.models.some(m => m.key === key); + const exists = this.models.some((m) => m.key === key); if (exists) { this.currentModelKey = key; } @@ -112,7 +112,8 @@ export const useModelStore = defineStore('model', { supportsImage, supportsVideo, contextWindow: typeof item.context_window === 'number' ? item.context_window : null, - maxOutputTokens: typeof item.max_output_tokens === 'number' ? item.max_output_tokens : null, + maxOutputTokens: + typeof item.max_output_tokens === 'number' ? item.max_output_tokens : null, fastOnly: !!item.fast_only, supportsThinking: !!item.supports_thinking, deepOnly: !!item.deep_only diff --git a/static/src/stores/monitor.ts b/static/src/stores/monitor.ts index 1dd863b..dab8500 100644 --- a/static/src/stores/monitor.ts +++ b/static/src/stores/monitor.ts @@ -1,5 +1,9 @@ import { defineStore } from 'pinia'; -import type { MonitorBubbleOptions, MonitorDriver, MonitorSceneRuntime } from '@/components/chat/monitor/types'; +import type { + MonitorBubbleOptions, + MonitorDriver, + MonitorSceneRuntime +} from '@/components/chat/monitor/types'; import { getSceneProgressLabel } from '@/components/chat/monitor/progressMap'; import { useConnectionStore } from '@/stores/connection'; @@ -116,7 +120,8 @@ const TOOL_SCENE_MAP: Record = { terminal_run: 'runCommand' }; -const randomId = (prefix = 'evt') => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; +const randomId = (prefix = 'evt') => + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; export const useMonitorStore = defineStore('monitor', { state: (): MonitorState => ({ @@ -140,7 +145,7 @@ export const useMonitorStore = defineStore('monitor', { } }), getters: { - isLocked: state => state.playing || state.queue.length > 0 + isLocked: (state) => state.playing || state.queue.length > 0 }, actions: { registerDriver(driver: MonitorDriver) { @@ -179,7 +184,10 @@ export const useMonitorStore = defineStore('monitor', { this.driver?.setDesktopRoots(this.lastTreeSnapshot, { immediate: true }); return; } - const folders = tree.filter(node => node && node.type === 'folder').map(node => node.name).filter(Boolean); + const folders = tree + .filter((node) => node && node.type === 'folder') + .map((node) => node.name) + .filter(Boolean); this.lastTreeSnapshot = folders.length ? folders : [...DEFAULT_ROOTS]; this.driver?.setDesktopRoots(this.lastTreeSnapshot); }, @@ -207,7 +215,7 @@ export const useMonitorStore = defineStore('monitor', { this.awaitingTools = {}; } if (!preservePendingResults) { - Object.values(this.pendingResults).forEach(entry => { + Object.values(this.pendingResults).forEach((entry) => { if (entry?.timeout) { clearTimeout(entry.timeout); } @@ -222,7 +230,12 @@ export const useMonitorStore = defineStore('monitor', { this.thinkingActive = false; this.pendingProgressScene = null; this.progressIndicator = { id: null, label: '', scene: null }; - this.driver?.resetScene({ desktopRoots: this.lastTreeSnapshot, preserveBubble, preservePointer, preserveWindows }); + this.driver?.resetScene({ + desktopRoots: this.lastTreeSnapshot, + preserveBubble, + preservePointer, + preserveWindows + }); this.driver?.setManualInteractionEnabled(!this.isLocked); }, setProgressIndicator(payload: { id: string | null; label: string; scene: string }) { @@ -268,7 +281,10 @@ export const useMonitorStore = defineStore('monitor', { if (id && this.progressIndicator.id && id !== this.progressIndicator.id) { return; } - monitorProgressDebug('progress-indicator:clear', { id: this.progressIndicator.id, label: this.progressIndicator.label }); + monitorProgressDebug('progress-indicator:clear', { + id: this.progressIndicator.id, + label: this.progressIndicator.label + }); this.progressIndicator = { id: null, label: '', scene: null }; }, setStatus(label: string) { @@ -395,7 +411,11 @@ export const useMonitorStore = defineStore('monitor', { monitorProgressDebug('enqueueToolEvent:preview-now', { tool: payload.name, script, id }); this.driver.previewSceneProgress(script); } else { - monitorProgressDebug('enqueueToolEvent:pending-preview', { tool: payload.name, script, id }); + monitorProgressDebug('enqueueToolEvent:pending-preview', { + tool: payload.name, + script, + id + }); this.pendingProgressScene = script; } if (this.bubbleActive) { @@ -404,7 +424,11 @@ export const useMonitorStore = defineStore('monitor', { }; if (this.bubbleActive && recentSpeechGap >= 0 && recentSpeechGap < MIN_SPEECH_VISIBLE_MS) { const delay = MIN_SPEECH_VISIBLE_MS - recentSpeechGap; - monitorLifecycleLog('enqueue:delay-preview-for-speech', { delay, recentSpeechGap, tool: payload.name }); + monitorLifecycleLog('enqueue:delay-preview-for-speech', { + delay, + recentSpeechGap, + tool: payload.name + }); setTimeout(doPreview, delay); } else { doPreview(); @@ -456,12 +480,12 @@ export const useMonitorStore = defineStore('monitor', { return; } let resolver: (value: any) => void = () => {}; - const promise = new Promise(resolve => { + const promise = new Promise((resolve) => { resolver = resolve; }); const entry: PendingResultEntry = { promise, - resolve: value => { + resolve: (value) => { if (!entry.settled) { entry.settled = true; if (entry.timeout) { @@ -575,43 +599,47 @@ export const useMonitorStore = defineStore('monitor', { this.setStatus('待机'); monitorLifecycleLog('queue-end'); }, - async runScript(event: MonitorEvent) { - if (!this.driver) { - this.queue.unshift(event); - return; - } - const waitKey = - event.payload?.executionId || event.payload?.execution_id || event.id || event.payload?.id || null; - monitorLifecycleLog('runScript:start', { - script: event.script, - waitKey, - payloadTool: event.payload?.name, - payloadId: event.payload?.id - }); + async runScript(event: MonitorEvent) { + if (!this.driver) { + this.queue.unshift(event); + return; + } + const waitKey = + event.payload?.executionId || + event.payload?.execution_id || + event.id || + event.payload?.id || + null; + monitorLifecycleLog('runScript:start', { + script: event.script, + waitKey, + payloadTool: event.payload?.name, + payloadId: event.payload?.id + }); - let playbackResult: any = undefined; - let playbackSettled = false; - const waitForCompletion = async () => { - if (!waitKey) { - playbackSettled = true; - if (playbackResult === undefined) { + let playbackResult: any = undefined; + let playbackSettled = false; + const waitForCompletion = async () => { + if (!waitKey) { + playbackSettled = true; + if (playbackResult === undefined) { + playbackResult = null; + } + return playbackResult; + } + if (playbackSettled) { + return playbackResult; + } + try { + playbackResult = await this.waitForResult(waitKey); + } catch (error) { + console.warn('monitor waitForResult error', error); playbackResult = null; + } finally { + playbackSettled = true; } return playbackResult; - } - if (playbackSettled) { - return playbackResult; - } - try { - playbackResult = await this.waitForResult(waitKey); - } catch (error) { - console.warn('monitor waitForResult error', error); - playbackResult = null; - } finally { - playbackSettled = true; - } - return playbackResult; - }; + }; const transformStatus = (raw?: string) => { const label = typeof raw === 'string' && raw.trim().length ? raw.trim() : '进行中'; diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index e719ed7..0158709 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -117,8 +117,10 @@ const loadExperimentState = (): ExperimentState => { // 兼容旧版 stackedBlocksEnabled let blockDisplayMode: BlockDisplayMode = defaultExperimentState().blockDisplayMode; - if (typeof parsed?.blockDisplayMode === 'string' && - ['traditional', 'stacked', 'minimal'].includes(parsed.blockDisplayMode)) { + if ( + typeof parsed?.blockDisplayMode === 'string' && + ['traditional', 'stacked', 'minimal'].includes(parsed.blockDisplayMode) + ) { blockDisplayMode = parsed.blockDisplayMode; } else if (typeof parsed?.stackedBlocksEnabled === 'boolean') { // 兼容旧版:true -> stacked, false -> traditional @@ -207,7 +209,9 @@ export const usePersonalizationStore = defineStore('personalization', { applyPersonalizationData(data: any) { // 若后端未返回默认模型(旧版本接口),保持当前已选模型而不是回退为 Kimi const fallbackModel = - (this.form && typeof this.form.default_model === 'string' ? this.form.default_model : null) || 'kimi-k2.5'; + (this.form && typeof this.form.default_model === 'string' + ? this.form.default_model + : null) || 'kimi-k2.5'; this.form = { enabled: !!data.enabled, auto_generate_title: data.auto_generate_title !== false, @@ -215,8 +219,10 @@ export const usePersonalizationStore = defineStore('personalization', { skill_hints_enabled: !!data.skill_hints_enabled, skill_strict_terminal_enabled: !!data.skill_strict_terminal_enabled, skill_strict_sub_agent_enabled: !!data.skill_strict_sub_agent_enabled, - skill_strict_run_command_foreground_enabled: !!data.skill_strict_run_command_foreground_enabled, - skill_strict_run_command_background_enabled: !!data.skill_strict_run_command_background_enabled, + skill_strict_run_command_foreground_enabled: + !!data.skill_strict_run_command_foreground_enabled, + skill_strict_run_command_background_enabled: + !!data.skill_strict_run_command_background_enabled, silent_tool_disable: !!data.silent_tool_disable, enhanced_tool_display: data.enhanced_tool_display !== false, enabled_skills: Array.isArray(data.enabled_skills) @@ -228,21 +234,36 @@ export const usePersonalizationStore = defineStore('personalization', { profession: data.profession || '', tone: data.tone || '', considerations: Array.isArray(data.considerations) ? [...data.considerations] : [], - thinking_interval: typeof data.thinking_interval === 'number' ? data.thinking_interval : null, - disabled_tool_categories: Array.isArray(data.disabled_tool_categories) ? data.disabled_tool_categories.filter((item: unknown) => typeof item === 'string') : [], + thinking_interval: + typeof data.thinking_interval === 'number' ? data.thinking_interval : null, + disabled_tool_categories: Array.isArray(data.disabled_tool_categories) + ? data.disabled_tool_categories.filter((item: unknown) => typeof item === 'string') + : [], default_run_mode: - typeof data.default_run_mode === 'string' && RUN_MODE_OPTIONS.includes(data.default_run_mode as RunMode) - ? data.default_run_mode as RunMode + typeof data.default_run_mode === 'string' && + RUN_MODE_OPTIONS.includes(data.default_run_mode as RunMode) + ? (data.default_run_mode as RunMode) : null, default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel, - image_compression: typeof data.image_compression === 'string' ? data.image_compression : 'original', + image_compression: + typeof data.image_compression === 'string' ? data.image_compression : 'original', auto_shallow_compress_enabled: !!data.auto_shallow_compress_enabled, auto_deep_compress_enabled: !!data.auto_deep_compress_enabled, - shallow_compress_trigger_tokens: this.normalizeCompressionNumber(data.shallow_compress_trigger_tokens), - shallow_compress_keep_recent_tools: this.normalizeCompressionNumber(data.shallow_compress_keep_recent_tools), - shallow_compress_max_replace_per_round: this.normalizeCompressionNumber(data.shallow_compress_max_replace_per_round), - shallow_compress_trigger_tool_calls_interval: this.normalizeCompressionNumber(data.shallow_compress_trigger_tool_calls_interval), - deep_compress_trigger_tokens: this.normalizeCompressionNumber(data.deep_compress_trigger_tokens) + shallow_compress_trigger_tokens: this.normalizeCompressionNumber( + data.shallow_compress_trigger_tokens + ), + shallow_compress_keep_recent_tools: this.normalizeCompressionNumber( + data.shallow_compress_keep_recent_tools + ), + shallow_compress_max_replace_per_round: this.normalizeCompressionNumber( + data.shallow_compress_max_replace_per_round + ), + shallow_compress_trigger_tool_calls_interval: this.normalizeCompressionNumber( + data.shallow_compress_trigger_tool_calls_interval + ), + deep_compress_trigger_tokens: this.normalizeCompressionNumber( + data.deep_compress_trigger_tokens + ) }; this.clearFeedback(); }, @@ -279,7 +300,9 @@ export const usePersonalizationStore = defineStore('personalization', { this.toolCategories = payload.tool_categories .map((item: { id?: string; label?: string } = {}) => ({ id: typeof item.id === 'string' ? item.id : String(item.id ?? ''), - label: (item.label && String(item.label)) || (typeof item.id === 'string' ? item.id : String(item.id ?? '')) + label: + (item.label && String(item.label)) || + (typeof item.id === 'string' ? item.id : String(item.id ?? '')) })) .filter((item: { id: string }) => !!item.id); } else { @@ -289,7 +312,9 @@ export const usePersonalizationStore = defineStore('personalization', { this.skillsCatalog = payload.skills_catalog .map((item: { id?: string; label?: string; description?: string } = {}) => ({ id: typeof item.id === 'string' ? item.id : String(item.id ?? ''), - label: (item.label && String(item.label)) || (typeof item.id === 'string' ? item.id : String(item.id ?? '')), + label: + (item.label && String(item.label)) || + (typeof item.id === 'string' ? item.id : String(item.id ?? '')), description: typeof item.description === 'string' ? item.description : undefined })) .filter((item: { id: string }) => !!item.id); @@ -348,9 +373,11 @@ export const usePersonalizationStore = defineStore('personalization', { this.error = ''; try { const shallowTrigger = - this.normalizeCompressionNumber(this.form.shallow_compress_trigger_tokens) ?? DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS; + this.normalizeCompressionNumber(this.form.shallow_compress_trigger_tokens) ?? + DEFAULT_SHALLOW_COMPRESS_TRIGGER_TOKENS; const deepTrigger = - this.normalizeCompressionNumber(this.form.deep_compress_trigger_tokens) ?? DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS; + this.normalizeCompressionNumber(this.form.deep_compress_trigger_tokens) ?? + DEFAULT_DEEP_COMPRESS_TRIGGER_TOKENS; if (deepTrigger <= shallowTrigger) { throw new Error('深压缩触发上下文必须大于浅压缩触发上下文'); } @@ -546,7 +573,7 @@ export const usePersonalizationStore = defineStore('personalization', { const resp = await fetch('/logout', { method: 'POST', credentials: 'same-origin', - cache: 'no-store', + cache: 'no-store' }); console.info('[auth-debug] logout POST status:', resp.status); let payload: any = null; diff --git a/static/src/stores/policy.ts b/static/src/stores/policy.ts index 1920c12..230728b 100644 --- a/static/src/stores/policy.ts +++ b/static/src/stores/policy.ts @@ -1,7 +1,16 @@ import { defineStore } from 'pinia'; export interface EffectivePolicy { - categories: Record; + categories: Record< + string, + { + label: string; + tools: string[]; + default_enabled?: boolean; + locked?: boolean; + locked_state?: boolean | null; + } + >; forced_category_states: Record; disabled_models: string[]; ui_blocks: Record; diff --git a/static/src/stores/resource.ts b/static/src/stores/resource.ts index 4339cf4..121d290 100644 --- a/static/src/stores/resource.ts +++ b/static/src/stores/resource.ts @@ -130,8 +130,10 @@ export const useResourceStore = defineStore('resource', { const response = await fetch(`/api/conversations/${conversationId}/token-statistics`); const data = await response.json(); if (data.success && data.data) { - this.currentConversationTokens.cumulative_input_tokens = data.data.total_input_tokens || 0; - this.currentConversationTokens.cumulative_output_tokens = data.data.total_output_tokens || 0; + this.currentConversationTokens.cumulative_input_tokens = + data.data.total_input_tokens || 0; + this.currentConversationTokens.cumulative_output_tokens = + data.data.total_output_tokens || 0; this.currentConversationTokens.cumulative_total_tokens = data.data.total_tokens || 0; if (typeof data.data.current_context_tokens === 'number') { this.currentContextTokens = data.data.current_context_tokens; @@ -151,7 +153,9 @@ export const useResourceStore = defineStore('resource', { this.projectStorage.limit_bytes = project.limit_bytes ?? null; this.projectStorage.limit_label = project.limit_label || - (project.limit_bytes ? `${(project.limit_bytes / (1024 * 1024)).toFixed(0)} MB` : '未限制'); + (project.limit_bytes + ? `${(project.limit_bytes / (1024 * 1024)).toFixed(0)} MB` + : '未限制'); if (project.limit_bytes) { const pct = typeof project.usage_percent === 'number' @@ -177,8 +181,12 @@ export const useResourceStore = defineStore('resource', { if (stats && typeof stats.timestamp === 'number') { const currentSample: ContainerSample = { timestamp: stats.timestamp, - rx_bytes: stats.net_io && typeof stats.net_io.rx_bytes === 'number' ? stats.net_io.rx_bytes : null, - tx_bytes: stats.net_io && typeof stats.net_io.tx_bytes === 'number' ? stats.net_io.tx_bytes : null + rx_bytes: + stats.net_io && typeof stats.net_io.rx_bytes === 'number' + ? stats.net_io.rx_bytes + : null, + tx_bytes: + stats.net_io && typeof stats.net_io.tx_bytes === 'number' ? stats.net_io.tx_bytes : null }; const last = this.lastContainerSample; if ( diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index c524cb1..7b19d1d 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -2,391 +2,409 @@ import { defineStore } from 'pinia'; import { debugLog } from '../app/methods/common'; -const debugNotifyLog = (..._args: any[]) => {}; +const debugNotifyLog = (...args: any[]) => { + void args; +}; const keyNotifyLog = (...args: any[]) => { - console.log(...args); + console.log(...args); }; export const useTaskStore = defineStore('task', { - state: () => ({ - currentTaskId: null as string | null, - lastEventIndex: 0, - pollingInterval: null as number | null, - taskStatus: 'idle' as 'idle' | 'running' | 'succeeded' | 'failed' | 'canceled', - isPolling: false, - pollingError: null as string | null, - pollingErrorCount: 0, // 新增错误计数 - taskCreatedAt: null as number | null, - taskUpdatedAt: null as number | null, - }), + state: () => ({ + currentTaskId: null as string | null, + lastEventIndex: 0, + pollingInterval: null as number | null, + taskStatus: 'idle' as 'idle' | 'running' | 'succeeded' | 'failed' | 'canceled', + isPolling: false, + pollingError: null as string | null, + pollingErrorCount: 0, // 新增错误计数 + taskCreatedAt: null as number | null, + taskUpdatedAt: null as number | null + }), - getters: { - hasActiveTask: (state) => state.currentTaskId !== null && state.taskStatus === 'running', - isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled'].includes(state.taskStatus), + getters: { + hasActiveTask: (state) => state.currentTaskId !== null && state.taskStatus === 'running', + isTaskCompleted: (state) => ['succeeded', 'failed', 'canceled'].includes(state.taskStatus) + }, + + actions: { + async createTask( + message: string, + images: any[] = [], + videos: any[] = [], + conversationId: string | null = null, + options: { + model_key?: string | null; + run_mode?: 'fast' | 'thinking' | 'deep' | null; + thinking_mode?: boolean | null; + } = {} + ) { + try { + debugLog('[Task] 创建任务:', { message, conversationId }); + + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message, + images, + videos, + conversation_id: conversationId, + model_key: options.model_key ?? undefined, + run_mode: options.run_mode ?? undefined, + thinking_mode: + typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || '创建任务失败'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || '创建任务失败'); + } + + this.currentTaskId = result.data.task_id; + this.taskStatus = result.data.status; + this.lastEventIndex = 0; + this.taskCreatedAt = result.data.created_at; + this.pollingError = null; + + debugLog('[Task] 任务创建成功:', result.data.task_id); + + // 立即开始轮询 + this.startPolling(); + + return result.data; + } catch (error) { + debugLog('[Task] 创建任务失败:', error); + throw error; + } }, - actions: { - async createTask( - message: string, - images: any[] = [], - videos: any[] = [], - conversationId: string | null = null, - options: { - model_key?: string | null; - run_mode?: 'fast' | 'thinking' | 'deep' | null; - thinking_mode?: boolean | null; - } = {} - ) { + async pollTaskEvents(eventHandler: (event: any) => void) { + if (!this.currentTaskId) { + debugLog('[Task] 没有活跃任务,停止轮询'); + this.stopPolling(); + return; + } + + try { + const response = await fetch( + `/api/tasks/${this.currentTaskId}?from=${this.lastEventIndex}`, + { signal: AbortSignal.timeout(5000) } // 5秒超时 + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || '轮询任务失败'); + } + + const data = result.data; + + // 更新任务状态 + this.taskStatus = data.status; + this.taskUpdatedAt = data.updated_at; + let sawTaskStoppedEvent = false; + + // 处理新事件 + if (data.events && data.events.length > 0) { + debugLog(`[Task] 收到 ${data.events.length} 个新事件`); + + for (const event of data.events) { + if (event?.type === 'task_stopped') { + sawTaskStoppedEvent = true; + } try { - debugLog('[Task] 创建任务:', { message, conversationId }); - - const response = await fetch('/api/tasks', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - message, - images, - videos, - conversation_id: conversationId, - model_key: options.model_key ?? undefined, - run_mode: options.run_mode ?? undefined, - thinking_mode: typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || '创建任务失败'); - } - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || '创建任务失败'); - } - - this.currentTaskId = result.data.task_id; - this.taskStatus = result.data.status; - this.lastEventIndex = 0; - this.taskCreatedAt = result.data.created_at; - this.pollingError = null; - - debugLog('[Task] 任务创建成功:', result.data.task_id); - - // 立即开始轮询 - this.startPolling(); - - return result.data; - } catch (error) { - debugLog('[Task] 创建任务失败:', error); - throw error; + eventHandler(event); + } catch (err) { + console.error('[Task] 处理事件失败:', err, event); } - }, - - async pollTaskEvents(eventHandler: (event: any) => void) { - if (!this.currentTaskId) { - debugLog('[Task] 没有活跃任务,停止轮询'); - this.stopPolling(); - return; - } - - try { - const response = await fetch( - `/api/tasks/${this.currentTaskId}?from=${this.lastEventIndex}`, - { signal: AbortSignal.timeout(5000) } // 5秒超时 - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || '轮询任务失败'); - } - - const data = result.data; - - // 更新任务状态 - this.taskStatus = data.status; - this.taskUpdatedAt = data.updated_at; - let sawTaskStoppedEvent = false; - - // 处理新事件 - if (data.events && data.events.length > 0) { - debugLog(`[Task] 收到 ${data.events.length} 个新事件`); - - for (const event of data.events) { - if (event?.type === 'task_stopped') { - sawTaskStoppedEvent = true; - } - try { - eventHandler(event); - } catch (err) { - console.error('[Task] 处理事件失败:', err, event); - } - } - const interesting = data.events.filter((e: any) => - ['user_message', 'sub_agent_waiting', 'task_complete', 'task_stopped', 'error'].includes(e?.type) - ); - if (interesting.length > 0) { - keyNotifyLog('[DEBUG_NOTIFY_KEY][task] events', { - taskId: this.currentTaskId, - from: this.lastEventIndex, - nextOffset: data.next_offset, - count: interesting.length, - items: interesting.map((e: any) => ({ - idx: e?.idx, - type: e?.type, - sub_agent_notice: !!e?.data?.sub_agent_notice, - has_running_sub_agents: e?.data?.has_running_sub_agents, - has_running_background_commands: e?.data?.has_running_background_commands, - task_id: e?.data?.task_id - })) - }); - } - - this.lastEventIndex = data.next_offset; - } - - // 兜底:如果后端状态已经是 canceled 但未返回 task_stopped 事件, - // 补发一个本地停止事件,确保前端解除“停止中”状态。 - if (data.status === 'canceled' && !sawTaskStoppedEvent) { - debugLog('[Task] 状态已取消但未收到 task_stopped,补发本地事件'); - try { - eventHandler({ - type: 'task_stopped', - data: { - task_id: data.task_id || this.currentTaskId, - conversation_id: data.conversation_id || null, - synthetic: true - } - }); - } catch (err) { - console.error('[Task] 处理补发 task_stopped 失败:', err); - } - } - - // 如果任务已完成,停止轮询 - if (this.isTaskCompleted) { - debugLog('[Task] 任务已完成,停止轮询:', this.taskStatus); - this.stopPolling(); - } - - // 重置错误计数 - this.pollingError = null; - this.pollingErrorCount = 0; - } catch (error) { - this.pollingErrorCount++; - this.pollingError = error.message; - - console.error('[Task] 轮询失败:', error); - debugNotifyLog('[DEBUG_NOTIFY][task] pollTaskEvents:error', { - taskId: this.currentTaskId, - from: this.lastEventIndex, - error: error?.message || String(error), - pollingErrorCount: this.pollingErrorCount - }); - - // 连续失败 5 次后停止 - if (this.pollingErrorCount >= 5) { - this.stopPolling(); - // 通知用户 - if ((window as any).__vueApp?.uiPushToast) { - (window as any).__vueApp.uiPushToast({ - title: '轮询失败', - message: '请刷新页面重试', - type: 'error', - duration: 10000 - }); - } - } - } - }, - - startPolling(eventHandler?: (event: any) => void) { - if (this.isPolling) { - debugLog('[Task] 轮询已在运行'); - debugNotifyLog('[DEBUG_NOTIFY][task] startPolling:already-running', { - taskId: this.currentTaskId, - lastEventIndex: this.lastEventIndex - }); - return; - } - - if (!this.currentTaskId) { - debugLog('[Task] 没有任务ID,无法启动轮询'); - return; - } - - debugLog('[Task] 启动轮询:', this.currentTaskId); - debugNotifyLog('[DEBUG_NOTIFY][task] startPolling', { - taskId: this.currentTaskId, - lastEventIndex: this.lastEventIndex + } + const interesting = data.events.filter((e: any) => + [ + 'user_message', + 'sub_agent_waiting', + 'task_complete', + 'task_stopped', + 'error' + ].includes(e?.type) + ); + if (interesting.length > 0) { + keyNotifyLog('[DEBUG_NOTIFY_KEY][task] events', { + taskId: this.currentTaskId, + from: this.lastEventIndex, + nextOffset: data.next_offset, + count: interesting.length, + items: interesting.map((e: any) => ({ + idx: e?.idx, + type: e?.type, + sub_agent_notice: !!e?.data?.sub_agent_notice, + has_running_sub_agents: e?.data?.has_running_sub_agents, + has_running_background_commands: e?.data?.has_running_background_commands, + task_id: e?.data?.task_id + })) }); - this.isPolling = true; + } - // 如果没有传入 eventHandler,从根实例获取 - const handler = eventHandler || ((window as any).__taskEventHandler); + this.lastEventIndex = data.next_offset; + } - if (!handler) { - console.error('[Task] 没有事件处理器,无法启动轮询'); - this.isPolling = false; - return; - } - - // 立即执行一次 - this.pollTaskEvents(handler); - - // 设置定时轮询(150ms 间隔,接近流式输出效果) - this.pollingInterval = window.setInterval(() => { - this.pollTaskEvents(handler); - }, 150); - }, - - resumeTask(taskId: string, options: { status?: 'running' | 'pending' | 'succeeded' | 'failed' | 'canceled'; resetOffset?: boolean; eventHandler?: (event: any) => void } = {}) { - if (!taskId) { - return; - } - debugNotifyLog('[DEBUG_NOTIFY][task] resumeTask', { - incomingTaskId: taskId, - currentTaskId: this.currentTaskId, - options + // 兜底:如果后端状态已经是 canceled 但未返回 task_stopped 事件, + // 补发一个本地停止事件,确保前端解除“停止中”状态。 + if (data.status === 'canceled' && !sawTaskStoppedEvent) { + debugLog('[Task] 状态已取消但未收到 task_stopped,补发本地事件'); + try { + eventHandler({ + type: 'task_stopped', + data: { + task_id: data.task_id || this.currentTaskId, + conversation_id: data.conversation_id || null, + synthetic: true + } }); - keyNotifyLog('[DEBUG_NOTIFY_KEY][task] resumeTask', { - incomingTaskId: taskId, - currentTaskId: this.currentTaskId, - resetOffset: options?.resetOffset !== false + } catch (err) { + console.error('[Task] 处理补发 task_stopped 失败:', err); + } + } + + // 如果任务已完成,停止轮询 + if (this.isTaskCompleted) { + debugLog('[Task] 任务已完成,停止轮询:', this.taskStatus); + this.stopPolling(); + } + + // 重置错误计数 + this.pollingError = null; + this.pollingErrorCount = 0; + } catch (error) { + this.pollingErrorCount++; + this.pollingError = error.message; + + console.error('[Task] 轮询失败:', error); + debugNotifyLog('[DEBUG_NOTIFY][task] pollTaskEvents:error', { + taskId: this.currentTaskId, + from: this.lastEventIndex, + error: error?.message || String(error), + pollingErrorCount: this.pollingErrorCount + }); + + // 连续失败 5 次后停止 + if (this.pollingErrorCount >= 5) { + this.stopPolling(); + // 通知用户 + if ((window as any).__vueApp?.uiPushToast) { + (window as any).__vueApp.uiPushToast({ + title: '轮询失败', + message: '请刷新页面重试', + type: 'error', + duration: 10000 }); - if (this.currentTaskId && this.currentTaskId !== taskId) { - this.stopPolling(); - } - this.currentTaskId = taskId; - this.taskStatus = options.status || 'running'; - if (options.resetOffset !== false) { - this.lastEventIndex = 0; - } - this.pollingError = null; - this.startPolling(options.eventHandler); - }, - - stopPolling() { - if (this.pollingInterval) { - debugLog('[Task] 停止轮询'); - clearInterval(this.pollingInterval); - this.pollingInterval = null; - } - this.isPolling = false; - this.currentTaskId = null; // 清除任务 ID - }, - - async cancelTask() { - if (!this.currentTaskId) { - return; - } - - try { - debugLog('[Task] 取消任务:', this.currentTaskId); - - const response = await fetch(`/api/tasks/${this.currentTaskId}/cancel`, { - method: 'POST', - }); - - if (!response.ok) { - throw new Error('取消任务失败'); - } - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || '取消任务失败'); - } - - debugLog('[Task] 已发送取消请求,等待后端停止确认'); - } catch (error) { - console.error('[Task] 取消任务失败:', error); - throw error; - } - }, - - async loadRunningTask(conversationId: string | null = null) { - try { - debugLog('[Task] 查找运行中的任务'); - - const response = await fetch('/api/tasks'); - if (!response.ok) { - throw new Error('获取任务列表失败'); - } - - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || '获取任务列表失败'); - } - - // 查找运行中的任务 - const runningTask = result.data.find((task: any) => - task.status === 'running' && - (!conversationId || task.conversation_id === conversationId) - ); - - if (runningTask) { - debugLog('[Task] 发现运行中的任务:', runningTask.task_id); - - this.currentTaskId = runningTask.task_id; - this.taskStatus = runningTask.status; - this.taskCreatedAt = runningTask.created_at; - this.taskUpdatedAt = runningTask.updated_at; - this.pollingError = null; - - // 获取任务详情,计算已处理的事件数量 - try { - const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`); - if (detailResponse.ok) { - const detailResult = await detailResponse.json(); - if (detailResult.success && detailResult.data.events) { - // 设置为当前事件数量,只获取新事件 - this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length; - debugLog('[Task] 设置起始偏移量:', this.lastEventIndex); - } else { - this.lastEventIndex = 0; - } - } else { - this.lastEventIndex = 0; - } - } catch (error) { - console.warn('[Task] 获取任务详情失败,从头开始:', error); - this.lastEventIndex = 0; - } - - return runningTask; - } - - debugLog('[Task] 没有运行中的任务'); - return null; - } catch (error) { - console.error('[Task] 加载运行中任务失败:', error); - return null; - } - }, - - clearTask() { - debugLog('[Task] 清理任务状态'); - this.stopPolling(); - this.currentTaskId = null; - this.lastEventIndex = 0; - this.taskStatus = 'idle'; - this.pollingError = null; - this.taskCreatedAt = null; - this.taskUpdatedAt = null; - }, - - resetForNewConversation() { - debugLog('[Task] 重置任务状态(新对话)'); - this.stopPolling(); - this.currentTaskId = null; - this.lastEventIndex = 0; - this.taskStatus = 'idle'; - this.pollingError = null; - }, + } + } + } }, + + startPolling(eventHandler?: (event: any) => void) { + if (this.isPolling) { + debugLog('[Task] 轮询已在运行'); + debugNotifyLog('[DEBUG_NOTIFY][task] startPolling:already-running', { + taskId: this.currentTaskId, + lastEventIndex: this.lastEventIndex + }); + return; + } + + if (!this.currentTaskId) { + debugLog('[Task] 没有任务ID,无法启动轮询'); + return; + } + + debugLog('[Task] 启动轮询:', this.currentTaskId); + debugNotifyLog('[DEBUG_NOTIFY][task] startPolling', { + taskId: this.currentTaskId, + lastEventIndex: this.lastEventIndex + }); + this.isPolling = true; + + // 如果没有传入 eventHandler,从根实例获取 + const handler = eventHandler || (window as any).__taskEventHandler; + + if (!handler) { + console.error('[Task] 没有事件处理器,无法启动轮询'); + this.isPolling = false; + return; + } + + // 立即执行一次 + this.pollTaskEvents(handler); + + // 设置定时轮询(150ms 间隔,接近流式输出效果) + this.pollingInterval = window.setInterval(() => { + this.pollTaskEvents(handler); + }, 150); + }, + + resumeTask( + taskId: string, + options: { + status?: 'running' | 'pending' | 'succeeded' | 'failed' | 'canceled'; + resetOffset?: boolean; + eventHandler?: (event: any) => void; + } = {} + ) { + if (!taskId) { + return; + } + debugNotifyLog('[DEBUG_NOTIFY][task] resumeTask', { + incomingTaskId: taskId, + currentTaskId: this.currentTaskId, + options + }); + keyNotifyLog('[DEBUG_NOTIFY_KEY][task] resumeTask', { + incomingTaskId: taskId, + currentTaskId: this.currentTaskId, + resetOffset: options?.resetOffset !== false + }); + if (this.currentTaskId && this.currentTaskId !== taskId) { + this.stopPolling(); + } + this.currentTaskId = taskId; + this.taskStatus = options.status || 'running'; + if (options.resetOffset !== false) { + this.lastEventIndex = 0; + } + this.pollingError = null; + this.startPolling(options.eventHandler); + }, + + stopPolling() { + if (this.pollingInterval) { + debugLog('[Task] 停止轮询'); + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + this.isPolling = false; + this.currentTaskId = null; // 清除任务 ID + }, + + async cancelTask() { + if (!this.currentTaskId) { + return; + } + + try { + debugLog('[Task] 取消任务:', this.currentTaskId); + + const response = await fetch(`/api/tasks/${this.currentTaskId}/cancel`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error('取消任务失败'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || '取消任务失败'); + } + + debugLog('[Task] 已发送取消请求,等待后端停止确认'); + } catch (error) { + console.error('[Task] 取消任务失败:', error); + throw error; + } + }, + + async loadRunningTask(conversationId: string | null = null) { + try { + debugLog('[Task] 查找运行中的任务'); + + const response = await fetch('/api/tasks'); + if (!response.ok) { + throw new Error('获取任务列表失败'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || '获取任务列表失败'); + } + + // 查找运行中的任务 + const runningTask = result.data.find( + (task: any) => + task.status === 'running' && + (!conversationId || task.conversation_id === conversationId) + ); + + if (runningTask) { + debugLog('[Task] 发现运行中的任务:', runningTask.task_id); + + this.currentTaskId = runningTask.task_id; + this.taskStatus = runningTask.status; + this.taskCreatedAt = runningTask.created_at; + this.taskUpdatedAt = runningTask.updated_at; + this.pollingError = null; + + // 获取任务详情,计算已处理的事件数量 + try { + const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`); + if (detailResponse.ok) { + const detailResult = await detailResponse.json(); + if (detailResult.success && detailResult.data.events) { + // 设置为当前事件数量,只获取新事件 + this.lastEventIndex = + detailResult.data.next_offset || detailResult.data.events.length; + debugLog('[Task] 设置起始偏移量:', this.lastEventIndex); + } else { + this.lastEventIndex = 0; + } + } else { + this.lastEventIndex = 0; + } + } catch (error) { + console.warn('[Task] 获取任务详情失败,从头开始:', error); + this.lastEventIndex = 0; + } + + return runningTask; + } + + debugLog('[Task] 没有运行中的任务'); + return null; + } catch (error) { + console.error('[Task] 加载运行中任务失败:', error); + return null; + } + }, + + clearTask() { + debugLog('[Task] 清理任务状态'); + this.stopPolling(); + this.currentTaskId = null; + this.lastEventIndex = 0; + this.taskStatus = 'idle'; + this.pollingError = null; + this.taskCreatedAt = null; + this.taskUpdatedAt = null; + }, + + resetForNewConversation() { + debugLog('[Task] 重置任务状态(新对话)'); + this.stopPolling(); + this.currentTaskId = null; + this.lastEventIndex = 0; + this.taskStatus = 'idle'; + this.pollingError = null; + } + } }); diff --git a/static/src/stores/tool.ts b/static/src/stores/tool.ts index 92b3ec6..3fe47d6 100644 --- a/static/src/stores/tool.ts +++ b/static/src/stores/tool.ts @@ -51,7 +51,7 @@ export const useToolStore = defineStore('tool', { if (action.tool && action.tool.executionId) { keys.add(action.tool.executionId); } - keys.forEach(key => { + keys.forEach((key) => { if (key !== undefined && key !== null) { this.toolActionIndex.set(String(key), action); } @@ -67,12 +67,16 @@ export const useToolStore = defineStore('tool', { keysToRemove.push(key); } } - keysToRemove.forEach(key => this.toolActionIndex.delete(key)); + keysToRemove.forEach((key) => this.toolActionIndex.delete(key)); if (action.tool && action.tool.name) { this.releaseToolAction(action.tool.name, action); } }, - findToolAction(id?: string | number | null, preparingId?: string | number | null, executionId?: string | number | null) { + findToolAction( + id?: string | number | null, + preparingId?: string | number | null, + executionId?: string | number | null + ) { const candidates = [executionId, id, preparingId]; for (const candidate of candidates) { if (candidate === undefined || candidate === null) { diff --git a/static/src/stores/tutorial.ts b/static/src/stores/tutorial.ts index 82040de..581213e 100644 --- a/static/src/stores/tutorial.ts +++ b/static/src/stores/tutorial.ts @@ -3,7 +3,11 @@ import { useModelStore } from './model'; export type TutorialPlacement = 'auto' | 'top' | 'right' | 'bottom' | 'left' | 'center'; export type TutorialStepMode = 'info' | 'must_click'; -export type TutorialCondition = 'model_supports_media' | 'is_app_shell' | 'is_mobile_viewport' | 'not_mobile_viewport'; +export type TutorialCondition = + | 'model_supports_media' + | 'is_app_shell' + | 'is_mobile_viewport' + | 'not_mobile_viewport'; export interface TutorialStep { id: string; @@ -30,18 +34,119 @@ const DEFAULT_STEPS: TutorialStep[] = [ mode: 'info', placement: 'center' }, - { id: 'sidebar-conversations-open', title: '展开对话记录', description: '下一步会自动点击展开对话记录。', target: '[data-tutorial="conversation-menu"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'sidebar-conversations-close', title: '折叠对话记录', description: '下一步会自动点击折叠按钮收起对话记录。', target: '[data-tutorial="conversation-collapse"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'sidebar-new-chat', title: '新建对话', description: '下一步会自动新建并进入一个对话。', target: '[data-tutorial="quick-new-conversation"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'sidebar-workspace-toggle', title: '工作区折叠', description: '显示/隐藏左侧工作区面板。', target: '[data-tutorial="workspace-toggle"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'sidebar-monitor-toggle', title: '虚拟显示器', description: '切换到虚拟显示器模式。', target: '[data-tutorial="monitor-toggle"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'workspace-panel-switch', title: '工作区面板切换', description: '下一步会自动展开面板切换菜单。', target: '[data-tutorial="panel-menu-toggle"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'workspace-panel-options', title: '四合一面板', description: '这里可以切换文件 / 待办 / 子智能体 / 后台指令。', target: '[data-tutorial="panel-menu"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'workspace-mode-indicator', title: '思考模式', description: '点击切换快速 / 思考 / 深度思考。', target: '[data-tutorial="run-mode-indicator"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'workspace-connection-indicator', title: '连接状态指示灯', description: '绿色表示连接正常;红色表示与后端断开连接。', target: '[data-tutorial="connection-indicator"]', mode: 'info', placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'header-model-selector', title: '模型与模式选择', description: '下一步会自动展开模型与运行模式弹窗。', target: '[data-tutorial="header-model-selector"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'not_mobile_viewport' }, - { id: 'header-model-options', title: '模型列表', description: '这里是可用模型列表。', target: '[data-tutorial="header-model-options"]', mode: 'info', placement: 'bottom', condition: 'not_mobile_viewport' }, - { id: 'header-runmode-options', title: '运行模式列表', description: '这里可切换快速 / 思考 / 深度思考。', target: '[data-tutorial="header-runmode-options"]', mode: 'info', placement: 'bottom', condition: 'not_mobile_viewport' }, + { + id: 'sidebar-conversations-open', + title: '展开对话记录', + description: '下一步会自动点击展开对话记录。', + target: '[data-tutorial="conversation-menu"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'sidebar-conversations-close', + title: '折叠对话记录', + description: '下一步会自动点击折叠按钮收起对话记录。', + target: '[data-tutorial="conversation-collapse"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'sidebar-new-chat', + title: '新建对话', + description: '下一步会自动新建并进入一个对话。', + target: '[data-tutorial="quick-new-conversation"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'sidebar-workspace-toggle', + title: '工作区折叠', + description: '显示/隐藏左侧工作区面板。', + target: '[data-tutorial="workspace-toggle"]', + mode: 'info', + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'sidebar-monitor-toggle', + title: '虚拟显示器', + description: '切换到虚拟显示器模式。', + target: '[data-tutorial="monitor-toggle"]', + mode: 'info', + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'workspace-panel-switch', + title: '工作区面板切换', + description: '下一步会自动展开面板切换菜单。', + target: '[data-tutorial="panel-menu-toggle"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'workspace-panel-options', + title: '四合一面板', + description: '这里可以切换文件 / 待办 / 子智能体 / 后台指令。', + target: '[data-tutorial="panel-menu"]', + mode: 'info', + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'workspace-mode-indicator', + title: '思考模式', + description: '点击切换快速 / 思考 / 深度思考。', + target: '[data-tutorial="run-mode-indicator"]', + mode: 'info', + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'workspace-connection-indicator', + title: '连接状态指示灯', + description: '绿色表示连接正常;红色表示与后端断开连接。', + target: '[data-tutorial="connection-indicator"]', + mode: 'info', + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'header-model-selector', + title: '模型与模式选择', + description: '下一步会自动展开模型与运行模式弹窗。', + target: '[data-tutorial="header-model-selector"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'not_mobile_viewport' + }, + { + id: 'header-model-options', + title: '模型列表', + description: '这里是可用模型列表。', + target: '[data-tutorial="header-model-options"]', + mode: 'info', + placement: 'bottom', + condition: 'not_mobile_viewport' + }, + { + id: 'header-runmode-options', + title: '运行模式列表', + description: '这里可切换快速 / 思考 / 深度思考。', + target: '[data-tutorial="header-runmode-options"]', + mode: 'info', + placement: 'bottom', + condition: 'not_mobile_viewport' + }, { id: 'header-menu-close', title: '关闭模型菜单', @@ -52,52 +157,422 @@ const DEFAULT_STEPS: TutorialStep[] = [ placement: 'bottom', condition: 'not_mobile_viewport' }, - { id: 'mobile-menu-open-conversation', title: '打开菜单', description: '下一步会自动打开左上角菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-menu-conversation', title: '对话记录', description: '下一步会自动进入对话记录。', target: '[data-tutorial="mobile-menu-conversation"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-conversation-close', title: '关闭对话记录', description: '下一步会自动关闭对话记录面板。', target: '[data-tutorial="conversation-collapse"]', mode: 'info', autoClick: true, placement: 'right', condition: 'is_mobile_viewport' }, - { id: 'mobile-menu-open-workspace', title: '再次打开菜单', description: '下一步会自动打开菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-menu-workspace', title: '工作文件', description: '下一步会自动进入工作文件。', target: '[data-tutorial="mobile-menu-workspace"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-workspace-panel-switch', title: '工作区面板切换', description: '下一步会自动展开切换弹窗。', target: '[data-tutorial="panel-menu-toggle"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-workspace-panel-options', title: '切换弹窗选项', description: '这里可切换文件 / 待办 / 子智能体 / 后台指令。', target: '[data-tutorial="panel-menu"]', mode: 'info', placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-workspace-close', title: '关闭工作文件', description: '下一步会自动关闭工作文件面板。', target: '[data-tutorial="mobile-workspace-close"]', mode: 'info', autoClick: true, placement: 'right', condition: 'is_mobile_viewport' }, - { id: 'mobile-menu-open-newchat', title: '再次打开菜单', description: '下一步会自动打开菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-menu-new-chat', title: '新建对话', description: '下一步会自动新建对话。', target: '[data-tutorial="mobile-menu-new-chat"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-model-selector-open', title: '模型与思考模式', description: '下一步会自动展开手机端模型/思考模式选择。', target: '[data-tutorial="header-model-selector-mobile"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-model-options', title: '模型列表', description: '这里是手机端可选模型。', target: '[data-tutorial="header-model-options"]', mode: 'info', placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-runmode-options', title: '思考模式列表', description: '这里可切换快速 / 思考 / 深度思考。', target: '[data-tutorial="header-runmode-options"]', mode: 'info', placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-model-selector-close', title: '关闭选择菜单', description: '下一步会自动点击空白区域关闭菜单。', target: '.model-mode-dropdown', mode: 'info', autoOutsideClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'open-quick-menu', title: '打开 + 菜单', description: '将自动为你展开快捷菜单。', target: '[data-tutorial="quick-menu-open"]', mode: 'info', autoClick: true, placement: 'top' }, - { id: 'quick-upload', title: '上传文件', description: '上传本地文件到当前工作区。', target: '[data-tutorial="quick-upload"]', mode: 'info', placement: 'left' }, - { id: 'quick-review', title: '对话回顾', description: '回顾并压缩上下文。', target: '[data-tutorial="quick-review"]', mode: 'info', placement: 'left' }, - { id: 'quick-send-image', title: '发送图片', description: '当前模型支持时,可发送图片给 AI 分析。', target: '[data-tutorial="quick-send-image"]', mode: 'info', placement: 'left', condition: 'model_supports_media' }, - { id: 'quick-send-video', title: '发送视频', description: '当前模型支持时,可发送视频给 AI 分析。', target: '[data-tutorial="quick-send-video"]', mode: 'info', placement: 'left', condition: 'model_supports_media' }, - { id: 'quick-tool-disable', title: '工具禁用', description: '临时禁用工具分类,控制模型行为范围。', target: '[data-tutorial="quick-tool-menu"]', mode: 'info', placement: 'left' }, - { id: 'quick-settings', title: '设置菜单', description: '这里可快速访问常用功能。', target: '[data-tutorial="quick-settings-menu"]', mode: 'info', placement: 'left' }, - { id: 'open-settings-submenu', title: '打开设置子菜单', description: '下一步会自动展开设置子菜单。', target: '[data-tutorial="quick-settings-menu"]', mode: 'info', autoClick: true, placement: 'left' }, - { id: 'open-token-panel', title: '打开用量统计', description: '下一步会自动打开用量统计面板。', target: '[data-tutorial="settings-token-panel"]', mode: 'info', autoClick: true, placement: 'left' }, - { id: 'token-panel', title: 'Token 统计面板', description: '这里可以查看上下文、额度和资源统计。', target: '[data-tutorial="token-drawer"]', mode: 'info', placement: 'left' }, - { id: 'close-token-panel', title: '关闭统计面板', description: '下一步会自动关闭统计面板。', target: '[data-tutorial="token-close"]', mode: 'info', autoClick: true, placement: 'left' }, - { id: 'open-personal-space', title: '打开个人空间', description: '下一步会自动打开个人空间。', target: '[data-tutorial="open-personal-space"]', mode: 'info', autoClick: true, placement: 'right', condition: 'not_mobile_viewport' }, - { id: 'mobile-menu-open-personal', title: '打开菜单', description: '下一步会自动打开菜单。', target: '[data-tutorial="mobile-menu-trigger"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'mobile-menu-personal', title: '进入个人空间', description: '下一步会自动进入个人空间。', target: '[data-tutorial="mobile-menu-personal"]', mode: 'info', autoClick: true, placement: 'bottom', condition: 'is_mobile_viewport' }, - { id: 'personal-overview', title: '个人空间总览', description: '这里是个性化设置与高级配置中心。', target: '[data-tutorial="personal-card"]', mode: 'info', placement: 'right' }, - { id: 'tab-preferences', title: '个性化设置', description: '下一步自动切换到“个性化设置”。', target: '[data-tutorial="personal-tab-preferences"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-preferences', title: '个性化设置内容', description: '可配置昵称、语气、职业和必备信息。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'tab-model', title: '模型偏好', description: '下一步自动切换到“模型偏好”。', target: '[data-tutorial="personal-tab-model"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-model', title: '模型偏好内容', description: '可设置默认模型、默认运行模式和思考频率。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'tab-usage', title: '用量统计', description: '下一步自动切换到“用量统计”。', target: '[data-tutorial="personal-tab-usage"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-usage', title: '用量统计内容', description: '可查看累计 Token、对话数和工具调用数据。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'tab-app-update', title: '软件更新', description: '移动端将自动切换到“软件更新”。', target: '[data-tutorial="personal-tab-app-update"]', mode: 'info', autoClick: true, placement: 'right', condition: 'is_app_shell' }, - { id: 'page-app-update', title: '软件更新内容', description: '这里会展示最新版本和更新说明。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right', condition: 'is_app_shell' }, - { id: 'tab-behavior', title: '模型行为', description: '下一步自动切换到“模型行为”。', target: '[data-tutorial="personal-tab-behavior"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-behavior', title: '模型行为内容', description: '可配置工具显示、压缩策略等高级选项。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'tab-skills', title: 'Skills', description: '下一步自动切换到“Skills”。', target: '[data-tutorial="personal-tab-skills"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-skills', title: 'Skills 内容', description: '可启用/禁用技能并同步到工作区。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'tab-image', title: '图片压缩', description: '下一步自动切换到“图片压缩”。', target: '[data-tutorial="personal-tab-image"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-image', title: '图片压缩内容', description: '可选择原图、1080p、720p、540p 压缩策略。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'tab-theme', title: '主题切换', description: '下一步自动切换到“主题切换”。', target: '[data-tutorial="personal-tab-theme"]', mode: 'info', autoClick: true, placement: 'right' }, - { id: 'page-theme', title: '主题切换内容', description: '可在经典、明亮、夜间主题间切换。', target: '[data-tutorial="personal-content-shell"]', mode: 'info', placement: 'right' }, - { id: 'close-personal-space', title: '关闭个人空间', description: '下一步自动关闭个人空间并结束导览。', target: '[data-tutorial="personal-close"]', mode: 'info', autoClick: true, placement: 'bottom' }, + { + id: 'mobile-menu-open-conversation', + title: '打开菜单', + description: '下一步会自动打开左上角菜单。', + target: '[data-tutorial="mobile-menu-trigger"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-menu-conversation', + title: '对话记录', + description: '下一步会自动进入对话记录。', + target: '[data-tutorial="mobile-menu-conversation"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-conversation-close', + title: '关闭对话记录', + description: '下一步会自动关闭对话记录面板。', + target: '[data-tutorial="conversation-collapse"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-menu-open-workspace', + title: '再次打开菜单', + description: '下一步会自动打开菜单。', + target: '[data-tutorial="mobile-menu-trigger"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-menu-workspace', + title: '工作文件', + description: '下一步会自动进入工作文件。', + target: '[data-tutorial="mobile-menu-workspace"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-workspace-panel-switch', + title: '工作区面板切换', + description: '下一步会自动展开切换弹窗。', + target: '[data-tutorial="panel-menu-toggle"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-workspace-panel-options', + title: '切换弹窗选项', + description: '这里可切换文件 / 待办 / 子智能体 / 后台指令。', + target: '[data-tutorial="panel-menu"]', + mode: 'info', + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-workspace-close', + title: '关闭工作文件', + description: '下一步会自动关闭工作文件面板。', + target: '[data-tutorial="mobile-workspace-close"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-menu-open-newchat', + title: '再次打开菜单', + description: '下一步会自动打开菜单。', + target: '[data-tutorial="mobile-menu-trigger"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-menu-new-chat', + title: '新建对话', + description: '下一步会自动新建对话。', + target: '[data-tutorial="mobile-menu-new-chat"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-model-selector-open', + title: '模型与思考模式', + description: '下一步会自动展开手机端模型/思考模式选择。', + target: '[data-tutorial="header-model-selector-mobile"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-model-options', + title: '模型列表', + description: '这里是手机端可选模型。', + target: '[data-tutorial="header-model-options"]', + mode: 'info', + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-runmode-options', + title: '思考模式列表', + description: '这里可切换快速 / 思考 / 深度思考。', + target: '[data-tutorial="header-runmode-options"]', + mode: 'info', + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-model-selector-close', + title: '关闭选择菜单', + description: '下一步会自动点击空白区域关闭菜单。', + target: '.model-mode-dropdown', + mode: 'info', + autoOutsideClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'open-quick-menu', + title: '打开 + 菜单', + description: '将自动为你展开快捷菜单。', + target: '[data-tutorial="quick-menu-open"]', + mode: 'info', + autoClick: true, + placement: 'top' + }, + { + id: 'quick-upload', + title: '上传文件', + description: '上传本地文件到当前工作区。', + target: '[data-tutorial="quick-upload"]', + mode: 'info', + placement: 'left' + }, + { + id: 'quick-review', + title: '对话回顾', + description: '回顾并压缩上下文。', + target: '[data-tutorial="quick-review"]', + mode: 'info', + placement: 'left' + }, + { + id: 'quick-send-image', + title: '发送图片', + description: '当前模型支持时,可发送图片给 AI 分析。', + target: '[data-tutorial="quick-send-image"]', + mode: 'info', + placement: 'left', + condition: 'model_supports_media' + }, + { + id: 'quick-send-video', + title: '发送视频', + description: '当前模型支持时,可发送视频给 AI 分析。', + target: '[data-tutorial="quick-send-video"]', + mode: 'info', + placement: 'left', + condition: 'model_supports_media' + }, + { + id: 'quick-tool-disable', + title: '工具禁用', + description: '临时禁用工具分类,控制模型行为范围。', + target: '[data-tutorial="quick-tool-menu"]', + mode: 'info', + placement: 'left' + }, + { + id: 'quick-settings', + title: '设置菜单', + description: '这里可快速访问常用功能。', + target: '[data-tutorial="quick-settings-menu"]', + mode: 'info', + placement: 'left' + }, + { + id: 'open-settings-submenu', + title: '打开设置子菜单', + description: '下一步会自动展开设置子菜单。', + target: '[data-tutorial="quick-settings-menu"]', + mode: 'info', + autoClick: true, + placement: 'left' + }, + { + id: 'open-token-panel', + title: '打开用量统计', + description: '下一步会自动打开用量统计面板。', + target: '[data-tutorial="settings-token-panel"]', + mode: 'info', + autoClick: true, + placement: 'left' + }, + { + id: 'token-panel', + title: 'Token 统计面板', + description: '这里可以查看上下文、额度和资源统计。', + target: '[data-tutorial="token-drawer"]', + mode: 'info', + placement: 'left' + }, + { + id: 'close-token-panel', + title: '关闭统计面板', + description: '下一步会自动关闭统计面板。', + target: '[data-tutorial="token-close"]', + mode: 'info', + autoClick: true, + placement: 'left' + }, + { + id: 'open-personal-space', + title: '打开个人空间', + description: '下一步会自动打开个人空间。', + target: '[data-tutorial="open-personal-space"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'not_mobile_viewport' + }, + { + id: 'mobile-menu-open-personal', + title: '打开菜单', + description: '下一步会自动打开菜单。', + target: '[data-tutorial="mobile-menu-trigger"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'mobile-menu-personal', + title: '进入个人空间', + description: '下一步会自动进入个人空间。', + target: '[data-tutorial="mobile-menu-personal"]', + mode: 'info', + autoClick: true, + placement: 'bottom', + condition: 'is_mobile_viewport' + }, + { + id: 'personal-overview', + title: '个人空间总览', + description: '这里是个性化设置与高级配置中心。', + target: '[data-tutorial="personal-card"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-preferences', + title: '个性化设置', + description: '下一步自动切换到“个性化设置”。', + target: '[data-tutorial="personal-tab-preferences"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-preferences', + title: '个性化设置内容', + description: '可配置昵称、语气、职业和必备信息。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-model', + title: '模型偏好', + description: '下一步自动切换到“模型偏好”。', + target: '[data-tutorial="personal-tab-model"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-model', + title: '模型偏好内容', + description: '可设置默认模型、默认运行模式和思考频率。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-usage', + title: '用量统计', + description: '下一步自动切换到“用量统计”。', + target: '[data-tutorial="personal-tab-usage"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-usage', + title: '用量统计内容', + description: '可查看累计 Token、对话数和工具调用数据。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-app-update', + title: '软件更新', + description: '移动端将自动切换到“软件更新”。', + target: '[data-tutorial="personal-tab-app-update"]', + mode: 'info', + autoClick: true, + placement: 'right', + condition: 'is_app_shell' + }, + { + id: 'page-app-update', + title: '软件更新内容', + description: '这里会展示最新版本和更新说明。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right', + condition: 'is_app_shell' + }, + { + id: 'tab-behavior', + title: '模型行为', + description: '下一步自动切换到“模型行为”。', + target: '[data-tutorial="personal-tab-behavior"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-behavior', + title: '模型行为内容', + description: '可配置工具显示、压缩策略等高级选项。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-skills', + title: 'Skills', + description: '下一步自动切换到“Skills”。', + target: '[data-tutorial="personal-tab-skills"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-skills', + title: 'Skills 内容', + description: '可启用/禁用技能并同步到工作区。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-image', + title: '图片压缩', + description: '下一步自动切换到“图片压缩”。', + target: '[data-tutorial="personal-tab-image"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-image', + title: '图片压缩内容', + description: '可选择原图、1080p、720p、540p 压缩策略。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'tab-theme', + title: '主题切换', + description: '下一步自动切换到“主题切换”。', + target: '[data-tutorial="personal-tab-theme"]', + mode: 'info', + autoClick: true, + placement: 'right' + }, + { + id: 'page-theme', + title: '主题切换内容', + description: '可在经典、明亮、夜间主题间切换。', + target: '[data-tutorial="personal-content-shell"]', + mode: 'info', + placement: 'right' + }, + { + id: 'close-personal-space', + title: '关闭个人空间', + description: '下一步自动关闭个人空间并结束导览。', + target: '[data-tutorial="personal-close"]', + mode: 'info', + autoClick: true, + placement: 'bottom' + }, { id: 'done', title: '教程完成!', @@ -251,7 +726,10 @@ export const useTutorialStore = defineStore('tutorial', { skipHiddenSteps(direction: 'forward' | 'backward') { if (!this.running) return; if (direction === 'forward') { - while (this.currentIndex < this.steps.length && !this.shouldIncludeStep(this.steps[this.currentIndex])) { + while ( + this.currentIndex < this.steps.length && + !this.shouldIncludeStep(this.steps[this.currentIndex]) + ) { this.currentIndex += 1; } if (this.currentIndex >= this.steps.length) { diff --git a/static/src/stores/ui.ts b/static/src/stores/ui.ts index c3d01ba..a3741f7 100644 --- a/static/src/stores/ui.ts +++ b/static/src/stores/ui.ts @@ -200,7 +200,9 @@ export const useUiStore = defineStore('ui', { closable: options.closable !== false, timeoutId: null }; - const duration = Object.prototype.hasOwnProperty.call(options, 'duration') ? options.duration : 4000; + const duration = Object.prototype.hasOwnProperty.call(options, 'duration') + ? options.duration + : 4000; if (duration !== null) { entry.timeoutId = setTimeout(() => this.dismissToast(id), duration); } @@ -208,7 +210,7 @@ export const useUiStore = defineStore('ui', { return id; }, updateToast(id: number, patch: ToastOptions = {}) { - const entry = this.toastQueue.find(item => item.id === id); + const entry = this.toastQueue.find((item) => item.id === id); if (!entry) { return; } @@ -235,7 +237,7 @@ export const useUiStore = defineStore('ui', { } }, dismissToast(id: number) { - const index = this.toastQueue.findIndex(item => item.id === id); + const index = this.toastQueue.findIndex((item) => item.id === id); if (index === -1) { return; } diff --git a/static/src/stores/upload.ts b/static/src/stores/upload.ts index f1a251a..ef7751b 100644 --- a/static/src/stores/upload.ts +++ b/static/src/stores/upload.ts @@ -92,7 +92,11 @@ export const useUploadStore = defineStore('upload', { }, async uploadFile( file: File, - options: { manageUploading?: boolean; refreshFileTree?: boolean; skipPolicyCheck?: boolean } = {} + options: { + manageUploading?: boolean; + refreshFileTree?: boolean; + skipPolicyCheck?: boolean; + } = {} ): Promise { const { manageUploading = true, refreshFileTree = true, skipPolicyCheck = false } = options; if (!file || (manageUploading && this.uploading)) { diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index 7ee6ab0..e8d0903 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -182,11 +182,7 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean }) }; return runningMap[readType] || '正在读取文件...'; } - const label = - RUNNING_STATUS_TEXTS[tool.name] || - tool.display_name || - tool.name || - ''; + const label = RUNNING_STATUS_TEXTS[tool.name] || tool.display_name || tool.name || ''; return label ? label : '调用工具中'; } if (tool.status === 'completed') { @@ -283,9 +279,7 @@ export function formatSearchDomains(filters: ToolPayload): string { if (!Array.isArray(domains) || domains.length === 0) { return '未限定网站'; } - const normalized = domains - .map(item => String(item || '').trim()) - .filter(Boolean); + const normalized = domains.map((item) => String(item || '').trim()).filter(Boolean); return normalized.length ? normalized.join(', ') : '未限定网站'; } diff --git a/static/src/utils/formatters.ts b/static/src/utils/formatters.ts index 9bc7e3c..4866879 100644 --- a/static/src/utils/formatters.ts +++ b/static/src/utils/formatters.ts @@ -109,7 +109,7 @@ export function buildQuotaResetSummary(quota: UsageQuotaMap): string { return ''; } const parts: string[] = []; - ['fast', 'thinking', 'search'].forEach(type => { + ['fast', 'thinking', 'search'].forEach((type) => { const entry = quota?.[type]; if (entry && entry.reset_at && normalizeNumber(entry.count) > 0) { parts.push(`${quotaTypeLabel(type)} ${formatResetTime(entry.reset_at)}`); @@ -133,7 +133,11 @@ export function isQuotaExceeded(quota: UsageQuotaMap, type: string): boolean { return normalizeNumber(entry.count) >= limit; } -export function buildQuotaToastMessage(type: string, quota: UsageQuotaMap, resetAt?: NumericLike): string { +export function buildQuotaToastMessage( + type: string, + quota: UsageQuotaMap, + resetAt?: NumericLike +): string { const entry = quota?.[type]; const referenceResetAt = typeof resetAt !== 'undefined' ? resetAt : entry?.reset_at; return `${quotaTypeLabel(type)} 配额已用完,将在 ${formatResetTime(referenceResetAt)} 重置`;