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 = ''; sendBtn.title = '停止生成'; } else { const text = userInput.value.trim(); sendBtn.classList.toggle('disabled', !text && attachedFiles.length === 0); sendBtn.classList.remove('stop-mode'); sendBtn.innerHTML = ''; 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 `
${displayName}
${highlighted}
`; }; 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 = ' 已复制'; 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 = ' 已复制'; 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 => ``) .join(''); const filesHtml = attachedFiles .filter(f => !f.type.startsWith('image/')) .map(f => `
${escapeHtml(f.name)}
`) .join(''); userDisplayContent += (imagesHtml ? '
' + 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 = "生成已手动停止"; } 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 = `
正在为你${actionName}图片:${escapeHtml(prompt)}...
`; 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 = ''; } else { avatar.innerHTML = ''; } const content = document.createElement('div'); content.className = 'content'; if (!text) { content.innerHTML = '
'; } 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 = ``; } else { const ext = file.name.split('.').pop().toUpperCase(); item.innerHTML = `
${ext}
`; } 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 = `

你好,我是你的 AI 助手

我可以帮你写作、编码、学习或提供创意建议。有什么可以帮你的吗?

帮我写一段 Python 爬虫代码
解释量子力学的基础概念
策划一次 3 天的上海旅行
画一张未来城市的夜景
`; 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(); });