├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── stale.yml └── workflows │ ├── build-zip.yml │ ├── greetings.yml │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── Chat.gif ├── ChatWithDocs.gif ├── ChatWithPage.gif ├── LICENSE ├── Overview.png ├── README.md ├── Summary.gif ├── commitlint.config.js ├── jest.config.js ├── manifest.ts ├── package.json ├── pnpm-lock.yaml ├── public ├── icon-128.png └── icon-34.png ├── src ├── assets │ ├── img │ │ └── logo.svg │ └── style │ │ └── theme.scss ├── environment.d.ts ├── global.d.ts ├── pages │ ├── background │ │ └── index.ts │ ├── common │ │ ├── Header.css │ │ └── Header.tsx │ ├── content │ │ └── index.ts │ ├── options │ │ ├── Options.css │ │ ├── Options.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ ├── popup │ │ ├── Popup.css │ │ ├── Popup.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ ├── sidePanel │ │ ├── ChatWithDocument.tsx │ │ ├── Instructions.tsx │ │ ├── PageMetadata.tsx │ │ ├── PageSummary.tsx │ │ ├── QandA.ts │ │ ├── QandABubble.tsx │ │ ├── Settings.tsx │ │ ├── SidePanel.css │ │ ├── SidePanel.tsx │ │ ├── Summarize.ts │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ └── utils │ │ ├── constants.ts │ │ ├── getPageContent.ts │ │ └── processing.ts ├── shared │ └── style │ │ └── twind.ts └── vite-env.d.ts ├── test-utils └── jest.setup.js ├── tsconfig.json ├── twind.config.ts ├── utils ├── log.ts ├── manifest-parser │ └── index.ts ├── plugins │ ├── add-hmr.ts │ ├── custom-dynamic-import.ts │ ├── make-manifest.ts │ └── watch-rebuild.ts └── reload │ ├── constant.ts │ ├── initReloadClient.ts │ ├── initReloadServer.ts │ ├── injections │ ├── script.ts │ └── view.ts │ ├── interpreter │ ├── index.ts │ └── types.ts │ ├── rollup.config.mjs │ └── utils.ts ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:import/recommended", 13 | "plugin:jsx-a11y/recommended", 14 | "prettier" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": "latest", 22 | "sourceType": "module" 23 | }, 24 | "plugins": ["react", "@typescript-eslint", "react-hooks", "import", "jsx-a11y", "prettier"], 25 | "settings": { 26 | "react": { 27 | "version": "detect" 28 | } 29 | }, 30 | "rules": { 31 | "react/react-in-jsx-scope": "off", 32 | "import/no-unresolved": "off" 33 | }, 34 | "globals": { 35 | "chrome": "readonly" 36 | }, 37 | "ignorePatterns": ["watch.js", "dist/**"] 38 | } 39 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Jonghakseo -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: shreyaskarnik 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. Mac, Window, Linux] 30 | - Browser [e.g. chrome, firefox] 31 | - Node Version [e.g. 18.12.0] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: shreyaskarnik 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an Issue or Pull Request becomes stale 2 | daysUntilStale: 90 3 | # Number of days of inactivity before a stale Issue or Pull Request is closed 4 | daysUntilClose: 30 5 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking as stale 10 | staleLabel: stale 11 | # Comment to post when marking as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when removing the stale label. Set to `false` to disable 17 | unmarkComment: false 18 | # Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable 19 | closeComment: ture 20 | # Limit to only `issues` or `pulls` 21 | only: issues 22 | -------------------------------------------------------------------------------- /.github/workflows/build-zip.yml: -------------------------------------------------------------------------------- 1 | name: Build And Upload Extension Zip Via Artifact 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version-file: ".nvmrc" 20 | 21 | - uses: actions/cache@v3 22 | with: 23 | path: node_modules 24 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 25 | 26 | - uses: pnpm/action-setup@v2 27 | 28 | - run: pnpm install --frozen-lockfile 29 | 30 | - run: pnpm build 31 | 32 | - uses: actions/upload-artifact@v3 33 | with: 34 | path: dist/* 35 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | issue-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.' 16 | pr-message: 'Thank you for your contribution. We will check and reply to you as soon as possible.' 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version-file: ".nvmrc" 19 | 20 | - uses: actions/cache@v3 21 | with: 22 | path: node_modules 23 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 24 | 25 | - uses: pnpm/action-setup@v2 26 | 27 | - run: pnpm install --frozen-lockfile 28 | 29 | - run: pnpm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # build 8 | /dist 9 | 10 | # etc 11 | .DS_Store 12 | .env.local 13 | .idea 14 | 15 | # compiled 16 | utils/reload/*.js 17 | utils/reload/injections/*.js 18 | public/manifest.json 19 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run commitlint ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.12.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .gitignore 4 | .github 5 | .eslintignore 6 | .husky 7 | .nvmrc 8 | .prettierignore 9 | LICENSE 10 | *.md 11 | package-lock.json 12 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "printWidth": 120, 7 | "bracketSameLine": true, 8 | "htmlWhitespaceSensitivity": "strict" 9 | } 10 | -------------------------------------------------------------------------------- /Chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/Chat.gif -------------------------------------------------------------------------------- /ChatWithDocs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/ChatWithDocs.gif -------------------------------------------------------------------------------- /ChatWithPage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/ChatWithPage.gif -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shreyas Karnik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/Overview.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DistiLlama 2 | 3 | ![image](public/icon-128.png) 4 | 5 | ## What is DistiLlama? 6 | 7 | DistiLlama is a Chrome extension that leverages locally running LLM perform following tasks. 8 | 9 | ![Overview](./Overview.png) 10 | 11 | One of the things that I was experimenting with is how to use a locally running LLM instance for various tasks and summarization (tl;dr) was on the top of my list. It was key to have all calls to LLM be local and all the data to stay private. 12 | 13 | This project utilizes [Ollama](https://ollama.ai/) as the locally running LLM instance. Ollama is a great project that is easy to setup and use. I highly recommend checking it out. 14 | 15 | To generate the summary I am using the following approach: 16 | 17 | - Grab the current active tab id 18 | - Use [Readability](https://github.com/mozilla/readability) to extract the text content from the page. In my experiments it was clear that the quality of the summary was much better when using Readability as it removed a lot of un-necessary content from the page. 19 | - Use [LangChain (LangChain.js)](https://js.langchain.com/docs/get_started/introduction/) to summarize the text content. 20 | - Display the summary in a popup window. 21 | 22 | ## How to use DistiLlama? 23 | 24 | - Prerequisites: 25 | - Install [Ollama](https://ollama.ai/download) you can also choose to run Ollama in a [Docker container](https://ollama.ai/blog/ollama-is-now-available-as-an-official-docker-image). 26 | - Start Ollama using the following command: `OLLAMA_ORIGINS=* OLLAMA_HOST=127.0.0.1:11435 ollama serve` 27 | - In another terminal you can run `ollama pull llama2:latest` or `ollama pull mistral:latest` 28 | - Choice of model depends on your use case. Here are the models supported by Ollama 29 | - Make sure you set OLLAMA_ORIGINS=* for the Ollama environment by following instructions [here](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) 30 | 31 | - Clone this repo 32 | - Install pnpm `npm install -g pnpm` 33 | - run `pnpm install` 34 | - run `pnpm dev` 35 | - Open Chrome and navigate to `chrome://extensions/` 36 | - Enable developer mode (if not already enabled) 37 | - Click on `Load unpacked` and select the `dist` folder from the base of the cloned project. 38 | - You should see the DistiLlama added to your Chrome extensions. 39 | - You may want to pin the extension to your Chrome toolbar for easy access. 40 | 41 | ## Demo 42 | 43 | ### Chat with LLM 44 | 45 | ![Chat](./Chat.gif) 46 | 47 | ### Chat with Documents (PDF) 48 | 49 | ![ChatWithDocs](./ChatWithDocs.gif) 50 | 51 | ### Chat with Web Page 52 | 53 | ![ChatWithPage](./ChatWithPage.gif) 54 | 55 | ### Summarization 56 | 57 | ![Summary](./Summary.gif) 58 | 59 | ## TODOS 60 | 61 | - [ ] Make the summarization chain configurable 62 | - [x] Make LLM model configurable 63 | - [ ] Save summary in local storage 64 | - [ ] Improve the UI (not an expert in this area but will try to learn) 65 | - [ ] Add TTS support 66 | - [ ] Check out performance with different tuned prompts 67 | - [x] Extend to chat with the page (use embeddings and LLMs for RAG) 68 | - [x] Use [transformers.js](https://github.com/xenova/transformers.js) for local in browser embeddings and [Voy](https://github.com/tantaraio/voy) for the storage similar to this [Building LLM-Powered Web Apps with Client-Side Technology](https://ollama.ai/blog/building-llm-powered-web-apps) 69 | - [ ] Focus on improving the quality of the summarization and chat 70 | - [ ] Multimodal support 71 | 72 | ## References and Inspiration 73 | 74 | - [LangChain](https://github.com/langchain-ai/langchainjs) 75 | - [Ollama](https://ollama.ai/) 76 | - [Building LLM-Powered Web Apps with Client-Side Technology](https://ollama.ai/blog/building-llm-powered-web-apps) 77 | - [Chrome Extension Template](https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite) 78 | - Artwork generated using [DALL·E 3](https://openai.com/dall-e-3) 79 | -------------------------------------------------------------------------------- /Summary.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/Summary.gif -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/gs/f4d067nn1sx7q0x__98xhcmw0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls, instances and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: false, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: 'v8', 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: undefined, 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: undefined, 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: 2, 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "jsx", 77 | // "ts", 78 | // "tsx", 79 | // "json", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | moduleNameMapper: { 85 | '^@src(.*)$': '/src$1', 86 | '^@assets(.*)$': '/src/assets$1', 87 | '^@pages(.*)$': '/src/pages$1', 88 | }, 89 | 90 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 91 | // modulePathIgnorePatterns: [], 92 | 93 | // Activates notifications for test results 94 | // notify: false, 95 | 96 | // An enum that specifies notification mode. Requires { notify: true } 97 | // notifyMode: "failure-change", 98 | 99 | // A preset that is used as a base for Jest's configuration 100 | preset: 'ts-jest', 101 | 102 | // Run tests from one or more projects 103 | // projects: undefined, 104 | 105 | // Use this configuration option to add custom reporters to Jest 106 | // reporters: undefined, 107 | 108 | // Automatically reset mock state before every test 109 | // resetMocks: false, 110 | 111 | // Reset the module registry before running each individual test 112 | // resetModules: false, 113 | 114 | // A path to a custom resolver 115 | // resolver: undefined, 116 | 117 | // Automatically restore mock state and implementation before every test 118 | // restoreMocks: false, 119 | 120 | // The root directory that Jest should scan for tests and modules within 121 | // rootDir: undefined, 122 | 123 | // A list of paths to directories that Jest should use to search for files in 124 | // roots: [ 125 | // "" 126 | // ], 127 | 128 | // Allows you to use a custom runner instead of Jest's default test runner 129 | // runner: "jest-runner", 130 | 131 | // The paths to modules that run some code to configure or set up the testing environment before each test 132 | setupFiles: ['./test-utils/jest.setup.js'], 133 | 134 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 135 | // setupFilesAfterEnv: [], 136 | 137 | // The number of seconds after which a test is considered as slow and reported as such in the results. 138 | // slowTestThreshold: 5, 139 | 140 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 141 | // snapshotSerializers: [], 142 | 143 | // The test environment that will be used for testing 144 | testEnvironment: 'jsdom', 145 | 146 | // Options that will be passed to the testEnvironment 147 | // testEnvironmentOptions: {}, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | // testMatch: [ 154 | // "**/__tests__/**/*.[jt]s?(x)", 155 | // "**/?(*.)+(spec|test).[tj]s?(x)" 156 | // ], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | testPathIgnorePatterns: [ 160 | '/node_modules/', 161 | '/test-utils/', 162 | '/vite.config.ts', 163 | '/jest.config.js', 164 | ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 176 | // testURL: "http://localhost", 177 | 178 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 179 | // timers: "real", 180 | 181 | // A map from regular expressions to paths to transformers 182 | // transform: { 183 | // // Use babel-jest to transpile tests with the next/babel preset 184 | // // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object 185 | // "^.+\\.(js|jsx|ts|tsx)$": "babel-jest", 186 | // }, 187 | 188 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 189 | // transformIgnorePatterns: [ 190 | // "/node_modules/", 191 | // "^.+\\.module\\.(css|sass|scss)$", 192 | // ], 193 | 194 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 195 | // unmockedModulePathPatterns: undefined, 196 | 197 | // Indicates whether each individual test should be reported during the run 198 | // verbose: undefined, 199 | 200 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 201 | // watchPathIgnorePatterns: [], 202 | 203 | // Whether to use watchman for file crawling 204 | // watchman: true, 205 | passWithNoTests: true, 206 | }; 207 | -------------------------------------------------------------------------------- /manifest.ts: -------------------------------------------------------------------------------- 1 | import packageJson from './package.json'; 2 | 3 | /** 4 | * After changing, please reload the extension at `chrome://extensions` 5 | */ 6 | const manifest: chrome.runtime.ManifestV3 = { 7 | manifest_version: 3, 8 | name: 'DistiLlama', 9 | version: packageJson.version, 10 | description: packageJson.description, 11 | permissions: ['storage', 'sidePanel'], 12 | options_page: 'src/pages/options/index.html', 13 | background: { 14 | service_worker: 'src/pages/background/index.js', 15 | type: 'module', 16 | }, 17 | action: { 18 | default_title: 'Click to open panel', 19 | }, 20 | side_panel: { 21 | default_path: 'src/pages/sidePanel/index.html', 22 | default_icon: 'icon-34.png', 23 | }, 24 | icons: { 25 | '128': 'icon-128.png', 26 | }, 27 | content_scripts: [ 28 | { 29 | matches: ['http://*/*', 'https://*/*', ''], 30 | js: ['src/pages/content/index.js'], 31 | }, 32 | ], 33 | content_security_policy: { 34 | extension_pages: 35 | "script-src 'self' 'wasm-unsafe-eval' http://localhost:* http://127.0.0.1:*; script-src-elem 'self' 'wasm-unsafe-eval'; object-src 'self'", 36 | }, 37 | web_accessible_resources: [ 38 | { 39 | resources: ['assets/js/*.js', 'assets/css/*.css', 'icon-128.png', 'icon-34.png'], 40 | matches: ['*://*/*'], 41 | }, 42 | ], 43 | host_permissions: [''], 44 | }; 45 | 46 | export default manifest; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "distillama", 3 | "version": "0.0.1", 4 | "description": "Chrome Extension to Summarize Web Pages Using LLMs", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/shreyaskarnik/DistiLlama.git" 9 | }, 10 | "scripts": { 11 | "build": "tsc --noEmit && vite build", 12 | "build:firefox": "tsc --noEmit && cross-env __FIREFOX__=true vite build", 13 | "build:watch": "cross-env __DEV__=true vite build -w --mode development", 14 | "build:firefox:watch": "cross-env __DEV__=true __FIREFOX__=true vite build -w --mode development", 15 | "build:hmr": "rollup --config utils/reload/rollup.config.mjs", 16 | "wss": "node utils/reload/initReloadServer.js", 17 | "dev": "pnpm build:hmr && (run-p wss build:watch)", 18 | "dev:firefox": "pnpm build:hmr && (run-p wss build:firefox:watch)", 19 | "test": "jest --passWithNoTests", 20 | "commitlint": "commitlint --edit", 21 | "lint": "eslint src --ext .ts", 22 | "lint:fix": "pnpm lint -- --fix", 23 | "prettier": "prettier . --write" 24 | }, 25 | "type": "module", 26 | "dependencies": { 27 | "@emotion/react": "^11.11.4", 28 | "@emotion/styled": "^11.11.5", 29 | "@mozilla/readability": "^0.5.0", 30 | "@mui/material": "^5.15.18", 31 | "@xenova/transformers": "^2.17.1", 32 | "construct-style-sheets-polyfill": "^3.1.0", 33 | "langchain": "^0.1.36", 34 | "moment": "^2.30.1", 35 | "pdf-parse": "^1.1.1", 36 | "pdfjs-dist": "4.0.379", 37 | "prop-types": "^15.8.1", 38 | "react": "18.3.1", 39 | "react-dom": "18.3.1", 40 | "react-icons": "^5.2.1", 41 | "react-markdown": "^9.0.1", 42 | "react-toastify": "^10.0.5", 43 | "styled-components": "^6.1.11", 44 | "voy-search": "^0.6.3" 45 | }, 46 | "devDependencies": { 47 | "@commitlint/cli": "^19.3.0", 48 | "@commitlint/config-conventional": "^19.2.2", 49 | "@rollup/plugin-typescript": "^11.1.6", 50 | "@testing-library/react": "15.0.5", 51 | "@twind/core": "^1.1.3", 52 | "@twind/preset-autoprefix": "^1.0.7", 53 | "@twind/preset-tailwind": "^1.1.4", 54 | "@types/chrome": "0.0.268", 55 | "@types/jest": "29.5.12", 56 | "@types/node": "20.12.12", 57 | "@types/react": "18.3.3", 58 | "@types/react-dom": "18.3.0", 59 | "@types/ws": "^8.5.10", 60 | "@typescript-eslint/eslint-plugin": "^6.21.0", 61 | "@typescript-eslint/parser": "^6.21.0", 62 | "@vitejs/plugin-react": "4.2.1", 63 | "chokidar": "^3.6.0", 64 | "cross-env": "^7.0.3", 65 | "eslint": "^8.57.0", 66 | "eslint-config-airbnb-typescript": "^17.1.0", 67 | "eslint-config-prettier": "^9.1.0", 68 | "eslint-plugin-import": "^2.29.1", 69 | "eslint-plugin-jsx-a11y": "^6.8.0", 70 | "eslint-plugin-prettier": "^5.1.3", 71 | "eslint-plugin-react": "^7.34.1", 72 | "eslint-plugin-react-hooks": "^4.6.2", 73 | "fs-extra": "11.2.0", 74 | "husky": "^9.0.11", 75 | "jest": "29.7.0", 76 | "jest-environment-jsdom": "29.7.0", 77 | "lint-staged": "^15.2.7", 78 | "npm-run-all": "^4.1.5", 79 | "prettier": "^3.2.5", 80 | "rollup": "4.18.0", 81 | "sass": "1.77.1", 82 | "ts-jest": "29.1.4", 83 | "ts-loader": "9.5.1", 84 | "tslib": "^2.6.2", 85 | "typescript": "5.4.5", 86 | "vite": "5.3.1", 87 | "vite-plugin-top-level-await": "^1.4.1", 88 | "vite-plugin-wasm": "^3.3.0", 89 | "ws": "8.17.1" 90 | }, 91 | "lint-staged": { 92 | "*.{js,jsx,ts,tsx}": [ 93 | "prettier --write", 94 | "eslint --fix" 95 | ] 96 | }, 97 | "packageManager": "pnpm@8.9.2" 98 | } 99 | -------------------------------------------------------------------------------- /public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/public/icon-128.png -------------------------------------------------------------------------------- /public/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/public/icon-34.png -------------------------------------------------------------------------------- /src/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/style/theme.scss: -------------------------------------------------------------------------------- 1 | .crx-class { 2 | color: pink; 3 | } 4 | -------------------------------------------------------------------------------- /src/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | __DEV__: string; 5 | __FIREFOX__: string; 6 | } 7 | } 8 | } 9 | 10 | export {}; 11 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import Chrome from 'chrome'; 2 | 3 | declare namespace chrome { 4 | export default Chrome; 5 | } 6 | 7 | declare module 'virtual:reload-on-update-in-background-script' { 8 | export const reloadOnUpdate: (watchPath: string) => void; 9 | export default reloadOnUpdate; 10 | } 11 | 12 | declare module 'virtual:reload-on-update-in-view' { 13 | const refreshOnUpdate: (watchPath: string) => void; 14 | export default refreshOnUpdate; 15 | } 16 | 17 | declare module '*.svg' { 18 | import React = require('react'); 19 | export const ReactComponent: React.SFC>; 20 | const src: string; 21 | export default src; 22 | } 23 | 24 | declare module '*.jpg' { 25 | const content: string; 26 | export default content; 27 | } 28 | 29 | declare module '*.png' { 30 | const content: string; 31 | export default content; 32 | } 33 | 34 | declare module '*.json' { 35 | const content: string; 36 | export default content; 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/background/index.ts: -------------------------------------------------------------------------------- 1 | import reloadOnUpdate from 'virtual:reload-on-update-in-background-script'; 2 | 3 | reloadOnUpdate('pages/background'); 4 | 5 | console.log('background loaded'); 6 | chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); 7 | -------------------------------------------------------------------------------- /src/pages/common/Header.css: -------------------------------------------------------------------------------- 1 | /* Header.css */ 2 | .header { 3 | display: flex; 4 | justify-content: space-between; 5 | align-items: center; 6 | background-color: #282c34; 7 | /* Darker background for the header */ 8 | padding: 10px 15px; 9 | box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); 10 | border-radius: 5px 5px 0 0; 11 | /* Adding a slight curve to the top edges */ 12 | color: #FFF; 13 | /* White text */ 14 | width: 100%; 15 | position: sticky; 16 | top: 0; 17 | z-index: 1000; 18 | opacity: 0.7; 19 | } 20 | 21 | .button { 22 | background: none; 23 | border: none; 24 | cursor: pointer; 25 | padding: 5px; 26 | /* Padding to increase touch area */ 27 | color: #FFF; 28 | /* White color for icons */ 29 | opacity: 0.9; 30 | /* Slightly reduced opacity for better visibility */ 31 | transition: opacity 0.2s; 32 | /* Smooth transition for hover effect */ 33 | } 34 | 35 | .button:hover { 36 | opacity: 1; 37 | /* Full opacity on hover */ 38 | color: #007BFF; 39 | } 40 | 41 | .button svg { 42 | width: 24px; 43 | height: 24px; 44 | } 45 | 46 | .center-button { 47 | position: absolute; 48 | left: 50%; 49 | transform: translateX(-50%); 50 | } 51 | 52 | .button.disabled { 53 | cursor: default; 54 | opacity: 0.5; 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/common/Header.tsx: -------------------------------------------------------------------------------- 1 | // this is a common header component which will be used in both the summary and Q&A pages 2 | 3 | import '@pages/common/Header.css'; 4 | import { FaBackspace, FaCog } from 'react-icons/fa'; 5 | import { IoIosRefresh } from 'react-icons/io'; 6 | // eslint-disable-next-line react/prop-types 7 | const Header = ({ onBack, onRefresh, onOpenSettings }) => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | 17 | export default Header; 18 | -------------------------------------------------------------------------------- /src/pages/content/index.ts: -------------------------------------------------------------------------------- 1 | import { Readability } from '@mozilla/readability'; 2 | 3 | export type GetPageContentRequest = { 4 | action: 'getPageContent'; 5 | tabID?: number; 6 | }; 7 | 8 | export type GetPageContentResponse = { 9 | title: string; 10 | content: string; 11 | textContent: string; 12 | length: number; 13 | excerpt: string; 14 | byline: string; 15 | dir: string; 16 | siteName: string; 17 | lang: string; 18 | pageURL: string; 19 | tabID?: number; 20 | }; 21 | 22 | chrome.runtime.onMessage.addListener((request: GetPageContentRequest, _sender, sendResponse) => { 23 | if (request.action === 'getPageContent') { 24 | try { 25 | const documentClone = document.cloneNode(true) as Document; 26 | console.log('extracting content'); 27 | const article = new Readability(documentClone).parse(); 28 | if (article) { 29 | const a: GetPageContentResponse = { ...article, pageURL: documentClone.URL, tabID: request.tabID }; 30 | console.log('sending response', a); 31 | sendResponse(a); 32 | } else { 33 | sendResponse({ error: 'Readability failed' }); 34 | } 35 | } catch (e) { 36 | console.error('Error in parsing:', e); 37 | sendResponse({ error: 'Readability exception' }); 38 | } 39 | return true; // will respond asynchronously 40 | } 41 | }); 42 | 43 | console.log('content loaded'); 44 | -------------------------------------------------------------------------------- /src/pages/options/Options.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | height: 50vh; 4 | font-size: 2rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import '@pages/options/Options.css'; 2 | import React from 'react'; 3 | 4 | const Options: React.FC = () => { 5 | return
Options
; 6 | }; 7 | 8 | export default Options; 9 | -------------------------------------------------------------------------------- /src/pages/options/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shreyaskarnik/DistiLlama/d0bff7ae6a310d910488a87016e0f14be286c31d/src/pages/options/index.css -------------------------------------------------------------------------------- /src/pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/pages/options/index.tsx: -------------------------------------------------------------------------------- 1 | import Options from '@pages/options/Options'; 2 | import '@pages/options/index.css'; 3 | import React from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import refreshOnUpdate from 'virtual:reload-on-update-in-view'; 6 | 7 | refreshOnUpdate('pages/options'); 8 | 9 | function init() { 10 | const appContainer = document.querySelector('#app-container'); 11 | if (!appContainer) { 12 | throw new Error('Can not find #app-container'); 13 | } 14 | const root = createRoot(appContainer); 15 | root.render(); 16 | } 17 | 18 | init(); 19 | -------------------------------------------------------------------------------- /src/pages/popup/Popup.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: relative; 3 | /* Change from absolute to relative */ 4 | top: 0; 5 | bottom: 0; 6 | left: 0; 7 | right: 0; 8 | text-align: center; 9 | height: 100vh; 10 | overflow-y: auto; 11 | padding: 10px; 12 | padding-top: 40px; 13 | /* Add some padding at the top to prevent cut-off */ 14 | background-color: #282c34; 15 | color: white; 16 | } 17 | 18 | .App-logo { 19 | height: 30vmin; 20 | pointer-events: none; 21 | } 22 | 23 | .custom-select { 24 | background-color: transparent; 25 | color: white; 26 | } 27 | 28 | @media (prefers-reduced-motion: no-preference) { 29 | .App-logo { 30 | animation: App-logo-spin infinite 20s linear; 31 | } 32 | } 33 | 34 | .App-header { 35 | min-height: 20%; 36 | /* Changed from height: 100%; */ 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | justify-content: center; 41 | font-size: calc(10px + 2vmin); 42 | color: white; 43 | padding: 10px; 44 | /* Added padding here instead of .App */ 45 | } 46 | 47 | .App-link { 48 | color: #61dafb; 49 | } 50 | 51 | .App-body { 52 | display: flex; 53 | flex-direction: column; 54 | align-items: center; 55 | justify-content: center; 56 | height: auto; 57 | /* Adjusted to auto so content defines the height */ 58 | max-height: 100%; 59 | /* Ensure it doesn’t exceed parent container */ 60 | overflow-y: auto; 61 | /* Scrollable if content overflows */ 62 | width: 100%; 63 | color: white; 64 | } 65 | 66 | .content-box { 67 | padding: 10px; 68 | /* Add some padding around the text */ 69 | margin: 5px; 70 | /* Give some margin for aesthetics */ 71 | overflow-y: auto; 72 | /* Add a scrollbar if content overflows vertically */ 73 | max-height: 300px; 74 | /* Define a maximum height for the content box */ 75 | text-align: justify; 76 | /* Justify the text for better readability */ 77 | line-height: 1.5; 78 | /* Increase line height for better legibility */ 79 | word-wrap: break-word; 80 | /* Ensure long words don't overflow */ 81 | border: 1px solid #ccc; 82 | /* Optional: A border for clarity */ 83 | box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); 84 | /* Optional: A subtle shadow for depth */ 85 | font-size: calc(10px + 2vmin); 86 | } 87 | 88 | @keyframes App-logo-spin { 89 | from { 90 | transform: rotate(0deg); 91 | } 92 | 93 | to { 94 | transform: rotate(360deg); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/pages/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | const Popup = () => { 2 | return ( 3 |
4 |

