Repository: josStorer/chatGPT-search-engine-extension
Branch: main
Commit: e50db01c9c1e
Files: 66
Total size: 127.2 KB
Directory structure:
gitextract_q1_r9qfc/
├── .eslintrc.json
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── pr-tests.yml
│ ├── pre-release-build.yml
│ ├── scripts/
│ │ └── verify-search-engine-configs.mjs
│ ├── tagged-release.yml
│ └── verify-configs.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── build.mjs
├── package.json
├── safari/
│ ├── appdmg.json
│ ├── build.sh
│ ├── export-options.plist
│ └── project.patch
├── screenshot/
│ └── engines/
│ └── README.md
└── src/
├── background/
│ ├── apis/
│ │ ├── chatgpt-web.mjs
│ │ └── openai-api.mjs
│ └── index.mjs
├── components/
│ ├── ConversationCard/
│ │ └── index.jsx
│ ├── ConversationItem/
│ │ └── index.jsx
│ ├── CopyButton/
│ │ └── index.jsx
│ ├── DecisionCard/
│ │ └── index.jsx
│ ├── FeedbackForChatGPTWeb/
│ │ └── index.jsx
│ ├── FloatingToolbar/
│ │ └── index.jsx
│ ├── InputBox/
│ │ └── index.jsx
│ └── MarkdownRender/
│ ├── markdown-without-katex.jsx
│ └── markdown.jsx
├── config.mjs
├── content-script/
│ ├── index.jsx
│ ├── selection-tools/
│ │ └── index.mjs
│ ├── site-adapters/
│ │ ├── arxiv/
│ │ │ └── index.mjs
│ │ ├── baidu/
│ │ │ └── index.mjs
│ │ ├── bilibili/
│ │ │ └── index.mjs
│ │ ├── github/
│ │ │ └── index.mjs
│ │ ├── gitlab/
│ │ │ └── index.mjs
│ │ ├── index.mjs
│ │ ├── quora/
│ │ │ └── index.mjs
│ │ ├── reddit/
│ │ │ └── index.mjs
│ │ ├── stackoverflow/
│ │ │ └── index.mjs
│ │ ├── youtube/
│ │ │ └── index.mjs
│ │ └── zhihu/
│ │ └── index.mjs
│ └── styles.scss
├── manifest.json
├── manifest.v2.json
├── popup/
│ ├── Popup.jsx
│ ├── index.html
│ ├── index.jsx
│ └── styles.scss
└── utils/
├── create-element-at-position.mjs
├── crop-text.mjs
├── ends-with-question-mark.mjs
├── fetch-sse.mjs
├── get-conversation-pairs.mjs
├── get-possible-element-by-query-selector.mjs
├── index.mjs
├── init-session.mjs
├── is-mobile.mjs
├── is-safari.mjs
├── limited-fetch.mjs
├── set-element-position-in-viewport.mjs
├── stream-async-iterable.mjs
└── update-ref-height.mjs
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"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"
}
}
}
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore"
include: "scope"
================================================
FILE: .github/workflows/pr-tests.yml
================================================
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
================================================
FILE: .github/workflows/pre-release-build.yml
================================================
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
================================================
FILE: .github/workflows/scripts/verify-search-engine-configs.mjs
================================================
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()
================================================
FILE: .github/workflows/tagged-release.yml
================================================
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
================================================
FILE: .github/workflows/verify-configs.yml
================================================
name: verify-configs
on:
workflow_dispatch:
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
================================================
FILE: .gitignore
================================================
.idea/
.vscode/
node_modules/
build/
.DS_Store
*.zip
================================================
FILE: .prettierignore
================================================
build/
src/manifest.json
src/manifest.v2.json
================================================
FILE: .prettierrc
================================================
{
"printWidth": 100,
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}
================================================
FILE: LICENSE
================================================
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.
================================================
FILE: README.md
================================================
# This repo has moved to [ChatGPTBox](https://github.com/josStorer/chatGPTBox). Due to the upstream repo being acquired and closed source, and there has been a period of time where issues and PRs in the upstream have gone unhandled. I decided to publish this extension to the store and keep open-source
[](https://github.com/josStorer/chatGPT-search-engine-extension/actions/workflows/verify-configs.yml)
[](https://github.com/josStorer/chatGPT-search-engine-extension/releases/latest)
[Installation](#installation)
A browser extension to display ChatGPT response alongside Search Engine results, supports Chrome/Edge/Firefox/Safari(macOS) and
Android.
Support most search engines, including Google, Bing, Yahoo, DuckDuckGo, StartPage, Baidu, Kagi, Yandex, Naver, Brave,
Searx, Ecosia, Neeva in total.
Request more search engine support in [#6](https://github.com/josStorer/chatGPT-search-engine-extension/issues/6)
See more in [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases)
and [Pre-release build](https://github.com/josStorer/chatGPT-search-engine-extension/actions/workflows/pre-release-build.yml)
## Notice
This repository exists only to support some features that are not supported or denied
in [upstream](https://github.com/wong2/chat-gpt-google-extension), and for ethical reasons, I have not uploaded it to
any app store. It isn't related to any extensions of the same name that may exist in some app store
## Diff with upstream
<details>
<summary>Details:</summary>
- Support StartPage, Ecosia, Neeva, Searx(searx.tiekoetter.com, searx.fmac.xyz, searx.be and more)
- Android support
- Safari(macOS) support
- Custom mount point (e.g. for some unsupported engines)
- Preview your setting (e.g. theme, mount point) in realtime
- Katex: [upstream#75](https://github.com/wong2/chat-gpt-google-extension/pull/75)
- Linkify in ReactMarkdown
- Interactive mode: [upstream#103](https://github.com/wong2/chat-gpt-google-extension/pull/103), now support generating
separate sessions for each page
- Fix answer being overwritten due to "network error" or other errors
- Theme switcher: [#9](https://github.com/josStorer/chatGPT-search-engine-extension/pull/9)
- Collapse answers
- Popup Setting Window (Upstream has switched to a standalone options page)
- Allow `Insert chatGPT at the top of search results` in Setting Window
- Switch to webpack
- Javascript
- See more in [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases)
</details>
## Upstream supports, but not here
<details>
<summary>Details:</summary>
(I don't think these contents are very valuable, but I could be wrong, so if you think of some suitable application
scenario or related need, please create an issue)
1. Upstream supports setting the desired language, and will force the relevant words to be inserted at the end after you
enter the question
- but I think, users always expect to get the language corresponding to their question, when you want to get a
different language, you should take the initiative to specify when searching, which is also consistent with the
habits of using search engines, and this fork supports interactive mode, you can also continue to tell chatGPT
what you want. Once you set up forced insertion, it will change the actual content of the user's question, for
example, when you configure French and search in English, chatGPT will always reply to you in French, when you
expect a reply in English, you will have to open the settings page, make changes, then refresh and ask the
question again, which I think is a very bad process
2. The upstream extension popup window has an embedded chatGPT page (iframe)
- but you have to open the chatGPT website and log in to use it, so I think, in that case, why not use it directly
on the official website? In addition, interactive mode is already supported here, and each page can be used as a
separate session, so this feature is less necessary
</details>
## Preview
- [SearchEngines](screenshot/engines/README.md)
- Code highlight, interactive mode, dark mode, copy/collapse answers, theme switcher and more
(Click on the extension icon to open the setting window)

- LaTeX

- Android

## Installation
### Install to Chrome/Edge
1. Download `chromium.zip` from [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases).
2. Unzip the file.
3. In Chrome/Edge go to the extensions page (`chrome://extensions` or `edge://extensions`).
4. Enable Developer Mode.
5. Drag the unzipped folder anywhere on the page to import it (do not delete the folder afterwards).
### Install to Firefox
1. Download `firefox.zip` from [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases).
2. Unzip the file.
3. Go to `about:debugging`, click "This Firefox" on the sidebar.
4. Click "Load Temporary Add-on" button, then select any file in the unzipped folder.
### Install to Android
1. Install [Kiwi Browser](https://play.google.com/store/apps/details?id=com.kiwibrowser.browser) or other mobile browsers that support installing extensions from local files.
2. Download `chromium.zip` from [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases) on
your phone.
3. Go to `Extensions` and enable developer mode.
4. Click `+ (from .zip/.crx/.user.js)` button and load the downloaded zip file.
5. Click the browser option button, scroll down and click on the extension icon to open the setting window.
6. Enable `Insert chatGPT at the top of search results`.
### Install to Safari(macOS)
1. Download `safari.dmg` from [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases).
2. Double-click `safari.dmg` to open it and drag the extension’s icon to your Applications folder
3. Run this extension in your Applications folder
4. Click `Quit and Open Safari Settings...`
5. Click `Advanced` in Safari Settings and then turn on `Show Develop menu in menu bar`
6. Click `Develop` in Safari menu bar and then turn on `Allow Unsigned Extensions`
7. You will see this extension in Extensions of Safari Settings, turn on it
8. Click `Always Allow on Every Website`
## Enable for single website
1. Click on the extension icon to open the popup setting window.
2. Click `Advanced`.
3. Input the website name (of the hostname) in `Custom Site Regex`, e.g. google
4. Enable `Only use Custom Site Regex...`
## Build from source
1. Clone the repo
2. Install dependencies with `npm install`
3. `npm run build`
4. Load `build/chromium/` or `build/firefox/` directory to your browser
## My contributions
- [Pull Requests](https://github.com/wong2/chat-gpt-google-extension/pulls?q=is%3Apr+author%3AjosStorer+)
- ### Other
- Merge and improve some PRs
- Support for most search engines
- Android support
- Safari(macOS) support
- Custom mount point
- Preview your setting in realtime
- Fix answer being overwritten due to "network error" or other errors
- Linkify in ReactMarkdown
- Generate separate sessions for each page
- Code highlight
- Collapse answers
- Copy answers
- Allow insert chatGPT at the top of search results
- Automated build workflow (with esbuild/webpack)
- Verify search engine configs automatically
- See more in [Releases](https://github.com/josStorer/chatGPT-search-engine-extension/releases)
## Credit
This project is forked from [wong2/chat-gpt-google-extension](https://github.com/wong2/chat-gpt-google-extension) and
detached since 14 December of 2022
The original repository is inspired by [ZohaibAhmed/ChatGPT-Google](https://github.com/ZohaibAhmed/ChatGPT-Google) ([upstream-c54528b](https://github.com/wong2/chatgpt-google-extension/commit/c54528b0e13058ab78bfb433c92603db017d1b6b))
================================================
FILE: build.mjs
================================================
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()
================================================
FILE: package.json
================================================
{
"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"
}
}
================================================
FILE: safari/appdmg.json
================================================
{
"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" }
]
}
================================================
FILE: safari/build.sh
================================================
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
================================================
FILE: safari/export-options.plist
================================================
<?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>
================================================
FILE: safari/project.patch
================================================
--- 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,
================================================
FILE: screenshot/engines/README.md
================================================
The images below are for preview purposes only and this project actually supports style adaptation now













================================================
FILE: src/background/apis/chatgpt-web.mjs
================================================
// 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}`)
},
})
}
================================================
FILE: src/background/apis/openai-api.mjs
================================================
// 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}`)
},
})
}
================================================
FILE: src/background/index.mjs
================================================
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,
})
})
})
================================================
FILE: src/components/ConversationCard/index.jsx
================================================
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)
================================================
FILE: src/components/ConversationItem/index.jsx
================================================
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
================================================
FILE: src/components/CopyButton/index.jsx
================================================
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
================================================
FILE: src/components/DecisionCard/index.jsx
================================================
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
================================================
FILE: src/components/FeedbackForChatGPTWeb/index.jsx
================================================
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)
================================================
FILE: src/components/FloatingToolbar/index.jsx
================================================
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
================================================
FILE: src/components/InputBox/index.jsx
================================================
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
================================================
FILE: src/components/MarkdownRender/markdown-without-katex.jsx
================================================
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
================================================
FILE: src/components/MarkdownRender/markdown.jsx
================================================
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
================================================
FILE: src/config.mjs
================================================
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('')
}
}
================================================
FILE: src/content-script/index.jsx
================================================
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()
================================================
FILE: src/content-script/selection-tools/index.mjs
================================================
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}"`
},
},
}
================================================
FILE: src/content-script/site-adapters/arxiv/index.mjs
================================================
//TODO
================================================
FILE: src/content-script/site-adapters/baidu/index.mjs
================================================
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 */
}
},
}
================================================
FILE: src/content-script/site-adapters/bilibili/index.mjs
================================================
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)
}
},
}
================================================
FILE: src/content-script/site-adapters/github/index.mjs
================================================
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)
}
},
}
================================================
FILE: src/content-script/site-adapters/gitlab/index.mjs
================================================
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)
}
},
}
================================================
FILE: src/content-script/site-adapters/index.mjs
================================================
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: [],
},
}
================================================
FILE: src/content-script/site-adapters/quora/index.mjs
================================================
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)
}
},
}
================================================
FILE: src/content-script/site-adapters/reddit/index.mjs
================================================
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)
}
},
}
================================================
FILE: src/content-script/site-adapters/stackoverflow/index.mjs
================================================
//TODO
================================================
FILE: src/content-script/site-adapters/youtube/index.mjs
================================================
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()
const subtitleUrlStartAt = docText.indexOf('https://www.youtube.com/api/timedtext')
if (subtitleUrlStartAt === -1) return
let subtitleUrl = docText.substring(subtitleUrlStartAt)
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)
if (!subtitleResponse.ok) return
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)
}
},
}
================================================
FILE: src/content-script/site-adapters/zhihu/index.mjs
================================================
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)
}
},
}
================================================
FILE: src/content-script/styles.scss
================================================
[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);
}
================================================
FILE: src/manifest.json
================================================
{
"name": "ChatGPT for Search Engine",
"description": "Display ChatGPT response alongside Search Engine results",
"version": "2.0.0",
"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>"
]
}
]
}
================================================
FILE: src/manifest.v2.json
================================================
{
"name": "ChatGPT for Search Engine",
"description": "Display ChatGPT response alongside Search Engine results",
"version": "2.0.0",
"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"
]
}
================================================
FILE: src/popup/Popup.jsx
================================================
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
================================================
FILE: src/popup/index.html
================================================
<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>
================================================
FILE: src/popup/index.jsx
================================================
import { render } from 'preact'
import Popup from './Popup'
render(<Popup />, document.getElementById('app'))
================================================
FILE: src/popup/styles.scss
================================================
[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);
}
}
================================================
FILE: src/utils/create-element-at-position.mjs
================================================
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
}
================================================
FILE: src/utils/crop-text.mjs
================================================
// 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
}
================================================
FILE: src/utils/ends-with-question-mark.mjs
================================================
export function endsWithQuestionMark(question) {
return (
question.endsWith('?') || // ASCII
question.endsWith('?') || // Chinese/Japanese
question.endsWith('؟') || // Arabic
question.endsWith('⸮') // Arabic
)
}
================================================
FILE: src/utils/fetch-sse.mjs
================================================
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()
}
================================================
FILE: src/utils/get-conversation-pairs.mjs
================================================
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
}
================================================
FILE: src/utils/get-possible-element-by-query-selector.mjs
================================================
export function getPossibleElementByQuerySelector(queryArray) {
for (const query of queryArray) {
if (query) {
try {
const element = document.querySelector(query)
if (element) {
return element
}
} catch (e) {
/* empty */
}
}
}
}
================================================
FILE: src/utils/index.mjs
================================================
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'
================================================
FILE: src/utils/init-session.mjs
================================================
/**
* @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,
}
}
================================================
FILE: src/utils/is-mobile.mjs
================================================
// 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
}
================================================
FILE: src/utils/is-safari.mjs
================================================
export function isSafari() {
return navigator.vendor === 'Apple Computer, Inc.'
}
================================================
FILE: src/utils/limited-fetch.mjs
================================================
// 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)
}
})
}
================================================
FILE: src/utils/set-element-position-in-viewport.mjs
================================================
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 }
}
================================================
FILE: src/utils/stream-async-iterable.mjs
================================================
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()
}
}
================================================
FILE: src/utils/update-ref-height.mjs
================================================
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`
}
gitextract_q1_r9qfc/
├── .eslintrc.json
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── pr-tests.yml
│ ├── pre-release-build.yml
│ ├── scripts/
│ │ └── verify-search-engine-configs.mjs
│ ├── tagged-release.yml
│ └── verify-configs.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── build.mjs
├── package.json
├── safari/
│ ├── appdmg.json
│ ├── build.sh
│ ├── export-options.plist
│ └── project.patch
├── screenshot/
│ └── engines/
│ └── README.md
└── src/
├── background/
│ ├── apis/
│ │ ├── chatgpt-web.mjs
│ │ └── openai-api.mjs
│ └── index.mjs
├── components/
│ ├── ConversationCard/
│ │ └── index.jsx
│ ├── ConversationItem/
│ │ └── index.jsx
│ ├── CopyButton/
│ │ └── index.jsx
│ ├── DecisionCard/
│ │ └── index.jsx
│ ├── FeedbackForChatGPTWeb/
│ │ └── index.jsx
│ ├── FloatingToolbar/
│ │ └── index.jsx
│ ├── InputBox/
│ │ └── index.jsx
│ └── MarkdownRender/
│ ├── markdown-without-katex.jsx
│ └── markdown.jsx
├── config.mjs
├── content-script/
│ ├── index.jsx
│ ├── selection-tools/
│ │ └── index.mjs
│ ├── site-adapters/
│ │ ├── arxiv/
│ │ │ └── index.mjs
│ │ ├── baidu/
│ │ │ └── index.mjs
│ │ ├── bilibili/
│ │ │ └── index.mjs
│ │ ├── github/
│ │ │ └── index.mjs
│ │ ├── gitlab/
│ │ │ └── index.mjs
│ │ ├── index.mjs
│ │ ├── quora/
│ │ │ └── index.mjs
│ │ ├── reddit/
│ │ │ └── index.mjs
│ │ ├── stackoverflow/
│ │ │ └── index.mjs
│ │ ├── youtube/
│ │ │ └── index.mjs
│ │ └── zhihu/
│ │ └── index.mjs
│ └── styles.scss
├── manifest.json
├── manifest.v2.json
├── popup/
│ ├── Popup.jsx
│ ├── index.html
│ ├── index.jsx
│ └── styles.scss
└── utils/
├── create-element-at-position.mjs
├── crop-text.mjs
├── ends-with-question-mark.mjs
├── fetch-sse.mjs
├── get-conversation-pairs.mjs
├── get-possible-element-by-query-selector.mjs
├── index.mjs
├── init-session.mjs
├── is-mobile.mjs
├── is-safari.mjs
├── limited-fetch.mjs
├── set-element-position-in-viewport.mjs
├── stream-async-iterable.mjs
└── update-ref-height.mjs
SYMBOL INDEX (67 symbols across 28 files)
FILE: .github/workflows/scripts/verify-search-engine-configs.mjs
function verify (line 136) | async function verify(errorTag, urls, headers, queryNames) {
function main (line 177) | async function main() {
FILE: build.mjs
function deleteOldDir (line 17) | async function deleteOldDir() {
function runWebpack (line 21) | async function runWebpack(isWithoutKatex, callback) {
function zipFolder (line 182) | async function zipFolder(dir) {
function copyFiles (line 192) | async function copyFiles(entryPoints, targetDir) {
function finishOutput (line 201) | async function finishOutput(outputDirSuffix) {
function generateWebpackCallback (line 230) | function generateWebpackCallback(finishOutputFunc) {
function build (line 242) | async function build() {
FILE: src/background/apis/chatgpt-web.mjs
function request (line 7) | async function request(token, method, path, data) {
function sendMessageFeedback (line 22) | async function sendMessageFeedback(token, data) {
function setConversationProperty (line 26) | async function setConversationProperty(token, conversationId, propertyOb...
function sendModerations (line 30) | async function sendModerations(token, question, conversationId, messageI...
function getModels (line 39) | async function getModels(token) {
function generateAnswersWithChatgptWebApi (line 50) | async function generateAnswersWithChatgptWebApi(port, question, session,...
FILE: src/background/apis/openai-api.mjs
function generateAnswersWithGptCompletionApi (line 30) | async function generateAnswersWithGptCompletionApi(
function generateAnswersWithChatgptApi (line 100) | async function generateAnswersWithChatgptApi(port, question, session, ap...
FILE: src/background/index.mjs
constant KEY_ACCESS_TOKEN (line 19) | const KEY_ACCESS_TOKEN = 'accessToken'
function getAccessToken (line 25) | async function getAccessToken() {
FILE: src/components/ConversationCard/index.jsx
class ConversationItemData (line 15) | class ConversationItemData extends Object {
method constructor (line 22) | constructor(type, content, session = null, done = false) {
function ConversationCard (line 31) | function ConversationCard(props) {
FILE: src/components/ConversationItem/index.jsx
function ConversationItem (line 8) | function ConversationItem({ type, content, session, done }) {
FILE: src/components/CopyButton/index.jsx
function CopyButton (line 11) | function CopyButton({ className, contentFn, size }) {
FILE: src/components/DecisionCard/index.jsx
function DecisionCard (line 9) | function DecisionCard(props) {
FILE: src/components/FloatingToolbar/index.jsx
function FloatingToolbar (line 12) | function FloatingToolbar(props) {
FILE: src/components/InputBox/index.jsx
function InputBox (line 5) | function InputBox({ onSubmit, enabled }) {
FILE: src/components/MarkdownRender/markdown-without-katex.jsx
function Pre (line 9) | function Pre({ className, children }) {
function MarkdownRender (line 28) | function MarkdownRender(props) {
FILE: src/components/MarkdownRender/markdown.jsx
function Pre (line 12) | function Pre({ className, children }) {
function MarkdownRender (line 31) | function MarkdownRender(props) {
FILE: src/config.mjs
function getUserLanguage (line 72) | async function getUserLanguage() {
function getUserLanguageNative (line 76) | async function getUserLanguageNative() {
function getPreferredLanguage (line 80) | async function getPreferredLanguage() {
function getPreferredLanguageNative (line 86) | async function getPreferredLanguageNative() {
function isUsingApiKey (line 92) | function isUsingApiKey(config) {
function getUserConfig (line 102) | async function getUserConfig() {
function setUserConfig (line 111) | async function setUserConfig(value) {
function setAccessToken (line 115) | async function setAccessToken(accessToken) {
constant TOKEN_DURATION (line 119) | const TOKEN_DURATION = 30 * 24 * 3600 * 1000
function clearOldAccessToken (line 121) | async function clearOldAccessToken() {
FILE: src/content-script/index.jsx
function mountComponent (line 20) | async function mountComponent(siteConfig, userConfig) {
function getInput (line 54) | async function getInput(inputQuery) {
function prepareForSafari (line 66) | async function prepareForSafari() {
function prepareForSelectionTools (line 87) | async function prepareForSelectionTools() {
function prepareForRightClickMenu (line 136) | async function prepareForRightClickMenu() {
function prepareForStaticCard (line 183) | async function prepareForStaticCard() {
function run (line 212) | async function run() {
FILE: src/popup/Popup.jsx
function GeneralPart (line 22) | function GeneralPart({ config, updateConfig }) {
function AdvancedPart (line 164) | function AdvancedPart({ config, updateConfig }) {
function SelectionTools (line 265) | function SelectionTools({ config, updateConfig }) {
function SiteAdapters (line 292) | function SiteAdapters({ config, updateConfig }) {
function Donation (line 319) | function Donation() {
function Footer (line 343) | function Footer({ currentVersion, latestVersion }) {
function Popup (line 378) | function Popup() {
FILE: src/utils/create-element-at-position.mjs
function createElementAtPosition (line 1) | function createElementAtPosition(x = 0, y = 0, zIndex = 2147483647) {
FILE: src/utils/crop-text.mjs
function cropText (line 27) | function cropText(
FILE: src/utils/ends-with-question-mark.mjs
function endsWithQuestionMark (line 1) | function endsWithQuestionMark(question) {
FILE: src/utils/fetch-sse.mjs
function fetchSSE (line 4) | async function fetchSSE(resource, options) {
FILE: src/utils/get-conversation-pairs.mjs
function getConversationPairs (line 1) | function getConversationPairs(records, isChatgpt) {
FILE: src/utils/get-possible-element-by-query-selector.mjs
function getPossibleElementByQuerySelector (line 1) | function getPossibleElementByQuerySelector(queryArray) {
FILE: src/utils/init-session.mjs
function initSession (line 14) | function initSession({
FILE: src/utils/is-mobile.mjs
function isMobile (line 3) | async function isMobile() {
FILE: src/utils/is-safari.mjs
function isSafari (line 1) | function isSafari() {
FILE: src/utils/limited-fetch.mjs
function limitedFetch (line 3) | async function limitedFetch(url, maxBytes) {
FILE: src/utils/set-element-position-in-viewport.mjs
function setElementPositionInViewport (line 1) | function setElementPositionInViewport(element, x = 0, y = 0) {
FILE: src/utils/update-ref-height.mjs
function updateRefHeight (line 1) | function updateRefHeight(ref) {
Condensed preview — 66 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (140K chars).
[
{
"path": ".eslintrc.json",
"chars": 423,
"preview": "{\n \"env\": {\n \"browser\": true,\n \"es2021\": true\n },\n \"extends\": [\"eslint:recommended\", \"plugin:react/recommended\""
},
{
"path": ".github/dependabot.yml",
"chars": 182,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"github-actions\"\n directory: \"/\"\n schedule:\n interval: \"weekly\"\n "
},
{
"path": ".github/workflows/pr-tests.yml",
"chars": 390,
"preview": "name: pr-tests\n\non:\n pull_request:\n types:\n - \"opened\"\n - \"reopened\"\n - \"synchronize\"\n paths:\n "
},
{
"path": ".github/workflows/pre-release-build.yml",
"chars": 1654,
"preview": "name: pre-release\non:\n workflow_dispatch:\n# push:\n# branches:\n# - main\n# paths:\n# - \"src/**\"\n# - "
},
{
"path": ".github/workflows/scripts/verify-search-engine-configs.mjs",
"chars": 6400,
"preview": "import { JSDOM } from 'jsdom'\nimport fetch, { Headers } from 'node-fetch'\n\nconst config = {\n google: {\n inputQuery: "
},
{
"path": ".github/workflows/tagged-release.yml",
"chars": 1597,
"preview": "name: tagged-release\non:\n push:\n tags:\n - \"v*\"\n\njobs:\n build_and_release:\n runs-on: macos-12\n\n steps:\n "
},
{
"path": ".github/workflows/verify-configs.yml",
"chars": 268,
"preview": "name: verify-configs\non:\n workflow_dispatch:\n\njobs:\n verify_configs:\n runs-on: ubuntu-22.04\n\n steps:\n - use"
},
{
"path": ".gitignore",
"chars": 53,
"preview": ".idea/\n.vscode/\nnode_modules/\nbuild/\n.DS_Store\n*.zip\n"
},
{
"path": ".prettierignore",
"chars": 45,
"preview": "build/\nsrc/manifest.json\nsrc/manifest.v2.json"
},
{
"path": ".prettierrc",
"chars": 249,
"preview": "{\n \"printWidth\": 100,\n \"semi\": false,\n \"tabWidth\": 2,\n \"singleQuote\": true,\n \"trailingComma\": \"all\",\n \"bracketSpac"
},
{
"path": "LICENSE",
"chars": 1091,
"preview": "MIT License\n\nCopyright (c) 2022 josStorer\nCopyright (c) 2022 wong2\n\nPermission is hereby granted, free of charge, to any"
},
{
"path": "README.md",
"chars": 8199,
"preview": "# This repo has moved to [ChatGPTBox](https://github.com/josStorer/chatGPTBox). Due to the upstream repo being acquired "
},
{
"path": "build.mjs",
"chars": 6718,
"preview": "import archiver from 'archiver'\nimport fs from 'fs-extra'\nimport path from 'path'\nimport webpack from 'webpack'\nimport P"
},
{
"path": "package.json",
"chars": 2590,
"preview": "{\n \"name\": \"chat-gpt-search-engine-extension\",\n \"scripts\": {\n \"build\": \"node build.mjs --production\",\n \"build:sa"
},
{
"path": "safari/appdmg.json",
"chars": 255,
"preview": "{\n \"title\": \"chatGPT for Search Engine\",\n \"icon\": \"../src/logo.png\",\n \"contents\": [\n { \"x\": 448, \"y\": 344, \"type\":"
},
{
"path": "safari/build.sh",
"chars": 743,
"preview": "xcrun safari-web-extension-converter ./build/firefox \\\n --project-location ./build/safari --app-name chatGPT-for-Search-"
},
{
"path": "safari/export-options.plist",
"chars": 240,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "safari/project.patch",
"chars": 1436,
"preview": "--- a/build/safari/chatGPT-for-Search-Engine/chatGPT-for-Search-Engine.xcodeproj/project.pbxproj\n+++ b/build/safari/chat"
},
{
"path": "screenshot/engines/README.md",
"chars": 389,
"preview": "The images below are for preview purposes only and this project actually supports style adaptation now\n\n => {\n"
},
{
"path": "src/content-script/site-adapters/bilibili/index.mjs",
"chars": 1828,
"preview": "import { cropText } from '../../../utils'\nimport { config } from '../index.mjs'\n\nexport default {\n init: async (hostnam"
},
{
"path": "src/content-script/site-adapters/github/index.mjs",
"chars": 1667,
"preview": "import { cropText, limitedFetch } from '../../../utils'\nimport { config } from '../index.mjs'\n\nconst getPatchUrl = async"
},
{
"path": "src/content-script/site-adapters/gitlab/index.mjs",
"chars": 942,
"preview": "import { cropText, limitedFetch } from '../../../utils'\n\nconst getPatchUrl = async () => {\n const patchUrl = location.o"
},
{
"path": "src/content-script/site-adapters/index.mjs",
"chars": 4932,
"preview": "import baidu from './baidu'\nimport bilibili from './bilibili'\nimport youtube from './youtube'\nimport github from './gith"
},
{
"path": "src/content-script/site-adapters/quora/index.mjs",
"chars": 767,
"preview": "import { cropText } from '../../../utils'\n\nexport default {\n inputQuery: async () => {\n try {\n if (location.pat"
},
{
"path": "src/content-script/site-adapters/reddit/index.mjs",
"chars": 843,
"preview": "import { cropText } from '../../../utils'\n\nexport default {\n inputQuery: async () => {\n try {\n const title = do"
},
{
"path": "src/content-script/site-adapters/stackoverflow/index.mjs",
"chars": 7,
"preview": "//TODO\n"
},
{
"path": "src/content-script/site-adapters/youtube/index.mjs",
"chars": 1873,
"preview": "import { cropText } from '../../../utils'\nimport { config } from '../index.mjs'\n\nexport default {\n init: async (hostnam"
},
{
"path": "src/content-script/site-adapters/zhihu/index.mjs",
"chars": 910,
"preview": "import { cropText } from '../../../utils'\n\nexport default {\n inputQuery: async () => {\n try {\n const title = do"
},
{
"path": "src/content-script/styles.scss",
"chars": 4232,
"preview": "[data-theme='auto'] {\n @import 'github-markdown-css/github-markdown.css';\n @media screen and (prefers-color-scheme: da"
},
{
"path": "src/manifest.json",
"chars": 857,
"preview": "{\n \"name\": \"ChatGPT for Search Engine\",\n \"description\": \"Display ChatGPT response alongside Search Engine results\",\n "
},
{
"path": "src/manifest.v2.json",
"chars": 750,
"preview": "{\n \"name\": \"ChatGPT for Search Engine\",\n \"description\": \"Display ChatGPT response alongside Search Engine results\",\n "
},
{
"path": "src/popup/Popup.jsx",
"chars": 12205,
"preview": "import '@picocss/pico'\nimport { useEffect, useState } from 'react'\nimport {\n setUserConfig,\n getUserConfig,\n TriggerM"
},
{
"path": "src/popup/index.html",
"chars": 272,
"preview": "<html>\n <head>\n <title>ChatGPT for Search Engine</title>\n <link rel=\"stylesheet\" href=\"popup.css\" />\n <meta ch"
},
{
"path": "src/popup/index.jsx",
"chars": 111,
"preview": "import { render } from 'preact'\nimport Popup from './Popup'\n\nrender(<Popup />, document.getElementById('app'))\n"
},
{
"path": "src/popup/styles.scss",
"chars": 1619,
"preview": "[data-theme='auto'] {\n @import 'github-markdown-css/github-markdown.css';\n @media screen and (prefers-color-scheme: da"
},
{
"path": "src/utils/create-element-at-position.mjs",
"chars": 322,
"preview": "export function createElementAtPosition(x = 0, y = 0, zIndex = 2147483647) {\n const element = document.createElement('d"
},
{
"path": "src/utils/crop-text.mjs",
"chars": 3085,
"preview": "// MIT License\n//\n// Copyright (c) 2023 josStorer\n//\n// Permission is hereby granted, free of charge, to any person obta"
},
{
"path": "src/utils/ends-with-question-mark.mjs",
"chars": 232,
"preview": "export function endsWithQuestionMark(question) {\n return (\n question.endsWith('?') || // ASCII\n question.endsWith"
},
{
"path": "src/utils/fetch-sse.mjs",
"chars": 709,
"preview": "import { createParser } from 'eventsource-parser'\nimport { streamAsyncIterable } from './stream-async-iterable'\n\nexport "
},
{
"path": "src/utils/get-conversation-pairs.mjs",
"chars": 443,
"preview": "export function getConversationPairs(records, isChatgpt) {\n let pairs\n if (isChatgpt) {\n pairs = []\n for (const "
},
{
"path": "src/utils/get-possible-element-by-query-selector.mjs",
"chars": 301,
"preview": "export function getPossibleElementByQuerySelector(queryArray) {\n for (const query of queryArray) {\n if (query) {\n "
},
{
"path": "src/utils/index.mjs",
"chars": 487,
"preview": "export * from './create-element-at-position'\nexport * from './crop-text'\nexport * from './ends-with-question-mark'\nexpor"
},
{
"path": "src/utils/init-session.mjs",
"chars": 699,
"preview": "/**\n * @typedef {object} Session\n * @property {string|null} question\n * @property {string|null} conversationId - chatGPT"
},
{
"path": "src/utils/is-mobile.mjs",
"chars": 2266,
"preview": "// https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser\n\nexport async function isMobile() {\n let che"
},
{
"path": "src/utils/is-safari.mjs",
"chars": 84,
"preview": "export function isSafari() {\n return navigator.vendor === 'Apple Computer, Inc.'\n}\n"
},
{
"path": "src/utils/limited-fetch.mjs",
"chars": 676,
"preview": "// https://stackoverflow.com/questions/64304365/stop-request-after-x-amount-is-fetched\n\nexport async function limitedFet"
},
{
"path": "src/utils/set-element-position-in-viewport.mjs",
"chars": 335,
"preview": "export function setElementPositionInViewport(element, x = 0, y = 0) {\n const retX = Math.min(window.innerWidth - elemen"
},
{
"path": "src/utils/stream-async-iterable.mjs",
"chars": 276,
"preview": "export async function* streamAsyncIterable(stream) {\n const reader = stream.getReader()\n try {\n while (true) {\n "
},
{
"path": "src/utils/update-ref-height.mjs",
"chars": 482,
"preview": "export function updateRefHeight(ref) {\n ref.current.style.height = 'auto'\n const computed = window.getComputedStyle(re"
}
]
About this extraction
This page contains the full source code of the josStorer/chatGPT-search-engine-extension GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 66 files (127.2 KB), approximately 34.8k tokens, and a symbol index with 67 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.