去提问»
提问背景 Question:
对于登录的用户,我想让它在屏幕中,显示历史聊天内容,我该如何做,我先给你看看我的对话模板。 数据可以在渲染这个模板的go程序中实现读取。就是想不好我该如何做。 <!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(); let userBubbleHtml = 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>` + marked.parse(userText); } const userMsgDiv = appendMessage("user", userBubbleHtml, false); 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"); // }, //}, //], }); </script> </body> </html>

AI 历史聊天渲染方案

发布时间:2026-08-19 15:46 👁️ 阅读:7

在 Go 后端配合 HTML 模板渲染登录用户的历史聊天记录时,最优雅且不易出错的方案是:Go 后端查询数据库并将历史数据序列化为 JSON 字符串,以 <script type="application/json"> 的形式注入到 HTML 中,随后在前端页面加载时由 JavaScript 解析并循环调用你已有的 appendMessage 函数渲染出来。

这样做的好处是,能够完美复用你前端已经写好的 Markdown 解析(marked.js)、代码复制按钮、服务通道(provider)标签等逻辑,避免在 Go 模板里写重复的 HTML 渲染代码,同时极大降低了 XSS 注入风险。

具体实现步骤如下:

第一步:Go 后端准备数据

  1. 定义与你前端数据结构对应的 Go 结构体:
type ChatMessage struct {
    Role     string `json:"role"`     // "user" 或 "ai"
    Content  string `json:"content"`  // 消息文本(Markdown)
    Provider string `json:"provider"` // 可选:AI提供商代号
}
  1. 在渲染模板的路由处理器中,查询数据库并转为 JSON 字符串:
func ChatHandler(w http.ResponseWriter, r *http.Request) {
    // 假设这是你从数据库查询出来的当前登录用户的历史消息
    var history []ChatMessage
    history = append(history, ChatMessage{Role: "user", Content: "你好,帮我写个 Go 协程"})
    history = append(history, ChatMessage{Role: "ai", Content: "没问题!
```go
go func() {
    fmt.Println(\"Hello\")
}()
```", Provider: "Silicon"})

    // 序列化为 JSON
    historyJSON, err := json.Marshal(history)
    if err != nil {
        historyJSON = []byte("[]")
    }

    // 传递给模板(注意:直接传 string 即可,无需在 Go 里转 template.JS,前端用 JSON 容器承接更安全)
    data := map[string]interface{}{
        "CHAT_UNIT":   "silicon",
        "HistoryJSON": string(historyJSON),
    }

    tmpl.Execute(w, data)
}

第二步:修改你的 HTML 模板

我们需要在 HTML 的 <body> 中安置一个“数据容器”来承接 Go 渲染出的 JSON,并在页面初始化(DOMContentLoaded)时读取并渲染它。

1. 放入 JSON 数据容器

在你的 <body> 结束标签 </body> 之前,或者任何不影响布局的地方,添加以下代码:

<!-- Go 后端注入的历史记录 JSON 容器 -->
<script id="chat-history-data" type="application/json">
  {{.HistoryJSON}}
</script>

2. 修改前端初始化 JavaScript 代码

找到你模板最底部的 document.addEventListener("DOMContentLoaded", () => { ... }) 初始化块,将其改造为:

// ==================== 🌟 初始化:加载历史记录或默认帮助信息 ====================
document.addEventListener("DOMContentLoaded", () => {
  const chatWindow = document.getElementById("chat-window");
  const spacer = document.getElementById("bottom-spacer");

  // 1. 尝试从数据容器中解析 Go 注入的历史记录
  const historyDataEl = document.getElementById("chat-history-data");
  let chatHistory = [];
  try {
    if (historyDataEl && historyDataEl.textContent.trim()) {
      chatHistory = JSON.parse(historyDataEl.textContent);
    }
  } catch (e) {
    console.error("解析历史聊天记录失败:", e);
  }

  // 2. 如果存在历史记录,循环调用你原有的方法进行渲染
  if (Array.isArray(chatHistory) && chatHistory.length > 0) {
    chatHistory.forEach(msg => {
      // 调用你已有的 appendMessage 方法渲染,保留完整的 Markdown、复制按钮和服务通道标签
      appendMessage(msg.role, msg.content, false, msg.provider || "");
    });
    
    // 渲染完毕后,将滚动条拉到最底部
    chatWindow.scrollTop = chatWindow.scrollHeight;
  }

  // 3. 检查当前是不是空窗口(只有在既没有历史记录、也没有欢迎词时,才展示默认欢迎信息)
  const hasMessages = chatWindow.querySelectorAll(".message").length > 0;
  if (!hasMessages) {
    const welcomeText = `
### 💡 欢迎使用【百问百答】AI 助手!

我是你的智能助手,已完美接入 AI 引擎。您可以问我任何难题。

---

*直接在下方输入框中写下你的问题,然后按(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);
  }
});

为什么这个方案最好?

  1. 不污染 HTML 语法:使用 <script type="application/json"> 避免了在 JS 直接书写 {{.HistoryJSON}} 时可能导致的 IDE 语法报错。
  2. 零重复代码:你不需要在 Go 语言端去处理 Markdown 转 HTML 的逻辑,直接利用前端的 marked.min.js 完成历史记录中代码块高亮、图片渲染、换行解析等所有任务。
  3. 防 XSS 注入:浏览器不会执行 application/json 脚本块中的任何 HTML 标签,数据通过 JSON.parse 读取,极其安全。