Spaces:
Paused
Paused
| const API_BASE_URL = ''; | |
| const CHAT_ENDPOINT = `${API_BASE_URL}/v1/chat/completions`; | |
| const chatContainer = document.getElementById('chat-container'); | |
| const userInput = document.getElementById('user-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const newChatBtn = document.getElementById('new-chat-btn'); | |
| const fileInput = document.getElementById('file-input'); | |
| const uploadBtn = document.getElementById('upload-btn'); | |
| const previewContainer = document.getElementById('preview-container'); | |
| const drawModeSwitch = document.getElementById('draw-mode-switch'); | |
| const healthDot = document.getElementById('health-dot'); | |
| let currentAbortController = null; | |
| function updateUIState(typing) { | |
| isTyping = typing; | |
| if (typing) { | |
| sendBtn.classList.remove('disabled'); | |
| sendBtn.classList.add('stop-mode'); | |
| sendBtn.innerHTML = '<i class="fas fa-stop"></i>'; | |
| sendBtn.title = '停止生成'; | |
| } else { | |
| const text = userInput.value.trim(); | |
| sendBtn.classList.toggle('disabled', !text && attachedFiles.length === 0); | |
| sendBtn.classList.remove('stop-mode'); | |
| sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>'; | |
| sendBtn.title = '发送'; | |
| } | |
| } | |
| async function checkHealth() { | |
| if (!healthDot) return; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/health`); | |
| const data = await response.json(); | |
| if (data && data.ok) { | |
| healthDot.className = 'status-dot online'; | |
| healthDot.title = '后端服务正常'; | |
| } else { | |
| healthDot.className = 'status-dot offline'; | |
| healthDot.title = '后端服务异常'; | |
| } | |
| } catch (error) { | |
| healthDot.className = 'status-dot offline'; | |
| healthDot.title = '连接失败'; | |
| } | |
| } | |
| // 检测是否为移动设备 | |
| const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); | |
| // 画图/搜图意图识别关键词 | |
| const DRAW_KEYWORDS = [ | |
| '画', '图', '生成图片', '生成图像', '画一张', '画个', '画一', | |
| '搜索', '搜', '搜图', '查找图片', '找图片', | |
| 'image', 'draw', 'paint', 'illustration', 'picture', 'photo', 'search' | |
| ]; | |
| function isDrawingRequest(text) { | |
| if (!text) return false; | |
| const lowerText = text.toLowerCase(); | |
| return DRAW_KEYWORDS.some(key => lowerText.includes(key.toLowerCase())); | |
| } | |
| // 压缩历史记录中的图片:移除 base64 编码的图片数据以减小请求体积 | |
| function compressHistoryForAPI(history) { | |
| return history.map(msg => { | |
| if (typeof msg.content === 'string') { | |
| return { | |
| role: msg.role, | |
| content: msg.content.replace(/!\[([^\]]*)\]\(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+\)/g, '') | |
| }; | |
| } | |
| if (Array.isArray(msg.content)) { | |
| return { | |
| role: msg.role, | |
| content: msg.content.filter(part => { | |
| if (part.type === 'image_url' && part.image_url.url.startsWith('data:')) { | |
| return false; // 提示词中移除大的 base64 图片 | |
| } | |
| return true; | |
| }) | |
| }; | |
| } | |
| return msg; | |
| }); | |
| } | |
| let chatHistory = []; | |
| let attachedFiles = []; // 存储当前待发送的文件 | |
| let isTyping = false; | |
| // 配置 marked 自定义渲染器 | |
| const renderer = new marked.Renderer(); | |
| // 语言显示名称映射 | |
| const LANG_DISPLAY_NAMES = { | |
| 'plaintext': '文本', | |
| 'text': '文本', | |
| 'python': 'Python', | |
| 'py': 'Python', | |
| 'javascript': 'JavaScript', | |
| 'js': 'JavaScript', | |
| 'typescript': 'TypeScript', | |
| 'ts': 'TypeScript', | |
| 'java': 'Java', | |
| 'cpp': 'C++', | |
| 'c': 'C', | |
| 'csharp': 'C#', | |
| 'cs': 'C#', | |
| 'go': 'Go', | |
| 'rust': 'Rust', | |
| 'rs': 'Rust', | |
| 'ruby': 'Ruby', | |
| 'rb': 'Ruby', | |
| 'php': 'PHP', | |
| 'swift': 'Swift', | |
| 'kotlin': 'Kotlin', | |
| 'kt': 'Kotlin', | |
| 'scala': 'Scala', | |
| 'r': 'R', | |
| 'matlab': 'MATLAB', | |
| 'sql': 'SQL', | |
| 'bash': 'Bash', | |
| 'sh': 'Shell', | |
| 'shell': 'Shell', | |
| 'powershell': 'PowerShell', | |
| 'ps': 'PowerShell', | |
| 'html': 'HTML', | |
| 'css': 'CSS', | |
| 'scss': 'SCSS', | |
| 'sass': 'Sass', | |
| 'less': 'Less', | |
| 'xml': 'XML', | |
| 'json': 'JSON', | |
| 'yaml': 'YAML', | |
| 'yml': 'YAML', | |
| 'toml': 'TOML', | |
| 'ini': 'INI', | |
| 'dockerfile': 'Dockerfile', | |
| 'docker': 'Dockerfile', | |
| 'makefile': 'Makefile', | |
| 'markdown': 'Markdown', | |
| 'md': 'Markdown', | |
| 'latex': 'LaTeX', | |
| 'tex': 'LaTeX', | |
| 'regex': 'Regex' | |
| }; | |
| renderer.code = function (code, lang) { | |
| const rawLang = (lang || 'plaintext').toLowerCase().trim(); | |
| const displayName = LANG_DISPLAY_NAMES[rawLang] || rawLang.toUpperCase(); | |
| // 使用 highlight.js 进行高亮处理 | |
| const highlighted = hljs.getLanguage(rawLang) | |
| ? hljs.highlight(code, { language: rawLang }).value | |
| : hljs.highlightAuto(code).value; | |
| return ` | |
| <div class="code-block-wrapper"> | |
| <div class="code-header"> | |
| <span class="code-lang">${displayName}</span> | |
| <button class="copy-code-btn" onclick="copyToClipboard(this)"> | |
| <i class="far fa-copy"></i> 复制 | |
| </button> | |
| </div> | |
| <pre><code class="hljs language-${rawLang}">${highlighted}</code></pre> | |
| </div> | |
| `; | |
| }; | |
| marked.setOptions({ | |
| renderer: renderer, | |
| breaks: true, | |
| gfm: true, | |
| headerIds: false, | |
| mangle: false | |
| }); | |
| // 全局复制函数 | |
| window.copyToClipboard = (btn) => { | |
| const code = btn.closest('.code-block-wrapper').querySelector('code').innerText; | |
| navigator.clipboard.writeText(code).then(() => { | |
| const originalHtml = btn.innerHTML; | |
| btn.innerHTML = '<i class="fas fa-check"></i> 已复制'; | |
| btn.classList.add('success'); | |
| setTimeout(() => { | |
| btn.innerHTML = originalHtml; | |
| btn.classList.remove('success'); | |
| }, 2000); | |
| }).catch(err => { | |
| console.error('复制失败:', err); | |
| // Fallback for mobile | |
| const textArea = document.createElement('textarea'); | |
| textArea.value = code; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| try { | |
| document.execCommand('copy'); | |
| btn.innerHTML = '<i class="fas fa-check"></i> 已复制'; | |
| btn.classList.add('success'); | |
| setTimeout(() => { | |
| btn.innerHTML = originalHtml; | |
| btn.classList.remove('success'); | |
| }, 2000); | |
| } catch (e) { | |
| console.error('复制失败:', e); | |
| } | |
| document.body.removeChild(textArea); | |
| }); | |
| }; | |
| // 自动调整输入框高度 | |
| function adjustTextareaHeight() { | |
| userInput.style.height = 'auto'; | |
| const maxHeight = isMobile ? 120 : 200; | |
| const newHeight = Math.min(userInput.scrollHeight, maxHeight); | |
| userInput.style.height = newHeight + 'px'; | |
| } | |
| userInput.addEventListener('input', () => { | |
| adjustTextareaHeight(); | |
| sendBtn.classList.toggle('disabled', !userInput.value.trim() && attachedFiles.length === 0); | |
| }); | |
| // 聊天记录持久化管理 | |
| function saveHistory() { | |
| try { | |
| localStorage.setItem('gemini_chat_history', JSON.stringify(chatHistory)); | |
| } catch (e) { | |
| console.warn('无法保存历史记录:', e); | |
| } | |
| } | |
| function loadHistory() { | |
| try { | |
| const saved = localStorage.getItem('gemini_chat_history'); | |
| if (saved) { | |
| chatHistory = JSON.parse(saved); | |
| if (chatHistory.length > 0) { | |
| chatContainer.innerHTML = ''; | |
| chatHistory.forEach(msg => appendMessage(msg.role, msg.content)); | |
| scrollToBottom(); | |
| } | |
| } | |
| } catch (e) { | |
| console.warn('无法加载历史记录:', e); | |
| chatHistory = []; | |
| } | |
| } | |
| function scrollToBottom() { | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| // 模型选择逻辑(固定为 gemini-3.0-pro) | |
| let currentModel = 'gemini-3.0-pro'; | |
| const currentModelNameSpan = document.getElementById('current-model-name'); | |
| if (currentModelNameSpan) { | |
| currentModelNameSpan.textContent = 'Gemini 3 Pro'; | |
| } | |
| // 修改发送消息逻辑以包含模型参数 | |
| async function sendMessage() { | |
| // 如果正在输入,点击按钮则停止生成 | |
| if (isTyping) { | |
| if (currentAbortController) { | |
| currentAbortController.abort(); | |
| } | |
| return; | |
| } | |
| const text = userInput.value.trim(); | |
| if (!text && attachedFiles.length === 0) return; | |
| // 创建新的中断控制器 | |
| currentAbortController = new AbortController(); | |
| const signal = currentAbortController.signal; | |
| if (chatContainer.querySelector('.welcome-screen')) chatContainer.innerHTML = ''; | |
| // 1. 构建显示内容 | |
| let userDisplayContent = text ? escapeHtml(text) : ''; | |
| if (attachedFiles.length > 0) { | |
| const imagesHtml = attachedFiles | |
| .filter(f => f.type.startsWith('image/')) | |
| .map(f => `<img src="${f.data}" style="max-width: 100%; max-height: 280px; border-radius: 8px; margin-top: 10px; display: block;">`) | |
| .join(''); | |
| const filesHtml = attachedFiles | |
| .filter(f => !f.type.startsWith('image/')) | |
| .map(f => `<div style="background: var(--bg-tertiary); padding: 8px 12px; border-radius: 8px; margin-top: 6px; font-size: 13px; display: inline-flex; align-items: center; gap: 8px;"><i class="fas fa-file"></i> ${escapeHtml(f.name)}</div>`) | |
| .join(''); | |
| userDisplayContent += (imagesHtml ? '<br>' + imagesHtml : '') + filesHtml; | |
| } | |
| appendMessage('user', userDisplayContent); | |
| // 2. 构建 API 消息格式 | |
| const messageParts = []; | |
| if (text) messageParts.push({ type: 'text', text: text }); | |
| attachedFiles.forEach(file => { | |
| messageParts.push({ | |
| type: 'image_url', | |
| image_url: { url: `data:${file.type};base64,${file.base64}` } | |
| }); | |
| }); | |
| // 3. 清空输入状态 | |
| userInput.value = ''; | |
| userInput.style.height = 'auto'; | |
| attachedFiles = []; | |
| renderPreviews(); | |
| updateUIState(true); | |
| // 4. 更新历史记录 (保存结构化内容以保留图片等上下文) | |
| chatHistory.push({ role: 'user', content: messageParts }); | |
| saveHistory(); | |
| const contentDiv = appendMessage('assistant', '', 'ai-' + Date.now()); | |
| let aiText = ''; | |
| try { | |
| const API_KEY = window.GEMINI_CONFIG?.API_KEY || ''; | |
| let response; | |
| const isDrawMode = drawModeSwitch.checked; | |
| if (isDrawMode) { | |
| // 1. 画图模式:使用特殊的 /v1/responses 接口和格式 | |
| // 构建带历史记录的输入 | |
| const compressedHistory = compressHistoryForAPI(chatHistory.slice(0, -1)); | |
| const historyContext = compressedHistory.map(m => `${m.role}: ${m.content}`).join('\n'); | |
| const inputWithHistory = historyContext ? `${historyContext}\nuser: ${text}` : text; | |
| response = await fetch(`${API_BASE_URL}/v1/responses`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${API_KEY}` | |
| }, | |
| body: JSON.stringify({ | |
| model: 'gemini-3.0-pro', | |
| input: inputWithHistory, | |
| tools: [{ type: 'image_generation' }], | |
| temperature: 0.9 | |
| }), | |
| signal: signal | |
| }); | |
| if (!response.ok) throw new Error(`画图请求失败: ${response.status}`); | |
| const result = await response.json(); | |
| let finalMarkdown = ''; | |
| // 解析响应:提取文字和图片URL | |
| if (result.output && Array.isArray(result.output)) { | |
| result.output.forEach(item => { | |
| if (item.type === 'message' && item.content) { | |
| item.content.forEach(c => { | |
| if (c.type === 'output_text') finalMarkdown += c.text; | |
| }); | |
| } | |
| }); | |
| } | |
| if (finalMarkdown) { | |
| contentDiv.innerHTML = marked.parse(finalMarkdown); | |
| chatHistory.push({ role: 'assistant', content: finalMarkdown }); | |
| } else { | |
| contentDiv.innerHTML = "未能提取到有效响应"; | |
| } | |
| updateUIState(false); | |
| saveHistory(); | |
| return; // 结束画图逻辑 | |
| } | |
| // 2. 普通对话模式:自动识别画图意图 | |
| const autoDetectDraw = isDrawingRequest(text); | |
| // 使用压缩后的历史记录(移除 base64 图片数据) | |
| const compressedHistory = compressHistoryForAPI(chatHistory.slice(0, -1)); | |
| response = await fetch(CHAT_ENDPOINT, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${API_KEY}` | |
| }, | |
| body: JSON.stringify({ | |
| model: currentModel, | |
| messages: [ | |
| ...compressedHistory, | |
| { role: 'user', content: messageParts } | |
| ], | |
| tools: autoDetectDraw ? [{ | |
| type: 'function', | |
| function: { | |
| name: 'image_generation', | |
| description: 'Generate an image based on user prompt', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| prompt: { type: 'string' } | |
| }, | |
| required: ['prompt'] | |
| } | |
| } | |
| }] : [], | |
| stream: !autoDetectDraw // 检测到画图意图时关闭流式输出 | |
| }), | |
| signal: signal | |
| }); | |
| if (!response.ok) { | |
| if (response.status === 502 || response.status === 504) { | |
| throw new Error('上游服务(Gemini)响应超时或连接中断,请稍后重试或更换图片。'); | |
| } else if (response.status === 503) { | |
| throw new Error('服务器当前繁忙,请稍后重试。'); | |
| } | |
| throw new Error(`API 请求失败: ${response.status}`); | |
| } | |
| // 如果检测到画图意图,使用非流式处理 | |
| if (autoDetectDraw) { | |
| const result = await response.json(); | |
| const message = result.choices[0].message; | |
| if (message.tool_calls && message.tool_calls.length > 0) { | |
| await handleToolCalls(message.tool_calls, contentDiv, API_KEY); | |
| } else if (message.content) { | |
| contentDiv.innerHTML = marked.parse(message.content); | |
| chatHistory.push({ role: 'assistant', content: message.content }); | |
| } | |
| updateUIState(false); | |
| saveHistory(); | |
| return; | |
| } | |
| // 渲染文并处理代码块 | |
| const renderText = (text) => { | |
| let processed = text; | |
| const codeBlockCount = (processed.match(/```/g) || []).length; | |
| if (codeBlockCount % 2 !== 0) { | |
| if (!processed.endsWith('\n')) processed += '\n'; | |
| processed += '```'; | |
| } | |
| contentDiv.innerHTML = marked.parse(processed); | |
| contentDiv.querySelectorAll('pre code').forEach((block) => hljs.highlightElement(block)); | |
| }; | |
| // 普通流式处理 | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let streamedToolCalls = []; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| const lines = (buffer + chunk).split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.trim().startsWith('data: ')) { | |
| const data = line.trim().slice(6); | |
| if (data === '[DONE]') continue; | |
| try { | |
| const parsed = JSON.parse(data); | |
| // --- OpenAI 格式处理 --- | |
| if (parsed.choices && parsed.choices[0]) { | |
| const choice = parsed.choices[0]; | |
| const delta = choice.delta; | |
| if (delta.content) { | |
| aiText += delta.content; | |
| renderText(aiText); | |
| } | |
| if (delta.tool_calls) { | |
| for (const tcall of delta.tool_calls) { | |
| if (!streamedToolCalls[tcall.index]) { | |
| streamedToolCalls[tcall.index] = { | |
| id: tcall.id, | |
| type: tcall.type || 'function', | |
| function: { name: '', arguments: '' } | |
| }; | |
| } | |
| if (tcall.function) { | |
| if (tcall.function.name) streamedToolCalls[tcall.index].function.name += tcall.function.name; | |
| if (tcall.function.arguments) streamedToolCalls[tcall.index].function.arguments += tcall.function.arguments; | |
| } | |
| } | |
| } | |
| } | |
| // --- Anthropic 格式处理 --- | |
| else if (parsed.type) { | |
| if (parsed.type === 'content_block_delta' && parsed.delta) { | |
| if (parsed.delta.type === 'text_delta') { | |
| aiText += parsed.delta.text; | |
| renderText(aiText); | |
| } else if (parsed.delta.type === 'input_json_delta') { | |
| const idx = parsed.index; | |
| if (!streamedToolCalls[idx]) streamedToolCalls[idx] = { function: { name: '', arguments: '' } }; | |
| streamedToolCalls[idx].function.arguments += parsed.delta.partial_json; | |
| } | |
| } else if (parsed.type === 'content_block_start' && parsed.content_block) { | |
| if (parsed.content_block.type === 'tool_use') { | |
| const idx = parsed.index; | |
| streamedToolCalls[idx] = { | |
| id: parsed.content_block.id, | |
| function: { name: parsed.content_block.name, arguments: '' } | |
| }; | |
| } | |
| } | |
| } | |
| if (!isMobile || Math.random() > 0.7) scrollToBottom(); | |
| } catch (e) { } | |
| } | |
| } | |
| } | |
| // 流结束后检查是否有工具调用需要处理 | |
| if (streamedToolCalls.length > 0) { | |
| await handleToolCalls(streamedToolCalls.filter(c => c), contentDiv, API_KEY); | |
| } else { | |
| chatHistory.push({ role: 'assistant', content: aiText }); | |
| } | |
| updateUIState(false); | |
| saveHistory(); | |
| } catch (error) { | |
| if (error.name === 'AbortError') { | |
| console.log('生成已停止'); | |
| if (!aiText) contentDiv.innerHTML = "<em>生成已手动停止</em>"; | |
| } else { | |
| console.error('Error:', error); | |
| contentDiv.innerHTML = marked.parse(`抱歉,出错了:${error.message}`); | |
| } | |
| updateUIState(false); | |
| } finally { | |
| isTyping = false; | |
| scrollToBottom(); | |
| } | |
| } | |
| // 统一的工具调用处理逻辑 | |
| async function handleToolCalls(toolCalls, contentDiv, API_KEY) { | |
| if (!toolCalls || toolCalls.length === 0) return; | |
| // 目前逻辑主要处理搜图和画图。如果有多个工具调用,我们优先处理第一个图像相关的。 | |
| const toolCall = toolCalls.find(c => c.function.name === 'image_generation' || c.function.name === 'image_retrieval:search') || toolCalls[0]; | |
| const toolName = toolCall.function.name; | |
| // 仅自动处理画图和搜图工具 | |
| if (toolName === 'image_generation' || toolName === 'image_retrieval:search') { | |
| try { | |
| const args = JSON.parse(toolCall.function.arguments); | |
| // 兼容不同工具的参数名:画图用 prompt,搜图用 query | |
| const prompt = args.prompt || args.query || ""; | |
| const actionName = toolName === 'image_generation' ? '生成' : '搜索'; | |
| if (!prompt) return; | |
| contentDiv.innerHTML = `<div class="typing-indicator"><span></span><span></span><span></span></div><div style="margin-top: 8px;">正在为你${actionName}图片:<strong>${escapeHtml(prompt)}</strong>...</div>`; | |
| const imageResponse = await fetch(`${API_BASE_URL}/v1/responses`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${API_KEY}` | |
| }, | |
| body: JSON.stringify({ | |
| model: 'gemini-3.0-pro', | |
| input: prompt, | |
| tools: [{ type: 'image_generation' }], | |
| temperature: 0.9 | |
| }) | |
| }); | |
| if (!imageResponse.ok) throw new Error('服务响应异常'); | |
| const imageResult = await imageResponse.json(); | |
| let finalMarkdown = ''; | |
| if (imageResult.output && Array.isArray(imageResult.output)) { | |
| imageResult.output.forEach(item => { | |
| if (item.type === 'message' && item.content) { | |
| item.content.forEach(c => { | |
| if (c.type === 'output_text') finalMarkdown += c.text; | |
| }); | |
| } | |
| }); | |
| } | |
| if (finalMarkdown) { | |
| contentDiv.innerHTML = marked.parse(finalMarkdown); | |
| chatHistory.push({ role: 'assistant', content: finalMarkdown }); | |
| saveHistory(); | |
| } else { | |
| contentDiv.innerHTML = "未能获取到图片内容"; | |
| } | |
| } catch (error) { | |
| console.error('工具调用处理失败:', error); | |
| contentDiv.innerHTML = marked.parse(`操作失败:${escapeHtml(error.message)}`); | |
| } | |
| } | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| function appendMessage(role, text, id = null) { | |
| const msgDiv = document.createElement('div'); | |
| msgDiv.className = `message ${role}`; | |
| if (id) msgDiv.id = id; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'avatar'; | |
| if (role === 'user') { | |
| avatar.innerHTML = '<i class="fas fa-user"></i>'; | |
| } else { | |
| avatar.innerHTML = '<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzRhOTBmNSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCBkPSJNMTIgM2MtNC45NyAwLTkgNC4wMy05IDlzNC4wMyA5IDkgOSA5LTQuMDMgOS05LTQuMDMtOS05LTl6bTAgMTRjLTIuNzYgMC01LTIuMjQtNS01czIuMjQtNSA1LTUgNSAyLjI0IDUgNS0yLjI0IDUtNSA1em0wLTljLTEuNjYgMC0zIDEuMzQtMyAzczEuMzQgMyAzIDMgMy0xLjM0IDMtMy0xLjM0LTMtMy0zeiIvPjwvc3ZnPg==">'; | |
| } | |
| const content = document.createElement('div'); | |
| content.className = 'content'; | |
| if (!text) { | |
| content.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>'; | |
| } else { | |
| content.innerHTML = text.startsWith('<') ? text : marked.parse(text); | |
| // 高亮代码块 | |
| setTimeout(() => { | |
| content.querySelectorAll('pre code').forEach((block) => { | |
| hljs.highlightElement(block); | |
| }); | |
| }, 0); | |
| } | |
| msgDiv.appendChild(avatar); | |
| msgDiv.appendChild(content); | |
| chatContainer.appendChild(msgDiv); | |
| scrollToBottom(); | |
| return content; | |
| } | |
| // 事件绑定 | |
| uploadBtn.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', (e) => handleFiles(e.target.files)); | |
| // 监听粘贴事件 | |
| userInput.addEventListener('paste', (e) => { | |
| const items = e.clipboardData.items; | |
| for (let item of items) { | |
| if (item.type.indexOf('image') !== -1) { | |
| e.preventDefault(); | |
| const file = item.getAsFile(); | |
| handleFiles([file]); | |
| } | |
| } | |
| }); | |
| function handleFiles(files) { | |
| Array.from(files).forEach(file => { | |
| if (attachedFiles.length >= 5) { | |
| alert('最多只能上传 5 个文件'); | |
| return; | |
| } | |
| // 检查文件大小 (最大 10MB) | |
| if (file.size > 10 * 1024 * 1024) { | |
| alert(`文件 ${file.name} 超过 10MB 限制`); | |
| return; | |
| } | |
| if (file.type.startsWith('image/')) { | |
| // 图片压缩处理 | |
| compressImage(file, 1024, 0.8).then(compressed => { | |
| const fileObj = { | |
| name: file.name, | |
| type: compressed.type, | |
| data: compressed.dataUrl, | |
| base64: compressed.base64 | |
| }; | |
| attachedFiles.push(fileObj); | |
| renderPreviews(); | |
| }).catch(err => { | |
| console.error('图片压缩失败:', err); | |
| // 压缩失败则尝试原样读取 | |
| readOriginalFile(file); | |
| }); | |
| } else { | |
| readOriginalFile(file); | |
| } | |
| }); | |
| // 重置 input 以便可以重复选择同一文件 | |
| fileInput.value = ''; | |
| } | |
| function readOriginalFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| const fileObj = { | |
| name: file.name, | |
| type: file.type, | |
| data: e.target.result, | |
| base64: e.target.result.split(',')[1] | |
| }; | |
| attachedFiles.push(fileObj); | |
| renderPreviews(); | |
| }; | |
| reader.onerror = () => { | |
| alert(`读取文件 ${file.name} 失败`); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| /** | |
| * 压缩图片 | |
| * @param {File} file 原始文件 | |
| * @param {number} maxSide 最大边长 | |
| * @param {number} quality 压缩质量 (0-1) | |
| */ | |
| function compressImage(file, maxSide, quality) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(file); | |
| reader.onload = (e) => { | |
| const img = new Image(); | |
| img.src = e.target.result; | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| let width = img.width; | |
| let height = img.height; | |
| // 计算缩放比例 | |
| if (width > height) { | |
| if (width > maxSide) { | |
| height = Math.round((height * maxSide) / width); | |
| width = maxSide; | |
| } | |
| } else { | |
| if (height > maxSide) { | |
| width = Math.round((width * maxSide) / height); | |
| height = maxSide; | |
| } | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| // 导出为 JPEG (体积更小) | |
| const dataUrl = canvas.toDataURL('image/jpeg', quality); | |
| const base64 = dataUrl.split(',')[1]; | |
| resolve({ | |
| dataUrl: dataUrl, | |
| base64: base64, | |
| type: 'image/jpeg' | |
| }); | |
| }; | |
| img.onerror = reject; | |
| }; | |
| reader.onerror = reject; | |
| }); | |
| } | |
| function renderPreviews() { | |
| previewContainer.innerHTML = ''; | |
| attachedFiles.forEach((file, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'preview-item'; | |
| if (file.type.startsWith('image/')) { | |
| item.innerHTML = `<img src="${file.data}" alt=""><button onclick="removeFile(${index})" aria-label="删除"><i class="fas fa-times"></i></button>`; | |
| } else { | |
| const ext = file.name.split('.').pop().toUpperCase(); | |
| item.innerHTML = `<div class="file-icon"><i class="fas fa-file-alt"></i><span>${ext}</span></div><button onclick="removeFile(${index})" aria-label="删除"><i class="fas fa-times"></i></button>`; | |
| } | |
| previewContainer.appendChild(item); | |
| }); | |
| sendBtn.classList.toggle('disabled', !userInput.value.trim() && attachedFiles.length === 0); | |
| } | |
| window.removeFile = (index) => { | |
| attachedFiles.splice(index, 1); | |
| renderPreviews(); | |
| }; | |
| sendBtn.addEventListener('click', sendMessage); | |
| // 键盘事件处理 | |
| userInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| // 移动端优化:防止键盘弹起时布局问题 | |
| if (isMobile) { | |
| userInput.addEventListener('focus', () => { | |
| setTimeout(() => { | |
| scrollToBottom(); | |
| }, 300); | |
| }); | |
| } | |
| // 新对话按钮 | |
| newChatBtn.addEventListener('click', () => { | |
| if (isTyping) return; | |
| if (chatHistory.length > 0 && !confirm('确定要开始新对话吗?当前对话记录将被清除。')) { | |
| return; | |
| } | |
| chatHistory = []; | |
| localStorage.removeItem('gemini_chat_history'); | |
| attachedFiles = []; | |
| renderPreviews(); | |
| chatContainer.innerHTML = ` | |
| <div class="welcome-screen"> | |
| <h1>你好,我是你的 AI 助手</h1> | |
| <p>我可以帮你写作、编码、学习或提供创意建议。有什么可以帮你的吗?</p> | |
| <div class="suggested-cards"> | |
| <div class="card" data-prompt="帮我写一段 Python 爬虫代码"> | |
| <i class="fas fa-code" style="color: var(--accent-blue); margin-bottom: 8px; display: block;"></i> | |
| 帮我写一段 Python 爬虫代码 | |
| </div> | |
| <div class="card" data-prompt="解释量子力学的基础概念"> | |
| <i class="fas fa-atom" style="color: var(--accent-purple); margin-bottom: 8px; display: block;"></i> | |
| 解释量子力学的基础概念 | |
| </div> | |
| <div class="card" data-prompt="策划一次 3 天的上海旅行"> | |
| <i class="fas fa-plane" style="color: #4ade80; margin-bottom: 8px; display: block;"></i> | |
| 策划一次 3 天的上海旅行 | |
| </div> | |
| <div class="card" data-prompt="画一张未来城市的夜景"> | |
| <i class="fas fa-paint-brush" style="color: #f472b6; margin-bottom: 8px; display: block;"></i> | |
| 画一张未来城市的夜景 | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| bindCardEvents(); | |
| checkHealth(); // 新对话时也检查一次 | |
| }); | |
| // 建议卡片点击事件 | |
| function bindCardEvents() { | |
| document.querySelectorAll('.card').forEach(card => { | |
| card.addEventListener('click', () => { | |
| const prompt = card.getAttribute('data-prompt'); | |
| if (prompt) { | |
| userInput.value = prompt; | |
| userInput.style.height = 'auto'; | |
| userInput.style.height = userInput.scrollHeight + 'px'; | |
| sendBtn.classList.remove('disabled'); | |
| userInput.focus(); | |
| // 移动端自动发送 | |
| if (isMobile) { | |
| sendMessage(); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| // 初始化界面 | |
| loadHistory(); | |
| renderPreviews(); | |
| bindCardEvents(); | |
| checkHealth(); // 首次打开页面检查一次 | |
| // 处理窗口大小变化 | |
| window.addEventListener('resize', () => { | |
| scrollToBottom(); | |
| }); | |