├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── badges ├── chrome-add-on-badge.png ├── edge-add-on-badge.png └── firefox-add-on-badge.png ├── base.webpack.config.mjs ├── eslint.config.mjs ├── firefox.manifest.json ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── common │ ├── browser.ts │ ├── constants.ts │ └── styles │ │ └── variables.scss ├── popup │ ├── assets │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon48.png │ │ ├── nav-arrow-down.svg │ │ ├── switch-off.svg │ │ └── switch-on.svg │ ├── popup.html │ ├── popup.scss │ └── popup.ts ├── tab │ ├── assets │ │ ├── attachment.svg │ │ ├── bin-full.svg │ │ ├── copy.svg │ │ ├── download-circle-solid.svg │ │ ├── eye-solid.svg │ │ ├── media-image.svg │ │ ├── menu-scale.svg │ │ ├── priority-down-solid.svg │ │ ├── refresh-double.svg │ │ └── square-dashed.svg │ ├── components │ │ ├── App │ │ │ ├── App.scss │ │ │ ├── App.tsx │ │ │ └── index.ts │ │ ├── ChatBox │ │ │ ├── ChatBox.scss │ │ │ ├── ChatBox.tsx │ │ │ ├── components │ │ │ │ ├── ChatHeader │ │ │ │ │ ├── ChatHeader.scss │ │ │ │ │ ├── ChatHeader.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ChatInput │ │ │ │ │ ├── ChatInput.scss │ │ │ │ │ ├── ChatInput.tsx │ │ │ │ │ ├── components │ │ │ │ │ │ └── ChatInputSuggestions │ │ │ │ │ │ │ ├── ChatInputSuggestions.scss │ │ │ │ │ │ │ ├── ChatInputSuggestions.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── ChatLog │ │ │ │ │ ├── ChatLog.scss │ │ │ │ │ ├── ChatLog.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── FilesModal │ │ │ │ │ ├── FilesModal.scss │ │ │ │ │ ├── FilesModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ImageModal │ │ │ │ │ ├── ImageModal.scss │ │ │ │ │ ├── ImageModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ReferencesList │ │ │ │ │ ├── ReferencesList.scss │ │ │ │ │ ├── ReferencesList.tsx │ │ │ │ │ ├── components │ │ │ │ │ │ └── ReferenceTag │ │ │ │ │ │ │ ├── ReferenceTag.scss │ │ │ │ │ │ │ ├── ReferenceTag.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── RichTextModal │ │ │ │ │ ├── RichTextModal.scss │ │ │ │ │ ├── RichTextModal.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Tools │ │ │ │ │ ├── Tools.scss │ │ │ │ │ ├── Tools.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── Tooltip │ │ │ │ │ ├── Tooltip.tsx │ │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── Group │ │ │ ├── Group.scss │ │ │ ├── Group.tsx │ │ │ └── index.ts │ │ └── MarkdownRenderer │ │ │ ├── MarkdownRenderer.scss │ │ │ ├── MarkdownRenderer.tsx │ │ │ └── index.ts │ ├── contexts │ │ └── ShadowContext.tsx │ ├── features │ │ ├── capture │ │ │ ├── captureImageTool.ts │ │ │ └── captureTool.ts │ │ └── chat │ │ │ └── actions │ │ │ └── showChat.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useAutoScroll.tsx │ │ ├── useChatLog.ts │ │ ├── useDraggablePosition.tsx │ │ ├── useKeepInViewport.ts │ │ ├── usePersistentState.tsx │ │ └── useTheme.tsx │ ├── injectedScript.ts │ ├── privilegedAPIs │ │ ├── constants.ts │ │ ├── contentReceiver.ts │ │ ├── privilegedAPIs.ts │ │ └── types.ts │ ├── serviceWorker.ts │ ├── services │ │ └── ollamaService.ts │ ├── styles │ │ ├── mixins.scss │ │ └── variables.scss │ └── utils │ │ ├── constants.ts │ │ ├── encryption.ts │ │ ├── storageHelper.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── withShadowStyles.tsx └── types │ └── custom.d.ts ├── tsconfig.json └── webpack.config.mjs /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "src/**" 8 | - "webpack.config.mjs" 9 | - "package.json" 10 | - ".github/workflows/**" 11 | pull_request: 12 | paths: 13 | - "src/**" 14 | - "webpack.config.mjs" 15 | - "package.json" 16 | - ".github/workflows/**" 17 | 18 | permissions: 19 | contents: write 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | # Step 1: Checkout the code 27 | - name: Checkout code 28 | uses: actions/checkout@v3 29 | 30 | # Step 2: Set up Node.js using .nvmrc 31 | - name: Set up Node.js 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version-file: .nvmrc 35 | 36 | # Step 3: Install dependencies 37 | - name: Install dependencies 38 | run: npm install 39 | 40 | # Step 4: Build the project 41 | - name: Build project 42 | run: npm run build 43 | 44 | # Step 4.5: Extract version from package.json 45 | - name: Get Package Version 46 | id: pkg_version 47 | run: | 48 | VERSION=$(jq -r '.version' package.json) 49 | echo "VERSION=$VERSION" >> $GITHUB_ENV 50 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 51 | 52 | # Step 5: Create a GitHub Release 53 | - name: Create Release 54 | id: create_release 55 | uses: actions/create-release@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | tag_name: v${{ steps.pkg_version.outputs.VERSION }} 60 | release_name: Release v${{ steps.pkg_version.outputs.VERSION }} 61 | draft: false 62 | prerelease: false 63 | 64 | # Step 6: Archive each folder within dist and upload each as a release asset 65 | - name: Archive and Upload Release Assets 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | run: | 69 | UPLOAD_URL="${{ steps.create_release.outputs.upload_url }}" 70 | UPLOAD_URL="${UPLOAD_URL%\{*}" 71 | for folder in dist/*/; do 72 | folder=${folder%/} # Remove trailing slash 73 | zip_name="${folder##*/}.zip" 74 | echo "Zipping contents of $folder into $zip_name" 75 | pushd "$folder" > /dev/null 76 | zip -r "$GITHUB_WORKSPACE/$zip_name" ./* 77 | popd > /dev/null 78 | echo "Uploading $zip_name" 79 | curl -s --data-binary @"$GITHUB_WORKSPACE/$zip_name" \ 80 | -H "Content-Type: application/zip" \ 81 | -H "Authorization: token ${GITHUB_TOKEN}" \ 82 | "$UPLOAD_URL?name=$(basename "$zip_name")" 83 | done 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | dist -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v24.0.0 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScribePal 2 | 3 | > ⭐️ If you like this plugin, please consider [starring the repository](https://github.com/code-forge-temple/scribe-pal) on GitHub! 4 | 5 | ScribePal is an Open Source intelligent browser extension that leverages AI to empower your web experience by providing contextual insights, efficient content summarization, and seamless interaction while you browse. 6 | 7 | ## Table of Contents 8 | 9 | 10 | 11 | - [Table of Contents](#table-of-contents) 12 | - [Privacy](#privacy) 13 | - [Compatibility](#compatibility) 14 | - [Features](#features) 15 | - [Prerequisites](#prerequisites) 16 | - [Linux](#linux) 17 | - [Windows](#windows) 18 | - [Development](#development) 19 | - [Installation](#installation) 20 | - [Alternative Installation Options](#alternative-installation-options) 21 | - [1. Install via Browser Stores](#1-install-via-browser-stores) 22 | - [2. Download from Releases](#2-download-from-releases) 23 | - [Building](#building) 24 | - [Linting](#linting) 25 | - [Installing in browser](#installing-in-browser) 26 | - [Usage](#usage) 27 | - [License](#license) 28 | 29 | 30 | 31 | ## Privacy 32 | 33 | ScribePal works with local Ollama models, ensuring that all AI processing and messaging is conducted within your local network. Your private data remains on your system and is never transmitted to external servers. This design provides you with full control over your information and guarantees that nobody outside your network has access to your data. 34 | 35 | ## Compatibility 36 | 37 | It is compatible with all Chromium and Gecko-based browsers: Chrome, Vivaldi, Opera, Edge, Firefox, Brave etc. 38 | 39 | ## Features 40 | 41 | - **AI-powered assistance:** Communicates with an AI service (using [ollama](https://www.npmjs.com/package/ollama)) to generate responses. 42 | - **It is PRIVATE:** Because it communicates with a local (within your LAN) Ollama service and LLMs, all your information stays private. 43 | - **Theming:** Supports light and dark themes. 44 | - **Chat Interface:** A draggable chat box for sending and receiving messages. 45 | - **Model Management:** Select, refresh, download, and delete models. 46 | - **Advanced Capture Tools:** Options for capturing both text and images are available. Captured content is inserted directly into your chat using special tags (`@captured-text` for text and `@captured-image` for images). 47 | - **Prompt Customization:** Adjust and customize prompts to instruct the AI model on how to generate responses. 48 | - **File Attachments:** Upload files to the chat interface and reference them in discussions using the `@attached-files` tag. 49 | 50 | ## Prerequisites 51 | 52 | Before installing ScribePal, ensure that you have Node Version Manager (nvm) installed. You can install nvm by following the instructions at [nvm-sh/nvm](https://github.com/nvm-sh/nvm#installing-and-updating). nvm helps you easily switch to the Node.js version specified in [`.nvmrc`](.nvmrc). 53 | 54 | Also, ensure that the [Ollama](https://ollama.com) host is installed on your local machine or available on your LAN: 55 | 56 | ### Linux 57 | 58 | 1. Install Ollama on your host. 59 | 2. Edit the systemd service file by running: 60 | ```sh 61 | sudo nano /etc/systemd/system/ollama.service 62 | ``` 63 | 3. Add the following environment variables in the `[Service]` section: 64 | ``` 65 | Environment="OLLAMA_HOST=0.0.0.0" 66 | Environment="OLLAMA_ORIGINS=chrome-extension://*,moz-extension://*" 67 | ``` 68 | > [!NOTE] 69 | > The `OLLAMA_HOST=0.0.0.0` setting is optional if the Ollama server is running on localhost and you do not need the Ollama server to be accessed from LAN. 70 | 71 | 4. Save the file, then reload and restart the service: 72 | ```sh 73 | sudo systemctl daemon-reload 74 | sudo systemctl restart ollama.service 75 | ``` 76 | 77 | ### Windows 78 | 79 | 1. Install Ollama on your host. 80 | 2. On the machine running Ollama, set the environment variables: 81 | ``` 82 | OLLAMA_HOST=0.0.0.0 83 | OLLAMA_ORIGINS=chrome-extension://*,moz-extension://* 84 | ``` 85 | You can do this via the System Properties or using PowerShell. 86 | > [!NOTE] 87 | > The `OLLAMA_HOST=0.0.0.0` setting is optional if the Ollama server is running on localhost and you do not need the Ollama server to be accessed from LAN. 88 | 89 | 3. Restart Ollama app. 90 | 91 | ## Development 92 | 93 | ### Installation 94 | 95 | 1. Clone the repository: 96 | ```sh 97 | git clone https://github.com/code-forge-temple/scribe-pal.git 98 | cd scribe-pal 99 | ``` 100 | 101 | 2. Set the Node.js version: 102 | - For Unix-based systems: 103 | ```sh 104 | nvm use 105 | ``` 106 | - For Windows: 107 | ```sh 108 | nvm use $(cat .nvmrc) 109 | ``` 110 | 111 | 3. Install dependencies: 112 | ```sh 113 | npm install 114 | ``` 115 | 116 | ### Alternative Installation Options 117 | 118 | If you're not a developer, you can choose one of the following methods: 119 | 120 | ### 1. Install via Browser Stores 121 | 122 | [![Chrome Store](./badges/chrome-add-on-badge.png)](https://chromewebstore.google.com/detail/godcjpkfkipmljclkgmohpookphckdfl?utm_source=github-repo) 123 | [![Edge Store](./badges/edge-add-on-badge.png)](https://microsoftedge.microsoft.com/addons/detail/scribepal/omffcjaihckmfdphencecfigafaoocmb) 124 | [![Firefox Add-on](./badges/firefox-add-on-badge.png)](https://addons.mozilla.org/en-US/firefox/addon/scribe-pal/) 125 | 126 | > [!NOTE] 127 | > Releases available in the browser stores might be slightly out of sync with the GitHub releases. This can be due to the review process, packaging delays, or manual submission requirements. For the most up-to-date version, please refer to the [Releases](https://github.com/code-forge-temple/scribe-pal/releases) page. 128 | 129 | ### 2. Download from Releases 130 | 131 | Visit the [Releases](https://github.com/code-forge-temple/scribe-pal/releases) page to download the latest packages: 132 | 133 | - For Chromium-based browsers, download `chrome.zip`. 134 | - For Gecko-based browsers, download `firefox.zip`. 135 | 136 | After downloading, unzip the package and [install](#installing) the extension manually. 137 | 138 | ### Building 139 | 140 | #### I. For development 141 | 142 | To build the project for development, run: 143 | 144 | A. For Chromium-based browsers like Chrome, Vivaldi, Edge, Brave, Opera and others: 145 | ```sh 146 | npm run dev:chrome 147 | ``` 148 | 149 | B. For Gecko-based browsers like Firefox, Waterfox, Pale Moon, and others: 150 | ```sh 151 | npm run dev:firefox 152 | ``` 153 | 154 | #### II. For production 155 | 156 | To build the project for production, run: 157 | 158 | 1. For Chromium-based browsers: 159 | ```sh 160 | npm run build:chrome 161 | ``` 162 | 163 | 2. For Gecko-based browsers: 164 | ```sh 165 | npm run build:firefox 166 | ``` 167 | 168 | ### Linting 169 | 170 | To lint the project, run: 171 | ```sh 172 | npm run lint 173 | ``` 174 | 175 | ### Installing in browser 176 | 177 | To install the the compiled extension, for: 178 | - Chromium based browsers you need to go to 179 | - `chrome://extensions/` (in Chrome browser) 180 | - `vivaldi://extensions/` (in Vivaldi browser) 181 | - `opera://extensions/` (in Opera browser) 182 | - etc. 183 | 184 | and activate the `Developer Mode`, then `Load unpacked` then select `/dist/chrome` folder. 185 | 186 | - For Gecko-based browsers, navigate to 187 | - `about:debugging#/runtime/this-firefox` 188 | - etc. 189 | 190 | and click on `Load Temporary Add-on…` then select `/dist/firefox` folder. 191 | 192 | ## Usage 193 | 194 | 1. **Open the Extension Popup:** 195 | - Once installed, click the extension icon in your browser’s toolbar. 196 | - The popup allows you to set your configuration options. 197 | 198 | 2. **Configure Settings:** 199 | - **Ollama Server URL:** 200 | Enter the URL for your Ollama API server in the provided text field and click “Save”. 201 | - **Theme Selection:** 202 | Use the toggle switch to activate the dark theme as desired. 203 | 204 | 3. **Launch the Chat Interface:** 205 | - Click “Show ScribePal chat” in the popup or press Ctrl+Shift+Y. 206 | - A responsive, draggable chat box will open on the active webpage. 207 | - Use the chat interface to send messages to the Ollama AI service, review conversation history, and manage models. 208 | - Additional features include capturing selected HTML content (that can be referenced in the discussion with `@captured-text` tag), capturing an image of an area on the page (that can be referenced in the discussion with `@captured-image` tag) for VISION LLMs, and customizing prompts (to instruct the loaded model on how to answer). 209 | - You can also attach files to the chat using the **Attach Files** button. Uploaded files can be referenced in the discussion using the `@attached-files` tag. 210 | 211 | 4. **Interacting with the Chat:** 212 | - Type your query in the chat input and press Enter or click the `Send` button. 213 | - The AI response is rendered below the input as markdown. 214 | - You can manage (delete or refresh) available Ollama models using the available controls in the model select dropdown. 215 | 216 | Some short video tutorials on how to use the plugin: 217 | 1. Release 1.0.x: 218 | 219 | [![IR7Jufc0zxo](https://img.youtube.com/vi/IR7Jufc0zxo/0.jpg)](https://www.youtube.com/watch?v=IR7Jufc0zxo) 220 | 221 | 2. Release 1.2.x: 222 | 223 | [![m7pw6q5qgY0](https://img.youtube.com/vi/m7pw6q5qgY0/0.jpg)](https://www.youtube.com/watch?v=m7pw6q5qgY0) 224 | 225 | ## License 226 | This project is licensed under the GNU General Public License v3.0. See the [LICENSE](LICENSE) file for more details. 227 | -------------------------------------------------------------------------------- /badges/chrome-add-on-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-forge-temple/scribe-pal/bd297c7cbedc0cf272969731c00b9d983dfb6d2f/badges/chrome-add-on-badge.png -------------------------------------------------------------------------------- /badges/edge-add-on-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-forge-temple/scribe-pal/bd297c7cbedc0cf272969731c00b9d983dfb6d2f/badges/edge-add-on-badge.png -------------------------------------------------------------------------------- /badges/firefox-add-on-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-forge-temple/scribe-pal/bd297c7cbedc0cf272969731c00b9d983dfb6d2f/badges/firefox-add-on-badge.png -------------------------------------------------------------------------------- /base.webpack.config.mjs: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | import path from "path"; 9 | import CopyWebpackPlugin from "copy-webpack-plugin"; 10 | import fs from "fs/promises"; 11 | 12 | function convertToTitleCase (str) { 13 | return str 14 | .split("-") 15 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 16 | .join(""); 17 | } 18 | 19 | function convertToDotNotation (str) { 20 | return str.toLowerCase().trim().split(/\s+/).join("."); 21 | } 22 | 23 | const BUILD_FOLDER = "./dist"; 24 | 25 | const baseConfig = (browser, manifestFile) => ({ 26 | mode: "development", 27 | devtool: "source-map", 28 | entry: { 29 | popup: "./src/popup/popup.ts", 30 | serviceWorker: "./src/tab/serviceWorker.ts", 31 | injectedScript: "./src/tab/injectedScript.ts", 32 | }, 33 | output: { 34 | path: path.resolve(process.cwd(), `${BUILD_FOLDER}/${browser}`), 35 | filename: "[name].js", 36 | clean: true, 37 | }, 38 | optimization: { 39 | runtimeChunk: false, 40 | }, 41 | resolve: { 42 | extensions: [".js", ".jsx", ".ts", ".tsx"], 43 | }, 44 | module: { 45 | rules: [ 46 | { 47 | test: /\.(ts|tsx|js|jsx)$/, 48 | exclude: /node_modules/, 49 | use: { 50 | loader: "babel-loader", 51 | options: { 52 | presets: [ 53 | "@babel/preset-env", 54 | "@babel/preset-react", 55 | "@babel/preset-typescript", 56 | ], 57 | }, 58 | }, 59 | }, 60 | { 61 | test: /\.scss$/, 62 | oneOf: [ 63 | { 64 | resourceQuery: /inline/, 65 | use: ["raw-loader", "sass-loader"], 66 | }, 67 | { 68 | use: ["style-loader", "css-loader", "sass-loader"], 69 | }, 70 | ], 71 | }, 72 | { 73 | test: /\.svg$/i, 74 | use: ["@svgr/webpack"], 75 | exclude: /node_modules/, 76 | }, 77 | ], 78 | }, 79 | plugins: [ 80 | new CopyWebpackPlugin({ 81 | patterns: [ 82 | {from: path.resolve("src/popup/popup.html"), to: "."}, 83 | {from: path.resolve("src/popup/assets"), to: "assets"}, 84 | { 85 | from: path.resolve(manifestFile), 86 | to: "./manifest.json", 87 | transform: async (content) => { 88 | try { 89 | const pkgPath = path.resolve("package.json"); 90 | const pkgData = await fs.readFile(pkgPath, "utf8"); 91 | const pkg = JSON.parse(pkgData); 92 | const manifest = JSON.parse(content.toString()); 93 | 94 | manifest.name = convertToTitleCase(pkg.name); 95 | manifest.version = pkg.version; 96 | manifest.description = pkg.description; 97 | 98 | if(manifest.browser_specific_settings) { 99 | manifest.browser_specific_settings.gecko.id = `${pkg.name}@${convertToDotNotation(pkg.author)}`; 100 | } 101 | 102 | return JSON.stringify(manifest, null, 4); 103 | } catch (error) { 104 | console.error("Error updating manifest.json:", error); 105 | 106 | return content; 107 | } 108 | }, 109 | }, 110 | ], 111 | }), 112 | ], 113 | }); 114 | 115 | export default baseConfig; 116 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | {ignores: ["dist", "node_modules"]}, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx,js,mjs}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | {allowConstantExport: true}, 25 | ], 26 | "max-len": ["error", {code: 180}], 27 | indent: [ 28 | "error", 29 | 4, 30 | { 31 | SwitchCase: 1, 32 | VariableDeclarator: 1, 33 | outerIIFEBody: 1, 34 | FunctionDeclaration: {parameters: "first", body: 1}, 35 | FunctionExpression: {parameters: "first", body: 1}, 36 | CallExpression: {arguments: "first"}, 37 | ArrayExpression: 1, 38 | ObjectExpression: 1, 39 | ImportDeclaration: 1, 40 | flatTernaryExpressions: false, 41 | }, 42 | ], 43 | "@typescript-eslint/no-explicit-any": "off", 44 | "no-trailing-spaces": "error", // Disallow trailing spaces 45 | "no-multi-spaces": "error", // Disallow multiple spaces 46 | "no-irregular-whitespace": "error", // Disallow irregular whitespace 47 | "space-in-parens": ["error", "never"], // Enforce spacing inside parentheses 48 | "space-before-function-paren": ["error", "always"], // Enforce spacing before function parenthesis 49 | "comma-spacing": ["error", {before: false, after: true}], // Enforce spacing after commas 50 | "object-curly-spacing": ["error", "never"], // Enforce spacing inside curly braces 51 | }, 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /firefox.manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "TBD", 4 | "version": "TBD", 5 | "description": "TBD", 6 | "icons": { 7 | "16": "assets/icon16.png", 8 | "48": "assets/icon48.png", 9 | "128": "assets/icon128.png" 10 | }, 11 | "browser_action": { 12 | "default_popup": "popup.html", 13 | "default_icon": { 14 | "16": "assets/icon16.png", 15 | "48": "assets/icon48.png", 16 | "128": "assets/icon128.png" 17 | } 18 | }, 19 | "permissions": [ 20 | "activeTab", 21 | "storage", 22 | "webNavigation", 23 | "notifications", 24 | "http://*/*", 25 | "https://*/*" 26 | ], 27 | "background": { 28 | "scripts": [ 29 | "serviceWorker.js" 30 | ], 31 | "persistent": true 32 | }, 33 | "content_scripts": [ 34 | { 35 | "matches": [ 36 | "" 37 | ], 38 | "js": [ 39 | "contentReceiver.js" 40 | ], 41 | "run_at": "document_start" 42 | } 43 | ], 44 | "browser_specific_settings": { 45 | "gecko": { 46 | "id": "TBD", 47 | "strict_min_version": "91.0" 48 | } 49 | }, 50 | "commands": { 51 | "show-chat": { 52 | "suggested_key": { 53 | "default": "Ctrl+Shift+Y", 54 | "mac": "Command+Shift+Y" 55 | }, 56 | "description": "Show ScribePal chat" 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "TBD", 4 | "version": "TBD", 5 | "description": "TBD", 6 | "icons": { 7 | "16": "assets/icon16.png", 8 | "48": "assets/icon48.png", 9 | "128": "assets/icon128.png" 10 | }, 11 | "action": { 12 | "default_popup": "popup.html", 13 | "default_icon": { 14 | "16": "assets/icon16.png", 15 | "48": "assets/icon48.png", 16 | "128": "assets/icon128.png" 17 | } 18 | }, 19 | "host_permissions": [ 20 | "http://*/*", 21 | "https://*/*" 22 | ], 23 | "background": { 24 | "service_worker": "serviceWorker.js", 25 | "type": "module" 26 | }, 27 | "permissions": [ 28 | "activeTab", 29 | "scripting", 30 | "storage", 31 | "webNavigation", 32 | "notifications", 33 | "commands" 34 | ], 35 | "commands": { 36 | "show-chat": { 37 | "suggested_key": { 38 | "default": "Ctrl+Shift+Y", 39 | "mac": "Command+Shift+Y" 40 | }, 41 | "description": "Show ScribePal chat" 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scribe-pal", 3 | "version": "1.5.1", 4 | "description": "ScribePal is an intelligent browser extension that leverages AI to empower your web experience.", 5 | "author": "Code Forge Temple", 6 | "type": "module", 7 | "license": "GPL", 8 | "scripts": { 9 | "typecheck": "tsc --noEmit", 10 | "dev:chrome": "tsc --noEmit && webpack --mode development --env browser=chrome --config webpack.config.mjs", 11 | "dev:firefox": "tsc --noEmit && webpack --mode development --env browser=firefox --config webpack.config.mjs", 12 | "build:chrome": "tsc --noEmit && webpack --mode production --env browser=chrome --config webpack.config.mjs", 13 | "build:firefox": "tsc --noEmit && webpack --mode production --env browser=firefox --config webpack.config.mjs", 14 | "lint": "eslint .", 15 | "dev": "npm run lint && concurrently \"npm run dev:chrome\" \"npm run dev:firefox\"", 16 | "build": "npm run lint && concurrently \"npm run build:chrome\" \"npm run build:firefox\"" 17 | }, 18 | "dependencies": { 19 | "crypto-browserify": "^3.12.1", 20 | "ollama": "^0.5.12", 21 | "path-browserify": "^1.0.1", 22 | "react": "^19.0.0", 23 | "react-dom": "^19.0.0", 24 | "react-markdown": "^9.0.3", 25 | "react-syntax-highlighter": "^15.6.1", 26 | "remark-gfm": "^4.0.0" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.27.1", 30 | "@babel/preset-env": "^7.27.2", 31 | "@babel/preset-react": "^7.27.1", 32 | "@babel/preset-typescript": "^7.27.1", 33 | "@eslint/js": "^9.21.0", 34 | "@svgr/webpack": "^8.1.0", 35 | "@types/chrome": "^0.0.304", 36 | "@types/react": "^19.0.8", 37 | "@types/react-dom": "^19.0.3", 38 | "@types/react-syntax-highlighter": "^15.5.13", 39 | "babel-loader": "^9.2.1", 40 | "concurrently": "^9.1.2", 41 | "copy-webpack-plugin": "^12.0.2", 42 | "css-loader": "^7.1.2", 43 | "eslint": "^9.21.0", 44 | "eslint-plugin-react-hooks": "^5.1.0", 45 | "eslint-plugin-react-refresh": "^0.4.19", 46 | "globals": "^16.0.0", 47 | "raw-loader": "^4.0.2", 48 | "sass": "^1.84.0", 49 | "sass-loader": "^16.0.4", 50 | "style-loader": "^4.0.0", 51 | "typescript": "^5.7.3", 52 | "typescript-eslint": "^8.24.1", 53 | "webpack": "^5.97.1", 54 | "webpack-cli": "^6.0.1" 55 | } 56 | } -------------------------------------------------------------------------------- /src/common/browser.ts: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | export const browser = (globalThis as any).browser || (globalThis as any).chrome || {}; 9 | 10 | export const getManifestVersion = () => { 11 | return Number(browser.runtime.getManifest().manifest_version); 12 | } -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | export const EXTENSION_NAME = "ScribePal"; 9 | 10 | export const MESSAGE_TYPES = { 11 | ACTION_SHOW_CHAT: "actionShowChat", 12 | ACTION_OLLAMA_HOST_UPDATED: "actionOllamaHostUpdated", 13 | ACTION_UPDATE_THEME: "actionUpdateTheme", 14 | FETCH_MODELS: "fetchModels", 15 | FETCH_MODEL: "fetchModel", 16 | FETCH_AI_RESPONSE: "fetchAIResponse", 17 | ABORT_AI_RESPONSE: "abortAIResponse", 18 | CAPTURE_HTML: "captureHtml", 19 | DELETE_MODEL: "deleteModel", 20 | TOGGLE_CHAT_VISIBILITY: "toggleChatVisibility", 21 | CAPTURE_VISIBLE_TAB: "captureVisibleTab", 22 | ACTION_DEFAULT_LLM_UPDATED: "actionDefaultLlmUpdated", 23 | } as const; -------------------------------------------------------------------------------- /src/common/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $dark-theme-primary-color: #212121; 2 | $dark-theme-secondary-color: #303030; -------------------------------------------------------------------------------- /src/popup/assets/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-forge-temple/scribe-pal/bd297c7cbedc0cf272969731c00b9d983dfb6d2f/src/popup/assets/icon128.png -------------------------------------------------------------------------------- /src/popup/assets/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-forge-temple/scribe-pal/bd297c7cbedc0cf272969731c00b9d983dfb6d2f/src/popup/assets/icon16.png -------------------------------------------------------------------------------- /src/popup/assets/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-forge-temple/scribe-pal/bd297c7cbedc0cf272969731c00b9d983dfb6d2f/src/popup/assets/icon48.png -------------------------------------------------------------------------------- /src/popup/assets/nav-arrow-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/popup/assets/switch-off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/popup/assets/switch-on.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 13 | 18 | 21 | 34 |
    35 | 38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/popup/popup.scss: -------------------------------------------------------------------------------- 1 | @use "../common/styles/variables.scss" as vars; 2 | 3 | html, 4 | body { 5 | width: 400px; 6 | padding: 0; 7 | margin: 0; 8 | overflow: hidden; 9 | } 10 | 11 | .list-group { 12 | display: flex; 13 | flex-direction: column; 14 | padding: 0; 15 | margin: 0; 16 | 17 | .list-group-item { 18 | position: relative; 19 | margin-bottom: -1px; 20 | } 21 | 22 | .list-group-item:hover { 23 | cursor: pointer; 24 | background-color: #0d6efd; 25 | color: white; 26 | } 27 | } 28 | 29 | $menu-item-height: 40px; 30 | $menu-item-padding: 5px; 31 | $menu-item-border-bottom: 1px; 32 | 33 | .menu-item, 34 | .menu-item-short { 35 | display: flex; 36 | align-items: center; 37 | padding: $menu-item-padding; 38 | height: $menu-item-height; 39 | border-bottom: $menu-item-border-bottom solid #ccc; 40 | } 41 | 42 | .menu-item-short { 43 | height: 20px; 44 | } 45 | 46 | #extra-settings-toggle { 47 | height: 25px; 48 | margin: auto; 49 | cursor: pointer; 50 | transition: transform 0.5s ease; 51 | } 52 | 53 | .rotated { 54 | transform: rotate(180deg); 55 | } 56 | 57 | $extra-settings-items: 2; // TODO: if we add more settings, we need to update this value 58 | 59 | .extra-settings { 60 | overflow: hidden; 61 | transition: max-height 0.5s ease-in-out; 62 | max-height: calc($menu-item-height + $menu-item-padding*2 + $menu-item-border-bottom) * $extra-settings-items; 63 | } 64 | 65 | .collapsed { 66 | max-height: 0; 67 | } 68 | 69 | .theme-selection { 70 | #dark-theme-toggle { 71 | cursor: pointer; 72 | margin-left: 10px; 73 | height: 37px; 74 | } 75 | 76 | #dark-theme-status { 77 | margin-left: 5px; 78 | } 79 | } 80 | 81 | .input-with-button { 82 | label { 83 | padding-right: 5px; 84 | min-width: 110px; 85 | } 86 | 87 | input[type="text"], select { 88 | flex-grow: 1; 89 | padding: 5px; 90 | border: 1px solid #ccc; 91 | border-radius: 4px 0 0 4px; 92 | } 93 | 94 | select { 95 | cursor: pointer; 96 | width: auto; 97 | padding: 4px 0px 4px 0px; 98 | max-width: 200px; 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | white-space: nowrap; 102 | } 103 | 104 | button { 105 | padding: 6px 10px; 106 | background: #0078d7; 107 | color: #fff; 108 | border: none; 109 | border-radius: 0 4px 4px 0; 110 | cursor: pointer; 111 | min-width: 75px; 112 | 113 | &:hover { 114 | background: #005fa3; 115 | } 116 | } 117 | } 118 | 119 | .dark-theme { 120 | background: vars.$dark-theme-primary-color; 121 | color: white; 122 | 123 | .menu-item, 124 | .menu-item-short { 125 | border-color: vars.$dark-theme-secondary-color; 126 | } 127 | 128 | .input-with-button { 129 | border-color: vars.$dark-theme-secondary-color; 130 | 131 | input, select { 132 | background: vars.$dark-theme-secondary-color; 133 | color: white; 134 | border-color: vars.$dark-theme-secondary-color !important; 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /src/popup/popup.ts: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | import {browser} from "../common/browser"; 9 | import {MESSAGE_TYPES} from "../common/constants"; 10 | import {Model} from "../tab/utils/types"; 11 | import "./popup.scss"; 12 | 13 | 14 | const showNotification = (title: string, message: string) => { 15 | const iconUrl = browser.runtime.getURL("assets/icon48.png"); 16 | browser.notifications.create("", { 17 | type: "basic", 18 | iconUrl, 19 | title, 20 | message, 21 | }); 22 | }; 23 | 24 | document.addEventListener("DOMContentLoaded", () => { 25 | const toggleBtn = document.getElementById("extra-settings-toggle"); 26 | const extraSettings = document.querySelector(".extra-settings"); 27 | 28 | if (toggleBtn && extraSettings) { 29 | toggleBtn.addEventListener("click", async () => { 30 | const extraSettingsCollapsed = extraSettings.classList.toggle("collapsed"); 31 | toggleBtn.classList.toggle("rotated"); 32 | 33 | /* For Firefox */ 34 | document.documentElement.style.height = "auto"; 35 | 36 | setTimeout(() => { 37 | document.documentElement.style.height = `${document.body.scrollHeight}px`; 38 | }, 500); 39 | /***************/ 40 | 41 | if(!extraSettingsCollapsed) { 42 | const response = await browser.runtime.sendMessage({type: MESSAGE_TYPES.FETCH_MODELS}); 43 | 44 | if (response && response.models) { 45 | const {defaultLlm} = await browser.storage.local.get("defaultLlm"); 46 | const defaultLlmSelect = document.getElementById("default-llm") as HTMLSelectElement; 47 | const models: Model[] = response.models; 48 | const defaultLlmOptions = models.map((model: Model) => { 49 | const option = document.createElement("option"); 50 | 51 | option.value = model.name; 52 | option.textContent = model.name; 53 | 54 | if (defaultLlm && model.name === defaultLlm) { 55 | option.selected = true; 56 | } 57 | 58 | return option; 59 | }); 60 | const defaultLlmLabel = document.createElement("option"); 61 | 62 | defaultLlmLabel.value = ""; 63 | defaultLlmLabel.textContent = "Select a model..."; 64 | 65 | if(!defaultLlm) { 66 | defaultLlmLabel.selected = true; 67 | } 68 | 69 | defaultLlmSelect.innerHTML = ""; 70 | defaultLlmSelect.append(defaultLlmLabel); 71 | defaultLlmSelect.append(...defaultLlmOptions); 72 | } 73 | } 74 | }); 75 | } 76 | 77 | if (browser.commands && browser.commands.getAll) { 78 | browser.commands.getAll().then((commands: any[]) => { 79 | const showChatCommand = commands.find(cmd => cmd.name === 'show-chat'); 80 | const shortcutInput = document.getElementById("keyboard-shortcut") as HTMLInputElement; 81 | 82 | if (showChatCommand?.shortcut && shortcutInput) { 83 | shortcutInput.value = showChatCommand.shortcut; 84 | shortcutInput.title = "Click 'Change Shortcut' to modify"; 85 | 86 | const showChatButton = document.getElementById("showChat"); 87 | 88 | if(showChatButton) { 89 | showChatButton.textContent = `${showChatButton.textContent} (${showChatCommand.shortcut})`; 90 | } 91 | } 92 | }); 93 | } 94 | 95 | browser.storage.local.get(["ollamaHost", "activeTheme"], (result: any) => { 96 | const ollamaUrl = document.getElementById("ollama-url") as HTMLInputElement; 97 | const toggle = document.getElementById("dark-theme-toggle") as HTMLImageElement | null; 98 | const status = document.getElementById("dark-theme-status") as HTMLSpanElement | null; 99 | let darkThemeActive = result.activeTheme === "dark"; 100 | 101 | if (result.ollamaHost) { 102 | ollamaUrl.value = result.ollamaHost; 103 | } 104 | 105 | if (darkThemeActive) { 106 | document.body.classList.add("dark-theme"); 107 | 108 | if (toggle) toggle.src = "assets/switch-on.svg"; 109 | if (status) status.textContent = "On"; 110 | } else { 111 | document.body.classList.remove("dark-theme"); 112 | 113 | if (toggle) toggle.src = "assets/switch-off.svg"; 114 | if (status) status.textContent = "Off"; 115 | } 116 | 117 | if (toggle && status) { 118 | toggle.addEventListener("click", () => { 119 | darkThemeActive = !darkThemeActive; 120 | toggle.src = darkThemeActive ? "assets/switch-on.svg" : "assets/switch-off.svg"; 121 | status.textContent = darkThemeActive ? "On" : "Off"; 122 | 123 | if (darkThemeActive) { 124 | document.body.classList.add("dark-theme"); 125 | } else { 126 | document.body.classList.remove("dark-theme"); 127 | } 128 | 129 | browser.storage.local.set({activeTheme: darkThemeActive ? "dark" : "light"}); 130 | 131 | browser.runtime.sendMessage({ 132 | type: MESSAGE_TYPES.ACTION_UPDATE_THEME, 133 | theme: darkThemeActive ? "dark" : "light", 134 | }); 135 | }); 136 | } 137 | }); 138 | }); 139 | 140 | document.getElementById("save-ollama-url")?.addEventListener("click", () => { 141 | const ollamaUrl = document.getElementById("ollama-url") as HTMLInputElement; 142 | const url = ollamaUrl.value.trim(); 143 | 144 | if (!url.startsWith("http")) { 145 | showNotification("Invalid URL", "Please enter a valid URL (e.g., http://localhost:11434)"); 146 | 147 | return; 148 | } 149 | 150 | browser.storage.local.set({ollamaHost: url}, () => { 151 | showNotification("Saved", "Ollama API URL saved!"); 152 | 153 | browser.runtime.sendMessage({ 154 | type: MESSAGE_TYPES.ACTION_OLLAMA_HOST_UPDATED 155 | }); 156 | }); 157 | }); 158 | 159 | document.getElementById("showChat")?.addEventListener("click", () => { 160 | browser.runtime.sendMessage({ 161 | type: MESSAGE_TYPES.ACTION_SHOW_CHAT, 162 | message: "showChat", 163 | }); 164 | }); 165 | 166 | document.getElementById("save-default-llm")?.addEventListener("click", () => { 167 | const selectedLlm = (document.getElementById("default-llm") as HTMLSelectElement).value; 168 | 169 | browser.storage.local.set({defaultLlm: selectedLlm}, () => { 170 | showNotification("Saved", "Default LLM saved!"); 171 | 172 | browser.runtime.sendMessage({ 173 | type: MESSAGE_TYPES.ACTION_DEFAULT_LLM_UPDATED 174 | }); 175 | }); 176 | }); 177 | 178 | document.getElementById("save-keyboard-shortcut")?.addEventListener("click", async () => { 179 | try { 180 | const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; 181 | 182 | if (isFirefox) { 183 | showNotification( 184 | "Keyboard Shortcuts", 185 | "Type 'about:addons' in URL bar → Extensions → ScribePal → ⚙️ → Shortcuts" 186 | ); 187 | } else { 188 | await browser.tabs.create({ 189 | url: 'chrome://extensions/shortcuts' 190 | }); 191 | 192 | showNotification( 193 | "Keyboard Shortcuts", 194 | "Please modify the shortcut in Chrome's extension shortcuts page" 195 | ); 196 | } 197 | } catch (error) { 198 | console.error('Error handling shortcuts:', error); 199 | 200 | showNotification( 201 | "Keyboard Shortcuts", 202 | `Error handling shortcuts: ${error}` 203 | ); 204 | } 205 | }); -------------------------------------------------------------------------------- /src/tab/assets/attachment.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/bin-full.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/download-circle-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/eye-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/media-image.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/menu-scale.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/priority-down-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/refresh-double.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/assets/square-dashed.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tab/components/App/App.scss: -------------------------------------------------------------------------------- 1 | @use "../../../common/styles/variables.scss" as commonVars; 2 | 3 | *:not(style):not(svg):not(svg *) { 4 | all: unset; 5 | box-sizing: border-box; 6 | } 7 | 8 | ::-webkit-scrollbar { 9 | width: 8px; 10 | height: 8px; 11 | } 12 | 13 | ::-webkit-scrollbar-thumb { 14 | background: #666767; 15 | border-radius: 10px; 16 | } 17 | 18 | ::-webkit-scrollbar-track { 19 | box-shadow: inset 0 0 5px grey; 20 | border-radius: 10px; 21 | } 22 | 23 | .dark-theme { 24 | background: commonVars.$dark-theme-primary-color; 25 | color: white; 26 | 27 | input[type="text"]::selection, 28 | textarea::selection { 29 | background: #3399ff; 30 | color: white; 31 | } 32 | } 33 | 34 | .prevent-select { 35 | -webkit-user-select: none; 36 | -ms-user-select: none; 37 | user-select: none; 38 | } -------------------------------------------------------------------------------- /src/tab/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from "./App.scss?inline"; 3 | import {withShadowStyles} from '../../utils/withShadowStyles'; 4 | 5 | type AppProps= { 6 | children: React.ReactNode; 7 | } 8 | 9 | export const App=withShadowStyles(({children}: AppProps)=> { 10 | return <>{children}; 11 | }, styles); -------------------------------------------------------------------------------- /src/tab/components/App/index.ts: -------------------------------------------------------------------------------- 1 | export * from './App'; -------------------------------------------------------------------------------- /src/tab/components/ChatBox/ChatBox.scss: -------------------------------------------------------------------------------- 1 | @use "../../../common/styles/variables.scss" as commonVars; 2 | @use "../../styles/variables.scss" as vars; 3 | @use "../../styles/mixins.scss" as mixins; 4 | 5 | 6 | .chat-box { 7 | @include mixins.modal-window; 8 | width: vars.$default-modal-width; 9 | top: 100px; 10 | left: 100px; 11 | transform: none; 12 | line-height: normal; 13 | } 14 | 15 | .dark-theme { 16 | &.chat-box { 17 | border-color: commonVars.$dark-theme-primary-color; 18 | } 19 | } -------------------------------------------------------------------------------- /src/tab/components/ChatBox/ChatBox.tsx: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | import React, {useRef, useEffect, useState, useCallback} from "react"; 9 | import {usePersistentState, useDraggablePosition} from "../../hooks"; 10 | import {ChatBoxIds, FileData, Model} from "../../utils/types"; 11 | import {EXTENSION_NAME, MESSAGE_TYPES} from "../../../common/constants"; 12 | import {ChatInput} from "./components/ChatInput"; 13 | import {ChatLog} from "./components/ChatLog"; 14 | import {ChatHeader} from "./components/ChatHeader"; 15 | import {useChatLog} from "../../hooks/useChatLog"; 16 | import {Tools} from "./components/Tools"; 17 | import {useKeepInViewport} from "../../hooks/useKeepInViewport"; 18 | import {startCapture} from "../../features/capture/captureTool"; 19 | import {RichTextModal} from "./components/RichTextModal"; 20 | import {ReferencesList} from "./components/ReferencesList"; 21 | import {getLanguageForExtension, prefixChatBoxId} from "../../utils/utils"; 22 | import PromptSvg from '../../assets/eye-solid.svg'; 23 | import CaptureTxtSvg from '../../assets/menu-scale.svg'; 24 | import CaptureImgSvg from '../../assets/media-image.svg'; 25 | import AttachSvg from '../../assets/attachment.svg'; 26 | import {useTheme} from "../../hooks/useTheme"; 27 | import {polyfillRuntimeSendMessage, polyfillGetTabStorage, polyfillRuntimeConnect, polyfillSetTabStorage} from "../../privilegedAPIs/privilegedAPIs"; 28 | import {browser} from "../../../common/browser"; 29 | import styles from "./ChatBox.scss?inline"; 30 | import {withShadowStyles} from "../../utils/withShadowStyles"; 31 | import {startCaptureImage} from "../../features/capture/captureImageTool"; 32 | import {ImageModal} from "./components/ImageModal"; 33 | import {ATTACHED_TAG, CAPTURED_IMAGE_TAG, CAPTURED_TAG} from "../../utils/constants"; 34 | import {FilesModal} from "./components/FilesModal"; 35 | 36 | const formatMessage = (message: string, {capturedText, capturedImage, attachedFiles}: {capturedText: string, capturedImage: string, attachedFiles: FileData[]}) => { 37 | let newMessage = message.replace( 38 | CAPTURED_IMAGE_TAG, 39 | capturedImage ? (`\n![Captured Image](${capturedImage})\n`) : "" 40 | ); 41 | 42 | let attachedFilesContents: string|undefined = ""; 43 | 44 | for(const attachedFile of attachedFiles) { 45 | const fileExtension = attachedFile.name.split(".").pop()?.toLowerCase(); 46 | const language = getLanguageForExtension(fileExtension); 47 | 48 | attachedFilesContents += `\n\`\`\`${language}\n${attachedFile.content}\n\`\`\`\n`; 49 | } 50 | 51 | if(attachedFilesContents) { 52 | newMessage = newMessage.replace(ATTACHED_TAG, attachedFilesContents); 53 | } 54 | 55 | newMessage = newMessage.replace( 56 | CAPTURED_TAG, 57 | capturedText ? ("\n```text\n" + capturedText + "\n```\n") : "" 58 | ); 59 | 60 | return newMessage; 61 | } 62 | 63 | const ROLE = { 64 | USER: "user", 65 | SYSTEM: "system", 66 | ASSISTANT: "assistant" 67 | } as const; 68 | 69 | type ChatBoxProps = ChatBoxIds & { 70 | onRemove: () => void; 71 | coordsOffset: number; 72 | }; 73 | 74 | export const ChatBox = withShadowStyles(({tabId, chatBoxId, onRemove, coordsOffset}: ChatBoxProps) => { 75 | const boxRef = useRef(null); 76 | const [isVisible, setIsVisible] = useState(false); 77 | const [position, setPosition, handleDrag] = useDraggablePosition( 78 | {tabId, chatBoxId}, 79 | { 80 | left: `calc(100% - 600px - ${30 * coordsOffset}px)`, 81 | top: `calc(210px + ${30 * coordsOffset}px)` 82 | }); 83 | const [message, setMessage] = usePersistentState("chatBoxMessage", "", {tabId, chatBoxId}); 84 | const {chatLog, setChatLog} = useChatLog({tabId, chatBoxId}); 85 | const [selectedModel, setSelectedModel] = usePersistentState("chatBoxSelectedModel", "", {tabId, chatBoxId}); 86 | const [models, setModels] = useState([]); 87 | const [isMinimized, setIsMinimized] = usePersistentState("chatBoxMinimized", false, {tabId, chatBoxId}); 88 | const [capturedText, setCapturedText] = usePersistentState("capturedText", "", {tabId, chatBoxId}); 89 | const [capturedImage, setCapturedImage] = usePersistentState("capturedImage", "", {tabId, chatBoxId}); 90 | const [capturedModalVisible, setCapturedModalVisible] = useState(false); 91 | const [capturedImageModalVisible, setCapturedImageModalVisible] = useState(false); 92 | const [promptMessage, setPromptMessage] = usePersistentState("promptMessage", "", {tabId, chatBoxId}); 93 | const [promptModalVisible, setPromptModalVisible] = useState(false); 94 | 95 | const [attachedFiles, setAttachedFiles] = usePersistentState("attachedFiles", [], {tabId, chatBoxId}); 96 | const [attachedFilesModalVisible, setAttachedFilesModalVisible] = useState(false); 97 | 98 | const updateTheme = useTheme(boxRef); 99 | 100 | const fetchModels = useCallback(() => { 101 | polyfillRuntimeSendMessage({type: MESSAGE_TYPES.FETCH_MODELS}).then((response: any) => { 102 | if (response && response.models) { 103 | setModels(response.models); 104 | } else { 105 | setChatLog({ 106 | text: `${EXTENSION_NAME}: ${response && response.error 107 | ? "Error fetching models: " + response.error 108 | : "No models received from service worker." 109 | }`, 110 | sender: EXTENSION_NAME 111 | }); 112 | 113 | setModels([]); 114 | } 115 | }); 116 | }, [setModels, setChatLog]); 117 | 118 | const pollForNewModel = useCallback((targetModel: string) => { 119 | const intervalId = setInterval(() => { 120 | polyfillRuntimeSendMessage({type: MESSAGE_TYPES.FETCH_MODELS}).then((response: any) => { 121 | if (response && response.models) { 122 | setModels(response.models); 123 | 124 | if (response.models.some((model: any) => model.name.startsWith(targetModel))) { 125 | clearInterval(intervalId); 126 | } 127 | } 128 | }); 129 | }, 2000); 130 | }, [setModels]); 131 | 132 | useEffect(() => { 133 | fetchModels(); 134 | // eslint-disable-next-line react-hooks/exhaustive-deps 135 | }, []); 136 | 137 | useEffect(() => { 138 | requestAnimationFrame(() => { 139 | setIsVisible(true); 140 | }); 141 | }, []); 142 | 143 | useEffect(() => { 144 | return handleDrag(boxRef); 145 | }, [handleDrag]); 146 | 147 | useEffect(() => { 148 | const listener = (message: any) => { 149 | if (message.type === MESSAGE_TYPES.ACTION_OLLAMA_HOST_UPDATED) { 150 | fetchModels(); 151 | } else if (message.type === MESSAGE_TYPES.ACTION_UPDATE_THEME) { 152 | updateTheme(); 153 | } 154 | }; 155 | 156 | browser.runtime.onMessage.addListener(listener); 157 | 158 | return () => { 159 | browser.runtime.onMessage.removeListener(listener); 160 | }; 161 | // eslint-disable-next-line react-hooks/exhaustive-deps 162 | }, [updateTheme]); 163 | 164 | useEffect(() => { 165 | const listener = (event: MessageEvent) => { 166 | const {type, visible} = event.data; 167 | 168 | if (type === MESSAGE_TYPES.TOGGLE_CHAT_VISIBILITY) { 169 | setIsVisible(visible); 170 | } 171 | }; 172 | 173 | window.addEventListener("message", listener); 174 | 175 | return () => { 176 | window.removeEventListener("message", listener); 177 | }; 178 | }, []); 179 | 180 | useKeepInViewport({ref: boxRef, position, setPosition, isVisible}); 181 | 182 | const handleSend = async () => { 183 | if (message.trim() === "") return; 184 | 185 | const formattedMessage = formatMessage(message, {capturedText, capturedImage, attachedFiles}); 186 | 187 | const conversationHistory = chatLog 188 | .filter((msg) => !msg.loading) 189 | .map((msg) => ({ 190 | role: msg.sender === ROLE.USER ? ROLE.USER : ROLE.ASSISTANT, 191 | content: msg.text 192 | })); 193 | 194 | const systemMessage = promptMessage.trim() 195 | ? [{role: ROLE.SYSTEM, content: promptMessage.trim()}] 196 | : []; 197 | 198 | const conversation = [ 199 | ...systemMessage, 200 | ...conversationHistory, 201 | {role: ROLE.USER, content: formattedMessage} 202 | ]; 203 | 204 | setChatLog({text: formattedMessage, sender: ROLE.USER}); 205 | 206 | if (!selectedModel) { 207 | setChatLog({ 208 | text: `${EXTENSION_NAME}: Please select a model before sending a message.`, 209 | sender: EXTENSION_NAME 210 | }); 211 | 212 | return; 213 | } 214 | 215 | const pendingMessageId = setChatLog({ 216 | text: `${EXTENSION_NAME}: `, 217 | sender: EXTENSION_NAME, 218 | loading: true 219 | }); 220 | 221 | polyfillRuntimeConnect({ 222 | name: MESSAGE_TYPES.FETCH_AI_RESPONSE, 223 | data: {type: MESSAGE_TYPES.FETCH_AI_RESPONSE, messages: conversation, model: selectedModel}, 224 | onMessage: (response) => { 225 | if ("error" in response) { 226 | setChatLog({ 227 | text: `${EXTENSION_NAME}: Sorry, there was an error: ${response.error}`, 228 | messageId: pendingMessageId, 229 | }); 230 | } else { 231 | setChatLog({text: response.reply, messageId: pendingMessageId}, response.final === true); 232 | } 233 | 234 | setMessage(""); 235 | }, 236 | }); 237 | }; 238 | 239 | const handleClose = () => { 240 | (async () => { 241 | const tabStorage = await polyfillGetTabStorage(tabId); 242 | 243 | if (tabStorage.chatBoxes?.[chatBoxId]) { 244 | delete tabStorage.chatBoxes[chatBoxId]; 245 | 246 | await polyfillSetTabStorage(tabId, tabStorage); 247 | } 248 | })(); 249 | 250 | onRemove(); 251 | }; 252 | 253 | const handleModelDelete = (modelName: string) => { 254 | if (!window.confirm("Are you sure you want to delete this model?")) { 255 | return; 256 | } 257 | 258 | polyfillRuntimeSendMessage({type: MESSAGE_TYPES.DELETE_MODEL, model: modelName}).then((response: any) => { 259 | if (response && response.success) { 260 | setSelectedModel(""); 261 | fetchModels(); 262 | } else { 263 | setChatLog({ 264 | text: `${EXTENSION_NAME}: Failed to delete model: ${response && response.error ? response.error : "Unknown error" 265 | }`, 266 | sender: EXTENSION_NAME 267 | }); 268 | } 269 | }); 270 | }; 271 | 272 | const handleDeleteMessage = (messageId: string, messageIndex: number) => { 273 | if (messageIndex === chatLog.length - 1) { 274 | polyfillRuntimeSendMessage({type: MESSAGE_TYPES.ABORT_AI_RESPONSE}); 275 | 276 | }; 277 | setChatLog({delete: true, messageId}); 278 | }; 279 | 280 | const handleMinimize = () => { setIsMinimized(!isMinimized); }; 281 | 282 | const toggleChatVisibility = (visible: boolean) => { 283 | window.postMessage( 284 | { 285 | type: MESSAGE_TYPES.TOGGLE_CHAT_VISIBILITY, 286 | visible, 287 | }, 288 | "*" 289 | ); 290 | }; 291 | 292 | const fileInputRef = useRef(null); 293 | 294 | const handleFileChange = (event: React.ChangeEvent) => { 295 | const files = event.target.files; 296 | 297 | if (!files) return; 298 | 299 | if (files.length > 0) { 300 | for (let i = 0; i < files.length; i++) { 301 | const file = files[i]; 302 | const reader = new FileReader(); 303 | 304 | reader.onload = (e) => { 305 | const fileContent = e.target?.result as string; 306 | 307 | const fileData: FileData = { 308 | name: file.name, 309 | content: fileContent, 310 | }; 311 | 312 | setAttachedFiles((prevFiles) => [...prevFiles, fileData]); 313 | }; 314 | 315 | reader.readAsText(file); 316 | } 317 | 318 | if (fileInputRef.current) { 319 | fileInputRef.current.value = ""; 320 | } 321 | } 322 | }; 323 | 324 | return ( 325 |
336 | setChatLog({text: error, sender: EXTENSION_NAME})} 341 | onModelDelete={handleModelDelete} 342 | isMinimized={isMinimized} 343 | onModelSelect={setSelectedModel} 344 | onModelsRefresh={fetchModels} 345 | onMinimize={handleMinimize} 346 | onClose={handleClose} 347 | /> 348 | {!isMinimized && ( 349 | <> 350 | { 355 | const foundMessage = chatLog.find((msg) => msg.id === msgId); 356 | const message = (foundMessage?.text ?? '').replace(`${EXTENSION_NAME}: `, ""); 357 | 358 | navigator.clipboard.writeText(message); 359 | } 360 | } 361 | /> 362 | 370 | { 373 | toggleChatVisibility(false); 374 | 375 | startCapture((capturedText) => { 376 | toggleChatVisibility(true); 377 | 378 | setCapturedText(capturedText); 379 | setCapturedModalVisible(true); 380 | }); 381 | }, 382 | label: <>Capture , 383 | tooltip: "capture text", 384 | }, 385 | { 386 | call: () => { 387 | toggleChatVisibility(false); 388 | 389 | startCaptureImage((capturedImageBase64) => { 390 | toggleChatVisibility(true); 391 | 392 | setCapturedImage(capturedImageBase64); 393 | setCapturedImageModalVisible(true); 394 | }); 395 | }, 396 | label: <>Capture , 397 | tooltip: "capture image", 398 | }, 399 | { 400 | call: () => { 401 | if (fileInputRef.current) { 402 | fileInputRef.current.click(); 403 | } 404 | }, 405 | label: <>Attach , 406 | tooltip: "attach files", 407 | }, 408 | { 409 | call: () => { 410 | setPromptModalVisible(true); 411 | }, 412 | label: "Prompt", 413 | tooltip: "prompt", 414 | icon: promptMessage ? : undefined 415 | }, 416 | ]} /> 417 | { 423 | setCapturedModalVisible(true); 424 | }, 425 | onClose: () => { 426 | setCapturedText(""); 427 | } 428 | } 429 | ] : []), 430 | ... (capturedImage ? [ 431 | { 432 | text: CAPTURED_IMAGE_TAG, 433 | tooltip: "Click to view captured image", 434 | onClick: () => { 435 | setCapturedImageModalVisible(true); 436 | }, 437 | onClose: () => { 438 | setCapturedImage(""); 439 | } 440 | } 441 | ] : []), 442 | ... (attachedFiles.length ? [ 443 | { 444 | text: ATTACHED_TAG, 445 | tooltip: "Click to view attached files", 446 | onClick: () => { 447 | setAttachedFilesModalVisible(true); 448 | }, 449 | onClose: () => { 450 | setAttachedFiles([]); 451 | } 452 | } 453 | ] : []), 454 | 455 | ]} /> 456 | { setCapturedText(txt); setCapturedModalVisible(false); }} /> 461 | { setAttachedFiles(files); setAttachedFilesModalVisible(false); }} /> 466 | { setCapturedImage(txt); setCapturedImageModalVisible(false); }} /> 471 | { setPromptMessage(txt); setPromptModalVisible(false); }} /> 476 | 481 | 482 | )} 483 |
484 | ); 485 | }, styles); -------------------------------------------------------------------------------- /src/tab/components/ChatBox/components/ChatHeader/ChatHeader.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../../common/styles/variables.scss" as commonVars; 2 | 3 | .chat-box-header { 4 | background: #0078d7; 5 | color: #fff; 6 | padding: 10px; 7 | cursor: move; 8 | border-top-left-radius: 4px; 9 | border-top-right-radius: 4px; 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: space-between; 13 | 14 | .model-selection { 15 | display: flex; 16 | 17 | select { 18 | cursor: pointer; 19 | padding: 0 5px 0 5px; 20 | width: auto; 21 | max-width: 150px; 22 | overflow: hidden; 23 | text-overflow: ellipsis; 24 | white-space: nowrap; 25 | 26 | option { 27 | color: black; 28 | background: white; 29 | } 30 | } 31 | 32 | .delete-model { 33 | color: white; 34 | } 35 | 36 | svg { 37 | cursor: pointer; 38 | height: 21px; 39 | width: auto; 40 | margin-right: 5px; 41 | display: block; 42 | } 43 | } 44 | 45 | .header-buttons { 46 | width: auto; 47 | margin: 0; 48 | padding: 0; 49 | background: transparent; 50 | box-shadow: none; 51 | border-radius: 0; 52 | } 53 | 54 | .header-button { 55 | cursor: pointer; 56 | margin-left: 10px; 57 | } 58 | 59 | } 60 | 61 | .dark-theme { 62 | .chat-box-header { 63 | select { 64 | option { 65 | background: commonVars.$dark-theme-secondary-color; 66 | color: white; 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /src/tab/components/ChatBox/components/ChatHeader/ChatHeader.tsx: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | import React, {useEffect, useState} from 'react'; 9 | import {Model} from '../../../../utils/types'; 10 | import {EXTENSION_NAME, MESSAGE_TYPES} from '../../../../../common/constants'; 11 | import {RichTextModal} from '../RichTextModal'; 12 | import DeleteModelSvg from '../../../../assets/bin-full.svg'; 13 | import {Tooltip} from '../Tooltip/Tooltip'; 14 | import {polyfillRuntimeConnect, polyfillStorageLocalGet} from '../../../../privilegedAPIs/privilegedAPIs'; 15 | import styles from "./ChatHeader.scss?inline"; 16 | import {withShadowStyles} from '../../../../utils/withShadowStyles'; 17 | import {browser} from '../../../../../common/browser'; 18 | 19 | 20 | type ChatHeaderProps = { 21 | selectedModel: string; 22 | models: Model[]; 23 | isMinimized: boolean; 24 | onModelSelect: (model: string) => void; 25 | onMinimize: () => void; 26 | onNewModel: (modelName: string) => void; 27 | onError: (error: string) => void; 28 | onModelsRefresh: () => void; 29 | onModelDelete: (modelName: string) => void; 30 | onClose: () => void; 31 | } 32 | 33 | const SELECT_MODEL_ACTIONS = { 34 | REFRESH: "REFRESH", 35 | NEW: "NEW", 36 | } 37 | 38 | export const ChatHeader = withShadowStyles(({ 39 | selectedModel, 40 | models, 41 | isMinimized, 42 | onModelSelect, 43 | onMinimize, 44 | onNewModel, 45 | onError, 46 | onModelsRefresh, 47 | onModelDelete, 48 | onClose, 49 | }: ChatHeaderProps) => { 50 | const [newModelModalVisible, setNewModelModalVisible] = useState(false); 51 | const [newModel, setNewModel] = useState(""); 52 | const [isModelDownloading, setIsModelDownloading] = useState(false); 53 | const [modelDownloadedPercentage, setModelDownloadedPercentage] = useState(0); 54 | 55 | const onModelDownload = (modelName: string) => { 56 | setNewModel(modelName); 57 | setNewModelModalVisible(false); 58 | 59 | if (!modelName) return; 60 | 61 | setIsModelDownloading(true); 62 | 63 | polyfillRuntimeConnect({ 64 | name: MESSAGE_TYPES.FETCH_MODEL, 65 | data: {type: MESSAGE_TYPES.FETCH_MODEL, model: modelName}, 66 | onMessage: (response) => { 67 | if ("error" in response) { 68 | console.error("Pull model error:", response.error); 69 | 70 | onError(`"Pull model error: ${response.error}`); 71 | 72 | setIsModelDownloading(false); 73 | setNewModel(""); 74 | } else { 75 | setModelDownloadedPercentage(response.reply); 76 | 77 | if (response.reply === 100) { 78 | setIsModelDownloading(false); 79 | onNewModel(modelName); 80 | setNewModel(""); 81 | } 82 | } 83 | }, 84 | }); 85 | } 86 | 87 | useEffect(() => { 88 | const updateDefaultLlm = () => { 89 | if (!selectedModel) { 90 | polyfillStorageLocalGet("defaultLlm").then(({defaultLlm}: { defaultLlm: string }) => { 91 | if (defaultLlm && models.length && models.some(model => model.name === defaultLlm)) { 92 | onModelSelect(defaultLlm); 93 | } 94 | }); 95 | } 96 | }; 97 | 98 | const listener = (message: any) => { 99 | if (message.type === MESSAGE_TYPES.ACTION_DEFAULT_LLM_UPDATED) { 100 | updateDefaultLlm(); 101 | } 102 | }; 103 | 104 | browser.runtime.onMessage.addListener(listener); 105 | 106 | updateDefaultLlm(); 107 | 108 | return () => { 109 | browser.runtime.onMessage.removeListener(listener); 110 | }; 111 | }, [models, onModelSelect, selectedModel]); 112 | 113 | return ( 114 | <> 115 |
116 | {`${EXTENSION_NAME} Chat`} 117 |
118 | {selectedModel ? ( 119 | 120 | { onModelDelete(selectedModel) }} /> 121 | 122 | ) : null} 123 | 153 |
154 |
155 | 162 | 169 |
170 |
171 | 178 | 179 | ); 180 | }, styles); -------------------------------------------------------------------------------- /src/tab/components/ChatBox/components/ChatHeader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatHeader'; -------------------------------------------------------------------------------- /src/tab/components/ChatBox/components/ChatInput/ChatInput.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../../common/styles/variables.scss" as commonVars; 2 | @use "../../../../styles/mixins.scss" as mixins; 3 | 4 | .chat-input-container { 5 | display: flex; 6 | margin: 10px 0 0 0; 7 | 8 | textarea { 9 | flex-grow: 1; 10 | padding: 5px; 11 | border: 1px solid #ccc; 12 | border-radius: 4px 0 0 4px; 13 | background: white; 14 | color: black; 15 | white-space: nowrap; 16 | overflow: hidden; 17 | resize: none; 18 | font-family: monospace; 19 | } 20 | 21 | button { 22 | @include mixins.button-style; 23 | border-radius: 0 4px 4px 0; 24 | 25 | } 26 | } 27 | 28 | .dark-theme { 29 | .chat-input-container { 30 | textarea { 31 | background: commonVars.$dark-theme-secondary-color; 32 | border-color: commonVars.$dark-theme-secondary-color; 33 | color: white; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/tab/components/ChatBox/components/ChatInput/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | /************************************************************************ 2 | * Copyright (C) 2025 Code Forge Temple * 3 | * This file is part of scribe-pal project * 4 | * Licensed under the GNU General Public License v3.0. * 5 | * See the LICENSE file in the project root for more information. * 6 | ************************************************************************/ 7 | 8 | import React, {useRef, useState, useLayoutEffect, useEffect} from 'react'; 9 | import styles from "./ChatInput.scss?inline"; 10 | import {withShadowStyles} from '../../../../utils/withShadowStyles'; 11 | import {SUGGESTIONS} from '../../../../utils/constants'; 12 | import {ChatInputSuggestions} from './components/ChatInputSuggestions'; 13 | 14 | type ChatInputProps = { 15 | message: string; 16 | onMessageChange: (value: string) => void; 17 | onSend: () => void; 18 | } 19 | 20 | export const ChatInput = withShadowStyles(({message, onMessageChange, onSend}: ChatInputProps) => { 21 | const inputRef = useRef(null); 22 | const [caret, setCaret] = useState(0); 23 | const [suggestionsVisible, setSuggestionsVisible] = useState(false); 24 | const [suggestionPosition, setSuggestionPosition] = useState({top: 0, left: 0}); 25 | const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0); 26 | 27 | useLayoutEffect(() => { 28 | if (inputRef.current) { 29 | inputRef.current.setSelectionRange(caret, caret); 30 | 31 | const canvas = document.createElement('canvas'); 32 | const ctx = canvas.getContext('2d'); 33 | 34 | if (ctx) { 35 | const computedStyle = window.getComputedStyle(inputRef.current); 36 | 37 | ctx.font = computedStyle.font; 38 | 39 | const textBeforeCaret = message.slice(0, caret); 40 | const caretPixelPos = ctx.measureText(textBeforeCaret).width; 41 | 42 | const scrollLeft = inputRef.current.scrollLeft; 43 | const visibleWidth = inputRef.current.clientWidth; 44 | 45 | if (caretPixelPos > scrollLeft + visibleWidth) { 46 | inputRef.current.scrollLeft = caretPixelPos - visibleWidth + ctx.measureText('M').width; 47 | } 48 | } 49 | } 50 | }, [message, caret]); 51 | 52 | useEffect(() => { 53 | const textUpToCaret = message.slice(0, caret); 54 | const lastWord = textUpToCaret.split(" ").pop() || ""; 55 | 56 | if (lastWord.includes("@") && inputRef.current) { 57 | const canvas = document.createElement("canvas"); 58 | const ctx = canvas.getContext("2d"); 59 | 60 | if (ctx) { 61 | const computedStyle = window.getComputedStyle(inputRef.current); 62 | 63 | ctx.font = computedStyle.font; 64 | 65 | const textWidth = ctx.measureText(textUpToCaret).width; 66 | const {top, left} = inputRef.current.getBoundingClientRect(); 67 | 68 | setSuggestionPosition({top: top, left: left + textWidth - inputRef.current.scrollLeft}); 69 | setSuggestionsVisible(true); 70 | 71 | setSelectedSuggestionIndex(0); 72 | } 73 | } else { 74 | setSuggestionsVisible(false); 75 | } 76 | }, [message, caret]); 77 | 78 | const onSuggestionClick = (option: string) => { 79 | const textUpToCaret = message.slice(0, caret); 80 | const restText = message.slice(caret); 81 | const words = textUpToCaret.split(" "); 82 | 83 | words[words.length - 1] = option; 84 | 85 | const newText = words.join(" ") + " " + restText; 86 | const newCaret = words.join(" ").length + 1; 87 | 88 | onMessageChange(newText); 89 | setCaret(newCaret); 90 | setSuggestionsVisible(false); 91 | }; 92 | 93 | return ( 94 |
95 |