2025-07-29 15:05:06 +02:00
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 ) {
2025-07-29 17:15:23 +02:00
const wrapper = document . createElement ( 'div' ) ;
wrapper . className = ` message-wrapper ${ sender } -wrapper ` ;
2025-07-29 15:05:06 +02:00
const messageEl = document . createElement ( 'div' ) ;
messageEl . classList . add ( 'message' , ` ${ sender } -message ` ) ;
if ( isError ) messageEl . style . color = 'var(--danger)' ;
2025-07-29 17:15:23 +02:00
if ( sender === 'assistant' ) {
const avatar = document . createElement ( 'div' ) ;
avatar . className = 'avatar' ;
avatar . innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm-2.5-5h5v2h-5v-2z"/></svg> ` ;
wrapper . appendChild ( avatar ) ;
}
2025-07-29 15:05:06 +02:00
const p = document . createElement ( 'p' ) ;
p . textContent = text ;
messageEl . appendChild ( p ) ;
if ( sender === 'assistant' && ! isError ) {
this . addMediaActionButtons ( messageEl , text ) ;
}
2025-07-29 17:15:23 +02:00
wrapper . appendChild ( messageEl ) ;
this . dom . messagesContainer . appendChild ( wrapper ) ;
2025-07-29 15:05:06 +02:00
this . scrollToBottom ( ) ;
}
addTypingIndicator ( ) {
2025-07-29 17:15:23 +02:00
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 = ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm-2.5-5h5v2h-5v-2z"/></svg> ` ;
wrapper . appendChild ( avatar ) ;
2025-07-29 15:05:06 +02:00
const indicator = document . createElement ( 'div' ) ;
2025-07-29 17:15:23 +02:00
indicator . classList . add ( 'message' , 'assistant-message' , 'typing-indicator-bubble' ) ;
2025-07-29 15:05:06 +02:00
indicator . innerHTML = '<span></span><span></span><span></span>' ;
2025-07-29 17:15:23 +02:00
wrapper . appendChild ( indicator ) ;
this . dom . messagesContainer . appendChild ( wrapper ) ;
2025-07-29 15:05:06 +02:00
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' ) ;
}
}
}