<!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回复内容。实现上使用ReadableStreamgetReader()方法逐块读取响应体,并解析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数组管理用户上传的附件,isCacheEnabledisTTSEnabled等布尔变量控制功能开关。这种扁平化的状态管理方式虽然简单,但对于单页面应用来说足够清晰且易于维护。

// 全局状态变量
let messages = [];           // 对话历史:[{ role, content }]
let attachedFiles = [];     // 用户上传的附件
let isCacheEnabled =        // 是否启用本地缓存
  localStorage.getItem('cacheEnabled') !== 'false';
let temperature = 0.7;     // 深度思考参数

4.2 API调用流程

核心的对话请求流程如下:

  1. 构建消息数组,包含系统角色提示词(设定AI身份为"小创")
  2. 检测是否有图片附件,决定调用视觉模型还是文本模型
  3. 构建POST请求体,设置stream参数
  4. 根据是否为流式模式,分别处理SSE流式数据或一次性JSON响应
  5. 将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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
            
            // 处理代码块 (保留)
            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>

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