mirror of
https://github.com/wassname/chatGPTBox.git
synced 2026-06-27 17:00:17 +08:00
first commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended"],
|
||||
"overrides": [],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"ignorePatterns": ["build/**", "build.mjs", "src/utils/is-mobile.mjs"],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
@@ -0,0 +1,24 @@
|
||||
name: pr-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- "opened"
|
||||
- "reopened"
|
||||
- "synchronize"
|
||||
paths:
|
||||
- "src/**"
|
||||
- "build.mjs"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
@@ -0,0 +1,61 @@
|
||||
name: pre-release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# paths:
|
||||
# - "src/**"
|
||||
# - "!src/**/*.json"
|
||||
# - "build.mjs"
|
||||
# tags-ignore:
|
||||
# - "v*"
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
- uses: josStorer/get-current-time@v2
|
||||
id: current-time
|
||||
with:
|
||||
format: YYYY_MMDD_HHmm
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Chromium_Build_${{ steps.current-time.outputs.formattedTime }}
|
||||
path: build/chromium/*
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Firefox_Build_${{ steps.current-time.outputs.formattedTime }}
|
||||
path: build/firefox/*
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Chromium_Build_WithoutKatex_${{ steps.current-time.outputs.formattedTime }}
|
||||
path: build/chromium-without-katex/*
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Firefox_Build_WithoutKatex_${{ steps.current-time.outputs.formattedTime }}
|
||||
path: build/firefox-without-katex/*
|
||||
|
||||
- uses: marvinpinto/action-automatic-releases@v1.2.1
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
automatic_release_tag: "latest"
|
||||
prerelease: true
|
||||
title: "Development Build"
|
||||
files: |
|
||||
build/chromium.zip
|
||||
build/firefox.zip
|
||||
build/chromium-without-katex.zip
|
||||
build/firefox-without-katex.zip
|
||||
@@ -0,0 +1,187 @@
|
||||
import { JSDOM } from 'jsdom'
|
||||
import fetch, { Headers } from 'node-fetch'
|
||||
|
||||
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'],
|
||||
},
|
||||
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'],
|
||||
},
|
||||
}
|
||||
|
||||
const urls = {
|
||||
google: ['https://www.google.com/search?q=hello'],
|
||||
bing: ['https://www.bing.com/search?q=hello', 'https://cn.bing.com/search?q=hello'],
|
||||
yahoo: ['https://search.yahoo.com/search?p=hello', 'https://search.yahoo.co.jp/search?p=hello'],
|
||||
duckduckgo: ['https://duckduckgo.com/s?q=hello'],
|
||||
startpage: [], // need redirect and post https://www.startpage.com/do/search?query=hello
|
||||
baidu: ['https://www.baidu.com/s?wd=hello'],
|
||||
kagi: [], // need login https://kagi.com/search?q=hello
|
||||
yandex: [], // need cookie https://yandex.com/search/?text=hello
|
||||
naver: ['https://search.naver.com/search.naver?query=hello'],
|
||||
brave: ['https://search.brave.com/search?q=hello'],
|
||||
searx: ['https://searx.tiekoetter.com/search?q=hello'],
|
||||
ecosia: [], // unknown verify method https://www.ecosia.org/search?q=hello
|
||||
neeva: [], // unknown verify method(FetchError: maximum redirect reached) https://neeva.com/search?q=hello
|
||||
}
|
||||
|
||||
const commonHeaders = {
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
||||
Connection: 'keep-alive',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', // for baidu
|
||||
}
|
||||
|
||||
const desktopHeaders = new Headers({
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/108.0.1462.76',
|
||||
...commonHeaders,
|
||||
})
|
||||
|
||||
const mobileHeaders = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Mobile Safari/537.36 Edg/108.0.1462.76',
|
||||
...commonHeaders,
|
||||
}
|
||||
|
||||
const desktopQueryNames = [
|
||||
'inputQuery',
|
||||
'sidebarContainerQuery',
|
||||
'appendContainerQuery',
|
||||
'resultsContainerQuery',
|
||||
]
|
||||
|
||||
const mobileQueryNames = ['inputQuery', 'resultsContainerQuery']
|
||||
|
||||
let errors = ''
|
||||
|
||||
async function verify(errorTag, urls, headers, queryNames) {
|
||||
await Promise.all(
|
||||
Object.entries(urls).map(([siteName, urlArray]) =>
|
||||
Promise.all(
|
||||
urlArray.map((url) =>
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
})
|
||||
.then((response) => response.text())
|
||||
.then((text) => {
|
||||
const dom = new JSDOM(text)
|
||||
for (const queryName of queryNames) {
|
||||
const queryArray = config[siteName][queryName]
|
||||
if (queryArray.length === 0) continue
|
||||
|
||||
let foundQuery
|
||||
for (const query of queryArray) {
|
||||
const element = dom.window.document.querySelector(query)
|
||||
if (element) {
|
||||
foundQuery = query
|
||||
break
|
||||
}
|
||||
}
|
||||
if (foundQuery) {
|
||||
console.log(`${siteName} ${url} ${queryName}: ${foundQuery} passed`)
|
||||
} else {
|
||||
const error = `${siteName} ${url} ${queryName} failed`
|
||||
errors += errorTag + error + '\n'
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
errors += errorTag + error + '\n'
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Verify desktop search engine configs:')
|
||||
await verify('desktop: ', urls, desktopHeaders, desktopQueryNames)
|
||||
console.log('\nVerify mobile search engine configs:')
|
||||
await verify('mobile: ', urls, mobileHeaders, mobileQueryNames)
|
||||
|
||||
if (errors.length > 0) throw new Error('\n' + errors)
|
||||
else console.log('\nAll passed')
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
name: tagged-release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
runs-on: macos-12
|
||||
|
||||
steps:
|
||||
- run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update manifest.json version
|
||||
uses: jossef/action-set-json-field@v2.1
|
||||
with:
|
||||
file: src/manifest.json
|
||||
field: version
|
||||
value: ${{ env.VERSION }}
|
||||
|
||||
- name: Update manifest.v2.json version
|
||||
uses: jossef/action-set-json-field@v2.1
|
||||
with:
|
||||
file: src/manifest.v2.json
|
||||
field: version
|
||||
value: ${{ env.VERSION }}
|
||||
|
||||
- name: Push files
|
||||
run: |
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git commit -am "release v${{ env.VERSION }}"
|
||||
git push
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 14.2
|
||||
- run: sed -i '' "s/0.0.0/${{ env.VERSION }}/g" safari/project.patch
|
||||
- run: npm run build:safari
|
||||
|
||||
- uses: marvinpinto/action-automatic-releases@v1.2.1
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
prerelease: false
|
||||
files: |
|
||||
build/chromium.zip
|
||||
build/firefox.zip
|
||||
build/safari.dmg
|
||||
build/chromium-without-katex.zip
|
||||
build/firefox-without-katex.zip
|
||||
@@ -0,0 +1,17 @@
|
||||
name: verify-configs
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
jobs:
|
||||
verify_configs:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
@@ -0,0 +1,6 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
node_modules/
|
||||
build/
|
||||
.DS_Store
|
||||
*.zip
|
||||
@@ -0,0 +1,3 @@
|
||||
build/
|
||||
src/manifest.json
|
||||
src/manifest.v2.json
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ".prettierrc",
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 josStorer
|
||||
Copyright (c) 2022 wong2
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,255 @@
|
||||
import archiver from 'archiver'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import webpack from 'webpack'
|
||||
import ProgressBarPlugin from 'progress-bar-webpack-plugin'
|
||||
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
||||
import TerserPlugin from 'terser-webpack-plugin'
|
||||
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
|
||||
|
||||
const outdir = 'build'
|
||||
|
||||
const __dirname = path.resolve()
|
||||
const isProduction = process.argv[2] !== '--development' // --production and --analyze are both production
|
||||
const isAnalyzing = process.argv[2] === '--analyze'
|
||||
|
||||
async function deleteOldDir() {
|
||||
await fs.rm(outdir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function runWebpack(isWithoutKatex, callback) {
|
||||
const compiler = webpack({
|
||||
entry: {
|
||||
'content-script': {
|
||||
import: './src/content-script/index.jsx',
|
||||
dependOn: 'shared',
|
||||
},
|
||||
background: {
|
||||
import: './src/background/index.mjs',
|
||||
},
|
||||
popup: {
|
||||
import: './src/popup/index.jsx',
|
||||
dependOn: 'shared',
|
||||
},
|
||||
shared: [
|
||||
'preact',
|
||||
'webextension-polyfill',
|
||||
'@primer/octicons-react',
|
||||
'react-bootstrap-icons',
|
||||
'./src/utils',
|
||||
],
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, outdir),
|
||||
},
|
||||
mode: isProduction ? 'production' : 'development',
|
||||
devtool: isProduction ? false : 'inline-source-map',
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
output: { ascii_only: true },
|
||||
},
|
||||
}),
|
||||
new CssMinimizerPlugin(),
|
||||
],
|
||||
concatenateModules: !isAnalyzing,
|
||||
},
|
||||
plugins: [
|
||||
new ProgressBarPlugin({
|
||||
format: ' build [:bar] :percent (:elapsed seconds)',
|
||||
clear: false,
|
||||
}),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].css',
|
||||
}),
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: isAnalyzing ? 'static' : 'disable',
|
||||
}),
|
||||
...(isWithoutKatex
|
||||
? [
|
||||
new webpack.NormalModuleReplacementPlugin(/markdown\.jsx/, (result) => {
|
||||
if (result.request) {
|
||||
result.request = result.request.replace(
|
||||
'markdown.jsx',
|
||||
'markdown-without-katex.jsx',
|
||||
)
|
||||
}
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.jsx', '.mjs', '.js'],
|
||||
alias: {
|
||||
parse5: path.resolve(__dirname, 'node_modules/parse5'),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.m?jsx?$/,
|
||||
exclude: /(node_modules)/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
{
|
||||
plugins: ['@babel/plugin-transform-runtime'],
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
'@babel/plugin-transform-react-jsx',
|
||||
{
|
||||
runtime: 'automatic',
|
||||
importSource: 'preact',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.s[ac]ss$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'sass-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.less$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'less-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: 'css-loader',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(woff|ttf)$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
emit: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.woff2$/,
|
||||
type: 'asset/inline',
|
||||
},
|
||||
{
|
||||
test: /\.jpg$/,
|
||||
type: 'asset/inline',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
if (isProduction) compiler.run(callback)
|
||||
else compiler.watch({}, callback)
|
||||
}
|
||||
|
||||
async function zipFolder(dir) {
|
||||
const output = fs.createWriteStream(`${dir}.zip`)
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
})
|
||||
archive.pipe(output)
|
||||
archive.directory(dir, false)
|
||||
await archive.finalize()
|
||||
}
|
||||
|
||||
async function copyFiles(entryPoints, targetDir) {
|
||||
if (!fs.existsSync(targetDir)) await fs.mkdir(targetDir)
|
||||
await Promise.all(
|
||||
entryPoints.map(async (entryPoint) => {
|
||||
await fs.copy(entryPoint.src, `${targetDir}/${entryPoint.dst}`)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function finishOutput(outputDirSuffix) {
|
||||
const commonFiles = [
|
||||
{ src: 'build/shared.js', dst: 'shared.js' },
|
||||
{ src: 'build/content-script.js', dst: 'content-script.js' },
|
||||
{ src: 'build/content-script.css', dst: 'content-script.css' },
|
||||
{ src: 'build/background.js', dst: 'background.js' },
|
||||
{ src: 'build/popup.js', dst: 'popup.js' },
|
||||
{ src: 'build/popup.css', dst: 'popup.css' },
|
||||
{ src: 'src/popup/index.html', dst: 'popup.html' },
|
||||
{ src: 'src/logo.png', dst: 'logo.png' },
|
||||
]
|
||||
|
||||
// chromium
|
||||
const chromiumOutputDir = `./${outdir}/chromium${outputDirSuffix}`
|
||||
await copyFiles(
|
||||
[...commonFiles, { src: 'src/manifest.json', dst: 'manifest.json' }],
|
||||
chromiumOutputDir,
|
||||
)
|
||||
if (isProduction) await zipFolder(chromiumOutputDir)
|
||||
|
||||
// firefox
|
||||
const firefoxOutputDir = `./${outdir}/firefox${outputDirSuffix}`
|
||||
await copyFiles(
|
||||
[...commonFiles, { src: 'src/manifest.v2.json', dst: 'manifest.json' }],
|
||||
firefoxOutputDir,
|
||||
)
|
||||
if (isProduction) await zipFolder(firefoxOutputDir)
|
||||
}
|
||||
|
||||
function generateWebpackCallback(finishOutputFunc) {
|
||||
return async function webpackCallback(err, stats) {
|
||||
if (err || stats.hasErrors()) {
|
||||
console.error(err || stats.toString())
|
||||
return
|
||||
}
|
||||
// console.log(stats.toString())
|
||||
|
||||
await finishOutputFunc()
|
||||
}
|
||||
}
|
||||
|
||||
async function build() {
|
||||
await deleteOldDir()
|
||||
if (isProduction && !isAnalyzing)
|
||||
await runWebpack(
|
||||
true,
|
||||
generateWebpackCallback(() => finishOutput('-without-katex')),
|
||||
)
|
||||
await runWebpack(
|
||||
false,
|
||||
generateWebpackCallback(() => finishOutput('')),
|
||||
)
|
||||
}
|
||||
|
||||
build()
|
||||
Generated
+10300
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "chat-gpt-search-engine-extension",
|
||||
"scripts": {
|
||||
"build": "node build.mjs --production",
|
||||
"build:safari": "bash ./safari/build.sh",
|
||||
"dev": "node build.mjs --development",
|
||||
"analyze": "node build.mjs --analyze",
|
||||
"lint": "eslint --ext .js,.mjs,.jsx .",
|
||||
"lint:fix": "eslint --ext .js,.mjs,.jsx . --fix",
|
||||
"pretty": "prettier --write ./**/*.{js,mjs,jsx,json,css,scss}",
|
||||
"stage": "run-script-os",
|
||||
"stage:default": "git add $(git diff --name-only --cached --diff-filter=d)",
|
||||
"stage:win32": "powershell git add $(git diff --name-only --cached --diff-filter=d)",
|
||||
"verify": "node .github/workflows/scripts/verify-search-engine-configs.mjs"
|
||||
},
|
||||
"pre-commit": [
|
||||
"pretty",
|
||||
"stage",
|
||||
"lint"
|
||||
],
|
||||
"dependencies": {
|
||||
"@nem035/gpt-3-encoder": "^1.1.7",
|
||||
"@picocss/pico": "^1.5.6",
|
||||
"@primer/octicons-react": "^17.11.1",
|
||||
"countries-list": "^2.6.1",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"expiry-map": "^2.0.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"gpt-3-encoder": "^1.1.4",
|
||||
"katex": "^0.16.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parse5": "^6.0.1",
|
||||
"preact": "^10.11.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "npm:@preact/compat@^17.1.2",
|
||||
"react-bootstrap-icons": "^1.10.2",
|
||||
"react-dom": "npm:@preact/compat@^17.1.2",
|
||||
"react-draggable": "^4.4.5",
|
||||
"react-markdown": "^8.0.5",
|
||||
"react-tabs": "^4.2.1",
|
||||
"rehype-highlight": "^6.0.0",
|
||||
"rehype-katex": "^6.0.2",
|
||||
"rehype-raw": "^6.1.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1",
|
||||
"uuid": "^9.0.0",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-react-jsx": "^7.20.13",
|
||||
"@babel/plugin-transform-runtime": "^7.19.6",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/jsdom": "^21.1.0",
|
||||
"@types/webextension-polyfill": "^0.10.0",
|
||||
"archiver": "^5.3.1",
|
||||
"babel-loader": "^9.1.2",
|
||||
"css-loader": "^6.7.3",
|
||||
"css-minimizer-webpack-plugin": "^4.2.2",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"fs-extra": "^11.1.0",
|
||||
"jsdom": "^21.1.0",
|
||||
"less-loader": "^11.1.0",
|
||||
"mini-css-extract-plugin": "^2.7.2",
|
||||
"node-fetch": "^3.3.0",
|
||||
"pre-commit": "^1.2.2",
|
||||
"prettier": "^2.8.4",
|
||||
"progress-bar-webpack-plugin": "^2.1.0",
|
||||
"run-script-os": "^1.1.6",
|
||||
"sass": "^1.58.1",
|
||||
"sass-loader": "^13.2.0",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "chatGPT for Search Engine",
|
||||
"icon": "../src/logo.png",
|
||||
"contents": [
|
||||
{ "x": 448, "y": 344, "type": "link", "path": "/Applications" },
|
||||
{ "x": 192, "y": 344, "type": "file", "path": "../build/chatGPT-for-Search-Engine.app" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
xcrun safari-web-extension-converter ./build/firefox \
|
||||
--project-location ./build/safari --app-name chatGPT-for-Search-Engine \
|
||||
--bundle-identifier dev.josStorer.chatGPT-for-Search-Engine --force --no-prompt --no-open
|
||||
git apply safari/project.patch
|
||||
xcodebuild archive -project ./build/safari/chatGPT-for-Search-Engine/chatGPT-for-Search-Engine.xcodeproj \
|
||||
-scheme "chatGPT-for-Search-Engine (macOS)" -configuration Release -archivePath ./build/safari/chatGPT-for-Search-Engine.xcarchive
|
||||
xcodebuild -exportArchive -archivePath ./build/safari/chatGPT-for-Search-Engine.xcarchive \
|
||||
-exportOptionsPlist ./safari/export-options.plist -exportPath ./build
|
||||
npm install -D appdmg
|
||||
rm ./build/safari.dmg
|
||||
appdmg ./safari/appdmg.json ./build/safari.dmg
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>mac-application</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,39 @@
|
||||
--- a/build/safari/chatGPT-for-Search-Engine/chatGPT-for-Search-Engine.xcodeproj/project.pbxproj
|
||||
+++ b/build/safari/chatGPT-for-Search-Engine/chatGPT-for-Search-Engine.xcodeproj/project.pbxproj
|
||||
@@ -825,7 +825,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
- MARKETING_VERSION = 1.0;
|
||||
+ MARKETING_VERSION = 0.0.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -878,6 +878,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
+ ARCHS = (
|
||||
+ arm64,
|
||||
+ x86_64,
|
||||
+ );
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "macOS (App)/chatGPT-for-Search-Engine.entitlements";
|
||||
@@ -887,6 +891,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "macOS (App)/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "chatGPT-for-Search-Engine";
|
||||
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSMainStoryboardFile = Main;
|
||||
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -894,7 +899,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
- MARKETING_VERSION = 1.0;
|
||||
+ MARKETING_VERSION = 0.0.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Browser from 'webextension-polyfill'
|
||||
import InputBox from '../InputBox'
|
||||
import ConversationItem from '../ConversationItem'
|
||||
import { createElementAtPosition, initSession, isSafari } from '../../utils'
|
||||
import { DownloadIcon } from '@primer/octicons-react'
|
||||
import { WindowDesktop, XLg } from 'react-bootstrap-icons'
|
||||
import FileSaver from 'file-saver'
|
||||
import { render } from 'preact'
|
||||
import FloatingToolbar from '../FloatingToolbar'
|
||||
|
||||
const logo = Browser.runtime.getURL('logo.png')
|
||||
|
||||
class ConversationItemData extends Object {
|
||||
/**
|
||||
* @param {'question'|'answer'|'error'} type
|
||||
* @param {string} content
|
||||
* @param {object} session
|
||||
* @param {bool} done
|
||||
*/
|
||||
constructor(type, content, session = null, done = false) {
|
||||
super()
|
||||
this.type = type
|
||||
this.content = content
|
||||
this.session = session
|
||||
this.done = done
|
||||
}
|
||||
}
|
||||
|
||||
function ConversationCard(props) {
|
||||
const [isReady, setIsReady] = useState(!props.question)
|
||||
const [port, setPort] = useState(() => Browser.runtime.connect())
|
||||
const [session, setSession] = useState(props.session)
|
||||
/**
|
||||
* @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]}
|
||||
*/
|
||||
const [conversationItemData, setConversationItemData] = useState(
|
||||
(() => {
|
||||
if (props.session.conversationRecords.length === 0)
|
||||
if (props.question)
|
||||
return [
|
||||
new ConversationItemData(
|
||||
'answer',
|
||||
'<p class="gpt-loading">Waiting for response...</p>',
|
||||
),
|
||||
]
|
||||
else return []
|
||||
else {
|
||||
const ret = []
|
||||
for (const record of props.session.conversationRecords) {
|
||||
ret.push(
|
||||
new ConversationItemData('question', record.question + '\n<hr/>', props.session, true),
|
||||
)
|
||||
ret.push(
|
||||
new ConversationItemData('answer', record.answer + '\n<hr/>', props.session, true),
|
||||
)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onUpdate) props.onUpdate()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// when the page is responsive, session may accumulate redundant data and needs to be cleared after remounting and before making a new request
|
||||
if (props.question) {
|
||||
const newSession = initSession({ question: props.question })
|
||||
setSession(newSession)
|
||||
port.postMessage({ session: newSession })
|
||||
}
|
||||
}, [props.question]) // usually only triggered once
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {boolean} appended
|
||||
* @param {'question'|'answer'|'error'} newType
|
||||
* @param {boolean} done
|
||||
*/
|
||||
const UpdateAnswer = (value, appended, newType, done = false) => {
|
||||
setConversationItemData((old) => {
|
||||
const copy = [...old]
|
||||
const index = copy.findLastIndex((v) => v.type === 'answer')
|
||||
if (index === -1) return copy
|
||||
copy[index] = new ConversationItemData(
|
||||
newType,
|
||||
appended ? copy[index].content + value : value,
|
||||
)
|
||||
copy[index].session = { ...session }
|
||||
copy[index].done = done
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setPort(Browser.runtime.connect())
|
||||
}
|
||||
port.onDisconnect.addListener(listener)
|
||||
return () => {
|
||||
port.onDisconnect.removeListener(listener)
|
||||
}
|
||||
}, [port])
|
||||
useEffect(() => {
|
||||
const listener = (msg) => {
|
||||
if (msg.answer) {
|
||||
UpdateAnswer(msg.answer, false, 'answer')
|
||||
}
|
||||
if (msg.session) {
|
||||
setSession(msg.session)
|
||||
}
|
||||
if (msg.done) {
|
||||
UpdateAnswer('\n<hr/>', true, 'answer', true)
|
||||
setIsReady(true)
|
||||
}
|
||||
if (msg.error) {
|
||||
switch (msg.error) {
|
||||
case 'UNAUTHORIZED':
|
||||
UpdateAnswer(
|
||||
`UNAUTHORIZED<br>Please login at https://chat.openai.com first${
|
||||
isSafari() ? '<br>Then open https://chat.openai.com/api/auth/session' : ''
|
||||
}<br>And refresh this page or type you question again` +
|
||||
`<br><br>Consider creating an api key at https://platform.openai.com/account/api-keys<hr>`,
|
||||
false,
|
||||
'error',
|
||||
)
|
||||
break
|
||||
case 'CLOUDFLARE':
|
||||
UpdateAnswer(
|
||||
`OpenAI Security Check Required<br>Please open ${
|
||||
isSafari() ? 'https://chat.openai.com/api/auth/session' : 'https://chat.openai.com'
|
||||
}<br>And refresh this page or type you question again` +
|
||||
`<br><br>Consider creating an api key at https://platform.openai.com/account/api-keys<hr>`,
|
||||
false,
|
||||
'error',
|
||||
)
|
||||
break
|
||||
default:
|
||||
setConversationItemData([
|
||||
...conversationItemData,
|
||||
new ConversationItemData('error', msg.error + '\n<hr/>'),
|
||||
])
|
||||
break
|
||||
}
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
port.onMessage.addListener(listener)
|
||||
return () => {
|
||||
port.onMessage.removeListener(listener)
|
||||
}
|
||||
}, [conversationItemData])
|
||||
|
||||
return (
|
||||
<div className="gpt-inner">
|
||||
<div className="gpt-header">
|
||||
{!props.closeable ? (
|
||||
<img src={logo} width="20" height="20" style="margin:5px 15px 0px;user-select:none;" />
|
||||
) : (
|
||||
<XLg
|
||||
className="gpt-util-icon"
|
||||
style="margin:5px 15px 0px;"
|
||||
title="Close the Window"
|
||||
size={16}
|
||||
onClick={() => {
|
||||
if (props.onClose) props.onClose()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{props.draggable ? (
|
||||
<div className="dragbar" />
|
||||
) : (
|
||||
<WindowDesktop
|
||||
className="gpt-util-icon"
|
||||
title="Float the Window"
|
||||
size={16}
|
||||
onClick={() => {
|
||||
const position = { x: window.innerWidth / 2 - 300, y: window.innerHeight / 2 - 200 }
|
||||
const toolbarContainer = createElementAtPosition(position.x, position.y)
|
||||
toolbarContainer.className = 'toolbar-container-not-queryable'
|
||||
render(
|
||||
<FloatingToolbar
|
||||
session={session}
|
||||
selection=""
|
||||
position={position}
|
||||
container={toolbarContainer}
|
||||
closeable={true}
|
||||
triggered={true}
|
||||
onClose={() => toolbarContainer.remove()}
|
||||
/>,
|
||||
toolbarContainer,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
title="Save Conversation"
|
||||
className="gpt-util-icon"
|
||||
style="margin:15px 15px 10px;"
|
||||
onClick={() => {
|
||||
let output = ''
|
||||
session.conversationRecords.forEach((data) => {
|
||||
output += `Question:\n\n${data.question}\n\nAnswer:\n\n${data.answer}\n\n<hr/>\n\n`
|
||||
})
|
||||
const blob = new Blob([output], { type: 'text/plain;charset=utf-8' })
|
||||
FileSaver.saveAs(blob, 'conversation.md')
|
||||
}}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="markdown-body">
|
||||
{conversationItemData.map((data, idx) => (
|
||||
<ConversationItem
|
||||
content={data.content}
|
||||
key={idx}
|
||||
type={data.type}
|
||||
session={data.session}
|
||||
done={data.done}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<InputBox
|
||||
enabled={isReady}
|
||||
onSubmit={(question) => {
|
||||
const newQuestion = new ConversationItemData('question', question + '\n<hr/>')
|
||||
const newAnswer = new ConversationItemData(
|
||||
'answer',
|
||||
'<p class="gpt-loading">Waiting for response...</p>',
|
||||
)
|
||||
setConversationItemData([...conversationItemData, newQuestion, newAnswer])
|
||||
setIsReady(false)
|
||||
|
||||
const newSession = { ...session, question }
|
||||
setSession(newSession)
|
||||
try {
|
||||
port.postMessage({ session: newSession })
|
||||
} catch (e) {
|
||||
UpdateAnswer(e, false, 'error')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ConversationCard.propTypes = {
|
||||
session: PropTypes.object.isRequired,
|
||||
question: PropTypes.string.isRequired,
|
||||
onUpdate: PropTypes.func,
|
||||
draggable: PropTypes.bool,
|
||||
closeable: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
}
|
||||
|
||||
export default memo(ConversationCard)
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import FeedbackForChatGPTWeb from '../FeedbackForChatGPTWeb'
|
||||
import { ChevronDownIcon, LinkExternalIcon, XCircleIcon } from '@primer/octicons-react'
|
||||
import CopyButton from '../CopyButton'
|
||||
import PropTypes from 'prop-types'
|
||||
import MarkdownRender from '../MarkdownRender/markdown.jsx'
|
||||
|
||||
export function ConversationItem({ type, content, session, done }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
switch (type) {
|
||||
case 'question':
|
||||
return (
|
||||
<div className={type} dir="auto">
|
||||
<div className="gpt-header">
|
||||
<p>You:</p>
|
||||
<div style="display: flex; gap: 15px;">
|
||||
<CopyButton contentFn={() => content} size={14} />
|
||||
{!collapsed ? (
|
||||
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
|
||||
<XCircleIcon size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
|
||||
<ChevronDownIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
|
||||
</div>
|
||||
)
|
||||
case 'answer':
|
||||
return (
|
||||
<div className={type} dir="auto">
|
||||
<div className="gpt-header">
|
||||
<p>{session ? 'ChatGPT:' : 'Loading...'}</p>
|
||||
<div style="display: flex; gap: 15px;">
|
||||
{done && session && session.conversationId && (
|
||||
<FeedbackForChatGPTWeb
|
||||
messageId={session.messageId}
|
||||
conversationId={session.conversationId}
|
||||
/>
|
||||
)}
|
||||
{session && session.conversationId && (
|
||||
<a
|
||||
title="Continue on official website"
|
||||
href={'https://chat.openai.com/chat/' + session.conversationId}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
style="color: inherit;"
|
||||
>
|
||||
<LinkExternalIcon size={14} />
|
||||
</a>
|
||||
)}
|
||||
{session && <CopyButton contentFn={() => content} size={14} />}
|
||||
{!collapsed ? (
|
||||
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
|
||||
<XCircleIcon size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
|
||||
<ChevronDownIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
|
||||
</div>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<div className={type} dir="auto">
|
||||
<div className="gpt-header">
|
||||
<p>Error:</p>
|
||||
<div style="display: flex; gap: 15px;">
|
||||
<CopyButton contentFn={() => content} size={14} />
|
||||
{!collapsed ? (
|
||||
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
|
||||
<XCircleIcon size={14} />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
|
||||
<ChevronDownIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ConversationItem.propTypes = {
|
||||
type: PropTypes.oneOf(['question', 'answer', 'error']).isRequired,
|
||||
content: PropTypes.string.isRequired,
|
||||
session: PropTypes.object.isRequired,
|
||||
done: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default ConversationItem
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckIcon, CopyIcon } from '@primer/octicons-react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
CopyButton.propTypes = {
|
||||
contentFn: PropTypes.func.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
className: PropTypes.string,
|
||||
}
|
||||
|
||||
function CopyButton({ className, contentFn, size }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onClick = () => {
|
||||
navigator.clipboard
|
||||
.writeText(contentFn())
|
||||
.then(() => setCopied(true))
|
||||
.then(() =>
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 600),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span title="Copy" className={`gpt-util-icon ${className ? className : ''}`} onClick={onClick}>
|
||||
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -0,0 +1,143 @@
|
||||
import { LightBulbIcon, SearchIcon } from '@primer/octicons-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import ConversationCard from '../ConversationCard'
|
||||
import { defaultConfig, getUserConfig } from '../../config'
|
||||
import Browser from 'webextension-polyfill'
|
||||
import { getPossibleElementByQuerySelector, endsWithQuestionMark } from '../../utils'
|
||||
|
||||
function DecisionCard(props) {
|
||||
const [triggered, setTriggered] = useState(false)
|
||||
const [config, setConfig] = useState(defaultConfig)
|
||||
const [render, setRender] = useState(false)
|
||||
|
||||
const question = props.question
|
||||
|
||||
useEffect(() => {
|
||||
getUserConfig()
|
||||
.then(setConfig)
|
||||
.then(() => setRender(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (changes) => {
|
||||
const changedItems = Object.keys(changes)
|
||||
let newConfig = {}
|
||||
for (const key of changedItems) {
|
||||
newConfig[key] = changes[key].newValue
|
||||
}
|
||||
setConfig({ ...config, ...newConfig })
|
||||
}
|
||||
Browser.storage.local.onChanged.addListener(listener)
|
||||
return () => {
|
||||
Browser.storage.local.onChanged.removeListener(listener)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!render) return
|
||||
|
||||
const container = props.container
|
||||
const siteConfig = props.siteConfig
|
||||
container.classList.remove('sidebar-free')
|
||||
|
||||
if (config.appendQuery) {
|
||||
const appendContainer = getPossibleElementByQuerySelector([config.appendQuery])
|
||||
if (appendContainer) {
|
||||
appendContainer.appendChild(container)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (config.prependQuery) {
|
||||
const prependContainer = getPossibleElementByQuerySelector([config.prependQuery])
|
||||
if (prependContainer) {
|
||||
prependContainer.prepend(container)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!siteConfig) return
|
||||
|
||||
if (config.insertAtTop) {
|
||||
const resultsContainerQuery = getPossibleElementByQuerySelector(
|
||||
siteConfig.resultsContainerQuery,
|
||||
)
|
||||
if (resultsContainerQuery) resultsContainerQuery.prepend(container)
|
||||
} else {
|
||||
const sidebarContainer = getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery)
|
||||
if (sidebarContainer) {
|
||||
sidebarContainer.prepend(container)
|
||||
} else {
|
||||
const appendContainer = getPossibleElementByQuerySelector(siteConfig.appendContainerQuery)
|
||||
if (appendContainer) {
|
||||
container.classList.add('sidebar-free')
|
||||
appendContainer.appendChild(container)
|
||||
} else {
|
||||
const resultsContainerQuery = getPossibleElementByQuerySelector(
|
||||
siteConfig.resultsContainerQuery,
|
||||
)
|
||||
if (resultsContainerQuery) resultsContainerQuery.prepend(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => updatePosition(), [config])
|
||||
|
||||
return (
|
||||
render && (
|
||||
<div data-theme={config.themeMode}>
|
||||
{(() => {
|
||||
if (question)
|
||||
switch (config.triggerMode) {
|
||||
case 'always':
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
case 'manually':
|
||||
if (triggered) {
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
}
|
||||
return (
|
||||
<p
|
||||
className="gpt-inner manual-btn icon-and-text"
|
||||
onClick={() => setTriggered(true)}
|
||||
>
|
||||
<SearchIcon size="small" /> Ask ChatGPT
|
||||
</p>
|
||||
)
|
||||
case 'questionMark':
|
||||
if (endsWithQuestionMark(question.trim())) {
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
}
|
||||
if (triggered) {
|
||||
return <ConversationCard session={props.session} question={question} />
|
||||
}
|
||||
return (
|
||||
<p
|
||||
className="gpt-inner manual-btn icon-and-text"
|
||||
onClick={() => setTriggered(true)}
|
||||
>
|
||||
<SearchIcon size="small" /> Ask ChatGPT
|
||||
</p>
|
||||
)
|
||||
}
|
||||
else
|
||||
return (
|
||||
<p className="gpt-inner icon-and-text">
|
||||
<LightBulbIcon size="small" /> No Input Found
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DecisionCard.propTypes = {
|
||||
session: PropTypes.object.isRequired,
|
||||
question: PropTypes.string.isRequired,
|
||||
siteConfig: PropTypes.object.isRequired,
|
||||
container: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export default DecisionCard
|
||||
@@ -0,0 +1,64 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { ThumbsupIcon, ThumbsdownIcon } from '@primer/octicons-react'
|
||||
import Browser from 'webextension-polyfill'
|
||||
|
||||
const FeedbackForChatGPTWeb = (props) => {
|
||||
const [action, setAction] = useState(null)
|
||||
|
||||
const clickThumbsUp = useCallback(async () => {
|
||||
if (action) {
|
||||
return
|
||||
}
|
||||
setAction('thumbsUp')
|
||||
await Browser.runtime.sendMessage({
|
||||
type: 'FEEDBACK',
|
||||
data: {
|
||||
conversation_id: props.conversationId,
|
||||
message_id: props.messageId,
|
||||
rating: 'thumbsUp',
|
||||
},
|
||||
})
|
||||
}, [props, action])
|
||||
|
||||
const clickThumbsDown = useCallback(async () => {
|
||||
if (action) {
|
||||
return
|
||||
}
|
||||
setAction('thumbsDown')
|
||||
await Browser.runtime.sendMessage({
|
||||
type: 'FEEDBACK',
|
||||
data: {
|
||||
conversation_id: props.conversationId,
|
||||
message_id: props.messageId,
|
||||
rating: 'thumbsDown',
|
||||
text: '',
|
||||
tags: [],
|
||||
},
|
||||
})
|
||||
}, [props, action])
|
||||
|
||||
return (
|
||||
<div title="Feedback" className="gpt-feedback">
|
||||
<span
|
||||
onClick={clickThumbsUp}
|
||||
className={action === 'thumbsUp' ? 'gpt-feedback-selected' : undefined}
|
||||
>
|
||||
<ThumbsupIcon size={14} />
|
||||
</span>
|
||||
<span
|
||||
onClick={clickThumbsDown}
|
||||
className={action === 'thumbsDown' ? 'gpt-feedback-selected' : undefined}
|
||||
>
|
||||
<ThumbsdownIcon size={14} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
FeedbackForChatGPTWeb.propTypes = {
|
||||
messageId: PropTypes.string.isRequired,
|
||||
conversationId: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
export default memo(FeedbackForChatGPTWeb)
|
||||
@@ -0,0 +1,132 @@
|
||||
import Browser from 'webextension-polyfill'
|
||||
import { cloneElement, useEffect, useState } from 'react'
|
||||
import ConversationCard from '../ConversationCard'
|
||||
import PropTypes from 'prop-types'
|
||||
import { defaultConfig, getUserConfig } from '../../config.mjs'
|
||||
import { config as toolsConfig } from '../../content-script/selection-tools'
|
||||
import { setElementPositionInViewport } from '../../utils'
|
||||
import Draggable from 'react-draggable'
|
||||
|
||||
const logo = Browser.runtime.getURL('logo.png')
|
||||
|
||||
function FloatingToolbar(props) {
|
||||
const [prompt, setPrompt] = useState(props.prompt)
|
||||
const [triggered, setTriggered] = useState(props.triggered)
|
||||
const [config, setConfig] = useState(defaultConfig)
|
||||
const [render, setRender] = useState(false)
|
||||
const [position, setPosition] = useState(props.position)
|
||||
const [virtualPosition, setVirtualPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
getUserConfig()
|
||||
.then(setConfig)
|
||||
.then(() => setRender(true))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (changes) => {
|
||||
const changedItems = Object.keys(changes)
|
||||
let newConfig = {}
|
||||
for (const key of changedItems) {
|
||||
newConfig[key] = changes[key].newValue
|
||||
}
|
||||
setConfig({ ...config, ...newConfig })
|
||||
}
|
||||
Browser.storage.local.onChanged.addListener(listener)
|
||||
return () => {
|
||||
Browser.storage.local.onChanged.removeListener(listener)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
if (!render) return <div />
|
||||
|
||||
if (triggered) {
|
||||
const updatePosition = () => {
|
||||
const newPosition = setElementPositionInViewport(props.container, position.x, position.y)
|
||||
if (position.x !== newPosition.x || position.y !== newPosition.y) setPosition(newPosition) // clear extra virtual position offset
|
||||
}
|
||||
|
||||
const dragEvent = {
|
||||
onDrag: (e, ui) => {
|
||||
setVirtualPosition({ x: virtualPosition.x + ui.deltaX, y: virtualPosition.y + ui.deltaY })
|
||||
},
|
||||
onStop: () => {
|
||||
setPosition({ x: position.x + virtualPosition.x, y: position.y + virtualPosition.y })
|
||||
setVirtualPosition({ x: 0, y: 0 })
|
||||
},
|
||||
}
|
||||
|
||||
if (virtualPosition.x === 0 && virtualPosition.y === 0) {
|
||||
updatePosition() // avoid jitter
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-theme={config.themeMode}>
|
||||
<Draggable
|
||||
handle=".dragbar"
|
||||
onDrag={dragEvent.onDrag}
|
||||
onStop={dragEvent.onStop}
|
||||
position={virtualPosition}
|
||||
>
|
||||
<div className="gpt-selection-window">
|
||||
<div className="chat-gpt-container">
|
||||
<ConversationCard
|
||||
session={props.session}
|
||||
question={prompt}
|
||||
draggable={true}
|
||||
closeable={props.closeable}
|
||||
onClose={props.onClose}
|
||||
onUpdate={() => {
|
||||
updatePosition()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
if (config.activeSelectionTools.length === 0) return <div />
|
||||
|
||||
const tools = []
|
||||
|
||||
for (const key in toolsConfig) {
|
||||
if (config.activeSelectionTools.includes(key)) {
|
||||
const toolConfig = toolsConfig[key]
|
||||
tools.push(
|
||||
cloneElement(toolConfig.icon, {
|
||||
size: 20,
|
||||
className: 'gpt-selection-toolbar-button',
|
||||
title: toolConfig.label,
|
||||
onClick: async () => {
|
||||
setPrompt(await toolConfig.genPrompt(props.selection))
|
||||
setTriggered(true)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-theme={config.themeMode}>
|
||||
<div className="gpt-selection-toolbar">
|
||||
<img src={logo} width="24" height="24" style="user-select:none;" />
|
||||
{tools}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FloatingToolbar.propTypes = {
|
||||
session: PropTypes.object.isRequired,
|
||||
selection: PropTypes.string.isRequired,
|
||||
position: PropTypes.object.isRequired,
|
||||
container: PropTypes.object.isRequired,
|
||||
triggered: PropTypes.bool,
|
||||
closeable: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
prompt: PropTypes.string,
|
||||
}
|
||||
|
||||
export default FloatingToolbar
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { updateRefHeight } from '../../utils'
|
||||
|
||||
export function InputBox({ onSubmit, enabled }) {
|
||||
const [value, setValue] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
updateRefHeight(inputRef)
|
||||
})
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
||||
e.preventDefault()
|
||||
if (!value) return
|
||||
onSubmit(value)
|
||||
setValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
disabled={!enabled}
|
||||
className="interact-input"
|
||||
placeholder={
|
||||
enabled
|
||||
? 'Type your question here\nEnter to send, shift + enter to break line'
|
||||
: 'Wait for the answer to finish and then continue here'
|
||||
}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
InputBox.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
enabled: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default InputBox
|
||||
@@ -0,0 +1,66 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import CopyButton from '../CopyButton'
|
||||
import { useRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
function Pre({ className, children }) {
|
||||
const preRef = useRef(null)
|
||||
return (
|
||||
<pre className={className} ref={preRef} style="position: relative;">
|
||||
<CopyButton
|
||||
className="code-copy-btn"
|
||||
contentFn={() => preRef.current.textContent}
|
||||
size={14}
|
||||
/>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
Pre.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export function MarkdownRender(props) {
|
||||
const linkProperties = {
|
||||
target: '_blank',
|
||||
style: 'color: #8ab4f8;',
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
a: (props) => (
|
||||
<a href={props.href} {...linkProperties}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
pre: Pre,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
MarkdownRender.propTypes = {
|
||||
...ReactMarkdown.propTypes,
|
||||
}
|
||||
|
||||
export default MarkdownRender
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import CopyButton from '../CopyButton'
|
||||
import { useRef } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
function Pre({ className, children }) {
|
||||
const preRef = useRef(null)
|
||||
return (
|
||||
<pre className={className} ref={preRef} style="position: relative;">
|
||||
<CopyButton
|
||||
className="code-copy-btn"
|
||||
contentFn={() => preRef.current.textContent}
|
||||
size={14}
|
||||
/>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
Pre.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export function MarkdownRender(props) {
|
||||
const linkProperties = {
|
||||
target: '_blank',
|
||||
style: 'color: #8ab4f8;',
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
rehypeRaw,
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
},
|
||||
],
|
||||
]}
|
||||
components={{
|
||||
a: (props) => (
|
||||
<a href={props.href} {...linkProperties}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
pre: Pre,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
MarkdownRender.propTypes = {
|
||||
...ReactMarkdown.propTypes,
|
||||
}
|
||||
|
||||
export default MarkdownRender
|
||||
+126
@@ -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('')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { render } from 'preact'
|
||||
import Popup from './Popup'
|
||||
|
||||
render(<Popup />, document.getElementById('app'))
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function endsWithQuestionMark(question) {
|
||||
return (
|
||||
question.endsWith('?') || // ASCII
|
||||
question.endsWith('?') || // Chinese/Japanese
|
||||
question.endsWith('؟') || // Arabic
|
||||
question.endsWith('⸮') // Arabic
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isSafari() {
|
||||
return navigator.vendor === 'Apple Computer, Inc.'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user