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 [![Verify search engine configs](https://github.com/josStorer/chatGPT-search-engine-extension/workflows/verify-configs/badge.svg)](https://github.com/josStorer/chatGPT-search-engine-extension/actions/workflows/verify-configs.yml) [![GitHub release](https://img.shields.io/github/release/josStorer/chatGPT-search-engine-extension.svg)](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: - 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)
## Upstream supports, but not here
Details: (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
## 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) ![code-highlight](screenshot/code-highlight.png) - LaTeX ![latex](screenshot/latex.png) - Android ![android](screenshot/android.jpg) ## 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 ================================================ method mac-application ================================================ 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 ![bing](bing.png) ![duckduckgo](duckduckgo.png) ![google](google.png) ![kagi](kagi.png) ![naver](naver.png) ![startpage](startpage.png) ![yahoojp](yahoo.jp.png) ![yahoo](yahoo.png) ![yandex](yandex.png) ![baidu](baidu.png) ![brave](brave.png) ![searx](searx.png) ![ecosia](ecosia.png) ================================================ 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} */ 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', '

Waiting for response...

', ), ] else return [] else { const ret = [] for (const record of props.session.conversationRecords) { ret.push( new ConversationItemData('question', record.question + '\n
', props.session, true), ) ret.push( new ConversationItemData('answer', record.answer + '\n
', 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
', true, 'answer', true) setIsReady(true) } if (msg.error) { switch (msg.error) { case 'UNAUTHORIZED': UpdateAnswer( `UNAUTHORIZED
Please login at https://chat.openai.com first${ isSafari() ? '
Then open https://chat.openai.com/api/auth/session' : '' }
And refresh this page or type you question again` + `

Consider creating an api key at https://platform.openai.com/account/api-keys
`, false, 'error', ) break case 'CLOUDFLARE': UpdateAnswer( `OpenAI Security Check Required
Please open ${ isSafari() ? 'https://chat.openai.com/api/auth/session' : 'https://chat.openai.com' }
And refresh this page or type you question again` + `

Consider creating an api key at https://platform.openai.com/account/api-keys
`, false, 'error', ) break default: setConversationItemData([ ...conversationItemData, new ConversationItemData('error', msg.error + '\n
'), ]) break } setIsReady(true) } } port.onMessage.addListener(listener) return () => { port.onMessage.removeListener(listener) } }, [conversationItemData]) return (
{!props.closeable ? ( ) : ( { if (props.onClose) props.onClose() }} /> )} {props.draggable ? (
) : ( { 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( toolbarContainer.remove()} />, toolbarContainer, ) }} /> )} { let output = '' session.conversationRecords.forEach((data) => { output += `Question:\n\n${data.question}\n\nAnswer:\n\n${data.answer}\n\n
\n\n` }) const blob = new Blob([output], { type: 'text/plain;charset=utf-8' }) FileSaver.saveAs(blob, 'conversation.md') }} >

{conversationItemData.map((data, idx) => ( ))}
{ const newQuestion = new ConversationItemData('question', question + '\n
') const newAnswer = new ConversationItemData( 'answer', '

Waiting for response...

', ) setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) const newSession = { ...session, question } setSession(newSession) try { port.postMessage({ session: newSession }) } catch (e) { UpdateAnswer(e, false, 'error') } }} />
) } 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 (

You:

content} size={14} /> {!collapsed ? ( setCollapsed(true)}> ) : ( setCollapsed(false)}> )}
{!collapsed && {content}}
) case 'answer': return (

{session ? 'ChatGPT:' : 'Loading...'}

{done && session && session.conversationId && ( )} {session && session.conversationId && ( )} {session && content} size={14} />} {!collapsed ? ( setCollapsed(true)}> ) : ( setCollapsed(false)}> )}
{!collapsed && {content}}
) case 'error': return (

Error:

content} size={14} /> {!collapsed ? ( setCollapsed(true)}> ) : ( setCollapsed(false)}> )}
{!collapsed && {content}}
) } } 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 ( {copied ? : } ) } 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 && (
{(() => { if (question) switch (config.triggerMode) { case 'always': return case 'manually': if (triggered) { return } return (

setTriggered(true)} > Ask ChatGPT

) case 'questionMark': if (endsWithQuestionMark(question.trim())) { return } if (triggered) { return } return (

setTriggered(true)} > Ask ChatGPT

) } else return (

No Input Found

) })()}
) ) } 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 (
) } 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
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 (
{ updatePosition() }} />
) } else { if (config.activeSelectionTools.length === 0) return
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 (
{tools}
) } } 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 (