first commit

This commit is contained in:
josc146
2023-03-15 16:18:51 +08:00
commit e642f2b922
67 changed files with 14469 additions and 0 deletions
+21
View File
@@ -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"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore"
include: "scope"
+24
View File
@@ -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
+61
View File
@@ -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()
+59
View File
@@ -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
+17
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.idea/
.vscode/
node_modules/
build/
.DS_Store
*.zip
+3
View File
@@ -0,0 +1,3 @@
build/
src/manifest.json
src/manifest.v2.json
+16
View File
@@ -0,0 +1,16 @@
{
"printWidth": 100,
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}
+22
View File
@@ -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.
+255
View File
@@ -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()
+10300
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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"
}
}
+8
View File
@@ -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" }
]
}
+11
View File
@@ -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
+8
View File
@@ -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>
+39
View File
@@ -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,
+124
View File
@@ -0,0 +1,124 @@
// web version
import { fetchSSE } from '../../utils/fetch-sse'
import { isEmpty } from 'lodash-es'
import { chatgptWebModelKeys, getUserConfig, Models } from '../../config'
async function request(token, method, path, data) {
const apiUrl = (await getUserConfig()).customChatGptWebApiUrl
const response = await fetch(`${apiUrl}/backend-api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
})
const responseText = await response.text()
console.debug(`request: ${path}`, responseText)
return { response, responseText }
}
export async function sendMessageFeedback(token, data) {
await request(token, 'POST', '/conversation/message_feedback', data)
}
export async function setConversationProperty(token, conversationId, propertyObject) {
await request(token, 'PATCH', `/conversation/${conversationId}`, propertyObject)
}
export async function sendModerations(token, question, conversationId, messageId) {
await request(token, 'POST', `/moderations`, {
conversation_id: conversationId,
input: question,
message_id: messageId,
model: 'text-moderation-playground',
})
}
export async function getModels(token) {
const response = JSON.parse((await request(token, 'GET', '/models')).responseText)
return response.models
}
/**
* @param {Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} accessToken
*/
export async function generateAnswersWithChatgptWebApi(port, question, session, accessToken) {
const deleteConversation = () => {
setConversationProperty(accessToken, session.conversationId, { is_visible: false })
}
const controller = new AbortController()
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
deleteConversation()
})
const models = await getModels(accessToken).catch(() => {})
const config = await getUserConfig()
let answer = ''
await fetchSSE(`${config.customChatGptWebApiUrl}${config.customChatGptWebApiPath}`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
action: 'next',
conversation_id: session.conversationId,
messages: [
{
id: session.messageId,
role: 'user',
content: {
content_type: 'text',
parts: [question],
},
},
],
model: models ? models[0].slug : Models[chatgptWebModelKeys[0]].value,
parent_message_id: session.parentMessageId,
}),
onMessage(message) {
console.debug('sse message', message)
if (message === '[DONE]') {
session.conversationRecords.push({ question: question, answer: answer })
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: null, done: true, session: session })
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
if (data.conversation_id) session.conversationId = data.conversation_id
if (data.message?.id) session.parentMessageId = data.message.id
answer = data.message?.content?.parts?.[0]
if (answer) {
port.postMessage({ answer: answer, done: false, session: session })
}
},
async onStart() {
// sendModerations(accessToken, question, session.conversationId, session.messageId)
},
async onEnd() {},
async onError(resp) {
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
},
})
}
+154
View File
@@ -0,0 +1,154 @@
// api version
import { maxResponseTokenLength, Models, getUserConfig } from '../../config'
import { fetchSSE } from '../../utils/fetch-sse'
import { getConversationPairs } from '../../utils/get-conversation-pairs'
import { isEmpty } from 'lodash-es'
const getChatgptPromptBase = async () => {
return `You are a helpful, creative, clever, and very friendly assistant. You are familiar with various languages in the world.`
}
const getGptPromptBase = async () => {
return (
`The following is a conversation with an AI assistant.` +
`The assistant is helpful, creative, clever, and very friendly. The assistant is familiar with various languages in the world.\n\n` +
`Human: Hello, who are you?\n` +
`AI: I am an AI created by OpenAI. How can I help you today?\n` +
`Human: 谢谢\n` +
`AI: 不客气\n`
)
}
/**
* @param {Browser.Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} apiKey
* @param {string} modelName
*/
export async function generateAnswersWithGptCompletionApi(
port,
question,
session,
apiKey,
modelName,
) {
const controller = new AbortController()
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
})
const prompt =
(await getGptPromptBase()) +
getConversationPairs(session.conversationRecords, false) +
`Human:${question}\nAI:`
const apiUrl = (await getUserConfig()).customOpenAiApiUrl
let answer = ''
await fetchSSE(`${apiUrl}/v1/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
prompt: prompt,
model: Models[modelName].value,
stream: true,
max_tokens: maxResponseTokenLength,
}),
onMessage(message) {
console.debug('sse message', message)
if (message === '[DONE]') {
session.conversationRecords.push({ question: question, answer: answer })
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: null, done: true, session: session })
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
answer += data.choices[0].text
port.postMessage({ answer: answer, done: false, session: null })
},
async onStart() {},
async onEnd() {},
async onError(resp) {
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
},
})
}
/**
* @param {Browser.Runtime.Port} port
* @param {string} question
* @param {Session} session
* @param {string} apiKey
* @param {string} modelName
*/
export async function generateAnswersWithChatgptApi(port, question, session, apiKey, modelName) {
const controller = new AbortController()
port.onDisconnect.addListener(() => {
console.debug('port disconnected')
controller.abort()
})
const prompt = getConversationPairs(session.conversationRecords, true)
prompt.unshift({ role: 'system', content: await getChatgptPromptBase() })
prompt.push({ role: 'user', content: question })
const apiUrl = (await getUserConfig()).customOpenAiApiUrl
let answer = ''
await fetchSSE(`${apiUrl}/v1/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
messages: prompt,
model: Models[modelName].value,
stream: true,
max_tokens: maxResponseTokenLength,
}),
onMessage(message) {
console.debug('sse message', message)
if (message === '[DONE]') {
session.conversationRecords.push({ question: question, answer: answer })
console.debug('conversation history', { content: session.conversationRecords })
port.postMessage({ answer: null, done: true, session: session })
return
}
let data
try {
data = JSON.parse(message)
} catch (error) {
console.debug('json error', error)
return
}
if ('content' in data.choices[0].delta) answer += data.choices[0].delta.content
port.postMessage({ answer: answer, done: false, session: null })
},
async onStart() {},
async onEnd() {},
async onError(resp) {
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const error = await resp.json().catch(() => ({}))
throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`)
},
})
}
+136
View File
@@ -0,0 +1,136 @@
import { v4 as uuidv4 } from 'uuid'
import Browser from 'webextension-polyfill'
import ExpiryMap from 'expiry-map'
import { generateAnswersWithChatgptWebApi, sendMessageFeedback } from './apis/chatgpt-web'
import {
generateAnswersWithChatgptApi,
generateAnswersWithGptCompletionApi,
} from './apis/openai-api'
import {
chatgptApiModelKeys,
chatgptWebModelKeys,
getUserConfig,
gptApiModelKeys,
isUsingApiKey,
} from '../config'
import { isSafari } from '../utils/is-safari'
import { config as toolsConfig } from '../content-script/selection-tools'
const KEY_ACCESS_TOKEN = 'accessToken'
const cache = new ExpiryMap(10 * 1000)
/**
* @returns {Promise<string>}
*/
async function getAccessToken() {
if (cache.get(KEY_ACCESS_TOKEN)) {
return cache.get(KEY_ACCESS_TOKEN)
}
if (isSafari()) {
const userConfig = await getUserConfig()
if (userConfig.accessToken) {
cache.set(KEY_ACCESS_TOKEN, userConfig.accessToken)
} else {
throw new Error('UNAUTHORIZED')
}
} else {
const resp = await fetch('https://chat.openai.com/api/auth/session')
if (resp.status === 403) {
throw new Error('CLOUDFLARE')
}
const data = await resp.json().catch(() => ({}))
if (!data.accessToken) {
throw new Error('UNAUTHORIZED')
}
cache.set(KEY_ACCESS_TOKEN, data.accessToken)
}
return cache.get(KEY_ACCESS_TOKEN)
}
Browser.runtime.onConnect.addListener((port) => {
console.debug('connected')
port.onMessage.addListener(async (msg) => {
console.debug('received msg', msg)
const config = await getUserConfig()
const session = msg.session
if (session.useApiKey == null) {
session.useApiKey = isUsingApiKey(config)
}
try {
if (chatgptWebModelKeys.includes(config.modelName)) {
const accessToken = await getAccessToken()
session.messageId = uuidv4()
if (session.parentMessageId == null) {
session.parentMessageId = uuidv4()
}
await generateAnswersWithChatgptWebApi(port, session.question, session, accessToken)
} else if (gptApiModelKeys.includes(config.modelName)) {
await generateAnswersWithGptCompletionApi(
port,
session.question,
session,
config.apiKey,
config.modelName,
)
} else if (chatgptApiModelKeys.includes(config.modelName)) {
await generateAnswersWithChatgptApi(
port,
session.question,
session,
config.apiKey,
config.modelName,
)
}
} catch (err) {
console.error(err)
port.postMessage({ error: err.message })
cache.delete(KEY_ACCESS_TOKEN)
}
})
})
Browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'FEEDBACK') {
const token = await getAccessToken()
await sendMessageFeedback(token, message.data)
}
})
Browser.contextMenus.removeAll().then(() => {
const menuId = 'ChatGPTBox-Menu'
Browser.contextMenus.create({
id: menuId,
title: 'ChatGPTBox',
contexts: ['all'],
})
Browser.contextMenus.create({
id: menuId + 'new',
parentId: menuId,
title: 'New Chat',
contexts: ['selection'],
})
for (const key in toolsConfig) {
const toolConfig = toolsConfig[key]
Browser.contextMenus.create({
id: menuId + key,
parentId: menuId,
title: toolConfig.label,
contexts: ['selection'],
})
}
Browser.contextMenus.onClicked.addListener((info, tab) => {
const itemId = info.menuItemId === menuId ? 'new' : info.menuItemId.replace(menuId, '')
const message = {
itemId: itemId,
selectionText: info.selectionText,
}
console.debug('menu clicked', message)
Browser.tabs.sendMessage(tab.id, {
type: 'MENU',
data: message,
})
})
})
+260
View File
@@ -0,0 +1,260 @@
import { memo, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import Browser from 'webextension-polyfill'
import InputBox from '../InputBox'
import ConversationItem from '../ConversationItem'
import { createElementAtPosition, initSession, isSafari } from '../../utils'
import { DownloadIcon } from '@primer/octicons-react'
import { WindowDesktop, XLg } from 'react-bootstrap-icons'
import FileSaver from 'file-saver'
import { render } from 'preact'
import FloatingToolbar from '../FloatingToolbar'
const logo = Browser.runtime.getURL('logo.png')
class ConversationItemData extends Object {
/**
* @param {'question'|'answer'|'error'} type
* @param {string} content
* @param {object} session
* @param {bool} done
*/
constructor(type, content, session = null, done = false) {
super()
this.type = type
this.content = content
this.session = session
this.done = done
}
}
function ConversationCard(props) {
const [isReady, setIsReady] = useState(!props.question)
const [port, setPort] = useState(() => Browser.runtime.connect())
const [session, setSession] = useState(props.session)
/**
* @type {[ConversationItemData[], (conversationItemData: ConversationItemData[]) => void]}
*/
const [conversationItemData, setConversationItemData] = useState(
(() => {
if (props.session.conversationRecords.length === 0)
if (props.question)
return [
new ConversationItemData(
'answer',
'<p class="gpt-loading">Waiting for response...</p>',
),
]
else return []
else {
const ret = []
for (const record of props.session.conversationRecords) {
ret.push(
new ConversationItemData('question', record.question + '\n<hr/>', props.session, true),
)
ret.push(
new ConversationItemData('answer', record.answer + '\n<hr/>', props.session, true),
)
}
return ret
}
})(),
)
useEffect(() => {
if (props.onUpdate) props.onUpdate()
})
useEffect(() => {
// when the page is responsive, session may accumulate redundant data and needs to be cleared after remounting and before making a new request
if (props.question) {
const newSession = initSession({ question: props.question })
setSession(newSession)
port.postMessage({ session: newSession })
}
}, [props.question]) // usually only triggered once
/**
* @param {string} value
* @param {boolean} appended
* @param {'question'|'answer'|'error'} newType
* @param {boolean} done
*/
const UpdateAnswer = (value, appended, newType, done = false) => {
setConversationItemData((old) => {
const copy = [...old]
const index = copy.findLastIndex((v) => v.type === 'answer')
if (index === -1) return copy
copy[index] = new ConversationItemData(
newType,
appended ? copy[index].content + value : value,
)
copy[index].session = { ...session }
copy[index].done = done
return copy
})
}
useEffect(() => {
const listener = () => {
setPort(Browser.runtime.connect())
}
port.onDisconnect.addListener(listener)
return () => {
port.onDisconnect.removeListener(listener)
}
}, [port])
useEffect(() => {
const listener = (msg) => {
if (msg.answer) {
UpdateAnswer(msg.answer, false, 'answer')
}
if (msg.session) {
setSession(msg.session)
}
if (msg.done) {
UpdateAnswer('\n<hr/>', true, 'answer', true)
setIsReady(true)
}
if (msg.error) {
switch (msg.error) {
case 'UNAUTHORIZED':
UpdateAnswer(
`UNAUTHORIZED<br>Please login at https://chat.openai.com first${
isSafari() ? '<br>Then open https://chat.openai.com/api/auth/session' : ''
}<br>And refresh this page or type you question again` +
`<br><br>Consider creating an api key at https://platform.openai.com/account/api-keys<hr>`,
false,
'error',
)
break
case 'CLOUDFLARE':
UpdateAnswer(
`OpenAI Security Check Required<br>Please open ${
isSafari() ? 'https://chat.openai.com/api/auth/session' : 'https://chat.openai.com'
}<br>And refresh this page or type you question again` +
`<br><br>Consider creating an api key at https://platform.openai.com/account/api-keys<hr>`,
false,
'error',
)
break
default:
setConversationItemData([
...conversationItemData,
new ConversationItemData('error', msg.error + '\n<hr/>'),
])
break
}
setIsReady(true)
}
}
port.onMessage.addListener(listener)
return () => {
port.onMessage.removeListener(listener)
}
}, [conversationItemData])
return (
<div className="gpt-inner">
<div className="gpt-header">
{!props.closeable ? (
<img src={logo} width="20" height="20" style="margin:5px 15px 0px;user-select:none;" />
) : (
<XLg
className="gpt-util-icon"
style="margin:5px 15px 0px;"
title="Close the Window"
size={16}
onClick={() => {
if (props.onClose) props.onClose()
}}
/>
)}
{props.draggable ? (
<div className="dragbar" />
) : (
<WindowDesktop
className="gpt-util-icon"
title="Float the Window"
size={16}
onClick={() => {
const position = { x: window.innerWidth / 2 - 300, y: window.innerHeight / 2 - 200 }
const toolbarContainer = createElementAtPosition(position.x, position.y)
toolbarContainer.className = 'toolbar-container-not-queryable'
render(
<FloatingToolbar
session={session}
selection=""
position={position}
container={toolbarContainer}
closeable={true}
triggered={true}
onClose={() => toolbarContainer.remove()}
/>,
toolbarContainer,
)
}}
/>
)}
<span
title="Save Conversation"
className="gpt-util-icon"
style="margin:15px 15px 10px;"
onClick={() => {
let output = ''
session.conversationRecords.forEach((data) => {
output += `Question:\n\n${data.question}\n\nAnswer:\n\n${data.answer}\n\n<hr/>\n\n`
})
const blob = new Blob([output], { type: 'text/plain;charset=utf-8' })
FileSaver.saveAs(blob, 'conversation.md')
}}
>
<DownloadIcon size={16} />
</span>
</div>
<hr />
<div className="markdown-body">
{conversationItemData.map((data, idx) => (
<ConversationItem
content={data.content}
key={idx}
type={data.type}
session={data.session}
done={data.done}
/>
))}
</div>
<InputBox
enabled={isReady}
onSubmit={(question) => {
const newQuestion = new ConversationItemData('question', question + '\n<hr/>')
const newAnswer = new ConversationItemData(
'answer',
'<p class="gpt-loading">Waiting for response...</p>',
)
setConversationItemData([...conversationItemData, newQuestion, newAnswer])
setIsReady(false)
const newSession = { ...session, question }
setSession(newSession)
try {
port.postMessage({ session: newSession })
} catch (e) {
UpdateAnswer(e, false, 'error')
}
}}
/>
</div>
)
}
ConversationCard.propTypes = {
session: PropTypes.object.isRequired,
question: PropTypes.string.isRequired,
onUpdate: PropTypes.func,
draggable: PropTypes.bool,
closeable: PropTypes.bool,
onClose: PropTypes.func,
}
export default memo(ConversationCard)
+102
View File
@@ -0,0 +1,102 @@
import { useState } from 'react'
import FeedbackForChatGPTWeb from '../FeedbackForChatGPTWeb'
import { ChevronDownIcon, LinkExternalIcon, XCircleIcon } from '@primer/octicons-react'
import CopyButton from '../CopyButton'
import PropTypes from 'prop-types'
import MarkdownRender from '../MarkdownRender/markdown.jsx'
export function ConversationItem({ type, content, session, done }) {
const [collapsed, setCollapsed] = useState(false)
switch (type) {
case 'question':
return (
<div className={type} dir="auto">
<div className="gpt-header">
<p>You:</p>
<div style="display: flex; gap: 15px;">
<CopyButton contentFn={() => content} size={14} />
{!collapsed ? (
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
<XCircleIcon size={14} />
</span>
) : (
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
<ChevronDownIcon size={14} />
</span>
)}
</div>
</div>
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
</div>
)
case 'answer':
return (
<div className={type} dir="auto">
<div className="gpt-header">
<p>{session ? 'ChatGPT:' : 'Loading...'}</p>
<div style="display: flex; gap: 15px;">
{done && session && session.conversationId && (
<FeedbackForChatGPTWeb
messageId={session.messageId}
conversationId={session.conversationId}
/>
)}
{session && session.conversationId && (
<a
title="Continue on official website"
href={'https://chat.openai.com/chat/' + session.conversationId}
target="_blank"
rel="nofollow noopener noreferrer"
style="color: inherit;"
>
<LinkExternalIcon size={14} />
</a>
)}
{session && <CopyButton contentFn={() => content} size={14} />}
{!collapsed ? (
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
<XCircleIcon size={14} />
</span>
) : (
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
<ChevronDownIcon size={14} />
</span>
)}
</div>
</div>
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
</div>
)
case 'error':
return (
<div className={type} dir="auto">
<div className="gpt-header">
<p>Error:</p>
<div style="display: flex; gap: 15px;">
<CopyButton contentFn={() => content} size={14} />
{!collapsed ? (
<span title="Collapse" className="gpt-util-icon" onClick={() => setCollapsed(true)}>
<XCircleIcon size={14} />
</span>
) : (
<span title="Expand" className="gpt-util-icon" onClick={() => setCollapsed(false)}>
<ChevronDownIcon size={14} />
</span>
)}
</div>
</div>
{!collapsed && <MarkdownRender>{content}</MarkdownRender>}
</div>
)
}
}
ConversationItem.propTypes = {
type: PropTypes.oneOf(['question', 'answer', 'error']).isRequired,
content: PropTypes.string.isRequired,
session: PropTypes.object.isRequired,
done: PropTypes.bool.isRequired,
}
export default ConversationItem
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react'
import { CheckIcon, CopyIcon } from '@primer/octicons-react'
import PropTypes from 'prop-types'
CopyButton.propTypes = {
contentFn: PropTypes.func.isRequired,
size: PropTypes.number.isRequired,
className: PropTypes.string,
}
function CopyButton({ className, contentFn, size }) {
const [copied, setCopied] = useState(false)
const onClick = () => {
navigator.clipboard
.writeText(contentFn())
.then(() => setCopied(true))
.then(() =>
setTimeout(() => {
setCopied(false)
}, 600),
)
}
return (
<span title="Copy" className={`gpt-util-icon ${className ? className : ''}`} onClick={onClick}>
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
</span>
)
}
export default CopyButton
+143
View File
@@ -0,0 +1,143 @@
import { LightBulbIcon, SearchIcon } from '@primer/octicons-react'
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import ConversationCard from '../ConversationCard'
import { defaultConfig, getUserConfig } from '../../config'
import Browser from 'webextension-polyfill'
import { getPossibleElementByQuerySelector, endsWithQuestionMark } from '../../utils'
function DecisionCard(props) {
const [triggered, setTriggered] = useState(false)
const [config, setConfig] = useState(defaultConfig)
const [render, setRender] = useState(false)
const question = props.question
useEffect(() => {
getUserConfig()
.then(setConfig)
.then(() => setRender(true))
}, [])
useEffect(() => {
const listener = (changes) => {
const changedItems = Object.keys(changes)
let newConfig = {}
for (const key of changedItems) {
newConfig[key] = changes[key].newValue
}
setConfig({ ...config, ...newConfig })
}
Browser.storage.local.onChanged.addListener(listener)
return () => {
Browser.storage.local.onChanged.removeListener(listener)
}
}, [config])
const updatePosition = () => {
if (!render) return
const container = props.container
const siteConfig = props.siteConfig
container.classList.remove('sidebar-free')
if (config.appendQuery) {
const appendContainer = getPossibleElementByQuerySelector([config.appendQuery])
if (appendContainer) {
appendContainer.appendChild(container)
return
}
}
if (config.prependQuery) {
const prependContainer = getPossibleElementByQuerySelector([config.prependQuery])
if (prependContainer) {
prependContainer.prepend(container)
return
}
}
if (!siteConfig) return
if (config.insertAtTop) {
const resultsContainerQuery = getPossibleElementByQuerySelector(
siteConfig.resultsContainerQuery,
)
if (resultsContainerQuery) resultsContainerQuery.prepend(container)
} else {
const sidebarContainer = getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery)
if (sidebarContainer) {
sidebarContainer.prepend(container)
} else {
const appendContainer = getPossibleElementByQuerySelector(siteConfig.appendContainerQuery)
if (appendContainer) {
container.classList.add('sidebar-free')
appendContainer.appendChild(container)
} else {
const resultsContainerQuery = getPossibleElementByQuerySelector(
siteConfig.resultsContainerQuery,
)
if (resultsContainerQuery) resultsContainerQuery.prepend(container)
}
}
}
}
useEffect(() => updatePosition(), [config])
return (
render && (
<div data-theme={config.themeMode}>
{(() => {
if (question)
switch (config.triggerMode) {
case 'always':
return <ConversationCard session={props.session} question={question} />
case 'manually':
if (triggered) {
return <ConversationCard session={props.session} question={question} />
}
return (
<p
className="gpt-inner manual-btn icon-and-text"
onClick={() => setTriggered(true)}
>
<SearchIcon size="small" /> Ask ChatGPT
</p>
)
case 'questionMark':
if (endsWithQuestionMark(question.trim())) {
return <ConversationCard session={props.session} question={question} />
}
if (triggered) {
return <ConversationCard session={props.session} question={question} />
}
return (
<p
className="gpt-inner manual-btn icon-and-text"
onClick={() => setTriggered(true)}
>
<SearchIcon size="small" /> Ask ChatGPT
</p>
)
}
else
return (
<p className="gpt-inner icon-and-text">
<LightBulbIcon size="small" /> No Input Found
</p>
)
})()}
</div>
)
)
}
DecisionCard.propTypes = {
session: PropTypes.object.isRequired,
question: PropTypes.string.isRequired,
siteConfig: PropTypes.object.isRequired,
container: PropTypes.object.isRequired,
}
export default DecisionCard
@@ -0,0 +1,64 @@
import PropTypes from 'prop-types'
import { memo, useCallback, useState } from 'react'
import { ThumbsupIcon, ThumbsdownIcon } from '@primer/octicons-react'
import Browser from 'webextension-polyfill'
const FeedbackForChatGPTWeb = (props) => {
const [action, setAction] = useState(null)
const clickThumbsUp = useCallback(async () => {
if (action) {
return
}
setAction('thumbsUp')
await Browser.runtime.sendMessage({
type: 'FEEDBACK',
data: {
conversation_id: props.conversationId,
message_id: props.messageId,
rating: 'thumbsUp',
},
})
}, [props, action])
const clickThumbsDown = useCallback(async () => {
if (action) {
return
}
setAction('thumbsDown')
await Browser.runtime.sendMessage({
type: 'FEEDBACK',
data: {
conversation_id: props.conversationId,
message_id: props.messageId,
rating: 'thumbsDown',
text: '',
tags: [],
},
})
}, [props, action])
return (
<div title="Feedback" className="gpt-feedback">
<span
onClick={clickThumbsUp}
className={action === 'thumbsUp' ? 'gpt-feedback-selected' : undefined}
>
<ThumbsupIcon size={14} />
</span>
<span
onClick={clickThumbsDown}
className={action === 'thumbsDown' ? 'gpt-feedback-selected' : undefined}
>
<ThumbsdownIcon size={14} />
</span>
</div>
)
}
FeedbackForChatGPTWeb.propTypes = {
messageId: PropTypes.string.isRequired,
conversationId: PropTypes.string.isRequired,
}
export default memo(FeedbackForChatGPTWeb)
+132
View File
@@ -0,0 +1,132 @@
import Browser from 'webextension-polyfill'
import { cloneElement, useEffect, useState } from 'react'
import ConversationCard from '../ConversationCard'
import PropTypes from 'prop-types'
import { defaultConfig, getUserConfig } from '../../config.mjs'
import { config as toolsConfig } from '../../content-script/selection-tools'
import { setElementPositionInViewport } from '../../utils'
import Draggable from 'react-draggable'
const logo = Browser.runtime.getURL('logo.png')
function FloatingToolbar(props) {
const [prompt, setPrompt] = useState(props.prompt)
const [triggered, setTriggered] = useState(props.triggered)
const [config, setConfig] = useState(defaultConfig)
const [render, setRender] = useState(false)
const [position, setPosition] = useState(props.position)
const [virtualPosition, setVirtualPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
getUserConfig()
.then(setConfig)
.then(() => setRender(true))
}, [])
useEffect(() => {
const listener = (changes) => {
const changedItems = Object.keys(changes)
let newConfig = {}
for (const key of changedItems) {
newConfig[key] = changes[key].newValue
}
setConfig({ ...config, ...newConfig })
}
Browser.storage.local.onChanged.addListener(listener)
return () => {
Browser.storage.local.onChanged.removeListener(listener)
}
}, [config])
if (!render) return <div />
if (triggered) {
const updatePosition = () => {
const newPosition = setElementPositionInViewport(props.container, position.x, position.y)
if (position.x !== newPosition.x || position.y !== newPosition.y) setPosition(newPosition) // clear extra virtual position offset
}
const dragEvent = {
onDrag: (e, ui) => {
setVirtualPosition({ x: virtualPosition.x + ui.deltaX, y: virtualPosition.y + ui.deltaY })
},
onStop: () => {
setPosition({ x: position.x + virtualPosition.x, y: position.y + virtualPosition.y })
setVirtualPosition({ x: 0, y: 0 })
},
}
if (virtualPosition.x === 0 && virtualPosition.y === 0) {
updatePosition() // avoid jitter
}
return (
<div data-theme={config.themeMode}>
<Draggable
handle=".dragbar"
onDrag={dragEvent.onDrag}
onStop={dragEvent.onStop}
position={virtualPosition}
>
<div className="gpt-selection-window">
<div className="chat-gpt-container">
<ConversationCard
session={props.session}
question={prompt}
draggable={true}
closeable={props.closeable}
onClose={props.onClose}
onUpdate={() => {
updatePosition()
}}
/>
</div>
</div>
</Draggable>
</div>
)
} else {
if (config.activeSelectionTools.length === 0) return <div />
const tools = []
for (const key in toolsConfig) {
if (config.activeSelectionTools.includes(key)) {
const toolConfig = toolsConfig[key]
tools.push(
cloneElement(toolConfig.icon, {
size: 20,
className: 'gpt-selection-toolbar-button',
title: toolConfig.label,
onClick: async () => {
setPrompt(await toolConfig.genPrompt(props.selection))
setTriggered(true)
},
}),
)
}
}
return (
<div data-theme={config.themeMode}>
<div className="gpt-selection-toolbar">
<img src={logo} width="24" height="24" style="user-select:none;" />
{tools}
</div>
</div>
)
}
}
FloatingToolbar.propTypes = {
session: PropTypes.object.isRequired,
selection: PropTypes.string.isRequired,
position: PropTypes.object.isRequired,
container: PropTypes.object.isRequired,
triggered: PropTypes.bool,
closeable: PropTypes.bool,
onClose: PropTypes.func,
prompt: PropTypes.string,
}
export default FloatingToolbar
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { updateRefHeight } from '../../utils'
export function InputBox({ onSubmit, enabled }) {
const [value, setValue] = useState('')
const inputRef = useRef(null)
useEffect(() => {
updateRefHeight(inputRef)
})
const onKeyDown = (e) => {
if (e.keyCode === 13 && e.shiftKey === false) {
e.preventDefault()
if (!value) return
onSubmit(value)
setValue('')
}
}
return (
<textarea
ref={inputRef}
disabled={!enabled}
className="interact-input"
placeholder={
enabled
? 'Type your question here\nEnter to send, shift + enter to break line'
: 'Wait for the answer to finish and then continue here'
}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
/>
)
}
InputBox.propTypes = {
onSubmit: PropTypes.func.isRequired,
enabled: PropTypes.bool,
}
export default InputBox
@@ -0,0 +1,66 @@
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeHighlight from 'rehype-highlight'
import remarkGfm from 'remark-gfm'
import CopyButton from '../CopyButton'
import { useRef } from 'react'
import PropTypes from 'prop-types'
function Pre({ className, children }) {
const preRef = useRef(null)
return (
<pre className={className} ref={preRef} style="position: relative;">
<CopyButton
className="code-copy-btn"
contentFn={() => preRef.current.textContent}
size={14}
/>
{children}
</pre>
)
}
Pre.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.object.isRequired,
}
export function MarkdownRender(props) {
const linkProperties = {
target: '_blank',
style: 'color: #8ab4f8;',
rel: 'nofollow noopener noreferrer',
}
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
},
],
]}
components={{
a: (props) => (
<a href={props.href} {...linkProperties}>
{props.children}
</a>
),
pre: Pre,
}}
{...props}
>
{props.children}
</ReactMarkdown>
)
}
MarkdownRender.propTypes = {
...ReactMarkdown.propTypes,
}
export default MarkdownRender
@@ -0,0 +1,70 @@
import 'katex/dist/katex.min.css'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeHighlight from 'rehype-highlight'
import rehypeKatex from 'rehype-katex'
import remarkMath from 'remark-math'
import remarkGfm from 'remark-gfm'
import CopyButton from '../CopyButton'
import { useRef } from 'react'
import PropTypes from 'prop-types'
function Pre({ className, children }) {
const preRef = useRef(null)
return (
<pre className={className} ref={preRef} style="position: relative;">
<CopyButton
className="code-copy-btn"
contentFn={() => preRef.current.textContent}
size={14}
/>
{children}
</pre>
)
}
Pre.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.object.isRequired,
}
export function MarkdownRender(props) {
const linkProperties = {
target: '_blank',
style: 'color: #8ab4f8;',
rel: 'nofollow noopener noreferrer',
}
return (
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[
rehypeKatex,
rehypeRaw,
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
},
],
]}
components={{
a: (props) => (
<a href={props.href} {...linkProperties}>
{props.children}
</a>
),
pre: Pre,
}}
{...props}
>
{props.children}
</ReactMarkdown>
)
}
MarkdownRender.propTypes = {
...ReactMarkdown.propTypes,
}
export default MarkdownRender
+126
View File
@@ -0,0 +1,126 @@
import { defaults } from 'lodash-es'
import Browser from 'webextension-polyfill'
import { isMobile } from './utils/is-mobile'
import { config as toolsConfig } from './content-script/selection-tools'
import { languages } from 'countries-list'
/**
* @typedef {object} Model
* @property {string} value
* @property {string} desc
*/
/**
* @type {Object.<string,Model>}
*/
export const Models = {
chatgptFree: { value: 'text-davinci-002-render-sha', desc: 'ChatGPT (Web)' },
chatgptApi: { value: 'gpt-3.5-turbo', desc: 'ChatGPT (GPT-3.5)' },
gptDavinci: { value: 'text-davinci-003', desc: 'GPT3' },
}
export const chatgptWebModelKeys = ['chatgptFree']
export const gptApiModelKeys = ['gptDavinci']
export const chatgptApiModelKeys = ['chatgptApi']
export const TriggerMode = {
always: 'Always',
questionMark: 'When query ends with question mark (?)',
manually: 'Manually',
}
export const ThemeMode = {
light: 'Light',
dark: 'Dark',
auto: 'Auto',
}
export const languageList = { auto: { name: 'Auto', native: 'Auto' }, ...languages }
export const maxResponseTokenLength = 1000
/**
* @typedef {typeof defaultConfig} UserConfig
*/
export const defaultConfig = {
/** @type {keyof TriggerMode}*/
triggerMode: 'manually',
/** @type {keyof ThemeMode}*/
themeMode: 'auto',
/** @type {keyof Models}*/
modelName: 'chatgptFree',
apiKey: '',
insertAtTop: isMobile(),
siteRegex: 'match nothing',
userSiteRegexOnly: false,
inputQuery: '',
appendQuery: '',
prependQuery: '',
accessToken: '',
tokenSavedOn: 0,
preferredLanguage: navigator.language.substring(0, 2),
userLanguage: navigator.language.substring(0, 2), // unchangeable
customChatGptWebApiUrl: 'https://chat.openai.com',
customChatGptWebApiPath: '/backend-api/conversation',
customOpenAiApiUrl: 'https://api.openai.com',
selectionTools: Object.keys(toolsConfig),
activeSelectionTools: Object.keys(toolsConfig),
// importing configuration will result in gpt-3-encoder being packaged into the output file
siteAdapters: ['bilibili', 'github', 'gitlab', 'quora', 'reddit', 'youtube', 'zhihu'],
activeSiteAdapters: ['bilibili', 'github', 'gitlab', 'quora', 'reddit', 'youtube', 'zhihu'],
}
export async function getUserLanguage() {
return languageList[defaultConfig.userLanguage].name
}
export async function getUserLanguageNative() {
return languageList[defaultConfig.userLanguage].native
}
export async function getPreferredLanguage() {
const config = await getUserConfig()
if (config.preferredLanguage === 'auto') return await getUserLanguage()
return languageList[config.preferredLanguage].name
}
export async function getPreferredLanguageNative() {
const config = await getUserConfig()
if (config.preferredLanguage === 'auto') return await getUserLanguageNative()
return languageList[config.preferredLanguage].native
}
export function isUsingApiKey(config) {
return (
gptApiModelKeys.includes(config.modelName) || chatgptApiModelKeys.includes(config.modelName)
)
}
/**
* get user config from local storage
* @returns {Promise<UserConfig>}
*/
export async function getUserConfig() {
const options = await Browser.storage.local.get(Object.keys(defaultConfig))
return defaults(options, defaultConfig)
}
/**
* set user config to local storage
* @param {Partial<UserConfig>} value
*/
export async function setUserConfig(value) {
await Browser.storage.local.set(value)
}
export async function setAccessToken(accessToken) {
await setUserConfig({ accessToken, tokenSavedOn: Date.now() })
}
const TOKEN_DURATION = 30 * 24 * 3600 * 1000
export async function clearOldAccessToken() {
const duration = Date.now() - (await getUserConfig()).tokenSavedOn
if (duration > TOKEN_DURATION) {
await setAccessToken('')
}
}
+220
View File
@@ -0,0 +1,220 @@
import './styles.scss'
import { render } from 'preact'
import DecisionCard from '../components/DecisionCard'
import { config as siteConfig } from './site-adapters'
import { config as toolsConfig } from './selection-tools'
import { clearOldAccessToken, getUserConfig, setAccessToken, getPreferredLanguage } from '../config'
import {
createElementAtPosition,
getPossibleElementByQuerySelector,
initSession,
isSafari,
} from '../utils'
import FloatingToolbar from '../components/FloatingToolbar'
import Browser from 'webextension-polyfill'
/**
* @param {SiteConfig} siteConfig
* @param {UserConfig} userConfig
*/
async function mountComponent(siteConfig, userConfig) {
if (
!getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery) &&
!getPossibleElementByQuerySelector(siteConfig.appendContainerQuery) &&
!getPossibleElementByQuerySelector(siteConfig.sidebarContainerQuery) &&
!getPossibleElementByQuerySelector([userConfig.prependQuery]) &&
!getPossibleElementByQuerySelector([userConfig.appendQuery])
)
return
document.querySelectorAll('.chat-gpt-container').forEach((e) => e.remove())
let question
if (userConfig.inputQuery) question = await getInput([userConfig.inputQuery])
if (!question && siteConfig) question = await getInput(siteConfig.inputQuery)
document.querySelectorAll('.chat-gpt-container').forEach((e) => e.remove())
const container = document.createElement('div')
container.className = 'chat-gpt-container'
render(
<DecisionCard
session={initSession()}
question={question}
siteConfig={siteConfig}
container={container}
/>,
container,
)
}
/**
* @param {string[]|function} inputQuery
* @returns {Promise<string>}
*/
async function getInput(inputQuery) {
if (typeof inputQuery === 'function') {
const input = await inputQuery()
if (input) return `Reply in ${await getPreferredLanguage()}.\n` + input
return input
}
const searchInput = getPossibleElementByQuerySelector(inputQuery)
if (searchInput && searchInput.value) {
return searchInput.value
}
}
async function prepareForSafari() {
await clearOldAccessToken()
if (location.hostname !== 'chat.openai.com' || location.pathname !== '/api/auth/session') return
const response = document.querySelector('pre').textContent
let data
try {
data = JSON.parse(response)
} catch (error) {
console.error('json error', error)
return
}
if (data.accessToken) {
await setAccessToken(data.accessToken)
}
}
let toolbarContainer
async function prepareForSelectionTools() {
document.addEventListener('mouseup', (e) => {
if (toolbarContainer && toolbarContainer.contains(e.target)) return
if (
toolbarContainer &&
window.getSelection()?.rangeCount > 0 &&
toolbarContainer.contains(window.getSelection()?.getRangeAt(0).endContainer.parentElement)
)
return
if (toolbarContainer) toolbarContainer.remove()
setTimeout(() => {
const selection = window.getSelection()?.toString()
if (selection) {
const position = { x: e.clientX + 15, y: e.clientY - 15 }
toolbarContainer = createElementAtPosition(position.x, position.y)
toolbarContainer.className = 'toolbar-container'
render(
<FloatingToolbar
session={initSession()}
selection={selection}
position={position}
container={toolbarContainer}
/>,
toolbarContainer,
)
}
})
})
document.addEventListener('mousedown', (e) => {
if (toolbarContainer && toolbarContainer.contains(e.target)) return
document.querySelectorAll('.toolbar-container').forEach((e) => e.remove())
})
document.addEventListener('keydown', (e) => {
if (
toolbarContainer &&
!toolbarContainer.contains(e.target) &&
(e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA')
) {
setTimeout(() => {
if (!window.getSelection()?.toString()) toolbarContainer.remove()
})
}
})
}
let menuX, menuY
async function prepareForRightClickMenu() {
document.addEventListener('contextmenu', (e) => {
menuX = e.clientX
menuY = e.clientY
})
Browser.runtime.onMessage.addListener(async (message) => {
if (message.type === 'MENU') {
const data = message.data
if (data.itemId === 'new') {
const position = { x: menuX, y: menuY }
const container = createElementAtPosition(position.x, position.y)
container.className = 'toolbar-container-not-queryable'
render(
<FloatingToolbar
session={initSession()}
selection=""
position={position}
container={container}
triggered={true}
closeable={true}
onClose={() => container.remove()}
/>,
container,
)
} else {
const position = { x: menuX, y: menuY }
const container = createElementAtPosition(position.x, position.y)
container.className = 'toolbar-container-not-queryable'
render(
<FloatingToolbar
session={initSession()}
selection={data.selectionText}
position={position}
container={container}
triggered={true}
closeable={true}
onClose={() => container.remove()}
prompt={await toolsConfig[data.itemId].genPrompt(data.selectionText)}
/>,
container,
)
}
}
})
}
async function prepareForStaticCard() {
let siteRegex
if (userConfig.userSiteRegexOnly) siteRegex = userConfig.siteRegex
else
siteRegex = new RegExp(
(userConfig.siteRegex && userConfig.siteRegex + '|') + Object.keys(siteConfig).join('|'),
)
const matches = location.hostname.match(siteRegex)
if (matches) {
const siteName = matches[0]
if (siteName in siteConfig) {
const siteAction = siteConfig[siteName].action
if (siteAction && siteAction.init) {
await siteAction.init(location.hostname, userConfig, getInput, mountComponent)
}
}
if (
userConfig.siteAdapters.includes(siteName) &&
!userConfig.activeSiteAdapters.includes(siteName)
)
return
mountComponent(siteConfig[siteName], userConfig)
}
}
let userConfig
async function run() {
userConfig = await getUserConfig()
if (isSafari()) await prepareForSafari()
prepareForSelectionTools()
prepareForStaticCard()
prepareForRightClickMenu()
}
run()
@@ -0,0 +1,60 @@
import {
CardHeading,
CardList,
EmojiSmile,
Palette,
QuestionCircle,
Translate,
} from 'react-bootstrap-icons'
import { getPreferredLanguage } from '../../config.mjs'
export const config = {
translate: {
icon: <Translate />,
label: 'Translate',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return (
`Translate the following into ${preferredLanguage} and only show me the translated content.` +
`If it is already in ${preferredLanguage},` +
`translate it into English and only show me the translated content:\n"${selection}"`
)
},
},
summary: {
icon: <CardHeading />,
label: 'Summary',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return `Reply in ${preferredLanguage}.Summarize the following as concisely as possible:\n"${selection}"`
},
},
polish: {
icon: <Palette />,
label: 'Polish',
genPrompt: async (selection) =>
`Check the following content for possible diction and grammar problems,and polish it carefully:\n"${selection}"`,
},
sentiment: {
icon: <EmojiSmile />,
label: 'Sentiment Analysis',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return `Reply in ${preferredLanguage}.Analyze the sentiments expressed in the following content and make a brief summary of the sentiments:\n"${selection}"`
},
},
divide: {
icon: <CardList />,
label: 'Divide Paragraphs',
genPrompt: async (selection) =>
`Divide the following into paragraphs that are easy to read and understand:\n"${selection}"`,
},
ask: {
icon: <QuestionCircle />,
label: 'Ask',
genPrompt: async (selection) => {
const preferredLanguage = await getPreferredLanguage()
return `Reply in ${preferredLanguage}.Analyze the following content and express your opinion,or give your answer:\n"${selection}"`
},
},
}
@@ -0,0 +1 @@
//TODO
@@ -0,0 +1,26 @@
import { config } from '../index'
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
const targetNode = document.getElementById('wrapper_wrapper')
const observer = new MutationObserver(async (records) => {
if (
records.some(
(record) =>
record.type === 'childList' &&
[...record.addedNodes].some((node) => node.id === 'container'),
)
) {
const searchValue = await getInput(config.baidu.inputQuery)
if (searchValue) {
mountComponent(config.baidu, userConfig)
}
}
})
observer.observe(targetNode, { childList: true })
} catch (e) {
/* empty */
}
},
}
@@ -0,0 +1,58 @@
import { cropText } from '../../../utils'
import { config } from '../index.mjs'
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
let oldUrl = location.href
const checkUrlChange = async () => {
if (location.href !== oldUrl) {
oldUrl = location.href
mountComponent(config.bilibili, userConfig)
}
}
window.setInterval(checkUrlChange, 500)
} catch (e) {
/* empty */
}
},
inputQuery: async () => {
try {
const bvid = location.pathname.replace('video', '').replaceAll('/', '')
const p = Number(new URLSearchParams(location.search).get('p') || 1) - 1
const pagelistResponse = await fetch(
`https://api.bilibili.com/x/player/pagelist?bvid=${bvid}`,
)
const pagelistData = await pagelistResponse.json()
const videoList = pagelistData.data
const cid = videoList[p].cid
const title = videoList[p].part
const infoResponse = await fetch(
`https://api.bilibili.com/x/player/v2?bvid=${bvid}&cid=${cid}`,
{
credentials: 'include',
},
)
const infoData = await infoResponse.json()
const subtitleUrl = infoData.data.subtitle.subtitles[0].subtitle_url
const subtitleResponse = await fetch(subtitleUrl)
const subtitleData = await subtitleResponse.json()
const subtitles = subtitleData.body
let subtitleContent = ''
for (let i = 0; i < subtitles.length; i++) {
if (i === subtitles.length - 1) subtitleContent += subtitles[i].content
else subtitleContent += subtitles[i].content + ','
}
return cropText(
`用尽量简练的语言,联系视频标题,对视频进行内容摘要,视频标题为:"${title}",字幕内容为:\n${subtitleContent}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,56 @@
import { cropText, limitedFetch } from '../../../utils'
import { config } from '../index.mjs'
const getPatchUrl = async () => {
const patchUrl = location.origin + location.pathname + '.patch'
const response = await fetch(patchUrl, { method: 'HEAD' })
if (response.ok) return patchUrl
return ''
}
const getPatchData = async (patchUrl) => {
if (!patchUrl) return
let patchData = await limitedFetch(patchUrl, 1024 * 40)
patchData = patchData.substring(patchData.indexOf('---'))
return patchData
}
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
const targetNode = document.querySelector('body')
const observer = new MutationObserver(async (records) => {
if (
records.some(
(record) =>
record.type === 'childList' &&
[...record.addedNodes].some((node) => node.classList.contains('page-responsive')),
)
) {
const patchUrl = await getPatchUrl()
if (patchUrl) {
mountComponent(config.github, userConfig)
}
}
})
observer.observe(targetNode, { childList: true })
} catch (e) {
/* empty */
}
},
inputQuery: async () => {
try {
const patchUrl = await getPatchUrl()
const patchData = await getPatchData(patchUrl)
if (!patchData) return
return cropText(
`Analyze the contents of a git commit,provide a suitable commit message,and summarize the contents of the commit.` +
`The patch contents of this commit are as follows:\n${patchData}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,33 @@
import { cropText, limitedFetch } from '../../../utils'
const getPatchUrl = async () => {
const patchUrl = location.origin + location.pathname + '.patch'
const response = await fetch(patchUrl, { method: 'HEAD' })
if (response.ok) return patchUrl
return ''
}
const getPatchData = async (patchUrl) => {
if (!patchUrl) return
let patchData = await limitedFetch(patchUrl, 1024 * 40)
patchData = patchData.substring(patchData.indexOf('---'))
return patchData
}
export default {
inputQuery: async () => {
try {
const patchUrl = await getPatchUrl()
const patchData = await getPatchData(patchUrl)
if (!patchData) return
return cropText(
`Analyze the contents of a git commit,provide a suitable commit message,and summarize the contents of the commit.` +
`The patch contents of this commit are as follows:\n${patchData}`,
)
} catch (e) {
console.log(e)
}
},
}
+163
View File
@@ -0,0 +1,163 @@
import baidu from './baidu'
import bilibili from './bilibili'
import youtube from './youtube'
import github from './github'
import gitlab from './gitlab'
import zhihu from './zhihu'
import reddit from './reddit'
import quora from './quora'
/**
* @typedef {object} SiteConfigAction
* @property {function} init
*/
/**
* @typedef {object} SiteConfig
* @property {string[]|function} inputQuery - for search box
* @property {string[]} sidebarContainerQuery - prepend child to
* @property {string[]} appendContainerQuery - if sidebarContainer not exists, append child to
* @property {string[]} resultsContainerQuery - prepend child to if insertAtTop is true
* @property {SiteConfigAction} action
*/
/**
* @type {Object.<string,SiteConfig>}
*/
export const config = {
google: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['#rhs'],
appendContainerQuery: ['#rcnt'],
resultsContainerQuery: ['#rso'],
},
bing: {
inputQuery: ["[name='q']"],
sidebarContainerQuery: ['#b_context'],
appendContainerQuery: [],
resultsContainerQuery: ['#b_results'],
},
yahoo: {
inputQuery: ["input[name='p']"],
sidebarContainerQuery: ['#right', '.Contents__inner.Contents__inner--sub'],
appendContainerQuery: ['#cols', '#contents__wrap'],
resultsContainerQuery: [
'#main-algo',
'.searchCenterMiddle',
'.Contents__inner.Contents__inner--main',
'#contents',
],
},
duckduckgo: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.results--sidebar.js-results-sidebar'],
appendContainerQuery: ['#links_wrapper'],
resultsContainerQuery: ['.results'],
},
startpage: {
inputQuery: ["input[name='query']"],
sidebarContainerQuery: ['.layout-web__sidebar.layout-web__sidebar--web'],
appendContainerQuery: ['.layout-web__body.layout-web__body--desktop'],
resultsContainerQuery: ['.mainline-results'],
},
baidu: {
inputQuery: ["input[id='kw']"],
sidebarContainerQuery: ['#content_right'],
appendContainerQuery: ['#container'],
resultsContainerQuery: ['#content_left', '#results'],
action: {
init: baidu.init,
},
},
kagi: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.right-content-box._0_right_sidebar'],
appendContainerQuery: ['#_0_app_content'],
resultsContainerQuery: ['#main', '#app'],
},
yandex: {
inputQuery: ["input[name='text']"],
sidebarContainerQuery: ['#search-result-aside'],
appendContainerQuery: [],
resultsContainerQuery: ['#search-result'],
},
naver: {
inputQuery: ["input[name='query']"],
sidebarContainerQuery: ['#sub_pack'],
appendContainerQuery: ['#content'],
resultsContainerQuery: ['#main_pack', '#ct'],
},
brave: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['#side-right'],
appendContainerQuery: [],
resultsContainerQuery: ['#results'],
},
searx: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['#sidebar_results', '#sidebar'],
appendContainerQuery: [],
resultsContainerQuery: ['#urls', '#main_results', '#results'],
},
ecosia: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.sidebar.web__sidebar'],
appendContainerQuery: ['#main'],
resultsContainerQuery: ['.mainline'],
},
neeva: {
inputQuery: ["input[name='q']"],
sidebarContainerQuery: ['.result-group-layout__stickyContainer-iDIO8'],
appendContainerQuery: ['.search-index__searchHeaderContainer-2JD6q'],
resultsContainerQuery: ['.result-group-layout__component-1jzTe', '#search'],
},
bilibili: {
inputQuery: bilibili.inputQuery,
sidebarContainerQuery: ['#danmukuBox'],
appendContainerQuery: [],
resultsContainerQuery: [],
action: {
init: bilibili.init,
},
},
youtube: {
inputQuery: youtube.inputQuery,
sidebarContainerQuery: ['#secondary'],
appendContainerQuery: [],
resultsContainerQuery: [],
action: {
init: youtube.init,
},
},
github: {
inputQuery: github.inputQuery,
sidebarContainerQuery: ['#diff', '.commit'],
appendContainerQuery: [],
resultsContainerQuery: [],
action: {
init: github.init,
},
},
gitlab: {
inputQuery: gitlab.inputQuery,
sidebarContainerQuery: ['.js-commit-box-info'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
zhihu: {
inputQuery: zhihu.inputQuery,
sidebarContainerQuery: ['.Question-sideColumn', '.Post-Header'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
reddit: {
inputQuery: reddit.inputQuery,
sidebarContainerQuery: ['.side .spacer .linkinfo'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
quora: {
inputQuery: quora.inputQuery,
sidebarContainerQuery: ['.q-box.PageContentsLayout___StyledBox-d2uxks-0'],
appendContainerQuery: [],
resultsContainerQuery: [],
},
}
@@ -0,0 +1,26 @@
import { cropText } from '../../../utils'
export default {
inputQuery: async () => {
try {
if (location.pathname === '/') return
const texts = document.querySelectorAll('.q-box.qu-userSelect--text')
let title
if (texts.length > 0) title = texts[0].textContent
let answers = ''
if (texts.length > 1)
for (let i = 1; i < texts.length; i++) {
answers += `answer${i}:${texts[i].textContent}|`
}
return cropText(
`Below is the content from a question and answer platform,giving the corresponding summary and your opinion on it.` +
`The question is:'${title}',` +
`Some answers are as follows:\n${answers}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,25 @@
import { cropText } from '../../../utils'
export default {
inputQuery: async () => {
try {
const title = document.querySelector('.entry .title').textContent
const texts = document.querySelectorAll('.entry .usertext-body')
let description
if (texts.length > 0) description = texts[0].textContent
let answers = ''
if (texts.length > 1)
for (let i = 1; i < texts.length; i++) {
answers += `answer${i}:${texts[i].textContent}|`
}
return cropText(
`Below is the content from a social forum,giving the corresponding summary and your opinion on it.` +
`The title is:'${title}',and the further description of the title is:'${description}'.` +
`Some answers are as follows:\n${answers}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1 @@
//TODO
@@ -0,0 +1,53 @@
import { cropText } from '../../../utils'
import { config } from '../index.mjs'
export default {
init: async (hostname, userConfig, getInput, mountComponent) => {
try {
let oldUrl = location.href
const checkUrlChange = async () => {
if (location.href !== oldUrl) {
oldUrl = location.href
mountComponent(config.youtube, userConfig)
}
}
window.setInterval(checkUrlChange, 500)
} catch (e) {
/* empty */
}
},
inputQuery: async () => {
try {
const docText = await (
await fetch(location.href, {
credentials: 'include',
})
).text()
let subtitleUrl = docText.substring(docText.indexOf('https://www.youtube.com/api/timedtext'))
subtitleUrl = subtitleUrl.substring(0, subtitleUrl.indexOf('"'))
subtitleUrl = subtitleUrl.replaceAll('\\u0026', '&')
let title = docText.substring(docText.indexOf('"title":"') + '"title":"'.length)
title = title.substring(0, title.indexOf('","'))
const subtitleResponse = await fetch(subtitleUrl)
let subtitleData = await subtitleResponse.text()
let subtitleContent = ''
while (subtitleData.indexOf('">') !== -1) {
subtitleData = subtitleData.substring(subtitleData.indexOf('">') + 2)
subtitleContent += subtitleData.substring(0, subtitleData.indexOf('<')) + ','
}
await new Promise((r) => setTimeout(r, 1000))
return cropText(
`Provide a brief summary of the video using concise language and incorporating the video title.` +
`The video title is:"${title}".The subtitle content is as follows:\n${subtitleContent}`,
)
} catch (e) {
console.log(e)
}
},
}
@@ -0,0 +1,29 @@
import { cropText } from '../../../utils'
export default {
inputQuery: async () => {
try {
const title = document.querySelector('.QuestionHeader-title')?.textContent
if (title) {
const description = document.querySelector('.QuestionRichText')?.textContent
const answer = document.querySelector('.AnswerItem .RichText')?.textContent
return cropText(
`以下是一个问答平台的提问与回答内容,给出相应的摘要,以及你对此的看法.问题是:"${title}",问题的进一步描述是:"${description}".` +
`其中一个回答如下:\n${answer}`,
)
} else {
const title = document.querySelector('.Post-Title')?.textContent
const description = document.querySelector('.Post-RichText')?.textContent
if (title) {
return cropText(
`以下是一篇文章,给出相应的摘要,以及你对此的看法.标题是:"${title}",内容是:\n"${description}"`,
)
}
}
} catch (e) {
console.log(e)
}
},
}
+236
View File
@@ -0,0 +1,236 @@
[data-theme='auto'] {
@import 'github-markdown-css/github-markdown.css';
@media screen and (prefers-color-scheme: dark) {
@import 'highlight.js/scss/github-dark.scss';
--font-color: #c9d1d9;
--theme-color: #202124;
--theme-border-color: #3c4043;
--dragbar-color: #3c4043;
}
@media screen and (prefers-color-scheme: light) {
@import 'highlight.js/scss/github.scss';
--font-color: #24292f;
--theme-color: #eaecf0;
--theme-border-color: #aeafb2;
--dragbar-color: #dfe0e1;
}
}
[data-theme='dark'] {
@import 'highlight.js/scss/github-dark.scss';
@import 'github-markdown-css/github-markdown-dark.css';
--font-color: #c9d1d9;
--theme-color: #202124;
--theme-border-color: #3c4043;
--dragbar-color: #3c4043;
}
[data-theme='light'] {
@import 'highlight.js/scss/github.scss';
@import 'github-markdown-css/github-markdown-light.css';
--font-color: #24292f;
--theme-color: #eaecf0;
--theme-border-color: #aeafb2;
--dragbar-color: #ccced0;
}
.sidebar-free {
margin-left: 60px;
}
.chat-gpt-container {
width: 100%;
flex-basis: 0;
flex-grow: 1;
margin-bottom: 20px;
.gpt-inner {
border-radius: 8px;
border: 1px solid;
overflow: hidden;
border-color: var(--theme-border-color);
background-color: var(--theme-color);
margin: 0;
hr {
height: 1px;
background-color: var(--theme-border-color);
border: none;
}
}
.markdown-body {
padding: 5px 15px 10px;
background-color: var(--theme-color);
color: var(--font-color);
max-height: 800px;
overflow-y: auto;
ul,
ol {
padding-left: 1.5em;
}
ol {
list-style: none;
counter-reset: item;
li {
counter-increment: item;
&:before {
content: counter(item) '. ';
margin-left: -0.75em;
}
}
}
}
.icon-and-text {
color: var(--font-color);
display: flex;
align-items: center;
padding: 15px;
gap: 6px;
}
.manual-btn {
cursor: pointer;
}
.gpt-loading {
color: var(--font-color);
animation: gpt-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.code-copy-btn {
color: inherit;
position: absolute;
right: 10px;
top: 3px;
cursor: pointer;
}
:is(.answer, .question, .error) {
font-size: 15px;
line-height: 1.6;
border-radius: 8px;
word-break: break-all;
pre {
margin-top: 10px;
}
& > p {
margin-bottom: 10px;
}
code {
white-space: pre-wrap;
word-break: break-word;
border-radius: 8px;
.hljs {
padding: 0;
}
}
p {
margin: 0;
}
}
.gpt-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
color: var(--font-color);
p {
font-weight: bold;
}
.gpt-feedback {
display: flex;
gap: 6px;
cursor: pointer;
}
.gpt-feedback-selected {
color: #f08080;
}
.gpt-util-icon {
cursor: pointer;
}
}
.error {
color: #ec4336;
}
.interact-input {
box-sizing: border-box;
padding: 5px 15px;
border: 0;
border-top: 1px solid var(--theme-border-color);
width: 100%;
background-color: var(--theme-color);
color: var(--font-color);
resize: none;
max-height: 240px;
}
.dragbar {
cursor: move;
width: 250px;
height: 12px;
border-radius: 10px;
background-color: var(--dragbar-color);
}
}
@keyframes gpt-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.gpt-selection-toolbar {
display: flex;
align-items: center;
border-radius: 15px;
padding: 2px;
background-color: #ffffff;
box-shadow: 4px 2px 4px rgba(0, 0, 0, 0.2);
}
.gpt-selection-toolbar-button {
margin-left: 2px;
padding: 2px;
border-radius: 30px;
background-color: #ffffff;
color: #24292f;
cursor: pointer;
}
.gpt-selection-toolbar-button:hover {
background-color: #d4d5da;
}
.gpt-selection-window {
width: 600px;
height: auto;
border-radius: 8px;
background-color: var(--theme-color);
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.2);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

After

Width:  |  Height:  |  Size: 16 KiB

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