main e50db01c9c1e cached
66 files
127.2 KB
34.8k tokens
67 symbols
1 requests
Download .txt
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>
<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)
  ![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
================================================
<?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

![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<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`
}
Download .txt
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
Download .txt
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![bing](bing.png"
  },
  {
    "path": "src/background/apis/chatgpt-web.mjs",
    "chars": 3946,
    "preview": "// web version\n\nimport { fetchSSE } from '../../utils/fetch-sse'\nimport { isEmpty } from 'lodash-es'\nimport { chatgptWeb"
  },
  {
    "path": "src/background/apis/openai-api.mjs",
    "chars": 4852,
    "preview": "// api version\n\nimport { maxResponseTokenLength, Models, getUserConfig } from '../../config'\nimport { fetchSSE } from '."
  },
  {
    "path": "src/background/index.mjs",
    "chars": 3824,
    "preview": "import { v4 as uuidv4 } from 'uuid'\nimport Browser from 'webextension-polyfill'\nimport ExpiryMap from 'expiry-map'\nimpor"
  },
  {
    "path": "src/components/ConversationCard/index.jsx",
    "chars": 8354,
    "preview": "import { memo, useEffect, useState } from 'react'\nimport PropTypes from 'prop-types'\nimport Browser from 'webextension-p"
  },
  {
    "path": "src/components/ConversationItem/index.jsx",
    "chars": 3826,
    "preview": "import { useState } from 'react'\nimport FeedbackForChatGPTWeb from '../FeedbackForChatGPTWeb'\nimport { ChevronDownIcon, "
  },
  {
    "path": "src/components/CopyButton/index.jsx",
    "chars": 810,
    "preview": "import { useState } from 'react'\nimport { CheckIcon, CopyIcon } from '@primer/octicons-react'\nimport PropTypes from 'pro"
  },
  {
    "path": "src/components/DecisionCard/index.jsx",
    "chars": 4597,
    "preview": "import { LightBulbIcon, SearchIcon } from '@primer/octicons-react'\nimport { useState, useEffect } from 'react'\nimport Pr"
  },
  {
    "path": "src/components/FeedbackForChatGPTWeb/index.jsx",
    "chars": 1637,
    "preview": "import PropTypes from 'prop-types'\nimport { memo, useCallback, useState } from 'react'\nimport { ThumbsupIcon, Thumbsdown"
  },
  {
    "path": "src/components/FloatingToolbar/index.jsx",
    "chars": 4078,
    "preview": "import Browser from 'webextension-polyfill'\nimport { cloneElement, useEffect, useState } from 'react'\nimport Conversatio"
  },
  {
    "path": "src/components/InputBox/index.jsx",
    "chars": 1022,
    "preview": "import { useEffect, useRef, useState } from 'react'\nimport PropTypes from 'prop-types'\nimport { updateRefHeight } from '"
  },
  {
    "path": "src/components/MarkdownRender/markdown-without-katex.jsx",
    "chars": 1446,
    "preview": "import ReactMarkdown from 'react-markdown'\nimport rehypeRaw from 'rehype-raw'\nimport rehypeHighlight from 'rehype-highli"
  },
  {
    "path": "src/components/MarkdownRender/markdown.jsx",
    "chars": 1589,
    "preview": "import 'katex/dist/katex.min.css'\nimport ReactMarkdown from 'react-markdown'\nimport rehypeRaw from 'rehype-raw'\nimport r"
  },
  {
    "path": "src/config.mjs",
    "chars": 3782,
    "preview": "import { defaults } from 'lodash-es'\nimport Browser from 'webextension-polyfill'\nimport { isMobile } from './utils/is-mo"
  },
  {
    "path": "src/content-script/index.jsx",
    "chars": 6548,
    "preview": "import './styles.scss'\nimport { render } from 'preact'\nimport DecisionCard from '../components/DecisionCard'\nimport { co"
  },
  {
    "path": "src/content-script/selection-tools/index.mjs",
    "chars": 2017,
    "preview": "import {\n  CardHeading,\n  CardList,\n  EmojiSmile,\n  Palette,\n  QuestionCircle,\n  Translate,\n} from 'react-bootstrap-icon"
  },
  {
    "path": "src/content-script/site-adapters/arxiv/index.mjs",
    "chars": 7,
    "preview": "//TODO\n"
  },
  {
    "path": "src/content-script/site-adapters/baidu/index.mjs",
    "chars": 759,
    "preview": "import { config } from '../index'\n\nexport default {\n  init: async (hostname, userConfig, getInput, mountComponent) => {\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.

Copied to clipboard!