From 66b846ee378008c4cc1e18f7321db04ea85f2b98 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 8 Mar 2026 00:03:14 +0800 Subject: [PATCH] refactor: split frontend app modules --- static/src/app.ts | 3983 +----------------------- static/src/app/bootstrap.ts | 139 + static/src/app/components.ts | 27 + static/src/app/computed.ts | 175 ++ static/src/app/lifecycle.ts | 87 + static/src/app/methods/common.ts | 21 + static/src/app/methods/conversation.ts | 407 +++ static/src/app/methods/history.ts | 396 +++ static/src/app/methods/message.ts | 287 ++ static/src/app/methods/monitor.ts | 24 + static/src/app/methods/resources.ts | 276 ++ static/src/app/methods/route.ts | 0 static/src/app/methods/search.ts | 170 + static/src/app/methods/tooling.ts | 247 ++ static/src/app/methods/ui.ts | 1174 +++++++ static/src/app/methods/upload.ts | 404 +++ static/src/app/state.ts | 90 + static/src/app/watchers.ts | 62 + 18 files changed, 4019 insertions(+), 3950 deletions(-) create mode 100644 static/src/app/bootstrap.ts create mode 100644 static/src/app/components.ts create mode 100644 static/src/app/computed.ts create mode 100644 static/src/app/lifecycle.ts create mode 100644 static/src/app/methods/common.ts create mode 100644 static/src/app/methods/conversation.ts create mode 100644 static/src/app/methods/history.ts create mode 100644 static/src/app/methods/message.ts create mode 100644 static/src/app/methods/monitor.ts create mode 100644 static/src/app/methods/resources.ts create mode 100644 static/src/app/methods/route.ts create mode 100644 static/src/app/methods/search.ts create mode 100644 static/src/app/methods/tooling.ts create mode 100644 static/src/app/methods/ui.ts create mode 100644 static/src/app/methods/upload.ts create mode 100644 static/src/app/state.ts create mode 100644 static/src/app/watchers.ts diff --git a/static/src/app.ts b/static/src/app.ts index 5941ae9..f36ea7f 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -1,21 +1,7 @@ // @ts-nocheck // static/app-enhanced.js - 修复版,正确实现Token实时更新 -import katex from 'katex'; -import { mapActions, mapState, mapWritableState } from 'pinia'; -import ChatArea from './components/chat/ChatArea.vue'; -import ConversationSidebar from './components/sidebar/ConversationSidebar.vue'; -import LeftPanel from './components/panels/LeftPanel.vue'; -import FocusPanel from './components/panels/FocusPanel.vue'; -import TokenDrawer from './components/token/TokenDrawer.vue'; -import PersonalizationDrawer from './components/personalization/PersonalizationDrawer.vue'; -import LiquidGlassWidget from './components/experiments/LiquidGlassWidget.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 { mapActions } from 'pinia'; import { useUiStore } from './stores/ui'; -import { useConversationStore } from './stores/conversation'; import { useChatStore } from './stores/chat'; import { useInputStore } from './stores/input'; import { useToolStore } from './stores/tool'; @@ -26,755 +12,42 @@ import { useSubAgentStore } from './stores/subAgent'; import { useFocusStore } from './stores/focus'; import { usePersonalizationStore } from './stores/personalization'; import { useModelStore } from './stores/model'; -import { useChatActionStore } from './stores/chatActions'; import { useMonitorStore } from './stores/monitor'; -import { usePolicyStore } from './stores/policy'; -import { ICONS, TOOL_CATEGORY_ICON_MAP } from './utils/icons'; -import { initializeLegacySocket } from './composables/useLegacySocket'; -import { useConnectionStore } from './stores/connection'; -import { useEasterEgg } from './composables/useEasterEgg'; -import { renderMarkdown as renderMarkdownHelper } from './composables/useMarkdownRenderer'; -import { - formatTokenCount, - formatBytes, - formatPercentage, - formatRate, - formatResetTime, - formatQuotaValue, - quotaTypeLabel, - buildQuotaResetSummary, - isQuotaExceeded as isQuotaExceededUtil, - buildQuotaToastMessage -} from './utils/formatters'; -import { - getToolIcon, - getToolAnimationClass, - getToolStatusText as baseGetToolStatusText, - getToolDescription, - cloneToolArguments, - buildToolLabel, - formatSearchTopic, - formatSearchTime, - formatSearchDomains, - getLanguageClass -} from './utils/chatDisplay'; -import { - scrollToBottom as scrollToBottomHelper, - conditionalScrollToBottom as conditionalScrollToBottomHelper, - toggleScrollLock as toggleScrollLockHelper, - normalizeScrollLock, - scrollThinkingToBottom as scrollThinkingToBottomHelper -} from './composables/useScrollControl'; -import { - startResize as startPanelResize, - handleResize as handlePanelResize, - stopResize as stopPanelResize -} from './composables/usePanelResize'; +import { appComponents } from './app/components'; +import { dataState } from './app/state'; +import { computed } from './app/computed'; +import { watchers } from './app/watchers'; +import { created, mounted, beforeUnmount } from './app/lifecycle'; +import { conversationMethods } from './app/methods/conversation'; +import { historyMethods } from './app/methods/history'; +import { messageMethods } from './app/methods/message'; +import { searchMethods } from './app/methods/search'; +import { uploadMethods } from './app/methods/upload'; +import { resourceMethods } from './app/methods/resources'; +import { toolingMethods } from './app/methods/tooling'; +import { uiMethods } from './app/methods/ui'; +import { monitorMethods } from './app/methods/monitor'; -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); -} - -function escapeHtml(input: string) { - 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)); - } - - 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); - - if (safeAlt) { - const caption = document.createElement('figcaption'); - caption.innerHTML = safeAlt; - figure.appendChild(caption); - } - - node.replaceChildren(figure); - node.setAttribute('data-rendered', '1'); - }); -} - -let showImageObserver: MutationObserver | null = null; - -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 }); -} - -function teardownShowImageObserver() { - if (showImageObserver) { - showImageObserver.disconnect(); - showImageObserver = null; - } -} - -window.katex = katex; - -function updateViewportHeightVar() { - 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)'); - } -} - -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); -} - -const ENABLE_APP_DEBUG_LOGS = true; -function debugLog(...args) { - if (!ENABLE_APP_DEBUG_LOGS) return; - try { - console.log('[app]', ...args); - } catch (e) { - /* ignore logging errors */ - } -} -// 临时排查对话切换问题的调试输出 -const TRACE_CONV = true; -const traceLog = (...args) => { - if (!TRACE_CONV) return; - try { - console.log('[conv-trace]', ...args); - } catch (e) { - // ignore - } -}; - -const SEARCH_INITIAL_BATCH = 100; -const SEARCH_MORE_BATCH = 50; -const SEARCH_PREVIEW_LIMIT = 20; +// 其他初始化逻辑已迁移到 app/bootstrap.ts const appOptions = { - data() { - return { - // 路由相关 - initialRouteResolved: false, - dropToolEvents: false, - - // 工具状态跟踪 - preparingTools: new Map(), - activeTools: new Map(), - toolActionIndex: new Map(), - toolStacks: new Map(), - // 当前任务是否仍在进行中(用于保持输入区的“停止”状态) - taskInProgress: false, - // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 - lastHistoryLoadedConversationId: null, - - // ========================================== - // 对话管理相关状态 - // ========================================== - - // 搜索功能 - // ========================================== - searchRequestSeq: 0, - searchActiveQuery: '', - searchResultIdSet: new Set(), - searchPreviewCache: {}, - - // Token统计相关状态(修复版) - // ========================================== - - // 对话压缩状态 - compressing: false, - 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, - - // 工具控制菜单 - icons: ICONS, - toolCategoryIcons: TOOL_CATEGORY_ICON_MAP, - - // 对话回顾 - reviewDialogOpen: false, - reviewSelectedConversationId: null, - reviewSubmitting: false, - reviewPreviewLines: [], - reviewPreviewLoading: false, - reviewPreviewError: null, - reviewPreviewLimit: 20, - reviewSendToModel: true, - reviewGeneratedPath: null - } - }, - - 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, - getSocket: () => this.socket, - downloadResource: (url, filename) => this.downloadResource(url, filename) - }); - }, - - async mounted() { - debugLog('Vue应用已挂载'); - if (window.ensureCsrfToken) { - window.ensureCsrfToken().catch((err) => { - console.warn('CSRF token 初始化失败:', err); - }); - } - // 并行启动路由解析与实时连接,避免等待路由加载阻塞思考模式同步 - const routePromise = this.bootstrapRoute(); - const socketPromise = this.initSocket(); - await routePromise; - await socketPromise; - this.$nextTick(() => { - this.ensureScrollListener(); - // 刷新后若无输出,自动解锁滚动锁定 - normalizeScrollLock(this); - }); - setupShowImageObserver(); - - // 立即加载初始数据(并行获取状态,优先同步运行模式) - this.loadInitialData(); - - document.addEventListener('click', this.handleClickOutsideQuickMenu); - document.addEventListener('click', this.handleClickOutsidePanelMenu); - document.addEventListener('click', this.handleClickOutsideHeaderMenu); - document.addEventListener('click', this.handleClickOutsideMobileMenu); - window.addEventListener('popstate', this.handlePopState); - window.addEventListener('keydown', this.handleMobileOverlayEscape); - this.setupMobileViewportWatcher(); - - this.subAgentFetch(); - this.subAgentStartPolling(); - - this.$nextTick(() => { - this.autoResizeInput(); - }); - this.resourceBindContainerVisibilityWatcher(); - this.resourceStartContainerStatsPolling(); - this.resourceStartProjectStoragePolling(); - this.resourceStartUsageQuotaPolling(); - }, - - 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 false; - }, - streamingUi() { - return this.streamingMessage || this.hasPendingToolActions(); - }, - composerBusy() { - const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; - return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested; - }, - 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; - } - }, - - beforeUnmount() { - document.removeEventListener('click', this.handleClickOutsideQuickMenu); - document.removeEventListener('click', this.handleClickOutsidePanelMenu); - document.removeEventListener('click', this.handleClickOutsideHeaderMenu); - document.removeEventListener('click', this.handleClickOutsideMobileMenu); - window.removeEventListener('popstate', this.handlePopState); - window.removeEventListener('keydown', this.handleMobileOverlayEscape); - this.teardownMobileViewportWatcher(); - this.subAgentStopPolling(); - this.resourceStopContainerStatsPolling(); - this.resourceStopProjectStoragePolling(); - this.resourceStopUsageQuotaPolling(); - teardownShowImageObserver(); - if (this.titleTypingTimer) { - clearInterval(this.titleTypingTimer); - this.titleTypingTimer = null; - } - const cleanup = this.destroyEasterEggEffect(true); - if (cleanup && typeof cleanup.catch === 'function') { - cleanup.catch(() => {}); - } - }, - - watch: { - 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); - } - } - }, + data: dataState, + created, + mounted, + beforeUnmount, + computed, + watch: watchers, methods: { - 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); - } - }, - 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); - }, - updateMobileViewportState(isMobile) { - this.uiSetMobileViewport(!!isMobile); - if (!isMobile) { - this.uiSetMobileOverlayMenuOpen(false); - this.closeMobileOverlay(); - } - }, - 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); - }, - 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'); - } - }, - 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); - }, - 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); - }, - handleMobileOverlayEscape(event) { - if (event.key !== 'Escape' || !this.isMobileViewport) { - return; - } - if (this.mobileOverlayMenuOpen) { - this.uiSetMobileOverlayMenuOpen(false); - return; - } - if (this.activeMobileOverlay) { - this.closeMobileOverlay(); - } - }, + ...conversationMethods, + ...historyMethods, + ...searchMethods, + ...messageMethods, + ...uploadMethods, + ...resourceMethods, + ...toolingMethods, + ...uiMethods, + ...monitorMethods, ...mapActions(useUiStore, { uiToggleSidebar: 'toggleSidebar', uiSetSidebarCollapsed: 'setSidebarCollapsed', @@ -914,3200 +187,10 @@ const appOptions = { }), ...mapActions(usePersonalizationStore, { personalizationOpenDrawer: 'openDrawer' - }), - 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; - }, - - toolCategoryIcon(categoryId) { - return this.toolCategoryIcons[categoryId] || 'settings'; - }, - - openGuiFileManager() { - if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) { - return; - } - window.open('/file-manager', '_blank'); - }, - - 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; - }, - - renderMarkdown(content, isStreaming = false) { - return renderMarkdownHelper(content, isStreaming); - }, - - 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 bootstrapRoute() { - // 在路由解析期间抑制标题动画,避免预置“新对话”闪烁 - this.suppressTitleTyping = true; - this.titleReady = false; - this.currentConversationTitle = ''; - this.titleTypingText = ''; - const path = window.location.pathname.replace(/^\/+/, ''); - if (!path || path === 'new') { - this.currentConversationId = null; - this.currentConversationTitle = '新对话'; - this.titleReady = true; - this.suppressTitleTyping = false; - this.startTitleTyping('新对话', { animate: false }); - this.initialRouteResolved = true; - 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; - } - }, - - 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); - }, - - stripConversationPrefix(conversationId) { - if (!conversationId) return ''; - return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; - }, - - 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(); - } - }, - - 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() { - await initializeLegacySocket(this); - }, - - 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(); - }, - - 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 (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); - }); - }); - }, - - maybeResetStreamingState(reason = 'unspecified') { - if (!this.streamingMessage) { - return false; - } - if (this.hasPendingToolActions()) { - return false; - } - this.streamingMessage = false; - this.stopRequested = false; - debugLog('流式状态已结束', { reason }); - return true; - }, - - // 完整重置所有状态 - resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { - 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(); - - // 新增:将所有未完成的工具标记为已完成 - this.messages.forEach(msg => { - if (msg.role === 'assistant' && msg.actions) { - msg.actions.forEach(action => { - if (action.type === 'tool' && - (action.tool.status === 'preparing' || action.tool.status === 'running')) { - action.tool.status = 'completed'; - } - }); - } - }); - - // 清理Markdown缓存 - if (this.markdownCache) { - this.markdownCache.clear(); - } - this.chatClearThinkingLocks(); - - // 强制更新视图 - this.$forceUpdate(); - - 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([]); - - debugLog('前端状态重置完成'); - this._scrollListenerReady = false; - this.$nextTick(() => { - this.ensureScrollListener(); - }); - - // 重置已加载对话标记,便于后续重新加载新对话历史 - this.lastHistoryLoadedConversationId = null; - - this.logMessageState('resetAllStates:after-cleanup', { reason }); - }, - - 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(); - }, - - // 重置Token统计 - - openRealtimeTerminal() { - const { protocol, hostname, port } = window.location; - const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`; - window.open(target, '_blank'); - }, - - resetTokenStatistics() { - this.resourceResetTokenStatistics(); - }, - - async loadInitialData() { - try { - debugLog('加载初始数据...'); - - const statusResponse = await fetch('/api/status'); - const statusData = await statusResponse.json(); - this.projectPath = statusData.project_path || ''; - this.agentVersion = statusData.version || this.agentVersion; - this.thinkingMode = !!statusData.thinking_mode; - this.applyStatusSnapshot(statusData); - // 立即更新配额和运行模式,避免等待其他慢接口 - this.fetchUsageQuota(); - // 拉取管理员策略 - const policyStore = usePolicyStore(); - await policyStore.fetchPolicy(); - this.applyPolicyUiLocks(); - - 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 statusConversationId = statusData.conversation && statusData.conversation.current_id; - if (statusConversationId && !this.currentConversationId) { - this.skipConversationHistoryReload = true; - // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 - this.skipConversationLoadedEvent = true; - this.suppressTitleTyping = true; - this.titleReady = false; - this.currentConversationTitle = ''; - this.titleTypingText = ''; - this.currentConversationId = statusConversationId; - - // 如果有当前对话,尝试获取标题和历史 - 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); - - debugLog('初始数据加载完成'); - } catch (error) { - console.error('加载初始数据失败:', error); - } - }, - - // ========================================== - // Token / 资源状态封装(Pinia store) - // ========================================== - - 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; - } - }, - - 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 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(); - - if (data.success) { - if (refreshToken !== this.conversationListRefreshToken) { - debugLog('忽略已过期的对话列表响应', { - requestSeq, - responseOffset: queryOffset - }); - return; - } - - 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) { - 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; - } - - 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 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 - }); - 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, - timestamp: Date.now(), - blockId - }); - debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...'); - } - - // 处理普通文本内容(移除思考标签后的内容) - const metadata = message.metadata || {}; - const appendPayloadMeta = metadata.append_payload; - const modifyPayloadMeta = metadata.modify_payload; - const isAppendMessage = message.name === 'append_to_file'; - const isModifyMessage = message.name === 'modify_file'; - const containsAppendMarkers = /<<<\s*(APPEND|MODIFY)/i.test(content || '') || /<<>>/i.test(content || ''); - - const textContent = content.trim(); - - if (appendPayloadMeta) { - currentAssistantMessage.actions.push({ - id: `history-append-payload-${Date.now()}-${Math.random()}`, - type: 'append_payload', - append: { - path: appendPayloadMeta.path || '未知文件', - forced: !!appendPayloadMeta.forced, - success: appendPayloadMeta.success === undefined ? true : !!appendPayloadMeta.success, - lines: appendPayloadMeta.lines ?? null, - bytes: appendPayloadMeta.bytes ?? null - }, - timestamp: Date.now() - }); - debugLog('添加append占位信息:', appendPayloadMeta.path); - } else if (modifyPayloadMeta) { - currentAssistantMessage.actions.push({ - id: `history-modify-payload-${Date.now()}-${Math.random()}`, - type: 'modify_payload', - modify: { - path: modifyPayloadMeta.path || '未知文件', - total: modifyPayloadMeta.total_blocks ?? null, - completed: modifyPayloadMeta.completed || [], - failed: modifyPayloadMeta.failed || [], - forced: !!modifyPayloadMeta.forced, - details: modifyPayloadMeta.details || [] - }, - timestamp: Date.now() - }); - debugLog('添加modify占位信息:', modifyPayloadMeta.path); - } - - if (textContent && !appendPayloadMeta && !modifyPayloadMeta && !isAppendMessage && !isModifyMessage && !containsAppendMarkers) { - 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, - 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; - } - } - if (message.name === 'append_to_file' && result && result.message) { - toolAction.tool.message = result.message; - } - debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`); - - // append_to_file 的摘要在 append_payload 占位中呈现,此处无需重复 - } else { - console.warn('找不到对应的工具调用:', message.name, message.tool_call_id); - } - } - - } else { - // 其他类型消息(如system)- 先结束当前assistant消息 - if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { - this.messages.push(currentAssistantMessage); - currentAssistantMessage = null; - } - - 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 createNewConversation() { - debugLog('创建新对话...'); - traceLog('createNewConversation:start', { - currentConversationId: this.currentConversationId, - convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' - }); - this.logMessageState('createNewConversation:start'); - - 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' - }); - } 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) { - this.currentConversationId = newId; - } - - 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' - }); - } - }, - - 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; - }, - - handleMobileOverlaySelect(conversationId) { - this.loadConversation(conversationId); - this.closeMobileOverlay(); - }, - handleMobilePersonalClick() { - this.closeMobileOverlay(); - this.uiSetMobileOverlayMenuOpen(false); - this.openPersonalPage(); - }, - - toggleSidebar() { - if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') { - this.closeMobileOverlay(); - return; - } - this.uiToggleSidebar(); - }, - - togglePanelMenu() { - this.uiTogglePanelMenu(); - }, - - selectPanelMode(mode) { - this.uiSetPanelMode(mode); - this.uiSetPanelMenuOpen(false); - }, - - openPersonalPage() { - if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) { - return; - } - this.personalizationOpenDrawer(); - }, - - fetchTodoList() { - return this.fileFetchTodoList(); - }, - - fetchSubAgents() { - return this.subAgentFetch(); - }, - - async toggleThinkingMode() { - await this.handleCycleRunMode(); - }, - - handleQuickModeToggle() { - if (!this.isConnected || this.streamingMessage) { - return; - } - this.handleCycleRunMode(); - }, - - 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)); - if (!valid.length) { - this.uiPushToast({ - title: '无法上传', - message: '仅支持图片文件', - type: 'warning' - }); - return; - } - if (valid.length < list.length) { - this.uiPushToast({ - title: '已忽略', - message: '已跳过非图片文件', - type: 'info' - }); - } - const limited = valid.slice(0, remaining); - if (valid.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); - }); - }, - - 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)); - if (!valid.length) { - this.uiPushToast({ - title: '无法上传', - message: '仅支持视频文件', - type: 'warning' - }); - return; - } - if (valid.length < list.length) { - this.uiPushToast({ - title: '已忽略', - message: '已跳过非视频文件', - type: 'info' - }); - } - if (valid.length > 1) { - this.uiPushToast({ - title: '视频数量过多', - message: '一次只能选择 1 个视频,已使用第一个', - type: 'warning' - }); - } - const [file] = valid; - 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); - }, - - handleSendOrStop() { - if (this.composerBusy) { - this.stopTask(); - } else { - this.sendMessage(); - } - }, - - sendMessage() { - if (this.streamingUi || !this.isConnected) { - 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; - } - - if (hasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { - this.uiPushToast({ - title: '当前模型不支持图片', - message: '请切换到 Qwen3.5 或 Kimi-k2.5 再发送图片', - type: 'error' - }); - return; - } - - if (hasVideos && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { - this.uiPushToast({ - title: '当前模型不支持视频', - message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频', - 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) { - this.socket.emit('send_command', { command: message }); - 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); - } - - // 标记任务进行中,直到任务完成或用户手动停止 - this.taskInProgress = true; - this.chatAddUserMessage(message, images, videos); - this.socket.emit('send_message', { message: message, images, videos, conversation_id: this.currentConversationId }); - 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); - }, - - // 新增:停止任务方法 - stopTask() { - const canStop = this.composerBusy && !this.stopRequested; - if (!canStop) { - return; - } - - const shouldDropToolEvents = this.streamingUi; - this.stopRequested = true; - this.dropToolEvents = shouldDropToolEvents; - if (this.socket) { - this.socket.emit('stop_task'); - debugLog('发送停止请求'); - } - - // 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态 - this.clearPendingTools('user_stop'); - this.streamingMessage = false; - this.taskInProgress = false; - this.forceUnlockMonitor('user_stop'); - }, - - forceUnlockMonitor(reason = 'unspecified') { - try { - this.monitorResetVisual({ - preserveBubble: true, - preservePointer: true, - preserveWindows: true, - preserveQueue: false, - preservePendingResults: false, - preserveAwaitingTools: false - }); - debugLog('Monitor unlocked', { reason }); - } catch (error) { - console.warn('强制解锁监控面板失败', error); - } - }, - clearPendingTools(reason = 'unspecified') { - debugLog('清理未完成工具', { reason }); - if (Array.isArray(this.messages)) { - this.messages.forEach(msg => { - if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.actions)) { - return; - } - msg.actions.forEach(action => { - if (action && action.type === 'tool' && action.tool) { - action.tool.status = action.tool.status || 'cancelled'; - action.tool.awaiting_content = false; - action.streaming = false; - } - }); - }); - } - this.toolResetTracking(); - this.preparingTools.clear(); - this.activeTools.clear(); - this.toolActionIndex.clear(); - this.toolStacks.clear(); - this.stopRequested = false; - this.taskInProgress = false; - }, - - async clearChat() { - const confirmed = await this.confirmAction({ - title: '清除对话', - message: '确定要清除所有对话记录吗?该操作不可撤销。', - confirmText: '清除', - cancelText: '取消' - }); - if (confirmed) { - this.socket.emit('send_command', { command: '/clear' }); - } - }, - - 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; - - try { - const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, { - method: 'POST' - }); - - const result = await response.json(); - - if (response.ok && result.success) { - const newId = result.compressed_conversation_id; - if (newId) { - this.currentConversationId = newId; - } - debugLog('对话压缩完成:', result); - } else { - const message = result.message || result.error || '压缩失败'; - this.uiPushToast({ - title: '压缩失败', - message, - type: 'error' - }); - } - } catch (error) { - console.error('压缩对话异常:', error); - this.uiPushToast({ - title: '压缩对话异常', - message: error.message || '请稍后重试', - type: 'error' - }); - } finally { - this.compressing = 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); - } - }, - - 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; - }, - - async openImagePicker() { - if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { - this.uiPushToast({ - title: '当前模型不支持图片', - message: '请选择 Qwen3.5 或 Kimi-k2.5 后再发送图片', - type: 'error' - }); - return; - } - this.closeQuickMenu(); - this.inputSetImagePickerOpen(true); - await this.loadWorkspaceImages(); - }, - - closeImagePicker() { - this.inputSetImagePickerOpen(false); - }, - - async openVideoPicker() { - if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { - this.uiPushToast({ - title: '当前模型不支持视频', - message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频', - 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(); - }, - - 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(); - } - } - }, - - 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 handleHeaderRunModeSelect(mode) { - await this.handleModeSelect(mode); - this.closeHeaderMenu(); - }, - - async handleModelSelect(key) { - if (!this.isConnected || this.streamingMessage) { - return; - } - const policyStore = usePolicyStore(); - if (policyStore.isModelDisabled(key)) { - this.uiPushToast({ - title: '模型被禁用', - message: '被管理员强制禁用', - type: 'warning' - }); - return; - } - if (this.conversationHasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(key)) { - this.uiPushToast({ - title: '切换失败', - message: '当前对话包含图片,仅支持 Qwen3.5 或 Kimi-k2.5', - type: 'error' - }); - return; - } - const modelStore = useModelStore(); - 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); - } - }, - - 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 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.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 = 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 - }; - }, - - 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, []); - if (this.socket) { - this.socket.emit('send_message', { - message, - images: [], - conversation_id: this.currentConversationId - }); - } - 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`; - }); - }); - }, - - 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; - } - }, - - 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 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); - }, - - 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) { - this.chatAddSystemMessage(content); - this.$forceUpdate(); - this.conditionalScrollToBottom(); - }, - 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); - }, - 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, - - 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); - }, - - async handleEasterEggPayload(payload) { - const controller = useEasterEgg(); - await controller.handlePayload(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); - }, - - finishEasterEggCleanup() { - const controller = useEasterEgg(); - controller.finishCleanup(); - }, - formatTokenCount, - formatBytes, - formatPercentage, - formatRate, - quotaTypeLabel, - formatResetTime, - formatQuotaValue, - - 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); - }, - - confirmAction(options = {}) { - return this.uiRequestConfirm(options); - } + }) } }; -(appOptions as any).components = { - ChatArea, - ConversationSidebar, - LeftPanel, - FocusPanel, - TokenDrawer, - PersonalizationDrawer, - LiquidGlassWidget, - QuickMenu, - InputComposer, - AppShell, - ImagePicker, - ConversationReviewDialog -}; +(appOptions as any).components = appComponents; export default appOptions; diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts new file mode 100644 index 0000000..fac1da7 --- /dev/null +++ b/static/src/app/bootstrap.ts @@ -0,0 +1,139 @@ +// @ts-nocheck +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); +} + +function escapeHtml(input: string) { + 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)); + } + + 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); + + if (safeAlt) { + const caption = document.createElement('figcaption'); + caption.innerHTML = safeAlt; + figure.appendChild(caption); + } + + 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 }); +} + +export function teardownShowImageObserver() { + if (showImageObserver) { + showImageObserver.disconnect(); + showImageObserver = null; + } +} + +function updateViewportHeightVar() { + 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 (typeof window !== 'undefined') { + 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); + } +} diff --git a/static/src/app/components.ts b/static/src/app/components.ts new file mode 100644 index 0000000..87d2228 --- /dev/null +++ b/static/src/app/components.ts @@ -0,0 +1,27 @@ +import ChatArea from '../components/chat/ChatArea.vue'; +import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue'; +import LeftPanel from '../components/panels/LeftPanel.vue'; +import FocusPanel from '../components/panels/FocusPanel.vue'; +import TokenDrawer from '../components/token/TokenDrawer.vue'; +import PersonalizationDrawer from '../components/personalization/PersonalizationDrawer.vue'; +import LiquidGlassWidget from '../components/experiments/LiquidGlassWidget.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'; + +export const appComponents = { + ChatArea, + ConversationSidebar, + LeftPanel, + FocusPanel, + TokenDrawer, + PersonalizationDrawer, + LiquidGlassWidget, + QuickMenu, + InputComposer, + AppShell, + ImagePicker, + ConversationReviewDialog +}; diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts new file mode 100644 index 0000000..f86bbb5 --- /dev/null +++ b/static/src/app/computed.ts @@ -0,0 +1,175 @@ +// @ts-nocheck +import { mapState, mapWritableState } from 'pinia'; +import { useConnectionStore } from '../stores/connection'; +import { useFileStore } from '../stores/file'; +import { useUiStore } from '../stores/ui'; +import { useConversationStore } from '../stores/conversation'; +import { useModelStore } from '../stores/model'; +import { useChatStore } from '../stores/chat'; +import { useInputStore } from '../stores/input'; +import { useToolStore } from '../stores/tool'; +import { useResourceStore } from '../stores/resource'; +import { useFocusStore } from '../stores/focus'; +import { useUploadStore } from '../stores/upload'; +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 false; + }, + streamingUi() { + return this.streamingMessage || this.hasPendingToolActions(); + }, + composerBusy() { + const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; + return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested; + }, + 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 new file mode 100644 index 0000000..9e185c5 --- /dev/null +++ b/static/src/app/lifecycle.ts @@ -0,0 +1,87 @@ +// @ts-nocheck +import { useChatActionStore } from '../stores/chatActions'; +import { normalizeScrollLock } from '../composables/useScrollControl'; +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, + getSocket: () => this.socket, + downloadResource: (url, filename) => this.downloadResource(url, filename) + }); +} + +export async function mounted() { + debugLog('Vue应用已挂载'); + if (window.ensureCsrfToken) { + window.ensureCsrfToken().catch((err) => { + console.warn('CSRF token 初始化失败:', err); + }); + } + // 并行启动路由解析与实时连接,避免等待路由加载阻塞思考模式同步 + const routePromise = this.bootstrapRoute(); + const socketPromise = this.initSocket(); + await routePromise; + await socketPromise; + this.$nextTick(() => { + this.ensureScrollListener(); + // 刷新后若无输出,自动解锁滚动锁定 + normalizeScrollLock(this); + }); + setupShowImageObserver(); + + // 立即加载初始数据(并行获取状态,优先同步运行模式) + this.loadInitialData(); + + document.addEventListener('click', this.handleClickOutsideQuickMenu); + document.addEventListener('click', this.handleClickOutsidePanelMenu); + document.addEventListener('click', this.handleClickOutsideHeaderMenu); + document.addEventListener('click', this.handleClickOutsideMobileMenu); + window.addEventListener('popstate', this.handlePopState); + window.addEventListener('keydown', this.handleMobileOverlayEscape); + this.setupMobileViewportWatcher(); + + this.subAgentFetch(); + this.subAgentStartPolling(); + + this.$nextTick(() => { + this.autoResizeInput(); + }); + this.resourceBindContainerVisibilityWatcher(); + this.resourceStartContainerStatsPolling(); + this.resourceStartProjectStoragePolling(); + this.resourceStartUsageQuotaPolling(); +} + +export function beforeUnmount() { + document.removeEventListener('click', this.handleClickOutsideQuickMenu); + document.removeEventListener('click', this.handleClickOutsidePanelMenu); + document.removeEventListener('click', this.handleClickOutsideHeaderMenu); + document.removeEventListener('click', this.handleClickOutsideMobileMenu); + window.removeEventListener('popstate', this.handlePopState); + window.removeEventListener('keydown', this.handleMobileOverlayEscape); + this.teardownMobileViewportWatcher(); + this.subAgentStopPolling(); + this.resourceStopContainerStatsPolling(); + this.resourceStopProjectStoragePolling(); + this.resourceStopUsageQuotaPolling(); + 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 new file mode 100644 index 0000000..77e1686 --- /dev/null +++ b/static/src/app/methods/common.ts @@ -0,0 +1,21 @@ +// @ts-nocheck +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 */ + } +} + +export const traceLog = (...args) => { + 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 new file mode 100644 index 0000000..58f47ed --- /dev/null +++ b/static/src/app/methods/conversation.ts @@ -0,0 +1,407 @@ +// @ts-nocheck +import { debugLog, traceLog } from './common'; + +export const conversationMethods = { + // 完整重置所有状态 + resetAllStates(reason = 'unspecified', options: { preserveMonitorWindows?: boolean } = {}) { + 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(); + + // 新增:将所有未完成的工具标记为已完成 + this.messages.forEach(msg => { + if (msg.role === 'assistant' && msg.actions) { + msg.actions.forEach(action => { + if (action.type === 'tool' && + (action.tool.status === 'preparing' || action.tool.status === 'running')) { + action.tool.status = 'completed'; + } + }); + } + }); + + // 清理Markdown缓存 + if (this.markdownCache) { + this.markdownCache.clear(); + } + this.chatClearThinkingLocks(); + + // 强制更新视图 + this.$forceUpdate(); + + 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([]); + + debugLog('前端状态重置完成'); + this._scrollListenerReady = false; + this.$nextTick(() => { + this.ensureScrollListener(); + }); + + // 重置已加载对话标记,便于后续重新加载新对话历史 + this.lastHistoryLoadedConversationId = null; + + this.logMessageState('resetAllStates:after-cleanup', { reason }); + }, + + 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(); + }, + + resetTokenStatistics() { + this.resourceResetTokenStatistics(); + }, + + // ========================================== + // 对话管理核心功能 + // ========================================== + + 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(); + + if (data.success) { + if (refreshToken !== this.conversationListRefreshToken) { + debugLog('忽略已过期的对话列表响应', { + requestSeq, + responseOffset: queryOffset + }); + return; + } + + 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) { + 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; + } + + 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() { + debugLog('创建新对话...'); + traceLog('createNewConversation:start', { + currentConversationId: this.currentConversationId, + convCount: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' + }); + this.logMessageState('createNewConversation:start'); + + 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' + }); + } 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) { + this.currentConversationId = newId; + } + + 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 new file mode 100644 index 0000000..56b0a07 --- /dev/null +++ b/static/src/app/methods/history.ts @@ -0,0 +1,396 @@ +// @ts-nocheck +import { debugLog } from './common'; + +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 + }); + 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, + timestamp: Date.now(), + blockId + }); + debugLog('添加思考内容:', reasoningText.substring(0, 50) + '...'); + } + + // 处理普通文本内容(移除思考标签后的内容) + const metadata = message.metadata || {}; + const appendPayloadMeta = metadata.append_payload; + const modifyPayloadMeta = metadata.modify_payload; + const isAppendMessage = message.name === 'append_to_file'; + const isModifyMessage = message.name === 'modify_file'; + const containsAppendMarkers = /<<<\s*(APPEND|MODIFY)/i.test(content || '') || /<<>>/i.test(content || ''); + + const textContent = content.trim(); + + if (appendPayloadMeta) { + currentAssistantMessage.actions.push({ + id: `history-append-payload-${Date.now()}-${Math.random()}`, + type: 'append_payload', + append: { + path: appendPayloadMeta.path || '未知文件', + forced: !!appendPayloadMeta.forced, + success: appendPayloadMeta.success === undefined ? true : !!appendPayloadMeta.success, + lines: appendPayloadMeta.lines ?? null, + bytes: appendPayloadMeta.bytes ?? null + }, + timestamp: Date.now() + }); + debugLog('添加append占位信息:', appendPayloadMeta.path); + } else if (modifyPayloadMeta) { + currentAssistantMessage.actions.push({ + id: `history-modify-payload-${Date.now()}-${Math.random()}`, + type: 'modify_payload', + modify: { + path: modifyPayloadMeta.path || '未知文件', + total: modifyPayloadMeta.total_blocks ?? null, + completed: modifyPayloadMeta.completed || [], + failed: modifyPayloadMeta.failed || [], + forced: !!modifyPayloadMeta.forced, + details: modifyPayloadMeta.details || [] + }, + timestamp: Date.now() + }); + debugLog('添加modify占位信息:', modifyPayloadMeta.path); + } + + if (textContent && !appendPayloadMeta && !modifyPayloadMeta && !isAppendMessage && !isModifyMessage && !containsAppendMarkers) { + 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, + 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; + } + } + if (message.name === 'append_to_file' && result && result.message) { + toolAction.tool.message = result.message; + } + debugLog(`更新工具结果: ${message.name} -> ${message.content?.substring(0, 50)}...`); + + // append_to_file 的摘要在 append_payload 占位中呈现,此处无需重复 + } else { + console.warn('找不到对应的工具调用:', message.name, message.tool_call_id); + } + } + + } else { + // 其他类型消息(如system)- 先结束当前assistant消息 + if (currentAssistantMessage && currentAssistantMessage.actions.length > 0) { + this.messages.push(currentAssistantMessage); + currentAssistantMessage = null; + } + + 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 new file mode 100644 index 0000000..618e98d --- /dev/null +++ b/static/src/app/methods/message.ts @@ -0,0 +1,287 @@ +// @ts-nocheck +import { debugLog } from './common'; + +export const messageMethods = { + handleSendOrStop() { + if (this.composerBusy) { + this.stopTask(); + } else { + this.sendMessage(); + } + }, + + sendMessage() { + if (this.streamingUi || !this.isConnected) { + 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; + } + + if (hasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { + this.uiPushToast({ + title: '当前模型不支持图片', + message: '请切换到 Qwen3.5 或 Kimi-k2.5 再发送图片', + type: 'error' + }); + return; + } + + if (hasVideos && !['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { + this.uiPushToast({ + title: '当前模型不支持视频', + message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频', + 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) { + this.socket.emit('send_command', { command: message }); + 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); + } + + // 标记任务进行中,直到任务完成或用户手动停止 + this.taskInProgress = true; + this.chatAddUserMessage(message, images, videos); + this.socket.emit('send_message', { message: message, images, videos, conversation_id: this.currentConversationId }); + 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); + }, + + // 新增:停止任务方法 + stopTask() { + const canStop = this.composerBusy && !this.stopRequested; + if (!canStop) { + return; + } + + const shouldDropToolEvents = this.streamingUi; + this.stopRequested = true; + this.dropToolEvents = shouldDropToolEvents; + if (this.socket) { + this.socket.emit('stop_task'); + debugLog('发送停止请求'); + } + + // 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态 + this.clearPendingTools('user_stop'); + this.streamingMessage = false; + this.taskInProgress = false; + this.forceUnlockMonitor('user_stop'); + }, + + async clearChat() { + const confirmed = await this.confirmAction({ + title: '清除对话', + message: '确定要清除所有对话记录吗?该操作不可撤销。', + confirmText: '清除', + cancelText: '取消' + }); + if (confirmed) { + this.socket.emit('send_command', { command: '/clear' }); + } + }, + + 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; + + try { + const response = await fetch(`/api/conversations/${this.currentConversationId}/compress`, { + method: 'POST' + }); + + const result = await response.json(); + + if (response.ok && result.success) { + const newId = result.compressed_conversation_id; + if (newId) { + this.currentConversationId = newId; + } + debugLog('对话压缩完成:', result); + } else { + const message = result.message || result.error || '压缩失败'; + this.uiPushToast({ + title: '压缩失败', + message, + type: 'error' + }); + } + } catch (error) { + console.error('压缩对话异常:', error); + this.uiPushToast({ + title: '压缩对话异常', + message: error.message || '请稍后重试', + type: 'error' + }); + } finally { + this.compressing = false; + } + }, + + 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, []); + if (this.socket) { + this.socket.emit('send_message', { + message, + images: [], + conversation_id: this.currentConversationId + }); + } + 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 new file mode 100644 index 0000000..075f93f --- /dev/null +++ b/static/src/app/methods/monitor.ts @@ -0,0 +1,24 @@ +// @ts-nocheck +import { useEasterEgg } from '../../composables/useEasterEgg'; + +export const monitorMethods = { + async handleEasterEggPayload(payload) { + const controller = useEasterEgg(); + await controller.handlePayload(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); + }, + + finishEasterEggCleanup() { + const controller = useEasterEgg(); + controller.finishCleanup(); + } +}; diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts new file mode 100644 index 0000000..5844e38 --- /dev/null +++ b/static/src/app/methods/resources.ts @@ -0,0 +1,276 @@ +// @ts-nocheck +import { + 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 + ); + }, + + 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; + } + }, + + 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/route.ts b/static/src/app/methods/route.ts new file mode 100644 index 0000000..e69de29 diff --git a/static/src/app/methods/search.ts b/static/src/app/methods/search.ts new file mode 100644 index 0000000..dfb71c6 --- /dev/null +++ b/static/src/app/methods/search.ts @@ -0,0 +1,170 @@ +// @ts-nocheck +const SEARCH_INITIAL_BATCH = 100; +const SEARCH_MORE_BATCH = 50; +const SEARCH_PREVIEW_LIMIT = 20; + +export const searchMethods = { + 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; + } +}; diff --git a/static/src/app/methods/tooling.ts b/static/src/app/methods/tooling.ts new file mode 100644 index 0000000..0c20acf --- /dev/null +++ b/static/src/app/methods/tooling.ts @@ -0,0 +1,247 @@ +// @ts-nocheck +import { usePersonalizationStore } from '../../stores/personalization'; +import { usePolicyStore } from '../../stores/policy'; +import { + 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'; + }, + + 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(); + }, + + 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 (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); + }); + }); + }, + + 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); + 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 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); + } + }, + + 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 new file mode 100644 index 0000000..66c4d14 --- /dev/null +++ b/static/src/app/methods/ui.ts @@ -0,0 +1,1174 @@ +// @ts-nocheck +import { usePolicyStore } from '../../stores/policy'; +import { useModelStore } from '../../stores/model'; +import { initializeLegacySocket } from '../../composables/useLegacySocket'; +import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer'; +import { + 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 +} from '../../composables/usePanelResize'; +import { debugLog } from './common'; + +export const uiMethods = { + 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); + } + }, + + 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); + }, + + updateMobileViewportState(isMobile) { + this.uiSetMobileViewport(!!isMobile); + if (!isMobile) { + this.uiSetMobileOverlayMenuOpen(false); + this.closeMobileOverlay(); + } + }, + + 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); + }, + + 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'); + } + }, + + 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); + }, + + 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); + }, + + 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(); + }, + + handleMobilePersonalClick() { + this.closeMobileOverlay(); + this.uiSetMobileOverlayMenuOpen(false); + this.openPersonalPage(); + }, + + toggleSidebar() { + if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') { + this.closeMobileOverlay(); + return; + } + this.uiToggleSidebar(); + }, + + togglePanelMenu() { + this.uiTogglePanelMenu(); + }, + + selectPanelMode(mode) { + this.uiSetPanelMode(mode); + this.uiSetPanelMenuOpen(false); + }, + + openPersonalPage() { + if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) { + return; + } + this.personalizationOpenDrawer(); + }, + + fetchTodoList() { + return this.fileFetchTodoList(); + }, + + fetchSubAgents() { + return this.subAgentFetch(); + }, + + async toggleThinkingMode() { + await 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; + } + }, + + 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(); + } + } + }, + + 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(); + } + }, + + async handleModeSelect(mode) { + if (!this.isConnected || this.streamingMessage) { + return; + } + await this.setRunMode(mode); + }, + + async handleHeaderRunModeSelect(mode) { + await this.handleModeSelect(mode); + this.closeHeaderMenu(); + }, + + async handleModelSelect(key) { + if (!this.isConnected || this.streamingMessage) { + return; + } + const policyStore = usePolicyStore(); + if (policyStore.isModelDisabled(key)) { + this.uiPushToast({ + title: '模型被禁用', + message: '被管理员强制禁用', + type: 'warning' + }); + return; + } + if (this.conversationHasImages && !['qwen3-vl-plus', 'kimi-k2.5'].includes(key)) { + this.uiPushToast({ + title: '切换失败', + message: '当前对话包含图片,仅支持 Qwen3.5 或 Kimi-k2.5', + type: 'error' + }); + return; + } + const modelStore = useModelStore(); + 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); + } + }, + + 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 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.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 = 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) { + this.chatAddSystemMessage(content); + this.$forceUpdate(); + this.conditionalScrollToBottom(); + }, + + 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); + }, + + async bootstrapRoute() { + // 在路由解析期间抑制标题动画,避免预置“新对话”闪烁 + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; + const path = window.location.pathname.replace(/^\/+/, ''); + if (!path || path === 'new') { + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + this.startTitleTyping('新对话', { animate: false }); + this.initialRouteResolved = true; + 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; + } + }, + + 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); + }, + + stripConversationPrefix(conversationId) { + if (!conversationId) return ''; + return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; + }, + + 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() { + await initializeLegacySocket(this); + }, + + 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'); + const statusData = await statusResponse.json(); + this.projectPath = statusData.project_path || ''; + this.agentVersion = statusData.version || this.agentVersion; + this.thinkingMode = !!statusData.thinking_mode; + this.applyStatusSnapshot(statusData); + // 立即更新配额和运行模式,避免等待其他慢接口 + this.fetchUsageQuota(); + // 拉取管理员策略 + const policyStore = usePolicyStore(); + await policyStore.fetchPolicy(); + this.applyPolicyUiLocks(); + + 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 statusConversationId = statusData.conversation && statusData.conversation.current_id; + if (statusConversationId && !this.currentConversationId) { + this.skipConversationHistoryReload = true; + // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 + this.skipConversationLoadedEvent = true; + this.suppressTitleTyping = true; + this.titleReady = false; + this.currentConversationTitle = ''; + this.titleTypingText = ''; + this.currentConversationId = statusConversationId; + + // 如果有当前对话,尝试获取标题和历史 + 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); + + debugLog('初始数据加载完成'); + } catch (error) { + console.error('加载初始数据失败:', error); + } + }, + + confirmAction(options = {}) { + return this.uiRequestConfirm(options); + } +}; diff --git a/static/src/app/methods/upload.ts b/static/src/app/methods/upload.ts new file mode 100644 index 0000000..54e329e --- /dev/null +++ b/static/src/app/methods/upload.ts @@ -0,0 +1,404 @@ +// @ts-nocheck +import { usePolicyStore } from '../../stores/policy'; + +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)); + if (!valid.length) { + this.uiPushToast({ + title: '无法上传', + message: '仅支持图片文件', + type: 'warning' + }); + return; + } + if (valid.length < list.length) { + this.uiPushToast({ + title: '已忽略', + message: '已跳过非图片文件', + type: 'info' + }); + } + const limited = valid.slice(0, remaining); + if (valid.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); + }); + }, + + 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)); + if (!valid.length) { + this.uiPushToast({ + title: '无法上传', + message: '仅支持视频文件', + type: 'warning' + }); + return; + } + if (valid.length < list.length) { + this.uiPushToast({ + title: '已忽略', + message: '已跳过非视频文件', + type: 'info' + }); + } + if (valid.length > 1) { + this.uiPushToast({ + title: '视频数量过多', + message: '一次只能选择 1 个视频,已使用第一个', + type: 'warning' + }); + } + const [file] = valid; + 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() { + if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { + this.uiPushToast({ + title: '当前模型不支持图片', + message: '请选择 Qwen3.5 或 Kimi-k2.5 后再发送图片', + type: 'error' + }); + return; + } + this.closeQuickMenu(); + this.inputSetImagePickerOpen(true); + await this.loadWorkspaceImages(); + }, + + closeImagePicker() { + this.inputSetImagePickerOpen(false); + }, + + async openVideoPicker() { + if (!['qwen3-vl-plus', 'kimi-k2.5'].includes(this.currentModelKey)) { + this.uiPushToast({ + title: '当前模型不支持视频', + message: '请切换到 Qwen3.5 或 Kimi-k2.5 后再发送视频', + 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 new file mode 100644 index 0000000..a8b21c3 --- /dev/null +++ b/static/src/app/state.ts @@ -0,0 +1,90 @@ +// @ts-nocheck +import { ICONS, TOOL_CATEGORY_ICON_MAP } from '../utils/icons'; + +export function dataState() { + return { + // 路由相关 + initialRouteResolved: false, + dropToolEvents: false, + + // 工具状态跟踪 + preparingTools: new Map(), + activeTools: new Map(), + toolActionIndex: new Map(), + toolStacks: new Map(), + // 当前任务是否仍在进行中(用于保持输入区的“停止”状态) + taskInProgress: false, + // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 + lastHistoryLoadedConversationId: null, + + // ========================================== + // 对话管理相关状态 + // ========================================== + + // 搜索功能 + // ========================================== + searchRequestSeq: 0, + searchActiveQuery: '', + searchResultIdSet: new Set(), + searchPreviewCache: {}, + + // Token统计相关状态(修复版) + // ========================================== + + // 对话压缩状态 + compressing: false, + 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, + + // 工具控制菜单 + icons: ICONS, + toolCategoryIcons: TOOL_CATEGORY_ICON_MAP, + + // 对话回顾 + reviewDialogOpen: false, + reviewSelectedConversationId: null, + reviewSubmitting: false, + reviewPreviewLines: [], + reviewPreviewLoading: false, + reviewPreviewError: null, + reviewPreviewLimit: 20, + reviewSendToModel: true, + reviewGeneratedPath: null + }; +} diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts new file mode 100644 index 0000000..e7a8f90 --- /dev/null +++ b/static/src/app/watchers.ts @@ -0,0 +1,62 @@ +// @ts-nocheck +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); + } + } +};