342 lines
13 KiB
JavaScript
342 lines
13 KiB
JavaScript
import { state } from './state.js';
|
||
import { showNotification, _ } from './utils.js';
|
||
import { getFromDB } from './db.js';
|
||
import { showItemDetails, addStreamToList, downloadM3U } from './ui.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.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));
|
||
}
|
||
|
||
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) {
|
||
this.addMessage(_('chatWelcome'), 'assistant');
|
||
this.conversationHistory.push({ role: 'assistant', content: _('chatWelcome') });
|
||
}
|
||
}
|
||
|
||
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';
|
||
}});
|
||
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', content: userInput });
|
||
this.dom.input.value = '';
|
||
this.autoResizeTextarea();
|
||
this.dom.sendBtn.disabled = true;
|
||
|
||
this.addTypingIndicator();
|
||
|
||
try {
|
||
const response = await this.getOpenAIResponse();
|
||
this.removeTypingIndicator();
|
||
this.addMessage(response, 'assistant');
|
||
this.conversationHistory.push({ role: 'assistant', content: response });
|
||
} catch (error) {
|
||
this.removeTypingIndicator();
|
||
this.addMessage(error.message, 'assistant', true);
|
||
} finally {
|
||
this.dom.sendBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
addMessage(text, sender, isError = false) {
|
||
const messageEl = document.createElement('div');
|
||
messageEl.classList.add('message', `${sender}-message`);
|
||
if(isError) messageEl.style.color = 'var(--danger)';
|
||
|
||
const p = document.createElement('p');
|
||
p.textContent = text;
|
||
messageEl.appendChild(p);
|
||
|
||
if (sender === 'assistant' && !isError) {
|
||
this.addMediaActionButtons(messageEl, text);
|
||
}
|
||
|
||
this.dom.messagesContainer.appendChild(messageEl);
|
||
this.scrollToBottom();
|
||
}
|
||
|
||
addTypingIndicator() {
|
||
const indicator = document.createElement('div');
|
||
indicator.id = 'typing-indicator';
|
||
indicator.classList.add('message', 'assistant-message', 'typing-indicator');
|
||
indicator.innerHTML = '<span></span><span></span><span></span>';
|
||
this.dom.messagesContainer.appendChild(indicator);
|
||
this.scrollToBottom();
|
||
}
|
||
|
||
removeTypingIndicator() {
|
||
const indicator = document.getElementById('typing-indicator');
|
||
if (indicator) indicator.remove();
|
||
}
|
||
|
||
async getLocalContent() {
|
||
const movieEntries = await getFromDB('movies');
|
||
const seriesEntries = await getFromDB('series');
|
||
|
||
const processEntries = (entries, type) => {
|
||
return entries.flatMap(entry =>
|
||
(entry.titulos || []).map(titulo => ({ ...titulo, type }))
|
||
);
|
||
};
|
||
|
||
const movies = processEntries(movieEntries, 'movie');
|
||
const series = processEntries(seriesEntries, 'show');
|
||
|
||
return { movies, series };
|
||
}
|
||
|
||
async findLocalContent(title) {
|
||
const { movies, series } = await this.getLocalContent();
|
||
const searchTerm = title.trim().toLowerCase();
|
||
|
||
const allContent = [...movies, ...series];
|
||
|
||
let foundContent = allContent.find(m => m.title.toLowerCase() === searchTerm);
|
||
if (foundContent) return foundContent;
|
||
|
||
foundContent = allContent.find(m => m.title.toLowerCase().includes(searchTerm));
|
||
return foundContent;
|
||
}
|
||
|
||
async getOpenAIResponse() {
|
||
const apiKey = state.settings.openaiApiKey;
|
||
if (!apiKey) {
|
||
return _('chatApiKeyMissing');
|
||
}
|
||
|
||
const systemPrompt = `You are a helpful assistant for a movie and TV show enthusiast named CinePlex Assistant. Your main goal is to provide information about films, series, etc. When you identify a movie or series title in your response, you MUST enclose it in double quotes, for example: "The Matrix". Do not mention the user's local library or your ability to access it.`;
|
||
|
||
const messagesForApi = [
|
||
{ role: 'system', content: systemPrompt },
|
||
...this.conversationHistory
|
||
];
|
||
|
||
try {
|
||
const response = await fetch('https://api.proxyapi.ru/openai/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'gpt-4o-2024-05-13',
|
||
messages: messagesForApi,
|
||
max_tokens: 250,
|
||
temperature: 0.7
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
const errorMessage = errorData?.error?.message || errorData?.error || `Error HTTP ${response.status}`;
|
||
console.error('Proxy API Error Response:', errorData);
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
|
||
return data.choices[0].message.content;
|
||
} else {
|
||
console.error('Invalid successful response format from proxy:', data);
|
||
throw new Error(_('chatApiInvalidResponse'));
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('OpenAI Proxy API call failed:', error);
|
||
showNotification(_('chatApiError'), 'error');
|
||
return _('chatApiError') + `: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
async addMediaActionButtons(messageEl, text) {
|
||
const contentTitles = this.extractContentTitles(text);
|
||
if (contentTitles.length === 0) return;
|
||
|
||
const allContent = [];
|
||
for (const title of contentTitles) {
|
||
const content = await this.findLocalContent(title);
|
||
if (content) {
|
||
allContent.push(content);
|
||
}
|
||
}
|
||
|
||
if (allContent.length > 0) {
|
||
allContent.forEach(content => {
|
||
const itemContainer = document.createElement('div');
|
||
itemContainer.className = 'chat-item-actions';
|
||
|
||
const titleEl = document.createElement('span');
|
||
titleEl.className = 'chat-action-title';
|
||
titleEl.textContent = content.title;
|
||
itemContainer.appendChild(titleEl);
|
||
|
||
const buttonContainer = document.createElement('div');
|
||
buttonContainer.className = 'chat-action-buttons';
|
||
|
||
const buttons = [
|
||
{ label: _('moreInfo'), action: 'info', icon: 'ℹ️' },
|
||
{ label: _('addStream'), action: 'stream', icon: '▶️' },
|
||
{ label: _('download'), action: 'download', icon: '⬇️' }
|
||
];
|
||
|
||
buttons.forEach(btnInfo => {
|
||
const button = document.createElement('button');
|
||
button.innerHTML = `${btnInfo.icon} ${btnInfo.label}`;
|
||
button.addEventListener('click', () => this.handleMediaAction(btnInfo.action, content, button));
|
||
buttonContainer.appendChild(button);
|
||
});
|
||
itemContainer.appendChild(buttonContainer);
|
||
messageEl.appendChild(itemContainer);
|
||
});
|
||
|
||
if (allContent.length > 1) {
|
||
const downloadAllButtonContainer = document.createElement('div');
|
||
downloadAllButtonContainer.className = 'chat-action-buttons chat-download-all';
|
||
const downloadAllButton = document.createElement('button');
|
||
downloadAllButton.innerHTML = `⬇️ ${_('downloadAll')}`;
|
||
downloadAllButton.addEventListener('click', (e) => {
|
||
const streams = allContent.map(content => ({ title: content.title, type: content.type }));
|
||
downloadM3U(streams, e.target);
|
||
});
|
||
downloadAllButtonContainer.appendChild(downloadAllButton);
|
||
messageEl.appendChild(downloadAllButtonContainer);
|
||
}
|
||
}
|
||
}
|
||
|
||
extractContentTitles(text) {
|
||
const matches = text.match(/"([^"]+)"/g);
|
||
if (!matches) return [];
|
||
return matches.map(match => match.substring(1, match.length - 1));
|
||
}
|
||
|
||
handleMediaAction(action, content, buttonEl) {
|
||
switch (action) {
|
||
case 'info':
|
||
showItemDetails(Number(content.id), content.type);
|
||
this.close();
|
||
break;
|
||
case 'stream':
|
||
addStreamToList(content.title, content.type, buttonEl);
|
||
break;
|
||
case 'download':
|
||
downloadM3U(content.title, content.type, buttonEl);
|
||
break;
|
||
default:
|
||
showNotification(`Acción desconocida: ${action}`, 'warning');
|
||
}
|
||
}
|
||
} |