去提问»
提问背景 Question:
你学得有道理,先转义,再md解析,另外就是我发送的内容比较长,我想要个收缩和展开功能,代码如下。 我应该怎么做呢? ``` <!doctype html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" /> <title>百问百答 - AI Chat</title> <meta name="description" content="BBSGOOD 百问百答:您的全能智能助手。无论解答学习疑问、翻译全球语言,还是寻找日常美食菜谱,这里都能为您一站式搞定。融合 AI 与社区智慧,轻松服务您的品质生活。" /> <link rel="stylesheet" href="/static/css/auth.css" /> <script src="/static/js/auth.js"></script> <script src="/static/js/marked.min.js"></script> <style> body { margin: 0; background: #0f141c; color: #e3e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; } #chat-window { flex: 1; overflow-y: auto; padding: 20px; border-bottom: 1px solid #1e293b; scroll-behavior: smooth; display: flex; flex-direction: column; } #bottom-spacer { height: 0px; flex-shrink: 0; } .message { margin-bottom: 25px; line-height: 1.7; font-size: 15px; padding: 12px 18px; border-radius: 8px; box-sizing: border-box; } /* 用户气泡:保持 max-width 限宽靠右 */ .user { max-width: 85%; align-self: flex-end; margin-left: auto; background: #1e293b; color: #f8fafc; border-left: 4px solid #38bdf8; } /* AI 气泡:🌟 彻底打破 85% 宽度限制,100% 满宽横向铺满,拒绝右侧留空 */ .ai { width: 100%; max-width: 100%; align-self: flex-start; background: #172030; border-left: 4px solid #10b981; animation: fadeInUp 0.4s ease-out forwards; } /* 支持 Markdown 中的图片样式 */ .ai img, .user img { max-width: 300px; max-height: 300px; border-radius: 6px; margin-top: 8px; display: inline-block; margin-right: 8px; border: 1px solid #334155; } @keyframes fadeInUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* 思考中样式 */ .thinking { max-width: 85%; align-self: flex-start; background: #131b26; border-left: 4px solid #f59e0b; color: #94a3b8; display: flex; align-items: center; gap: 8px; } .dot-flashing { display: inline-flex; align-items: center; gap: 4px; height: 15px; } .dot-flashing span { width: 6px; height: 6px; background-color: #10b981; border-radius: 50%; display: inline-block; animation: bounce 1.4s infinite ease-in-out both; } .dot-flashing span:nth-child(1) { animation-delay: -0.32s; } .dot-flashing span:nth-child(2) { animation-delay: -0.16s; } @keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } } /* 🌟 动态图片多图预览区 */ #preview-area { display: none; padding: 10px 15px; background: #131a26; border-top: 1px solid #1e293b; align-items: center; gap: 12px; flex-wrap: wrap; } #preview-list { display: flex; gap: 12px; flex-wrap: wrap; } .preview-box { position: relative; width: 60px; height: 60px; border-radius: 6px; overflow: hidden; border: 1px solid #334155; } .preview-box img { width: 100%; height: 100%; object-fit: cover; } .preview-box .close-btn { position: absolute; top: 2px; right: 2px; background: rgba(239, 68, 68, 0.9); color: white; border: none; border-radius: 50%; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; font-size: 10px; cursor: pointer; padding: 0; } #input-area { padding: 15px; display: flex; align-items: center; background: #0f141c; border-top: 1px solid #1e293b; gap: 12px; } /* 开关区样式 */ .switch-container { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 11px; color: #94a3b8; user-select: none; cursor: pointer; min-width: 50px; } .switch { position: relative; display: inline-block; width: 40px; height: 22px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #334155; transition: 0.3s; border-radius: 34px; } .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; } input:checked + .slider { background-color: #10b981; } input:checked + .slider:before { transform: translateX(18px); } /* 手动添加文件的 + 号按钮样式 */ .add-file-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 50%; background: #1e293b; color: #94a3b8; cursor: pointer; transition: all 0.2s ease; border: 1px solid #334155; flex-shrink: 0; } .add-file-btn:hover { background: #10b981; color: #ffffff; border-color: #10b981; transform: scale(1.08); } textarea { flex: 1; background: #1e293b; border: 1px solid #334155; color: #f8fafc; padding: 12px 15px; border-radius: 6px; outline: none; font-size: 14px; font-family: inherit; resize: none; height: 24px; max-height: 200px; overflow-y: hidden; box-sizing: content-box; } textarea:focus { border-color: #10b981; } #send-btn { background: #10b981; color: #fff; border: none; padding: 14px 24px; cursor: pointer; border-radius: 6px; font-weight: bold; transition: all 0.2s; white-space: nowrap; } #send-btn:hover:not(:disabled) { background: #059669; } #send-btn:disabled { background: #334155; color: #64748b; cursor: not-allowed; } .hint-text { font-size: 11px; color: #64748b; margin-top: -10px; margin-bottom: 5px; /* 👈 新增这一行,数值可以根据视觉效果微调(如 6px 到 10px) */ text-align: right; padding-right: 15px; } /* Markdown 样式 */ .ai p { margin: 8px 0; } .ai code { background: #273549; color: #f43f5e; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; font-family: Consolas, Monaco, monospace; } .code-block-wrapper { position: relative; margin: 15px 0; background: #0b0f17; border: 1px solid #1e293b; border-radius: 8px; overflow: hidden; } .ai pre { margin: 0; padding: 15px; overflow-x: auto; } .ai pre code { background: transparent; color: #38bdf8; padding: 0; font-family: Consolas, Monaco, monospace; } .copy-btn { position: absolute; top: 8px; right: 8px; background: rgba(30, 41, 59, 0.8); border: 1px solid #334155; color: #94a3b8; padding: 4px 10px; font-size: 12px; cursor: pointer; border-radius: 4px; transition: all 0.2s; } .copy-btn:hover { background: #10b981; color: #fff; border-color: #10b981; } .ai ul, .ai ol { padding-left: 20px; margin: 10px 0; } .ai h1, .ai h2, .ai h3 { color: #38bdf8; margin: 20px 0 10px 0; } /* ==================== 🌟 顶部固定导航条样式 ==================== */ #top-navbar { position: fixed; top: 0; left: 0; width: 100%; height: 50px; background: #111827; /* 深色背景,和主体呼应 */ border-bottom: 1px solid #1e293b; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; box-sizing: border-box; z-index: 1000; } #top-navbar .nav-left a { color: #94a3b8; text-decoration: none; font-size: 14px; font-weight: bold; transition: color 0.2s; } #top-navbar .nav-left a:hover { color: #38bdf8; } #top-navbar .nav-title { color: #38bdf8; font-size: 16px; font-weight: bold; letter-spacing: 1px; } /* 🌟 核心修正:因为顶部横条是 fixed 定位的,聊天窗口必须空出 50px 的上边距,防止第一条内容被遮挡 */ #chat-window { flex: 1; overflow-y: auto; padding: 70px 20px 20px 20px; /* 👈 padding-top 改为 70px */ border-bottom: 1px solid #1e293b; scroll-behavior: smooth; display: flex; flex-direction: column; } /*标签最底部追加一小段红点闪烁动画,让录音更有科技感):*/ @keyframes pulse { from { opacity: 0.3; } to { opacity: 1; } } /*菜单按钮menu样式begin*/ .hamburger-icon-btn { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 8px 10px; cursor: pointer; display: flex; flex-direction: column; justify-content: space-between; width: 38px; height: 34px; outline: none; } .hamburger-icon-btn span { display: block; height: 2px; width: 100%; background-color: #38bdf8; border-radius: 2px; } /*菜单按钮menu样式end*/ </style> </head> <body> <div id="top-navbar"> <div class="nav-left"> <button type="button" class="hamburger-icon-btn" onclick="toggleBbsMenu(event)" title="菜单" > <span></span> <span></span> <span></span> </button> </div> <!-- 引入母页面统一控制脚本 --> <script src="/static/js/menu.js"></script> <div class="nav-title">百问百答</div> <div id="login_reg_status" class="user-status-bar"> <!-- 页面初次加载时的骨架/占位 --> <a href="javascript:;" onclick="window.BbsAuth.open('login')" class="login-btn-link" >登录 / 注册</a > </div> </div> <div id="chat-window"> <div id="bottom-spacer"></div> </div> <div> <div id="preview-area"> <div id="preview-list"></div> <span style="font-size: 11px; color: #64748b" >(已自动无损压缩为极致 WebP 格式)</span > </div> <div id="input-area"> <div class="switch-container" title="默认提问模式,开启后才会携带上下文进行连续对话" > <label class="switch"> <input type="checkbox" id="history-toggle" checked /> <span class="slider"></span> </label> <span id="switch-label">提问模式</span> </div> <div class="add-file-btn" onclick="triggerFileSelect()" title="选择本地图片" > <svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" > <line x1="12" y1="5" x2="12" y2="19"></line> <line x1="5" y1="12" x2="19" y2="12"></line> </svg> </div> <!-- 🎯 【语音新增】:麦克风录音控制按钮 --> <div id="voice-btn" class="add-file-btn" onclick="toggleRecording()" title="点击录音" style="position: relative" > <svg id="mic-icon" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" > <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" ></path> <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path> <line x1="12" y1="19" x2="12" y2="23"></line> <line x1="8" y1="23" x2="16" y2="23"></line> </svg> <!-- 录音时的闪烁红点提示器 --> <span id="recording-dot" style=" display: none; position: absolute; top: 2px; right: 2px; width: 8px; height: 8px; background: #ef4444; border-radius: 50%; animation: pulse 1s infinite alternate; " ></span> </div> <input type="file" id="file-input" accept="image/*" multiple style="display: none" onchange="handleFileSelect(event)" /> <textarea id="msg" placeholder="支持截图粘贴、拖拽,Ctrl+Enter发送" oninput="autoResize(this)" ></textarea> <button id="send-btn" onclick="sendMsg()">发送</button> </div> <div class="hint-text"> 换行直接回车,发送请按 Ctrl + Enter 或 Ctrl + S </div> </div> <script> // 🌟 核心修复:配置 marked 解析器,彻底解决不换行、暴露 * 的问题 marked.setOptions({ breaks: true, // 👈 极其重要!强制将单换行(\n)解析为 <br> 换行符,不再合并为空格 gfm: true, // 开启 GitHub 风格的 Markdown 渲染,保证列表和代码块最完美 }); let currentAudioBlob = null; // 👈 手工加上这个,用来存录音文件 // ==================== 🎯 【语音新增】:录音接口与状态管理 ==================== let mediaRecorder = null; let audioChunks = []; let isRecording = false; async function toggleRecording() { const voiceBtn = document.getElementById("voice-btn"); const micIcon = document.getElementById("mic-icon"); const recDot = document.getElementById("recording-dot"); const inputEl = document.getElementById("msg"); const sendBtn = document.getElementById("send-btn"); // 👈 顺手把发送按钮也拿出来 if (!isRecording) { // 1. 请求麦克风权限并初始化录音接口 try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); // 采用标准 Web 容器录制(大多数现代浏览器支持 webm 或 mp4 音频,Go 后端可用标准语音库或 Gemini 直接接收) mediaRecorder = new MediaRecorder(stream); audioChunks = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunks.push(event.data); } }; mediaRecorder.onstop = () => { // 录音停止后,组装成最终的语音 Blob 文件 currentAudioBlob = new Blob(audioChunks, { type: "audio/mp3" }); // 录音完成后自动帮用户往输入框提示一下,并自动触发发送 inputEl.value = "🎙️ [语音消息]"; sendMsg(); }; // 2. 开始录音,更新 UI mediaRecorder.start(); isRecording = true; // 🔒 【安全锁】:正在录音时,禁止打字,禁止手动点击发送 inputEl.disabled = true; sendBtn.disabled = true; voiceBtn.style.borderColor = "#ef4444"; voiceBtn.style.color = "#ef4444"; recDot.style.display = "block"; inputEl.placeholder = "正在录音,再次点击麦克风按钮结束并发送..."; } catch (err) { console.error("无法获取麦克风权限:", err); alert("无法启动录音:请确保已允许网站访问麦克风权限。"); } } else { // 3. 再次点击,停止录音 if (mediaRecorder && mediaRecorder.state !== "inactive") { mediaRecorder.stream.getTracks().forEach((track) => track.stop()); // 释放麦克风硬件红灯 mediaRecorder.stop(); } isRecording = false; // 🔓 【解锁】:录音结束,恢复正常输入状态(sendMsg内部自己还会接管一把disabled) inputEl.disabled = false; sendBtn.disabled = false; voiceBtn.style.borderColor = ""; voiceBtn.style.color = ""; recDot.style.display = "none"; inputEl.placeholder = "支持截图粘贴、拖拽,Ctrl+Enter发送"; } } let selectedImagesList = []; const chatWindow = document.getElementById("chat-window"); function autoResize(textarea) { textarea.style.height = "24px"; const scrollHeight = textarea.scrollHeight; if (scrollHeight > 200) { textarea.style.height = "200px"; textarea.style.overflowY = "auto"; } else { textarea.style.height = scrollHeight + "px"; textarea.style.overflowY = "hidden"; } } const toggleInput = document.getElementById("history-toggle"); const switchLabel = document.getElementById("switch-label"); toggleInput.addEventListener("change", () => { if (toggleInput.checked) { switchLabel.style.color = "#10b981"; switchLabel.innerText = "对话模式"; } else { switchLabel.style.color = "#64748b"; switchLabel.innerText = "提问模式"; } }); toggleInput.dispatchEvent(new Event("change")); function triggerFileSelect() { document.getElementById("file-input").click(); } function handleFileSelect(event) { const files = event.target.files; if (files && files.length > 0) { for (let i = 0; i < files.length; i++) { processImage(files[i]); } } event.target.value = ""; } function compressToWebP(file, maxDim = 400, quality = 0.6) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = (e) => { const img = new Image(); img.src = e.target.result; img.onload = () => { let width = img.width; let height = img.height; if (width > maxDim || height > maxDim) { if (width > height) { height = Math.round((height * maxDim) / width); width = maxDim; } else { width = Math.round((width * maxDim) / height); height = maxDim; } } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, width, height); canvas.toBlob( (blob) => { if (blob) { const pureName = file.name.replace(/\.[^/.]+$/, ""); const webpFile = new File([blob], pureName + ".webp", { type: "image/webp", lastModified: Date.now(), }); resolve(webpFile); } else { reject(new Error("WebP 转换失败")); } }, "image/webp", quality, ); }; img.onerror = (err) => reject(err); }; reader.onerror = (err) => reject(err); }); } async function processImage(file) { if (!file.type.startsWith("image/")) { alert("只能选择图片文件哦!"); return; } try { const compressedWebP = await compressToWebP(file); selectedImagesList.push(compressedWebP); renderPreviewList(); } catch (err) { console.error("图片处理失败:", err); alert("图片加载失败,请重试"); } } function renderPreviewList() { const previewArea = document.getElementById("preview-area"); const previewList = document.getElementById("preview-list"); previewList.innerHTML = ""; if (selectedImagesList.length === 0) { previewArea.style.display = "none"; return; } selectedImagesList.forEach((file, index) => { const localUrl = URL.createObjectURL(file); const box = document.createElement("div"); box.className = "preview-box"; box.innerHTML = ` <img src="${localUrl}"> <button class="close-btn" onclick="removeImage(${index})">✕</button> `; previewList.appendChild(box); }); previewArea.style.display = "flex"; } function removeImage(index) { selectedImagesList.splice(index, 1); renderPreviewList(); } function clearAllSelectedImages() { selectedImagesList = []; renderPreviewList(); } document.addEventListener("paste", function (event) { const items = (event.clipboardData || window.clipboardData).items; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf("image") !== -1) { const blob = items[i].getAsFile(); processImage(blob); event.preventDefault(); break; } } }); window.addEventListener("dragover", (e) => e.preventDefault()); window.addEventListener("drop", (e) => { e.preventDefault(); const files = e.dataTransfer.files; if (files && files.length > 0) { for (let i = 0; i < files.length; i++) { if (files[i].type.startsWith("image/")) { processImage(files[i]); } } } }); document .getElementById("msg") .addEventListener("keydown", function (event) { const isCtrlPressed = event.ctrlKey || event.metaKey; const isEnter = event.key === "Enter"; const isS = event.key === "s" || event.key === "S"; if (isCtrlPressed && (isEnter || isS)) { event.preventDefault(); sendMsg(); } }); async function sendMsg() { const inputEl = document.getElementById("msg"); const sendBtn = document.getElementById("send-btn"); const historyToggle = document.getElementById("history-toggle"); const userText = inputEl.value; if ( (!userText || userText.trim() === "") && selectedImagesList.length === 0 ) { alert("请输入内容或选择图片后再发送哦~"); return; } sendBtn.disabled = true; sendBtn.innerText = "思考中..."; // 🎯 【语音修改点 1】:生成一个唯一 ID,用来后面定点替换“语音数据”四个字 const voiceSpanId = "voice_text_" + Date.now(); //对于用户的内容,包括html必须先显示代码,再允许显示md的渲染内容 let userBubbleHtml = marked.parse(escapeHtml(userText)); if (currentAudioBlob) { // 如果有语音数据,上面提示的地方强行显示“🎙️ 语音数据”,并套上 id 标签 userBubbleHtml = `<span id="${voiceSpanId}">🎙️ 语音数据</span>`; } else if (selectedImagesList.length > 0) { let imgHtmls = ""; selectedImagesList.forEach((file) => { const localUrl = URL.createObjectURL(file); imgHtmls += `<img src="${localUrl}">`; }); userBubbleHtml = `你: <br>${imgHtmls}<br>` + userText; } const userMsgDiv = appendMessage("user", userBubbleHtml, true); inputEl.value = ""; autoResize(inputEl); const thinkingDiv = appendMessage( "thinking", ` <span>AI 正在思考中</span> <div class="dot-flashing"> <span></span> <span></span> <span></span> </div> `, true, ); // 发送后立即坚决置底 chatWindow.scrollTop = chatWindow.scrollHeight; try { const formData = new FormData(); if (currentAudioBlob) { formData.append("msg", ""); } else { formData.append("msg", userText); } formData.append( "keepHistory", historyToggle.checked ? "true" : "false", ); // 🎯 【语音修改点 2】:如果有音频,打包塞给后端 if (currentAudioBlob) { formData.append("audio", currentAudioBlob, "voice.mp3"); } selectedImagesList.forEach((file) => { formData.append("image", file); }); clearAllSelectedImages(); const CHAT_UNIT = "{{.CHAT_UNIT}}"; let apiurl = "/api/chat_gemini"; if (CHAT_UNIT == "silicon") { apiurl = "/api/chat"; } const response = await fetch(apiurl, { method: "POST", body: formData, }); thinkingDiv.remove(); if (!response.ok) { const errorData = await response.text(); let cleanError = errorData; if ( errorData.includes("429") || errorData.includes("RetryInfo") || errorData.includes("resource_exhausted") ) { cleanError = "API 请求频率超限 (429)。请稍后 1 分钟再试哦!"; } else if ( errorData.includes("500") || errorData.includes("internal") ) { cleanError = "服务器内部繁忙 (500),请重试一次。"; } throw new Error(cleanError); } const data = await response.json(); // 🎯 【语音修改点 3】:回传到文本的时候,把“语音数据”四个字变成识别后的文本 // 这里的 data.user_voice_text 需要与您 Go 后端新加的 Schema 字段对应 if (currentAudioBlob) { const voiceSpan = document.getElementById(voiceSpanId); if (voiceSpan && data.user_voice_text) { voiceSpan.innerText = data.user_voice_text; } else if (voiceSpan) { voiceSpan.innerText = "🎙️ 语音识别空或失败"; } // 发送完毕,清空当前的语音状态 currentAudioBlob = null; } const cleanText = data.response ? data.response.replace(/\\+n/g, "\n") : "未捕获到有效正文"; const aiMsgDiv = appendMessage( "ai", "**AI回复:**\n\n" + cleanText, false, data.provider, // 👈 完美承接! ); // 🌟 核心:确保在浏览器完全渲染、图片可能加载完后,精准将提问推到最顶部 setTimeout(() => { requestAnimationFrame(() => { // 1. 获取当前这次提问气泡(userMsgDiv)距离聊天窗口顶部的绝对偏移量 const targetTop = userMsgDiv.offsetTop; // 2. 直接将聊天窗口的滚轮,精准定死在这次提问的起点 chatWindow.scrollTo({ top: targetTop, behavior: "smooth", // 丝滑滚动过渡 }); }); }, 150); // 给 150ms 缓冲,确保大段文本和 Markdown 已经完全撑开并落盘视图 } catch (err) { if (document.querySelector(".thinking")) { document.querySelector(".thinking").remove(); } console.error("异常:", err.message); // 1. 原地创建一个精致的错误提示盒,带上更换通道重试的动作 const msgDiv = document.createElement("div"); msgDiv.className = "message ai"; // 🌟 重点 1:将边框颜色强行纠正为危险红 msgDiv.style.borderLeft = "4px solid #ef4444"; msgDiv.style.background = "rgba(239, 68, 68, 0.05)"; // 🌟 重点 2:这里的模板字符串我们顶格写,或者直接用 innerHTML 渲染(它不经过 marked 转换) msgDiv.innerHTML = ` <div style="color: #fca5a5; font-weight: bold; margin-bottom: 4px;">⚠️ 服务暂时不可用</div> <div style="font-size: 13px; color: #cbd5e1; margin-bottom: 10px;">${err.message}</div> `; // 2. 动态塞入一个重试按钮 const retryBtn = document.createElement("button"); retryBtn.innerText = "🔄 更换通道并重试"; retryBtn.style.cssText = "background:#1e293b; color:#38bdf8; border:1px solid #38bdf8; padding:5px 10px; font-size:12px; border-radius:4px; cursor:pointer;"; // 3. 绑定点击事件:直接再次触发 sendMsg(),同时把这个失败的红色气泡当场删掉 retryBtn.onclick = () => { msgDiv.remove(); inputEl.value = userText; autoResize(inputEl); }; msgDiv.appendChild(retryBtn); const spacer = document.getElementById("bottom-spacer"); // 🌟 加上这一行! chatWindow.insertBefore(msgDiv, spacer); setTimeout(() => { chatWindow.scrollTop = chatWindow.scrollHeight; }, 50); } finally { sendBtn.disabled = false; sendBtn.innerText = "发送"; // 🎯 【语音新增】:发送完毕后确保提示词回到初始状态 document.getElementById("msg").placeholder = "支持截图粘贴、拖拽,Ctrl+Enter发送"; } } function appendMessage(sender, text, isHtml = false, provider = "") { const chatWindow = document.getElementById("chat-window"); const spacer = document.getElementById("bottom-spacer"); const msgDiv = document.createElement("div"); msgDiv.className = "message " + sender; if (sender === "ai") { const rawHtml = marked.parse(text); msgDiv.innerHTML = rawHtml; // 🌟 核心:如果传入了设备代号,在气泡最底部追加一个精致的流式元数据栏 if (provider) { const metaDiv = document.createElement("div"); // 采用与主题极搭的深蓝虚线分割,右对齐 metaDiv.style.borderTop = "1px dashed #1e293b"; metaDiv.style.marginTop = "12px"; metaDiv.style.paddingTop = "8px"; metaDiv.style.fontSize = "11px"; metaDiv.style.color = "#64748b"; metaDiv.style.display = "flex"; metaDiv.style.alignItems = "center"; metaDiv.style.justifyContent = "flex-end"; metaDiv.style.gap = "4px"; metaDiv.style.userSelect = "none"; // 避免用户复制正文时顺便复制了代号 // 塞入一个绿色的闪烁呼吸灯小圆点,并显示通道代号 metaDiv.innerHTML = ` <span style="display: inline-block; width: 6px; height: 6px; background-color: #10b981; border-radius: 50%;"></span> <span>服务通道: ${provider}</span> `; msgDiv.appendChild(metaDiv); } const preElements = msgDiv.querySelectorAll("pre"); preElements.forEach((pre) => { const wrapper = document.createElement("div"); wrapper.className = "code-block-wrapper"; pre.parentNode.insertBefore(wrapper, pre); wrapper.appendChild(pre); const copyBtn = document.createElement("button"); copyBtn.className = "copy-btn"; copyBtn.innerText = "复制"; copyBtn.onclick = async () => { try { const codeText = pre.querySelector("code").innerText; await navigator.clipboard.writeText(codeText); copyBtn.innerText = "已复制 ✓"; copyBtn.style.background = "#10b981"; copyBtn.style.color = "#fff"; setTimeout(() => { copyBtn.innerText = "复制"; copyBtn.style.background = ""; copyBtn.style.color = ""; }, 2000); } catch (e) { alert("复制失败,请手动选择复制"); } }; wrapper.appendChild(copyBtn); }); } else if (sender === "user" && isHtml) { msgDiv.innerHTML = text; } else if (sender === "thinking") { msgDiv.innerHTML = text; } else { msgDiv.innerHTML = marked.parse(text); } chatWindow.insertBefore(msgDiv, spacer); if (sender === "user") { chatWindow.scrollTop = chatWindow.scrollHeight; } return msgDiv; } //////////////////////////////////////////////////////////////// // ==================== 🌟 初始化:默认加载百问百答使用帮助 ==================== document.addEventListener("DOMContentLoaded", () => { const chatWindow = document.getElementById("chat-window"); const spacer = document.getElementById("bottom-spacer"); // 检查当前是不是空窗口(只有底部占位符),防止刷新时重复添加 const hasMessages = chatWindow.querySelectorAll(".message").length > 0; if (!hasMessages) { const welcomeText = ` ### 💡 欢迎使用【百问百答】AI 助手! 我是你的智能助手,已完美接入 AI 引擎。你可以问我任何难题、编写程序、排查错误、翻译各种外语,甚至是生活常识、教学知识比如数学、英语、历史等等。 --- #### 🛠️ 快捷功能与指南: 1. **会话模式**:支持提问模式和对话模式,对话模式支持上下文多轮对话的连惯性。提问模式支持一问一答。 2. **多模态支持**:点击输入框旁边的 + 按钮,可以直接上传图片让我帮你分析图片,支持图片粘贴发送。 3. **语音识别**:点击语音输入按钮,再次点击结束并输出结果。 4. **大文本支持**:输入框支持回车换行,支持大文本分析,支持快捷键(Ctrl+S或Ctrl+Enter)。 5. **云端同步**:建议点击右上角的 **【登录】**,登录后系统将自动保存您的历史聊天日志,方便随时翻看。 *直接在下方输入框中写下你的问题,然后按(Ctrl+S、Ctrl+Enter)或点击“发送”即可开始!* `; // 创建一个系统欢迎气泡 const welcomeDiv = document.createElement("div"); welcomeDiv.className = "message ai"; welcomeDiv.style.borderLeft = "4px solid #38bdf8"; // 亮蓝色左边框,显眼且高级 welcomeDiv.style.background = "rgba(56, 189, 248, 0.02)"; welcomeDiv.innerHTML = marked.parse(welcomeText); chatWindow.insertBefore(welcomeDiv, spacer); } }); </script> <script> // 设置 autoReload 为 false BbsAuth.configure({ autoReload: true, onSuccess: function (data) { console.log("登录成功,静默更新数据,无需刷新页面!"); // 这里可以手动调用你页面自己的加载数据函数,比如 loadVideoList(); }, // 支持自定义增加菜单项(插入在“个人中心”与“退出登录”之间) //customItems: [ // { // text: "历史对话", // icon: "⭐", // href: "/ai/history", // }, //{ // text: "切换暗黑模式", // icon: "🌙", // action: function () { // document.body.classList.toggle("dark-mode"); // }, //}, //], }); /** * 将HTML字符串中的特殊字符转义为HTML实体,以便在页面上以文本形式显示HTML代码。 * @param {string} htmlString 待转义的HTML字符串。 * @returns {string} 转义后的字符串。 */ function escapeHtml(htmlString) { if (typeof htmlString !== "string") { return htmlString; } return htmlString .replace(/&/g, "&amp;") // 必须最先转义 .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); // 兼容性最好的单引号转义 } </script> </body> </html> ```

