import { memo, useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' import Browser from 'webextension-polyfill' import InputBox from '../InputBox' import ConversationItem from '../ConversationItem' import { createElementAtPosition, isFirefox, isMobile, isSafari } from '../../utils' import { LinkExternalIcon, ArchiveIcon, DesktopDownloadIcon, MoveToBottomIcon, } from '@primer/octicons-react' import { WindowDesktop, XLg, Pin } from 'react-bootstrap-icons' import FileSaver from 'file-saver' import { render } from 'preact' import FloatingToolbar from '../FloatingToolbar' import { useClampWindowSize } from '../../hooks/use-clamp-window-size' import { ModelMode, Models } from '../../config/index.mjs' import { useTranslation } from 'react-i18next' import DeleteButton from '../DeleteButton' import { useConfig } from '../../hooks/use-config.mjs' import { createSession } from '../../services/local-session.mjs' import { v4 as uuidv4 } from 'uuid' import { initSession } from '../../services/init-session.mjs' import { findLastIndex } from 'lodash-es' const logo = Browser.runtime.getURL('logo.png') class ConversationItemData extends Object { /** * @param {'question'|'answer'|'error'} type * @param {string} content * @param {bool} done */ constructor(type, content, done = false) { super() this.type = type this.content = content this.done = done } } function ConversationCard(props) { const { t } = useTranslation() const [isReady, setIsReady] = useState(!props.question) const [port, setPort] = useState(() => Browser.runtime.connect()) const [session, setSession] = useState(props.session) const windowSize = useClampWindowSize([750, 1500], [250, 1100]) const bodyRef = useRef(null) const [completeDraggable, setCompleteDraggable] = useState(false) /** * @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]} */ const [conversationItemData, setConversationItemData] = useState( (() => { if (session.conversationRecords.length === 0) if (props.question) return [ new ConversationItemData( 'answer', `
${t(`Waiting for response...`)}
`, ), ] else return [] else { const ret = [] for (const record of session.conversationRecords) { ret.push(new ConversationItemData('question', record.question, true)) ret.push(new ConversationItemData('answer', record.answer, true)) } return ret } })(), ) const config = useConfig() useEffect(() => { setCompleteDraggable(!isSafari() && !isFirefox() && !isMobile()) }, []) useEffect(() => { if (props.onUpdate) props.onUpdate(port, session, conversationItemData) }, [session, conversationItemData]) useEffect(() => { const { offsetHeight, scrollHeight, scrollTop } = bodyRef.current if ( config.lockWhenAnswer && scrollHeight <= scrollTop + offsetHeight + config.answerScrollMargin ) { bodyRef.current.scrollTo({ top: scrollHeight, behavior: 'instant', }) } }, [conversationItemData]) useEffect(() => { // when the page is responsive, session may accumulate redundant data and needs to be cleared after remounting and before making a new request if (props.question) { const newSession = initSession({ question: props.question }) setSession(newSession) port.postMessage({ session: newSession }) } }, [props.question]) // usually only triggered once /** * @param {string} value * @param {boolean} appended * @param {'question'|'answer'|'error'} newType * @param {boolean} done */ const updateAnswer = (value, appended, newType, done = false) => { setConversationItemData((old) => { const copy = [...old] const index = findLastIndex(copy, (v) => v.type === 'answer' || v.type === 'error') if (index === -1) return copy copy[index] = new ConversationItemData( newType, appended ? copy[index].content + value : value, ) copy[index].done = done return copy }) } useEffect(() => { const portListener = () => { setPort(Browser.runtime.connect()) setIsReady(true) } const closeChatsListener = (message) => { if (message.type === 'CLOSE_CHATS') { port.disconnect() if (props.onClose) props.onClose() } } if (props.closeable) Browser.runtime.onMessage.addListener(closeChatsListener) port.onDisconnect.addListener(portListener) return () => { if (props.closeable) Browser.runtime.onMessage.removeListener(closeChatsListener) port.onDisconnect.removeListener(portListener) } }, [port]) useEffect(() => { const listener = (msg) => { if (msg.answer) { updateAnswer(msg.answer, false, 'answer') } if (msg.session) { if (msg.done) msg.session = { ...msg.session, isRetry: false } setSession(msg.session) } if (msg.done) { updateAnswer('', true, 'answer', true) setIsReady(true) } if (msg.error) { switch (msg.error) { case 'UNAUTHORIZED': updateAnswer( `${t('UNAUTHORIZED')}${t('Waiting for response...')}
`, false, 'answer') setIsReady(false) const newSession = { ...session, isRetry: true } setSession(newSession) try { port.postMessage({ stop: true }) port.postMessage({ session: newSession }) } catch (e) { updateAnswer(e, false, 'error') } } return (${t('Waiting for response...')}
`, ) setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) const newSession = { ...session, question, isRetry: false } setSession(newSession) try { port.postMessage({ session: newSession }) } catch (e) { updateAnswer(e, false, 'error') } }} />