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 }] });
}
}