first commit

This commit is contained in:
josc146
2023-03-15 16:18:51 +08:00
commit e642f2b922
67 changed files with 14469 additions and 0 deletions
+260
View File
@@ -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)
+102
View File
@@ -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
+32
View File
@@ -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
+143
View File
@@ -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)
+132
View File
@@ -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
+44
View File
@@ -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