├── src ├── content-script.ts ├── options.css ├── popup.css ├── types.ts ├── components │ ├── Input.tsx │ ├── LoadingSpinner.tsx │ ├── Switch.tsx │ ├── ColorPicker.tsx │ ├── FilterRules.tsx │ └── toast.ts ├── service-provider │ ├── index.ts │ ├── gpt.ts │ └── gemini.ts ├── const.ts ├── utils.ts ├── services.ts ├── background.ts ├── options.tsx └── popup.tsx ├── public ├── icon.png ├── manifest.json └── cog.svg ├── .husky └── pre-commit ├── .gitignore ├── postcss.config.cjs ├── options.html ├── popup.html ├── tailwind.config.js ├── .github └── workflows │ ├── notify.yml │ ├── main.yml │ └── build.yml ├── tsconfig.json ├── lint-staged.config.cjs ├── vite.config.ts ├── LICENSE ├── package.json ├── README.md └── pnpm-lock.yaml /src/content-script.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/options.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/popup.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-extension-ai-group-tabs/main/public/icon.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules/ 3 | dist/ 4 | tmp/ 5 | .DS_Store 6 | .pnpm-store/ 7 | dist.zip -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Options 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AI Group Tabs 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type RuleType = "DOMAIN" | "DOMAIN-SUFFIX" | "DOMAIN-KEYWORD" | "REGEX"; 2 | 3 | export type FilterRuleItem = { 4 | id: number; 5 | type: RuleType; 6 | rule: string; 7 | }; 8 | 9 | export type ServiceProvider = "GPT" | "Gemini"; 10 | 11 | export interface TabInfo { 12 | id: number | undefined; 13 | title: string | undefined; 14 | url: string | undefined; 15 | } 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{js,jsx,ts,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | primary: "#6300FF", 8 | }, 9 | opacity: { 10 | lg: "0.87", 11 | md: "0.6", 12 | sm: "0.38", 13 | }, 14 | }, 15 | }, 16 | plugins: [require("@tailwindcss/forms")], 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/notify.yml: -------------------------------------------------------------------------------- 1 | name: Notify 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | notify: 8 | name: Notify via Telegram 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Send message to Telegram 12 | uses: Lukasss93/telegram-action@v2 13 | env: 14 | TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} 15 | TELEGRAM_CHAT: ${{ secrets.TELEGRAM_CHAT }} 16 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes } from "react"; 2 | 3 | export default function Input(props: InputHTMLAttributes) { 4 | return ( 5 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/service-provider/index.ts: -------------------------------------------------------------------------------- 1 | import { TabInfo } from "../types"; 2 | import { getServiceProvider } from "../utils"; 3 | import { fetchGemini } from "./gemini"; 4 | import { fetchGpt } from "./gpt"; 5 | 6 | const fetchMap = { 7 | GPT: fetchGpt, 8 | Gemini: fetchGemini, 9 | } as const; 10 | 11 | export const fetchType = async ( 12 | apiKey: string, 13 | tabInfo: TabInfo, 14 | types: string[] 15 | ) => { 16 | const serviceProvider = await getServiceProvider(); 17 | if (!fetchMap[serviceProvider]) { 18 | throw new Error("unexpected serviceProvider: " + serviceProvider); 19 | } 20 | return fetchMap[serviceProvider](apiKey, tabInfo, types); 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: telegram message 2 | on: [push] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: send telegram message on push 10 | uses: appleboy/telegram-action@master 11 | with: 12 | to: ${{ secrets.TELEGRAM_TO }} 13 | token: ${{ secrets.TELEGRAM_TOKEN }} 14 | message: | 15 | ${{ github.actor }} created commit: 16 | Commit message: ${{ github.event.commits[0].message }} 17 | 18 | Repository: ${{ github.repository }} 19 | 20 | See changes: https://github.com/${{ github.repository }}/commit/${{github.sha}} 21 | -------------------------------------------------------------------------------- /lint-staged.config.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/vercel/next.js/blob/canary/lint-staged.config.js 3 | */ 4 | 5 | const { quote } = require("shell-quote"); 6 | 7 | const isWin = process.platform === "win32"; 8 | 9 | module.exports = { 10 | "**/*.{js,jsx,cjs,mjs,ts,tsx,mts,cts,md,json}": (filenames) => { 11 | const escapedFileNames = filenames 12 | .map((filename) => `"${isWin ? filename : escape([filename])}"`) 13 | .join(" "); 14 | return [ 15 | `prettier --write ${escapedFileNames}`, 16 | `git add ${escapedFileNames}`, 17 | ]; 18 | }, 19 | }; 20 | 21 | function escape(str) { 22 | const escaped = quote(str); 23 | return escaped.replace(/\\@/g, "@"); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | // Ported from https://tailwindcss.com/docs/animation#spin 2 | export const LoadingSpinner = () => ( 3 | 9 | 17 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "AI Group Tabs", 4 | "description": "Group your tabs with AI", 5 | "version": "1.1.2", 6 | "options_ui": { 7 | "page": "options.html", 8 | "open_in_tab": true 9 | }, 10 | "action": { 11 | "default_icon": "icon.png", 12 | "default_popup": "popup.html" 13 | }, 14 | "content_scripts": [ 15 | { 16 | "matches": [""], 17 | "js": ["content_script.js"] 18 | } 19 | ], 20 | "background": { 21 | "type": "module", 22 | "service_worker": "service_worker.js" 23 | }, 24 | "icons": { 25 | "16": "icon.png", 26 | "48": "icon.png", 27 | "128": "icon.png" 28 | }, 29 | "permissions": ["storage", "tabs", "tabGroups"], 30 | "host_permissions": [""] 31 | } 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { resolve } from "path"; 3 | import react from "@vitejs/plugin-react-swc"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | build: { 9 | rollupOptions: { 10 | input: { 11 | popup: resolve(__dirname, "popup.html"), 12 | options: resolve(__dirname, "options.html"), 13 | service_worker: resolve(__dirname, "src/background.ts"), 14 | content_script: resolve(__dirname, "src/content-script.ts"), 15 | }, 16 | output: { 17 | chunkFileNames: "[name].[hash].js", 18 | assetFileNames: "[name].[hash].[ext]", 19 | entryFileNames: "[name].js", 20 | dir: "dist", 21 | }, 22 | }, 23 | sourcemap: process.env.NODE_ENV === "development", 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v4 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v2 21 | 22 | - name: Set node 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: lts/* 26 | 27 | - name: Setup 28 | run: npm i -g @antfu/ni 29 | 30 | - name: Install 31 | run: nci 32 | 33 | - name: Lint 34 | run: nr style 35 | 36 | - name: Type check 37 | run: nr typecheck 38 | 39 | - name: Build 40 | run: nr build 41 | 42 | - name: zip bundle file 43 | run: zip -r ai-group-tabs.zip dist/ 44 | 45 | - name: Upload package 46 | uses: actions/upload-artifact@v3 47 | with: 48 | name: ai-group-tabs 49 | path: ai-group-tabs.zip 50 | retention-days: 5 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yuhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | export default function Switch({ 2 | isChecked, 3 | onChange, 4 | text, 5 | }: { 6 | isChecked: boolean; 7 | onChange: () => void; 8 | text: string; 9 | }) { 10 | return ( 11 |
12 | 23 | 24 | 28 | {text} 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-group-tabs", 3 | "private": true, 4 | "type": "module", 5 | "packageManager": "pnpm@8.11.0", 6 | "scripts": { 7 | "prepare": "husky install", 8 | "dev": "cross-env NODE_ENV=development vite build --watch", 9 | "build": "vite build", 10 | "typecheck": "tsc --noEmit", 11 | "style": "prettier --check \"src/**/*.{ts,tsx}\"", 12 | "style:fix": "prettier --write \"src/**/*.{ts,tsx}\"" 13 | }, 14 | "author": "MichaelYuhe", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@types/mustache": "4.1.0", 18 | "mustache": "4.1.0", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0" 21 | }, 22 | "prettier": { 23 | "tabWidth": 2 24 | }, 25 | "devDependencies": { 26 | "@tailwindcss/forms": "^0.5.7", 27 | "@types/chrome": "0.0.254", 28 | "@types/node": "^20.10.4", 29 | "@types/react": "^18.0.29", 30 | "@types/react-dom": "^18.0.11", 31 | "@vitejs/plugin-react-swc": "^3.5.0", 32 | "autoprefixer": "^10.4.16", 33 | "cross-env": "^7.0.3", 34 | "husky": "^8.0.3", 35 | "lint-staged": "^15.2.0", 36 | "postcss": "^8.4.32", 37 | "prettier": "^2.2.1", 38 | "shell-quote": "^1.8.1", 39 | "tailwindcss": "^3.3.6", 40 | "typescript": "^5.0.4", 41 | "vite": "^5.0.7" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PROMPT: string = 2 | `Classify the tab group base on the provided URL ({{tabURL}}) and title ({{tabTitle}}) into one of the categories: ` + 3 | `{{types}}. Response with the category only, without any comments.`; 4 | 5 | export const DEFAULT_GROUP = [ 6 | "Social", 7 | "Entertainment", 8 | "Read Material", 9 | "Education", 10 | "Productivity", 11 | "Utilities", 12 | ]; 13 | 14 | export enum Color { 15 | grey = "grey", 16 | blue = "blue", 17 | red = "red", 18 | yellow = "yellow", 19 | green = "green", 20 | pink = "pink", 21 | purple = "purple", 22 | cyan = "cyan", 23 | orange = "orange", 24 | } 25 | 26 | export const TabColorConfig = [ 27 | { 28 | name: Color.grey, 29 | value: "rgb(218, 220, 224)", 30 | }, 31 | { 32 | name: Color.blue, 33 | value: "rgb(147, 179, 242)", 34 | }, 35 | { 36 | name: Color.red, 37 | value: "rgb(228, 144, 134)", 38 | }, 39 | { 40 | name: Color.yellow, 41 | value: "rgb(247, 215, 117)", 42 | }, 43 | { 44 | name: Color.green, 45 | value: "rgb(145, 199, 153)", 46 | }, 47 | { 48 | name: Color.pink, 49 | value: "rgb(240, 145, 200)", 50 | }, 51 | { 52 | name: Color.purple, 53 | value: "rgb(188, 140, 242)", 54 | }, 55 | { 56 | name: Color.cyan, 57 | value: "rgb(144, 215, 233)", 58 | }, 59 | { 60 | name: Color.orange, 61 | value: "rgb(240, 176, 122)", 62 | }, 63 | ] as const; 64 | -------------------------------------------------------------------------------- /src/service-provider/gpt.ts: -------------------------------------------------------------------------------- 1 | import { TabInfo } from "../types"; 2 | import Mustache from "mustache"; 3 | import { getStorage, removeQueryParameters } from "../utils"; 4 | import { DEFAULT_PROMPT } from "../const"; 5 | 6 | const renderPromptForOpenAI = async ( 7 | tab: TabInfo, 8 | types: string[] 9 | ): Promise< 10 | [{ role: string; content: string }, { role: string; content: string }] 11 | > => { 12 | const prompt: string = (await getStorage("prompt")) || DEFAULT_PROMPT; 13 | return [ 14 | { 15 | role: "system", 16 | content: "You are a brwoser tab group classificator", 17 | }, 18 | { 19 | role: "user", 20 | content: Mustache.render(prompt, { 21 | tabURL: removeQueryParameters(tab.url), 22 | tabTitle: tab.title, 23 | types: types.join(", "), 24 | }), 25 | }, 26 | ]; 27 | }; 28 | 29 | export const fetchGpt = async ( 30 | apiKey: string, 31 | tabInfo: TabInfo, 32 | types: string[] 33 | ) => { 34 | // See https://platform.openai.com/docs/api-reference/chat/create 35 | const apiURL = 36 | (await getStorage("apiURL")) || 37 | "https://api.openai.com/v1/chat/completions"; 38 | 39 | const model = (await getStorage("model")) || "gpt-3.5-turbo"; 40 | 41 | const response = await fetch(apiURL, { 42 | method: "POST", 43 | headers: { 44 | "Content-Type": "application/json", 45 | Authorization: `Bearer ${apiKey}`, 46 | "Api-Key": apiKey, 47 | }, 48 | body: JSON.stringify({ 49 | messages: await renderPromptForOpenAI(tabInfo, types), 50 | model, 51 | }), 52 | }); 53 | 54 | const data = await response.json(); 55 | const type: string = data.choices[0].message.content; 56 | return type; 57 | }; 58 | -------------------------------------------------------------------------------- /src/service-provider/gemini.ts: -------------------------------------------------------------------------------- 1 | import { TabInfo } from "../types"; 2 | import Mustache from "mustache"; 3 | import { getStorage, removeQueryParameters } from "../utils"; 4 | import { DEFAULT_PROMPT } from "../const"; 5 | 6 | const renderPromptForGemini = async ( 7 | tab: TabInfo, 8 | types: string[] 9 | ): Promise<{ role: string; parts: [{ text: string }] }[]> => { 10 | const prompt: string = (await getStorage("prompt")) || DEFAULT_PROMPT; 11 | return [ 12 | { 13 | role: "user", 14 | parts: [ 15 | { 16 | text: "", 17 | }, 18 | ], 19 | }, 20 | { 21 | role: "model", 22 | parts: [ 23 | { 24 | text: "You are a brwoser tab group classificator", 25 | }, 26 | ], 27 | }, 28 | { 29 | role: "user", 30 | parts: [ 31 | { 32 | text: Mustache.render(prompt, { 33 | tabURL: removeQueryParameters(tab.url), 34 | tabTitle: tab.title, 35 | types: types.join(", "), 36 | }), 37 | }, 38 | ], 39 | }, 40 | ]; 41 | }; 42 | 43 | export const fetchGemini = async ( 44 | apiKey: string, 45 | tabInfo: TabInfo, 46 | types: string[] 47 | ) => { 48 | const response = await fetch( 49 | "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" + 50 | apiKey, 51 | { 52 | method: "POST", 53 | headers: { 54 | "Content-Type": "application/json", 55 | }, 56 | body: JSON.stringify({ 57 | contents: await renderPromptForGemini(tabInfo, types), 58 | }), 59 | } 60 | ); 61 | 62 | const data = await response.json(); 63 | 64 | const type: string = data.candidates[0].content.parts[0].text; 65 | return type; 66 | }; 67 | -------------------------------------------------------------------------------- /public/cog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Group Tabs 2 | 3 | ![Frame 7](https://github.com/MichaelYuhe/ai-group-tabs/assets/63531512/fef62a35-8193-4ef1-8082-cfc771d0b4e6) 4 | 5 | Demo Video: 6 | 7 | > [![Watch the video](https://img.youtube.com/vi/SjfKiXy3zOc/default.jpg)](https://youtu.be/SjfKiXy3zOc) 8 | 9 | ## Roadmap 10 | 11 | - [x] Group tabs with AI by default categories 12 | - [x] Fill OpenAI API key in popup and save in Chrome storage 13 | - [x] Customize categories in popup 14 | - [x] Group new tabs automatically 15 | - [x] Publish on Chrome store 16 | - [x] Better prompt engineering 17 | - [x] Logo and name 18 | - [x] CI / CD for build and release new version 19 | - [x] Add toast 20 | - [x] Use Vite and pnpm 21 | - [x] Group the updated tab only when a tab is updated 22 | - [x] Custom model and API server 23 | 24 | ## Download and Start Using 25 | 26 | ### Download from Chrome Web Store 27 | 28 | https://chromewebstore.google.com/detail/ai-group-tabs/hihpejmkeabgeockcemeikfkkbdofhji 29 | 30 | ### Download from source 31 | 32 | Download the latest released `dist.zip` from [the release page](https://github.com/MichaelYuhe/ai-group-tabs/releases), unzip after download, you will get a folder named `dist`. 33 | 34 | Open Chrome, go to `chrome://extensions/`, turn on `Developer mode` on the top right corner, click `Load unpacked` on the top left corner, select the `dist` folder you just unzipped. 35 | 36 | > You can change the model and API server in the options page. 37 | 38 | ## Development 39 | 40 | ```bash 41 | # Install dependencies 42 | pnpm install 43 | 44 | # Development 45 | pnpm dev 46 | 47 | # Build 48 | pnpm build 49 | ``` 50 | 51 | ## Special Thanks 52 | 53 | > Everyone contributor can get your one month free of Developer Plan on Zeabur. 54 | 55 | [![Deployed on Zeabur](https://zeabur.com/deployed-on-zeabur-dark.svg)](https://zeabur.com?referralCode=MichaelYuhe&utm_source=ai-group-tab&utm_campaign=oss) 56 | 57 | ## Sponsor 58 | 59 | > This extension is free forever, if you love this extension, you can buy me a coffee here :D 60 | 61 | Buy Me A Coffee 62 | 63 | ## Contributors 64 | 65 |

66 | 67 | 68 |

69 | -------------------------------------------------------------------------------- /src/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, forwardRef, useState } from "react"; 2 | import { Color, TabColorConfig } from "../const"; 3 | 4 | const ColorCircle = ({ 5 | color, 6 | selected = false, 7 | onClick, 8 | }: { 9 | color: Color; 10 | selected?: boolean; 11 | onClick: () => void; 12 | }) => { 13 | const DEFAULT_COLOR = 14 | "linear-gradient(to right, rgb(228, 144, 134) 0%, rgb(147, 179, 242) 100%)"; 15 | const colorValue = 16 | TabColorConfig.find((c) => c.name === color)?.value ?? DEFAULT_COLOR; 17 | return ( 18 | 30 | ); 31 | }; 32 | 33 | const ColorPickerPopup = forwardRef< 34 | HTMLDivElement, 35 | { 36 | selected: Color; 37 | onChange: (newColor: Color) => void; 38 | onClose?: () => void; 39 | } 40 | >(({ selected, onChange, onClose }, ref) => { 41 | return ( 42 | <> 43 |
47 | {TabColorConfig.map(({ name: colorOption }) => ( 48 | { 53 | onChange(colorOption); 54 | }} 55 | /> 56 | ))} 57 |
58 | {/* overlay mask */} 59 |
60 | 61 | ); 62 | }); 63 | 64 | export const ColorPicker = ({ 65 | color, 66 | onChange, 67 | ...props 68 | }: { 69 | color: Color; 70 | onChange: (newColor: Color) => void; 71 | } & Omit, "onChange">) => { 72 | const [show, setShow] = useState(false); 73 | return ( 74 |
75 | setShow(!show)} /> 76 | {show && ( 77 | { 80 | onChange(newColor); 81 | setShow(false); 82 | }} 83 | onClose={() => setShow(false)} 84 | /> 85 | )} 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/components/FilterRules.tsx: -------------------------------------------------------------------------------- 1 | import { RuleType, FilterRuleItem } from "../types"; 2 | 3 | const ruleTypes: { label: string; value: RuleType }[] = [ 4 | { 5 | label: "Domain", 6 | value: "DOMAIN", 7 | }, 8 | { 9 | label: "Domain suffix", 10 | value: "DOMAIN-SUFFIX", 11 | }, 12 | { 13 | label: "Domain keyword", 14 | value: "DOMAIN-KEYWORD", 15 | }, 16 | { 17 | label: "Regex", 18 | value: "REGEX", 19 | }, 20 | ]; 21 | 22 | type FilterRuleItemProps = { 23 | id: number; 24 | handleRuleChange: (id: number, rule: string) => void; 25 | handleTypeChange: (id: number, type: RuleType) => void; 26 | handleRuleDelete: (id: number) => void; 27 | ruleItem: FilterRuleItem; 28 | }; 29 | 30 | const FilterRuleItem = ({ 31 | id, 32 | handleRuleChange, 33 | handleTypeChange, 34 | ruleItem, 35 | handleRuleDelete, 36 | }: FilterRuleItemProps) => { 37 | return ( 38 |
39 | 51 | handleRuleChange(id, e.target.value)} 58 | /> 59 | 60 |
61 | ); 62 | }; 63 | 64 | type FilterRulesProps = { 65 | filterRules: FilterRuleItem[]; 66 | updateFilterRules: (rules: FilterRuleItem[]) => void; 67 | }; 68 | 69 | const FilterRules = ({ filterRules, updateFilterRules }: FilterRulesProps) => { 70 | const handleAddItem = () => { 71 | updateFilterRules([ 72 | ...filterRules, 73 | { id: filterRules.length, type: "DOMAIN", rule: "" }, 74 | ]); 75 | }; 76 | 77 | const handleRuleChange: FilterRuleItemProps["handleRuleChange"] = ( 78 | id, 79 | newRule 80 | ) => { 81 | updateFilterRules( 82 | filterRules.map((item) => 83 | item.id === id ? { ...item, rule: newRule } : item 84 | ) 85 | ); 86 | }; 87 | 88 | const handleTypeChange: FilterRuleItemProps["handleTypeChange"] = ( 89 | id, 90 | newType 91 | ) => { 92 | updateFilterRules( 93 | filterRules.map((item) => 94 | item.id === id ? { ...item, type: newType } : item 95 | ) 96 | ); 97 | }; 98 | const handleRuleDelete: FilterRuleItemProps["handleRuleDelete"] = (id) => { 99 | updateFilterRules(filterRules.filter((item) => item.id !== id)); 100 | }; 101 | 102 | return ( 103 |
104 |
105 |
- Domain: Exact match; example.com should match example.com
106 |
107 | - Domain suffix: Suffix matching; example.com should match 108 | www.example.com 109 |
110 |
111 | - Domain keyword: Keyword matching; example should match 112 | www.example.com 113 |
114 |
115 | - Regex: Regular expression matching; https?://mail.google.com/* 116 | should match https://mail.google.com/mail/u/0/#inbox 117 |
118 |
119 | 120 | {filterRules.map((item) => ( 121 | 129 | ))} 130 | 131 | 139 |
140 | ); 141 | }; 142 | 143 | export default FilterRules; 144 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { FilterRuleItem, ServiceProvider } from "./types"; 2 | 3 | export function setStorage(key: string, value: V) { 4 | return new Promise((resolve, reject) => { 5 | chrome.storage.local.set({ [key]: value }, () => { 6 | if (chrome.runtime.lastError) { 7 | reject(chrome.runtime.lastError); 8 | } else { 9 | resolve(true); 10 | } 11 | }); 12 | }); 13 | } 14 | 15 | export function getStorage(key: string): Promise { 16 | return new Promise((resolve, reject) => { 17 | chrome.storage.local.get(key, (result) => { 18 | if (chrome.runtime.lastError) { 19 | reject(chrome.runtime.lastError); 20 | } else { 21 | resolve(result[key]); 22 | } 23 | }); 24 | }); 25 | } 26 | 27 | export function matchesRule(url: URL, rule: FilterRuleItem) { 28 | const { type, rule: value } = rule; 29 | if (!value) { 30 | return false; 31 | } 32 | const host = url.host; 33 | switch (type) { 34 | case "DOMAIN": 35 | // Exact match; example.com should match example.com 36 | return host === value; 37 | case "DOMAIN-SUFFIX": 38 | // Suffix matching; example.com should match www.example.com 39 | return host.endsWith("." + value) || host === value; 40 | case "DOMAIN-KEYWORD": 41 | // Keyword matching; example should match www.example.com 42 | return host.includes(value); 43 | case "REGEX": 44 | // Regular expression matching; https?://mail.google.com/* should match https://mail.google.com/mail/u/0/#inbox 45 | return new RegExp(value).test(url.href); 46 | default: 47 | // If the rule type is unknown, return false. 48 | return false; 49 | } 50 | } 51 | 52 | export function getRootDomain(url: URL) { 53 | const host = url.host; 54 | const parts = host.split("."); 55 | if (parts.length <= 2) { 56 | return host; 57 | } 58 | return parts.slice(1).join("."); 59 | } 60 | 61 | export const getTabsFromGroup = async ( 62 | groupId: number 63 | ): Promise => { 64 | return new Promise((resolve) => { 65 | chrome.tabs.query({ groupId }, (tabs) => { 66 | resolve(tabs); 67 | }); 68 | }); 69 | }; 70 | 71 | export const tabGroupMap: { 72 | [key: number]: { 73 | type: "manual" | "setting"; 74 | title: string; 75 | }; 76 | } = {}; 77 | 78 | export const createdManualType = ( 79 | types: string[], 80 | group: chrome.tabGroups.TabGroup 81 | ) => { 82 | if (!group.title) return; 83 | const hasCreatedType = types.find((type, index) => { 84 | if (type === group.title) { 85 | types[index] = group.title; 86 | return true; 87 | } 88 | return false; 89 | }); 90 | if (!hasCreatedType) { 91 | types.push(group.title); 92 | tabGroupMap[group.id] = { type: "manual", title: group.title }; 93 | setStorage("types", types); 94 | } 95 | }; 96 | 97 | export const updatedManualType = ( 98 | types: string[], 99 | group: chrome.tabGroups.TabGroup 100 | ) => { 101 | if (!group.title) return; 102 | const existType = types.findIndex( 103 | (type) => type === tabGroupMap[group.id].title 104 | ); 105 | if (existType) { 106 | types.splice(existType, 1, group.title); 107 | tabGroupMap[group.id] = { type: "manual", title: group.title }; 108 | setStorage("types", types); 109 | } 110 | }; 111 | 112 | export const curryFilterManualGroups = async () => { 113 | const manualGroups = Object.entries(tabGroupMap).filter( 114 | ([_groupId, group]) => group.type === "manual" 115 | ); 116 | const manualGroupsTabs = ( 117 | await Promise.all( 118 | manualGroups.map(async ([groupId, _group]) => { 119 | const groupTabs = getTabsFromGroup(parseInt(groupId)); 120 | return groupTabs; 121 | }) 122 | ) 123 | ).flat(); 124 | 125 | // filter out tabs that are in manual groups 126 | return (tabId: number) => { 127 | return !manualGroupsTabs.map((tab) => tab.id).includes(tabId); 128 | }; 129 | }; 130 | 131 | export const getServiceProvider = async () => { 132 | const serviceProvider = 133 | (await getStorage("serviceProvider")) || "GPT"; 134 | return serviceProvider; 135 | }; 136 | 137 | export const removeQueryParameters = ( 138 | urlString: string | undefined 139 | ): string => { 140 | if (typeof urlString !== "string") { 141 | return "about:blank"; 142 | } 143 | const url = new URL(urlString); 144 | url.search = ""; 145 | return url.toString(); 146 | }; 147 | -------------------------------------------------------------------------------- /src/components/toast.ts: -------------------------------------------------------------------------------- 1 | let ToastContainer: HTMLDivElement | null = null; 2 | const html = String.raw; 3 | 4 | /** 5 | * DO NOT USE FOR USER INPUT 6 | * 7 | * See https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518 8 | */ 9 | const htmlToElement = (html: string) => { 10 | const template = document.createElement("template"); 11 | html = html.trim(); // Never return a text node of whitespace as the result 12 | template.innerHTML = html; 13 | return template.content.firstChild as T; 14 | }; 15 | 16 | const createToastContainer = () => { 17 | const styles = ` 18 | position: fixed; 19 | bottom: 0; 20 | left: 0; 21 | width: 100%; 22 | display: flex; 23 | flex-direction: column-reverse; 24 | align-items: center; 25 | `; 26 | const template = html`
`; 27 | const element = htmlToElement(template); 28 | const container = document.body; 29 | container.appendChild(element); 30 | return element; 31 | }; 32 | 33 | type ToastOptions = { 34 | type: "info" | "success" | "warn" | "error"; 35 | message: string; 36 | duration: number; 37 | }; 38 | 39 | const defaultToastOptions: ToastOptions = { 40 | type: "info", 41 | message: "", 42 | duration: 2500, 43 | }; 44 | 45 | // Ported from https://tailwindui.com/components/application-ui/feedback/alerts 46 | const toastTypeStyles = { 47 | info: ` 48 | color: rgb(29 78 216); 49 | background: rgb(239 246 255); 50 | `, 51 | success: ` 52 | color: rgb(21 128 61); 53 | background: rgb(240 253 244); 54 | `, 55 | warn: ` 56 | color: rgb(161 98 7); 57 | background: rgb(254 252 232); 58 | `, 59 | error: ` 60 | color: rgb(185 28 28); 61 | background: rgb(254 242 242); 62 | `, 63 | } as const; 64 | 65 | /** 66 | * @example 67 | * ```ts 68 | * toast('Hello World'); 69 | * toast({ message: 'Hello World', type: 'info' }); 70 | * ``` 71 | */ 72 | export function toast(message: string): HTMLDivElement; 73 | export function toast(options: Partial): HTMLDivElement; 74 | export function toast(messageOrOptions: string | Partial) { 75 | const toastOptions: ToastOptions = 76 | typeof messageOrOptions === "string" 77 | ? { ...defaultToastOptions, message: messageOrOptions } 78 | : { ...defaultToastOptions, ...messageOrOptions }; 79 | if (!ToastContainer) { 80 | ToastContainer = createToastContainer(); 81 | } 82 | 83 | const toastStyles = ` 84 | width: 100%; 85 | font-family: 'Inter', 'Source Sans 3', Poppins, apple-system, BlinkMacSystemFont,'Helvetica Neue', Tahoma, 'PingFang SC', 'Microsoft Yahei', Arial,'Hiragino Sans GB', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji','Segoe UI Symbol', 'Noto Color Emoji'; 86 | font-size: 14px; 87 | padding: 6px 12px; 88 | margin: 10px 0 0 0; 89 | box-shadow: 0px 0px 10px rgba(0, 0, 0, .05), 0px 0px 0px .5px rgba(0, 0, 0, .1); 90 | transition: all 230ms cubic-bezier(0.21, 1.02, 0.73, 1); 91 | opacity: 0; 92 | ${toastTypeStyles[toastOptions.type]} 93 | `; 94 | 95 | const template = html`
`; 96 | const element = htmlToElement(template); 97 | // use textContent instead of innerText to avoid XSS 98 | element.textContent = toastOptions.message; 99 | // We can only create one toast at a time since there is no enough space. 100 | ToastContainer.innerHTML = ""; 101 | ToastContainer.appendChild(element); 102 | 103 | const fadeIn = [ 104 | { 105 | opacity: 0, 106 | }, 107 | { opacity: 1 }, 108 | ]; 109 | const animationOptions = { 110 | duration: 230, 111 | easing: "cubic-bezier(0.21, 1.02, 0.73, 1)", 112 | fill: "forwards" as const, 113 | }; // satisfies KeyframeAnimationOptions; 114 | element.animate(fadeIn, animationOptions); 115 | 116 | setTimeout(async () => { 117 | const fadeOut = fadeIn.reverse(); 118 | const animation = element.animate(fadeOut, animationOptions); 119 | await animation.finished; 120 | element.remove(); 121 | }, toastOptions.duration); 122 | return element; 123 | } 124 | 125 | toast.success = (message: string) => toast({ type: "success", message }); 126 | toast.info = (message: string) => toast({ type: "info", message }); 127 | toast.warn = (message: string) => toast({ type: "warn", message }); 128 | toast.error = (message: string) => toast({ type: "error", message }); 129 | -------------------------------------------------------------------------------- /src/services.ts: -------------------------------------------------------------------------------- 1 | import { getStorage, matchesRule } from "./utils"; 2 | import { FilterRuleItem, ServiceProvider, TabInfo } from "./types"; 3 | import { fetchType } from "./service-provider"; 4 | import { toast } from "./components/toast"; 5 | 6 | interface TabGroup { 7 | type: string; 8 | tabIds: (number | undefined)[]; 9 | } 10 | 11 | const filterTabInfo = (tabInfo: TabInfo, filterRules: FilterRuleItem[]) => { 12 | if (!filterRules || !filterRules?.length) return true; 13 | const url = new URL(tabInfo.url ?? ""); 14 | return !filterRules.some((rule) => { 15 | return matchesRule(url, rule); 16 | }); 17 | }; 18 | 19 | export async function batchGroupTabs( 20 | tabs: chrome.tabs.Tab[], 21 | types: string[], 22 | apiKey: string 23 | ) { 24 | const filterRules = (await getStorage("filterRules")) || []; 25 | const tabInfoList: TabInfo[] = tabs 26 | .map((tab) => { 27 | return { 28 | id: tab.id, 29 | title: tab.title, 30 | url: tab.url, 31 | }; 32 | }) 33 | .filter((tab) => filterTabInfo(tab, filterRules)); 34 | 35 | const result: TabGroup[] = types.map((type) => { 36 | return { 37 | type, 38 | tabIds: [], 39 | }; 40 | }); 41 | 42 | await Promise.all( 43 | tabInfoList.map(async (tabInfo) => { 44 | if (!tabInfo.url) return; 45 | const type = await fetchType(apiKey, tabInfo, types); 46 | const index = types.indexOf(type); 47 | if (index === -1) return; 48 | result[index].tabIds.push(tabInfo.id); 49 | }) 50 | ); 51 | return result; 52 | } 53 | 54 | export async function handleOneTab( 55 | tab: chrome.tabs.Tab, 56 | types: string[], 57 | apiKey: string 58 | ) { 59 | const tabInfo: TabInfo = { id: tab.id, title: tab.title, url: tab.url }; 60 | const filterRules = (await getStorage("filterRules")) || []; 61 | const shouldFilter = !filterTabInfo(tabInfo, filterRules); 62 | if (shouldFilter) return; 63 | 64 | const type = await fetchType(apiKey, tabInfo, types); 65 | return type; 66 | } 67 | 68 | // TODO merge this to service-provider 69 | /** 70 | * This function will show a toast! 71 | */ 72 | export const validateApiKey = async ( 73 | apiKey: string, 74 | serviceProvider: ServiceProvider 75 | ) => { 76 | try { 77 | if (serviceProvider === "Gemini") { 78 | const response = await fetch( 79 | "https://generativelanguage.googleapis.com/v1beta3/models/text-bison-001:generateText?key=" + 80 | apiKey, 81 | { 82 | method: "POST", 83 | headers: { 84 | "Content-Type": "application/json", 85 | }, 86 | body: JSON.stringify({ 87 | prompt: { text: "This is a test" }, 88 | }), 89 | } 90 | ); 91 | if (response.ok) { 92 | return true; 93 | } else { 94 | const txt = await response.text(); 95 | toast.error("Invalid Gemini Key: " + response.status + " " + txt); 96 | return false; 97 | } 98 | } else { 99 | const apiURL = 100 | (await getStorage("apiURL")) || 101 | "https://api.openai.com/v1/chat/completions"; 102 | const model = (await getStorage("model")) || "gpt-3.5-turbo"; 103 | 104 | // https://platform.openai.com/docs/api-reference/chat/create 105 | const response = await fetch(apiURL, { 106 | method: "POST", 107 | headers: { 108 | "Content-Type": "application/json", 109 | Authorization: `Bearer ${apiKey}`, 110 | }, 111 | body: JSON.stringify({ 112 | model, 113 | messages: [ 114 | { 115 | role: "system", 116 | content: "ping", 117 | }, 118 | ], 119 | max_tokens: 1, 120 | temperature: 0.5, 121 | top_p: 1, 122 | frequency_penalty: 0, 123 | presence_penalty: 0, 124 | stop: ["\n"], 125 | }), 126 | }); 127 | if (response.ok) { 128 | toast.success("Valid OpenAI Key"); 129 | return true; 130 | } else { 131 | const txt = await response.text(); 132 | toast.error("Invalid OpenAI Key: " + response.status + " " + txt); 133 | return false; 134 | } 135 | } 136 | } catch (error) { 137 | console.error(error); 138 | if (error instanceof Error) { 139 | toast.error("Invalid OpenAI Key: " + error.message); 140 | } else { 141 | toast.error("Invalid OpenAI Key"); 142 | } 143 | return false; 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { Color, DEFAULT_GROUP, DEFAULT_PROMPT } from "./const"; 2 | import { handleOneTab } from "./services"; 3 | import { 4 | getRootDomain, 5 | getStorage, 6 | setStorage, 7 | tabGroupMap, 8 | createdManualType, 9 | updatedManualType, 10 | curryFilterManualGroups, 11 | } from "./utils"; 12 | 13 | chrome.runtime.onInstalled.addListener((details) => { 14 | if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) { 15 | setStorage("isOn", true); 16 | setStorage("isAutoPosition", false); 17 | setStorage("types", DEFAULT_GROUP); 18 | setStorage("prompt", DEFAULT_PROMPT); 19 | } 20 | }); 21 | 22 | let types: string[] = []; 23 | let colors: Color[] = []; 24 | 25 | chrome.storage.local.get("types", (result) => { 26 | if (result.types) { 27 | types = result.types; 28 | } 29 | }); 30 | chrome.storage.local.get("colors", (result) => { 31 | if (result.colors) { 32 | colors = result.colors; 33 | } 34 | }); 35 | 36 | const windowGroupMaps: { [key: number]: Map } = {}; 37 | 38 | // tab map: { tabId: tabInformation } 39 | const tabMap: { [key: number]: chrome.tabs.Tab } = {}; 40 | 41 | chrome.runtime.onMessage.addListener((message) => { 42 | chrome.storage.local.get("types", async (resultStorage) => { 43 | chrome.storage.local.get("colors", async (resultColors) => { 44 | if (resultStorage.types) { 45 | types = resultStorage.types; 46 | if (resultColors.colors) colors = resultColors.colors; 47 | const result = message.result; 48 | 49 | const filterTabs = await curryFilterManualGroups(); 50 | 51 | types.forEach((_, i) => { 52 | // Check if result[i] exists before accessing the 'type' property 53 | if (result[i]) { 54 | groupOneType( 55 | result[i].type, 56 | result[i].tabIds.filter(filterTabs), 57 | colors[i] 58 | ); 59 | result[i].tabIds.forEach((tabId: number) => { 60 | if (tabId) { 61 | chrome.tabs.get(tabId, (tab) => { 62 | tabMap[tabId] = tab; 63 | }); 64 | } 65 | }); 66 | } else { 67 | // Handle the case where there is no corresponding entry in result for this type 68 | console.error(`No corresponding result for type index ${i}`); 69 | } 70 | }); 71 | } 72 | }); 73 | }); 74 | }); 75 | 76 | chrome.tabGroups.onUpdated.addListener(async (group) => { 77 | if (!windowGroupMaps.hasOwnProperty(group.windowId)) { 78 | windowGroupMaps[group.windowId] = new Map(); 79 | } 80 | if (group.title) { 81 | windowGroupMaps[group.windowId].set(group.title, group.id); 82 | } 83 | 84 | const types = await getStorage("types"); 85 | // 更新types中的群组条目 86 | if (types && types.length > 0) { 87 | if (!tabGroupMap[group.id]) { 88 | createdManualType(types, group); 89 | } else { 90 | updatedManualType(types, group); 91 | } 92 | } 93 | }); 94 | 95 | async function groupOneType(type: string, tabIds: number[], color: Color) { 96 | const windowIdMap: { [key: number]: number[] } = {}; 97 | 98 | const getTab = (tabId: number) => 99 | new Promise((resolve) => { 100 | chrome.tabs.get(tabId, (tab) => { 101 | if (!windowIdMap[tab.windowId]) { 102 | windowIdMap[tab.windowId] = [tabId]; 103 | } else { 104 | windowIdMap[tab.windowId].push(tabId); 105 | } 106 | resolve(tab); 107 | }); 108 | }); 109 | 110 | await Promise.all(tabIds.map((tabId) => getTab(tabId))); 111 | 112 | for (const windowId in windowIdMap) { 113 | const tabsForWindow = windowIdMap[windowId]; 114 | chrome.tabs.group( 115 | { 116 | tabIds: tabsForWindow, 117 | createProperties: { 118 | windowId: parseInt(windowId), 119 | }, 120 | }, 121 | async (groupId) => { 122 | if (groupId) { 123 | await chrome.tabGroups.update(groupId, { title: type, color }); 124 | } else { 125 | throw new Error( 126 | `Failed to create group for tabs ${JSON.stringify( 127 | tabsForWindow 128 | )} in window ${windowId}` 129 | ); 130 | } 131 | } 132 | ); 133 | } 134 | } 135 | 136 | async function createGroupWithTitle(tabId: number, title: string) { 137 | try { 138 | chrome.tabs.get(tabId, async (tab) => { 139 | if (tab.windowId) { 140 | const groupId = await chrome.tabs.group({ tabIds: [tabId] }); 141 | if (groupId) { 142 | await chrome.tabGroups.update(groupId, { title }); 143 | windowGroupMaps[tab.windowId].set(title, groupId); 144 | } else { 145 | throw new Error( 146 | `Failed to create group for tabs ${tabId} in window ${tab.windowId}` 147 | ); 148 | } 149 | } 150 | }); 151 | } catch (error) { 152 | console.error("Error creating tab group:", error); 153 | throw error; 154 | } 155 | } 156 | 157 | async function processTabAndGroup(tab: chrome.tabs.Tab, types: any) { 158 | if (!tab.id || !tab.windowId) { 159 | throw new Error("Tab ID or WindowID is undefined!"); 160 | } 161 | const openAIKey = await getStorage("openai_key"); 162 | if (!openAIKey) return; 163 | 164 | const type = await handleOneTab(tab, types, openAIKey); 165 | if (!type) return; 166 | // Get or create proper tabMap for the window 167 | if (!windowGroupMaps.hasOwnProperty(tab.windowId)) { 168 | windowGroupMaps[tab.windowId] = new Map(); 169 | } 170 | const tabMap = windowGroupMaps[tab.windowId]; 171 | 172 | // Query all groups and update tabMap accordingly 173 | const allGroups = await chrome.tabGroups.query({}); 174 | allGroups.forEach( 175 | (group) => 176 | group.windowId === tab.windowId && 177 | group.title && 178 | tabMap.set(group.title, group.id) 179 | ); 180 | 181 | // Check if a group already exists for this type 182 | const groupId = tabMap.get(type); 183 | 184 | // If groupId is not undefined, it means a group with that type already exists 185 | if (groupId !== undefined) { 186 | // Existing group is valid, add tab to this group. 187 | await chrome.tabs.group({ tabIds: tab.id, groupId }); 188 | 189 | const isAutoPosition = await getStorage("isAutoPosition"); 190 | 191 | const currentWindowTabs = await chrome.tabs.query({ 192 | windowId: tab.windowId, 193 | }); 194 | const isRightmost = 195 | tab.index == Math.max(...currentWindowTabs.map((tab) => tab.index)); 196 | if (isAutoPosition && isRightmost) { 197 | await chrome.tabGroups.move(groupId, { index: -1 }); 198 | } 199 | } else { 200 | // If no valid group is found, create a new group for this type 201 | await createGroupWithTitle(tab.id, type); 202 | } 203 | } 204 | 205 | async function handleNewTab(tab: chrome.tabs.Tab) { 206 | const enable = await getStorage("isOn"); 207 | const window = await chrome.windows.get(tab.windowId); 208 | if ( 209 | !enable || 210 | !tab.id || 211 | !tab.url || 212 | window.type != "normal" || 213 | !types.length || 214 | tab.url?.startsWith("chrome") || 215 | tab.url.startsWith("about") 216 | ) { 217 | return; 218 | } 219 | 220 | tabMap[tab.id] = tab; 221 | 222 | await processTabAndGroup(tab, types); 223 | } 224 | 225 | async function handleTabUpdate( 226 | _tabId: number, 227 | changeInfo: chrome.tabs.TabChangeInfo, 228 | tab: chrome.tabs.Tab 229 | ) { 230 | const enable = await getStorage("isOn"); 231 | const window = await chrome.windows.get(tab.windowId); 232 | 233 | if ( 234 | !enable || 235 | !tab.id || 236 | !tab.url || 237 | tab.url.startsWith("chrome") || 238 | tab.url.startsWith("about") 239 | ) 240 | return; 241 | 242 | const oldTab = tabMap[tab.id]; 243 | if ( 244 | oldTab && 245 | oldTab.url && 246 | getRootDomain(new URL(oldTab.url)) === getRootDomain(new URL(tab.url)) 247 | ) { 248 | return; 249 | } 250 | 251 | if (window.type != "normal" || changeInfo.status !== "complete") { 252 | return; 253 | } 254 | 255 | tabMap[tab.id] = tab; 256 | 257 | await processTabAndGroup(tab, types); 258 | } 259 | 260 | chrome.tabs.onCreated.addListener(handleNewTab); 261 | chrome.tabs.onUpdated.addListener(handleTabUpdate); 262 | chrome.tabs.onDetached.addListener((_tabId, detachInfo) => { 263 | const windowId = detachInfo.oldWindowId; 264 | if ( 265 | windowGroupMaps.hasOwnProperty(windowId) && 266 | !chrome.tabs.query({ windowId }) 267 | ) { 268 | delete windowGroupMaps[windowId]; 269 | } 270 | }); 271 | chrome.tabs.onRemoved.addListener((tabId) => { 272 | delete tabMap[tabId]; 273 | }); 274 | -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useCallback, useEffect, useState } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./options.css"; 4 | import { getStorage, setStorage } from "./utils"; 5 | import Switch from "./components/Switch"; 6 | import FilterRules from "./components/FilterRules"; 7 | import { FilterRuleItem, ServiceProvider } from "./types"; 8 | import { DEFAULT_PROMPT } from "./const"; 9 | 10 | const TABS = [ 11 | "Basic Settings", 12 | "Prompt Settings", 13 | "Style Settings", 14 | "Feature Flags", 15 | ]; 16 | 17 | function BasicSettings() { 18 | const [model, setModel] = useState("gpt-3.5-turbo"); 19 | const [serviceProvider, setServiceProvider] = useState( 20 | "GPT" 21 | ); 22 | const [apiURL, setApiURL] = useState( 23 | "https://api.openai.com/v1/chat/completions" 24 | ); 25 | const [filterRules, setFilterRules] = useState([ 26 | { id: 0, type: "DOMAIN", rule: "" }, 27 | ]); 28 | useEffect(() => { 29 | getStorage("model").then(setModel); 30 | getStorage("serviceProvider").then((value) => { 31 | if (value) { 32 | setServiceProvider(value); 33 | } 34 | }); 35 | getStorage("apiURL").then(setApiURL); 36 | getStorage("filterRules").then(setFilterRules); 37 | }, []); 38 | 39 | const updateModel = useCallback((e: ChangeEvent) => { 40 | setModel(e.target.value); 41 | setStorage("model", e.target.value); 42 | }, []); 43 | 44 | const updateServiceProvider = useCallback( 45 | (e: ChangeEvent) => { 46 | setServiceProvider(e.target.value as ServiceProvider); 47 | setStorage("serviceProvider", e.target.value); 48 | }, 49 | [] 50 | ); 51 | 52 | const updateApiURL = useCallback((e: ChangeEvent) => { 53 | setApiURL(e.target.value); 54 | setStorage("apiURL", e.target.value); 55 | }, []); 56 | 57 | const updateFilterRules = useCallback((rules: FilterRuleItem[]) => { 58 | setFilterRules(rules); 59 | setStorage("filterRules", rules); 60 | }, []); 61 | 62 | return ( 63 |
64 |
65 | 68 | 69 | 79 |
80 | 81 | {serviceProvider === "GPT" && ( 82 | <> 83 |
84 | 87 | 88 | 101 |
102 | 103 |
104 | 107 | 108 | 115 |
116 | 117 | )} 118 |
119 | 122 | 123 |
124 | 129 |
130 | 131 | 135 |
136 |
137 | ); 138 | } 139 | 140 | function PromptSettings() { 141 | const [prompt, setPrompt] = useState(DEFAULT_PROMPT); 142 | const [isPromptValid, setIsPromptValid] = useState(true); 143 | 144 | const promptFormatWarning: string = `{{tabURL}} {{tabTitle}} {{types}} must be in the prompt`; 145 | 146 | useEffect(() => { 147 | getStorage("prompt").then(setPrompt); 148 | }, []); 149 | 150 | const updatePrompt = useCallback((e: ChangeEvent) => { 151 | const newPrompt: string = e.target.value; 152 | setIsPromptValid( 153 | /{{tabURL}}/.test(newPrompt) && 154 | /{{tabTitle}}/.test(newPrompt) && 155 | /{{types}}/.test(newPrompt) 156 | ); 157 | if (isPromptValid) { 158 | setPrompt(newPrompt); 159 | setStorage("prompt", newPrompt); 160 | } 161 | }, []); 162 | 163 | return ( 164 |
165 |
166 | 169 | {isPromptValid && ( 170 | 176 | )} 177 | 178 | {!isPromptValid && ( 179 |
183 | {promptFormatWarning} 184 |
185 | )} 186 | 187 |