import { state } from './state.js'; import { showNotification, _ } from './utils.js'; import { AITools } from './ai-tools.js'; export class Chat { constructor() { this.dom = { fab: document.getElementById('chat-fab'), window: document.getElementById('chat-window'), header: document.querySelector('.chat-header'), closeBtn: document.getElementById('chat-close-btn'), messagesContainer: document.getElementById('chat-messages'), inputForm: document.getElementById('chat-input-form'), input: document.getElementById('chat-input'), sendBtn: document.getElementById('chat-send-btn') }; this.isOpen = false; this.isDragging = false; this.offset = { x: 0, y: 0 }; this.conversationHistory = []; this.aiTools = new AITools(this); this.bindEvents(); } bindEvents() { this.dom.fab.addEventListener('click', () => this.toggle()); this.dom.closeBtn.addEventListener('click', () => this.close()); this.dom.inputForm.addEventListener('submit', (e) => { e.preventDefault(); this.sendMessage(); }); this.dom.input.addEventListener('input', this.autoResizeTextarea.bind(this)); this.dom.input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); this.dom.header.addEventListener('mousedown', this.startDrag.bind(this)); document.addEventListener('mousemove', this.drag.bind(this)); document.addEventListener('mouseup', this.stopDrag.bind(this)); document.addEventListener('mouseleave', this.stopDrag.bind(this)); } toggle() { this.isOpen ? this.close() : this.open(); } open() { if (this.isOpen) return; this.isOpen = true; this.dom.window.style.top = ''; this.dom.window.style.left = ''; this.dom.window.style.bottom = '95px'; this.dom.window.style.right = '2rem'; this.dom.window.style.display = 'flex'; gsap.fromTo(this.dom.window, { opacity: 0, scale: 0.9, y: 20 }, { opacity: 1, scale: 1, y: 0, duration: 0.3, ease: 'power3.out' }); gsap.to(this.dom.fab, { scale: 0, opacity: 0, duration: 0.2, ease: 'power2.in' }); if (this.conversationHistory.length === 0) { const welcomeMessage = _('chatWelcome'); this.addMessage(welcomeMessage, 'assistant'); this.conversationHistory.push({ role: 'model', parts: [{ text: welcomeMessage }] }); } } close() { if (!this.isOpen) return; this.isOpen = false; gsap.to(this.dom.window, { opacity: 0, scale: 0.9, y: 20, duration: 0.3, ease: 'power2.in', onComplete: () => { this.dom.window.style.display = 'none'; }}); if (!document.body.classList.contains('miniplayer-active')) { gsap.fromTo(this.dom.fab, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)', delay: 0.2 }); } } async sendMessage() { const userInput = this.dom.input.value.trim(); if (!userInput) return; this.addMessage(userInput, 'user'); this.conversationHistory.push({ role: 'user', parts: [{ text: userInput }] }); this.dom.input.value = ''; this.autoResizeTextarea(); this.dom.sendBtn.disabled = true; this.addTypingIndicator(); try { await this.getAIResponseWithTools(); } catch (error) { this.addMessage(error.message, 'assistant', true); } finally { this.dom.sendBtn.disabled = false; this.removeTypingIndicator(); } } formatMessageText(text) { const markdownLinkRegex = /\[([^\]]+?)\]\((https?:\/\/[^\s)]+?)\)/g; return text.replace(markdownLinkRegex, `$1`); } addMessage(text, sender, isError = false, toolName = null) { const wrapper = document.createElement('div'); wrapper.className = `message-wrapper ${sender}-wrapper`; const messageEl = document.createElement('div'); messageEl.classList.add('message', `${sender}-message`); if(isError) messageEl.style.color = 'var(--danger)'; if (sender === 'assistant' || sender === 'tool-call' || sender === 'tool-result') { const avatar = document.createElement('div'); avatar.className = 'avatar'; let icon = ''; if (sender === 'assistant') { icon = ''; } else { icon = ''; } avatar.innerHTML = icon; wrapper.appendChild(avatar); } const p = document.createElement('p'); if (sender === 'tool-call' || sender === 'tool-result') { p.innerHTML = `${toolName}: ${this.formatMessageText(text)}`; } else { p.innerHTML = this.formatMessageText(text); } messageEl.appendChild(p); wrapper.appendChild(messageEl); this.dom.messagesContainer.appendChild(wrapper); this.scrollToBottom(); } addTypingIndicator() { const wrapper = document.createElement('div'); wrapper.id = 'typing-indicator'; wrapper.className = 'message-wrapper assistant-wrapper'; const avatar = document.createElement('div'); avatar.className = 'avatar'; avatar.innerHTML = ``; wrapper.appendChild(avatar); const indicator = document.createElement('div'); indicator.classList.add('message', 'assistant-message', 'typing-indicator-bubble'); indicator.innerHTML = ''; wrapper.appendChild(indicator); this.dom.messagesContainer.appendChild(wrapper); this.scrollToBottom(); } removeTypingIndicator() { const indicator = document.getElementById('typing-indicator'); if (indicator) indicator.remove(); } formatCitations(chunks) { if (!chunks || chunks.length === 0) return ''; let citationText = '\n\n**' + _('chatSources') + ':**\n'; chunks.forEach((chunk, index) => { if (chunk.web && chunk.web.uri) { const title = chunk.web.title || _('chatUnnamedSource'); citationText += `[${index + 1}] [${title}](${chunk.web.uri})\n`; } }); return citationText; } async getAIResponseWithTools(isRecursiveCall = false) { const apiKey = state.settings.googleApiKey; if (!apiKey) { this.removeTypingIndicator(); const errorMessage = _('chatGoogleApiKeyMissing'); this.addMessage(errorMessage, 'assistant'); this.conversationHistory.push({ role: 'model', parts: [{ text: errorMessage }] }); return; } const model = "gemini-2.5-flash"; const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; const currentDate = new Date().toLocaleDateString(_('appLocaleCode'), { year: 'numeric', month: 'long', day: 'numeric' }); const systemPrompt = _('aiSystemPrompt_v3', [currentDate]); const functionTools = [{ functionDeclarations: this.aiTools.toolDefinitions }]; const googleSearchTool = [{ googleSearch: {} }]; try { if (!isRecursiveCall) { const functionCallResponse = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify({ contents: this.conversationHistory, tools: functionTools, system_instruction: { parts: [{ text: systemPrompt }] } }) }); if (!functionCallResponse.ok) throw new Error((await functionCallResponse.json()).error.message); const functionCallData = await functionCallResponse.json(); const candidate = functionCallData.candidates[0]; if (candidate && candidate.content.parts[0].functionCall) { const part = candidate.content.parts[0]; this.conversationHistory.push(candidate.content); const toolCall = { id: `call_${Date.now()}`, function: { name: part.functionCall.name, arguments: part.functionCall.args }, }; const toolResult = await this.aiTools.executeTool(toolCall); this.conversationHistory.push({ role: 'tool', parts: [{ functionResponse: { name: part.functionCall.name, response: JSON.parse(toolResult) } }] }); await this.getAIResponseWithTools(true); return; } } const finalResponse = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify({ contents: this.conversationHistory, tools: isRecursiveCall ? undefined : googleSearchTool, system_instruction: { parts: [{ text: systemPrompt }] } }) }); if (!finalResponse.ok) throw new Error((await finalResponse.json()).error.message); const finalData = await finalResponse.json(); if (!finalData.candidates || !finalData.candidates.length) throw new Error(_('chatApiInvalidResponse')); const finalCandidate = finalData.candidates[0]; let aiResponseText = finalCandidate.content.parts[0].text; if (finalCandidate.groundingMetadata && finalCandidate.groundingMetadata.groundingChunks) { aiResponseText += this.formatCitations(finalCandidate.groundingMetadata.groundingChunks); } this.removeTypingIndicator(); this.addMessage(aiResponseText, 'assistant'); this.conversationHistory.push({ role: 'model', parts: [{ text: aiResponseText }] }); } catch (error) { this.removeTypingIndicator(); console.error(_('googleApiFailure'), error); const errorMessage = _('chatApiError') + `: ${error.message}`; showNotification(errorMessage, 'error'); this.addMessage(errorMessage, 'assistant', true); } } autoResizeTextarea() { this.dom.input.style.height = 'auto'; const scrollHeight = this.dom.input.scrollHeight; if (scrollHeight > 200) { this.dom.input.style.height = '200px'; this.dom.input.style.overflowY = 'auto'; } else { this.dom.input.style.height = `${scrollHeight}px`; this.dom.input.style.overflowY = 'hidden'; } } scrollToBottom() { this.dom.messagesContainer.scrollTop = this.dom.messagesContainer.scrollHeight; } startDrag(e) { if (e.target !== this.dom.header && e.target !== this.dom.header.querySelector('.chat-title')) return; this.isDragging = true; const rect = this.dom.window.getBoundingClientRect(); this.offset.x = e.clientX - rect.left; this.offset.y = e.clientY - rect.top; this.dom.header.style.cursor = 'grabbing'; } drag(e) { if (!this.isDragging) return; e.preventDefault(); let newX = e.clientX - this.offset.x; let newY = e.clientY - this.offset.y; const winWidth = this.dom.window.offsetWidth; const winHeight = this.dom.window.offsetHeight; const docWidth = document.documentElement.clientWidth; const docHeight = document.documentElement.clientHeight; newX = Math.max(0, Math.min(newX, docWidth - winWidth)); newY = Math.max(0, Math.min(newY, docHeight - winHeight)); this.dom.window.style.left = `${newX}px`; this.dom.window.style.top = `${newY}px`; this.dom.window.style.bottom = 'auto'; this.dom.window.style.right = 'auto'; } stopDrag() { this.isDragging = false; this.dom.header.style.cursor = 'move'; } clearHistory() { this.conversationHistory = []; this.dom.messagesContainer.innerHTML = ''; const welcomeMessage = _('chatWelcome'); this.addMessage(welcomeMessage, 'assistant'); this.conversationHistory.push({ role: 'model', parts: [{ text: welcomeMessage }] }); } }