当前位置: 首页 > news >正文

基于浏览器运行的本地大模型语音助手

基于webllm技术,大模型在浏览器的WebGPU中运行。

代码如下:

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>本地语音助手</title><!-- 引入Tailwind CSS --><script src="https://cdn.tailwindcss.com"></script><!-- 引入Font Awesome --><link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet"><!-- 引入WebLLM --><script type="module">import * as webllm from "https://esm.run/@mlc-ai/web-llm";window.webllm = webllm;</script><!-- Tail    Tailwind配置和样式部分保持不变--><script>tailwind.config = {theme: {extend: {colors: {primary: '#4F46E5',secondary: '#10B981',accent: '#F59E0B',dark: '#1F2937',light: '#F3F4F6',},fontFamily: {inter: ['Inter', 'system-ui', 'sans-serif'],},animation: {'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite','float': 'float 3s ease-in-out infinite',},keyframes: {float: {'0%, 100%': { transform: 'translateY(0)' },'50%': { transform: 'translateY(-10px)' },}}},}}</script><style type="text/tailwindcss">@layer utilities {.content-auto {content-visibility: auto;}.text-shadow {text-shadow: 0 2px 4px rgba(0,0,0,0.1);}.bg-gradient-custom {background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);}.glass-effect {backdrop-filter: blur(10px);background-color: rgba(255, 255, 255, 0.8);}.collapsed-content {max-height: 60px;overflow: hidden;position: relative;}.collapsed-content::after {content: "";position: absolute;bottom: 0;left: 0;right: 0;height: 30px;background: linear-gradient(to top, #f3f4f6, transparent);}.thinking-container {max-height: 80px;overflow: hidden;position: relative;transition: max-height 0.3s ease;}.thinking-container.expanded {max-height: 500px;}.thinking-mask {position: absolute;bottom: 0;left: 0;right: 0;height: 40px;background: linear-gradient(to top, #f3f4f6, transparent);transition: opacity 0.3s ease;}.thinking-mask.hidden {opacity: 0;}}</style>
</head>
<body class="font-inter bg-gray-50 text-dark min-h-screen flex flex-col"><!--HTML结构部分保持不变--><header class="glass-effect border-b border-gray-200 sticky top-0 z-50 transition-all duration-300"><div class="container mx-auto px-4 py-3 flex justify-between items-center"><div class="flex items-center space-x-2"><i class="fa fa-microphone text-primary text-2xl"></i><h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-custom">本地语音助手</h1></div><div class="flex items-center space-x-4"><button id="settingsBtn" class="p-2 rounded-full hover:bg-gray-200 transition-colors"><i class="fa fa-cog text-gray-600"></i></button><button id="infoBtn" class="p-2 rounded-full hover:bg-gray-200 transition-colors"><i class="fa fa-info-circle text-gray-600"></i></button></div></div></header><main class="flex-1 container mx-auto px-4 py-8 flex flex-col md:flex-row gap-8"><section class="w-full md:w-2/3 bg-white rounded-2xl shadow-lg p-6 overflow-hidden flex flex-col"><h2 class="text-xl font-semibold mb-6 flex items-center"><i class="fa fa-comments text-primary mr-2"></i>聊天记录</h2><div id="chatContainer" class="flex-1 overflow-y-auto mb-6 space-y-6"><div class="flex items-start space-x-3"><div class="bg-primary/10 p-2 rounded-full"><i class="fa fa-robot text-primary text-xl"></i></div><div class="bg-gray-100 rounded-xl p-4 max-w-[80%]"><p>你好!我是你的本地语音助手。正在初始化系统组件,请稍候...</p><p class="text-xs text-gray-500 mt-2">刚刚</p></div></div></div><div class="flex justify-center items-center"><button id="startListeningBtn" class="w-16 h-16 rounded-full bg-primary text-white shadow-lg flex items-center justify-center transform transition-all duration-300 hover:scale-110 hover:bg-primary/90 active:scale-95"><i class="fa fa-microphone text-2xl"></i></button></div></section><section class="w-full md:w-1/3 space-y-6"><div class="bg-white rounded-2xl shadow-lg p-6"><h2 class="text-xl font-semibold mb-4 flex items-center"><i class="fa fa-tachometer text-primary mr-2"></i>系统状态</h2><div class="space-y-4"><div class="flex justify-between items-center"><div class="flex items-center"><i class="fa fa-microphone-slash text-gray-500 mr-2"></i><span>语音识别 (ASR)</span></div><div class="flex items-center"><span id="asrStatus" class="text-gray-500 text-sm">未就绪</span><div id="asrIndicator" class="w-2 h-2 rounded-full bg-gray-300 ml-2"></div></div></div><div class="flex justify-between items-center"><div class="flex items-center"><i class="fa fa-brain text-gray-500 mr-2"></i><span>语言模型 (LLM)</span></div><div class="flex items-center"><span id="llmStatus" class="text-gray-500 text-sm">未加载</span><div id="llmIndicator" class="w-2 h-2 rounded-full bg-gray-300 ml-2"></div></div></div><div class="flex justify-between items-center"><div class="flex items-center"><i class="fa fa-volume-off text-gray-500 mr-2"></i><span>语音合成 (TTS)</span></div><div class="flex items-center"><span id="ttsStatus" class="text-gray-500 text-sm">未就绪</span><div id="ttsIndicator" class="w-2 h-2 rounded-full bg-gray-300 ml-2"></div></div></div></div><div id="download-status" class="mt-4 p-3 bg-gray-100 rounded-lg hidden"><p class="text-sm text-gray-700">准备下载模型...</p></div><div class="mt-6"><div class="flex justify-between text-sm mb-1"><span>模型加载</span><span id="modelProgressText">0%</span></div><div class="w-full bg-gray-200 rounded-full h-2.5"><div id="modelProgressBar" class="bg-primary h-2.5 rounded-full w-0 transition-all duration-300"></div></div></div><div class="mt-4 text-sm text-gray-600" id="downloadSpeed">下载速度: 等待开始...</div><div class="mt-6"><label class="block text-sm font-medium text-gray-700 mb-2">选择模型</label><div class="flex space-x-2"><select id="model-selection" class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"></select><button id="downloadModelBtn" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">加载</button></div></div><div id="chat-stats" class="mt-4 p-3 bg-gray-100 rounded-lg text-sm hidden"></div></div><div class="bg-gradient-custom text-white rounded-2xl shadow-lg p-6"><h2 class="text-xl font-semibold mb-4 flex items-center"><i class="fa fa-info-circle mr-2"></i>关于助手</h2><p class="text-white/90 text-sm mb-4">这是一个完全在本地运行的语音助手,所有语音识别、处理和合成都在你的浏览器中完成,不会上传任何数据到云端。</p><div class="space-y-2 text-sm"><div class="flex items-center"><i class="fa fa-check-circle mr-2"></i><span>保护隐私,数据不会离开你的设备</span></div><div class="flex items-center"><i class="fa fa-check-circle mr-2"></i><span>无需网络连接即可使用</span></div><div class="flex items-center"><i class="fa fa-check-circle mr-2"></i><span>快速响应,低延迟交互体验</span></div></div></div></section></main><footer class="bg-dark text-white py-4"><div class="container mx-auto px-4 flex flex-col md:flex-row justify-between items-center"><div class="text-sm text-gray-400 mb-2 md:mb-0">本地语音助手 &copy; 2025 | 完全在浏览器中运行</div><div class="flex space-x-4"><button class="text-gray-400 hover:text-white transition-colors"><i class="fa fa-github"></i></button><button class="text-gray-400 hover:text-white transition-colors"><i class="fa fa-question-circle"></i></button><button class="text-gray-400 hover:text-white transition-colors"><i class="fa fa-cog"></i></button></div></div></footer><div id="settingsModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4"><div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 transform transition-all"><div class="flex justify-between items-center mb-4"><h3 class="text-xl font-semibold">设置</h3><button id="closeSettingsBtn" class="text-gray-500 hover:text-gray-700"><i class="fa fa-times"></i></button></div><div class="space-y-4"><div><label class="block text-sm font-medium text-gray-700 mb-1">语音识别语言</label><select id="asrLanguage" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"><option value="zh-CN">中文 (中国大陆)</option><option value="en-US">英语 (美国)</option><option value="ja-JP">日语</option><option value="ko-KR">韩语</option></select></div><div><label class="block text-sm font-medium text-gray-700 mb-1">语音合成声音</label><select id="ttsVoice" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"></select></div><div><label class="block text-sm font-medium text-gray-700 mb-1">语音合成音量</label><input type="range" id="ttsVolume" min="0" max="1" step="0.1" value="0.7" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-primary"></div><div><label class="block text-sm font-medium text-gray-700 mb-1">生成温度 (0-1)</label><input type="range" id="llmTemperature" min="0" max="1" step="0.1" value="0.7" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-primary"></div></div><div class="mt-6 flex justify-end"><button id="saveSettingsBtn" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">保存设置</button></div></div></div><div id="infoModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center p-4"><div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 transform transition-all"><div class="flex justify-between items-center mb-4"><h3 class="text-xl font-semibold">关于本地语音助手</h3><button id="closeInfoBtn" class="text-gray-500 hover:text-gray-700"><i class="fa fa-times"></i></button></div><div class="space-y-4 text-sm"><p>本地语音助手是一个完全在浏览器中运行的AI助手,无需任何服务器支持。</p><div><h4 class="font-medium mb-1">核心技术</h4><ul class="list-disc list-inside space-y-1 text-gray-600"><li>ASR: 使用浏览器内置的Web Speech API进行语音识别</li><li>LLM: 使用WebLLM在浏览器中运行大语言模型</li><li>TTS: 使用浏览器内置的Web Speech API进行语音合成</li></ul></div><div><h4 class="font-medium mb-1">首次使用提示</h4><ul class="list-disc list-inside space-y-1 text-gray-600"><li>首次使用需要手动下载并加载语言模型</li><li>模型会保存在浏览器缓存中,后续使用无需重新下载</li><li>建议使用现代浏览器(Chrome、Edge、Firefox最新版)</li><li>较大的模型可能需要较高配置的设备才能流畅运行</li></ul></div></div><div class="mt-6 flex justify-end"><button id="closeInfoBtn2" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors">我知道了</button></div></div></div><script type="module">// 全局状态管理const state = {isListening: false,isProcessing: false,isSpeaking: false,recognition: null,synthesizer: null,engine: null,          messages: [            {content: "你是一个乐于助人的AI语音助手,用简洁明了的中文回答用户问题。",role: "system",}],webllmLoaded: false,modelDownloadStartTime: null,modelLoaded: false,    currentSpeechQueue: [],isProcessingSpeech: false,settings: {asrLanguage: 'zh-CN',ttsVoice: null,ttsVolume: 0.8,llmTemperature: 0.7,selectedModel: "Qwen2.5-0.5B-Instruct-q4f16_1-MLC"}};// DOM元素const elements = {startListeningBtn: document.getElementById('startListeningBtn'),chatContainer: document.getElementById('chatContainer'),asrStatus: document.getElementById('asrStatus'),asrIndicator: document.getElementById('asrIndicator'),llmStatus: document.getElementById('llmStatus'),llmIndicator: document.getElementById('llmIndicator'),ttsStatus: document.getElementById('ttsStatus'),ttsIndicator: document.getElementById('ttsIndicator'),modelProgressBar: document.getElementById('modelProgressBar'),modelProgressText: document.getElementById('modelProgressText'),downloadSpeed: document.getElementById('downloadSpeed'),downloadStatus: document.getElementById('download-status'),modelSelection: document.getElementById('model-selection'),downloadModelBtn: document.getElementById('downloadModelBtn'),chatStats: document.getElementById('chat-stats'),settingsBtn: document.getElementById('settingsBtn'),infoBtn: document.getElementById('infoBtn'),settingsModal: document.getElementById('settingsModal'),infoModal: document.getElementById('infoModal'),closeSettingsBtn: document.getElementById('closeSettingsBtn'),closeInfoBtn: document.getElementById('closeInfoBtn'),closeInfoBtn2: document.getElementById('closeInfoBtn2'),saveSettingsBtn: document.getElementById('saveSettingsBtn'),asrLanguage: document.getElementById('asrLanguage'),ttsVoice: document.getElementById('ttsVoice'),ttsVolume: document.getElementById('ttsVolume'),llmTemperature: document.getElementById('llmTemperature')};// 初始化应用async function initApp() {checkWebLLMLoaded();loadSettings();initASR();await initTTS();if (state.webllmLoaded) {initWebLLM();} else {const checkWebLLMTimer = setInterval(() => {checkWebLLMLoaded();if (state.webllmLoaded) {clearInterval(checkWebLLMTimer);initWebLLM();}}, 1000);setTimeout(() => {if (!state.webllmLoaded) {clearInterval(checkWebLLMTimer);showErrorMessage("WebLLM库加载失败,无法初始化语言模型。请检查网络连接或尝试刷新页面。");updateLLMStatus(false, "加载失败");}}, 5000);}setupEventListeners();}// 其他辅助函数保持不变function checkWebLLMLoaded() {if (window.webllm) {state.webllmLoaded = true;console.log('WebLLM库已加载');return true;}return false;}function initWebLLM() {try {state.engine = new window.webllm.MLCEngine();state.engine.setInitProgressCallback(updateEngineInitProgressCallback);const availableModels = window.webllm.prebuiltAppConfig.model_list.map((m) => m.model_id);if (elements.modelSelection) {elements.modelSelection.innerHTML = '';availableModels.forEach((modelId) => {const option = document.createElement("option");option.value = modelId;option.textContent = modelId;elements.modelSelection.appendChild(option);});elements.modelSelection.value = state.settings.selectedModel;}updateLLMStatus(false, "未加载");} catch (error) {console.error("初始化WebLLM失败:", error);showErrorMessage("初始化WebLLM失败: " + error.message);updateLLMStatus(false, "初始化失败");}}function updateEngineInitProgressCallback(report) {console.log("WebLLM初始化进度:", report.progress, report.text);if (elements.downloadStatus) {elements.downloadStatus.textContent = report.text || `加载中... ${Math.round(report.progress * 100)}%`;}const percentage = Math.round(report.progress * 100);if (elements.modelProgressBar) {elements.modelProgressBar.style.width = `${percentage}%`;}if (elements.modelProgressText) {elements.modelProgressText.textContent = `${percentage}%`;}if (elements.llmStatus) {if (report.stage === "download") {elements.llmStatus.textContent = `下载中...`;} else if (report.stage === "init") {elements.llmStatus.textContent = `初始化中...`;}}if (elements.downloadSpeed) {if (report.stage === "download") {if (!state.modelDownloadStartTime && report.progress > 0) {state.modelDownloadStartTime = new Date();}if (state.modelDownloadStartTime && report.progress > 0) {const elapsedTime = (new Date() - state.modelDownloadStartTime) / 1000;const downloadedMB = (report.downloaded / (1024 * 1024)).toFixed(2);const speedMbps = (downloadedMB / elapsedTime).toFixed(2);elements.downloadSpeed.textContent = `下载速度: ${speedMbps} MB/s (已下载: ${downloadedMB} MB)`;}} else if (report.stage === "init") {elements.downloadSpeed.textContent = `正在初始化模型...`;}}if (report.progress === 1.0 && report.stage === "init") {state.modelLoaded = true;}}async function initializeWebLLMEngine() {if (!state.webllmLoaded || !state.engine) {showErrorMessage("WebLLM未初始化,请稍后重试。");return;}try {if (elements.downloadStatus) {elements.downloadStatus.classList.remove("hidden");elements.downloadStatus.textContent = "准备加载模型...";}if (elements.modelSelection) {state.settings.selectedModel = elements.modelSelection.value;}addMessage('assistant', `正在加载语言模型 "${state.settings.selectedModel}",这可能需要几分钟时间...`);if (elements.modelProgressBar) elements.modelProgressBar.style.width = "0%";if (elements.modelProgressText) elements.modelProgressText.textContent = "0%";state.modelDownloadStartTime = null;state.modelLoaded = false;const config = {temperature: parseFloat(state.settings.llmTemperature),top_p: 1,};await state.engine.reload(state.settings.selectedModel, config);state.modelLoaded = true;updateLLMStatus(true);if (elements.downloadSpeed) {elements.downloadSpeed.textContent = `模型加载完成`;}addMessage('assistant', "语言模型加载完成,现在可以开始使用语音助手了!");saveSettings();state.engine.runtimeStatsText().then(statsText => {if (elements.chatStats) {elements.chatStats.classList.remove('hidden');elements.chatStats.textContent = statsText;}});} catch (error) {console.error("加载语言模型失败:", error);updateLLMStatus(false, "加载失败");state.modelLoaded = false;let errorMsg = "加载语言模型失败: " + error.message;if (error.message.includes("out of memory") || error.message.includes("内存不足")) {errorMsg += "。您的设备内存可能不足,请尝试选择更小的模型。";} else if (error.message.includes("not supported")) {errorMsg += "。您的浏览器可能不支持,请使用最新版Chrome浏览器。";} else {errorMsg += "。请尝试选择其他模型或刷新页面重试。";}showErrorMessage(errorMsg);}}function initASR() {try {const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;if (SpeechRecognition) {state.recognition = new SpeechRecognition();state.recognition.lang = state.settings.asrLanguage;state.recognition.interimResults = false;state.recognition.maxAlternatives = 1;state.recognition.onstart = handleRecognitionStart;state.recognition.onresult = handleRecognitionResult;state.recognition.onerror = handleRecognitionError;state.recognition.onend = handleRecognitionEnd;updateASRStatus(true);} else {updateASRStatus(false, "浏览器不支持");showErrorMessage("您的浏览器不支持语音识别功能,请使用Chrome或Edge浏览器。");}} catch (error) {console.error("初始化语音识别失败:", error);updateASRStatus(false, "初始化失败");showErrorMessage("初始化语音识别失败: " + error.message);}}async function initTTS() {try {if ('speechSynthesis' in window) {state.synthesizer = window.speechSynthesis;await new Promise(resolve => {const checkVoices = () => {if (state.synthesizer.getVoices().length > 0) {resolve();} else {setTimeout(checkVoices, 100);}};checkVoices();});populateVoiceOptions();updateTTSStatus(true);} else {updateTTSStatus(false, "浏览器不支持");showErrorMessage("您的浏览器不支持语音合成功能,请使用Chrome或Edge浏览器。");}} catch (error) {console.error("初始化语音合成失败:", error);updateTTSStatus(false, "初始化失败");showErrorMessage("初始化语音合成失败: " + error.message);}}function populateVoiceOptions() {if (!elements.ttsVoice) return;const voices = state.synthesizer.getVoices();elements.ttsVoice.innerHTML = '';const chineseVoices = voices.filter(voice => voice.lang.includes('zh') || voice.name.includes('Chinese'));if (chineseVoices.length > 0) {chineseVoices.forEach(voice => {const option = document.createElement('option');option.value = voice.voiceURI;option.textContent = `${voice.name} (${voice.lang})`;elements.ttsVoice.appendChild(option);});const separator = document.createElement('option');separator.disabled = true;separator.textContent = '--- 其他语言 ---';elements.ttsVoice.appendChild(separator);}voices.forEach(voice => {if (chineseVoices.some(v => v.voiceURI === voice.voiceURI)) {return;}const option = document.createElement('option');option.value = voice.voiceURI;option.textContent = `${voice.name} (${voice.lang})`;elements.ttsVoice.appendChild(option);});if (state.settings.ttsVoice) {elements.ttsVoice.value = state.settings.ttsVoice;} else if (chineseVoices.length > 0) {elements.ttsVoice.value = chineseVoices[0].voiceURI;state.settings.ttsVoice = chineseVoices[0].voiceURI;}}function setupEventListeners() {if (elements.startListeningBtn) {elements.startListeningBtn.addEventListener('click', toggleListening);}if (elements.downloadModelBtn) {elements.downloadModelBtn.addEventListener('click', initializeWebLLMEngine);}if (elements.settingsBtn) {elements.settingsBtn.addEventListener('click', () => {if (elements.settingsModal) elements.settingsModal.classList.remove('hidden');});}if (elements.closeSettingsBtn) {elements.closeSettingsBtn.addEventListener('click', () => {if (elements.settingsModal) elements.settingsModal.classList.add('hidden');});}if (elements.saveSettingsBtn) {elements.saveSettingsBtn.addEventListener('click', saveSettings);}if (elements.infoBtn) {elements.infoBtn.addEventListener('click', () => {if (elements.infoModal) elements.infoModal.classList.remove('hidden');});}if (elements.closeInfoBtn) {elements.closeInfoBtn.addEventListener('click', () => {if (elements.infoModal) elements.infoModal.classList.add('hidden');});}if (elements.closeInfoBtn2) {elements.closeInfoBtn2.addEventListener('click', () => {if (elements.infoModal) elements.infoModal.classList.add('hidden');});}if (elements.ttsVolume) {elements.ttsVolume.addEventListener('input', (e) => {state.settings.ttsVolume = parseFloat(e.target.value);});}if (elements.llmTemperature) {elements.llmTemperature.addEventListener('input', (e) => {state.settings.llmTemperature = parseFloat(e.target.value);});}window.speechSynthesis.onvoiceschanged = populateVoiceOptions;}function toggleListening() {if (!state.engine || !state.modelLoaded) {showErrorMessage("语言模型尚未准备好,请先加载模型。");return;}if (state.isProcessing || state.isSpeaking) {return;}if (state.isListening) {stopListening();} else {startListening();}}function startListening() {if (!state.recognition) {showErrorMessage("语音识别未初始化,请刷新页面重试。");return;}try {state.recognition.start();state.isListening = true;if (elements.startListeningBtn) {elements.startListeningBtn.innerHTML = '<i class="fa fa-stop text-2xl"></i>';elements.startListeningBtn.classList.remove('bg-primary');elements.startListeningBtn.classList.add('bg-red-500');}addMessage('user', '', true);} catch (error) {console.error("开始语音识别失败:", error);showErrorMessage("无法开始语音识别: " + error.message);}}function stopListening() {if (state.recognition && state.isListening) {state.recognition.stop();state.isListening = false;}}function handleRecognitionStart() {console.log("语音识别已开始");updateASRStatus(true, "正在监听...");}function handleRecognitionResult(event) {const transcript = event.results[0][0].transcript;console.log("识别结果:", transcript);updateLastUserMessage(transcript);const userMessage = {content: transcript,role: "user"};state.messages.push(userMessage);processTranscript();}function handleRecognitionError(event) {console.error("语音识别错误:", event.error);let errorMessage = "语音识别出错: ";switch (event.error) {case 'not-allowed':errorMessage += "需要麦克风权限,请在浏览器设置中启用。";break;case 'no-speech':errorMessage += "未检测到语音。";break;case 'audio-capture':errorMessage += "无法访问麦克风。";break;default:errorMessage += event.error;}removeLastUserMessageIfEmpty();showErrorMessage(errorMessage);}function handleRecognitionEnd() {console.log("语音识别已结束");state.isListening = false;if (elements.startListeningBtn) {elements.startListeningBtn.innerHTML = '<i class="fa fa-microphone text-2xl"></i>';elements.startListeningBtn.classList.remove('bg-red-500');elements.startListeningBtn.classList.add('bg-primary');}updateASRStatus(true);}function createCollapsibleThinkingContainer() {if (!elements.chatContainer) return "";const containerId = `thinking-container-${Date.now()}`;const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });const containerHtml = `<div id="${containerId}" class="flex items-start space-x-3 my-4 thinking-container"><div class="bg-primary/10 p-2 rounded-full mt-1"><i class="fa fa-lightbulb-o text-primary"></i></div><div class="bg-gray-50 rounded-xl p-4 max-w-[80%]"><div class="flex justify-between items-center mb-2"><span class="text-sm font-medium text-gray-600">AI思考过程</span><button class="toggle-thinking text-xs text-primary hover:underline">展开</button></div><div class="thinking-content text-sm text-gray-700 leading-relaxed">正在分析问题...</div><div class="thinking-mask"></div><p class="text-xs text-gray-500 mt-2">${time}</p></div></div>`;elements.chatContainer.insertAdjacentHTML('beforeend', containerHtml);elements.chatContainer.scrollTop = elements.chatContainer.scrollHeight;return containerId;}// 修复后的processTranscript函数async function processTranscript() {state.isProcessing = true;updateLLMStatus(true, "处理中...");try {let thinkingContainerId = null;let thinkingContainer = null;let thinkingContent = null;let thinkingMask = null;let toggleBtn = null;let hasThinkingContent = false; // 标记是否有思考内容let curMessage = ""; // 存储最终回答内容let isInThinking = false; // 标记是否处于思考片段中let currentThinkingChunk = ""; // 存储当前思考片段const completion = await state.engine.chat.completions.create({stream: true,messages: state.messages,temperature: parseFloat(state.settings.llmTemperature),top_p: 1});// 处理流式响应for await (const chunk of completion) {const curDelta = chunk.choices[0].delta.content;console.log('delta:', curDelta);if (!curDelta) continue;// 识别思考片段标记(`开头/结尾)if (curDelta.includes('think>')) {// 切换思考状态isInThinking = !isInThinking;// 不将标记字符添加到内容中const contentWithoutMarker = curDelta.replace('<think>', '').replace('</think>','');if (contentWithoutMarker) {currentThinkingChunk += contentWithoutMarker;hasThinkingContent = true;// 如果是首次有思考内容,创建思考容器if (!thinkingContainerId) {thinkingContainerId = createCollapsibleThinkingContainer();thinkingContainer = document.getElementById(thinkingContainerId);thinkingContent = thinkingContainer.querySelector('.thinking-content');thinkingMask = thinkingContainer.querySelector('.thinking-mask');toggleBtn = thinkingContainer.querySelector('.toggle-thinking');}// 更新思考内容thinkingContent.textContent = currentThinkingChunk;}} else if (isInThinking) {// 处理思考内容currentThinkingChunk += curDelta;hasThinkingContent = true;// 如果是首次有思考内容,创建思考容器if (!thinkingContainerId) {thinkingContainerId = createCollapsibleThinkingContainer();thinkingContainer = document.getElementById(thinkingContainerId);thinkingContent = thinkingContainer.querySelector('.thinking-content');thinkingMask = thinkingContainer.querySelector('.thinking-mask');toggleBtn = thinkingContainer.querySelector('.toggle-thinking');}// 更新思考内容thinkingContent.textContent = currentThinkingChunk;} else {// 处理回答内容curMessage += curDelta;}}// 如果没有思考内容,移除思考容器if (thinkingContainerId && !hasThinkingContent) {const container = document.getElementById(thinkingContainerId);if (container) container.remove();} else if (toggleBtn) {// 绑定折叠/展开事件toggleBtn.addEventListener('click', () => {thinkingContainer.classList.toggle('expanded');thinkingMask.classList.toggle('hidden');toggleBtn.textContent = thinkingContainer.classList.contains('expanded') ? '折叠' : '展开';});}// 确保完整输出最后一句if (curMessage.trim().length > 0) {addMessage('assistant', curMessage);addToSpeechQueue(curMessage);}// 保存最终消息到历史const finalMessage = { content: curMessage, role: "assistant" };state.messages.push(finalMessage);// 处理语音队列和统计信息processSpeechQueue();state.engine.runtimeStatsText().then(statsText => {if (elements.chatStats) {elements.chatStats.classList.remove('hidden');elements.chatStats.textContent = statsText;}});} catch (error) {console.error("处理文本失败:", error);showErrorMessage("处理请求失败: " + error.message);} finally {state.isProcessing = false;updateLLMStatus(true);}}function endsWithPunctuation(str) {const punctuation = ['.', '。', '!', '!', '?', '?', ',', ',', ';', ';', ':'];return punctuation.some(p => str.trim().endsWith(p));}function getLastPunctuationIndex(str) {const punctuation = ['.', '。', '!', '!', '?', '?', ',', ',', ';', ';', ':'];let lastIndex = -1;for (const p of punctuation) {const index = str.lastIndexOf(p);if (index > lastIndex) {lastIndex = index;}}return lastIndex;}function addToSpeechQueue(text) {if (text.trim().length > 0) {state.currentSpeechQueue.push(text.trim());}}async function processSpeechQueue() {if (state.isProcessingSpeech || state.currentSpeechQueue.length === 0) {return;}state.isProcessingSpeech = true;while (state.currentSpeechQueue.length > 0) {const text = state.currentSpeechQueue.shift();await speakText(text);}state.isProcessingSpeech = false;}function updateLastAssistantMessage(text) {if (!elements.chatContainer) return;let lastMessage = elements.chatContainer.lastChild;let hasAssistantMessage = false;if (lastMessage && lastMessage.nodeType === 1 && typeof lastMessage.querySelector === 'function') {hasAssistantMessage = !!lastMessage.querySelector('.bg-gray-100');}if (!lastMessage || !hasAssistantMessage) {addMessage('assistant', text);return;}const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });lastMessage.innerHTML = `<div class="bg-primary/10 p-2 rounded-full"><i class="fa fa-robot text-primary"></i></div><div class="bg-gray-100 rounded-xl p-4 max-w-[80%]"><p>${text}</p><p class="text-xs text-gray-500 mt-2">${time}</p></div>`;elements.chatContainer.scrollTop = elements.chatContainer.scrollHeight;}async function speakText(text) {if (!state.synthesizer || state.isSpeaking) {return;}state.isSpeaking = true;updateTTSStatus(true, "正在说话...");try {state.synthesizer.cancel();const utterance = new SpeechSynthesisUtterance(text);utterance.volume = state.settings.ttsVolume;utterance.lang = state.settings.asrLanguage;const voices = state.synthesizer.getVoices();const selectedVoice = voices.find(voice => voice.voiceURI === state.settings.ttsVoice);if (selectedVoice) {utterance.voice = selectedVoice;}await new Promise((resolve, reject) => {utterance.onend = resolve;utterance.onerror = reject;state.synthesizer.speak(utterance);});} catch (error) {console.error("语音合成失败:", error);showErrorMessage("语音合成失败: " + error.message);} finally {state.isSpeaking = false;updateTTSStatus(true);}}function addMessage(sender, text, isLoading = false) {if (!elements.chatContainer) return null;const messageId = `msg-${Date.now()}`;const messageDiv = document.createElement('div');messageDiv.id = messageId;messageDiv.className = `flex items-start space-x-3 ${sender === 'user' ? 'justify-end' : ''}`;let contentHtml = '';if (isLoading) {if (sender === 'user') {contentHtml = `<div class="bg-blue-50 rounded-xl p-4 max-w-[80%]"><div class="flex space-x-1"><div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div><div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.2s"></div><div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0.4s"></div></div></div>`;} else {contentHtml = `<div class="bg-gray-100 rounded-xl p-4 max-w-[80%]"><div class="flex space-x-1"><div class="w-2 h-2 bg-gray-500 rounded-full animate-bounce"></div><div class="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.2s"></div><div class="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style="animation-delay: 0.4s"></div></div></div>`;}} else {const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });if (sender === 'user') {contentHtml = `<div class="bg-primary/10 text-dark rounded-xl p-4 max-w-[80%]"><p>${text}</p><p class="text-xs text-gray-500 mt-2">${time}</p></div><div class="bg-primary p-2 rounded-full"><i class="fa fa-user text-white"></i></div>`;} else {contentHtml = `<div class="bg-primary/10 p-2 rounded-full"><i class="fa fa-robot text-primary"></i></div><div class="bg-gray-100 rounded-xl p-4 max-w-[80%]"><p>${text}</p><p class="text-xs text-gray-500 mt-2">${time}</p></div>`;}}messageDiv.innerHTML = contentHtml;elements.chatContainer.appendChild(messageDiv);elements.chatContainer.scrollTop = elements.chatContainer.scrollHeight;return messageId;}function updateLastUserMessage(text) {if (!elements.chatContainer) return;const lastMessage = elements.chatContainer.lastChild;if (lastMessage && lastMessage.querySelector('.bg-blue-50')) {const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });lastMessage.innerHTML = `<div class="bg-primary/10 text-dark rounded-xl p-4 max-w-[80%]"><p>${text}</p><p class="text-xs text-gray-500 mt-2">${time}</p></div><div class="bg-primary p-2 rounded-full"><i class="fa fa-user text-white"></i></div>`;}}function removeLastUserMessageIfEmpty() {if (!elements.chatContainer) return;const lastMessage = elements.chatContainer.lastChild;if (lastMessage && lastMessage.querySelector('.bg-blue-50')) {elements.chatContainer.removeChild(lastMessage);}}function removeMessage(messageId) {if (!messageId) return;const message = document.getElementById(messageId);if (message) {message.remove();}}function showErrorMessage(message) {addMessage('assistant', `<span class="text-red-500">${message}</span>`);}function updateASRStatus(ready, text = null) {if (elements.asrStatus) {elements.asrStatus.textContent = text || (ready ? "就绪" : "未就绪");}if (elements.asrIndicator) {elements.asrIndicator.className = `w-2 h-2 rounded-full ${ready ? 'bg-green-500' : 'bg-red-500'} ml-2`;}}function updateLLMStatus(ready, text = null) {if (elements.llmStatus) {elements.llmStatus.textContent = text || (ready ? "就绪" : "未就绪");}if (elements.llmIndicator) {elements.llmIndicator.className = `w-2 h-2 rounded-full ${ready ? 'bg-green-500' : 'bg-red-500'} ml-2`;}}function updateTTSStatus(ready, text = null) {if (elements.ttsStatus) {elements.ttsStatus.textContent = text || (ready ? "就绪" : "未就绪");}if (elements.ttsIndicator) {elements.ttsIndicator.className = `w-2 h-2 rounded-full ${ready ? 'bg-green-500' : 'bg-red-500'} ml-2`;}}function saveSettings() {if (elements.asrLanguage) state.settings.asrLanguage = elements.asrLanguage.value;if (elements.ttsVoice) state.settings.ttsVoice = elements.ttsVoice.value;if (elements.ttsVolume) state.settings.ttsVolume = parseFloat(elements.ttsVolume.value);if (elements.llmTemperature) state.settings.llmTemperature = parseFloat(elements.llmTemperature.value);if (elements.modelSelection) state.settings.selectedModel = elements.modelSelection.value;localStorage.setItem('voiceAssistantSettings', JSON.stringify(state.settings));if (state.recognition) {state.recognition.lang = state.settings.asrLanguage;}if (elements.settingsModal) {elements.settingsModal.classList.add('hidden');}addMessage('assistant', "设置已保存");}function loadSettings() {const savedSettings = localStorage.getItem('voiceAssistantSettings');if (savedSettings) {try {const parsedSettings = JSON.parse(savedSettings);state.settings = { ...state.settings, ...parsedSettings };if (elements.asrLanguage) elements.asrLanguage.value = state.settings.asrLanguage;if (elements.ttsVolume) elements.ttsVolume.value = state.settings.ttsVolume;if (elements.llmTemperature) elements.llmTemperature.value = state.settings.llmTemperature;if (elements.modelSelection) {elements.modelSelection.value = state.settings.selectedModel || "Qwen2.5-0.5B-Instruct-q4f16_1-MLC";}} catch (error) {console.error("加载设置失败:", error);}}}window.addEventListener('DOMContentLoaded', initApp);</script>
</body>
</html>


文章转载自:

http://TGGhiSzI.mrfjr.cn
http://Y0mQjm8t.mrfjr.cn
http://j76Ne1RW.mrfjr.cn
http://qzoCH0hq.mrfjr.cn
http://DCdoeK0g.mrfjr.cn
http://NSbtjxHs.mrfjr.cn
http://WsV4B0Gg.mrfjr.cn
http://jFnGQ0aP.mrfjr.cn
http://TOQKO2cX.mrfjr.cn
http://chjMx0Zg.mrfjr.cn
http://3UgromTq.mrfjr.cn
http://9vvgIBVd.mrfjr.cn
http://dcj9tEz0.mrfjr.cn
http://KWQzG4J1.mrfjr.cn
http://dj1nY535.mrfjr.cn
http://Ti7g66Oj.mrfjr.cn
http://EICWMw5O.mrfjr.cn
http://gHkLNReg.mrfjr.cn
http://zPMTwKih.mrfjr.cn
http://S0JEU4zw.mrfjr.cn
http://m4VWqGYV.mrfjr.cn
http://V3ZEEop4.mrfjr.cn
http://Zz8HsktM.mrfjr.cn
http://BtjiyPK7.mrfjr.cn
http://8IEY4Wiu.mrfjr.cn
http://03ObEglK.mrfjr.cn
http://mlSOQK0o.mrfjr.cn
http://20Zh3u0c.mrfjr.cn
http://cRohGTzq.mrfjr.cn
http://NAZxDMb1.mrfjr.cn
http://www.dtcms.com/a/379134.html

相关文章:

  • 动态热机械分析测试(DMA):解析材料的粘弹性能
  • 【龙智Atlassian插件】Confluence周报插件上线AI智能总结,一键生成专业报告
  • 因表并行引发的血案【故障处理案例】
  • 实现双向循环链表
  • Flutter Riverpod 3.0 发布,大规模重构下的全新状态管理框架
  • This is Game
  • Git分支管理:从创建到合并冲突解决(二)
  • Elasticsearch 7.15 存储类型详解
  • 深入解析数据结构之栈及其应用
  • (一)昇腾AI处理器技术
  • BUUCTF刷题十一道(14)
  • Linux防火墙-Iptables
  • python访问基于docker搭建的elasticsearch
  • logback-spring.xml文件说明
  • 【PyTorch训练】为什么要有 loss.backward() 和 optimizer.step()?
  • 抖音大数据开发一面(0905)
  • 原生js的轮播图
  • 连接池项目考点
  • ruoyi-flowable-plus框架节点表单的理解
  • js.228汇总区间
  • BERT中文预训练模型介绍
  • 光平面标定建立激光点与世界坐标的对应关系
  • Jmeter执行数据库操作
  • 基于FPGA的图像中值滤波算法Verilog开发与开发板硬件测试
  • 微软Aurora大模型实战:五大数据源驱动、可视化对比与应用
  • 【论文笔记】SpaRC: Sparse Radar-Camera Fusion for 3D Object Detection
  • C++基本数据类型的范围
  • Spring AI(三)多模态支持(豆包)
  • agentic Deep search相关内容补充
  • 第一篇:如何在数组中操作数据【数据结构入门】