├── .editorconfig ├── .eslintrc.cjs ├── .github ├── actions │ ├── build │ │ └── action.yml │ └── tag │ │ ├── action.yml │ │ ├── index.js │ │ └── package.json └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── .stylelintrc ├── LICENSE ├── README-ja.md ├── README.md ├── assets ├── download.svg ├── icon-512x512.png └── screenshots │ ├── docs-installtion-download-01.png │ ├── gallery-01.png │ ├── gallery-02.png │ ├── webui-01.png │ ├── webui-02.png │ └── webui-03.png ├── docs ├── installation-ja.md └── installation.md ├── i18n ├── en.ts ├── index.ts ├── ja.ts └── types.ts ├── index.html ├── package.json ├── pnpm-lock.yaml ├── scripts └── build.js ├── src ├── __setup.ts ├── constants.ts ├── features │ ├── conda │ │ ├── index.ts │ │ └── ipc.ts │ ├── gallery │ │ ├── index.ts │ │ ├── ipc.ts │ │ ├── parse-png-meta.ts │ │ ├── png-meta.ts │ │ └── types.ts │ ├── git │ │ ├── index.ts │ │ └── ipc.ts │ ├── stable-diffusion-webui │ │ ├── index.ts │ │ └── ipc.ts │ └── system │ │ └── ipc │ │ ├── server.ts │ │ └── types.ts ├── index.ts ├── ipc │ ├── client.ts │ ├── index.ts │ └── server.ts ├── preload.ts ├── scripts │ └── webui │ │ ├── __inject_webui.js │ │ └── send_to_webui.js ├── types.ts ├── updater.ts └── web │ ├── app.tsx │ ├── components │ ├── dependencies │ │ ├── conda-install.tsx │ │ └── git-install.tsx │ ├── log.tsx │ ├── menu-button.tsx │ ├── modal.tsx │ ├── process.tsx │ ├── ui │ │ ├── auto-complete.tsx │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── icon-button.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── required.tsx │ │ ├── select.tsx │ │ ├── spinner.tsx │ │ ├── stack.tsx │ │ ├── tabs.tsx │ │ └── toast.tsx │ └── updater.tsx │ ├── global.css │ ├── hooks │ └── use-floating.tsx │ ├── index.tsx │ ├── lib │ ├── classnames.ts │ ├── config.ts │ ├── ipc.ts │ ├── node │ │ ├── electron.ts │ │ └── path.ts │ └── store-serialize.ts │ ├── pages │ ├── gallery │ │ ├── config.tsx │ │ ├── image.tsx │ │ ├── images.tsx │ │ └── index.tsx │ ├── general │ │ └── index.tsx │ └── webui │ │ ├── config.tsx │ │ ├── index.tsx │ │ ├── launcher.tsx │ │ ├── settings.tsx │ │ ├── ui-config.tsx │ │ └── ui.tsx │ └── styles.tsx ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | 9 | [*.{ts,js}] 10 | indent_size = 4 11 | [*.{tsx,jsx,json,yaml,yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'plugin:solid/recommended', 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:import/recommended', 12 | 'plugin:import/typescript', 13 | 'prettier', 14 | ], 15 | plugins: ['unused-imports', 'solid'], 16 | rules: { 17 | 'no-empty': 'off', 18 | '@typescript-eslint/ban-types': 'off', 19 | '@typescript-eslint/no-var-requires': 'off', 20 | '@typescript-eslint/no-empty-interface': 'off', 21 | '@typescript-eslint/no-unused-vars': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-non-null-assertion': 'off', 24 | '@typescript-eslint/no-empty-function': 'off', 25 | '@typescript-eslint/no-namespace': 'off', 26 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', 27 | 'import/no-unresolved': 'off', 28 | 'unused-imports/no-unused-imports': 'error', 29 | 'unused-imports/no-unused-vars': [ 30 | 'warn', 31 | { 32 | vars: 'all', 33 | varsIgnorePattern: '^_', 34 | args: 'all', 35 | argsIgnorePattern: '^_', 36 | }, 37 | ], 38 | 'import/order': [ 39 | 'error', 40 | { 41 | alphabetize: { 42 | order: 'asc', 43 | }, 44 | groups: [['builtin', 'external', 'internal'], ['parent', 'sibling', 'index'], ['object']], 45 | 'newlines-between': 'always', 46 | }, 47 | ], 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /.github/actions/build/action.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | description: Build the application 3 | 4 | runs: 5 | using: 'composite' 6 | steps: 7 | - uses: actions/setup-node@v2 8 | with: 9 | node-version: '18' 10 | 11 | - name: Setup pnpm 12 | shell: bash 13 | run: npm i -g pnpm 14 | 15 | - name: PNPM install 16 | shell: bash 17 | run: pnpm i 18 | 19 | - name: Build application 20 | shell: bash 21 | run: pnpm build 22 | -------------------------------------------------------------------------------- /.github/actions/tag/action.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | description: Get current tag 3 | outputs: 4 | tag: 5 | description: Current tag 6 | runs: 7 | using: 'node16' 8 | main: 'index.js' 9 | -------------------------------------------------------------------------------- /.github/actions/tag/index.js: -------------------------------------------------------------------------------- 1 | import core from '@actions/core' 2 | import github from '@actions/github' 3 | 4 | const tag = github.context.ref.replace('refs/tags/', '') 5 | core.setOutput('tag', tag) 6 | -------------------------------------------------------------------------------- /.github/actions/tag/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actions-tag", 3 | "type": "module", 4 | "dependencies": { 5 | "@actions/core": "^1.8.2", 6 | "@actions/exec": "^1.1.1", 7 | "@actions/github": "^5.0.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Build the application 19 | uses: ./.github/actions/build 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Build the application 19 | uses: ./.github/actions/build 20 | 21 | - name: Setup actions 22 | run: pnpm i 23 | working-directory: ./.github/actions/tag 24 | 25 | - name: Get current tag 26 | uses: ./.github/actions/tag 27 | id: get_tag 28 | 29 | - name: Release 30 | uses: softprops/action-gh-release@v1 31 | 32 | if: startsWith(github.ref, 'refs/tags/') 33 | with: 34 | files: | 35 | product/flat-${{ steps.get_tag.outputs.tag }}.zip 36 | product/flat-${{ steps.get_tag.outputs.tag }}.exe 37 | product/app.zip 38 | product/meta.yaml 39 | body: |- 40 | # Flat ${{ steps.get_tag.outputs.tag }} 41 | ## Download 42 | - [Windows x64 Installer](https://github.com/ddPn08/flat/releases/download/${{ steps.get_tag.outputs.tag }}/flat-${{ steps.get_tag.outputs.tag }}.exe) 43 | - [Windows x64 Potable](https://github.com/ddPn08/flat/releases/download/${{ steps.get_tag.outputs.tag }}/flat-${{ steps.get_tag.outputs.tag }}.zip) 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | !.yarn/patches 3 | !.yarn/plugins 4 | !.yarn/releases 5 | !.yarn/sdks 6 | !.yarn/versions 7 | 8 | # Swap the comments on the following lines if you don't wish to use zero-installs 9 | # Documentation here: https://yarnpkg.com/features/zero-installs 10 | !.yarn/cache 11 | #.pnp.* 12 | 13 | node_modules 14 | dist 15 | product 16 | 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | trailingComma: all 4 | printWidth: 100 5 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | extends: 2 | - stylelint-config-standard 3 | - stylelint-config-idiomatic-order 4 | - stylelint-config-prettier 5 | rules: 6 | alpha-value-notation: number 7 | color-function-notation: legacy 8 | function-no-unknown: null 9 | function-name-case: null 10 | function-whitespace-after: null 11 | font-family-no-missing-generic-family-keyword: null 12 | value-keyword-case: null 13 | overrides: 14 | - files: 15 | - 'src/**/*.{ts,tsx}' 16 | customSyntax: '@stylelint/postcss-css-in-js' 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ddPn08 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 | -------------------------------------------------------------------------------- /README-ja.md: -------------------------------------------------------------------------------- 1 |

flat

2 |
3 |

AI画像生成のオールインワン (になるはず...)

4 | 5 |
6 |
7 | 8 | --- 9 | 10 |
11 | 12 | [日本語](./README-ja.md) 13 | 14 | [English](./README.md) 15 | 16 |
17 | 18 | --- 19 | 20 | > **Warning** 21 | > 22 | > 🚧 これはベータリリースです。予期せぬエラーが発生する可能性があります。 23 | > 24 | > エラーが発生した場合は開発者にお伝えください。 25 | 26 | # インストール方法 27 | 28 | 👇 インストール方法はこちらをご覧ください。 29 | 30 | [installation-ja.md](/docs/installation-ja.md) 31 | 32 |
33 | 34 | # 機能 35 | 36 | ## AUTOMATIC1111 Stable Diffusion Webui 37 | 38 | 数クリックで StableDiffusionWebUI を起動できます。 39 | 40 | Python のインストールや Git の操作は必要ありません。 41 | 42 |
43 | 44 | ![](./assets/screenshots/webui-01.png) 45 | ![](./assets/screenshots/webui-02.png) 46 | ![](./assets/screenshots/webui-03.png) 47 | 48 |
49 | 50 | ## Image gallery 51 | 52 | 生成した画像をプロンプト等の情報とともに一覧に表示します。 53 | 54 | 画像のフォルダは自由に設定できます。 55 | 56 | ![](./assets/screenshots/gallery-01.png) 57 | ![](./assets/screenshots/gallery-02.png) 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

flat

2 |
3 |

All-in-one image generation AI (to be...)

4 | 5 |
6 |
7 | 8 | --- 9 | 10 |
11 | 12 | [日本語](./README-ja.md) 13 | 14 | [English](./README.md) 15 | 16 |
17 | 18 | --- 19 | 20 | > **Warning** 21 | > 22 | > 🚧 This is a beta release. Unexpected errors may occur. 23 | > 24 | > If you get an error, please let the developer know. 25 | 26 | # How to install 27 | 28 | 👇Click here for installation instructions. 29 | 30 | [installation.md](/docs/installation.md) 31 | 32 | # Features 33 | 34 | ## AUTOMATIC1111 Stable Diffusion Webui 35 | 36 | Launch StableDiffusionWebUI with just a few clicks. 37 | 38 | No Python installation or repository cloning required! 39 | 40 |
41 | 42 | ![](./assets/screenshots/webui-01.png) 43 | ![](./assets/screenshots/webui-02.png) 44 | ![](./assets/screenshots/webui-03.png) 45 | 46 | ## Image gallery 47 | 48 | Displays generated images in a list with information such as prompts. 49 | The image folder can be set freely. 50 | 51 | ![](./assets/screenshots/gallery-01.png) 52 | ![](./assets/screenshots/gallery-02.png) 53 | -------------------------------------------------------------------------------- /assets/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/icon-512x512.png -------------------------------------------------------------------------------- /assets/screenshots/docs-installtion-download-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/screenshots/docs-installtion-download-01.png -------------------------------------------------------------------------------- /assets/screenshots/gallery-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/screenshots/gallery-01.png -------------------------------------------------------------------------------- /assets/screenshots/gallery-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/screenshots/gallery-02.png -------------------------------------------------------------------------------- /assets/screenshots/webui-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/screenshots/webui-01.png -------------------------------------------------------------------------------- /assets/screenshots/webui-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/screenshots/webui-02.png -------------------------------------------------------------------------------- /assets/screenshots/webui-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/assets/screenshots/webui-03.png -------------------------------------------------------------------------------- /docs/installation-ja.md: -------------------------------------------------------------------------------- 1 | # インストール方法 2 | 3 | ## 1. リリースページを開く 4 | 5 | 👇 のリンクを開いてリリースページを開いてください。 6 | 7 | https://github.com/ddPn08/flat/releases/latest 8 | 9 | ## 2. ダウンロード 10 | 11 | ソフトウェアをダウンロードします。 12 | ![](/assets/screenshots/docs-installtion-download-01.png) 13 | 14 | インストーラー版を利用したい場合は、`flat-[バージョン名].exe`を、ポータブル版を利用したい場合は、`flat-[バージョン名].zip`をダウンロードしてください。 15 | 16 | あんまり有名なソフトウェアじゃないためブラウザから「危険だ!」と警告が出る場合があります。特に危ない実装はしていないので信頼していただける場合は続行してください。 17 | 18 | ## 3. インストール 19 | 20 | ### インストーラー版の場合 21 | 22 | ダウンロードした`flat-[バージョン名].exe`を実行してください。 23 | この時にも Windows が警告を出す場合があります。 24 | 25 | ### ポータブル版の場合 26 | 27 | ダウンロードした`flat-[バージョン名].zip`を展開してください。 28 | 展開先のフォルダにある`flat.exe`を実行してください。 29 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # How to install 2 | 3 | ## 1. Open the release page 4 | 5 | Please click the 👇 link to open the release page. 6 | 7 | https://github.com/ddPn08/flat/releases/latest 8 | 9 | ## 2. Download it 10 | 11 | Download the software. 12 | 13 | ![](/assets/screenshots/docs-installtion-download-01.png) 14 | 15 | If you want to use the installer version, download `flat-[version name].exe`, and if you want to use the portable version, download `flat-[version name].zip`. 16 | 17 | Since this is not a very popular software, you may get a warning from your browser saying "This is dangerous! There is no particularly dangerous implementation, so if you trust it, please proceed. 18 | 19 | ## 3. Installing 20 | 21 | ### Installer version 22 | 23 | Run the downloaded `flat-[version name].exe'. 24 | Windows may also display a warning at this point. 25 | 26 | ### For portable version 27 | 28 | Extract the downloaded `flat-[version name].zip`. 29 | Run `flat.exe` in the extracted folder. 30 | -------------------------------------------------------------------------------- /i18n/en.ts: -------------------------------------------------------------------------------- 1 | import type { LangConfig } from './types' 2 | 3 | export const en: LangConfig = { 4 | 'context-menu/labels/copy': 'copy', 5 | 'context-menu/labels/copyImage': 'copyImage', 6 | 'context-menu/labels/copyImageAddress': 'copyImageAddress', 7 | 'context-menu/labels/copyLink': 'copyLink', 8 | 'context-menu/labels/copyVideoAddress': 'copyVideoAddress', 9 | 'context-menu/labels/cut': 'cut', 10 | 'context-menu/labels/inspect': 'inspect', 11 | 'context-menu/labels/learnSpelling': 'learnSpelling', 12 | 'context-menu/labels/lookUpSelection': 'lookUpSelection', 13 | 'context-menu/labels/paste': 'paste', 14 | 'context-menu/labels/saveImage': 'saveImage', 15 | 'context-menu/labels/saveImageAs': 'saveImageAs', 16 | 'context-menu/labels/saveLinkAs': 'saveLinkAs', 17 | 'context-menu/labels/saveVideo': 'saveVideo', 18 | 'context-menu/labels/saveVideoAs': 'saveVideoAs', 19 | 'context-menu/labels/searchWithGoogle': 'searchWithGoogle', 20 | 'context-menu/labels/selectAll': 'selectAll', 21 | 'context-menu/labels/services': 'services', 22 | 23 | 'system/settings/lang': 'Language', 24 | 'system/settings/theme': 'Theme', 25 | 'system/update/latest': 'Is up to date', 26 | 'system/update/check': 'Check for updates', 27 | 'system/update/install': 'Install', 28 | 'system/update-installed/title': 'Installed the update.', 29 | 'system/update-installed/description': 'Please restart the app.', 30 | 'system/update-available/title': 'Update is available.', 31 | 'system/update-available/description': 'Please update from the General page.', 32 | 'system/git-install/title': 'Git is not installed.', 33 | 'system/git-install/description': 34 | 'Git must be installed to use this app. Would you like to install it?', 35 | 'system/git-install/button': 'Install Git', 36 | 'system/git-install-finish/title': 'Git is installed.', 37 | 'system/git-install-finish/description': 'You have to restart the app once.', 38 | 'system/git-install-error/title': 'An error occurred while installing Git.', 39 | 'system/git-install-error/description': 40 | 'Please restart the app and try again. \nIf the problem persists, please contact the developer.', 41 | 42 | 'general/app/description': 43 | 'flat is (will be) an all-in-one toolkit for image generation AI.\nAUTOMATIC1111 StableDiffusionWebUI and a gallery function that shows generated images in a list are provided.', 44 | 'general/app/repository': 'Github repository', 45 | 'general/app/report': 'Report bug', 46 | 47 | 'gallery/tabs/all': 'All', 48 | 'gallery/tabs/favorites': 'Favorites', 49 | 'gallery/config/paths': 'Directory to search for images', 50 | 'gallery/config/apply': 'Apply', 51 | 'gallery/open-folder/button': 'Open folder', 52 | 'gallery/send-to-webui/button': 'Send to WebUI', 53 | 'gallery/search/title': 'Search', 54 | 'gallery/search/prompt': 'Prompt', 55 | 'gallery/search/model': 'ModelName', 56 | 'gallery/search/button': 'Search', 57 | 58 | 'webui/launcher/install-success/title': 'Installation was successful.', 59 | 'webui/launcher/install-success/description': 'WebUI can be started.', 60 | 'webui/launcher/launched/title': 'WebUI started.', 61 | 'webui/launcher/not-installed/title': 'WebUI is not installed.', 62 | 'webui/launcher/not-installed/description': 63 | 'Do you want to install WebUI? (This may take several minutes.)', 64 | 'webui/launcher/not-installed/button': 'Install', 65 | 'webui/launcher/not-running/title': 'WebUI is not running.', 66 | 'webui/launcher/uninstall-env/button': 'UnInstall Environment', 67 | 'webui/launcher/uninstall-env/title': 'Uninstall your Python environment', 68 | 'webui/launcher/uninstall-env/description': 69 | 'WebUI files such as output images are not deleted.', 70 | 'webui/launcher/uninstall/button': 'Uninstall WebUI', 71 | 'webui/launcher/open-folder/button': 'Open webui folder', 72 | 73 | 'webui/launcher/config/commit': 'Commit hash (or Branch name)', 74 | 'webui/launcher/config/update': 'Update WebUI', 75 | 76 | 'webui/config/ckpt-dir': 'Checkpoints directory', 77 | 'webui/config/vae-dir': 'VAE directory', 78 | 'webui/config/hypernetwork-dir': 'Hypernetworks directory', 79 | 'webui/config/embeddings-dir': 'Embeddings directory', 80 | 'webui/config/xformers': 'Enable xformers', 81 | 'webui/config/custom': 'Custom arguments', 82 | 'webui/config/env': 'Environment variables', 83 | } 84 | -------------------------------------------------------------------------------- /i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { en } from './en' 2 | import { ja } from './ja' 3 | import type { LangConfig } from './types' 4 | 5 | export const dict: Record<'ja' | 'en', LangConfig> = { 6 | ja, 7 | en, 8 | } 9 | -------------------------------------------------------------------------------- /i18n/ja.ts: -------------------------------------------------------------------------------- 1 | import type { LangConfig } from './types' 2 | 3 | export const ja: LangConfig = { 4 | 'context-menu/labels/copy': 'コピー', 5 | 'context-menu/labels/copyImage': '画像をコピー', 6 | 'context-menu/labels/copyImageAddress': '画像のアドレスをコピー', 7 | 'context-menu/labels/copyLink': 'リンクのアドレスをコピー', 8 | 'context-menu/labels/copyVideoAddress': '動画のアドレスをコピー', 9 | 'context-menu/labels/cut': '切り取り', 10 | 'context-menu/labels/inspect': '検証', 11 | 'context-menu/labels/learnSpelling': 'スペルを学習', 12 | 'context-menu/labels/lookUpSelection': '選択されたテキストを検索', 13 | 'context-menu/labels/paste': '貼り付け', 14 | 'context-menu/labels/saveImage': '画像を保存', 15 | 'context-menu/labels/saveImageAs': '形式を指定して画像を保存', 16 | 'context-menu/labels/saveLinkAs': '形式を指定してリンクを保存', 17 | 'context-menu/labels/saveVideo': '動画を保存', 18 | 'context-menu/labels/saveVideoAs': '形式を指定して動画を保存', 19 | 'context-menu/labels/searchWithGoogle': 'Googleで検索', 20 | 'context-menu/labels/selectAll': 'すべて選択', 21 | 'context-menu/labels/services': 'サービス', 22 | 23 | 'system/settings/lang': '言語', 24 | 'system/settings/theme': 'テーマ', 25 | 'system/update/latest': '最新の状態です', 26 | 'system/update/check': '更新を確認する', 27 | 'system/update/install': 'インストール', 28 | 'system/update-installed/title': '更新をインストールしました。', 29 | 'system/update-installed/description': 'アプリを再起動してください。', 30 | 'system/update-available/title': 'アップデートの準備ができました。', 31 | 'system/update-available/description': 'Generalページから更新してください。', 32 | 'system/git-install/title': 'Gitがインストールされていません。', 33 | 'system/git-install/description': 34 | 'このアプリを利用するにはGitをインストールする必要があります。インストールしますか?', 35 | 'system/git-install/button': 'Gitをインストール', 36 | 'system/git-install-finish/title': 'Gitがインストールされました。', 37 | 'system/git-install-finish/description': 'アプリを一度再起動する必要があります。', 38 | 'system/git-install-error/title': 'Gitのインストール中にエラーが発生しました。', 39 | 'system/git-install-error/description': 40 | 'アプリを再起動して、再度試してください。\n解決しない場合は開発者にお問い合わせください。', 41 | 42 | 'general/app/description': 43 | 'flatは、画像生成AIのオールインワンツールキット(になる予定)です。\nAUTOMATIC1111 StableDiffusionWebUIや、生成画像を一覧で表示するギャラリー機能等が備わっています。', 44 | 'general/app/repository': 'Githubのリポジトリ', 45 | 'general/app/report': 'バグを報告', 46 | 47 | 'gallery/tabs/all': '全て', 48 | 'gallery/tabs/favorites': 'お気に入り', 49 | 'gallery/config/paths': '画像を検索するディレクトリ', 50 | 'gallery/config/apply': '適応', 51 | 'gallery/open-folder/button': 'フォルダを開く', 52 | 'gallery/send-to-webui/button': 'WebUIに送信', 53 | 'gallery/search/title': '検索', 54 | 'gallery/search/prompt': 'プロンプト', 55 | 'gallery/search/model': 'モデル名', 56 | 'gallery/search/button': '検索', 57 | 58 | 'webui/launcher/install-success/title': 'インストールに成功しました。', 59 | 'webui/launcher/install-success/description': 'WebUIを起動できます。', 60 | 'webui/launcher/launched/title': 'WebUIが起動しました。', 61 | 'webui/launcher/not-installed/title': 'WebUIがインストールされていません。', 62 | 'webui/launcher/not-installed/description': 63 | 'WebUIをインストールしますか?(これには数分かかる場合があります。)', 64 | 'webui/launcher/not-installed/button': 'インストールする', 65 | 'webui/launcher/not-running/title': 'WebUIは起動していません。', 66 | 'webui/launcher/uninstall-env/button': 'Python環境をアンインストール', 67 | 'webui/launcher/uninstall-env/title': 'Python環境をアンインストールします', 68 | 'webui/launcher/uninstall-env/description': '出力画像等の、WebUIのファイルは削除されません。', 69 | 'webui/launcher/uninstall/button': 'WebUIをアンインストール', 70 | 'webui/launcher/open-folder/button': 'WebUIのフォルダを開く', 71 | 72 | 'webui/launcher/config/commit': 'Commit hash (またはブランチ名)', 73 | 'webui/launcher/config/update': 'WebUIを更新', 74 | 75 | 'webui/config/ckpt-dir': 'モデルディレクトリ', 76 | 'webui/config/vae-dir': 'VAEディレクトリ', 77 | 'webui/config/hypernetwork-dir': 'Hypernetworksディレクトリ', 78 | 'webui/config/embeddings-dir': 'Embeddingsディレクトリ', 79 | 'webui/config/xformers': 'xformersを有効にする', 80 | 'webui/config/custom': 'コマンドライン引数', 81 | 'webui/config/env': "環境変数" 82 | } 83 | -------------------------------------------------------------------------------- /i18n/types.ts: -------------------------------------------------------------------------------- 1 | const LangKeys = [ 2 | 'context-menu/labels/copy', 3 | 'context-menu/labels/copyImage', 4 | 'context-menu/labels/copyImageAddress', 5 | 'context-menu/labels/copyLink', 6 | 'context-menu/labels/copyVideoAddress', 7 | 'context-menu/labels/cut', 8 | 'context-menu/labels/inspect', 9 | 'context-menu/labels/learnSpelling', 10 | 'context-menu/labels/lookUpSelection', 11 | 'context-menu/labels/paste', 12 | 'context-menu/labels/saveImage', 13 | 'context-menu/labels/saveImageAs', 14 | 'context-menu/labels/saveLinkAs', 15 | 'context-menu/labels/saveVideo', 16 | 'context-menu/labels/saveVideoAs', 17 | 'context-menu/labels/searchWithGoogle', 18 | 'context-menu/labels/selectAll', 19 | 'context-menu/labels/services', 20 | 21 | 'system/settings/lang', 22 | 'system/settings/theme', 23 | 'system/update/latest', 24 | 'system/update/check', 25 | 'system/update/install', 26 | 'system/update-installed/title', 27 | 'system/update-installed/description', 28 | 'system/update-available/title', 29 | 'system/update-available/description', 30 | 'system/git-install/title', 31 | 'system/git-install/description', 32 | 'system/git-install/button', 33 | 'system/git-install-finish/title', 34 | 'system/git-install-finish/description', 35 | 'system/git-install-error/title', 36 | 'system/git-install-error/description', 37 | 38 | 'general/app/description', 39 | 'general/app/repository', 40 | 'general/app/report', 41 | 42 | 'gallery/tabs/all', 43 | 'gallery/tabs/favorites', 44 | 'gallery/config/paths', 45 | 'gallery/config/apply', 46 | 'gallery/open-folder/button', 47 | 'gallery/send-to-webui/button', 48 | 'gallery/search/title', 49 | 'gallery/search/prompt', 50 | 'gallery/search/model', 51 | 'gallery/search/button', 52 | 53 | 'webui/launcher/install-success/title', 54 | 'webui/launcher/install-success/description', 55 | 'webui/launcher/launched/title', 56 | 'webui/launcher/not-installed/title', 57 | 'webui/launcher/not-installed/description', 58 | 'webui/launcher/not-installed/button', 59 | 'webui/launcher/not-running/title', 60 | 'webui/launcher/uninstall-env/button', 61 | 'webui/launcher/uninstall-env/title', 62 | 'webui/launcher/uninstall-env/description', 63 | 'webui/launcher/uninstall/button', 64 | 'webui/launcher/open-folder/button', 65 | 66 | 'webui/launcher/config/commit', 67 | 'webui/launcher/config/update', 68 | 69 | 'webui/config/ckpt-dir', 70 | 'webui/config/vae-dir', 71 | 'webui/config/embeddings-dir', 72 | 'webui/config/hypernetwork-dir', 73 | 'webui/config/xformers', 74 | 'webui/config/custom', 75 | 'webui/config/env', 76 | ] as const 77 | export type LangKeys = (typeof LangKeys)[number] 78 | export type LangConfig = Record 79 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | flat 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flat", 3 | "description": "All-in-one image generation AI (to be...)", 4 | "type": "module", 5 | "version": "0.1.26", 6 | "author": { 7 | "name": "ddpn08", 8 | "url": "https://me.ddpn.world" 9 | }, 10 | "main": "dist/index.cjs", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/ddPn08/flat" 14 | }, 15 | "files": [ 16 | "dist/**/*" 17 | ], 18 | "scripts": { 19 | "build": "node ./scripts/build.js", 20 | "dev": "cross-env NODE_ENV=\"development\" pnpm build --bundle-only --run", 21 | "eslint": "eslint src/**/*.{ts,tsx}", 22 | "stylelint": "stylelint src/**/*.{ts,tsx}", 23 | "prettier": "prettier src/**/*.{ts,tsx}", 24 | "format-all": "pnpm eslint --fix & pnpm stylelint --fix & pnpm prettier --write" 25 | }, 26 | "devDependencies": { 27 | "@iconify-json/material-symbols": "^1.1.26", 28 | "@stylelint/postcss-css-in-js": "^0.38.0", 29 | "@types/adm-zip": "^0.5.0", 30 | "@types/color": "^3.0.3", 31 | "@types/node": "^18.11.18", 32 | "@typescript-eslint/eslint-plugin": "^5.30.7", 33 | "@typescript-eslint/parser": "^5.30.7", 34 | "cross-env": "^7.0.3", 35 | "electron": "^22.0.2", 36 | "electron-builder": "^23.6.0", 37 | "esbuild": "^0.17.0", 38 | "esbuild-register": "^3.4.2", 39 | "eslint": "^8.32.0", 40 | "eslint-config-prettier": "^8.6.0", 41 | "eslint-import-resolver-typescript": "^3.5.3", 42 | "eslint-plugin-import": "^2.27.4", 43 | "eslint-plugin-solid": "^0.9.3", 44 | "eslint-plugin-unused-imports": "^2.0.0", 45 | "postcss-syntax": "^0.36.2", 46 | "prettier": "^2.8.3", 47 | "stylelint": "^14.16.1", 48 | "stylelint-config-idiomatic-order": "^9.0.0", 49 | "stylelint-config-prettier": "^9.0.4", 50 | "stylelint-config-standard": "^29.0.0", 51 | "typescript": "^4.9.4", 52 | "unplugin-icons": "^0.15.1", 53 | "vite": "^4.0.4", 54 | "vite-plugin-monaco-editor": "^1.1.0", 55 | "vite-plugin-solid": "^2.5.0" 56 | }, 57 | "dependencies": { 58 | "@solid-primitives/i18n": "^1.1.2", 59 | "adm-zip": "^0.5.10", 60 | "color": "^4.2.3", 61 | "dayjs": "^1.11.7", 62 | "decorock": "^0.1.8", 63 | "electron-context-menu": "^3.6.1", 64 | "eventemitter3": "^5.0.0", 65 | "fast-glob": "^3.2.12", 66 | "monaco-editor": "^0.34.1", 67 | "node-fetch": "^3.3.0", 68 | "simple-git": "^3.16.0", 69 | "solid-js": "^1.6.9", 70 | "yaml": "^2.2.1" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | import AdmZip from 'adm-zip' 2 | import { spawn } from 'child_process' 3 | import crypto from 'crypto' 4 | import builder from 'electron-builder' 5 | import esbuild from 'esbuild' 6 | import fs from 'fs' 7 | import path from 'path' 8 | import { fileURLToPath } from 'url' 9 | import * as vite from 'vite' 10 | import yaml from 'yaml' 11 | 12 | import packageJson from '../package.json' assert { type: 'json' } 13 | 14 | const __dirname = fileURLToPath(path.dirname(import.meta.url)) 15 | const __dev = process.env['NODE_ENV'] === 'development' 16 | const cwd = path.dirname(__dirname) 17 | 18 | /** 19 | * @param {boolean} run 20 | */ 21 | const bundle = async (run) => { 22 | const outdir = path.join(cwd, 'dist') 23 | if (fs.existsSync(outdir)) await fs.promises.rm(outdir, { recursive: true }) 24 | 25 | if (!run) 26 | await vite.build({ 27 | configFile: './vite.config.ts', 28 | }) 29 | await esbuild.build({ 30 | entryPoints: { 31 | index: path.join(cwd, 'src', 'index.ts'), 32 | preload: path.join(cwd, 'src', 'preload.ts'), 33 | }, 34 | outdir, 35 | logLevel: 'info', 36 | bundle: true, 37 | sourcemap: __dev, 38 | minify: !__dev, 39 | format: 'cjs', 40 | outExtension: { 41 | '.js': '.cjs', 42 | }, 43 | external: ['electron'], 44 | platform: 'node', 45 | }) 46 | } 47 | 48 | const build = async () => { 49 | await builder.build({ 50 | publish: 'never', 51 | config: { 52 | appId: 'world.ddpn.flat', 53 | productName: 'flat', 54 | artifactName: '${productName}-v${version}.${ext}', 55 | copyright: 'Copyright © 2023 ddPn08', 56 | directories: { app: cwd, output: path.join(cwd, 'product') }, 57 | win: { 58 | target: [ 59 | { 60 | target: 'nsis', 61 | arch: 'x64', 62 | }, 63 | ], 64 | }, 65 | icon: './assets/icon-512x512.png', 66 | compression: 'store', 67 | files: ['dist/**/*', '!node_modules/**/*'], 68 | asar: false, 69 | }, 70 | }) 71 | await fs.promises.writeFile(path.join(cwd, 'product/win-unpacked/resources/.portable'), '') 72 | 73 | const application = new AdmZip() 74 | application.addLocalFolder(path.join(cwd, 'product/win-unpacked')) 75 | application.writeZip(path.join(cwd, `product/flat-v${packageJson.version}.zip`)) 76 | 77 | const app = new AdmZip() 78 | app.addLocalFolder(path.join(cwd, 'product/win-unpacked/resources/app')) 79 | app.writeZip(path.join(cwd, `product/app.zip`)) 80 | 81 | const file = await fs.promises.readFile(path.join(cwd, 'product/app.zip')) 82 | const size = file.byteLength 83 | const md5 = crypto.createHash('md5').update(file).digest('hex') 84 | const meta = { 85 | version: packageJson.version, 86 | size, 87 | md5, 88 | } 89 | await fs.promises.writeFile(path.join(cwd, 'product/meta.yaml'), yaml.stringify(meta)) 90 | } 91 | 92 | const bundleOnly = process.argv.includes('--bundle-only') 93 | const run = process.argv.includes('--run') 94 | 95 | await bundle(run) 96 | if (run) { 97 | const port = 3000 98 | 99 | const server = await vite.createServer({ 100 | configFile: './vite.config.ts', 101 | }) 102 | server.listen(port) 103 | const ps = spawn('pnpm', ['electron', 'dist/index.cjs', '--remote-debugging-port=9222'], { 104 | env: { 105 | ...process.env, 106 | NODE_ENV: 'development', 107 | DEV_SERVER_ADDRESS: `http://localhost:${port}`, 108 | }, 109 | }) 110 | ps.stdout.pipe(process.stdout) 111 | ps.stderr.pipe(process.stderr) 112 | ps.on('close', async () => { 113 | await server.close() 114 | process.exit() 115 | }) 116 | } else { 117 | if (bundleOnly) process.exit() 118 | await build() 119 | } 120 | -------------------------------------------------------------------------------- /src/__setup.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | const isDev = process.env['NODE_ENV'] === 'development' 6 | const appRoot = path.join(__dirname, '../../../') 7 | const isPortable = !isDev && fs.existsSync(path.join(appRoot, 'resources/.portable')) 8 | const userData = isPortable 9 | ? path.join(appRoot, 'userData') 10 | : path.join(app.getPath('appData'), 'flat') 11 | 12 | if (!fs.existsSync(userData)) fs.mkdirSync(userData, { recursive: true }) 13 | app.setPath('userData', userData) 14 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import path from 'path' 3 | 4 | export const FEATURES_PATH = path.join(app.getPath('userData'), 'features') 5 | -------------------------------------------------------------------------------- /src/features/conda/index.ts: -------------------------------------------------------------------------------- 1 | import { spawn, SpawnOptions, spawnSync } from 'child_process' 2 | import { app } from 'electron' 3 | import fs from 'fs' 4 | import fetch from 'node-fetch' 5 | import path from 'path' 6 | 7 | import type { ClientToServerEvents, ServerToClientEvents } from './ipc' 8 | 9 | import { IpcServer } from '~/ipc/server' 10 | 11 | const ext = process.platform === 'win32' ? '.exe' : '' 12 | 13 | class Conda { 14 | private readonly condaDir = path.join(app.getPath('userData'), 'miniconda') 15 | private readonly ipc = new IpcServer('conda') 16 | private installing = false 17 | 18 | public setup() { 19 | process.env['CONDA_EXE'] = path.join(this.condaDir, 'Scripts', 'conda' + ext) 20 | process.env['CONDA_PREFIX'] = this.condaDir 21 | process.env['CONDA_PYTHON_EXE'] = path.join(this.condaDir, 'python' + ext) 22 | this.ipc.handle('install/start', this.install.bind(this)) 23 | this.ipc.handle('env/installed', this.isInstalled.bind(this)) 24 | } 25 | 26 | public isInstalled() { 27 | return fs.existsSync(path.join(this.condaDir)) 28 | } 29 | 30 | public async install() { 31 | if (this.isInstalled() || this.installing) return 32 | 33 | switch (process.platform) { 34 | case 'win32': { 35 | this.installing = true 36 | const url = 37 | 'https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe' 38 | 39 | const res = await fetch(url) 40 | const buf = Buffer.from(await res.arrayBuffer()) 41 | const installer = path.join(app.getPath('temp'), 'flat-miniconda-installer.exe') 42 | fs.writeFileSync(installer, buf) 43 | 44 | const ps = spawn(installer, [ 45 | '/InstallationType=JustMe', 46 | '/RegisterPython=0', 47 | '/AddToPath=0', 48 | '/S', 49 | `/D=${this.condaDir}`, 50 | ]) 51 | 52 | ps.stdout.on('data', (data: Buffer) => 53 | this.ipc.emit('install/log', data.toString()), 54 | ) 55 | ps.stderr.on('data', (data: Buffer) => 56 | this.ipc.emit('install/log', data.toString()), 57 | ) 58 | ps.on('close', (code) => { 59 | this.installing = false 60 | this.ipc.emit('install/close', code ?? 1) 61 | }) 62 | ps.on('error', (error) => { 63 | this.installing = false 64 | this.ipc.emit('install/error', error.message) 65 | }) 66 | } 67 | } 68 | } 69 | 70 | public condaBin() { 71 | switch (process.platform) { 72 | case 'win32': 73 | return path.resolve(path.join(this.condaDir, 'Library', 'bin', 'conda.bat')) 74 | default: 75 | throw new Error('This platform is not supported.') 76 | } 77 | } 78 | 79 | public async version(bin = this.condaBin()) { 80 | const { stdout } = spawnSync(bin, ['--version']) 81 | const str = stdout.toString() 82 | const matches = str.match(/conda (\d+.\d+.\d+)/) 83 | if (!matches || matches.length < 2) return 84 | return matches[1] 85 | } 86 | 87 | public envs() { 88 | const dir = path.join(this.condaDir, 'envs') 89 | if (!fs.existsSync(dir)) return [] 90 | return fs.readdirSync(dir) 91 | } 92 | 93 | public createEnvVars(env: string) { 94 | const sep = process.platform === 'win32' ? ';' : ':' 95 | const paths = [[], ['Library', 'bin'], ['Scripts'], ['bin']] 96 | const create = (dir: string) => `${paths.map((v) => path.join(dir, ...v)).join(sep)}` 97 | const PATH = 98 | (env !== 'base' ? create(path.join(this.condaDir, 'envs', env)) : '') + 99 | `;${create(this.condaDir)}${'PATH' in process.env ? ';' + process.env['PATH'] : ''}` 100 | const envs: Record = { 101 | PATH, 102 | CONDA_DEFAULT_ENV: env, 103 | CONDA_EXE: path.join(this.condaDir, 'Scripts', 'conda.exe'), 104 | CONDA_PREFIX: env === 'base' ? this.condaDir : path.join(this.condaDir, 'envs', env), 105 | CONDA_PYTHON_EXE: path.join(this.condaDir, 'python' + ext), 106 | } 107 | 108 | if (env !== 'base') envs['CONDA_PREFIX_1'] = this.condaDir 109 | return envs 110 | } 111 | 112 | public run(cmd: string, env?: string | undefined, options?: SpawnOptions | undefined) { 113 | const re = /[^\s'"]+|'([^']*)'|"([^"]*)"/gi 114 | const matches = cmd.match(re) || [] 115 | const [exec, ...args] = matches.map((v) => v.replace(/['"]/g, '')) 116 | options = options || {} 117 | options.env = { 118 | ...options.env, 119 | ...this.createEnvVars(env || 'base'), 120 | } 121 | if (!exec) throw new Error('Command is empty.') 122 | return spawn(exec, args, options) 123 | } 124 | 125 | public promise(cmd: string, env?: string | undefined, options?: SpawnOptions | undefined) { 126 | return new Promise<{ 127 | status: number | null 128 | pid: number | undefined 129 | stdout: string 130 | stderr: string 131 | }>((resolve, reject) => { 132 | const ps = this.run(cmd, env, options) 133 | let stdout = '' 134 | let stderr = '' 135 | ps.stdout?.on('data', (data) => (stdout += data.toString())) 136 | ps.stderr?.on('data', (data) => (stderr += data.toString())) 137 | ps.on('close', (code) => { 138 | resolve({ 139 | status: code, 140 | pid: ps.pid, 141 | stdout, 142 | stderr, 143 | }) 144 | }) 145 | ps.on('error', reject) 146 | }) 147 | } 148 | } 149 | 150 | export const conda = new Conda() 151 | -------------------------------------------------------------------------------- /src/features/conda/ipc.ts: -------------------------------------------------------------------------------- 1 | export interface ServerToClientEvents { 2 | 'install/log': (log: string) => void 3 | 'install/close': (code: number) => void 4 | 'install/error': (error: string) => void 5 | } 6 | 7 | export interface ClientToServerEvents { 8 | 'install/start': () => void 9 | 'env/installed': () => boolean 10 | } 11 | -------------------------------------------------------------------------------- /src/features/gallery/index.ts: -------------------------------------------------------------------------------- 1 | import fg from 'fast-glob' 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | import type { ClientToServerEvents, ServerToClientEvents } from './ipc' 6 | import { parseMetadata } from './parse-png-meta' 7 | import { loadMeta } from './png-meta' 8 | import type { ImageData, ImageSearchOptions } from './types' 9 | 10 | import { FEATURES_PATH } from '~/constants' 11 | import { getConfig } from '~/index' 12 | import { IpcServer } from '~/ipc/server' 13 | 14 | const toArrayBuffer = (buffer: Buffer) => { 15 | const ab = new ArrayBuffer(buffer.length) 16 | const view = new Uint8Array(ab) 17 | for (let i = 0; i < buffer.length; ++i) { 18 | view[i] = buffer[i] as number 19 | } 20 | return ab 21 | } 22 | 23 | class Gallery { 24 | private readonly dir = path.join(FEATURES_PATH, 'gallery') 25 | private readonly ipc = new IpcServer('gallery') 26 | private readonly dbFilePath = path.join(this.dir, 'db.json') 27 | private dirs: string[] = [] 28 | private db: Record = {} 29 | private globbing = false 30 | 31 | public async setup() { 32 | if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir) 33 | if (!fs.existsSync(this.dbFilePath)) fs.writeFileSync(this.dbFilePath, '{}') 34 | const config = getConfig() 35 | if (config) { 36 | this.dirs = Array.isArray(config['gallery/dirs']) ? config['gallery/dirs'] : [] 37 | } 38 | this.ipc.handle('dirs/update', async (_, paths) => { 39 | this.dirs = paths 40 | }) 41 | this.ipc.handle('images/get', (_, dir, search) => this.get(dir, search)) 42 | this.ipc.handle('images/glob', (_, dir) => this.glob(dir)) 43 | this.ipc.handle('favorite/add', (_, dir, path) => this.addFav(dir, path)) 44 | this.ipc.handle('favorite/remove', (_, dir, path) => this.removeFav(dir, path)) 45 | this.load() 46 | this.glob() 47 | } 48 | 49 | private load() { 50 | const saved = fs.readFileSync(this.dbFilePath, 'utf-8') 51 | try { 52 | const parsed = JSON.parse(saved) 53 | if (Array.isArray(parsed)) return (this.db = {}) 54 | this.db = JSON.parse(saved) 55 | } catch (error) { 56 | this.db = {} 57 | } 58 | } 59 | 60 | private sort() { 61 | for (const [key, images] of Object.entries(this.db)) 62 | this.db[key] = images.sort((a, b) => (a.created_at < b.created_at ? -1 : 1)) 63 | } 64 | 65 | private save() { 66 | this.sort() 67 | return fs.promises.writeFile(this.dbFilePath, JSON.stringify(this.db)) 68 | } 69 | 70 | public addFav(dir: string, path: string) { 71 | if (!(dir in this.db)) return 72 | const db = this.db[dir]! 73 | const i = db.findIndex((v) => v.filepath === path) 74 | if (i === -1) return 75 | db[i]!.favorite = true 76 | } 77 | 78 | public removeFav(dir: string, path: string) { 79 | if (!(dir in this.db)) return 80 | const db = this.db[dir]! 81 | const i = db.findIndex((v) => v.filepath === path) 82 | if (i === -1) return 83 | db[i]!.favorite = false 84 | } 85 | 86 | public async glob(dir?: string) { 87 | if (this.globbing) return 88 | this.globbing = true 89 | const dirs = this.dirs.slice().filter((v) => !dir || v === dir) 90 | await Promise.all( 91 | dirs.map(async (cwd) => { 92 | if (!(cwd in this.db)) this.db[cwd] = [] 93 | const db = this.db[cwd]! 94 | const update: ImageData[] = [] 95 | const files = fg.stream('**/*.png', { cwd: cwd }) 96 | const exists: string[] = [] 97 | for await (const filename of files) { 98 | const filepath = path.join(cwd, filename.toString()) 99 | exists.push(filepath) 100 | if (db.findIndex((v) => v.filepath === filepath) !== -1) continue 101 | const [stat, buf] = await Promise.all([ 102 | fs.promises.stat(filepath), 103 | fs.promises.readFile(filepath), 104 | ]) 105 | const meta = await loadMeta(toArrayBuffer(buf)) 106 | const info = parseMetadata(meta) 107 | if (info) 108 | update.push({ 109 | filepath, 110 | created_at: stat.ctime, 111 | favorite: false, 112 | info, 113 | }) 114 | } 115 | 116 | this.db[cwd]! = db 117 | .concat(update) 118 | .filter((img) => img.filepath.startsWith(cwd) && exists.includes(img.filepath)) 119 | .filter( 120 | (img, index, self) => 121 | self.findIndex((e) => e.filepath === img.filepath) === index, 122 | ) 123 | }), 124 | ) 125 | await this.save() 126 | this.globbing = false 127 | } 128 | 129 | public async get(cwd: string, search?: ImageSearchOptions) { 130 | search = search || {} 131 | if (!(cwd in this.db)) return [] 132 | const db = this.db[cwd]! 133 | const result: ImageData[] = [] 134 | const limit = search.limit || 100 135 | const since = search.since || 0 136 | 137 | const files = [...db] 138 | 139 | if (typeof search.latest !== 'boolean' || search.latest) files.reverse() 140 | 141 | for (const v of files.slice(since, since + limit)) { 142 | if (result.length >= limit) break 143 | if (typeof search.favorite === 'boolean' && v.favorite !== search.favorite) continue 144 | if (search.filename && !path.basename(v.filepath).includes(search.filename)) continue 145 | if (search.info) { 146 | let c = false 147 | for (const [ik, iv] of Object.entries(search.info)) { 148 | if ( 149 | Object.keys(v.info).includes(ik) && 150 | !`${v.info[ik as keyof typeof v.info]}`.includes(`${iv}`) 151 | ) 152 | c = true 153 | } 154 | if (c) continue 155 | } 156 | result.push(v) 157 | } 158 | return result 159 | } 160 | } 161 | 162 | export const gallery = new Gallery() 163 | -------------------------------------------------------------------------------- /src/features/gallery/ipc.ts: -------------------------------------------------------------------------------- 1 | import type { ImageData, ImageSearchOptions } from './types' 2 | 3 | export interface ServerToClientEvents {} 4 | 5 | export interface ClientToServerEvents { 6 | 'dirs/update': (paths: string[]) => void 7 | 'images/get': (dir: string, search?: ImageSearchOptions) => ImageData[] 8 | 'images/glob': (dir?: string | undefined) => void 9 | 'favorite/add': (dir: string, path: string) => void 10 | 'favorite/remove': (dir: string, path: string) => void 11 | } 12 | -------------------------------------------------------------------------------- /src/features/gallery/parse-png-meta.ts: -------------------------------------------------------------------------------- 1 | import type { ImageInformation } from './types' 2 | 3 | const parseAutomatic1111Meta = (parameters: string) => { 4 | const result: ImageInformation = {} 5 | const prompt = parameters.split(/\nNegative prompt:/)[0] 6 | const negative_prompt = parameters.split(/\nNegative prompt:/)[1]?.split(/Steps: \d+/)[0] 7 | const others = parameters.split(/\n/g).slice(-1)[0] 8 | result.prompt = prompt || '' 9 | result.negative_prompt = (negative_prompt || '').replace(/\n$/, '') 10 | if (others) { 11 | for (const prop of others.split(/, /g)) { 12 | const [key, value] = prop.split(': ') 13 | switch (key) { 14 | case 'Steps': 15 | result.steps = parseInt(value || '0') 16 | break 17 | case 'Sampler': 18 | result.sampler = value || '' 19 | break 20 | case 'CFG scale': 21 | result.cfg_scale = parseInt(value || '0') 22 | break 23 | case 'Seed': 24 | result.seed = value || '' 25 | break 26 | case 'Model': 27 | result.model = value || '' 28 | break 29 | case 'Model hash': 30 | result.model_hash = value || '' 31 | break 32 | case 'Clip skip': 33 | result.clip_skip = value || '' 34 | break 35 | } 36 | } 37 | } 38 | return result 39 | } 40 | 41 | const parseNovelAIMeta = (meta: Record) => { 42 | const comment = JSON.parse(meta['Comment']) 43 | const prompt = meta['Description'] 44 | const result: ImageInformation = { 45 | prompt, 46 | negative_prompt: comment['uc'], 47 | steps: parseInt(comment['steps']), 48 | sampler: `${comment['sampler']}`, 49 | cfg_scale: parseInt(comment['scale']), 50 | seed: `${comment['seed']}`, 51 | model: `${meta['Software']}`, 52 | } 53 | return result 54 | } 55 | 56 | export const parseMetadata = (meta: any) => { 57 | if (meta?.['parameters']) return parseAutomatic1111Meta(meta['parameters'] as string) 58 | else if (meta?.['Software'] === 'NovelAI') return parseNovelAIMeta(meta) 59 | } 60 | -------------------------------------------------------------------------------- /src/features/gallery/png-meta.ts: -------------------------------------------------------------------------------- 1 | const PNG_MAGIC_BYTES = '\x89\x50\x4e\x47\x0d\x0a\x1a\x0a' 2 | const LENGTH_SIZE = 4 3 | const TYPE_SIZE = 4 4 | const CRC_SIZE = 4 5 | 6 | type Segment = { 7 | name: string 8 | type: string 9 | offset: number 10 | length: number 11 | start: number 12 | size: number 13 | marker: number 14 | } 15 | 16 | export const loadMeta = async (buf: ArrayBuffer) => { 17 | const dv = new DataView(buf) 18 | const ba = new Uint8Array(buf) 19 | 20 | const chunks: Segment[] = [] 21 | const textDecoder = new TextDecoder('utf-8') 22 | let offset = PNG_MAGIC_BYTES.length 23 | 24 | while (offset < ba.length) { 25 | const size = dv.getUint32(offset) 26 | const marker = dv.getUint32(offset + LENGTH_SIZE) 27 | const name = textDecoder.decode(ba.slice(offset + LENGTH_SIZE, offset + LENGTH_SIZE + 4)) 28 | const type = name.toLowerCase() 29 | const start = offset + LENGTH_SIZE + TYPE_SIZE 30 | const length = size + LENGTH_SIZE + TYPE_SIZE + CRC_SIZE 31 | const seg = { type, offset, length, start, name, size, marker } 32 | chunks.push(seg) 33 | offset += length 34 | } 35 | 36 | const result: Record = {} 37 | 38 | for (const chunk of chunks) { 39 | if (chunk.type === 'idat') continue 40 | const b = ba.slice(chunk.start, chunk.start + chunk.size) 41 | if (['itxt', 'text'].includes(chunk.type)) { 42 | const text = textDecoder.decode(b) 43 | const [key, ...val] = text.split('\0') 44 | result[key!] = val.join('') 45 | } 46 | } 47 | 48 | return result 49 | } 50 | -------------------------------------------------------------------------------- /src/features/gallery/types.ts: -------------------------------------------------------------------------------- 1 | export type ImageInformation = Partial<{ 2 | prompt: string 3 | negative_prompt: string 4 | model: string 5 | model_hash: string 6 | steps: number 7 | cfg_scale: number 8 | sampler: string 9 | seed: string 10 | clip_skip: string 11 | embedding: string 12 | hypernetwork: string 13 | vae: string 14 | }> 15 | 16 | export type ImageData = { 17 | filepath: string 18 | created_at: Date 19 | favorite: boolean 20 | info: ImageInformation 21 | } 22 | 23 | export type ImageSearchOptions = Partial<{ 24 | since: number 25 | limit: number 26 | latest: boolean 27 | filename: string 28 | info: ImageInformation 29 | favorite: boolean 30 | }> 31 | -------------------------------------------------------------------------------- /src/features/git/index.ts: -------------------------------------------------------------------------------- 1 | import { execSync, spawn } from 'child_process' 2 | import { app } from 'electron' 3 | import fs from 'fs' 4 | import fetch from 'node-fetch' 5 | import path from 'path' 6 | 7 | import type { ClientToServerEvents, ServerToClientEvents } from './ipc' 8 | 9 | import { IpcServer } from '~/ipc/server' 10 | 11 | class Git { 12 | private readonly ipc = new IpcServer('git') 13 | private installing = false 14 | 15 | public setup() { 16 | this.ipc.handle('check', this.check.bind(this)) 17 | this.ipc.handle('install', this.install.bind(this)) 18 | } 19 | 20 | public check() { 21 | let git 22 | try { 23 | git = execSync('git --version').toString('utf-8').trim() 24 | } catch (err) { 25 | git = null 26 | } 27 | return !!git 28 | } 29 | 30 | public async install() { 31 | switch (process.platform) { 32 | case 'win32': { 33 | this.ipc.emit('log', 'Downloading git installer...') 34 | const url = 35 | 'https://github.com/git-for-windows/git/releases/download/v2.39.1.windows.1/Git-2.39.1-64-bit.exe' 36 | 37 | const res = await fetch(url) 38 | const buf = Buffer.from(await res.arrayBuffer()) 39 | const installer = path.join(app.getPath('temp'), 'flat-git-installer.exe') 40 | fs.writeFileSync(installer, buf) 41 | 42 | const ps = spawn(installer, { detached: true }) 43 | ps.stdout.on('data', (data: Buffer) => this.ipc.emit('log', data.toString())) 44 | ps.stderr.on('data', (data: Buffer) => this.ipc.emit('log', data.toString())) 45 | ps.on('close', (code) => { 46 | this.installing = false 47 | this.ipc.emit('install/close', code ?? 1) 48 | }) 49 | ps.on('error', (error) => { 50 | this.installing = false 51 | this.ipc.emit('install/error', error.message) 52 | }) 53 | } 54 | } 55 | } 56 | } 57 | 58 | export const git = new Git() 59 | -------------------------------------------------------------------------------- /src/features/git/ipc.ts: -------------------------------------------------------------------------------- 1 | export interface ServerToClientEvents { 2 | log: (log: string) => void 3 | 'install/close': (code: number) => void 4 | 'install/error': (error: string) => void 5 | } 6 | 7 | export interface ClientToServerEvents { 8 | check: () => boolean 9 | install: () => void 10 | } 11 | -------------------------------------------------------------------------------- /src/features/stable-diffusion-webui/index.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess } from 'child_process' 2 | import { shell } from 'electron' 3 | import fs from 'fs' 4 | import net, { AddressInfo } from 'net' 5 | import path from 'path' 6 | import { simpleGit } from 'simple-git' 7 | 8 | import type { ClientToServerEvents, ServerToClientEvents } from './ipc' 9 | import { conda } from '../conda' 10 | 11 | import { FEATURES_PATH } from '~/constants' 12 | import { IpcServer } from '~/ipc/server' 13 | 14 | const GIT_URL = 'https://github.com/AUTOMATIC1111/stable-diffusion-webui.git' 15 | 16 | const getFreePort = async () => { 17 | return new Promise((res) => { 18 | const srv = net.createServer() 19 | srv.listen(0, () => { 20 | const address = srv.address() as AddressInfo 21 | srv.close(() => res(address.port)) 22 | }) 23 | }) 24 | } 25 | 26 | class StableDiffusionWebUI { 27 | private readonly dir = path.join(FEATURES_PATH, 'stable-diffusion-webui') 28 | private readonly condaEnv = 'stable-diffusion-webui' 29 | private readonly ipc = new IpcServer( 30 | 'stable-diffusion-webui', 31 | ) 32 | private port = 0 33 | private ps: ChildProcess | null = null 34 | private logs: string[] = [] 35 | 36 | public setup() { 37 | if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir) 38 | 39 | this.ipc.handle('env/install', this.install.bind(this)) 40 | this.ipc.handle('env/installed', this.isInstalled.bind(this)) 41 | this.ipc.handle('env/uninstall', this.uninstallEnv.bind(this)) 42 | 43 | this.ipc.handle('config/get', this.getConfig.bind(this, 'config.json')) 44 | this.ipc.handle('config/save', (_, str) => this.saveConfig('config.json', str)) 45 | 46 | this.ipc.handle('ui-config/get', this.getConfig.bind(this, 'ui-config.json')) 47 | this.ipc.handle('ui-config/save', (_, str) => this.saveConfig('ui-config.json', str)) 48 | 49 | this.ipc.handle('folder/open', () => 50 | shell.showItemInFolder(path.join(this.dir, 'repository', 'launch.py')), 51 | ) 52 | 53 | this.ipc.handle('webui/running', () => this.ps !== null) 54 | this.ipc.handle('webui/launch', (_, args, env, commit) => this.launch(args, env, commit)) 55 | this.ipc.handle('webui/stop', this.stop.bind(this)) 56 | this.ipc.handle('webui/logs', () => this.logs) 57 | this.ipc.handle('webui/port', () => this.port) 58 | this.ipc.handle('webui/data-dir', () => this.dir) 59 | 60 | this.ipc.handle('git/log', this.commits.bind(this)) 61 | this.ipc.handle('git/pull', this.pull.bind(this)) 62 | } 63 | 64 | public getConfig(filename: string) { 65 | const filepath = path.join(this.dir, 'repository', filename) 66 | if (fs.existsSync(filepath)) { 67 | return fs.promises.readFile(filepath, 'utf-8') 68 | } else { 69 | return '' 70 | } 71 | } 72 | 73 | public saveConfig(filename: string, str: string) { 74 | const filepath = path.join(this.dir, 'repository', filename) 75 | return fs.promises.writeFile(filepath, str) 76 | } 77 | 78 | public isInstalled() { 79 | return ( 80 | fs.existsSync(path.join(this.dir, 'repository')) && conda.envs().includes(this.condaEnv) 81 | ) 82 | } 83 | 84 | public log(ps: ChildProcess) { 85 | return new Promise((resolve, reject) => { 86 | ps.stdout?.on('data', (data: Buffer) => this.ipc.emit('log', data.toString())) 87 | ps.stderr?.on('data', (data: Buffer) => this.ipc.emit('log', data.toString())) 88 | ps.on('error', (error) => { 89 | reject(error) 90 | }) 91 | ps.on('close', resolve) 92 | }) 93 | } 94 | 95 | public async uninstallEnv() { 96 | await this.log(conda.run(`conda remove -y -n ${this.condaEnv} --all`)) 97 | this.ipc.emit('env/uninstalled') 98 | } 99 | 100 | public async install() { 101 | this.ipc.emit('log', 'Cloning repository...') 102 | const repoPath = path.join(this.dir, 'repository') 103 | if (!fs.existsSync(repoPath)) 104 | await simpleGit().clone(GIT_URL, path.join(this.dir, 'repository')) 105 | this.ipc.emit('log', 'Successfully cloned the repository.') 106 | 107 | const envs = conda.envs() 108 | 109 | if (envs.includes(this.condaEnv)) await this.uninstallEnv() 110 | await this.log( 111 | conda.run(`conda create -y -n ${this.condaEnv} python==3.10 pip=22.2.2`, 'base'), 112 | ) 113 | if (process.platform === 'linux') 114 | await this.log( 115 | conda.run('conda install -y xformers -c xformers/label/dev', this.condaEnv), 116 | ) 117 | 118 | await this.log( 119 | conda.run( 120 | 'python -u -c "from launch import prepare_environment; prepare_environment()"', 121 | this.condaEnv, 122 | { cwd: repoPath }, 123 | ), 124 | ) 125 | this.ipc.emit('log', 'Installation finished🎉') 126 | } 127 | 128 | public async launch(args: string, env: Record, commit = 'master') { 129 | if (this.ps) throw new Error('already running.') 130 | const git = this.git() 131 | try { 132 | await git.checkout(commit) 133 | } catch (_) { 134 | this.ipc.emit('log', 'Incorrect commit hash.') 135 | } 136 | this.port = await getFreePort() 137 | args += ` --port ${this.port}` 138 | this.ps = conda.run(`python -u launch.py`, this.condaEnv, { 139 | cwd: path.join(this.dir, 'repository'), 140 | env: { 141 | COMMANDLINE_ARGS: args, 142 | ...env, 143 | }, 144 | }) 145 | this.ps.stdout?.on('data', (data: Buffer) => { 146 | this.logs.push(...data.toString().split('\n')) 147 | this.ipc.emit('log', data.toString()) 148 | }) 149 | this.ps.stderr?.on('data', (data: Buffer) => { 150 | this.logs.push(...data.toString().split('\n')) 151 | this.ipc.emit('log', data.toString()) 152 | }) 153 | this.ps.on('close', (code) => { 154 | this.ps = null 155 | this.ipc.emit('webui/close', code ?? 1) 156 | }) 157 | this.ps.on('error', (error) => this.ipc.emit('webui/error', error.message)) 158 | 159 | return this.port 160 | } 161 | 162 | public stop() { 163 | this.logs = [] 164 | this.ps?.kill() 165 | this.ps = null 166 | this.port = 0 167 | } 168 | 169 | public git() { 170 | return simpleGit(path.join(this.dir, 'repository')) 171 | } 172 | 173 | public async pull() { 174 | const git = this.git() 175 | await git.fetch() 176 | const log = await git.pull('origin', 'master') 177 | this.ipc.emit('log', 'Updated WebUI.') 178 | return log 179 | } 180 | 181 | public async commits() { 182 | if (!fs.existsSync(path.join(this.dir, 'repository'))) 183 | throw new Error('Repository not cloned.') 184 | const git = this.git() 185 | const log = await git.log() 186 | return log 187 | } 188 | } 189 | 190 | export const webui = new StableDiffusionWebUI() 191 | -------------------------------------------------------------------------------- /src/features/stable-diffusion-webui/ipc.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultLogFields, LogResult, PullResult } from 'simple-git' 2 | 3 | export interface ServerToClientEvents { 4 | log: (log: string) => void 5 | 'env/uninstalled': () => void 6 | 'webui/launch': (port: number) => void 7 | 'webui/close': (code: number) => void 8 | 'webui/error': (error: string) => void 9 | } 10 | 11 | export interface ClientToServerEvents { 12 | 'env/install': () => void 13 | 'env/uninstall': () => void 14 | 'env/installed': () => boolean 15 | 'git/log': () => LogResult 16 | 'git/pull': () => PullResult 17 | 'config/get': () => string 18 | 'config/save': (str: string) => void 19 | 'ui-config/get': () => string 20 | 'ui-config/save': (str: string) => void 21 | 'folder/open': () => void 22 | 'webui/running': () => boolean 23 | 'webui/logs': () => string[] 24 | 'webui/launch': (args: string, env: Record, commit: string) => number 25 | 'webui/stop': () => void 26 | 'webui/port': () => number 27 | 'webui/data-dir': () => string 28 | } 29 | -------------------------------------------------------------------------------- /src/features/system/ipc/server.ts: -------------------------------------------------------------------------------- 1 | import type { ClientToServerEvents, ServerToClientEvents } from './types' 2 | 3 | import { IpcServer } from '~/ipc/server' 4 | 5 | export const ipcSystem = new IpcServer('system') 6 | -------------------------------------------------------------------------------- /src/features/system/ipc/types.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '~/web/lib/config' 2 | 3 | export interface ServerToClientEvents {} 4 | 5 | export interface ClientToServerEvents { 6 | 'app/restart': () => void 7 | 'path/user-data': () => string 8 | 'update/check': () => boolean 9 | 'update/prepare': () => void 10 | 'update/install': () => void 11 | 'window/is-focused': () => boolean 12 | 'config/save': (config: Config) => void 13 | } 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './__setup' 2 | import { app, BrowserWindow } from 'electron' 3 | import contextMenu from 'electron-context-menu' 4 | import fs from 'fs' 5 | import path from 'path' 6 | 7 | import { FEATURES_PATH } from './constants' 8 | import { conda } from './features/conda' 9 | import { gallery } from './features/gallery' 10 | import { git } from './features/git' 11 | import { webui } from './features/stable-diffusion-webui' 12 | import { ipcSystem } from './features/system/ipc/server' 13 | import { check, prepare, update } from './updater' 14 | import type { Config } from './web/lib/config' 15 | 16 | import { dict } from '~i18n/index' 17 | 18 | export const isDev = process.env['NODE_ENV'] === 'development' 19 | 20 | export const getConfig = (): Config | null => { 21 | const filepath = path.join(app.getPath('userData'), 'flat-config.json') 22 | if (!fs.existsSync(filepath)) return null 23 | const txt = fs.readFileSync(filepath, 'utf-8') 24 | try { 25 | return JSON.parse(txt) 26 | } catch (_) {} 27 | return null 28 | } 29 | 30 | app.on('web-contents-created', (e, contents) => { 31 | if (isDev) contents.openDevTools() 32 | const lang = getConfig()?.['system/lang'] || 'en' 33 | const d = dict[lang] 34 | 35 | const labels: Record = Object.fromEntries( 36 | Object.entries(d) 37 | .filter(([k]) => k.startsWith('context-menu/labels')) 38 | .map(([k, v]) => [k.replace('context-menu/labels/', ''), v]), 39 | ) 40 | 41 | contextMenu({ 42 | window: contents, 43 | showInspectElement: false, 44 | labels, 45 | }) 46 | }) 47 | 48 | app.once('ready', async () => { 49 | try { 50 | if (!fs.statSync(FEATURES_PATH).isDirectory()) 51 | fs.renameSync(FEATURES_PATH, 'flat-rm-file.' + FEATURES_PATH) 52 | } catch (_) {} 53 | if (!fs.existsSync(FEATURES_PATH)) fs.mkdirSync(FEATURES_PATH, { recursive: true }) 54 | 55 | ipcSystem.handle('app/restart', () => app.relaunch()) 56 | ipcSystem.handle('path/user-data', () => app.getPath('userData')) 57 | ipcSystem.handle('update/check', check) 58 | ipcSystem.handle('update/prepare', prepare) 59 | ipcSystem.handle('update/install', update) 60 | ipcSystem.handle('window/is-focused', (e) => e.sender.isFocused()) 61 | ipcSystem.handle('config/save', (_, config) => { 62 | const filepath = path.join(app.getPath('userData'), 'flat-config.json') 63 | return fs.promises.writeFile(filepath, JSON.stringify(config)) 64 | }) 65 | 66 | git.setup() 67 | conda.setup() 68 | gallery.setup() 69 | webui.setup() 70 | await createWindow() 71 | }) 72 | 73 | const createWindow = async () => { 74 | const window = new BrowserWindow({ 75 | width: 1280, 76 | height: 720, 77 | minWidth: 960, 78 | minHeight: 480, 79 | webPreferences: { 80 | webSecurity: false, 81 | webviewTag: true, 82 | nodeIntegration: true, 83 | contextIsolation: false, 84 | preload: path.join(__dirname, 'preload.cjs'), 85 | }, 86 | backgroundColor: '#ffffff', 87 | }) 88 | window.setMenuBarVisibility(false) 89 | 90 | if (isDev) window.loadURL(process.env['DEV_SERVER_ADDRESS'] as string) 91 | else window.loadFile('./dist/index.html') 92 | } 93 | -------------------------------------------------------------------------------- /src/ipc/client.ts: -------------------------------------------------------------------------------- 1 | import type { IpcRendererEvent } from 'electron/renderer' 2 | import EventEmitter from 'eventemitter3' 3 | 4 | import { ipcRenderer } from '../web/lib/node/electron' 5 | 6 | export class IpcClient< 7 | StoC extends Record = Record, 8 | CtoS extends Record = Record, 9 | > { 10 | constructor(private readonly key: string) {} 11 | 12 | public local = new EventEmitter() 13 | 14 | public on( 15 | key: K, 16 | listener: (event: IpcRendererEvent, ...args: Parameters) => void, 17 | ) { 18 | ipcRenderer.on(`${this.key}:${key}`, (event, ...args) => { 19 | return listener(event, ...(args as any)) 20 | }) 21 | } 22 | 23 | public once( 24 | key: K, 25 | listener: (event: IpcRendererEvent, ...args: Parameters) => void, 26 | ) { 27 | ipcRenderer.once(`${this.key}:${key}`, (event, ...args) => { 28 | return listener(event, ...(args as any)) 29 | }) 30 | } 31 | 32 | public off(key: K, listener: any) { 33 | ipcRenderer.off(`${this.key}:${key}`, listener) 34 | } 35 | 36 | public removeAllListeners(key: K) { 37 | ipcRenderer.removeAllListeners(`${this.key}:${key}`) 38 | } 39 | 40 | public invoke( 41 | key: K, 42 | ...args: Parameters 43 | ): Promise> { 44 | return ipcRenderer.invoke(`${this.key}:${key}`, ...args) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ipc/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ddPn08/flat/1ce15ac6076fab003bded7080d6ef71fe014e3d5/src/ipc/index.ts -------------------------------------------------------------------------------- /src/ipc/server.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron' 2 | 3 | export class IpcServer< 4 | StoC extends Record = Record, 5 | CtoS extends Record = Record, 6 | > { 7 | constructor(private readonly key: string) {} 8 | public emit(key: K, ...args: Parameters) { 9 | BrowserWindow.getAllWindows().forEach((window) => { 10 | window.webContents.send(`${this.key}:${key}`, ...args) 11 | }) 12 | } 13 | public handle( 14 | key: K, 15 | listener: ( 16 | event: IpcMainInvokeEvent, 17 | ...args: Parameters 18 | ) => ReturnType | Promise>, 19 | ) { 20 | ipcMain.handle(`${this.key}:${key}`, (event, ...args) => listener(event, ...(args as any))) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import path from 'path' 3 | 4 | window['__node_lib'] = { 5 | path, 6 | electron, 7 | } 8 | 9 | declare global { 10 | interface Window { 11 | __node_lib: { 12 | path: typeof path 13 | electron: typeof electron 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/scripts/webui/__inject_webui.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | 4 | const webui_url = new URL('__WEBUI_URL') 5 | 6 | if (webui_url.origin !== document.location.origin) document.location.href = webui_url.href 7 | else if (typeof window['__flat_registered'] === 'undefined') { 8 | const { ipcRenderer } = require('electron') 9 | window['__flat_registered'] = true 10 | gradioApp().addEventListener('click', (e) => { 11 | const el = e.target 12 | if (el.tagName && el.tagName === 'A') ipcRenderer.sendToHost('click_anchor', el.href) 13 | }) 14 | } 15 | 16 | function ask_for_style_name(_, prompt_text, negative_prompt_text) { 17 | const { ipcRenderer } = require('electron') 18 | return new Promise((resolve, reject) => { 19 | ipcRenderer.sendToHost('ask_for_style_name', 'Style Name') 20 | ipcRenderer.once('ask_for_style_name', (e, name) => { 21 | resolve([name, prompt_text, negative_prompt_text]) 22 | }) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/scripts/webui/send_to_webui.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | 4 | async function run() { 5 | /** 6 | * @type {string} 7 | */ 8 | const path = __PNG_PATH__ 9 | const basename = __PNG_BASENAME__ 10 | 11 | /** 12 | * @type {Document} 13 | */ 14 | const gradio = gradioApp() 15 | 16 | const res = await fetch(path) 17 | const file = new File([await res.blob()], basename) 18 | 19 | const png_info_input = gradio.getElementById('pnginfo_image').querySelector('input') 20 | const send_button = gradio.getElementById('txt2img_tab') 21 | const parameters = gradio.getElementById('tab_pnginfo').querySelectorAll('.output-html')[1] 22 | const dt = new DataTransfer() 23 | dt.items.add(file) 24 | 25 | png_info_input.files = dt.files 26 | png_info_input.dispatchEvent(new Event('change')) 27 | 28 | const png_info_tab = Array.from( 29 | gradio.getElementById('tabs').querySelectorAll(':scope > div:first-child button'), 30 | ).find((v) => v.innerText === 'PNG Info') 31 | png_info_tab.click() 32 | } 33 | run() 34 | delete run 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type BuildMeta = { 2 | version: string 3 | size: number 4 | md5: string 5 | } 6 | -------------------------------------------------------------------------------- /src/updater.ts: -------------------------------------------------------------------------------- 1 | import AdmZip from 'adm-zip' 2 | import crypto from 'crypto' 3 | import { app } from 'electron' 4 | import fs from 'fs' 5 | import fetch, { Response } from 'node-fetch' 6 | import path from 'path' 7 | import yaml from 'yaml' 8 | 9 | import { isDev } from '.' 10 | import type { BuildMeta } from './types' 11 | import packageJson from '../package.json' 12 | 13 | const BASE_RELEASE_URL = 'https://github.com/ddPn08/flat/releases/' 14 | 15 | let checking = false 16 | 17 | const fetchRelease = async (url: string): Promise => { 18 | const res = await fetch(url, { 19 | headers: { 20 | 'User-Agent': 'flat', 21 | }, 22 | }) 23 | switch (res.status) { 24 | case 302: 25 | return await fetchRelease(res.headers.get('location')!) 26 | case 200: 27 | return res 28 | } 29 | throw new Error(`Failed to fetch release: ${url} ${res.status}`) 30 | } 31 | 32 | const getMeta = async (): Promise => { 33 | const res = await fetchRelease(BASE_RELEASE_URL + 'latest/download/meta.yaml') 34 | const text = await res.text() 35 | return yaml.parse(text) 36 | } 37 | 38 | const getNext = async () => { 39 | const filepath = path.join(app.getPath('userData'), '.next', 'package.json') 40 | if (!fs.existsSync(filepath)) return 41 | 42 | const txt = await fs.promises.readFile(filepath, 'utf-8') 43 | try { 44 | return JSON.parse(txt).version 45 | } catch (_) {} 46 | } 47 | 48 | export const check = async () => { 49 | const next = await getNext() 50 | if (next) return true 51 | const meta = await getMeta() 52 | if (meta.version > packageJson.version) return true 53 | return false 54 | } 55 | 56 | export const prepare = async () => { 57 | if (checking) return 58 | checking = true 59 | const meta = await getMeta() 60 | if (meta.version === packageJson.version) return 61 | 62 | const appResponse = await fetchRelease(BASE_RELEASE_URL + 'latest/download/app.zip') 63 | const appBuf = Buffer.from(await appResponse.arrayBuffer()) 64 | const md5 = crypto.createHash('md5').update(appBuf).digest('hex') 65 | if (appBuf.byteLength !== meta.size) return 66 | if (md5 !== meta.md5) return 67 | 68 | const zip = new AdmZip(appBuf) 69 | zip.extractAllTo(path.join(app.getPath('userData'), '.next')) 70 | console.log('Update available') 71 | 72 | checking = false 73 | 74 | return 75 | } 76 | 77 | export const update = async () => { 78 | const prepared = () => fs.existsSync(path.join(app.getPath('userData'), '.next')) 79 | while (!prepared()) await prepare() 80 | if (!isDev && prepared()) { 81 | const appRoot = path.join(__dirname, '../../../') 82 | fs.rmSync(path.join(appRoot, 'resources/app'), { recursive: true }) 83 | fs.renameSync( 84 | path.join(app.getPath('userData'), '.next'), 85 | path.join(appRoot, 'resources/app'), 86 | ) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/web/app.tsx: -------------------------------------------------------------------------------- 1 | import { createI18nContext, I18nContext } from '@solid-primitives/i18n' 2 | import { css } from 'decorock' 3 | import { createSignal, onCleanup, onMount } from 'solid-js' 4 | 5 | import { GitInstall } from './components/dependencies/git-install' 6 | import { MenuButton } from './components/menu-button' 7 | import { Tabs, TabPanel } from './components/ui/tabs' 8 | import { ToastProvider } from './components/ui/toast' 9 | import { Updater } from './components/updater' 10 | import { config } from './lib/config' 11 | import { ipc } from './lib/ipc' 12 | import { Gallery } from './pages/gallery' 13 | import { General } from './pages/general' 14 | import { WebUI } from './pages/webui' 15 | import { ThemeProvider } from './styles' 16 | 17 | import { dict } from '~i18n/index' 18 | 19 | const value = createI18nContext(dict, config['system/lang']) 20 | 21 | export const App = () => { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | const PAGES = { 34 | General, 35 | Gallery, 36 | WebUI, 37 | } 38 | 39 | const Index = () => { 40 | const [isOpen, setIsOpen] = createSignal(true) 41 | const [current, setCurrent] = createSignal('General') 42 | 43 | onMount(() => { 44 | ipc.system.local.on('main-tab/change', setCurrent) 45 | }) 46 | onCleanup(() => { 47 | ipc.system.local.off('main-tab/change', setCurrent) 48 | }) 49 | 50 | return ( 51 | <> 52 | 53 | 54 | 55 | { 62 | return ( 63 | 72 | 73 | 74 | ) 75 | }} 76 | /> 77 | 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/web/components/dependencies/conda-install.tsx: -------------------------------------------------------------------------------- 1 | import { css, styled, useTheme } from 'decorock' 2 | import { Component, createSignal, onCleanup, onMount } from 'solid-js' 3 | 4 | import { ipc } from '../../lib/ipc' 5 | import { Log } from '../log' 6 | import { Modal } from '../modal' 7 | 8 | const Container = styled.div`` 9 | 10 | export const CondaInstall: Component = () => { 11 | const theme = useTheme() 12 | const [isOpen, setIsOpen] = createSignal(false) 13 | const [message, setMessage] = createSignal('Checking conda environment...') 14 | const [logs, setLogs] = createSignal([]) 15 | 16 | const onLog = (_: any, log: string) => { 17 | setLogs((prev) => [...prev, ...log.split('\n')]) 18 | } 19 | 20 | onMount(async () => { 21 | const isInstalled = await ipc.conda.invoke('env/installed') 22 | if (isInstalled) return setIsOpen(false) 23 | setIsOpen(true) 24 | setMessage('Installing miniconda3 ...') 25 | ipc.conda.invoke('install/start') 26 | ipc.conda.on('install/log', onLog) 27 | ipc.conda.once('install/close', () => { 28 | setIsOpen(false) 29 | }) 30 | }) 31 | 32 | onCleanup(() => { 33 | ipc.conda.off('install/log', onLog) 34 | }) 35 | 36 | return ( 37 | 38 | setIsOpen(false)}> 39 |

46 | {message()} 47 |

48 |
49 | {logs()} 50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/web/components/dependencies/git-install.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@solid-primitives/i18n' 2 | import { css, styled, useTheme } from 'decorock' 3 | import { Component, createSignal, Match, onCleanup, onMount, Show, Switch } from 'solid-js' 4 | 5 | import { CondaInstall } from './conda-install' 6 | import { ipc } from '../../lib/ipc' 7 | import { Log } from '../log' 8 | import { Modal } from '../modal' 9 | import { Button } from '../ui/button' 10 | 11 | const Container = styled.div`` 12 | 13 | export const GitInstall: Component = () => { 14 | const theme = useTheme() 15 | const [t] = useI18n() 16 | const [isOpen, setIsOpen] = createSignal(false) 17 | const [installed, setInstalled] = createSignal(false) 18 | const [errorInstallation, setErrorInstallation] = createSignal(false) 19 | const [finishInstallation, setFinishInstallation] = createSignal(false) 20 | const [installing, setInstalling] = createSignal(false) 21 | const [logs, setLogs] = createSignal([]) 22 | 23 | const onLog = (_: any, log: string) => { 24 | setLogs((prev) => [...prev, ...log.split('\n')]) 25 | } 26 | 27 | onMount(async () => { 28 | const isInstalled = await ipc.git.invoke('check') 29 | setInstalled(isInstalled) 30 | if (isInstalled) return setIsOpen(false) 31 | setIsOpen(true) 32 | }) 33 | 34 | onCleanup(() => { 35 | ipc.git.off('log', onLog) 36 | }) 37 | 38 | return ( 39 | 40 | setIsOpen(false)}> 41 | 42 | 43 |

Installing Git...

44 | {logs()} 45 |
46 | 47 |

54 | {t('system/git-install-error/title')} 55 |

56 |

{t('system/git-install-error/description')}

57 |
58 | 59 |

66 | {t('system/git-install/title')} 67 |

68 |

{t('system/git-install/description')}

69 |
70 | 84 |
85 | 86 |

{t('system/git-install-finish/title')}

87 |

{t('system/git-install-finish/description')}

88 | 95 |
96 |
97 |
98 | 99 | 100 | 101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /src/web/components/log.tsx: -------------------------------------------------------------------------------- 1 | import Color from 'color' 2 | import { css, styled } from 'decorock' 3 | import { on, Component, createEffect, createSignal, For, Show } from 'solid-js' 4 | 5 | import { classnames } from '~/web/lib/classnames' 6 | 7 | const Container = styled.div` 8 | overflow: auto; 9 | padding: 0.5rem 1rem; 10 | background-color: ${(p) => p.theme.colors.primary.fade(0.85)}; 11 | 12 | p { 13 | font-family: 'Roboto Mono', 'Noto Sans JP', monospace; 14 | font-size: 0.9rem; 15 | white-space: nowrap; 16 | } 17 | 18 | a { 19 | color: ${(p) => 20 | p.theme.name === 'light' 21 | ? Color('rgb(0, 255, 255)').darken(0.25) 22 | : Color('rgb(0, 255, 255)')}; 23 | text-underline-offset: 2px; 24 | &:hover { 25 | text-decoration: underline; 26 | } 27 | } 28 | ` 29 | 30 | export const Log: Component<{ 31 | children: string[] 32 | class?: string | undefined 33 | height?: number | string 34 | autoScroll?: boolean 35 | }> = (props) => { 36 | const [ref, setRef] = createSignal() 37 | const [onBottom, setOnBottom] = createSignal(true) 38 | 39 | createEffect( 40 | on( 41 | () => props.children, 42 | () => { 43 | const el = ref()! 44 | if (typeof props.autoScroll !== 'undefined' && !props.autoScroll) return 45 | if (onBottom()) el.scrollTop = el.scrollHeight 46 | }, 47 | ), 48 | ) 49 | 50 | return ( 51 | { 65 | setOnBottom( 66 | e.currentTarget.scrollHeight - e.currentTarget.scrollTop === e.currentTarget.clientHeight, 67 | ) 68 | }} 69 | > 70 | 71 | {(log) => { 72 | const logs = log.split(/(http:\/\/[^\s]+)/g) 73 | return ( 74 |

75 | 76 | {(t) => ( 77 | 78 | {t} 79 | 80 | )} 81 | 82 |

83 | ) 84 | }} 85 |
86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/web/components/menu-button.tsx: -------------------------------------------------------------------------------- 1 | import { css, useTheme } from 'decorock' 2 | import { Component, createEffect, createSignal, on } from 'solid-js' 3 | 4 | import IconMenu from '~icons/material-symbols/menu' 5 | 6 | export const MenuButton: Component<{ onChange: (open: boolean) => void }> = (props) => { 7 | const theme = useTheme() 8 | const [hover, setHover] = createSignal(false) 9 | const [isOpen, setIsOpen] = createSignal(true) 10 | 11 | createEffect(on(isOpen, (open) => props.onChange(open))) 12 | 13 | return ( 14 |
26 |
33 |
setIsOpen(!isOpen())} 61 | > 62 | 63 |
64 |
65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/web/components/modal.tsx: -------------------------------------------------------------------------------- 1 | import { css, styled } from 'decorock' 2 | import { Component, createEffect, createSignal, JSX, on } from 'solid-js' 3 | 4 | import { useFloating } from '../hooks/use-floating' 5 | 6 | const Background = styled.div` 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | width: 100vw; 11 | height: 100vh; 12 | background-color: rgba(0, 0, 0, 0.5); 13 | ` 14 | 15 | const Container = styled.div` 16 | position: fixed; 17 | z-index: 100; 18 | top: 0; 19 | left: 0; 20 | display: flex; 21 | width: 100vw; 22 | height: 100vh; 23 | align-items: center; 24 | justify-content: center; 25 | ` 26 | export const ModalPanel = styled.div` 27 | overflow: hidden; 28 | width: 80%; 29 | max-height: 100%; 30 | padding: 1.5rem; 31 | border-radius: 1rem; 32 | margin: 2rem 0; 33 | background-color: ${(p) => p.theme.colors.secondary}; 34 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); 35 | transition-property: all; 36 | ` 37 | 38 | export const Modal: Component<{ 39 | children: JSX.Element 40 | isOpen: boolean 41 | onClose: () => void 42 | transition?: string 43 | closable?: boolean 44 | }> = (props) => { 45 | const [ref, setRef] = createSignal() 46 | // eslint-disable-next-line solid/reactivity 47 | const [isOpen, setIsOpen] = useFloating(ref as any, !props.closable) 48 | 49 | createEffect( 50 | on( 51 | () => props.isOpen, 52 | (isOpen) => { 53 | setIsOpen(isOpen) 54 | }, 55 | ), 56 | ) 57 | createEffect( 58 | on(isOpen, (isOpen) => { 59 | if (!isOpen) props.onClose() 60 | }), 61 | ) 62 | 63 | return ( 64 | <> 65 | 72 | 80 | {props.children} 81 | 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/web/components/process.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from 'decorock' 2 | import type { Component } from 'solid-js' 3 | 4 | const Container = styled.div`` 5 | 6 | export const Process: Component<{ 7 | progress: number 8 | message: string 9 | }> = (props) => { 10 | return ( 11 | 12 |

{props.message}

13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/web/components/ui/auto-complete.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from 'decorock' 2 | import { 3 | Component, 4 | createEffect, 5 | createReaction, 6 | createSignal, 7 | For, 8 | onCleanup, 9 | onMount, 10 | Show, 11 | } from 'solid-js' 12 | import { isServer } from 'solid-js/web' 13 | 14 | import { Input } from './input' 15 | 16 | type PropsT = { 17 | suggestions: ((value: string) => Promise | string[]) | string[] 18 | onInput?: (value: string) => void 19 | onChange?: (value: string) => void 20 | value: string 21 | limit?: number 22 | confirmKey?: string[] 23 | 24 | placeholder?: string 25 | class?: string 26 | error?: string | undefined 27 | } 28 | 29 | const Container = styled.div` 30 | position: relative; 31 | ` 32 | 33 | const Suggestions = styled.div` 34 | position: absolute; 35 | z-index: 5; 36 | top: 100%; 37 | left: 0; 38 | display: flex; 39 | overflow: hidden; 40 | width: 100%; 41 | max-height: 330px; 42 | flex-direction: column; 43 | padding: 0.5rem; 44 | border-radius: 0.25rem; 45 | margin-top: 0.5rem; 46 | background-color: ${(p) => p.theme.colors.secondary}; 47 | box-shadow: 0 0 16px -6px rgba(0, 0, 0, 0.6); 48 | overflow-y: auto; 49 | ` 50 | 51 | const Item = styled.div<{ selecting: boolean }>` 52 | width: 100%; 53 | padding: 0.5rem; 54 | background-color: ${(p) => 55 | p.selecting ? p.theme.colors.secondary.darken(0.25) : p.theme.colors.secondary}; 56 | cursor: pointer; 57 | text-align: left; 58 | transition: 0.25s; 59 | 60 | &:hover { 61 | background-color: ${(p) => p.theme.colors.secondary.darken(0.25)}; 62 | } 63 | ` 64 | 65 | export const AutoComplete: Component = (props) => { 66 | const [suggestions, setSuggestions] = createSignal([]) 67 | const [currentIndex, setCurrentIndex] = createSignal(-1) 68 | const [inputting, setInputting] = createSignal(false) 69 | const [task, setTask] = createSignal(0) 70 | 71 | const track = createReaction(() => { 72 | track(() => props.value) 73 | clearInterval(task()) 74 | setTask(0) 75 | }) 76 | track(() => props.value) 77 | 78 | createEffect(() => { 79 | if (Array.isArray(props.suggestions)) { 80 | setSuggestions( 81 | props.suggestions.filter( 82 | (v) => !props.value || v.match(new RegExp('^' + props.value, 'i')), 83 | ), 84 | ) 85 | } else { 86 | if (task() === 0) 87 | setTask( 88 | setTimeout(async () => { 89 | if (typeof props.suggestions === 'function') 90 | setSuggestions(await props.suggestions(props.value)) 91 | }, 500) as any, 92 | ) 93 | } 94 | }) 95 | 96 | let ref: HTMLDivElement 97 | 98 | const listener = (e: MouseEvent) => { 99 | const isThis = ref === e.target || ref.contains(e.target as Node) 100 | if (inputting() && !isThis) setInputting(false) 101 | } 102 | onMount(() => { 103 | if (!isServer) window.addEventListener('click', listener) 104 | }) 105 | onCleanup(() => { 106 | if (!isServer) window.removeEventListener('click', listener) 107 | }) 108 | 109 | return ( 110 | { 114 | if (e.key === 'Tab') { 115 | if (e.isComposing) return 116 | e.preventDefault() 117 | if (!inputting()) setInputting(true) 118 | if (currentIndex() >= suggestions().length) setCurrentIndex(-1) 119 | else setCurrentIndex(currentIndex() + 1) 120 | } 121 | if (props.confirmKey?.includes(e.key) || e.key === 'Enter') { 122 | if (currentIndex() === -1) { 123 | props.onChange?.(props.value) 124 | return 125 | } 126 | e.preventDefault() 127 | props.onInput?.(suggestions()[currentIndex()] || '') 128 | setCurrentIndex(-1) 129 | setInputting(false) 130 | } 131 | }} 132 | > 133 | { 137 | setInputting(true) 138 | // eslint-disable-next-line no-irregular-whitespace 139 | props.onInput?.(e.currentTarget.value.trim().replace(/ /g, '')) 140 | }} 141 | onFocusIn={() => setInputting(true)} 142 | error={props.error} 143 | /> 144 | 0}> 145 | 146 | 147 | {(value, i) => ( 148 | { 151 | setInputting(false) 152 | props.onInput?.(value) 153 | props.onChange?.(value) 154 | }} 155 | > 156 | {value} 157 | 158 | )} 159 | 160 | 161 | 162 | 163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /src/web/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { css, styled } from 'decorock' 2 | import { 3 | Component, 4 | ComponentProps, 5 | createMemo, 6 | createSignal, 7 | For, 8 | Show, 9 | splitProps, 10 | } from 'solid-js' 11 | 12 | import { CircleSpinner } from './spinner' 13 | 14 | const StyledButton = styled.button<{ _padding: boolean }>` 15 | position: relative; 16 | ${(p) => (p._padding ? '' : 'padding: 0.75rem 1.5rem;')} 17 | 18 | border: none; 19 | border-radius: 1rem; 20 | margin: 0.5rem; 21 | background-color: ${(p) => p.theme.colors.primary.fade(0.8)}; 22 | color: ${(p) => p.theme.colors.primary}; 23 | cursor: pointer; 24 | font-size: medium; 25 | font-weight: bold; 26 | outline: none; 27 | text-align: center; 28 | text-decoration: none; 29 | transition: 0.2s; 30 | 31 | &:disabled { 32 | pointer-events: none; 33 | } 34 | 35 | &:hover { 36 | background-color: ${(p) => p.theme.colors.primary.fade(0.7)}; 37 | } 38 | 39 | &:active { 40 | div { 41 | color: ${(p) => p.theme.colors.primary.lighten(0.7)}; 42 | } 43 | 44 | background-color: ${(p) => p.theme.colors.primary.darken(0.75).fade(0.75)}; 45 | } 46 | ` 47 | 48 | const Inner = styled.div<{ disabled: boolean }>` 49 | width: 100%; 50 | height: 100%; 51 | color: ${(p) => p.theme.colors.primary.fade(p.disabled ? 0.5 : 0)}; 52 | ` 53 | 54 | const Status = styled.div` 55 | margin-top: 0.5rem; 56 | color: ${(p) => p.theme.colors.primary.fade(0.5)}; 57 | font-size: 0.9rem; 58 | ` 59 | 60 | export const Button: Component< 61 | ComponentProps<'button'> & { 62 | task?: ( 63 | e: MouseEvent & { 64 | currentTarget: HTMLButtonElement 65 | target: Element 66 | }, 67 | ) => any 68 | loading?: boolean 69 | status?: string | undefined 70 | _padding?: boolean | undefined 71 | } 72 | > = (props) => { 73 | const [local, others] = splitProps(props, ['children', 'loading', 'status', 'onClick', 'task']) 74 | const [running, setRunning] = createSignal(false) 75 | const loading = createMemo(() => local.loading || running()) 76 | return ( 77 | { 81 | if (loading()) return e.preventDefault() 82 | if (typeof local.task === 'function') { 83 | setRunning(true) 84 | const t = local.task(e) 85 | if (typeof t !== 'function' && t.then) t.then(() => setRunning(false)) 86 | } 87 | if (typeof local.onClick === 'function') local.onClick(e) 88 | }} 89 | > 90 | 98 | }> 99 | {local.children} 100 | 101 | 102 | 103 | 104 | 105 | {(line, i) => ( 106 | <> 107 | 108 |
109 |
110 | {line} 111 | 112 | )} 113 |
114 |
115 |
116 |
117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/web/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from 'decorock' 2 | import { Component, ComponentProps, splitProps } from 'solid-js' 3 | 4 | const Container = styled.div` 5 | display: inline-flex; 6 | align-items: center; 7 | justify-content: center; 8 | cursor: pointer; 9 | gap: 1rem; 10 | 11 | p { 12 | font-size: 1rem; 13 | font-weight: bold; 14 | user-select: none; 15 | } 16 | 17 | input { 18 | cursor: pointer; 19 | transform: scale(1.5); 20 | } 21 | ` 22 | 23 | const Input: Component, 'type'>> = (props) => { 24 | const [local, others] = splitProps(props, ['children', 'class', 'ref']) 25 | // eslint-disable-next-line prefer-const 26 | let ref = local.ref as HTMLInputElement 27 | return ( 28 | { 31 | if (ref !== e.target) ref.click() 32 | }} 33 | > 34 | 35 |

{local.children}

36 |
37 | ) 38 | } 39 | 40 | export const CheckBox = styled(Input)`` 41 | -------------------------------------------------------------------------------- /src/web/components/ui/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'decorock' 2 | import { Component, ComponentProps, splitProps } from 'solid-js' 3 | 4 | import { Button } from './button' 5 | 6 | import { classnames } from '~/web/lib/classnames' 7 | 8 | export const IconButton: Component> = (props) => { 9 | const [local, others] = splitProps(props, ['class']) 10 | return ( 11 | 72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/web/pages/gallery/image.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@solid-primitives/i18n' 2 | import dayjs from 'dayjs' 3 | import { css, useTheme } from 'decorock' 4 | import { Component, createSignal, Show } from 'solid-js' 5 | 6 | import type { ImageData } from '~/features/gallery/types' 7 | import send_to_webui from '~/scripts/webui/send_to_webui.js?raw' 8 | import { Modal } from '~/web/components/modal' 9 | import { Button } from '~/web/components/ui/button' 10 | import { IconButton } from '~/web/components/ui/icon-button' 11 | import { HStack } from '~/web/components/ui/stack' 12 | import { ipc } from '~/web/lib/ipc' 13 | import { shell } from '~/web/lib/node/electron' 14 | import IconClose from '~icons/material-symbols/close' 15 | import IconFavorite from '~icons/material-symbols/favorite' 16 | import IconFavoriteOutline from '~icons/material-symbols/favorite-outline' 17 | 18 | const Info: Component<{ label: string; value?: string | number | undefined }> = (props) => ( 19 | 20 | {(v) => ( 21 | <> 22 |
23 | {props.label}: {v} 24 |
25 | 26 | )} 27 |
28 | ) 29 | 30 | export const Image: Component< 31 | ImageData & { 32 | dir: string 33 | zoom: number 34 | } 35 | > = (props) => { 36 | const [t] = useI18n() 37 | const theme = useTheme() 38 | const [isOpen, setIsOpen] = createSignal(false) 39 | const [fav, setFav] = createSignal(null) 40 | 41 | const toggleFav = () => { 42 | if (fav() ?? props.favorite) { 43 | ipc.gallery.invoke('favorite/remove', props.dir, props.filepath) 44 | setFav(false) 45 | } else { 46 | ipc.gallery.invoke('favorite/add', props.dir, props.filepath) 47 | setFav(true) 48 | } 49 | } 50 | 51 | return ( 52 | <> 53 |
64 | setIsOpen(true)} 76 | /> 77 |
div { 92 | margin-bottom: 0.5rem; 93 | font-size: 0.9rem; 94 | } 95 | `} 96 | > 97 |
98 |

{props.info.prompt?.slice(0, Math.ceil(50 * (props.zoom * props.zoom)))}...

99 |
100 |
101 | 102 | {(model) =>
Model: {model}
} 103 |
104 |
{dayjs(props.created_at).format('YYYY-MM/DD HH:mm')}
105 | 106 | }> 107 | 108 | 109 | 110 |
111 |
112 |
113 |
119 | setIsOpen(false)} closable> 120 | setIsOpen(false)}> 121 | 122 | 123 |
140 |
141 | 142 |
143 |
div { 146 | margin-bottom: 1rem; 147 | font-size: 1rem; 148 | } 149 | 150 | span { 151 | font-weight: bold; 152 | } 153 | 154 | height: 100%; 155 | overflow-y: auto; 156 | `} 157 | > 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 |
169 |
{dayjs(props.created_at).format('YYYY-MM/DD HH:mm')}
170 |
171 | 172 | 179 | 193 | 194 | }> 195 | 196 | 197 | 198 | 199 |
200 |
201 |
202 |
203 | 204 | ) 205 | } 206 | -------------------------------------------------------------------------------- /src/web/pages/gallery/images.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@solid-primitives/i18n' 2 | import { css, styled } from 'decorock' 3 | import { createEffect, createSignal, For, on, onMount, Show } from 'solid-js' 4 | import { createStore } from 'solid-js/store' 5 | 6 | import { Image } from './image' 7 | 8 | import type { ImageData, ImageSearchOptions } from '~/features/gallery/types' 9 | import { IconButton } from '~/web/components/ui/icon-button' 10 | import { Input } from '~/web/components/ui/input' 11 | import { Select } from '~/web/components/ui/select' 12 | import { RectSpinner } from '~/web/components/ui/spinner' 13 | import { HStack } from '~/web/components/ui/stack' 14 | import { Tab, TabGroup, TabList } from '~/web/components/ui/tabs' 15 | import { config } from '~/web/lib/config' 16 | import { ipc } from '~/web/lib/ipc' 17 | import { serialize } from '~/web/lib/store-serialize' 18 | import IconSearch from '~icons/material-symbols/search' 19 | 20 | const RangeInput = styled.input` 21 | width: 20%; 22 | height: 5px; 23 | border-radius: 6px; 24 | appearance: none; 25 | background-color: #fff; 26 | 27 | &:focus, 28 | &:active { 29 | outline: none; 30 | } 31 | 32 | &::-webkit-slider-thumb { 33 | position: relative; 34 | display: block; 35 | width: 22px; 36 | height: 22px; 37 | border: 2px solid rgba(0, 0, 0, 0.7); 38 | border-radius: 50%; 39 | appearance: none; 40 | background-color: #fff; 41 | cursor: pointer; 42 | } 43 | ` 44 | 45 | export const Images = () => { 46 | const [t] = useI18n() 47 | const [dir, setDir] = createSignal( 48 | config['gallery/dirs'].length > 0 ? config['gallery/dirs'][0]! : '', 49 | ) 50 | const [options, setOptions] = createStore({ 51 | limit: 50, 52 | latest: true, 53 | info: {}, 54 | }) 55 | const [images, setImages] = createSignal([]) 56 | const [zoom, setZoom] = createSignal(1) 57 | const [fetching, setFetching] = createSignal(false) 58 | const [complete, setComplete] = createSignal(false) 59 | const [tab, setTab] = createSignal<'all' | 'favorites'>('all') 60 | 61 | const fetch = async (force?: boolean | undefined) => { 62 | if (!force && (fetching() || complete())) return 63 | setFetching(true) 64 | const v = await ipc.gallery.invoke( 65 | 'images/get', 66 | dir(), 67 | serialize({ since: images().length, ...options }), 68 | ) 69 | if (v.length < 50) setComplete(true) 70 | setFetching(false) 71 | setImages((prev) => [...prev, ...v]) 72 | } 73 | 74 | const refetch = async () => { 75 | setImages([]) 76 | setComplete(false) 77 | setFetching(true) 78 | await ipc.gallery.invoke('images/glob', dir()) 79 | fetch(true) 80 | } 81 | 82 | createEffect( 83 | on(tab, (tab) => { 84 | if (tab === 'all') setOptions('favorite', undefined) 85 | else if (tab === 'favorites') setOptions('favorite', true) 86 | refetch() 87 | }), 88 | ) 89 | 90 | onMount(refetch) 91 | 92 | return ( 93 | <> 94 | 95 | 96 | setTab('all')}> 97 | {t('gallery/tabs/all')} 98 | 99 | setTab('favorites')}> 100 | {t('gallery/tabs/favorites')} 101 | 102 | 103 |
111 |
{ 117 | if (e.key === 'Enter') refetch() 118 | }} 119 | > 120 | 126 | 132 | setOptions('info', 'prompt', e.currentTarget.value)} 136 | /> 137 | setOptions('info', 'model', e.currentTarget.value)} 141 | /> 142 | refetch()}> 143 | 144 | 145 | 146 | { 153 | setZoom(parseFloat(e.currentTarget.value)) 154 | }} 155 | /> 156 | 157 | { 148 | setConfig('system/lang', option.value as Config['system/lang']) 149 | locale(option.value) 150 | }} 151 | /> 152 | 153 |
154 | 155 | 156 | setConfig('webui/args/ckpt-dir', e.currentTarget.value)} 81 | /> 82 |
83 | 84 | 85 | setConfig('webui/args/vae-dir', e.currentTarget.value)} 88 | /> 89 |
90 | 91 | 92 | setConfig('webui/args/hypernetwork-dir', e.currentTarget.value)} 95 | /> 96 |
97 | 98 | 99 | setConfig('webui/args/embeddings-dir', e.currentTarget.value)} 102 | /> 103 |
104 | 105 | 106 | 107 |
108 | setConfig('webui/args/xformers', e.currentTarget.checked)} 111 | /> 112 |
113 |
114 | 115 | 116 | setConfig('webui/args/custom', e.currentTarget.value)} 119 | /> 120 |
121 | 122 | 123 | setConfig('webui/settings/env', e.currentTarget.value)} 126 | /> 127 |
128 | 129 | 130 | 138 | {/* */} 139 | 140 | 141 | setIsOpen(false)} closable> 142 | 143 | setIsOpen(false)} /> 144 | 145 | 146 | 147 | ) 148 | } 149 | -------------------------------------------------------------------------------- /src/web/pages/webui/ui-config.tsx: -------------------------------------------------------------------------------- 1 | import { css, useTheme } from 'decorock' 2 | import { editor } from 'monaco-editor' 3 | import { Component, createSignal, onMount } from 'solid-js' 4 | 5 | import { ipc } from '~/web/lib/ipc' 6 | 7 | export const UIConfig: Component = () => { 8 | const theme = useTheme() 9 | const [ref, setRef] = createSignal() 10 | 11 | onMount(async () => { 12 | const value = await ipc.webui.invoke('ui-config/get') 13 | const ctx = editor.create(ref()!, { 14 | value, 15 | language: 'json', 16 | theme: theme.name === 'dark' ? 'vs-dark' : 'vs-light', 17 | }) 18 | ctx.onDidChangeModelContent((e) => { 19 | const json = ctx.getValue() 20 | try { 21 | JSON.parse(json) 22 | ipc.webui.invoke('ui-config/save', json) 23 | } catch (_) {} 24 | }) 25 | }) 26 | 27 | return ( 28 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/web/pages/webui/ui.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@solid-primitives/i18n' 2 | import { css, styled } from 'decorock' 3 | import { 4 | Component, 5 | createEffect, 6 | createSignal, 7 | on, 8 | onCleanup, 9 | onMount, 10 | Show, 11 | useContext, 12 | } from 'solid-js' 13 | import { createStore } from 'solid-js/store' 14 | 15 | import { WebUIContext } from '.' 16 | 17 | import __inject_webui from '~/scripts/webui/__inject_webui.js?raw' 18 | import { Modal } from '~/web/components/modal' 19 | import { Button } from '~/web/components/ui/button' 20 | import { Input } from '~/web/components/ui/input' 21 | import { VStack } from '~/web/components/ui/stack' 22 | import { ipc } from '~/web/lib/ipc' 23 | import { shell } from '~/web/lib/node/electron' 24 | 25 | const Container = styled.div` 26 | height: 100%; 27 | 28 | webview { 29 | width: 100%; 30 | height: 100%; 31 | border: none; 32 | vertical-align: bottom; 33 | } 34 | ` 35 | 36 | export const UI: Component = () => { 37 | const { url } = useContext(WebUIContext) 38 | const [ref, setRef] = createSignal() 39 | const [promptData, setPromptData] = createStore({ 40 | open: false, 41 | title: '', 42 | value: '', 43 | fin: false, 44 | cancel: false, 45 | }) 46 | const [t] = useI18n() 47 | 48 | const prompt = (title: string) => { 49 | return new Promise((resolve, reject) => { 50 | setPromptData({ 51 | open: true, 52 | title, 53 | fin: false, 54 | cancel: false, 55 | }) 56 | setInterval(() => { 57 | if (!promptData.fin && !promptData.cancel) return 58 | if (promptData.cancel) reject() 59 | const value = promptData.value.toString() 60 | setPromptData({ 61 | open: false, 62 | title: '', 63 | value: '', 64 | fin: false, 65 | cancel: false, 66 | }) 67 | resolve(value) 68 | }, 100) 69 | }) 70 | } 71 | 72 | const evalWebView = (js: string) => { 73 | const webview = ref()! 74 | webview.executeJavaScript(js) 75 | } 76 | 77 | const setup = (url: string) => { 78 | if (!url) return 79 | const webview = ref()! 80 | webview.addEventListener('ipc-message', async (e) => { 81 | switch (e.channel) { 82 | case 'click_anchor': { 83 | const url = e.args[0] 84 | shell.openExternal(url) 85 | break 86 | } 87 | case 'ask_for_style_name': { 88 | try { 89 | const name = await prompt(e.args[0]) 90 | webview.send('ask_for_style_name', name) 91 | } catch (_) { 92 | webview.send('ask_for_style_name', null) 93 | } 94 | break 95 | } 96 | } 97 | }) 98 | webview.addEventListener('dom-ready', () => { 99 | webview.executeJavaScript(__inject_webui.replace('__WEBUI_URL', url)) 100 | }) 101 | } 102 | 103 | onMount(() => { 104 | ipc.webui.local.on('webui/eval', evalWebView) 105 | }) 106 | 107 | onCleanup(() => { 108 | ipc.webui.local.off('webui/eval', evalWebView) 109 | }) 110 | 111 | createEffect(on(url, setup)) 112 | 113 | return ( 114 | 115 | setPromptData('open', false)}> 116 |

{promptData.title}

117 |
118 | setPromptData('value', e.currentTarget.value)} 121 | /> 122 |
123 | 130 | 137 |
138 | 148 |

{t('webui/launcher/not-running/title')}

149 | 150 | } 151 | > 152 | 159 |
160 |
161 | ) 162 | } 163 | 164 | declare module 'solid-js' { 165 | export namespace JSX { 166 | export interface IntrinsicElements 167 | extends HTMLElementTags, 168 | HTMLElementDeprecatedTags, 169 | SVGElementTags { 170 | webview: Partial< 171 | Electron.WebviewTag & 172 | HTMLElement & { 173 | ref: IntrinsicElements['webview'] | ((el: IntrinsicElements['webview']) => void) 174 | } 175 | > 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/web/styles.tsx: -------------------------------------------------------------------------------- 1 | import Color from 'color' 2 | import { createThemeStore, DecoRockProvider, type DefaultTheme } from 'decorock' 3 | import { Component, createEffect, JSX } from 'solid-js' 4 | 5 | import './global.css' 6 | import { config } from './lib/config' 7 | 8 | const themes: Record<'dark' | 'light', DefaultTheme> = { 9 | dark: { 10 | name: 'dark', 11 | colors: { 12 | primary: Color('#ddd'), 13 | secondary: Color('#333'), 14 | }, 15 | }, 16 | light: { 17 | name: 'light', 18 | colors: { 19 | primary: Color('#333'), 20 | secondary: Color('#ddd'), 21 | }, 22 | }, 23 | } 24 | 25 | export const [theme, setTheme] = createThemeStore({ ...themes[config['system/theme']] }) 26 | 27 | const GlobalStyles: Component = () => { 28 | return ( 29 |