├── .erb ├── mocks │ └── fileMock.js ├── img │ ├── erb-logo.png │ └── erb-banner.svg ├── configs │ ├── .eslintrc │ ├── webpack.config.eslint.ts │ ├── webpack.paths.ts │ ├── webpack.config.base.ts │ ├── webpack.config.main.dev.ts │ ├── webpack.config.renderer.dev.dll.ts │ ├── webpack.config.preload.dev.ts │ ├── webpack.config.main.prod.ts │ ├── webpack.config.renderer.prod.ts │ └── webpack.config.renderer.dev.ts └── scripts │ ├── .eslintrc │ ├── clean.js │ ├── check-node-env.js │ ├── check-port-in-use.js │ ├── link-modules.ts │ ├── delete-source-maps.js │ ├── notarize.js │ ├── electron-rebuild.js │ ├── check-build-exists.ts │ └── check-native-dep.js ├── assets ├── icon.png ├── icon.icns ├── entitlements.mac.plist ├── template.html └── assets.d.ts ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── src ├── lib │ ├── utils │ │ ├── index.ts │ │ ├── render.ts │ │ ├── ollama.ts │ │ ├── format-zod-error.ts │ │ ├── main.ts │ │ └── format-zod-error.test.ts │ ├── config.ts │ └── store.ts ├── browser │ ├── preload │ │ ├── public.ts │ │ ├── private.ts │ │ ├── device.ts │ │ ├── controls.ts │ │ └── ai.ts │ ├── controls │ │ ├── entry.tsx │ │ ├── actions.ts │ │ ├── components │ │ │ ├── icons.tsx │ │ │ └── badge.tsx │ │ ├── use-connect.tsx │ │ └── index.tsx │ ├── settings │ │ ├── entry.tsx │ │ ├── components │ │ │ ├── skeleton.tsx │ │ │ ├── switch.tsx │ │ │ └── table.tsx │ │ └── index.tsx │ ├── types.ts │ ├── global.css │ └── index.ts ├── ai-api │ ├── index.ts │ ├── validate.ts │ ├── __tests__ │ │ └── index.test.ts │ ├── types.ts │ └── device │ │ ├── session.ts │ │ ├── index.ts │ │ └── __tests__ │ │ └── index.test.ts └── main │ ├── menu.ts │ └── main.ts ├── release └── app │ ├── pnpm-lock.yaml │ ├── package-lock.json │ └── package.json ├── .gitattributes ├── .gitignore ├── tsconfig.json ├── .eslintignore ├── LICENSE ├── .eslintrc.js ├── global.d.ts ├── tailwind.config.js ├── ollama-models.json ├── README.md ├── setupJest.ts └── package.json /.erb/mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzic/browser.ai/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzic/browser.ai/HEAD/assets/icon.icns -------------------------------------------------------------------------------- /.erb/img/erb-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenzic/browser.ai/HEAD/.erb/img/erb-logo.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"] 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function cleanName(name: string): string { 2 | return name.split(':')[0]; 3 | } 4 | -------------------------------------------------------------------------------- /src/browser/preload/public.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron'; 2 | import { ai } from './ai'; 3 | 4 | contextBridge.exposeInMainWorld('ai', ai); 5 | -------------------------------------------------------------------------------- /.erb/configs/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "global-require": "off", 5 | "import/no-dynamic-require": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.eslint.ts: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unresolved: off, import/no-self-import: off */ 2 | 3 | module.exports = require('./webpack.config.renderer.dev').default; 4 | -------------------------------------------------------------------------------- /release/app/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: {} 10 | -------------------------------------------------------------------------------- /.erb/scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "global-require": "off", 5 | "import/no-dynamic-require": "off", 6 | "import/no-extraneous-dependencies": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/utils/render.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.exe binary 3 | *.png binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.ico binary 7 | *.icns binary 8 | *.eot binary 9 | *.otf binary 10 | *.ttf binary 11 | *.woff binary 12 | *.woff2 binary 13 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /release/app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-ai", 3 | "version": "0.1.4", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "browser-ai", 9 | "version": "0.1.4", 10 | "hasInstallScript": true, 11 | "license": "MIT" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /assets/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /.erb/scripts/clean.js: -------------------------------------------------------------------------------- 1 | import { rimrafSync } from 'rimraf'; 2 | import fs from 'fs'; 3 | import webpackPaths from '../configs/webpack.paths'; 4 | 5 | const foldersToRemove = [ 6 | webpackPaths.distPath, 7 | webpackPaths.buildPath, 8 | webpackPaths.dllPath, 9 | ]; 10 | 11 | foldersToRemove.forEach((folder) => { 12 | if (fs.existsSync(folder)) rimrafSync(folder); 13 | }); 14 | -------------------------------------------------------------------------------- /src/browser/controls/entry.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Control } from './index'; 4 | 5 | const container = document.getElementById('app'); 6 | if (container) { 7 | const root = createRoot(container); 8 | root.render(); 9 | } else { 10 | // eslint-disable-next-line no-console 11 | console.error('Failed to find the app container'); 12 | } 13 | -------------------------------------------------------------------------------- /src/browser/settings/entry.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Display } from './index'; 4 | 5 | const container = document.getElementById('app'); 6 | if (container) { 7 | const root = createRoot(container); 8 | root.render(); 9 | } else { 10 | // eslint-disable-next-line no-console 11 | console.error('Failed to find the app container'); 12 | } 13 | -------------------------------------------------------------------------------- /src/browser/preload/private.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron'; 2 | 3 | import { device } from './device'; 4 | import { controls, actions } from './controls'; 5 | import { ai } from './ai'; 6 | 7 | const browserai = { 8 | device, 9 | controls: { 10 | ...controls, 11 | actions, 12 | }, 13 | } as const; 14 | 15 | contextBridge.exposeInMainWorld('ai', ai); 16 | contextBridge.exposeInMainWorld('browserai', browserai); 17 | -------------------------------------------------------------------------------- /.erb/scripts/check-node-env.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export default function checkNodeEnv(expectedEnv) { 4 | if (!expectedEnv) { 5 | throw new Error('"expectedEnv" not set'); 6 | } 7 | 8 | if (process.env.NODE_ENV !== expectedEnv) { 9 | console.log( 10 | chalk.whiteBright.bgRed.bold( 11 | `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`, 12 | ), 13 | ); 14 | process.exit(2); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/browser/settings/components/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '../../../lib/utils/render'; 3 | 4 | function Skeleton({ 5 | className, 6 | ...props 7 | }: React.HTMLAttributes) { 8 | return ( 9 |
14 | ); 15 | } 16 | 17 | export { Skeleton }; 18 | -------------------------------------------------------------------------------- /src/lib/utils/ollama.ts: -------------------------------------------------------------------------------- 1 | import availableModelsData from '../../../ollama-models.json'; 2 | 3 | export function getModelMetaData(model: string): { 4 | name: string; 5 | link: string; 6 | tags: string[]; 7 | } | null { 8 | const modelData = availableModelsData.find((m) => m.title === model); 9 | if (!modelData) { 10 | return null; 11 | } 12 | 13 | return { 14 | name: modelData.title, 15 | link: modelData.link, 16 | tags: modelData.tags ?? [], 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | ollamaEndpoint: 'http://localhost:11434', 3 | startPage: 'https://playground.browser.christophermckenzie.com', 4 | blankPage: 'about:blank', 5 | blankTitle: 'New Tab', 6 | defaultDebug: false, 7 | appTitle: 'Browser.AI', 8 | browserTitle: 'Browser.AI Prototype', 9 | } as const; 10 | 11 | export default { 12 | values: CONFIG, 13 | get: (key: K): (typeof CONFIG)[K] => { 14 | return CONFIG[key]; 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.erb/scripts/check-port-in-use.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import detectPort from 'detect-port'; 3 | 4 | const port = process.env.PORT || '1212'; 5 | 6 | detectPort(port, (_err, availablePort) => { 7 | if (port !== String(availablePort)) { 8 | throw new Error( 9 | chalk.whiteBright.bgRed.bold( 10 | `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`, 11 | ), 12 | ); 13 | } else { 14 | process.exit(0); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /.erb/scripts/link-modules.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import webpackPaths from '../configs/webpack.paths'; 3 | 4 | const { srcNodeModulesPath, appNodeModulesPath, erbNodeModulesPath } = 5 | webpackPaths; 6 | 7 | if (fs.existsSync(appNodeModulesPath)) { 8 | if (!fs.existsSync(srcNodeModulesPath)) { 9 | fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); 10 | } 11 | if (!fs.existsSync(erbNodeModulesPath)) { 12 | fs.symlinkSync(appNodeModulesPath, erbNodeModulesPath, 'junction'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | # Logs 3 | logs 4 | *.log 5 | notes 6 | .env 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | .eslintcache 16 | 17 | # Dependency directory 18 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 19 | node_modules 20 | 21 | # OSX 22 | .DS_Store 23 | 24 | release/app/dist 25 | release/build 26 | .erb/dll 27 | 28 | .idea 29 | npm-debug.log.* 30 | *.css.d.ts 31 | *.sass.d.ts 32 | *.scss.d.ts 33 | -------------------------------------------------------------------------------- /.erb/scripts/delete-source-maps.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { rimrafSync } from 'rimraf'; 4 | import webpackPaths from '../configs/webpack.paths'; 5 | 6 | export default function deleteSourceMaps() { 7 | if (fs.existsSync(webpackPaths.distMainPath)) 8 | rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), { 9 | glob: true, 10 | }); 11 | if (fs.existsSync(webpackPaths.distRendererPath)) 12 | rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), { 13 | glob: true, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2022", 5 | "module": "commonjs", 6 | "lib": ["dom", "es2022"], 7 | "jsx": "react-jsx", 8 | "strict": true, 9 | "sourceMap": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "allowJs": true, 15 | "outDir": ".erb/dll", 16 | "skipLibCheck": true 17 | }, 18 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"] 19 | } 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | .eslintcache 13 | 14 | # Dependency directory 15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 16 | node_modules 17 | 18 | # OSX 19 | .DS_Store 20 | 21 | release/app/dist 22 | release/build 23 | .erb/dll 24 | 25 | .idea 26 | npm-debug.log.* 27 | *.css.d.ts 28 | *.sass.d.ts 29 | *.scss.d.ts 30 | 31 | # eslint ignores hidden directories by default: 32 | # https://github.com/eslint/eslint/issues/8429 33 | !.erb 34 | -------------------------------------------------------------------------------- /release/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-ai", 3 | "version": "0.1.5", 4 | "description": "Prototype of a window.ai api for accessing on-device models in the browser", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Chris Mckenzie", 8 | "url": "https://card.christophermckenzie.com" 9 | }, 10 | "main": "./dist/main/main.js", 11 | "scripts": { 12 | "rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js", 13 | "postinstall": "npm run rebuild && npm run link-modules", 14 | "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts" 15 | }, 16 | "dependencies": {} 17 | } -------------------------------------------------------------------------------- /src/lib/utils/format-zod-error.ts: -------------------------------------------------------------------------------- 1 | import { ZodError } from 'zod'; 2 | 3 | export function formatZodError(error: ZodError): string { 4 | let formattedError = 'Validation errors:\n'; 5 | 6 | error.errors.forEach((err, index) => { 7 | formattedError += `\n${index + 1}. `; 8 | 9 | if (err.path.length > 0) { 10 | formattedError += `Field: ${err.path.join('.')} - `; 11 | } 12 | 13 | formattedError += `Error: ${err.message}`; 14 | 15 | if (err.code === 'invalid_type') { 16 | formattedError += `\n Expected: ${err.expected}, Received: ${err.received}`; 17 | } 18 | }); 19 | 20 | return formattedError; 21 | } 22 | -------------------------------------------------------------------------------- /.erb/scripts/notarize.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { notarize } = require('electron-notarize'); 3 | 4 | exports.default = async function notarizing(context) { 5 | const { electronPlatformName, appOutDir } = context; 6 | if (electronPlatformName !== 'darwin') { 7 | return; 8 | } 9 | 10 | const appName = context.packager.appInfo.productFilename; 11 | 12 | await notarize({ 13 | tool: 'notarytool', 14 | teamId: process.env.APPLE_TEAM_ID, 15 | appBundleId: 'com.2dx3.BrowserAI', 16 | appPath: `${appOutDir}/${appName}.app`, 17 | appleId: process.env.APPLE_ID, 18 | appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/browser/preload/device.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import { ModelName } from '../../ai-api/types'; 3 | 4 | export const device = { 5 | setHost: async (host: string) => { 6 | return ipcRenderer.invoke('device:host:set', host); 7 | }, 8 | getHost: async () => { 9 | return ipcRenderer.invoke('device:host:get'); 10 | }, 11 | enable: async (model: ModelName) => { 12 | return ipcRenderer.invoke('device:model:enable', model); 13 | }, 14 | disable: async (model: ModelName) => { 15 | return ipcRenderer.invoke('device:model:disable', model); 16 | }, 17 | isConnected: async () => { 18 | return ipcRenderer.invoke('device:connected'); 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | ".eslintrc": "jsonc", 4 | ".prettierrc": "jsonc", 5 | ".eslintignore": "ignore" 6 | }, 7 | 8 | "eslint.validate": [ 9 | "javascript", 10 | "javascriptreact", 11 | "html", 12 | "typescriptreact" 13 | ], 14 | 15 | "javascript.validate.enable": false, 16 | "javascript.format.enable": false, 17 | "typescript.format.enable": false, 18 | 19 | "search.exclude": { 20 | ".git": true, 21 | ".eslintcache": true, 22 | ".erb/dll": true, 23 | "release/{build,app/dist}": true, 24 | "node_modules": true, 25 | "npm-debug.log.*": true, 26 | "test/**/__snapshots__": true, 27 | "package-lock.json": true, 28 | "*.{css,sass,scss}.d.ts": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.erb/scripts/electron-rebuild.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | // eslint-disable-next-line import/no-relative-packages 4 | import { dependencies } from '../../release/app/package.json'; 5 | import webpackPaths from '../configs/webpack.paths'; 6 | 7 | if ( 8 | Object.keys(dependencies || {}).length > 0 && 9 | fs.existsSync(webpackPaths.appNodeModulesPath) 10 | ) { 11 | const electronRebuildCmd = 12 | '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .'; 13 | const cmd = 14 | process.platform === 'win32' 15 | ? electronRebuildCmd.replace(/\//g, '\\') 16 | : electronRebuildCmd; 17 | execSync(cmd, { 18 | cwd: webpackPaths.appPath, 19 | stdio: 'inherit', 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /assets/assets.d.ts: -------------------------------------------------------------------------------- 1 | type Styles = Record; 2 | 3 | declare module '*.svg' { 4 | import React = require('react'); 5 | 6 | export const ReactComponent: React.FC>; 7 | 8 | const content: string; 9 | export default content; 10 | } 11 | 12 | declare module '*.png' { 13 | const content: string; 14 | export default content; 15 | } 16 | 17 | declare module '*.jpg' { 18 | const content: string; 19 | export default content; 20 | } 21 | 22 | declare module '*.scss' { 23 | const content: Styles; 24 | export default content; 25 | } 26 | 27 | declare module '*.sass' { 28 | const content: Styles; 29 | export default content; 30 | } 31 | 32 | declare module '*.css' { 33 | const content: Styles; 34 | export default content; 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Electron: Main", 6 | "type": "node", 7 | "request": "launch", 8 | "protocol": "inspector", 9 | "runtimeExecutable": "npm", 10 | "runtimeArgs": ["run", "start"], 11 | "env": { 12 | "MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223" 13 | } 14 | }, 15 | { 16 | "name": "Electron: Renderer", 17 | "type": "chrome", 18 | "request": "attach", 19 | "port": 9223, 20 | "webRoot": "${workspaceFolder}", 21 | "timeout": 15000 22 | } 23 | ], 24 | "compounds": [ 25 | { 26 | "name": "Electron: All", 27 | "configurations": ["Electron: Main", "Electron: Renderer"] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.erb/scripts/check-build-exists.ts: -------------------------------------------------------------------------------- 1 | // Check if the renderer and main bundles are built 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import fs from 'fs'; 5 | import webpackPaths from '../configs/webpack.paths'; 6 | 7 | const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); 8 | const rendererPath = path.join( 9 | webpackPaths.distRendererPath, 10 | 'controls.bundle.js', 11 | ); 12 | 13 | if (!fs.existsSync(mainPath)) { 14 | throw new Error( 15 | chalk.whiteBright.bgRed.bold( 16 | 'The main process is not built yet. Build it by running "npm run build:main"', 17 | ), 18 | ); 19 | } 20 | 21 | if (!fs.existsSync(rendererPath)) { 22 | throw new Error( 23 | chalk.whiteBright.bgRed.bold( 24 | 'The renderer process is not built yet. Build it by running "npm run build:renderer"', 25 | ), 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/browser/controls/actions.ts: -------------------------------------------------------------------------------- 1 | import { TabID } from '../types'; 2 | 3 | export const sendEnterURL = (url: string) => 4 | window.browserai.controls.actions.sendEnterURL(url); 5 | 6 | export const sendChangeURL = (url: string) => 7 | window.browserai.controls.actions.sendChangeURL(url); 8 | 9 | export const sendAct = (actName: string) => 10 | window.browserai.controls.actions.sendAct(actName); 11 | 12 | export const sendGoBack = () => sendAct('goBack'); 13 | 14 | export const sendGoForward = () => sendAct('goForward'); 15 | 16 | export const sendReload = () => sendAct('reload'); 17 | 18 | export const sendStop = () => sendAct('stop'); 19 | 20 | export const sendCloseTab = (id: TabID) => 21 | window.browserai.controls.actions.sendCloseTab(id); 22 | 23 | export const sendNewTab = (url?: string, references?: object) => 24 | window.browserai.controls.actions.sendNewTab(url, references); 25 | 26 | export const sendSwitchTab = (id: TabID) => 27 | window.browserai.controls.actions.sendSwitchTab(id); 28 | -------------------------------------------------------------------------------- /src/browser/controls/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | X, 4 | RotateCw, 5 | ChevronRight, 6 | ChevronLeft, 7 | Plus, 8 | Loader, 9 | } from 'lucide-react'; 10 | 11 | interface IconProps { 12 | // eslint-disable-next-line react/require-default-props 13 | className?: string; 14 | } 15 | 16 | export const IconLoading: React.FC = ({ className }) => ( 17 | 18 | ); 19 | 20 | export const IconClose: React.FC = ({ className }) => ( 21 | 22 | ); 23 | 24 | export const IconPlus: React.FC = ({ className }) => ( 25 | 26 | ); 27 | 28 | export const IconReload: React.FC = ({ className }) => ( 29 | 30 | ); 31 | 32 | export const IconLeft: React.FC = ({ className }) => ( 33 | 34 | ); 35 | 36 | export const IconRight: React.FC = ({ className }) => ( 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Chris McKenzie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/store.ts: -------------------------------------------------------------------------------- 1 | export interface StoreType { 2 | localModels: Array<{ 3 | model: string; 4 | enabled: boolean; 5 | }>; 6 | deviceHost?: string; 7 | } 8 | 9 | export type FakeStoreType = { 10 | get: (key: keyof T) => T[typeof key]; 11 | set: (key: string, value: any) => void; 12 | }; 13 | 14 | export type StoreInstance = FakeStoreType; 15 | 16 | // eslint-disable-next-line no-underscore-dangle 17 | let _store: null = null; 18 | 19 | export async function getStore(): Promise | null> { 20 | const schema = { 21 | deviceHost: { 22 | type: 'string', 23 | default: '', 24 | }, 25 | localModels: { 26 | type: 'array', 27 | default: [], 28 | items: { 29 | type: 'object', 30 | properties: { 31 | name: { 32 | type: 'string', 33 | }, 34 | enabled: { 35 | type: 'boolean', 36 | default: false, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }; 42 | if (!_store) { 43 | const ElectronStore = (await import('electron-store')).default; 44 | // @ts-ignore 45 | _store = new ElectronStore({ schema }); 46 | } 47 | 48 | return _store; 49 | } 50 | -------------------------------------------------------------------------------- /src/browser/preload/controls.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import { TabID } from '../types'; 3 | 4 | export const actions = { 5 | sendEnterURL: (url: string) => ipcRenderer.send('url-enter', url), 6 | 7 | sendChangeURL: (url: string) => ipcRenderer.send('url-change', url), 8 | 9 | sendAct: (actName: string) => ipcRenderer.send('act', actName), 10 | 11 | sendCloseTab: (id: TabID) => ipcRenderer.send('close-tab', id), 12 | 13 | sendNewTab: (url?: string, references?: object) => 14 | ipcRenderer.send('new-tab', url, references), 15 | 16 | sendSwitchTab: (id: TabID) => ipcRenderer.send('switch-tab', id), 17 | }; 18 | 19 | export const controls = { 20 | controlReady: () => ipcRenderer.send('control-ready'), 21 | onActiveUpdate: (callback: any) => { 22 | // Do I need to remove the listener? 23 | ipcRenderer.on('active-update', callback); 24 | return () => ipcRenderer.removeListener('active-update', callback); 25 | }, 26 | onTabsUpdate: (callback: any) => { 27 | ipcRenderer.on('tabs-update', callback); 28 | return () => ipcRenderer.removeListener('tabs-update', callback); 29 | }, 30 | onFocusAddressBar: (callback: any) => { 31 | ipcRenderer.on('focus-address-bar', callback); 32 | return () => ipcRenderer.removeListener('focus-address-bar', callback); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/browser/types.ts: -------------------------------------------------------------------------------- 1 | import { WebContents, IpcMainEvent } from 'electron'; 2 | 3 | export type TabID = number; 4 | 5 | export type TabsList = TabID[]; 6 | 7 | export interface TabConfig extends Object { 8 | canGoBack: boolean; 9 | canGoForward: boolean; 10 | isLoading: boolean; 11 | url: string; 12 | href: string; 13 | title: string; 14 | favicon: string; 15 | } 16 | 17 | export type TabConfigs = Record; 18 | 19 | export interface Tab { 20 | url: string; 21 | href: string; 22 | title: string; 23 | favicon: string; 24 | isLoading: boolean; 25 | canGoBack: boolean; 26 | canGoForward: boolean; 27 | } 28 | 29 | export type Tabs = Record; 30 | 31 | export type TabPreferences = object; 32 | 33 | export type WebContentsActions = keyof WebContents; 34 | 35 | export interface BrowserConfig { 36 | startPage: string; 37 | blankPage: string; 38 | blankTitle: string; 39 | debug: boolean; 40 | } 41 | 42 | export interface BrowserOptions { 43 | width: number; 44 | height: number; 45 | controlPanel: string; 46 | onNewWindow: (event: Event) => void; 47 | debug: boolean; 48 | } 49 | 50 | export interface ChannelListener { 51 | (e: IpcMainEvent, ...args: any[]): void; 52 | } 53 | 54 | export interface ChannelEntry { 55 | name: string; 56 | listener: ChannelListener; 57 | } 58 | -------------------------------------------------------------------------------- /src/browser/controls/components/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '../../../lib/utils/render'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', 13 | secondary: 14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 15 | destructive: 16 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', 17 | outline: 'text-foreground', 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: 'default', 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 | // eslint-disable-next-line react/jsx-props-no-spreading 33 |
34 | ); 35 | } 36 | 37 | export { Badge, badgeVariants }; 38 | -------------------------------------------------------------------------------- /src/browser/settings/components/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as SwitchPrimitives from '@radix-ui/react-switch'; 5 | 6 | import { cn } from '../../../lib/utils/render'; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | // eslint-disable-next-line react/prop-types 12 | >(({ className, ...props }, ref) => ( 13 | 22 | 27 | 28 | )); 29 | Switch.displayName = SwitchPrimitives.Root.displayName; 30 | 31 | export { Switch }; 32 | -------------------------------------------------------------------------------- /.erb/configs/webpack.paths.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const rootPath = path.join(__dirname, '../..'); 4 | 5 | const erbPath = path.join(__dirname, '..'); 6 | const erbNodeModulesPath = path.join(erbPath, 'node_modules'); 7 | 8 | const dllPath = path.join(__dirname, '../dll'); 9 | 10 | const srcPath = path.join(rootPath, 'src'); 11 | const srcMainPath = path.join(srcPath, 'main'); 12 | const srcRendererPath = path.join(srcPath, 'renderer'); 13 | const srcBrowserPath = path.join(srcPath, 'browser'); 14 | const srcPreloadPath = path.join(srcBrowserPath, 'preload'); 15 | 16 | const releasePath = path.join(rootPath, 'release'); 17 | const appPath = path.join(releasePath, 'app'); 18 | const appPackagePath = path.join(appPath, 'package.json'); 19 | const appNodeModulesPath = path.join(appPath, 'node_modules'); 20 | const srcNodeModulesPath = path.join(srcPath, 'node_modules'); 21 | 22 | const distPath = path.join(appPath, 'dist'); 23 | const distMainPath = path.join(distPath, 'main'); 24 | const distRendererPath = path.join(distPath, 'renderer'); 25 | 26 | const buildPath = path.join(releasePath, 'build'); 27 | 28 | const assetsPath = path.join(rootPath, 'assets'); 29 | 30 | export default { 31 | rootPath, 32 | erbNodeModulesPath, 33 | dllPath, 34 | srcPath, 35 | srcMainPath, 36 | srcRendererPath, 37 | srcBrowserPath, 38 | srcPreloadPath, 39 | releasePath, 40 | appPath, 41 | appPackagePath, 42 | appNodeModulesPath, 43 | srcNodeModulesPath, 44 | distPath, 45 | distMainPath, 46 | distRendererPath, 47 | buildPath, 48 | assetsPath, 49 | }; 50 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['erb'], 3 | plugins: ['@typescript-eslint'], 4 | rules: { 5 | // A temporary hack related to IDE not resolving correct package.json 6 | 'import/no-extraneous-dependencies': 'off', 7 | 'react/react-in-jsx-scope': 'off', 8 | 'react/jsx-filename-extension': 'off', 9 | 'import/extensions': 'off', 10 | 'import/no-unresolved': 'off', 11 | 'import/no-import-module-exports': 'off', 12 | 'no-shadow': 'off', 13 | '@typescript-eslint/no-shadow': 'error', 14 | 'no-unused-vars': 'off', 15 | '@typescript-eslint/no-unused-vars': 'error', 16 | 'react/function-component-definition': 'off', 17 | 'import/prefer-default-export': 'off', 18 | 'react/require-default-props': [ 19 | 'error', 20 | { 21 | forbidDefaultForRequired: true, 22 | classes: 'defaultProps', 23 | functions: 'defaultArguments', 24 | }, 25 | ], 26 | }, 27 | parserOptions: { 28 | ecmaVersion: 2022, 29 | sourceType: 'module', 30 | }, 31 | settings: { 32 | 'import/resolver': { 33 | // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below 34 | node: { 35 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 36 | moduleDirectory: ['node_modules', 'src/'], 37 | }, 38 | webpack: { 39 | config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), 40 | }, 41 | typescript: {}, 42 | }, 43 | 'import/parsers': { 44 | '@typescript-eslint/parser': ['.ts', '.tsx'], 45 | }, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RequestOptions, 3 | ConnectSessionOptions, 4 | ModelInfoOptions, 5 | ModelInfo, 6 | ModelSession, 7 | } from './src/ai-api/types'; 8 | 9 | interface AIModel { 10 | model: string; 11 | enabled: boolean; 12 | } 13 | interface AIInterface { 14 | permissions: { 15 | models: () => Promise; 16 | request: (options: RequestOptions) => Promise; 17 | }; 18 | model: { 19 | info: (options: ModelInfoOptions) => Promise; 20 | connect: (options: ConnectSessionOptions) => Promise; 21 | }; 22 | } 23 | 24 | interface Actions { 25 | sendEnterURL: (url: string) => void; 26 | sendChangeURL: (url: string) => void; 27 | sendAct: (actName: string) => void; 28 | sendCloseTab: (id: string | number) => void; 29 | sendNewTab: (url?: string, references?: object) => void; 30 | sendSwitchTab: (id: string | number) => void; 31 | } 32 | 33 | interface Controls { 34 | controlReady: () => void; 35 | onActiveUpdate: (callback: (...args: any[]) => void) => () => void; 36 | onTabsUpdate: (callback: (...args: any[]) => void) => () => void; 37 | onFocusAddressBar: (callback: () => void) => () => void; 38 | } 39 | 40 | interface Device { 41 | enable: (model: string) => Promise; 42 | disable: (model: string) => Promise; 43 | isConnected: () => Promise; 44 | } 45 | 46 | interface BrowserAI { 47 | device: Device; 48 | controls: Controls & { 49 | actions: Actions; 50 | }; 51 | } 52 | 53 | declare global { 54 | interface Window { 55 | ai: AIInterface; 56 | browserai: BrowserAI; 57 | } 58 | } 59 | 60 | export {}; 61 | -------------------------------------------------------------------------------- /src/ai-api/index.ts: -------------------------------------------------------------------------------- 1 | import { models } from './device/index'; 2 | import { connectSession } from './device/session'; 3 | import { 4 | WindowAIHandler, 5 | ConnectSessionOptions, 6 | ModelInfo, 7 | ModelInfoOptions, 8 | PrivateModelSessionConnectionResponse, 9 | RequestOptions, 10 | } from './types'; 11 | 12 | // eslint-disable-next-line import/prefer-default-export 13 | export const handleAPI: WindowAIHandler = { 14 | permissions: { 15 | models: async () => { 16 | if (!models.isConnected()) { 17 | return []; 18 | } 19 | return models.listEnabled(); 20 | }, 21 | request: async (requestOptions: RequestOptions): Promise => { 22 | if (!models.isConnected()) { 23 | return false; 24 | } 25 | return models.isEnabled(requestOptions.model); 26 | }, 27 | }, 28 | model: { 29 | info: async (options: ModelInfoOptions): Promise => { 30 | if (!models.isConnected()) { 31 | throw new Error('Not connected to Model'); 32 | } 33 | return models.getInformation(options); 34 | }, 35 | connect: async ({ 36 | model, 37 | }: ConnectSessionOptions): Promise => { 38 | if (!models.isConnected()) { 39 | throw new Error('Not connected to Model'); 40 | } 41 | const result = await connectSession({ model }); 42 | // @ts-ignore - Come back to this 43 | if (!result.active) { 44 | throw Error( 45 | 'Session could not be created. Confirm you have permissions to access model, and Ollama is running.', 46 | ); 47 | } 48 | return result; 49 | }, 50 | }, 51 | } as const; 52 | -------------------------------------------------------------------------------- /src/browser/controls/use-connect.tsx: -------------------------------------------------------------------------------- 1 | import { type IpcRendererEvent } from 'electron'; 2 | import { useEffect, useState } from 'react'; 3 | import { TabConfig, TabConfigs, TabsList, TabID } from '../types'; 4 | 5 | type TabUpdateValue = { confs: TabConfigs; tabs: TabsList }; 6 | 7 | const noop = () => {}; 8 | 9 | export default function useConnect( 10 | options: { 11 | onTabsUpdate?: (tab: TabUpdateValue) => void; 12 | onTabActive?: (activeTab: TabConfig) => void; 13 | } = {}, 14 | ) { 15 | const { onTabsUpdate = noop, onTabActive = noop } = options; 16 | const [tabs, setTabs] = useState(() => ({}) as TabConfigs); 17 | const [tabIDs, setTabIDs] = useState([]); 18 | const [activeID, setActiveID] = useState(null); 19 | 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | useEffect(() => { 22 | window.browserai.controls.controlReady(); 23 | const destoryTabsUpdate = window.browserai.controls.onTabsUpdate( 24 | (e: IpcRendererEvent, value: TabUpdateValue) => { 25 | setTabIDs(value.tabs); 26 | setTabs(value.confs); 27 | onTabsUpdate(value); 28 | }, 29 | ); 30 | 31 | const destroyActiveUpdate = window.browserai.controls.onActiveUpdate( 32 | (e: IpcRendererEvent, value: TabID) => { 33 | setActiveID(value); 34 | if (tabs[value as keyof TabConfigs]) { 35 | onTabActive(tabs[value as keyof TabConfigs]); 36 | } 37 | }, 38 | ); 39 | 40 | return () => { 41 | destoryTabsUpdate(); 42 | destroyActiveUpdate(); 43 | }; 44 | }, [tabs, onTabsUpdate, onTabActive]); 45 | 46 | return { tabIDs, tabs, activeID }; 47 | } 48 | -------------------------------------------------------------------------------- /src/browser/preload/ai.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import { WindowAIBinding } from '../../ai-api/types'; 3 | /** 4 | * @summary 5 | * 6 | * *You can mostly ignore this file* 7 | * This code is required by Electron's contextBridge for security purposes. 8 | * It prevents the renderer process from directly accessing the main process. 9 | * The code exposes `window.ai` and forwards calls to the main process, which then interacts with the AI API. 10 | * 11 | * For more details, you can read about context isolation here: https://www.electronjs.org/docs/tutorial/context-isolation 12 | */ 13 | export const ai: WindowAIBinding = { 14 | permissions: { 15 | models: async () => { 16 | return ipcRenderer.invoke('ai:permissions:models'); 17 | }, 18 | request: async (model) => { 19 | return ipcRenderer.invoke('ai:permissions:request', model); 20 | }, 21 | }, 22 | model: { 23 | info: async (options) => { 24 | return ipcRenderer.invoke('ai:model:info', options); 25 | }, 26 | connect: async (model) => { 27 | const session = await ipcRenderer.invoke('ai:model:connect', model); 28 | if (!session.active || !session.model) { 29 | throw new Error(`Failed to connect to model ${model}`); 30 | } 31 | 32 | return { 33 | chat: async (options) => { 34 | return ipcRenderer.invoke('ai:model:session:chat', { 35 | ...options, 36 | model: session.model, 37 | }); 38 | }, 39 | embed: async (options) => { 40 | return ipcRenderer.invoke('ai:model:session:embed', { 41 | ...options, 42 | model: session.model, 43 | }); 44 | }, 45 | }; 46 | }, 47 | }, 48 | } as const; 49 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base webpack config used across other specific configs 3 | */ 4 | import webpack from 'webpack'; 5 | import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; 6 | import webpackPaths from './webpack.paths'; 7 | // eslint-disable-next-line import/no-relative-packages 8 | import { dependencies as externals } from '../../release/app/package.json'; 9 | 10 | const configuration: webpack.Configuration = { 11 | externals: [...Object.keys(externals || {})], 12 | 13 | stats: 'errors-only', 14 | 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.[jt]sx?$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'ts-loader', 22 | options: { 23 | // Remove this line to enable type checking in webpack builds 24 | transpileOnly: true, 25 | compilerOptions: { 26 | module: 'esnext', 27 | }, 28 | }, 29 | }, 30 | }, 31 | ], 32 | }, 33 | 34 | output: { 35 | path: webpackPaths.srcPath, 36 | // https://github.com/webpack/webpack/issues/1114 37 | library: { 38 | type: 'commonjs2', 39 | }, 40 | }, 41 | 42 | /** 43 | * Determine the array of extensions that should be used to resolve modules. 44 | */ 45 | resolve: { 46 | fallback: { 47 | path: false, 48 | fs: false, 49 | }, 50 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '.css'], 51 | modules: [webpackPaths.srcPath, 'node_modules'], 52 | // There is no need to add aliases here, the paths in tsconfig get mirrored 53 | plugins: [new TsconfigPathsPlugins()], 54 | }, 55 | 56 | plugins: [ 57 | new webpack.EnvironmentPlugin({ 58 | NODE_ENV: 'production', 59 | }), 60 | ], 61 | }; 62 | 63 | export default configuration; 64 | -------------------------------------------------------------------------------- /src/lib/utils/main.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import path from 'path'; 3 | import { screen, app } from 'electron'; 4 | 5 | interface WindowSize { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | const RESOURCES_PATH = app.isPackaged 11 | ? path.join(process.resourcesPath, 'assets') 12 | : path.join(__dirname, '../../assets'); 13 | 14 | export function getWindowSize(): WindowSize { 15 | const primaryDisplay = screen.getPrimaryDisplay(); 16 | const width = Math.round(primaryDisplay.workAreaSize.width * 0.8); 17 | const height = Math.round(primaryDisplay.workAreaSize.height * 0.9); 18 | return { width, height }; 19 | } 20 | 21 | export function resolveHtmlPath(htmlFileName: string) { 22 | if (process.env.NODE_ENV === 'development') { 23 | const port = process.env.PORT || 1212; 24 | const url = new URL(`http://localhost:${port}`); 25 | url.pathname = htmlFileName; 26 | return url.href; 27 | } 28 | return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; 29 | } 30 | 31 | export const getAssetPath = (...paths: string[]): string => { 32 | const fialPath = path.join(RESOURCES_PATH, ...paths); 33 | return fialPath; 34 | }; 35 | 36 | const ALIAS_DOMAIN = { 37 | 'about:preferences': resolveHtmlPath('settings.html'), 38 | } as const; 39 | 40 | export function isAliasDomain(url: string): url is keyof typeof ALIAS_DOMAIN { 41 | return url in ALIAS_DOMAIN; 42 | } 43 | 44 | export function getAliasURL(url: keyof typeof ALIAS_DOMAIN): string { 45 | return ALIAS_DOMAIN[url]; 46 | } 47 | 48 | export function getAliasFromURL(url: string): keyof typeof ALIAS_DOMAIN | null { 49 | if (!url) return null; 50 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 51 | const alias = Object.entries(ALIAS_DOMAIN).find(([_, href]) => 52 | url.includes(href), 53 | ) as [keyof typeof ALIAS_DOMAIN, string]; 54 | return alias ? alias[0] : null; 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/utils/format-zod-error.test.ts: -------------------------------------------------------------------------------- 1 | import { ZodError, ZodIssue } from 'zod'; 2 | import { formatZodError } from './format-zod-error'; 3 | 4 | describe('formatZodError', () => { 5 | test('should format single error correctly', () => { 6 | const error = new ZodError([ 7 | { 8 | path: ['field1'], 9 | message: 'Invalid input', 10 | code: 'invalid_type', 11 | expected: 'string', 12 | received: 'number', 13 | } as ZodIssue, 14 | ]); 15 | 16 | const result = formatZodError(error); 17 | expect(result).toBe( 18 | 'Validation errors:\n\n1. Field: field1 - Error: Invalid input\n Expected: string, Received: number', 19 | ); 20 | }); 21 | 22 | test('should format multiple errors correctly', () => { 23 | const error = new ZodError([ 24 | { 25 | path: ['field1'], 26 | message: 'Invalid input', 27 | code: 'invalid_type', 28 | expected: 'string', 29 | received: 'number', 30 | } as ZodIssue, 31 | { 32 | path: ['field2'], 33 | message: 'Required', 34 | code: 'invalid_type', 35 | expected: 'string', 36 | received: 'undefined', 37 | } as ZodIssue, 38 | ]); 39 | 40 | const result = formatZodError(error); 41 | expect(result).toBe( 42 | 'Validation errors:\n\n1. Field: field1 - Error: Invalid input\n Expected: string, Received: number\n2. Field: field2 - Error: Required\n Expected: string, Received: undefined', 43 | ); 44 | }); 45 | 46 | test('should format error without path correctly', () => { 47 | const error = new ZodError([ 48 | { 49 | path: [], 50 | message: 'General error', 51 | code: 'custom', 52 | } as ZodIssue, 53 | ]); 54 | 55 | const result = formatZodError(error); 56 | expect(result).toBe('Validation errors:\n\n1. Error: General error'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/ai-api/validate.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const modelNameSchema = z.string().describe('name of the model'); 4 | 5 | export const messageSchema = z.object({ 6 | role: z.string(), 7 | content: z.string(), 8 | }); 9 | 10 | const optionsSchema = z.object({ 11 | temperature: z.number(), 12 | stop: z.array(z.string()), 13 | seed: z.number().optional(), 14 | repeat_penalty: z.number(), 15 | presence_penalty: z.number(), 16 | frequency_penalty: z.number(), 17 | top_k: z.number(), 18 | top_p: z.number(), 19 | }); 20 | 21 | const toolSchema = z.object({ 22 | type: z.literal('function'), 23 | function: z.object({ 24 | name: z.string(), 25 | description: z.string(), 26 | parameters: z.object({ 27 | type: z.literal('object'), 28 | required: z.array(z.string()), 29 | properties: z.record( 30 | z.object({ 31 | type: z.string(), 32 | description: z.string(), 33 | }), 34 | ), 35 | }), 36 | }), 37 | }); 38 | 39 | export const chatRequestSchema = z.object({ 40 | model: modelNameSchema, 41 | messages: z.array(messageSchema), 42 | // stream: z.boolean().optional(), 43 | format: z.string().optional(), 44 | tools: z.array(toolSchema).optional(), 45 | options: optionsSchema.partial().optional(), 46 | }); 47 | 48 | export const modelInfoOptionsSchema = z.object({ 49 | model: modelNameSchema, 50 | }); 51 | 52 | export const embedOptionsSchema = z.object({ 53 | model: modelNameSchema, 54 | input: z.union([z.string(), z.array(z.string())]), 55 | truncate: z.boolean().optional(), 56 | keep_alive: z.union([z.string(), z.number()]).optional(), 57 | options: optionsSchema.partial().optional(), 58 | }); 59 | 60 | export const connectSessionOptionsSchema = z.object({ 61 | model: modelNameSchema, 62 | }); 63 | 64 | export const ollamaHostUrlSchema = z 65 | .string() 66 | .url() 67 | .describe('Ollama Server Url (default: http://localhost:11434)'); 68 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.main.dev.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack config for development electron main process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 8 | import { merge } from 'webpack-merge'; 9 | import checkNodeEnv from '../scripts/check-node-env'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | 13 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 14 | // at the dev webpack config is not accidentally run in a production environment 15 | if (process.env.NODE_ENV === 'production') { 16 | checkNodeEnv('development'); 17 | } 18 | 19 | const configuration: webpack.Configuration = { 20 | devtool: 'inline-source-map', 21 | 22 | mode: 'development', 23 | 24 | target: 'electron-main', 25 | 26 | entry: { 27 | main: path.join(webpackPaths.srcMainPath, 'main.ts'), 28 | preloadPublic: path.join(webpackPaths.srcPreloadPath, 'public.ts'), 29 | preloadPrivate: path.join(webpackPaths.srcPreloadPath, 'private.ts'), 30 | }, 31 | 32 | output: { 33 | path: webpackPaths.dllPath, 34 | filename: '[name].bundle.dev.js', 35 | library: { 36 | type: 'umd', 37 | }, 38 | }, 39 | 40 | plugins: [ 41 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 42 | // @ts-ignore 43 | new BundleAnalyzerPlugin({ 44 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 45 | analyzerPort: 8888, 46 | }), 47 | 48 | new webpack.DefinePlugin({ 49 | 'process.type': '"browser"', 50 | }), 51 | ], 52 | 53 | /** 54 | * Disables webpack processing of __dirname and __filename. 55 | * If you run the bundle in node.js it falls back to these values of node.js. 56 | * https://github.com/webpack/webpack/issues/2010 57 | */ 58 | // node: { 59 | // __dirname: false, 60 | // __filename: false, 61 | // }, 62 | }; 63 | 64 | export default merge(baseConfig, configuration); 65 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.dll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Builds the DLL for development electron renderer process 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import path from 'path'; 7 | import { merge } from 'webpack-merge'; 8 | import baseConfig from './webpack.config.base'; 9 | import webpackPaths from './webpack.paths'; 10 | import { dependencies } from '../../package.json'; 11 | import checkNodeEnv from '../scripts/check-node-env'; 12 | 13 | checkNodeEnv('development'); 14 | 15 | const dist = webpackPaths.dllPath; 16 | 17 | const configuration: webpack.Configuration = { 18 | context: webpackPaths.rootPath, 19 | 20 | devtool: 'eval', 21 | 22 | mode: 'development', 23 | 24 | target: 'electron-renderer', 25 | 26 | externals: ['fsevents', 'crypto-browserify'], 27 | 28 | module: require('./webpack.config.renderer.dev').default.module, 29 | 30 | entry: { 31 | // renderer: Object.keys(dependencies || {}), 32 | controls: Object.keys(dependencies || {}), 33 | settings: Object.keys(dependencies || {}), 34 | }, 35 | 36 | output: { 37 | path: dist, 38 | filename: '[name].dev.dll.js', 39 | library: { 40 | name: 'renderer', 41 | type: 'var', 42 | }, 43 | }, 44 | 45 | plugins: [ 46 | new webpack.DllPlugin({ 47 | path: path.join(dist, '[name].json'), 48 | name: '[name]', 49 | }), 50 | 51 | /** 52 | * Create global constants which can be configured at compile time. 53 | * 54 | * Useful for allowing different behaviour between development builds and 55 | * release builds 56 | * 57 | * NODE_ENV should be production so that modules do not perform certain 58 | * development checks 59 | */ 60 | new webpack.EnvironmentPlugin({ 61 | NODE_ENV: 'development', 62 | }), 63 | 64 | new webpack.LoaderOptionsPlugin({ 65 | debug: true, 66 | options: { 67 | context: webpackPaths.srcPath, 68 | output: { 69 | path: webpackPaths.dllPath, 70 | }, 71 | }, 72 | }), 73 | ], 74 | }; 75 | 76 | export default merge(baseConfig, configuration); 77 | -------------------------------------------------------------------------------- /.erb/scripts/check-native-dep.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import chalk from 'chalk'; 3 | import { execSync } from 'child_process'; 4 | import { dependencies } from '../../package.json'; 5 | 6 | if (dependencies) { 7 | const dependenciesKeys = Object.keys(dependencies); 8 | const nativeDeps = fs 9 | .readdirSync('node_modules') 10 | .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`)); 11 | if (nativeDeps.length === 0) { 12 | process.exit(0); 13 | } 14 | try { 15 | // Find the reason for why the dependency is installed. If it is installed 16 | // because of a devDependency then that is okay. Warn when it is installed 17 | // because of a dependency 18 | const { dependencies: dependenciesObject } = JSON.parse( 19 | execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(), 20 | ); 21 | const rootDependencies = Object.keys(dependenciesObject); 22 | const filteredRootDependencies = rootDependencies.filter((rootDependency) => 23 | dependenciesKeys.includes(rootDependency), 24 | ); 25 | if (filteredRootDependencies.length > 0) { 26 | const plural = filteredRootDependencies.length > 1; 27 | console.log(` 28 | ${chalk.whiteBright.bgYellow.bold( 29 | 'Webpack does not work with native dependencies.', 30 | )} 31 | ${chalk.bold(filteredRootDependencies.join(', '))} ${ 32 | plural ? 'are native dependencies' : 'is a native dependency' 33 | } and should be installed inside of the "./release/app" folder. 34 | First, uninstall the packages from "./package.json": 35 | ${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')} 36 | ${chalk.bold( 37 | 'Then, instead of installing the package to the root "./package.json":', 38 | )} 39 | ${chalk.whiteBright.bgRed.bold('npm install your-package')} 40 | ${chalk.bold('Install the package to "./release/app/package.json"')} 41 | ${chalk.whiteBright.bgGreen.bold( 42 | 'cd ./release/app && npm install your-package', 43 | )} 44 | Read more about native dependencies at: 45 | ${chalk.bold( 46 | 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure', 47 | )} 48 | `); 49 | process.exit(1); 50 | } 51 | } catch (e) { 52 | console.log('Native dependencies could not be checked'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/browser/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --border: 214.3 31.8% 91.4%; 17 | --input: 214.3 31.8% 91.4%; 18 | 19 | --card: 0 0% 100%; 20 | --card-foreground: 222.2 47.4% 11.2%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --accent: 216 34% 17%; 47 | --accent-foreground: 210 40% 98%; 48 | 49 | --popover: 224 71% 4%; 50 | --popover-foreground: 215 20.2% 65.1%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --card: 224 71% 4%; 56 | --card-foreground: 213 31% 91%; 57 | 58 | --primary: 210 40% 98%; 59 | --primary-foreground: 222.2 47.4% 1.2%; 60 | 61 | --secondary: 222.2 47.4% 11.2%; 62 | --secondary-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | /* @font-face { 75 | font-family: 'Helvetica Neue'; 76 | font-style: normal; 77 | font-weight: 400; 78 | font-display: swap; 79 | } */ 80 | * { 81 | @apply border-border; 82 | } 83 | body { 84 | @apply bg-background text-foreground; 85 | font-feature-settings: "rlig" 1, "calt" 1; 86 | font-family: 'Helvetica Neue', sans-serif; 87 | } 88 | } 89 | 90 | @layer components { 91 | .tab.active + .tab { 92 | @apply border-l-0; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.preload.dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import { merge } from 'webpack-merge'; 4 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 5 | import baseConfig from './webpack.config.base'; 6 | import webpackPaths from './webpack.paths'; 7 | import checkNodeEnv from '../scripts/check-node-env'; 8 | 9 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 10 | // at the dev webpack config is not accidentally run in a production environment 11 | if (process.env.NODE_ENV === 'production') { 12 | checkNodeEnv('development'); 13 | } 14 | 15 | const configuration: webpack.Configuration = { 16 | devtool: 'inline-source-map', 17 | 18 | mode: 'development', 19 | 20 | target: 'electron-preload', 21 | 22 | entry: { 23 | preloadPublic: path.join(webpackPaths.srcPreloadPath, 'public.ts'), 24 | preloadPrivate: path.join(webpackPaths.srcPreloadPath, 'private.ts'), 25 | }, 26 | 27 | output: { 28 | path: webpackPaths.dllPath, 29 | filename: '[name].js', 30 | library: { 31 | type: 'umd', 32 | }, 33 | }, 34 | 35 | plugins: [ 36 | new BundleAnalyzerPlugin({ 37 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 38 | }), 39 | 40 | /** 41 | * Create global constants which can be configured at compile time. 42 | * 43 | * Useful for allowing different behaviour between development builds and 44 | * release builds 45 | * 46 | * NODE_ENV should be production so that modules do not perform certain 47 | * development checks 48 | * 49 | * By default, use 'development' as NODE_ENV. This can be overriden with 50 | * 'staging', for example, by changing the ENV variables in the npm scripts 51 | */ 52 | new webpack.EnvironmentPlugin({ 53 | NODE_ENV: 'development', 54 | }), 55 | 56 | new webpack.LoaderOptionsPlugin({ 57 | debug: true, 58 | }), 59 | ], 60 | 61 | /** 62 | * Disables webpack processing of __dirname and __filename. 63 | * If you run the bundle in node.js it falls back to these values of node.js. 64 | * https://github.com/webpack/webpack/issues/2010 65 | */ 66 | // node: { 67 | // __dirname: false, 68 | // __filename: false, 69 | // }, 70 | 71 | watch: true, 72 | }; 73 | 74 | export default merge(baseConfig, configuration); 75 | -------------------------------------------------------------------------------- /src/ai-api/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { handleAPI } from '../index'; 2 | 3 | describe('handleAPI api', () => { 4 | beforeEach(() => { 5 | jest.clearAllMocks(); 6 | }); 7 | 8 | test('should be able to request permissions', async () => { 9 | const result1 = await handleAPI.permissions.request({ 10 | model: 'gemma2', 11 | }); 12 | const result2 = await handleAPI.permissions.request({ 13 | model: 'unavailable-model', 14 | }); 15 | expect(result1).toEqual(true); 16 | expect(result2).toEqual(false); 17 | }); 18 | 19 | test('should be able to list avilable models', async () => { 20 | const result = await handleAPI.permissions.models(); 21 | expect(result).toEqual([{ enabled: true, model: 'gemma2' }]); 22 | }); 23 | 24 | test('should be able to return empty list if no models avilable', async () => { 25 | jest.resetModules(); 26 | 27 | // Mock the module for this specific test 28 | jest.doMock('../../lib/store', () => ({ 29 | getStore: jest.fn().mockResolvedValue({ 30 | get: jest.fn().mockReturnValue([]), 31 | set: jest.fn(), 32 | }), 33 | })); 34 | 35 | // Re-import the module that uses the store 36 | // eslint-disable-next-line @typescript-eslint/no-shadow 37 | const { handleAPI } = await import('../index'); 38 | 39 | const result = await handleAPI.permissions.models(); 40 | expect(result).toEqual([]); 41 | }); 42 | 43 | test('should be able to get model info', async () => { 44 | jest.resetModules(); 45 | 46 | const result = await handleAPI.model.info({ model: 'llama3.1' }); 47 | expect(result).toEqual({ 48 | details: { 49 | families: ['llama'], 50 | family: 'llama', 51 | format: 'gguf', 52 | parameter_size: '3.2B', 53 | parent_model: '', 54 | quantization_level: 'Q4_K_M', 55 | }, 56 | license: 'Model license text', 57 | model: 'llama3.1', 58 | }); 59 | }); 60 | 61 | test('should connect to model', async () => { 62 | jest.resetModules(); 63 | 64 | // Re-import the module that uses the store 65 | // eslint-disable-next-line @typescript-eslint/no-shadow 66 | const { handleAPI } = await import('../index'); 67 | 68 | const result = await handleAPI.model.connect({ model: 'llama3.1' }); 69 | expect(result).toEqual({ active: true, model: 'llama3.1' }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.main.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack config for production electron main process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import { merge } from 'webpack-merge'; 8 | import TerserPlugin from 'terser-webpack-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | import checkNodeEnv from '../scripts/check-node-env'; 13 | import deleteSourceMaps from '../scripts/delete-source-maps'; 14 | 15 | checkNodeEnv('production'); 16 | deleteSourceMaps(); 17 | 18 | const configuration: webpack.Configuration = { 19 | devtool: 'source-map', 20 | 21 | mode: 'production', 22 | 23 | target: 'electron-main', 24 | 25 | entry: { 26 | main: path.join(webpackPaths.srcMainPath, 'main.ts'), 27 | preloadPublic: path.join(webpackPaths.srcPreloadPath, 'public.ts'), 28 | preloadPrivate: path.join(webpackPaths.srcPreloadPath, 'private.ts'), 29 | }, 30 | 31 | output: { 32 | path: webpackPaths.distMainPath, 33 | filename: '[name].js', 34 | library: { 35 | type: 'umd', 36 | }, 37 | }, 38 | 39 | optimization: { 40 | minimizer: [ 41 | new TerserPlugin({ 42 | parallel: true, 43 | }), 44 | ], 45 | }, 46 | 47 | plugins: [ 48 | new BundleAnalyzerPlugin({ 49 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 50 | analyzerPort: 8888, 51 | }), 52 | 53 | /** 54 | * Create global constants which can be configured at compile time. 55 | * 56 | * Useful for allowing different behaviour between development builds and 57 | * release builds 58 | * 59 | * NODE_ENV should be production so that modules do not perform certain 60 | * development checks 61 | */ 62 | new webpack.EnvironmentPlugin({ 63 | NODE_ENV: 'production', 64 | DEBUG_PROD: false, 65 | START_MINIMIZED: false, 66 | }), 67 | 68 | new webpack.DefinePlugin({ 69 | 'process.type': '"browser"', 70 | }), 71 | ], 72 | 73 | /** 74 | * Disables webpack processing of __dirname and __filename. 75 | * If you run the bundle in node.js it falls back to these values of node.js. 76 | * https://github.com/webpack/webpack/issues/2010 77 | */ 78 | node: { 79 | __dirname: false, 80 | __filename: false, 81 | }, 82 | }; 83 | 84 | export default merge(baseConfig, configuration); 85 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require('tailwindcss/defaultTheme'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ['class'], 6 | content: [ 7 | 'src/browser/**/*.{ts,tsx}', 8 | 'src/browser/components/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: '2rem', 14 | screens: { 15 | '2xl': '1400px', 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: 'hsl(var(--border))', 21 | input: 'hsl(var(--input))', 22 | ring: 'hsl(var(--ring))', 23 | background: 'hsl(var(--background))', 24 | foreground: 'hsl(var(--foreground))', 25 | primary: { 26 | DEFAULT: 'hsl(var(--primary))', 27 | foreground: 'hsl(var(--primary-foreground))', 28 | }, 29 | secondary: { 30 | DEFAULT: 'hsl(var(--secondary))', 31 | foreground: 'hsl(var(--secondary-foreground))', 32 | }, 33 | destructive: { 34 | DEFAULT: 'hsl(var(--destructive))', 35 | foreground: 'hsl(var(--destructive-foreground))', 36 | }, 37 | muted: { 38 | DEFAULT: 'hsl(var(--muted))', 39 | foreground: 'hsl(var(--muted-foreground))', 40 | }, 41 | accent: { 42 | DEFAULT: 'hsl(var(--accent))', 43 | foreground: 'hsl(var(--accent-foreground))', 44 | }, 45 | popover: { 46 | DEFAULT: 'hsl(var(--popover))', 47 | foreground: 'hsl(var(--popover-foreground))', 48 | }, 49 | card: { 50 | DEFAULT: 'hsl(var(--card))', 51 | foreground: 'hsl(var(--card-foreground))', 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: `var(--radius)`, 56 | md: `calc(var(--radius) - 2px)`, 57 | sm: 'calc(var(--radius) - 4px)', 58 | }, 59 | fontFamily: { 60 | sans: ['var(--font-sans)', ...fontFamily.sans], 61 | }, 62 | keyframes: { 63 | 'accordion-down': { 64 | from: { height: '0' }, 65 | to: { height: 'var(--radix-accordion-content-height)' }, 66 | }, 67 | 'accordion-up': { 68 | from: { height: 'var(--radix-accordion-content-height)' }, 69 | to: { height: '0' }, 70 | }, 71 | }, 72 | animation: { 73 | 'accordion-down': 'accordion-down 0.2s ease-out', 74 | 'accordion-up': 'accordion-up 0.2s ease-out', 75 | }, 76 | }, 77 | }, 78 | // eslint-disable-next-line global-require 79 | plugins: [require('tailwindcss-animate')], 80 | }; 81 | -------------------------------------------------------------------------------- /src/ai-api/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { 3 | chatRequestSchema, 4 | connectSessionOptionsSchema, 5 | embedOptionsSchema, 6 | messageSchema, 7 | modelInfoOptionsSchema, 8 | } from './validate'; 9 | 10 | export interface Site { 11 | name: string; 12 | host: string; 13 | permission: boolean; 14 | } 15 | 16 | export interface UserPreferences { 17 | availableModels: string[]; 18 | ollamaHost: string; 19 | sites: Site[]; 20 | } 21 | 22 | export type ModelName = string; 23 | 24 | export type Message = z.infer; 25 | 26 | export type ChatOptions = z.infer; 27 | 28 | export type Tools = ChatOptions['tools']; 29 | 30 | export type FinishReason = 31 | | 'stop' 32 | | 'length' 33 | | 'tool_calls' 34 | | 'content_filter' 35 | | 'function_call'; 36 | 37 | export interface ChatChoice { 38 | message: Message; 39 | finish_reason: FinishReason; 40 | } 41 | 42 | interface ChatResponseUsage { 43 | total_duration: number; 44 | load_duration: number; 45 | prompt_eval_count: number; 46 | prompt_eval_duration: number; 47 | eval_count: number; 48 | eval_duration: number; 49 | } 50 | 51 | export interface ChatResponse { 52 | id: string; 53 | choices: Array; 54 | created: Date; 55 | model: string; 56 | usage: ChatResponseUsage; 57 | } 58 | 59 | export interface LoadModelStatus { 60 | status: string; 61 | message?: string; 62 | } 63 | 64 | export type EmbedOptions = z.infer; 65 | 66 | export interface EmbedResponse { 67 | id: string; 68 | model: string; 69 | embeddings: number[][]; 70 | } 71 | 72 | export interface RequestOptions { 73 | model: ModelName; 74 | silent?: boolean; 75 | } 76 | 77 | export type EnabledModel = { 78 | enabled: boolean; 79 | model: string; 80 | }; 81 | 82 | export interface PermissionProperties { 83 | models: () => Promise; 84 | request: (options: RequestOptions) => Promise; 85 | } 86 | 87 | export interface ModelSession { 88 | chat: (options: ChatOptions) => Promise; 89 | embed: (options: EmbedOptions) => Promise; 90 | } 91 | 92 | export interface PrivateModelSessionConnectionResponse { 93 | active: boolean; 94 | model: string | null; 95 | } 96 | 97 | export type ConnectSessionOptions = z.infer; 98 | 99 | export type ModelInfoOptions = z.infer; 100 | 101 | interface ModelDetails { 102 | parent_model: string; 103 | format: string; 104 | family: string; 105 | families: string[]; 106 | parameter_size: string; 107 | quantization_level: string; 108 | } 109 | 110 | export interface ModelInfo { 111 | model: string; 112 | license: string; 113 | details: ModelDetails; 114 | } 115 | 116 | export interface ModelProperties { 117 | connect(options: ConnectSessionOptions): Promise; 118 | info(options: ModelInfoOptions): Promise; 119 | } 120 | 121 | export interface WindowAIBinding { 122 | permissions: PermissionProperties; 123 | model: ModelProperties; 124 | } 125 | 126 | export interface WindowAIHandler { 127 | permissions: PermissionProperties; 128 | model: Omit & { 129 | connect: ( 130 | options: ConnectSessionOptions, 131 | ) => Promise; 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /ollama-models.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "qwq", 4 | "link": "https://ollama.com/library/qwq", 5 | "tags": [ 6 | "family:alibaba" 7 | ] 8 | }, 9 | { 10 | "title": "deepseek-r1", 11 | "link": "https://ollama.com/library/deepseek-r1", 12 | "tags": [ 13 | "family:deepseek" 14 | ] 15 | }, 16 | { 17 | "title": "llama3.2", 18 | "link": "https://ollama.com/library/llama3.2", 19 | "tags": [ 20 | "tools", 21 | "family:meta" 22 | ] 23 | }, 24 | { 25 | "title": "llama3.3", 26 | "link": "https://ollama.com/library/llama3.3", 27 | "tags": [ 28 | "tools", 29 | "family:meta" 30 | ] 31 | }, 32 | { 33 | "title": "llama3.1", 34 | "link": "https://ollama.com/library/llama3.1", 35 | "tags": [ 36 | "tools", 37 | "family:meta" 38 | ] 39 | }, 40 | { 41 | "title": "llama4", 42 | "link": "https://ollama.com/library/llama4", 43 | "tags": [ 44 | "tools", 45 | "family:meta" 46 | ] 47 | }, 48 | { 49 | "title": "phi4", 50 | "link": "https://ollama.com/library/phi4", 51 | "tags": [ 52 | "family:microsoft" 53 | ] 54 | }, 55 | { 56 | "title": "gemma2", 57 | "link": "https://ollama.com/library/gemma2", 58 | "tags": [ 59 | "family:google" 60 | ] 61 | }, 62 | { 63 | "title": "gemma3", 64 | "link": "https://ollama.com/library/gemma3", 65 | "tags": [ 66 | "family:google", 67 | "vision" 68 | ] 69 | }, 70 | { 71 | "title": "qwen2.5", 72 | "link": "https://ollama.com/library/qwen2.5", 73 | "tags": [ 74 | "tools", 75 | "family:alibaba" 76 | ] 77 | }, 78 | { 79 | "title": "qwen3", 80 | "link": "https://ollama.com/library/qwen3", 81 | "tags": [ 82 | "tools", 83 | "family:alibaba" 84 | ] 85 | }, 86 | { 87 | "title": "phi3.5", 88 | "link": "https://ollama.com/library/phi3.5", 89 | "tags": [ 90 | "family:microsoft" 91 | ] 92 | }, 93 | { 94 | "title": "mistral-small3.1", 95 | "link": "https://ollama.com/library/mistral-small3.1", 96 | "tags": [ 97 | "tools", 98 | "vision", 99 | "family:mistral" 100 | ] 101 | }, 102 | { 103 | "title": "mistral-nemo", 104 | "link": "https://ollama.com/library/mistral-nemo", 105 | "tags": [ 106 | "tools", 107 | "family:mistral" 108 | ] 109 | }, 110 | { 111 | "title": "mistral", 112 | "link": "https://ollama.com/library/mistral", 113 | "tags": [ 114 | "tools", 115 | "family:mistral" 116 | ] 117 | }, 118 | { 119 | "title": "mixtral", 120 | "link": "https://ollama.com/library/mixtral", 121 | "tags": [ 122 | "tools", 123 | "family:mistral" 124 | ] 125 | }, 126 | { 127 | "title": "smollm2", 128 | "link": "https://ollama.com/library/smollm2", 129 | "tags": [ 130 | "family:huggingface", 131 | "tools" 132 | ] 133 | }, 134 | { 135 | "title": "phi3", 136 | "link": "https://ollama.com/library/phi3", 137 | "tags": [ 138 | "family:microsoft" 139 | ] 140 | }, 141 | { 142 | "title": "deepseek-coder-v2", 143 | "link": "https://ollama.com/library/deepseek-coder-v2", 144 | "tags": [ 145 | "family:deepseek" 146 | ] 147 | }, 148 | { 149 | "title": "deepseek-v3", 150 | "link": "https://ollama.com/library/deepseek-v3", 151 | "tags": [ 152 | "family:deepseek" 153 | ] 154 | } 155 | ] -------------------------------------------------------------------------------- /src/browser/settings/components/table.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import * as React from 'react'; 3 | 4 | import { cn } from '../../../lib/utils/render'; 5 | 6 | const Table = React.forwardRef< 7 | HTMLTableElement, 8 | React.HTMLAttributes 9 | >(({ className, ...props }, ref) => ( 10 |
11 | 16 | 17 | )); 18 | Table.displayName = 'Table'; 19 | 20 | const TableHeader = React.forwardRef< 21 | HTMLTableSectionElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 | 25 | )); 26 | TableHeader.displayName = 'TableHeader'; 27 | 28 | const TableBody = React.forwardRef< 29 | HTMLTableSectionElement, 30 | React.HTMLAttributes 31 | >(({ className, ...props }, ref) => ( 32 | 37 | )); 38 | TableBody.displayName = 'TableBody'; 39 | 40 | const TableFooter = React.forwardRef< 41 | HTMLTableSectionElement, 42 | React.HTMLAttributes 43 | >(({ className, ...props }, ref) => ( 44 | tr]:last:border-b-0', 48 | className, 49 | )} 50 | {...props} 51 | /> 52 | )); 53 | TableFooter.displayName = 'TableFooter'; 54 | 55 | const TableRow = React.forwardRef< 56 | HTMLTableRowElement, 57 | React.HTMLAttributes 58 | >(({ className, ...props }, ref) => ( 59 | 67 | )); 68 | TableRow.displayName = 'TableRow'; 69 | 70 | const TableHead = React.forwardRef< 71 | HTMLTableCellElement, 72 | React.ThHTMLAttributes 73 | // eslint-disable-next-line react/prop-types 74 | >(({ className, ...props }, ref) => ( 75 |
83 | )); 84 | TableHead.displayName = 'TableHead'; 85 | 86 | const TableCell = React.forwardRef< 87 | HTMLTableCellElement, 88 | React.TdHTMLAttributes 89 | // eslint-disable-next-line react/prop-types 90 | >(({ className, ...props }, ref) => ( 91 | 96 | )); 97 | TableCell.displayName = 'TableCell'; 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )); 109 | TableCaption.displayName = 'TableCaption'; 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | }; 121 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build config for electron renderer process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; 11 | import { merge } from 'webpack-merge'; 12 | import TerserPlugin from 'terser-webpack-plugin'; 13 | import baseConfig from './webpack.config.base'; 14 | import webpackPaths from './webpack.paths'; 15 | import checkNodeEnv from '../scripts/check-node-env'; 16 | import deleteSourceMaps from '../scripts/delete-source-maps'; 17 | 18 | checkNodeEnv('production'); 19 | deleteSourceMaps(); 20 | 21 | const configuration: webpack.Configuration = { 22 | devtool: 'source-map', 23 | 24 | mode: 'production', 25 | 26 | target: ['web', 'electron-renderer'], 27 | 28 | // entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], 29 | entry: { 30 | controls: path.join(webpackPaths.srcBrowserPath, 'controls/entry.tsx'), 31 | settings: path.join(webpackPaths.srcBrowserPath, 'settings/entry.tsx'), 32 | }, 33 | 34 | output: { 35 | path: webpackPaths.distRendererPath, 36 | publicPath: './', 37 | filename: '[name].bundle.js', 38 | library: { 39 | type: 'umd', 40 | }, 41 | }, 42 | 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.css$/, 47 | use: [ 48 | MiniCssExtractPlugin.loader, 49 | 'css-loader', 50 | { 51 | loader: 'postcss-loader', 52 | options: { 53 | postcssOptions: { 54 | plugins: [require('tailwindcss'), require('autoprefixer')], 55 | }, 56 | }, 57 | }, 58 | ], 59 | }, 60 | // Fonts 61 | { 62 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 63 | type: 'asset/resource', 64 | }, 65 | // Images 66 | { 67 | test: /\.(png|jpg|jpeg|gif)$/i, 68 | type: 'asset/resource', 69 | }, 70 | // SVG 71 | { 72 | test: /\.svg$/, 73 | use: [ 74 | { 75 | loader: '@svgr/webpack', 76 | options: { 77 | prettier: false, 78 | svgo: false, 79 | svgoConfig: { 80 | plugins: [{ removeViewBox: false }], 81 | }, 82 | titleProp: true, 83 | ref: true, 84 | }, 85 | }, 86 | 'file-loader', 87 | ], 88 | }, 89 | ], 90 | }, 91 | 92 | optimization: { 93 | minimize: true, 94 | minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], 95 | }, 96 | 97 | plugins: [ 98 | /** 99 | * Create global constants which can be configured at compile time. 100 | * 101 | * Useful for allowing different behaviour between development builds and 102 | * release builds 103 | * 104 | * NODE_ENV should be production so that modules do not perform certain 105 | * development checks 106 | */ 107 | new webpack.EnvironmentPlugin({ 108 | NODE_ENV: 'production', 109 | DEBUG_PROD: false, 110 | }), 111 | 112 | new MiniCssExtractPlugin({ 113 | filename: '[name].css', 114 | }), 115 | 116 | new BundleAnalyzerPlugin({ 117 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 118 | analyzerPort: 8889, 119 | }), 120 | 121 | new HtmlWebpackPlugin({ 122 | filename: 'controls.html', 123 | template: path.join(webpackPaths.assetsPath, 'template.html'), 124 | title: 'Controls', 125 | chunks: ['controls'], 126 | minify: { 127 | collapseWhitespace: true, 128 | removeAttributeQuotes: true, 129 | removeComments: true, 130 | }, 131 | isBrowser: false, 132 | isDevelopment: false, 133 | }), 134 | 135 | new HtmlWebpackPlugin({ 136 | filename: 'settings.html', 137 | template: path.join(webpackPaths.assetsPath, 'template.html'), 138 | title: 'Model Settings', 139 | chunks: ['settings'], 140 | minify: { 141 | collapseWhitespace: true, 142 | removeAttributeQuotes: true, 143 | removeComments: true, 144 | }, 145 | isBrowser: false, 146 | isDevelopment: false, 147 | }), 148 | 149 | new webpack.DefinePlugin({ 150 | 'process.type': '"renderer"', 151 | }), 152 | ], 153 | }; 154 | 155 | export default merge(baseConfig, configuration); 156 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { Menu, app } from 'electron'; 2 | import Browser from '../browser/index'; 3 | 4 | const currentYear = new Date().getFullYear().toString(); 5 | 6 | export const setupMenu = (browser: Browser) => { 7 | const isMac = process.platform === 'darwin'; 8 | 9 | const tab = () => browser.currentView; 10 | const tabWebContent = () => tab()?.webContents; 11 | 12 | const credits = ` 13 | This prototype was developed with 爱 by Chris Mckenzie in NYC\n\n 14 | The goal of this project is to provide a prototype to explore the possibilities of on device models accessable via a simple API on the window object 15 | `; 16 | 17 | app.setAboutPanelOptions({ 18 | applicationName: 'Browser.AI', 19 | applicationVersion: 'Mount Abraham', 20 | version: '0.1.5', 21 | credits, 22 | copyright: `©️ ${currentYear} Chris Mckenzie`, 23 | authors: ['Chris Mckenzie'], 24 | website: 'https://browser.christophermckenzie.com/', 25 | }); 26 | 27 | const template = [ 28 | ...(isMac 29 | ? [ 30 | { 31 | role: 'appMenu', 32 | label: 'Browser.AI', 33 | submenu: [ 34 | { 35 | label: 'About Browser.AI', 36 | selector: 'orderFrontStandardAboutPanel:', 37 | }, 38 | { type: 'separator' }, 39 | { 40 | label: 'Preferences', 41 | submenu: [ 42 | { 43 | label: 'Model Settings', 44 | accelerator: 'CmdOrCtrl+,', 45 | click: () => browser.newTab('about:preferences'), 46 | }, 47 | ], 48 | }, 49 | { type: 'separator' }, 50 | { label: 'Services', submenu: [] }, 51 | { type: 'separator' }, 52 | { 53 | label: 'Hide Browser.AI', 54 | accelerator: 'Command+H', 55 | selector: 'hide:', 56 | }, 57 | { 58 | label: 'Hide Others', 59 | accelerator: 'Command+Shift+H', 60 | selector: 'hideOtherApplications:', 61 | }, 62 | { label: 'Show All', selector: 'unhideAllApplications:' }, 63 | { type: 'separator' }, 64 | { 65 | label: 'Quit', 66 | accelerator: 'Command+Q', 67 | click: () => { 68 | app.quit(); 69 | }, 70 | }, 71 | ], 72 | }, 73 | ] 74 | : []), 75 | { 76 | role: 'fileMenu', 77 | submenu: [ 78 | { 79 | label: 'Address Bar', 80 | accelerator: 'Command+L', 81 | click: () => browser.focusAddressBar(), 82 | }, 83 | { 84 | label: 'New Tab', 85 | accelerator: 'CmdOrCtrl+T', 86 | nonNativeMacOSRole: true, 87 | click: () => browser.newTab(), 88 | }, 89 | { 90 | label: 'Close tab', 91 | accelerator: 'Command+W', 92 | nonNativeMacOSRole: true, 93 | click: () => { 94 | const tabID = tab()?.id; 95 | if (tabID) { 96 | browser.closeTab(tabID); 97 | } 98 | }, 99 | }, 100 | ], 101 | }, 102 | { role: 'editMenu' }, 103 | { 104 | label: 'View', 105 | submenu: [ 106 | { 107 | label: 'Reload', 108 | accelerator: 'CmdOrCtrl+R', 109 | nonNativeMacOSRole: true, 110 | click: () => tabWebContent()?.reload(), 111 | }, 112 | { 113 | label: 'Force Reload', 114 | accelerator: 'Shift+CmdOrCtrl+R', 115 | nonNativeMacOSRole: true, 116 | click: () => tabWebContent()?.reloadIgnoringCache(), 117 | }, 118 | { 119 | label: 'Toggle Developer Tool', 120 | accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I', 121 | nonNativeMacOSRole: true, 122 | click: () => tabWebContent()?.toggleDevTools(), 123 | }, 124 | { type: 'separator' }, 125 | { role: 'resetZoom' }, 126 | { role: 'zoomIn' }, 127 | { role: 'zoomOut' }, 128 | { type: 'separator' }, 129 | { role: 'togglefullscreen' }, 130 | ], 131 | }, 132 | { role: 'windowMenu' }, 133 | { 134 | role: 'helpMenu', 135 | label: 'Help', 136 | submenu: [ 137 | { 138 | label: 'Playground', 139 | click: () => 140 | browser.newTab( 141 | 'https://playground.browser.christophermckenzie.com/', 142 | ), 143 | }, 144 | { 145 | label: 'Documentation', 146 | 147 | click: () => 148 | browser.newTab( 149 | 'https://playground.browser.christophermckenzie.com/docs', 150 | ), 151 | }, 152 | { 153 | label: 'Browser AI API', 154 | click: () => 155 | browser.newTab( 156 | 'https://playground.browser.christophermckenzie.com/api', 157 | ), 158 | }, 159 | { 160 | label: 'GitHub Repo', 161 | click: () => browser.newTab('https://github.com/kenzic/browser.ai'), 162 | }, 163 | ], 164 | }, 165 | ]; 166 | 167 | const menu = Menu.buildFromTemplate(template as any); 168 | Menu.setApplicationMenu(menu); 169 | }; 170 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | app, 3 | BrowserWindow, 4 | dialog, 5 | ipcMain, 6 | IpcMainInvokeEvent, 7 | } from 'electron'; 8 | 9 | import { models } from '../ai-api/device/index'; 10 | import { Session } from '../ai-api/device/session'; 11 | import { handleAPI } from '../ai-api/index'; 12 | import { 13 | ConnectSessionOptions, 14 | ModelInfoOptions, 15 | ModelName, 16 | RequestOptions, 17 | } from '../ai-api/types'; 18 | import Browser from '../browser/index'; 19 | import { FakeStoreType, getStore, StoreType } from '../lib/store'; 20 | import { getWindowSize } from '../lib/utils/main'; 21 | import config from '../lib/config'; 22 | import { setupMenu } from './menu'; 23 | 24 | async function loadDebug() { 25 | return import('electron-debug'); 26 | } 27 | 28 | loadDebug() 29 | .then((debug) => debug.default({ showDevTools: true })) 30 | // eslint-disable-next-line no-console 31 | .catch((error) => console.error('Failed to load debug module:', error)); 32 | 33 | // This is a temp solve for 34 | // https://www.perplexity.ai/search/electron-error-error-gl-displa-BdcASse9Qhi7Noslyk1esg 35 | app.disableHardwareAcceleration(); 36 | 37 | function createBrowserWindow() { 38 | const { width, height } = getWindowSize(); 39 | const browser = new Browser({ 40 | width, 41 | height, 42 | }); 43 | return browser; 44 | } 45 | 46 | ipcMain.handle( 47 | 'device:host:set', 48 | async (event: IpcMainInvokeEvent, url: string) => { 49 | const store = (await getStore()) as FakeStoreType; 50 | store.set('deviceHost', url); 51 | return true; 52 | }, 53 | ); 54 | 55 | ipcMain.handle('device:host:get', async () => { 56 | const store = (await getStore()) as FakeStoreType; 57 | return store.get('deviceHost') ?? config.get('ollamaEndpoint'); 58 | }); 59 | 60 | ipcMain.handle( 61 | 'device:model:enable', 62 | async (event: IpcMainInvokeEvent, model: ModelName) => { 63 | const store = (await getStore()) as FakeStoreType; 64 | const localModels = store.get('localModels') as StoreType['localModels']; 65 | 66 | const result = await models.load(model); 67 | if (result.status === 'success') { 68 | localModels.push({ 69 | model, 70 | enabled: true, 71 | }); 72 | 73 | store.set('localModels', localModels); 74 | } 75 | return result; 76 | }, 77 | ); 78 | 79 | ipcMain.handle( 80 | 'device:model:disable', 81 | async (event: IpcMainInvokeEvent, model: ModelName) => { 82 | const store = (await getStore()) as FakeStoreType; 83 | const localModels = store.get('localModels') as StoreType['localModels']; 84 | const idx = localModels.findIndex((m) => m.model === model); 85 | // delete model 86 | if (idx !== -1) { 87 | localModels.splice(idx, 1); 88 | store.set('localModels', localModels); 89 | } 90 | return { 91 | status: 'success', 92 | }; 93 | }, 94 | ); 95 | 96 | ipcMain.handle('device:connected', async () => { 97 | return models.isConnected(); 98 | }); 99 | 100 | ipcMain.handle('ai:permissions:models', async () => { 101 | return handleAPI.permissions.models(); 102 | }); 103 | 104 | // const sessions = {}; 105 | 106 | ipcMain.handle( 107 | 'ai:model:info', 108 | async (event: IpcMainInvokeEvent, options: ModelInfoOptions) => { 109 | // sessions[id] = Session.create(data); 110 | // and then this could be destroyed when the window is closed, or session is destroyed 111 | return handleAPI.model.info({ model: options.model }); 112 | }, 113 | ); 114 | 115 | ipcMain.handle( 116 | 'ai:model:connect', 117 | async (event: IpcMainInvokeEvent, options: ConnectSessionOptions) => { 118 | // sessions[id] = Session.create(data); 119 | // and then this could be destroyed when the window is closed, or session is destroyed 120 | return handleAPI.model.connect({ model: options.model }); 121 | }, 122 | ); 123 | 124 | ipcMain.handle( 125 | 'ai:model:session:chat', 126 | async (event: IpcMainInvokeEvent, options) => { 127 | const session = Session.create(); 128 | return session.chat(options); 129 | }, 130 | ); 131 | 132 | ipcMain.handle( 133 | 'ai:model:session:embed', 134 | async (event: IpcMainInvokeEvent, options) => { 135 | const session = Session.create(); 136 | return session.embed(options); 137 | }, 138 | ); 139 | 140 | app 141 | .whenReady() 142 | .then(async () => { 143 | // initialize store 144 | await getStore(); 145 | 146 | const browser = createBrowserWindow(); 147 | setupMenu(browser); 148 | 149 | ipcMain.handle( 150 | 'ai:permissions:request', 151 | async (event: IpcMainInvokeEvent, requestOptions: RequestOptions) => { 152 | const enabled = await handleAPI.permissions.request(requestOptions); 153 | 154 | if (!enabled && requestOptions?.silent !== true) { 155 | const userRequest = await dialog.showMessageBox({ 156 | type: 'info', 157 | title: `Site is requesting access to ${requestOptions.model} model`, 158 | message: `Site is requesting access to ${requestOptions.model} model. To make model available, go to model preferences and enable the model ${requestOptions.model}`, 159 | buttons: ['Preferences', 'Cancel'], 160 | cancelId: 1, 161 | textWidth: 300, 162 | }); 163 | if (userRequest.response === 0) { 164 | browser.newTab('about:preferences'); 165 | } 166 | } 167 | return enabled; 168 | }, 169 | ); 170 | 171 | app.on('activate', () => { 172 | if (BrowserWindow.getAllWindows().length === 0) { 173 | createBrowserWindow(); 174 | } 175 | }); 176 | 177 | return true; 178 | }) 179 | .catch((error) => { 180 | // eslint-disable-next-line no-console 181 | console.log('app error', error); 182 | }); 183 | 184 | app.on('window-all-closed', () => { 185 | if (process.platform !== 'darwin') { 186 | app.quit(); 187 | } 188 | }); 189 | -------------------------------------------------------------------------------- /src/ai-api/device/session.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Ollama, 3 | ChatRequest as OllamaChatRequest, 4 | ChatResponse as OllamaChatResponse, 5 | EmbedResponse as OllamaEmbedResponse, 6 | } from 'ollama'; 7 | import { 8 | ChatResponse, 9 | ChatOptions, 10 | EmbedResponse, 11 | EmbedOptions, 12 | FinishReason, 13 | ConnectSessionOptions, 14 | PrivateModelSessionConnectionResponse, 15 | } from '../types'; 16 | import { 17 | chatRequestSchema, 18 | embedOptionsSchema, 19 | connectSessionOptionsSchema, 20 | } from '../validate'; 21 | 22 | import config from '../../lib/config'; 23 | import { getModelMetaData } from '../../lib/utils/ollama'; 24 | import { formatZodError } from '../../lib/utils/format-zod-error'; 25 | import { models as modelInstance } from './index'; 26 | 27 | export async function connectSession( 28 | sessionOptions: ConnectSessionOptions, 29 | ollamaClient: Ollama = new Ollama({ host: config.get('ollamaEndpoint') }), 30 | ): Promise { 31 | const result = connectSessionOptionsSchema.safeParse(sessionOptions); 32 | if (!result.success) { 33 | throw new Error(formatZodError(result.error)); 34 | } 35 | 36 | if ( 37 | !modelInstance.isConnected() || 38 | !modelInstance.isEnabled(result.data.model) 39 | ) { 40 | return { 41 | active: false, 42 | model: null, 43 | }; 44 | } 45 | 46 | try { 47 | // force ollama to load model. This is a workaround for the issue where the model is not loaded when the session is created. 48 | await ollamaClient.chat({ 49 | model: result.data.model, 50 | messages: [ 51 | { 52 | role: 'user', 53 | content: 'status check. Say "hello" to continue.', 54 | }, 55 | ], 56 | }); 57 | return { 58 | active: true, 59 | model: result.data.model, 60 | }; 61 | } catch (error) { 62 | return { 63 | active: false, 64 | model: null, 65 | }; 66 | } 67 | } 68 | 69 | export class Session { 70 | private client: Ollama; 71 | 72 | static create(): Session { 73 | return new Session(new Ollama({ host: config.get('ollamaEndpoint') })); 74 | } 75 | 76 | constructor(ollama: Ollama) { 77 | this.client = ollama; 78 | } 79 | 80 | static convertChatOptions( 81 | chatOptions: ChatOptions, 82 | ): OllamaChatRequest & { stream: false } { 83 | const { model, messages, format, tools, options } = chatOptions; 84 | 85 | const filteredOptions = options 86 | ? Session.filterOptions(options) 87 | : undefined; 88 | 89 | return { 90 | stream: false, 91 | model, 92 | messages, 93 | ...(format && { format }), 94 | ...(tools && { tools }), 95 | ...(filteredOptions && { options: filteredOptions }), 96 | }; 97 | } 98 | 99 | private static filterOptions(options: ChatOptions['options']) { 100 | return { 101 | ...(options?.temperature != null && { temperature: options.temperature }), 102 | ...(options?.stop != null && { stop: options.stop }), 103 | ...(options?.seed != null && { seed: options.seed }), 104 | ...(options?.repeat_penalty !== undefined && { 105 | repeat_penalty: options.repeat_penalty, 106 | }), 107 | ...(options?.presence_penalty !== undefined && { 108 | presence_penalty: options.presence_penalty, 109 | }), 110 | ...(options?.frequency_penalty !== undefined && { 111 | frequency_penalty: options.frequency_penalty, 112 | }), 113 | ...(options?.top_k !== undefined && { top_k: options.top_k }), 114 | ...(options?.top_p !== undefined && { top_p: options.top_p }), 115 | }; 116 | } 117 | 118 | static convertOllamaChatResponse(response: OllamaChatResponse): ChatResponse { 119 | // Generate a random ID (you might want to use a more robust ID generation method) 120 | const id = Math.random().toString(36).substring(2, 15); 121 | 122 | return { 123 | id, 124 | choices: [ 125 | { 126 | message: response.message, 127 | finish_reason: response.done_reason as FinishReason, 128 | }, 129 | ], 130 | created: response.created_at, 131 | model: response.model, 132 | usage: { 133 | total_duration: response.total_duration, 134 | load_duration: response.load_duration, 135 | prompt_eval_count: response.prompt_eval_count, 136 | prompt_eval_duration: response.prompt_eval_duration, 137 | eval_count: response.eval_count, 138 | eval_duration: response.eval_duration, 139 | }, 140 | }; 141 | } 142 | 143 | static convertOllamaEmbedResponse( 144 | response: OllamaEmbedResponse, 145 | ): EmbedResponse & { stream: false } { 146 | const id = Math.random().toString(36).substring(2, 15); 147 | const { model, embeddings } = response; 148 | return { 149 | id, 150 | model, 151 | embeddings, 152 | stream: false, 153 | }; 154 | } 155 | 156 | async chat(options: ChatOptions): Promise { 157 | const result = chatRequestSchema.strict().safeParse(options); 158 | if (!result.success) { 159 | throw new Error(formatZodError(result.error)); 160 | } 161 | 162 | if (options.tools) { 163 | const metadata = getModelMetaData(options.model); 164 | if (metadata?.tags?.includes('tools') !== true) { 165 | throw new Error( 166 | `Model ${options.model} does not support tools. Please use a different model.`, 167 | ); 168 | } 169 | } 170 | 171 | const response = await this.client.chat( 172 | Session.convertChatOptions(options), 173 | ); 174 | return Session.convertOllamaChatResponse(response); 175 | } 176 | 177 | async embed(options: EmbedOptions): Promise { 178 | const result = embedOptionsSchema.safeParse(options); 179 | if (!result.success) { 180 | throw new Error(formatZodError(result.error)); 181 | } 182 | 183 | return Session.convertOllamaEmbedResponse(await this.client.embed(options)); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/ai-api/device/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ListResponse, 3 | ModelResponse as OllamaModelResponse, 4 | Ollama, 5 | } from 'ollama'; 6 | import config from '../../lib/config'; 7 | import { getStore, StoreInstance } from '../../lib/store'; 8 | import { cleanName } from '../../lib/utils'; 9 | import { formatZodError } from '../../lib/utils/format-zod-error'; 10 | import { 11 | LoadModelStatus, 12 | ModelInfo, 13 | ModelInfoOptions, 14 | ModelName, 15 | EnabledModel, 16 | } from '../types'; 17 | import { modelInfoOptionsSchema, modelNameSchema } from '../validate'; 18 | 19 | // interface ModelWhitelist { 20 | // domain: string; 21 | // models: string[]; 22 | // } 23 | 24 | interface ModelPreferences { 25 | model: string; 26 | enabled: boolean; 27 | } 28 | 29 | interface UserPreferences { 30 | enabledModels: ModelPreferences[]; 31 | deviceHost: string; 32 | // whitelist: ModelWhitelist[]; // TODO: enable whitelist 33 | } 34 | 35 | async function getUserPreferences(): Promise { 36 | const store = (await getStore()) as StoreInstance; 37 | const localModels = store.get('localModels') as unknown as ModelPreferences[]; 38 | const deviceHost = (store.get('deviceHost') || 39 | config.get('ollamaEndpoint')) as string; 40 | 41 | return { 42 | enabledModels: localModels.filter((model) => model.enabled) || [], 43 | deviceHost, 44 | // whitelist: [], // TODO: enable whitelist 45 | }; 46 | } 47 | 48 | /** 49 | * Retrieves the models enabled by the user from the provided device models. 50 | * 51 | * @param deviceModels - An array of models available on the device. 52 | * @returns A filtered array of models that are enabled by the user based on their preferences. 53 | */ 54 | export async function getUserEnabledModels(deviceModels: EnabledModel[]) { 55 | const userPreferences = await getUserPreferences(); 56 | const nameSet = new Set( 57 | userPreferences.enabledModels.map((item) => item.model), 58 | ); 59 | return deviceModels.filter((model) => { 60 | return nameSet.has(model.model); 61 | }); 62 | } 63 | 64 | /** 65 | * Device API functions. 66 | * 67 | * This is intentionally left incomplete. 68 | * Ultimately, the concept of a "device" should have it's own abstraction layer. 69 | * This would allow for the implementation of different devices, such as a local device or a remote device. 70 | * For the sake of this prototype, I kept it simple because it didn't want to overcomplicate the codebase, and be too oppinionated about how 71 | * the device should be implemented before I have a clear understanding of the total scope of the functionality 72 | */ 73 | export const models = { 74 | // TODO: do I need this? 75 | _host: config.get('ollamaEndpoint'), 76 | getClient: (client = new Ollama({ host: config.get('ollamaEndpoint') })) => { 77 | return client; 78 | }, 79 | async getInformation(options: ModelInfoOptions): Promise { 80 | const result = modelInfoOptionsSchema.safeParse(options); 81 | if (!result.success) { 82 | throw new Error(formatZodError(result.error)); 83 | } 84 | 85 | try { 86 | const response = await this.getClient().show(options); 87 | 88 | return { 89 | model: options.model, 90 | license: response.license, 91 | details: { 92 | ...response.details, 93 | }, 94 | }; 95 | } catch (error) { 96 | if (error instanceof Error) { 97 | throw new Error(error.message); 98 | } else { 99 | throw new Error(String(error)); 100 | } 101 | } 102 | }, 103 | async load(model: ModelName): Promise { 104 | const result = modelNameSchema.safeParse(model); 105 | if (!result.success) { 106 | throw new Error(formatZodError(result.error)); 107 | } 108 | 109 | try { 110 | await this.getClient().pull({ 111 | model, 112 | }); 113 | return { 114 | status: 'success', 115 | }; 116 | } catch (error) { 117 | return { 118 | status: 'error', 119 | message: (error as Error).message, 120 | }; 121 | } 122 | }, 123 | async listRunning(): Promise { 124 | return this.getClient().ps(); 125 | }, 126 | async listAvailable(): Promise { 127 | const response = await this.getClient().list(); 128 | return response.models.map((model: OllamaModelResponse): EnabledModel => { 129 | return { 130 | model: cleanName(model.name), 131 | enabled: true, 132 | }; 133 | }); 134 | }, 135 | async listEnabled(): Promise { 136 | const deviceModels = await this.listAvailable(); 137 | 138 | const userEnabledModels = await getUserEnabledModels(deviceModels); 139 | 140 | return userEnabledModels.map((model) => ({ 141 | model: model.model, 142 | enabled: true, 143 | })); 144 | }, 145 | async isConnected(): Promise { 146 | try { 147 | await this.getClient().ps(); 148 | return true; 149 | } catch (error) { 150 | return false; 151 | } 152 | }, 153 | async isAvailable(model: ModelName): Promise { 154 | const result = modelNameSchema.safeParse(model); 155 | if (!result.success) { 156 | throw new Error(formatZodError(result.error)); 157 | } 158 | // TODO: implement 159 | return true; 160 | }, 161 | async isEnabled(model: ModelName): Promise { 162 | const result = modelNameSchema.safeParse(model); 163 | if (!result.success) { 164 | throw new Error(formatZodError(result.error)); 165 | } 166 | const deviceModels = await this.listAvailable(); 167 | const userEnabledModels = await getUserEnabledModels(deviceModels); 168 | 169 | return userEnabledModels.some( 170 | (enabledModel) => enabledModel.model === model, 171 | ); 172 | }, 173 | async isRunning(model: ModelName): Promise { 174 | const result = modelNameSchema.safeParse(model); 175 | if (!result.success) { 176 | throw new Error(formatZodError(result.error)); 177 | } 178 | // return this.listRunning().then((response) => { 179 | // return response.models.some((runningModel) => runningModel.name === model); 180 | // }); 181 | // TODO: implement 182 | return true; 183 | }, 184 | }; 185 | -------------------------------------------------------------------------------- /src/browser/controls/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-static-element-interactions */ 2 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 3 | import React, { useEffect } from 'react'; 4 | 5 | import cx from 'classnames'; 6 | import useConnect from './use-connect'; 7 | import * as action from './actions'; 8 | import { 9 | IconClose, 10 | IconLeft, 11 | IconLoading, 12 | IconPlus, 13 | IconReload, 14 | IconRight, 15 | } from './components/icons'; 16 | 17 | import '../global.css'; 18 | import { TabID } from '../types'; 19 | 20 | // eslint-disable-next-line import/prefer-default-export 21 | export function Control() { 22 | const addressBarRef = React.useRef(null); 23 | const { tabs, tabIDs, activeID } = useConnect(); 24 | 25 | const { url, href, canGoForward, canGoBack, isLoading } = 26 | tabs[activeID as TabID] || {}; 27 | 28 | const onUrlChange = (e: React.ChangeEvent) => { 29 | // Sync to tab config 30 | const v = e.target.value; 31 | action.sendChangeURL(v); 32 | }; 33 | 34 | const onPressEnter = (e: React.KeyboardEvent) => { 35 | if (e.keyCode !== 13) return; 36 | const value = (e.target as HTMLInputElement).value.trim(); 37 | if (!value) return; 38 | 39 | let hrefValue = value; 40 | if (!/^.*?:\/\//.test(value)) { 41 | hrefValue = `http://${value}`; 42 | } 43 | action.sendEnterURL(hrefValue); 44 | }; 45 | const close = (e: React.MouseEvent, id: TabID) => { 46 | e.stopPropagation(); 47 | action.sendCloseTab(id); 48 | }; 49 | const newTab = () => { 50 | action.sendNewTab(); 51 | }; 52 | const switchTab = (id: TabID) => { 53 | action.sendSwitchTab(id); 54 | }; 55 | 56 | const focusAddressBar = () => { 57 | if (addressBarRef.current) { 58 | addressBarRef.current.focus(); 59 | addressBarRef.current.select(); 60 | } 61 | }; 62 | 63 | const onAddressBarBlur = () => { 64 | if (addressBarRef.current) { 65 | addressBarRef.current.blur(); 66 | // FIXME: this breaks alias urls 67 | addressBarRef.current.value = href || ''; 68 | } 69 | }; 70 | 71 | useEffect(() => { 72 | const destory = window.browserai.controls.onFocusAddressBar(() => { 73 | focusAddressBar(); 74 | }); 75 | return () => { 76 | destory(); 77 | }; 78 | }, []); 79 | 80 | return ( 81 |
82 |
83 | {tabIDs.map((id) => { 84 | // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow 85 | const { title, isLoading, favicon } = tabs[id] || {}; 86 | return ( 87 |
switchTab(id)} 95 | > 96 | {isLoading ? ( 97 | 98 | ) : ( 99 | !!favicon && 100 | )} 101 |
102 |
108 | {title} 109 |
110 |
111 |
112 |
close(e, id)} 115 | > 116 | 117 |
118 |
119 |
120 | ); 121 | })} 122 | 123 | 124 | 125 |
126 |
127 |
128 |
129 |
138 | 139 |
140 |
149 | 150 |
151 |
157 | {isLoading ? : } 158 |
159 |
160 | 170 |
171 |
172 |
173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.ts: -------------------------------------------------------------------------------- 1 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 2 | import chalk from 'chalk'; 3 | import { execSync, spawn } from 'child_process'; 4 | import fs from 'fs'; 5 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 6 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 7 | import path from 'path'; 8 | import webpack from 'webpack'; 9 | import 'webpack-dev-server'; 10 | import { merge } from 'webpack-merge'; 11 | import checkNodeEnv from '../scripts/check-node-env'; 12 | import baseConfig from './webpack.config.base'; 13 | import webpackPaths from './webpack.paths'; 14 | 15 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 16 | // at the dev webpack config is not accidentally run in a production environment 17 | if (process.env.NODE_ENV === 'production') { 18 | checkNodeEnv('development'); 19 | } 20 | 21 | const port = process.env.PORT || 1212; 22 | const manifest = path.resolve(webpackPaths.dllPath, 'controls.json'); 23 | const skipDLLs = 24 | module.parent?.filename.includes('webpack.config.renderer.dev.dll') || 25 | module.parent?.filename.includes('webpack.config.eslint'); 26 | 27 | /** 28 | * Warn if the DLL is not built 29 | */ 30 | if ( 31 | !skipDLLs && 32 | !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) 33 | ) { 34 | console.log( 35 | chalk.black.bgYellow.bold( 36 | 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', 37 | ), 38 | ); 39 | execSync('npm run postinstall'); 40 | } 41 | 42 | const configuration: webpack.Configuration = { 43 | devtool: 'inline-source-map', 44 | 45 | mode: 'development', 46 | 47 | target: ['web', 'electron-renderer'], 48 | 49 | // entry: [ 50 | // `webpack-dev-server/client?http://localhost:${port}/dist`, 51 | // 'webpack/hot/only-dev-server', 52 | // path.join(webpackPaths.srcBrowserPath, 'index.tsx'), 53 | // ], 54 | 55 | entry: { 56 | // controls: path.join(webpackPaths.srcBrowserPath, 'controls/entry.tsx'), 57 | controls: [ 58 | `webpack-dev-server/client?http://localhost:${port}/dist`, 59 | 'webpack/hot/only-dev-server', 60 | path.join(webpackPaths.srcBrowserPath, 'controls/entry.tsx'), 61 | ], 62 | settings: path.join(webpackPaths.srcBrowserPath, 'settings/entry.tsx'), 63 | }, 64 | output: { 65 | path: webpackPaths.distRendererPath, 66 | publicPath: '/', 67 | filename: '[name].bundle.js', 68 | library: { 69 | type: 'umd', 70 | }, 71 | }, 72 | 73 | module: { 74 | rules: [ 75 | { 76 | test: /\.css$/, 77 | use: [ 78 | MiniCssExtractPlugin.loader, 79 | 'css-loader', 80 | { 81 | loader: 'postcss-loader', 82 | options: { 83 | postcssOptions: { 84 | plugins: [require('tailwindcss'), require('autoprefixer')], 85 | }, 86 | }, 87 | }, 88 | ], 89 | }, 90 | // { 91 | // test: /\.s?css$/, 92 | // use: ['style-loader', 'css-loader'], 93 | // exclude: /\.module\.s?(c|a)ss$/, 94 | // }, 95 | // Fonts 96 | { 97 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 98 | type: 'asset/resource', 99 | }, 100 | // Images 101 | { 102 | test: /\.(png|jpg|jpeg|gif)$/i, 103 | type: 'asset/resource', 104 | }, 105 | // SVG 106 | { 107 | test: /\.svg$/, 108 | use: [ 109 | { 110 | loader: '@svgr/webpack', 111 | options: { 112 | prettier: false, 113 | svgo: false, 114 | svgoConfig: { 115 | plugins: [{ removeViewBox: false }], 116 | }, 117 | titleProp: true, 118 | ref: true, 119 | }, 120 | }, 121 | 'file-loader', 122 | ], 123 | }, 124 | ], 125 | }, 126 | plugins: [ 127 | ...(skipDLLs 128 | ? [] 129 | : [ 130 | new webpack.DllReferencePlugin({ 131 | context: webpackPaths.dllPath, 132 | manifest: require(manifest), 133 | sourceType: 'var', 134 | }), 135 | ]), 136 | 137 | new webpack.NoEmitOnErrorsPlugin(), 138 | 139 | new MiniCssExtractPlugin({ 140 | filename: '[name].css', 141 | }), 142 | 143 | /** 144 | * Create global constants which can be configured at compile time. 145 | * 146 | * Useful for allowing different behaviour between development builds and 147 | * release builds 148 | * 149 | * NODE_ENV should be production so that modules do not perform certain 150 | * development checks 151 | * 152 | * By default, use 'development' as NODE_ENV. This can be overriden with 153 | * 'staging', for example, by changing the ENV variables in the npm scripts 154 | */ 155 | new webpack.EnvironmentPlugin({ 156 | NODE_ENV: 'development', 157 | }), 158 | 159 | new webpack.LoaderOptionsPlugin({ 160 | debug: true, 161 | }), 162 | 163 | new ReactRefreshWebpackPlugin(), 164 | 165 | new HtmlWebpackPlugin({ 166 | filename: 'controls.html', 167 | template: path.join(webpackPaths.assetsPath, 'template.html'), 168 | chunks: ['controls'], 169 | minify: { 170 | collapseWhitespace: true, 171 | removeAttributeQuotes: true, 172 | removeComments: true, 173 | }, 174 | isBrowser: false, 175 | env: process.env.NODE_ENV, 176 | isDevelopment: process.env.NODE_ENV !== 'production', 177 | nodeModules: webpackPaths.appNodeModulesPath, 178 | }), 179 | 180 | new HtmlWebpackPlugin({ 181 | filename: 'settings.html', 182 | template: path.join(webpackPaths.assetsPath, 'template.html'), 183 | chunks: ['settings'], 184 | minify: { 185 | collapseWhitespace: true, 186 | removeAttributeQuotes: true, 187 | removeComments: true, 188 | }, 189 | isBrowser: false, 190 | env: process.env.NODE_ENV, 191 | isDevelopment: process.env.NODE_ENV !== 'production', 192 | nodeModules: webpackPaths.appNodeModulesPath, 193 | }), 194 | ], 195 | 196 | node: { 197 | __dirname: false, 198 | __filename: false, 199 | }, 200 | 201 | devServer: { 202 | port, 203 | compress: true, 204 | hot: true, 205 | headers: { 'Access-Control-Allow-Origin': '*' }, 206 | static: { 207 | publicPath: '/', 208 | }, 209 | historyApiFallback: { 210 | verbose: true, 211 | }, 212 | setupMiddlewares(middlewares) { 213 | console.log('Starting preload.js builder...'); 214 | const preloadProcess = spawn('npm', ['run', 'start:preload'], { 215 | shell: true, 216 | stdio: 'inherit', 217 | }) 218 | .on('close', (code: number) => process.exit(code!)) 219 | .on('error', (spawnError) => console.error(spawnError)); 220 | 221 | console.log('Starting Main Process...'); 222 | let args = ['run', 'start:main']; 223 | if (process.env.MAIN_ARGS) { 224 | args = args.concat( 225 | ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(), 226 | ); 227 | } 228 | spawn('npm', args, { 229 | shell: true, 230 | stdio: 'inherit', 231 | }) 232 | .on('close', (code: number) => { 233 | preloadProcess.kill(); 234 | process.exit(code!); 235 | }) 236 | .on('error', (spawnError) => console.error(spawnError)); 237 | return middlewares; 238 | }, 239 | }, 240 | }; 241 | 242 | export default merge(baseConfig, configuration); 243 | -------------------------------------------------------------------------------- /src/ai-api/device/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EmbedRequest as OllamaEmbedRequest, 3 | ChatRequest as OllamaChatRequest, 4 | Ollama, 5 | } from 'ollama'; 6 | import { models } from '../index'; 7 | import { Session, connectSession } from '../session'; 8 | import { ChatOptions, EmbedOptions, Tools } from '../../types'; 9 | 10 | describe('connectSession', () => { 11 | test('should create a session and return active status', async () => { 12 | const sessionOptions = { model: 'test-model' }; 13 | const result = await connectSession(sessionOptions); 14 | expect(result).toEqual({ active: true, model: 'test-model' }); 15 | }); 16 | 17 | test('should return inactive status if chat throws an error', async () => { 18 | const sessionOptions = { model: 'test-model' }; 19 | const result = await connectSession(sessionOptions, { 20 | chat: jest.fn().mockRejectedValue(new Error('Chat error')), 21 | } as unknown as Ollama); 22 | expect(result).toEqual({ active: false, model: null }); 23 | }); 24 | }); 25 | 26 | describe('session.chat', () => { 27 | beforeEach(() => { 28 | jest.clearAllMocks(); 29 | }); 30 | 31 | test('should call session.chat with minimal required options', async () => { 32 | // eslint-disable-next-line no-underscore-dangle 33 | const session = Session.create(); 34 | 35 | const options: ChatOptions = { 36 | model: 'test-model', 37 | messages: [{ role: 'user', content: 'Why is the sky blue' }], 38 | }; 39 | 40 | const result = await session.chat(options); 41 | 42 | const ollama = new Ollama(); 43 | 44 | const ollamaResponse = (await ollama.chat( 45 | Session.convertChatOptions(options), 46 | )) as any; 47 | 48 | expect(result).toEqual({ 49 | ...Session.convertOllamaChatResponse(ollamaResponse), 50 | id: expect.any(String), 51 | }); 52 | }); 53 | 54 | test('should call session.chat with all provided options', async () => { 55 | const session = Session.create(); 56 | 57 | const options: ChatOptions = { 58 | model: 'test-model', 59 | messages: [{ role: 'user', content: 'Why is the sky blue' }], 60 | options: { 61 | temperature: 0.5, 62 | stop: undefined, 63 | seed: undefined, 64 | repeat_penalty: 1.0, 65 | presence_penalty: 0.0, 66 | frequency_penalty: 0.0, 67 | top_k: 50, 68 | top_p: 1.0, 69 | }, 70 | }; 71 | 72 | const result = await session.chat(options); 73 | 74 | const ollama = new Ollama(); 75 | const ollamaResponse = await ollama.chat( 76 | Session.convertChatOptions(options), 77 | ); 78 | 79 | expect(result).toEqual({ 80 | ...Session.convertOllamaChatResponse(ollamaResponse), 81 | id: expect.any(String), 82 | }); 83 | }); 84 | 85 | test('should support tools', async () => { 86 | // eslint-disable-next-line 87 | const addFunc = (a: number, b: number) => a + b; 88 | 89 | const addTool = { 90 | type: 'function', 91 | function: { 92 | name: 'addFunc', 93 | description: 'Add two numbers together.', 94 | parameters: { 95 | type: 'object', 96 | required: ['a', 'b'], 97 | properties: { 98 | a: { 99 | type: 'number', 100 | description: 'The first number to add.', 101 | }, 102 | b: { 103 | type: 'number', 104 | description: 'The second number to add.', 105 | }, 106 | }, 107 | }, 108 | }, 109 | }; 110 | // eslint-disable-next-line no-underscore-dangle 111 | const session = Session.create(); 112 | 113 | const options: ChatOptions = { 114 | model: 'llama3.2', 115 | messages: [{ role: 'user', content: 'Add 1 and 2' }], 116 | tools: [addTool] as ChatOptions['tools'], 117 | }; 118 | const result = await session.chat(options); 119 | 120 | const ollama = new Ollama(); 121 | const ollamaResponse = await ollama.chat( 122 | Session.convertChatOptions(options), 123 | ); 124 | 125 | expect(result).toEqual({ 126 | ...Session.convertOllamaChatResponse(ollamaResponse), 127 | id: expect.any(String), 128 | }); 129 | }); 130 | 131 | test('should throw validation error if session.chat messages format is incorrect', async () => { 132 | const session = Session.create(); 133 | 134 | const options: ChatOptions = { 135 | model: 'test-model', 136 | messages: 'Hello', 137 | } as unknown as ChatOptions; 138 | 139 | await expect(async () => { 140 | return session.chat(options); 141 | }).rejects.toThrow(/Validation errors:/); 142 | }); 143 | 144 | test('should throw validation error if session.chat options contain invalid values', async () => { 145 | const session = Session.create(); 146 | 147 | const options: ChatOptions = { 148 | model: 'test-model', 149 | messages: [{ role: 'user', content: 'Why is the sky blue' }], 150 | options: { 151 | temperature: 'HOT', 152 | }, 153 | } as unknown as ChatOptions; 154 | 155 | await expect(async () => { 156 | return session.chat(options); 157 | }).rejects.toThrow(/Validation errors:/); 158 | }); 159 | }); 160 | 161 | describe('session.embed', () => { 162 | beforeEach(() => { 163 | jest.clearAllMocks(); 164 | }); 165 | 166 | test('should call session.embed with minimal required options', async () => { 167 | // eslint-disable-next-line no-underscore-dangle 168 | const session = Session.create(); 169 | 170 | const options: EmbedOptions = { 171 | model: 'test-model', 172 | input: 'Hello', 173 | }; 174 | 175 | const result = await session.embed(options); 176 | 177 | const ollama = new Ollama(); 178 | 179 | const ollamaResponse = (await ollama.embed( 180 | options as OllamaEmbedRequest, 181 | )) as any; 182 | 183 | expect(result).toEqual({ 184 | ...Session.convertOllamaEmbedResponse(ollamaResponse), 185 | id: expect.any(String), 186 | }); 187 | }); 188 | 189 | test('should call session.chat with all provided options', async () => { 190 | const session = Session.create(); 191 | 192 | const options: ChatOptions = { 193 | model: 'test-model', 194 | messages: [{ role: 'user', content: 'Why is the sky blue' }], 195 | options: { 196 | temperature: 0.5, 197 | stop: undefined, 198 | seed: undefined, 199 | repeat_penalty: 1.0, 200 | presence_penalty: 0.0, 201 | frequency_penalty: 0.0, 202 | top_k: 50, 203 | top_p: 1.0, 204 | }, 205 | }; 206 | 207 | const result = await session.chat(options); 208 | 209 | const ollama = new Ollama(); 210 | 211 | const ollamaResponse = (await ollama.chat( 212 | Session.convertChatOptions(options), 213 | )) as any; 214 | 215 | expect(result).toEqual({ 216 | ...Session.convertOllamaChatResponse(ollamaResponse), 217 | id: expect.any(String), 218 | }); 219 | }); 220 | 221 | test('should throw validation error if session.chat messages format is incorrect', async () => { 222 | const session = Session.create(); 223 | 224 | const options: ChatOptions = { 225 | model: 'test-model', 226 | messages: 'Hello', 227 | } as unknown as ChatOptions; 228 | 229 | await expect(async () => { 230 | return session.chat(options); 231 | }).rejects.toThrow(/Validation errors:/); 232 | }); 233 | 234 | test('should throw validation error if session.chat options contain invalid values', async () => { 235 | const session = Session.create(); 236 | 237 | const options: ChatOptions = { 238 | model: 'test-model', 239 | messages: [{ role: 'user', content: 'Why is the sky blue' }], 240 | options: { 241 | temperature: 'HOT', 242 | }, 243 | } as unknown as ChatOptions; 244 | 245 | await expect(async () => { 246 | return session.chat(options); 247 | }).rejects.toThrow(/Validation errors:/); 248 | }); 249 | }); 250 | 251 | describe('models.isAvailable', () => { 252 | test('should return true if the model is available', async () => { 253 | const result = await models.isAvailable('test-model'); 254 | expect(result).toBe(true); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Browser.AI: Run AI Models Locally in Your Browser 2 | 3 | Browser.AI enables developers to run AI models directly on users' devices through a browser-based API. This prototype tests the concept of local AI model execution to improve privacy, speed, and offline functionality for web applications. It exists to provide a demo to support a 4 | 5 | ## Overview 6 | Browser.AI is a browser prototype that provides an API on the `window` object, enabling websites to run AI models locally on the user’s device. This allows for faster, more private, and offline-capable AI interactions. 7 | 8 | ## Run 9 | - Download and start [Ollama](https://ollama.com/download). 10 | - [Download browser](https://browser.christophermckenzie.com/) 11 | - Have fun 12 | 13 | ## Features 14 | - **On-device AI models** 15 | Keep AI computations on your machine, enhancing privacy, security, and speed. 16 | - **API access via window object** 17 | Interact with models directly using a simple, intuitive API through the `window` object. 18 | - **Chat & Embedding AI models** 19 | A streamlined interface for integrating chatbots and other AI features. 20 | 21 | ## Why Use Browser.AI? 22 | - **Privacy:** Your data stays on your device—nothing is sent to external servers. 23 | - **Speed:** AI models run locally, minimizing latency. 24 | - **Offline:** Works even when you’re not connected to the internet. 25 | - **Security:** You have full control over which models run on your machine. 26 | - **Cost Savings:** No server costs. 27 | - **Control:** You choose the models that run locally, giving you power over AI usage. 28 | 29 | ## How It Works 30 | Browser.AI exposes three key APIs: 31 | 1. **Permissions:** `window.ai.permissions` - Manage permissions for running AI models on your device. 32 | 1. Request permissions: `window.ai.permissions.request({ model: 'name' })` 33 | 2. List enabled models: `window.ai.permissions.models()` 34 | 2. **Model:** `window.ai.model` - Connect to and interact with AI models directly. 35 | 1. Information: `window.ai.model.info({ model: 'name' })` 36 | 2. Connect to session: `window.ai.model.connect({ model: 'name'});` 37 | 3. **Session:** - `connect` returns a session object, which makes available methods for `chat` and `embed` 38 | 1. Chat: `session.chat({ messages: [{ role: 'user', content: 'hello' }]});` 39 | 2. Embed `session.embed({ input: 'hello' });` 40 | 41 | ### Example Usage 42 | 43 | **Simple Chat** 44 | ```js 45 | if(await window.ai.permissions.request({ model: 'llama3.2', silent: true })) { 46 | const session = await window.ai.model.connect({ model: 'llama3.2' }); 47 | const response = await session.chat({ messages: [{ role: 'user', content: 'hello' }] }) 48 | console.log(response) 49 | } 50 | ``` 51 | **Tool support for Chat** 52 | ```js 53 | const addFunc = (a, b) => a + b; 54 | 55 | const addTool = { 56 | type: "function", 57 | function: { 58 | name: "addFunc", 59 | description: "Add two numbers together.", 60 | parameters: { 61 | type: "object", 62 | required: ["a", "b"], 63 | properties: { 64 | a: { 65 | type: "number", 66 | description: "The first number to add.", 67 | }, 68 | b: { 69 | type: "number", 70 | description: "The second number to add.", 71 | }, 72 | }, 73 | }, 74 | }, 75 | } 76 | 77 | if(await window.ai.permissions.request({ model: 'llama3.2', silent: true })) { 78 | const session = await window.ai.model.connect({ model: 'llama3.2' }); 79 | const response = await session.chat({ messages: [{ role: 'user', content: 'add 1 and 2' }], tools: [addTool] }) 80 | 81 | if (response.choices[0].message.tool_calls) { 82 | const { name, arguments } = response.choices[0].message.tool_calls[0].function; 83 | const result = addFunc(arguments.a, arguments.b); 84 | console.log(`Function ${name} called with result: ${result}`); 85 | } 86 | 87 | console.log(response) 88 | } 89 | ``` 90 | **Generate Embeddings** 91 | ```js 92 | const docsToEmbed = [...] 93 | const embededDocs = []; 94 | if(await window.ai.permissions.request({ model: 'llama3.2', silent: true })) { 95 | const session = await window.ai.model.connect({ model: 'llama3.2' }); 96 | let i = 0; 97 | while(i < docsToEmbed.length) { 98 | const response = await session.embed({ input: doc }) 99 | embededDocs.push(response.embeddings) 100 | i++; 101 | } 102 | } 103 | ``` 104 | **Request user enables model** 105 | ```js 106 | const response = await window.ai.permissions.request({ model: 'llama3.2' }) 107 | if (response) { 108 | // write code that accesses model 109 | } else { 110 | // handle state where user has not yet enabled model. 111 | // because silent is false by default, they will get a browser 112 | // dialog asking them if they want to enable. 113 | } 114 | ``` 115 | 116 | ## Current Limitations 117 | - The current implementation has chat and embed methods. This functionality will be expanded to handle all common interfaces for models. 118 | - Does not support streaming that this time. Will add support. 119 | - Does not support function calls. Will add support shortly. 120 | - Permissions are on model level. Will add support for domain level model permissions. 121 | 122 | ## Architecture 123 | 124 | ### AI API 125 | The API is exposed on the `window` object, providing access to AI models running on the user’s machine. It communicates with the **Device Bridge**, which handles permissions, model management, and returns the output. 126 | 127 | ### Device Bridge 128 | The Device Bridge manages models on the user’s machine. It can download, enable, or disable models but doesn’t actually run them. In this prototype, we use **Ollama** as the model runtime. 129 | > The Device Bridge is currently tied to Ollama for the sake of this prototype. 130 | 131 | ### Device (Model Runtime) 132 | This is the actual runtime environment where AI models are executed. For this prototype, Ollama is used to run models on the user’s machine. 133 | 134 | ### Key Concepts 135 | - **Available**: A model that is present on the user’s machine (or can be downloaded) but is not yet enabled for use. 136 | - **Enabled**: A model that is available and can be accessed by the AI API. 137 | - **Device**: Hardware the browser, and model, is running on. 138 | 139 | ## FAQs 140 | 141 | - **Is this a real browser?** 142 | Not exactly. It’s a prototype with limited functionality, mainly for experimenting with AI models in a browser-like environment. 143 | - **Why should I use this?** 144 | If you want to build or test web apps that leverage AI models running locally on users' devices, this is the tool for you! 145 | - **Can I contribute?** 146 | Absolutely! Feel free to reach out if you’re interested in contributing or collaborating. Link to proposal 147 | 148 | ### Roadmap 149 | - [ ] Add support for streaming. 150 | - [x] Add function-calling support for models. 151 | - [ ] Add support for for text completion. 152 | - [ ] Add support (should work now, but not tested) for image generation. 153 | - [ ] Add support for other runtimes (device adapters) 154 | - [ ] Add experimental support for 3rd-party providers 155 | 156 | ## Development 157 | 158 | ### Getting Started 159 | 160 | To set up the project locally, follow these steps: 161 | 162 | 1. **Clone the repo:** git clone git@github.com:kenzic/browser.ai.git 163 | 2. **Install dependencies:** Make sure you have [Node.js](https://nodejs.org/) 20+ installed, then run: `pnpm install` 164 | 3. **Start the development server:** To run the app in development mode: `pnpm start` 165 | 4. **Build the app:** To create a production build of the app: `pnpm package` 166 | 167 | ### Contributing 168 | 169 | Contributions are welcome! Here’s how you can contribute: 170 | 171 | 1. **Fork the repository** 172 | 2. **Create a new branch** for your feature or bugfix: `git checkout -b feature-name` 173 | 3. **Make your changes**, ensuring you follow the project’s code style. 174 | 4. **Open a pull request** with a description of your changes. 175 | 176 | Feel free to reach out if you have any questions or need help getting started! 177 | 178 | ## Thank You 179 | 180 | This is my first Electron app, and I used [electron-react-boilerplate](https://electron-react-boilerplate.js.org/) to kickstart the project. I was also inspired by the [Electron as Browser](https://github.com/hulufei/electron-as-browser) project. Thanks to everyone who made those tools! 181 | -------------------------------------------------------------------------------- /src/browser/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import availableModelsData from '../../../ollama-models.json'; 3 | import { Skeleton } from './components/skeleton'; 4 | import { Switch } from './components/switch'; 5 | import { 6 | Table, 7 | TableBody, 8 | TableCaption, 9 | TableCell, 10 | TableHead, 11 | TableHeader, 12 | TableRow, 13 | } from './components/table'; 14 | 15 | import '../global.css'; 16 | 17 | type ModelConfigureation = { 18 | title: string; 19 | link: string; 20 | tags: string[]; 21 | }; 22 | 23 | type ModelsConfigurationFile = Array; 24 | 25 | const SpinningEmoji: React.FC<{ emoji?: string }> = ({ emoji = '😊' }) => { 26 | return ( 27 |
28 |
{emoji}
29 |
30 | ); 31 | }; 32 | 33 | type ModelRowProps = { 34 | model: { 35 | title: string; 36 | }; 37 | isEnabled: boolean; 38 | }; 39 | 40 | const ModelRow: React.FC = ({ model, isEnabled }) => { 41 | const [enabled, setEnabled] = useState(isEnabled); 42 | const [loading, setLoading] = useState(false); 43 | const getStatusText = (showEnabled: boolean) => { 44 | if (showEnabled) { 45 | return 'Enabled'; 46 | } 47 | return 'Available for Download'; 48 | }; 49 | 50 | useEffect(() => { 51 | setEnabled(isEnabled); 52 | }, [isEnabled]); 53 | return ( 54 | 55 | {model.title} 56 | 57 | {loading ? : getStatusText(enabled)} 58 | 59 | 60 | { 64 | if (boolean) { 65 | setLoading(true); 66 | const response = await window.browserai.device.enable( 67 | model.title, 68 | ); 69 | 70 | if (response.status === 'error') { 71 | throw new Error(response.error); 72 | } else if (response.status !== 'success') { 73 | throw new Error('Unexpected response'); 74 | } 75 | 76 | // TODO: add to list of available models 77 | setLoading(false); 78 | setEnabled(true); 79 | } else { 80 | setLoading(true); 81 | const response = await window.browserai.device.disable( 82 | model.title, 83 | ); 84 | if (response.status === 'error') { 85 | throw new Error(response.error); 86 | } else if (response.status !== 'success') { 87 | throw new Error('Unexpected response'); 88 | } 89 | setLoading(false); 90 | setEnabled(false); 91 | } 92 | }} 93 | /> 94 | 95 | 96 | ); 97 | }; 98 | 99 | const ConnectionStatus = ({ 100 | isConnected = false, 101 | }: { 102 | isConnected?: boolean; 103 | }) => { 104 | // const [hostUrl, setHostUrl] = useState(''); 105 | 106 | // useEffect(() => { 107 | // async function getHost() { 108 | // const host = await window.browserai.device.getHost(); 109 | // setHostUrl(host); 110 | // } 111 | // getHost(); 112 | // }, [setHostUrl]); 113 | 114 | return ( 115 |
116 |
117 | 118 | Ollama connection status:{' '} 119 | 120 |
123 | {isConnected ? 'Connected' : 'Not Connected'} 124 |
125 |
126 |
127 | {/* { 132 | const { value } = e.target; 133 | setHostUrl(value); 134 | }} 135 | onBlur={() => { 136 | window.browserai.device.setHost(hostUrl); 137 | }} 138 | /> */} 139 |
140 |
141 | ); 142 | }; 143 | 144 | const TableSkeleton = () => { 145 | return ( 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | ); 158 | }; 159 | 160 | function getModelFamily(model: ModelConfigureation) { 161 | return model.tags.find((tag) => tag.includes('family:')) ?? 'unknown'; 162 | } 163 | 164 | export const Display = () => { 165 | const [isConnected, setIsConnected] = useState(false); 166 | const [availableModels, setAvailableModels] = useState(new Set([])); 167 | 168 | const groupedModels: [string, Array][] = Object.entries( 169 | Object.groupBy( 170 | availableModelsData as ModelsConfigurationFile, 171 | (model: ModelConfigureation) => { 172 | return getModelFamily(model); 173 | }, 174 | ), 175 | ); 176 | 177 | useEffect(() => { 178 | async function checkIsConnectedToOllama() { 179 | const connected = await window.browserai.device.isConnected(); 180 | setIsConnected(connected); 181 | } 182 | 183 | async function listModels() { 184 | const modelList = await window.ai.permissions.models(); 185 | const modelNames = modelList.map((item) => item.model.split(':')[0]); 186 | setAvailableModels(new Set(modelNames)); 187 | } 188 | listModels(); 189 | checkIsConnectedToOllama(); 190 | const intervalID = setInterval(checkIsConnectedToOllama, 3000); 191 | 192 | return () => clearInterval(intervalID); 193 | }, []); 194 | 195 | return ( 196 |
197 |

198 | 199 | Browser.AI Settings 200 | 201 |

202 |
203 |

204 | Browser.AI leverages Ollama to support on-device models. Ollama must 205 | be running on your machine for AI API to function properly. You can 206 | download Ollama from{' '} 207 | 213 | https://ollama.com/download 214 | 215 | . 216 |

217 |
218 | 219 |
220 |

Model Settings

221 |

222 | You can enable or disable each model using the switch on the right. 223 | Once enabled, sites using the AI API can preform tasks using the model 224 | via the AI API. 225 |

226 |
227 | 228 | Models Available to Install 229 | 230 | 231 | Model 232 | Status 233 | Enable 234 | 235 | 236 | 237 | {isConnected && 238 | groupedModels.map(([family, models]) => { 239 | const groupDisplay = [ 240 | 244 | 248 | {family.replace('family:', '')} 249 | 250 | , 251 | ]; 252 | models 253 | .sort((a, b) => b.title.localeCompare(a.title)) 254 | .forEach((model) => { 255 | groupDisplay.push( 256 | , 261 | ); 262 | }); 263 | return groupDisplay; 264 | })} 265 | {!isConnected && 266 | Array.from({ length: 6 }).map((_, index) => ( 267 | // eslint-disable-next-line react/no-array-index-key 268 | 269 | ))} 270 | 271 |
272 |
273 | ); 274 | }; 275 | -------------------------------------------------------------------------------- /setupJest.ts: -------------------------------------------------------------------------------- 1 | const ollamaClient = { 2 | Ollama: jest.fn().mockImplementation(() => { 3 | return { 4 | chat: jest.fn().mockReturnValue({ 5 | model: 'llama3.2', 6 | created_at: new Date('2024-10-08T19:50:39.718631Z'), 7 | message: { 8 | role: 'assistant', 9 | content: 10 | "The sky appears blue to our eyes due to a phenomenon called Rayleigh scattering, named after the British physicist Lord Rayleigh, who first explained it in the late 19th century.\n\nHere's what happens:\n\n1. **Sunlight enters Earth's atmosphere**: When sunlight enters our atmosphere, it consists of a spectrum of colors, including all the colors of the visible light spectrum.\n2. **Light interacts with nitrogen and oxygen molecules**: As sunlight travels through the atmosphere, it encounters tiny molecules of nitrogen (N2) and oxygen (O2). These molecules scatter the light in all directions.\n3. **Shorter wavelengths are scattered more**: The shorter (blue) wavelengths of light are scattered more than the longer (red) wavelengths by the smaller nitrogen and oxygen molecules. This is because the smaller molecules have a larger cross-sectional area, which allows them to scatter the shorter wavelengths more efficiently.\n4. **Blue light is dispersed in all directions**: As a result of this scattering, the blue light is dispersed in all directions and reaches our eyes from every part of the sky.\n5. **Our eyes perceive the scattered blue light as the dominant color**: Because of the way that the light is scattered, our eyes perceive the blue light as the dominant color, making the sky appear blue to us.\n\nIt's worth noting that during sunrise and sunset, the sky can take on a range of colors, including pink, orange, and red. This is because the sunlight has to travel through more of the Earth's atmosphere to reach our eyes, which scatters the shorter wavelengths even further, leaving mainly longer wavelengths (like red and orange) to be visible.\n\nSo, in short, the sky appears blue because of the way that sunlight interacts with the tiny molecules of nitrogen and oxygen in our atmosphere, scattering the shorter wavelengths more than the longer ones.", 11 | }, 12 | done_reason: 'stop', 13 | done: true, 14 | total_duration: 39007631218, 15 | load_duration: 34515208, 16 | prompt_eval_count: 31, 17 | prompt_eval_duration: 117012000, 18 | eval_count: 370, 19 | eval_duration: 38854044000, 20 | }), 21 | embed: jest.fn().mockReturnValue({ 22 | model: 'test-model', 23 | embeddings: [[-0.0062045706, -0.013037177, 0.009031619]], 24 | total_duration: 10409949560, 25 | load_duration: 9907444385, 26 | prompt_eval_count: 2, 27 | }), 28 | ps: jest.fn().mockReturnValue({ 29 | models: [ 30 | { 31 | name: 'mistral:latest', 32 | model: 'mistral:latest', 33 | size: 5137025024, 34 | digest: 35 | '2ae6f6dd7a3dd734790bbbf58b8909a606e0e7e97e94b7604e0aa7ae4490e6d8', 36 | details: { 37 | parent_model: '', 38 | format: 'gguf', 39 | family: 'llama', 40 | families: ['llama'], 41 | parameter_size: '7.2B', 42 | quantization_level: 'Q4_0', 43 | }, 44 | expires_at: '2024-06-04T14:38:31.83753-07:00', 45 | size_vram: 5137025024, 46 | }, 47 | ], 48 | }), 49 | pull: jest.fn().mockReturnValue({ status: 'success' }), 50 | list: jest.fn().mockReturnValue({ 51 | models: [ 52 | { 53 | name: 'gemma2:latest', 54 | model: 'gemma2:latest', 55 | modified_at: '2024-09-29T18:48:29.085228211-04:00', 56 | size: 5443152417, 57 | digest: '123', 58 | details: { 59 | parent_model: '', 60 | format: 'gguf', 61 | family: 'gemma2', 62 | families: ['gemma2'], 63 | parameter_size: '9.2B', 64 | quantization_level: 'Q4_0', 65 | }, 66 | }, 67 | { 68 | name: 'llama3.1:latest', 69 | model: 'llama3.1:latest', 70 | modified_at: '2024-09-29T18:48:27.795520534-04:00', 71 | size: 4661230766, 72 | digest: 73 | '42182419e9508c30c4b1fe55015f06b65f4ca4b9e28a744be55008d21998a093', 74 | details: { 75 | parent_model: '', 76 | format: 'gguf', 77 | family: 'llama', 78 | families: ['llama'], 79 | parameter_size: '8.0B', 80 | quantization_level: 'Q4_0', 81 | }, 82 | }, 83 | { 84 | name: 'llama3.2:latest', 85 | model: 'llama3.2:latest', 86 | modified_at: '2024-09-29T18:48:26.599147061-04:00', 87 | size: 2019393189, 88 | digest: '234', 89 | details: { 90 | parent_model: '', 91 | format: 'gguf', 92 | family: 'llama', 93 | families: ['llama'], 94 | parameter_size: '3.2B', 95 | quantization_level: 'Q4_K_M', 96 | }, 97 | }, 98 | { 99 | name: 'qwen2.5:latest', 100 | model: 'qwen2.5:latest', 101 | modified_at: '2024-09-28T16:42:21.537757929-04:00', 102 | size: 4683087332, 103 | digest: '456', 104 | details: { 105 | parent_model: '', 106 | format: 'gguf', 107 | family: 'qwen2', 108 | families: ['qwen2'], 109 | parameter_size: '7.6B', 110 | quantization_level: 'Q4_K_M', 111 | }, 112 | }, 113 | { 114 | name: 'llava:latest', 115 | model: 'llava:latest', 116 | modified_at: '2024-09-13T21:38:28.050957108-04:00', 117 | size: 4733363377, 118 | digest: 119 | '8dd30f6b0cb19f555f2c7a7ebda861449ea2cc76bf1f44e262931f45fc81d081', 120 | details: { 121 | parent_model: '', 122 | format: 'gguf', 123 | family: 'llama', 124 | families: ['llama', 'clip'], 125 | parameter_size: '7B', 126 | quantization_level: 'Q4_0', 127 | }, 128 | }, 129 | ], 130 | }), 131 | show: jest.fn().mockReturnValue({ 132 | license: 'Model license text', 133 | modelfile: 'blah blah blah', 134 | parameters: 'blah blah blah', 135 | template: 'blah blah blah', 136 | details: { 137 | parent_model: '', 138 | format: 'gguf', 139 | family: 'llama', 140 | families: ['llama'], 141 | parameter_size: '3.2B', 142 | quantization_level: 'Q4_K_M', 143 | }, 144 | model_info: { 145 | 'general.architecture': 'llama', 146 | 'general.basename': 'Llama-3.2', 147 | 'general.file_type': 15, 148 | 'general.finetune': 'Instruct', 149 | 'general.languages': ['en', 'de', 'fr', 'it', 'pt', 'hi', 'es', 'th'], 150 | 'general.parameter_count': 3212749888, 151 | 'general.quantization_version': 2, 152 | 'general.size_label': '3B', 153 | 'general.tags': [ 154 | 'facebook', 155 | 'meta', 156 | 'pytorch', 157 | 'llama', 158 | 'llama-3', 159 | 'text-generation', 160 | ], 161 | 'general.type': 'model', 162 | 'llama.attention.head_count': 24, 163 | 'llama.attention.head_count_kv': 8, 164 | 'llama.attention.key_length': 128, 165 | 'llama.attention.layer_norm_rms_epsilon': 0.00001, 166 | 'llama.attention.value_length': 128, 167 | 'llama.block_count': 28, 168 | 'llama.context_length': 131072, 169 | 'llama.embedding_length': 3072, 170 | 'llama.feed_forward_length': 8192, 171 | 'llama.rope.dimension_count': 128, 172 | 'llama.rope.freq_base': 500000, 173 | 'llama.vocab_size': 128256, 174 | 'tokenizer.ggml.bos_token_id': 128000, 175 | 'tokenizer.ggml.eos_token_id': 128009, 176 | 'tokenizer.ggml.merges': null, 177 | 'tokenizer.ggml.model': 'gpt2', 178 | 'tokenizer.ggml.pre': 'llama-bpe', 179 | 'tokenizer.ggml.token_type': null, 180 | 'tokenizer.ggml.tokens': null, 181 | }, 182 | modified_at: '2024-09-29T18:48:26.599147061-04:00', 183 | }), 184 | }; 185 | }), 186 | }; 187 | 188 | jest.mock('ollama', () => ollamaClient); 189 | 190 | jest.mock('./src/lib/config'); 191 | jest.mock('./src/lib/store', () => ({ 192 | getStore: jest.fn().mockResolvedValue({ 193 | get: jest.fn().mockImplementation((key) => { 194 | if (key === 'localModels') { 195 | return [ 196 | { 197 | model: 'test-model', 198 | enabled: true, 199 | }, 200 | { 201 | model: 'gemma2', 202 | enabled: true, 203 | }, 204 | ]; 205 | } 206 | 207 | if (key === 'deviceHost') { 208 | return 'http://localhost:11434'; 209 | } 210 | 211 | throw new Error('Invalid key passed to store.get mock'); 212 | }), 213 | set: jest.fn(), 214 | }), 215 | })); 216 | 217 | jest.mock('electron-store'); 218 | 219 | // eslint-disable-next-line no-underscore-dangle 220 | (global as any).__mocks__ = { 221 | ollamaClient, 222 | }; 223 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browserai", 3 | "version": "0.1.5", 4 | "description": "Prototype of a window.ai api for accessing on-device models in the browser", 5 | "keywords": [ 6 | "browser", 7 | "ai", 8 | "prototype", 9 | "electron", 10 | "typescript", 11 | "react", 12 | "tailwindcss", 13 | "webpack" 14 | ], 15 | "homepage": "https://github.com/kenzic/browser.ai#readme", 16 | "bugs": { 17 | "url": "https://github.com/kenzic/browser.ai/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/kenzic/browser.ai.git" 22 | }, 23 | "license": "MIT", 24 | "author": { 25 | "name": "Chris Mckenzie", 26 | "url": "https://card.christophermckenzie.com" 27 | }, 28 | "main": "./.erb/dll/main.bundle.dev.js", 29 | "scripts": { 30 | "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", 31 | "build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", 32 | "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", 33 | "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", 34 | "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", 35 | "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", 36 | "lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix", 37 | "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", 38 | "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", 39 | "prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.dev.ts", 40 | "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", 41 | "start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon .\"", 42 | "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", 43 | "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", 44 | "test": "jest" 45 | }, 46 | "browserslist": [ 47 | "extends browserslist-config-erb" 48 | ], 49 | "prettier": { 50 | "singleQuote": true, 51 | "overrides": [ 52 | { 53 | "files": [ 54 | ".prettierrc", 55 | ".eslintrc" 56 | ], 57 | "options": { 58 | "parser": "json" 59 | } 60 | } 61 | ] 62 | }, 63 | "jest": { 64 | "moduleDirectories": [ 65 | "node_modules", 66 | "release/app/node_modules", 67 | "src" 68 | ], 69 | "moduleFileExtensions": [ 70 | "js", 71 | "jsx", 72 | "ts", 73 | "tsx", 74 | "json" 75 | ], 76 | "moduleNameMapper": { 77 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", 78 | "\\.(css|less|sass|scss)$": "identity-obj-proxy" 79 | }, 80 | "setupFiles": [ 81 | "./.erb/scripts/check-build-exists.ts", 82 | "./setupJest.ts" 83 | ], 84 | "testEnvironment": "jsdom", 85 | "testEnvironmentOptions": { 86 | "url": "http://localhost/" 87 | }, 88 | "testPathIgnorePatterns": [ 89 | "release/app/dist", 90 | ".erb/dll" 91 | ], 92 | "transform": { 93 | "\\.(ts|tsx|js|jsx)$": [ 94 | "ts-jest", 95 | { 96 | "diagnostics": { 97 | "warnOnly": true 98 | } 99 | } 100 | ] 101 | } 102 | }, 103 | "dependencies": { 104 | "@electron/notarize": "^2.5.0", 105 | "@radix-ui/react-icons": "^1.3.2", 106 | "@radix-ui/react-switch": "^1.1.3", 107 | "class-variance-authority": "^0.7.1", 108 | "classnames": "^2.5.1", 109 | "clsx": "^2.1.1", 110 | "dotenv": "^16.4.7", 111 | "electron": "^35.0.1", 112 | "electron-debug": "^4.1.0", 113 | "electron-log": "^5.3.2", 114 | "electron-notarize": "^1.2.2", 115 | "electron-store": "^10.0.1", 116 | "electron-updater": "^6.3.9", 117 | "lucide-react": "^0.446.0", 118 | "ollama": "^0.5.14", 119 | "react": "^18.3.1", 120 | "react-dom": "^18.3.1", 121 | "tailwind-merge": "^2.6.0", 122 | "tailwindcss-animate": "^1.0.7", 123 | "zod": "^3.24.2" 124 | }, 125 | "devDependencies": { 126 | "@electron/rebuild": "^3.7.1", 127 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", 128 | "@svgr/webpack": "^8.1.0", 129 | "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", 130 | "@testing-library/jest-dom": "^6.6.3", 131 | "@testing-library/react": "^14.3.1", 132 | "@types/electron": "^1.6.12", 133 | "@types/jest": "^29.5.14", 134 | "@types/node": "20.6.2", 135 | "@types/react": "^18.3.18", 136 | "@types/react-dom": "^18.3.5", 137 | "@types/react-test-renderer": "^18.3.1", 138 | "@types/webpack-bundle-analyzer": "^4.7.0", 139 | "@typescript-eslint/eslint-plugin": "^6.21.0", 140 | "@typescript-eslint/parser": "^6.21.0", 141 | "autoprefixer": "^10.4.21", 142 | "browserslist-config-erb": "^0.0.3", 143 | "chalk": "^4.1.2", 144 | "concurrently": "^8.2.2", 145 | "core-js": "^3.41.0", 146 | "cross-env": "^7.0.3", 147 | "css-loader": "^7.1.2", 148 | "css-minimizer-webpack-plugin": "^5.0.1", 149 | "detect-port": "^1.6.1", 150 | "electron-builder": "^25.1.8", 151 | "electron-devtools-installer": "^3.2.1", 152 | "electronmon": "^2.0.3", 153 | "eslint": "^8.57.1", 154 | "eslint-config-airbnb-base": "^15.0.0", 155 | "eslint-config-erb": "^4.1.0", 156 | "eslint-config-prettier": "^9.1.0", 157 | "eslint-import-resolver-typescript": "^3.8.3", 158 | "eslint-import-resolver-webpack": "^0.13.10", 159 | "eslint-plugin-compat": "^4.2.0", 160 | "eslint-plugin-import": "^2.31.0", 161 | "eslint-plugin-jest": "^27.9.0", 162 | "eslint-plugin-jsx-a11y": "^6.10.2", 163 | "eslint-plugin-prettier": "^5.2.3", 164 | "eslint-plugin-promise": "^6.6.0", 165 | "eslint-plugin-react": "^7.37.4", 166 | "eslint-plugin-react-hooks": "^4.6.2", 167 | "file-loader": "^6.2.0", 168 | "html-webpack-plugin": "^5.6.3", 169 | "identity-obj-proxy": "^3.0.0", 170 | "jest": "^29.7.0", 171 | "jest-environment-jsdom": "^29.7.0", 172 | "mini-css-extract-plugin": "^2.9.2", 173 | "postcss": "^8.5.3", 174 | "postcss-loader": "^8.1.1", 175 | "prettier": "^3.5.3", 176 | "react-refresh": "^0.14.2", 177 | "react-test-renderer": "^18.3.1", 178 | "rimraf": "^5.0.10", 179 | "sass": "^1.85.1", 180 | "sass-loader": "^13.3.3", 181 | "style-loader": "^3.3.4", 182 | "tailwindcss": "^3.4.17", 183 | "terser-webpack-plugin": "^5.3.14", 184 | "ts-jest": "^29.2.6", 185 | "ts-loader": "^9.5.2", 186 | "ts-node": "^10.9.2", 187 | "tsconfig-paths-webpack-plugin": "^4.2.0", 188 | "typescript": "^5.8.2", 189 | "url-loader": "^4.1.1", 190 | "webpack": "^5.98.0", 191 | "webpack-bundle-analyzer": "^4.10.2", 192 | "webpack-cli": "^5.1.4", 193 | "webpack-dev-server": "^4.15.2", 194 | "webpack-merge": "^5.10.0" 195 | }, 196 | "build": { 197 | "productName": "Browser.AI", 198 | "appId": "com.2dx3.BrowserAI", 199 | "asar": true, 200 | "asarUnpack": "**\\*.{node,dll}", 201 | "files": [ 202 | "dist", 203 | "node_modules", 204 | "package.json" 205 | ], 206 | "afterSign": "./.erb/scripts/notarize.js", 207 | "mac": { 208 | "notarize": true, 209 | "target": { 210 | "target": "default", 211 | "arch": [ 212 | "arm64", 213 | "x64" 214 | ] 215 | }, 216 | "type": "distribution", 217 | "hardenedRuntime": true, 218 | "entitlements": "assets/entitlements.mac.plist", 219 | "entitlementsInherit": "assets/entitlements.mac.plist", 220 | "gatekeeperAssess": false, 221 | "forceCodeSigning": true 222 | }, 223 | "dmg": { 224 | "sign": false, 225 | "contents": [ 226 | { 227 | "x": 130, 228 | "y": 220 229 | }, 230 | { 231 | "x": 410, 232 | "y": 220, 233 | "type": "link", 234 | "path": "/Applications" 235 | } 236 | ] 237 | }, 238 | "win": { 239 | "target": [ 240 | "nsis" 241 | ] 242 | }, 243 | "linux": { 244 | "target": [ 245 | "AppImage" 246 | ], 247 | "category": "Development" 248 | }, 249 | "directories": { 250 | "app": "release/app", 251 | "buildResources": "assets", 252 | "output": "release/build" 253 | }, 254 | "extraResources": [ 255 | "./assets/**" 256 | ], 257 | "publish": { 258 | "provider": "github", 259 | "owner": "kenzic", 260 | "repo": "browser.ai" 261 | } 262 | }, 263 | "devEngines": { 264 | "node": ">=20.x", 265 | "npm": ">=10.x" 266 | }, 267 | "electronmon": { 268 | "patterns": [ 269 | "!**/**", 270 | "src/main/**", 271 | ".erb/dll/**" 272 | ], 273 | "logLevel": "quiet" 274 | }, 275 | "pnpm": { 276 | "onlyBuiltDependencies": [ 277 | "@parcel/watcher", 278 | "core-js", 279 | "core-js-pure", 280 | "electron" 281 | ] 282 | } 283 | } -------------------------------------------------------------------------------- /src/browser/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line max-classes-per-file 2 | import Electron, { 3 | BrowserWindow, 4 | BaseWindow, 5 | WebContentsView, 6 | ipcMain, 7 | app, 8 | } from 'electron'; 9 | import EventEmitter from 'events'; 10 | import log from 'electron-log'; 11 | import path from 'path'; 12 | import config from '../lib/config'; 13 | import { 14 | resolveHtmlPath, 15 | getAssetPath, 16 | isAliasDomain, 17 | getAliasFromURL, 18 | getAliasURL, 19 | } from '../lib/utils/main'; 20 | import { 21 | TabID, 22 | Tab, 23 | TabPreferences, 24 | Tabs, 25 | BrowserOptions, 26 | WebContentsActions, 27 | BrowserConfig, 28 | ChannelListener, 29 | ChannelEntry, 30 | } from './types'; 31 | 32 | log.transports.file.level = false; 33 | log.transports.console.level = false; 34 | 35 | const preloadPublicPath = app.isPackaged 36 | ? path.join(__dirname, 'preloadPublic.js') 37 | : path.join(__dirname, '../../.erb/dll/preloadPublic.js'); 38 | 39 | const preloadPrivatePath = app.isPackaged 40 | ? path.join(__dirname, 'preloadPrivate.js') 41 | : path.join(__dirname, '../../.erb/dll/preloadPrivate.js'); 42 | 43 | class ControlView extends WebContentsView { 44 | constructor(controlOptions?: object) { 45 | super({ 46 | webPreferences: { 47 | preload: preloadPrivatePath, 48 | contextIsolation: true, 49 | nodeIntegration: false, 50 | // enableRemoteModule: false, 51 | // Allow loadURL with file path in dev environment 52 | webSecurity: false, 53 | ...controlOptions, 54 | }, 55 | }); 56 | 57 | this.webContents.loadURL(resolveHtmlPath('controls.html')); 58 | // this.webContents.openDevTools(); 59 | } 60 | 61 | destory() { 62 | this.webContents.removeAllListeners(); 63 | this.webContents.close({ waitForBeforeUnload: false }); 64 | } 65 | } 66 | 67 | class WebView extends WebContentsView { 68 | id: number; 69 | 70 | constructor(options: TabPreferences) { 71 | super({ 72 | webPreferences: { 73 | preload: preloadPublicPath, 74 | // Set sandbox to support window.opener 75 | // See: https://github.com/electron/electron/issues/1865#issuecomment-249989894 76 | sandbox: true, 77 | webSecurity: true, 78 | ...options, 79 | }, 80 | }); 81 | 82 | this.id = this.webContents.id; 83 | } 84 | } 85 | 86 | class SettingsView extends WebContentsView { 87 | id: number; 88 | 89 | constructor(options: TabPreferences) { 90 | super({ 91 | webPreferences: { 92 | preload: preloadPrivatePath, 93 | // Set sandbox to support window.opener 94 | // See: https://github.com/electron/electron/issues/1865#issuecomment-249989894 95 | sandbox: true, 96 | webSecurity: true, 97 | ...options, 98 | }, 99 | }); 100 | 101 | this.id = this.webContents.id; 102 | } 103 | } 104 | 105 | export default class Browser extends EventEmitter { 106 | options: Partial; 107 | 108 | win: BaseWindow; 109 | 110 | defaultCurrentViewId: TabID | null; 111 | 112 | defaultTabConfigs: Tabs; 113 | 114 | views: Record; 115 | 116 | tabs: TabID[]; 117 | 118 | ipc: any; 119 | 120 | controlView: ControlView | null; 121 | 122 | config: BrowserConfig; 123 | 124 | constructor(options: Partial) { 125 | super(); 126 | 127 | this.options = options; 128 | const { width = 1024, height = 800 } = options; 129 | 130 | this.config = { 131 | startPage: config.get('startPage'), 132 | blankPage: config.get('blankPage'), 133 | blankTitle: config.get('blankTitle'), 134 | debug: options.debug || config.get('defaultDebug'), 135 | }; 136 | 137 | this.win = new BaseWindow({ 138 | width, 139 | height, 140 | minWidth: 400, 141 | minHeight: 400, 142 | icon: getAssetPath('icon.png'), 143 | title: config.get('browserTitle'), 144 | }); 145 | 146 | this.defaultCurrentViewId = null; 147 | this.defaultTabConfigs = {}; 148 | // Prevent browser views garbage collected 149 | this.views = {}; 150 | // keep order 151 | this.tabs = []; 152 | // ipc channel 153 | this.ipc = null; 154 | 155 | this.controlView = new ControlView(); 156 | 157 | this.win.on('resized', () => { 158 | this.setContentBounds(); 159 | this.controlView?.setBounds(this.getControlBounds()); 160 | }); 161 | 162 | this.win.contentView.addChildView(this.controlView); 163 | this.controlView.setBounds(this.getControlBounds()); 164 | 165 | const webContentsAct = (actionName: WebContentsActions) => { 166 | const webContents = this.currentWebContents; 167 | const action = webContents && webContents[actionName]; 168 | if (typeof action === 'function') { 169 | if (actionName === 'reload' && webContents?.getURL() === '') return; 170 | // @ts-ignore 171 | action.call(webContents); 172 | log.debug( 173 | `do webContents action ${actionName.toString()} for ${this.currentViewId}:${ 174 | webContents && webContents.getTitle() 175 | }`, 176 | ); 177 | } else { 178 | log.error('Invalid webContents action ', actionName); 179 | } 180 | }; 181 | 182 | const channels: [string, ChannelListener][] = Object.entries({ 183 | 'control-ready': (e: Electron.IpcMainEvent) => { 184 | this.ipc = e; 185 | // TODO: should this only fire once? 186 | this.newTab(this.config.startPage || ''); 187 | this.emit('control-ready', e); 188 | }, 189 | 'will-navigate': (e: Electron.IpcMainEvent, url: string) => { 190 | console.log('will-navigate', url); 191 | }, 192 | 'url-change': (e: Electron.IpcMainEvent, url: string) => { 193 | if (this.currentViewId) this.setTabConfig(this.currentViewId, { url }); 194 | }, 195 | 'url-enter': (e: Electron.IpcMainEvent, url: string) => { 196 | this.loadURL(url); 197 | }, 198 | act: (e: Electron.IpcMainEvent, actName: WebContentsActions) => 199 | webContentsAct(actName), 200 | 'new-tab': ( 201 | e: Electron.IpcMainEvent, 202 | url: string, 203 | tabPreferences: TabPreferences, 204 | ) => { 205 | log.debug('new-tab with url', url); 206 | this.newTab(url, undefined, tabPreferences); 207 | }, 208 | 'switch-tab': (e: Electron.IpcMainEvent, id: TabID) => { 209 | this.switchTab(id); 210 | }, 211 | 'close-tab': (e: Electron.IpcMainEvent, id: TabID) => { 212 | this.closeTab(id); 213 | }, 214 | }); 215 | 216 | channels 217 | .map( 218 | ([name, listener]: [string, ChannelListener]): ChannelEntry => ({ 219 | name, 220 | listener: (e, ...args) => { 221 | // Support multiple BrowserLikeWindow 222 | if (this.controlView && e.sender === this.controlView.webContents) { 223 | log.debug(`Trigger ${name} from ${e.sender.id}`); 224 | listener(e, ...args); 225 | } 226 | }, 227 | }), 228 | ) 229 | .forEach(({ name, listener }) => { 230 | if (name === 'control-ready') { 231 | ipcMain.once(name, listener); 232 | } else { 233 | ipcMain.on(name, listener); 234 | } 235 | }); 236 | 237 | this.win.on('closed', () => { 238 | // Remember to clear all ipcMain events as ipcMain bind 239 | // on every new browser instance 240 | channels.forEach(([name, listener]) => 241 | ipcMain.removeListener(name, listener), 242 | ); 243 | 244 | // Prevent WebContentsView memory leak on close 245 | this.tabs.forEach((id) => this.destroyView(id)); 246 | if (this.controlView) { 247 | this.controlView.destory(); 248 | this.controlView = null; 249 | log.debug('Control view destroyed'); 250 | } 251 | this.emit('closed'); 252 | }); 253 | 254 | if (this.config.debug) { 255 | this.controlView.webContents.openDevTools({ mode: 'detach' }); 256 | log.transports.console.level = 'debug'; 257 | } 258 | } 259 | 260 | getControlBounds() { 261 | const contentBounds = this.win.getContentBounds(); 262 | return { 263 | x: 0, 264 | y: 0, 265 | width: contentBounds.width, 266 | height: 82, 267 | }; 268 | } 269 | 270 | setContentBounds() { 271 | const [contentWidth, contentHeight] = this.win.getContentSize(); 272 | const controlBounds = this.getControlBounds(); 273 | if (this.currentView) { 274 | this.currentView.setBounds({ 275 | x: 0, 276 | y: controlBounds.y + controlBounds.height, 277 | width: contentWidth, 278 | height: contentHeight - controlBounds.height, 279 | }); 280 | } 281 | } 282 | 283 | get currentView(): WebView | SettingsView | null { 284 | return this.currentViewId ? this.views[this.currentViewId] : null; 285 | } 286 | 287 | get currentWebContents() { 288 | const { webContents } = this.currentView || {}; 289 | return webContents; 290 | } 291 | 292 | get currentViewId(): number | null { 293 | return this.defaultCurrentViewId; 294 | } 295 | 296 | set currentViewId(id: number) { 297 | this.defaultCurrentViewId = id; 298 | this.setContentBounds(); 299 | if (this.ipc) { 300 | this.ipc.reply('active-update', id); 301 | } 302 | } 303 | 304 | get tabConfigs() { 305 | return this.defaultTabConfigs; 306 | } 307 | 308 | set tabConfigs(v) { 309 | this.defaultTabConfigs = v; 310 | if (this.ipc) { 311 | this.ipc.reply('tabs-update', { 312 | confs: v, 313 | tabs: this.tabs, 314 | }); 315 | } 316 | } 317 | 318 | setTabConfig(viewId: number, tabConfig: Partial) { 319 | const tab = this.tabConfigs[viewId]; 320 | const { webContents } = this.views[viewId] || {}; 321 | const alias = getAliasFromURL(tab?.url); 322 | if (alias) { 323 | tab.url = alias; 324 | } 325 | this.tabConfigs = { 326 | ...this.tabConfigs, 327 | [viewId]: { 328 | ...tab, 329 | canGoBack: webContents && webContents.canGoBack(), 330 | canGoForward: webContents && webContents.canGoForward(), 331 | ...tabConfig, 332 | }, 333 | }; 334 | 335 | return this.tabConfigs; 336 | } 337 | 338 | loadURL(url: string): void { 339 | const { currentView } = this; 340 | if (!url || !currentView) return; 341 | if (isAliasDomain(url)) { 342 | const href = getAliasURL(url); 343 | this.loadURL(href); 344 | 345 | return; 346 | } 347 | const { id, webContents } = currentView; 348 | 349 | // Prevent addEventListeners on same webContents when enter urls in same tab 350 | const MARKS = '__IS_INITIALIZED__'; 351 | // @ts-ignore 352 | if (webContents[MARKS]) { 353 | webContents.loadURL(url); 354 | return; 355 | } 356 | 357 | const onNewWindow = ( 358 | e: Electron.IpcMainEvent, 359 | newUrl: string, 360 | frameName: string, 361 | disposition: string, 362 | winOptions: TabPreferences, 363 | ) => { 364 | log.debug('on new-window', { disposition, newUrl, frameName }); 365 | 366 | if (!new URL(newUrl).host) { 367 | // Handle newUrl = 'about:blank' in some cases 368 | log.debug('Invalid url open with default window'); 369 | return; 370 | } 371 | 372 | e.preventDefault(); 373 | if (disposition === 'new-window') { 374 | // @ts-ignore 375 | e.newGuest = new BrowserWindow(winOptions); 376 | } else if (disposition === 'foreground-tab') { 377 | this.newTab(newUrl, id); 378 | // `newGuest` must be setted to prevent freeze trigger tab in case. 379 | // The window will be destroyed automatically on trigger tab closed. 380 | // @ts-ignore 381 | e.newGuest = new BrowserWindow({ ...winOptions, show: false }); 382 | } else { 383 | this.newTab(newUrl, id); 384 | } 385 | }; 386 | // @ts-ignore 387 | webContents.on('new-window', this.options.onNewWindow || onNewWindow); 388 | 389 | // Keep event in order 390 | webContents 391 | .on('did-start-loading', () => { 392 | log.debug('did-start-loading > set loading'); 393 | this.setTabConfig(id, { isLoading: true }); 394 | }) 395 | .on('did-start-navigation', (e, href, isInPlace, isMainFrame) => { 396 | if (isMainFrame) { 397 | log.debug('did-start-navigation > set url address', { 398 | href, 399 | isInPlace, 400 | isMainFrame, 401 | }); 402 | this.setTabConfig(id, { url: href, href }); 403 | this.emit('url-updated', { view: currentView, href }); 404 | } 405 | }) 406 | .on('will-redirect', (e, href) => { 407 | log.debug('will-redirect > update url address', { href }); 408 | this.setTabConfig(id, { url: href, href }); 409 | this.emit('url-updated', { view: currentView, href }); 410 | }) 411 | .on('page-title-updated', (e, title) => { 412 | log.debug('page-title-updated', title); 413 | this.setTabConfig(id, { title }); 414 | }) 415 | .on('page-favicon-updated', (e, favicons) => { 416 | log.debug('page-favicon-updated', favicons); 417 | this.setTabConfig(id, { favicon: favicons[0] }); 418 | }) 419 | .on('did-stop-loading', () => { 420 | log.debug('did-stop-loading', { title: webContents.getTitle() }); 421 | this.setTabConfig(id, { isLoading: false }); 422 | }) 423 | .on('dom-ready', () => { 424 | webContents.focus(); 425 | }); 426 | 427 | webContents.loadURL(url); 428 | // @ts-ignore 429 | webContents[MARKS] = true; 430 | 431 | this.setContentBounds(); 432 | 433 | if (this.config.debug) { 434 | webContents.openDevTools({ mode: 'detach' }); 435 | } 436 | } 437 | 438 | setCurrentView(viewId: number) { 439 | if (!viewId) return; 440 | 441 | if (this.currentView) 442 | this.win.contentView.removeChildView(this.currentView); 443 | this.win.contentView.addChildView(this.views[viewId]); 444 | this.currentViewId = viewId; 445 | } 446 | 447 | newTab(url?: string, appendTo?: number, tabPreferences: TabPreferences = {}) { 448 | let view: WebView; 449 | if (url && isAliasDomain(url)) { 450 | view = new SettingsView(tabPreferences); 451 | } else { 452 | view = new WebView(tabPreferences); 453 | } 454 | 455 | view.id = view.webContents.id; 456 | 457 | if (appendTo) { 458 | const prevIndex = this.tabs.indexOf(appendTo); 459 | this.tabs.splice(prevIndex + 1, 0, view.id); 460 | } else { 461 | this.tabs.push(view.id); 462 | } 463 | this.views[view.id] = view; 464 | 465 | // Add to manager first 466 | const lastView = this.currentView; 467 | this.setCurrentView(view.id); 468 | 469 | this.loadURL(url || this.config.blankPage); 470 | this.setTabConfig(view.id, { 471 | title: this.config.blankTitle, 472 | }); 473 | this.emit('new-tab', view, { openedURL: url, lastView }); 474 | return view; 475 | } 476 | 477 | switchTab(viewId: number): void { 478 | log.debug('switch to tab', viewId); 479 | this.setCurrentView(viewId); 480 | this.currentView?.webContents.focus(); 481 | } 482 | 483 | closeTab(id: number): void { 484 | log.debug('close tab ', { id, currentViewId: this.currentViewId }); 485 | if (id === this.currentViewId) { 486 | const removeIndex = this.tabs.indexOf(id); 487 | const nextIndex = 488 | removeIndex === this.tabs.length - 1 ? 0 : removeIndex + 1; 489 | this.setCurrentView(this.tabs[nextIndex]); 490 | } 491 | this.tabs = this.tabs.filter((v) => v !== id); 492 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 493 | const { [id]: _, ...newTabConfigs } = this.tabConfigs; 494 | this.tabConfigs = newTabConfigs; 495 | this.destroyView(id); 496 | 497 | if (this.tabs.length === 0) { 498 | this.newTab(); 499 | } 500 | } 501 | 502 | focusAddressBar() { 503 | const webContents = this.controlView?.webContents; 504 | if (webContents) { 505 | webContents.focus(); 506 | webContents.send('focus-address-bar'); 507 | } 508 | } 509 | 510 | destroyView(viewId: TabID) { 511 | const view: WebView = this.views[viewId]; 512 | if (view) { 513 | view.webContents.removeAllListeners(); 514 | // this.win.contentView.removeChildView(view); 515 | delete this.views[viewId]; 516 | (view.webContents as any).destroy(); 517 | view.webContents.close({ waitForBeforeUnload: false }); 518 | log.debug(`${viewId} destroyed`); 519 | } 520 | } 521 | } 522 | -------------------------------------------------------------------------------- /.erb/img/erb-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | --------------------------------------------------------------------------------