mirror of
https://github.com/wassname/chatGPTBox.git
synced 2026-06-27 21:06:35 +08:00
480 lines
16 KiB
React
480 lines
16 KiB
React
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',
|
|
`<p class="gpt-loading">${t(`Waiting for response...`)}</p>`,
|
|
),
|
|
]
|
|
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')}<br>${t('Please login at https://chat.openai.com first')}${
|
|
isSafari() ? `<br>${t('Then open https://chat.openai.com/api/auth/session')}` : ''
|
|
}<br>${t('And refresh this page or type you question again')}` +
|
|
`<br><br>${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')}<br>${
|
|
isSafari()
|
|
? t('Please open https://chat.openai.com/api/auth/session')
|
|
: t('Please open https://chat.openai.com')
|
|
}<br>${t('And refresh this page or type you question again')}` +
|
|
`<br><br>${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(`<p class="gpt-loading">${t('Waiting for response...')}</p>`, 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 (
|
|
<div className="gpt-inner">
|
|
<div
|
|
className={
|
|
props.draggable ? `gpt-header${completeDraggable ? ' draggable' : ''}` : 'gpt-header'
|
|
}
|
|
style="user-select:none;"
|
|
>
|
|
<span
|
|
className="gpt-util-group"
|
|
style={{
|
|
padding: '15px 0 15px 15px',
|
|
...(props.notClampSize ? {} : { flexGrow: 1 }),
|
|
}}
|
|
>
|
|
{props.closeable ? (
|
|
<XLg
|
|
className="gpt-util-icon"
|
|
title={t('Close the Window')}
|
|
size={16}
|
|
onClick={() => {
|
|
port.disconnect()
|
|
if (props.onClose) props.onClose()
|
|
}}
|
|
/>
|
|
) : props.dockable ? (
|
|
<Pin
|
|
className="gpt-util-icon"
|
|
title={t('Pin the Window')}
|
|
size={16}
|
|
onClick={() => {
|
|
if (props.onDock) props.onDock()
|
|
}}
|
|
/>
|
|
) : (
|
|
<img src={logo} style="user-select:none;width:20px;height:20px;" />
|
|
)}
|
|
<select
|
|
style={props.notClampSize ? {} : { width: 0, flexGrow: 1 }}
|
|
className="normal-button"
|
|
required
|
|
onChange={(e) => {
|
|
const modelName = e.target.value
|
|
const newSession = { ...session, modelName, aiName: Models[modelName].desc }
|
|
if (config.autoRegenAfterSwitchModel && conversationItemData.length > 0)
|
|
getRetryFn(newSession)()
|
|
else setSession(newSession)
|
|
}}
|
|
>
|
|
{config.activeApiModes.map((modelName) => {
|
|
let desc
|
|
if (modelName.includes('-')) {
|
|
const splits = modelName.split('-')
|
|
if (splits[0] in Models)
|
|
desc = `${t(Models[splits[0]].desc)} (${t(ModelMode[splits[1]])})`
|
|
} else {
|
|
if (modelName in Models) desc = t(Models[modelName].desc)
|
|
}
|
|
if (desc)
|
|
return (
|
|
<option
|
|
value={modelName}
|
|
key={modelName}
|
|
selected={modelName === session.modelName}
|
|
>
|
|
{desc}
|
|
</option>
|
|
)
|
|
})}
|
|
</select>
|
|
</span>
|
|
{props.draggable && !completeDraggable && (
|
|
<div className="draggable" style={{ flexGrow: 2, cursor: 'move', height: '55px' }} />
|
|
)}
|
|
<span
|
|
className="gpt-util-group"
|
|
style={{
|
|
padding: '15px 15px 15px 0',
|
|
justifyContent: 'flex-end',
|
|
flexGrow: props.draggable && !completeDraggable ? 0 : 1,
|
|
}}
|
|
>
|
|
{!config.disableWebModeHistory && session && session.conversationId && (
|
|
<a
|
|
title={t('Continue on official website')}
|
|
href={'https://chat.openai.com/chat/' + session.conversationId}
|
|
target="_blank"
|
|
rel="nofollow noopener noreferrer"
|
|
className="gpt-util-icon"
|
|
style="color: inherit;"
|
|
>
|
|
<LinkExternalIcon size={16} />
|
|
</a>
|
|
)}
|
|
<WindowDesktop
|
|
className="gpt-util-icon"
|
|
title={t('Float the Window')}
|
|
size={16}
|
|
onClick={() => {
|
|
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(
|
|
<FloatingToolbar
|
|
session={session}
|
|
selection=""
|
|
container={toolbarContainer}
|
|
closeable={true}
|
|
triggered={true}
|
|
/>,
|
|
toolbarContainer,
|
|
)
|
|
}}
|
|
/>
|
|
<DeleteButton
|
|
size={16}
|
|
text={t('Clear Conversation')}
|
|
onConfirm={() => {
|
|
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 && (
|
|
<span
|
|
title={t('Store to Independent Conversation Page')}
|
|
className="gpt-util-icon"
|
|
onClick={() => {
|
|
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'),
|
|
},
|
|
}),
|
|
)
|
|
}}
|
|
>
|
|
<ArchiveIcon size={16} />
|
|
</span>
|
|
)}
|
|
<span
|
|
title={t('Save Conversation')}
|
|
className="gpt-util-icon"
|
|
onClick={() => {
|
|
let output = ''
|
|
session.conversationRecords.forEach((data) => {
|
|
output += `${t('Question')}:\n\n${data.question}\n\n${t('Answer')}:\n\n${
|
|
data.answer
|
|
}\n\n<hr/>\n\n`
|
|
})
|
|
const blob = new Blob([output], { type: 'text/plain;charset=utf-8' })
|
|
FileSaver.saveAs(blob, 'conversation.md')
|
|
}}
|
|
>
|
|
<DesktopDownloadIcon size={16} />
|
|
</span>
|
|
<span>
|
|
{conversationItemData.length > 0 && (
|
|
<span
|
|
title={t('Jump to bottom')}
|
|
className="gpt-util-icon"
|
|
onClick={() => {
|
|
bodyRef.current.scrollTo({
|
|
top: bodyRef.current.scrollHeight,
|
|
behavior: 'smooth',
|
|
})
|
|
}}
|
|
>
|
|
<MoveToBottomIcon size={16} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<hr />
|
|
<div
|
|
ref={bodyRef}
|
|
className="markdown-body"
|
|
style={
|
|
props.notClampSize
|
|
? { flexGrow: 1 }
|
|
: { maxHeight: windowSize[1] * 0.55 + 'px', resize: 'vertical' }
|
|
}
|
|
>
|
|
{conversationItemData.map((data, idx) => (
|
|
<ConversationItem
|
|
content={data.content}
|
|
key={idx}
|
|
type={data.type}
|
|
session={session}
|
|
done={data.done}
|
|
port={port}
|
|
onRetry={idx === conversationItemData.length - 1 ? getRetryFn(session) : null}
|
|
/>
|
|
))}
|
|
</div>
|
|
<InputBox
|
|
enabled={isReady}
|
|
port={port}
|
|
reverseResizeDir={props.pageMode}
|
|
onSubmit={(question) => {
|
|
const newQuestion = new ConversationItemData('question', question)
|
|
const newAnswer = new ConversationItemData(
|
|
'answer',
|
|
`<p class="gpt-loading">${t('Waiting for response...')}</p>`,
|
|
)
|
|
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')
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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)
|