聊天消息超长收缩展开功能

发布时间:2026-08-19 17:29 👁️ 阅读:5

要实现发送/回复的长文本自动「收缩和展开」功能,最好的方式是通过 JavaScript 动态获取渲染后的气泡高度。如果高度超过设定阈值(比如 200px),就动态添加折叠类名并附上一个「展开/收起」按钮。

这样既不影响 Markdown 的正常解析渲染,也不会破坏代码块复制按钮和设备标识通道的布局。以下是具体改造步骤:

1. 补充 CSS 样式

<style> 标签内添加以下样式。我们利用 max-heightlinear-gradient 渐变遮罩,来实现平滑的折叠过渡效果:

/* 消息内容容器基础样式 */
.msg-content {
  position: relative;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

/* 折叠状态(限制高度,并显示底部半透明渐变) */
.msg-content.collapsed {
  max-height: 200px; /* 超过此高度自动折叠 */
}

/* 用户气泡折叠遮罩 */
.user .msg-content.collapsed::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 50px;
  background: linear-gradient(to bottom, transparent, #1e293b); /* 匹配用户气泡背景底色 */
  pointer-events: none;
}

/* AI 气泡折叠遮罩 */
.ai .msg-content.collapsed::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 50px;
  background: linear-gradient(to bottom, transparent, #172030); /* 匹配 AI 气泡背景底色 */
  pointer-events: none;
}

/* 展开/收起按钮样式 */
.toggle-expand-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  background: none;
  border: none;
  color: #38bdf8; /* 用户气泡按钮颜色 */
  cursor: pointer;
  font-size: 13px;
  font-weight: bold;
  padding: 8px 0 0 0;
  outline: none;
  user-select: none;
}

