Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/20atri_deepseek-chat.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
88 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "20atri/deepseek-chat", "files": {"script.js": {"0c81eaa12ee97d648b735e49c18114a6d8093a84": {"t": 1786068491.970419, "n": 73660, "h": "4dbb45ef1d6622ab", "c": "// --- 全局变量和常量 ---\r\nconst chatContainer = document.getElementById('chat-container');\r\nconst userInput = document.getElementById('user-input');\r\nconst sendButton = document.getElementById('send-button');\r\nconst saveButton = document.getElementById('save-button');\r\nconst loadInput = document.getElementById('load-input');\r\nconst clearButton = document.getElementById('clear-button');\r\nconst manualGenerateButton = document.getElementById('manual-generate-button'); // 【新增】\r\nconst scrollControlBtn = document.getElementById('scroll-control-btn'); // 新增:滚动控制按钮\r\nconst scrollPopover = document.getElementById('scroll-popover'); // 新增:滚动 Popover\r\nconst scrollToTopBtn = document.getElementById('scroll-to-top-btn'); // 新增:一键置顶\r\nconst scrollToBottomBtn = document.getElementById('scroll-to-bottom-btn'); // 新增:一键到底\r\nconst hideAllReasoningBtn = document.getElementById('hide-all-reasoning-btn'); // 新增:隐藏所有思维链\r\nconst showAllReasoningBtn = document.getElementById('show-all-reasoning-btn'); // 新增:展开所有思维链\r\nconst settingsButton = document.getElementById('settings-button');\r\nconst settingsModal = document.getElementById('settings-modal');\r\nconst closeModalButton = settingsModal.querySelector('.close-button');\r\n\r\n// 设置项元素获取 (包括新增的)\r\nconst apiKeyInput = document.getElementById('api-key');\r\n// 【修改】模型选择相关\r\nconst modelSelect = document.getElementById('model-select');\r\nconst customModelInput = document.getElementById('custom-model-input'); // 新增自定义模型输入框\r\n\r\n// 【新增】API URL 选择相关\r\nconst apiUrlSelect = document.getElementById('api-url-select');\r\nconst customUrlInput = document.getElementById('custom-url-input');\r\n\r\nconst temperatureSlider = document.getElementById('temperature');\r\nconst tempValueSpan = document.getElementById('temp-value');\r\nconst topPSlider = document.getElementById('top-p'); // 新增:Top P 滑块\r\nconst topPValueSpan = document.getElementById('top-p-value'); // 新增:Top P 显示值\r\nconst maxTokensInput = document.getElementById('max-tokens'); // 新增:Max Tokens 输入框\r\nconst stopSequencesInput = document.getElementById('stop-sequences'); // 新增:Stop Sequences 输入框\r\nconst frequencyPenaltySlider = document.getElementById('frequency-penalty');\r\nconst freqPenaltyValueSpan = document.getElementById('freq-penalty-value'); // ID 修正为 freq-penalty-value\r\nconst presencePenaltySlider = document.getElementById('presence-penalty'); // 新增:Presence Penalty 滑块\r\nconst presPenaltyValueSpan = document.getElementById('pres-penalty-value'); // 新增:Presence Penalty 显示值\r\nconst predefinedPromptSelect = document.getElementById('predefined-prompt-select');\r\nconst systemPromptInput = document.getElementById('system-prompt');\r\nconst reasoningDefaultVisibleCheckbox = document.getElementById('reasoning-default-visible');\r\nconst reasoningEffortSelect = document.getElementById('reasoning-effort'); // 新增:思考强度\r\nconst reasoningEffortCustomInput = document.getElementById('reasoning-effort-custom'); // 新增:自定义思考强度输入框\r\nconst thinkingModeCheckbox = document.getElementById('thinking-mode-enabled'); // 新增:思考模式开关\r\nconst thinkingModeLabel = document.getElementById('thinking-mode-label'); // 新增:思考模式开关文字\r\n\r\n// const DEEPSEEK_API_BASE_URL = 'https://api.deepseek.com'; // 【修改】移除常量,改为动态获取\r\n\r\n// --- 预设提示词数据 ---\r\nconst predefinedPrompts =[\r\n { name: \"默认助理 (通用)\", content: \"You are a helpful, respectful and honest assistant. Always answer as helpfully as possible, while being safe. Your answers should not include any harmful, unethical, racist, sexist, toxic, dangerous, or illegal content. Please ensure that your responses are socially unbiased and positive in nature.\\n\\nIf a question does not make any sense, or is not factually coherent, explain why instead of answering something not correct. If you don't know the answer to a question, please don't share false information.\" },\r\n { name: \"周报生成器\", content: \"你是一个周报生成助手,根据用户提供的工作内容,生成一份简洁、专业的周报。请注意使用书面语,突出重点,条理清晰。\" },\r\n // 在这里可以添加更多预设提示词对象\r\n // { name: \"提示词名称\", content: \"提示词内容...\" },\r\n];\r\nconst customPromptValue = \"custom\";\r\n\r\n// --- 状态变量 ---\r\nlet messages = []; // 对话历史核心数据\r\nlet currentAssistantMessageId = null; // 当前 AI 消息 ID\r\nlet currentAssistantMessageDiv = null; // 当前 AI 消息 DOM\r\nlet currentReasoningDiv = null; // 当前思维链 DOM\r\nlet currentContentDiv = null; // 当前内容 DOM\r\nlet currentAbortController = null; // 中断控制器\r\nlet currentTimerEl = null; // 当前思维链计时器 DOM 元素\nlet currentTimerStart = null; // 计时器开始时间戳\nlet currentTimerInterval = null; // 计时器刷新间隔 ID\nlet isUserScrolling = false; // 标记用户是否正在手动滚动\nlet scrollTimeout = null; // 用于检测滚动停止的计时器\r\n\r\n// --- 初始化 ---\r\ndocument.addEventListener('DOMContentLoaded', () => {\r\n console.log(\"文档加载完成,开始初始化...\");\r\n populatePredefinedPrompts();\r\n loadSettings();\r\n loadConversationFromLocalStorage();\r\n addEventListeners();\r\n // 初始化所有滑块的显示值\r\n updateSliderValue(temperatureSlider, tempValueSpan);\r\n updateSliderValue(topPSlider, topPValueSpan);\r\n updateSliderValue(frequencyPenaltySlider, freqPenaltyValueSpan);\r\n updateSliderValue(presencePenaltySlider, presPenaltyValueSpan);\r\n adjustTextareaHeight(userInput);\r\n handlePredefinedPromptChange(); // 根据加载设置更新提示词状态\r\n handleApiUrlChange(); // 【新增】初始化 URL 输入框状态\r\n handleModelChange(); // 【新增】初始化模型输入框状态\r\n console.log(\"初始化完成.\");\r\n});\r\n\r\n// --- 填充预设提示词 ---\r\nfunction populatePredefinedPrompts() {\r\n predefinedPrompts.forEach((prompt, index) => {\r\n const option = document.createElement('option');\r\n option.value = index.toString();\r\n option.textContent = prompt.name;\r\n predefinedPromptSelect.appendChild(option);\r\n });\r\n const customOption = document.createElement('option');\r\n customOption.value = customPromptValue;\r\n customOption.textContent = \"自定义...\";\r\n predefinedPromptSelect.appendChild(customOption);\r\n}\r\n\r\n// --- 事件监听器 ---\r\nfunction addEventListeners() {\r\n sendButton.addEventListener('click', handleSendMessage);\r\n\r\n // **【核心修改点】** 处理用户输入框的按键事件\r\n userInput.addEventListener('keydown', (e) => {\r\n // 当按下 Ctrl + Enter 时发送消息\r\n if (e.key === 'Enter' && e.ctrlKey) {\r\n e.preventDefault(); // 阻止可能的默认行为(如在某些表单中提交)\r\n handleSendMessage();\r\n }\r\n // 如果只是按下 Enter 键 (没有 Ctrl),则会执行 textarea 的默认行为,即换行\r\n // 不需要额外处理 Shift+Enter,它也会默认换行\r\n });\r\n\r\n userInput.addEventListener('input', () => adjustTextareaHeight(userInput));\r\n saveButton.addEventListener('click', saveConversationToFile);\r\n loadInput.addEventListener('change', loadConversationFromFile);\r\n clearButton.addEventListener('click', clearConversation);\r\n \r\n // 【新增】监听手动生成按钮\r\n if (manualGenerateButton) {\r\n manualGenerateButton.addEventListener('click', handleManualGenerate);\r\n }\r\n\r\n // 滚动控制\r\n scrollControlBtn.addEventListener('click', toggleScrollPopover);\r\n scrollToTopBtn.addEventListener('click', scrollToChatTop);\r\n scrollToBottomBtn.addEventListener('click', scrollToChatBottom);\r\n hideAllReasoningBtn.addEventListener('click', hideAllReasoning);\r\n showAllReasoningBtn.addEventListener('click', showAllReasoning);\r\n document.addEventListener('click', handleClickOutsidePopover);\r\n\r\n settingsButton.addEventListener('click', openSettingsModal);\r\n closeModalButton.addEventListener('click', closeSettingsModal);\r\n settingsModal.addEventListener('click', (event) => { if (event.target === settingsModal) closeSettingsModal(); });\r\n\r\n // 设置项变化监听 (包括新增的)\r\n apiKeyInput.addEventListener('change', saveSettings);\r\n \r\n // 【新增】URL 和模型变化监听\r\n apiUrlSelect.addEventListener('change', () => { handleApiUrlChange(); saveSettings(); });\r\n customUrlInput.addEventListener('change', saveSettings);\r\n modelSelect.addEventListener('change', () => { handleModelChange(); saveSettings(); });\r\n customModelInput.addEventListener('change', saveSettings);\r\n \r\n temperatureSlider.addEventListener('input', () => { updateSliderValue(temperatureSlider, tempValueSpan); saveSettings(); });\r\n topPSlider.addEventListener('input', () => { updateSliderValue(topPSlider, topPValueSpan); saveSettings(); }); // 新增\r\n maxTokensInput.addEventListener('change', saveSettings); // 新增 (用 change 而不是 input)\r\n stopSequencesInput.addEventListener('change', saveSettings); // 新增 (用 change)\r\n frequencyPenaltySlider.addEventListener('input', () => { updateSliderValue(frequencyPenaltySlider, freqPenaltyValueSpan); saveSettings(); });\r\n presencePenaltySlider.addEventListener('input', () => { updateSliderValue(presencePenaltySlider, presPenaltyValueSpan); saveSettings(); }); // 新增\r\n predefinedPromptSelect.addEventListener('change', handlePredefinedPromptChange);\r\n predefinedPromptSelect.addEventListener('change', saveSettings);\r\n systemPromptInput.addEventListener('change', saveSettings);\r\n reasoningDefaultVisibleCheckbox.addEventListener('change', saveSettings);\r\n reasoningEffortSelect.addEventListener('change', () => { handleReasoningEffortChange(); saveSettings(); }); // 新增:思考强度\r\n reasoningEffortCustomInput.addEventListener('change', saveSettings); // 新增:自定义思考强度\r\n thinkingModeCheckbox.addEventListener('change', () => { updateThinkingModeUI(); saveSettings(); }); // 新增:思考模式开关\r\n\r\n // 聊天容器滚动监听 (用于优化自动滚动)\r\n chatContainer.addEventListener('scroll', handleChatScroll);\r\n\r\n // 消息操作事件委托\r\n chatContainer.addEventListener('click', handleMessageActions);\r\n}\r\n\r\n// --- 界面逻辑处理函数 ---\r\n\r\n// 【新增】处理 URL 选择变化\r\nfunction handleApiUrlChange() {\r\n if (apiUrlSelect.value === 'custom') {\r\n customUrlInput.classList.remove('hidden');\r\n } else {\r\n customUrlInput.classList.add('hidden');\r\n }\r\n}\r\n\r\n// 【新增】处理模型选择变化\r\nfunction handleModelChange() {\r\n if (modelSelect.value === 'custom') {\r\n customModelInput.classList.remove('hidden');\r\n } else {\r\n customModelInput.classList.add('hidden');\r\n }\r\n}\r\n\r\n// 【新增】处理思考强度选择变化 (显示/隐藏自定义输入框)\r\nfunction handleReasoningEffortChange() {\r\n if (!reasoningEffortCustomInput) return;\r\n if (reasoningEffortSelect.value === 'custom') {\r\n reasoningEffortCustomInput.classList.remove('hidden');\r\n } else {\r\n reasoningEffortCustomInput.classList.add('hidden');\r\n }\r\n}\r\n\r\n// 【新增】更新思考模式开关的中英文显示\r\nfunction updateThinkingModeUI() {\r\n if (!thinkingModeCheckbox || !thinkingModeLabel) return;\r\n thinkingModeLabel.textContent = thinkingModeCheckbox.checked ? '启用 (Enabled)' : '禁用 (Disabled)';\r\n}\r\n\r\n// 【新增】判断思考模式是否开启\r\nfunction isThinkingEnabled() {\r\n return thinkingModeCheckbox ? thinkingModeCheckbox.checked : true;\r\n}\r\n\r\n// --- 处理预设提示词选择 ---\r\nfunction handlePredefinedPromptChange() {\r\n const selectedValue = predefinedPromptSelect.value;\r\n if (selectedValue === customPromptValue) {\r\n systemPromptInput.disabled = false;\r\n systemPromptInput.placeholder = \"在此输入自定义提示词...\";\r\n // 不自动聚焦,避免加载时弹窗跳出\r\n // systemPromptInput.focus();\r\n } else {\r\n const index = parseInt(selectedValue, 10);\r\n if (!isNaN(index) && index >= 0 && index < predefinedPrompts.length) {\r\n systemPromptInput.value = predefinedPrompts[index].content;\r\n systemPromptInput.disabled = true;\r\n systemPromptInput.placeholder = \"使用上方选择的模板\";\r\n adjustTextareaHeight(systemPromptInput); // 调整高度以适应新内容\r\n } else {\r\n // 处理无效索引或未选择的情况(例如页面刚加载时)\r\n console.warn(\"未选择或无效的预设索引:\", selectedValue);\r\n systemPromptInput.disabled = false; // 允许编辑\r\n systemPromptInput.value = ''; // 清空内容\r\n systemPromptInput.placeholder = \"选择模板或自定义...\";\r\n }\r\n }\r\n // 确保自定义选项被选中时,systemPromptInput 是启用的\r\n if (selectedValue === customPromptValue) {\r\n systemPromptInput.disabled = false;\r\n }\r\n}\r\n\r\n// --- 核心功能 ---\r\n\r\n/**\r\n * 处理发送消息\r\n */\r\nasync function handleSendMessage() {\r\n if (sendButton.disabled) {\r\n console.log(\"正在等待响应,请稍候...\");\r\n return;\r\n }\r\n const userText = userInput.value.trim();\r\n if (!userText) return;\r\n\r\n const apiKey = apiKeyInput.value.trim();\r\n if (!apiKey) {\r\n alert(\"请点击设置按钮 ⚙️ 输入 API Key。\");\r\n openSettingsModal(); // 自动打开设置\r\n return;\r\n }\r\n\r\n const userMessage = { id: generateUniqueId('user'), role: 'user', content: userText };\r\n messages.push(userMessage);\r\n appendMessageToUI(userMessage); // 显示用户消息\r\n saveConversationToLocalStorage(); // 保存包含用户消息的状态\r\n\r\n userInput.value = ''; // 清空输入框\r\n adjustTextareaHeight(userInput); // 调整输入框高度\r\n\r\n // 发送消息时,认为用户希望看到最新消息,强制滚动到底部\r\n forceScrollChatToBottom();\r\n\r\n toggleSendButton(false); // 禁用发送按钮\r\n\r\n try {\r\n await sendRequestToDeepSeekAPI();\r\n } catch (error) {\r\n console.error(\"发送或处理请求出错:\", error);\r\n if (error.name !== 'AbortError') {\r\n // 如果是网络错误或API错误,显示错误信息\r\n appendErrorMessageToUI(\"与 DeepSeek API 通信出错: \" + error.message);\r\n } else {\r\n // 如果是用户主动中止,则不显示错误信息\r\n console.log(\"请求被中止。\");\r\n // 检查是否需要清理流式输出的占位符\r\n const placeholderIndex = messages.findIndex(msg => msg.id === currentAssistantMessageId && msg.content === '...');\r\n if (placeholderIndex !== -1) {\r\n messages.splice(placeholderIndex, 1); // 从数据中移除\r\n const placeholderDiv = chatContainer.querySelector(`.message[data-message-id=\"${currentAssistantMessageId}\"]`);\r\n if (placeholderDiv) placeholderDiv.remove(); // 从 UI 移除\r\n saveConversationToLocalStorage(); // 更新本地存储\r\n }\r\n }\r\n }\r\n // finally 块将在 sendRequestToDeepSeekAPI 中处理按钮启用等\r\n}\r\n\r\n/**\r\n * 【新增】处理手动触发 AI 回复\r\n * 用于在修改历史记录后,手动请求 AI 基于当前上下文生成内容\r\n */\r\nasync function handleManualGenerate() {\r\n // 1. 检查是否正在生成中\r\n if (sendButton.disabled) {\r\n alert(\"AI 正在响应中,请稍候...\");\r\n return;\r\n }\r\n\r\n // 2. 检查是否有消息\r\n if (messages.length === 0) {\r\n alert(\"当前没有对话记录,无法生成回复。\");\r\n return;\r\n }\r\n\r\n // 3. 检查最后一条消息的角色\r\n // 只有当最后一条是 User (或 System) 时,AI 回复才符合逻辑\r\n const lastMessage = messages[messages.length - 1];\r\n if (lastMessage.role !== 'user' && lastMessage.role !== 'system') {\r\n alert(\"最后一条消息不是用户发送的。请先发送消息或删除末尾的 AI 消息后再试。\");\r\n return;\r\n }\r\n\r\n // 4. 二次确认 (防止误触)\r\n const confirmText = \"确定要基于当前的对话历史请求 AI 生成新回复吗?\";\r\n if (!confirm(confirmText)) {\r\n return;\r\n }\r\n\r\n const apiKey = apiKeyInput.value.trim();\r\n if (!apiKey) {\r\n alert(\"请点击设置按钮 ⚙️ 输入 API Key。\");\r\n openSettingsModal();\r\n return;\r\n }\r\n\r\n // 5. 触发 API 请求\r\n // 逻辑与 handleSendMessage 类似,但不需要处理输入框\r\n toggleSendButton(false); // 禁用按钮\r\n forceScrollChatToBottom(); // 滚动到底部\r\n\r\n try {\r\n // 直接调用现有的请求函数,它会读取全局 messages 数组\r\n // 并自动添加 assistant 的占位符\r\n await sendRequestToDeepSeekAPI(); \r\n } catch (error) {\r\n console.error(\"手动请求出错:\", error);\r\n if (error.name !== 'AbortError') {\r\n appendErrorMessageToUI(\"请求出错: \" + error.message);\r\n }\r\n }\r\n // finally 块会在 sendRequestToDeepSeekAPI 中处理\r\n}\r\n\r\n/**\r\n * 发送请求到 DeepSeek API (增加新参数)\r\n */\r\nasync function sendRequestToDeepSeekAPI() {\r\n const apiKey = apiKeyInput.value.trim();\r\n \r\n // 【修改】获取动态 URL\r\n let baseUrl = apiUrlSelect.value;\r\n if (baseUrl === 'custom') {\r\n baseUrl = customUrlInput.value.trim();\r\n // 确保自定义 URL 不为空\r\n if (!baseUrl) throw new Error(\"请在设置中输入自定义 API 地址\");\r\n }\r\n // 移除末尾的斜杠以防万一\r\n baseUrl = baseUrl.replace(/\\/$/, '');\r\n\r\n // 【修改】获取动态模型\r\n let model = modelSelect.value;\r\n if (model === 'custom') {\r\n model = customModelInput.value.trim();\r\n if (!model) throw new Error(\"请在设置中输入自定义模型名称\");\r\n }\r\n\r\n const temperature = parseFloat(temperatureSlider.value);\r\n const frequency_penalty = parseFloat(frequencyPenaltySlider.value);\r\n const systemPrompt = systemPromptInput.value.trim();\r\n // --- 新增参数获取 ---\r\n const presence_penalty = parseFloat(presencePenaltySlider.value);\r\n const top_p = parseFloat(topPSlider.value);\r\n const max_tokens_str = maxTokensInput.value.trim();\r\n const max_tokens = max_tokens_str ? parseInt(max_tokens_str, 10) : null; // 如果为空则为 null\r\n const stopSequencesStr = stopSequencesInput.value.trim();\r\n // 将逗号分隔的字符串转为数组,过滤空字符串\r\n const stop = stopSequencesStr ? stopSequencesStr.split(',').map(s => s.trim()).filter(s => s) : null;\r\n // --- 新增结束 ---\r\n\r\n // 构建发送消息列表 (过滤逻辑不变)\r\n let apiMessages = messages.map(({ role, content }) => ({ role, content }));\r\n if (systemPrompt) {\r\n if (apiMessages.length === 0 || apiMessages[0].role !== 'system') {\r\n apiMessages.unshift({ role: 'system', content: systemPrompt });\r\n } else { // 如果第一条已经是 system, 更新它\r\n apiMessages[0].content = systemPrompt;\r\n }\r\n }\r\n // 过滤掉连续的同角色消息(除了 system)\r\n apiMessages = apiMessages.reduce((acc, current, index, arr) => {\r\n if (index > 0 && current.role === arr[index - 1].role && current.role !== 'system') {\r\n // 如果当前消息和前一个消息角色相同(非system),用当前消息覆盖前一个\r\n // 这通常不应该发生,因为用户和助手是交替的,但作为健壮性检查\r\n console.warn(\"过滤掉连续的同角色消息:\", arr[index - 1], current);\r\n acc[acc.length - 1] = current; // 替换数组中最后一个元素\r\n } else {\r\n acc.push(current); // 否则,正常添加\r\n }\r\n return acc;\r\n }, []);\r\n\r\n // 添加 AI 占位符到 messages 数组\r\n currentAssistantMessageId = generateUniqueId('assistant');\r\n const assistantMessagePlaceholder = { id: currentAssistantMessageId, role: 'assistant', content: '...', reasoning_content: '' };\r\n messages.push(assistantMessagePlaceholder);\r\n saveConversationToLocalStorage(); // 保存包含占位符的状态\r\n\r\n // 显示占位符 UI\r\n appendMessageToUI(assistantMessagePlaceholder, true);\r\n\r\n currentAbortController = new AbortController();\r\n const signal = currentAbortController.signal;\r\n\r\n // 获取思考强度 (支持自定义)\r\n let reasoningEffort = reasoningEffortSelect.value;\r\n if (reasoningEffort === 'custom') {\r\n reasoningEffort = reasoningEffortCustomInput.value.trim();\r\n }\r\n\r\n // --- 构建请求体 (包含新参数) ---\r\n const requestBody = {\r\n model: model, // 使用动态模型变量\r\n messages: apiMessages,\r\n temperature: temperature,\r\n top_p: top_p, // 新增\r\n max_tokens: max_tokens, // 新增 (API 接受 null)\r\n stop: stop, // 新增 (API 接受 null 或数组)\r\n frequency_penalty: frequency_penalty,\r\n presence_penalty: presence_penalty, // 新增\r\n stream: true\r\n };\r\n // 仅当 reasoning_effort 有值时添加(仅对 reasoner 模型有效)\r\n if (reasoningEffort) {\r\n requestBody.reasoning_effort = reasoningEffort;\r\n }\r\n // 思考模式开关: 发送 {\"thinking\": {\"type\": \"enabled/disabled\"}}\r\n requestBody.thinking = { type: thinkingModeCheckbox.checked ? 'enabled' : 'disabled' };\r\n // 移除值为 null 或无效值的参数,或与默认值相同的参数(可选,但更规范)\r\n if (requestBody.max_tokens === null || isNaN(requestBody.max_tokens) || requestBody.max_tokens <= 0) {\r\n delete requestBody.max_tokens;\r\n }\r\n if (requestBody.stop === null || requestBody.stop.length === 0) {\r\n delete requestBody.stop;\r\n }\r\n // API 通常有默认值,如果参数值等于默认值,可以不传以减小请求体积\r\n // if (requestBody.top_p === 1.0) delete requestBody.top_p;\r\n // if (requestBody.temperature === 默认值) delete requestBody.temperature;\r\n // ...等等\r\n // --- 请求体构建结束 ---\r\n\r\n try {\r\n console.log(\"发送请求:\", JSON.stringify(requestBody, null, 2), \"URL:\", baseUrl); // 打印请求体(调试用)\r\n // 【修改】使用动态获取的 baseUrl\r\n const response = await fetch(`${baseUrl}/chat/completions`, {\r\n method: 'POST',\r\n headers: {\r\n 'Content-Type': 'application/json',\r\n 'Authorization': `Bearer ${apiKey}`\r\n },\r\n body: JSON.stringify(requestBody), // 使用构建好的请求体\r\n signal: signal // 传递中止信号\r\n });\r\n\r\n if (!response.ok) {\r\n const errorData = await response.json().catch(() => ({})); // 尝试解析错误信息\r\n const detail = errorData.error?.message || errorData.message || JSON.stringify(errorData);\r\n throw new Error(`API 请求失败: ${response.status} ${response.statusText}. ${detail}`);\r\n }\r\n\r\n await processStream(response.body); // 处理流式响应\r\n\r\n } catch (error) {\r\n if (error.name === 'AbortError') {\r\n console.log(\"请求被用户中止。\");\r\n // 确保移除占位符或部分消息\r\n const msgIndex = messages.findIndex(m => m.id === currentAssistantMessageId);\r\n if (msgIndex !== -1) {\r\n messages.splice(msgIndex, 1); // 从数据中移除\r\n const msgDiv = chatContainer.querySelector(`.message[data-message-id=\"${currentAssistantMessageId}\"]`);\r\n if (msgDiv) msgDiv.remove(); // 从 UI 中移除\r\n }\r\n } else {\r\n console.error(\"API 请求或流处理中出错:\", error);\r\n // 尝试更新 UI 显示错误信息\r\n if (currentContentDiv && document.body.contains(currentContentDiv)) {\r\n currentContentDiv.textContent = `请求出错: ${error.message}`;\r\n currentContentDiv.style.color = 'red';\r\n // 将错误信息也保存到 messages 数组\r\n const msgIndex = messages.findIndex(m => m.id === currentAssistantMessageId);\r\n if (msgIndex !== -1) {\r\n messages[msgIndex].content = `请求出错: ${error.message}`;\r\n messages[msgIndex].role = 'error'; // 可以加个角色标记\r\n }\r\n } else {\r\n appendErrorMessageToUI(\"请求出错: \" + error.message);\r\n }\r\n }\r\n throw error; // 重新抛出错误,让上层调用者知道出错了\r\n } finally {\r\n // 无论成功失败,最后都清理状态\r\n toggleSendButton(true); // 重新启用发送按钮\r\n currentAssistantMessageId = null;\r\n currentAssistantMessageDiv = null;\r\n currentReasoningDiv = null;\r\n currentContentDiv = null;\r\n currentAbortController = null; // 清理中止控制器\r\n currentTimerEl = null; // 清理计时器引用\n currentTimerStart = null;\n if (currentTimerInterval) { clearInterval(currentTimerInterval); currentTimerInterval = null; }\n conditionalScrollChatToBottom(); // 尝试滚动到底部\n saveConversationToLocalStorage(); // 保存最终状态\r\n console.log(\"sendRequestToDeepSeekAPI finally block executed.\");\r\n }\r\n}\r\n\r\n/**\r\n * 处理 API 返回的流式数据\r\n */\r\nasync function processStream(stream) {\r\n const reader = stream.getReader();\r\n const decoder = new TextDecoder(\"utf-8\");\r\n let buffer = \"\";\r\n let accumulatedContent = \"\";\r\n let accumulatedReasoning = \"\";\r\n let initialChunkReceived = false; // 标记是否收到第一个有效块\r\n let reasoningTimerStarted = false; // 标记计时器是否已启动\r\n let lastUsage = null; // 最近收到的 usage 统计 (部分 API 会在流结束时携带)\r\n const localMessageId = currentAssistantMessageId; // 捕获当前 ID\r\n console.log(\"开始处理流 for ID:\", localMessageId);\r\n\r\n // 查找对应的消息在数组中的索引\r\n const messageIndex = messages.findIndex(msg => msg.id === localMessageId);\r\n if (messageIndex === -1) {\r\n console.error(\"无法在 messages 数组中找到流对应的消息, ID:\", localMessageId);\r\n // 也许应该停止处理?或者记录错误?\r\n return; // 提前退出以防后续错误\r\n }\r\n\r\n while (true) {\r\n // 检查是否已中止\r\n if (currentAbortController?.signal.aborted) {\r\n console.log(\"流处理中止。\");\r\n break;\r\n }\r\n\r\n const { done, value } = await reader.read();\r\n if (done) {\r\n console.log(\"流读取完成 for ID:\", localMessageId);\r\n break; // 读取完毕,退出循环\r\n }\r\n\r\n buffer += decoder.decode(value, { stream: true }); // 解码并追加到缓冲区\r\n const lines = buffer.split('\\n\\n'); // 按 SSE 的分隔符分割\r\n buffer = lines.pop() || \"\"; // 保留最后不完整的行到缓冲区\r\n\r\n for (const line of lines) {\r\n if (line.startsWith(\"data: \")) {\r\n const dataJson = line.substring(6).trim();\r\n if (dataJson === \"[DONE]\") {\r\n console.log(\"[DONE] 信号收到 for ID:\", localMessageId);\r\n // 通常不需要在这里 break,等 reader.read() 返回 done\r\n continue;\r\n }\r\n\r\n try {\r\n const chunk = JSON.parse(dataJson);\r\n // 捕获 usage (部分 API 会在流结束时携带)\r\n if (chunk.usage) {\r\n lastUsage = chunk.usage;\r\n }\r\n if (chunk.choices && chunk.choices.length > 0) {\r\n const delta = chunk.choices[0].delta;\r\n\r\n // 首次收到有效内容时,清空占位符 '...'\n if (!initialChunkReceived && (delta.reasoning_content || delta.content)) {\n initialChunkReceived = true;\n if (currentContentDiv && document.body.contains(currentContentDiv) && currentContentDiv.textContent === '...') {\n currentContentDiv.textContent = ''; // 清空占位符\n }\n // 启动生成计时器 (不受思考模式开关影响, 关闭思考模式时气泡也正常显示)\n if (!reasoningTimerStarted) {\n reasoningTimerStarted = true;\n startReasoningTimer();\n }\n }\n\n // 更新思维链内容\n if (delta.reasoning_content) {\n accumulatedReasoning += delta.reasoning_content;\n if (messages[messageIndex]) { // 再次检查索引有效性\r\n messages[messageIndex].reasoning_content = accumulatedReasoning;\r\n }\r\n // 更新 UI (确保元素存在)\r\n if (currentReasoningDiv && document.body.contains(currentReasoningDiv)) {\r\n currentReasoningDiv.textContent = accumulatedReasoning; // 直接更新文本\r\n // 确保切换按钮可见 (思考模式关闭时不显示)\r\n const toggle = currentAssistantMessageDiv?.querySelector('.reasoning-toggle');\r\n if (toggle && document.body.contains(toggle)) {\r\n if (isThinkingEnabled() && toggle.style.display === 'none') toggle.style.display = 'block';\r\n // 如果思考模式开启且默认可见,则添加 visible 类\r\n if (isThinkingEnabled() && !currentReasoningDiv.classList.contains('visible') && reasoningDefaultVisibleCheckbox.checked) {\r\n currentReasoningDiv.classList.add('visible');\r\n }\r\n }\r\n }\r\n }\r\n\r\n // 更新主要内容\n if (delta.content) {\n // 有思维链时, 正文开始即停止计时器 (记录思维链用时); 无思维链时计时器持续到流结束 (记录生成用时)\n if (reasoningTimerStarted && accumulatedReasoning) {\n stopReasoningTimer();\n reasoningTimerStarted = false;\n }\n accumulatedContent += delta.content;\r\n if (messages[messageIndex]) { // 再次检查索引有效性\r\n messages[messageIndex].content = accumulatedContent;\r\n }\r\n // 更新 UI (确保元素存在)\r\n if (currentContentDiv && document.body.contains(currentContentDiv)) {\r\n // **【修改点】** 流式更新时,依然使用 textContent 提供即时反馈\r\n // Markdown 渲染将在流结束后进行\r\n currentContentDiv.textContent = accumulatedContent;\r\n }\r\n }\r\n\r\n }\n // --- 修改点:调用条件滚动 ---\r\n conditionalScrollChatToBottom(); // 每次收到数据块都尝试滚动\r\n } catch (e) {\r\n console.error(\"解析流 JSON 失败:\", e, \"数据:\", dataJson);\r\n }\r\n }\r\n }\r\n } // end while loop\r\n\r\n // --- 流结束后 ---\r\n // 兜底:确保计时器已停止\r\n if (reasoningTimerStarted) {\r\n stopReasoningTimer();\r\n reasoningTimerStarted = false;\r\n }\r\n console.log(\"流结束,最终更新 UI for ID:\", localMessageId);\r\n // **【修改点】** 在流结束后,使用 marked.js 渲染最终的 Markdown 内容\r\n if (currentContentDiv && document.body.contains(currentContentDiv)) {\r\n renderMarkdown(currentContentDiv, accumulatedContent); // 调用 Markdown 渲染函数\r\n }\r\n // 最终处理 reasoning 显隐\r\n if (currentReasoningDiv && document.body.contains(currentReasoningDiv)) {\r\n currentReasoningDiv.textContent = accumulatedReasoning; // 确保最终文本正确\r\n const toggle = currentAssistantMessageDiv?.querySelector('.reasoning-toggle');\r\n if (toggle && document.body.contains(toggle)) {\r\n // 如果最终没有 reasoning 内容,隐藏它和切换按钮\r\n if (!accumulatedReasoning) {\r\n currentReasoningDiv.classList.remove('visible');\r\n currentReasoningDiv.style.display = 'none';\r\n toggle.style.display = 'none';\r\n } else {\r\n // 否则确保它们可见 (思考模式关闭时仍隐藏切换按钮)\r\n currentReasoningDiv.style.display = ''; // 或者 'block' 如果需要\r\n toggle.style.display = isThinkingEnabled() ? 'block' : 'none';\r\n // 如果思考模式开启且默认可见,添加 visible 类\r\n if (isThinkingEnabled() && reasoningDefaultVisibleCheckbox.checked && !currentReasoningDiv.classList.contains('visible')) {\r\n currentReasoningDiv.classList.add('visible');\r\n }\r\n }\r\n }\r\n }\r\n // 最终 Token 统计\n if (messages[messageIndex]) {\n let finalUsage;\n if (lastUsage && lastUsage.completion_tokens) {\r\n // 如果 API 提供了 usage, 以其总数为准, 按估算比例拆分 thinking/content\r\n const estReasoning = estimateTokens(accumulatedReasoning);\r\n const estContent = estimateTokens(accumulatedContent);\r\n const estTotal = estReasoning + estContent;\r\n const apiTotal = lastUsage.completion_tokens;\r\n let finalReasoning = estTotal > 0 ? Math.round(apiTotal * estReasoning / estTotal) : 0;\r\n let finalContent = apiTotal - finalReasoning;\r\n if (finalReasoning < 0) finalReasoning = 0;\r\n if (finalContent < 0) finalContent = 0;\r\n finalUsage = { reasoning_tokens: finalReasoning, content_tokens: finalContent, total_tokens: apiTotal };\r\n } else {\r\n const estReasoning = estimateTokens(accumulatedReasoning);\r\n const estContent = estimateTokens(accumulatedContent);\r\n finalUsage = { reasoning_tokens: estReasoning, content_tokens: estContent, total_tokens: estReasoning + estContent };\r\n }\r\n // 流式期间气泡处于加载状态, 结束时从 0 平滑过渡到 API 真实值\n const prevUsage = messages[messageIndex].usage;\n messages[messageIndex].usage = finalUsage;\n const finalUsageEl = currentAssistantMessageDiv?.querySelector('.token-usage');\n if (finalUsageEl && document.body.contains(finalUsageEl)) {\n animateTokenUsage(finalUsageEl, prevUsage, finalUsage);\n finalUsageEl.classList.remove('active');\n }\n }\r\n // 确保保存包含完整内容的对话记录\r\n saveConversationToLocalStorage();\r\n}\r\n\r\n\r\n// --- UI 更新 ---\r\n\r\n/**\r\n * 将单条消息添加到聊天界面\r\n */\r\nfunction appendMessageToUI(message, isStreaming = false) {\r\n try {\r\n if (!chatContainer || !document.body.contains(chatContainer)) {\r\n console.error(\"无法附加消息:聊天容器不存在。\");\r\n return;\r\n }\r\n const messageDiv = document.createElement('div');\r\n messageDiv.className = `message ${message.role || 'unknown'}`; // 添加默认角色以防万一\r\n messageDiv.dataset.messageId = message.id;\r\n\r\n const avatar = document.createElement('img');\r\n avatar.className = 'avatar';\r\n avatar.src = message.role === 'user' ? 'user-avatar.png' : 'bot-avatar.png';\r\n avatar.alt = message.role || 'avatar';\r\n // 简单的 SVG 占位符作为备用\r\n avatar.onerror = () => avatar.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNjY2MiPjwvcmVjdD48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMjBweCIgZmlsbD0iI2ZmZiI+PzwvdGV4dD48L3N2Zz4=';\r\n\r\n const contentWrapper = document.createElement('div');\r\n contentWrapper.className = 'message-content-wrapper';\r\n\r\n let reasoningDiv = null, reasoningToggle = null;\r\n\r\n // 只为 assistant 消息创建 reasoning 相关元素\r\n if (message.role === 'assistant') {\r\n const thinkingEnabled = isThinkingEnabled();\r\n reasoningToggle = document.createElement('span');\r\n reasoningToggle.className = 'reasoning-toggle';\r\n reasoningToggle.textContent = '显示/隐藏 思维链';\r\n reasoningToggle.dataset.targetId = `reasoning-${message.id}`;\r\n // 初始时,如果思考模式开启且正在流式传输或已有内容,则显示按钮\r\n reasoningToggle.style.display = (thinkingEnabled && (message.reasoning_content || isStreaming)) ? 'block' : 'none';\r\n contentWrapper.appendChild(reasoningToggle);\r\n\r\n // 思维链计时器 — 放在切换按钮下方\r\n const timerEl = document.createElement('span');\r\n timerEl.className = 'reasoning-timer';\r\n timerEl.textContent = '0.0s';\r\n timerEl.id = `timer-${message.id}`;\r\n contentWrapper.appendChild(timerEl);\n if (isStreaming) currentTimerEl = timerEl;\n\n // Token 消耗显示 — 放在思维链计时器(回复时间气泡栏)旁边, 可点击展开/折叠\n const tokenUsageEl = document.createElement('span');\n tokenUsageEl.className = 'token-usage';\n tokenUsageEl.id = `token-usage-${message.id}`;\n tokenUsageEl.title = '点击展开 Token 详情';\n tokenUsageEl.style.cursor = 'pointer';\n if (isStreaming) {\n renderTokenLoading(tokenUsageEl);\n tokenUsageEl.classList.add('active');\n } else if (message.usage) {\n renderTokenUsage(tokenUsageEl, message.usage);\n } else {\n tokenUsageEl.style.display = 'none';\n }\n tokenUsageEl.addEventListener('click', () => toggleTokenUsage(tokenUsageEl));\n contentWrapper.appendChild(tokenUsageEl);\n\n reasoningDiv = document.createElement('div');\n reasoningDiv.className = 'reasoning-content';\r\n reasoningDiv.id = `reasoning-${message.id}`;\r\n reasoningDiv.textContent = message.reasoning_content || ''; // 设置现有内容\r\n // 如果思考模式开启、设置了默认可见,并且有内容或正在流式传输,则添加 'visible' 类\r\n if ((thinkingEnabled && reasoningDefaultVisibleCheckbox.checked && (message.reasoning_content || isStreaming))) {\r\n reasoningDiv.classList.add('visible');\r\n }\r\n // 如果不是流式传输且没有内容,则彻底隐藏\r\n if (!isStreaming && !message.reasoning_content) {\r\n reasoningDiv.style.display = 'none';\r\n }\r\n contentWrapper.appendChild(reasoningDiv);\r\n }\r\n\r\n const messageBubble = document.createElement('div');\r\n messageBubble.className = 'message-bubble';\r\n\r\n const actualContentDiv = document.createElement('div');\r\n actualContentDiv.className = 'actual-content';\r\n\r\n if (isStreaming && message.role === 'assistant') {\r\n // 如果是流式传输的助手消息,设置全局引用并显示初始文本 ('...')\r\n currentAssistantMessageDiv = messageDiv;\r\n currentReasoningDiv = reasoningDiv; // 即使开始为空也要设置\r\n currentContentDiv = actualContentDiv;\r\n actualContentDiv.textContent = message.content; // 初始是 '...'\r\n } else if (message.role === 'assistant') {\r\n // 如果是已完成的助手消息,渲染 Markdown\r\n renderMarkdown(actualContentDiv, message.content);\r\n } else {\r\n // 如果是用户消息或其他,直接设置文本内容\r\n actualContentDiv.textContent = message.content;\r\n }\r\n\r\n messageBubble.appendChild(actualContentDiv);\n contentWrapper.appendChild(messageBubble);\n\n // 添加操作按钮\n const actionsDiv = createMessageActions(message.id, message.role);\r\n contentWrapper.appendChild(actionsDiv);\r\n\r\n messageDiv.append(avatar, contentWrapper);\r\n chatContainer.appendChild(messageDiv);\r\n\r\n // --- 修改点:添加新消息时,判断是否滚动到底部 ---\r\n conditionalScrollChatToBottom(); // 每次添加消息都检查滚动\r\n\r\n } catch (error) {\r\n console.error(\"添加到 UI 时出错:\", error, \"消息:\", message);\r\n appendErrorMessageToUI(\"显示消息时出错: \" + error.message);\r\n }\r\n}\r\n\r\n/**\r\n * 创建消息的操作按钮区域\r\n */\r\nfunction createMessageActions(messageId, role) {\r\n const actionsDiv = document.createElement('div');\r\n actionsDiv.className = 'message-actions';\r\n\r\n // 编辑按钮 (所有消息都有)\r\n const editBtn = document.createElement('i');\r\n editBtn.className = 'fas fa-pencil-alt action-icon edit-btn';\r\n editBtn.title = '编辑';\r\n editBtn.dataset.action = 'edit';\r\n actionsDiv.appendChild(editBtn);\r\n\r\n // 删除按钮 (所有消息都有)\r\n const deleteBtn = document.createElement('i');\r\n deleteBtn.className = 'fas fa-trash-alt action-icon delete-btn';\r\n deleteBtn.title = '删除';\r\n deleteBtn.dataset.action = 'delete';\r\n actionsDiv.appendChild(deleteBtn);\r\n\r\n // 重新生成按钮 (仅 assistant 消息有)\r\n if (role === 'assistant') {\r\n const regenerateBtn = document.createElement('i');\r\n regenerateBtn.className = 'fas fa-sync-alt action-icon regenerate-btn';\r\n regenerateBtn.title = '重新生成';\r\n regenerateBtn.dataset.action = 'regenerate';\r\n actionsDiv.appendChild(regenerateBtn);\r\n }\r\n return actionsDiv;\r\n}\r\n\r\n\r\n/**\r\n * 将错误信息添加到聊天界面\r\n */\r\nfunction appendErrorMessageToUI(errorText) {\r\n try {\r\n const errorDiv = document.createElement('div');\r\n errorDiv.className = 'message error'; // 使用 message 类,但添加 error 子类\r\n // 增加更明显的错误样式\r\n errorDiv.style.cssText = `\r\n color: #dc3545; /* 红色文字 */\r\n background-color: #f8d7da; /* 淡红色背景 */\r\n border: 1px solid #f5c6cb; /* 红色边框 */\r\n border-radius: 8px;\r\n padding: 10px 15px;\r\n margin-top: 10px;\r\n align-self: center; /* 居中显示 */\r\n max-width: 80%;\r\n word-wrap: break-word;\r\n `;\r\n errorDiv.textContent = `错误:${errorText}`; // 添加“错误:”前缀\r\n\r\n if (chatContainer && document.body.contains(chatContainer)) {\r\n chatContainer.appendChild(errorDiv);\r\n conditionalScrollChatToBottom(); // 尝试滚动到底部\r\n } else {\r\n console.error(\"无法添加错误消息 UI:聊天容器不存在。错误信息:\", errorText);\r\n }\r\n } catch(uiError) {\r\n console.error(\"在 appendErrorMessageToUI 中发生错误:\", uiError);\r\n }\r\n}\r\n\r\n\r\n/**\r\n * 条件滚动:仅当用户视口接近底部时才滚动\r\n */\r\nfunction conditionalScrollChatToBottom() {\r\n if (!chatContainer) return;\r\n // 定义一个阈值,比如比容器高度少 50 像素,判断是否接近底部\r\n const threshold = 50;\r\n const isNearBottom = chatContainer.scrollHeight - chatContainer.clientHeight <= chatContainer.scrollTop + threshold;\r\n\r\n // 如果用户没有正在手动滚动,并且当前视口接近底部,则滚动到底部\r\n if (!isUserScrolling && isNearBottom) {\r\n // 使用平滑滚动以获得更好的体验\r\n chatContainer.scrollTo({ top: chatContainer.scrollHeight, behavior: 'smooth' });\r\n // console.log(\"Conditional scroll executed.\");\r\n } else {\r\n // console.log(`Scroll skipped: isUserScrolling=${isUserScrolling}, isNearBottom=${isNearBottom}`);\r\n }\r\n}\r\n\r\n/**\r\n * 强制滚动到底部 (用于发送消息等明确需要滚动到底部的场景)\r\n */\r\n function forceScrollChatToBottom() {\r\n if (chatContainer) {\r\n // 强制滚动,不管用户当前在哪\r\n chatContainer.scrollTo({ top: chatContainer.scrollHeight, behavior: 'smooth' });\r\n console.log(\"Forced scroll executed.\");\r\n }\r\n }\r\n\r\n\r\n/**\r\n * 切换滚动 Popover 显隐\r\n */\r\nfunction toggleScrollPopover(e) {\r\n e.stopPropagation();\r\n const isHidden = scrollPopover.classList.contains('hidden');\r\n if (isHidden) {\r\n scrollPopover.classList.remove('hidden');\r\n scrollControlBtn.classList.add('active');\r\n } else {\r\n scrollPopover.classList.add('hidden');\r\n scrollControlBtn.classList.remove('active');\r\n }\r\n}\r\n\r\n/**\r\n * 点击 Popover 外部时关闭\r\n */\r\nfunction handleClickOutsidePopover(e) {\r\n if (!scrollPopover.classList.contains('hidden') &&\r\n !scrollPopover.contains(e.target) &&\r\n e.target !== scrollControlBtn &&\r\n !scrollControlBtn.contains(e.target)) {\r\n scrollPopover.classList.add('hidden');\r\n scrollControlBtn.classList.remove('active');\r\n }\r\n}\r\n\r\n/**\r\n * 滚动到聊天顶部\r\n */\r\nfunction scrollToChatTop() {\r\n scrollPopover.classList.add('hidden');\r\n scrollControlBtn.classList.remove('active');\r\n if (chatContainer) {\r\n chatContainer.scrollTo({ top: 0, behavior: 'smooth' });\r\n }\r\n}\r\n\r\n/**\r\n * 滚动到聊天底部\r\n */\r\nfunction scrollToChatBottom() {\r\n scrollPopover.classList.add('hidden');\r\n scrollControlBtn.classList.remove('active');\r\n if (chatContainer) {\r\n chatContainer.scrollTo({ top: chatContainer.scrollHeight, behavior: 'smooth' });\r\n }\r\n}\r\n\r\n/**\r\n * 一键隐藏所有思维链\r\n */\r\nfunction hideAllReasoning() {\r\n scrollPopover.classList.add('hidden');\r\n scrollControlBtn.classList.remove('active');\r\n chatContainer.querySelectorAll('.reasoning-content').forEach(el => el.classList.remove('visible'));\r\n}\r\n\r\n/**\r\n * 一键展开所有思维链\r\n */\r\nfunction showAllReasoning() {\r\n scrollPopover.classList.add('hidden');\r\n scrollControlBtn.classList.remove('active');\r\n chatContainer.querySelectorAll('.reasoning-content').forEach(el => el.classList.add('visible'));\r\n}\r\n\r\n/**\r\n * 处理聊天容器滚动事件,标记用户是否在滚动\r\n */\r\nfunction handleChatScroll() {\r\n if (!chatContainer) return;\r\n // 用户开始滚动时设置标记\r\n isUserScrolling = true;\r\n // 清除之前的计时器(如果存在)\r\n clearTimeout(scrollTimeout);\r\n // 设置一个新的计时器,一段时间(例如 150ms)后认为用户停止了滚动\r\n scrollTimeout = setTimeout(() => {\r\n isUserScrolling = false;\r\n // console.log(\"User scrolling stopped.\");\r\n }, 150); // 150ms 的延迟,可以调整\r\n}\r\n\r\n\r\n/**\r\n * 更新滑块旁边显示的数值\r\n */\r\nfunction updateSliderValue(slider, valueSpan) {\r\n if(slider && valueSpan) {\r\n valueSpan.textContent = slider.value;\r\n }\r\n}\r\n\r\n/**\r\n * 启动思维链计时器\r\n */\r\nfunction startReasoningTimer() {\r\n if (!currentTimerEl) return;\r\n currentTimerStart = performance.now();\r\n currentTimerEl.classList.add('active');\r\n currentTimerEl.classList.remove('done');\r\n\r\n currentTimerInterval = setInterval(() => {\r\n if (!currentTimerEl || !document.body.contains(currentTimerEl)) {\r\n clearInterval(currentTimerInterval);\r\n currentTimerInterval = null;\r\n return;\r\n }\r\n const elapsed = (performance.now() - currentTimerStart) / 1000;\r\n currentTimerEl.textContent = elapsed.toFixed(1) + 's';\r\n }, 100);\r\n}\r\n\r\n/**\r\n * 停止思维链计时器,保留最终时间\r\n */\r\nfunction stopReasoningTimer() {\r\n if (currentTimerInterval) {\r\n clearInterval(currentTimerInterval);\r\n currentTimerInterval = null;\r\n }\r\n if (currentTimerEl && document.body.contains(currentTimerEl)) {\r\n if (currentTimerStart) {\r\n const elapsed = (performance.now() - currentTimerStart) / 1000;\r\n currentTimerEl.textContent = elapsed.toFixed(1) + 's';\r\n }\r\n currentTimerEl.classList.remove('active');\r\n currentTimerEl.classList.add('done');\r\n }\r\n currentTimerStart = null;\r\n}\r\n\r\n// --- Token 统计工具 ---\n\r\n/**\r\n * 估算一段文本对应的 token 数量 (中文字符约 1 token/字, 其他文本约 4 字符/token)\r\n */\r\nfunction estimateTokens(text) {\r\n if (!text) return 0;\r\n const cjkCount = (text.match(/[\\u2E80-\\u9FFF\\uF900-\\uFAFF\\uFF00-\\uFFEF\\u3000-\\u303F]/g) || []).length;\r\n const otherCount = text.length - cjkCount;\r\n return Math.ceil(cjkCount + otherCount / 4);\r\n}\r\n\r\n/**\r\n * 将 token 数量格式化为 K 单位\r\n */\r\nfunction formatTokenCount(tokens) {\n // 不足 1000 时直接显示实际数值,达到 1000 后再使用 k 缩写\n if (tokens < 1000) return String(tokens);\n return (tokens / 1000).toFixed(1) + 'k';\n}\n\r\n/**\n * 确保 token 气泡的 DOM 结构存在, 返回各子元素引用\n */\nfunction ensureTokenChildren(el) {\n let totalEl = el.querySelector('.tk-total');\n if (!totalEl) {\n totalEl = document.createElement('span');\n totalEl.className = 'tk-total';\n el.appendChild(totalEl);\n }\n let detailEl = el.querySelector('.tk-detail');\n if (!detailEl) {\n detailEl = document.createElement('span');\n detailEl.className = 'tk-detail';\n el.appendChild(detailEl);\n }\n let thinkingEl = detailEl.querySelector('.tk-thinking');\n if (!thinkingEl) {\n thinkingEl = document.createElement('span');\n thinkingEl.className = 'tk-thinking';\n detailEl.appendChild(thinkingEl);\n }\n let contentEl = detailEl.querySelector('.tk-content');\n if (!contentEl) {\n contentEl = document.createElement('span');\n contentEl.className = 'tk-content';\n detailEl.appendChild(contentEl);\n }\n return { totalEl, thinkingEl, contentEl };\n}\n\n/**\n * 渲染 Token 消耗气泡 (默认折叠只显示总消耗, 点击展开后显示思考消耗/正文消耗明细)\n * 不足 1000 显示实际数值, 达到 1000 使用 k 缩写\n */\nfunction renderTokenUsage(el, usage) {\n if (!el) return;\n const reasoning = usage && usage.reasoning_tokens !== undefined ? usage.reasoning_tokens : 0;\n const content = usage && usage.content_tokens !== undefined ? usage.content_tokens : 0;\n const total = usage && usage.total_tokens !== undefined ? usage.total_tokens : (reasoning + content);\n const { totalEl, thinkingEl, contentEl } = ensureTokenChildren(el);\n totalEl.textContent = `${formatTokenCount(total)} tokens`;\n thinkingEl.textContent = `思考消耗:${formatTokenCount(reasoning)} tokens`;\n contentEl.textContent = `正文消耗:${formatTokenCount(content)} tokens`;\n}\n\n/**\n * 渲染 Token 气泡的加载状态 (API 未返回真实 token 数据前显示)\n */\nfunction renderTokenLoading(el) {\n if (!el) return;\n const { totalEl, thinkingEl, contentEl } = ensureTokenChildren(el);\n totalEl.textContent = '… tokens';\n thinkingEl.textContent = '思考消耗:… tokens';\n contentEl.textContent = '正文消耗:… tokens';\n}\n\n/**\n * 展开/折叠 Token 消耗气泡\n */\nfunction toggleTokenUsage(el) {\n if (!el) return;\n el.classList.toggle('expanded');\n el.title = el.classList.contains('expanded') ? '点击收起 Token 详情' : '点击展开 Token 详情';\n}\n\n/**\n * 流结束后, 将 token 数值从流式估算值平滑过渡到 API 真实值, 避免数字倒转跳变\n */\nfunction animateTokenUsage(el, fromUsage, toUsage, duration = 500) {\n if (!el || !toUsage) return;\n const getU = (u, key) => (u && u[key] !== undefined) ? u[key] : 0;\n const from = {\n reasoning: getU(fromUsage, 'reasoning_tokens'),\n content: getU(fromUsage, 'content_tokens'),\n total: getU(fromUsage, 'total_tokens') || (getU(fromUsage, 'reasoning_tokens') + getU(fromUsage, 'content_tokens'))\n };\n const to = {\n reasoning: getU(toUsage, 'reasoning_tokens'),\n content: getU(toUsage, 'content_tokens'),\n total: getU(toUsage, 'total_tokens') || (getU(toUsage, 'reasoning_tokens') + getU(toUsage, 'content_tokens'))\n };\n const start = performance.now();\n function frame(now) {\n const t = Math.min(1, (now - start) / duration);\n const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic\n const cur = {\n reasoning_tokens: Math.round(from.reasoning + (to.reasoning - from.reasoning) * ease),\n content_tokens: Math.round(from.content + (to.content - from.content) * ease),\n total_tokens: Math.round(from.total + (to.total - from.total) * ease)\n };\n renderTokenUsage(el, cur);\n if (t < 1) {\n requestAnimationFrame(frame);\n } else {\n renderTokenUsage(el, toUsage);\n }\n }\n requestAnimationFrame(frame);\n}\n\r\n/**\r\n * 切换发送按钮的启用/禁用状态和样式\r\n */\r\nfunction toggleSendButton(enabled) {\r\n if (sendButton) {\r\n sendButton.disabled = !enabled;\r\n sendButton.style.cursor = enabled ? 'pointer' : 'not-allowed';\r\n sendButton.style.opacity = enabled ? '1' : '0.6';\r\n // 可选:更改图标或背景色以示区别\r\n // const icon = sendButton.querySelector('i');\r\n // if (icon) icon.className = enabled ? 'fas fa-paper-plane' : 'fas fa-spinner fa-spin'; // 示例:用加载图标\r\n }\r\n\r\n // 【新增】同步控制手动生成按钮的状态\r\n if (manualGenerateButton) {\r\n manualGenerateButton.disabled = !enabled;\r\n manualGenerateButton.style.cursor = enabled ? 'pointer' : 'not-allowed';\r\n manualGenerateButton.style.opacity = enabled ? '1' : '0.6';\r\n }\r\n}\r\n\r\n/**\r\n * 动态调整文本输入框的高度\r\n */\r\nfunction adjustTextareaHeight(textarea) {\r\n if(textarea) {\r\n textarea.style.height = 'auto'; // 重置高度以便获取 scrollHeight\r\n const scrollHeight = textarea.scrollHeight;\r\n // 获取 CSS 中定义的最大高度,如果没有则默认为 150px\r\n const maxHeightStyle = window.getComputedStyle(textarea).maxHeight;\r\n const maxHeight = parseInt(maxHeightStyle, 10) || 150;\r\n\r\n // 设置高度,但不超过最大高度\r\n textarea.style.height = Math.min(scrollHeight, maxHeight) + 'px';\r\n\r\n // 如果内容超过最大高度,显示滚动条,否则隐藏\r\n textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';\r\n }\r\n}\r\n\r\n/**\r\n * **【修改点】** 使用 marked.js 渲染 Markdown 文本到指定元素\r\n * @param {HTMLElement} element 要渲染内容的 HTML 元素\r\n * @param {string} markdownText Markdown 格式的文本\r\n */\r\nfunction renderMarkdown(element, markdownText) {\r\n if (element) {\r\n if (typeof marked === 'undefined') {\r\n console.error('Marked.js library is not loaded!');\r\n // 作为后备,显示原始文本\r\n element.textContent = markdownText || '';\r\n return;\r\n }\r\n try {\r\n // **核心:使用 marked.parse() 转换 Markdown 为 HTML**\r\n const htmlContent = marked.parse(markdownText || ''); // 处理 null 或 undefined 输入\r\n // **核心:使用 innerHTML 将转换后的 HTML 放入元素**\r\n element.innerHTML = htmlContent;\r\n } catch (error) {\r\n console.error(\"Markdown 渲染失败:\", error);\r\n // 渲染失败时,显示原始文本并提示错误\r\n element.textContent = `--- Markdown渲染出错 ---\\n${markdownText || ''}`;\r\n }\r\n } else {\r\n console.warn(\"尝试渲染 Markdown 但目标元素无效。\");\r\n }\r\n}\r\n\r\n\r\n// --- 消息操作处理 ---\r\n\r\n/**\r\n * 处理消息操作按钮点击 (编辑、删除、重新生成、切换思维链)\r\n */\r\nfunction handleMessageActions(event) {\r\n const target = event.target;\r\n\r\n // 切换思维链可见性\r\n if (target.classList.contains('reasoning-toggle')) {\r\n const targetId = target.dataset.targetId;\r\n const reasoningDiv = document.getElementById(targetId);\r\n if (reasoningDiv) {\r\n reasoningDiv.classList.toggle('visible');\r\n // 可以选择保存这个状态到 messages 数组或本地存储,以便刷新后保持\r\n }\r\n return; // 处理完毕,不再继续\r\n }\r\n\r\n // 检查是否是操作图标\r\n if (!target.classList.contains('action-icon')) return;\r\n\r\n // 获取相关元素和 ID\r\n const contentWrapper = target.closest('.message-content-wrapper');\r\n if (!contentWrapper) return;\r\n const messageDiv = contentWrapper.closest('.message');\r\n if (!messageDiv) return;\r\n const messageId = messageDiv.dataset.messageId;\r\n const action = target.dataset.action;\r\n\r\n console.log(`操作: ${action}, 消息 ID: ${messageId}`);\r\n\r\n // 根据操作类型调用相应处理函数\r\n switch (action) {\r\n case 'edit':\r\n handleEdit(messageId, messageDiv, contentWrapper);\r\n break;\r\n case 'delete':\r\n handleDelete(messageId, messageDiv);\r\n break;\r\n case 'regenerate':\r\n handleRegenerate(messageId);\r\n break;\r\n default:\r\n console.warn(\"未知的消息操作:\", action);\r\n }\r\n}\r\n\r\n\r\n/**\r\n * 处理编辑消息\r\n * **【修改点】** 保存和取消时调用 renderMarkdown\r\n */\r\nfunction handleEdit(messageId, messageDiv, contentWrapper) {\r\n const messageIndex = messages.findIndex(msg => msg.id === messageId);\r\n if (messageIndex === -1) { console.error(\"未找到要编辑的消息, ID:\", messageId); return; }\r\n\r\n const message = messages[messageIndex];\r\n const messageBubble = contentWrapper.querySelector('.message-bubble');\r\n const contentDiv = messageBubble?.querySelector('.actual-content');\r\n const actionsDiv = contentWrapper.querySelector('.message-actions');\r\n\r\n if (!messageBubble || !contentDiv || messageBubble.classList.contains('editing')) return; // 防止重复编辑\r\n\r\n if (actionsDiv) actionsDiv.style.display = 'none'; // 隐藏操作按钮\r\n\r\n const originalContent = message.content; // 保存原始 Markdown 文本\r\n messageBubble.classList.add('editing'); // 添加编辑状态类\r\n\r\n // 创建编辑用的 textarea\r\n const editTextArea = document.createElement('textarea');\r\n editTextArea.className = 'edit-textarea';\r\n editTextArea.value = originalContent; // 填充原始内容\r\n editTextArea.addEventListener('input', () => adjustTextareaHeight(editTextArea)); // 动态调整高度\r\n\r\n // 创建编辑操作按钮(保存、取消)\r\n const editActionsDiv = document.createElement('div');\r\n editActionsDiv.className = 'edit-actions';\r\n\r\n const saveBtn = document.createElement('button');\r\n saveBtn.textContent = '保存';\r\n saveBtn.className = 'save-edit-btn';\r\n saveBtn.onclick = () => {\r\n const newContent = editTextArea.value.trim(); // 获取新内容\r\n if (newContent && newContent !== originalContent) {\r\n messages[messageIndex].content = newContent; // 更新数据模型\r\n renderMarkdown(contentDiv, newContent); // **修改点:** 使用 renderMarkdown 显示新内容\r\n saveConversationToLocalStorage(); // 保存更改\r\n console.log(\"编辑已保存, ID:\", messageId);\r\n } else {\r\n // 如果内容为空或未改变,恢复原始内容\r\n renderMarkdown(contentDiv, originalContent); // **修改点:** 恢复时也用 renderMarkdown\r\n console.log(\"编辑取消或无变化, ID:\", messageId);\r\n }\r\n // 清理编辑 UI\r\n if (messageBubble.contains(editTextArea)) messageBubble.removeChild(editTextArea);\r\n if (messageBubble.contains(editActionsDiv)) messageBubble.removeChild(editActionsDiv);\r\n if (!messageBubble.contains(contentDiv)) messageBubble.appendChild(contentDiv); // 确保内容区域存在\r\n messageBubble.classList.remove('editing'); // 移除编辑状态\r\n if (actionsDiv) actionsDiv.style.display = ''; // 恢复操作按钮\r\n };\r\n\r\n const cancelBtn = document.createElement('button');\r\n cancelBtn.textContent = '取消';\r\n cancelBtn.className = 'cancel-edit-btn';\r\n cancelBtn.onclick = () => {\r\n // 取消编辑,恢复原始内容\r\n renderMarkdown(contentDiv, originalContent); // **修改点:** 恢复时也用 renderMarkdown\r\n // 清理编辑 UI\r\n if (messageBubble.contains(editTextArea)) messageBubble.removeChild(editTextArea);\r\n if (messageBubble.contains(editActionsDiv)) messageBubble.removeChild(editActionsDiv);\r\n if (!messageBubble.contains(contentDiv)) messageBubble.appendChild(contentDiv);\r\n messageBubble.classList.remove('editing');\r\n if (actionsDiv) actionsDiv.style.display = '';\r\n console.log(\"编辑已取消, ID:\", messageId);\r\n };\r\n\r\n editActionsDiv.append(saveBtn, cancelBtn); // 添加按钮到操作区\r\n\r\n // 替换内容显示为编辑区域\r\n if (messageBubble.contains(contentDiv)) messageBubble.removeChild(contentDiv);\r\n messageBubble.append(editTextArea, editActionsDiv);\r\n\r\n // 初始调整高度并聚焦\r\n adjustTextareaHeight(editTextArea);\r\n editTextArea.focus();\r\n editTextArea.select(); // 选中所有文本,方便修改\r\n}\r\n\r\n\r\n/**\r\n * 处理删除单条消息\r\n */\r\nfunction handleDelete(messageId, messageDiv) {\r\n // 确保元素存在于 DOM 中\r\n if (!document.body.contains(messageDiv)) {\r\n console.warn(\"尝试删除的消息不在 DOM 中, ID:\", messageId);\r\n return;\r\n }\r\n\r\n if (confirm(\"确定删除这条消息?\")) {\r\n const messageIndex = messages.findIndex(msg => msg.id === messageId);\r\n if (messageIndex !== -1) {\r\n messages.splice(messageIndex, 1); // 从数据模型中删除\r\n saveConversationToLocalStorage(); // 保存更改\r\n console.log(\"消息数据已删除, ID:\", messageId);\r\n } else {\r\n console.warn(\"未在数据模型中找到要删除的消息, ID:\", messageId);\r\n }\r\n\r\n // 从 UI 中平滑移除 (可选动画)\r\n messageDiv.style.opacity = '0';\r\n messageDiv.style.transition = 'opacity 0.3s ease-out';\r\n setTimeout(() => {\r\n if (messageDiv.parentNode) {\r\n messageDiv.remove();\r\n console.log(\"消息 UI 已删除, ID:\", messageId);\r\n }\r\n }, 300); // 等待动画完成\r\n\r\n } else {\r\n console.log(\"删除操作已取消, ID:\", messageId);\r\n }\r\n}\r\n\r\n\r\n/**\r\n * 处理重新生成 AI 消息 (包含删除后续对话逻辑)\r\n */\r\nasync function handleRegenerate(messageId) {\r\n // 如果当前正在请求,则阻止\r\n if (sendButton.disabled) {\r\n alert(\"请等待当前响应完成。\");\r\n return;\r\n }\r\n\r\n const messageIndex = messages.findIndex(msg => msg.id === messageId);\r\n // 必须是 assistant 消息且存在\r\n if (messageIndex === -1 || messages[messageIndex].role !== 'assistant') {\r\n console.error(\"无法重新生成:无效消息或非 AI 消息, ID:\", messageId);\r\n return;\r\n }\r\n\r\n // 检查重新生成的消息后面是否有其他消息\r\n const messagesToDeleteCount = messages.length - 1 - messageIndex;\r\n let userConfirmed = true; // 默认确认(如果后面没有消息)\r\n\r\n if (messagesToDeleteCount > 0) {\r\n userConfirmed = confirm(`重新生成这条回复将会删除其后的 ${messagesToDeleteCount} 条对话记录。\\n确定要继续吗?`);\r\n }\r\n\r\n if (userConfirmed) {\r\n console.log(\"准备重新生成, ID:\", messageId);\r\n\r\n // --- 删除后续消息 (数据和 UI) ---\r\n if (messagesToDeleteCount > 0) {\r\n // 从数据模型中删除\r\n const deletedMessages = messages.splice(messageIndex + 1); // 删除 messageIndex 后面的所有元素\r\n console.log(`删除了 ${deletedMessages.length} 条后续消息数据。`);\r\n\r\n // 从 UI 中删除\r\n deletedMessages.forEach(deletedMsg => {\r\n const msgDiv = chatContainer?.querySelector(`.message[data-message-id=\"${deletedMsg.id}\"]`);\r\n if (msgDiv?.parentNode) {\r\n msgDiv.remove();\r\n console.log(`删除了 UI 消息, ID: ${deletedMsg.id}`);\r\n }\r\n });\r\n }\r\n // --- 删除结束 ---\r\n\r\n // 删除当前 AI 消息本身 (数据和 UI)\r\n // 注意:现在我们不立即删除当前 AI 消息,而是让 sendRequestToDeepSeekAPI 发起请求\r\n // 请求完成后,旧的占位符会被新的流式响应替换\r\n // messages 数组在发起请求前应该只包含到当前 AI 消息之前的对话\r\n const messagesToSend = messages.slice(0, messageIndex); // 获取重新生成点之前的所有消息\r\n messages = messagesToSend; // 更新全局 messages 数组\r\n\r\n saveConversationToLocalStorage(); // 保存删除操作后的状态\r\n\r\n // 从 UI 中移除旧的 AI 消息\r\n const aiMessageDiv = chatContainer?.querySelector(`.message[data-message-id=\"${messageId}\"]`);\r\n if (aiMessageDiv?.parentNode) {\r\n aiMessageDiv.remove();\r\n console.log(\"已删除旧 AI 消息 UI, ID:\", messageId);\r\n } else {\r\n console.warn(\"未找到要移除的旧 AI 消息 UI, ID:\", messageId);\r\n }\r\n\r\n // 禁用按钮并发送请求 (此时 messages 已经是截断后的)\r\n toggleSendButton(false);\r\n try {\r\n await sendRequestToDeepSeekAPI(); // sendRequest 会使用当前的 messages 数组\r\n } catch (error) {\r\n console.error(\"重新生成出错:\", error);\r\n // 错误处理已包含在 sendRequest... 和其调用者中\r\n // 考虑是否需要恢复被删除的消息?(目前不恢复)\r\n // 如果请求失败,messages 数组已经是截断状态,UI 也已移除旧消息\r\n // 可以在这里添加逻辑来恢复被删除的消息和 UI,但会增加复杂度\r\n }\r\n // finally 块在 sendRequestToDeepSeekAPI 中处理状态重置和按钮启用\r\n } else {\r\n console.log(\"用户取消了重新生成操作。\");\r\n }\r\n}\r\n\r\n\r\n// --- 对话存储与加载 ---\r\n\r\n/**\r\n * 保存对话到 JSON 文件\r\n */\r\nfunction saveConversationToFile() {\r\n if (messages.length === 0) {\r\n alert(\"对话为空,无需保存。\");\r\n return;\r\n }\r\n try {\r\n const settingsToSave = getCurrentSettings(); // 获取当前设置\r\n const conversationData = {\r\n version: 1, // 版本号,便于未来扩展\r\n timestamp: new Date().toISOString(), // 保存时间戳\r\n settings: settingsToSave, // 保存相关设置\r\n history: messages // 保存对话历史\r\n };\r\n const jsonString = JSON.stringify(conversationData, null, 2); // 格式化 JSON\r\n const blob = new Blob([jsonString], { type: 'application/json' });\r\n const url = URL.createObjectURL(blob);\r\n const a = document.createElement('a');\r\n a.href = url;\r\n // 生成文件名,包含日期时间\r\n a.download = `deepseek_chat_${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}.json`;\r\n document.body.appendChild(a); // 添加到页面以便触发点击\r\n a.click(); // 模拟点击下载\r\n document.body.removeChild(a); // 移除元素\r\n URL.revokeObjectURL(url); // 释放内存\r\n console.log(\"对话已保存到文件。\");\r\n } catch (error) {\r\n console.error(\"保存对话到文件失败:\", error);\r\n alert(\"保存失败: \" + error.message);\r\n }\r\n}\r\n\r\n/**\r\n * 从 JSON 文件加载对话\r\n */\r\nfunction loadConversationFromFile(event) {\r\n const file = event.target.files[0];\r\n if (!file) return;\r\n\r\n if (!confirm(\"加载新对话将覆盖当前对话和设置,确定要继续吗?\")) {\r\n event.target.value = ''; // 清空文件选择,以便可以再次选择同一个文件\r\n return;\r\n }\r\n\r\n const reader = new FileReader();\r\n reader.onload = (e) => {\r\n try {\r\n const loadedData = JSON.parse(e.target.result);\r\n // 基础验证\r\n if (!loadedData || !(typeof loadedData === 'object')) {\r\n throw new Error(\"文件格式无效,不是有效的 JSON 对象。\");\r\n }\r\n // 检查 history 是否存在且为数组\r\n if (!Array.isArray(loadedData.history)) {\r\n // 兼容旧格式(直接是消息数组)\r\n if(Array.isArray(loadedData)) {\r\n loadedData = { history: loadedData, settings: null }; // 包装成新格式\r\n console.log(\"检测到旧格式文件,已尝试兼容。\");\r\n } else {\r\n throw new Error(\"文件格式无效,缺少 'history' 数组。\");\r\n }\r\n }\r\n\r\n // 加载消息,确保每条消息有 ID\r\n messages = loadedData.history.map(msg => ({\r\n ...msg,\r\n id: msg.id || generateUniqueId(msg.role || 'unknown') // 补充或生成 ID\r\n }));\r\n\r\n // 清空当前 UI\r\n if (chatContainer) chatContainer.innerHTML = '';\r\n else console.error(\"加载对话时 chatContainer 不存在!\");\r\n\r\n // 加载设置 (如果存在)\r\n if (loadedData.settings && typeof loadedData.settings === 'object') {\r\n applySettings(loadedData.settings);\r\n saveSettings(); // 将加载的设置保存到 LocalStorage\r\n console.log(\"对话文件中的设置已加载并应用。\");\r\n } else {\r\n console.log(\"对话文件中未找到设置,将保留当前或应用默认设置。\");\r\n // 可以选择加载默认设置:applyDefaultSettings();\r\n // 或者保留当前已加载的设置(通常更好)\r\n }\r\n\r\n // 重新渲染 UI\r\n messages.forEach(msg => appendMessageToUI(msg));\r\n saveConversationToLocalStorage(); // 保存加载的对话到 LocalStorage\r\n conditionalScrollChatToBottom(); // 滚动到底部\r\n console.log(\"对话已从文件加载。\");\r\n alert(\"对话加载成功!\");\r\n\r\n } catch (error) {\r\n console.error(\"加载对话文件失败:\", error);\r\n alert(\"加载失败: \" + error.message + \"\\n请确保文件是正确的 JSON 格式。\");\r\n // 出错时不应清除当前对话\r\n // messages = [];\r\n // if(chatContainer) chatContainer.innerHTML = '';\r\n // saveConversationToLocalStorage();\r\n // applyDefaultSettings(); // 重置为默认设置?\r\n } finally {\r\n event.target.value = ''; // 清空文件选择\r\n }\r\n };\r\n reader.onerror = (e) => {\r\n console.error(\"读取文件时出错:\", e);\r\n alert(\"读取文件失败。\");\r\n event.target.value = ''; // 清空文件选择\r\n };\r\n reader.readAsText(file); // 读取文件内容\r\n}\r\n\r\n/**\r\n * 保存对话到 LocalStorage\r\n */\r\nfunction saveConversationToLocalStorage() {\r\n try {\r\n localStorage.setItem('deepseekChatHistory', JSON.stringify(messages));\r\n } catch (error) {\r\n console.error(\"保存对话到 LocalStorage 失败:\", error);\r\n // 可以考虑给出提示,例如 LocalStorage 已满\r\n }\r\n}\r\n\r\n/**\r\n * 从 LocalStorage 加载对话\r\n */\r\nfunction loadConversationFromLocalStorage() {\r\n try {\r\n const savedHistory = localStorage.getItem('deepseekChatHistory');\r\n if (savedHistory) {\r\n // 验证是否是有效的 JSON 数组\r\n let parsedHistory; try { parsedHistory = JSON.parse(savedHistory); } catch { throw new Error(\"LocalStorage 中的历史记录格式无效。\"); }\r\n if (!Array.isArray(parsedHistory)) throw new Error(\"LocalStorage 中的历史记录不是数组。\");\r\n\r\n messages = parsedHistory.map(msg => ({\r\n ...msg,\r\n id: msg.id || generateUniqueId(msg.role || 'unknown') // 确保有 ID\r\n }));\r\n\r\n console.log(`从 LocalStorage 加载了 ${messages.length} 条历史记录.`);\r\n if (chatContainer) chatContainer.innerHTML = ''; // 清空现有 UI\r\n else console.error(\"加载历史时 chatContainer 不存在!\");\r\n\r\n messages.forEach(msg => appendMessageToUI(msg)); // 重新渲染 UI\r\n conditionalScrollChatToBottom(); // 滚动到底部\r\n\r\n } else {\r\n console.log(\"LocalStorage 中未找到历史记录。\");\r\n }\r\n } catch (error) {\r\n console.error(\"从 LocalStorage 加载历史记录失败:\", error);\r\n localStorage.removeItem('deepseekChatHistory'); // 清除损坏的数据\r\n messages = []; // 重置消息数组\r\n if (chatContainer) chatContainer.innerHTML = ''; // 清空 UI\r\n }\r\n}\r\n\r\n/**\r\n * 清空对话\r\n */\r\nfunction clearConversation() {\r\n // 如果正在响应,先中止\r\n if (sendButton?.disabled && currentAbortController) {\r\n if (!confirm(\"AI 正在响应中,确定要中止并清空所有对话记录吗?\")) return;\r\n console.log(\"用户中止并清空对话...\");\r\n currentAbortController.abort(\"User cleared conversation.\"); // 发出中止信号\r\n // 中止后,finally 块会清理状态,但这里需要确保 messages 被清空\r\n } else if (messages.length === 0) {\r\n alert(\"对话已经为空。\");\r\n return;\r\n } else {\r\n // 正常清空确认\r\n if (!confirm(\"确定要清空所有对话记录吗?此操作不可恢复。\")) return;\r\n }\r\n\r\n console.log(\"执行清空操作...\");\r\n messages = []; // 清空消息数组\r\n // 重置可能存在的流式输出状态\r\n currentAssistantMessageId = null;\r\n currentAssistantMessageDiv = null;\r\n currentReasoningDiv = null;\n currentContentDiv = null;\n currentAbortController = null; // 确保中止控制器为空\n toggleSendButton(true); // 确保发送按钮可用\n\r\n // 清空 UI\r\n if (chatContainer) chatContainer.innerHTML = '';\r\n else console.error(\"清空对话时 chatContainer 不存在!\");\r\n\r\n saveConversationToLocalStorage(); // 清除 LocalStorage 中的记录\r\n console.log(\"对话已清空。\");\r\n}\r\n\r\n\r\n// --- 设置管理 ---\r\n\r\n/**\r\n * 获取当前界面的所有设置项值\r\n */\r\nfunction getCurrentSettings() {\r\n const settings = {\r\n // 【修改】新增 URL 和模型配置\r\n apiUrl: apiUrlSelect.value,\r\n customUrl: customUrlInput.value,\r\n model: modelSelect.value,\r\n customModel: customModelInput.value,\r\n\r\n temperature: parseFloat(temperatureSlider.value),\r\n top_p: parseFloat(topPSlider.value),\r\n max_tokens: maxTokensInput.value ? parseInt(maxTokensInput.value, 10) : null,\r\n stop: stopSequencesInput.value.trim() ? stopSequencesInput.value.split(',').map(s => s.trim()).filter(s => s) : null,\r\n frequency_penalty: parseFloat(frequencyPenaltySlider.value),\r\n presence_penalty: parseFloat(presencePenaltySlider.value),\r\n selectedPromptValue: predefinedPromptSelect.value, // 保存当前选中的模板值\r\n systemPromptContent: systemPromptInput.value, // 保存系统提示词内容(即使是模板生成的)\r\n reasoningDefaultVisible: reasoningDefaultVisibleCheckbox.checked,\r\n reasoningEffort: reasoningEffortSelect.value, // 新增:思考强度\r\n reasoningEffortCustom: reasoningEffortCustomInput.value.trim(), // 新增:自定义思考强度\r\n thinkingModeEnabled: thinkingModeCheckbox.checked // 新增:思考模式开关\r\n };\r\n\r\n // 清理无效的 max_tokens (确保是正整数)\r\n if (settings.max_tokens !== null && (isNaN(settings.max_tokens) || settings.max_tokens < 1)) {\r\n console.warn(\"无效的 Max Tokens 值将被忽略:\", settings.max_tokens);\r\n settings.max_tokens = null; // 设为 null,让 API 使用默认值\r\n }\r\n return settings;\r\n}\r\n\r\n/**\r\n * 将设置对象应用到界面控件\r\n */\r\nfunction applySettings(settings) {\r\n console.log(\"正在应用设置:\", settings);\r\n // API Key 加载由 loadSettings 单独处理,不在此处覆盖\r\n\r\n // 【修改】应用 URL 和模型配置\r\n if (settings.apiUrl !== undefined) apiUrlSelect.value = settings.apiUrl;\r\n if (settings.customUrl !== undefined) customUrlInput.value = settings.customUrl;\r\n if (settings.model !== undefined) modelSelect.value = settings.model;\r\n if (settings.customModel !== undefined) customModelInput.value = settings.customModel;\r\n \r\n // 触发 UI 更新 (显示/隐藏自定义输入框)\r\n handleApiUrlChange();\r\n handleModelChange();\r\n\r\n if (settings.temperature !== undefined) { temperatureSlider.value = settings.temperature; updateSliderValue(temperatureSlider, tempValueSpan); }\r\n if (settings.top_p !== undefined) { topPSlider.value = settings.top_p; updateSliderValue(topPSlider, topPValueSpan); }\r\n if (settings.max_tokens !== undefined && settings.max_tokens !== null) maxTokensInput.value = settings.max_tokens; else maxTokensInput.value = '';\r\n if (settings.stop !== undefined && settings.stop !== null && Array.isArray(settings.stop)) stopSequencesInput.value = settings.stop.join(','); else stopSequencesInput.value = '';\r\n if (settings.frequency_penalty !== undefined) { frequencyPenaltySlider.value = settings.frequency_penalty; updateSliderValue(frequencyPenaltySlider, freqPenaltyValueSpan); }\r\n if (settings.presence_penalty !== undefined) { presencePenaltySlider.value = settings.presence_penalty; updateSliderValue(presencePenaltySlider, presPenaltyValueSpan); }\r\n\r\n // 处理预设提示词和自定义提示词\r\n if (settings.selectedPromptValue !== undefined) {\r\n // 检查加载的值是否在当前选项中有效\r\n const isValidOption = Array.from(predefinedPromptSelect.options).some(opt => opt.value === settings.selectedPromptValue);\r\n if (isValidOption) {\r\n predefinedPromptSelect.value = settings.selectedPromptValue;\r\n handlePredefinedPromptChange(); // 更新 systemPromptInput 的状态和内容(如果是模板)\r\n // 如果加载的是自定义选项,确保 systemPrompt 的内容也被加载\r\n if (settings.selectedPromptValue === customPromptValue && settings.systemPromptContent !== undefined) {\r\n systemPromptInput.value = settings.systemPromptContent;\r\n adjustTextareaHeight(systemPromptInput); // 调整高度\r\n }\r\n } else {\r\n console.warn(\"加载了无效的 selectedPromptValue:\", settings.selectedPromptValue, \"将使用默认选项。\");\r\n predefinedPromptSelect.selectedIndex = 0; // 回退到第一个选项\r\n handlePredefinedPromptChange();\r\n }\r\n } else {\r\n // 如果设置中没有 selectedPromptValue,则使用默认选项\r\n predefinedPromptSelect.selectedIndex = 0;\r\n handlePredefinedPromptChange();\r\n }\r\n\r\n // 加载思维链可见性设置\r\n if (settings.reasoningDefaultVisible !== undefined) {\r\n reasoningDefaultVisibleCheckbox.checked = settings.reasoningDefaultVisible;\r\n }\r\n // 加载思考强度设置\n if (settings.reasoningEffort !== undefined) {\n // 校验保存的值是否在当前选项列表中,无效/过期值回退到默认 high\n const validEffortValues = Array.from(reasoningEffortSelect.options).map(opt => opt.value);\n reasoningEffortSelect.value = validEffortValues.includes(settings.reasoningEffort) ? settings.reasoningEffort : 'high';\n }\n // 加载自定义思考强度设置\r\n if (settings.reasoningEffortCustom !== undefined) {\r\n reasoningEffortCustomInput.value = settings.reasoningEffortCustom;\r\n }\r\n // 加载思考模式开关设置\r\n if (settings.thinkingModeEnabled !== undefined) {\r\n thinkingModeCheckbox.checked = settings.thinkingModeEnabled;\r\n }\r\n handleReasoningEffortChange(); // 同步自定义输入框显隐\r\n updateThinkingModeUI(); // 同步思考模式开关文字\r\n}\r\n\r\n/**\r\n * 应用默认设置到界面\r\n */\r\nfunction applyDefaultSettings() {\r\n console.log(\"应用默认设置。\");\r\n // 【修改】默认值\r\n apiUrlSelect.value = \"https://api.deepseek.com\"; \r\n customUrlInput.value = \"\";\r\n modelSelect.value = \"deepseek-v4-pro\";\r\n customModelInput.value = \"\";\r\n handleApiUrlChange();\r\n handleModelChange();\r\n\r\n temperatureSlider.value = 0.7; updateSliderValue(temperatureSlider, tempValueSpan);\r\n topPSlider.value = 1.0; updateSliderValue(topPSlider, topPValueSpan);\r\n maxTokensInput.value = ''; // 默认空,让 API 使用默认值\r\n stopSequencesInput.value = '';\r\n frequencyPenaltySlider.value = 0.5; updateSliderValue(frequencyPenaltySlider, freqPenaltyValueSpan);\r\n presencePenaltySlider.value = 0.0; updateSliderValue(presencePenaltySlider, presPenaltyValueSpan);\r\n predefinedPromptSelect.selectedIndex = 0; // 默认选择第一个预设提示词\r\n handlePredefinedPromptChange(); // 应用提示词\r\n reasoningDefaultVisibleCheckbox.checked = true; // 默认显示思维链\r\n reasoningEffortSelect.value = \"high\"; // 默认高思考强度\n reasoningEffortCustomInput.value = ''; // 清空自定义思考强度\r\n thinkingModeCheckbox.checked = true; // 默认启用思考模式\r\n handleReasoningEffortChange(); // 同步自定义输入框显隐\r\n updateThinkingModeUI(); // 同步思考模式开关文字\r\n // 注意:不应在此处清除 API Key\r\n // apiKeyInput.value = '';\r\n}\r\n\r\n\r\n/**\r\n * 保存当前设置到 LocalStorage\r\n */\r\nfunction saveSettings() {\r\n try {\r\n const currentSettings = getCurrentSettings(); // 获取界面上的设置\r\n // 将 API Key 也保存进去,但不包含在 getCurrentSettings 返回的对象中(那里用于 API 请求)\r\n currentSettings.apiKey = apiKeyInput.value.trim();\r\n localStorage.setItem('deepseekChatSettings', JSON.stringify(currentSettings));\r\n // console.log(\"设置已保存到 LocalStorage:\", currentSettings); // 减少日志噪音\r\n } catch (error) {\r\n console.error(\"保存设置到 LocalStorage 失败:\", error);\r\n }\r\n}\r\n\r\n/**\r\n * 从 LocalStorage 加载设置\r\n */\r\nfunction loadSettings() {\r\n try {\r\n const savedSettings = localStorage.getItem('deepseekChatSettings');\r\n if (savedSettings) {\r\n const loadedSettings = JSON.parse(savedSettings);\r\n applySettings(loadedSettings); // 应用加载的设置到界面\r\n // 单独加载 API Key\r\n if (loadedSettings.apiKey) {\r\n apiKeyInput.value = loadedSettings.apiKey;\r\n }\r\n console.log(\"设置已从 LocalStorage 加载。\");\r\n } else {\r\n console.log(\"未找到保存的设置,应用默认设置。\");\r\n applyDefaultSettings(); // 如果没有保存的设置,应用默认值\r\n }\r\n } catch (error) {\r\n console.error(\"从 LocalStorage 加载设置失败:\", error);\r\n localStorage.removeItem('deepseekChatSettings'); // 清除可能损坏的数据\r\n applyDefaultSettings(); // 加载失败时应用默认值\r\n }\r\n}\r\n\r\n// --- 设置弹窗控制 ---\r\nfunction openSettingsModal() {\r\n if (settingsModal) settingsModal.style.display = 'block';\r\n}\r\nfunction closeSettingsModal() {\r\n if (settingsModal) settingsModal.style.display = 'none';\r\n}\r\n\r\n// --- 工具函数 ---\r\nfunction generateUniqueId(prefix = 'msg') {\r\n // 生成更可靠的唯一 ID\r\n return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;\r\n}\n"}}}}