├── .babelrc ├── .gitignore ├── PRIVACY_POLICY.md ├── README.md ├── advancedSearch.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── background │ ├── index.ts │ ├── init.ts │ └── requests.ts ├── common │ └── searchStyle.css ├── comps │ └── svgs.tsx ├── declare.d.ts ├── defaults.ts ├── globalVar.ts ├── helper.ts ├── hooks │ └── useStorage.tsx ├── main.ts ├── mainLoader.ts ├── options │ ├── App.tsx │ ├── comps │ │ ├── ColorWell.css │ │ ├── ColorWell.tsx │ │ ├── FloatTooltip.css │ │ ├── FloatTooltip.tsx │ │ ├── NumericInput.css │ │ ├── NumericInput.tsx │ │ ├── Option.css │ │ ├── Option.tsx │ │ ├── Reset.css │ │ ├── Reset.tsx │ │ ├── Toggle.css │ │ └── Toggle.tsx │ ├── index.tsx │ ├── styles.css │ └── utils.ts ├── preamble │ ├── index.ts │ └── style.css ├── raccoon │ ├── App.tsx │ ├── comps │ │ ├── CleverDiv.tsx │ │ ├── LoadMore.tsx │ │ └── ResultItem.tsx │ ├── hooks │ │ ├── useAutoBar.tsx │ │ ├── useClickBlur.tsx │ │ └── useResize.tsx │ ├── index.tsx │ ├── searchChats │ │ ├── Grabby.ts │ │ ├── core.ts │ │ ├── extractContext.ts │ │ ├── extractOpts.ts │ │ ├── filterChats.ts │ │ ├── index.ts │ │ └── preFilter.ts │ ├── style.css │ ├── types.ts │ └── utils │ │ ├── bump.tsx │ │ ├── extractChats.ts │ │ ├── fetchChats.ts │ │ ├── getElapsed.tsx │ │ ├── getTtl.ts │ │ ├── gizmo.ts │ │ ├── misc.tsx │ │ └── rawTypes.ts ├── types.ts └── utils │ ├── GsmType.ts │ ├── browser.ts │ ├── getKnown.ts │ ├── gsm.ts │ └── state.ts ├── static ├── 128.png ├── _locales │ ├── en │ │ └── messages.json │ ├── es │ │ └── messages.json │ ├── it │ │ └── messages.json │ ├── ja │ │ └── messages.json │ ├── ko │ │ └── messages.json │ ├── pt_BR │ │ └── messages.json │ ├── ru │ │ └── messages.json │ ├── tr │ │ └── messages.json │ ├── vi │ │ └── messages.json │ ├── zh_CN │ │ └── messages.json │ └── zh_TW │ │ └── messages.json ├── locales │ ├── en.json │ ├── es.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── pt_BR.json │ ├── ru.json │ ├── tr.json │ ├── vi.json │ ├── zh_CN.json │ └── zh_TW.json └── options.html ├── staticCh └── manifest.json ├── staticFf └── manifest.json ├── tools ├── generateGsmType.js └── validateLocale.js ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/typescript", 5 | ["@babel/react", { 6 | "runtime": "automatic" 7 | }] 8 | ] 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | build/ 10 | buildFf 11 | .DS_STORE 12 | 13 | node_modules 14 | build 15 | dist-ssr 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | 2 | "GPT Search" does not collect any personal information, including chat data. This document may later change. 3 | 4 | ### Data safety 5 | 6 | Chats are cached locally to allow for fast searches. If another person has access to your computer, they might be able to look at your cached chats even if you're not signed in to ChatGPT. 7 | 8 | To remove cached chats, uninstall the extension or "Reset cache" in the options page. If you're using a shared computer, it's recommended to enable 'Session-only caching' in the options page. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # GPT Search: Chat History 3 | 4 | ## Install on [Chrome](https://chromewebstore.google.com/detail/gpt-search/glhkbfoibolghhfikadjikgfmaknpelb), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/gpt-search), or [Edge](https://microsoftedge.microsoft.com/addons/detail/gpt-search/hcnfioacjbamffbgigbjpdlflnlpaole). 5 | 6 | ## Main features 7 | 1. Search through your conversation history. 8 | 2. Beautifully integrated into ChatGPT's UI. 9 | 3. Blazingly fast after initial caching. 10 | 4. [Advanced search](./advancedSearch.md) 11 | 12 | ![screenshot1](https://github.com/user-attachments/assets/60350b14-d7b8-4f9e-8a3d-9e6816387c0c) 13 | -------------------------------------------------------------------------------- /advancedSearch.md: -------------------------------------------------------------------------------- 1 | ## Advanced search for searching chat history. 2 | 3 | ### Flags 4 | **+dalle**: Filters for chats that used DALL-E. 5 | 6 | **+python**: Filters for chats that used data analysis. 7 | 8 | **+browse**: Filters for chats that used web browsing. 9 | 10 | **+gizmo**: Filters for chats that used a custom GPT. 11 | 12 | **+gizmos**: Filters for chats that used multiple GPTs. 13 | 14 | **+gpt4**: Filter for chats that used GPT-4. 15 | 16 | **+archived**: Filter for archived chats. 17 | 18 | Adding a minus sign (-) before any of these flags, e.g., -dalle, filters out chats that used the specified tool or feature. 19 | 20 | #### Example 21 | Search for chats with "spatula" that used DALL-E, but not a custom GPT. 22 | ```text 23 | spatula +dalle -gizmo 24 | ``` 25 | 26 | Search for hammer, but in archives. 27 | ```text 28 | hammer +archived 29 | ``` 30 | 31 | ### Other Flags 32 | **+turns \**: Filter for chats with more than \ messages. 33 | 34 | **-turns \**: Filter for chats with less than \ messages. 35 | 36 | #### Example 37 | Search for long chats with word "docker". 38 | ```text 39 | docker +turns 20 40 | ``` 41 | 42 | Search for short chats with word ketchup. 43 | ```text 44 | ketchup -turns 5 45 | ``` 46 | 47 | 48 | ### Date Flags: 49 | **+c** YYYY/MM/DD: Filters for chats created after the specified date. 50 | 51 | **-c** YYYY/MM/DD: Filters for chats created before the specified date. 52 | 53 | **+u** YYYY/MM/DD: Filters for chats updated after the specified date. 54 | 55 | **-u** YYYY/MM/DD: Filters for chats updated before the specified date. 56 | 57 | #### Example 58 | Search for chats with "javascript" created in 2023. 59 | ```text 60 | javascript +c 2023/01/01 -c 2024/01/01 61 | ``` 62 | 63 | ### Source flags 64 | **+title**: Check matches only from chat's title. 65 | 66 | **+body**: Check matches only from chat's body. You can also specify **+ast** to only include ChatGPT's replies, and **+user** to only include your replies. 67 | 68 | +**gpt**: Search only GPT titles. If you just installed GPT Search, this might not work as no GPTs have been cached. 69 | 70 | Using these flags with a minus sign (-) will exclude them them instead. 71 | 72 | Search for chats with "spatula" but exclude ChatGPT's replies. 73 | ```text 74 | spatula -ast 75 | ``` 76 | 77 | Search for chats with "africa" in title. 78 | 79 | ``` 80 | +title africa 81 | ``` 82 | 83 | Search Consensus gpt. 84 | 85 | ``` 86 | +gpt consensus 87 | ``` 88 | 89 | ### Search chats by custom GPT. 90 | 91 | Find all chats that used a specific GPT. This feature might not work if you recently installed GPT Search as no GPTs might be cached yet. 92 | ``` 93 | +g write for me 94 | ``` 95 | 96 | Find all chats that used a specific GPT created before a specific date. 97 | ``` 98 | +g write for me -c 2024/1/31 99 | ``` 100 | 101 | 102 | ### Exclude keywords 103 | You can exclude keywords by including a minus sign. 104 | 105 | Search for chats that contain "rabbit", but not "lion". 106 | 107 | ``` 108 | rabbit -lion 109 | ``` 110 | 111 | ### Chaining 112 | You can filter search results by another query with the && operator. 113 | 114 | Search for chats with "king" in title and "troll" in the body. 115 | ```text 116 | +title king && +body troll 117 | ``` 118 | 119 | 120 | Search for chats that used Consensus GPT, but only those that have 'troll' in the title. 121 | ```text 122 | +g consensus && +title troll 123 | ``` 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gbar", 3 | "version": "1.0.0", 4 | "description": "aa", 5 | "main": "index.js", 6 | "sideEffects": [ 7 | "./src/background/*.ts" 8 | ], 9 | "browserslist": [ 10 | "chrome >= 112", 11 | "firefox >= 112" 12 | ], 13 | "scripts": { 14 | "build:common": " rm -rf build && webpack --config webpack.config.js && cp -r static/. staticCh/. build/unpacked && find build -name '.DS_Store' -type f -delete", 15 | "build:commonFf": "rm -rf buildFf && FIREFOX=true webpack --config webpack.config.js && cp -r static/. staticFf/. buildFf/unpacked && find buildFf -name '.DS_Store' -type f -delete", 16 | "build:dev": " export NODE_ENV=development && npm run build:common", 17 | "build:devFf": " export NODE_ENV=development && npm run build:commonFf", 18 | "build:prod": " export NODE_ENV=production && npm run build:common && cd build/unpacked && zip -r ../packed.zip .", 19 | "build:prodFf": " export NODE_ENV=production && npm run build:commonFf && cd buildFf/unpacked && zip -r ../packed.zip .", 20 | "build:prodFff": "npm run build:prodFf && rm -rf zed && mkdir -p zed/unpacked && cp -r src static staticCh staticFf tools .babelrc package-lock.json package.json postcss.config.js tsconfig.json webpack.config.js zed/unpacked && (cd zed/unpacked && zip -r ../packed.zip .) && mv zed/packed.zip buildFf/source.zip && rm -rf zed" 21 | }, 22 | "author": "", 23 | "dependencies": { 24 | "@babel/core": "^7.22.10", 25 | "@babel/preset-env": "^7.23.9", 26 | "@babel/preset-react": "^7.23.3", 27 | "@babel/preset-typescript": "^7.22.5", 28 | "@types/chrome": "^0.0.243", 29 | "@types/lodash.debounce": "^4.0.9", 30 | "@types/node": "^20.11.17", 31 | "@types/react": "^18.2.55", 32 | "@types/react-dom": "^18.2.19", 33 | "babel-loader": "^9.1.3", 34 | "clsx": "^2.1.0", 35 | "css-loader": "^6.10.0", 36 | "cssnano": "^6.0.3", 37 | "escape-string-regexp": "^5.0.0", 38 | "immer": "^10.0.3", 39 | "lodash.debounce": "^4.0.8", 40 | "postcss": "^8.4.35", 41 | "postcss-loader": "^8.1.0", 42 | "react": "^18.2.0", 43 | "react-dom": "^18.2.0", 44 | "react-icons": "^5.0.1", 45 | "style-loader": "^3.3.4", 46 | "terser-webpack-plugin": "^5.3.10", 47 | "typescript": "^5.1.6", 48 | "webpack": "^5.88.2", 49 | "webpack-cli": "^5.1.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | plugins: [ 4 | require('cssnano')({ 5 | preset: 'default', 6 | }), 7 | ] 8 | } -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import "./init" 3 | import "./requests" 4 | 5 | import { loadGsm } from "../utils/gsm" 6 | import { ensureMigrated } from "./init" 7 | import { AnyDict } from "../types" 8 | 9 | 10 | // Prep is for preamble stuff. 11 | async function getPrep() { 12 | const ph = await getSearchPlaceholder() 13 | return {ph: ph || null} 14 | } 15 | 16 | async function getSearchPlaceholder(): Promise { 17 | const v = (await chrome.storage.local.get('o:ph'))['o:ph'] 18 | if (v) return v; 19 | return (await loadGsm()).search 20 | } 21 | 22 | 23 | chrome.action?.onClicked.addListener(tab => { 24 | chrome.tabs.create({url: chrome.runtime.getURL('options.html')}) 25 | }) 26 | 27 | 28 | chrome.runtime.onMessage.addListener((msg, sender, reply) => { 29 | if (msg.type === "LOAD_RACCOON") { 30 | chrome.scripting.executeScript({ 31 | target: {tabId: sender.tab.id, allFrames: false}, 32 | files: ["./raccoon.js"] 33 | }) 34 | reply(true) 35 | } else if (msg.type === "REQUEST_GSM") { 36 | loadGsm().then(gsm => { 37 | chrome.storage.local.set({'o:ph': gsm.search}) 38 | reply(gsm) 39 | }, err => reply(null)) 40 | return true 41 | } else if (msg.type === "PREP") { 42 | getPrep().then(prep => { 43 | reply(prep) 44 | }) 45 | return true 46 | } else if (msg.type === "OPEN_LINK") { 47 | chrome.tabs.create({url: msg.url, active: msg.active, index: sender.tab.index + 1}) 48 | } else if (msg.type === "RESET") { 49 | reset().then(() => reply({}), error => reply({error})) 50 | return true 51 | } else if (msg.type === "REQUEST_CREATE_TAB") { 52 | chrome.tabs.create({ 53 | url: msg.url, 54 | index: sender.tab.index + 1, 55 | active: msg.active 56 | }) 57 | reply(true) 58 | } else if (msg.type === "GET_SESSION_ITEM") { 59 | chrome.storage.session.get(msg.keys as chrome.storage.StorageGet).then(ok => { 60 | reply({ok}) 61 | }, error => reply({error})) 62 | return true 63 | } else if (msg.type === "SET_SESSION_ITEM") { 64 | chrome.storage.session.set(msg.items as AnyDict).then(() => { 65 | reply({}) 66 | }, error => reply({error})) 67 | return true 68 | } 69 | }) 70 | 71 | async function reset() { 72 | await chrome.storage.local.clear() 73 | await chrome.storage.session.clear() 74 | await ensureMigrated() 75 | } -------------------------------------------------------------------------------- /src/background/init.ts: -------------------------------------------------------------------------------- 1 | import { CURRENT_VERSION, generateConfig } from "../defaults" 2 | import { CONFIG_KEYS, Config } from "../types" 3 | 4 | chrome.runtime.onInstalled.addListener(async () => { 5 | chrome.storage.session.setAccessLevel?.({accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS'}) 6 | ensureMigrated() 7 | }) 8 | 9 | export async function ensureMigrated() { 10 | let config = await chrome.storage.local.get(CONFIG_KEYS) 11 | config = await migrateConfig(config) 12 | await chrome.storage.local.set(config) 13 | } 14 | 15 | async function migrateConfig(config: Partial) { 16 | const version = config["g:version"] 17 | if (version === 1) { 18 | config = config 19 | } 20 | if (config["g:version"] !== CURRENT_VERSION) { 21 | return generateConfig() 22 | } 23 | return config 24 | } 25 | 26 | async function migrateOneToTwo(config: Partial): Promise> { 27 | return config 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/background/requests.ts: -------------------------------------------------------------------------------- 1 | import { Gizmo, TempState } from "../types" 2 | import { getLocalItem, setLocal } from "../utils/getKnown" 3 | 4 | chrome.webRequest.onBeforeSendHeaders.addListener(deets => { 5 | processGizmoUrl(deets.url) 6 | 7 | const auth = deets.requestHeaders?.find(r => r.name === "Authorization")?.value 8 | if (!auth) return 9 | chrome.storage.session.set({'s:auth': auth}) 10 | getLocalItem('g:sessionOnly').then(sessionOnly => { 11 | if (!sessionOnly) setLocal({'o:auth': auth}) 12 | }) 13 | }, {urls: [ 14 | 'https://chatgpt.com/backend-api/*' 15 | ], types: ['xmlhttprequest']}, ['requestHeaders']) 16 | 17 | 18 | const gizmoRegex = /gizmos\/g\-([a-zA-Z0-9]+)/ 19 | let gizmos: TempState["o:gizmos"] 20 | 21 | async function processGizmoUrl(url: string) { 22 | const gizmoId = gizmoRegex.exec(url)?.[1] 23 | if (!gizmoId) return 24 | 25 | const stk = `s:g:${gizmoId}` 26 | if ((await chrome.storage.session.get(stk))[stk]) return 27 | chrome.storage.session.set({[stk]: true}) 28 | const res = await fetch(`https://chatgpt.com/public-api/gizmos/g-${gizmoId}`) 29 | if (!res.ok) return 30 | const json = await res.json() 31 | const displayName = json?.gizmo?.display?.name 32 | if (!displayName) return 33 | const imageUrl = json.gizmo.display.profile_picture_url 34 | if (!imageUrl) return 35 | 36 | if (!gizmos) gizmos = await getLocalItem('o:gizmos') ?? {} 37 | gizmos[gizmoId] = { 38 | id: gizmoId, 39 | name: displayName, 40 | added: Date.now(), 41 | imageUrl 42 | } 43 | 44 | setLocal({'o:gizmos': gizmos}) 45 | } -------------------------------------------------------------------------------- /src/common/searchStyle.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .wrapper { 4 | margin-top: 10px; 5 | margin-right: 9px; 6 | margin-left: 10px; 7 | 8 | &.new { 9 | margin: 10px; 10 | margin-top: 15px; 11 | margin-bottom: 0px; 12 | } 13 | } 14 | 15 | .search { 16 | font-size: 16px; 17 | display: grid; 18 | position: relative; 19 | align-items: center; 20 | background-color: transparent; 21 | color: var(--text-color-primary); 22 | position: relative; 23 | -webkit-font-smoothing: antialiased; 24 | 25 | & > .searchIcon { 26 | position: absolute; 27 | left: 10px; 28 | pointer-events: none; 29 | } 30 | 31 | & > input { 32 | width: 100%; 33 | box-sizing: border-box; 34 | -webkit-font-smoothing: antialiased; 35 | background-color: var(--bg-color); 36 | /* font-family: "Segoe UI", "Avenir", Courier, monospace; */ 37 | padding: 8px; 38 | display: block; 39 | padding-left: 34px; 40 | padding-right: 24px; 41 | border-radius: 10px; 42 | border: 1px solid var(--border-medium); 43 | font-size: inherit; 44 | background-color: inherit; 45 | color: var(--text-color-primary); 46 | cursor: text; 47 | 48 | &:focus { 49 | outline: none; 50 | /* border: 1px solid var(--context-color); */ 51 | border: 1px solid var(--border-xheavy); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/comps/svgs.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as React from "react" 3 | 4 | type SvgPropsBase = { 5 | width?: React.SVGAttributes["width"], 6 | height?: React.SVGAttributes["height"], 7 | style?: React.SVGAttributes["style"], 8 | className?: React.SVGAttributes["className"], 9 | color?: React.SVGAttributes["color"] 10 | } 11 | 12 | export type SvgProps = SvgPropsBase & { 13 | size?: number | string, 14 | onClick?: (e: React.MouseEvent) => void 15 | } 16 | 17 | function prepareProps(props: SvgProps) { 18 | props = { ...(props ?? {}) } 19 | props.width = props.width ?? props.size ?? "1em" 20 | props.height = props.height ?? props.size ?? "1em" 21 | 22 | delete props.size 23 | return props as SvgPropsBase 24 | } 25 | 26 | 27 | export function Gear(props: SvgProps) { 28 | return ( 29 | 38 | 43 | 44 | ) 45 | } 46 | 47 | export function Github(props: SvgProps) { 48 | return ( 49 | 58 | 63 | 64 | ) 65 | } 66 | 67 | export function Star(props: SvgProps) { 68 | return ( 69 | 78 | 82 | 83 | ) 84 | } 85 | 86 | export function Heart(props: SvgProps) { 87 | return ( 88 | 97 | 101 | 102 | ) 103 | } 104 | 105 | export function Pin(props: SvgProps) { 106 | return ( 107 | 117 | 121 | 122 | ) 123 | } 124 | 125 | export function Diamond(props: SvgProps) { 126 | return ( 127 | 137 | 141 | 142 | ) 143 | } 144 | 145 | 146 | export function Close(props: SvgProps) { 147 | return ( 148 | 158 | 159 | 163 | 164 | ) 165 | } 166 | 167 | export function SearchSvg(props: SvgProps) { 168 | return ( 169 | 181 | 182 | 183 | 184 | ) 185 | } 186 | 187 | export function ResetSvg(props: SvgProps) { 188 | return ( 189 | 200 | 204 | 205 | ) 206 | } -------------------------------------------------------------------------------- /src/declare.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module '*.css?raw' { 3 | const content: string; 4 | export default content; 5 | } 6 | 7 | declare namespace chrome.storage { 8 | export type StorageChanges = {[key: string]: chrome.storage.StorageChange} 9 | 10 | export type StorageKeysArgument = string | string[] | {[key: string]: any} | null | undefined 11 | 12 | export type StorageGet = string | string[] | { [key: string]: any }; 13 | } 14 | -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Config } from "./types"; 3 | import { loadGsm } from "./utils/gsm"; 4 | 5 | export const CURRENT_VERSION = 1 6 | 7 | 8 | export async function generateConfig() { 9 | gvar.gsm = await loadGsm() 10 | return ({ 11 | 'g:version': CURRENT_VERSION 12 | }) satisfies Partial 13 | } -------------------------------------------------------------------------------- /src/globalVar.ts: -------------------------------------------------------------------------------- 1 | 2 | export const gvar = ((globalThis.document ?? globalThis) as any).gvar ?? {} 3 | ;((globalThis.document ?? globalThis) as any).gvar = gvar -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | 2 | export function timeout(ms: number): Promise { 3 | return new Promise((res, rej) => setTimeout(() => res(), ms)) 4 | } 5 | 6 | export class QueueLock { 7 | public promiseChain = Promise.resolve() 8 | private chainLength = 0 9 | 10 | wait(ms: number) { 11 | const oldChain = this.promiseChain 12 | this.promiseChain = oldChain.then(() => timeout(ms)) 13 | 14 | if (this.chainLength++ > 100) { 15 | this.promiseChain = Promise.resolve() 16 | this.chainLength = 0 17 | } 18 | return oldChain 19 | } 20 | } 21 | 22 | export function round(value: number, precision: number): number { 23 | const scalar = 10 ** precision 24 | return Math.round(value * scalar) / scalar 25 | } 26 | 27 | export function clamp(min: number, max: number, value: number) { 28 | let clamped = value 29 | if (min != null) { 30 | clamped = Math.max(min, clamped) 31 | } 32 | if (max != null) { 33 | clamped = Math.min(max, clamped) 34 | } 35 | return clamped 36 | } 37 | 38 | export function randomId() { 39 | return Math.ceil(Math.random() * 1E10).toString() 40 | } 41 | 42 | export function shuffle(arr: T[]): T { 43 | return arr.at(Math.floor(Math.random() * arr.length)) 44 | } 45 | 46 | export function assertType(value: any): asserts value is T { } 47 | 48 | export function createElement(text: string) { 49 | const temp = document.createElement('div') 50 | temp.innerHTML = text 51 | return temp.firstElementChild 52 | } -------------------------------------------------------------------------------- /src/hooks/useStorage.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { SubscribeStorageKeys } from "../utils/state"; 3 | import { LocalState, AnyDict } from "../types"; 4 | 5 | type Env = { 6 | client?: SubscribeStorageKeys 7 | } 8 | 9 | export function useStorage(keys: string[], wait?: number, maxWait?: number) { 10 | const [items, _setItems] = useState(null as AnyDict) 11 | const env = useRef({} as Env).current 12 | 13 | useEffect(() => { 14 | env.client = new SubscribeStorageKeys(keys, true, _setItems, wait, maxWait) 15 | 16 | return () => { 17 | env.client?.release() 18 | delete env.client 19 | } 20 | }, []) 21 | 22 | const setItems = useCallback(async (view: AnyDict) => { 23 | return env.client?.push(view) 24 | }, []) 25 | 26 | 27 | return [items, setItems] 28 | } 29 | 30 | export function useKnownKeys(keys: (keyof LocalState)[], wait?: number, maxWait?: number): [Partial, (v: Partial) => void] { 31 | return useStorage(keys, wait, maxWait) as [Partial, (v: Partial) => void] 32 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | 2 | let blockScrollId: number 3 | 4 | window.addEventListener('busbusab', (e: CustomEvent) => { 5 | const deets = JSON.parse(e.detail) 6 | if (deets.type === 'NAV') { 7 | if ((window as any).next?.router?.push) { 8 | (window as any).next.router.push(deets.path) 9 | } else if ((window as any).__reactRouterDataRouter?.navigate) { 10 | (window as any).__reactRouterDataRouter.navigate(deets.path) 11 | } else if ((window as any).__remixRouter?.navigate) { 12 | (window as any).__remixRouter.navigate(deets.path) 13 | } else { 14 | window.dispatchEvent(new CustomEvent('rusrusar', {detail: JSON.stringify({type: 'NO_PUSH', path: deets.path}), bubbles: false})) 15 | } 16 | } else if (deets.type === "BLOCK_SCROLL") { 17 | } 18 | e.stopImmediatePropagation() 19 | }, {capture: true}) 20 | 21 | 22 | function shimScroll2() { 23 | let originalDesc = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop") 24 | Object.defineProperty(Element.prototype, "scrollTop", {set: function(value: number) { 25 | window.dispatchEvent(new CustomEvent("rusrusar", {detail: JSON.stringify({type: "SCROLL_SET"})})) 26 | return originalDesc.set.call(this, value) 27 | }, get: originalDesc.get, configurable: true}) 28 | } 29 | 30 | shimScroll2() -------------------------------------------------------------------------------- /src/mainLoader.ts: -------------------------------------------------------------------------------- 1 | 2 | function main() { 3 | const s = document.createElement("script") 4 | s.type = "text/javascript" 5 | s.async = true 6 | s.src = chrome.runtime.getURL('main.js') 7 | document.documentElement.appendChild(s) 8 | } 9 | 10 | main() -------------------------------------------------------------------------------- /src/options/App.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Toggle } from "./comps/Toggle" 3 | import { LOCALE_MAP } from "../utils/gsm" 4 | import { Option } from "./comps/Option" 5 | import { NumericInput } from "./comps/NumericInput" 6 | import { ColorWell } from "./comps/ColorWell" 7 | import { useKnownKeys } from "../hooks/useStorage" 8 | import { openLink } from "../utils/browser" 9 | import { CONFIG_KEYS } from "../types" 10 | import "./styles.css" 11 | import { isFirefox } from "./utils" 12 | 13 | export function App() { 14 | const [items, setItems] = useKnownKeys(["g:lang", "g:autoClear", "g:context", "g:highlightColorDark", "g:highlightColorLight", "g:highlightBold", "o:lastTheme", "g:sessionOnly", "g:showImage", "g:orderByDate", "g:strictSearch", "g:scrollTop"]) 15 | if (!items) return 16 | 17 | return
18 |
19 |
{gvar.gsm.general}
20 | {} 30 | {} 35 | {items['o:lastTheme'] === 'dark' && ( 36 | 43 | )} 44 | {items['o:lastTheme'] === 'light' && ( 45 | 52 | )} 53 | {} 58 | 63 | 68 |
69 |
70 |
{gvar.gsm.search}
71 | 76 | {gvar.gsm._morpho || } 81 | 86 |
87 |
88 |
{gvar.gsm.data}
89 | {} 101 |
102 |
103 |
{gvar.gsm.otherProjects.header}
104 |
105 | Ask Screenshot for ChatGPT: 106 | 107 |
108 |
109 | Global Speed: 110 | {gvar.gsm.otherProjects.globalSpeed} 111 |
112 |
113 |
114 |
{gvar.gsm.help}
115 |
116 |
{`${gvar.gsm.issuePrompt} `}{gvar.gsm.issueDirective}
117 |
118 |
119 | 126 | 133 |
134 | 137 |
138 |
139 |
140 | } 141 | 142 | 143 | async function removeCachedChats() { 144 | const items = await chrome.storage.local.get() 145 | let keysToDelete: string[] = ['o:auth'] 146 | for (let key in items) { 147 | if (key.startsWith("o:c:")) keysToDelete.push(key) 148 | } 149 | chrome.storage.local.remove(keysToDelete) 150 | } 151 | 152 | async function removeAllCache() { 153 | const items = await chrome.storage.local.get(CONFIG_KEYS) 154 | await Promise.all([ 155 | chrome.storage.local.clear(), 156 | chrome.storage.session.clear() 157 | ]) 158 | await chrome.storage.local.set(items) 159 | } -------------------------------------------------------------------------------- /src/options/comps/ColorWell.css: -------------------------------------------------------------------------------- 1 | 2 | .ColorWell { 3 | 4 | input[type="color"] { 5 | width: 32px; 6 | margin-left: 10px; 7 | } 8 | input[type="color"]::-webkit-color-swatch-wrapper { 9 | padding: 0; 10 | border: none; 11 | } 12 | input[type="color"]::-webkit-color-swatch { 13 | border: none; 14 | } 15 | } -------------------------------------------------------------------------------- /src/options/comps/ColorWell.tsx: -------------------------------------------------------------------------------- 1 | import { Reset } from "./Reset" 2 | import "./ColorWell.css" 3 | 4 | type ColorWellProps = { 5 | color: string, 6 | onChange: (newColor: string) => void, 7 | isActive?: boolean, 8 | onReset?: () => void 9 | } 10 | 11 | export function ColorWell(props: ColorWellProps) { 12 | return
13 | { 14 | props.onReset?.() 15 | }}/> 16 | { 17 | props.onChange(e.target.value) 18 | }}/> 19 |
20 | } -------------------------------------------------------------------------------- /src/options/comps/FloatTooltip.css: -------------------------------------------------------------------------------- 1 | 2 | .FloatTooltip { 3 | position: absolute; 4 | left: -80px; 5 | right: -80px; 6 | bottom: 2.85rem; 7 | font-size: 0.9em; 8 | display: grid; 9 | justify-content: center; 10 | 11 | & > div { 12 | display: inline-block; 13 | padding: 0.357rem; 14 | background-color: red; 15 | color: white; 16 | } 17 | } -------------------------------------------------------------------------------- /src/options/comps/FloatTooltip.tsx: -------------------------------------------------------------------------------- 1 | 2 | import "./FloatTooltip.css" 3 | 4 | 5 | type FloatTooltipProps = { 6 | value: string 7 | } 8 | 9 | export const FloatTooltip = (props: FloatTooltipProps) => { 10 | return
11 |
12 | {props.value} 13 |
14 |
15 | } -------------------------------------------------------------------------------- /src/options/comps/NumericInput.css: -------------------------------------------------------------------------------- 1 | 2 | .NumericInput input { 3 | width: 60px; 4 | } -------------------------------------------------------------------------------- /src/options/comps/NumericInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, ChangeEvent } from "react" 2 | import { FloatTooltip } from "./FloatTooltip" 3 | import { round } from "../../helper" 4 | import "./NumericInput.css" 5 | 6 | 7 | const NUMERIC_REGEX = /^-?(?=[\d\.])\d*(\.\d+)?$/ 8 | 9 | type NumericInputProps = { 10 | value: number, 11 | onChange: (newValue: number) => any, 12 | min?: number, 13 | max?: number, 14 | rounding?: number, 15 | disabled?: boolean, 16 | className?: string 17 | } 18 | 19 | 20 | export const NumericInput = (props: NumericInputProps) => { 21 | const [ghostValue, setGhostValue] = useState("") 22 | const [problem, setProblem] = useState(null as string) 23 | 24 | useEffect(() => { 25 | setProblem(null) 26 | if (props.value == null) { 27 | ghostValue !== "" && setGhostValue("") 28 | } else { 29 | let parsedGhostValue = parseFloat(ghostValue) 30 | if (parsedGhostValue !== props.value) { 31 | setGhostValue(`${round(props.value, props.rounding ?? 4)}`) 32 | } 33 | } 34 | }, [props.value]) 35 | 36 | 37 | const handleOnChange = (e: ChangeEvent) => { 38 | setGhostValue(e.target.value) 39 | const value = e.target.value.trim() 40 | 41 | const parsed = round(parseFloat(value), props.rounding ?? 4) 42 | 43 | if (!isNaN(parsed) && NUMERIC_REGEX.test(value)) { 44 | let min = props.min 45 | let max = props.max 46 | 47 | if (min != null && parsed < min) { 48 | setProblem(`>= ${min}`) 49 | return 50 | } 51 | if (max != null && parsed > max) { 52 | setProblem(`<= ${max}`) 53 | return 54 | } 55 | 56 | if (parsed !== round(props.value, props.rounding ?? 4)) { 57 | props.onChange(parsed) 58 | } 59 | setProblem(null) 60 | } else { 61 | setProblem(`NaN`) 62 | } 63 | 64 | } 65 | 66 | return ( 67 |
68 | { 71 | setProblem(null) 72 | setGhostValue(props.value == null ? "" : `${round(props.value, props.rounding ?? 4)}`) 73 | }} 74 | className={problem ? "error" : ""} 75 | type="text" 76 | onChange={handleOnChange} value={ghostValue} 77 | /> 78 | {problem && ( 79 | 80 | )} 81 |
82 | ) 83 | } 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/options/comps/Option.css: -------------------------------------------------------------------------------- 1 | 2 | .Option { 3 | display: grid; 4 | grid-template-columns: 1fr max-content; 5 | align-items: center; 6 | margin-bottom: 20px; 7 | column-gap: 40px; 8 | 9 | &:last-child { 10 | margin-bottom: 0px; 11 | } 12 | 13 | & > .display { 14 | & > .context { 15 | font-size: 0.9em; 16 | opacity: 0.8; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/options/comps/Option.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react' 2 | import './Option.css' 3 | 4 | type OptionProps = { 5 | label: string, 6 | tooltip?: string, 7 | children?: ReactElement 8 | } 9 | 10 | export function Option(props: OptionProps) { 11 | return
12 |
13 |
{props.label}
14 | {props.tooltip && ( 15 |
{props.tooltip}
16 | )} 17 |
18 | {props.children} 19 |
20 | } -------------------------------------------------------------------------------- /src/options/comps/Reset.css: -------------------------------------------------------------------------------- 1 | .Reset { 2 | color: var(--text-color); 3 | border: 1px solid var(--text-color); 4 | padding: 2px; 5 | box-sizing: content-box; 6 | opacity: 0.5; 7 | user-select: none; 8 | border-radius: 5px; 9 | 10 | &.active { 11 | opacity: 1; 12 | } 13 | } -------------------------------------------------------------------------------- /src/options/comps/Reset.tsx: -------------------------------------------------------------------------------- 1 | import { GiAnticlockwiseRotation } from "react-icons/gi"; 2 | import "./Reset.css" 3 | 4 | type ResetProps = { 5 | onClick?: () => void, 6 | active?: boolean 7 | } 8 | 9 | export function Reset(props: ResetProps) { 10 | return 11 | } -------------------------------------------------------------------------------- /src/options/comps/Toggle.css: -------------------------------------------------------------------------------- 1 | 2 | .Toggle { 3 | display: inline-block; 4 | width: 45px; 5 | /* border-radius: 50px; */ 6 | border-radius: 5px; 7 | background-color: #a4a4a4; 8 | border: 2px solid #a4a4a4; 9 | line-height: 0; 10 | cursor: pointer; 11 | 12 | &::after { 13 | pointer-events: none; 14 | content: ""; 15 | display: inline-block; 16 | background-color: white; 17 | border: 1px solid #aaa; 18 | border-radius: inherit; 19 | box-sizing: border-box; 20 | width: 18px; 21 | height: 18px; 22 | transition: transform 0.05s linear; 23 | transform: translateX(0px); 24 | } 25 | 26 | &.active { 27 | background-color: var(--accent); 28 | border-color: var(--accent); 29 | 30 | &::after { 31 | transform: translateX(26px); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/options/comps/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import './Toggle.css' 2 | 3 | type ToggleProps = { 4 | value: boolean, 5 | onChange: (newValue: boolean) => void 6 | } 7 | 8 | export function Toggle(props: ToggleProps) { 9 | return
{ 10 | if (e.key === "Enter") { 11 | props.onChange(!props.value) 12 | } 13 | }} onClick={e => { 14 | props.onChange(!props.value) 15 | }} className={`Toggle ${props.value ? "active" : ""}`}/> 16 | } -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { loadGsm } from "../utils/gsm"; 3 | import { App } from "./App"; 4 | 5 | loadGsm().then(gsm => { 6 | gvar.gsm = gsm 7 | if (gvar.gsm) main() 8 | }) 9 | 10 | async function main() { 11 | const root = createRoot(document.querySelector('#root')) 12 | root.render() 13 | } -------------------------------------------------------------------------------- /src/options/styles.css: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --accent: #4e00fd; 4 | --accent-text-color: white; 5 | 6 | 7 | --text-color: white; 8 | --text-color-alt: #eaeaea; 9 | 10 | --true-bg: black; 11 | --bg: #181818; 12 | --bg-secondary: #222; 13 | --link-color: #c7a4f9; 14 | 15 | --border-1: #ffffff44; 16 | --border: #ffffff66; 17 | --border1: #ffffff88; 18 | 19 | --menu-color: #422d73; 20 | --menu-color-secondary: #594094; 21 | --section-width: 700px; 22 | } 23 | 24 | body { 25 | margin: 0; 26 | background-color: var(--true-bg); 27 | font-family: "Segoe UI", "Avenir", Courier, monospace; 28 | } 29 | 30 | .Options { 31 | color: var(--text-color-alt); 32 | font-size: 16px; 33 | display: grid; 34 | justify-content: center; 35 | margin-top: 80px; 36 | 37 | & > .section { 38 | background-color: var(--bg); 39 | width: var(--section-width); 40 | padding: 15px; 41 | padding-bottom: 40px; 42 | margin-bottom: 50px; 43 | font-size: 0.95rem; 44 | 45 | & > .title { 46 | background-color: var(--accent); 47 | color: var(--accent-text-color); 48 | padding: 3px 12px; 49 | display: inline-block; 50 | margin-bottom: 20px; 51 | font-size: 1.1rem; 52 | } 53 | 54 | .raw { 55 | white-space: word-break; 56 | } 57 | 58 | .colorWithReset { 59 | display: grid; 60 | align-items: center; 61 | grid-auto-flow: column; 62 | column-gap: 5px; 63 | } 64 | 65 | & > .promo { 66 | margin-bottom: 10px; 67 | line-height: 1.5; 68 | } 69 | } 70 | } 71 | 72 | select, input[type="text"], button { 73 | font-family: inherit; 74 | font-size: inherit; 75 | background-color: inherit; 76 | color: inherit; 77 | padding: 8px; 78 | 79 | &:disabled { 80 | cursor: not-allowed; 81 | } 82 | } 83 | 84 | /* Need explicit for Edge. */ 85 | select { 86 | background-color: var(--bg); 87 | color: var(--text-color); 88 | } 89 | 90 | button { 91 | border: 1px solid var(--border-1); 92 | } 93 | 94 | 95 | select, input[type="text"] { 96 | --border: var(--border-1); 97 | border: none; 98 | border-radius: 0px; 99 | border-bottom: 2px solid var(--border); 100 | 101 | &:focus { 102 | border: none; 103 | outline: none; 104 | border-bottom: 2px solid var(--border1); 105 | } 106 | } 107 | 108 | 109 | 110 | select, button { 111 | cursor: pointer; 112 | &:hover { 113 | opacity: 0.8; 114 | } 115 | } 116 | 117 | 118 | 119 | button { 120 | font-size: 1.1em; 121 | } 122 | 123 | select, input[type="text"] { 124 | text-align: center; 125 | } 126 | 127 | .card { 128 | padding: 10px; 129 | border: 1px solid var(--border-1); 130 | display: inline-block; 131 | font-size: 1.1em; 132 | white-space: pre; 133 | } 134 | 135 | a:any-link { 136 | color: var(--link-color); 137 | text-decoration: none; 138 | } 139 | 140 | a:hover { 141 | text-decoration: underline; 142 | } 143 | 144 | .section.help { 145 | .buttons { 146 | display: grid; 147 | margin-top: 40px; 148 | font-size: 1rem; 149 | grid-template-columns: max-content max-content 1fr max-content; 150 | column-gap: 10px; 151 | align-items: center; 152 | } 153 | 154 | .RedButton { 155 | background-color: #9b3232; 156 | color: white; 157 | border: none; 158 | } 159 | } 160 | 161 | 162 | -------------------------------------------------------------------------------- /src/options/utils.ts: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce" 2 | 3 | export function _requestTemplateSync() { 4 | chrome.runtime.sendMessage({type: 'SYNC_TEMPLATES'}) 5 | } 6 | 7 | export const requestTemplateSync = debounce(_requestTemplateSync, 1000, {leading: true}) 8 | 9 | let isFirefoxResult: boolean 10 | export function isFirefox() { 11 | isFirefoxResult = isFirefoxResult ?? navigator.userAgent.includes("Firefox/") 12 | return isFirefoxResult 13 | } 14 | -------------------------------------------------------------------------------- /src/preamble/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import rawStyle from '../common/searchStyle.css?raw' 3 | import { createElement, timeout } from '../helper' 4 | import preStyle from './style.css?raw' 5 | 6 | declare global { 7 | interface GlobalVar { 8 | askedForRaccoon: boolean, 9 | preambleHost: HTMLDivElement, 10 | preambleProxy: HTMLElement, 11 | preambleStub: HTMLInputElement, 12 | auth?: string, 13 | prep?: { 14 | ph?: string 15 | }, 16 | mo?: MutationObserver 17 | lastNav: HTMLElement 18 | } 19 | 20 | var gvar: GlobalVar 21 | } 22 | 23 | 24 | function loadScaffold() { 25 | const proxy = document.createElement('div') 26 | const host = createElement(`
`) as HTMLDivElement 27 | proxy.appendChild(host) 28 | const shadow = host.attachShadow({mode: 'closed'}) 29 | 30 | const style = document.createElement('style') 31 | style.textContent = `${preStyle}\n${rawStyle}` 32 | 33 | shadow.appendChild(style) 34 | 35 | const searchWrapper = createElement(`
`) 36 | if (gvar.lastNav.className.includes("_sidebar")) { 37 | searchWrapper.classList.add('new') 38 | } 39 | 40 | const search = createElement(``) as HTMLDivElement 41 | const searchIcon = createElement(``) as SVGElement 42 | const searchInput = createElement(``) as HTMLInputElement 43 | searchInput.placeholder = gvar.prep.ph 44 | search.appendChild(searchIcon) 45 | search.appendChild(searchInput) 46 | searchWrapper.appendChild(search) 47 | shadow.appendChild(searchWrapper) 48 | 49 | searchInput.addEventListener('pointerdown', handleStubClick, {capture: true, once: true}) 50 | 51 | gvar.preambleProxy = proxy 52 | gvar.preambleStub = searchInput 53 | gvar.preambleHost = host 54 | } 55 | 56 | 57 | function insertPageStyle() { 58 | const s = document.createElement("style") 59 | s.textContent = `div[role="presentation"]:focus { 60 | outline: none; 61 | }` 62 | document.documentElement.appendChild(s) 63 | } 64 | 65 | function handleStubClick(e: KeyboardEvent) { 66 | loadRaccoon() 67 | } 68 | 69 | function loadRaccoon() { 70 | if (gvar.askedForRaccoon) return 71 | chrome.runtime.sendMessage({type: 'LOAD_RACCOON'}) 72 | gvar.askedForRaccoon = true 73 | } 74 | 75 | function handleMut(muts: MutationRecord[]) { 76 | if (gvar.lastNav?.isConnected) return 77 | for (let mut of muts) { 78 | for (let added of mut.addedNodes) { 79 | if (added.nodeType !== Node.ELEMENT_NODE) continue 80 | 81 | if (checkIfNav(added as Element)) { 82 | onNewNav(added as HTMLElement) 83 | return 84 | } 85 | 86 | let nav = getNav(added as Element) 87 | nav && onNewNav(nav) 88 | 89 | } 90 | } 91 | } 92 | 93 | function checkIfNav(elem: Element) { 94 | if (elem?.tagName === "NAV" && elem.ariaLabel && elem.classList.contains("h-full")) return true 95 | if (elem?.tagName === "NAV" && elem.className.includes("_sidebar")) return true 96 | } 97 | 98 | function onNewNav(nav: HTMLElement) { 99 | gvar.lastNav = nav 100 | gvar.preambleProxy ?? loadScaffold() 101 | nav.insertAdjacentElement('afterbegin', gvar.preambleProxy) 102 | } 103 | 104 | function getNav(root?: Element) { 105 | for (let nav of (root ?? document.body).getElementsByTagName("nav")) { 106 | if (nav.ariaLabel && nav.classList.contains("h-full")) { 107 | return nav 108 | } 109 | if (nav.className.includes("_sidebar")) { 110 | return nav 111 | } 112 | } 113 | } 114 | 115 | 116 | async function onLoaded() { 117 | await timeout(500) 118 | const nav = getNav() 119 | nav && onNewNav(nav) 120 | 121 | gvar.mo = new MutationObserver(handleMut) 122 | gvar.mo.observe(document, {subtree: true, childList: true}) 123 | } 124 | 125 | function main() { 126 | gvar.prep = {} 127 | chrome.runtime.sendMessage({type: 'PREP'}).then(v => { 128 | gvar.prep = v ?? gvar.prep 129 | }).finally(() => { 130 | if (document.readyState === "loading") { 131 | document.addEventListener("DOMContentLoaded", onLoaded, {capture: true, once: true}) 132 | } else { 133 | onLoaded() 134 | } 135 | }) 136 | 137 | insertPageStyle() 138 | } 139 | 140 | 141 | main() 142 | -------------------------------------------------------------------------------- /src/preamble/style.css: -------------------------------------------------------------------------------- 1 | 2 | .search { 3 | 4 | } -------------------------------------------------------------------------------- /src/raccoon/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | import { SearchChats } from "./searchChats" 3 | import { ResultItem } from "./comps/ResultItem" 4 | import { Close, Gear, Github, Heart, Pin, SearchSvg, Star } from "../comps/svgs" 5 | import { Status } from "./types" 6 | import { LoadMore } from "./comps/LoadMore" 7 | import { useAutoBar } from "./hooks/useAutoBar" 8 | import { useClickBlur } from "./hooks/useClickBlur" 9 | import clsx from "clsx" 10 | import { Config } from "../types" 11 | import { useResize } from "./hooks/useResize" 12 | 13 | export const App = (props: {dark: boolean, top: number, left: number, config: Config, isNewDesign: boolean}) => { 14 | const { config } = props 15 | 16 | 17 | const [blur, setBlur] = useState(false) 18 | const [pinned, setPinned] = useState(false) 19 | const mainRef = useRef(null) 20 | const searchRef = useRef(null) 21 | const [query, setQuery] = useState("") 22 | const [status, setStatus] = useState(null) 23 | const smartBlur = useRef((value: boolean) => { 24 | if (value && config["g:autoClear"]) { 25 | setQuery("") 26 | } 27 | setBlur(value) 28 | }) 29 | gvar.raccoonSearch = searchRef.current 30 | 31 | useAutoBar(blur, searchRef) 32 | useClickBlur(blur, !!query && pinned, smartBlur.current) 33 | const [scale, windowSize] = useResize(mainRef) 34 | 35 | useEffect(() => { 36 | if (!query) { 37 | setStatus(null) 38 | return 39 | } 40 | setStatus({results: []}) 41 | mainRef.current.scrollTop = 0 42 | const searchChats = new SearchChats(query, setStatus, mainRef) 43 | return () => { 44 | searchChats?.release() 45 | } 46 | }, [query]) 47 | 48 | const colorOverride = config[props.dark ? "g:highlightColorDark" : "g:highlightColorLight"] 49 | 50 | return
61 |
62 | 63 | { 64 | if (e.key === "Escape") { 65 | setQuery('') 66 | setBlur(true) 67 | } 68 | }} ref={searchRef} onFocusCapture={e => setBlur(false)} autoFocus={true} type="text" placeholder={gvar.gsm.search} value={(blur && query) ? "..." : query} onChange={e => setQuery(e.target.value)}/> 69 | {query && { 70 | setQuery('') 71 | setBlur(true) 72 | }}/>} 73 |
74 | {!blur && ( 75 | <> 76 |
80 | {!!(status?.results.length) && status.results.map(v => ( 81 | 82 | ))} 83 | {status && !status.finished && } 84 | {status && status.finished && !status.results?.length &&
{gvar.gsm.notFound}
} 85 | 86 |
87 |
88 | 91 | 96 | 99 |
100 | {scale} 101 | 102 | )} 103 |
104 | } 105 | 106 | window.addEventListener("keypress", e => { 107 | const shadow = (e.target as any)?.shadowRoot as ShadowRoot 108 | if (shadow && shadow.activeElement?.tagName === "INPUT") e.stopImmediatePropagation() 109 | }, true) 110 | -------------------------------------------------------------------------------- /src/raccoon/comps/CleverDiv.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | type CleverDivProps = React.HTMLProps & { 4 | onCleverClick?: (e: React.KeyboardEvent | React.PointerEvent) => void 5 | } 6 | 7 | export function CleverDiv(props: CleverDivProps) { 8 | let p = {...props} 9 | const cleverClick = p.onCleverClick 10 | const ogPointerUp = p.onPointerUp 11 | const ogKeyUp = p.onKeyUp 12 | 13 | delete p.onCleverClick 14 | 15 | if (cleverClick) { 16 | delete p.onPointerUp 17 | delete p.onKeyUp 18 | } 19 | 20 | return
{ 22 | if (e.button === 0) cleverClick(e) 23 | ogPointerUp?.(e) 24 | }, 25 | onKeyDown: e => { 26 | if (e.key === "Enter") { 27 | cleverClick(e) 28 | } 29 | ogKeyUp?.(e) 30 | } 31 | } : {})}/> 32 | } -------------------------------------------------------------------------------- /src/raccoon/comps/LoadMore.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react" 2 | import { SearchChats } from "../searchChats" 3 | 4 | 5 | let latestWasIntersected = false 6 | 7 | export function LoadMore(props: {}) { 8 | const ref = useRef(null as HTMLDivElement) 9 | useEffect(() => { 10 | const obs = new IntersectionObserver(entries => { 11 | latestWasIntersected = entries.at(-1).isIntersecting 12 | if (latestWasIntersected) { 13 | SearchChats.ref?.loadSafely() 14 | } 15 | }, { 16 | threshold: 0.75, 17 | root: ref.current.parentElement 18 | }) 19 | obs.observe(ref.current) 20 | 21 | const intervalId = setInterval(() => { 22 | if (latestWasIntersected) { 23 | SearchChats.ref?.loadSafely() 24 | } 25 | }, 100) 26 | 27 | return () => { 28 | obs.disconnect() 29 | clearInterval(intervalId) 30 | } 31 | }, [ref.current, ref.current?.parentElement]) 32 | 33 | return
34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/raccoon/comps/ResultItem.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { memo, useRef, useState } from "react" 3 | import { Result } from "../types" 4 | import { softLink } from "../utils/misc" 5 | import { timeout } from "../../helper" 6 | import { CleverDiv } from "./CleverDiv" 7 | import { openLink } from "../../utils/browser" 8 | import { isFirefox } from "../../options/utils" 9 | 10 | const _ResultItem = (props: { result: Result, scrollTop: boolean}) => { 11 | const [max, setMax] = useState(4) 12 | const env = useRef({lastWasChild: false}).current 13 | const r = props.result 14 | const titleResult = r.headerResult 15 | 16 | const open = (messageId?: string, mode?: "fg" | "bg") => { 17 | let url = `/c/${r.id}` 18 | if (r.isGpt) { 19 | url = `/g/g-${r.id}` 20 | } 21 | if (mode) { 22 | openLink(`https://chatgpt.com${url}`, mode === "fg") 23 | } else { 24 | if (location.pathname !== url) softLink(url) 25 | clearTimeoutInfo() 26 | 27 | let scrollTo: {id: String, isCurrent?: boolean} 28 | 29 | if (messageId) { 30 | scrollTo = {id: messageId} 31 | } else if (r.currentNodeId) { 32 | scrollTo = {id: r.currentNodeId, isCurrent: true} 33 | } 34 | env.lastWasChild = !!messageId 35 | 36 | if (scrollTo) tryScrollIntoView(`div[data-message-id="${scrollTo.id}"]`, scrollTo.isCurrent, props.scrollTop) 37 | } 38 | 39 | } 40 | 41 | const handleTitleClick = (e: React.PointerEvent | React.KeyboardEvent) => { 42 | open(null) 43 | } 44 | 45 | const handleMessageClick = (messageId: string, e: React.PointerEvent | React.KeyboardEvent) => { 46 | open(messageId) 47 | } 48 | 49 | const handleItemPointerUp= (e: React.PointerEvent) => { 50 | if (e.button !== 1) return 51 | open(null, e.shiftKey ? "fg" : "bg") 52 | } 53 | 54 | let metaChunks: JSX.Element[] = [] 55 | if (!r.isGpt && r.gizmoName) { 56 | r.gizmoImg && metaChunks.push() 57 | metaChunks.push({r.gizmoName}) 58 | } 59 | if (r.elapsed) metaChunks.unshift({`${r.elapsed}${metaChunks.length === 1 ? ' · ' : ''}`}) 60 | 61 | return
62 | 63 |
64 | {r.isGpt && r.gizmoImg && ( 65 | 66 | )} 67 | {titleResult ? titleResult.prefix : r.title} 68 | {titleResult && <> 69 | {titleResult.needle} 70 | {titleResult.suffix} 71 | } 72 |
73 | {!!metaChunks.length && ( 74 |
{metaChunks.map(c => c)}
75 | )} 76 |
77 | {!!r.parts.length && ( 78 |
79 | <> 80 | {r.parts.slice(0, max).map(part => ( 81 | 82 | {part.prefix} 83 | {part.needle} 84 | {part.suffix} 85 | 86 | ))} 87 | 88 | {r.parts.length > max && ( 89 | { 90 | setMax(max + 10) 91 | }}>{`${gvar.gsm.showMore} (${Math.min(r.parts.length - max, 10)})`} 92 | )} 93 |
94 | )} 95 |
96 | } 97 | 98 | export const ResultItem = memo(_ResultItem) 99 | 100 | let latestSymbol: Symbol 101 | let lastScrollPath: string 102 | let SUPPORTS_SCROLL_INTO_VIEW = "scrollIntoViewIfNeeded" in Element.prototype 103 | let recentScroll: { 104 | target: string, 105 | subtle?: boolean, 106 | scrollTop?: boolean, 107 | time: number 108 | } 109 | 110 | 111 | 112 | async function tryScrollIntoView(target: string, subtle?: boolean, scrollTop?: boolean, isFake?: boolean, n = 60, delay = 100) { 113 | gvar.scrollSetCbs.add(handleScrollSet) 114 | recentScroll = null 115 | const mySymbol = Symbol() 116 | latestSymbol = mySymbol 117 | for (let i = 0; i < n; i++) { 118 | i > 0 && await timeout(delay) 119 | if (latestSymbol !== mySymbol) return 120 | const elem = document.querySelector(target) 121 | if (elem) { 122 | const isFirst = lastScrollPath !== location.pathname 123 | lastScrollPath = location.pathname 124 | // if (isFirst) await timeout(isFirefox() ? 500 : 500) 125 | if (isFirst && !isFake) recentScroll = {target, subtle, scrollTop, time: Date.now()} 126 | 127 | if (subtle) { 128 | const parent = getScrollableParent(elem) 129 | if (parent) { 130 | parent.scrollTo({top: scrollTop ? 0 : 999999999, behavior: "instant"}) 131 | return 132 | } 133 | } 134 | !subtle && activateFor(elem) 135 | SUPPORTS_SCROLL_INTO_VIEW ? (elem as any).scrollIntoViewIfNeeded() : (elem as any).scrollIntoView() 136 | return 137 | } 138 | } 139 | } 140 | 141 | 142 | let timeoutInfo: { 143 | target: Element, 144 | timeoutId: number 145 | } 146 | 147 | function clearTimeoutInfo() { 148 | if (timeoutInfo) { 149 | clearTimeout(timeoutInfo.timeoutId) 150 | timeoutInfo.target.removeAttribute("bornagain") 151 | timeoutInfo = null 152 | } 153 | } 154 | 155 | function activateFor(target: Element) { 156 | ensureStyleElement() 157 | clearTimeoutInfo() 158 | 159 | target.setAttribute("bornagain", "") 160 | timeoutInfo = { 161 | target, 162 | timeoutId: window.setTimeout(clearTimeoutInfo, 2500) 163 | } 164 | } 165 | 166 | 167 | let focusStyle: HTMLStyleElement 168 | function ensureStyleElement() { 169 | if (!focusStyle) { 170 | focusStyle = document.createElement('style') 171 | focusStyle.textContent = ":is([bornagain], #bornagainnnn > #woo > #barked) { outline: 1px solid red !important; }" 172 | } 173 | if (!focusStyle.isConnected) document.documentElement.appendChild(focusStyle) 174 | } 175 | 176 | 177 | 178 | function getScrollableParent(element: Element) { 179 | let parent = element.parentNode as Element 180 | 181 | while (parent) { 182 | const overflowY = window.getComputedStyle(parent).overflowY 183 | if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) { 184 | return parent 185 | } 186 | parent = parent.parentNode as Element 187 | } 188 | } 189 | 190 | 191 | 192 | async function handleScrollSet() { 193 | let originalRecentScroll = recentScroll 194 | if (!(recentScroll && Date.now() - recentScroll.time < 10_000)) return 195 | await timeout(1000) 196 | if (originalRecentScroll !== recentScroll) return 197 | tryScrollIntoView(recentScroll.target, recentScroll.subtle, recentScroll.scrollTop, true) 198 | } -------------------------------------------------------------------------------- /src/raccoon/hooks/useAutoBar.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from "react" 2 | import { isFirefox } from "../../options/utils" 3 | 4 | export function useAutoBar(blur: boolean, searchRef: React.MutableRefObject) { 5 | useLayoutEffect(() => { 6 | if (blur) { 7 | enterSidebar() 8 | } else { 9 | exitSidebar() 10 | searchRef.current.focus() 11 | isFirefox() && requestAnimationFrame(() => { 12 | searchRef.current.focus() 13 | }) 14 | } 15 | }, [blur, searchRef]) 16 | } 17 | 18 | function enterSidebar() { 19 | gvar.preambleProxy.insertAdjacentElement('afterbegin', gvar.raccoonHost) 20 | gvar.preambleHost.remove() 21 | } 22 | 23 | function exitSidebar() { 24 | gvar.preambleProxy.insertAdjacentElement('afterbegin', gvar.preambleHost) 25 | document.documentElement.appendChild(gvar.raccoonHost) 26 | } -------------------------------------------------------------------------------- /src/raccoon/hooks/useClickBlur.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | export function useClickBlur(blur: boolean, pinned: boolean, setBlur: (newValue: boolean) => any) { 4 | useEffect(() => { 5 | if (blur || pinned) return 6 | 7 | const handleClick = (e: PointerEvent) => { 8 | if (e.target !== gvar.preambleHost && e.target !== gvar.raccoonHost) { 9 | setBlur(true) 10 | } 11 | } 12 | 13 | window.addEventListener('pointerdown', handleClick, true) 14 | 15 | return () => { 16 | window.removeEventListener('pointerdown', handleClick, true) 17 | } 18 | }, [blur, pinned]) 19 | } -------------------------------------------------------------------------------- /src/raccoon/hooks/useResize.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { clamp } from "../../helper" 3 | 4 | type DragRef = { 5 | x: number, 6 | y: number, 7 | w: number, 8 | h: number 9 | } 10 | 11 | type Bounds = { 12 | w: number, 13 | h: number 14 | } 15 | 16 | 17 | export function useResize(mainRef: React.MutableRefObject>) { 18 | const [dragRef, setDragRef] = useState(null as DragRef) 19 | const [windowSize, setWindowSize] = useState({w: 350, h: 60} as Bounds) 20 | 21 | useEffect(() => { 22 | if (!dragRef) return 23 | const handleMove = (e: PointerEvent) => { 24 | const deltaX = e.clientX - dragRef.x 25 | const x = clamp(300, 600, dragRef.w + deltaX) 26 | 27 | const deltaY = (e.clientY - dragRef.y) / window.innerHeight * 100 28 | let y = clamp(50, 80, Math.min(dragRef.h + deltaY, mainRef.current.scrollHeight / window.innerHeight * 100)) 29 | 30 | setWindowSize({w: x, h: y}) 31 | } 32 | const handleUp = (e: PointerEvent) => { 33 | handleMove(e) 34 | setDragRef(null) 35 | } 36 | window.addEventListener('pointermove', handleMove, {capture: true}) 37 | window.addEventListener('pointerup', handleUp, {capture: true}) 38 | 39 | return () => { 40 | window.removeEventListener('pointermove', handleMove, {capture: true}) 41 | window.removeEventListener('pointerup', handleUp, {capture: true}) 42 | } 43 | }, [dragRef]) 44 | 45 | return [ 46 |
{ 47 | setDragRef({x: e.clientX, y: e.clientY, w: windowSize.w, h: windowSize.h}) 48 | }}/>, 49 | windowSize 50 | ] as [React.ReactElement,Bounds] 51 | } 52 | -------------------------------------------------------------------------------- /src/raccoon/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client" 2 | import rawStyle from './style.css?raw' 3 | import rawSearchStyle from '../common/searchStyle.css?raw' 4 | import { App } from './App' 5 | import { requestGsm } from "../utils/gsm" 6 | import { CONFIG_KEYS, Config } from "../types" 7 | import { timeout } from "../helper" 8 | import { localGet, localSet, sessionGetFallback, sessionSetFallback } from "../utils/browser" 9 | 10 | declare global { 11 | interface GlobalVar { 12 | raccoonHost: HTMLDivElement, 13 | raccoonSearch?: HTMLInputElement, 14 | config?: Config, 15 | getItems?: typeof localGet, 16 | setItems?: typeof localSet, 17 | scrollSetCbs?: Set<() => void> 18 | } 19 | } 20 | gvar.scrollSetCbs = gvar.scrollSetCbs || new Set() 21 | 22 | async function main() { 23 | const b = gvar.preambleStub?.getBoundingClientRect() 24 | if (b.left == null || b.top == null) return 25 | 26 | const [config, auth] = await Promise.all([ 27 | chrome.storage.local.get(CONFIG_KEYS) as Promise, 28 | getAuth() 29 | ]) 30 | if (auth) gvar.auth = auth 31 | if (!gvar.auth) { 32 | await timeout(1000) 33 | gvar.auth = await chrome.runtime.sendMessage({type: 'GET_AUTH'}) 34 | } 35 | if (!gvar.auth) console.error('No auth found') 36 | 37 | if (config["g:sessionOnly"]) { 38 | gvar.getItems = sessionGetFallback 39 | gvar.setItems = sessionSetFallback 40 | } else { 41 | gvar.getItems = localGet 42 | gvar.setItems = localSet 43 | } 44 | 45 | gvar.config = config 46 | 47 | gvar.raccoonHost = document.createElement('div') 48 | 49 | const shadow = gvar.raccoonHost.attachShadow({mode: 'open'}) 50 | const rootBase = document.createElement('div') 51 | const style = document.createElement('style') 52 | style.textContent = rawSearchStyle.concat(rawStyle) 53 | 54 | shadow.appendChild(rootBase) 55 | shadow.appendChild(style) 56 | 57 | document.documentElement.appendChild(gvar.raccoonHost) 58 | 59 | const root = createRoot(rootBase) 60 | chrome.storage.local.set({'o:lastTheme': document.documentElement.classList.contains('dark') ? 'dark' : 'light'}) 61 | 62 | const dark = document.documentElement.classList.contains('dark') 63 | 64 | root.render() 65 | } 66 | 67 | 68 | requestGsm().then(gsm => { 69 | gvar.gsm = gsm 70 | if (gvar.gsm) main() 71 | }) 72 | 73 | 74 | window.addEventListener('rusrusar', (e: CustomEvent) => { 75 | const deets = JSON.parse(e.detail) 76 | if (deets.type === 'NO_PUSH') { 77 | chrome.runtime.sendMessage({type: "OPEN_LINK", url: `${location.origin}${deets.path}`, active: false}) 78 | } else if (deets.type === "SCROLL_SET") { 79 | gvar.scrollSetCbs?.forEach(cb => cb()) 80 | } 81 | e.stopImmediatePropagation() 82 | }, {capture: true}) 83 | 84 | async function getAuth() { 85 | let auth = (await sessionGetFallback(['s:auth']))['s:auth'] 86 | if (auth) return auth 87 | auth = (await chrome.storage.local.get('o:auth'))['o:auth'] 88 | if (auth) return auth 89 | } 90 | 91 | -------------------------------------------------------------------------------- /src/raccoon/searchChats/Grabby.ts: -------------------------------------------------------------------------------- 1 | import { QueueLock } from "../../helper" 2 | import { Chats } from "../types" 3 | import { fetchChats } from "../utils/fetchChats" 4 | import { getTtl } from "../utils/getTtl" 5 | import { getGizmosAsChats } from "../utils/gizmo" 6 | 7 | 8 | 9 | export class Grabby { 10 | static lastFetch = new QueueLock() 11 | static lastFetchActive = new QueueLock() 12 | static maxPages: number = Infinity 13 | 14 | static fetchMap: {[key: string]: Promise} = {} 15 | static bgFetchMap: {[key: string]: Promise} = {} 16 | 17 | static requestFetch = async (page: number, oldChats?: Chats) => { 18 | const key = (oldChats ? "bgFetchMap" : "fetchMap") satisfies keyof typeof Grabby 19 | 20 | let prom = Grabby[key][page] 21 | const now = Date.now() 22 | if (prom && prom.time > (now - 60_000 * 10)) { 23 | return prom 24 | } 25 | Grabby[key][page] = this.fetchChatsAndSave(page, oldChats) 26 | Grabby[key][page].time = now 27 | prom = Grabby[key][page] 28 | 29 | return prom 30 | } 31 | static fetchChatsAndSave = async (page: number, oldChats?: Chats) => { 32 | if (oldChats) { 33 | await Grabby.lastFetch.wait(3_000) 34 | } else { 35 | await Grabby.lastFetchActive.wait(3_000) 36 | } 37 | 38 | const chats = await fetchChats(page, gvar.auth) 39 | Grabby.maxPages = chats.maxPages 40 | 41 | await gvar.setItems({[`o:c:${page.toFixed(0)}`]: chats}) 42 | 43 | if (oldChats) await this.backgroundShift(chats, oldChats) 44 | } 45 | static backgroundShift = async (chats: Chats, oldChats: Chats) => { 46 | const current = new Set(chats.chats.map(c => c.id)) 47 | const ghosts = oldChats.chats.filter(c => !current.has(c.id)) 48 | const key = `o:c:${(chats.page + 1).toFixed(0)}` 49 | const nextPage = (await gvar.getItems(key))[key] as Chats 50 | if (!nextPage) return 51 | nextPage.chats = [...nextPage.chats, ...ghosts] 52 | await gvar.setItems({[key]: nextPage}) 53 | } 54 | static getCached = async (page: number) => { 55 | const key = `o:c:${page.toFixed(0)}` 56 | const cached = (await gvar.getItems([key]))[key] as Chats 57 | if (cached) { 58 | if (cached.indexed < Date.now() - getTtl(page)) Grabby.requestFetch(page, cached) 59 | return cached 60 | } 61 | } 62 | static getPage: (page: number) => Promise = async page => { 63 | if (page === -1) { 64 | return {chats: await getGizmosAsChats(), hasMore: true} as Chats 65 | } 66 | 67 | // cached 68 | let cached = await Grabby.getCached(page) 69 | if (cached) return cached 70 | 71 | await Grabby.requestFetch(page) 72 | cached = await Grabby.getCached(page) 73 | if (cached) return cached 74 | 75 | return {page, chats: [], hasMore: false, indexed: Date.now()} as Chats 76 | } 77 | } -------------------------------------------------------------------------------- /src/raccoon/searchChats/core.ts: -------------------------------------------------------------------------------- 1 | 2 | import escapeStringRegexp from 'escape-string-regexp'; 3 | 4 | type Context = { 5 | opts: Options, 6 | exclude: string[], 7 | terms: Term[] 8 | } 9 | 10 | 11 | type Options = { 12 | ordered?: boolean, 13 | threshold?: number 14 | } 15 | 16 | type Solt = {lb: number, rb: number, idx: number, score: number} 17 | 18 | type Term = { 19 | needle: string, 20 | needleSize: number, 21 | groups: {match: string | RegExp, points: number}[][] 22 | } 23 | 24 | // First check if contains 25 | // Next check if contains strictly with delims. 26 | 27 | export function search(items: string[], query: string, opts: Options): Solt[] { 28 | const { needles, exclude } = getNeedlesAndExclude(query || "") 29 | 30 | if (needles.length === 0) { 31 | if (exclude.length) return getNonExcluded(items, exclude) 32 | return [] 33 | } 34 | 35 | 36 | const env: Context = { 37 | exclude, 38 | opts, 39 | terms: needles.map(needle => { 40 | let escaped = escapeStringRegexp(needle)?.trim() 41 | const term = { 42 | needle, 43 | needleSize: needle.length, 44 | groups: [ 45 | [{match: needle.toLocaleLowerCase(), points: 20}] 46 | ] 47 | } as Term 48 | 49 | try { 50 | term.groups.push( 51 | [ 52 | {match: new RegExp(String.raw`${escaped}(?!\p{Letter})`, 'ui'), points: 20}, 53 | {match: new RegExp(String.raw`(?= (opts.threshold ?? 20)) newItems.push({...solt, idx: i} as Solt) 68 | } 69 | 70 | if (opts.ordered) { 71 | newItems.sort((a, b) => { 72 | return b.score - a.score 73 | }) 74 | } 75 | 76 | return newItems 77 | } 78 | 79 | function getNeedlesAndExclude(query: string) { 80 | let needles = [] 81 | let current: string[] = [] 82 | let exclude: string[] = [] 83 | for (let token of query.split(' ')) { 84 | token = token.trim() 85 | const startsWithMinus = token.startsWith("-") 86 | if (startsWithMinus || token.startsWith("+")) { 87 | if (current.length) { 88 | needles.push(current.join(" ").trim()) 89 | current = [] 90 | } 91 | token.length > 1 && (startsWithMinus ? exclude : needles).push(token.slice(1)) 92 | continue 93 | } 94 | token.length && current.push(token) 95 | } 96 | if (current.length) needles.push(current.join(" ").trim()) 97 | 98 | return {needles, exclude} 99 | } 100 | 101 | function scoreWithTerm(v: string, term: Term, env: Context): Partial { 102 | let score = 0 103 | let lb: number 104 | let rb: number 105 | 106 | for (let group of term.groups) { 107 | let matched = false 108 | for (let {match, points} of group) { 109 | let idx = typeof match === "object" ? v.search(match) : v.indexOf(match) 110 | if (idx < 0) continue 111 | matched = true 112 | score += points 113 | lb = idx 114 | rb = lb + term.needleSize 115 | } 116 | if (!matched) break 117 | } 118 | 119 | return {score, lb, rb} 120 | } 121 | 122 | function scoreItem(v: string, env: Context): Partial { 123 | let highestRes: Partial 124 | 125 | for (let term of env.terms) { 126 | const res = scoreWithTerm(v, term, env) 127 | if (res && res.score > (highestRes?.score ?? -1)) { 128 | highestRes = res 129 | } 130 | } 131 | 132 | if (checkExcluded(v, env.exclude)) return 133 | 134 | return highestRes 135 | } 136 | 137 | 138 | function checkExcluded(v: string, exclude: string[]) { 139 | for (let token of exclude) { 140 | if (v.includes(token)) return true 141 | } 142 | } 143 | 144 | function getNonExcluded(items: string[], exclude: string[]) { 145 | let solts: Solt[] = [] 146 | 147 | for (let i = 0; i < items.length; i++) { 148 | const item = items[i] 149 | if (checkExcluded(item, exclude)) continue 150 | solts.push({idx: i, lb: 0, rb: 0, score: 20}) 151 | } 152 | return solts 153 | } -------------------------------------------------------------------------------- /src/raccoon/searchChats/extractContext.ts: -------------------------------------------------------------------------------- 1 | const FULL_STOP = /[?!\.。!?۔।॥\|¡¿\n\t]/ 2 | 3 | export type Context = {prefix: string, needle: string, suffix: string} 4 | 5 | export function getContext(content: string, start: number, end: number, context = 2) { 6 | if (!end || start === end) return {prefix: content.slice(0, context * 200), suffix: '', needle: ''} 7 | 8 | content = content.trim() // replace(/\n+/, '\n') 9 | const preContext = Math.ceil(context / 2) 10 | const postContext = context - preContext 11 | 12 | const needle = content.slice(start, end) 13 | 14 | let prefix = '' 15 | if (start) { 16 | const tempPrefix = content.slice(0, start).split('').reverse().join('') 17 | prefix = collectSearch(tempPrefix, preContext, preContext * 100, true).split('').reverse().join('').trimStart() 18 | } 19 | 20 | const tempSuffix = content.slice(end) 21 | let suffix = collectSearch(tempSuffix, postContext, postContext * 100).trimEnd() 22 | return {prefix, needle, suffix} as Context 23 | } 24 | 25 | 26 | export function collectSearch(content: string, n: number, max: number, minus?: boolean) { 27 | let collected = 0 28 | for (let i = 0; i < n; i++) { 29 | const idx = content.slice(collected).search(FULL_STOP) 30 | if (idx === -1) return content.slice(0, max) 31 | 32 | collected += idx + 1 33 | } 34 | return content.slice(0, Math.min(minus ? collected - 1 : collected, max)) 35 | } -------------------------------------------------------------------------------- /src/raccoon/searchChats/extractOpts.ts: -------------------------------------------------------------------------------- 1 | import { getGizmosSync } from "../utils/gizmo" 2 | import { search } from "./core" 3 | 4 | const optBaseFlags = ['dalle', 'browse', 'python', 'gizmo', 'gizmos', 'title', 'gpt', 'body', 'ast', 'user', 'gpt4', 'archived', 'archive', 'g'] 5 | const optSourceMapping = [ 6 | ['+c', 'createdAfter', 'date'], ['-c', 'createdBefore', 'date'], 7 | ['+u', 'updatedAfter', 'date'], ['-u', 'updatedBefore', 'date'], 8 | ['+turns', 'turnsPlus', 'int'], ['-turns', 'turnsMinus', 'int'] 9 | ] 10 | 11 | export function extractOpts(query: string): {query: string, opts: PreFilter} { 12 | let opts: PreFilter = {} 13 | let tokens = query.toLowerCase().split(' ') 14 | let tokensB: string[] = [] 15 | 16 | tokens.forEach(token => { 17 | const suffix = token.slice(1) 18 | let matched = false 19 | if (optBaseFlags.includes(suffix)) { 20 | if (token[0] === '+') { 21 | (opts as any)[suffix] = true 22 | matched = true 23 | } else if (token[0] === '-') { 24 | (opts as any)[suffix] = false 25 | matched = true 26 | } 27 | } 28 | if (!matched) { 29 | tokensB.push(token.trim()) 30 | } 31 | }) 32 | 33 | let matched = false 34 | tokens = [] 35 | for (let [index, token] of tokensB.entries()) { 36 | if (matched) { 37 | matched = false 38 | continue 39 | } 40 | 41 | for (let [flag, propertyName, type] of optSourceMapping) { 42 | if (token === flag) { 43 | matched = true; 44 | const parsed = parseAs(tokensB[index + 1], type as any) 45 | if (parsed) (opts as any)[propertyName] = parsed 46 | } 47 | } 48 | if (!matched) { 49 | tokens.push(token.trim()) 50 | } 51 | } 52 | // pre-processing 53 | let exclusionMode = ![opts.ast, opts.body, opts.title, opts.user, opts.gpt].some(v => v) 54 | opts.title = opts.title || (exclusionMode && opts.title !== false) 55 | opts.gpt = opts.gpt || (exclusionMode && opts.gpt !== false) 56 | 57 | // False first to preserve specificity 58 | opts.ast = opts.ast === false ? false : ( opts.ast || opts.body || (exclusionMode && opts.body !== false) ) 59 | opts.user = opts.user === false ? false : ( opts.user || opts.body || (exclusionMode && opts.body !== false) ) 60 | 61 | delete opts.body 62 | 63 | let _query = tokens.join(' ') 64 | 65 | if (opts.g) { 66 | opts.gizmoIds = findGizmoIdByTitle(_query) 67 | opts.title = true 68 | opts.ast = opts.user = opts.gpt = false 69 | return {query: '', opts} 70 | } 71 | 72 | return {query: _query, opts} 73 | } 74 | 75 | function parseAs(v: string, type: 'date' | 'int') { 76 | if (type === "date") { 77 | const d = parseDateAsYY(v) 78 | if (d) { 79 | return d.getTime() 80 | } 81 | } else if (type === "int") { 82 | const d = parseInt(v) 83 | if (d && !isNaN(d)) { 84 | return d 85 | } 86 | } 87 | } 88 | 89 | 90 | const YY_REGEX = /^\d{4}[-\/:,\._]\d{1,2}[-\/:,\._]\d{1,2}$/; 91 | function parseDateAsYY(v: string) { 92 | if (!v) return 93 | if (!YY_REGEX.test(v)) return 94 | return new Date(v) 95 | } 96 | 97 | export type PreFilter = { 98 | title?: boolean, 99 | body?: boolean, 100 | user?: boolean, 101 | ast?: boolean, 102 | 103 | dalle?: boolean 104 | browse?: boolean, 105 | python?: boolean, 106 | gizmo?: boolean, 107 | gizmos?: boolean, 108 | gpt4?: boolean, 109 | archived?: boolean, 110 | gpt?: boolean, 111 | g?: boolean, 112 | gg?: boolean, 113 | 114 | createdAfter?: number, 115 | createdBefore?: number, 116 | updatedAfter?: number, 117 | updatedBefore?: number 118 | 119 | turnsPlus?: number, 120 | turnsMinus?: number, 121 | 122 | gizmoIds?: Set 123 | } 124 | 125 | 126 | function findGizmoIdByTitle(query: string): Set { 127 | const gizmos = getGizmosSync() 128 | return new Set((search(gizmos.map(g => g.name), query, {}) ?? []).map(s => `g-${gizmos[s.idx].id}`)) 129 | } -------------------------------------------------------------------------------- /src/raccoon/searchChats/filterChats.ts: -------------------------------------------------------------------------------- 1 | import { Chat, ChatPart, MessagePart, PartResult, Result } from "../types" 2 | import { extractOpts } from "./extractOpts" 3 | import { getElapsed } from "../utils/getElapsed" 4 | import { preFilterChats } from "./preFilter" 5 | import { Context, getContext } from "./extractContext" 6 | import { getGizmoByIdSync } from "../utils/gizmo" 7 | import { search } from "./core" 8 | 9 | const DELIM = /\s*&&\s*/ 10 | 11 | export function multiFilterChats(chats: Chat[], _query: string, contextLevel: number): Result[] { 12 | // filter chats 13 | for (let query of _query.split(DELIM)) { 14 | chats = filterChats(chats, query, contextLevel) 15 | } 16 | 17 | return chats.map(c => chatToResult(c)) 18 | } 19 | 20 | 21 | 22 | function filterChats(chats: Chat[], _query: string, contextLevel: number): Chat[] { 23 | if (!chats?.length) return [] 24 | 25 | let {query, opts} = extractOpts(_query) 26 | const parts = preFilterChats(chats, opts) 27 | 28 | // after pre-filtering, there might not be any query left. 29 | query = query.trim() 30 | if (query === "") return rebuildChats(parts) 31 | 32 | 33 | const solts = search(parts.map(c => c.content), query, { 34 | threshold: (gvar.config["g:strictSearch"] && !gvar.gsm._morpho) ? 40 : 20, 35 | ordered: !gvar.config["g:orderByDate"] 36 | }) 37 | if (!solts.length) return [] 38 | 39 | let newParts: ChatPart[] = [] 40 | 41 | try { 42 | for (let solt of solts) { 43 | const part = parts[solt.idx] 44 | if (!(solt.rb || part.type === "title")) continue 45 | let ctx: Context 46 | if (contextLevel) { 47 | ctx = getContext(part.content, solt.lb, solt.rb, contextLevel) 48 | } 49 | newParts.push(part) 50 | 51 | part.result = { 52 | messageId: (part as MessagePart).messageId, 53 | ...(ctx ?? {}) 54 | } 55 | } 56 | } catch (err) { 57 | console.error(err) 58 | } 59 | 60 | return rebuildChats(newParts) 61 | } 62 | 63 | function chatToResult(c: Chat): Result { 64 | 65 | let headerResult: PartResult 66 | const headerIdx = c.parts.findIndex(p => p.type === "title" || p.type === "gpt") 67 | if (headerIdx >= 0) { 68 | headerResult = c.parts.splice(headerIdx, 1)[0].result 69 | } 70 | 71 | let gizmoName: string 72 | let gizmoImg: string 73 | let gizmoId: string 74 | 75 | if (c._gizmo) { 76 | gizmoName = c._gizmo.name, 77 | gizmoImg = c._gizmo.imageUrl, 78 | gizmoId = `g-${c._gizmo.id}` 79 | } else if (c.gizmoId) { 80 | let info = getGizmoByIdSync(c.gizmoId.slice(2)) 81 | if (info) { 82 | gizmoName = info.name 83 | if (gvar.config["g:showImage"]) gizmoImg = info.imageUrl 84 | } 85 | } 86 | 87 | return { 88 | id: c.id, 89 | title: c._gizmo?.name ?? c.title, 90 | elapsed: c.updateTime ? getElapsed(c.updateTime, gvar.gsm?._lang ?? 'en') : null, 91 | parts: c.parts.map(c => c.result).filter(v => v), 92 | headerResult, 93 | gizmoId: gizmoId ?? c.gizmoId, 94 | isGpt: c._gizmo ? true : false, 95 | gizmoName, 96 | gizmoImg, 97 | currentNodeId: c.currentNodeId 98 | } 99 | } 100 | 101 | function rebuildChats(parts: ChatPart[]) { 102 | const chatSet: Set = new Set() 103 | const chats: Chat[] = [] 104 | 105 | parts.forEach(part => { 106 | const chat = part.chat 107 | if (!chatSet.has(chat)) { 108 | chat.parts = [] 109 | chatSet.add(chat) 110 | chats.push(chat) 111 | } 112 | 113 | // // if no result with +gg. 114 | // if (part.type === 'message' && !part.result) { 115 | // part.result = { 116 | // messageId: part.messageId, 117 | // needle: '', 118 | // prefix: part.content.slice(0, 35).concat('...'), 119 | // suffix: '' 120 | // } 121 | // } 122 | 123 | chat.parts.push(part) 124 | }) 125 | 126 | return chats 127 | } 128 | 129 | -------------------------------------------------------------------------------- /src/raccoon/searchChats/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Result, Status } from "../types" 3 | import { multiFilterChats } from "./filterChats" 4 | import { produce } from "immer" 5 | import { Grabby } from "./Grabby" 6 | import debounce from "lodash.debounce" 7 | 8 | declare global { 9 | interface Promise { 10 | time?: number 11 | } 12 | } 13 | 14 | 15 | export class SearchChats { 16 | released = false 17 | page = -2 18 | latestResults: Result[] = [] 19 | locked = false 20 | errorCount = 0 21 | throttleTimeoutId: number 22 | context = 2 23 | processed: Set = new Set() 24 | initAwait: Promise 25 | 26 | constructor(private query: string, private setStatus: (status: Status) => void, private mainRef: React.MutableRefObject) { 27 | SearchChats.ref = this 28 | this.initAwait = this.start() 29 | } 30 | start = async () => { 31 | if (this.released) return 32 | this.context = gvar.config["g:context"] ?? 2 33 | this.loadSafely() 34 | } 35 | next = async (replace?: boolean) => { 36 | this.page++ 37 | const data = await Grabby.getPage(this.page) 38 | if (this.released) return 39 | 40 | if (replace) { 41 | this.latestResults = [] 42 | this.mainRef.current?.scrollTo({left: 0, top: 0, behavior: 'instant'}) 43 | } else { 44 | this.setStatus?.({results: this.latestResults}) 45 | } 46 | 47 | 48 | // ignore duplicates 49 | data.chats = data.chats.filter(c => { 50 | if (this.processed.has(c.id)) return false 51 | this.processed.add(c.id) 52 | return true 53 | }) 54 | 55 | let res: Result[] 56 | try { 57 | const chats = [...data.chats] 58 | res = multiFilterChats(chats, this.query, this.context) ?? [] 59 | } catch (err) { 60 | console.error('ERROR', err) 61 | throw err 62 | } 63 | 64 | 65 | this.latestResults = produce(this.latestResults, d => { 66 | d.push(...res) 67 | }) 68 | 69 | this.setStatus?.({ 70 | results: this.latestResults, 71 | finished: !data.hasMore 72 | }) 73 | } 74 | loadSafely = debounce(async () => { 75 | await this.initAwait 76 | if (this.released || this.locked || (this.page + 1 >= Grabby.maxPages) || this.errorCount > 10) return 77 | 78 | this.locked = true 79 | this.next().then(() => { 80 | this.errorCount = 0 81 | this.locked = false 82 | }, err => { 83 | this.errorCount++ 84 | this.locked = false 85 | }) 86 | }, 50, {trailing: true, leading: true, maxWait: 50}) 87 | release = () => { 88 | if (this.released) return 89 | delete this.setStatus 90 | this.released = true 91 | delete SearchChats.ref 92 | clearTimeout(this.throttleTimeoutId) 93 | } 94 | static ref: SearchChats 95 | } 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /src/raccoon/searchChats/preFilter.ts: -------------------------------------------------------------------------------- 1 | import { Chat, ChatPart } from "../types" 2 | import { PreFilter } from "./extractOpts" 3 | 4 | export function preFilterChats(chats: Chat[], opts: PreFilter): ChatPart[] { 5 | 6 | if ((opts as any).archive != null) { 7 | opts.archived = (opts as any).archive 8 | } 9 | 10 | chats = chats.filter(c => { 11 | if (opts.dalle != null && opts.dalle != !!c.usedDalle) return false 12 | if (opts.browse != null && opts.browse != !!c.usedBrowser) return false 13 | if (opts.python != null && opts.python != !!c.usedPython) return false 14 | if (opts.gizmo != null && opts.gizmo != !!c.gizmoId) return false 15 | if (opts.gizmos != null && opts.gizmos != !!c.multiGizmo) return false 16 | if (opts.gpt4 != null && opts.gpt4 != !!c.usedGPT4) return false 17 | if (opts.archived != null && opts.archived != !!c.isArchived) return false 18 | 19 | if (opts.createdAfter && (c.createTime < opts.createdAfter || c.createTime == null)) return false 20 | if (opts.createdBefore && (c.createTime > opts.createdBefore || c.createTime == null)) return false 21 | if (opts.updatedAfter && (c.updateTime < opts.updatedAfter || c.updateTime == null)) return false 22 | if (opts.updatedBefore && (c.updateTime > opts.updatedBefore || c.updateTime == null)) return false 23 | 24 | if (opts.turnsPlus && (c.childCount < opts.turnsPlus || c.childCount == null)) return false 25 | if (opts.turnsMinus && (c.childCount > opts.turnsMinus || c.childCount == null)) return false 26 | if (opts.gizmoIds && !opts.gizmoIds.has(c.gizmoId)) return false 27 | 28 | return true 29 | }) 30 | 31 | const messageParts: ChatPart[] = [] 32 | 33 | 34 | chats.forEach(c => { 35 | if (opts.gpt && c._gizmo) { 36 | messageParts.push({ 37 | type: 'gpt', 38 | content: c._gizmo.name, 39 | chat: c 40 | }) 41 | } else if (opts.title && !c._gizmo) { 42 | messageParts.push({ 43 | type: 'title', 44 | content: c.title, 45 | chat: c 46 | }) 47 | } 48 | 49 | c.userChilds.forEach(v => v.chat = c) 50 | c.astChilds.forEach(v => v.chat = c) 51 | 52 | if (opts.user) messageParts.push(...c.userChilds) 53 | if (opts.ast) messageParts.push(...c.astChilds) 54 | }) 55 | 56 | return messageParts 57 | } 58 | -------------------------------------------------------------------------------- /src/raccoon/style.css: -------------------------------------------------------------------------------- 1 | 2 | #App { 3 | 4 | --bg: var(--sidebar-surface-primary, #f9f9f9); 5 | --bg-secondary: var(--sidebar-surface-secondary, #ececec); 6 | 7 | &.new { 8 | --bg: var(--sidebar-surface-secondary, #ececec); 9 | --bg-secondary: var(--sidebar-surface-tertiary, #e3e3e3); 10 | } 11 | 12 | --border-light: rgba(0,0,0,.1); 13 | --border-medium: rgba(0,0,0,.15); 14 | --border-heavy: rgba(0,0,0,.2); 15 | --border-xheavy: rgba(0,0,0,.25); 16 | 17 | --text-color-primary: #161616; 18 | --text-color-primary-off: #303030; 19 | --text-color-secondary: #535353; 20 | --text-color-tertiary: #626262; 21 | --text-color-quaternary: #868686; 22 | --context-color: #008de5; 23 | 24 | --heart: #ff0000; 25 | --star: #0031ff; 26 | --loading: red; 27 | 28 | --needle-color: var(--context-color); 29 | 30 | &.dark { 31 | --bg: var(--sidebar-surface-primary, #0d0d0d); 32 | --bg-secondary: var(--sidebar-surface-secondary, #262626); 33 | 34 | &.new { 35 | --bg: var(--sidebar-surface-secondary, #262626); 36 | --bg-secondary: var(--sidebar-surface-tertiary, #333); 37 | } 38 | 39 | --border-light: rgba(255, 255, 255,.1); 40 | --border-medium: rgba(255, 255, 255,.15); 41 | --border-heavy: rgba(255, 255, 255,.2); 42 | --border-xheavy: rgba(255, 255, 255,.25); 43 | 44 | --text-color-primary: #fbfbfb; 45 | --text-color-primary-off: #f2f2f2; 46 | --text-color-secondary: #cbcbcb; 47 | --text-color-tertiary: #a8a8a8; 48 | --text-color-quaternary: #8f8f8f; 49 | --context-color: #6dffd8; 50 | 51 | --heart: #ff0000; 52 | --star: #ffd900; 53 | } 54 | 55 | -webkit-font-smoothing: antialiased; 56 | user-select: none; 57 | 58 | &.bold .needle { 59 | font-weight: bold; 60 | } 61 | 62 | &.peacock { 63 | border-radius: 10px; 64 | top: 60px; 65 | left: 12px; 66 | z-index: 999999; 67 | width: 350px; 68 | position: fixed; 69 | background-color: var(--bg); 70 | outline: 1px solid var(--border-medium); 71 | 72 | .search { 73 | margin-top: 0px; 74 | margin-bottom: 10px; 75 | } 76 | } 77 | 78 | & > .search { 79 | 80 | & > .closeIcon { 81 | position: absolute; 82 | right: 10px; 83 | cursor: pointer; 84 | } 85 | } 86 | 87 | & > .main { 88 | overflow-y: auto; 89 | max-height: 60vh; 90 | font-size: 0.95rem; 91 | font-weight: 400; 92 | color: var(--text-color); 93 | 94 | & > *:last-child { 95 | margin-bottom: 40px; 96 | } 97 | } 98 | 99 | & > .footer { 100 | padding: 10px 10px; 101 | font-size: 0.9em; 102 | font-weight: 300; 103 | 104 | display: grid; 105 | grid-template-columns: max-content 1fr max-content; 106 | align-items: center; 107 | justify-items: start; 108 | column-gap: 10px; 109 | color: var(--text-color-secondary); 110 | 111 | & > .svgButton { 112 | font-size: 1.2em; 113 | 114 | 115 | 116 | &.pin { 117 | transform: scale(1.15) translate(-1px, 1px) rotate(30deg); 118 | } 119 | 120 | &.heart, &.star { 121 | transition: color 0.08s ease-in; 122 | 123 | &.heart:hover { 124 | color: var(--heart); 125 | } 126 | &.star:hover { 127 | color: var(--star); 128 | } 129 | } 130 | 131 | } 132 | } 133 | 134 | & > .scale { 135 | width: 20px; 136 | height: 20px; 137 | background-color: transparent; 138 | position: absolute; 139 | right: -7px; 140 | bottom: -5px; 141 | cursor: grab; 142 | border-radius: 50%; 143 | } 144 | } 145 | 146 | .svgButton { 147 | background-color: transparent; 148 | padding: 0px; 149 | border: none; 150 | cursor: pointer; 151 | color: var(--text-color-secondary); 152 | 153 | &:hover { 154 | /* opacity: 0.75; */ 155 | color: var(--text-color-primary); 156 | } 157 | 158 | &.toggable { 159 | opacity: 0.75; 160 | } 161 | 162 | &.active { 163 | opacity: 1; 164 | color: var(--text-color-primary); 165 | } 166 | } 167 | 168 | .ResultItem { 169 | cursor: pointer; 170 | 171 | 172 | & > .header { 173 | padding: 10px; 174 | padding-top: 20px; 175 | padding-bottom: 5px; 176 | 177 | &:first-child { 178 | padding-top: 10px; 179 | } 180 | 181 | &:hover > .meta > img { 182 | /* opacity: 1; */ 183 | } 184 | 185 | & > .meta { 186 | font-weight: 300; 187 | font-size: 0.9em; 188 | 189 | overflow-x: hidden; 190 | text-overflow: ellipsis; 191 | text-wrap: nowrap; 192 | 193 | & > span { 194 | opacity: 0.75; 195 | } 196 | 197 | & > img { 198 | border-radius: 25%; 199 | margin-right: 3px; 200 | margin-left: 8px; 201 | } 202 | } 203 | 204 | 205 | & > .title > img { 206 | margin-right: 10px; 207 | border-radius: 50%; 208 | } 209 | 210 | & img { 211 | vertical-align: middle; 212 | border: 1px solid var(--border-xheavy); 213 | } 214 | } 215 | 216 | & > .header, & > .conti .context { 217 | 218 | &:hover { 219 | background-color: var(--bg-secondary); 220 | } 221 | } 222 | 223 | 224 | span.needle { 225 | color: var(--needle-color); 226 | } 227 | 228 | & > .conti { 229 | padding-left: 10px; 230 | 231 | & > .context { 232 | padding: 8px; 233 | white-space: pre; 234 | font-size: 0.9em; 235 | text-wrap: wrap; 236 | color: var(--text-color-primary-off); 237 | border-top: 1px solid var(--border-medium); 238 | 239 | &:first-child { 240 | border-top: none; 241 | } 242 | 243 | & > span { 244 | word-wrap: break-word; 245 | } 246 | } 247 | } 248 | 249 | 250 | } 251 | 252 | 253 | @keyframes oscillateSize { 254 | 0%, 100% { transform: scale(0.8); } 255 | 50% { transform: scale(1.1); } 256 | } 257 | 258 | .LoadMore { 259 | display: grid; 260 | align-items: center; 261 | justify-content: center; 262 | padding: 30px 0; 263 | 264 | &::after { 265 | content: ''; 266 | width: 5em; 267 | height: 0.2em; 268 | background-color: var(--text-color-primary); 269 | animation: oscillateSize 2s infinite ease-in-out; 270 | } 271 | } 272 | 273 | .NonFound { 274 | display: grid; 275 | justify-items: center; 276 | justify-content: center; 277 | padding: 30px 0; 278 | 279 | &::after { 280 | content: ''; 281 | width: 10em; 282 | height: 0.1em; 283 | background-color: var(--loading); 284 | animation: oscillateSize 50s infinite ease-in-out; 285 | } 286 | } 287 | 288 | 289 | 290 | 291 | ::-webkit-scrollbar { 292 | width: 6px; 293 | } 294 | 295 | ::-webkit-scrollbar-track { 296 | background: var(--bg); 297 | } 298 | 299 | ::-webkit-scrollbar-thumb { 300 | background: var(--bg-secondary); 301 | border-radius: 2px; 302 | } 303 | 304 | -------------------------------------------------------------------------------- /src/raccoon/types.ts: -------------------------------------------------------------------------------- 1 | import { Gizmo } from "../types" 2 | 3 | export type Status = { 4 | results: Result[], 5 | finished?: boolean 6 | 7 | } 8 | 9 | export type Chats = { 10 | page: number, 11 | chats: Chat[], 12 | indexed: number, 13 | hasMore: boolean, 14 | maxPages: number 15 | } 16 | 17 | export type Chat = { 18 | id: string, 19 | gizmoId?: string, 20 | gizmoIds?: string[], 21 | multiGizmo?: boolean, 22 | usedDalle?: boolean, 23 | usedBrowser?: boolean, 24 | usedPython?: boolean, 25 | usedGPT4?: boolean, 26 | childCount?: number, // ast + user 27 | 28 | createTime: number, 29 | updateTime: number, 30 | title: string, 31 | isArchived: boolean, 32 | userChilds: MessagePart[], 33 | astChilds: MessagePart[], 34 | parts?: ChatPart[], 35 | currentNodeId?: string, 36 | 37 | _gizmo?: Gizmo 38 | } 39 | 40 | export type MessagePart = { 41 | type: 'message', 42 | messageId: string, 43 | byAst?: boolean, 44 | content?: string, 45 | chat?: Chat, 46 | result?: PartResult 47 | } 48 | 49 | export type TitlePart = { 50 | type: 'title', 51 | content?: string, 52 | chat?: Chat, 53 | result?: PartResult 54 | } 55 | 56 | export type GptPart = { 57 | type: 'gpt', 58 | content?: string 59 | chat?: Chat, 60 | result?: PartResult, 61 | gptId?: string 62 | } 63 | 64 | export type ChatPart = MessagePart | TitlePart | GptPart 65 | 66 | 67 | export type LoadRequest = { 68 | page?: number, 69 | cached?: Chats 70 | } 71 | 72 | 73 | export type Result = { 74 | title: string, 75 | id: string, 76 | elapsed?: string, 77 | parts: PartResult[], 78 | headerResult?: PartResult, 79 | isGpt?: boolean, 80 | currentNodeId?: string, 81 | 82 | gizmoId?: string, 83 | gizmoImg?: string 84 | gizmoName?: string 85 | } 86 | 87 | export type PartResult = { 88 | messageId?: string, 89 | prefix?: string 90 | suffix?: string 91 | needle?: string 92 | } 93 | -------------------------------------------------------------------------------- /src/raccoon/utils/bump.tsx: -------------------------------------------------------------------------------- 1 | 2 | export async function bump(chatId: string, auth: string) { 3 | const title = await getTitle(chatId, auth); 4 | if (!title) throw 'No title'; 5 | 6 | const res = await fetch(`https://chatgpt.com/backend-api/conversation/${chatId}`, { 7 | method: 'PATCH', 8 | body: JSON.stringify({ title: title }), 9 | headers: { 10 | 'Authorization': auth, 11 | 'Content-Type': 'application/json' 12 | } 13 | }); 14 | if (!res.ok) throw 'Bump not OK'; 15 | } 16 | 17 | async function getTitle(chatId: string, auth: string) { 18 | const json = await (await fetch(`https://chatgpt.com/backend-api/conversation/${chatId}`, { 19 | method: 'GET', 20 | headers: { 21 | 'Authorization': auth 22 | } 23 | })).json() 24 | return json.title 25 | } 26 | -------------------------------------------------------------------------------- /src/raccoon/utils/extractChats.ts: -------------------------------------------------------------------------------- 1 | import { Chat } from "../types" 2 | import { ConversationInterface } from "./rawTypes" 3 | 4 | export function extractChat(json: ConversationInterface) { 5 | let chat: Partial = {} 6 | chat.id = json.id ?? json.conversation_id 7 | chat.gizmoId = json.gizmo_id 8 | chat.createTime = json.create_time ? new Date(json.create_time).getTime() : null 9 | chat.updateTime = json.update_time ? new Date(json.update_time).getTime() : null 10 | chat.title = json.title 11 | chat.isArchived = json.is_archived 12 | chat.currentNodeId = typeof json.current_node === "string" ? json.current_node : null 13 | 14 | chat.astChilds = [] 15 | chat.userChilds = [] 16 | 17 | const gizmoIds = new Set() 18 | 19 | for (let [_, mapping] of Object.entries(json.mapping ?? {})) { 20 | const m = mapping.message 21 | if (!m) continue 22 | 23 | if (m.metadata) { 24 | if (m.metadata.gizmo_id) gizmoIds.add(m.metadata.gizmo_id) 25 | if (m.metadata.model_slug?.startsWith('gpt-4')) chat.usedGPT4 = true 26 | } 27 | 28 | if (m.author?.role === 'tool') { 29 | if (m.author.name === 'dalle.text2im') chat.usedDalle = true 30 | if (m.author.name === 'python') chat.usedPython = true 31 | if (m.author.name === 'browser') chat.usedBrowser = true 32 | } 33 | 34 | // text extraction 35 | if (!(m.id && m.author && m.content?.parts) || m.metadata?.is_visually_hidden_from_conversation) continue 36 | if (!(m.content.content_type === "text" || m.content.content_type === "multimodal_text")) continue 37 | if (!(m.author.role === "user" || m.author.role === "assistant")) continue 38 | 39 | const isUser = m.author.role === "user" 40 | const texts: string[] = [] 41 | m.content.parts.forEach(part => { 42 | if (typeof part === "string") texts.push(part) 43 | }) 44 | const text = texts.join('\n\n').trim() 45 | 46 | text.length && ( 47 | chat[isUser ? 'userChilds' : 'astChilds'].push({ 48 | type: 'message', 49 | content: text, 50 | messageId: m.id, 51 | byAst: m.author.role === "assistant" 52 | }) 53 | ) 54 | } 55 | 56 | chat.gizmoIds = [...gizmoIds] 57 | 58 | if (chat.gizmoIds.length === 1) { 59 | chat.gizmoId = chat.gizmoIds[0] 60 | } else if (chat.gizmoIds.length > 1) { 61 | chat.multiGizmo = true 62 | } 63 | 64 | chat.childCount = chat.astChilds.length + chat.userChilds.length 65 | 66 | return chat as Chat 67 | } 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/raccoon/utils/fetchChats.ts: -------------------------------------------------------------------------------- 1 | import { Chat, Chats } from "../types" 2 | import { extractChat } from "./extractChats" 3 | 4 | 5 | export async function fetchChats(page: number, auth: string) { 6 | const res = await fetch(`https://chatgpt.com/backend-api/conversations?offset=${page * 100}&limit=100&order=updated&expand=true`, { 7 | headers: { 8 | 'Authorization': auth 9 | } 10 | }) 11 | if (!res.ok) throw Error('Failed') 12 | const json = await res.json() 13 | const chats: Chat[] = [] 14 | for (let item of json.items) { 15 | try { 16 | const chat = extractChat(item) 17 | if (chat) chats.push(chat) 18 | } catch (err) { } 19 | } 20 | 21 | return { 22 | chats, 23 | indexed: Date.now(), 24 | page, 25 | hasMore: (page + 1) * 100 < json.total, 26 | maxPages: Math.ceil(json.total / 100) 27 | } satisfies Chats 28 | } -------------------------------------------------------------------------------- /src/raccoon/utils/getElapsed.tsx: -------------------------------------------------------------------------------- 1 | 2 | export function getElapsed(rb: number, locale: string) { 3 | const d = new Date(rb); 4 | const sameYear = new Date().getFullYear() === d.getFullYear(); 5 | 6 | const r = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); 7 | const seconds = (Date.now() - rb) / 1000; 8 | const minutes = seconds / 60; 9 | const hours = minutes / 60; 10 | const days = hours / 24; 11 | const years = days / 365; 12 | const weeks = years * 52; 13 | 14 | if (days < 14) { 15 | return r.format(-Math.round(days), 'days'); 16 | } else if (weeks < 4) { 17 | return r.format(-Math.round(weeks), 'weeks'); 18 | } else if (days < 60) { 19 | return d.toLocaleDateString(locale, { month: 'long', day: 'numeric' }); 20 | } else if (sameYear) { 21 | return d.toLocaleDateString(locale, { month: 'long' }); 22 | } else { 23 | return d.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/raccoon/utils/getTtl.ts: -------------------------------------------------------------------------------- 1 | 2 | export function getTtl(page: number) { 3 | if (page < 1) return 60_000 * 10 4 | else if (page < 2) return 60_000 * 60 * 6 5 | else if (page < 4) return 60_000 * 60 * 24 * 2 6 | else if (page < 12) return 60_000 * 60 * 24 * 6 7 | else if (page < 24) return 60_000 * 60 * 24 * 15 8 | else if (page < 48) return 60_000 * 60 * 24 * 30 9 | } 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/raccoon/utils/gizmo.ts: -------------------------------------------------------------------------------- 1 | import { Gizmo } from "../../types" 2 | import { getLocalItem } from "../../utils/getKnown" 3 | import { Chat } from "../types" 4 | 5 | let gizmos: {[key: string]: Gizmo} 6 | 7 | export async function getGizmoById(id: string) { 8 | await ensureLoaded() 9 | if (!gizmos) return 10 | return gizmos[id] 11 | } 12 | 13 | export function getGizmoByIdSync(id: string) { 14 | return gizmos[id] 15 | } 16 | 17 | export function getGizmosSync() { 18 | return Object.entries(gizmos ?? {}).map(v => v[1]) 19 | } 20 | 21 | async function ensureLoaded() { 22 | if (!gizmos) { 23 | gizmos = await getLocalItem('o:gizmos') || {} 24 | } 25 | } 26 | 27 | export async function getGizmosAsChats(): Promise { 28 | await ensureLoaded() 29 | if (!gizmos) return [] 30 | 31 | return Object.entries(gizmos).map(([k, g]) => ({ 32 | astChilds: [], 33 | userChilds: [], 34 | title: g.name, 35 | id: g.id, 36 | gizmoId: `g-${g.id}`, 37 | _gizmo: g 38 | })) as Chat[] 39 | } -------------------------------------------------------------------------------- /src/raccoon/utils/misc.tsx: -------------------------------------------------------------------------------- 1 | 2 | export function softLink(path: string, blockScroll?: number) { 3 | window.dispatchEvent(new CustomEvent('busbusab', {detail: JSON.stringify({type: 'NAV', path, blockScroll}), bubbles: false})) 4 | } 5 | 6 | export function blockScroll(blockScroll: number) { 7 | window.dispatchEvent(new CustomEvent('busbusab', {detail: JSON.stringify({type: 'BLOCK_SCROLL', blockScroll}), bubbles: false})) 8 | } 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/raccoon/utils/rawTypes.ts: -------------------------------------------------------------------------------- 1 | 2 | export type ConversationInterface = { 3 | title: string, 4 | create_time: TimeSeconds, 5 | update_time: TimeSeconds, 6 | mapping: {[key: Id]: Mapping}, 7 | current_node: Id, 8 | id?: Id, 9 | conversation_id?: Id, 10 | conversation_template_id?: string, 11 | gizmo_id?: string, 12 | is_archived?: boolean 13 | } 14 | 15 | type Id = string 16 | type TimeSeconds = number 17 | 18 | type Mapping = { 19 | id: Id, 20 | message: Message, 21 | parent: Id, 22 | children: Id[] 23 | } 24 | 25 | type Message = { 26 | id: Id, 27 | author: Author, 28 | create_time: null, 29 | update_time: null, 30 | content: Content, 31 | status: "finished_successfully" | string, 32 | end_turn: boolean, 33 | weight: number, 34 | metadata: MessageMetadata, 35 | recipient: "all" | string 36 | } 37 | 38 | type Author = { 39 | role: "system" | "user" | "assistant" | "tool" 40 | name: string, 41 | metadata: {} 42 | } 43 | 44 | type Content = TextContent | MultiModalContent 45 | 46 | type TextContent = { 47 | content_type: "text", 48 | parts: string[] 49 | } 50 | 51 | type MultiModalContent = { 52 | content_type: "multimodal_text", 53 | parts: (MultiModalContentPart | string)[] 54 | } 55 | 56 | type MultiModalContentPart = { 57 | content_type: "image_asset_pointer" | string, 58 | asset_pointer: string, 59 | size_bytes: number, 60 | width?: number, 61 | height?: number 62 | } 63 | 64 | type MessageMetadata = { 65 | is_visually_hidden_from_conversation: boolean, 66 | gizmo_id?: string, 67 | model_slug?: string 68 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export type Config = { 4 | 'g:version': number, 5 | 'g:lang': string, 6 | 'g:context': number, 7 | 'g:autoClear': boolean, 8 | 'g:highlightColorDark': string, 9 | 'g:highlightColorLight': string, 10 | 'g:highlightUnderline': boolean, 11 | 'g:highlightBold': boolean, 12 | 'g:showImage': boolean, 13 | 'g:sessionOnly': boolean, 14 | 'g:orderByDate': boolean, 15 | 'g:scrollTop': boolean, 16 | 'g:strictSearch': boolean, 17 | } 18 | 19 | export type TempState = { 20 | 'o:lastTheme': string, 21 | 'o:auth': string, 22 | 'o:ph': string, 23 | 'o:changeId': string, 24 | 'o:gizmos': { 25 | [key: string]: Gizmo 26 | } 27 | } 28 | 29 | export type SessionState = { 30 | 's:auth': string 31 | } 32 | 33 | export type Gizmo = { 34 | id: string, 35 | name: string, 36 | added: number, 37 | imageUrl: string 38 | } 39 | 40 | export type LocalState = Config & TempState 41 | 42 | export const CONFIG_KEYS = ['g:version', 'g:lang', 'g:context', 'g:autoClear', 'g:highlightColorDark', 'g:highlightColorLight', 'g:highlightUnderline', 'g:highlightBold', 'g:showImage', 'g:sessionOnly', 'g:orderByDate', 'g:scrollTop', 'g:strictSearch'] as const 43 | 44 | export const TEMP_KEYS = ['o:lastTheme', 'o:auth', 'o:ph', 'o:changeId', 'o:gizmos'] as const 45 | 46 | export const KNOWN_KEYS = [...CONFIG_KEYS, ...TEMP_KEYS] as const 47 | 48 | 49 | export type AnyDict = {[key: string]: any} 50 | 51 | export type StringDict = {[key: string]: string} 52 | 53 | -------------------------------------------------------------------------------- /src/utils/GsmType.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Gsm = { 3 | _lang?: string, 4 | _morpho?: boolean, 5 | general: string, 6 | help: string, 7 | strictSearch: string, 8 | strictSearchTooltip: string, 9 | sortDate: string, 10 | sortDateTooltip: string, 11 | scrollTop: string, 12 | scrollTopTooltip: string, 13 | issuePrompt: string, 14 | issueDirective: string, 15 | areYouSure: string, 16 | showMore: string, 17 | notFound: string, 18 | data: string, 19 | sessionOnly: string, 20 | sessionOnlyTooltip: string, 21 | showImage: string, 22 | showImageTooltip: string, 23 | searchChats: string, 24 | search: string, 25 | language: string, 26 | context: string, 27 | contextTooltip: string, 28 | reset: string, 29 | clearCache: string, 30 | autoClear: string, 31 | autoClearTooltip: string, 32 | enableShortcut: string, 33 | enableShortcutTooltip: string, 34 | highlightColor: string, 35 | highlightColorTooltip: string, 36 | highlightBold: string, 37 | highlightBoldTooltip: string, 38 | advancedSearch: string, 39 | otherProjects: { 40 | header: string, 41 | askScreenshot: string, 42 | globalSpeed: string 43 | } 44 | } -------------------------------------------------------------------------------- /src/utils/browser.ts: -------------------------------------------------------------------------------- 1 | import { AnyDict } from "../types" 2 | 3 | export function openLink(url: string, active = true) { 4 | chrome.runtime.sendMessage({type: "REQUEST_CREATE_TAB", url, active}) 5 | } 6 | 7 | export function localGet(keys: chrome.storage.StorageGet) { 8 | return chrome.storage.local.get(keys) 9 | } 10 | 11 | export function localSet(items: AnyDict) { 12 | return chrome.storage.local.set(items) 13 | } 14 | 15 | export function sessionGet(keys: chrome.storage.StorageGet) { 16 | return chrome.storage.session.get(keys) 17 | } 18 | 19 | export function sessionSet(items: AnyDict) { 20 | return chrome.storage.session.set(items) 21 | } 22 | 23 | async function requestSessionGet(keys: chrome.storage.StorageGet) { 24 | const res = await chrome.runtime.sendMessage({type: 'GET_SESSION_ITEM', keys}) 25 | if (res?.error) throw res.error 26 | return res?.ok as AnyDict 27 | } 28 | 29 | async function requestSessionSet(items: AnyDict) { 30 | const res = await chrome.runtime.sendMessage({type: 'SET_SESSION_ITEM', items}) 31 | if (res?.error) throw res.error 32 | return res?.ok as void 33 | } 34 | 35 | export const sessionGetFallback = chrome.storage.session?.setAccessLevel ? sessionGet : requestSessionGet 36 | export const sessionSetFallback = chrome.storage.session?.setAccessLevel ? sessionSet : requestSessionSet -------------------------------------------------------------------------------- /src/utils/getKnown.ts: -------------------------------------------------------------------------------- 1 | import { LocalState, SessionState } from "../types"; 2 | 3 | export async function getLocal(keys: (keyof LocalState)[]) { 4 | return (await chrome.storage.local.get(keys)) as LocalState 5 | } 6 | 7 | export async function getLocalItem(key: T) { 8 | return (await chrome.storage.local.get(key))[key] as LocalState[T] 9 | } 10 | 11 | export async function setLocal(override: Partial) { 12 | await chrome.storage.local.set(override) 13 | } 14 | 15 | export async function setSessionKnown(override: Partial) { 16 | await chrome.storage.session.set(override) 17 | } 18 | 19 | export async function getSession(keys: (keyof SessionState)[]) { 20 | return (await chrome.storage.session.get(keys)) as SessionState 21 | } 22 | 23 | export async function getSessionItem(key: T) { 24 | return (await chrome.storage.session.get(key))[key] as SessionState[T] 25 | } 26 | 27 | -------------------------------------------------------------------------------- /src/utils/gsm.ts: -------------------------------------------------------------------------------- 1 | import { Gsm } from "./GsmType"; 2 | 3 | declare global { 4 | interface GlobalVar { 5 | gsm?: Gsm 6 | } 7 | } 8 | 9 | export async function loadGsm(): Promise { 10 | const language = (await chrome.storage.local.get("g:lang"))["g:lang"] 11 | return readLocaleFile(getValidLocale(language)) 12 | } 13 | 14 | export async function requestGsm(): Promise { 15 | return chrome.runtime.sendMessage({type: "REQUEST_GSM"}) 16 | } 17 | 18 | export async function readLocaleFile(locale: string): Promise { 19 | const fetched = await fetch(chrome.runtime.getURL(`locales/${locale}.json`)) 20 | const json = await fetched.json() as Gsm 21 | json._lang = locale.replace("_", "-") 22 | return json 23 | } 24 | 25 | function getValidLocale(overrideLang?: string) { 26 | if (overrideLang && AVAILABLE_LOCALES.has(overrideLang)) return overrideLang 27 | const languages = new Set(navigator.languages.map(l => l.replace("-", "_"))) 28 | languages.forEach(l => { 29 | if (l.includes("_")) { 30 | const langPart = l.split("_")[0] 31 | languages.add(langPart) 32 | } 33 | }) 34 | languages.add("en") 35 | return [...languages].find(l => AVAILABLE_LOCALES.has(l)) 36 | } 37 | 38 | export function replaceArgs(raw: string, args: string[]) { 39 | let idx = 0 40 | for (let arg of args) { 41 | raw = raw.replaceAll(`$${++idx}`, arg) 42 | } 43 | return raw 44 | } 45 | 46 | export const LOCALE_MAP: { 47 | [key: string]: { 48 | display: string, 49 | title: string 50 | } 51 | } = { 52 | "detect": {display: "Auto", title: "Try to find a match using browser language settings, system language settings, or fallback to English."}, 53 | "en": { display: "English", title: "English" }, 54 | "es": { display: "Español", title: "Spanish" }, 55 | "it": { display: "Italiano", title: "Italian" }, 56 | "ja": { display: "日本語", title: "Japanese" }, 57 | "ko": { display: "한국어", title: "Korean" }, 58 | "pt_BR": { display: "Português", title: "Portuguese" }, 59 | "ru": { display: "Русский", title: "Russian" }, 60 | "tr": { display: "Türkçe", title: "Turkish" }, 61 | "vi": { display: "Tiếng Việt", title: "Vietnamese" }, 62 | "zh_CN": { display: "中文 (简体)", title: "Chinese (Simplified)" }, 63 | "zh_TW": { display: "中文 (繁體)", title: "Chinese (Traditional)" } 64 | } 65 | 66 | 67 | const AVAILABLE_LOCALES = new Set(["en", "es", "it", "ja", "ko", "pt_BR", "ru", "tr", "vi", "zh_CN", "zh_TW"]) 68 | 69 | -------------------------------------------------------------------------------- /src/utils/state.ts: -------------------------------------------------------------------------------- 1 | import debounce from "lodash.debounce" 2 | import { AnyDict } from "../types" 3 | import type { DebouncedFunc } from "lodash" 4 | import { randomId } from "../helper" 5 | 6 | export type SubStorageCallback = (view: AnyDict, forOnLaunch?: boolean) => void 7 | 8 | export class SubscribeStorageKeys { 9 | keys: Set 10 | cbs: Set = new Set() 11 | private rawMap?: AnyDict 12 | latestRaw?: AnyDict 13 | released = false 14 | processedChangeIds: Set = new Set() 15 | 16 | constructor(_keys: string[], private onLaunch?: boolean, cb?: SubStorageCallback, public wait?: number, public maxWait?: number) { 17 | this.triggerCbs = this.wait ? ( 18 | debounce(this._triggerCbs, this.wait ?? 0, {trailing: true, leading: true, ...(this.maxWait == null ? {} : {maxWait: this.maxWait})}) 19 | ) : this._triggerCbs 20 | 21 | this.keys = new Set(_keys) 22 | cb && this.cbs.add(cb) 23 | this.start() 24 | } 25 | start = async () => { 26 | chrome.storage.local.onChanged.addListener(this.handleChange) 27 | if (this.onLaunch) { 28 | await this.handleChange(null) 29 | } 30 | } 31 | release = () => { 32 | if (this.released) return 33 | this.released = true 34 | ;(this.triggerCbs as any).cancel?.() 35 | delete this.triggerCbs 36 | chrome.storage.local.onChanged.removeListener(this.handleChange) 37 | this.cbs.clear() 38 | delete this.cbs, delete this.rawMap, 39 | delete this.latestRaw, delete this.keys, delete this.rawMap 40 | } 41 | handleChange = async (changes: chrome.storage.StorageChanges) => { 42 | changes = changes ?? {} 43 | const changeId = changes["o:changeId"]?.newValue as string 44 | if (changeId) { 45 | if (this.processedChangeIds.has(changeId)) { 46 | this.processedChangeIds.delete(changeId) 47 | return 48 | } else { 49 | this.processedChangeIds.add(changeId) 50 | } 51 | } 52 | 53 | let hadChanges = false 54 | if (!this.rawMap) { 55 | this.rawMap = await chrome.storage.local.get([...this.keys]) 56 | hadChanges = true 57 | } 58 | for (let key in changes) { 59 | if (!this.keys.has(key)) continue 60 | this.rawMap[key] = changes[key].newValue 61 | if (this.rawMap[key] === undefined) delete this.rawMap[key] 62 | hadChanges = true 63 | } 64 | if (!hadChanges) return 65 | 66 | this.latestRaw = structuredClone(this.rawMap) 67 | this.triggerCbs() 68 | } 69 | _triggerCbs = () => { 70 | this.cbs.forEach(cb => cb(this.latestRaw)) 71 | } 72 | triggerCbs: typeof this._triggerCbs | DebouncedFunc 73 | 74 | push = (_override: AnyDict) => { 75 | const override = structuredClone(_override) 76 | override["o:changeId"] = randomId() 77 | 78 | const changes = {} as chrome.storage.StorageChanges 79 | for (let key in override) { 80 | if (override[key] === undefined) continue 81 | changes[key] = {newValue: override[key]} 82 | } 83 | 84 | return Promise.all([ 85 | this.handleChange(changes), 86 | chrome.storage.local.set(override) 87 | ]) 88 | } 89 | } -------------------------------------------------------------------------------- /static/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polywock/gpt-search/026b993bfa94eace403d8e8a296a34f0c518eafb/static/128.png -------------------------------------------------------------------------------- /static/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Chat History" 4 | }, 5 | "appDesc": { 6 | "message": "Search your ChatGPT conversation history." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Buscar chats" 4 | }, 5 | "appDesc": { 6 | "message": "Busca en tu historial de conversaciones de GPT." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/it/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Cerca chat" 4 | }, 5 | "appDesc": { 6 | "message": "Cerca nel tuo storico conversazioni GPT." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /static/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: チャットを検索" 4 | }, 5 | "appDesc": { 6 | "message": "あなたのGPT会話履歴を検索します。" 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/ko/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: 채팅 검색" 4 | }, 5 | "appDesc": { 6 | "message": "당신의 GPT 대화 기록을 검색하세요." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Pesquisar chats" 4 | }, 5 | "appDesc": { 6 | "message": "Pesquise no seu histórico de conversas GPT." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Поиск чатов" 4 | }, 5 | "appDesc": { 6 | "message": "Искать в истории ваших бесед GPT." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/tr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Sohbetleri Ara" 4 | }, 5 | "appDesc": { 6 | "message": "GPT sohbet geçmişinizi arayın." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/vi/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: Tìm kiếm cuộc trò chuyện" 4 | }, 5 | "appDesc": { 6 | "message": "Tìm kiếm lịch sử trò chuyện GPT của bạn." 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: 搜索聊天" 4 | }, 5 | "appDesc": { 6 | "message": "搜索您的GPT对话历史。" 7 | } 8 | } -------------------------------------------------------------------------------- /static/_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "GPT Search: 搜尋聊天" 4 | }, 5 | "appDesc": { 6 | "message": "搜尋您的GPT對話歷史。" 7 | } 8 | } -------------------------------------------------------------------------------- /static/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_lang": "en", 3 | "_morpho": false, 4 | "general": "General", 5 | "help": "Help", 6 | "strictSearch": "Strict search", 7 | "strictSearchTooltip": "Increase threshold for matching results.", 8 | "sortDate": "Sort by date", 9 | "sortDateTooltip": "Sort results by date.", 10 | "scrollTop": "Scroll to top", 11 | "scrollTopTooltip": "Scroll to the top when selecting a conversation.", 12 | 13 | "issuePrompt": "Have issues or a suggestion?", 14 | "issueDirective": "Provide feedback on Github.", 15 | "areYouSure": "Are you sure?", 16 | "showMore": "Show more", 17 | "notFound": "Not found", 18 | 19 | "data": "Data", 20 | "sessionOnly": "Session-only caching", 21 | "sessionOnlyTooltip": "Clear cached chat history on browser close. This increases search times and data usage, so use only if your device is shared.", 22 | "showImage": "Show icon", 23 | "showImageTooltip": "If a conversation used a custom GPT, show the icon if available.", 24 | "searchChats": "Search chats...", 25 | "search": "Search", 26 | "language": "Language", 27 | "context": "Context", 28 | "contextTooltip": "Adjust how much surrounding information is displayed with each search result.", 29 | "reset": "Reset", 30 | "clearCache": "Clear cache", 31 | "autoClear": "Auto-clear search", 32 | "autoClearTooltip": "Clear search field when you click away.", 33 | "enableShortcut": "Enable shortcut", 34 | "enableShortcutTooltip": "Press '/' to quickly focus on the search box.", 35 | 36 | 37 | "highlightColor": "Highlight color", 38 | "highlightColorTooltip": "Adjust the appearance of highlighted text.", 39 | 40 | "highlightBold": "Bold highlight", 41 | "highlightBoldTooltip": "Makes highlighted text bold for better visibility.", 42 | "advancedSearch": "Advanced search", 43 | 44 | "otherProjects": { 45 | "header": "Other Projects", 46 | "askScreenshot": "Take a screenshot on any page and automatically open it with ChatGPT. Also available for Claude and Gemini.", 47 | "globalSpeed": "Set a default speed for video and audio!" 48 | } 49 | } -------------------------------------------------------------------------------- /static/locales/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "General", 3 | "help": "Ayuda", 4 | "strictSearch": "Búsqueda estricta", 5 | "strictSearchTooltip": "Aumentar el umbral para los resultados coincidentes.", 6 | "sortDate": "Ordenar por fecha", 7 | "sortDateTooltip": "Ordenar los resultados por fecha.", 8 | "scrollTop": "Desplazarse hacia arriba", 9 | "scrollTopTooltip": "Desplácese hacia arriba al seleccionar una conversación.", 10 | "issuePrompt": "¿Tienes problemas o una sugerencia?", 11 | "issueDirective": "Proporciona comentarios en Github.", 12 | "areYouSure": "¿Estás seguro?", 13 | "showMore": "Mostrar más", 14 | "notFound": "No encontrado", 15 | "data": "Datos", 16 | "sessionOnly": "Caché solo durante la sesión", 17 | "sessionOnlyTooltip": "Borrar el historial de chat almacenado en caché al cerrar el navegador. Esto aumenta los tiempos de búsqueda y el uso de datos, por lo que se debe usar solo si el dispositivo es compartido.", 18 | "showImage": "Mostrar icono", 19 | "showImageTooltip": "Si una conversación utilizó un GPT personalizado, mostrar el icono si está disponible.", 20 | "searchChats": "Buscar chats...", 21 | "search": "Buscar", 22 | "language": "Idioma", 23 | "context": "Contexto", 24 | "contextTooltip": "Ajusta cuánta información circundante se muestra con cada resultado de búsqueda.", 25 | "reset": "Restablecer", 26 | "clearCache": "Borrar caché", 27 | "autoClear": "Borrado automático de búsqueda", 28 | "autoClearTooltip": "Borra el campo de búsqueda cuando haces clic fuera.", 29 | "enableShortcut": "Habilitar atajo", 30 | "enableShortcutTooltip": "Presiona '/' para enfocarte en el cuadro de búsqueda.", 31 | "highlightColor": "Color de resaltado", 32 | "highlightColorTooltip": "Ajusta la apariencia del texto resaltado.", 33 | "highlightBold": "Resaltado en negrita", 34 | "highlightBoldTooltip": "Hace que el texto resaltado sea en negrita para una mejor visibilidad.", 35 | "advancedSearch": "Búsqueda avanzada", 36 | "otherProjects": { 37 | "header": "Otros Proyectos", 38 | "askScreenshot": "Toma una captura de pantalla en cualquier página y ábrela automáticamente con ChatGPT. También disponible para Claude y Gemini.", 39 | "globalSpeed": "¡Establece una velocidad predeterminada para vídeo y audio!" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/locales/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "Generale", 3 | "help": "Aiuto", 4 | "strictSearch": "Ricerca rigorosa", 5 | "strictSearchTooltip": "Aumenta la soglia per i risultati corrispondenti.", 6 | "sortDate": "Ordina per data", 7 | "sortDateTooltip": "Ordina i risultati per data.", 8 | "scrollTop": "Scorri verso l'alto", 9 | "scrollTopTooltip": "Scorri verso l'alto quando selezioni una conversazione.", 10 | "issuePrompt": "Hai problemi o un suggerimento?", 11 | "issueDirective": "Fornisci feedback su Github.", 12 | "areYouSure": "Sei sicuro?", 13 | "showMore": "Mostra altro", 14 | "notFound": "Non trovato", 15 | "data": "Dati", 16 | "sessionOnly": "Caching solo per la sessione", 17 | "sessionOnlyTooltip": "Cancella la cronologia delle chat memorizzate nella cache alla chiusura del browser. Questo aumenta i tempi di ricerca e l'utilizzo dei dati, quindi usare solo se il dispositivo è condiviso.", 18 | "showImage": "Mostra icona", 19 | "showImageTooltip": "Se una conversazione ha utilizzato un GPT personalizzato, mostra l'icona se disponibile.", 20 | "searchChats": "Cerca chat...", 21 | "search": "Cerca", 22 | "language": "Lingua", 23 | "context": "Contesto", 24 | "contextTooltip": "Regola quanto contesto circostante viene mostrato con ogni risultato di ricerca.", 25 | "reset": "Reimposta", 26 | "clearCache": "Cancella cache", 27 | "autoClear": "Cancellazione automatica ricerca", 28 | "autoClearTooltip": "Cancella il campo di ricerca quando clicchi fuori.", 29 | "enableShortcut": "Abilita scorciatoia", 30 | "enableShortcutTooltip": "Premi '/' per focalizzarti sulla casella di ricerca.", 31 | "highlightColor": "Colore evidenziatore", 32 | "highlightColorTooltip": "Regola l'aspetto del testo evidenziato.", 33 | "highlightBold": "Evidenziatura in grassetto", 34 | "highlightBoldTooltip": "Rende il testo evidenziato in grassetto per una migliore visibilità.", 35 | "advancedSearch": "Ricerca avanzata", 36 | "otherProjects": { 37 | "header": "Altri Progetti", 38 | "askScreenshot": "Fai uno screenshot su qualsiasi pagina e aprilo automaticamente con ChatGPT. Disponibile anche per Claude e Gemini.", 39 | "globalSpeed": "Imposta una velocità predefinita per video e audio!" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "_morpho": true, 3 | "general": "一般", 4 | "help": "ヘルプ", 5 | "strictSearch": "厳格な検索", 6 | "strictSearchTooltip": "一致する結果の閾値を上げる。", 7 | "sortDate": "日付で並べ替え", 8 | "sortDateTooltip": "結果を日付順に並べ替える。", 9 | "scrollTop": "トップへスクロール", 10 | "scrollTopTooltip": "会話を選択するとトップにスクロールします。", 11 | "issuePrompt": "問題または提案がありますか?", 12 | "issueDirective": "Githubでフィードバックを提供してください。", 13 | "areYouSure": "よろしいですか?", 14 | "showMore": "もっと見る", 15 | "notFound": "見つかりません", 16 | "data": "データ", 17 | "sessionOnly": "セッションのみのキャッシング", 18 | "sessionOnlyTooltip": "ブラウザのクローズ時にキャッシュされたチャット履歴をクリアします。これにより検索時間とデータ使用量が増加するため、デバイスが共有されている場合のみ使用してください。", 19 | "showImage": "アイコンを表示", 20 | "showImageTooltip": "カスタムGPTを使用した会話の場合、利用可能であればアイコンを表示します。", 21 | "searchChats": "チャットを検索...", 22 | "search": "検索", 23 | "language": "言語", 24 | "context": "コンテキスト", 25 | "contextTooltip": "各検索結果に表示される周囲の情報の量を調整します。", 26 | "reset": "リセット", 27 | "clearCache": "キャッシュをクリア", 28 | "autoClear": "検索自動クリア", 29 | "autoClearTooltip": "離れるときに検索フィールドをクリアします。", 30 | "enableShortcut": "ショートカットを有効にする", 31 | "enableShortcutTooltip": "'/'を押して検索ボックスにフォーカスします。", 32 | "highlightColor": "ハイライト色", 33 | "highlightColorTooltip": "ハイライトされたテキストの外観を調整します。", 34 | "highlightBold": "太字ハイライト", 35 | "highlightBoldTooltip": "より良い可視性のために、ハイライトされたテキストを太字にします。", 36 | "advancedSearch": "高度な検索", 37 | "otherProjects": { 38 | "header": "その他のプロジェクト", 39 | "askScreenshot": "任意のページでスクリーンショットを取り、自動的にChatGPTで開きます。ClaudeGeminiにも利用可能です。", 40 | "globalSpeed": "ビデオとオーディオのデフォルト速度を設定!" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /static/locales/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "일반", 3 | "help": "도움말", 4 | "strictSearch": "엄격한 검색", 5 | "strictSearchTooltip": "일치하는 결과의 임계값을 높입니다.", 6 | "sortDate": "날짜별 정렬", 7 | "sortDateTooltip": "결과를 날짜순으로 정렬합니다.", 8 | "scrollTop": "맨 위로 스크롤", 9 | "scrollTopTooltip": "대화를 선택할 때 맨 위로 스크롤합니다.", 10 | "issuePrompt": "문제나 제안이 있나요?", 11 | "issueDirective": "Github에서 피드백 제공하기.", 12 | "areYouSure": "확실합니까?", 13 | "showMore": "더 보기", 14 | "notFound": "찾을 수 없음", 15 | "data": "데이터", 16 | "sessionOnly": "세션-만 캐싱", 17 | "sessionOnlyTooltip": "브라우저 닫을 때 캐시된 채팅 기록을 지웁니다. 이는 검색 시간과 데이터 사용량을 증가시키므로, 기기가 공유되는 경우에만 사용하세요.", 18 | "showImage": "아이콘 표시", 19 | "showImageTooltip": "대화에서 사용자 지정 GPT를 사용한 경우, 가능하다면 아이콘을 표시합니다.", 20 | "searchChats": "채팅 검색...", 21 | "search": "검색", 22 | "language": "언어", 23 | "context": "컨텍스트", 24 | "contextTooltip": "각 검색 결과와 함께 표시되는 주변 정보의 양을 조정합니다.", 25 | "reset": "초기화", 26 | "clearCache": "캐시 지우기", 27 | "autoClear": "자동 지우기 검색", 28 | "autoClearTooltip": "다른 곳을 클릭할 때 검색 필드를 자동으로 지웁니다.", 29 | "enableShortcut": "단축키 활성화", 30 | "enableShortcutTooltip": "'/'를 눌러 검색 상자에 초점을 맞춥니다.", 31 | "highlightColor": "하이라이트 색상", 32 | "highlightColorTooltip": "하이라이트된 텍스트의 외관을 조정합니다.", 33 | "highlightBold": "볼드 하이라이트", 34 | "highlightBoldTooltip": "더 나은 가시성을 위해 하이라이트된 텍스트를 굵게 만듭니다.", 35 | "advancedSearch": "고급 검색", 36 | "otherProjects": { 37 | "header": "기타 프로젝트", 38 | "askScreenshot": "아무 페이지에서 스크린샷을 찍고 자동으로 ChatGPT로 엽니다. ClaudeGemini에서도 사용 가능합니다.", 39 | "globalSpeed": "비디오 및 오디오에 대한 기본 속도 설정!" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/locales/pt_BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "Geral", 3 | "help": "Ajuda", 4 | "strictSearch": "Pesquisa rigorosa", 5 | "strictSearchTooltip": "Aumentar o limiar para resultados correspondentes.", 6 | "sortDate": "Ordenar por data", 7 | "sortDateTooltip": "Ordenar os resultados por data.", 8 | "scrollTop": "Rolar para o topo", 9 | "scrollTopTooltip": "Role para o topo ao selecionar uma conversa.", 10 | "issuePrompt": "Tem problemas ou uma sugestão?", 11 | "issueDirective": "Forneça feedback no Github.", 12 | "areYouSure": "Você tem certeza?", 13 | "showMore": "Mostrar mais", 14 | "notFound": "Não encontrado", 15 | "data": "Dados", 16 | "sessionOnly": "Caching apenas para a sessão", 17 | "sessionOnlyTooltip": "Limpar o histórico de chat armazenado em cache ao fechar o navegador. Isso aumenta os tempos de busca e o uso de dados, então use apenas se o dispositivo for compartilhado.", 18 | "showImage": "Mostrar ícone", 19 | "showImageTooltip": "Se uma conversa usou um GPT personalizado, mostrar o ícone se disponível.", 20 | "searchChats": "Pesquisar chats...", 21 | "search": "Pesquisar", 22 | "language": "Idioma", 23 | "context": "Contexto", 24 | "contextTooltip": "Ajuste a quantidade de informações circundantes exibidas com cada resultado de pesquisa.", 25 | "reset": "Redefinir", 26 | "clearCache": "Limpar cache", 27 | "autoClear": "Limpeza automática de pesquisa", 28 | "autoClearTooltip": "Limpa o campo de pesquisa quando você clica fora.", 29 | "enableShortcut": "Habilitar atalho", 30 | "enableShortcutTooltip": "Pressione '/' para focar na caixa de pesquisa.", 31 | "highlightColor": "Cor de destaque", 32 | "highlightColorTooltip": "Ajuste a aparência do texto destacado.", 33 | "highlightBold": "Destaque em negrito", 34 | "highlightBoldTooltip": "Torna o texto destacado em negrito para melhor visibilidade.", 35 | "advancedSearch": "Pesquisa avançada", 36 | "otherProjects": { 37 | "header": "Outros Projetos", 38 | "askScreenshot": "Tire uma captura de tela em qualquer página e abra automaticamente com o ChatGPT. Também disponível para Claude e Gemini.", 39 | "globalSpeed": "Defina uma velocidade padrão para vídeo e áudio!" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/locales/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "Общее", 3 | "help": "Помощь", 4 | "strictSearch": "Строгий поиск", 5 | "strictSearchTooltip": "Увеличить порог для соответствующих результатов.", 6 | "sortDate": "Сортировать по дате", 7 | "sortDateTooltip": "Сортировать результаты по дате.", 8 | "scrollTop": "Прокрутить вверх", 9 | "scrollTopTooltip": "Прокрутите вверх при выборе разговора.", 10 | "issuePrompt": "Есть проблемы или предложение?", 11 | "issueDirective": "Оставьте отзыв на Github.", 12 | "areYouSure": "Вы уверены?", 13 | "showMore": "Показать больше", 14 | "notFound": "Не найдено", 15 | "data": "Данные", 16 | "sessionOnly": "Кэширование только для сессии", 17 | "sessionOnlyTooltip": "Очистка кэшированной истории чата при закрытии браузера. Это увеличивает время поиска и использование данных, поэтому используйте только если ваше устройство используется совместно.", 18 | "showImage": "Показать иконку", 19 | "showImageTooltip": "Если в разговоре использовался настраиваемый GPT, показать иконку, если она доступна.", 20 | "searchChats": "Поиск чатов...", 21 | "search": "Поиск", 22 | "language": "Язык", 23 | "context": "Контекст", 24 | "contextTooltip": "Настройте, сколько окружающей информации отображается с каждым результатом поиска.", 25 | "reset": "Сброс", 26 | "clearCache": "Очистить кэш", 27 | "autoClear": "Автоочистка поиска", 28 | "autoClearTooltip": "Очищает поле поиска, когда вы кликаете вне его.", 29 | "enableShortcut": "Включить быструю клавишу", 30 | "enableShortcutTooltip": "Нажмите '/', чтобы сфокусироваться на поле поиска.", 31 | "highlightColor": "Цвет выделения", 32 | "highlightColorTooltip": "Настройте внешний вид выделенного текста.", 33 | "highlightBold": "Жирное выделение", 34 | "highlightBoldTooltip": "Делает выделенный текст жирным для лучшей видимости.", 35 | "advancedSearch": "Расширенный поиск", 36 | "otherProjects": { 37 | "header": "Другие Проекты", 38 | "askScreenshot": "Сделайте скриншот на любой странице и автоматически откройте его через ChatGPT. Также доступно для Claude и Gemini.", 39 | "globalSpeed": "Установите стандартную скорость для видео и аудио!" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/locales/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "Genel", 3 | "help": "Yardım", 4 | "strictSearch": "Katı Arama", 5 | "strictSearchTooltip": "Eşleşen sonuçlar için eşiği artırın.", 6 | "sortDate": "Tarihe Göre Sırala", 7 | "sortDateTooltip": "Sonuçları tarihe göre sıralayın.", 8 | "scrollTop": "Yukarı kaydır", 9 | "scrollTopTooltip": "Bir konuşma seçerken yukarı kaydırın.", 10 | "issuePrompt": "Bir sorununuz veya öneriniz mi var?", 11 | "issueDirective": "Github üzerinden geri bildirimde bulunun.", 12 | "areYouSure": "Emin misiniz?", 13 | "showMore": "Daha fazla göster", 14 | "notFound": "Bulunamadı", 15 | "data": "Veri", 16 | "sessionOnly": "Yalnızca oturum için önbelleğe alma", 17 | "sessionOnlyTooltip": "Tarayıcı kapatıldığında önbelleğe alınmış sohbet geçmişini temizle. Bu, arama sürelerini ve veri kullanımını artırır, bu yüzden cihaz paylaşılıyorsa sadece kullanın.", 18 | "showImage": "Simgeyi göster", 19 | "showImageTooltip": "Bir konuşma özel bir GPT kullandıysa, simgeyi mevcutsa göster.", 20 | "searchChats": "Sohbetleri ara...", 21 | "search": "Ara", 22 | "language": "Dil", 23 | "context": "Bağlam", 24 | "contextTooltip": "Her arama sonucuyla birlikte gösterilen çevreleyen bilgi miktarını ayarlayın.", 25 | "reset": "Sıfırla", 26 | "clearCache": "Önbelleği temizle", 27 | "autoClear": "Otomatik temizleme araması", 28 | "autoClearTooltip": "Başka bir yere tıkladığınızda arama alanını temizler.", 29 | "enableShortcut": "Kısayolu etkinleştir", 30 | "enableShortcutTooltip": "'/' tuşuna basarak arama kutusuna odaklanın.", 31 | "highlightColor": "Vurgu rengi", 32 | "highlightColorTooltip": "Vurgulanan metnin görünümünü ayarlayın.", 33 | "highlightBold": "Kalın vurgu", 34 | "highlightBoldTooltip": "Daha iyi görünürlük için vurgulanan metni kalın yapar.", 35 | "advancedSearch": "Gelişmiş arama", 36 | "otherProjects": { 37 | "header": "Diğer Projeler", 38 | "askScreenshot": "Herhangi bir sayfada ekran görüntüsü alın ve otomatik olarak ChatGPT ile açın. Claude ve Gemini için de mevcuttur.", 39 | "globalSpeed": "Video ve ses için varsayılan hızı ayarlayın!" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /static/locales/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "_morpho": true, 3 | "general": "Chung", 4 | "help": "Trợ giúp", 5 | "strictSearch": "Tìm kiếm chặt chẽ", 6 | "strictSearchTooltip": "Tăng ngưỡng cho kết quả phù hợp.", 7 | "sortDate": "Sắp xếp theo ngày", 8 | "sortDateTooltip": "Sắp xếp kết quả theo ngày.", 9 | "scrollTop": "Cuộn lên trên", 10 | "scrollTopTooltip": "Cuộn lên trên khi chọn một cuộc trò chuyện.", 11 | "issuePrompt": "Có vấn đề hoặc gợi ý?", 12 | "issueDirective": "Cung cấp phản hồi trên Github.", 13 | "areYouSure": "Bạn có chắc không?", 14 | "showMore": "Xem thêm", 15 | "notFound": "Không tìm thấy", 16 | "data": "Dữ liệu", 17 | "sessionOnly": "Bộ nhớ đệm chỉ trong phiên", 18 | "sessionOnlyTooltip": "Xóa lịch sử trò chuyện được lưu trong bộ nhớ đệm khi đóng trình duyệt. Điều này làm tăng thời gian tìm kiếm và sử dụng dữ liệu, vì vậy chỉ sử dụng nếu thiết bị của bạn được chia sẻ.", 19 | "showImage": "Hiển thị biểu tượng", 20 | "showImageTooltip": "Nếu một cuộc trò chuyện sử dụng GPT tùy chỉnh, hiển thị biểu tượng nếu có.", 21 | "searchChats": "Tìm kiếm cuộc trò chuyện...", 22 | "search": "Tìm kiếm", 23 | "language": "Ngôn ngữ", 24 | "context": "Bối cảnh", 25 | "contextTooltip": "Điều chỉnh lượng thông tin xung quanh hiển thị với mỗi kết quả tìm kiếm.", 26 | "reset": "Đặt lại", 27 | "clearCache": "Xóa bộ nhớ cache", 28 | "autoClear": "Tự động xóa tìm kiếm", 29 | "autoClearTooltip": "Xóa trường tìm kiếm khi bạn nhấp ra ngoài.", 30 | "enableShortcut": "Kích hoạt phím tắt", 31 | "enableShortcutTooltip": "Nhấn '/' để tập trung vào hộp tìm kiếm.", 32 | "highlightColor": "Màu nổi bật", 33 | "highlightColorTooltip": "Điều chỉnh vẻ ngoài của văn bản được làm nổi bật.", 34 | "highlightBold": "Nổi bật đậm", 35 | "highlightBoldTooltip": "Làm cho văn bản nổi bật được in đậm để dễ nhìn hơn.", 36 | "advancedSearch": "Tìm kiếm nâng cao", 37 | "otherProjects": { 38 | "header": "Các Dự Án Khác", 39 | "askScreenshot": "Chụp ảnh màn hình trên bất kỳ trang nào và tự động mở nó với ChatGPT. Cũng có sẵn cho ClaudeGemini.", 40 | "globalSpeed": "Thiết lập tốc độ mặc định cho video và âm thanh!" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /static/locales/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "_morpho": true, 3 | "general": "通用", 4 | "help": "帮助", 5 | "strictSearch": "严格搜索", 6 | "strictSearchTooltip": "提高匹配结果的阈值。", 7 | "sortDate": "按日期排序", 8 | "sortDateTooltip": "按日期对结果进行排序。", 9 | "scrollTop": "滚动到顶部", 10 | "scrollTopTooltip": "选择对话时滚动到顶部。", 11 | "issuePrompt": "有问题或建议吗?", 12 | "issueDirective": "在Github上提供反馈。", 13 | "areYouSure": "您确定吗?", 14 | "showMore": "显示更多", 15 | "notFound": "未找到", 16 | "data": "数据", 17 | "sessionOnly": "仅限会话缓存", 18 | "sessionOnlyTooltip": "关闭浏览器时清除缓存的聊天历史记录。这将增加搜索时间和数据使用量,因此仅在您的设备被共享时使用。", 19 | "showImage": "显示图标", 20 | "showImageTooltip": "如果对话使用了自定义GPT,可用时显示图标。", 21 | "searchChats": "搜索聊天...", 22 | "search": "搜索", 23 | "language": "语言", 24 | "context": "上下文", 25 | "contextTooltip": "调整每个搜索结果显示的周围信息量。", 26 | "reset": "重置", 27 | "clearCache": "清除缓存", 28 | "autoClear": "自动清除搜索", 29 | "autoClearTooltip": "当您点击其他地方时清除搜索框。", 30 | "enableShortcut": "启用快捷键", 31 | "enableShortcutTooltip": "按'/'聚焦搜索框。", 32 | "highlightColor": "高亮颜色", 33 | "highlightColorTooltip": "调整高亮文本的外观。", 34 | "highlightBold": "加粗高亮", 35 | "highlightBoldTooltip": "使高亮文本加粗以便更好的可见性。", 36 | "advancedSearch": "高级搜索", 37 | "otherProjects": { 38 | "header": "其他项目", 39 | "askScreenshot": "在任何页面上截图,并自动用ChatGPT打开。也适用于ClaudeGemini", 40 | "globalSpeed": "为视频和音频设置默认速度!" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /static/locales/zh_TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "_morpho": true, 3 | "general": "一般", 4 | "help": "幫助", 5 | "strictSearch": "嚴格搜尋", 6 | "strictSearchTooltip": "提高匹配結果的閾值。", 7 | "sortDate": "按日期排序", 8 | "sortDateTooltip": "按日期對結果進行排序。", 9 | "scrollTop": "滾動到頂部", 10 | "scrollTopTooltip": "選擇對話時滾動到頂部。", 11 | "issuePrompt": "有問題或建議嗎?", 12 | "issueDirective": "在Github上提供反饋。", 13 | "areYouSure": "您確定嗎?", 14 | "showMore": "顯示更多", 15 | "notFound": "未找到", 16 | "data": "資料", 17 | "sessionOnly": "僅限階段快取", 18 | "sessionOnlyTooltip": "關閉瀏覽器時清除快取的聊天歷史。這會增加搜尋時間和資料使用量,所以只有在您的裝置被共享時才使用。", 19 | "showImage": "顯示圖標", 20 | "showImageTooltip": "如果對話中使用了自訂GPT,則顯示圖標(如果可用)。", 21 | "searchChats": "搜尋聊天...", 22 | "search": "搜尋", 23 | "language": "語言", 24 | "context": "上下文", 25 | "contextTooltip": "調整每個搜尋結果顯示的周圍信息量。", 26 | "reset": "重設", 27 | "clearCache": "清除快取", 28 | "autoClear": "自動清除搜尋", 29 | "autoClearTooltip": "當您點擊其他地方時清除搜尋框。", 30 | "enableShortcut": "啟用快捷鍵", 31 | "enableShortcutTooltip": "按'/'聚焦搜尋框。", 32 | "highlightColor": "高亮顏色", 33 | "highlightColorTooltip": "調整高亮文本的外觀。", 34 | "highlightBold": "粗體高亮", 35 | "highlightBoldTooltip": "使高亮文本粗體以便更好的可見性。", 36 | "advancedSearch": "進階搜尋", 37 | "otherProjects": { 38 | "header": "其他專案", 39 | "askScreenshot": "在任何頁面上截圖,並自動用ChatGPT開啟。也適用於ClaudeGemini", 40 | "globalSpeed": "為視頻和音頻設置默認速度!" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /static/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /staticCh/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_appName__", 3 | "short_name": "GPT Search", 4 | "version": "0.0.985", 5 | "description": "__MSG_appDesc__", 6 | "manifest_version": 3, 7 | "default_locale": "en", 8 | "host_permissions": ["https://*.chatgpt.com/*"], 9 | "permissions": ["storage", "unlimitedStorage", "webRequest", "scripting"], 10 | "icons": { 11 | "128": "128.png" 12 | }, 13 | "background": { 14 | "service_worker": "background.js", 15 | "type": "module" 16 | }, 17 | "action": {}, 18 | "options_ui": { 19 | "open_in_tab": true, 20 | "page": "options.html" 21 | }, 22 | "content_scripts": [ 23 | { 24 | "matches": ["https://chatgpt.com/*"], 25 | "js": ["preamble.js"], 26 | "run_at": "document_start" 27 | }, 28 | { 29 | "matches": ["https://chatgpt.com/*"], 30 | "js": ["main.js"], 31 | "world": "MAIN", 32 | "run_at": "document_start" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /staticFf/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_appName__", 3 | "short_name": "GPT Search", 4 | "version": "0.0.985", 5 | "description": "__MSG_appDesc__", 6 | "manifest_version": 3, 7 | "browser_specific_settings": { 8 | "gecko": { 9 | "id": "{1e7006c8-e1f9-4d0d-a451-93d8ce23b365}", 10 | "strict_min_version": "113.0" 11 | } 12 | }, 13 | "web_accessible_resources": [ 14 | {"resources": ["main.js"], "matches": ["https://chatgpt.com/*"]} 15 | ], 16 | "default_locale": "en", 17 | "host_permissions": ["https://*.chatgpt.com/*"], 18 | "permissions": ["storage", "unlimitedStorage", "webRequest", "scripting"], 19 | "icons": { 20 | "128": "128.png" 21 | }, 22 | "background": { 23 | "scripts": ["background.js"] 24 | }, 25 | "content_scripts": [ 26 | { 27 | "matches": ["https://chatgpt.com/*"], 28 | "js": ["preamble.js", "mainLoader.js"], 29 | "run_at": "document_start" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /tools/generateGsmType.js: -------------------------------------------------------------------------------- 1 | // /// 2 | 3 | const { access, constants, writeFile, readFile } = require("fs").promises 4 | const { join } = require("path") 5 | 6 | const EN_PATH = join("static", "locales", "en.json") 7 | const GSM_PATH = join("src", "utils", "GsmType.ts") 8 | 9 | 10 | let newData = "" 11 | async function main() { 12 | if (!await pathExists(EN_PATH)) return console.error("en.json does not exist") 13 | const data = JSON.parse( await readFile(EN_PATH, {encoding: "utf8"})) 14 | walk(data) 15 | writeFile(GSM_PATH, newData, {encoding: "utf8"}) 16 | } 17 | 18 | function walk(d, level = 0) { 19 | if (level === 0) newData = "\nexport type Gsm = {" 20 | const e = Object.entries(d) 21 | for (let i = 0; i < e.length; i++) { 22 | let postfix = (i === e.length - 1) ? "" : "," 23 | let l = level + 1 24 | let p = "\n".concat(" ".repeat(l * 2)) 25 | let isOptional = e[i][0].startsWith("_") 26 | 27 | const type = typeof e[i][1] 28 | if (type !== "object") { 29 | newData = newData.concat(p, e[i][0], isOptional ? "?" : "", `: ${type}`, postfix) 30 | } else if (Array.isArray(e[i][1])) { 31 | newData = newData.concat(p, e[i][0], ": {") 32 | walk(e[i][1][0], l) 33 | newData = newData.concat(p, "}[]", postfix) 34 | } else { 35 | newData = newData.concat(p, e[i][0], ": {") 36 | walk(e[i][1], l) 37 | newData = newData.concat(p, "}", postfix) 38 | } 39 | } 40 | if (level === 0) newData = newData.concat("\n}") 41 | } 42 | 43 | async function pathExists(path) { 44 | try { 45 | await access(path, constants.W_OK) 46 | return true 47 | } catch (err) { 48 | return false 49 | } 50 | } 51 | 52 | main() -------------------------------------------------------------------------------- /tools/validateLocale.js: -------------------------------------------------------------------------------- 1 | 2 | // /// 3 | 4 | // Test to make sure all locales have the required strings. 5 | 6 | const { readFileSync } = require("fs") 7 | const { exit } = require("process") 8 | 9 | const locales = ["en", "it", "es", "ja", "ko", "pt_BR", "ru", "tr", "zh_CN", "zh_TW"] 10 | 11 | let targetLeaves; 12 | 13 | for (let locale of locales) { 14 | let leaves; 15 | try { 16 | leaves = getLeafs(JSON.parse(readFileSync(`./static/locales/${locale}.json`, {encoding: "utf8"}))) 17 | } catch (err) { 18 | console.log("Could not parse", locale, leaves) 19 | exit() 20 | } 21 | 22 | if (!targetLeaves) { 23 | targetLeaves = leaves; 24 | continue 25 | } 26 | 27 | 28 | const omitted = new Set(targetLeaves.filter(v => !leaves.includes(v))) 29 | const extra = new Set(leaves.filter(v => !targetLeaves.includes(v))) 30 | 31 | omitted.forEach(o => o.startsWith("_") && omitted.delete(o)) 32 | extra.forEach(o => o.startsWith("_") && extra.delete(o)) 33 | 34 | if (omitted.size) { 35 | console.log("OMITTED", "\n=========") 36 | omitted.forEach(v => console.log(v)) 37 | } 38 | 39 | if (extra.size) { 40 | console.log("\nEXTRA", "\n=========") 41 | extra.forEach(v => console.log(v)) 42 | } 43 | 44 | if (omitted.size + extra.size ) { 45 | console.log("\nFIX", locale) 46 | exit() 47 | } 48 | } 49 | 50 | console.log("ALL GOOD!") 51 | 52 | function getLeafs(obj, ctx = []) { 53 | const leafs = [] 54 | for (let [k, v] of Object.entries(obj)) { 55 | if (typeof v === "object") { 56 | leafs.push(...getLeafs(v, [...ctx, k])) 57 | } else { 58 | leafs.push([...ctx, k].join('.')) 59 | } 60 | } 61 | return leafs 62 | } 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["DOM", "DOM.iterable", "ESNext"], 7 | "types": ["@types/chrome", "@types/react", "@types/react-dom"], 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | 14 | "noImplicitAny": true, 15 | "allowJs": true, 16 | "sourceMap": false, 17 | "jsx": "react-jsx" 18 | }, 19 | "exclude": ["./node_modules", "./build"] 20 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("path") 2 | const { env } = require("process") 3 | const webpack = require('webpack') 4 | const TerserPlugin = require('terser-webpack-plugin') 5 | 6 | const tsx = { 7 | test: /\.tsx?$/, 8 | exclude: /node_modules/, 9 | resourceQuery: { not: [/sfx/] }, 10 | use: "babel-loader" 11 | } 12 | 13 | const entry = { 14 | raccoon: "./src/raccoon/index.tsx", 15 | main: "./src/main.ts", 16 | preamble: "./src/preamble/index.ts", 17 | background: "./src/background/index.ts", 18 | options: "./src/options/index.tsx" 19 | } 20 | 21 | if (env.FIREFOX) { 22 | entry["mainLoader"] = "./src/mainLoader.ts" 23 | } 24 | 25 | const common = { 26 | target: "browserslist", 27 | entry, 28 | output: { 29 | path: resolve(__dirname, env.FIREFOX ? "buildFf": "build", "unpacked") 30 | }, 31 | module: { 32 | rules: [ 33 | tsx, 34 | {...tsx, resourceQuery: /sfx/, sideEffects: true}, 35 | { 36 | sideEffects: true, 37 | test: /\.css$/, 38 | exclude: /node_modules/, 39 | resourceQuery: { not: [/raw/] }, 40 | use: [ 41 | "style-loader", 42 | { 43 | loader: "css-loader", 44 | options: { 45 | import: true, 46 | } 47 | }, 48 | "postcss-loader" 49 | ], 50 | }, 51 | { 52 | test: /\.css$/, 53 | resourceQuery: /raw/, 54 | exclude: [/node_modules/], 55 | type: 'asset/source', 56 | use: [ 57 | "postcss-loader" 58 | ] 59 | } 60 | ] 61 | }, 62 | resolve: { 63 | extensions: [".tsx", '.ts', '.js'] 64 | }, 65 | plugins: [ 66 | new webpack.ProvidePlugin({ 67 | gvar: [resolve(__dirname, "src", "globalVar.ts"), "gvar"] 68 | }) 69 | ] 70 | } 71 | 72 | if (env.NODE_ENV === "production") { 73 | module.exports = { 74 | ...common, 75 | mode: "production", 76 | optimization: { 77 | minimize: true, 78 | minimizer: [ 79 | new TerserPlugin({ 80 | terserOptions: { 81 | format: { 82 | comments: false, 83 | } 84 | }, 85 | extractComments: false, 86 | }) 87 | ] 88 | } 89 | } 90 | } else { 91 | module.exports = { 92 | ...common, 93 | mode: "development", 94 | devtool: false 95 | } 96 | } --------------------------------------------------------------------------------