提问背景 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 历史聊天渲染方案
在 Go 后端配合 HTML 模板渲染登录用户的历史聊天记录时,最优雅且不易出错的方案是:Go 后端查询数据库并将历史数据序列化为 JSON 字符串,以 <script type="application/json"> 的形式注入到 HTML 中,随后在前端页面加载时由 JavaScript 解析并循环调用你已有的 appendMessage 函数渲染出来。
这样做的好处是,能够完美复用你前端已经写好的 Markdown 解析(marked.js)、代码复制按钮、服务通道(provider)标签等逻辑,避免在 Go 模板里写重复的 HTML 渲染代码,同时极大降低了 XSS 注入风险。
具体实现步骤如下:
第一步:Go 后端准备数据
- 定义与你前端数据结构对应的 Go 结构体:
type ChatMessage struct {
Role string `json:"role"` // "user" 或 "ai"
Content string `json:"content"` // 消息文本(Markdown)
Provider string `json:"provider"` // 可选:AI提供商代号
}
- 在渲染模板的路由处理器中,查询数据库并转为 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);
}
});
为什么这个方案最好?
- 不污染 HTML 语法:使用
<script type="application/json">避免了在 JS 直接书写{{.HistoryJSON}}时可能导致的 IDE 语法报错。 - 零重复代码:你不需要在 Go 语言端去处理 Markdown 转 HTML 的逻辑,直接利用前端的
marked.min.js完成历史记录中代码块高亮、图片渲染、换行解析等所有任务。 - 防 XSS 注入:浏览器不会执行
application/json脚本块中的任何 HTML 标签,数据通过JSON.parse读取,极其安全。