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
+124
View File
@@ -0,0 +1,124 @@
// web version
import { fetchSSE } from '../../utils/fetch-sse'
import { isEmpty } from 'lodash-es'
import { chatgptWebModelKeys, getUserConfig, Models } from '../../config'
async function request(token, method, path, data) {
const apiUrl = (await getUserConfig()).customChatGptWebApiUrl
const response = await fetch(`${apiUrl}/backend-api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
})
const responseText = await response.text()
console.debug(`request: ${path}`, responseText)
return { response, responseText }
}
export async function sendMessageFeedback(token, data) {
await request(token, 'POST', '/conversation/message_feedback', data)
}
export async function setConversationProperty(token, conversationId, propertyObject) {
await request(token, 'PATCH', `/conversation/${conversationId}`, propertyObject)
}
export async function sendModerations(token, question, conversationId, messageId) {
await request(token, 'POST', `/moderations`, {
conversation_id: conversationId,
input: question,
message_id: messageId,
model: 'text-moderation-playground',
})
}
export async function getModels(token) {
const response = JSON.parse((await request(token, 'GET', '/models')).responseText)
return response.models
}
/**
* @param {Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} accessToken
*/
export async function generateAnswersWithChatgptWebApi(port, question, session, accessToken) {
const deleteConversation = () => {
setConversationProperty(accessToken, session.conversationId, { is_visible: false })
}
const controller = new AbortController()
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
deleteConversation()
})
const models = await getModels(accessToken).catch(() => {})
const config = await getUserConfig()
let answer = ''
await fetchSSE(`${config.customChatGptWebApiUrl}${config.customChatGptWebApiPath}`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
action: 'next',
conversation_id: session.conversationId,
messages: [
{
id: session.messageId,
role: 'user',
content: {
content_type: 'text',
parts: [question],
},
},
],
model: models ? models[0].slug : Models[chatgptWebModelKeys[0]].value,
parent_message_id: session.parentMessageId,
}),
onMessage(message) {
console.debug('sse message', message)
if (message === '[DONE]') {
session.conversationRecords.push({ question: question, answer: answer })
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: null, done: true, session: session })
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
if (data.conversation_id) session.conversationId = data.conversation_id
if (data.message?.id) session.parentMessageId = data.message.id
answer = data.message?.content?.parts?.[0]
if (answer) {
port.postMessage({ answer: answer, done: false, session: session })
}
},
async onStart() {
// sendModerations(accessToken, question, session.conversationId, session.messageId)
},
async onEnd() {},
async onError(resp) {
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
},
})
}
+154
View File
@@ -0,0 +1,154 @@
// api version
import { maxResponseTokenLength, Models, getUserConfig } from '../../config'
import { fetchSSE } from '../../utils/fetch-sse'
import { getConversationPairs } from '../../utils/get-conversation-pairs'
import { isEmpty } from 'lodash-es'
const getChatgptPromptBase = async () => {
return `You are a helpful, creative, clever, and very friendly assistant. You are familiar with various languages in the world.`
}
const getGptPromptBase = async () => {
return (
`The following is a conversation with an AI assistant.` +
`The assistant is helpful, creative, clever, and very friendly. The assistant is familiar with various languages in the world.\n\n` +
`Human: Hello, who are you?\n` +
`AI: I am an AI created by OpenAI. How can I help you today?\n` +
`Human: 谢谢\n` +
`AI: 不客气\n`
)
}
/**
* @param {Browser.Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} apiKey
* @param {string} modelName
*/
export async function generateAnswersWithGptCompletionApi(
port,
question,
session,
apiKey,
modelName,
) {
const controller = new AbortController()
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
})
const prompt =
(await getGptPromptBase()) +
getConversationPairs(session.conversationRecords, false) +
`Human:${question}\nAI:`
const apiUrl = (await getUserConfig()).customOpenAiApiUrl
let answer = ''
await fetchSSE(`${apiUrl}/v1/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
prompt: prompt,
model: Models[modelName].value,
stream: true,
max_tokens: maxResponseTokenLength,
}),
onMessage(message) {
console.debug('sse message', message)
if (message === '[DONE]') {
session.conversationRecords.push({ question: question, answer: answer })
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: null, done: true, session: session })
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
answer += data.choices[0].text
port.postMessage({ answer: answer, done: false, session: null })
},
async onStart() {},
async onEnd() {},
async onError(resp) {
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
},
})
}
/**
* @param {Browser.Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} apiKey
* @param {string} modelName
*/
export async function generateAnswersWithChatgptApi(port, question, session, apiKey, modelName) {
const controller = new AbortController()
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
})
const prompt = getConversationPairs(session.conversationRecords, true)
prompt.unshift({ role: 'system', content: await getChatgptPromptBase() })
prompt.push({ role: 'user', content: question })
const apiUrl = (await getUserConfig()).customOpenAiApiUrl
let answer = ''
await fetchSSE(`${apiUrl}/v1/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
messages: prompt,
model: Models[modelName].value,
stream: true,
max_tokens: maxResponseTokenLength,
}),
onMessage(message) {
console.debug('sse message', message)
if (message === '[DONE]') {
session.conversationRecords.push({ question: question, answer: answer })
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: null, done: true, session: session })
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
if ('content' in data.choices[0].delta) answer += data.choices[0].delta.content
port.postMessage({ answer: answer, done: false, session: null })
},
async onStart() {},
async onEnd() {},
async onError(resp) {
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
},
})
}
+136
View File
@@ -0,0 +1,136 @@
import { v4 as uuidv4 } from 'uuid'
import Browser from 'webextension-polyfill'
import ExpiryMap from 'expiry-map'
import { generateAnswersWithChatgptWebApi, sendMessageFeedback } from './apis/chatgpt-web'
import {
generateAnswersWithChatgptApi,
generateAnswersWithGptCompletionApi,
} from './apis/openai-api'
import {
chatgptApiModelKeys,
chatgptWebModelKeys,
getUserConfig,
gptApiModelKeys,
isUsingApiKey,
} from '../config'
import { isSafari } from '../utils/is-safari'
import { config as toolsConfig } from '../content-script/selection-tools'
const KEY_ACCESS_TOKEN = 'accessToken'
const cache = new ExpiryMap(10 * 1000)
/**
* @returns {Promise<string>}
*/
async function getAccessToken() {
if (cache.get(KEY_ACCESS_TOKEN)) {
return cache.get(KEY_ACCESS_TOKEN)
}
if (isSafari()) {
const userConfig = await getUserConfig()
if (userConfig.accessToken) {
cache.set(KEY_ACCESS_TOKEN, userConfig.accessToken)
} else {
throw new Error('UNAUTHORIZED')
}
} else {
const resp = await fetch('https://chat.openai.com/api/auth/session')
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const data = await resp.json().catch(() => ({}))
if (!data.accessToken) {
throw new Error('UNAUTHORIZED')
}
cache.set(KEY_ACCESS_TOKEN, data.accessToken)
}
return cache.get(KEY_ACCESS_TOKEN)
}
Browser.runtime.onConnect.addListener((port) => {
console.debug('connected')
port.onMessage.addListener(async (msg) => {
console.debug('received msg', msg)
const config = await getUserConfig()
const session = msg.session
if (session.useApiKey == null) {
session.useApiKey = isUsingApiKey(config)
}
try {
if (chatgptWebModelKeys.includes(config.modelName)) {
const accessToken = await getAccessToken()
session.messageId = uuidv4()
if (session.parentMessageId == null) {
session.parentMessageId = uuidv4()
}
await generateAnswersWithChatgptWebApi(port, session.question, session, accessToken)
} else if (gptApiModelKeys.includes(config.modelName)) {
await generateAnswersWithGptCompletionApi(
port,
session.question,
session,
config.apiKey,
config.modelName,
)
} else if (chatgptApiModelKeys.includes(config.modelName)) {
await generateAnswersWithChatgptApi(
port,
session.question,
session,
config.apiKey,
config.modelName,
)
}
} catch (err) {
console.error(err)
port.postMessage({ error: err.message })
cache.delete(KEY_ACCESS_TOKEN)
}
})
})
Browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'FEEDBACK') {
const token = await getAccessToken()
await sendMessageFeedback(token, message.data)
}
})
Browser.contextMenus.removeAll().then(() => {
const menuId = 'ChatGPTBox-Menu'
Browser.contextMenus.create({
id: menuId,
title: 'ChatGPTBox',
contexts: ['all'],
})
Browser.contextMenus.create({
id: menuId + 'new',
parentId: menuId,
title: 'New Chat',
contexts: ['selection'],
})
for (const key in toolsConfig) {
const toolConfig = toolsConfig[key]
Browser.contextMenus.create({
id: menuId + key,
parentId: menuId,
title: toolConfig.label,
contexts: ['selection'],
})
}
Browser.contextMenus.onClicked.addListener((info, tab) => {
const itemId = info.menuItemId === menuId ? 'new' : info.menuItemId.replace(menuId, '')
const message = {
itemId: itemId,
selectionText: info.selectionText,
}
console.debug('menu clicked', message)
Browser.tabs.sendMessage(tab.id, {
type: 'MENU',
data: message,
})
})
})
+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
+126
View File
@@ -0,0 +1,126 @@
import { defaults } from 'lodash-es'
import Browser from 'webextension-polyfill'
import { isMobile } from './utils/is-mobile'
import { config as toolsConfig } from './content-script/selection-tools'
import { languages } from 'countries-list'
/**
* @typedef {object} Model
* @property {string} value
* @property {string} desc
*/
/**
* @type {Object.<string,Model>}
*/
export const Models = {
chatgptFree: { value: 'text-davinci-002-render-sha', desc: 'ChatGPT (Web)' },
chatgptApi: { value: 'gpt-3.5-turbo', desc: 'ChatGPT (GPT-3.5)' },
gptDavinci: { value: 'text-davinci-003', desc: 'GPT3' },
}
export const chatgptWebModelKeys = ['chatgptFree']
export const gptApiModelKeys = ['gptDavinci']
export const chatgptApiModelKeys = ['chatgptApi']
export const TriggerMode = {
always: 'Always',
questionMark: 'When query ends with question mark (?)',
manually: 'Manually',
}
export const ThemeMode = {
light: 'Light',
dark: 'Dark',
auto: 'Auto',
}
export const languageList = { auto: { name: 'Auto', native: 'Auto' }, ...languages }
export const maxResponseTokenLength = 1000
/**
* @typedef {typeof defaultConfig} UserConfig
*/
export const defaultConfig = {
/** @type {keyof TriggerMode}*/
triggerMode: 'manually',
/** @type {keyof ThemeMode}*/
themeMode: 'auto',
/** @type {keyof Models}*/
modelName: 'chatgptFree',
apiKey: '',
insertAtTop: isMobile(),
siteRegex: 'match nothing',
userSiteRegexOnly: false,
inputQuery: '',
appendQuery: '',
prependQuery: '',
accessToken: '',
tokenSavedOn: 0,
preferredLanguage: navigator.language.substring(0, 2),
userLanguage: navigator.language.substring(0, 2), // unchangeable
customChatGptWebApiUrl: 'https://chat.openai.com',
customChatGptWebApiPath: '/backend-api/conversation',
customOpenAiApiUrl: 'https://api.openai.com',
selectionTools: Object.keys(toolsConfig),
activeSelectionTools: Object.keys(toolsConfig),
// importing configuration will result in gpt-3-encoder being packaged into the output file
siteAdapters: ['bilibili', 'github', 'gitlab', 'quora', 'reddit', 'youtube', 'zhihu'],
activeSiteAdapters: ['bilibili', 'github', 'gitlab', 'quora', 'reddit', 'youtube', 'zhihu'],
}
export async function getUserLanguage() {
return languageList[defaultConfig.userLanguage].name
}
export async function getUserLanguageNative() {
return languageList[defaultConfig.userLanguage].native
}
export async function getPreferredLanguage() {
const config = await getUserConfig()
if (config.preferredLanguage === 'auto') return await getUserLanguage()
return languageList[config.preferredLanguage].name
}
export async function getPreferredLanguageNative() {
const config = await getUserConfig()
if (config.preferredLanguage === 'auto') return await getUserLanguageNative()
return languageList[config.preferredLanguage].native
}
export function isUsingApiKey(config) {
return (
gptApiModelKeys.includes(config.modelName) || chatgptApiModelKeys.includes(config.modelName)
)
}
/**
* get user config from local storage
* @returns {Promise<UserConfig>}
*/
export async function getUserConfig() {
const options = await Browser.storage.local.get(Object.keys(defaultConfig))
return defaults(options, defaultConfig)
}
/**
* set user config to local storage
* @param {Partial<UserConfig>} value
*/
export async function setUserConfig(value) {
await Browser.storage.local.set(value)
}
export async function setAccessToken(accessToken) {
await setUserConfig({ accessToken, tokenSavedOn: Date.now() })
}
const TOKEN_DURATION = 30 * 24 * 3600 * 1000
export async function clearOldAccessToken() {
const duration = Date.now() - (await getUserConfig()).tokenSavedOn
if (duration > TOKEN_DURATION) {
await setAccessToken('')
}
}
+220
View File
@@ -0,0 +1,220 @@
import './styles.scss'
import { render } from 'preact'
import DecisionCard from '../components/DecisionCard'
import { config as siteConfig } from './site-adapters'
import { config as toolsConfig } from './selection-tools'
import { clearOldAccessToken, getUserConfig, setAccessToken, getPreferredLanguage } from '../config'
import {
createElementAtPosition,
getPossibleElementByQuerySelector,
initSession,
isSafari,
} from '../utils'
import FloatingToolbar from '../components/FloatingToolbar'
import Browser from 'webextension-polyfill'
/**
* @param {SiteConfig} siteConfig
* @param {UserConfig} userConfig
*/
async function mountComponent(siteConfig, userConfig) {
if (
!getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery) &&
!getPossibleElementByQuerySelector(siteConfig.appendContainerQuery) &&
!getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery) &&
!getPossibleElementByQuerySelector([userConfig.prependQuery]) &&
!getPossibleElementByQuerySelector([userConfig.appendQuery])
)
return
document.querySelectorAll('.chat-gpt-container').forEach((e) => e.remove())
let question
if (userConfig.inputQuery) question = await getInput([userConfig.inputQuery])
if (!question && siteConfig) question = await getInput(siteConfig.inputQuery)
document.querySelectorAll('.chat-gpt-container').forEach((e) => e.remove())
const container = document.createElement('div')
container.className = 'chat-gpt-container'
render(
<DecisionCard
session={initSession()}
question={question}
siteConfig={siteConfig}
container={container}
/>,
container,
)
}
/**
* @param {string[]|function} inputQuery
* @returns {Promise<string>}
*/
async function getInput(inputQuery) {
if (typeof inputQuery === 'function') {
const input = await inputQuery()
if (input) return `Reply in ${await getPreferredLanguage()}.\n` + input
return input
}
const searchInput = getPossibleElementByQuerySelector(inputQuery)
if (searchInput && searchInput.value) {
return searchInput.value
}
}
async function prepareForSafari() {
await clearOldAccessToken()
if (location.hostname !== 'chat.openai.com' || location.pathname !== '/api/auth/session') return
const response = document.querySelector('pre').textContent
let data
try {
data = JSON.parse(response)
} catch (error) {
console.error('json error', error)
return
}
if (data.accessToken) {
await setAccessToken(data.accessToken)
}
}
let toolbarContainer
async function prepareForSelectionTools() {
document.addEventListener('mouseup', (e) => {
if (toolbarContainer && toolbarContainer.contains(e.target)) return
if (
toolbarContainer &&
window.getSelection()?.rangeCount > 0 &&
toolbarContainer.contains(window.getSelection()?.getRangeAt(0).endContainer.parentElement)
)
return
if (toolbarContainer) toolbarContainer.remove()
setTimeout(() => {
const selection = window.getSelection()?.toString()
if (selection) {
const position = { x: e.clientX + 15, y: e.clientY - 15 }
toolbarContainer = createElementAtPosition(position.x, position.y)
toolbarContainer.className = 'toolbar-container'
render(
<FloatingToolbar
session={initSession()}
selection={selection}
position={position}
container={toolbarContainer}
/>,
toolbarContainer,
)
}
})
})
document.addEventListener('mousedown', (e) => {
if (toolbarContainer && toolbarContainer.contains(e.target)) return
document.querySelectorAll('.toolbar-container').forEach((e) => e.remove())
})
document.addEventListener('keydown', (e) => {
if (
toolbarContainer &&
!toolbarContainer.contains(e.target) &&
(e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA')
) {
setTimeout(() => {
if (!window.getSelection()?.toString()) toolbarContainer.remove()
})
}
})
}
let menuX, menuY
async function prepareForRightClickMenu() {
document.addEventListener('contextmenu', (e) => {
menuX = e.clientX
menuY = e.clientY
})
Browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'MENU') {
const data = message.data
if (data.itemId === 'new') {
const position = { x: menuX, y: menuY }
const container = createElementAtPosition(position.x, position.y)
container.className = 'toolbar-container-not-queryable'
render(
<FloatingToolbar
session={initSession()}
selection=""
position={position}
container={container}
triggered={true}
closeable={true}
onClose={() => container.remove()}
/>,
container,
)
} else {
const position = { x: menuX, y: menuY }
const container = createElementAtPosition(position.x, position.y)
container.className = 'toolbar-container-not-queryable'
render(
<FloatingToolbar
session={initSession()}
selection={data.selectionText}
position={position}
container={container}
triggered={true}
closeable={true}
onClose={() => container.remove()}
prompt={await toolsConfig[data.itemId].genPrompt(data.selectionText)}
/>,
container,
)
}
}
})
}
async function prepareForStaticCard() {
let siteRegex
if (userConfig.userSiteRegexOnly) siteRegex = userConfig.siteRegex
else
siteRegex = new RegExp(
(userConfig.siteRegex && userConfig.siteRegex + '|') + Object.keys(siteConfig).join('|'),
)
const matches = location.hostname.match(siteRegex)
if (matches) {
const siteName = matches[0]
if (siteName in siteConfig) {
const siteAction = siteConfig[siteName].action
if (siteAction && siteAction.init) {
await siteAction.init(location.hostname, userConfig, getInput, mountComponent)
}
}
if (
userConfig.siteAdapters.includes(siteName) &&
!userConfig.activeSiteAdapters.includes(siteName)
)
return
mountComponent(siteConfig[siteName], userConfig)
}
}
let userConfig
async function run() {
userConfig = await getUserConfig()
if (isSafari()) await prepareForSafari()
prepareForSelectionTools()
prepareForStaticCard()
prepareForRightClickMenu()
}
run()
@@ -0,0 +1,60 @@
import {
CardHeading,
CardList,
EmojiSmile,
Palette,
QuestionCircle,
Translate,
} from 'react-bootstrap-icons'
import { getPreferredLanguage } from '../../config.mjs'
export const config = {
translate: {
icon: <Translate />,
label: 'Translate',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return (
`Translate the following into ${preferredLanguage} and only show me the translated content.` +
`If it is already in ${preferredLanguage},` +
`translate it into English and only show me the translated content:\n"${selection}"`
)
},
},
summary: {
icon: <CardHeading />,
label: 'Summary',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return `Reply in ${preferredLanguage}.Summarize the following as concisely as possible:\n"${selection}"`
},
},
polish: {
icon: <Palette />,
label: 'Polish',
genPrompt: async (selection) =>
`Check the following content for possible diction and grammar problems,and polish it carefully:\n"${selection}"`,
},
sentiment: {
icon: <EmojiSmile />,
label: 'Sentiment Analysis',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return `Reply in ${preferredLanguage}.Analyze the sentiments expressed in the following content and make a brief summary of the sentiments:\n"${selection}"`
},
},
divide: {
icon: <CardList />,
label: 'Divide Paragraphs',
genPrompt: async (selection) =>
`Divide the following into paragraphs that are easy to read and understand:\n"${selection}"`,
},
ask: {
icon: <QuestionCircle />,
label: 'Ask',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return `Reply in ${preferredLanguage}.Analyze the following content and express your opinion,or give your answer:\n"${selection}"`
},
},
}
@@ -0,0 +1 @@
//TODO
@@ -0,0 +1,26 @@
import { config } from '../index'
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
const targetNode = document.getElementById('wrapper_wrapper')
const observer = new MutationObserver(async (records) => {
if (
records.some(
(record) =>
record.type === 'childList' &&
[...record.addedNodes].some((node) => node.id === 'container'),
)
) {
const searchValue = await getInput(config.baidu.inputQuery)
if (searchValue) {
mountComponent(config.baidu, userConfig)
}
}
})
observer.observe(targetNode, { childList: true })
} catch (e) {
/* empty */
}
},
}
@@ -0,0 +1,58 @@
import { cropText } from '../../../utils'
import { config } from '../index.mjs'
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
let oldUrl = location.href
const checkUrlChange = async () => {
if (location.href !== oldUrl) {
oldUrl = location.href
mountComponent(config.bilibili, userConfig)
}
}
window.setInterval(checkUrlChange, 500)
} catch (e) {
/* empty */
}
},
inputQuery: async () => {
try {
const bvid = location.pathname.replace('video', '').replaceAll('/', '')
const p = Number(new URLSearchParams(location.search).get('p') || 1) - 1
const pagelistResponse = await fetch(
`https://api.bilibili.com/x/player/pagelist?bvid=${bvid}`,
)
const pagelistData = await pagelistResponse.json()
const videoList = pagelistData.data
const cid = videoList[p].cid
const title = videoList[p].part
const infoResponse = await fetch(
`https://api.bilibili.com/x/player/v2?bvid=${bvid}&cid=${cid}`,
{
credentials: 'include',
},
)
const infoData = await infoResponse.json()
const subtitleUrl = infoData.data.subtitle.subtitles[0].subtitle_url
const subtitleResponse = await fetch(subtitleUrl)
const subtitleData = await subtitleResponse.json()
const subtitles = subtitleData.body
let subtitleContent = ''
for (let i = 0; i < subtitles.length; i++) {
if (i === subtitles.length - 1) subtitleContent += subtitles[i].content
else subtitleContent += subtitles[i].content + ','
}
return cropText(
`用尽量简练的语言,联系视频标题,对视频进行内容摘要,视频标题为:"${title}",字幕内容为:\n${subtitleContent}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,56 @@
import { cropText, limitedFetch } from '../../../utils'
import { config } from '../index.mjs'
const getPatchUrl = async () => {
const patchUrl = location.origin + location.pathname + '.patch'
const response = await fetch(patchUrl, { method: 'HEAD' })
if (response.ok) return patchUrl
return ''
}
const getPatchData = async (patchUrl) => {
if (!patchUrl) return
let patchData = await limitedFetch(patchUrl, 1024 * 40)
patchData = patchData.substring(patchData.indexOf('---'))
return patchData
}
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
const targetNode = document.querySelector('body')
const observer = new MutationObserver(async (records) => {
if (
records.some(
(record) =>
record.type === 'childList' &&
[...record.addedNodes].some((node) => node.classList.contains('page-responsive')),
)
) {
const patchUrl = await getPatchUrl()
if (patchUrl) {
mountComponent(config.github, userConfig)
}
}
})
observer.observe(targetNode, { childList: true })
} catch (e) {
/* empty */
}
},
inputQuery: async () => {
try {
const patchUrl = await getPatchUrl()
const patchData = await getPatchData(patchUrl)
if (!patchData) return
return cropText(
`Analyze the contents of a git commit,provide a suitable commit message,and summarize the contents of the commit.` +
`The patch contents of this commit are as follows:\n${patchData}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,33 @@
import { cropText, limitedFetch } from '../../../utils'
const getPatchUrl = async () => {
const patchUrl = location.origin + location.pathname + '.patch'
const response = await fetch(patchUrl, { method: 'HEAD' })
if (response.ok) return patchUrl
return ''
}
const getPatchData = async (patchUrl) => {
if (!patchUrl) return
let patchData = await limitedFetch(patchUrl, 1024 * 40)
patchData = patchData.substring(patchData.indexOf('---'))
return patchData
}
export default {
inputQuery: async () => {
try {
const patchUrl = await getPatchUrl()
const patchData = await getPatchData(patchUrl)
if (!patchData) return
return cropText(
`Analyze the contents of a git commit,provide a suitable commit message,and summarize the contents of the commit.` +
`The patch contents of this commit are as follows:\n${patchData}`,
)
} catch (e) {
console.log(e)
}
},
}
+163
View File
@@ -0,0 +1,163 @@
import baidu from './baidu'
import bilibili from './bilibili'
import youtube from './youtube'
import github from './github'
import gitlab from './gitlab'
import zhihu from './zhihu'
import reddit from './reddit'
import quora from './quora'
/**
* @typedef {object} SiteConfigAction
* @property {function} init
*/
/**
* @typedef {object} SiteConfig
* @property {string[]|function} inputQuery - for search box
* @property {string[]} sidebarContainerQuery - prepend child to
* @property {string[]} appendContainerQuery - if sidebarContainer not exists, append child to
* @property {string[]} resultsContainerQuery - prepend child to if insertAtTop is true
* @property {SiteConfigAction} action
*/
/**
* @type {Object.<string,SiteConfig>}
*/
export const config = {
google: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['#rhs'],
appendContainerQuery: ['#rcnt'],
resultsContainerQuery: ['#rso'],
},
bing: {
inputQuery: ["[name='q']"],
sidebarContainerQuery: ['#b_context'],
appendContainerQuery: [],
resultsContainerQuery: ['#b_results'],
},
yahoo: {
inputQuery: ["input[name='p']"],
sidebarContainerQuery: ['#right', '.Contents__inner.Contents__inner--sub'],
appendContainerQuery: ['#cols', '#contents__wrap'],
resultsContainerQuery: [
'#main-algo',
'.searchCenterMiddle',
'.Contents__inner.Contents__inner--main',
'#contents',
],
},
duckduckgo: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.results--sidebar.js-results-sidebar'],
appendContainerQuery: ['#links_wrapper'],
resultsContainerQuery: ['.results'],
},
startpage: {
inputQuery: ["input[name='query']"],
sidebarContainerQuery: ['.layout-web__sidebar.layout-web__sidebar--web'],
appendContainerQuery: ['.layout-web__body.layout-web__body--desktop'],
resultsContainerQuery: ['.mainline-results'],
},
baidu: {
inputQuery: ["input[id='kw']"],
sidebarContainerQuery: ['#content_right'],
appendContainerQuery: ['#container'],
resultsContainerQuery: ['#content_left', '#results'],
action: {
init: baidu.init,
},
},
kagi: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.right-content-box._0_right_sidebar'],
appendContainerQuery: ['#_0_app_content'],
resultsContainerQuery: ['#main', '#app'],
},
yandex: {
inputQuery: ["input[name='text']"],
sidebarContainerQuery: ['#search-result-aside'],
appendContainerQuery: [],
resultsContainerQuery: ['#search-result'],
},
naver: {
inputQuery: ["input[name='query']"],
sidebarContainerQuery: ['#sub_pack'],
appendContainerQuery: ['#content'],
resultsContainerQuery: ['#main_pack', '#ct'],
},
brave: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['#side-right'],
appendContainerQuery: [],
resultsContainerQuery: ['#results'],
},
searx: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['#sidebar_results', '#sidebar'],
appendContainerQuery: [],
resultsContainerQuery: ['#urls', '#main_results', '#results'],
},
ecosia: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.sidebar.web__sidebar'],
appendContainerQuery: ['#main'],
resultsContainerQuery: ['.mainline'],
},
neeva: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.result-group-layout__stickyContainer-iDIO8'],
appendContainerQuery: ['.search-index__searchHeaderContainer-2JD6q'],
resultsContainerQuery: ['.result-group-layout__component-1jzTe', '#search'],
},
bilibili: {
inputQuery: bilibili.inputQuery,
sidebarContainerQuery: ['#danmukuBox'],
appendContainerQuery: [],
resultsContainerQuery: [],
action: {
init: bilibili.init,
},
},
youtube: {
inputQuery: youtube.inputQuery,
sidebarContainerQuery: ['#secondary'],
appendContainerQuery: [],
resultsContainerQuery: [],
action: {
init: youtube.init,
},
},
github: {
inputQuery: github.inputQuery,
sidebarContainerQuery: ['#diff', '.commit'],
appendContainerQuery: [],
resultsContainerQuery: [],
action: {
init: github.init,
},
},
gitlab: {
inputQuery: gitlab.inputQuery,
sidebarContainerQuery: ['.js-commit-box-info'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
zhihu: {
inputQuery: zhihu.inputQuery,
sidebarContainerQuery: ['.Question-sideColumn', '.Post-Header'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
reddit: {
inputQuery: reddit.inputQuery,
sidebarContainerQuery: ['.side .spacer .linkinfo'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
quora: {
inputQuery: quora.inputQuery,
sidebarContainerQuery: ['.q-box.PageContentsLayout___StyledBox-d2uxks-0'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
}
@@ -0,0 +1,26 @@
import { cropText } from '../../../utils'
export default {
inputQuery: async () => {
try {
if (location.pathname === '/') return
const texts = document.querySelectorAll('.q-box.qu-userSelect--text')
let title
if (texts.length > 0) title = texts[0].textContent
let answers = ''
if (texts.length > 1)
for (let i = 1; i < texts.length; i++) {
answers += `answer${i}:${texts[i].textContent}|`
}
return cropText(
`Below is the content from a question and answer platform,giving the corresponding summary and your opinion on it.` +
`The question is:'${title}',` +
`Some answers are as follows:\n${answers}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,25 @@
import { cropText } from '../../../utils'
export default {
inputQuery: async () => {
try {
const title = document.querySelector('.entry .title').textContent
const texts = document.querySelectorAll('.entry .usertext-body')
let description
if (texts.length > 0) description = texts[0].textContent
let answers = ''
if (texts.length > 1)
for (let i = 1; i < texts.length; i++) {
answers += `answer${i}:${texts[i].textContent}|`
}
return cropText(
`Below is the content from a social forum,giving the corresponding summary and your opinion on it.` +
`The title is:'${title}',and the further description of the title is:'${description}'.` +
`Some answers are as follows:\n${answers}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1 @@
//TODO
@@ -0,0 +1,53 @@
import { cropText } from '../../../utils'
import { config } from '../index.mjs'
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
let oldUrl = location.href
const checkUrlChange = async () => {
if (location.href !== oldUrl) {
oldUrl = location.href
mountComponent(config.youtube, userConfig)
}
}
window.setInterval(checkUrlChange, 500)
} catch (e) {
/* empty */
}
},
inputQuery: async () => {
try {
const docText = await (
await fetch(location.href, {
credentials: 'include',
})
).text()
let subtitleUrl = docText.substring(docText.indexOf('https://www.youtube.com/api/timedtext'))
subtitleUrl = subtitleUrl.substring(0, subtitleUrl.indexOf('"'))
subtitleUrl = subtitleUrl.replaceAll('\\u0026', '&')
let title = docText.substring(docText.indexOf('"title":"') + '"title":"'.length)
title = title.substring(0, title.indexOf('","'))
const subtitleResponse = await fetch(subtitleUrl)
let subtitleData = await subtitleResponse.text()
let subtitleContent = ''
while (subtitleData.indexOf('">') !== -1) {
subtitleData = subtitleData.substring(subtitleData.indexOf('">') + 2)
subtitleContent += subtitleData.substring(0, subtitleData.indexOf('<')) + ','
}
await new Promise((r) => setTimeout(r, 1000))
return cropText(
`Provide a brief summary of the video using concise language and incorporating the video title.` +
`The video title is:"${title}".The subtitle content is as follows:\n${subtitleContent}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,29 @@
import { cropText } from '../../../utils'
export default {
inputQuery: async () => {
try {
const title = document.querySelector('.QuestionHeader-title')?.textContent
if (title) {
const description = document.querySelector('.QuestionRichText')?.textContent
const answer = document.querySelector('.AnswerItem .RichText')?.textContent
return cropText(
`以下是一个问答平台的提问与回答内容,给出相应的摘要,以及你对此的看法.问题是:"${title}",问题的进一步描述是:"${description}".` +
`其中一个回答如下:\n${answer}`,
)
} else {
const title = document.querySelector('.Post-Title')?.textContent
const description = document.querySelector('.Post-RichText')?.textContent
if (title) {
return cropText(
`以下是一篇文章,给出相应的摘要,以及你对此的看法.标题是:"${title}",内容是:\n"${description}"`,
)
}
}
} catch (e) {
console.log(e)
}
},
}
+236
View File
@@ -0,0 +1,236 @@
[data-theme='auto'] {
@import 'github-markdown-css/github-markdown.css';
@media screen and (prefers-color-scheme: dark) {
@import 'highlight.js/scss/github-dark.scss';
--font-color: #c9d1d9;
--theme-color: #202124;
--theme-border-color: #3c4043;
--dragbar-color: #3c4043;
}
@media screen and (prefers-color-scheme: light) {
@import 'highlight.js/scss/github.scss';
--font-color: #24292f;
--theme-color: #eaecf0;
--theme-border-color: #aeafb2;
--dragbar-color: #dfe0e1;
}
}
[data-theme='dark'] {
@import 'highlight.js/scss/github-dark.scss';
@import 'github-markdown-css/github-markdown-dark.css';
--font-color: #c9d1d9;
--theme-color: #202124;
--theme-border-color: #3c4043;
--dragbar-color: #3c4043;
}
[data-theme='light'] {
@import 'highlight.js/scss/github.scss';
@import 'github-markdown-css/github-markdown-light.css';
--font-color: #24292f;
--theme-color: #eaecf0;
--theme-border-color: #aeafb2;
--dragbar-color: #ccced0;
}
.sidebar-free {
margin-left: 60px;
}
.chat-gpt-container {
width: 100%;
flex-basis: 0;
flex-grow: 1;
margin-bottom: 20px;
.gpt-inner {
border-radius: 8px;
border: 1px solid;
overflow: hidden;
border-color: var(--theme-border-color);
background-color: var(--theme-color);
margin: 0;
hr {
height: 1px;
background-color: var(--theme-border-color);
border: none;
}
}
.markdown-body {
padding: 5px 15px 10px;
background-color: var(--theme-color);
color: var(--font-color);
max-height: 800px;
overflow-y: auto;
ul,
ol {
padding-left: 1.5em;
}
ol {
list-style: none;
counter-reset: item;
li {
counter-increment: item;
&:before {
content: counter(item) '. ';
margin-left: -0.75em;
}
}
}
}
.icon-and-text {
color: var(--font-color);
display: flex;
align-items: center;
padding: 15px;
gap: 6px;
}
.manual-btn {
cursor: pointer;
}
.gpt-loading {
color: var(--font-color);
animation: gpt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.code-copy-btn {
color: inherit;
position: absolute;
right: 10px;
top: 3px;
cursor: pointer;
}
:is(.answer, .question, .error) {
font-size: 15px;
line-height: 1.6;
border-radius: 8px;
word-break: break-all;
pre {
margin-top: 10px;
}
& > p {
margin-bottom: 10px;
}
code {
white-space: pre-wrap;
word-break: break-word;
border-radius: 8px;
.hljs {
padding: 0;
}
}
p {
margin: 0;
}
}
.gpt-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
color: var(--font-color);
p {
font-weight: bold;
}
.gpt-feedback {
display: flex;
gap: 6px;
cursor: pointer;
}
.gpt-feedback-selected {
color: #f08080;
}
.gpt-util-icon {
cursor: pointer;
}
}
.error {
color: #ec4336;
}
.interact-input {
box-sizing: border-box;
padding: 5px 15px;
border: 0;
border-top: 1px solid var(--theme-border-color);
width: 100%;
background-color: var(--theme-color);
color: var(--font-color);
resize: none;
max-height: 240px;
}
.dragbar {
cursor: move;
width: 250px;
height: 12px;
border-radius: 10px;
background-color: var(--dragbar-color);
}
}
@keyframes gpt-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.gpt-selection-toolbar {
display: flex;
align-items: center;
border-radius: 15px;
padding: 2px;
background-color: #ffffff;
box-shadow: 4px 2px 4px rgba(0, 0, 0, 0.2);
}
.gpt-selection-toolbar-button {
margin-left: 2px;
padding: 2px;
border-radius: 30px;
background-color: #ffffff;
color: #24292f;
cursor: pointer;
}
.gpt-selection-toolbar-button:hover {
background-color: #d4d5da;
}
.gpt-selection-window {
width: 600px;
height: auto;
border-radius: 8px;
background-color: var(--theme-color);
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.2);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+49
View File
@@ -0,0 +1,49 @@
{
"name": "ChatGPT for Search Engine",
"description": "Display ChatGPT response alongside Search Engine results",
"version": "1.26.1",
"manifest_version": 3,
"icons": {
"16": "logo.png",
"32": "logo.png",
"48": "logo.png",
"128": "logo.png"
},
"host_permissions": [
"https://*.openai.com/"
],
"permissions": [
"storage",
"contextMenus"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
},
"content_scripts": [
{
"matches": [
"https://*/*"
],
"js": [
"shared.js",
"content-script.js"
],
"css": [
"content-script.css"
]
}
],
"web_accessible_resources": [
{
"resources": [
"*.png"
],
"matches": [
"<all_urls>"
]
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "ChatGPT for Search Engine",
"description": "Display ChatGPT response alongside Search Engine results",
"version": "1.26.1",
"manifest_version": 2,
"icons": {
"16": "logo.png",
"32": "logo.png",
"48": "logo.png",
"128": "logo.png"
},
"permissions": [
"storage",
"contextMenus",
"https://*.openai.com/"
],
"background": {
"scripts": [
"background.js"
]
},
"browser_action": {
"default_popup": "popup.html"
},
"content_scripts": [
{
"matches": [
"https://*/*"
],
"js": [
"shared.js",
"content-script.js"
],
"css": [
"content-script.css"
]
}
],
"web_accessible_resources": [
"*.png"
]
}
+439
View File
@@ -0,0 +1,439 @@
import '@picocss/pico'
import { useEffect, useState } from 'react'
import {
setUserConfig,
getUserConfig,
TriggerMode,
ThemeMode,
defaultConfig,
Models,
isUsingApiKey,
languageList,
} from '../config'
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'
import 'react-tabs/style/react-tabs.css'
import './styles.scss'
import { MarkGithubIcon } from '@primer/octicons-react'
import Browser from 'webextension-polyfill'
import PropTypes from 'prop-types'
import { config as toolsConfig } from '../content-script/selection-tools'
import wechatpay from './donation/wechatpay.jpg'
function GeneralPart({ config, updateConfig }) {
const [balance, setBalance] = useState(null)
const getBalance = async () => {
const response = await fetch('https://api.openai.com/dashboard/billing/credit_grants', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
})
if (response.ok) setBalance((await response.json()).total_available.toFixed(2))
}
return (
<>
<label>
<legend>Trigger Mode</legend>
<select
required
onChange={(e) => {
const mode = e.target.value
updateConfig({ triggerMode: mode })
}}
>
{Object.entries(TriggerMode).map(([key, desc]) => {
return (
<option value={key} key={key} selected={key === config.triggerMode}>
{desc}
</option>
)
})}
</select>
</label>
<label>
<legend>Theme Mode</legend>
<select
required
onChange={(e) => {
const mode = e.target.value
updateConfig({ themeMode: mode })
}}
>
{Object.entries(ThemeMode).map(([key, desc]) => {
return (
<option value={key} key={key} selected={key === config.themeMode}>
{desc}
</option>
)
})}
</select>
</label>
<label>
<legend>API Mode</legend>
<span style="display: flex; gap: 15px;">
<select
style={isUsingApiKey(config) ? 'width: 50%;' : undefined}
required
onChange={(e) => {
const modelName = e.target.value
updateConfig({ modelName: modelName })
}}
>
{Object.entries(Models).map(([key, model]) => {
return (
<option value={key} key={key} selected={key === config.modelName}>
{model.desc}
</option>
)
})}
</select>
{isUsingApiKey(config) && (
<span style="width: 50%; display: flex; gap: 5px;">
<input
type="password"
value={config.apiKey}
placeholder="API Key"
onChange={(e) => {
const apiKey = e.target.value
updateConfig({ apiKey: apiKey })
}}
/>
{config.apiKey.length === 0 ? (
<a
href="https://platform.openai.com/account/api-keys"
target="_blank"
rel="nofollow noopener noreferrer"
>
<button type="button">Get</button>
</a>
) : balance ? (
<button type="button" onClick={getBalance}>
{balance}
</button>
) : (
<button type="button" onClick={getBalance}>
Balance
</button>
)}
</span>
)}
</span>
</label>
<label>
<legend>Preferred Language</legend>
<span style="display: flex; gap: 15px;">
<select
required
onChange={(e) => {
const preferredLanguageKey = e.target.value
updateConfig({ preferredLanguage: preferredLanguageKey })
}}
>
{Object.entries(languageList).map(([k, v]) => {
return (
<option value={k} key={k} selected={k === config.preferredLanguage}>
{v.native}
</option>
)
})}
</select>
</span>
</label>
<label>
<input
type="checkbox"
checked={config.insertAtTop}
onChange={(e) => {
const checked = e.target.checked
updateConfig({ insertAtTop: checked })
}}
/>
Insert chatGPT at the top of search results
</label>
</>
)
}
GeneralPart.propTypes = {
config: PropTypes.object.isRequired,
updateConfig: PropTypes.func.isRequired,
}
function AdvancedPart({ config, updateConfig }) {
return (
<>
<label>
Custom ChatGPT Web API Url
<input
type="text"
value={config.customChatGptWebApiUrl}
onChange={(e) => {
const value = e.target.value
updateConfig({ customChatGptWebApiUrl: value })
}}
/>
</label>
<label>
Custom ChatGPT Web API Path
<input
type="text"
value={config.customChatGptWebApiPath}
onChange={(e) => {
const value = e.target.value
updateConfig({ customChatGptWebApiPath: value })
}}
/>
</label>
<label>
Custom OpenAI API Url
<input
type="text"
value={config.customOpenAiApiUrl}
onChange={(e) => {
const value = e.target.value
updateConfig({ customOpenAiApiUrl: value })
}}
/>
</label>
<label>
Custom Site Regex:
<input
type="text"
value={config.siteRegex}
onChange={(e) => {
const regex = e.target.value
updateConfig({ siteRegex: regex })
}}
/>
</label>
<label>
<input
type="checkbox"
checked={config.userSiteRegexOnly}
onChange={(e) => {
const checked = e.target.checked
updateConfig({ userSiteRegexOnly: checked })
}}
/>
Only use Custom Site Regex for website matching, ignore built-in rules
</label>
<br />
<label>
Input Query:
<input
type="text"
value={config.inputQuery}
onChange={(e) => {
const query = e.target.value
updateConfig({ inputQuery: query })
}}
/>
</label>
<label>
Append Query:
<input
type="text"
value={config.appendQuery}
onChange={(e) => {
const query = e.target.value
updateConfig({ appendQuery: query })
}}
/>
</label>
<label>
Prepend Query:
<input
type="text"
value={config.prependQuery}
onChange={(e) => {
const query = e.target.value
updateConfig({ prependQuery: query })
}}
/>
</label>
</>
)
}
AdvancedPart.propTypes = {
config: PropTypes.object.isRequired,
updateConfig: PropTypes.func.isRequired,
}
function SelectionTools({ config, updateConfig }) {
return (
<>
{config.selectionTools.map((key) => (
<label key={key}>
<input
type="checkbox"
checked={config.activeSelectionTools.includes(key)}
onChange={(e) => {
const checked = e.target.checked
const activeSelectionTools = config.activeSelectionTools.filter((i) => i !== key)
if (checked) activeSelectionTools.push(key)
updateConfig({ activeSelectionTools })
}}
/>
{toolsConfig[key].label}
</label>
))}
</>
)
}
SelectionTools.propTypes = {
config: PropTypes.object.isRequired,
updateConfig: PropTypes.func.isRequired,
}
function SiteAdapters({ config, updateConfig }) {
return (
<>
{config.siteAdapters.map((key) => (
<label key={key}>
<input
type="checkbox"
checked={config.activeSiteAdapters.includes(key)}
onChange={(e) => {
const checked = e.target.checked
const activeSiteAdapters = config.activeSiteAdapters.filter((i) => i !== key)
if (checked) activeSiteAdapters.push(key)
updateConfig({ activeSiteAdapters })
}}
/>
{key}
</label>
))}
</>
)
}
SiteAdapters.propTypes = {
config: PropTypes.object.isRequired,
updateConfig: PropTypes.func.isRequired,
}
function Donation() {
return (
<div style="display:flex;flex-direction:column;align-items:center;">
<a
href="https://www.buymeacoffee.com/josStorer"
target="_blank"
rel="nofollow noopener noreferrer"
>
<img
align="center"
alt="buymeacoffee"
src="https://www.buymeacoffee.com/assets/img/guidelines/download-assets-sm-1.svg"
/>
</a>
<hr />
<>
Wechat Pay
<img alt="wechatpay" src={wechatpay} />
</>
</div>
)
}
// eslint-disable-next-line react/prop-types
function Footer({ currentVersion, latestVersion }) {
return (
<div className="footer">
<div>
Current Version: {currentVersion}{' '}
{currentVersion === latestVersion ? (
'(Latest)'
) : (
<>
(Latest:{' '}
<a
href={'https://github.com/josStorer/chatGPTBox/releases/tag/v' + latestVersion}
target="_blank"
rel="nofollow noopener noreferrer"
>
{latestVersion}
</a>
)
</>
)}
</div>
<div>
<a
href="https://github.com/josStorer/chatGPTBox"
target="_blank"
rel="nofollow noopener noreferrer"
>
<span>Help | Changelog </span>
<MarkGithubIcon />
</a>
</div>
</div>
)
}
function Popup() {
const [config, setConfig] = useState(defaultConfig)
const [currentVersion, setCurrentVersion] = useState('')
const [latestVersion, setLatestVersion] = useState('')
const updateConfig = (value) => {
setConfig({ ...config, ...value })
setUserConfig(value)
}
useEffect(() => {
getUserConfig().then((config) => {
setConfig(config)
setCurrentVersion(Browser.runtime.getManifest().version.replace('v', ''))
fetch('https://api.github.com/repos/josstorer/chatGPTBox/releases/latest').then((response) =>
response.json().then((data) => {
setLatestVersion(data.tag_name.replace('v', ''))
}),
)
})
}, [])
useEffect(() => {
document.documentElement.dataset.theme = config.themeMode
}, [config.themeMode])
return (
<div className="container">
<form>
<Tabs selectedTabClassName="popup-tab--selected">
<TabList>
<Tab className="popup-tab">General</Tab>
<Tab className="popup-tab">SelectionTools</Tab>
<Tab className="popup-tab">SiteAdapters</Tab>
<Tab className="popup-tab">Advanced</Tab>
<Tab className="popup-tab">Donation</Tab>
</TabList>
<TabPanel>
<GeneralPart config={config} updateConfig={updateConfig} />
</TabPanel>
<TabPanel>
<SelectionTools config={config} updateConfig={updateConfig} />
</TabPanel>
<TabPanel>
<SiteAdapters config={config} updateConfig={updateConfig} />
</TabPanel>
<TabPanel>
<AdvancedPart config={config} updateConfig={updateConfig} />
</TabPanel>
<TabPanel>
<Donation />
</TabPanel>
</Tabs>
</form>
<hr />
<Footer currentVersion={currentVersion} latestVersion={latestVersion} />
</div>
)
}
export default Popup
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+12
View File
@@ -0,0 +1,12 @@
<html>
<head>
<title>ChatGPT for Search Engine</title>
<link rel="stylesheet" href="popup.css" />
<meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<script src="shared.js"></script>
<script src="popup.js"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
import { render } from 'preact'
import Popup from './Popup'
render(<Popup />, document.getElementById('app'))
+81
View File
@@ -0,0 +1,81 @@
[data-theme='auto'] {
@import 'github-markdown-css/github-markdown.css';
@media screen and (prefers-color-scheme: dark) {
@import 'highlight.js/scss/github-dark.scss';
--font-color: #c9d1d9;
--theme-color: #202124;
--active-color: #3c4043;
}
@media screen and (prefers-color-scheme: light) {
@import 'highlight.js/scss/github.scss';
--font-color: #24292f;
--theme-color: #ffffff;
--active-color: #eaecf0;
}
}
[data-theme='dark'] {
@import 'highlight.js/scss/github-dark.scss';
@import 'github-markdown-css/github-markdown-dark.css';
--font-color: #c9d1d9;
--theme-color: #202124;
--active-color: #3c4043;
}
[data-theme='light'] {
@import 'highlight.js/scss/github.scss';
@import 'github-markdown-css/github-markdown-light.css';
--font-color: #24292f;
--theme-color: #ffffff;
--active-color: #eaecf0;
}
.container {
width: 440px;
height: 560px;
padding: 20px;
overflow-y: auto;
}
.container legend {
font-weight: bold;
}
.container form {
margin-bottom: 0;
}
.container fieldset {
margin-bottom: 0;
}
.footer {
width: 90%;
position: absolute;
bottom: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: var(--active-color);
border-radius: 5px;
padding: 6px;
}
.popup-tab {
display: inline-block;
position: relative;
list-style: none;
padding: 6px 12px 0;
cursor: pointer;
border-radius: 5px;
margin-right: 2px;
background-color: var(--theme-color);
color: var(--font-color);
&--selected {
background: var(--active-color);
}
}
+9
View File
@@ -0,0 +1,9 @@
export function createElementAtPosition(x = 0, y = 0, zIndex = 2147483647) {
const element = document.createElement('div')
element.style.position = 'fixed'
element.style.zIndex = zIndex
element.style.left = x + 'px'
element.style.top = y + 'px'
document.documentElement.appendChild(element)
return element
}
+83
View File
@@ -0,0 +1,83 @@
// MIT License
//
// Copyright (c) 2023 josStorer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { maxResponseTokenLength } from '../config.mjs'
import { encode } from '@nem035/gpt-3-encoder'
// TODO add model support
export function cropText(
text,
maxLength = 3900 - maxResponseTokenLength,
startLength = 400,
endLength = 300,
tiktoken = true,
) {
const splits = text.split(/[,,。?!;]/).map((s) => s.trim())
const splitsLength = splits.map((s) => (tiktoken ? encode(s).length : s.length))
const length = splitsLength.reduce((sum, length) => sum + length, 0)
const cropLength = length - startLength - endLength
const cropTargetLength = maxLength - startLength - endLength
const cropPercentage = cropTargetLength / cropLength
const cropStep = Math.max(0, 1 / cropPercentage - 1)
if (cropStep === 0) return text
let croppedText = ''
let currentLength = 0
let currentIndex = 0
let currentStep = 0
for (; currentIndex < splits.length; currentIndex++) {
if (currentLength + splitsLength[currentIndex] + 1 <= startLength) {
croppedText += splits[currentIndex] + ','
currentLength += splitsLength[currentIndex] + 1
} else if (currentLength + splitsLength[currentIndex] + 1 + endLength <= maxLength) {
if (currentStep < cropStep) {
currentStep++
} else {
croppedText += splits[currentIndex] + ','
currentLength += splitsLength[currentIndex] + 1
currentStep = currentStep - cropStep
}
} else {
break
}
}
let endPart = ''
let endPartLength = 0
for (let i = splits.length - 1; endPartLength + splitsLength[i] <= endLength; i--) {
endPart = splits[i] + ',' + endPart
endPartLength += splitsLength[i] + 1
}
currentLength += endPartLength
croppedText += endPart
console.log(
`maxLength: ${maxLength}\n` +
// `croppedTextLength: ${tiktoken ? encode(croppedText).length : croppedText.length}\n` +
`desiredLength: ${currentLength}\n` +
`content: ${croppedText}`,
)
return croppedText
}
+8
View File
@@ -0,0 +1,8 @@
export function endsWithQuestionMark(question) {
return (
question.endsWith('?') || // ASCII
question.endsWith('') || // Chinese/Japanese
question.endsWith('؟') || // Arabic
question.endsWith('⸮') // Arabic
)
}
+26
View File
@@ -0,0 +1,26 @@
import { createParser } from 'eventsource-parser'
import { streamAsyncIterable } from './stream-async-iterable'
export async function fetchSSE(resource, options) {
const { onMessage, onStart, onEnd, onError, ...fetchOptions } = options
const resp = await fetch(resource, fetchOptions)
if (!resp.ok) {
await onError(resp)
}
const parser = createParser((event) => {
if (event.type === 'event') {
onMessage(event.data)
}
})
let hasStarted = false
for await (const chunk of streamAsyncIterable(resp.body)) {
const str = new TextDecoder().decode(chunk)
parser.feed(str)
if (!hasStarted) {
hasStarted = true
await onStart(str)
}
}
await onEnd()
}
+17
View File
@@ -0,0 +1,17 @@
export function getConversationPairs(records, isChatgpt) {
let pairs
if (isChatgpt) {
pairs = []
for (const record of records) {
pairs.push({ role: 'user', content: record['question'] })
pairs.push({ role: 'assistant', content: record['answer'] })
}
} else {
pairs = ''
for (const record of records) {
pairs += 'Human:' + record.question + '\nAI:' + record.answer + '\n'
}
}
return pairs
}
@@ -0,0 +1,14 @@
export function getPossibleElementByQuerySelector(queryArray) {
for (const query of queryArray) {
if (query) {
try {
const element = document.querySelector(query)
if (element) {
return element
}
} catch (e) {
/* empty */
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
export * from './create-element-at-position'
export * from './crop-text'
export * from './ends-with-question-mark'
export * from './fetch-sse'
export * from './get-conversation-pairs'
export * from './get-possible-element-by-query-selector'
export * from './init-session'
export * from './is-mobile'
export * from './is-safari'
export * from './limited-fetch'
export * from './set-element-position-in-viewport'
export * from './stream-async-iterable'
export * from './update-ref-height'
+30
View File
@@ -0,0 +1,30 @@
/**
* @typedef {object} Session
* @property {string|null} question
* @property {string|null} conversationId - chatGPT web mode
* @property {string|null} messageId - chatGPT web mode
* @property {string|null} parentMessageId - chatGPT web mode
* @property {Object[]|null} conversationRecords
* @property {bool|null} useApiKey
*/
/**
* @param {Session} session
* @returns {Session}
*/
export function initSession({
question = null,
conversationId = null,
messageId = null,
parentMessageId = null,
conversationRecords = [],
useApiKey = null,
} = {}) {
return {
question,
conversationId,
messageId,
parentMessageId,
conversationRecords,
useApiKey,
}
}
+17
View File
@@ -0,0 +1,17 @@
// https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser
export async function isMobile() {
let check = false
;(function (a) {
if (
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
a,
) ||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
a.substr(0, 4),
)
)
check = true
})(navigator.userAgent || navigator.vendor || window.opera)
return check
}
+3
View File
@@ -0,0 +1,3 @@
export function isSafari() {
return navigator.vendor === 'Apple Computer, Inc.'
}
+25
View File
@@ -0,0 +1,25 @@
// https://stackoverflow.com/questions/64304365/stop-request-after-x-amount-is-fetched
export async function limitedFetch(url, maxBytes) {
return new Promise((resolve, reject) => {
try {
const xhr = new XMLHttpRequest()
xhr.onprogress = (ev) => {
if (ev.loaded < maxBytes) return
resolve(ev.target.responseText.substring(0, maxBytes))
xhr.abort()
}
xhr.onload = (ev) => {
resolve(ev.target.responseText.substring(0, maxBytes))
}
xhr.onerror = (ev) => {
reject(new Error(ev.target.status))
}
xhr.open('GET', url)
xhr.send()
} catch (err) {
reject(err)
}
})
}
@@ -0,0 +1,7 @@
export function setElementPositionInViewport(element, x = 0, y = 0) {
const retX = Math.min(window.innerWidth - element.offsetWidth, Math.max(0, x))
const retY = Math.min(window.innerHeight - element.offsetHeight, Math.max(0, y))
element.style.left = retX + 'px'
element.style.top = retY + 'px'
return { x: retX, y: retY }
}
+14
View File
@@ -0,0 +1,14 @@
export async function* streamAsyncIterable(stream) {
const reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return
}
yield value
}
} finally {
reader.releaseLock()
}
}
+11
View File
@@ -0,0 +1,11 @@
export function updateRefHeight(ref) {
ref.current.style.height = 'auto'
const computed = window.getComputedStyle(ref.current)
const height =
parseInt(computed.getPropertyValue('border-top-width'), 10) +
parseInt(computed.getPropertyValue('padding-top'), 10) +
ref.current.scrollHeight +
parseInt(computed.getPropertyValue('padding-bottom'), 10) +
parseInt(computed.getPropertyValue('border-bottom-width'), 10)
ref.current.style.height = `${height}px`
}