Repository: mlx-chat/mlx-chat-app Branch: main Commit: 20f441dc1188 Files: 66 Total size: 201.3 KB Directory structure: gitextract_9znslswk/ ├── .github/ │ └── workflows/ │ └── lint.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app/ │ ├── .eslintrc.cjs │ ├── assets/ │ │ └── icon.icns │ ├── components.json │ ├── dprint.json │ ├── mac/ │ │ └── entitlements.mac.inherit.plist │ ├── main/ │ │ ├── main.ts │ │ ├── preload.ts │ │ ├── renderer.d.ts │ │ ├── splash/ │ │ │ ├── index.css │ │ │ └── index.html │ │ └── tsconfig.json │ ├── next.config.js │ ├── notarize.js │ ├── package.json │ ├── postcss.config.js │ ├── src/ │ │ ├── AppProvider.tsx │ │ ├── app/ │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── settings/ │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── chat/ │ │ │ │ ├── Chat.tsx │ │ │ │ ├── ChatInput.tsx │ │ │ │ ├── ChatMessage.tsx │ │ │ │ ├── ChatMessages.tsx │ │ │ │ └── SystemMessage.tsx │ │ │ ├── options/ │ │ │ │ ├── SelectDirectory.tsx │ │ │ │ └── SelectModel.tsx │ │ │ └── ui/ │ │ │ ├── button.tsx │ │ │ ├── input.tsx │ │ │ ├── resizable.tsx │ │ │ ├── select.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ │ ├── constants/ │ │ │ └── chat.tsx │ │ └── lib/ │ │ ├── hooks.ts │ │ ├── store.ts │ │ └── utils.ts │ ├── tailwind.config.main.js │ ├── tailwind.config.ts │ └── tsconfig.json ├── runner.py ├── runner.sh └── server/ ├── __init__.py ├── convert.py ├── models/ │ ├── __init__.py │ ├── base.py │ ├── bert.py │ ├── gemma.py │ ├── layers.py │ └── llama.py ├── py.typed ├── requirements.txt ├── retriever/ │ ├── document.py │ ├── embeddings.py │ ├── loader.py │ ├── splitter.py │ └── vectorstore.py ├── server.py └── utils.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [pull_request] jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 with: fetch-depth: 1 - name: Use Node.js 16 uses: actions/setup-node@v1 with: node-version: 16 - name: Install App Deps run: npm i --ignore-scripts working-directory: ./app - name: Lint App working-directory: ./app run: npm run lint ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules/ .pnp/ .pnp.js # testing coverage/ # next.js .next/ out/ # production build/ app/main/tailwind.css dist/ # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ server/lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ ================================================ FILE: .vscode/settings.json ================================================ { "[python]": { "editor.tabSize": 4, "editor.defaultFormatter": "ms-python.autopep8", }, // "It is recommended that either ESLint or cspell checks a file, but not both." // https://www.npmjs.com/package/@cspell/eslint-plugin "cSpell.enableFiletypes": [ "!javascript", "!typescript", ], "css.format.spaceAroundSelectorSeparator": true, "editor.codeActionsOnSave": [ "source.fixAll.eslint", ], "eslint.codeActionsOnSave.mode": "problems", "eslint.options": { "reportUnusedDisableDirectives": "error", }, "eslint.rules.customizations": [ { "rule": "*", "severity": "warn" }, ], "editor.defaultFormatter": "dprint.dprint", "editor.formatOnSave": true, "editor.tabSize": 2, "editor.wordWrapColumn": 100, "eslint.workingDirectories": [ "./app", ], "files.insertFinalNewline": true, "files.trimFinalNewlines": true, "git.allowForcePush": true, "git.inputValidationSubjectLength": 100, "git.inputValidationLength": 100, "javascript.preferences.quoteStyle": "single", "typescript.preferences.quoteStyle": "single", "scss.format.spaceAroundSelectorSeparator": true, "typescript.tsdk": "node_modules/typescript/lib", } ================================================ FILE: CODEOWNERS ================================================ * @parkersm1th @stockeh ================================================ FILE: CONTRIBUTING.md ================================================ # Welcome to our contribution guide Thank you for wanting to contribute to our project! We apprecaite any contributions that you make. Chat with MLX is an open source project and we love to receive contributions from our community — you! There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into the application itself. ## New Contributor Guide To get an overview of the project, read the [README](https://github.com/mlx-chat/mlx-chat-app/blob/main/README.md). Here are some resources to help you get started with open source contributions: - [Ways to contribute on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github) - [Setup Git](https://docs.github.com/en/get-started/quickstart/set-up-git) - [GitHub workflow](https://docs.github.com/en/get-started/quickstart/github-flow) - [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests) ## Getting Started ### Issues **Create**: If you spot a problem, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue! **Solve**: Scan through our [existing issues](https://github.com/mlx-chat/mlx-chat-app) to find one that interests you. If you find an issue to work on, you are welcome to assign it to yourself and open a PR with a fix. ### Make Changes 1. Create your own fork of the code 2. Create a working branch and start with your changes 3. Commit and send a pull request ### Pull Request When you're finished with the changes, create a pull request. - Check to see your pull request passes our continuous integration (CI). If you cannot get a certain integration test to pass, let us know. We can assist you in fixing these issues or approve a merge manually. - Make sure your additions are properly documented! - Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one. - We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. - As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). - If you run into any merge issues, checkout this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to help you resolve merge conflicts and other issues. ### Your PR is Merged! Congratulations :tada::tada: we thank you! :sparkles: Once your PR is merged, your contributions will be publicly visible in the [Chat with MLX Repository](https://github.com/mlx-chat/mlx-chat-app). ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 MLX Chat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![](docs/design-logo-light.png#gh-light-mode-only) ![](docs/design-logo-dark.png#gh-dark-mode-only) **Chat with MLX** is a high-performance macOS application that connects your local documents to a personalized large language model (LLM). By leveraging retrieval-augmented generation (RAG), open source LLMs, and MLX for accelerated machine learning on Apple silicon, you can efficently search, query, and interact with your documents *without information ever leaving your device.* Our high-level features include: - **Query**: load and search with document-specific prompts - **Converse**: switch model interaction modes (converse vs. assist) in real time - **Instruct**: provide personalization and response tuning ## Installation and Setup :warning: **Preliminary Steps**: we are working to release with correct packaging ([pyinstaller](https://github.com/pyinstaller/pyinstaller/) & [electron-builder](https://github.com/electron-userland/electron-builder)) and authentication ([Apple codesign](https://developer.apple.com/support/code-signing/)). In the interium, please clone and run in development by first setting up authentication and requirements. First, setup huggingface [access tokens](https://huggingface.co/settings/tokens) to download models (request access to [google/gemma-7b-it](https://huggingface.co/google/gemma-7b-it)), then ```bash huggingface-cli login ``` Then download the npm/python requirements ```bash cd app && npm install pip install -r server/requirements.txt ``` Finally, start the application ```bash cd app && npm run dev ``` ## Contributions All contributions are welcome. Please take a look at [contributing](CONTRIBUTING.md) guide. ================================================ FILE: app/.eslintrc.cjs ================================================ const namingConventions = [ 'error', { format: ['camelCase'], selector: 'default', }, { format: ['camelCase', 'UPPER_CASE'], selector: 'variable', }, { format: ['camelCase', 'UPPER_CASE', 'PascalCase'], modifiers: ['const', 'exported', 'global'], selector: 'variable', }, { format: ['camelCase'], leadingUnderscore: 'allow', selector: 'parameter', }, { format: ['camelCase'], leadingUnderscore: 'allow', modifiers: ['private'], selector: 'memberLike', }, { format: ['PascalCase', 'UPPER_CASE'], selector: ['enum', 'enumMember'], }, { format: ['PascalCase'], selector: 'typeLike', }, { format: null, modifiers: ['destructured'], selector: 'variable', }, { format: null, modifiers: ['requiresQuotes'], selector: [ 'classProperty', 'objectLiteralProperty', 'typeProperty', 'classMethod', 'objectLiteralMethod', 'typeMethod', 'accessor', 'enumMember', ], }, { format: ['camelCase', 'PascalCase', 'UPPER_CASE'], leadingUnderscore: 'allow', selector: 'import', }, ]; const tsxNamingConventions = [ { format: ['camelCase', 'PascalCase', 'UPPER_CASE'], leadingUnderscore: 'forbid', modifiers: ['global'], selector: ['variable', 'function'], }, ]; module.exports = { env: { es2020: true, }, extends: [ 'airbnb-base', 'plugin:jsdoc/recommended', 'plugin:@typescript-eslint/recommended', 'plugin:import/typescript', 'plugin:no-unsanitized/DOM', ], ignorePatterns: [ 'node_modules', 'main', '.eslintrc.*', 'out', ], overrides: [ // Config files { files: [ 'common/**/*.ts*', '**/app*config.ts', '**/app*Config.ts', ], rules: { '@typescript-eslint/member-ordering': ['error', { default: { order: 'alphabetically' } }], 'sort-keys': ['error', 'asc', { minKeys: 2, natural: true }], }, }, { files: [ '*.ts*', ], rules: { '@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-unused-vars': 'off', // Using unused-imports plugin instead '@typescript-eslint/space-before-function-paren': [ 'error', { anonymous: 'never', asyncArrow: 'always', named: 'never', }, ], 'no-redeclare': 'off', // @typescript-eslint/no-redeclare is enabled and is more correct 'no-shadow': 'off', // @typescript-eslint/no-shadow is enabled and is more correct 'no-undef-init': 'off', 'no-unused-vars': 'off', // Using unused-imports plugin instead 'space-before-function-paren': 'off', // Using @typescript-eslint/space-before-function-paren instead 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': ['error', { args: 'after-used', argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_', ignoreRestSiblings: true, }], }, }, { files: [ 'src/**/*.ts*', ], rules: { '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/dot-notation': ['error', { allowIndexSignaturePropertyAccess: true }], '@typescript-eslint/no-base-to-string': ['error', { ignoredTypeNames: ['Error', 'RegExp'], }], '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-for-in-array': 'error', '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], '@typescript-eslint/no-throw-literal': 'error', '@typescript-eslint/no-unnecessary-condition': 'error', '@typescript-eslint/no-unnecessary-type-assertion': 'error', '@typescript-eslint/non-nullable-type-assertion-style': 'error', '@typescript-eslint/prefer-includes': 'error', '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/prefer-string-starts-ends-with': 'error', '@typescript-eslint/require-await': 'error', '@typescript-eslint/space-infix-ops': 'error', 'dot-notation': 'off', 'no-throw-literal': 'off', 'require-await': 'off', 'space-infix-ops': 'off', }, }, { files: [ '*.tsx', ], rules: { '@typescript-eslint/naming-convention': [ ...namingConventions, ...tsxNamingConventions, ], '@typescript-eslint/require-await': 'error', 'require-await': 'off', }, }, ], parser: '@typescript-eslint/parser', parserOptions: { project: 'tsconfig.json', }, plugins: [ 'react', '@typescript-eslint', 'jest-formatting', 'modules-newlines', 'unused-imports', ], root: true, rules: { '@typescript-eslint/ban-types': [ 'error', { extendDefaults: true, types: { object: { message: [ 'The `object` type is currently hard to use ([see this issue](https://github.com/microsoft/TypeScript/issues/21732)).', 'Consider using `Record` instead, as it allows you to more easily inspect and use the keys.', ].join('\n'), }, }, }, ], 'implicit-arrow-linebreak': 'off', '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }], '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/init-declarations': 'error', '@typescript-eslint/member-ordering': 'error', '@typescript-eslint/naming-convention': namingConventions, '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', '@typescript-eslint/no-use-before-define': ['error', { functions: false, }], '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/type-annotation-spacing': 'error', 'array-element-newline': ['error', 'consistent'], 'block-spacing': 'off', camelcase: 'off', // Using @typescript-eslint/naming-convention instead. 'comma-dangle': 'off', 'default-param-last': 'off', 'import/extensions': 'off', 'import/no-relative-packages': 'off', 'import/order': 'off', 'import/prefer-default-export': 'off', 'jsdoc/check-indentation': ['error', { excludeTags: ['description', 'example'] }], 'jsdoc/check-line-alignment': 'error', 'jsdoc/check-tag-names': ['error', { definedTags: ['jest-environment', 'jest-environment-options'], }], 'jsdoc/no-bad-blocks': 'error', 'jsdoc/no-multi-asterisks': 'off', 'jsdoc/no-undefined-types': 'off', 'jsdoc/require-jsdoc': 'off', 'jsdoc/require-param': 'off', 'jsdoc/require-param-description': 'off', 'jsdoc/require-param-name': 'off', 'jsdoc/require-param-type': 'off', 'jsdoc/require-property': 'off', 'jsdoc/require-property-description': 'off', 'jsdoc/require-property-name': 'off', 'jsdoc/require-property-type': 'off', 'jsdoc/require-returns': 'off', 'jsdoc/require-returns-description': 'off', 'jsdoc/require-returns-type': 'off', 'jsdoc/require-yields': 'off', 'jsdoc/require-yields-check': 'off', 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], 'max-classes-per-file': 'off', 'max-len': ['error', { code: 100, ignorePattern: '(/* eslint |eslint-disable-next-line |@ts-expect-error )', ignoreRegExpLiterals: true, ignoreStrings: true, ignoreTemplateLiterals: true, ignoreUrls: true, }], 'max-params': ['error', 3], 'new-cap': [ 'error', { capIsNew: true, capIsNewExceptions: [ 'express.Router', 'Immutable.Map', 'Immutable.Set', 'Immutable.List', 'RightRailView', 'URLWithSearchParams', ], newIsCap: true, newIsCapExceptions: [], properties: true, }, ], 'no-console': 'error', 'no-continue': 'off', 'no-empty-function': 'off', 'no-promise-executor-return': 'off', 'no-redeclare': 'error', 'no-restricted-properties': [ 'error', ], 'no-restricted-syntax': [ 'error', ], 'no-use-before-define': 'off', 'no-void': ['error', { allowAsStatement: true }], 'padding-line-between-statements': [ 'error', { blankLine: 'never', next: 'import', prev: 'import' }, ], 'prefer-arrow-callback': ['error', { allowNamedFunctions: true }], 'prefer-exponentiation-operator': 'off', 'prefer-regex-literals': 'off', }, settings: { 'import/typescript': { typescript: {}, }, }, }; ================================================ FILE: app/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "main/splash/index.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils" } } ================================================ FILE: app/dprint.json ================================================ { "lineWidth": 100, "typescript": { "indentWidth": 2, "quoteStyle": "alwaysSingle", "semiColons": "always", "quoteProps": "asNeeded", "useBraces": "always", "trailingCommas": "onlyMultiLine", "module.sortImportDeclarations": "caseInsensitive", "exportDeclaration.forceMultiLine": true, "importDeclaration.forceMultiLine": true }, "json": { "jsonTrailingCommaFiles": [ ".vscode/launch.json", ".vscode/extensions.json", ".vscode/settings.json", ".vscode/tasks.json", "tsconfig.json" ] }, "excludes": [ "**/node_modules", "**/*-lock.json", "**/Dockerfile", "**/src/ui-tests/fixtures/**/*", "**/storybook-static/**/*", "**/build/**/*", "**/dist/**/*", "**/artifacts/**/*", "extension/src/assets/**/*.json" ], "plugins": [ "https://plugins.dprint.dev/typescript-0.88.3.wasm", "https://plugins.dprint.dev/json-0.19.0.wasm", "https://plugins.dprint.dev/dockerfile-0.3.0.wasm" ] } ================================================ FILE: app/mac/entitlements.mac.inherit.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation ================================================ FILE: app/main/main.ts ================================================ // Main File for Electron import { exec, execFile, } from 'child_process'; import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeImage, Tray, } from 'electron'; import * as contextMenu from 'electron-context-menu'; import Store from 'electron-store'; import * as net from 'net'; const path = require('path'); const serve = require('electron-serve'); const { spawn } = require('child_process'); function handleSetTitle(event: any, title: string) { const webContents = event.sender; const win = BrowserWindow.fromWebContents(webContents); if (win !== null) { win.setTitle(title); } } // Python Server class ServerManager { private serverProcess: any | null = null; public port: number | null = null; private findOpenPort(startingPort: number): Promise { return new Promise((resolve) => { const server = net.createServer(); server.listen(startingPort, () => { const port = (server.address() as net.AddressInfo).port; server.close(() => resolve(port)); }); server.on( 'error', (err: any) => err.code === 'EADDRINUSE' && resolve(this.findOpenPort(startingPort + 1)), ); }); } private runPythonServer(port: number): any { const args = ['--host 127.0.0.1', `--port ${port}`]; const modifiedArgs = args.flatMap(arg => arg.split(/\s+/)); const pythonProcess = isProd ? execFile(path.join(process.resourcesPath, 'server', 'runner'), modifiedArgs) : spawn('python', ['-m', 'server.server', ...modifiedArgs], { cwd: '../', }); pythonProcess.stdout.on( 'data', (data: Buffer) => console.log('Server output:', data.toString('utf8')), ); pythonProcess.stderr.on( 'data', (data: Buffer) => console.log(`Server error: ${data.toString('utf8')}`), ); return pythonProcess; } start(model: string): Promise { return new Promise((resolve, reject) => { this.stop(); this.findOpenPort(8080).then((port) => { this.port = port; console.log(`APP: Starting server for model: ${model} on port: ${port}`); this.serverProcess = this.runPythonServer(port); this.serverProcess.stdout.on('data', async (data: Buffer) => { const output = data.toString('utf8'); await new Promise((resolve) => setTimeout(resolve, 1000)); // Check if the server is ready if (output.includes('Starting httpd')) { fetch(`http://127.0.0.1:${port}/api/init`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ model }), }).then(() => { resolve(); // Resolve the promise when the server is ready }).catch((err) => { console.error('Error initializing the server:', err); reject(err); }); } }); this.serverProcess.on('close', (code: number | null) => { console.log(`Server process exited with code ${code}`); this.serverProcess = null; }); this.serverProcess.on('error', (err: any) => { console.error(`Error in server process: ${err}`); this.serverProcess = null; reject(err); }); }); }); } stop(): void { if (this.serverProcess) { console.log('Stopping the server...'); this.serverProcess.kill(); this.serverProcess = null; } } } // Loading Screen let splash: BrowserWindow | null; const createSplashScreen = () => { /// create a browser window splash = new BrowserWindow( { width: 200, height: 100, focusable: false, /// remove the window frame, so it will become a frameless window frame: false, skipTaskbar: true, autoHideMenuBar: true, }, ); splash.setResizable(false); splash.loadURL(`file://${__dirname}/../splash/index.html`); splash.on('closed', () => (splash = null)); splash.webContents.on('did-finish-load', () => { if (splash) { splash.show(); } }); }; // run renderer const isProd = process.env.NODE_ENV !== 'development'; if (isProd) { serve({ directory: 'out' }); } else { app.setPath('userData', `${app.getPath('userData')} (development)`); } contextMenu.default({ showInspectElement: !isProd, }); let openModal: 'settings' | 'directory' | null = null; let globalWindow: BrowserWindow | null = null; const triggerShortcut = () => { if (openModal || !globalWindow) { return; } if (globalWindow.isFocused()) { globalWindow.blur(); return; } globalWindow.show(); }; const store = new Store({ schema: { keybind: { type: 'string', default: 'Cmd+O', }, model: { type: 'string', default: 'mistralai/Mistral-7B-Instruct-v0.2', }, personalization: { type: 'string', default: '', }, customResponse: { type: 'string', default: '', }, }, }); const serverManager = new ServerManager(); const createWindow = () => { const icon = nativeImage.createFromPath( !isProd ? '../assets/IconTemplate.png' : path.join(process.resourcesPath, 'IconTemplate.png'), ); // if you want to resize it, be careful, it creates a copy const trayIcon = icon.resize({ width: 16 }); // here is the important part (has to be set on the resized version) trayIcon.setTemplateImage(true); let tray = new Tray(trayIcon); tray.setTitle(isProd ? '' : 'M'); const win = new BrowserWindow({ webPreferences: { preload: path.join(__dirname, 'preload.js'), devTools: !isProd, }, show: false, width: 600, height: 99, resizable: false, type: 'panel', frame: false, skipTaskbar: true, autoHideMenuBar: true, vibrancy: 'under-window', // on MacOS backgroundMaterial: 'acrylic', icon: __dirname + '../../assets/public/icon.icns', }); globalWindow = win; win.setWindowButtonVisibility(false); win.setAlwaysOnTop(true, 'floating'); win.setVisibleOnAllWorkspaces(true); // Expose URL if (isProd) { win.loadURL('app://./home.html'); } else { // const port = process.argv[2]; win.loadURL('http://localhost:3000/'); } tray.addListener('click', () => { if (win.isFocused()) { win.blur(); return; } win.show(); }); win.webContents.on('did-finish-load', async () => { await serverManager.start(store.get('model') as string); /// then close the loading screen window and show the main window if (splash) { splash.close(); } app.dock.hide(); win.show(); globalShortcut.register(store.get('keybind') as string, triggerShortcut.bind(null)); }); // @ts-expect-error -- We don't have types for electron win.on('blur', (event) => { if (openModal) { win.setAlwaysOnTop(false); } if (openModal === 'directory') { return; } if (win.webContents.isDevToolsOpened()) { return; } globalShortcut.unregister('Escape'); globalShortcut.unregister('Cmd+Q'); win.hide(); if (openModal) { return; } Menu.sendActionToFirstResponder('hide:'); }); win.on('focus', () => { globalShortcut.register('Cmd+Q', () => { if (!win.isFocused()) { return; } app.quit(); }); globalShortcut.register('Escape', () => { if (!win.isFocused()) { return; } win.blur(); }); }); let settingsModal: BrowserWindow | null = null; const createSettings = () => { settingsModal = new BrowserWindow({ webPreferences: { preload: path.join(__dirname, 'preload.js'), }, width: 500, height: 500, resizable: false, minimizable: false, titleBarStyle: 'hidden', show: false, backgroundColor: '#000', }); if (isProd) { settingsModal.loadURL('app://./settings.html'); } else { // const port = process.argv[2]; settingsModal.loadURL('http://localhost:3000/settings'); } settingsModal.on('closed', () => { openModal = null; settingsModal?.destroy(); settingsModal = null; }); settingsModal.on('ready-to-show', () => { settingsModal?.show(); }); return settingsModal; }; const nativeMenus: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ { label: 'MLX Chat', submenu: [ { label: 'Settings', click() { openModal = 'settings'; if (settingsModal !== null) { settingsModal.close(); } createSettings(); }, accelerator: 'Cmd+,', }, ], }, { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'pasteAndMatchStyle' }, { role: 'delete' }, { role: 'selectAll' }, { type: 'separator' }, { label: 'Speech', submenu: [ { role: 'startSpeaking' }, { role: 'stopSpeaking' }, ], }, ], }, ]; const menu = Menu.buildFromTemplate(nativeMenus); Menu.setApplicationMenu(menu); }; app.whenReady().then(() => { ipcMain.on('set-title', handleSetTitle); ipcMain.on('select-directory', (event: any) => { openModal = 'directory'; dialog.showOpenDialog({ properties: ['openDirectory'] }).then((result: any) => { const win = BrowserWindow.fromWebContents(event.sender); // Weird hack to bring the window to the front after allowing windows in front of it win?.setAlwaysOnTop(true, 'floating'); openModal = null; event.sender.send('selected-directory', result.filePaths); }); }); ipcMain.on('resize-window', (event, arg) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) { return; } win.setBounds({ height: arg.height, }); win.center(); }); ipcMain.on('fetch-setting', (event, arg) => { event.returnValue = store.get(arg); }); ipcMain.on('update-setting', (_event, arg) => { if (arg.key === 'keybind') { globalShortcut.unregister(store.get('keybind') as string); globalShortcut.register(arg.value, triggerShortcut.bind(null)); } store.set(arg.key, arg.value); }); createSplashScreen(); setTimeout(() => { createWindow(); }, 500); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('will-quit', () => { exec( `lsof -i :${serverManager.port} -P | awk 'NR>1 {print $2}' | xargs kill`, (err, stdout, stderr) => { if (err) { console.log(err); return; } console.log(`stdout: ${stdout}`); console.log(`stderr: ${stderr}`); }, ); BrowserWindow.getAllWindows().forEach((win) => { win.close(); win.destroy(); }); }); ================================================ FILE: app/main/preload.ts ================================================ // eslint-disable-next-line import/no-extraneous-dependencies import { contextBridge, ipcRenderer, } from 'electron'; export const electronAPI = { setTitle: (title: string) => ipcRenderer.send('set-title', title), selectDirectory: () => ipcRenderer.send('select-directory'), onSelectDirectory: (cb: (customData: string[]) => void) => { ipcRenderer.on('selected-directory', (event, customData) => { // eslint-disable-next-line no-console console.log(event); cb(customData); }); }, resizeWindow: (height: number) => ipcRenderer.send('resize-window', { height }), fetchSetting: (key: string) => ipcRenderer.sendSync('fetch-setting', key), updateSetting: (key: string, value: any) => ipcRenderer.send('update-setting', { key, value }), }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); ================================================ FILE: app/main/renderer.d.ts ================================================ import { electronAPI } from "./preload"; declare global { interface Window { electronAPI: typeof electronAPI; } } export {}; ================================================ FILE: app/main/splash/index.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; div { -webkit-user-select: none; -webkit-app-region: drag; } .loading-bar { display: block; height: 0.2em; background-color: rgba(255, 255, 255, 0.2); position: relative; overflow: hidden; border-radius: 1rem; } .loading-bar:before { content: ""; display: block; position: absolute; left: -100%; width: 100%; height: 100%; background-color: white; animation: loading-bar 1.5s ease-in-out infinite; } @keyframes loading-bar { from { left: -100%; } to { left: 100%; } } ================================================ FILE: app/main/splash/index.html ================================================ FLOATING LOADING SCREEN
================================================ FILE: app/main/tsconfig.json ================================================ { "compilerOptions": { "allowJs": true, "alwaysStrict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "jsx": "preserve", "lib": ["dom", "es2017"], "module": "commonjs", "moduleResolution": "node", "noEmit": false, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, "skipLibCheck": true, "strict": true, "target": "esnext", "outDir": "./out", }, "compileOnSave": true, "exclude": ["node_modules", "./out/**/*"], "include": ["**/*.ts", "**/*.tsx", "**/*.js", "public/**.icns"], } ================================================ FILE: app/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { output: "export", distDir: "out", }; module.exports = nextConfig; ================================================ FILE: app/notarize.js ================================================ require('dotenv').config(); const { notarize } = require('electron-notarize'); exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context; if (electronPlatformName !== 'darwin') { return; } const appName = context.packager.appInfo.productFilename; return await notarize({ appBundleId: 'com.parkersmith.mlx-chat', appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLEID, appleIdPassword: process.env.APPLEIDPASS, }); }; ================================================ FILE: app/package.json ================================================ { "name": "electron-app", "productName": "Electron App", "version": "0.1.0", "private": true, "main": "main/out/main.js", "homepage": "./", "description": "My Next.js project", "author": "test", "scripts": { "dev": "cross-env NODE_ENV=development concurrently -k \"cross-env BROWSER=none npm run next:dev\" \"npm run electron:dev\"", "build": " npm run build:main && next build", "start": "cross-env npm run electron", "build:tailwindMain": "npx tailwindcss build --config tailwind.config.main.js -o ./main/tailwind.css", "build:main": "tsc -p main && npm run build:tailwindMain", "next:dev": "next dev", "next:start": "next start", "next:lint": "next lint", "electron:dev": "npm run build:main && wait-on tcp:3000 && electron .", "electron": "electron .", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", "lint": "npx eslint --max-warnings 0 --ext=.ts ." }, "dependencies": { "@electron/osx-sign": "^1.0.5", "@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "^0.2.0", "@matejmazur/react-katex": "^3.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^2.2.1", "@types/electron": "^1.6.10", "@types/node": "^20.6.0", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "autoprefixer": "^10.4.15", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "concurrently": "^8.2.1", "cross-env": "^7.0.3", "dprint": "^0.45.0", "electron-context-menu": "^3.6.1", "electron-serve": "^1.1.0", "electron-squirrel-startup": "^1.0.0", "electron-store": "^8.1.0", "eslint": "8.41.0", "eslint-config-next": "13.4.3", "markdown-to-jsx": "^7.4.1", "next": "13.4.3", "postcss": "^8.4.29", "react": "18.2.0", "react-dom": "18.2.0", "react-redux": "^9.1.0", "react-resizable-panels": "^2.0.11", "rxjs": "^7.8.1", "tailwind-merge": "^2.2.1", "tailwindcss": "^3.3.3", "tailwindcss-animate": "^1.0.7", "wait-on": "^7.0.1" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "dotenv": "^16.4.5", "dprint": "^0.45.0", "electron": "^26.2.0", "electron-builder": "^24.6.4", "electron-notarize": "^1.2.2", "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-compat": "^4.2.0", "eslint-plugin-jest": "^27.6.3", "eslint-plugin-jest-formatting": "^3.1.0", "eslint-plugin-jsdoc": "^48.0.6", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-justinanastos": "^1.3.1", "eslint-plugin-modules-newlines": "^0.0.7", "eslint-plugin-no-unsanitized": "^4.0.2", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-unused-imports": "^3.0.0", "typescript": "^5.2.2" }, "build": { "appId": "mlx-chat", "productName": "MLX Chat", "afterSign": "notarize.js", "win": { "target": [ "nsis" ] }, "nsis": { "oneClick": false, "perMachine": true, "allowToChangeInstallationDirectory": true, "uninstallDisplayName": "MLX Chat" }, "mac": { "category": "your.app.category.type", "target": [ "dmg" ], "gatekeeperAssess": false, "hardenedRuntime": true, "icon": "assets/icon.icns", "entitlements": "./mac/entitlements.mac.inherit.plist", "entitlementsInherit": "./mac/entitlements.mac.inherit.plist" }, "dmg": { "title": "MLX Chat Installer", "sign": false }, "extraFiles": [ { "from": "assets", "to": "resources", "filter": [ "**/*" ] }, { "from": "../dist", "to": "resources/server", "filter": [ "**/*" ] } ] } } ================================================ FILE: app/postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: app/src/AppProvider.tsx ================================================ 'use client'; import { useRef, } from 'react'; import { Provider, } from 'react-redux'; import type { AppStore, } from './lib/store'; import { makeStore, } from './lib/store'; export default function StoreProvider({ children, }: { children: React.ReactNode; }) { const storeRef = useRef(); if (!storeRef.current) { // Create the store instance the first time this renders storeRef.current = makeStore(); } return {children}; } ================================================ FILE: app/src/app/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; --destructive: 0 72.22% 50.59%; --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 5% 64.9%; --radius: 0.5rem; } @media screen and (prefers-color-scheme: dark) { :root { --background: 240 10% 3.9%; --foreground: 0 0% 98%; --card: 240 10% 3.9%; --card-foreground: 0 0% 98%; --popover: 240 10% 3.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 240 5.9% 10%; --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 240 3.7% 15.9%; --muted-foreground: 240 5% 64.9%; --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; } } } @layer base { * { @apply border-border; } body { @apply text-foreground; /* font-feature-settings: "rlig" 1, "calt" 1; */ font-synthesis-weight: none; text-rendering: optimizeLegibility; } } @layer utilities { .step { counter-increment: step; } .step:before { @apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background; @apply ml-[-50px] mt-[-4px]; content: counter(step); } } @media (max-width: 640px) { .container { @apply px-4; } } /* Update scrollbar when in dark mode */ @media screen and (prefers-color-scheme: dark) { ::-webkit-scrollbar-thumb { background-color: hsl(var(--muted)); border-radius: 5px; transition: all; } ::-webkit-scrollbar-thumb:hover { background-color: hsl(255, 4%, 20%); } } /* Update scrollbar when in light mode */ @media screen and (prefers-color-scheme: light) { ::-webkit-scrollbar-thumb { background-color: rgb(38 38 38); border-radius: 5px; transition: all; } ::-webkit-scrollbar-thumb:hover { background-color: hsl(255, 4%, 20%); } } ::-webkit-scrollbar { width: 7px; } ::-webkit-scrollbar-track { background-color: transparent; } html { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont; } ol, ul, menu { list-style: outside; margin: 0; padding-left: 20px; } .drag { -webkit-app-region: drag; } .no-drag { -webkit-app-region: no-drag; } ================================================ FILE: app/src/app/layout.tsx ================================================ 'use client'; import StoreProvider from '../AppProvider'; import './globals.css'; import '@fortawesome/fontawesome-svg-core/styles.css'; // Prevent fontawesome from adding its CSS since we did it manually above: import { config, } from '@fortawesome/fontawesome-svg-core'; import { TooltipProvider, } from '../components/ui/tooltip'; config.autoAddCss = false; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ================================================ FILE: app/src/app/page.tsx ================================================ 'use client'; import { faBan, faCheckCircle, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon, } from '@fortawesome/react-fontawesome'; import React, { useEffect, useState, } from 'react'; import Chat from '../components/chat/Chat'; import SelectDirectory from '../components/options/SelectDirectory'; import { Button, } from '../components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger, } from '../components/ui/tooltip'; import type { ChatMessage, } from '../constants/chat'; import { useAppDispatch, } from '../lib/hooks'; import { startDirectoryIndexing, stopDirectoryIndexing, } from '../lib/store'; export default function Home() { const [selectedDirectory, setSelectedDirectory] = useState(null); const [chatHistory, setChatHistory] = useState([]); const dispatch = useAppDispatch(); function handleOpen() { if (typeof window !== 'undefined') { window.electronAPI.selectDirectory(); } } useEffect(() => { window.electronAPI.onSelectDirectory(async (customData: string[]) => { setSelectedDirectory(customData[0]); try { dispatch(startDirectoryIndexing()); await fetch('http://localhost:8080/api/index', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ directory: customData[0], }), }); dispatch(stopDirectoryIndexing()); // TODO: spinner while indexing } catch (error) { // eslint-disable-next-line no-console console.error('Error sending message: ', error); dispatch(stopDirectoryIndexing()); } }); }, []); useEffect(() => { window.electronAPI.onSelectDirectory(() => { if (chatHistory.length) { setChatHistory([ ...chatHistory, { role: 'system', content: 'Assist' }, ]); } }); }, [chatHistory]); const handleClearHistory = () => { setChatHistory([]); if (typeof window !== 'undefined') { window.electronAPI.resizeWindow(99); } }; const clearDirectory = () => { setSelectedDirectory(null); if (chatHistory.length) { setChatHistory([ ...chatHistory, { role: 'system', content: 'Converse' }, ]); } }; return (
{chatHistory.length ? ( Clear History ) : ( )}
); } ================================================ FILE: app/src/app/settings/page.tsx ================================================ 'use client'; import type { IconProp, } from '@fortawesome/fontawesome-svg-core'; import { faCog, faMessage, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon, } from '@fortawesome/react-fontawesome'; import React, { useEffect, } from 'react'; import SelectModel from '../../components/options/SelectModel'; import { Textarea, } from '../../components/ui/textarea'; import { convertToNiceShortcut, useKeyboardShortcut, } from '../../lib/hooks'; import { cn, } from '../../lib/utils'; enum SETTINGS { GENERAL, PROMPTS, } function SettingsOption({ title, icon, onClick, selected, }: { title: string; icon: IconProp; onClick: () => void; selected?: boolean; }) { return (

{title}

); } function GeneralSettings() { const { startListening, stopListening, shortcut, } = useKeyboardShortcut(); const [keybind, setKeybind] = React.useState( typeof window !== 'undefined' ? window.electronAPI.fetchSetting('keybind') : '⌘O', ); const [model, setModel] = React.useState( typeof window !== 'undefined' ? window.electronAPI.fetchSetting('model') : 'mistralai/Mistral-7B-Instruct-v0.2', ); useEffect(() => { if (!shortcut) { return; } setKeybind(shortcut); }, [shortcut]); return (

Launch keybind:

{ stopListening(); if (typeof window !== 'undefined') { window.electronAPI.updateSetting('keybind', shortcut); } }} />

Default model:

{ setModel(selectedModel); if (typeof window !== 'undefined' && selectedModel) { window.electronAPI.updateSetting('model', selectedModel); } }} />
); } function PromptSettings() { const [personalization, setPersonalization] = React.useState( typeof window !== 'undefined' ? window.electronAPI.fetchSetting('personalization') : '', ); const [response, setResponse] = React.useState( typeof window !== 'undefined' ? window.electronAPI.fetchSetting('customResponse') : '', ); return (

Personalization