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('Please login at https://chat.openai.com first')}${ isSafari() ? `
${t('Then open https://chat.openai.com/api/auth/session')}` : '' }
${t('And refresh this page or type you question again')}` + `

${t( 'Consider creating an api key at https://platform.openai.com/account/api-keys', )}`, false, 'error', ) break case 'CLOUDFLARE': updateAnswer( `${t('OpenAI Security Check Required')}
${ isSafari() ? t('Please open https://chat.openai.com/api/auth/session') : t('Please open https://chat.openai.com') }
${t('And refresh this page or type you question again')}` + `

${t( 'Consider creating an api key at https://platform.openai.com/account/api-keys', )}`, false, 'error', ) break default: if ( conversationItemData[conversationItemData.length - 1].content.includes('gpt-loading') ) updateAnswer(msg.error, false, 'error') else setConversationItemData([ ...conversationItemData, new ConversationItemData('error', msg.error), ]) break } setIsReady(true) } } port.onMessage.addListener(listener) return () => { port.onMessage.removeListener(listener) } }, [conversationItemData]) const getRetryFn = (session) => () => { updateAnswer(`

${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 (
{props.closeable ? ( { port.disconnect() if (props.onClose) props.onClose() }} /> ) : props.dockable ? ( { if (props.onDock) props.onDock() }} /> ) : ( )} {props.draggable && !completeDraggable && (
)} {!config.disableWebModeHistory && session && session.conversationId && ( )} { const position = { x: window.innerWidth / 2 - 300, y: window.innerHeight / 2 - 200 } const toolbarContainer = createElementAtPosition(position.x, position.y) toolbarContainer.className = 'chatgptbox-toolbar-container-not-queryable' render( , toolbarContainer, ) }} /> { port.postMessage({ stop: true }) Browser.runtime.sendMessage({ type: 'DELETE_CONVERSATION', data: { conversationId: session.conversationId, }, }) setConversationItemData([]) const newSession = initSession({ ...session, question: null, conversationRecords: [], }) newSession.sessionId = session.sessionId setSession(newSession) }} /> {!props.pageMode && ( { const newSession = { ...session, sessionName: new Date().toLocaleString(), autoClean: false, sessionId: uuidv4(), } setSession(newSession) createSession(newSession).then(() => Browser.runtime.sendMessage({ type: 'OPEN_URL', data: { url: Browser.runtime.getURL('IndependentPanel.html'), }, }), ) }} > )} { let output = '' session.conversationRecords.forEach((data) => { output += `${t('Question')}:\n\n${data.question}\n\n${t('Answer')}:\n\n${ data.answer }\n\n
\n\n` }) const blob = new Blob([output], { type: 'text/plain;charset=utf-8' }) FileSaver.saveAs(blob, 'conversation.md') }} >
{conversationItemData.length > 0 && ( { bodyRef.current.scrollTo({ top: bodyRef.current.scrollHeight, behavior: 'smooth', }) }} > )}

{conversationItemData.map((data, idx) => ( ))}
{ const newQuestion = new ConversationItemData('question', question) const newAnswer = new ConversationItemData( 'answer', `

${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') } }} />
) } ConversationCard.propTypes = { session: PropTypes.object.isRequired, question: PropTypes.string.isRequired, onUpdate: PropTypes.func, draggable: PropTypes.bool, closeable: PropTypes.bool, onClose: PropTypes.func, dockable: PropTypes.bool, onDock: PropTypes.func, notClampSize: PropTypes.bool, pageMode: PropTypes.bool, } export default memo(ConversationCard)