// chatbot-ui.js /** * 全局函数,强制聊天机器人界面弹出(或切换到)模型选择器。 * * 主要逻辑: * 1. 设置 `window.isModelSelectorOpen = true`。 * 2. 调用 `ChatbotUI.updateChatbotUI()` 刷新界面以显示模型选择器。 */ window.showModelSelectorForChatbot = function() { window.isModelSelectorOpen = true; if (typeof window.ChatbotUI === 'object' && typeof window.ChatbotUI.updateChatbotUI === 'function') { window.ChatbotUI.updateChatbotUI(); } }; // 全局状态变量 window.isChatbotPositionedLeft = localStorage.getItem('chatbotPosition') === 'left' || false; window.isPresetQuestionsCollapsed = false; // 预设问题默认展开 window.presetAutoCollapseTriggeredForDoc = {}; // 记录文档是否已触发自动收起 // 全屏和宽度管理状态 window.isChatbotFullscreen = localStorage.getItem('chatbotFullscreen') === 'true' || false; window.forceChatbotWidthReset = false; // 是否强制重置聊天窗口宽度 window.lastIsChunkCompareActive = undefined; // 上一次 Chunk Compare 标签页的激活状态 window.chatbotInitialLoad = true; // 是否为首次加载 // 浮动模式状态 window.isChatbotFloating = localStorage.getItem('chatbotFloating') === 'true' || false; window.chatbotFloatingPosition = JSON.parse(localStorage.getItem('chatbotFloatingPosition') || '{"x": 100, "y": 100}'); window.chatbotFloatingSize = JSON.parse(localStorage.getItem('chatbotFloatingSize') || '{"width": 420, "height": 580}'); // 高级聊天功能选项 window.chatbotActiveOptions = { useContext: true, // 是否使用上下文 useReActMode: false, // 是否启用ReAct框架(推理+工具调用) enableSemanticFeatures: true, // 是否启用意群和向量搜索功能(默认开启) multiHopRetrieval: false, // 是否启用多轮取材(先选片段再回答) contentLengthStrategy: 'default', // 内容长度策略: 'default', 'segmented' summarySource: 'ocr', // 总结来源: 'ocr', 'none', 'translation' interestPointsActive: false, // 兴趣点功能 (占位) memoryManagementActive: false // 记忆管理功能 (占位) }; /** * 处理暂停对话按钮的点击事件。 * * 主要逻辑: * 1. 调用中止控制器来停止正在进行的请求。 * 2. 更新UI状态。 */ function handleChatbotStop() { if (window.chatbotAbortController) { window.chatbotAbortController.abort(); console.log('[Chatbot] 用户中止了对话'); } } /** * 处理聊天机器人发送按钮的点击事件。 * * 主要逻辑: * 1. 获取输入框内容和已选图片。 * 2. 如果文本和图片均为空,则不发送。 * 3. 构造消息内容 (支持文本和图片混合)。 * 4. 若使用 PromptConstructor,则增强用户输入。 * 5. 清空输入框和已选图片预览。 * 6. 调用 `ChatbotCore.sendChatbotMessage` 发送消息。 */ function handleChatbotSend() { const input = document.getElementById('chatbot-input'); if (!input) return; let val = input.value.trim(); const selectedImages = window.ChatbotImageUtils.selectedChatbotImages || []; if (!val && selectedImages.length === 0) return; let messageContent = []; let displayMessageContent = []; // 用于UI显示,可能包含缩略图 if (val) { messageContent.push({ type: 'text', text: val }); displayMessageContent.push({ type: 'text', text: val }); } selectedImages.forEach(img => { if (img.fullBase64) { messageContent.push({ type: 'image_url', image_url: { url: img.fullBase64 } }); displayMessageContent.push({ type: 'image_url', image_url: { url: img.thumbnailBase64 || img.fullBase64, // 优先用缩略图显示 fullUrl: img.fullBase64, // 点击放大用原图 originalSrc: img.originalSrc } }); } }); // 兼容旧的单模态模型,如果只有一个文本部分,则直接发送文本字符串 let sendVal = messageContent.length === 1 && messageContent[0].type === 'text' ? messageContent[0].text : messageContent; let displayVal = displayMessageContent.length === 1 && displayMessageContent[0].type === 'text' ? displayMessageContent[0].text : displayMessageContent; if (window.PromptConstructor && typeof window.PromptConstructor.enhanceUserPrompt === 'function') { sendVal = window.PromptConstructor.enhanceUserPrompt(sendVal); } input.value = ''; window.ChatbotImageUtils.selectedChatbotImages = []; window.ChatbotImageUtils.updateSelectedImagesPreview(); window.ChatbotCore.sendChatbotMessage(sendVal, updateChatbotUI, null, displayVal); } /** * 处理预设问题的点击事件 (UI层面)。 * * 主要逻辑: * 1. 将预设问题填充到输入框。 * 2. 调用 `handleChatbotSend` 发送。 * * @param {string} q - 预设问题文本。 */ // handlePresetQuestion 已在 ChatbotPreset 中定义(包含 Mermaid 和其他 prompt 注入逻辑) // 不再在此重复定义,避免覆盖 /** * 更新聊天机器人界面的核心函数。 * * 主要逻辑: * 1. **显隐控制**:根据 `isChatbotOpen` 控制 modal 和 fab。 * 2. **宽度/全屏管理**:根据 `isChatbotFullscreen` 和 `forceChatbotWidthReset` 等状态调整窗口大小和样式。 * 3. **模型信息获取**:从 `ChatbotCore` 获取模型配置。 * 4. **模型选择器模式** (`isModelSelectorOpen`): * - 若为自定义模型且 `isModelSelectorOpen` 为 true,则调用 `ChatbotModelSelectorUI.render` 显示模型选择器。 * - 否则,确保模型选择器被移除,聊天区和预设问题区可见。 * 5. **预设问题区渲染**:调用 `ChatbotPresetQuestionsUI.render`。 * 6. **聊天消息渲染**:调用 `ChatbotMessageRenderer` 模块渲染历史消息和加载指示器。 * 7. **滚动行为**:智能滚动聊天区域,确保新消息可见或保持用户当前视口。 * 8. **Mermaid图渲染**:调用 `ChatbotRenderingUtils.renderAllMermaidBlocks`。 * 9. **输入框与发送按钮状态更新**:根据加载状态启用/禁用。 * 10. **免责声明与清空历史按钮更新**。 * 11. **浮动高级选项按钮更新**:调用 `_updateFloatingOptionsDisplay`。 */ function updateChatbotUI() { const modal = document.getElementById('chatbot-modal'); const fab = document.getElementById('chatbot-fab'); if (!modal || !fab) return; // 检测沉浸式模式 const inImmersive = !!(window.ImmersiveLayout && typeof window.ImmersiveLayout.isActive === 'function' && window.ImmersiveLayout.isActive()); // 在沉浸式模式下强制禁用浮动模式 if (inImmersive && window.isChatbotFloating) { window.isChatbotFloating = false; } const currentDocId = window.ChatbotCore && typeof window.ChatbotCore.getCurrentDocId === 'function' ? window.ChatbotCore.getCurrentDocId() : 'default_doc'; const fullscreenButton = document.getElementById('chatbot-fullscreen-toggle-btn'); // --- 宽度重置逻辑 --- if (window.chatbotInitialLoad) { window.forceChatbotWidthReset = true; window.chatbotInitialLoad = false; } const chatbotWindowForCheck = modal.querySelector('.chatbot-window'); const isOnHistoryDetailForCheck = window.location.pathname.includes('history_detail.html'); const chunkCompareTabElementForCheck = document.getElementById('tab-chunk-compare'); const currentChunkCompareActive = isOnHistoryDetailForCheck && chunkCompareTabElementForCheck && chunkCompareTabElementForCheck.classList.contains('active'); if (window.lastIsChunkCompareActive !== undefined && window.lastIsChunkCompareActive !== currentChunkCompareActive) { window.forceChatbotWidthReset = true; } // --- 聊天窗口显隐与样式调整 --- if (window.isChatbotOpen) { fab.style.display = 'none'; const chatbotWindow = modal.querySelector('.chatbot-window'); if (chatbotWindow) { if (window.isChatbotFullscreen) { // 全屏样式 modal.style.display = 'block'; modal.style.position = 'fixed'; modal.style.width = '100vw'; modal.style.height = '100vh'; modal.style.top = '0px'; modal.style.left = '0px'; modal.style.bottom = '0px'; modal.style.right = '0px'; modal.style.padding = '0px'; modal.style.margin = '0px'; modal.style.border = 'none'; modal.style.background = 'var(--chat-bg,#ffffff)'; modal.style.pointerEvents = 'auto'; modal.style.zIndex = '100000'; chatbotWindow.style.position = 'absolute'; chatbotWindow.style.width = '100%'; chatbotWindow.style.height = '100%'; chatbotWindow.style.minWidth = '100%'; chatbotWindow.style.minHeight = '100%'; chatbotWindow.style.maxWidth = '100%'; chatbotWindow.style.maxHeight = '100%'; chatbotWindow.style.top = '0px'; chatbotWindow.style.left = '0px'; chatbotWindow.style.right = '0px'; chatbotWindow.style.bottom = '0px'; chatbotWindow.style.borderRadius = '0px'; chatbotWindow.style.padding = '0px'; chatbotWindow.style.margin = '0px'; chatbotWindow.style.border = 'none'; chatbotWindow.style.boxSizing = 'border-box'; chatbotWindow.style.overflow = 'hidden'; chatbotWindow.style.resize = 'none'; if (fullscreenButton) { fullscreenButton.innerHTML = ``; fullscreenButton.title = "退出全屏"; } } else { // 非全屏样式 modal.style.display = 'flex'; modal.style.position = 'fixed'; modal.style.width = 'auto'; modal.style.height = 'auto'; modal.style.top = '0'; modal.style.left = '0'; modal.style.bottom = '0'; modal.style.right = '0'; modal.style.padding = '0px'; modal.style.margin = '0px'; modal.style.border = 'none'; modal.style.background = 'transparent'; modal.style.pointerEvents = 'none'; if (window.isChatbotFloating) { // 浮动模式 chatbotWindow.style.position = 'fixed'; chatbotWindow.style.left = window.chatbotFloatingPosition.x + 'px'; chatbotWindow.style.top = window.chatbotFloatingPosition.y + 'px'; chatbotWindow.style.right = 'auto'; chatbotWindow.style.bottom = 'auto'; chatbotWindow.style.width = window.chatbotFloatingSize.width + 'px'; chatbotWindow.style.height = window.chatbotFloatingSize.height + 'px'; chatbotWindow.style.maxWidth = '90vw'; chatbotWindow.style.maxHeight = '90vh'; chatbotWindow.style.minWidth = '320px'; chatbotWindow.style.minHeight = '400px'; // 样式由 CSS .chatbot-window.floating-mode 控制 chatbotWindow.style.boxShadow = ''; chatbotWindow.style.zIndex = '100001'; chatbotWindow.classList.add('floating-mode'); } else { // 固定位置模式 chatbotWindow.classList.remove('floating-mode'); chatbotWindow.style.position = 'absolute'; let newMaxWidth = '720px'; let newWidth = '92vw'; let newMinHeight = 'calc(520px * 1.1)'; let newMaxHeight = 'calc(85vh * 1.1)'; const isOnHistoryDetail = window.location.pathname.includes('history_detail.html'); const chunkCompareTabElement = document.getElementById('tab-chunk-compare'); const isChunkCompareActive = isOnHistoryDetail && chunkCompareTabElement && chunkCompareTabElement.classList.contains('active'); if (isChunkCompareActive) { newMinHeight = 'calc(520px * 1.25)'; newMaxHeight = '99vh'; newMaxWidth = 'calc(720px * 0.90)'; newWidth = 'calc(92vw * 0.90)'; } if (window.forceChatbotWidthReset || !chatbotWindow.style.width.endsWith('px')) { chatbotWindow.style.width = newWidth; } chatbotWindow.style.maxWidth = newMaxWidth; chatbotWindow.style.minWidth = `calc(${newWidth} * 0.32)`; chatbotWindow.style.minHeight = newMinHeight; chatbotWindow.style.maxHeight = newMaxHeight; if (window.forceChatbotWidthReset || !chatbotWindow.style.height.endsWith('px')) { chatbotWindow.style.height = ''; } if (window.isChatbotPositionedLeft) { chatbotWindow.style.left = '44px'; chatbotWindow.style.right = 'auto'; } else { chatbotWindow.style.right = '44px'; chatbotWindow.style.left = 'auto'; } chatbotWindow.style.top = 'auto'; chatbotWindow.style.bottom = '44px'; // 样式由 CSS .chatbot-window 控制 chatbotWindow.style.borderRadius = ''; chatbotWindow.style.boxShadow = ''; } chatbotWindow.style.padding = ''; chatbotWindow.style.margin = ''; chatbotWindow.style.border = ''; chatbotWindow.style.boxSizing = 'border-box'; chatbotWindow.style.overflow = 'auto'; chatbotWindow.style.resize = 'none'; // 禁用默认resize,使用自定义拖拽 if (fullscreenButton) { fullscreenButton.innerHTML = ``; fullscreenButton.title = "全屏模式"; } } } } else { modal.style.display = 'none'; // 在沉浸式模式下不显示 FAB,以免与沉浸布局冲突 fab.style.display = inImmersive ? 'none' : 'block'; } window.forceChatbotWidthReset = false; window.lastIsChunkCompareActive = currentChunkCompareActive; const posToggleBtn = document.getElementById('chatbot-position-toggle-btn'); if (posToggleBtn) { if (window.isChatbotFloating) { // 浮动模式下隐藏位置切换按钮 posToggleBtn.style.display = 'none'; } else { posToggleBtn.style.display = 'flex'; if (window.isChatbotPositionedLeft) { posToggleBtn.innerHTML = ``; posToggleBtn.title = "切换到右下角"; } else { posToggleBtn.innerHTML = ``; posToggleBtn.title = "切换到左下角"; } } } const floatToggleBtn = document.getElementById('chatbot-float-toggle-btn'); if (floatToggleBtn) { // 在沉浸式模式隐藏浮动切换按钮 if (inImmersive) { floatToggleBtn.style.display = 'none'; } else { floatToggleBtn.style.display = 'flex'; } if (window.isChatbotFloating) { floatToggleBtn.innerHTML = ``; floatToggleBtn.title = "固定模式"; } else { floatToggleBtn.innerHTML = ``; floatToggleBtn.title = "浮动模式"; } } const chatBody = document.getElementById('chatbot-body'); const chatbotPresetHeader = document.getElementById('chatbot-preset-header'); const chatbotPresetBody = document.getElementById('chatbot-preset-body'); let modelSelectorDiv = document.getElementById('chatbot-model-selector'); const existingPresetContainer = document.getElementById('chatbot-preset-container'); if (existingPresetContainer) existingPresetContainer.remove(); const chatbotWindow = modal.querySelector('.chatbot-window'); if (!chatbotWindow) { console.error("Chatbot UI: .chatbot-window not found for preset container."); return; } let isCustomModel = false; let availableModels = []; let currentSettings = {}; try { // 缓存配置,避免流式更新时频繁加载 // 只在模型选择器打开或缓存失效时重新获取 if (!window._cachedChatbotConfig || window.isModelSelectorOpen) { window._cachedChatbotConfig = window.ChatbotCore.getChatbotConfig(); } const config = window._cachedChatbotConfig; currentSettings = config.settings || {}; isCustomModel = config.model === 'custom' || (typeof config.model === 'string' && config.model.startsWith('custom_source_')); availableModels = config.siteSpecificAvailableModels || []; } catch (e) { console.error("Error getting chatbot config for UI:", e); } const presetContainer = window.ChatbotPresetQuestionsUI.render( chatbotWindow, isCustomModel, currentDocId, updateChatbotUI, window.ChatbotPreset?.handlePresetQuestion || window.handlePresetQuestion ); chatbotWindow.appendChild(presetContainer); let userMessageCount = 0; if (window.ChatbotCore && window.ChatbotCore.chatHistory) { userMessageCount = window.ChatbotCore.chatHistory.filter(m => m.role === 'user').length; } if (userMessageCount >= 3 && !window.presetAutoCollapseTriggeredForDoc[currentDocId] && !window.isPresetQuestionsCollapsed && !window.isModelSelectorOpen) { window.isPresetQuestionsCollapsed = true; window.presetAutoCollapseTriggeredForDoc[currentDocId] = true; const presetToggleBtn = presetContainer.querySelector('#chatbot-preset-toggle-btn'); if (presetToggleBtn) { presetToggleBtn.innerHTML = ''; presetToggleBtn.title = "展开快捷指令"; } } // 获取 mainContentArea 元素 const mainContentArea = document.getElementById('chatbot-main-content-area'); if (mainContentArea) { let current_padding_top_for_main_content_area = 12; if (presetContainer && presetContainer.style.display !== 'none' && presetContainer.offsetHeight) { current_padding_top_for_main_content_area = presetContainer.offsetHeight; } mainContentArea.style.paddingTop = current_padding_top_for_main_content_area + 'px'; // 仅在“固定模式”下根据内容自适应高度;浮动/全屏不改动用户设置的尺寸 if (!window.isChatbotFloating && !window.isChatbotFullscreen) { const titleBar = document.getElementById('chatbot-title-bar'); const inputContainer = document.getElementById('chatbot-input-container'); if (titleBar && inputContainer && chatbotWindow) { const h_title_bar = titleBar.offsetHeight; const h_input_container = inputContainer.offsetHeight; const h_chat_body_target = 250; const desired_window_height = h_title_bar + current_padding_top_for_main_content_area + h_chat_body_target + h_input_container; const min_win_h_px = parseFloat(getComputedStyle(chatbotWindow).minHeight) || 520; const max_win_h_px = parseFloat(getComputedStyle(chatbotWindow).maxHeight) || (0.85 * window.innerHeight); chatbotWindow.style.height = Math.max(min_win_h_px, Math.min(max_win_h_px, desired_window_height)) + 'px'; } } } if (isCustomModel && window.isModelSelectorOpen) { if (window.ChatbotModelSelectorUI && typeof window.ChatbotModelSelectorUI.render === 'function') { window.ChatbotModelSelectorUI.render(mainContentArea, chatBody, availableModels, currentSettings, updateChatbotUI); } else { console.error("ChatbotModelSelectorUI.render is not available."); } return; } else { const existingModelSelectorDiv = document.getElementById('chatbot-model-selector'); if (existingModelSelectorDiv) existingModelSelectorDiv.remove(); if (presetContainer) presetContainer.style.display = ''; if (chatBody) chatBody.style.display = ''; } if (chatBody) { const oldScrollTop = chatBody.scrollTop; const oldScrollHeight = chatBody.scrollHeight; const oldClientHeight = chatBody.clientHeight; // Phase 3.5 提前定义消息计数(用于后续滚动逻辑) // 使用统一的状态管理对象,避免全局变量污染 if (!window.ChatbotRenderState) { window.ChatbotRenderState = { lastRenderedMessageCount: 0 }; } const currentMessageCount = window.ChatbotCore.chatHistory.length; const lastRenderedCount = window.ChatbotRenderState.lastRenderedMessageCount || 0; let docName = 'unknown_doc'; let docId = 'unknown_doc'; // 完整的 docId,用于 draw.io 等功能 let dataForMindmap = { images: [], ocr: '', translation: '' }; if (window.ChatbotCore && typeof window.ChatbotCore.getCurrentDocContent === 'function') { const currentDoc = window.ChatbotCore.getCurrentDocContent(); if (currentDoc) { docName = currentDoc.name || 'unknown_doc'; dataForMindmap = { images: currentDoc.images || [], ocr: currentDoc.ocr || '', translation: currentDoc.translation || '' }; } } // 获取完整的 docId(包含文档名、图片数量、OCR长度、翻译长度) if (window.ChatbotCore && typeof window.ChatbotCore.getCurrentDocId === 'function') { docId = window.ChatbotCore.getCurrentDocId(); } if (window.ChatbotMessageRenderer) { // Phase 3.5 增量渲染: 只渲染变化的消息,避免重新渲染整个历史 // 如果是流式更新(消息数量没变),只更新最后一条消息 if (window.ChatbotCore.isChatbotLoading && currentMessageCount === lastRenderedCount && currentMessageCount > 0) { const lastMessage = window.ChatbotCore.chatHistory[currentMessageCount - 1]; const lastMessageContainer = chatBody.querySelector(`.assistant-message[data-message-index="${currentMessageCount - 1}"]`); // 检查是否需要完整渲染(reasoning 第一次出现) let needFullRender = false; if (lastMessage.reasoningContent && lastMessageContainer) { const reasoningBlockId = `reasoning-block-${currentMessageCount - 1}`; const reasoningBlock = lastMessageContainer.querySelector(`#${reasoningBlockId}`); if (!reasoningBlock) { needFullRender = true; // reasoning 块不存在,需要完整渲染 } } // 如果需要完整渲染,跳过增量更新 if (!needFullRender && lastMessageContainer && lastMessage.role === 'assistant') { // 0. 更新 ReAct 可视化 (reactLog) - 增量追加 if (lastMessage.reactLog && lastMessage.reactLog.length > 0) { const vizId = `react-viz-${currentMessageCount - 1}`; let vizContainer = lastMessageContainer.querySelector(`#${vizId}`); // 如果容器不存在,说明是第一次出现 ReAct 日志,需要完整渲染 if (!vizContainer) { needFullRender = true; } else { // 增量更新步骤 const stepsContainer = vizContainer.querySelector('.react-steps-container'); if (stepsContainer) { const currentStepsCount = stepsContainer.children.length; const newStepsCount = lastMessage.reactLog.length; if (newStepsCount > currentStepsCount) { // 追加新步骤 const stepsToAdd = lastMessage.reactLog.slice(currentStepsCount); let newStepsHtml = ''; stepsToAdd.forEach((step, i) => { let icon = ''; let title = ''; let typeClass = ''; let content = ''; const stepIndex = currentStepsCount + i + 1; if (step.type === 'thought') { icon = 'carbon:idea'; title = `Thought ${step.iteration || stepIndex}`; typeClass = 'step-thought'; content = step.content; } else if (step.type === 'action') { icon = 'carbon:tools'; title = `Action ${step.iteration || stepIndex}`; typeClass = 'step-action'; content = `Tool: ${step.tool}\nInput: ${JSON.stringify(step.params, null, 2)}`; } else if (step.type === 'observation') { icon = 'carbon:view'; title = `Observation ${step.iteration || stepIndex}`; typeClass = 'step-observation'; content = typeof step.result === 'string' ? step.result : JSON.stringify(step.result, null, 2); if (content.length > 500) content = content.slice(0, 500) + '... (truncated)'; } if (content) { content = window.ChatbotUtils.escapeHtml(content); // 添加 slideIn 动画 newStepsHtml += `
错误:消息渲染模块加载失败。
"; } setTimeout(() => { if (!window.ChatbotCore.isChatbotLoading) { chatBody.querySelectorAll('code.language-mermaid, pre code.language-mermaid').forEach(block => { block.setAttribute('data-mermaid-final', 'true'); }); } }, 0); // Phase 3.5 智能滚动:完整渲染时保持用户阅读位置 const isUserAtBottom = oldScrollHeight - oldClientHeight <= oldScrollTop + 50; // 增加容差到 50px const isNewMessageArrival = currentMessageCount > lastRenderedCount; // 检测是否有新消息到达 // 自动滚动到底部的条件: // 1. 有新消息到达(用户发送消息或助手开始回复) // 2. 或者用户已经停留在底部附近 if (isNewMessageArrival || isUserAtBottom) { chatBody.scrollTop = chatBody.scrollHeight; } // 否则保持用户当前的阅读位置(即使正在加载也不强制滚动) if (window.ChatbotRenderingUtils && typeof window.ChatbotRenderingUtils.renderAllMermaidBlocks === 'function') { if (window.mermaidLoaded && typeof window.mermaid !== 'undefined') { window.ChatbotRenderingUtils.renderAllMermaidBlocks(chatBody); } else { setTimeout(() => { if (window.ChatbotRenderingUtils && typeof window.ChatbotRenderingUtils.renderAllMermaidBlocks === 'function') { window.ChatbotRenderingUtils.renderAllMermaidBlocks(chatBody); } }, 600); setTimeout(() => { if (window.ChatbotRenderingUtils && typeof window.ChatbotRenderingUtils.renderAllMermaidBlocks === 'function') { window.ChatbotRenderingUtils.renderAllMermaidBlocks(chatBody); } }, 1200); } } else { console.warn('ChatbotUI: ChatbotRenderingUtils.renderAllMermaidBlocks is not available.'); } // Phase 4.1: 表格滚动性能优化 - 使用 IntersectionObserver 优先,回退到 requestAnimationFrame 节流 setupChatbotTableScrollHints(chatBody); } const input = document.getElementById('chatbot-input'); const sendBtn = document.getElementById('chatbot-send-btn'); const stopBtn = document.getElementById('chatbot-stop-btn'); if (input && sendBtn) { input.disabled = window.ChatbotCore.isChatbotLoading; sendBtn.disabled = window.ChatbotCore.isChatbotLoading; if (window.ChatbotCore.isChatbotLoading) { sendBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'flex'; } else { sendBtn.style.display = 'flex'; if (stopBtn) stopBtn.style.display = 'none'; } } const disclaimerDiv = document.querySelector('.chatbot-disclaimer'); if (disclaimerDiv) { const currentChatHistory = window.ChatbotCore && window.ChatbotCore.chatHistory ? window.ChatbotCore.chatHistory : []; if (currentChatHistory.length > 0) { disclaimerDiv.innerHTML = 'AI助手可能会犯错。请核实重要信息。丨删除对话记录'; const clearBtn = document.getElementById('chatbot-clear-history-btn'); if (clearBtn) { clearBtn.onclick = function() { if (confirm('确定要删除当前对话的所有记录吗?')) { if (window.ChatbotCore && typeof window.ChatbotCore.clearCurrentDocChatHistory === 'function') { const docIdToClear = window.ChatbotCore.getCurrentDocId ? window.ChatbotCore.getCurrentDocId() : 'default_doc'; window.ChatbotCore.clearCurrentDocChatHistory(updateChatbotUI); window.isPresetQuestionsCollapsed = false; if (window.presetAutoCollapseTriggeredForDoc) { delete window.presetAutoCollapseTriggeredForDoc[docIdToClear]; } } else { console.error("clearCurrentDocChatHistory function not found on ChatbotCore"); } } }; } } else { disclaimerDiv.innerHTML = 'AI助手可能会犯错。请核实重要信息。
'; } } // 将调用 _updateFloatingOptionsDisplay() 改为调用独立模块 if (window.ChatbotFloatingOptionsUI && typeof window.ChatbotFloatingOptionsUI.updateDisplay === 'function') { window.ChatbotFloatingOptionsUI.updateDisplay(); } } /** * Phase 4.1 - 表格滚动提示性能优化 * 目标:在保持现有视觉行为(滚动到最右侧隐藏渐变阴影)的前提下,减少滚动事件压力。 * * 策略: * 1. 优先使用 IntersectionObserver 观察表格中“最右侧单元格”是否完全进入视口。 * - root: table,自身作为滚动容器。 * - threshold: 1.0,仅当最后单元格完全可见时认为滚动到末尾。 * 2. 当浏览器不支持 IntersectionObserver 时,回退到 requestAnimationFrame 节流的 scroll 监听。 * 3. 每次调用都会清理旧的监听器和观察器,避免重复绑定和内存泄漏。 */ function setupChatbotTableScrollHints(chatBody) { if (!chatBody) return; const tables = chatBody.querySelectorAll('.markdown-content table'); tables.forEach(function(table) { // 清理旧的 IntersectionObserver(如有) if (table._scrollObserver && typeof table._scrollObserver.disconnect === 'function') { try { table._scrollObserver.disconnect(); } catch (e) { if (window.PerfLogger) { window.PerfLogger.warn('ChatbotUI: disconnect table._scrollObserver failed', e); } } } table._scrollObserver = null; // 清理旧的 scroll 监听器(如有) if (table._scrollListener) { table.removeEventListener('scroll', table._scrollListener); } table._scrollListener = null; // 如果浏览器支持 IntersectionObserver,则优先使用 if (window.IntersectionObserver) { let lastCell = table.querySelector('tr:last-child td:last-child'); if (!lastCell) { lastCell = table.querySelector('td:last-child, th:last-child'); } // 如果找不到可观察的单元格,则直接视为已滚动到底部(不显示渐变阴影) if (!lastCell) { table.classList.add('scrolled-to-end'); return; } const observer = new IntersectionObserver(function(entries) { entries.forEach(function(entry) { // 当最后一个单元格完全进入视口时,认为滚动到了最右端 const isEnd = entry.isIntersecting && entry.intersectionRatio >= 1; if (isEnd) { table.classList.add('scrolled-to-end'); } else { table.classList.remove('scrolled-to-end'); } }); }, { root: table, threshold: 1.0 }); observer.observe(lastCell); table._scrollObserver = observer; return; } // 回退方案:使用 requestAnimationFrame 节流 scroll 事件 let scrollRAF = null; const scrollListener = function() { if (scrollRAF) return; // 若浏览器不支持 requestAnimationFrame,则直接同步执行 if (typeof window.requestAnimationFrame !== 'function') { const isScrolledToEnd = table.scrollLeft >= (table.scrollWidth - table.clientWidth - 5); if (isScrolledToEnd) { table.classList.add('scrolled-to-end'); } else { table.classList.remove('scrolled-to-end'); } return; } scrollRAF = window.requestAnimationFrame(function() { const isScrolledToEnd = table.scrollLeft >= (table.scrollWidth - table.clientWidth - 5); if (isScrolledToEnd) { table.classList.add('scrolled-to-end'); } else { table.classList.remove('scrolled-to-end'); } scrollRAF = null; }); }; table._scrollListener = scrollListener; table.addEventListener('scroll', scrollListener); // 初始检查(表格首次渲染时) scrollListener(); }); } /** * Phase 4.2 - 简单增量追加判定(纯文本场景) * 仅在追加内容较为“安全”时启用增量渲染: * - newContent 以 oldContent 为前缀(纯追加,而非回退/编辑) * - 追加部分不包含明显的结构/公式标记(如 ```、$$、\[…\] 等) * 复杂 Markdown / LaTeX 场景仍回退到完整重渲染,避免破坏结构。 */ function isChatbotSafePlainAppend(oldContent, appendedText) { if (!appendedText) return false; // 边界检查 1:若旧内容末尾处位于未闭合的块级结构中(代码块/公式等),则不做增量。 // 这里采用“保守策略”:一旦存在疑似未闭合结构,就直接回退完整渲染,以换取更高的渲染正确性。 try { if (isChatbotInsideUnclosedBlock(oldContent)) { return false; } } catch (e) { // 检测异常时同样回退完整渲染 if (window.PerfLogger) { window.PerfLogger.warn('ChatbotUI: isChatbotInsideUnclosedBlock failed, fallback to full render.', e); } return false; } // 若追加部分包含明显的代码块/公式/复杂结构标记,则不做增量 const riskyPatterns = [ /```/, // 代码块 /\$\$/, // 块级公式 /\\\[/, // \[ ... \] /\\\(/, // \( ... \) /<\/?[a-zA-Z]/, // HTML 标签 /^#{1,6}\s/m, // 标题 /^\s{0,3}[-*+]\s/m, // 无序列表 /^\s{0,3}\d+\.\s/m, // 有序列表 /^\s{0,3}>\s/m // 引用 ]; for (let i = 0; i < riskyPatterns.length; i++) { if (riskyPatterns[i].test(appendedText)) { return false; } } return true; } /** * Phase 4.2.2 - Markdown 结构边界检测 * 检测 oldContent 末尾是否位于未闭合的 Markdown 块级结构中: * - ``` fenced code block * - $$ 块级公式 * - \[ \] / \( \) LaTeX 公式包裹 * * 为保证性能: * - 优先仅分析结尾一段文本(TAIL_WINDOW),减少在极长消息上的全量扫描开销; * - 对 fenced code / $$ 使用“奇偶计数”策略;对 \[ / \] 与 \( / \) 使用“数量差值”近似判断; * - 一旦存在“不确定”或“可能未闭合”的结构,则视为不安全,回退完整渲染。 */ function isChatbotInsideUnclosedBlock(oldContent) { if (!oldContent || typeof oldContent !== 'string') return false; // 限制分析窗口,避免在超长内容上多次全量扫描 const TAIL_WINDOW = 4000; const text = oldContent.length > TAIL_WINDOW ? oldContent.slice(-TAIL_WINDOW) : oldContent; // 辅助函数:统计匹配次数 function countMatches(pattern) { const re = new RegExp(pattern, 'g'); let count = 0; while (re.exec(text) !== null) { count++; } return count; } // 1) Fenced code block: ``` ... ``` // 使用出现次数的奇偶性近似判断是否在未闭合的代码块中。 const codeFenceCount = countMatches('```'); if (codeFenceCount % 2 === 1) { return true; } // 2) Block math: $$ ... $$ const blockMathCount = countMatches('\\$\\$'); if (blockMathCount % 2 === 1) { return true; } // 3) LaTeX-style delimiters: \[ ... \], \( ... \) const openBracket = countMatches('\\\\\\['); const closeBracket = countMatches('\\\\\\]'); if (openBracket > closeBracket) { return true; } const openParen = countMatches('\\\\\\('); const closeParen = countMatches('\\\\\\)'); if (openParen > closeParen) { return true; } return false; } /** * 初始化聊天机器人浮动按钮 (FAB) 和主弹窗 (Modal) 的 UI。 * * 主要步骤: * 1. **FAB 初始化**:创建 FAB,设置样式和点击事件(打开聊天弹窗)。 * 2. **Modal 初始化**:创建 Modal,包含头部、预设区、聊天内容区、输入区等。 * - 头部:标题、全屏/位置切换/关闭按钮。 * - 主内容区:承载预设区和聊天内容区。 * - 输入区:图片添加、文本输入、发送按钮、免责声明。 * - 注入基础 CSS (滚动条、响应式、暗黑模式等)。 * 3. **浮动高级选项初始化**:调用 `_createFloatingOptionsBar` 创建选项按钮并插入到输入区。 * 4. **事件绑定**:为全屏、位置切换、关闭按钮绑定事件。 * 5. **初始UI更新**:调用 `updateChatbotUI`。 */ function initChatbotUI() { // --- FAB (浮动操作按钮) 初始化 --- let fab = document.getElementById('chatbot-fab'); if (!fab) { fab = document.createElement('div'); fab.id = 'chatbot-fab'; // 设置 FAB 的固定定位、初始位置(根据 isChatbotPositionedLeft 决定左右)和层级 fab.style.position = 'fixed'; fab.style.bottom = '32px'; if (window.isChatbotPositionedLeft) { fab.style.left = '32px'; fab.style.right = 'auto'; } else { fab.style.right = '32px'; fab.style.left = 'auto'; } fab.style.zIndex = '99999'; // FAB 内部的按钮 HTML,使用响应式尺寸和CSS变量 fab.innerHTML = ` `; document.body.appendChild(fab); } // FAB 点击事件:打开聊天窗口,默认展开预设问题,并强制重置窗口宽度 fab.onclick = function() { window.isChatbotOpen = true; window.isPresetQuestionsCollapsed = false; window.forceChatbotWidthReset = true; updateChatbotUI(); }; // --- Modal (主聊天窗口) 初始化 --- let modal = document.getElementById('chatbot-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'chatbot-modal'; // Modal 作为聊天窗口的容器,初始隐藏,通过 flex 布局控制 chatbot-window 的居中(非全屏时) modal.style.position = 'fixed'; modal.style.inset = '0'; // 等同于 top:0, left:0, bottom:0, right:0 modal.style.zIndex = '100000'; modal.style.background = 'transparent'; // 背景透明,依赖内部 chatbot-window 的背景 modal.style.display = 'none'; // 初始隐藏 modal.style.pointerEvents = 'none'; // 自身不接收鼠标事件,允许穿透 // Modal 内部的 HTML 结构 modal.innerHTML = `AI助手可能会犯错。请核实重要信息。