.ai .toggle-expand-btn {
  color: #10b981; /* AI 气泡按钮颜色 */
}

.toggle-expand-btn:hover {
  text-decoration: underline;
}

2. 重构 appendMessage 函数

我们需要调整气泡内部的 HTML 骨架。将真正的文本内容放入独立的 .msg-content 容器中。等元素插入页面并渲染后,获取其 scrollHeight,若超高则初始化折叠按钮:

function appendMessage(sender, text, isHtml = false, provider = "") {
  const chatWindow = document.getElementById("chat-window");
  const spacer = document.getElementById("bottom-spacer");
  const msgDiv = document.createElement("div");

  msgDiv.className = "message " + sender;

  // 1. 创建统一的内容包裹容器,方便测量真实渲染高度
  const msgContent = document.createElement("div");
  msgContent.className = "msg-content";

  // 2. 填充内容
  if (sender === "ai") {
    msgContent.innerHTML = marked.parse(text);
  } else if (sender === "user" && isHtml) {
    msgContent.innerHTML = text;
  } else if (sender === "thinking") {
    msgContent.innerHTML = text;
  } else {
    // 用户普通输入,先转义,再过 markdown
    msgContent.innerHTML = marked.parse(escapeHtml(text));
  }

  msgDiv.appendChild(msgContent);

  // 3. 针对 AI 的代码块初始化复制按钮(保持原逻辑,作用域限定在 msgContent 内)
  if (sender === "ai") {
    const preElements = msgContent.querySelectorAll("pre");
    preElements.forEach((pre) => {
      const wrapper = document.createElement("div");
      wrapper.className = "code-block-wrapper";
      pre.parentNode.insertBefore(wrapper, pre);
      wrapper.appendChild(pre);

      const copyBtn = document.createElement("button");
      copyBtn.className = "copy-btn";
      copyBtn.innerText = "复制";

      copyBtn.onclick = async () => {
        try {
          const codeText = pre.querySelector("code").innerText;
          await navigator.clipboard.writeText(codeText);
          copyBtn.innerText = "已复制 ✓";
          copyBtn.style.background = "#10b981";
          copyBtn.style.color = "#fff";
          setTimeout(() => {
            copyBtn.innerText = "复制";
            copyBtn.style.background = "";
            copyBtn.style.color = "";
          }, 2000);
        } catch (e) {
          alert("复制失败,请手动选择复制");
        }
      };
      wrapper.appendChild(copyBtn);
    });
  }

  // 4. 将主消息框挂载到 DOM 中(必须先挂载,才能读取到真实的 scrollHeight 物理高度)
  chatWindow.insertBefore(msgDiv, spacer);

  // 5. 🌟 核心折叠逻辑:动态检测高度(避开 thinking 状态)
  if (sender !== "thinking") {
    const limitHeight = 200; // 触发折叠的阀值高度
    if (msgContent.scrollHeight > limitHeight + 30) { 
      // 激活折叠
      msgContent.classList.add("collapsed");

      // 创建展开/收起切换按钮
      const toggleBtn = document.createElement("button");
      toggleBtn.className = "toggle-expand-btn";
      toggleBtn.innerHTML = `展开全部 ↕`;

      toggleBtn.onclick = () => {
        const isCollapsed = msgContent.classList.contains("collapsed");
        if (isCollapsed) {
          msgContent.classList.remove("collapsed");
          toggleBtn.innerHTML = `收起内容 ↕`;
        } else {
          msgContent.classList.add("collapsed");
          toggleBtn.innerHTML = `展开全部 ↕`;
          // 收回后,平滑滚动让气泡头部可见,避免视觉迷失
          msgDiv.scrollIntoView({ behavior: "smooth", block: "nearest" });
        }
      };
      // 按钮放在消息气泡内部的最下方
      msgDiv.appendChild(toggleBtn);
    }
  }

  // 6. 追加 AI 服务通道信息(保持原逻辑,放在最外层避免被折叠)
  if (sender === "ai" && provider) {
    const metaDiv = document.createElement("div");
    metaDiv.style.borderTop = "1px dashed #1e293b";
    metaDiv.style.marginTop = "12px";
    metaDiv.style.paddingTop = "8px";
    metaDiv.style.fontSize = "11px";
    metaDiv.style.color = "#64748b";
    metaDiv.style.display = "flex";
    metaDiv.style.alignItems = "center";
    metaDiv.style.justifyContent = "flex-end";
    metaDiv.style.gap = "4px";
    metaDiv.style.userSelect = "none";
    metaDiv.innerHTML = `
      <span style="display: inline-block; width: 6px; height: 6px; background-color: #10b981; border-radius: 50%;"></span>
      <span>服务通道: ${provider}</span>
    `;
    msgDiv.appendChild(metaDiv);
  }

  // 自动置底
  if (sender === "user") {
    chatWindow.scrollTop = chatWindow.scrollHeight;
  }

  return msgDiv;
}

3. 说明与优化细节

  1. 滚动定位保障:用户展开后看完,点击「收起内容」时,页面容易因为高度瞬间坍塌而导致视口发生剧烈跳动。代码中增加了 msgDiv.scrollIntoView({ behavior: "smooth", block: "nearest" }),确保收回后,气泡依然完美锁定在当前屏幕内,非常人性化。
  2. 解耦设计:服务通道标识和代码复制框设计在折叠区之外,因此即使折叠了,下方的通道提示依然正常展示。
  3. 防误判:高度判断增加了 limitHeight + 30 像素的缓冲(即超过 230 像素时才开始折叠),防止高度仅稍微超出一点点时也触发折叠,避免高频操作干扰用户阅读。