├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.ts ├── bun.lock ├── package.json ├── src ├── background │ └── index.ts ├── content │ ├── components │ │ └── SummaryButton.tsx │ ├── index.ts │ └── utils │ │ ├── ai.ts │ │ └── subtitles.ts ├── popup │ ├── Popup.css │ ├── Popup.tsx │ └── index.tsx ├── shared │ ├── components │ │ ├── ChatPanel.tsx │ │ ├── ModelSelect.tsx │ │ └── SettingsPanel.tsx │ ├── config.ts │ └── utils │ │ ├── googleAI.ts │ │ └── proxy.ts └── static │ ├── manifest │ └── manifest.json │ ├── popup │ ├── index.html │ └── styles.css │ └── styles │ └── styles.css └── tsconfig.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Extension 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Bun 19 | uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | 23 | - name: Install dependencies 24 | run: bun install 25 | 26 | - name: Build extension 27 | run: | 28 | mkdir -p dist 29 | bun run build 30 | ls -la dist || echo "dist directory not found" 31 | 32 | - name: Copy public files 33 | run: | 34 | mkdir -p dist 35 | cp -r public/* dist/ 36 | ls -la dist 37 | 38 | - name: Create unpacked extension ZIP 39 | run: | 40 | cd dist 41 | zip -r ../extension-unpacked.zip * 42 | cd .. 43 | 44 | - name: Create Release 45 | id: create_release 46 | uses: softprops/action-gh-release@v1 47 | with: 48 | files: | 49 | extension-unpacked.zip 50 | body: | 51 | ## Quick Installation 52 | 1. Download `extension-unpacked.zip` 53 | 2. Open Chrome and go to `chrome://extensions` 54 | 3. Enable "Developer mode" in the top right 55 | 4. Drag and drop `extension-unpacked.zip` directly onto the extensions page 56 | draft: false 57 | prerelease: false 58 | generate_release_notes: true 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | # Dependencies 178 | node_modules/ 179 | .env 180 | 181 | # Build output 182 | public/ 183 | dist/ 184 | build/ 185 | 186 | # IDE and editor files 187 | .idea/ 188 | .vscode/ 189 | *.swp 190 | *.swo 191 | .DS_Store 192 | 193 | # Logs 194 | *.log 195 | npm-debug.log* 196 | yarn-debug.log* 197 | yarn-error.log* 198 | 199 | # Bun 200 | bun.lockb 201 | 202 | 203 | *.pem -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 avarayr 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YouTube Summary Extension 2 | 3 | 4 | 5 | https://github.com/user-attachments/assets/670678a7-4ce2-4f6d-9f25-a4421309071e 6 | 7 | 8 | 9 | 10 | A Chrome extension that generates concise summaries of YouTube videos using AI. Supports multiple AI providers including OpenAI, Google AI, and OpenRouter. 11 | 12 | ## Features 13 | 14 | - Generate summaries of YouTube videos 15 | - Chat with AI about the video content 16 | - Support for multiple AI providers 17 | - Real-time streaming responses 18 | - Dark mode interface 19 | 20 | ## Quick Installation 21 | 22 | 1. Go to the [Releases](../../releases) page 23 | 2. Download `extension-unpacked.zip` 24 | 3. Open Chrome and go to `chrome://extensions` 25 | 4. Enable "Developer mode" in the top right 26 | 5. Drag and drop `extension-unpacked.zip` directly onto the extensions page 27 | 28 | That's it! No need to extract the ZIP or click any buttons. 29 | 30 | ## Configuration 31 | 32 | 1. Click the extension icon in Chrome 33 | 2. Select your preferred AI provider 34 | 3. Enter your API key 35 | 4. Optional: Select a specific model from the available options 36 | 37 | ## Usage 38 | 39 | 1. Navigate to any YouTube video 40 | 2. Click the "Summarize" button below the video 41 | 3. Wait for the AI to generate a summary 42 | 4. Use the chat feature to ask questions about the video 43 | 44 | ## Development 45 | 46 | ```bash 47 | # Install dependencies 48 | bun install 49 | 50 | # Watch for changes during development 51 | bun run dev 52 | 53 | # Build for production 54 | bun run build 55 | ``` 56 | 57 | Then, click "Load unpacked" in chrome://extensions and select the `public` folder 58 | 59 | When rebuilding, you can click on the "Refresh" icon to see changes propagate to the extension. 60 | 61 | ## Contributing 62 | 63 | Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change. 64 | 65 | ## License 66 | 67 | [MIT](LICENSE) 68 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import { build } from "bun"; 2 | import { copyFile, mkdir } from "fs/promises"; 3 | import { join } from "path"; 4 | import sharp from "sharp"; 5 | 6 | const isDev = process.argv.includes("--watch"); 7 | 8 | // Create a simple SVG icon 9 | const createSVG = (size: number) => ` 10 | 11 | 12 | S 15 | `; 16 | 17 | async function createIcons() { 18 | // Create icons directory 19 | const iconsDir = join("public", "icons"); 20 | await mkdir(iconsDir, { recursive: true }); 21 | 22 | // Create icons of different sizes 23 | const sizes = [16, 48, 128]; 24 | 25 | for (const size of sizes) { 26 | const svg = createSVG(size); 27 | await sharp(Buffer.from(svg)) 28 | .png() 29 | .toFile(join(iconsDir, `icon${size}.png`)); 30 | } 31 | } 32 | 33 | export async function copyStaticFiles() { 34 | // Ensure directories exist 35 | await mkdir("public/content", { recursive: true }); 36 | await mkdir("public/popup", { recursive: true }); 37 | 38 | // Copy static files 39 | await copyFile("src/static/styles/styles.css", "public/content/styles.css"); 40 | await copyFile("src/static/manifest/manifest.json", "public/manifest.json"); 41 | await copyFile("src/static/popup/index.html", "public/popup/index.html"); 42 | await copyFile("src/static/popup/styles.css", "public/popup/styles.css"); 43 | } 44 | 45 | async function buildExtension() { 46 | try { 47 | // Create extension icons 48 | await createIcons(); 49 | 50 | // Build content script 51 | await build({ 52 | entrypoints: ["./src/content/index.ts"], 53 | outdir: "./public/content", 54 | minify: !isDev, 55 | define: { 56 | "import.meta.env.DEV": JSON.stringify(isDev), 57 | }, 58 | }); 59 | 60 | // Build background script 61 | await build({ 62 | entrypoints: ["./src/background/index.ts"], 63 | outdir: "./public/background", 64 | minify: !isDev, 65 | define: { 66 | "import.meta.env.DEV": JSON.stringify(isDev), 67 | }, 68 | }); 69 | 70 | // Build popup 71 | await build({ 72 | entrypoints: ["./src/popup/index.tsx"], 73 | outdir: "./public/popup", 74 | minify: !isDev, 75 | define: { 76 | "import.meta.env.DEV": JSON.stringify(isDev), 77 | }, 78 | }); 79 | 80 | // Copy static files 81 | await copyStaticFiles(); 82 | 83 | console.log("Build completed successfully!"); 84 | } catch (error) { 85 | console.error("Build failed:", error); 86 | process.exit(1); 87 | } 88 | } 89 | 90 | buildExtension(); 91 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "yt-summary-extension", 6 | "dependencies": { 7 | "@google/generative-ai": "^0.21.0", 8 | "@types/chrome": "^0.0.260", 9 | "@types/dompurify": "^3.0.5", 10 | "@types/marked": "^5.0.2", 11 | "@types/react": "^18.2.55", 12 | "@types/react-dom": "^18.2.19", 13 | "autoprefixer": "^10.4.17", 14 | "dompurify": "^3.0.9", 15 | "marked": "^12.0.0", 16 | "postcss": "^8.4.35", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "tailwindcss": "^3.4.1", 20 | "typescript": "^5.0.0", 21 | }, 22 | "devDependencies": { 23 | "@types/bun": "latest", 24 | "sharp": "^0.33.2", 25 | }, 26 | }, 27 | }, 28 | "packages": { 29 | "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], 30 | 31 | "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], 32 | 33 | "@google/generative-ai": ["@google/generative-ai@0.21.0", "", {}, "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg=="], 34 | 35 | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], 36 | 37 | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], 38 | 39 | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], 40 | 41 | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], 42 | 43 | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], 44 | 45 | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], 46 | 47 | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], 48 | 49 | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], 50 | 51 | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], 52 | 53 | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], 54 | 55 | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], 56 | 57 | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], 58 | 59 | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], 60 | 61 | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], 62 | 63 | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], 64 | 65 | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], 66 | 67 | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], 68 | 69 | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], 70 | 71 | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], 72 | 73 | "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], 74 | 75 | "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], 76 | 77 | "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 78 | 79 | "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], 80 | 81 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], 82 | 83 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], 84 | 85 | "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 86 | 87 | "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], 88 | 89 | "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], 90 | 91 | "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], 92 | 93 | "@types/bun": ["@types/bun@1.2.10", "", { "dependencies": { "bun-types": "1.2.10" } }, "sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg=="], 94 | 95 | "@types/chrome": ["@types/chrome@0.0.260", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-lX6QpgfsZRTDpNcCJ+3vzfFnFXq9bScFRTlfhbK5oecSAjamsno+ejFTCbNtc5O/TPnVK9Tja/PyecvWQe0F2w=="], 96 | 97 | "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], 98 | 99 | "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], 100 | 101 | "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], 102 | 103 | "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], 104 | 105 | "@types/marked": ["@types/marked@5.0.2", "", {}, "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg=="], 106 | 107 | "@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], 108 | 109 | "@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="], 110 | 111 | "@types/react": ["@types/react@18.3.20", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg=="], 112 | 113 | "@types/react-dom": ["@types/react-dom@18.3.6", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw=="], 114 | 115 | "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], 116 | 117 | "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], 118 | 119 | "ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], 120 | 121 | "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], 122 | 123 | "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], 124 | 125 | "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], 126 | 127 | "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], 128 | 129 | "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 130 | 131 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], 132 | 133 | "brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], 134 | 135 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 136 | 137 | "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], 138 | 139 | "bun-types": ["bun-types@1.2.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ=="], 140 | 141 | "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], 142 | 143 | "caniuse-lite": ["caniuse-lite@1.0.30001715", "", {}, "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw=="], 144 | 145 | "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], 146 | 147 | "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 148 | 149 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 150 | 151 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 152 | 153 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 154 | 155 | "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], 156 | 157 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 158 | 159 | "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], 160 | 161 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 162 | 163 | "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], 164 | 165 | "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], 166 | 167 | "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], 168 | 169 | "dompurify": ["dompurify@3.2.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ=="], 170 | 171 | "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], 172 | 173 | "electron-to-chromium": ["electron-to-chromium@1.5.140", "", {}, "sha512-o82Rj+ONp4Ip7Cl1r7lrqx/pXhbp/lh9DpKcMNscFJdh8ebyRofnc7Sh01B4jx403RI0oqTBvlZ7OBIZLMr2+Q=="], 174 | 175 | "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], 176 | 177 | "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 178 | 179 | "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], 180 | 181 | "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], 182 | 183 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 184 | 185 | "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], 186 | 187 | "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], 188 | 189 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 190 | 191 | "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 192 | 193 | "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], 194 | 195 | "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], 196 | 197 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 198 | 199 | "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], 200 | 201 | "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], 202 | 203 | "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 204 | 205 | "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 206 | 207 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 208 | 209 | "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 210 | 211 | "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 212 | 213 | "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 214 | 215 | "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], 216 | 217 | "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], 218 | 219 | "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 220 | 221 | "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], 222 | 223 | "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], 224 | 225 | "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], 226 | 227 | "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 228 | 229 | "marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], 230 | 231 | "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], 232 | 233 | "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], 234 | 235 | "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 236 | 237 | "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 238 | 239 | "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], 240 | 241 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 242 | 243 | "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], 244 | 245 | "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], 246 | 247 | "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], 248 | 249 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], 250 | 251 | "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], 252 | 253 | "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], 254 | 255 | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 256 | 257 | "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 258 | 259 | "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], 260 | 261 | "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 262 | 263 | "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 264 | 265 | "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], 266 | 267 | "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], 268 | 269 | "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], 270 | 271 | "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], 272 | 273 | "postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="], 274 | 275 | "postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="], 276 | 277 | "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], 278 | 279 | "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], 280 | 281 | "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 282 | 283 | "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], 284 | 285 | "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], 286 | 287 | "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], 288 | 289 | "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], 290 | 291 | "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], 292 | 293 | "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], 294 | 295 | "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], 296 | 297 | "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 298 | 299 | "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], 300 | 301 | "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], 302 | 303 | "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], 304 | 305 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 306 | 307 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 308 | 309 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], 310 | 311 | "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 312 | 313 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 314 | 315 | "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], 316 | 317 | "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 318 | 319 | "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], 320 | 321 | "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 322 | 323 | "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], 324 | 325 | "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 326 | 327 | "tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], 328 | 329 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], 330 | 331 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], 332 | 333 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 334 | 335 | "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], 336 | 337 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 338 | 339 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 340 | 341 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 342 | 343 | "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], 344 | 345 | "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 346 | 347 | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 348 | 349 | "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], 350 | 351 | "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 352 | 353 | "yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], 354 | 355 | "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 356 | 357 | "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 358 | 359 | "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 360 | 361 | "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 362 | 363 | "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 364 | 365 | "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 366 | 367 | "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], 368 | 369 | "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 370 | 371 | "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 372 | 373 | "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 374 | 375 | "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yt-summary-extension", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "bun run build.ts --watch", 7 | "build": "bun run build.ts" 8 | }, 9 | "dependencies": { 10 | "@google/generative-ai": "^0.21.0", 11 | "@types/chrome": "^0.0.260", 12 | "@types/dompurify": "^3.0.5", 13 | "@types/marked": "^5.0.2", 14 | "@types/react": "^18.2.55", 15 | "@types/react-dom": "^18.2.19", 16 | "autoprefixer": "^10.4.17", 17 | "dompurify": "^3.0.9", 18 | "marked": "^12.0.0", 19 | "postcss": "^8.4.35", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "tailwindcss": "^3.4.1", 23 | "typescript": "^5.0.0" 24 | }, 25 | "devDependencies": { 26 | "@types/bun": "latest", 27 | "sharp": "^0.33.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | // Listen for install and update events 2 | chrome.runtime.onInstalled.addListener(() => { 3 | console.log("YouTube Summary Extension installed"); 4 | }); 5 | 6 | // Handle regular (non-streaming) messages 7 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 8 | if (request.type === "GET_SETTINGS") { 9 | chrome.storage.sync.get(["apiKey", "selectedProvider"], (result) => { 10 | sendResponse(result); 11 | }); 12 | return true; // Will respond asynchronously 13 | } 14 | 15 | if (request.type === "PROXY_REQUEST") { 16 | const { url, options } = request; 17 | console.log("Proxying request to:", url); 18 | 19 | // Check if this is a streaming request 20 | const isStreaming = 21 | options?.body && JSON.parse(options.body).stream === true; 22 | 23 | if (isStreaming) { 24 | // For streaming requests, tell the content script to open a port 25 | sendResponse({ type: "USE_PORT" }); 26 | return true; 27 | } 28 | 29 | // Handle regular (non-streaming) requests 30 | fetch(url, options) 31 | .then(async (response) => { 32 | const data = await response.json(); 33 | sendResponse({ ok: response.ok, data }); 34 | }) 35 | .catch((error) => { 36 | console.error("Proxy request failed:", error); 37 | sendResponse({ ok: false, error: error.message }); 38 | }); 39 | 40 | return true; // Will respond asynchronously 41 | } 42 | }); 43 | 44 | // Handle streaming connections 45 | chrome.runtime.onConnect.addListener((port) => { 46 | if (port.name === "proxy-stream") { 47 | port.onMessage.addListener(async (request) => { 48 | const { url, options } = request; 49 | 50 | try { 51 | const response = await fetch(url, options); 52 | if (!response.ok) { 53 | // For streaming responses, don't try to parse JSON on error 54 | port.postMessage({ 55 | error: `Request failed with status ${response.status}`, 56 | }); 57 | port.disconnect(); 58 | return; 59 | } 60 | 61 | const reader = response.body?.getReader(); 62 | if (!reader) { 63 | port.postMessage({ error: "No response body" }); 64 | port.disconnect(); 65 | return; 66 | } 67 | 68 | // Stream the response chunks 69 | while (true) { 70 | const { value, done } = await reader.read(); 71 | if (done) break; 72 | 73 | const chunk = new TextDecoder().decode(value); 74 | const lines = chunk 75 | .split("\n") 76 | .filter( 77 | (line) => line.trim() !== "" && line.trim() !== "data: [DONE]" 78 | ); 79 | 80 | for (const line of lines) { 81 | try { 82 | const trimmedLine = line.replace(/^data: /, "").trim(); 83 | if (!trimmedLine) continue; 84 | 85 | const parsed = JSON.parse(trimmedLine); 86 | if (parsed.choices?.[0]?.delta?.content) { 87 | port.postMessage({ chunk: parsed.choices[0].delta.content }); 88 | } 89 | } catch (e) { 90 | console.warn("Failed to parse streaming response line:", e); 91 | } 92 | } 93 | } 94 | 95 | // Signal stream completion 96 | port.postMessage({ done: true }); 97 | port.disconnect(); 98 | } catch (error: any) { 99 | // Type assertion for error 100 | console.error("Streaming request failed:", error); 101 | port.postMessage({ error: error?.message || "Unknown error occurred" }); 102 | port.disconnect(); 103 | } 104 | }); 105 | } 106 | }); 107 | -------------------------------------------------------------------------------- /src/content/components/SummaryButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from "react"; 2 | import { marked } from "marked"; 3 | import DOMPurify from "dompurify"; 4 | import { extractSubtitles } from "../utils/subtitles"; 5 | import { generateSummary } from "../utils/ai"; 6 | import type { AIProvider } from "../../shared/config"; 7 | import { providers } from "../../shared/config"; 8 | import { SettingsPanel } from "../../shared/components/SettingsPanel"; 9 | import { ChatPanel } from "../../shared/components/ChatPanel"; 10 | 11 | export const SummaryButton: React.FC = () => { 12 | const [isLoading, setIsLoading] = useState(false); 13 | const [error, setError] = useState(null); 14 | const [showSettings, setShowSettings] = useState(false); 15 | const [streamedText, setStreamedText] = useState(""); 16 | const [selectedProvider, setSelectedProvider] = useState( 17 | null 18 | ); 19 | const [selectedModel, setSelectedModel] = useState(null); 20 | const [apiKey, setApiKey] = useState(null); 21 | 22 | // Load initial settings on mount 23 | useEffect(() => { 24 | chrome.storage.sync.get( 25 | ["apiKey", "selectedProvider", "selectedModel"], 26 | (result) => { 27 | setApiKey(result.apiKey || null); 28 | setSelectedProvider(result.selectedProvider || providers[0]); 29 | setSelectedModel( 30 | result.selectedModel || 31 | result.selectedProvider?.model || 32 | providers[0].model 33 | ); 34 | } 35 | ); 36 | 37 | // Listen for changes to settings 38 | chrome.storage.onChanged.addListener((changes) => { 39 | if (changes.apiKey) { 40 | setApiKey(changes.apiKey.newValue || null); 41 | } 42 | if (changes.selectedProvider) { 43 | setSelectedProvider(changes.selectedProvider.newValue || providers[0]); 44 | } 45 | if (changes.selectedModel) { 46 | setSelectedModel( 47 | changes.selectedModel.newValue || 48 | changes.selectedProvider?.newValue?.model || 49 | providers[0].model 50 | ); 51 | } 52 | }); 53 | }, []); 54 | 55 | // Reset summary when URL changes 56 | useEffect(() => { 57 | const resetState = () => { 58 | setStreamedText(""); 59 | setError(null); 60 | setIsLoading(false); 61 | setShowSettings(false); 62 | }; 63 | 64 | // Listen for YouTube's navigation events 65 | const handleNavigation = (e: Event) => { 66 | if (e.type === "yt-navigate-finish") { 67 | resetState(); 68 | } 69 | }; 70 | 71 | // YouTube uses a custom event for navigation 72 | document.addEventListener("yt-navigate-finish", handleNavigation); 73 | 74 | return () => { 75 | document.removeEventListener("yt-navigate-finish", handleNavigation); 76 | }; 77 | }, []); 78 | 79 | const handleToken = useCallback((token: string) => { 80 | setStreamedText((prev) => prev + token); 81 | }, []); 82 | 83 | const renderMarkdown = useCallback((text: string) => { 84 | const rawHtml = marked(text) as string; 85 | const cleanHtml = DOMPurify.sanitize(rawHtml); 86 | return
; 87 | }, []); 88 | 89 | const handleSummarize = async () => { 90 | try { 91 | setIsLoading(true); 92 | setError(null); 93 | setStreamedText(""); 94 | 95 | const subtitles = await extractSubtitles(); 96 | if (!subtitles) { 97 | throw new Error("Could not extract subtitles from this video"); 98 | } 99 | 100 | if (!selectedProvider) { 101 | throw new Error("Please select an AI provider in settings"); 102 | } 103 | 104 | if (!selectedProvider.isLocal && !apiKey) { 105 | throw new Error("Please configure your API key in settings"); 106 | } 107 | 108 | const providerWithModel = { 109 | ...selectedProvider, 110 | model: selectedModel || selectedProvider.model, 111 | apiKey: apiKey || "", 112 | }; 113 | 114 | for await (const token of generateSummary(subtitles, providerWithModel)) { 115 | setStreamedText((prev) => prev + token); 116 | } 117 | } catch (error) { 118 | console.error("Error generating summary:", error); 119 | setError( 120 | error instanceof Error ? error.message : "An unknown error occurred" 121 | ); 122 | setStreamedText(""); 123 | } finally { 124 | setIsLoading(false); 125 | } 126 | }; 127 | 128 | if (streamedText) { 129 | return ( 130 |
131 |
132 |

Video Summary

133 | 136 |
137 |
138 | {renderMarkdown(streamedText)} 139 | {selectedProvider && ( 140 | 141 | )} 142 |
143 |
144 | ); 145 | } 146 | 147 | if (showSettings) { 148 | return ( 149 | setShowSettings(false)} 151 | showHeader={true} 152 | onProviderChange={setSelectedProvider} 153 | onModelChange={setSelectedModel} 154 | /> 155 | ); 156 | } 157 | 158 | return ( 159 |
160 | {error ?
{error}
: null} 161 |
162 | 173 | 179 |
180 |
181 | ); 182 | }; 183 | -------------------------------------------------------------------------------- /src/content/index.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { SummaryButton } from "./components/SummaryButton"; 4 | 5 | const BUTTON_CONTAINER_ID = "yt-summary-extension-container"; 6 | 7 | function injectSummaryButton() { 8 | // Check if we're on a YouTube video page 9 | if (!window.location.pathname.includes("/watch")) { 10 | return; 11 | } 12 | 13 | // Wait for the secondary column (recommendations) to load 14 | const secondaryInner = document.getElementById("secondary-inner"); 15 | if (!secondaryInner) { 16 | setTimeout(injectSummaryButton, 1000); 17 | return; 18 | } 19 | 20 | // Check if our container already exists 21 | let container = document.getElementById(BUTTON_CONTAINER_ID); 22 | if (!container) { 23 | container = document.createElement("div"); 24 | container.id = BUTTON_CONTAINER_ID; 25 | secondaryInner.insertBefore(container, secondaryInner.firstChild); 26 | } 27 | 28 | // Create root if it doesn't exist 29 | if (!container.hasAttribute("data-root-initialized")) { 30 | const root = createRoot(container); 31 | container.setAttribute("data-root-initialized", "true"); 32 | root.render(React.createElement(SummaryButton)); 33 | } 34 | } 35 | 36 | // Initial injection 37 | injectSummaryButton(); 38 | 39 | // Listen for YouTube's navigation events 40 | window.addEventListener("yt-navigate-finish", injectSummaryButton); 41 | -------------------------------------------------------------------------------- /src/content/utils/ai.ts: -------------------------------------------------------------------------------- 1 | import type { AIProvider } from "../../shared/config"; 2 | import { proxyFetch, proxyFetchStream } from "../../shared/utils/proxy"; 3 | import { fetchGoogleAIModels } from "../../shared/utils/googleAI"; 4 | 5 | interface Message { 6 | role: "user" | "assistant" | "system"; 7 | content: string; 8 | } 9 | 10 | interface StreamResponse { 11 | ok: boolean; 12 | error?: string; 13 | chunks?: Array<{ 14 | choices?: Array<{ 15 | delta?: { 16 | content?: string; 17 | }; 18 | }>; 19 | }>; 20 | } 21 | 22 | export interface AIModel { 23 | id: string; 24 | name?: string; 25 | } 26 | 27 | interface ModelsResponse { 28 | data?: any[]; 29 | models?: any[]; 30 | } 31 | 32 | async function* streamCompletion( 33 | provider: AIProvider, 34 | messages: Message[] 35 | ): AsyncGenerator { 36 | const { apiKey, selectedModel, customBaseUrl } = 37 | await chrome.storage.sync.get(["apiKey", "selectedModel", "customBaseUrl"]); 38 | 39 | if (!apiKey && !provider.isLocal) { 40 | throw new Error( 41 | "API key not found. Please set your API key in the extension settings." 42 | ); 43 | } 44 | 45 | const requestBody = { 46 | model: selectedModel || provider.model, 47 | messages, 48 | stream: true, 49 | }; 50 | 51 | const headers: Record = { 52 | "Content-Type": "application/json", 53 | }; 54 | 55 | // Only add Authorization header if API key is provided 56 | if (apiKey) { 57 | headers.Authorization = `Bearer ${apiKey}`; 58 | } 59 | 60 | const baseUrl = provider.isLocal 61 | ? customBaseUrl || provider.defaultBaseUrl || provider.baseUrl 62 | : provider.baseUrl; 63 | 64 | try { 65 | for await (const chunk of proxyFetchStream(`${baseUrl}/chat/completions`, { 66 | method: "POST", 67 | headers, 68 | body: JSON.stringify(requestBody), 69 | })) { 70 | yield chunk; 71 | } 72 | } catch (error) { 73 | console.error("Error in streamCompletion:", error); 74 | throw error; 75 | } 76 | } 77 | 78 | export async function* generateSummary( 79 | transcript: string, 80 | provider: AIProvider 81 | ): AsyncGenerator { 82 | const prompt = `Provide a concise summary of this YouTube video transcript using markdown formatting: 83 | 84 | ${transcript} 85 | 86 | Required format: 87 | # TLDR 88 | [2-3 sentence overview] 89 | 90 | # Key Points 91 | [Main content summary formatted as markdown] 92 | 93 | Formatting rules: 94 | - Use bullet points for key points 95 | - Use **bold** for important terms 96 | - Use *italic* for emphasis 97 | - Use > for notable quotes 98 | - Use --- for section breaks 99 | - Use \`code\` for technical terms 100 | - Use [text](link) for any references 101 | 102 | Be direct and concise. Do not use introductory phrases like "Here's a summary" or "Let me summarize".`; 103 | 104 | try { 105 | for await (const token of streamCompletion(provider, [ 106 | { role: "user", content: prompt }, 107 | ])) { 108 | yield token; 109 | } 110 | } catch (error) { 111 | console.error("Error generating summary:", error); 112 | throw error; 113 | } 114 | } 115 | 116 | export async function* askQuestion( 117 | question: string, 118 | summary: string, 119 | provider: AIProvider, 120 | chatHistory: Message[] = [] 121 | ): AsyncGenerator { 122 | const systemMessage: Message = { 123 | role: "system", 124 | content: `You are a friendly and helpful AI assistant discussing a YouTube video. Here is the video's summary for context: 125 | 126 | ${summary} 127 | 128 | If the user asks something that isn't covered in the summary, you can say so while still trying to be helpful based on the context you have. Be direct and concise in your responses.`, 129 | }; 130 | 131 | const userMessage: Message = { role: "user", content: question }; 132 | const messages = [systemMessage, ...chatHistory, userMessage]; 133 | 134 | try { 135 | for await (const token of streamCompletion(provider, messages)) { 136 | yield token; 137 | } 138 | } catch (error) { 139 | console.error("Error answering question:", error); 140 | throw error; 141 | } 142 | } 143 | 144 | export async function fetchModels( 145 | provider: AIProvider, 146 | apiKey: string | null, 147 | customBaseUrl?: string 148 | ): Promise { 149 | if (!provider.supportsModelList) { 150 | return []; 151 | } 152 | 153 | if (provider.name === "Google AI") { 154 | return fetchGoogleAIModels(apiKey || ""); 155 | } 156 | 157 | const baseUrl = provider.isLocal 158 | ? customBaseUrl || provider.defaultBaseUrl || provider.baseUrl 159 | : provider.baseUrl; 160 | 161 | const headers: Record = { 162 | "Content-Type": "application/json", 163 | }; 164 | 165 | // Only add Authorization header if API key is provided 166 | if (apiKey) { 167 | headers.Authorization = `Bearer ${apiKey}`; 168 | } 169 | 170 | try { 171 | const response = (await proxyFetch(`${baseUrl}/models`, { 172 | headers, 173 | })) as ModelsResponse; 174 | const models = response.data || response.models || []; 175 | return models.map((m: any) => ({ 176 | id: m.id, 177 | name: m.name || m.id, 178 | })); 179 | } catch (error) { 180 | console.error("Error fetching models:", error); 181 | throw error; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/content/utils/subtitles.ts: -------------------------------------------------------------------------------- 1 | interface SubtitleTrack { 2 | baseUrl: string; 3 | name: string; 4 | languageCode: string; 5 | isTranslatable: boolean; 6 | } 7 | 8 | export async function extractSubtitles(): Promise { 9 | try { 10 | // Get video ID from URL 11 | const urlParams = new URLSearchParams(window.location.search); 12 | const videoId = urlParams.get("v"); 13 | if (!videoId) { 14 | throw new Error("Could not find video ID"); 15 | } 16 | 17 | // First, try to get subtitles from YouTube's API 18 | const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`); 19 | const html = await response.text(); 20 | 21 | // Extract the ytInitialPlayerResponse from the page 22 | const playerResponseMatch = html.match( 23 | /ytInitialPlayerResponse\s*=\s*({.+?});/ 24 | ); 25 | if (!playerResponseMatch) { 26 | throw new Error("Could not find player response"); 27 | } 28 | 29 | const playerResponse = JSON.parse(playerResponseMatch[1]); 30 | const captions = playerResponse?.captions?.playerCaptionsTracklistRenderer; 31 | 32 | if (!captions) { 33 | throw new Error("No captions available"); 34 | } 35 | 36 | // Find English subtitles or auto-generated English subtitles 37 | const captionTracks: SubtitleTrack[] = captions.captionTracks || []; 38 | const englishTrack = captionTracks.find( 39 | (track) => track.languageCode === "en" || track.languageCode === "a.en" 40 | ); 41 | 42 | if (!englishTrack) { 43 | throw new Error("No English subtitles available"); 44 | } 45 | 46 | // Fetch the actual subtitle content 47 | const subtitleResponse = await fetch(englishTrack.baseUrl); 48 | const subtitleXml = await subtitleResponse.text(); 49 | 50 | // Parse the XML and extract text 51 | const parser = new DOMParser(); 52 | const xmlDoc = parser.parseFromString(subtitleXml, "text/xml"); 53 | const textNodes = xmlDoc.getElementsByTagName("text"); 54 | 55 | // Combine all subtitle text 56 | let fullTranscript = ""; 57 | for (let i = 0; i < textNodes.length; i++) { 58 | const text = textNodes[i].textContent; 59 | if (text) { 60 | fullTranscript += text + " "; 61 | } 62 | } 63 | 64 | return fullTranscript.trim(); 65 | } catch (error) { 66 | console.error("Error extracting subtitles:", error); 67 | return null; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/popup/Popup.css: -------------------------------------------------------------------------------- 1 | .popup-container { 2 | width: 320px; 3 | padding: 16px; 4 | background-color: #1f2937; 5 | color: #f3f4f6; 6 | } 7 | 8 | h2 { 9 | font-size: 20px; 10 | font-weight: 600; 11 | margin: 0 0 16px; 12 | color: #f3f4f6; 13 | } 14 | 15 | .settings-section { 16 | margin-bottom: 20px; 17 | } 18 | 19 | .settings-section:last-child { 20 | margin-bottom: 0; 21 | } 22 | 23 | .settings-section label { 24 | display: block; 25 | margin-bottom: 8px; 26 | font-weight: 500; 27 | color: #d1d5db; 28 | font-size: 14px; 29 | } 30 | 31 | .api-key-input { 32 | width: 100%; 33 | padding: 10px 12px; 34 | border: 1px solid #2c2d30; 35 | border-radius: 8px; 36 | font-size: 14px; 37 | background-color: #2c2d30; 38 | color: #f3f4f6; 39 | transition: all 0.2s ease; 40 | margin-bottom: 16px; 41 | font-family: "Menlo", "Monaco", "Courier New", monospace; 42 | box-sizing: border-box; 43 | } 44 | 45 | .api-key-input:focus { 46 | outline: none; 47 | border-color: #ef4444; 48 | box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); 49 | } 50 | 51 | .api-key-input::placeholder { 52 | color: #6b7280; 53 | } 54 | 55 | .provider-option { 56 | width: 100%; 57 | box-sizing: border-box; 58 | padding: 12px 16px; 59 | border-radius: 8px; 60 | cursor: pointer; 61 | transition: all 0.2s ease; 62 | color: #d1d5db; 63 | background-color: #2c2d30; 64 | border: 1px solid transparent; 65 | margin-bottom: 12px; 66 | } 67 | 68 | .provider-option:last-child { 69 | margin-bottom: 0; 70 | } 71 | 72 | .provider-option:hover { 73 | background-color: #374151; 74 | transform: translateX(4px); 75 | } 76 | 77 | .provider-option.selected { 78 | background-color: #374151; 79 | color: #f3f4f6; 80 | font-weight: 600; 81 | border-color: #ef4444; 82 | position: relative; 83 | } 84 | 85 | .provider-option.selected::before { 86 | content: "✓"; 87 | position: absolute; 88 | right: 16px; 89 | color: #ef4444; 90 | } 91 | 92 | .model-select { 93 | width: 100%; 94 | padding: 10px 12px; 95 | border: 1px solid #2c2d30; 96 | border-radius: 8px; 97 | font-size: 14px; 98 | background-color: #2c2d30; 99 | color: #f3f4f6; 100 | transition: all 0.2s ease; 101 | margin-bottom: 16px; 102 | cursor: pointer; 103 | appearance: none; 104 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.5L6 8L9.5 4.5' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); 105 | background-repeat: no-repeat; 106 | background-position: right 12px center; 107 | padding-right: 36px; 108 | } 109 | 110 | .model-select:focus { 111 | outline: none; 112 | border-color: #ef4444; 113 | box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); 114 | } 115 | 116 | .model-select:disabled { 117 | opacity: 0.7; 118 | cursor: not-allowed; 119 | } 120 | 121 | .model-select option { 122 | background-color: #1f2937; 123 | color: #f3f4f6; 124 | padding: 8px; 125 | } 126 | 127 | .error-message { 128 | color: #ef4444; 129 | font-size: 14px; 130 | margin-top: 8px; 131 | padding: 8px; 132 | background-color: rgba(239, 68, 68, 0.1); 133 | border-radius: 4px; 134 | border: 1px solid rgba(239, 68, 68, 0.2); 135 | } 136 | -------------------------------------------------------------------------------- /src/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Popup: React.FC = () => { 4 | return ( 5 |
6 |
7 |

YouTube Summary

8 |

9 | Configure settings and generate summaries directly on YouTube video 10 | pages. 11 |

12 |
20 | ⭐️ If you find this helpful, please{" "} 21 | 27 | star the project 28 | {" "} 29 | on GitHub! 30 |
31 |

32 | 38 | View on GitHub 39 | 40 | {" • "} 41 | 47 | Support this project 48 | 49 |

50 |

51 | 52 | Made with ❤️ by{" "} 53 | 59 | avarayr 60 | 61 | 62 |

63 |
64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Popup } from "./Popup"; 4 | 5 | // Debug log before render 6 | console.log("About to render Popup"); 7 | 8 | const rootElement = document.getElementById("root"); 9 | if (!rootElement) { 10 | console.error("Root element not found!"); 11 | } else { 12 | const root = createRoot(rootElement); 13 | root.render(); 14 | console.log("Popup rendered"); 15 | } 16 | -------------------------------------------------------------------------------- /src/shared/components/ChatPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import { marked } from "marked"; 3 | import DOMPurify from "dompurify"; 4 | import { askQuestion } from "../../content/utils/ai"; 5 | import type { AIProvider } from "../config"; 6 | 7 | interface Message { 8 | role: "user" | "assistant"; 9 | content: string; 10 | } 11 | 12 | interface ChatPanelProps { 13 | summary: string; 14 | provider: AIProvider; 15 | } 16 | 17 | export const ChatPanel: React.FC = ({ summary, provider }) => { 18 | const [messages, setMessages] = useState([]); 19 | const [inputValue, setInputValue] = useState(""); 20 | const [isLoading, setIsLoading] = useState(false); 21 | const messagesEndRef = useRef(null); 22 | const inputRef = useRef(null); 23 | 24 | const scrollToBottom = () => { 25 | if (messagesEndRef.current) { 26 | const container = messagesEndRef.current.closest(".summary-content"); 27 | if (container) { 28 | container.scrollTop = container.scrollHeight; 29 | } 30 | } 31 | }; 32 | 33 | useEffect(() => { 34 | if (messages.length > 0) { 35 | scrollToBottom(); 36 | } 37 | }, [messages]); 38 | 39 | const handleSubmit = async (e: React.FormEvent) => { 40 | e.preventDefault(); 41 | if (!inputValue.trim() || isLoading) return; 42 | 43 | const question = inputValue.trim(); 44 | setInputValue(""); 45 | setTimeout(() => inputRef.current?.focus(), 0); 46 | 47 | setMessages((prev) => [...prev, { role: "user", content: question }]); 48 | setIsLoading(true); 49 | 50 | try { 51 | let responseText = ""; 52 | const previousMessages = messages.slice(0, -1); 53 | for await (const token of askQuestion( 54 | question, 55 | summary, 56 | provider, 57 | previousMessages 58 | )) { 59 | responseText += token; 60 | setMessages((prev) => { 61 | const newMessages = [...prev]; 62 | if (newMessages[newMessages.length - 1]?.role === "assistant") { 63 | newMessages[newMessages.length - 1].content = responseText; 64 | } else { 65 | newMessages.push({ role: "assistant", content: responseText }); 66 | } 67 | return newMessages; 68 | }); 69 | } 70 | } catch (error) { 71 | console.error("Error asking question:", error); 72 | setMessages((prev) => [ 73 | ...prev, 74 | { 75 | role: "assistant", 76 | content: 77 | "Sorry, I encountered an error while processing your question.", 78 | }, 79 | ]); 80 | } finally { 81 | setIsLoading(false); 82 | setTimeout(() => inputRef.current?.focus(), 0); 83 | } 84 | }; 85 | 86 | const renderMarkdown = (text: string) => { 87 | const rawHtml = marked(text) as string; 88 | const cleanHtml = DOMPurify.sanitize(rawHtml); 89 | return
; 90 | }; 91 | 92 | useEffect(() => { 93 | inputRef.current?.focus(); 94 | }, []); 95 | 96 | return ( 97 |
98 |

Ask Questions

99 | {messages.length > 0 && ( 100 |
101 | {messages.map((message, index) => ( 102 |
108 | {renderMarkdown(message.content)} 109 |
110 | ))} 111 |
112 |
113 | )} 114 |
115 | setInputValue(e.target.value)} 119 | placeholder={ 120 | isLoading 121 | ? "Waiting for response..." 122 | : "Ask a question about the video..." 123 | } 124 | className={`chat-input ${isLoading ? "loading" : ""}`} 125 | ref={inputRef} 126 | /> 127 | 134 |
135 |
136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /src/shared/components/ModelSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { AIProvider } from "../config"; 3 | 4 | interface AIModel { 5 | id: string; 6 | name?: string; 7 | } 8 | 9 | interface ModelSelectProps { 10 | provider: AIProvider | null; 11 | models: AIModel[]; 12 | selectedModel: string | null; 13 | isLoading: boolean; 14 | onChange: (modelId: string) => void; 15 | className?: string; 16 | } 17 | 18 | export const ModelSelect: React.FC = ({ 19 | provider, 20 | models, 21 | selectedModel, 22 | isLoading, 23 | onChange, 24 | className = "", 25 | }) => { 26 | if (!provider || models.length === 0) return null; 27 | 28 | return ( 29 |
30 | 33 |
34 | 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/shared/components/SettingsPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from "react"; 2 | import type { AIProvider } from "../config"; 3 | import { providers } from "../config"; 4 | import { ModelSelect } from "./ModelSelect"; 5 | import { fetchModels } from "../../content/utils/ai"; 6 | 7 | interface AIModel { 8 | id: string; 9 | name?: string; 10 | } 11 | 12 | interface SettingsPanelProps { 13 | onClose?: () => void; 14 | showHeader?: boolean; 15 | className?: string; 16 | onProviderChange?: (provider: AIProvider) => void; 17 | onModelChange?: (model: string) => void; 18 | } 19 | 20 | export const SettingsPanel: React.FC = ({ 21 | onClose, 22 | showHeader = true, 23 | className = "", 24 | onProviderChange, 25 | onModelChange, 26 | }) => { 27 | const [apiKey, setApiKey] = useState(null); 28 | const [selectedProvider, setSelectedProvider] = useState( 29 | null 30 | ); 31 | const [availableModels, setAvailableModels] = useState([]); 32 | const [selectedModel, setSelectedModel] = useState(null); 33 | const [isLoadingModels, setIsLoadingModels] = useState(false); 34 | const [modelError, setModelError] = useState(null); 35 | const [apiKeyError, setApiKeyError] = useState(null); 36 | const [customBaseUrl, setCustomBaseUrl] = useState(""); 37 | const [customModel, setCustomModel] = useState(""); 38 | 39 | const handleFetchModels = useCallback( 40 | async (provider: AIProvider, key: string) => { 41 | if (!provider.supportsModelList) return; 42 | 43 | setIsLoadingModels(true); 44 | setModelError(null); 45 | try { 46 | const models = await fetchModels(provider, key, customBaseUrl); 47 | setAvailableModels(models); 48 | if (models.length > 0) { 49 | setSelectedModel(models[0].id); 50 | chrome.storage.sync.set({ selectedModel: models[0].id }); 51 | onModelChange?.(models[0].id); 52 | } 53 | } catch (error) { 54 | console.error("Error fetching models:", error); 55 | setModelError("Failed to fetch available models"); 56 | setAvailableModels([]); 57 | // Use provider's default model when fetch fails 58 | setSelectedModel(provider.model); 59 | chrome.storage.sync.set({ selectedModel: provider.model }); 60 | onModelChange?.(provider.model); 61 | } finally { 62 | setIsLoadingModels(false); 63 | } 64 | }, 65 | [onModelChange, customBaseUrl] 66 | ); 67 | 68 | // Load saved settings 69 | useEffect(() => { 70 | chrome.storage.sync.get( 71 | [ 72 | "apiKey", 73 | "selectedProvider", 74 | "selectedModel", 75 | "customBaseUrl", 76 | "customModel", 77 | ], 78 | (result) => { 79 | const provider = result.selectedProvider || providers[0]; 80 | const model = result.selectedModel || provider.model; 81 | const savedCustomBaseUrl = result.customBaseUrl || ""; 82 | const savedCustomModel = result.customModel || ""; 83 | 84 | setSelectedProvider(provider); 85 | setApiKey(result.apiKey || null); 86 | setSelectedModel(model); 87 | setCustomBaseUrl(savedCustomBaseUrl); 88 | setCustomModel(savedCustomModel); 89 | 90 | // Notify parent components 91 | onProviderChange?.(provider); 92 | onModelChange?.(model); 93 | 94 | // Only show API key error for non-local providers 95 | if (!result.apiKey && !provider.isLocal) { 96 | setApiKeyError("Please set your API key in the extension settings"); 97 | } 98 | 99 | // If it's a local provider with model list support or Google AI, try to fetch models 100 | if ( 101 | (provider.isLocal && provider.supportsModelList) || 102 | provider.name === "Google AI" 103 | ) { 104 | handleFetchModels(provider, result.apiKey || ""); 105 | } 106 | } 107 | ); 108 | }, [onProviderChange, onModelChange, handleFetchModels]); 109 | 110 | const handleProviderChange = (provider: AIProvider) => { 111 | setSelectedProvider(provider); 112 | 113 | // Reset model-related state when changing providers 114 | setSelectedModel(provider.model); 115 | setAvailableModels([]); 116 | setModelError(null); 117 | 118 | // Clear API key error if switching to a local provider 119 | if (provider.isLocal) { 120 | setApiKeyError(null); 121 | } else if (!apiKey) { 122 | setApiKeyError("Please set your API key in the extension settings"); 123 | } 124 | 125 | // Save provider and related settings 126 | chrome.storage.sync.set({ 127 | selectedProvider: provider, 128 | selectedModel: provider.model, 129 | }); 130 | 131 | // Notify parent components 132 | onProviderChange?.(provider); 133 | onModelChange?.(provider.model); 134 | 135 | // If the provider supports model listing, fetch models 136 | // For local providers, we don't require an API key 137 | if (provider.supportsModelList && (provider.isLocal || apiKey)) { 138 | handleFetchModels(provider, apiKey || ""); 139 | } 140 | }; 141 | 142 | const handleBaseUrlChange = (url: string) => { 143 | setCustomBaseUrl(url); 144 | chrome.storage.sync.set({ customBaseUrl: url }); 145 | 146 | // If we have a provider and API key, try to fetch models with the new base URL 147 | if (selectedProvider?.supportsModelList && apiKey) { 148 | handleFetchModels(selectedProvider, apiKey); 149 | } 150 | }; 151 | 152 | const handleCustomModelChange = (model: string) => { 153 | setCustomModel(model); 154 | setSelectedModel(model); 155 | chrome.storage.sync.set({ 156 | customModel: model, 157 | selectedModel: model, 158 | }); 159 | onModelChange?.(model); 160 | }; 161 | 162 | return ( 163 |
164 | {showHeader && ( 165 |
166 |

AI Provider Settings

167 | {onClose && ( 168 | 171 | )} 172 |
173 | )} 174 |
175 |
176 | 177 | { 184 | const newKey = e.target.value.trim(); 185 | chrome.storage.sync.set({ apiKey: newKey }); 186 | setApiKey(newKey); 187 | if (!newKey) { 188 | setApiKeyError("Please set your API key"); 189 | } else { 190 | setApiKeyError(null); 191 | if (selectedProvider?.supportsModelList) { 192 | handleFetchModels(selectedProvider, newKey); 193 | } 194 | } 195 | }} 196 | /> 197 |
198 |
199 | 200 | {providers.map((provider) => ( 201 |
handleProviderChange(provider)} 207 | > 208 | {provider.name} 209 |
210 | ))} 211 |
212 | 213 | {selectedProvider?.isLocal && ( 214 |
215 | 216 | handleBaseUrlChange(e.target.value.trim())} 223 | /> 224 |
225 | Default: {selectedProvider.defaultBaseUrl || "None"} 226 |
227 |
228 | )} 229 | 230 | {selectedProvider?.requiresModelInput ? ( 231 |
232 | 233 | handleCustomModelChange(e.target.value.trim())} 240 | /> 241 |
242 | ) : ( 243 | selectedProvider && 244 | availableModels.length > 0 && ( 245 | { 251 | setSelectedModel(modelId); 252 | chrome.storage.sync.set({ selectedModel: modelId }); 253 | onModelChange?.(modelId); 254 | }} 255 | /> 256 | ) 257 | )} 258 | 259 | {apiKeyError &&
{apiKeyError}
} 260 | {modelError &&
{modelError}
} 261 |
262 |
263 | ); 264 | }; 265 | -------------------------------------------------------------------------------- /src/shared/config.ts: -------------------------------------------------------------------------------- 1 | export interface AIProvider { 2 | name: string; 3 | baseUrl: string; 4 | model: string; 5 | isLocal?: boolean; 6 | defaultBaseUrl?: string; 7 | customBaseUrl?: string; 8 | supportsModelList?: boolean; 9 | requiresModelInput?: boolean; 10 | } 11 | 12 | export const providers: AIProvider[] = [ 13 | { 14 | name: "ChatGPT", 15 | baseUrl: "https://api.openai.com/v1", 16 | model: "gpt-4", 17 | supportsModelList: true, 18 | }, 19 | { 20 | name: "Google AI", 21 | baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", 22 | model: "gemini-2.0-flash-exp", 23 | supportsModelList: true, 24 | }, 25 | { 26 | name: "Groq", 27 | baseUrl: "https://api.groq.com/openai/v1", 28 | model: "llama-3.1-8b-instant", 29 | supportsModelList: true, 30 | }, 31 | { 32 | name: "OpenRouter", 33 | baseUrl: "https://openrouter.ai/api/v1", 34 | model: "openai/gpt-4-turbo", 35 | supportsModelList: true, 36 | }, 37 | { 38 | name: "Ollama", 39 | baseUrl: "http://127.0.0.1:11434/v1", 40 | defaultBaseUrl: "http://127.0.0.1:11434/v1", 41 | model: "llama2", 42 | isLocal: true, 43 | supportsModelList: true, 44 | }, 45 | { 46 | name: "LM Studio", 47 | baseUrl: "http://127.0.0.1:1234/v1", 48 | defaultBaseUrl: "http://127.0.0.1:1234/v1", 49 | model: "default", 50 | isLocal: true, 51 | supportsModelList: true, 52 | }, 53 | { 54 | name: "Other OpenAI Compatible", 55 | baseUrl: "", 56 | model: "", 57 | isLocal: true, 58 | requiresModelInput: true, 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /src/shared/utils/googleAI.ts: -------------------------------------------------------------------------------- 1 | import type { AIModel } from "../../content/utils/ai"; 2 | 3 | const GOOGLE_AI_MODELS: AIModel[] = [ 4 | { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, 5 | { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, 6 | { id: "gemini-1.5-flash-8b", name: "Gemini 1.5 Flash 8B" }, 7 | { id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Experimental)" }, 8 | { id: "gemini-exp-1206", name: "Gemini Experimental 1206" }, 9 | { 10 | id: "gemini-2.0-flash-thinking-exp-1219", 11 | name: "Gemini 2.0 Flash Thinking (Experimental)", 12 | }, 13 | { id: "gemma-2-2b-it", name: "Gemma 2B IT" }, 14 | { id: "gemma-2-9b-it", name: "Gemma 9B IT" }, 15 | { id: "gemma-2-27b-it", name: "Gemma 27B IT" }, 16 | ]; 17 | 18 | export async function fetchGoogleAIModels(_apiKey: string): Promise { 19 | // Return the hardcoded list directly 20 | return GOOGLE_AI_MODELS; 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | // Regular fetch for non-streaming requests 2 | export async function proxyFetch(url: string, options: RequestInit = {}) { 3 | console.log("ProxyFetch called with:", { url, options }); 4 | return new Promise((resolve, reject) => { 5 | chrome.runtime.sendMessage( 6 | { 7 | type: "PROXY_REQUEST", 8 | url, 9 | options, 10 | }, 11 | (response) => { 12 | console.log("Got proxy response:", response); 13 | if (chrome.runtime.lastError) { 14 | console.error("Chrome runtime error:", chrome.runtime.lastError); 15 | reject(chrome.runtime.lastError); 16 | return; 17 | } 18 | 19 | if (!response.ok) { 20 | console.error("Request failed:", response.error); 21 | reject(new Error(response.error || "Request failed")); 22 | return; 23 | } 24 | 25 | resolve(response.data); 26 | } 27 | ); 28 | }); 29 | } 30 | 31 | // Streaming fetch that returns an async generator 32 | export async function* proxyFetchStream( 33 | url: string, 34 | options: RequestInit = {} 35 | ) { 36 | console.log("ProxyFetchStream called with:", { url, options }); 37 | 38 | // First check if we should use port 39 | console.log("Checking if should use port for streaming"); 40 | const portResponse = await new Promise<{ type: string }>( 41 | (resolve, reject) => { 42 | chrome.runtime.sendMessage( 43 | { 44 | type: "PROXY_REQUEST", 45 | url, 46 | options, 47 | }, 48 | (response) => { 49 | console.log("Got port check response:", response); 50 | if (chrome.runtime.lastError) { 51 | console.error("Chrome runtime error:", chrome.runtime.lastError); 52 | reject(chrome.runtime.lastError); 53 | return; 54 | } 55 | resolve(response); 56 | } 57 | ); 58 | } 59 | ); 60 | 61 | if (portResponse.type !== "USE_PORT") { 62 | throw new Error("Expected streaming response"); 63 | } 64 | 65 | // Create a port for streaming 66 | console.log("Creating streaming port"); 67 | const port = chrome.runtime.connect({ name: "proxy-stream" }); 68 | 69 | try { 70 | // Set up promise to handle port errors 71 | const errorPromise = new Promise((_, reject) => { 72 | port.onMessage.addListener((msg) => { 73 | if (msg.error) { 74 | console.error("Port error:", msg.error); 75 | reject(new Error(msg.error)); 76 | } 77 | }); 78 | }); 79 | 80 | // Set up async iterator for chunks 81 | const chunkPromises: Promise[] = []; 82 | let resolveNext: ((value: string) => void) | null = null; 83 | let isDone = false; 84 | 85 | port.onMessage.addListener((msg) => { 86 | console.log("Got port message:", msg); 87 | if (msg.error) { 88 | return; // Error will be handled by errorPromise 89 | } 90 | if (msg.done) { 91 | isDone = true; 92 | if (resolveNext) { 93 | resolveNext(""); // Resolve with empty string to break the loop 94 | } 95 | return; 96 | } 97 | if (msg.chunk) { 98 | if (resolveNext) { 99 | resolveNext(msg.chunk); 100 | resolveNext = null; 101 | } else { 102 | chunkPromises.push(Promise.resolve(msg.chunk)); 103 | } 104 | } 105 | }); 106 | 107 | // Start the streaming request 108 | console.log("Starting streaming request"); 109 | port.postMessage({ url, options }); 110 | 111 | // Yield chunks as they arrive 112 | while (!isDone) { 113 | if (chunkPromises.length > 0) { 114 | const chunk = await chunkPromises.shift()!; 115 | if (chunk) yield chunk; 116 | } else { 117 | const nextChunk = new Promise((resolve) => { 118 | resolveNext = resolve; 119 | }); 120 | const chunk = await Promise.race([nextChunk, errorPromise]); 121 | if (chunk) yield chunk; 122 | } 123 | } 124 | } finally { 125 | console.log("Closing port"); 126 | port.disconnect(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/static/manifest/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "YouTube Summary", 4 | "version": "1.0.1", 5 | "description": "Generate AI summaries of YouTube videos", 6 | "permissions": ["storage", "activeTab"], 7 | "action": { 8 | "default_popup": "popup/index.html", 9 | "default_icon": { 10 | "16": "icons/icon16.png", 11 | "48": "icons/icon48.png", 12 | "128": "icons/icon128.png" 13 | } 14 | }, 15 | "icons": { 16 | "16": "icons/icon16.png", 17 | "48": "icons/icon48.png", 18 | "128": "icons/icon128.png" 19 | }, 20 | "content_scripts": [ 21 | { 22 | "matches": ["*://*.youtube.com/*"], 23 | "js": ["content/index.js"], 24 | "css": ["content/styles.css"] 25 | } 26 | ], 27 | "background": { 28 | "service_worker": "background/index.js" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/static/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | YouTube Summary 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/static/popup/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | min-height: 300px; 4 | margin: 0; 5 | padding: 0; 6 | background-color: #1a1b1e; 7 | color: #f3f4f6; 8 | } 9 | 10 | #root { 11 | height: 100%; 12 | width: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /src/static/styles/styles.css: -------------------------------------------------------------------------------- 1 | .yt-summary-container { 2 | position: relative; 3 | background-color: #1a1b1e; 4 | border-radius: 12px; 5 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); 6 | margin: 16px 0; 7 | font-family: "Roboto", sans-serif; 8 | border: 1px solid #2c2d30; 9 | transition: all 0.3s ease; 10 | animation: fadeIn 0.3s ease-out; 11 | display: flex; 12 | flex-direction: column; 13 | max-height: 80vh; 14 | overflow: hidden; 15 | } 16 | 17 | @keyframes fadeIn { 18 | from { 19 | opacity: 0; 20 | transform: translateY(10px); 21 | } 22 | to { 23 | opacity: 1; 24 | transform: translateY(0); 25 | } 26 | } 27 | 28 | @keyframes slideIn { 29 | from { 30 | opacity: 0; 31 | transform: translateX(-10px); 32 | } 33 | to { 34 | opacity: 1; 35 | transform: translateX(0); 36 | } 37 | } 38 | 39 | .error-message { 40 | background-color: rgba(220, 38, 38, 0.1); 41 | color: #ef4444; 42 | padding: 14px; 43 | border-radius: 8px; 44 | margin-bottom: 16px; 45 | font-size: 14px; 46 | line-height: 1.5; 47 | border: 1px solid rgba(220, 38, 38, 0.2); 48 | animation: slideIn 0.3s ease-out; 49 | } 50 | 51 | .button-container { 52 | display: flex; 53 | gap: 8px; 54 | align-items: center; 55 | padding: 16px; 56 | } 57 | 58 | .summarize-button { 59 | background-color: #ef4444; 60 | color: white; 61 | border: none; 62 | border-radius: 8px; 63 | padding: 12px 20px; 64 | font-size: 14px; 65 | font-weight: 600; 66 | cursor: pointer; 67 | flex: 1; 68 | transition: all 0.2s ease; 69 | text-transform: uppercase; 70 | letter-spacing: 0.5px; 71 | } 72 | 73 | .summarize-button:hover:not(:disabled) { 74 | background-color: #dc2626; 75 | transform: translateY(-1px); 76 | box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2); 77 | } 78 | 79 | .summarize-button:active:not(:disabled) { 80 | transform: translateY(0); 81 | } 82 | 83 | .summarize-button:disabled { 84 | background-color: #374151; 85 | cursor: not-allowed; 86 | opacity: 0.7; 87 | } 88 | 89 | .settings-button { 90 | background: none; 91 | border: none; 92 | font-size: 20px; 93 | cursor: pointer; 94 | padding: 10px; 95 | color: #9ca3af; 96 | transition: all 0.2s ease; 97 | border-radius: 8px; 98 | flex-shrink: 0; 99 | width: 44px; 100 | height: 44px; 101 | display: flex; 102 | align-items: center; 103 | justify-content: center; 104 | } 105 | 106 | .settings-button:hover { 107 | background-color: rgba(156, 163, 175, 0.1); 108 | color: #f3f4f6; 109 | } 110 | 111 | .summary-header, 112 | .settings-header { 113 | display: flex; 114 | justify-content: space-between; 115 | align-items: center; 116 | padding: 16px; 117 | border-bottom: 1px solid #2c2d30; 118 | } 119 | 120 | .summary-header h3, 121 | .settings-header h3 { 122 | margin: 0; 123 | font-size: 18px; 124 | font-weight: 600; 125 | color: #f3f4f6; 126 | letter-spacing: 0.5px; 127 | } 128 | 129 | .close-button { 130 | background: none; 131 | border: none; 132 | font-size: 24px; 133 | cursor: pointer; 134 | padding: 4px 8px; 135 | color: #9ca3af; 136 | transition: all 0.2s ease; 137 | border-radius: 6px; 138 | line-height: 1; 139 | } 140 | 141 | .close-button:hover { 142 | color: #f3f4f6; 143 | background-color: rgba(156, 163, 175, 0.1); 144 | } 145 | 146 | .summary-content { 147 | flex: 1; 148 | overflow-y: auto; 149 | padding: 2px 16px; 150 | font-size: 15px; 151 | line-height: 1.6; 152 | color: #d1d5db; 153 | } 154 | 155 | /* Markdown Styles */ 156 | .summary-content > *:first-child { 157 | margin-top: 0 !important; 158 | } 159 | 160 | .summary-content > *:last-child { 161 | margin-bottom: 0 !important; 162 | } 163 | 164 | .summary-content h1 { 165 | font-size: 24px; 166 | font-weight: 700; 167 | margin: 10px 0 12px; 168 | color: #f3f4f6; 169 | border-bottom: 1px solid #2c2d30; 170 | padding-bottom: 8px; 171 | } 172 | 173 | .summary-content h2 { 174 | font-size: 20px; 175 | font-weight: 600; 176 | margin: 7px 0 10px; 177 | color: #f3f4f6; 178 | } 179 | 180 | .summary-content h3 { 181 | font-size: 18px; 182 | font-weight: 600; 183 | margin: 7px 0 8px; 184 | color: #f3f4f6; 185 | } 186 | 187 | .summary-content p { 188 | margin: 0 0 12px; 189 | } 190 | 191 | .summary-content ul, 192 | .summary-content ol { 193 | margin: 0 0 12px; 194 | padding-left: 24px; 195 | } 196 | 197 | .summary-content li { 198 | margin: 4px 0; 199 | } 200 | 201 | .summary-content li > p { 202 | margin: 0; 203 | } 204 | 205 | .summary-content strong { 206 | color: #f3f4f6; 207 | font-weight: 600; 208 | } 209 | 210 | .summary-content em { 211 | color: #ef4444; 212 | font-style: italic; 213 | } 214 | 215 | .summary-content blockquote { 216 | border-left: 4px solid #ef4444; 217 | margin: 12px 0; 218 | padding: 8px 16px; 219 | background: rgba(239, 68, 68, 0.1); 220 | border-radius: 4px; 221 | font-style: italic; 222 | } 223 | 224 | .summary-content blockquote > *:last-child { 225 | margin-bottom: 0; 226 | } 227 | 228 | .summary-content code { 229 | background: #2c2d30; 230 | padding: 2px 6px; 231 | border-radius: 4px; 232 | font-family: "Menlo", "Monaco", "Courier New", monospace; 233 | font-size: 14px; 234 | color: #ef4444; 235 | } 236 | 237 | .summary-content pre { 238 | background: #2c2d30; 239 | padding: 16px; 240 | border-radius: 8px; 241 | overflow-x: auto; 242 | margin: 12px 0; 243 | } 244 | 245 | .summary-content pre code { 246 | background: none; 247 | padding: 0; 248 | color: #d1d5db; 249 | } 250 | 251 | .summary-content a { 252 | color: #ef4444; 253 | text-decoration: none; 254 | border-bottom: 1px dashed #ef4444; 255 | transition: all 0.2s ease; 256 | } 257 | 258 | .summary-content a:hover { 259 | border-bottom-style: solid; 260 | } 261 | 262 | .summary-content hr { 263 | border: none; 264 | border-top: 1px solid #2c2d30; 265 | margin: 16px 0; 266 | } 267 | 268 | /* Chat Panel Styles */ 269 | .chat-panel { 270 | border-top: 1px solid #2c2d30; 271 | background: #1a1b1e; 272 | display: flex; 273 | flex-direction: column; 274 | gap: 16px; 275 | max-height: 40vh; 276 | min-height: 200px; 277 | } 278 | 279 | .chat-messages { 280 | overflow-y: auto; 281 | display: flex; 282 | flex-direction: column; 283 | gap: 12px; 284 | padding: 16px; 285 | flex: 1; 286 | } 287 | 288 | .chat-message { 289 | padding: 12px 16px; 290 | border-radius: 12px; 291 | max-width: 80%; 292 | line-height: 1.4; 293 | } 294 | 295 | .user-message { 296 | background: #ef4444; 297 | color: white; 298 | align-self: flex-end; 299 | margin-left: 20%; 300 | } 301 | 302 | .assistant-message { 303 | background: #2c2d30; 304 | color: #d1d5db; 305 | align-self: flex-start; 306 | margin-right: 20%; 307 | } 308 | 309 | .chat-input-form { 310 | display: flex; 311 | gap: 8px; 312 | padding: 16px; 313 | background: #1a1b1e; 314 | border-top: 1px solid #2c2d30; 315 | } 316 | 317 | .chat-input { 318 | flex: 1; 319 | padding: 12px 16px; 320 | border: 1px solid #2c2d30; 321 | border-radius: 24px; 322 | background: #2c2d30; 323 | color: #f3f4f6; 324 | font-size: 14px; 325 | } 326 | 327 | .chat-input:focus { 328 | outline: none; 329 | border-color: #ef4444; 330 | } 331 | 332 | .chat-submit-button { 333 | padding: 8px 24px; 334 | border: none; 335 | border-radius: 24px; 336 | background: #ef4444; 337 | color: white; 338 | font-weight: 500; 339 | cursor: pointer; 340 | transition: background-color 0.2s; 341 | } 342 | 343 | .chat-submit-button:hover:not(:disabled) { 344 | background: #dc2626; 345 | } 346 | 347 | .chat-submit-button:disabled { 348 | opacity: 0.6; 349 | cursor: not-allowed; 350 | } 351 | 352 | /* Settings Styles */ 353 | .settings-content { 354 | display: flex; 355 | flex-direction: column; 356 | gap: 16px; 357 | padding: 16px; 358 | } 359 | 360 | .provider-option { 361 | width: 100%; 362 | box-sizing: border-box; 363 | padding: 12px 16px; 364 | border-radius: 8px; 365 | cursor: pointer; 366 | transition: all 0.2s ease; 367 | color: #d1d5db; 368 | background-color: #2c2d30; 369 | border: 1px solid transparent; 370 | margin-bottom: 8px; 371 | } 372 | 373 | .provider-option:last-child { 374 | margin-bottom: 0; 375 | } 376 | 377 | .provider-option:hover { 378 | background-color: #374151; 379 | transform: translateX(4px); 380 | } 381 | 382 | .provider-option.selected { 383 | background-color: #374151; 384 | color: #f3f4f6; 385 | font-weight: 600; 386 | border-color: #ef4444; 387 | position: relative; 388 | } 389 | 390 | .provider-option.selected::before { 391 | content: "✓"; 392 | position: absolute; 393 | right: 16px; 394 | color: #ef4444; 395 | } 396 | 397 | /* Scrollbar Styles */ 398 | .summary-content::-webkit-scrollbar, 399 | .chat-messages::-webkit-scrollbar { 400 | width: 8px; 401 | } 402 | 403 | .summary-content::-webkit-scrollbar-track, 404 | .chat-messages::-webkit-scrollbar-track { 405 | background: #2c2d30; 406 | border-radius: 4px; 407 | } 408 | 409 | .summary-content::-webkit-scrollbar-thumb, 410 | .chat-messages::-webkit-scrollbar-thumb { 411 | background: #4b5563; 412 | border-radius: 4px; 413 | transition: background 0.2s ease; 414 | } 415 | 416 | .summary-content::-webkit-scrollbar-thumb:hover, 417 | .chat-messages::-webkit-scrollbar-thumb:hover { 418 | background: #6b7280; 419 | } 420 | 421 | /* Loading Animation */ 422 | @keyframes pulse { 423 | 0% { 424 | opacity: 1; 425 | } 426 | 50% { 427 | opacity: 0.5; 428 | } 429 | 100% { 430 | opacity: 1; 431 | } 432 | } 433 | 434 | .summary-skeleton { 435 | padding: 16px; 436 | animation: shimmer 2s infinite linear; 437 | background: linear-gradient(90deg, #2c2d30 0%, #374151 50%, #2c2d30 100%); 438 | background-size: 200% 100%; 439 | border-radius: 8px; 440 | } 441 | 442 | .skeleton-line { 443 | height: 12px; 444 | margin-bottom: 12px; 445 | border-radius: 4px; 446 | background: #374151; 447 | } 448 | 449 | .skeleton-line:nth-child(2) { 450 | width: 95%; 451 | } 452 | .skeleton-line:nth-child(3) { 453 | width: 85%; 454 | } 455 | .skeleton-line:nth-child(4) { 456 | width: 90%; 457 | } 458 | .skeleton-line:nth-child(5) { 459 | width: 80%; 460 | } 461 | 462 | @keyframes shimmer { 463 | 0% { 464 | background-position: -200% 0; 465 | } 466 | 100% { 467 | background-position: 200% 0; 468 | } 469 | } 470 | 471 | /* Model Selection Styles */ 472 | .settings-section { 473 | margin-bottom: 20px; 474 | } 475 | 476 | .settings-section:last-child { 477 | margin-bottom: 0; 478 | } 479 | 480 | .settings-section label { 481 | display: block; 482 | margin-bottom: 8px; 483 | font-weight: 500; 484 | color: #d1d5db; 485 | font-size: 14px; 486 | } 487 | 488 | .api-key-input { 489 | width: 100%; 490 | padding: 10px 12px; 491 | border: 1px solid #2c2d30; 492 | border-radius: 8px; 493 | font-size: 14px; 494 | background-color: #2c2d30; 495 | color: #f3f4f6; 496 | transition: all 0.2s ease; 497 | margin-bottom: 16px; 498 | font-family: "Menlo", "Monaco", "Courier New", monospace; 499 | box-sizing: border-box; 500 | } 501 | 502 | .api-key-input:focus { 503 | outline: none; 504 | border-color: #ef4444; 505 | box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); 506 | } 507 | 508 | .api-key-input::placeholder { 509 | color: #6b7280; 510 | } 511 | 512 | .select-wrapper { 513 | position: relative; 514 | width: 100%; 515 | } 516 | 517 | .select-wrapper::after { 518 | content: ""; 519 | position: absolute; 520 | right: 12px; 521 | top: 50%; 522 | transform: translateY(-50%); 523 | width: 10px; 524 | height: 10px; 525 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M1 3L5 7L9 3' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); 526 | background-repeat: no-repeat; 527 | background-position: center; 528 | pointer-events: none; 529 | } 530 | 531 | .model-select { 532 | width: 100%; 533 | padding: 8px 32px 8px 12px; 534 | font-size: 14px; 535 | line-height: 1.4; 536 | color: #f3f4f6; 537 | background-color: #2c2d30; 538 | border: 1px solid #4b5563; 539 | border-radius: 6px; 540 | appearance: none; 541 | cursor: pointer; 542 | transition: all 0.2s ease; 543 | max-width: 100%; 544 | overflow: hidden; 545 | text-overflow: ellipsis; 546 | white-space: nowrap; 547 | } 548 | 549 | .model-select:hover:not(:disabled) { 550 | border-color: #ef4444; 551 | background-color: #374151; 552 | } 553 | 554 | .model-select:focus { 555 | outline: none; 556 | border-color: #ef4444; 557 | box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); 558 | } 559 | 560 | .model-select:disabled { 561 | opacity: 0.6; 562 | cursor: not-allowed; 563 | background-color: #1f2937; 564 | } 565 | 566 | .model-select option { 567 | padding: 8px; 568 | background-color: #1f2937; 569 | color: #f3f4f6; 570 | } 571 | 572 | /* Chat Section Styles */ 573 | .chat-section { 574 | margin-top: 24px; 575 | padding-top: 16px; 576 | border-top: 1px solid #2c2d30; 577 | } 578 | 579 | .chat-section h2 { 580 | font-size: 18px; 581 | font-weight: 600; 582 | margin: 0 0 12px; 583 | color: #f3f4f6; 584 | } 585 | 586 | .chat-messages { 587 | display: flex; 588 | flex-direction: column; 589 | gap: 8px; 590 | } 591 | 592 | .chat-message { 593 | padding: 8px 12px; 594 | border-radius: 12px; 595 | max-width: 80%; 596 | line-height: 1.4; 597 | } 598 | 599 | .chat-message > div > *:last-child { 600 | margin-bottom: 0; 601 | } 602 | 603 | .chat-message > div > *:first-child { 604 | margin-top: 0; 605 | } 606 | 607 | .user-message { 608 | background: #ef4444; 609 | color: white; 610 | align-self: flex-end; 611 | margin-left: 20%; 612 | } 613 | 614 | .assistant-message { 615 | background: #2c2d30; 616 | color: #d1d5db; 617 | align-self: flex-start; 618 | margin-right: 20%; 619 | } 620 | 621 | .chat-input-form { 622 | display: flex; 623 | gap: 8px; 624 | margin-top: 12px; 625 | } 626 | 627 | .chat-input { 628 | flex: 1; 629 | padding: 8px 16px; 630 | border: 1px solid #2c2d30; 631 | border-radius: 24px; 632 | background: #2c2d30; 633 | color: #f3f4f6; 634 | font-size: 14px; 635 | } 636 | 637 | .chat-input:focus { 638 | outline: none; 639 | border-color: #ef4444; 640 | } 641 | 642 | .chat-submit-button { 643 | padding: 8px 16px; 644 | border: none; 645 | border-radius: 24px; 646 | background: #ef4444; 647 | color: white; 648 | font-weight: 500; 649 | cursor: pointer; 650 | transition: background-color 0.2s; 651 | } 652 | 653 | .chat-submit-button:hover:not(:disabled) { 654 | background: #dc2626; 655 | } 656 | 657 | .chat-submit-button:disabled { 658 | opacity: 0.6; 659 | cursor: not-allowed; 660 | } 661 | 662 | .chat-input.loading { 663 | color: #6b7280; 664 | background: #1f2937; 665 | cursor: wait; 666 | } 667 | 668 | .chat-input.loading::placeholder { 669 | color: #4b5563; 670 | } 671 | 672 | .base-url-input, 673 | .model-input { 674 | width: 100%; 675 | padding: 10px 12px; 676 | border: 1px solid #2c2d30; 677 | border-radius: 8px; 678 | font-size: 14px; 679 | background-color: #2c2d30; 680 | color: #f3f4f6; 681 | transition: all 0.2s ease; 682 | margin-bottom: 8px; 683 | font-family: "Menlo", "Monaco", "Courier New", monospace; 684 | box-sizing: border-box; 685 | } 686 | 687 | .base-url-input:focus, 688 | .model-input:focus { 689 | outline: none; 690 | border-color: #ef4444; 691 | box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); 692 | } 693 | 694 | .base-url-input::placeholder, 695 | .model-input::placeholder { 696 | color: #6b7280; 697 | } 698 | 699 | .help-text { 700 | font-size: 12px; 701 | color: #9ca3af; 702 | margin-top: -4px; 703 | margin-bottom: 16px; 704 | font-style: italic; 705 | } 706 | 707 | .popup-container { 708 | width: 400px; 709 | min-height: 300px; 710 | padding: 16px; 711 | background-color: #1f2937; 712 | color: #f3f4f6; 713 | } 714 | 715 | .popup-settings { 716 | width: 100%; 717 | height: 100%; 718 | } 719 | 720 | .popup-settings .settings-content { 721 | padding: 0; 722 | } 723 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | --------------------------------------------------------------------------------