// @ts-nocheck import { usePolicyStore } from '../../stores/policy'; import { useModelStore } from '../../stores/model'; import { usePersonalizationStore } from '../../stores/personalization'; import { useTutorialStore } from '../../stores/tutorial'; import { renderMarkdown as renderMarkdownHelper } from '../../composables/useMarkdownRenderer'; import { scrollToBottom as scrollToBottomHelper, conditionalScrollToBottom as conditionalScrollToBottomHelper, scrollThinkingToBottom as scrollThinkingToBottomHelper } from '../../composables/useScrollControl'; import { startResize as startPanelResize, handleResize as handlePanelResize, stopResize as stopPanelResize } from '../../composables/usePanelResize'; import { debugLog } from './common'; const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/; const BG_RUN_COMMAND_DONE_PREFIX_RE = /^\[后台\s*run_command\s*完成\]/; const userMDebug = (...args: any[]) => { console.log('[USERMDEBUG]', ...args); }; let uiBounceTraceCount = 0; const UI_BOUNCE_TRACE_MAX = 140; const uiBounceTraceLastTsByKey = new Map(); function isUiBounceTraceEnabled() { if (typeof window === 'undefined') return true; try { const explicit = (window as any).__SCROLL_BOUNCE_TRACE__; if (explicit === false || explicit === '0') return false; if (explicit === true || explicit === '1') return true; const localFlag = window.localStorage?.getItem('scrollBounceTrace'); if (localFlag === '0' || localFlag === 'false') return false; if (localFlag === '1' || localFlag === 'true') return true; return true; } catch { return true; } } function uiBounceTrace( event: string, payload: Record = {}, key = event, throttleMs = 140 ) { if (!isUiBounceTraceEnabled()) return; if (uiBounceTraceCount >= UI_BOUNCE_TRACE_MAX) return; const now = Date.now(); const last = uiBounceTraceLastTsByKey.get(key) || 0; if (throttleMs > 0 && now - last < throttleMs) return; uiBounceTraceLastTsByKey.set(key, now); uiBounceTraceCount += 1; if (uiBounceTraceCount === UI_BOUNCE_TRACE_MAX) { console.warn('[SCROLL_BOUNCE_TRACE_UI]', 'log-limit-reached', { max: UI_BOUNCE_TRACE_MAX }); return; } console.log('[SCROLL_BOUNCE_TRACE_UI]', event, payload); } function isConnectionDiagEnabled() { if (typeof window === 'undefined') return true; try { const explicit = (window as any).__CONN_DIAG__; if (explicit === false || explicit === '0') return false; if (explicit === true || explicit === '1') return true; const localFlag = window.localStorage?.getItem('connDiag'); if (localFlag === '0' || localFlag === 'false') return false; if (localFlag === '1' || localFlag === 'true') return true; // 默认常开:无需手动启动 return true; } catch { return true; } } function pushConnectionDiagRecord(record: Record) { if (typeof window === 'undefined') return; try { const w = window as any; if (!Array.isArray(w.__CONN_DIAG_LOGS__)) { w.__CONN_DIAG_LOGS__ = []; } w.__CONN_DIAG_LOGS__.push(record); const max = 400; if (w.__CONN_DIAG_LOGS__.length > max) { w.__CONN_DIAG_LOGS__.splice(0, w.__CONN_DIAG_LOGS__.length - max); } } catch { // ignore } } function connectionDiag( level: 'log' | 'warn' | 'error', event: string, payload: Record = {}, options: { force?: boolean } = {} ) { const force = !!options.force; const enabled = isConnectionDiagEnabled(); if (!enabled && !force) { return; } const record = { ts: new Date().toISOString(), event, ...payload }; pushConnectionDiagRecord(record); const logger = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; logger('[CONN_DIAG]', event, record); } function parseSubAgentDoneLabel(rawContent: any): string | null { const content = (rawContent || '').toString().trim(); if (!content) { console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel: 空内容'); return null; } const match = content.match(SUB_AGENT_DONE_PREFIX_RE); const isCompleted = /已完成/.test(content); console.log('[DEBUG_SYSTEM][ui] parseSubAgentDoneLabel', { content, regex: SUB_AGENT_DONE_PREFIX_RE.toString(), matched: !!match, matchAgentId: match?.[1], isCompleted }); if (!match || !isCompleted) { return null; } const agentId = match[1]; return `子智能体${agentId} 任务完成`; } function parseBackgroundRunCommandDoneLabel(rawContent: any): string | null { const content = (rawContent || '').toString().trim(); if (!content) { return null; } if (!BG_RUN_COMMAND_DONE_PREFIX_RE.test(content)) { return null; } return '后台 run_command 完成'; } function parseSystemNoticeLabel(rawContent: any): string | null { const content = (rawContent || '').toString().trim(); if (!content) return null; return parseSubAgentDoneLabel(content) || parseBackgroundRunCommandDoneLabel(content); } export const uiMethods = { ensureScrollListener() { if (this._scrollListenerReady) { return; } const area = this.getMessagesAreaElement(); if (!area) { return; } this.initScrollListener(); this._scrollListenerReady = true; }, handleStickStateChange(payload) { const escaped = !!payload?.escapedFromLock; const isAtBottom = !!payload?.isAtBottom; const isNearBottom = !!payload?.isNearBottom; const nearBottom = isAtBottom || isNearBottom; const userEscaped = !!this._escapedByUserScroll; uiBounceTrace( 'stick-state-change', { isAtBottom, isNearBottom, escapedFromLock: escaped, userEscaped, autoScrollEnabled: this.autoScrollEnabled, userScrolling: this.userScrolling }, 'stick-state-change', 180 ); this.stickIsAtBottom = isAtBottom; this.stickIsNearBottom = isNearBottom; // 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态 if (userEscaped) { if (nearBottom) { this._escapedByUserScroll = false; this.chatSetScrollState({ userScrolling: false }); } else { this.chatSetScrollState({ userScrolling: true }); } return; } // 非用户触发的 escaped(例如高度突增导致的短暂锚点漂移)不应解除锁定 this.chatSetScrollState({ userScrolling: false }); if (!escaped) { return; } const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true; if (!this.autoScrollEnabled || !active) { return; } const chatArea = this.getChatAreaController(); if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') { chatArea.conditionalStickToBottom({ force: false }); return; } conditionalScrollToBottomHelper(this); }, handleUserScrollIntent(payload) { const ts = Number(payload?.ts || Date.now()); const chatArea = this.getChatAreaController(); if (chatArea && typeof chatArea.stopStickScroll === 'function') { chatArea.stopStickScroll(); } // 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态 this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600); this._escapedByUserScroll = true; this.chatSetScrollState({ userScrolling: true }); uiBounceTrace( 'ui.user-scroll-intent', { ts, delta: Number(payload?.delta || 0), top: Number(payload?.top || 0), suppressUntil: this._manualScrollSuppressUntil }, 'ui.user-scroll-intent', 80 ); }, 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) { console.log( '[UI_DEBUG] openMobileOverlay called, target:', target, 'isMobileViewport:', this.isMobileViewport, 'activeMobileOverlay:', this.activeMobileOverlay ); if (!this.isMobileViewport) { console.log('[UI_DEBUG] openMobileOverlay: blocked - not mobile viewport'); return; } if (target === 'approval') { this.fetchPendingToolApprovals(); } if (this.activeMobileOverlay === target) { console.log('[UI_DEBUG] openMobileOverlay: target already active, closing'); this.closeMobileOverlay('same-target-click'); return; } if (this.activeMobileOverlay === 'conversation') { this.uiSetSidebarCollapsed(true); } if (target === 'conversation') { this.uiSetSidebarCollapsed(false); } console.log('[UI_DEBUG] openMobileOverlay: opening target:', target); this.uiSetActiveMobileOverlay(target); this.uiSetMobileOverlayMenuOpen(false); }, normalizeComposerDraftContent(rawValue) { let text = ''; if (typeof rawValue === 'string') { text = rawValue; } else if (rawValue === null || rawValue === undefined) { text = ''; } else { text = String(rawValue); } if (text.length > 40000) { text = text.slice(0, 40000); } return text; }, scheduleComposerDraftPersist(reason = 'input') { const current = this.normalizeComposerDraftContent(this.inputMessage); const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent); const dirty = current !== lastSynced; this.composerDraftDirty = dirty; if (this.composerDraftSaveTimer) { clearTimeout(this.composerDraftSaveTimer); this.composerDraftSaveTimer = null; } if (!dirty) { return; } this.composerDraftSaveTimer = window.setTimeout(() => { this.persistComposerDraftNow({ reason: `debounce:${reason}` }).catch(() => {}); }, 1000); }, async persistComposerDraftNow(options = {}) { const reason = String(options?.reason || 'manual'); const force = !!options?.force; const useBeacon = !!options?.useBeacon; const content = this.normalizeComposerDraftContent( Object.prototype.hasOwnProperty.call(options || {}, 'content') ? options.content : this.inputMessage ); const lastSynced = this.normalizeComposerDraftContent(this.composerDraftLastSyncedContent); if (!force && content === lastSynced) { this.composerDraftDirty = false; return { success: true, skipped: true }; } if (this.composerDraftSaveTimer) { clearTimeout(this.composerDraftSaveTimer); this.composerDraftSaveTimer = null; } const payloadText = JSON.stringify({ content }); if ( useBeacon && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function' ) { try { const blob = new Blob([payloadText], { type: 'application/json' }); navigator.sendBeacon('/api/input-draft', blob); this.composerDraftLastSyncedContent = content; this.composerDraftDirty = false; return { success: true, queued: true, reason }; } catch (error) { // sendBeacon 失败时回落到 fetch } } const response = await fetch('/api/input-draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: payloadText, keepalive: !!options?.keepalive }); const data = await response.json().catch(() => ({})); if (!response.ok || !data?.success) { throw new Error(data?.error || '保存输入草稿失败'); } this.composerDraftLastSyncedContent = content; this.composerDraftDirty = false; return { success: true, saved: true, reason }; }, async restoreComposerDraftState(reason = 'manual') { const fetchSeq = Number(this.composerDraftFetchSeq || 0) + 1; this.composerDraftFetchSeq = fetchSeq; try { const response = await fetch('/api/input-draft', { method: 'GET', credentials: 'same-origin' }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { throw new Error(payload?.error || '获取输入草稿失败'); } if (Number(this.composerDraftFetchSeq || 0) !== fetchSeq) { return; } const content = this.normalizeComposerDraftContent(payload?.data?.content || ''); this.composerDraftLastSyncedContent = content; this.composerDraftDirty = false; this.inputSetMessage(content); this.$nextTick(() => { if (typeof this.autoResizeInput === 'function') { this.autoResizeInput(); } }); debugLog('[UI] 输入草稿已恢复', { reason, length: content.length }); } catch (error) { console.warn('[UI] 恢复输入草稿失败:', error); } }, handleBeforeUnloadDraftPersist() { this.persistComposerDraftNow({ reason: 'beforeunload', force: true, useBeacon: true }).catch(() => {}); }, clearComposerDraftState(reason = 'manual') { debugLog('[UI] 清理输入草稿状态', { reason }); this.inputClearMessage(); this.composerDraftLastSyncedContent = ''; this.composerDraftDirty = false; if (this.composerDraftSaveTimer) { clearTimeout(this.composerDraftSaveTimer); this.composerDraftSaveTimer = null; } this.inputSetLineCount(1); this.inputSetMultiline(false); this.inputClearSelectedImages(); this.inputClearSelectedVideos(); this.inputSetImagePickerOpen(false); this.inputSetVideoPickerOpen(false); this.imageEntries = []; this.videoEntries = []; this.imageLoading = false; this.videoLoading = false; this.mediaUploading = false; this.$nextTick(() => { if (typeof this.autoResizeInput === 'function') { this.autoResizeInput(); } }); }, refreshCurrentPage() { if (typeof window === 'undefined') { return; } this.persistComposerDraftNow({ reason: 'refresh-page', force: true, keepalive: true }) .catch(() => {}) .finally(() => { window.location.reload(); }); }, handleMobileRefreshClick() { this.uiSetMobileOverlayMenuOpen(false); this.refreshCurrentPage(); }, closeMobileOverlay(source = 'unknown') { console.log( '[UI_DEBUG] closeMobileOverlay called from:', source, 'activeMobileOverlay:', this.activeMobileOverlay ); 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); if (mode === 'subAgents') { // 切换到子智能体面板时立即刷新,避免等待轮询间隔 this.subAgentFetch(); this.subAgentStartPolling(); return; } if (mode === 'backgroundCommands') { // 切换到后台指令面板时立即刷新,避免等待轮询间隔 this.backgroundCommandFetch(); this.backgroundCommandStartPolling(); } }, openPersonalPage() { if (this.isPolicyBlocked('block_personal_space', '个人空间已被管理员禁用')) { return; } this.personalizationOpenDrawer(); }, async checkTutorialPrompt() { this.tutorialPromptVisible = false; this.tutorialPromptUsername = ''; try { const resp = await fetch('/api/tutorial-status', { credentials: 'same-origin' }); const data = await resp.json().catch(() => ({})); console.info('[tutorial-prompt] status response:', { ok: resp.ok, status: resp.status, data }); if (!resp.ok || !data?.success) { return; } const payload = data.data || {}; if (!payload.applicable || !payload.should_prompt) { return; } this.tutorialPromptUsername = payload.username || ''; this.tutorialPromptVisible = true; } catch (error) { console.warn('获取新手教程提示状态失败:', error); } }, async updateTutorialPromptStatus(completed = true) { this.tutorialPromptLoading = true; try { const resp = await fetch('/api/tutorial-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ tutorial_completed: !!completed }) }); const data = await resp.json().catch(() => ({})); if (!resp.ok || !data?.success) { throw new Error(data?.error || '更新新手教程状态失败'); } const tutorialStore = useTutorialStore(); tutorialStore.markCompleted(); this.tutorialPromptVisible = false; return true; } catch (error) { console.warn('更新新手教程提示状态失败:', error); this.uiPushToast({ title: '提示', message: '保存新手教程状态失败,请稍后重试', type: 'warning' }); return false; } finally { this.tutorialPromptLoading = false; } }, async handleNewUserTutorialStart() { const ok = await this.updateTutorialPromptStatus(true); if (!ok) { return; } const personalStore = usePersonalizationStore(); personalStore.closeDrawer(); const tutorialStore = useTutorialStore(); window.setTimeout(() => { tutorialStore.startTutorial(); }, 220); }, async handleNewUserTutorialSkip() { await this.updateTutorialPromptStatus(true); }, async fetchHostWorkspaces() { if (!this.versioningHostMode) { this.hostWorkspaces = []; this.currentHostWorkspaceId = ''; this.defaultHostWorkspaceId = ''; return; } try { const resp = await fetch('/api/host/workspaces'); const payload = await resp.json().catch(() => ({})); if (!resp.ok || !payload?.success) { throw new Error(payload?.error || '获取宿主机工作区列表失败'); } const data = payload.data || {}; const items = Array.isArray(data.workspaces) ? data.workspaces : []; this.hostWorkspaces = items.map((item: any) => ({ workspace_id: String(item.workspace_id || ''), label: String(item.label || item.workspace_id || '未命名工作区'), path: String(item.path || ''), is_current: !!item.is_current })); this.currentHostWorkspaceId = String(data.current_workspace_id || ''); this.defaultHostWorkspaceId = String(data.default_workspace_id || ''); } catch (error) { console.warn('加载宿主机工作区失败:', error); this.hostWorkspaces = []; this.currentHostWorkspaceId = ''; this.defaultHostWorkspaceId = ''; } }, async handleHostWorkspaceSwitch(workspaceId) { const targetId = String(workspaceId || '').trim(); if (!targetId || this.hostWorkspaceSwitching) { return; } if (!this.versioningHostMode) { return; } if (targetId === this.currentHostWorkspaceId) { return; } this.hostWorkspaceSwitching = true; try { // 先在“当前工作区作用域”落盘草稿,再切换工作区。 // 否则会把旧工作区草稿误写到目标工作区作用域里。 await this.persistComposerDraftNow({ reason: 'switch-host-workspace:before-select', force: true, keepalive: true }).catch(() => {}); const query = encodeURIComponent(targetId); const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`); const payload = await resp.json().catch(() => ({})); if (!resp.ok || !payload?.success) { throw new Error(payload?.error || '切换宿主机工作区失败'); } const data = payload.data || {}; this.currentHostWorkspaceId = String(data.current_workspace_id || targetId); if (data.project_path) { this.projectPath = String(data.project_path); } if (data.default_workspace_id) { this.defaultHostWorkspaceId = String(data.default_workspace_id); } this.uiPushToast({ title: '工作区已切换', message: '正在刷新页面…', type: 'success' }); window.setTimeout(() => { window.location.reload(); }, 260); } catch (error) { const message = error instanceof Error ? error.message : String(error || '切换失败'); this.uiPushToast({ title: '切换工作区失败', message, type: 'error' }); } finally { this.hostWorkspaceSwitching = false; } }, async handleCreateHostWorkspace() { if (!this.versioningHostMode) { return; } if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { return; } this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreateLabel = ''; this.hostWorkspaceCreateError = ''; this.hostWorkspaceCreateDialogOpen = true; }, closeHostWorkspaceCreateDialog() { if (this.hostWorkspaceCreateSubmitting) { return; } this.hostWorkspaceCreateDialogOpen = false; this.hostWorkspaceCreateError = ''; }, async submitHostWorkspaceCreate() { if (!this.versioningHostMode) { return; } if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { return; } const workspacePath = String(this.hostWorkspaceCreatePath || '').trim(); if (!workspacePath) { this.hostWorkspaceCreateError = '路径不能为空'; return; } const label = String(this.hostWorkspaceCreateLabel || '').trim(); this.hostWorkspaceCreateSubmitting = true; this.hostWorkspaceCreateError = ''; try { const resp = await fetch('/api/host/workspaces/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ path: workspacePath, label }) }); const payload = await resp.json().catch(() => ({})); if (!resp.ok || !payload?.success) { throw new Error(payload?.error || '新建宿主机工作区失败'); } await this.fetchHostWorkspaces(); const createdLabel = payload?.data?.workspace?.label || label || workspacePath; this.hostWorkspaceCreateDialogOpen = false; this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreateLabel = ''; this.uiPushToast({ title: '工作区已创建', message: createdLabel, type: 'success' }); } catch (error) { const message = error instanceof Error ? error.message : String(error || '新建失败'); this.hostWorkspaceCreateError = message; this.uiPushToast({ title: '新建工作区失败', message, type: 'error' }); } finally { this.hostWorkspaceCreateSubmitting = false; } }, 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; } const modelStore = useModelStore(); const targetModel = modelStore.models.find((m) => m.key === key); if (this.conversationHasImages && !targetModel?.supportsImage) { this.uiPushToast({ title: '切换失败', message: '当前对话包含图片,目标模型不支持图片输入', type: 'error' }); return; } if (this.conversationHasVideos && !targetModel?.supportsVideo) { this.uiPushToast({ title: '切换失败', message: '当前对话包含视频,目标模型不支持视频输入', type: 'error' }); return; } const prev = this.currentModelKey; try { const resp = await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model_key: key }) }); const payload = await resp.json(); if (!resp.ok || !payload.success) { throw new Error(payload.error || payload.message || '切换失败'); } const data = payload.data || {}; modelStore.setModel(data.model_key || key); if (data.run_mode) { this.runMode = data.run_mode; this.thinkingMode = data.thinking_mode ?? data.run_mode !== 'fast'; } else { // 前端兼容策略:根据模型特性自动调整运行模式 const currentModel = modelStore.currentModel; if (currentModel?.deepOnly) { this.runMode = 'deep'; this.thinkingMode = true; } else if (currentModel?.fastOnly) { this.runMode = 'fast'; this.thinkingMode = false; } else { this.thinkingMode = this.runMode !== 'fast'; } } this.uiPushToast({ title: '模型已切换', message: modelStore.currentModel?.label || key, type: 'success' }); } catch (error) { modelStore.setModel(prev); const msg = error instanceof Error ? error.message : String(error || '切换失败'); this.uiPushToast({ title: '切换模型失败', message: msg, type: 'error' }); } finally { this.modelMenuOpen = false; this.inputCloseMenus(); this.inputSetQuickMenuOpen(false); } }, 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(); } }, getPermissionModeLabel(mode) { const options = Array.isArray(this.permissionModeOptions) ? this.permissionModeOptions : []; const hit = options.find((item) => item.value === mode); return hit ? hit.label : mode || '未知'; }, getExecutionModeLabel(mode) { const options = Array.isArray(this.executionModeOptions) ? this.executionModeOptions : []; const hit = options.find((item) => item.value === mode); return hit ? hit.label : mode || '未知'; }, togglePermissionMenu() { if (!this.isConnected) { return; } this.permissionMenuOpen = !this.permissionMenuOpen; }, closePermissionMenu() { this.permissionMenuOpen = false; }, async changePermissionMode(mode) { const target = String(mode || '') .trim() .toLowerCase(); if (!target) { this.closePermissionMenu(); return; } try { const response = await fetch('/api/permission-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: target }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { throw new Error(payload?.message || payload?.error || '切换权限失败'); } if (typeof payload?.mode === 'string') { this.currentPermissionMode = payload.mode; } this.pendingPermissionMode = ''; this.uiPushToast({ title: '权限已更新', message: payload?.message || '已立即生效', type: 'info', duration: 1800 }); } catch (error) { const msg = error instanceof Error ? error.message : String(error || '切换权限失败'); this.uiPushToast({ title: '切换权限失败', message: msg, type: 'error' }); } finally { this.closePermissionMenu(); } }, async changeExecutionMode(mode) { const target = String(mode || '').trim().toLowerCase(); if (!this.executionModeEnabled || !target) { return; } try { const response = await fetch('/api/execution-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: target }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { throw new Error(payload?.message || payload?.error || '切换执行环境失败'); } const state = payload?.state || {}; if (typeof state.mode === 'string') { this.currentExecutionMode = state.mode; } this.pendingExecutionMode = ''; this.executionModeDirectUntil = state.direct_until ?? null; this.executionModeTtlSeconds = typeof state.ttl_seconds === 'number' ? state.ttl_seconds : this.executionModeTtlSeconds; this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil); this.uiPushToast({ title: '执行环境已更新', message: payload?.message || '已立即生效', type: this.currentExecutionMode === 'direct' ? 'warning' : 'info', duration: 1800 }); } catch (error) { const msg = error instanceof Error ? error.message : String(error || '切换执行环境失败'); this.uiPushToast({ title: '切换执行环境失败', message: msg, type: 'error' }); } }, async handleSwitchPermissionToUnrestricted(approvalId) { await this.changePermissionMode('unrestricted'); if (approvalId) { await this.approveToolApproval(approvalId); } }, async fetchPermissionMode() { try { const response = await fetch('/api/permission-mode'); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { return; } if (typeof payload.mode === 'string') { this.currentPermissionMode = payload.mode; } this.pendingPermissionMode = typeof payload.pending_mode === 'string' ? payload.pending_mode : ''; } catch (_error) { // ignore } }, async fetchExecutionMode() { try { const prevExecutionMode = this.currentExecutionMode; const prevDirectUntil = this.executionModeDirectUntil; const response = await fetch('/api/execution-mode'); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { return; } this.executionModeEnabled = !!payload.enabled; const state = payload.state || {}; if (typeof state.mode === 'string') { this.currentExecutionMode = state.mode; } this.pendingExecutionMode = typeof payload.pending_mode === 'string' ? payload.pending_mode : ''; this.executionModeDirectUntil = state.direct_until ?? null; if (typeof state.ttl_seconds === 'number') { this.executionModeTtlSeconds = state.ttl_seconds; } this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil); const parseDirectUntilMs = (value) => { if (!value) return 0; if (typeof value === 'number') { return value > 1e12 ? value : value * 1000; } if (typeof value === 'string') { const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; } return 0; }; const prevDirectUntilMs = parseDirectUntilMs(prevDirectUntil); const fallbackLikelyByExpiry = !!prevDirectUntilMs && Date.now() >= prevDirectUntilMs - 1200; const directExpiredToSandbox = prevExecutionMode === 'direct' && this.currentExecutionMode === 'sandbox' && fallbackLikelyByExpiry; if (directExpiredToSandbox) { if (this.currentPermissionMode === 'unrestricted') { this.currentPermissionMode = 'approval'; } Promise.resolve(this.fetchPermissionMode()).catch(() => {}); try { if (this.executionModeAutoFallbackToastId) { this.uiDismissToast(this.executionModeAutoFallbackToastId); } } catch (_err) {} this.executionModeAutoFallbackToastId = this.uiPushToast({ title: '执行环境已自动回退', message: '完全访问权限已到期,已自动切回受限权限并切换为沙箱执行环境。', type: 'warning', duration: null }); } } catch (_error) { // ignore } }, clearExecutionModeExpirySyncTimer() { if (this.executionModeExpirySyncTimer) { clearTimeout(this.executionModeExpirySyncTimer); this.executionModeExpirySyncTimer = null; } }, scheduleExecutionModeExpirySync(directUntil) { this.clearExecutionModeExpirySyncTimer(); if (!this.executionModeEnabled || this.currentExecutionMode !== 'direct' || !directUntil) { return; } let untilMs = 0; if (typeof directUntil === 'number') { untilMs = directUntil > 1e12 ? directUntil : directUntil * 1000; } else if (typeof directUntil === 'string') { const parsed = Date.parse(directUntil); if (!Number.isNaN(parsed)) { untilMs = parsed; } } if (!untilMs) { return; } const delayMs = Math.max(250, untilMs - Date.now() + 250); this.executionModeExpirySyncTimer = window.setTimeout(async () => { this.executionModeExpirySyncTimer = null; await this.fetchExecutionMode(); await this.fetchPermissionMode(); }, delayMs); }, async openPathAuthorizationDialog() { if (!this.executionModeEnabled) return; this.pathAuthorizationDialogOpen = true; try { const response = await fetch('/api/path-authorization'); const payload = await response.json().catch(() => ({})); if (response.ok && payload?.success) { const writablePaths = Array.isArray(payload.writable_paths) ? payload.writable_paths : []; const readableExtraPaths = Array.isArray(payload.readable_extra_paths) ? payload.readable_extra_paths : []; this.pathAuthorizationWritableDraft = writablePaths.join('\n'); this.pathAuthorizationReadableDraft = readableExtraPaths.join('\n'); this.pathAuthorizationMode = 'writable'; this.pathAuthorizationDraft = this.pathAuthorizationWritableDraft; } } catch (_error) { // ignore } }, setPathAuthorizationMode(mode) { if (this.pathAuthorizationMode === 'readable') { this.pathAuthorizationReadableDraft = String(this.pathAuthorizationDraft || ''); } else { this.pathAuthorizationWritableDraft = String(this.pathAuthorizationDraft || ''); } const next = mode === 'readable' ? 'readable' : 'writable'; this.pathAuthorizationMode = next; this.pathAuthorizationDraft = next === 'readable' ? this.pathAuthorizationReadableDraft : this.pathAuthorizationWritableDraft; }, closePathAuthorizationDialog() { this.pathAuthorizationDialogOpen = false; }, async savePathAuthorization() { const currentLines = String(this.pathAuthorizationDraft || '') .split('\n') .map((x) => x.trim()) .filter(Boolean); if (this.pathAuthorizationMode === 'readable') { this.pathAuthorizationReadableDraft = currentLines.join('\n'); } else { this.pathAuthorizationWritableDraft = currentLines.join('\n'); } const writableLines = String(this.pathAuthorizationWritableDraft || '') .split('\n') .map((x) => x.trim()) .filter(Boolean); const readableLines = String(this.pathAuthorizationReadableDraft || '') .split('\n') .map((x) => x.trim()) .filter(Boolean); this.pathAuthorizationSaving = true; try { const response = await fetch('/api/path-authorization', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ writable_paths: writableLines, readable_extra_paths: readableLines }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { throw new Error(payload?.error || '保存失败'); } const savedWritable = Array.isArray(payload.writable_paths) ? payload.writable_paths : writableLines; const savedReadable = Array.isArray(payload.readable_extra_paths) ? payload.readable_extra_paths : readableLines; this.pathAuthorizationWritableDraft = savedWritable.join('\n'); this.pathAuthorizationReadableDraft = savedReadable.join('\n'); this.pathAuthorizationDraft = this.pathAuthorizationMode === 'readable' ? this.pathAuthorizationReadableDraft : this.pathAuthorizationWritableDraft; this.uiPushToast({ title: '路径授权已保存', message: '命令工具立即生效;终端会话请重开后生效', type: 'success' }); this.pathAuthorizationDialogOpen = false; } catch (error) { const msg = error instanceof Error ? error.message : String(error || '保存失败'); this.uiPushToast({ title: '保存路径授权失败', message: msg, type: 'error' }); } finally { this.pathAuthorizationSaving = false; } }, async fetchPendingToolApprovals() { if (!this.currentConversationId) { this.pendingToolApprovals = []; return; } try { const response = await fetch( `/api/tool-approvals/pending?conversation_id=${encodeURIComponent(this.currentConversationId)}` ); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { return; } const items = Array.isArray(payload.items) ? payload.items : []; this.pendingToolApprovals = items; // 电脑端:有审批时自动展开面板 if (items.length > 0 && !this.isMobileViewport) { this.rightCollapsed = false; if (this.rightWidth < this.minPanelWidth) { this.rightWidth = this.minPanelWidth; } } } catch (_error) { // ignore } }, async decideToolApproval(approvalId, decision) { const id = String(approvalId || '').trim(); if (!id) { return; } if (!Array.isArray(this.decidingApprovalIds)) { this.decidingApprovalIds = []; } if (!this.decidingApprovalIds.includes(id)) { this.decidingApprovalIds.push(id); } try { const response = await fetch(`/api/tool-approvals/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { throw new Error(payload?.message || payload?.error || '提交审批失败'); } this.pendingToolApprovals = (this.pendingToolApprovals || []).filter( (item) => item && item.approval_id !== id ); if (!this.pendingToolApprovals.length) { this.rightCollapsed = true; if (this.isMobileViewport && this.activeMobileOverlay === 'approval') { this.closeMobileOverlay(); } } } catch (error) { const msg = error instanceof Error ? error.message : String(error || '审批失败'); this.uiPushToast({ title: '审批失败', message: msg, type: 'error' }); } finally { this.decidingApprovalIds = (this.decidingApprovalIds || []).filter((item) => item !== id); } }, approveToolApproval(approvalId) { return this.decideToolApproval(approvalId, 'approved'); }, rejectToolApproval(approvalId) { return this.decideToolApproval(approvalId, 'rejected'); }, handleInputChange() { this.autoResizeInput(); this.scheduleComposerDraftPersist('input-change'); }, handleInputFocus() { this.inputSetFocused(true); this.closeQuickMenu(); this.closePermissionMenu(); }, 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(); }, handleApprovalPanelToggleClick() { console.log( '[UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport:', this.isMobileViewport, 'currentConversationId:', this.currentConversationId, 'activeMobileOverlay:', this.activeMobileOverlay ); if (!this.currentConversationId) { console.log('[UI_DEBUG] handleApprovalPanelToggleClick: blocked - no conversation'); return; } if (this.isMobileViewport) { console.log('[UI_DEBUG] handleApprovalPanelToggleClick: mobile path, toggling overlay'); // 手机端:获取审批列表(不自动关闭),然后切换遮罩 this.fetchPendingToolApprovals(); this.openMobileOverlay('approval'); return; } console.log('[UI_DEBUG] handleApprovalPanelToggleClick: desktop path, toggling panel'); this.toggleApprovalPanel(); }, handleTokenPanelToggleClick(fromSettingsMenu = false) { console.log( '[UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu:', fromSettingsMenu, 'isMobileViewport:', this.isMobileViewport, 'tokenPanelCollapsed:', this.tokenPanelCollapsed, 'currentConversationId:', this.currentConversationId ); if (!this.currentConversationId) { console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - no conversation'); return; } if (this.isPolicyBlocked('block_token_panel', '用量统计已被管理员禁用')) { console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - policy'); return; } // 移动端禁用“点击展开顶部用量面板”,仅允许在已展开时点击收起 if (this.isMobileViewport && this.tokenPanelCollapsed && !fromSettingsMenu) { console.log('[UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction'); return; } console.log('[UI_DEBUG] handleTokenPanelToggleClick: calling toggleTokenPanel'); this.toggleTokenPanel(); }, handleCompressConversationClick() { if (this.compressing || this.streamingMessage || !this.isConnected) { return; } if (this.compressionInProgress) { this.uiPushToast({ title: '对话自动压缩中', message: '当前对话正在压缩,请稍后再试', type: 'warning' }); return; } if (this.isPolicyBlocked('block_compress_conversation', '压缩对话已被管理员禁用')) { return; } this.compressConversation(); }, openReviewDialog() { if (this.isPolicyBlocked('block_conversation_review', '对话引用已被管理员禁用')) { return; } if (!this.isConnected) { this.uiPushToast({ title: '无法使用', message: '当前未连接,无法生成回顾文件', type: 'warning' }); return; } if (!this.conversations.length && !this.conversationsLoading) { this.loadConversationsList(); } const fallback = this.conversations.find((c) => c.id !== this.currentConversationId); if (!fallback) { this.uiPushToast({ title: '暂无可用对话', message: '没有可供回顾的其他对话记录', type: 'info' }); return; } this.reviewSelectedConversationId = fallback.id; this.reviewDialogOpen = true; this.reviewPreviewLines = []; this.reviewPreviewError = null; this.reviewGeneratedPath = null; this.loadReviewPreview(fallback.id); this.closeQuickMenu(); }, closeHeaderMenu() { this.headerMenuOpen = false; }, handleReviewSelect(id) { if (id === this.currentConversationId) { this.uiPushToast({ title: '无法引用当前对话', message: '请选择其他对话生成回顾', type: 'warning' }); return; } this.reviewSelectedConversationId = id; this.loadReviewPreview(id); }, async handleConfirmReview() { if (this.reviewSubmitting) return; if (!this.reviewSelectedConversationId) { this.uiPushToast({ title: '请选择对话', message: '请选择要生成回顾的对话记录', type: 'info' }); return; } if (this.reviewSelectedConversationId === this.currentConversationId) { this.uiPushToast({ title: '无法引用当前对话', message: '请选择其他对话生成回顾', type: 'warning' }); return; } if (!this.currentConversationId) { this.uiPushToast({ title: '无法发送', message: '当前没有活跃对话,无法自动发送提示消息', type: 'warning' }); return; } this.reviewSubmitting = true; try { const { path, char_count } = await this.generateConversationReview( this.reviewSelectedConversationId ); if (!path) { throw new Error('未获取到生成的文件路径'); } const count = typeof char_count === 'number' ? char_count : 0; this.reviewGeneratedPath = path; const suggestion = count && count <= 10000 ? '建议直接完整阅读。' : '建议使用 read 工具进行搜索或分段阅读。'; if (this.reviewSendToModel) { const message = `帮我继续这个任务,对话文件在 ${path},文件长 ${count || '未知'} 字符,${suggestion} 请阅读文件了解后,不要直接继续工作,而是向我汇报你的理解,然后等我做出指示。`; const sent = await this.sendAutoUserMessage(message); if (sent) { this.reviewDialogOpen = false; } } else { this.uiPushToast({ title: '回顾文件已生成', message: path, type: 'success' }); } } catch (error) { const msg = error instanceof Error ? error.message : String(error || '生成失败'); this.uiPushToast({ title: '生成回顾失败', message: msg, type: 'error' }); } finally { this.reviewSubmitting = false; } }, async loadReviewPreview(conversationId) { this.reviewPreviewLoading = true; this.reviewPreviewError = null; this.reviewPreviewLines = []; try { const resp = await fetch( `/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}` ); const payload = await resp.json().catch(() => ({})); if (!resp.ok || !payload?.success) { const msg = payload?.message || payload?.error || '获取预览失败'; throw new Error(msg); } this.reviewPreviewLines = payload?.data?.preview || []; } catch (error) { const msg = error instanceof Error ? error.message : String(error || '获取预览失败'); this.reviewPreviewError = msg; } finally { this.reviewPreviewLoading = false; } }, async generateConversationReview(conversationId) { const response = await fetch(`/api/conversations/${conversationId}/review`, { method: 'POST' }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { const msg = payload?.message || payload?.error || '生成失败'; throw new Error(msg); } const data = payload.data || payload; return { path: data.path || data.file_path || data.relative_path, char_count: data.char_count ?? data.length ?? data.size ?? 0 }; }, handleClickOutsideQuickMenu(event) { if (!this.quickMenuOpen && !this.permissionMenuOpen) { return; } const shell = this.getComposerElement('stadiumShellOuter') || this.getComposerElement('compactInputShell'); if (shell && shell.contains(event.target)) { return; } this.closeQuickMenu(); this.closePermissionMenu(); }, 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; } }, toggleApprovalPanel() { console.log( '[UI_DEBUG] toggleApprovalPanel called, current rightCollapsed:', this.rightCollapsed, 'new state:', !this.rightCollapsed ); this.rightCollapsed = !this.rightCollapsed; if (!this.rightCollapsed && this.rightWidth < this.minPanelWidth) { this.rightWidth = this.minPanelWidth; } if (!this.rightCollapsed) { this.fetchPendingToolApprovals(); } }, addSystemMessage(content) { userMDebug('ui.addSystemMessage:incoming', { content }); const systemNoticeLabel = parseSystemNoticeLabel(content); if (!systemNoticeLabel) { // 其他 system 消息全部隐藏,且不进入渲染链路,避免出现空白块 userMDebug('ui.addSystemMessage:blocked', { content }); return; } userMDebug('ui.addSystemMessage:accepted', { original: content, normalizedLabel: systemNoticeLabel }); this.chatAddSystemMessage(systemNoticeLabel, { variant: 'sub_agent_done' }); this.$forceUpdate(); this.conditionalScrollToBottom(); }, appendSystemAction(content) { this.addSystemMessage(content); }, startTitleTyping(title: string, options: { animate?: boolean } = {}) { if (this.titleTypingTimer) { clearInterval(this.titleTypingTimer); this.titleTypingTimer = null; } const target = (title || '').trim(); const animate = options.animate ?? true; if (!animate) { this.titleTypingTarget = target; this.titleTypingText = target; return; } const previous = (this.titleTypingText || '').trim(); if (previous === target) { this.titleTypingTarget = target; this.titleTypingText = target; return; } this.titleTypingTarget = target; this.titleTypingText = previous; const frames: string[] = []; for (let i = previous.length; i >= 0; i--) { frames.push(previous.slice(0, i)); } for (let j = 1; j <= target.length; j++) { frames.push(target.slice(0, j)); } let index = 0; this.titleTypingTimer = window.setInterval(() => { if (index >= frames.length) { clearInterval(this.titleTypingTimer!); this.titleTypingTimer = null; this.titleTypingText = target; return; } this.titleTypingText = frames[index]; index += 1; }, 32); }, toggleBlock(blockId) { if (!blockId) { return; } if (this.expandedBlocks && this.expandedBlocks.has(blockId)) { this.chatCollapseBlock(blockId); } else { this.chatExpandBlock(blockId); } }, handleThinkingScroll(blockId, event) { if (!blockId || !event || !event.target) { return; } const el = event.target; const threshold = 12; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold; this.chatSetThinkingLock(blockId, atBottom); }, scrollToBottom() { uiBounceTrace( 'ui.scrollToBottom:called', { autoScrollEnabled: this.autoScrollEnabled, userScrolling: this.userScrolling }, 'ui.scrollToBottom:called', 80 ); this._escapedByUserScroll = false; const chatArea = this.getChatAreaController(); if (chatArea && typeof chatArea.scrollToBottom === 'function') { chatArea.scrollToBottom({ behavior: 'auto', force: this.autoScrollEnabled }); this.chatSetScrollState({ userScrolling: false }); return; } scrollToBottomHelper(this); }, conditionalScrollToBottom() { const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true; const chatArea = this.getChatAreaController(); const stickState = chatArea && typeof chatArea.getStickState === 'function' ? chatArea.getStickState() : null; uiBounceTrace( 'ui.conditionalScrollToBottom:called', { active, autoScrollEnabled: this.autoScrollEnabled, userScrolling: this.userScrolling, stickIsNearBottom: this.stickIsNearBottom, escapedFromLock: !!stickState?.escapedFromLock }, 'ui.conditionalScrollToBottom:called', 120 ); if (!active) { return; } const now = Date.now(); if (now <= (this._manualScrollSuppressUntil || 0)) { uiBounceTrace( 'ui.conditionalScrollToBottom:skip-manual-cooldown', { now, suppressUntil: this._manualScrollSuppressUntil }, 'ui.conditionalScrollToBottom:skip-manual-cooldown', 120 ); return; } // 仅在“确认是用户主动脱离锁定”时才阻断自动追底; // 对于内容高度突变导致的 escapedFromLock,不应长期卡住锁定。 if (stickState?.escapedFromLock && this._escapedByUserScroll) { uiBounceTrace( 'ui.conditionalScrollToBottom:skip-escaped-lock', { escapedFromLock: !!stickState?.escapedFromLock, escapedByUser: !!this._escapedByUserScroll, isNearBottom: !!stickState?.isNearBottom, isAtBottom: !!stickState?.isAtBottom }, 'ui.conditionalScrollToBottom:skip-escaped-lock', 120 ); return; } // 只要用户处于手动滚动状态,就停止自动追底,避免“被弹回” // (stickIsNearBottom 在 show_html 动态重排时可能短暂失真,不可作为阻断前置条件) if (this.userScrolling) { uiBounceTrace( 'ui.conditionalScrollToBottom:skip-user-scrolling', { userScrolling: this.userScrolling, stickIsNearBottom: this.stickIsNearBottom }, 'ui.conditionalScrollToBottom:skip-user-scrolling', 120 ); return; } if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') { chatArea.conditionalStickToBottom({ force: false }); return; } conditionalScrollToBottomHelper(this); }, toggleScrollLock() { uiBounceTrace( 'ui.scrollToBottomButton:clicked', { autoScrollEnabled: this.autoScrollEnabled, userScrolling: this.userScrolling, stickNearBottom: this.stickIsNearBottom }, 'ui.scrollToBottomButton:clicked', 0 ); const chatArea = this.getChatAreaController(); if (chatArea && typeof chatArea.scrollToBottom === 'function') { chatArea.scrollToBottom({ behavior: 'smooth', force: true }); } else { scrollToBottomHelper(this, { ignoreUserScrolling: true, resetUserScrolling: true, behavior: 'smooth', force: true }); } this._escapedByUserScroll = false; this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false }); return true; }, 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; }, handleComposerHeightChange(payload = {}) { const raw = Number(payload?.reservedHeight || 0) || Number(payload?.height || 0) || Number(payload?.composerHeight || 0); if (!Number.isFinite(raw) || raw <= 0) { return; } const next = Math.max(80, Math.min(560, Math.round(raw))); if (Math.abs(next - Number(this.composerReservedHeight || 0)) < 1) { return; } this.composerReservedHeight = next; }, 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; }, getChatAreaController() { const ref = this.$refs.messagesArea; if (!ref) return null; if (typeof ref === 'object') return ref; return null; }, getThinkingContentElement(blockId) { const chatArea = this.$refs.messagesArea; if (chatArea && typeof chatArea.getThinkingRef === 'function') { const el = chatArea.getThinkingRef(blockId); if (el) { return el; } } const refName = `thinkingContent-${blockId}`; const elRef = this.$refs[refName]; if (Array.isArray(elRef)) { return elRef[0] || null; } return elRef || null; }, isOutputActive() { return !!(this.streamingMessage || this.taskInProgress || this.hasPendingToolActions()); }, logMessageState(action, extra = {}) { const count = Array.isArray(this.messages) ? this.messages.length : 'N/A'; debugLog('[Messages]', { action, count, conversationId: this.currentConversationId, streaming: this.streamingMessage, ...extra }); }, iconStyle(iconKey, size) { const iconPath = this.icons ? this.icons[iconKey] : null; if (!iconPath) { return {}; } const style = { '--icon-src': `url(${iconPath})` }; if (size) { style['--icon-size'] = size; } return style; }, openGuiFileManager() { if (this.isPolicyBlocked('block_file_manager', '文件管理器已被管理员禁用')) { return; } window.open('/file-manager', '_blank'); }, renderMarkdown(content, isStreaming = false) { return renderMarkdownHelper(content, isStreaming); }, isExplicitNewConversationRoute() { const normalizedPath = window.location.pathname.replace(/^\/+|\/+$/g, ''); return normalizedPath === 'new'; }, async getRunningTaskConversationId() { try { const resp = await fetch('/api/tasks'); if (!resp.ok) { return null; } const payload = await resp.json(); const tasks = Array.isArray(payload?.data) ? payload.data : []; const runningTask = tasks.find( (task: any) => task?.status === 'running' && task?.conversation_id ); return runningTask?.conversation_id || null; } catch (error) { console.warn('获取运行中任务失败:', error); return null; } }, async bootstrapRoute() { // 在路由解析期间抑制标题动画,避免预置"新对话"闪烁 this.suppressTitleTyping = true; this.titleReady = false; this.currentConversationTitle = ''; this.titleTypingText = ''; const path = window.location.pathname.replace(/^\/+/, ''); if (!path || this.isExplicitNewConversationRoute()) { this.currentConversationId = null; this.currentConversationTitle = '新对话'; this.logMessageState('bootstrapRoute:clear-messages-for-new'); this.messages = []; this.titleReady = true; this.suppressTitleTyping = false; this.startTitleTyping('新对话', { animate: false }); this.initialRouteResolved = true; this.refreshBlankHeroState(); await this.restoreComposerDraftState('bootstrap-route:new'); 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) { if (typeof result.run_mode === 'string') { this.runMode = result.run_mode; this.thinkingMode = typeof result.thinking_mode === 'boolean' ? result.thinking_mode : result.run_mode !== 'fast'; } else if (typeof result.thinking_mode === 'boolean') { this.thinkingMode = result.thinking_mode; this.runMode = result.thinking_mode ? 'thinking' : 'fast'; } if (typeof result.model_key === 'string' && result.model_key) { this.modelSet(result.model_key); } 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; } await this.restoreComposerDraftState('bootstrap-route:conversation'); }, 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(); this.restoreComposerDraftState('popstate:new').catch(() => {}); return; } this.loadConversation(convId); }, stripConversationPrefix(conversationId) { if (!conversationId) return ''; return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; }, initScrollListener() { const chatArea = this.getChatAreaController(); if ( chatArea && typeof chatArea.isUsingStickToBottom === 'function' && chatArea.isUsingStickToBottom() ) { this._scrollListenerReady = true; return; } const messagesArea = this.getMessagesAreaElement(); if (!messagesArea) { console.warn('消息区域未找到'); return; } this._scrollListenerReady = true; let scrollDebugCount = 0; const SCROLL_DEBUG_MAX = 1200; let scrollDebugLastTs = 0; const isScrollDebugEnabled = () => { if (typeof window === 'undefined') return false; const until = Number((window as any).__SHOW_TAG_DRAWING_UNTIL__ || 0); return Date.now() <= until; }; const scrollDebug = (event, payload = {}, throttleMs = 80) => { if (!isScrollDebugEnabled()) { return; } if (scrollDebugCount >= SCROLL_DEBUG_MAX) { return; } const now = Date.now(); if (throttleMs > 0 && now - scrollDebugLastTs < throttleMs) { return; } scrollDebugLastTs = now; scrollDebugCount += 1; if (scrollDebugCount === SCROLL_DEBUG_MAX) { console.warn('[SCROLL_DEBUG]', 'listener:log-limit-reached', { max: SCROLL_DEBUG_MAX }); return; } console.log('[SCROLL_DEBUG]', event, payload); }; scrollDebug( 'listener:setup', { autoScrollEnabled: this.autoScrollEnabled, userScrolling: this.userScrolling }, 0 ); let isProgrammaticScroll = false; const bottomThreshold = 12; this._setScrollingFlag = (flag) => { isProgrammaticScroll = !!flag; scrollDebug('listener:programmatic-flag', { flag: !!flag }, 0); }; messagesArea.addEventListener('scroll', () => { if (isProgrammaticScroll) { scrollDebug( 'listener:scroll:ignore-programmatic', { top: messagesArea.scrollTop, height: messagesArea.scrollHeight, client: messagesArea.clientHeight }, 120 ); return; } const scrollTop = messagesArea.scrollTop; const scrollHeight = messagesArea.scrollHeight; const clientHeight = messagesArea.clientHeight; const isAtBottom = scrollHeight - scrollTop - clientHeight < bottomThreshold; const nearBottomThreshold = 48; const isNearBottom = scrollHeight - scrollTop - clientHeight < nearBottomThreshold; const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true; if (this.autoScrollEnabled && active) { // 锁定模式下不让 userScrolling 状态来回抖动 this.chatSetScrollState({ userScrolling: false }); scrollDebug('listener:scroll:locked-keep-bottom-state', { top: scrollTop, height: scrollHeight, client: clientHeight, remain: scrollHeight - scrollTop - clientHeight, isAtBottom, isNearBottom }); return; } // 仅维护状态,不在 scroll 事件中强制写 scrollTop(避免上下抖动) this.chatSetScrollState({ userScrolling: !(isAtBottom || isNearBottom) }); scrollDebug('listener:scroll:update-state', { top: scrollTop, height: scrollHeight, client: clientHeight, remain: scrollHeight - scrollTop - clientHeight, isAtBottom, isNearBottom, autoScrollEnabled: this.autoScrollEnabled, userScrollingBefore: this.userScrolling, userScrollingNext: !(isAtBottom || isNearBottom) }); }); }, async initSocket() { // 主网页端已切换为 REST + 轮询模式,这里保留空实现用于兼容旧调用 this.socket = null; }, async checkConnectionHealth() { if (this.connectionHeartbeatInFlight) { connectionDiag('log', 'health-skip-inflight', { seq: this.connectionHeartbeatSeq, isConnected: !!this.isConnected, failCount: this.connectionHeartbeatFailCount }); return; } this.connectionHeartbeatInFlight = true; const seq = Number(this.connectionHeartbeatSeq || 0) + 1; this.connectionHeartbeatSeq = seq; const requestId = `${Date.now()}-${seq}`; const startedAt = Date.now(); const diagEnabled = isConnectionDiagEnabled(); const healthUrl = diagEnabled ? '/api/health?diag=1' : '/api/health'; const wasConnected = !!this.isConnected; let responseStatus: number | null = null; const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeoutMs = typeof this.connectionHeartbeatRequestTimeoutMs === 'number' && this.connectionHeartbeatRequestTimeoutMs > 0 ? this.connectionHeartbeatRequestTimeoutMs : 5000; const timeoutId = controller ? window.setTimeout(() => controller.abort(), timeoutMs) : null; try { const response = await fetch(healthUrl, { method: 'GET', cache: 'no-store', headers: { 'X-Connection-Heartbeat': requestId }, signal: controller?.signal }); responseStatus = response.status; if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const failCountBeforeRecover = this.connectionHeartbeatFailCount || 0; this.isConnected = true; this.connectionHeartbeatFailCount = 0; this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt; this.connectionHeartbeatLastStatusCode = responseStatus; this.connectionHeartbeatLastError = ''; if (!wasConnected) { this.connectionHeartbeatLastChangeAt = Date.now(); connectionDiag( 'warn', 'health-recovered', { requestId, seq, elapsedMs: this.connectionHeartbeatLastLatencyMs, failCountBeforeRecover, status: responseStatus, diagEnabled, endpoint: healthUrl, conversationId: this.currentConversationId || null, taskInProgress: !!this.taskInProgress, streamingMessage: !!this.streamingMessage, visibility: document?.visibilityState || 'unknown', online: typeof navigator !== 'undefined' ? navigator.onLine : null }, { force: true } ); } else if ( isConnectionDiagEnabled() && (this.connectionHeartbeatLastLatencyMs >= 700 || seq <= 3 || seq % 60 === 0) ) { connectionDiag('log', 'health-ok', { requestId, seq, elapsedMs: this.connectionHeartbeatLastLatencyMs, status: responseStatus, endpoint: healthUrl, visibility: document?.visibilityState || 'unknown' }); } } catch (error) { const nextFailCount = (this.connectionHeartbeatFailCount || 0) + 1; this.connectionHeartbeatFailCount = nextFailCount; this.connectionHeartbeatLastLatencyMs = Date.now() - startedAt; this.connectionHeartbeatLastStatusCode = responseStatus; const errName = error?.name || 'Error'; const errMessage = error?.message || String(error); this.connectionHeartbeatLastError = `${errName}: ${errMessage}`; const failThreshold = typeof this.connectionHeartbeatFailThreshold === 'number' && this.connectionHeartbeatFailThreshold > 0 ? this.connectionHeartbeatFailThreshold : 3; const shouldDisconnect = nextFailCount >= failThreshold; // 改为连续失败阈值后再置灰,避免偶发请求超时导致“假断连” if (shouldDisconnect) { this.isConnected = false; } if (wasConnected && shouldDisconnect) { this.connectionHeartbeatLastChangeAt = Date.now(); } const shouldLogFail = wasConnected || nextFailCount <= failThreshold || nextFailCount % 10 === 0; connectionDiag( shouldLogFail ? 'warn' : 'log', 'health-failed', { requestId, seq, elapsedMs: this.connectionHeartbeatLastLatencyMs, failCount: nextFailCount, status: responseStatus, endpoint: healthUrl, diagEnabled, wasConnected, nowConnected: !!this.isConnected, failThreshold, shouldDisconnect, errorName: errName, errorMessage: errMessage, timeout: errName === 'AbortError', visibility: document?.visibilityState || 'unknown', online: typeof navigator !== 'undefined' ? navigator.onLine : null, conversationId: this.currentConversationId || null, taskInProgress: !!this.taskInProgress, streamingMessage: !!this.streamingMessage, hasPendingTools: typeof this.hasPendingToolActions === 'function' ? this.hasPendingToolActions() : null }, { force: shouldLogFail } ); } finally { this.connectionHeartbeatInFlight = false; if (timeoutId) { clearTimeout(timeoutId); } } }, startConnectionHeartbeat() { if (this.connectionHeartbeatActive) { return; } this.connectionHeartbeatActive = true; this.connectionHeartbeatFailCount = 0; connectionDiag( 'log', 'heartbeat-start', { connectedIntervalMs: this.connectionHeartbeatIntervalMs, disconnectedIntervalMs: this.connectionHeartbeatDisconnectedIntervalMs }, { force: true } ); const runHeartbeat = async () => { if (!this.connectionHeartbeatActive) { return; } await this.checkConnectionHealth(); if (!this.connectionHeartbeatActive) { return; } const connectedInterval = typeof this.connectionHeartbeatIntervalMs === 'number' && this.connectionHeartbeatIntervalMs > 0 ? this.connectionHeartbeatIntervalMs : 8000; const disconnectedInterval = typeof this.connectionHeartbeatDisconnectedIntervalMs === 'number' && this.connectionHeartbeatDisconnectedIntervalMs > 0 ? this.connectionHeartbeatDisconnectedIntervalMs : 1000; const nextInterval = this.isConnected ? connectedInterval : disconnectedInterval; if ( isConnectionDiagEnabled() && (!this.isConnected || (this.connectionHeartbeatSeq || 0) <= 3) ) { connectionDiag('log', 'heartbeat-next', { seq: this.connectionHeartbeatSeq, isConnected: !!this.isConnected, failCount: this.connectionHeartbeatFailCount, nextInterval }); } this.connectionHeartbeatTimer = window.setTimeout(() => { runHeartbeat(); }, nextInterval); }; // 先做一次即时探活,再根据连接状态动态设置下次轮询间隔 runHeartbeat(); }, stopConnectionHeartbeat() { this.connectionHeartbeatActive = false; if (this.connectionHeartbeatTimer) { clearTimeout(this.connectionHeartbeatTimer); this.connectionHeartbeatTimer = null; } connectionDiag('log', 'heartbeat-stop', {}, { force: true }); }, openRealtimeTerminal() { const { protocol, hostname, port } = window.location; const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`; window.open(target, '_blank'); }, async loadInitialData() { try { debugLog('加载初始数据...'); const statusResponse = await fetch('/api/status'); if (!statusResponse.ok) { throw new Error(`状态接口请求失败: ${statusResponse.status}`); } const statusData = await statusResponse.json(); this.isConnected = true; this.socket = null; this.projectPath = statusData.project_path || ''; this.agentVersion = statusData.version || this.agentVersion; this.thinkingMode = !!statusData.thinking_mode; this.applyStatusSnapshot(statusData); this.fetchPermissionMode(); this.fetchExecutionMode(); this.fetchPendingToolApprovals(); // 立即更新配额和运行模式,避免等待其他慢接口 this.fetchUsageQuota(); const modelStore = useModelStore(); await modelStore.fetchModels().catch((err) => { console.warn('加载模型列表失败,使用前端默认列表:', err); }); if (statusData && typeof statusData.model_key === 'string') { modelStore.setModel(statusData.model_key); this.currentModelKey = modelStore.currentModelKey; } // 拉取管理员策略 const policyStore = usePolicyStore(); await policyStore.fetchPolicy(); this.applyPolicyUiLocks(); // 加载个性化设置 const personalizationStore = usePersonalizationStore(); if (!personalizationStore.loaded && !personalizationStore.loading) { await personalizationStore.fetchPersonalization().catch((err) => { console.warn('加载个性化设置失败:', err); }); } // 仅在“无当前对话”的新会话入口应用默认模型/模式; // 对于已存在对话(含刷新恢复/URL指定对话),必须保留对话自身设置。 const statusConversationIdForDefaults = statusData?.conversation?.current_id; const hasActiveConversation = typeof statusConversationIdForDefaults === 'string' && statusConversationIdForDefaults.length > 0 && !statusConversationIdForDefaults.startsWith('temp_'); if (personalizationStore.loaded && !hasActiveConversation && !this.currentConversationId) { const defaultRunMode = personalizationStore.form.default_run_mode; const defaultModel = personalizationStore.form.default_model; if (defaultRunMode) { this.runMode = defaultRunMode; debugLog('应用默认运行模式:', defaultRunMode); } if (defaultModel) { modelStore.setModel(defaultModel); this.currentModelKey = modelStore.currentModelKey; debugLog('应用默认模型:', defaultModel); } // 根据默认运行模式设置思考模式 if (defaultRunMode === 'thinking') { this.thinkingMode = true; } else if (defaultRunMode === 'fast') { this.thinkingMode = false; } } const focusPromise = this.focusFetchFiles(); let treePromise: Promise | null = null; const isHostMode = statusData?.container?.mode === 'host'; this.versioningHostMode = !!isHostMode; if (isHostMode) { this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); await this.fetchHostWorkspaces(); } else { this.hostWorkspaces = []; this.currentHostWorkspaceId = ''; this.defaultHostWorkspaceId = ''; this.hostWorkspaceCreateDialogOpen = false; this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreateLabel = ''; this.hostWorkspaceCreateError = ''; this.hostWorkspaceCreateSubmitting = false; treePromise = this.fileFetchTree(); } // 获取当前对话信息 const isExplicitNewRoute = this.isExplicitNewConversationRoute(); const runningConversationId = await this.getRunningTaskConversationId(); const statusConversationId = statusData.conversation && statusData.conversation.current_id; const resumeConversationId = statusConversationId || runningConversationId; const shouldResumeOnNewRoute = isExplicitNewRoute && !!runningConversationId; if ( resumeConversationId && !this.currentConversationId && (!isExplicitNewRoute || shouldResumeOnNewRoute) ) { this.skipConversationHistoryReload = true; // 首次从状态恢复对话时,避免 socket 的 conversation_loaded 再次触发历史加载 this.skipConversationLoadedEvent = true; this.suppressTitleTyping = true; this.titleReady = false; this.currentConversationTitle = ''; this.titleTypingText = ''; this.currentConversationId = resumeConversationId; const pathFragment = this.stripConversationPrefix(resumeConversationId); const currentPath = window.location.pathname.replace(/^\/+/, ''); if (currentPath !== pathFragment) { history.replaceState({ conversationId: resumeConversationId }, '', `/${pathFragment}`); } // 如果有当前对话,尝试获取标题和历史 try { const convResponse = await fetch(`/api/conversations/current`); const convData = await convResponse.json(); if (convData.success && convData.data) { this.currentConversationTitle = convData.data.title; this.titleReady = true; this.suppressTitleTyping = false; this.startTitleTyping(this.currentConversationTitle, { animate: false }); } else { this.titleReady = true; this.suppressTitleTyping = false; const fallbackTitle = this.currentConversationTitle || '新对话'; this.currentConversationTitle = fallbackTitle; this.startTitleTyping(fallbackTitle, { animate: false }); } // 初始化时调用一次,因为 skipConversationHistoryReload 会阻止 watch 触发 if ( this.lastHistoryLoadedConversationId !== this.currentConversationId || !Array.isArray(this.messages) || this.messages.length === 0 ) { await this.fetchAndDisplayHistory(); } // 获取当前对话的Token统计 this.fetchConversationTokenStatistics(); this.updateCurrentContextTokens(); await this.fetchVersioningStatus(this.currentConversationId, { silent: true }); } catch (e) { console.warn('获取当前对话标题失败:', e); this.titleReady = true; this.suppressTitleTyping = false; this.startTitleTyping(this.currentConversationTitle || '新对话', ''); } } // 待办数据依赖当前会话上下文,放在会话恢复/切换之后拉取,避免初始化早期拿到空快照 const todoPromise = this.fileFetchTodoList(); // 等待其他加载项完成(允许部分失败不阻塞模式切换) const pendingPromises = [focusPromise, todoPromise]; if (treePromise) { pendingPromises.push(treePromise); } await Promise.allSettled(pendingPromises); await this.loadToolSettings(true); // 加载对话列表(修复:初始化时未加载对话列表的bug) this.conversationsOffset = 0; await this.loadConversationsList(); debugLog('初始数据加载完成'); } catch (error) { console.error('加载初始数据失败:', error); this.isConnected = false; } }, confirmAction(options = {}) { return this.uiRequestConfirm(options); }, async handleCopyCodeClick(event) { const target = event.target; if (!target || !target.classList || !target.classList.contains('copy-code-btn')) { return; } const blockId = target.getAttribute('data-code'); if (!blockId) { return; } // 防止重复点击 if (target.classList.contains('copied')) { return; } const selector = `[data-code-id="${blockId.replace(/"/g, '\\"')}"]`; const codeEl = document.querySelector(selector); if (!codeEl) { return; } const encoded = codeEl.getAttribute('data-original-code'); const content = encoded ? this.decodeHtmlEntities(encoded) : codeEl.textContent || ''; if (!content.trim()) { return; } try { await navigator.clipboard.writeText(content); // 添加 copied 类,切换为对勾图标 target.classList.add('copied'); // 5秒后恢复 setTimeout(() => { target.classList.remove('copied'); }, 5000); } catch (error) { console.warn('复制失败:', error); } }, decodeHtmlEntities(text) { const textarea = document.createElement('textarea'); textarea.innerHTML = text; return textarea.value; } };