mirror of
https://github.com/wassname/chatGPTBox.git
synced 2026-08-14 12:10:31 +08:00
first commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Browser from 'webextension-polyfill'
|
||||
import InputBox from '../InputBox'
|
||||
import ConversationItem from '../ConversationItem'
|
||||
import { createElementAtPosition, initSession, isSafari } from '../../utils'
|
||||
import { DownloadIcon } from '@primer/octicons-react'
|
||||
import { WindowDesktop, XLg } from 'react-bootstrap-icons'
|
||||
import FileSaver from 'file-saver'
|
||||
import { render } from 'preact'
|
||||
import FloatingToolbar from '../FloatingToolbar'
|
||||
|
||||
const logo = Browser.runtime.getURL('logo.png')
|
||||
|
||||
class ConversationItemData extends Object {
|
||||
/**
|
||||
* @param {'question'|'answer'|'error'} type
|
||||
* @param {string} content
|
||||
* @param {object} session
|
||||
* @param {bool} done
|
||||
*/
|
||||
constructor(type, content, session = null, done = false) {
|
||||
super()
|
||||
this.type = type
|
||||
this.content = content
|
||||
this.session = session
|
||||
this.done = done
|
||||
}
|
||||
}
|
||||
|
||||
function ConversationCard(props) {
|
||||
const [isReady, setIsReady] = useState(!props.question)
|
||||
const [port, setPort] = useState(() => Browser.runtime.connect())
|
||||
const [session, setSession] = useState(props.session)
|
||||
/**
|
||||
* @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]}
|
||||
*/
|
||||
const [conversationItemData, setConversationItemData] = useState(
|
||||
(() => {
|
||||
if (props.session.conversationRecords.length === 0)
|
||||
if (props.question)
|
||||
return [
|
||||
new ConversationItemData(
|
||||
'answer',
|
||||
'<p class="gpt-loading">Waiting for response...</p>',
|
||||
),
|
||||
]
|
||||
else return []
|
||||
else {
|
||||
const ret = []
|
||||
for (const record of props.session.conversationRecords) {
|
||||
ret.push(
|
||||
new ConversationItemData('question', record.question + '\n<hr/>', props.session, true),
|
||||
)
|
||||
ret.push(
|
||||
new ConversationItemData('answer', record.answer + '\n<hr/>', props.session, true),
|
||||
)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onUpdate) props.onUpdate()
|
||||
})
|
||||
|
||||
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 = copy.findLastIndex((v) => v.type === 'answer')
|
||||
if (index === -1) return copy
|
||||
copy[index] = new ConversationItemData(
|
||||
newType,
|
||||
appended ? copy[index].content + value : value,
|
||||
)
|
||||
copy[index].session = { ...session }
|
||||
copy[index].done = done
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setPort(Browser.runtime.connect())
|
||||
}
|
||||
port.onDisconnect.addListener(listener)
|
||||
return () => {
|
||||
port.onDisconnect.removeListener(listener)
|
||||
}
|
||||
}, [port])
|
||||
useEffect(() => {
|
||||
const listener = (msg) => {
|
||||
if (msg.answer) {
|
||||
UpdateAnswer(msg.answer, false, 'answer')
|
||||
}
|
||||
if (msg.session) {
|
||||
setSession(msg.session)
|
||||
}
|
||||
if (msg.done) {
|
||||
UpdateAnswer('\n<hr/>', true, 'answer', true)
|
||||
setIsReady(true)
|
||||
}
|
||||
if (msg.error) {
|
||||
switch (msg.error) {
|
||||
case 'UNAUTHORIZED':
|
||||
UpdateAnswer(
|
||||
`UNAUTHORIZED<br>Please login at https://chat.openai.com first${
|
||||
isSafari() ? '<br>Then open https://chat.openai.com/api/auth/session' : ''
|
||||
}<br>And refresh this page or type you question again` +
|
||||
`<br><br>Consider creating an api key at https://platform.openai.com/account/api-keys<hr>`,
|
||||
false,
|
||||
'error',
|
||||
)
|
||||
break
|
||||
case 'CLOUDFLARE':
|
||||
UpdateAnswer(
|
||||
`OpenAI Security Check Required<br>Please open ${
|
||||
isSafari() ? 'https://chat.openai.com/api/auth/session' : 'https://chat.openai.com'
|
||||
}<br>And refresh this page or type you question again` +
|
||||
`<br><br>Consider creating an api key at https://platform.openai.com/account/api-keys<hr>`,
|
||||
false,
|
||||
'error',
|
||||
)
|
||||
break
|
||||
default:
|
||||
setConversationItemData([
|
||||
...conversationItemData,
|
||||
new ConversationItemData('error', msg.error + '\n<hr/>'),
|
||||
])
|
||||
break
|
||||
}
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
port.onMessage.addListener(listener)
|
||||
return () => {
|
||||
port.onMessage.removeListener(listener)
|
||||
}
|
||||
}, [conversationItemData])
|
||||
|
||||
return (
|
||||
<div className="gpt-inner">
|
||||
<div className="gpt-header">
|
||||
{!props.closeable ? (
|
||||
<img src={logo} width="20" height="20" style="margin:5px 15px 0px;user-select:none;" />
|
||||
) : (
|
||||
<XLg
|
||||
className="gpt-util-icon"
|
||||
style="margin:5px 15px 0px;"
|
||||
title="Close the Window"
|
||||
size={16}
|
||||
onClick={() => {
|
||||
if (props.onClose) props.onClose()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{props.draggable ? (
|
||||
<div className="dragbar" />
|
||||
) : (
|
||||
<WindowDesktop
|
||||
className="gpt-util-icon"
|
||||
title="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 = 'toolbar-container-not-queryable'
|
||||
render(
|
||||
<FloatingToolbar
|
||||
session={session}
|
||||
selection=""
|
||||
position={position}
|
||||
container={toolbarContainer}
|
||||
closeable={true}
|
||||
triggered={true}
|
||||
onClose={() => toolbarContainer.remove()}
|
||||
/>,
|
||||
toolbarContainer,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
title="Save Conversation"
|
||||
className="gpt-util-icon"
|
||||
style="margin:15px 15px 10px;"
|
||||
onClick={() => {
|
||||
let output = ''
|
||||
session.conversationRecords.forEach((data) => {
|
||||
output += `Question:\n\n${data.question}\n\nAnswer:\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')
|
||||
}}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="markdown-body">
|
||||
{conversationItemData.map((data, idx) => (
|
||||
<ConversationItem
|
||||
content={data.content}
|
||||
key={idx}
|
||||
type={data.type}
|
||||
session={data.session}
|
||||
done={data.done}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<InputBox
|
||||
enabled={isReady}
|
||||
onSubmit={(question) => {
|
||||
const newQuestion = new ConversationItemData('question', question + '\n<hr/>')
|
||||
const newAnswer = new ConversationItemData(
|
||||
'answer',
|
||||
'<p class="gpt-loading">Waiting for response...</p>',
|
||||
)
|
||||
setConversationItemData([...conversationItemData, newQuestion, newAnswer])
|
||||
setIsReady(false)
|
||||
|
||||
const newSession = { ...session, question }
|
||||
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,
|
||||
}
|
||||
|
||||
export default memo(ConversationCard)
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import FeedbackForChatGPTWeb from '../FeedbackForChatGPTWeb'
|
||||
import { ChevronDownIcon, LinkExternalIcon, XCircleIcon } from '@primer/octicons-react'
|
||||
import CopyButton from '../CopyButton'
|
||||
import PropTypes from 'prop-types'
|
||||
import MarkdownRender from '../MarkdownRender/markdown.jsx'
|
||||
|
||||
export function ConversationItem({ type, content, session, done }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
switch (type) {
|
||||
case 'question':
|
||||
return (
|
||||
<div className={type} dir="auto">
|
||||
<div className="gpt-header">
|
||||
<p>You:</p>
|
||||
<div style="display: flex; gap: 15px;">
|
||||
<CopyButton contentFn={() => content} size={14} />
|
||||
{!collapsed ? (
|
||||
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
|
||||
<XCircleIcon size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
|
||||
<ChevronDownIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
|
||||
</div>
|
||||
)
|
||||
case 'answer':
|
||||
return (
|
||||
<div className={type} dir="auto">
|
||||
<div className="gpt-header">
|
||||
<p>{session ? 'ChatGPT:' : 'Loading...'}</p>
|
||||
<div style="display: flex; gap: 15px;">
|
||||
{done && session && session.conversationId && (
|
||||
<FeedbackForChatGPTWeb
|
||||
messageId={session.messageId}
|
||||
conversationId={session.conversationId}
|
||||
/>
|
||||
)}
|
||||
{session && session.conversationId && (
|
||||
<a
|
||||
title="Continue on official website"
|
||||
href={'https://chat.openai.com/chat/' + session.conversationId}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
style="color: inherit;"
|
||||
>
|
||||
<LinkExternalIcon size={14} />
|
||||
</a>
|
||||
)}
|
||||
{session && <CopyButton contentFn={() => content} size={14} />}
|
||||
{!collapsed ? (
|
||||
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
|
||||
<XCircleIcon size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
|
||||
<ChevronDownIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
|
||||
</div>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<div className={type} dir="auto">
|
||||
<div className="gpt-header">
|
||||
<p>Error:</p>
|
||||
<div style="display: flex; gap: 15px;">
|
||||
<CopyButton contentFn={() => content} size={14} />
|
||||
{!collapsed ? (
|
||||
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
|
||||
<XCircleIcon size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
|
||||
<ChevronDownIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ConversationItem.propTypes = {
|
||||
type: PropTypes.oneOf(['question', 'answer', 'error']).isRequired,
|
||||
content: PropTypes.string.isRequired,
|
||||
session: PropTypes.object.isRequired,
|
||||
done: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default ConversationItem
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckIcon, CopyIcon } from '@primer/octicons-react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
CopyButton.propTypes = {
|
||||
contentFn: PropTypes.func.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
className: PropTypes.string,
|
||||
}
|
||||
|
||||
function CopyButton({ className, contentFn, size }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onClick = () => {
|
||||
navigator.clipboard
|
||||
.writeText(contentFn())
|
||||
.then(() => setCopied(true))
|
||||
.then(() =>
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 600),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span title="Copy" className={`gpt-util-icon ${className ? className : ''}`} onClick={onClick}>
|
||||
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -0,0 +1,143 @@
|
||||
import { LightBulbIcon, SearchIcon } from '@primer/octicons-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import ConversationCard from '../ConversationCard'
|
||||
import { defaultConfig, getUserConfig } from '../../config'
|
||||
import Browser from 'webextension-polyfill'
|
||||
import { getPossibleElementByQuerySelector, endsWithQuestionMark } from '../../utils'
|
||||
|
||||
function DecisionCard(props) {
|
||||
const [triggered, setTriggered] = useState(false)
|
||||
const [config, setConfig] = useState(defaultConfig)
|
||||
const [render, setRender] = useState(false)
|
||||
|
||||
const question = props.question
|
||||
|
||||
useEffect(() => {
|
||||
getUserConfig()
|
||||
.then(setConfig)
|
||||
.then(() => setRender(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (changes) => {
|
||||
const changedItems = Object.keys(changes)
|
||||
let newConfig = {}
|
||||
for (const key of changedItems) {
|
||||
newConfig[key] = changes[key].newValue
|
||||
}
|
||||
setConfig({ ...config, ...newConfig })
|
||||
}
|
||||
Browser.storage.local.onChanged.addListener(listener)
|
||||
return () => {
|
||||
Browser.storage.local.onChanged.removeListener(listener)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!render) return
|
||||
|
||||
const container = props.container
|
||||
const siteConfig = props.siteConfig
|
||||
container.classList.remove('sidebar-free')
|
||||
|
||||
if (config.appendQuery) {
|
||||
const appendContainer = getPossibleElementByQuerySelector([config.appendQuery])
|
||||
if (appendContainer) {
|
||||
appendContainer.appendChild(container)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (config.prependQuery) {
|
||||
const prependContainer = getPossibleElementByQuerySelector([config.prependQuery])
|
||||
if (prependContainer) {
|
||||
prependContainer.prepend(container)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!siteConfig) return
|
||||
|
||||
if (config.insertAtTop) {
|
||||
const resultsContainerQuery = getPossibleElementByQuerySelector(
|
||||
siteConfig.resultsContainerQuery,
|
||||
)
|
||||
if (resultsContainerQuery) resultsContainerQuery.prepend(container)
|
||||
} else {
|
||||
const sidebarContainer = getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery)
|
||||
if (sidebarContainer) {
|
||||
sidebarContainer.prepend(container)
|
||||
} else {
|
||||
const appendContainer = getPossibleElementByQuerySelector(siteConfig.appendContainerQuery)
|
||||
if (appendContainer) {
|
||||
container.classList.add('sidebar-free')
|
||||
appendContainer.appendChild(container)
|
||||
} else {
|
||||
const resultsContainerQuery = getPossibleElementByQuerySelector(
|
||||
siteConfig.resultsContainerQuery,
|
||||
)
|
||||
if (resultsContainerQuery) resultsContainerQuery.prepend(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => updatePosition(), [config])
|
||||
|
||||
return (
|
||||
render && (
|
||||
<div data-theme={config.themeMode}>
|
||||
{(() => {
|
||||
if (question)
|
||||
switch (config.triggerMode) {
|
||||
case 'always':
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
case 'manually':
|
||||
if (triggered) {
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
}
|
||||
return (
|
||||
<p
|
||||
className="gpt-inner manual-btn icon-and-text"
|
||||
onClick={() => setTriggered(true)}
|
||||
>
|
||||
<SearchIcon size="small" /> Ask ChatGPT
|
||||
</p>
|
||||
)
|
||||
case 'questionMark':
|
||||
if (endsWithQuestionMark(question.trim())) {
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
}
|
||||
if (triggered) {
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
}
|
||||
return (
|
||||
<p
|
||||
className="gpt-inner manual-btn icon-and-text"
|
||||
onClick={() => setTriggered(true)}
|
||||
>
|
||||
<SearchIcon size="small" /> Ask ChatGPT
|
||||
</p>
|
||||
)
|
||||
}
|
||||
else
|
||||
return (
|
||||
<p className="gpt-inner icon-and-text">
|
||||
<LightBulbIcon size="small" /> No Input Found
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DecisionCard.propTypes = {
|
||||
session: PropTypes.object.isRequired,
|
||||
question: PropTypes.string.isRequired,
|
||||
siteConfig: PropTypes.object.isRequired,
|
||||
container: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export default DecisionCard
|
||||
@@ -0,0 +1,64 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { ThumbsupIcon, ThumbsdownIcon } from '@primer/octicons-react'
|
||||
import Browser from 'webextension-polyfill'
|
||||
|
||||
const FeedbackForChatGPTWeb = (props) => {
|
||||
const [action, setAction] = useState(null)
|
||||
|
||||
const clickThumbsUp = useCallback(async () => {
|
||||
if (action) {
|
||||
return
|
||||
}
|
||||
setAction('thumbsUp')
|
||||
await Browser.runtime.sendMessage({
|
||||
type: 'FEEDBACK',
|
||||
data: {
|
||||
conversation_id: props.conversationId,
|
||||
message_id: props.messageId,
|
||||
rating: 'thumbsUp',
|
||||
},
|
||||
})
|
||||
}, [props, action])
|
||||
|
||||
const clickThumbsDown = useCallback(async () => {
|
||||
if (action) {
|
||||
return
|
||||
}
|
||||
setAction('thumbsDown')
|
||||
await Browser.runtime.sendMessage({
|
||||
type: 'FEEDBACK',
|
||||
data: {
|
||||
conversation_id: props.conversationId,
|
||||
message_id: props.messageId,
|
||||
rating: 'thumbsDown',
|
||||
text: '',
|
||||
tags: [],
|
||||
},
|
||||
})
|
||||
}, [props, action])
|
||||
|
||||
return (
|
||||
<div title="Feedback" className="gpt-feedback">
|
||||
<span
|
||||
onClick={clickThumbsUp}
|
||||
className={action === 'thumbsUp' ? 'gpt-feedback-selected' : undefined}
|
||||
>
|
||||
<ThumbsupIcon size={14} />
|
||||
</span>
|
||||
<span
|
||||
onClick={clickThumbsDown}
|
||||
className={action === 'thumbsDown' ? 'gpt-feedback-selected' : undefined}
|
||||
>
|
||||
<ThumbsdownIcon size={14} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FeedbackForChatGPTWeb.propTypes = {
|
||||
messageId: PropTypes.string.isRequired,
|
||||
conversationId: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default memo(FeedbackForChatGPTWeb)
|
||||
@@ -0,0 +1,132 @@
|
||||
import Browser from 'webextension-polyfill'
|
||||
import { cloneElement, useEffect, useState } from 'react'
|
||||
import ConversationCard from '../ConversationCard'
|
||||
import PropTypes from 'prop-types'
|
||||
import { defaultConfig, getUserConfig } from '../../config.mjs'
|
||||
import { config as toolsConfig } from '../../content-script/selection-tools'
|
||||
import { setElementPositionInViewport } from '../../utils'
|
||||
import Draggable from 'react-draggable'
|
||||
|
||||
const logo = Browser.runtime.getURL('logo.png')
|
||||
|
||||
function FloatingToolbar(props) {
|
||||
const [prompt, setPrompt] = useState(props.prompt)
|
||||
const [triggered, setTriggered] = useState(props.triggered)
|
||||
const [config, setConfig] = useState(defaultConfig)
|
||||
const [render, setRender] = useState(false)
|
||||
const [position, setPosition] = useState(props.position)
|
||||
const [virtualPosition, setVirtualPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
getUserConfig()
|
||||
.then(setConfig)
|
||||
.then(() => setRender(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (changes) => {
|
||||
const changedItems = Object.keys(changes)
|
||||
let newConfig = {}
|
||||
for (const key of changedItems) {
|
||||
newConfig[key] = changes[key].newValue
|
||||
}
|
||||
setConfig({ ...config, ...newConfig })
|
||||
}
|
||||
Browser.storage.local.onChanged.addListener(listener)
|
||||
return () => {
|
||||
Browser.storage.local.onChanged.removeListener(listener)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
if (!render) return <div />
|
||||
|
||||
if (triggered) {
|
||||
const updatePosition = () => {
|
||||
const newPosition = setElementPositionInViewport(props.container, position.x, position.y)
|
||||
if (position.x !== newPosition.x || position.y !== newPosition.y) setPosition(newPosition) // clear extra virtual position offset
|
||||
}
|
||||
|
||||
const dragEvent = {
|
||||
onDrag: (e, ui) => {
|
||||
setVirtualPosition({ x: virtualPosition.x + ui.deltaX, y: virtualPosition.y + ui.deltaY })
|
||||
},
|
||||
onStop: () => {
|
||||
setPosition({ x: position.x + virtualPosition.x, y: position.y + virtualPosition.y })
|
||||
setVirtualPosition({ x: 0, y: 0 })
|
||||
},
|
||||
}
|
||||
|
||||
if (virtualPosition.x === 0 && virtualPosition.y === 0) {
|
||||
updatePosition() // avoid jitter
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-theme={config.themeMode}>
|
||||
<Draggable
|
||||
handle=".dragbar"
|
||||
onDrag={dragEvent.onDrag}
|
||||
onStop={dragEvent.onStop}
|
||||
position={virtualPosition}
|
||||
>
|
||||
<div className="gpt-selection-window">
|
||||
<div className="chat-gpt-container">
|
||||
<ConversationCard
|
||||
session={props.session}
|
||||
question={prompt}
|
||||
draggable={true}
|
||||
closeable={props.closeable}
|
||||
onClose={props.onClose}
|
||||
onUpdate={() => {
|
||||
updatePosition()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
if (config.activeSelectionTools.length === 0) return <div />
|
||||
|
||||
const tools = []
|
||||
|
||||
for (const key in toolsConfig) {
|
||||
if (config.activeSelectionTools.includes(key)) {
|
||||
const toolConfig = toolsConfig[key]
|
||||
tools.push(
|
||||
cloneElement(toolConfig.icon, {
|
||||
size: 20,
|
||||
className: 'gpt-selection-toolbar-button',
|
||||
title: toolConfig.label,
|
||||
onClick: async () => {
|
||||
setPrompt(await toolConfig.genPrompt(props.selection))
|
||||
setTriggered(true)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-theme={config.themeMode}>
|
||||
<div className="gpt-selection-toolbar">
|
||||
<img src={logo} width="24" height="24" style="user-select:none;" />
|
||||
{tools}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FloatingToolbar.propTypes = {
|
||||
session: PropTypes.object.isRequired,
|
||||
selection: PropTypes.string.isRequired,
|
||||
position: PropTypes.object.isRequired,
|
||||
container: PropTypes.object.isRequired,
|
||||
triggered: PropTypes.bool,
|
||||
closeable: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
prompt: PropTypes.string,
|
||||
}
|
||||
|
||||
export default FloatingToolbar
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { updateRefHeight } from '../../utils'
|
||||
|
||||
export function InputBox({ onSubmit, enabled }) {
|
||||
const [value, setValue] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
updateRefHeight(inputRef)
|
||||
})
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
||||
e.preventDefault()
|
||||
if (!value) return
|
||||
onSubmit(value)
|
||||
setValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
disabled={!enabled}
|
||||
className="interact-input"
|
||||
placeholder={
|
||||
enabled
|
||||
? 'Type your question here\nEnter to send, shift + enter to break line'
|
||||
: 'Wait for the answer to finish and then continue here'
|
||||
}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
InputBox.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
enabled: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default InputBox
|
||||
@@ -0,0 +1,66 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import CopyButton from '../CopyButton'
|
||||
import { useRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
function Pre({ className, children }) {
|
||||
const preRef = useRef(null)
|
||||
return (
|
||||
<pre className={className} ref={preRef} style="position: relative;">
|
||||
<CopyButton
|
||||
className="code-copy-btn"
|
||||
contentFn={() => preRef.current.textContent}
|
||||
size={14}
|
||||
/>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
Pre.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export function MarkdownRender(props) {
|
||||
const linkProperties = {
|
||||
target: '_blank',
|
||||
style: 'color: #8ab4f8;',
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
a: (props) => (
|
||||
<a href={props.href} {...linkProperties}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
pre: Pre,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
MarkdownRender.propTypes = {
|
||||
...ReactMarkdown.propTypes,
|
||||
}
|
||||
|
||||
export default MarkdownRender
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import CopyButton from '../CopyButton'
|
||||
import { useRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
function Pre({ className, children }) {
|
||||
const preRef = useRef(null)
|
||||
return (
|
||||
<pre className={className} ref={preRef} style="position: relative;">
|
||||
<CopyButton
|
||||
className="code-copy-btn"
|
||||
contentFn={() => preRef.current.textContent}
|
||||
size={14}
|
||||
/>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
Pre.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export function MarkdownRender(props) {
|
||||
const linkProperties = {
|
||||
target: '_blank',
|
||||
style: 'color: #8ab4f8;',
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
rehypeRaw,
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
a: (props) => (
|
||||
<a href={props.href} {...linkProperties}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
pre: Pre,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
MarkdownRender.propTypes = {
|
||||
...ReactMarkdown.propTypes,
|
||||
}
|
||||
|
||||
export default MarkdownRender
|
||||
Reference in New Issue
Block a user