基于智谱AI的“小创”智能聊天助手——前端全栈实现解析
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小创 - AI 智能助手</title>
<style>
:root {
--bg-main: #1a1a1e;
--bg-card: #25252b;
--text-primary: #e8ecf1;
--accent: #2f6bff;
--user-bubble: #2f6bff;
--ai-bubble: #2a2a30;
--border: #3a3a42;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, sans-serif; background: var(--bg-main); color: var(--text-primary); display: flex; height: 100vh; }
.sidebar { width: 260px; background: var(--bg-card); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.main { flex: 1; display: flex; flex-direction: column; }
.chat-area { flex: 1; overflow-y: auto; padding: 20px; }
.input-area { padding: 16px 20px; border-top: 1px solid var(--border); background: var(--bg-card); display: flex; gap: 8px; align-items: flex-end; }
textarea { flex: 1; background: #1e1e24; color: #e8ecf1; border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; resize: none; min-height: 48px; max-height: 160px; font-size: 14px; }
button { background: var(--accent); color: white; border: none; padding: 10px 20px; border-radius: 12px; cursor: pointer; }
.message { margin-bottom: 16px; display: flex; flex-direction: column; animation: fadeIn 0.3s ease; }
.message.user { align-items: flex-end; }
.message.assistant { align-items: flex-start; }
.bubble { max-width: 70%; padding: 12px 16px; border-radius: 20px 20px 2px 20px; background: var(--user-bubble); }
.message.assistant .bubble { border-radius: 2px 20px 20px 20px; background: var(--ai-bubble); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.preview-card { background: #1e1e24; border-radius: 8px; padding: 8px 12px; margin: 4px 0; display: inline-flex; align-items: center; gap: 8px; }
</style>
</head>
<body>
<div class="sidebar">
<h3>对话建议</h3>
<button>今天天气如何</button>
<button>写一段代码</button>
<label>对话缓存 <input type="checkbox" id="cacheToggle" checked /></label>
</div>
<div class="main">
<div class="chat-area" id="chatOutput"></div>
<div class="input-area">
<input type="file" id="fileInput" accept="image/*,.pdf,.txt" multiple />
<textarea id="userInput" placeholder="输入消息..."></textarea>
<button id="sendBtn">发送</button>
<button id="voiceBtn">🎤</button>
</div>
</div>
<script>
const API_KEY = 'YOUR_API_KEY';
let messages = [];
let attachedFiles = [];
let isCacheEnabled = localStorage.getItem('cacheEnabled') !== 'false';
let temperature = 0.7;
document.getElementById('cacheToggle').checked = isCacheEnabled;
// --- 语音识别 ---
const voiceBtn = document.getElementById('voiceBtn');
voiceBtn.addEventListener('click', () => {
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'zh-CN';
recognition.interimResults = false;
recognition.continuous = false;
recognition.start();
voiceBtn.textContent = '🔴';
let silenceTimer = setTimeout(() => recognition.stop(), 1200);
recognition.onresult = (e) => {
document.getElementById('userInput').value = e.results[0][0].transcript;
};
recognition.onend = () => {
clearTimeout(silenceTimer);
voiceBtn.textContent = '🎤';
sendMessage();
};
});
// --- 文件上传处理 ---
document.getElementById('fileInput').addEventListener('change', (e) => {
attachedFiles = [...e.target.files];
// 预览卡片展示逻辑省略,实际会渲染预览
});
// --- 发送消息 ---
async function sendMessage() {
const input = document.getElementById('userInput');
const content = input.value.trim();
if (!content && attachedFiles.length === 0) return;
messages.push({ role: 'user', content });
input.value = '';
renderChat();
await fetchAIResponse();
}
// --- API 调用与流式处理 ---
async function fetchAIResponse() {
const model = attachedFiles.some(f => f.type.startsWith('image/')) ? 'glm-4v' : 'glm-4-flash';
const body = {
model,
messages: [{ role: 'system', content: '你是小创,一个乐于助人的AI助手' }, ...messages],
stream: true,
temperature
};
const res = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify(body)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
messages.push({ role: 'assistant', content: '' });
renderChat();
const output = document.getElementById('chatOutput');
const aiBubble = output.lastElementChild.querySelector('.bubble');
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') return;
try {
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
if (delta) {
messages[messages.length - 1].content += delta;
aiBubble.textContent += delta;
}
} catch {}
}
}
if (isCacheEnabled) localStorage.setItem('chatHistory', JSON.stringify(messages));
}
// --- 渲染引擎 ---
function renderChat() {
const output = document.getElementById('chatOutput');
output.innerHTML = '';
messages.forEach(msg => {
const div = document.createElement('div');
div.className = `message ${msg.role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = msg.content;
div.appendChild(bubble);
output.appendChild(div);
});
output.scrollTop = output.scrollHeight;
}
// --- 缓存恢复 ---
if (isCacheEnabled) {
const saved = localStorage.getItem('chatHistory');
if (saved) { try { messages = JSON.parse(saved); renderChat(); } catch {} }
}
document.getElementById('cacheToggle').addEventListener('change', (e) => {
isCacheEnabled = e.target.checked;
localStorage.setItem('cacheEnabled', isCacheEnabled);
if (!isCacheEnabled) localStorage.removeItem('chatHistory');
});
// --- 深度思考切换 ---
document.querySelector('.sidebar').insertAdjacentHTML('beforeend', '<label>深度思考 <input type="checkbox" id="deepThink" /></label>');
document.getElementById('deepThink').addEventListener('change', (e) => {
temperature = e.target.checked ? 0.3 : 0.7;
});
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('userInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
</script>
</body>
</html>
一、项目简介
“小创”是一款基于智谱AI大模型(GLM系列)的智能聊天助手,采用纯前端技术栈构建,界面风格借鉴了3DMAX的深色主题设计。整个应用运行在浏览器端,通过调用智谱AI开放平台的Chat Completions API,实现了流式对话、多模态识别(图片文档上传)、语音输入等丰富的交互能力。
项目完全使用原生HTML、CSS和JavaScript编写,无需任何框架依赖,是一个非常适合学习前端工程化与AI应用开发结合的实战案例。
二、界面设计亮点
2.1 整体布局
界面采用经典的侧边栏+主内容区双栏布局。左侧260px宽的侧边栏承载了"对话建议"快捷入口、对话缓存开关以及设置等辅助功能;右侧主区域则完全服务于对话交互,由上方的消息滚动画布和下方的输入组件组成。
<div class="sidebar">
<!-- 左侧边栏:对话建议、设置入口 -->
</div>
<div class="main">
<div class="chat-area"><!-- 消息滚动区 --></div>
<div class="input-area"><!-- 输入组件 --></div>
</div>
body {
display: flex;
height: 100vh;
}
.sidebar {
width: 260px;
background: #25252b;
border-right: 1px solid #3a3a42;
padding: 20px;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-area {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.input-area {
padding: 16px 20px;
border-top: 1px solid #3a3a42;
background: #25252b;
display: flex;
gap: 8px;
align-items: flex-end;
}
2.2 深色主题设计
整体配色以深灰暗色系为基调,通过CSS变量统一管理设计令牌。主背景色使用#1a1a1e,卡片背景使用#25252b,文字主色为#e8ecf1,辅以蓝色强调色#2f6bff。这种配色方案有效降低了长时间使用的视觉疲劳,也让AI助手的对话气泡在深色背景上更加突出。
:root {
--bg-main: #1a1a1e; /* 主背景 */
--bg-card: #25252b; /* 卡片/面板背景 */
--text-primary: #e8ecf1; /* 文字主色 */
--accent: #2f6bff; /* 强调色 */
--user-bubble: #2f6bff; /* 用户气泡 */
--ai-bubble: #2a2a30; /* AI 气泡 */
--border: #3a3a42; /* 边框色 */
}
body {
background: var(--bg-main);
color: var(--text-primary);
font-family: -apple-system, sans-serif;
}
2.3 对话气泡系统
消息展示区分了用户消息和AI回复两种气泡样式:用户气泡采用蓝色背景、靠右对齐,AI气泡采用卡片样式、靠左对齐,并带有微妙的圆角差异(用户为2px+20px组合,AI为20px+2px组合),让对话流向一目了然。所有新消息都带有渐入动画效果,交互体验流畅自然。
.message {
margin-bottom: 16px;
display: flex;
flex-direction: column;
animation: fadeIn 0.3s ease;
}
.message.user { align-items: flex-end; }
.message.assistant { align-items: flex-start; }
.bubble {
max-width: 70%;
padding: 12px 16px;
border-radius: 20px 20px 2px 20px; /* 用户:右上直角 /
background: var(--user-bubble);
}
.message.assistant .bubble {
border-radius: 2px 20px 20px 20px; / AI:左上直角 */
background: var(--ai-bubble);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
三、核心功能解析
3.1 流式对话与打字机效果
应用支持Server-Sent Events(SSE)流式响应,通过Fetch API读取智谱AI返回的流式数据,实时逐字渲染AI回复内容。实现上使用ReadableStream的getReader()方法逐块读取响应体,并解析data:格式的SSE数据行,提取delta.content字段逐步拼接到前端DOM中,营造出"AI正在思考并打字"的真实感。
// 流式聊天核心逻辑
async function streamChat() {
const response = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: 'glm-4-flash',
messages: [{ role: 'user', content: '你好' }],
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') return;
try {
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
if (delta) {
// 逐字追加,实现打字机效果
for (const char of delta) {
outputElement.textContent += char;
await new Promise(resolve => setTimeout(resolve, 20));
}
}
} catch {}
}
}
}
3.2 多模态输入支持
除了常规的文本输入,小创还支持图片上传和文档上传两种多模态输入方式。图片模式下会调用智谱AI的glm-4v视觉模型进行图像理解;文档模式下会将文件内容以文本形式嵌入到对话上下文中。上传的文件会以预览卡片的形式展示在输入框上方,用户可以随时移除。
// 文件上传与多模态处理
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', (e) => {
attachedFiles = [...e.target.files];
renderPreviewCards(); // 渲染预览卡片
});
// 根据附件类型选择模型
function selectModel() {
return attachedFiles.some(f => f.type.startsWith('image/'))
? 'glm-4v'
: 'glm-4-flash';
}
// 文档文件转为文本嵌入上下文
async function readDocumentContent(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.readAsText(file);
});
}
3.3 语音输入
利用浏览器的Web Speech API实现了语音转文字功能。点击麦克风按钮后开始监听,识别结果会实时填充到输入框中,并设置了1.2秒的静音自动结束机制,提升了语音交互的便捷性。识别完成后自动发送消息,实现了"说完即发送"的无缝体验。
// Web Speech API 语音识别
voiceBtn.addEventListener('click', () => {
const recognition = new (window.SpeechRecognition
|| window.webkitSpeechRecognition)();
recognition.lang = 'zh-CN';
recognition.continuous = false;
recognition.start();
let silenceTimer = setTimeout(() => recognition.stop(), 1200);
recognition.onresult = (e) => {
userInput.value = e.results[0][0].transcript;
};
recognition.onend = () => {
clearTimeout(silenceTimer);
sendMessage(); // 自动发送
};
});
3.4 对话缓存与持久化
应用利用localStorage实现了对话历史的本地持久化存储,关闭页面后再次打开可恢复之前的对话记录。同时提供了缓存开关,用户可以根据需要开启或关闭此功能,兼顾了便捷性和隐私保护。
// localStorage 对话缓存
function saveCache() {
if (isCacheEnabled) {
localStorage.setItem('chatHistory', JSON.stringify(messages));
}
}
function loadCache() {
const saved = localStorage.getItem('chatHistory');
if (saved) {
try { messages = JSON.parse(saved); } catch {}
}
}
// 缓存开关
cacheToggle.addEventListener('change', (e) => {
isCacheEnabled = e.target.checked;
localStorage.setItem('cacheEnabled', isCacheEnabled);
if (!isCacheEnabled) {
localStorage.removeItem('chatHistory');
}
});
3.5 深度思考模式
通过调整API调用时的temperature参数,实现了"深度思考"模式的切换。普通模式下temperature为0.7,回答风格更具创造性;深度思考模式下temperature降至0.3,回答更加严谨和逻辑化。这个设计让用户可以根据场景灵活切换AI的行为模式。
// 深度思考模式:调整 temperature 参数
let temperature = 0.7;
deepThinkToggle.addEventListener('change', (e) => {
temperature = e.target.checked ? 0.3 : 0.7;
});
// API 调用时传入 temperature
async function fetchAIResponse() {
const body = {
model: 'glm-4-flash',
messages: [...messages],
stream: true,
temperature // 关键参数
};
// ... 发起请求
}
四、代码架构分析
4.1 全局状态管理
应用的核心状态集中在几个关键变量中:messages数组存储完整的对话历史,attachedFiles数组管理用户上传的附件,isCacheEnabled和isTTSEnabled等布尔变量控制功能开关。这种扁平化的状态管理方式虽然简单,但对于单页面应用来说足够清晰且易于维护。
// 全局状态变量
let messages = []; // 对话历史:[{ role, content }]
let attachedFiles = []; // 用户上传的附件
let isCacheEnabled = // 是否启用本地缓存
localStorage.getItem('cacheEnabled') !== 'false';
let temperature = 0.7; // 深度思考参数
4.2 API调用流程
核心的对话请求流程如下:
- 构建消息数组,包含系统角色提示词(设定AI身份为"小创")
- 检测是否有图片附件,决定调用视觉模型还是文本模型
- 构建POST请求体,设置stream参数
- 根据是否为流式模式,分别处理SSE流式数据或一次性JSON响应
- 将AI回复追加到消息历史并重新渲染
// API 调用完整流程
async function sendMessage() {
// 1. 收集用户输入
const content = userInput.value.trim();
messages.push({ role: 'user', content });
renderChat();
// 2. 选择模型
const model = attachedFiles.some(f => f.type.startsWith('image/'))
? 'glm-4v'
: 'glm-4-flash';
// 3. 构建请求体
const body = {
model,
messages: [
{ role: 'system', content: '你是小创,一个乐于助人的AI助手' },
...messages
],
stream: true,
temperature
};
// 4. 发起请求
const res = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${API_KEY}
},
body: JSON.stringify(body)
});
// 5. 处理流式响应
await handleStreamResponse(res);
}
async function handleStreamResponse(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
messages.push({ role: 'assistant', content: '' });
renderChat();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') return;
try {
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
if (delta) {
messages[messages.length - 1].content += delta;
updateLastBubble(delta);
}
} catch {}
}
}
saveCache();
}
4.3 渲染引擎
renderChat()函数是整个UI的核心渲染入口,它遍历messages数组并为每条消息创建对应的DOM元素。渲染时还会对AI回复内容进行Markdown解析,将代码块、加粗、列表等格式转换为HTML展示,并通过MathJax库支持数学公式的渲染。
// 渲染引擎核心
function renderChat() {
const output = document.getElementById('chatOutput');
output.innerHTML = ''; // 全量重绘
messages.forEach(msg => {
const messageDiv = document.createElement('div');
messageDiv.className = message ${msg.role};
const bubble = document.createElement('div');
bubble.className = 'bubble';
// 对 AI 回复进行 Markdown 解析(简化示例)
if (msg.role === 'assistant') {
bubble.innerHTML = marked.parse(msg.content);
} else {
bubble.textContent = msg.content;
}
messageDiv.appendChild(bubble);
output.appendChild(messageDiv);
});
// 滚动到最新消息
output.scrollTop = output.scrollHeight;
}
五、总结与展望
“小创”作为一个纯前端AI聊天应用,展示了如何在不依赖任何前端框架的情况下,构建一个功能完备、交互流畅的智能对话界面。项目涵盖了流式响应处理、多模态输入、语音识别、本地存储等多个实用技术点,是前端开发者入门AI应用开发的优秀参考。
未来可以考虑的优化方向包括:增加对话主题切换功能、支持对话导出与分享、引入对话分支管理、以及接入更多大模型提供商实现多模型切换等。
六、代码接入
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>彬佑AI · 4D超曲面集成 (3DMAX风格)</title>
<style>
:root {
--bg-page: #1a1a1e;
--bg-sidebar: #222226;
--bg-sidebar-hover: #2c2c32;
--bg-card: #25252b;
--bg-card-hover: #2f2f36;
--bg-input: #1e1e22;
--bg-input-focus: #28282e;
--bg-user-bubble: #2f6bff;
--border-main: #3a3a42;
--text-primary: #e8ecf1;
--text-secondary: #8f95a3;
--text-tertiary: #535865;
--accent-blue: #2f6bff;
--shadow-soft: 0 8px 20px rgba(0,0,0,0.6);
--radius-main: 16px;
--radius-sm: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: -apple-system, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif; }
*:focus, *:focus-visible, *:focus-within { outline: none !important; box-shadow: none !important; }
button::-moz-focus-inner, input::-moz-focus-inner { border: 0; }
body { background: var(--bg-page); color: var(--text-primary); height: 100vh; display: flex; overflow: hidden; font-size: 15px; }
::-webkit-scrollbar { width: 0px !important; display: none !important; }
* { scrollbar-width: none !important; }
/* 侧边栏 */
.sidebar {
width: 260px; background: var(--bg-sidebar); height: 100vh; padding: 20px 16px 16px 16px;
display: flex; flex-direction: column; flex-shrink: 0; border-right: 1px solid var(--border-main);
z-index: 10;
}
.logo-box { display: flex; align-items: center; gap: 12px; padding-bottom: 30px; color: var(--text-primary); font-weight: 600; font-size: 1.15rem; }
.logo-svg { width: 28px; height: 28px; fill: #e8ecf1; flex-shrink: 0; }
.suggest-scroll-area { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-top: 0; }
.suggest-title { font-size: 0.75rem; color: var(--text-tertiary); padding: 0 4px 14px 4px; font-weight: 500; }
.suggest-item { padding: 10px 12px; border-radius: var(--radius-sm); font-size: 0.9rem; color: var(--text-secondary); cursor: pointer; transition: 0.2s; display: flex; align-items: center; gap: 12px; background: transparent; }
.suggest-item:hover { background: var(--bg-sidebar-hover); color: var(--text-primary); }
.suggest-item svg { width: 16px; height: 16px; stroke: currentColor; stroke-width: 1.5; fill: none; flex-shrink: 0; }
.sidebar-bottom { margin-top: auto; border-top: 1px solid var(--border-main); padding-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.side-btn { width: 100%; display: flex; align-items: center; gap: 12px; padding: 10px 6px; border: none; background: transparent; border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer; font-size: 0.85rem; transition: 0.2s; }
.side-btn:hover { background: var(--bg-sidebar-hover); color: var(--text-primary); }
.side-btn svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 1.5; fill: none; }
.cache-toggle-wrap { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.switch { width: 34px; height: 18px; background: var(--bg-card); border-radius: 12px; position: relative; cursor: pointer; transition: 0.3s; border: 1px solid var(--border-main); }
.switch.active { background: var(--accent-blue); border-color: var(--accent-blue); }
.switch::after { content: ''; position: absolute; width: 12px; height: 12px; background: white; border-radius: 50%; top: 2px; left: 2px; transition: 0.3s; }
.switch.active::after { left: 18px; }
/* 主聊天区域 */
.main-wrap { flex: 1; display: flex; flex-direction: column; height: 100vh; background: var(--bg-page); position: relative; min-width: 0; }
.chat-container { flex: 1; overflow-y: auto; display: flex; flex-direction: column; align-items: center; padding: 20px 0; scroll-behavior: smooth; position: relative; z-index: 5;}
.chat-inner { width: 100%; max-width: 820px; padding: 0 20px; display: flex; flex-direction: column; flex: 1; gap: 20px; }
/* 欢迎页 */
.welcome-screen { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; padding-bottom: 60px; margin-top: -40px; }
.welcome-screen.hidden { display: none; }
.welcome-logo { width: 72px; height: 72px; background: var(--bg-card); border-radius: 24px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--border-main); box-shadow: var(--shadow-soft); }
.welcome-logo svg { width: 34px; height: 34px; fill: #e8ecf1; }
.welcome-text { font-size: 1.8rem; font-weight: 600; color: var(--text-primary); }
.welcome-sub { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 4px; }
.welcome-suggestions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.welcome-card { background: var(--bg-card); border: 1px solid var(--border-main); padding: 12px 18px; border-radius: var(--radius-main); font-size: 0.9rem; cursor: pointer; transition: 0.2s; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
.welcome-card:hover { background: var(--bg-card-hover); border-color: rgba(255,255,255,0.1); transform: translateY(-2px); }
.welcome-card svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.5; }
/* 气泡 */
.msg-row { display: flex; gap: 16px; width: 100%; animation: fadeUp 0.3s ease; padding: 4px 0; }
.msg-row.user { flex-direction: row-reverse; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.avatar { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 13px; margin-top: 2px; }
.msg-row.ai .avatar { background: var(--bg-card); color: var(--accent-blue); border: 1px solid var(--border-main); }
.msg-row.user .avatar { background: var(--accent-blue); color: white; }
.bubble-wrap { max-width: 85%; display: flex; flex-direction: column; gap: 4px; }
.msg-row.user .bubble-wrap { align-items: flex-end; }
.bubble { padding: 12px 18px; border-radius: 20px; font-size: 0.95rem; line-height: 1.6; word-wrap: break-word; }
.msg-row.ai .bubble { background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border-main); border-radius: 2px 20px 20px 20px; }
.msg-row.user .bubble { background: var(--bg-user-bubble); color: white; border-radius: 20px 2px 20px 20px; }
.bubble pre { background: #0a0b0e; padding: 14px; border-radius: var(--radius-sm); overflow-x: auto; margin: 10px 0; border: 1px solid var(--border-main); position: relative; }
.bubble pre code { font-size: 13px; line-height: 1.6; color: #d4d4d4; font-family: monospace; }
.copy-code-btn { position: absolute; top: 8px; right: 10px; background: var(--bg-card); border: 1px solid var(--border-main); padding: 2px 10px; border-radius: 6px; font-size: 11px; color: var(--text-secondary); cursor: pointer; transition: 0.2s; }
.copy-code-btn:hover { color: var(--text-primary); background: var(--bg-sidebar-hover); }
.copy-code-btn.copied { background: var(--accent-blue); color: white; border-color: var(--accent-blue); }
/* 数学公式渲染 */
.bubble .math { display: block; text-align: center; margin: 12px 0; padding: 8px; background: rgba(0,0,0,0.2); border-radius: 8px; }
.bubble .math.inline { display: inline-block; margin: 0 4px; padding: 0 4px; background: rgba(0,0,0,0.15); border-radius: 4px; }
/* 输入框 */
.input-area-wrap { padding: 0 20px 20px 20px; background: transparent; flex-shrink: 0; display: flex; justify-content: center; z-index: 10; position: relative;}
.input-box { width: 100%; max-width: 820px; background: var(--bg-input); border-radius: 24px; padding: 12px 16px 12px 16px; display: flex; flex-direction: column; gap: 4px; border: 1px solid var(--border-main); transition: 0.3s; position: relative; }
.input-box:focus-within { background: var(--bg-input-focus); border-color: rgba(47, 107, 255, 0.3); }
.input-label { position: absolute; top: 10px; left: 16px; font-size: 18px; color: var(--text-tertiary); pointer-events: none; user-select: none; z-index: 1; }
textarea#userInput { width: 100%; background: transparent; border: none; color: var(--text-primary); font-size: 1rem; resize: none; outline: none; line-height: 1.5; height: 24px; min-height: 24px; max-height: 150px; font-family: inherit; padding: 16px 0 2px 0; }
textarea#userInput::placeholder { color: var(--text-tertiary); }
.input-box:focus-within .input-label,
.input-box.has-text .input-label { display: none; }
.input-bottom-tools { display: flex; justify-content: space-between; align-items: center; margin-top: 4px;}
.input-tools-left { display: flex; align-items: center; }
.deep-think-btn { display: flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; background: transparent; border: 1px solid var(--border-main); color: var(--text-tertiary); font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.deep-think-btn:hover { background: var(--bg-sidebar-hover); color: var(--text-secondary); border-color: var(--text-tertiary); }
.deep-think-btn.active { border-color: var(--accent-blue); color: var(--accent-blue); background: rgba(47, 107, 255, 0.1); }
.deep-think-btn svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 1.5; fill: none; }
.input-tools-right { display: flex; align-items: center; gap: 6px; }
.tool-btn { background: transparent; border: 1px solid transparent; color: var(--text-tertiary); cursor: pointer; transition: 0.2s; display: flex; align-items: center; justify-content: center; border-radius: 6px; width: 30px; height: 30px; outline: none !important; box-shadow: none !important; }
.tool-btn:focus, .tool-btn:focus-visible, .tool-btn:focus-within { outline: none !important; box-shadow: none !important; }
#ttsToggleBtn:focus, #ttsToggleBtn:focus-visible { outline: none !important; box-shadow: none !important; }
.tool-btn:hover { background: rgba(255,255,255,0.06); color: var(--text-secondary); border-color: var(--border-main); }
.tool-btn.active { color: var(--accent-blue); border-color: var(--accent-blue); background: rgba(47,107,255,0.12); }
.tool-btn.recording { color: #ff4d4f; border-color: #ff4d4f; background: rgba(255,77,79,0.12); animation: mic-pulse 1.2s ease-in-out infinite; }
@keyframes mic-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(255,77,79,0.4); } 50% { box-shadow: 0 0 0 8px rgba(255,77,79,0); } }
.tool-btn svg { width: 16px; height: 16px; stroke: currentColor; stroke-width: 1.5; fill: none; }
.send-btn { width: 30px; height: 30px; background: var(--text-tertiary); border: none; border-radius: 50%; color: var(--bg-input); display: flex; align-items: center; justify-content: center; transition: 0.2s; cursor: default; pointer-events: none; }
.send-btn svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; fill: none; }
.send-btn.active { background: var(--accent-blue); cursor: pointer; pointer-events: auto; color: white; box-shadow: 0 2px 8px rgba(47, 107, 255, 0.2); }
.send-btn.active:hover { transform: scale(1.05); }
.preview-area { display: flex; flex-wrap: wrap; gap: 6px; padding-bottom: 4px; }
.preview-item { position: relative; background: var(--bg-sidebar); border: 1px solid var(--border-main); border-radius: var(--radius-sm); padding: 2px 8px; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-secondary); }
.preview-item img { width: 28px; height: 28px; object-fit: cover; border-radius: 4px; }
.del-preview { position: absolute; top: -5px; right: -5px; background: #ff4d4f; border: none; border-radius: 50%; color: white; width: 14px; height: 14px; font-size: 10px; text-align: center; cursor: pointer; }
.file-attach { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.08); padding: 4px 12px 4px 10px; border-radius: 8px; margin-bottom: 8px; max-width: 220px; font-size: 12px; color: inherit; cursor: default; }
.file-attach svg { flex-shrink: 0; }
.file-attach span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attach-img-wrap { display: block; margin-bottom: 8px; max-width: 240px; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-main); }
.attach-img-wrap img { display: block; width: 100%; height: auto; border-radius: 9px; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 1000; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
.modal-overlay.active { display: flex; }
.modal-box { background: var(--bg-card); border: 1px solid var(--border-main); border-radius: 20px; padding: 24px; width: 380px; }
.modal-box h3 { margin-bottom: 14px; font-size: 1rem; color: var(--text-primary); }
.modal-close { float: right; background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-tertiary); }
.modal-select { width: 100%; padding: 10px; background: var(--bg-sidebar); color: var(--text-primary); border: 1px solid var(--border-main); border-radius: var(--radius-sm); font-size: 0.9rem; margin-bottom: 14px; outline: none; }
.modal-btn { width: 100%; padding: 10px; background: var(--accent-blue); border: none; border-radius: var(--radius-sm); color: white; font-size: 0.9rem; cursor: pointer; }
video { width: 100%; border-radius: var(--radius-sm); background: black; }
.cam-btn { padding: 8px 16px; border-radius: var(--radius-sm); border: none; font-size: 0.85rem; cursor: pointer; }
.cam-btn.prim { background: var(--accent-blue); color: white; }
.cam-btn.sec { background: var(--bg-sidebar); color: var(--text-secondary); border: 1px solid var(--border-main); }
.cam-controls { display: flex; gap: 12px; justify-content: center; margin-top: 14px; }
/* =======================================================
4D 超曲面渲染容器 (3DMAX风格)
======================================================= */
#hyper-surface-container {
display: none;
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 200;
background: #0a0a1a;
overflow: hidden;
flex-direction: column;
}
#hyper-surface-container.active {
display: flex;
}
/* 3DMAX风格工具栏 */
#hyper-toolbar {
position: absolute;
top: 0; left: 0; right: 0;
height: 48px;
background: rgba(20, 20, 40, 0.92);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
padding: 0 16px;
gap: 8px;
z-index: 220;
flex-shrink: 0;
}
#hyper-toolbar .toolbar-group {
display: flex;
gap: 4px;
align-items: center;
padding: 0 10px;
border-right: 1px solid rgba(255,255,255,0.06);
}
#hyper-toolbar .toolbar-group:last-child { border-right: none; }
#hyper-toolbar .tb-btn {
background: transparent;
border: none;
color: #8888aa;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
#hyper-toolbar .tb-btn:hover { background: rgba(255,255,255,0.08); color: #ffffff; }
#hyper-toolbar .tb-btn.active { background: rgba(47, 107, 255, 0.2); color: #6bcbff; }
#hyper-toolbar .tb-btn svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 1.5; fill: none; }
/* 3D视口 */
#hyper-viewport {
flex: 1;
position: relative;
overflow: hidden;
}
#hyper-viewport canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
/* 右侧属性面板 */
#hyper-properties {
position: absolute;
right: 0; top: 48px; bottom: 0;
width: 280px;
background: rgba(16, 16, 32, 0.92);
backdrop-filter: blur(20px);
border-left: 1px solid rgba(255,255,255,0.06);
padding: 16px;
overflow-y: auto;
z-index: 210;
display: none;
}
#hyper-properties.visible { display: block; }
#hyper-properties .prop-group { margin-bottom: 16px; }
#hyper-properties .prop-group label { display: block; font-size: 11px; text-transform: uppercase; color: #666688; margin-bottom: 4px; font-weight: 600; letter-spacing: 0.5px; }
#hyper-properties .prop-group input[type="range"] { width: 100%; -webkit-appearance: none; height: 4px; border-radius: 2px; background: linear-gradient(90deg, #ff6b6b, #ffd93d); outline: none; }
#hyper-properties .prop-group input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #ff6b6b; cursor: pointer; border: 2px solid rgba(255,255,255,0.2); }
#hyper-properties .prop-group .value { font-size: 13px; font-weight: 600; color: #ff6b6b; text-align: right; margin-top: 2px; }
#hyper-properties .prop-group .color-row { display: flex; gap: 8px; align-items: center; }
#hyper-properties .prop-group .color-row input[type="color"] { width: 32px; height: 32px; border: none; border-radius: 6px; background: transparent; cursor: pointer; padding: 0; }
/* 底部信息栏 */
#hyper-stats {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 32px;
background: rgba(10, 10, 26, 0.85);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255,255,255,0.05);
display: flex;
align-items: center;
padding: 0 16px;
gap: 24px;
z-index: 210;
font-size: 11px;
color: #555577;
font-family: 'Courier New', monospace;
}
#hyper-stats span { color: #8888aa; }
#hyper-stats .stat-value { color: #aabbdd; }
#close-hyper {
position: absolute;
top: 52px; right: 12px;
z-index: 220;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #8888aa;
padding: 6px 14px;
border-radius: 12px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: 0.3s;
}
#close-hyper:hover { background: rgba(255, 107, 107, 0.15); border-color: #ff6b6b; color: #ff6b6b; }
@media (max-width: 768px) {
#hyper-properties { width: 200px; padding: 12px; }
#hyper-toolbar .tb-btn span { display: none; }
#hyper-toolbar .tb-btn { padding: 4px 8px; }
}
</style>
</head>
<body>
<!-- 侧边栏 -->
<div class="sidebar">
<div class="logo-box">
<svg class="logo-svg" viewBox="0 0 24 24"><path d="M12 2 L13.5 10.5 L22 12 L13.5 13.5 L12 22 L10.5 13.5 L2 12 L10.5 10.5 Z"/></svg>
彬佑AI
</div>
<div class="suggest-scroll-area">
<div class="suggest-title">猜你想搜</div>
<div class="suggest-item" onclick="fillInput('用Python写一个贪吃蛇游戏')">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h4"/></svg> 用Python写一个贪吃蛇游戏
</div>
<div class="suggest-item" onclick="fillInput('JavaScript数组去重的方法')">
<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4V4z"/><path d="M8 8h8M8 12h8M8 16h4"/></svg> JS数组去重方法
</div>
<div class="suggest-item" onclick="fillInput('React和Vue的区别')">
<svg viewBox="0 0 24 24"><path d="M12 2.5L2.5 12 12 21.5 21.5 12 12 2.5z"/><path d="M8.5 12l2 2 5-5"/></svg> React与Vue区别
</div>
<div class="suggest-item" onclick="fillInput('大语言模型底层原理')">
<svg viewBox="0 0 24 24"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4"/></svg> LLM底层原理
</div>
</div>
<div class="sidebar-bottom">
<button class="side-btn" onclick="toggleHyperSurface()" style="margin-bottom: 4px;">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none"><path d="M3 3h18v18H3z" stroke-width="1.5"/><path d="M8 12h8M12 8v8" stroke-width="1.5"/></svg> 4D 建模
</button>
<button class="side-btn" onclick="clearAllHistory()">
<svg viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg> 清除所有对话
</button>
<button class="side-btn" onclick="exportHistory()">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> 导出缓存
</button>
<div class="side-btn" style="cursor: default;">
<div class="cache-toggle-wrap">
<span style="display:flex;align-items:center;gap:6px;">
<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9"/><path d="M12 6v6l3 3"/></svg> 开启回答缓存
</span>
<button class="switch active" id="cacheToggle" onclick="toggleCache()"></button>
</div>
</div>
<div class="side-btn" style="cursor: default;">
<div class="cache-toggle-wrap">
<span style="display:flex;align-items:center;gap:6px;">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> 语音朗读
</span>
<span class="tts-status" id="ttsStatus" style="font-size:11px;color:#4ecdc4;font-weight:500;">已开启</span>
</div>
</div>
</div>
</div>
<!-- 聊天主界面 -->
<div class="main-wrap">
<div class="chat-container" id="chatContainer">
<div class="chat-inner" id="chatInner">
<div class="welcome-screen" id="welcomeScreen">
<div class="welcome-logo">
<svg viewBox="0 0 24 24"><path d="M12 2 L13.5 10.5 L22 12 L13.5 13.5 L12 22 L10.5 13.5 L2 12 L10.5 10.5 Z"/></svg>
</div>
<div class="welcome-text">彬佑AI 智能助手</div>
<div class="welcome-sub">极致性能 · 深度推理 · 3DMAX风格4D建模</div>
<div class="welcome-suggestions">
<div class="welcome-card" onclick="fillInput('用Python写个贪吃蛇游戏')">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h4"/></svg> 贪吃蛇游戏
</div>
<div class="welcome-card" onclick="fillInput('帮我写一封英文邮件')">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 8l9 5 9-5"/></svg> 撰写英文邮件
</div>
<div class="welcome-card" onclick="fillInput('解释Transformer模型')">
<svg viewBox="0 0 24 24"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4"/></svg> Transformer原理
</div>
</div>
</div>
<div id="messagesList"></div>
</div>
</div>
<!-- 输入框 -->
<div class="input-area-wrap">
<div class="input-box" id="inputBox">
<div class="preview-area" id="uploadPreview"></div>
<div class="input-label" id="inputLabel">给彬佑AI发送消息...</div>
<textarea id="userInput" rows="1"></textarea>
<div class="input-bottom-tools">
<div class="input-tools-left">
<button class="deep-think-btn" id="deepThinkBtn" onclick="toggleDeepThink()">
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1.27A7 7 0 0 1 14 23h-4a7 7 0 0 1-6.73-4H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"/></svg>
深度思考
</button>
</div>
<div class="input-tools-right">
<button class="tool-btn" onclick="document.getElementById('fileInput').click()" title="上传文件">
<svg viewBox="0 0 24 24"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
</button>
<button class="tool-btn" onclick="startVoiceInput()" title="语音输入">
<svg viewBox="0 0 24 24"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v4"/></svg>
</button>
<button class="tool-btn" id="ttsToggleBtn" onclick="toggleTTS()" title="语音朗读">
<svg viewBox="0 0 24 24"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
</button>
<button class="tool-btn" onclick="openCamera()" title="拍照">
<svg viewBox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
<svg viewBox="0 0 24 24"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<input type="file" id="fileInput" accept="image/*,application/pdf,application/msword,text/plain,.docx,.xls,.xlsx,.ppt,.pptx" multiple style="display:none;">
<div class="modal-overlay" id="settingsModal">
<div class="modal-box">
<button class="modal-close" onclick="closeSettings()">×</button>
<h3>模型设置</h3>
<select class="modal-select" id="modelSelect">
<option value="glm-5.2">GLM-5.2 (推荐)</option>
<option value="glm-4v">GLM-4V (图像解析)</option>
<option value="glm-4.6v">GLM-4.6V (图像解析-增强)</option>
<option value="glm-4-flash">GLM-4-Flash</option>
</select>
<button class="modal-btn" onclick="saveSettings()">保存设置</button>
</div>
</div>
<div class="modal-overlay" id="cameraModal">
<div class="modal-box" style="width: 420px;">
<button class="modal-close" onclick="closeCamera()">×</button>
<h3>拍照答疑</h3>
<video id="cameraVideo" autoplay playsinline style="width:100%; height:auto;"></video>
<div class="cam-controls">
<button class="cam-btn sec" onclick="closeCamera()">取消</button>
<button class="cam-btn prim" onclick="capturePhoto()">拍照</button>
</div>
</div>
</div>
<!-- =======================================================
4D 超曲面渲染容器 (3DMAX风格)
======================================================= -->
<div id="hyper-surface-container">
<!-- 工具栏 -->
<div id="hyper-toolbar">
<div class="toolbar-group">
<button class="tb-btn active" id="tbSelect"><svg viewBox="0 0 24 24"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg> <span>选择</span></button>
<button class="tb-btn" id="tbMove"><svg viewBox="0 0 24 24"><path d="M5 9l3-3 3 3M5 15l3 3 3-3M19 9l-3-3-3 3M19 15l-3 3-3-3"/></svg> <span>移动</span></button>
<button class="tb-btn" id="tbRotate"><svg viewBox="0 0 24 24"><path d="M12 2v4M12 22v-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M22 12h-4"/></svg> <span>旋转</span></button>
<button class="tb-btn" id="tbScale"><svg viewBox="0 0 24 24"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg> <span>缩放</span></button>
</div>
<div class="toolbar-group">
<button class="tb-btn" id="tbReset"><svg viewBox="0 0 24 24"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> <span>重置视图</span></button>
<button class="tb-btn" id="tbAutoRotate"><svg viewBox="0 0 24 24"><path d="M12 2v4M12 22v-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M22 12h-4"/><path d="M12 8v8M8 12h8"/></svg> <span>自动旋转</span></button>
<button class="tb-btn" id="tbWireframe"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg> <span>线框</span></button>
<button class="tb-btn" id="tbParticles"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="18" cy="18" r="2"/><circle cx="6" cy="6" r="2"/></svg> <span>粒子</span></button>
</div>
<div class="toolbar-group" style="margin-left:auto;">
<button class="tb-btn" id="tbProperties"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg> <span>属性</span></button>
</div>
</div>
<!-- 3D视口 -->
<div id="hyper-viewport"></div>
<!-- 右侧属性面板 -->
<div id="hyper-properties">
<div class="prop-group">
<label>Alpha (α)</label>
<input type="range" id="paramA" min="-3" max="3" step="0.01" value="1.5">
<div class="value" id="valA">1.50</div>
</div>
<div class="prop-group">
<label>Beta (β)</label>
<input type="range" id="paramB" min="-3" max="3" step="0.01" value="0.8">
<div class="value" id="valB">0.80</div>
</div>
<div class="prop-group">
<label>Gamma (γ)</label>
<input type="range" id="paramC" min="-3" max="3" step="0.01" value="0.5">
<div class="value" id="valC">0.50</div>
</div>
<div class="prop-group">
<label>Delta (δ)</label>
<input type="range" id="paramD" min="-3" max="3" step="0.01" value="0.3">
<div class="value" id="valD">0.30</div>
</div>
<div class="prop-group">
<label>Epsilon (ε)</label>
<input type="range" id="paramE" min="-3" max="3" step="0.01" value="0.2">
<div class="value" id="valE">0.20</div>
</div>
<div class="prop-group">
<label>Zeta (ζ)</label>
<input type="range" id="paramF" min="-3" max="3" step="0.01" value="0.7">
<div class="value" id="valF">0.70</div>
</div>
<div class="prop-group">
<label>表面颜色</label>
<div class="color-row">
<input type="color" id="surfaceColor" value="#ff6b6b">
<span style="font-size:12px; color:#8888aa;">渐变叠加</span>
</div>
</div>
</div>
<!-- 底部状态栏 -->
<div id="hyper-stats">
<span>FPS: <span class="stat-value" id="fps">60</span></span>
<span>顶点: <span class="stat-value" id="vertices">0</span></span>
<span>参数: <span class="stat-value" id="paramsDisplay">α:1.50 β:0.80 γ:0.50</span></span>
<span style="margin-left:auto;">拖拽旋转 · 滚轮缩放</span>
</div>
<button id="close-hyper" onclick="toggleHyperSurface()">✕ 返回聊天</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
// 4D Scene setup (3DMAX风格)
const hyperContainer = document.getElementById('hyper-viewport');
const hyperScene = new THREE.Scene();
hyperScene.background = new THREE.Color(0x0a0a1a);
hyperScene.fog = new THREE.Fog(0x0a0a1a, 20, 40);
const hyperCamera = new THREE.PerspectiveCamera(45, hyperContainer.clientWidth / hyperContainer.clientHeight || 1.6, 0.1, 100);
hyperCamera.position.set(12, 8, 12);
hyperCamera.lookAt(0, 0, 0);
const hyperRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" });
hyperRenderer.setSize(hyperContainer.clientWidth, hyperContainer.clientHeight);
hyperRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
hyperRenderer.shadowMap.enabled = true;
hyperRenderer.shadowMap.type = THREE.PCFSoftShadowMap;
hyperRenderer.toneMapping = THREE.ACESFilmicToneMapping;
hyperRenderer.toneMappingExposure = 1.2;
hyperContainer.appendChild(hyperRenderer.domElement);
const hyperControls = new THREE.OrbitControls(hyperCamera, hyperRenderer.domElement);
hyperControls.enableDamping = true; hyperControls.dampingFactor = 0.05; hyperControls.autoRotate = false; hyperControls.autoRotateSpeed = 1.5; hyperControls.minDistance = 5; hyperControls.maxDistance = 30; hyperControls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0x404060, 0.5);
hyperScene.add(ambient);
const mainL = new THREE.DirectionalLight(0xff6b6b, 1.5); mainL.position.set(10, 20, 10); mainL.castShadow = true; hyperScene.add(mainL);
const fillL = new THREE.DirectionalLight(0x4ecdc4, 0.8); fillL.position.set(-10, 0, 10); hyperScene.add(fillL);
const backL = new THREE.DirectionalLight(0x6bcbff, 0.5); backL.position.set(0, -10, -10); hyperScene.add(backL);
const pointL = new THREE.PointLight(0xffd93d, 0.5, 20); pointL.position.set(0, 5, 0); hyperScene.add(pointL);
const grid = new THREE.GridHelper(20, 20, 0x444466, 0x222244); grid.position.y = -5; hyperScene.add(grid);
let hyperParticles = null, showHParticles = true;
function createHParticles() {
const count = 2000; const pos = new Float32Array(count*3), col = new Float32Array(count*3);
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const radius = 5 + Math.random() * 10;
pos[i*3]=radius*Math.sin(phi)*Math.cos(theta); pos[i*3+1]=radius*Math.cos(phi); pos[i*3+2]=radius*Math.sin(phi)*Math.sin(theta);
col[i*3]=0.4+0.6*Math.random(); col[i*3+1]=0.3+0.7*Math.random(); col[i*3+2]=0.6+0.4*Math.random();
}
const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pos,3)); geo.setAttribute('color', new THREE.BufferAttribute(col,3));
const mat = new THREE.PointsMaterial({size:0.1, vertexColors:true, transparent:true, opacity:0.6, blending:THREE.AdditiveBlending, depthWrite:false, sizeAttenuation:true});
return new THREE.Points(geo, mat);
}
hyperParticles = createHParticles(); hyperScene.add(hyperParticles);
let surfaceMesh = null, wireframeMesh = null, showWireframe = false;
let hyperParams = { a:1.5, b:0.8, c:0.5, d:0.3, e:0.2, f:0.7 };
let surfaceColor = '#ff6b6b';
function computeSurface(a,b,c,d,e,f, resolution = 100) {
const size = 6; const step = (size * 2) / resolution;
const verts = [], colors = [], indices = [];
const baseColor = new THREE.Color(surfaceColor);
for(let i=0; i<=resolution; i++) {
const x = -size + i * step;
for(let j=0; j<=resolution; j++) {
const y = -size + j * step;
const r2 = x*x + y*y;
const z = a*Math.sin(x)*Math.cos(y)*Math.exp(-0.1*r2) + b*Math.cos(2*x)*Math.sin(2*y)*(1+0.3*Math.sin(x*y)) + c*(x*x-y*y)*Math.exp(-0.05*r2) + d*Math.sin(x*x+y*y)*Math.cos(x-y) + e*x*y*Math.sin(x+y)*Math.exp(-0.08*r2) + f*Math.cos(x*y)*Math.sin(x+2*y);
verts.push(x, y, z);
const nz = (z + 5) / 10;
const col = baseColor.clone().lerp(new THREE.Color(0x4ecdc4), Math.sin(nz * Math.PI * 0.8 + 0.5) * 0.3 + 0.3);
colors.push(col.r, col.g, col.b);
}
}
for(let i=0; i<resolution; i++) for(let j=0; j<resolution; j++) {
const a0=i*(resolution+1)+j, a1=i*(resolution+1)+j+1, b0=(i+1)*(resolution+1)+j, b1=(i+1)*(resolution+1)+j+1;
indices.push(a0, a1, b1, a0, b1, b0);
}
const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); geo.setIndex(indices); geo.computeVertexNormals(); geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); return geo;
}
function createSurface(a,b,c,d,e,f) {
const geo = computeSurface(a,b,c,d,e,f);
const mat = new THREE.MeshPhysicalMaterial({vertexColors:true, side:THREE.DoubleSide, roughness:0.3, metalness:0.1, clearcoat:0.1, clearcoatRoughness:0.3, transparent:true, opacity:0.95, wireframe:false, envMapIntensity:0.5, premultipliedAlpha:true});
const mesh = new THREE.Mesh(geo, mat); mesh.castShadow=true; mesh.receiveShadow=true; return mesh;
}
function createWireframe(a,b,c,d,e,f) {
const geo = computeSurface(a,b,c,d,e,f, 40);
return new THREE.Mesh(geo, new THREE.MeshBasicMaterial({color:0x6bcbff, wireframe:true, transparent:true, opacity:0.15}));
}
function updateHyperSurface() {
if(surfaceMesh) { hyperScene.remove(surfaceMesh); surfaceMesh.geometry.dispose(); surfaceMesh.material.dispose(); }
if(wireframeMesh) { hyperScene.remove(wireframeMesh); wireframeMesh.geometry.dispose(); wireframeMesh.material.dispose(); }
surfaceMesh = createSurface(hyperParams.a, hyperParams.b, hyperParams.c, hyperParams.d, hyperParams.e, hyperParams.f);
hyperScene.add(surfaceMesh);
if(showWireframe) { wireframeMesh = createWireframe(hyperParams.a, hyperParams.b, hyperParams.c, hyperParams.d, hyperParams.e, hyperParams.f); hyperScene.add(wireframeMesh); }
document.getElementById('vertices').textContent = surfaceMesh.geometry.attributes.position.count.toLocaleString();
document.getElementById('paramsDisplay').textContent = `α:${hyperParams.a.toFixed(2)} β:${hyperParams.b.toFixed(2)} γ:${hyperParams.c.toFixed(2)} δ:${hyperParams.d.toFixed(2)} ε:${hyperParams.e.toFixed(2)} ζ:${hyperParams.f.toFixed(2)}`;
}
updateHyperSurface();
// 参数绑定
['A','B','C','D','E','F'].forEach(n => {
const id = `param${n}`, disp = `val${n}`;
document.getElementById(id).addEventListener('input', function() {
const key = n.toLowerCase();
hyperParams[key] = parseFloat(this.value);
document.getElementById(disp).textContent = hyperParams[key].toFixed(2);
updateHyperSurface();
});
});
// 颜色
document.getElementById('surfaceColor').addEventListener('input', function() {
surfaceColor = this.value;
updateHyperSurface();
});
// 工具栏按钮
document.getElementById('tbReset').addEventListener('click', function() { hyperCamera.position.set(12,8,12); hyperControls.target.set(0,0,0); });
let autoRot = false;
document.getElementById('tbAutoRotate').addEventListener('click', function() {
autoRot = !autoRot; hyperControls.autoRotate = autoRot;
this.classList.toggle('active', autoRot);
});
document.getElementById('tbWireframe').addEventListener('click', function() {
showWireframe = !showWireframe; this.classList.toggle('active', showWireframe);
updateHyperSurface();
});
document.getElementById('tbParticles').addEventListener('click', function() {
showHParticles = !showHParticles; hyperParticles.visible = showHParticles;
this.classList.toggle('active', !showHParticles);
});
document.getElementById('tbProperties').addEventListener('click', function() {
document.getElementById('hyper-properties').classList.toggle('visible');
this.classList.toggle('active');
});
// 窗口大小自适应
function resizeHyper() {
const w = hyperContainer.clientWidth, h = hyperContainer.clientHeight;
hyperCamera.aspect = w / h; hyperCamera.updateProjectionMatrix();
hyperRenderer.setSize(w, h);
}
window.addEventListener('resize', resizeHyper);
let fC=0, lU=performance.now();
function animateHyper() {
requestAnimationFrame(animateHyper);
hyperControls.update();
if(hyperParticles && showHParticles) { hyperParticles.rotation.y += 0.0005; hyperParticles.rotation.x += 0.0002; }
hyperRenderer.render(hyperScene, hyperCamera);
fC++; const n = performance.now(); if(n - lU > 1000) { document.getElementById('fps').textContent = fC; fC=0; lU=n; }
}
animateHyper();
// 进入时自适应
setTimeout(resizeHyper, 50);
</script>
</div>
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']]
},
options: {
enableMenu: false
},
chtml: {
displayAlign: 'center',
matchFontHeight: true
}
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" async></script>
<script>
// ===== 主聊天脚本 =====
const API_KEY = '303aa9b750064a12b7ab847ef80acbcf.5kbbriUxnKFja78g';
let messages = [];
let attachedFiles = [];
let mediaStream = null;
let isCacheEnabled = true;
let storageAvailable = true;
let isDeepThinkActive = false;
let isTTSEnabled = true; // 语音朗读默认开启
let isListening = false; // 语音输入状态
let voiceRecognition = null;
const userInput = document.getElementById('userInput');
const sendBtn = document.getElementById('sendBtn');
const deepThinkBtn = document.getElementById('deepThinkBtn');
const messagesList = document.getElementById('messagesList');
const welcomeScreen = document.getElementById('welcomeScreen');
const uploadPreview = document.getElementById('uploadPreview');
const chatContainer = document.getElementById('chatContainer');
const CACHE_STATUS_KEY = 'binyou_cache_v1';
const CHAT_HISTORY_KEY = 'binyou_history_v1';
// ===== 4D 建模切换函数 =====
function toggleHyperSurface() {
const container = document.getElementById('hyper-surface-container');
container.classList.toggle('active');
if (container.classList.contains('active')) {
setTimeout(() => {
const evt = new Event('resize');
window.dispatchEvent(evt);
}, 100);
}
}
// ===== 缓存与导出 =====
function checkStorage() {
try { localStorage.setItem('t', 't'); localStorage.removeItem('t'); storageAvailable = true; }
catch (e) { storageAvailable = false; }
}
function safeSetItem(k, v) { if (storageAvailable) try { localStorage.setItem(k, v); } catch(e) {} }
function safeGetItem(k) { if (storageAvailable) try { return localStorage.getItem(k); } catch(e) { return null; } return null; }
function safeRemoveItem(k) { if (storageAvailable) try { localStorage.removeItem(k); } catch(e) {} }
function exportHistory() {
if(!storageAvailable) { alert("当前环境不支持缓存导出。"); return; }
const data = JSON.stringify({ messages, timestamp: new Date().toISOString() }, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `binyou_backup_${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
}
function initCacheStatus() {
const saved = safeGetItem(CACHE_STATUS_KEY);
if (saved !== null) isCacheEnabled = saved === 'true';
updateToggleUI();
}
function toggleCache() {
isCacheEnabled = !isCacheEnabled;
safeSetItem(CACHE_STATUS_KEY, String(isCacheEnabled));
updateToggleUI();
}
function updateToggleUI() { document.getElementById('cacheToggle').classList.toggle('active', isCacheEnabled); }
function saveDataToStorage() { if (!isCacheEnabled || !storageAvailable) return; safeSetItem(CHAT_HISTORY_KEY, JSON.stringify({ messages })); }
function loadDataFromStorage() {
if (!isCacheEnabled || !storageAvailable) return;
const raw = safeGetItem(CHAT_HISTORY_KEY);
if (raw) { try { messages = JSON.parse(raw).messages || []; renderChat(); } catch(e) { safeRemoveItem(CHAT_HISTORY_KEY); } }
}
function checkCacheHit(text, files) {
if (!isCacheEnabled || !storageAvailable) return null;
const current = JSON.stringify({ text, files: files.map(f => ({n: f.name, t: f.type, d: f.dataUrl})) });
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === 'user') {
let uText = messages[i].content;
let fSnap = messages[i].attachedFiles || [];
if (JSON.stringify({ text: uText, files: fSnap }) === current && i + 1 < messages.length && messages[i+1].role === 'assistant') {
return messages[i+1].content;
}
}
}
return null;
}
function toggleDeepThink() {
isDeepThinkActive = !isDeepThinkActive;
deepThinkBtn.classList.toggle('active', isDeepThinkActive);
}
// ===== TTS 语音朗读 =====
function toggleTTS() {
isTTSEnabled = !isTTSEnabled;
document.getElementById('ttsToggleBtn').classList.toggle('active', isTTSEnabled);
const statusEl = document.getElementById('ttsStatus');
if (statusEl) {
statusEl.textContent = isTTSEnabled ? '已开启' : '已关闭';
statusEl.style.color = isTTSEnabled ? '#4ecdc4' : '#8888aa';
}
if (!isTTSEnabled && window.speechSynthesis) {
window.speechSynthesis.cancel();
ttsBuffer = '';
}
}
// 初始化 TTS 按钮状态
setTimeout(() => {
document.getElementById('ttsToggleBtn').classList.toggle('active', isTTSEnabled);
const statusEl = document.getElementById('ttsStatus');
if (statusEl) {
statusEl.textContent = isTTSEnabled ? '已开启' : '已关闭';
statusEl.style.color = isTTSEnabled ? '#4ecdc4' : '#8888aa';
}
}, 0);
// ===== 渲染Markdown + 数学公式 =====
function renderMarkdown(text) {
// 先转义HTML
text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// 处理代码块 (保留)
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, l, c) =>
`<pre><button class="copy-code-btn" onclick="copyCode(this)">复制代码</button><code>${c.trim()}</code></pre>`
);
// 处理行内代码
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// 处理数学公式 (先于粗斜体,防止 * 被误解析)
// 块级: $$...$$ 和 \[...\]
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (m, c) => {
return `<div class="math">$$${c}$$</div>`;
});
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (m, c) => {
return `<div class="math">\\[${c}\\]</div>`;
});
// 行内: $...$ 和 \(...\)
text = text.replace(/\$([^\$]+)\$/g, (m, c) => {
return `<span class="math inline">\\(${c}\\)</span>`;
});
text = text.replace(/\\\(([\s\S]*?)\\\)/g, (m, c) => {
return `<span class="math inline">\\(${c}\\)</span>`;
});
// 粗体/斜体 (在数学之后处理,避免破坏数学公式中的 *)
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
text = text.replace(/\n/g, '<br>');
return text;
}
function renderChat() {
if (messages.length === 0) { welcomeScreen.classList.remove('hidden'); messagesList.innerHTML = ''; }
else { welcomeScreen.classList.add('hidden'); messagesList.innerHTML = messages.map((msg, idx) => {
const isUser = msg.role === 'user';
let fHtml = '';
if (msg.attachedFiles && msg.attachedFiles.length > 0) {
fHtml = msg.attachedFiles.map(f =>
f.isImage
? `<div class="attach-img-wrap"><img src="${f.dataUrl}" alt="${f.name}"></div>`
: `<div class="file-attach"><svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" fill="none"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15l3-3 3 3"/></svg><span>${f.name}</span></div>`
).join('');
}
let cHtml = renderMarkdown(msg.content);
if (fHtml) cHtml = fHtml + cHtml;
return `<div class="msg-row ${isUser ? 'user' : 'ai'}"><div class="avatar">${isUser ? 'U' : 'AI'}</div><div class="bubble-wrap"><div class="bubble" id="bubble-${idx}">${cHtml}</div></div></div>`;
}).join(''); }
chatContainer.scrollTop = chatContainer.scrollHeight;
// 重新渲染数学公式
if (window.MathJax && window.MathJax.typesetPromise) {
try { window.MathJax.typesetPromise(); } catch(e) {}
}
}
function copyCode(btn) {
const pre = btn.parentNode;
const code = pre.querySelector('code');
if (!code) return;
const text = code.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '已复制'; btn.classList.add('copied');
setTimeout(() => { btn.textContent = '复制代码'; btn.classList.remove('copied'); }, 2000);
});
}
// ===== 语音朗读优化:过滤代码、特殊符号、Emoji =====
function cleanTextForTTS(text) {
// 移除代码块 ```...```
text = text.replace(/```[\s\S]*?```/g, '');
// 移除行内代码 `...`
text = text.replace(/`[^`]+`/g, '');
// 移除HTML标签
text = text.replace(/<[^>]*>/g, '');
// 移除数学公式 $$...$$, $...$, \[...\], \(...\)
text = text.replace(/\$\$[\s\S]*?\$\$/g, '');
text = text.replace(/\$[^\$]+\$/g, '');
text = text.replace(/\\\[[\s\S]*?\\\]/g, '');
text = text.replace(/\\\([\s\S]*?\\\)/g, '');
// 移除 Emoji / 表情符号 / 颜文字
text = text.replace(/[\u{1F000}-\u{1FFFF}\u{2000}-\u{2BFF}\u{FE00}-\u{FE0F}\u{3000}-\u{303F}\u{FF00}-\u{FFEF}]/gu, '');
// 移除特殊符号 # & @ ! ~ ^ % * + = / \ | < > _ 等 (保留中文、英文、数字、空格、基本标点)
text = text.replace(/[#&@!~^%*+=/\\|<>_`]/g, '');
// 移除多余空格(保留单个空格分隔单词)
text = text.replace(/\s{2,}/g, ' ').trim();
return text;
}
let ttsBuffer = '';
let ttsTimeout = null;
let ttsQueue = [];
let ttsSpeaking = false;
let ttsSpokenPos = 0; // 已经入队的文本位置
function ttsSpeakNext() {
if (!isTTSEnabled) { ttsQueue = []; ttsSpeaking = false; return; }
if (ttsQueue.length === 0) { ttsSpeaking = false; return; }
ttsSpeaking = true;
const item = ttsQueue.shift();
const u = new SpeechSynthesisUtterance(item.text);
u.lang = 'zh-CN'; u.rate = 1.0;
u.onend = function() {
if (item.pause > 0) {
// 标点/符号/Emoji 后自然停顿(像正常人说话一样)
setTimeout(ttsSpeakNext, item.pause);
} else {
ttsSpeakNext();
}
};
window.speechSynthesis.speak(u);
}
function splitByPunct(text) {
// 按标点分割,保留标点归属到前一段
const segs = [];
const parts = text.split(/([,。!?;,\.!?;])/);
let buf = '';
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (/[,。!?;,\.!?;]/.test(p)) {
if (buf) segs.push({ raw: buf, hasPause: true });
buf = '';
} else {
buf += p;
}
}
if (buf) segs.push({ raw: buf, hasPause: false });
return segs;
}
function flushTTS() {
if (!isTTSEnabled) { ttsBuffer = ''; return; }
if (ttsBuffer.trim().length === 0) return;
if (!window.speechSynthesis) return;
// 只处理新增的文本,不重复朗读已入队的内容
const newText = ttsBuffer.slice(ttsSpokenPos);
if (!newText.trim()) return;
ttsSpokenPos = ttsBuffer.length;
// 按标点分割,实现自然停顿
const segs = splitByPunct(newText);
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
const clean = cleanTextForTTS(seg.raw);
if (!clean.trim()) continue;
ttsQueue.push({
text: clean,
pause: seg.hasPause ? 500 : 0
});
}
if (!ttsSpeaking) ttsSpeakNext();
}
function appendToTTS(delta) {
if (!isTTSEnabled) return;
ttsBuffer += delta;
if (ttsTimeout) clearTimeout(ttsTimeout);
// 每 80ms 刷新一次,只将新增文本入队,不重复、不取消旧语音
ttsTimeout = setTimeout(flushTTS, 80);
}
function startVoiceInput() {
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) return alert("请使用 Chrome 或 Edge");
const micBtn = document.querySelector('.tool-btn[onclick*="startVoiceInput"]');
if (isListening) {
// 停止录音
if (voiceRecognition) { voiceRecognition.stop(); voiceRecognition.abort(); }
voiceRecognition = null;
isListening = false;
micBtn.classList.remove('recording');
// 如果输入框有内容,自动发送
const txt = userInput.value.trim();
if (txt) { sendMessage(); }
return;
}
const r = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
r.lang = 'zh-CN';
r.interimResults = true;
r.continuous = true;
r.maxAlternatives = 1;
voiceRecognition = r;
isListening = true;
micBtn.classList.add('recording');
let finalText = '';
let silenceTimer = null;
function resetSilenceTimer() {
if (silenceTimer) clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
// 静音超过 1.2 秒自动结束
if (isListening && voiceRecognition) {
voiceRecognition.stop();
}
}, 1200);
}
r.onresult = function(e) {
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const result = e.results[i];
if (result.isFinal) {
finalText += result[0].transcript;
} else {
interim += result[0].transcript;
}
}
if (interim) {
userInput.value = finalText + interim;
} else {
userInput.value = finalText;
}
userInput.dispatchEvent(new Event('input'));
resetSilenceTimer();
};
r.onerror = function(e) {
if (e.error === 'no-speech' || e.error === 'aborted') return;
console.error('语音识别错误:', e.error);
isListening = false;
micBtn.classList.remove('recording');
if (finalText.trim()) { sendMessage(); }
};
r.onend = function() {
isListening = false;
micBtn.classList.remove('recording');
if (silenceTimer) clearTimeout(silenceTimer);
if (finalText.trim()) {
userInput.value = finalText;
userInput.dispatchEvent(new Event('input'));
sendMessage();
}
};
r.start();
resetSilenceTimer();
}
// ===== 发送消息 =====
async function sendMessage() {
const text = userInput.value.trim();
if (!text && attachedFiles.length === 0) return;
welcomeScreen.classList.add('hidden');
sendBtn.classList.remove('active'); sendBtn.disabled = true; userInput.disabled = true;
const currentFiles = [...attachedFiles];
const cached = isCacheEnabled && storageAvailable ? checkCacheHit(text, currentFiles) : null;
const uObj = { role: 'user', content: text, attachedFiles: currentFiles };
messages.push(uObj); renderChat();
userInput.value = ''; userInput.style.height = 'auto'; attachedFiles = []; renderPreviews();
document.getElementById('inputBox').classList.remove('has-text');
if (cached) {
messages.push({ role: 'assistant', content: cached }); saveDataToStorage(); renderChat();
if (isTTSEnabled) {
const clean = cleanTextForTTS(cached);
if (clean.trim()) { appendToTTS(clean); flushTTS(); }
}
sendBtn.classList.add('active'); sendBtn.disabled = false; userInput.disabled = false; return;
}
const typingId = addTypingIndicator();
try {
let apiMsgs = [...messages];
if (currentFiles.length > 0) {
const p = [{ type: 'text', text: text || '分析文件' }];
currentFiles.forEach(f => {
if (f.isImage) p.push({ type: 'image_url', image_url: { url: f.dataUrl } });
else p.push({ type: 'text', text: `[文档 ${f.name}]\n${f.dataUrl.slice(0, 3000)}` });
});
apiMsgs.pop(); apiMsgs.push({ role: 'user', content: p });
}
const userModel = safeGetItem('zhipu_model') || 'glm-5.2';
const hasImages = currentFiles.some(f => f.isImage);
const visionModel = hasImages ? 'glm-4v' : userModel;
const useStream = !hasImages; // 图片模式下不支持流式
const payload = { model: visionModel, messages: apiMsgs, stream: useStream, temperature: isDeepThinkActive ? 0.3 : 0.7 };
const res = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify(payload)
});
if (!res.ok) { const errBody = await res.text().catch(() => ''); throw new Error(`API请求失败: ${res.status}${errBody ? ' - ' + errBody.slice(0,200) : ''}`); }
let fullText = '';
if (useStream) {
// ===== 流式响应处理 =====
const reader = res.body.getReader(); const decoder = new TextDecoder();
let buf = '';
let isFirstChar = true;
while(true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n'); buf = lines.pop() || '';
for (let line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta?.content || '';
if (delta) {
if (isFirstChar) {
removeTypingIndicator(typingId);
const sId = addStreamBubble();
isFirstChar = false;
fullText += delta;
updateStreamContent(sId, fullText);
if (isTTSEnabled) appendToTTS(delta);
} else {
fullText += delta;
const sId = document.querySelector('.msg-row.ai:last-child')?.id;
if(sId) updateStreamContent(sId, fullText);
if (isTTSEnabled) appendToTTS(delta);
}
}
} catch(e){}
}
}
} else {
// ===== 非流式响应处理(图片/附件模式) =====
const json = await res.json();
removeTypingIndicator(typingId);
fullText = json.choices?.[0]?.message?.content || '';
if (fullText) {
const sId = addStreamBubble();
updateStreamContent(sId, fullText);
if (isTTSEnabled) {
const clean = cleanTextForTTS(fullText);
if (clean.trim()) { appendToTTS(clean); flushTTS(); }
}
}
}
if (isTTSEnabled) flushTTS();
messages.push({ role: 'assistant', content: fullText || '抱歉,AI 未能识别图片内容,请重试或换个角度拍摄。' });
saveDataToStorage(); renderChat();
} catch (err) {
removeTypingIndicator(typingId);
messages.push({ role: 'assistant', content: `请求异常: ${err.message}` });
renderChat();
} finally {
sendBtn.classList.add('active'); sendBtn.disabled = false; userInput.disabled = false;
}
}
function addTypingIndicator() {
const id = 't' + Date.now(); const d = document.createElement('div');
d.className = 'msg-row ai'; d.id = id;
d.innerHTML = `<div class="avatar">AI</div><div class="bubble-wrap"><div class="bubble">思考中<span style="display:inline-block;width:2px;height:12px;background:#8f95a3;margin-left:2px;animation:blink 1s step-end infinite;vertical-align:middle;"></span></div></div>`;
messagesList.appendChild(d); chatContainer.scrollTop = chatContainer.scrollHeight; return id;
}
function removeTypingIndicator(id) { const e = document.getElementById(id); if(e) e.remove(); }
function addStreamBubble() {
const id = 's' + Date.now(); const d = document.createElement('div');
d.className = 'msg-row ai'; d.id = id;
d.innerHTML = `<div class="avatar">AI</div><div class="bubble-wrap"><div class="bubble" id="${id}-c"></div></div>`;
messagesList.appendChild(d); chatContainer.scrollTop = chatContainer.scrollHeight; return id;
}
function updateStreamContent(id, text) {
const e = document.getElementById(id + '-c');
if(e) {
e.innerHTML = renderMarkdown(text);
chatContainer.scrollTop = chatContainer.scrollHeight;
if (window.MathJax && window.MathJax.typesetPromise) {
try { window.MathJax.typesetPromise(); } catch(e) {}
}
}
}
userInput.addEventListener('input', function() {
this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 150) + 'px';
const hasVal = this.value.trim().length > 0;
sendBtn.classList.toggle('active', hasVal || attachedFiles.length > 0);
document.getElementById('inputBox').classList.toggle('has-text', hasVal);
});
userInput.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
function fillInput(text) { userInput.value = text; userInput.dispatchEvent(new Event('input')); userInput.focus(); }
function handleFiles(e) {
if (!e || !e.target || !e.target.files || e.target.files.length === 0) return;
Array.from(e.target.files).forEach(f => {
const isImg = f.type.startsWith('image/');
const r = new FileReader();
r.onload = (ev) => { if (!ev || !ev.target || !ev.target.result) return; attachedFiles.push({ name: f.name, type: f.type, dataUrl: ev.target.result, isImage: isImg }); renderPreviews(); sendBtn.classList.toggle('active', true); document.getElementById('inputBox').classList.add('has-text'); };
r.onerror = () => { console.error('FileReader error:', f.name); };
isImg ? r.readAsDataURL(f) : r.readAsText(f);
}); if (e.target) e.target.value = '';
}
function renderPreviews() {
uploadPreview.innerHTML = attachedFiles.map((f, i) =>
`<div class="preview-item">${f.isImage ? `<img src="${f.dataUrl}">` : `<span>${f.name}</span>`}<button class="del-preview" onclick="removeFile(${i})">×</button></div>`
).join('');
}
function removeFile(i) { attachedFiles.splice(i, 1); renderPreviews(); const hasAny = userInput.value.trim().length > 0 || attachedFiles.length > 0; sendBtn.classList.toggle('active', hasAny); document.getElementById('inputBox').classList.toggle('has-text', hasAny); }
function clearAllHistory() {
if (confirm('确定清除所有对话吗?')) { messages = []; safeRemoveItem(CHAT_HISTORY_KEY); renderChat(); userInput.value = ''; attachedFiles = []; renderPreviews(); document.getElementById('inputBox').classList.remove('has-text'); }
}
async function openCamera() {
try { mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); document.getElementById('cameraVideo').srcObject = mediaStream; document.getElementById('cameraModal').classList.add('active'); } catch(err) { alert('摄像头错误: ' + err.message); }
}
function closeCamera() { if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; document.getElementById('cameraModal').classList.remove('active'); }
function capturePhoto() {
const v = document.getElementById('cameraVideo'); const c = document.createElement('canvas');
c.width = v.videoWidth; c.height = v.videoHeight; c.getContext('2d').drawImage(v, 0, 0);
const ts = new Date().toISOString().slice(0,19).replace(/[:-]/g, '');
attachedFiles.push({ name: `拍照_${ts}.jpg`, type: 'image/jpeg', dataUrl: c.toDataURL('image/jpeg', 0.9), isImage: true });
renderPreviews(); sendBtn.classList.toggle('active', true); document.getElementById('inputBox').classList.add('has-text'); closeCamera();
}
function openSettings() { document.getElementById('settingsModal').classList.add('active'); }
function closeSettings() { document.getElementById('settingsModal').classList.remove('active'); }
function saveSettings() { safeSetItem('zhipu_model', document.getElementById('modelSelect').value); closeSettings(); }
checkStorage(); initCacheStatus();
if (isCacheEnabled && storageAvailable) loadDataFromStorage(); else renderChat();
sendBtn.classList.toggle('active', false);
document.getElementById('fileInput').addEventListener('change', handleFiles);
</script>
</body>
</html>
更多推荐



所有评论(0)