Popup

5 |
6 | ); 7 | }; 8 | 9 | export default Popup; 10 | -------------------------------------------------------------------------------- /src/pages/popup/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | height: 260px; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 6 | 'Droid Sans', 'Helvetica Neue', sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | 10 | position: relative; 11 | } 12 | 13 | code { 14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/pages/popup/index.tsx: -------------------------------------------------------------------------------- 1 | // this popup action loads the side panel 2 | 3 | import React from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import '@pages/popup/index.css'; 6 | import Popup from '@root/src/pages/popup/Popup'; 7 | import refreshOnUpdate from 'virtual:reload-on-update-in-view'; 8 | import { attachTwindStyle } from '@src/shared/style/twind'; 9 | 10 | refreshOnUpdate('pages/popup'); 11 | 12 | function init() { 13 | const appContainer = document.querySelector('#app-container'); 14 | if (!appContainer) { 15 | throw new Error('Can not find #app-container'); 16 | } 17 | attachTwindStyle(appContainer, document); 18 | const root = createRoot(appContainer); 19 | root.render(); 20 | } 21 | 22 | init(); 23 | -------------------------------------------------------------------------------- /src/pages/sidePanel/ChatWithDocument.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import { BsFillArrowRightSquareFill } from 'react-icons/bs'; 3 | import Settings from '@pages/sidePanel/Settings'; 4 | 5 | // eslint-disable-next-line react/prop-types 6 | const ChatWithDocument = ({ handleQandAAction, setSelectedParams, setSelectedPDF }) => { 7 | const [selectedFileName, setSelectedFileName] = useState(''); 8 | 9 | const handleFileChange = useCallback( 10 | e => { 11 | if (e.target.files && e.target.files[0]) { 12 | const file = e.target.files[0]; 13 | setSelectedPDF(file); 14 | setSelectedFileName(file.name); // Set the file name 15 | } 16 | }, 17 | [setSelectedPDF], 18 | ); 19 | 20 | return ( 21 |
22 |
23 | 24 |
25 |
26 |
27 | 34 | 37 | 40 |
41 | {selectedFileName &&
Selected File: {selectedFileName}
} 42 |
43 |
44 |
45 |
46 | ); 47 | }; 48 | 49 | export default ChatWithDocument; 50 | -------------------------------------------------------------------------------- /src/pages/sidePanel/Instructions.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown'; 2 | export default function Instructions() { 3 | const markdown = ` 4 | # Ollama Not Running 5 | - Download Ollama from [here](https://ollama.ai) 6 | - To start the server, run the following command: 7 | \`\`\` 8 | OLLAMA_ORIGINS=* OLLAMA_HOST=127.0.0.1:11435 ollama serve 9 | \`\`\` 10 | - Download models from [here](https://ollama.ai/library) 11 | `; 12 | 13 | return ( 14 |
15 | {markdown} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/pages/sidePanel/PageMetadata.tsx: -------------------------------------------------------------------------------- 1 | import { VscBrowser } from 'react-icons/vsc'; 2 | import PropTypes from 'prop-types'; 3 | function switchToTab(tabId, pageURL) { 4 | // check if running on Chrome 5 | if (typeof chrome === 'undefined') { 6 | return; 7 | } 8 | chrome.tabs.update(tabId, { active: true }, function () { 9 | // You can handle errors here if the tab ID is invalid or the tab is closed 10 | if (chrome.runtime.lastError) { 11 | console.error(chrome.runtime.lastError.message); 12 | // open a new tab with the url 13 | chrome.tabs.create({ url: pageURL }); 14 | } 15 | }); 16 | } 17 | // eslint-disable-next-line react/prop-types 18 | export default function PageMetadata({ metadata, taskType }) { 19 | return metadata ? ( 20 |
21 |
22 | {metadata.tabID && ( 23 |
switchToTab(metadata.tabID, metadata.pageURL)} 25 | onKeyDown={event => { 26 | if (event.key === 'Enter' || event.key === ' ') { 27 | switchToTab(metadata.tabID, metadata.pageURL); 28 | } 29 | }} 30 | tabIndex={0} 31 | role="button" 32 | className="icon-wrapper" 33 | title="Switch to tab"> 34 | {/* Adjust the size as needed */} 35 |
36 | )} 37 | {/* Display task type specific text */} 38 | {taskType === 'summary' && Summarized: } 39 | {taskType === 'qanda' && Chatting with: } 40 | {/* Display the page URL or file name */} 41 | {metadata.pageURL ? ( 42 | 43 | {metadata.pageURL} 44 | 45 | ) : metadata.fileName ? ( 46 | Chatting with: {metadata.fileName} 47 | ) : null} 48 |
49 |
50 | ) : null; 51 | } 52 | 53 | PageMetadata.propTypes = { 54 | taskType: PropTypes.string.isRequired, 55 | metadata: PropTypes.shape({ 56 | text: PropTypes.string, // can be optional 57 | pageURL: PropTypes.string, // can be optional 58 | fileName: PropTypes.string, // can be optional 59 | tabID: PropTypes.number, // can be optional 60 | }), 61 | }; 62 | -------------------------------------------------------------------------------- /src/pages/sidePanel/PageSummary.tsx: -------------------------------------------------------------------------------- 1 | import { LinearProgress } from '@mui/material'; 2 | import PageMetadata from '@root/src/pages/sidePanel/PageMetadata'; 3 | 4 | /* eslint-disable react/prop-types */ 5 | export default function PageSummary({ loading, summary, taskType }) { 6 | console.log('PageSummary: ', loading, summary, taskType); 7 | return ( 8 |
9 | {/* while loading show LinearProgress */} 10 | {loading ? ( 11 |
12 | Generating summary... 13 | 14 |
15 | ) : summary ? ( 16 |
17 |
18 |

{summary.title}

19 |
{summary.text}
20 |
21 |
22 | 23 |
24 |
25 | ) : null} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/sidePanel/QandA.ts: -------------------------------------------------------------------------------- 1 | import { OLLAMA_BASE_URL } from '@src/pages/utils/constants'; 2 | import { getPageContent } from '@src/pages/utils/getPageContent'; 3 | import { ConversationChain } from 'langchain/chains'; 4 | import { ChatOllama } from 'langchain/chat_models/ollama'; 5 | import { Document } from 'langchain/document'; 6 | import { HuggingFaceTransformersEmbeddings } from 'langchain/embeddings/hf_transformers'; 7 | import { Ollama } from 'langchain/llms/ollama'; 8 | import { BufferMemory, ChatMessageHistory } from 'langchain/memory'; 9 | import { HumanMessage, AIMessage } from 'langchain/schema'; 10 | import { PromptTemplate, ChatPromptTemplate, MessagesPlaceholder } from 'langchain/prompts'; 11 | import { StringOutputParser } from 'langchain/schema/output_parser'; 12 | import { RunnablePassthrough, RunnableSequence } from 'langchain/schema/runnable'; 13 | import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; 14 | import { formatDocumentsAsString } from 'langchain/util/document'; 15 | import { VoyVectorStore } from 'langchain/vectorstores/voy'; 16 | import { ContextualCompressionRetriever } from 'langchain/retrievers/contextual_compression'; 17 | import { EmbeddingsFilter } from 'langchain/retrievers/document_compressors/embeddings_filter'; 18 | import { type TextItem } from 'pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js'; 19 | import * as PDFLib from 'pdfjs-dist'; 20 | import { Voy as VoyClient } from 'voy-search'; 21 | import * as pdfWorker from '../../../node_modules/pdfjs-dist/build/pdf.worker.mjs'; 22 | PDFLib.GlobalWorkerOptions.workerSrc = pdfWorker; 23 | 24 | export type ConversationalRetrievalQAChainInput = { 25 | question: string; 26 | chat_history: { question: string; answer: string }[]; 27 | }; 28 | 29 | async function setupVectorstore(selectedModel) { 30 | console.log('Setting up vectorstore', selectedModel); 31 | const embeddings = new HuggingFaceTransformersEmbeddings({ 32 | modelName: 'Xenova/jina-embeddings-v2-small-en', 33 | }); 34 | const voyClient = new VoyClient(); 35 | return new VoyVectorStore(voyClient, embeddings); 36 | } 37 | export type EmbedDocsOutput = { 38 | vectorstore: VoyVectorStore; 39 | pageURL?: string; 40 | tabID?: number; 41 | fileName?: string; 42 | starterQuestions?: string[]; 43 | }; 44 | export async function embedDocs(selectedModel, localFile): Promise { 45 | console.log('Embedding documents'); 46 | console.log('localFile', localFile); 47 | const vectorstore = await setupVectorstore(selectedModel); 48 | let documents: Document[] = []; 49 | let pageContent; 50 | if (!localFile) { 51 | pageContent = await getPageContent(); 52 | documents.push( 53 | new Document({ 54 | pageContent: pageContent.textContent, 55 | metadata: { 56 | pageURL: pageContent.pageURL, 57 | title: pageContent.title, 58 | length: pageContent.length, 59 | excerpt: pageContent.excerpt, 60 | byline: pageContent.byline, 61 | dir: pageContent.dir, 62 | siteName: pageContent.siteName, 63 | lang: pageContent.lang, 64 | }, 65 | }), 66 | ); 67 | } else { 68 | documents = await handlePDFFile(localFile); 69 | } 70 | const splitter = new RecursiveCharacterTextSplitter({ 71 | chunkOverlap: 20, 72 | chunkSize: 500, 73 | }); 74 | const splitDocs = await splitter.splitDocuments(documents); 75 | await vectorstore.addDocuments(splitDocs); 76 | console.log('Added documents to vectorstore'); 77 | const starterQuestions = await getDefaultStarterQuestions(selectedModel, vectorstore); 78 | return pageContent 79 | ? ({ 80 | vectorstore, 81 | pageURL: pageContent.pageURL, 82 | tabID: pageContent.tabID, 83 | starterQuestions: starterQuestions, 84 | } as EmbedDocsOutput) 85 | : ({ vectorstore, fileName: localFile.name, starterQuestions: starterQuestions } as EmbedDocsOutput); 86 | } 87 | 88 | export async function getDefaultStarterQuestions(selectedParams, vectorStore) { 89 | try { 90 | console.log('getDefaultStarterQuestions', selectedParams); 91 | const llm = new Ollama({ 92 | baseUrl: OLLAMA_BASE_URL, 93 | model: selectedParams.model.name, 94 | temperature: selectedParams.temperature, 95 | }); 96 | console.log('vectorStore', vectorStore); 97 | const retriever = vectorStore.asRetriever(); 98 | 99 | const generateStarterQuestionsPrompt = ` 100 | Given the following context and metadata, generate 1-2 questions that can be asked about the context. 101 | Return the questions as a list of strings. No additional output is needed. No numbers needed. Format output as JSON array of strings. 102 | 103 | {context} 104 | 105 | Metadata: 106 | {metadata} 107 | `; 108 | 109 | const starterQPrompt = PromptTemplate.fromTemplate(generateStarterQuestionsPrompt); 110 | const chain = RunnableSequence.from([ 111 | { 112 | context: retriever.pipe(formatDocumentsAsString), 113 | metadata: retriever.pipe(documents => getMetadataString(documents)), 114 | }, 115 | starterQPrompt, 116 | llm, 117 | new StringOutputParser(), 118 | ]); 119 | 120 | const resultString = await chain.invoke(''); 121 | const resultArray = JSON.parse(resultString); // Parse the JSON string to an array 122 | return resultArray; 123 | } catch (error) { 124 | console.error('Error in getDefaultStarterQuestions:', error); 125 | // Handle the error or return an empty array/fallback value 126 | return []; 127 | } 128 | } 129 | export async function* talkToDocument(selectedParams, vectorStore, input: ConversationalRetrievalQAChainInput) { 130 | console.log('talkToDocument', selectedParams); 131 | const llm = new Ollama({ 132 | baseUrl: OLLAMA_BASE_URL, 133 | model: selectedParams.model.name, 134 | temperature: selectedParams.temperature, 135 | }); 136 | console.log('question', input.question); 137 | console.log('chat_history', input.chat_history); 138 | console.log('vectorStore', vectorStore); 139 | let retriever = vectorStore.asRetriever(); 140 | if (input.chat_history.length !== 0) { 141 | const baseCompressor = new EmbeddingsFilter({ 142 | embeddings: new HuggingFaceTransformersEmbeddings({ 143 | modelName: 'Xenova/jina-embeddings-v2-small-en', 144 | }), 145 | similarityThreshold: 0.6, 146 | }); 147 | retriever = new ContextualCompressionRetriever({ 148 | baseCompressor, 149 | baseRetriever: vectorStore.asRetriever(), 150 | }); 151 | } 152 | const condenseQuestionTemplate = `Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question. 153 | 154 | Chat History: 155 | {chat_history} 156 | Follow Up Input: {question} 157 | Standalone question:`; 158 | const condense_question_prompt = PromptTemplate.fromTemplate(condenseQuestionTemplate); 159 | const prompt = PromptTemplate.fromTemplate(` 160 | Answer the question based only on the following context: 161 | Do not use any other sources of information. 162 | Do not provide any answer that is not based on the context. 163 | If there is no answer, type "Not sure based on the context". 164 | Additionally you will be given metadata like 165 | title,content,length,excerpt,byline,dir,siteName,lang 166 | in the metadata field. Use this information to help you answer the question. 167 | 168 | {context} 169 | 170 | Metadata: 171 | {metadata} 172 | 173 | Question: {question} 174 | Answer: 175 | `); 176 | const standaloneQuestionChain = RunnableSequence.from([ 177 | { 178 | question: (input: ConversationalRetrievalQAChainInput) => input.question, 179 | chat_history: (input: ConversationalRetrievalQAChainInput) => formatChatHistory(input.chat_history), 180 | }, 181 | condense_question_prompt, 182 | llm, 183 | new StringOutputParser(), 184 | ]); 185 | const answer_chain = RunnableSequence.from([ 186 | { 187 | context: retriever.pipe(formatDocumentsAsString), 188 | question: new RunnablePassthrough(), 189 | metadata: retriever.pipe(documents => getMetadataString(documents)), 190 | }, 191 | prompt, 192 | llm, 193 | new StringOutputParser(), 194 | ]); 195 | const chain = standaloneQuestionChain.pipe(answer_chain); 196 | const stream = await chain.stream(input); 197 | 198 | for await (const chunk of stream) { 199 | yield chunk; 200 | } 201 | } 202 | 203 | function getMetadataString(documents: Document[]) { 204 | try { 205 | const metadata = documents[0].metadata; 206 | if (!metadata) { 207 | return ''; 208 | } 209 | const result = []; 210 | 211 | for (const key in metadata) { 212 | // Check if the property is not an object and not an array 213 | if (Object.prototype.hasOwnProperty.call(metadata, key) && typeof metadata[key] !== 'object') { 214 | result.push(`${key}: ${metadata[key]}`); 215 | } 216 | } 217 | console.log('result', result); 218 | 219 | return result.join(' '); 220 | } catch (e) { 221 | console.log('error', e); 222 | return ''; 223 | } 224 | } 225 | 226 | export const formatChatHistory = (chatHistory: { question: string; answer: string }[]) => { 227 | console.log('chatHistory', chatHistory); 228 | const formattedDialogueTurns = chatHistory.map( 229 | dialogueTurn => `Human: ${dialogueTurn.question}\nAssistant: ${dialogueTurn.answer}`, 230 | ); 231 | console.log('formattedDialogueTurns', formattedDialogueTurns); 232 | return formattedDialogueTurns.join('\n'); 233 | }; 234 | 235 | export async function handlePDFFile(selectedFile) { 236 | // Load PDF document into array buffer 237 | const arrayBuffer = await selectedFile.arrayBuffer(); 238 | 239 | const arrayBufferUint8 = new Uint8Array(arrayBuffer); 240 | const parsedPdf = await PDFLib.getDocument(arrayBufferUint8).promise; 241 | 242 | const meta = await parsedPdf.getMetadata().catch(() => null); 243 | const documents: Document[] = []; 244 | 245 | for (let i = 1; i <= parsedPdf.numPages; i += 1) { 246 | const page = await parsedPdf.getPage(i); 247 | const content = await page.getTextContent(); 248 | 249 | if (content.items.length === 0) { 250 | continue; 251 | } 252 | 253 | const text = content.items.map(item => (item as TextItem).str).join('\n'); 254 | 255 | documents.push( 256 | new Document({ 257 | pageContent: text, 258 | metadata: { 259 | pdf: { 260 | info: meta?.info, 261 | metadata: meta?.metadata, 262 | totalPages: parsedPdf.numPages, 263 | }, 264 | loc: { 265 | pageNumber: i, 266 | }, 267 | }, 268 | }), 269 | ); 270 | } 271 | return documents; 272 | } 273 | 274 | export async function* chatWithLLM(selectedParams, input: ConversationalRetrievalQAChainInput) { 275 | console.log('chatWithLLM', selectedParams); 276 | const llm = new ChatOllama({ 277 | baseUrl: OLLAMA_BASE_URL, 278 | model: selectedParams.model.name, 279 | temperature: selectedParams.temperature, 280 | }); 281 | const chatPrompt = ChatPromptTemplate.fromMessages([ 282 | [ 283 | 'system', 284 | 'The following is a friendly conversation between a human and an assistant. The assistant polite and helpful and provides lots of specific details from its context. If the assistant does not know the answer to a question, it truthfully says it does not know.', 285 | ], 286 | new MessagesPlaceholder('history'), 287 | ['human', '{input}'], 288 | ]); 289 | const chatHistory = []; 290 | 291 | // Flatten the array of message pairs into a single array of BaseMessage instances 292 | input.chat_history.forEach(element => { 293 | chatHistory.push(new HumanMessage(element.question)); 294 | chatHistory.push(new AIMessage(element.answer)); 295 | }); 296 | const memory = new BufferMemory({ 297 | returnMessages: true, 298 | memoryKey: 'history', 299 | chatHistory: new ChatMessageHistory(chatHistory), 300 | }); 301 | const chain = new ConversationChain({ 302 | memory: memory, 303 | prompt: chatPrompt, 304 | llm: llm, 305 | verbose: true, 306 | }); 307 | const stream = await chain.stream({ 308 | input: input.question, 309 | }); 310 | 311 | for await (const chunk of stream) { 312 | yield chunk.response; 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/pages/sidePanel/QandABubble.tsx: -------------------------------------------------------------------------------- 1 | import LinearProgress from '@mui/material/LinearProgress'; 2 | import { talkToDocument, chatWithLLM } from '@root/src/pages/sidePanel/QandA'; 3 | import { useEffect, useRef, useState } from 'react'; 4 | import { BsFillArrowRightSquareFill } from 'react-icons/bs'; 5 | import PageMetadata from '@root/src/pages/sidePanel/PageMetadata'; 6 | /* eslint-disable react/prop-types */ 7 | export function QandAStatus({ embedding, vectorstore }) { 8 | return ( 9 |
10 | {/* while embedding==true show LinearProgress moving else show LinearProgress Solid */} 11 | {embedding && vectorstore === null ? ( 12 |
13 | 14 |
15 | ) : null} 16 |
17 | ); 18 | } 19 | 20 | export function AnsweringStatus({ answering }) { 21 | return ( 22 |
23 | {answering ? ( 24 |
25 | 26 |
27 | ) : null} 28 |
29 | ); 30 | } 31 | 32 | export function QandABubble({ taskType, selectedParams, vectorstore, starterQuestions }) { 33 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 34 | const [answer, setAnswer] = useState(''); 35 | const [question, setQuestion] = useState(''); 36 | const [answering, setAnswering] = useState(false); // eslint-disable-line @typescript-eslint/no-unused-vars 37 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 38 | const [chat_history = [], setChatHistory] = useState([]); 39 | const endOfChatHistoryRef = useRef(null); 40 | const scrollToBottom = () => { 41 | endOfChatHistoryRef.current?.scrollIntoView({ behavior: 'auto' }); 42 | }; 43 | const formContainerRef = useRef(null); 44 | const handleTextAreaInput = e => { 45 | e.target.style.height = 'auto'; // Reset the height 46 | e.target.style.height = `${e.target.scrollHeight}px`; // Set the height equal to the scroll height 47 | }; 48 | const handleKeyPress = e => { 49 | if (e.key === 'Enter' && !e.shiftKey) { 50 | // Check if Enter was pressed without the shift key 51 | e.preventDefault(); // Prevent the default action to avoid inserting a new line 52 | handleQandAAction(e); // Call your existing form submission handler 53 | } 54 | }; 55 | 56 | useEffect(() => { 57 | setAnswer(''); 58 | setQuestion(''); 59 | setAnswering(false); 60 | setChatHistory([]); 61 | }, [vectorstore]); 62 | useEffect(() => { 63 | scrollToBottom(); 64 | }, [chat_history]); 65 | 66 | const handleQandAAction = async e => { 67 | if (e) { 68 | e.preventDefault(); // Prevent page reload on form submit 69 | } 70 | if (!question) return; // Prevent sending empty questions 71 | 72 | console.log('Params used for QandA: ', selectedParams); 73 | console.log('Question: ', question); 74 | 75 | // Don't clear the question here, so it remains visible during the process 76 | setAnswering(true); // Indicate that the answer process has started 77 | 78 | const chain = 79 | taskType === 'qanda' || taskType === 'docs' 80 | ? talkToDocument(selectedParams, vectorstore.vectorstore, { question, chat_history }) 81 | : chatWithLLM(selectedParams, { question, chat_history }); 82 | 83 | for await (const chunk of chain) { 84 | if (chunk) { 85 | setAnswer(prevAnswer => prevAnswer + chunk); 86 | // Here, update the chat history with the incremental answer. 87 | setChatHistory(prevChatHistory => { 88 | // If the last chat history item is the current question, update it. 89 | // Otherwise, add a new entry. 90 | const historyUpdated = [...prevChatHistory]; 91 | const lastEntry = historyUpdated[historyUpdated.length - 1]; 92 | if (lastEntry && lastEntry.question === question) { 93 | lastEntry.answer += chunk; 94 | } else { 95 | historyUpdated.push({ question, answer: chunk }); 96 | } 97 | return historyUpdated; 98 | }); 99 | } 100 | } 101 | 102 | // After the final chunk has been received, stop the answering indicator 103 | setAnswering(false); 104 | // If you want to clear the question after the answer is fully received, uncomment the next line. 105 | setQuestion(''); 106 | }; 107 | 108 | useEffect(() => { 109 | // Clear the answer when a new model is selected 110 | setAnswer(''); 111 | }, [selectedParams]); 112 | 113 | return ( 114 |
115 |
116 | {chat_history.length > 0 ? ( 117 |
    118 | {chat_history.map(({ question, answer }, index) => ( 119 |
  • 120 |
    121 | 122 | 👤 123 | 124 | {question} 125 |
    126 |
    127 | 128 | 🤖 129 | 130 | {answer} 131 |
    132 |
  • 133 | ))} 134 |
    135 |
136 | ) : ( 137 |

Ask a question to start the conversation.

138 | )} 139 |
140 |
141 | {chat_history.length === 0 && ( 142 |
143 |
    144 | {starterQuestions.map((question, index) => ( 145 |
  • { 151 | setQuestion(question); 152 | // invoke handleQandAAction 153 | handleQandAAction(null); 154 | }} 155 | onKeyDown={e => { 156 | if (e.key === 'Enter' || e.key === ' ') { 157 | setQuestion(question); 158 | handleQandAAction(null); 159 | } 160 | }}> 161 | {question} 162 |
  • 163 | ))} 164 |
165 |
166 | )} 167 |
168 |
169 |