├── .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 | 
45 | 
46 | 
47 |
48 |
49 |
50 | ## Image gallery
51 |
52 | 生成した画像をプロンプト等の情報とともに一覧に表示します。
53 |
54 | 画像のフォルダは自由に設定できます。
55 |
56 | 
57 | 
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 | 
43 | 
44 | 
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 | 
52 | 
53 |
--------------------------------------------------------------------------------
/assets/download.svg:
--------------------------------------------------------------------------------
1 |
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 | 
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 | 
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 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/web/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import Color from 'color'
2 | import { css, styled } from 'decorock'
3 | import { Component, ComponentProps, createEffect, createSignal, Show, splitProps } from 'solid-js'
4 |
5 | import { Required } from './required'
6 |
7 | const Container = styled.div`
8 | width: 100%;
9 | text-align: left;
10 | `
11 |
12 | const StyledInput = styled.input`
13 | display: inline-block;
14 | width: 100%;
15 | box-sizing: border-box;
16 | padding: 0.5rem;
17 | border: 1px solid ${(p) => p.theme.colors.primary.fade(0.5).string()};
18 | border-radius: 0.5rem;
19 | background-color: rgba(0, 0, 0, 0.05);
20 | color: ${(p) => (p.disabled ? p.theme.colors.primary.fade(0.25) : p.theme.colors.primary)};
21 | font-size: 1rem;
22 | outline: none;
23 |
24 | &:focus {
25 | border: 1px solid ${(p) => p.theme.colors.primary.darken(0.25).string()};
26 | }
27 | `
28 | export const Input: Component<
29 | ComponentProps<'input'> & {
30 | error?: boolean | string | undefined
31 | }
32 | > = (props) => {
33 | const [local, others] = splitProps(props, ['class', 'error', 'onInput'])
34 | const [changed, setChanged] = createSignal(false)
35 | createEffect(() => {
36 | if (props.error) setChanged(false)
37 | })
38 | return (
39 |
40 |
41 | 必須
42 |
43 | {
52 | setChanged(true)
53 | if (typeof local.onInput === 'function') local.onInput(e)
54 | }}
55 | {...others}
56 | />
57 |
58 |
63 | {typeof local.error === 'string' ? local.error : ''}
64 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/web/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from 'decorock'
2 |
3 | export const Label = styled.div`
4 | font-family: 'Roboto Mono', 'Noto Sans JP', monospace;
5 | `
6 |
--------------------------------------------------------------------------------
/src/web/components/ui/required.tsx:
--------------------------------------------------------------------------------
1 | import Color from 'color'
2 | import { styled } from 'decorock'
3 | import { Component, ComponentProps, splitProps } from 'solid-js'
4 |
5 | export const Container = styled.label`
6 | display: inline-block;
7 | padding: 0.25rem 0.5rem;
8 | border-radius: 0.5rem;
9 | margin-bottom: 0.5rem;
10 | background-color: ${() => Color('red').lighten(0.25)};
11 | color: white;
12 | font-size: 0.75rem;
13 | font-weight: 400;
14 | `
15 | export const Required: Component> = (props) => {
16 | const [local, others] = splitProps(props, ['children'])
17 | return {local.children || '必須'}
18 | }
19 |
--------------------------------------------------------------------------------
/src/web/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import { css, styled, useTheme } from 'decorock'
2 | import { Component, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
3 | import { isServer } from 'solid-js/web'
4 |
5 | type Option = {
6 | value: string
7 | label: string
8 | }
9 |
10 | const Container = styled.div`
11 | position: relative;
12 | `
13 |
14 | const Suggestions = styled.div`
15 | position: absolute;
16 | z-index: 5;
17 | top: 100%;
18 | left: 0;
19 | display: flex;
20 | overflow: hidden;
21 | width: 100%;
22 | max-height: 330px;
23 | flex-direction: column;
24 | padding: 0.5rem;
25 | border-radius: 0.25rem;
26 | margin-top: 0.5rem;
27 | background-color: ${(p) => p.theme.colors.secondary};
28 | box-shadow: 0 0 16px -6px rgba(0, 0, 0, 0.6);
29 | overflow-y: auto;
30 | `
31 |
32 | const Item = styled.div<{ selecting: boolean }>`
33 | width: 100%;
34 | padding: 0.5rem;
35 | background-color: ${(p) => p.theme.colors.secondary};
36 | cursor: pointer;
37 | text-align: left;
38 | transition: 0.25s;
39 |
40 | &:hover {
41 | background-color: ${(p) => p.theme.colors.secondary.darken(0.25)};
42 | }
43 | `
44 |
45 | export const Select: Component<{
46 | options: Option[]
47 | value: string
48 | onChange?: (option: Option) => void
49 | }> = (props) => {
50 | const theme = useTheme()
51 | const [ref, setRef] = createSignal()
52 | const [selecting, setSelecting] = createSignal(false)
53 | const listener = (e: MouseEvent) => {
54 | const el = ref()!
55 | const isThis = el === e.target || el.contains(e.target as Node)
56 | if (selecting() && !isThis) setSelecting(false)
57 | }
58 | onMount(() => {
59 | if (!isServer) window.addEventListener('click', listener)
60 | })
61 | onCleanup(() => {
62 | if (!isServer) window.removeEventListener('click', listener)
63 | })
64 | const current = createMemo(() => props.options.find((v) => v.value === props.value))
65 |
66 | return (
67 |
68 | setSelecting(!selecting())}
82 | >
83 | {current()?.label}
84 |
85 |
86 |
87 |
88 | {(value, i) => (
89 | - v.value === props.value)}
91 | onClick={() => {
92 | setSelecting(false)
93 | props.onChange?.(value)
94 | }}
95 | >
96 | {value.label}
97 |
98 | )}
99 |
100 |
101 |
102 |
103 | )
104 | }
105 |
--------------------------------------------------------------------------------
/src/web/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from 'decorock'
2 |
3 | const RectSpinnerContainer = styled.div`
4 | @keyframes sk-stretchdelay {
5 | 0%,
6 | 40%,
7 | 100% {
8 | transform: scaleY(0.4);
9 | }
10 |
11 | 20% {
12 | transform: scaleY(1);
13 | }
14 | }
15 |
16 | width: 70px;
17 | height: 40px;
18 | margin: 100px auto;
19 | font-size: 10px;
20 | text-align: center;
21 |
22 | & > div {
23 | display: inline-block;
24 | width: 6px;
25 | height: 100%;
26 | margin: 0 4px;
27 | animation: sk-stretchdelay 1.2s infinite ease-in-out;
28 | background-color: ${(p) => p.theme.colors.primary};
29 | }
30 |
31 | div:nth-child(2) {
32 | animation-delay: -1.1s;
33 | }
34 |
35 | div:nth-child(3) {
36 | animation-delay: -1s;
37 | }
38 |
39 | div:nth-child(4) {
40 | animation-delay: -0.9s;
41 | }
42 |
43 | div:last-child {
44 | animation-delay: -0.8s;
45 | }
46 | `
47 |
48 | export const RectSpinner = () => (
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | )
57 |
58 | const CircleSpinnerContainer = styled.div`
59 | position: relative;
60 | width: 30px;
61 | height: 30px;
62 |
63 | & > div {
64 | position: absolute;
65 | top: 0;
66 | left: 0;
67 | width: 100%;
68 | height: 100%;
69 |
70 | &::before {
71 | display: block;
72 | width: 15%;
73 | height: 15%;
74 | border-radius: 100%;
75 | margin: 0 auto;
76 | animation: sk-circleFadeDelay 1.2s infinite ease-in-out both;
77 | background-color: ${(p) => p.theme.colors.primary};
78 | content: '';
79 | }
80 | }
81 |
82 | div:nth-child(2) {
83 | transform: rotate(30deg);
84 |
85 | &::before {
86 | animation-delay: -1.1s;
87 | }
88 | }
89 |
90 | div:nth-child(3) {
91 | transform: rotate(60deg);
92 |
93 | &::before {
94 | animation-delay: -1s;
95 | }
96 | }
97 |
98 | div:nth-child(4) {
99 | transform: rotate(90deg);
100 |
101 | &::before {
102 | animation-delay: -0.9s;
103 | }
104 | }
105 |
106 | div:nth-child(5) {
107 | transform: rotate(120deg);
108 |
109 | &::before {
110 | animation-delay: -0.8s;
111 | }
112 | }
113 |
114 | div:nth-child(6) {
115 | transform: rotate(150deg);
116 |
117 | &::before {
118 | animation-delay: -0.7s;
119 | }
120 | }
121 |
122 | div:nth-child(7) {
123 | transform: rotate(180deg);
124 |
125 | &::before {
126 | animation-delay: -0.6s;
127 | }
128 | }
129 |
130 | div:nth-child(8) {
131 | transform: rotate(210deg);
132 |
133 | &::before {
134 | animation-delay: -0.5s;
135 | }
136 | }
137 |
138 | div:nth-child(9) {
139 | transform: rotate(240deg);
140 |
141 | &::before {
142 | animation-delay: -0.4s;
143 | }
144 | }
145 |
146 | div:nth-child(10) {
147 | transform: rotate(270deg);
148 |
149 | &::before {
150 | animation-delay: -0.3s;
151 | }
152 | }
153 |
154 | div:nth-child(11) {
155 | transform: rotate(300deg);
156 |
157 | &::before {
158 | animation-delay: -0.2s;
159 | }
160 | }
161 |
162 | div:nth-child(12) {
163 | transform: rotate(330deg);
164 |
165 | &::before {
166 | animation-delay: -0.1s;
167 | }
168 | }
169 | @keyframes sk-circleFadeDelay {
170 | 0%,
171 | 39%,
172 | 100% {
173 | opacity: 0;
174 | }
175 |
176 | 40% {
177 | opacity: 1;
178 | }
179 | }
180 | `
181 | export const CircleSpinner = () => (
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 | )
197 |
--------------------------------------------------------------------------------
/src/web/components/ui/stack.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from 'decorock'
2 | import type { ComponentProps } from 'solid-js'
3 |
4 | type Props = ComponentProps<'div'> & {
5 | gap?: string | number
6 | }
7 |
8 | export const HStack = styled.div`
9 | display: flex;
10 | gap: ${(p) => p.gap || '0.5rem'};
11 | `
12 |
13 | export const VStack = styled(HStack)`
14 | flex-direction: column;
15 | `
16 |
--------------------------------------------------------------------------------
/src/web/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import { css, styled } from 'decorock'
2 | import {
3 | Accessor,
4 | Component,
5 | ComponentProps,
6 | createContext,
7 | createEffect,
8 | createMemo,
9 | createSignal,
10 | For,
11 | JSX,
12 | on,
13 | Setter,
14 | Show,
15 | splitProps,
16 | } from 'solid-js'
17 |
18 | import { classnames } from '~/web/lib/classnames'
19 |
20 | export const TabGroup = styled.div<{ vertical?: boolean | undefined }>`
21 | display: grid;
22 | height: 100%;
23 | grid-template-columns: ${(p) => (p.vertical ? '150px 1fr' : '100%')};
24 | grid-template-rows: ${(p) => (p.vertical ? '100%' : '50px 1fr')};
25 | `
26 |
27 | export const TabList = styled.div<{ vertical?: boolean | undefined; close?: boolean | undefined }>`
28 | display: flex;
29 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
30 | justify-content: flex-start;
31 | align-items: flex-end;
32 | transform: ${(p) =>
33 | `${p.vertical ? 'translateX' : 'translateY'}(${
34 | p.close ? (p.vertical ? '-150px' : '-50px') : '0'
35 | })`};
36 | transition: 0.2s;
37 |
38 | ${(p) => (p.vertical ? 'height: 100%' : 'width: 100%')};
39 |
40 | flex-direction: ${(p) => (p.vertical ? 'column' : 'row')};
41 | `
42 |
43 | export const Tab = styled.div<{ selected: boolean; vertical?: boolean | undefined }>`
44 | position: relative;
45 | display: inline-flex;
46 | padding: 0.5rem 1rem;
47 | color: ${(p) => p.theme.colors.primary.fade(p.selected ? 0 : 0.3)};
48 | cursor: pointer;
49 | font-weight: bold;
50 | transition: 0.2s;
51 | user-select: none;
52 | align-items: center;
53 | text-align: right;
54 | justify-content: ${(p) => (p.vertical ? 'flex-end' : 'center')};
55 | ${(p) => (p.vertical ? 'width: 100%' : 'height: 100%')};
56 |
57 | &::after {
58 | position: absolute;
59 | background: ${(p) => p.theme.colors.primary};
60 | bottom: ${(p) => (p.vertical ? '50' : '0')}%;
61 | right: ${(p) => (p.vertical ? '0' : '50')}%;
62 | ${(p) => `${p.vertical ? 'height' : 'width'}:${p.selected ? '30%' : '0'}`};
63 | ${(p) => (p.vertical ? 'width' : 'height')}: 2px;
64 |
65 | transform: ${(p) => (p.vertical ? 'translateY(50%)' : 'translateX(50%)')};
66 | content: '';
67 | transition: 0.2s;
68 | }
69 |
70 | &:hover {
71 | color: ${(p) => p.theme.colors.primary};
72 | }
73 | `
74 |
75 | const TabContext = createContext(
76 | {} as {
77 | current: Accessor
78 | setCurrent: Setter
79 | },
80 | )
81 |
82 | export const TabPanel: Component<
83 | {
84 | show: boolean
85 | unmount?: boolean
86 | close?: boolean | undefined
87 | vertical?: boolean | undefined
88 | } & ComponentProps<'div'>
89 | > = (props) => {
90 | const [local, others] = splitProps(props, ['show', 'unmount', 'class', 'children'])
91 |
92 | const unmount = createMemo(() => typeof local.unmount !== 'boolean' || local.unmount === true)
93 |
94 | return (
95 |
96 |
114 | {local.children}
115 |
116 |
117 | )
118 | }
119 |
120 | export const Tabs: Component<{
121 | tab?: string | number | undefined
122 | onChange?: (tab: [string, number]) => void
123 | tabs: Record
124 | close?: boolean
125 | vertical?: boolean
126 | component?: ([label, Comp]: [string, Component], isSelected: Accessor) => JSX.Element
127 | }> = (props) => {
128 | const [current, setCurrent] = createSignal(0)
129 | const selected = (i: number) => i === current()
130 |
131 | createEffect(
132 | on(
133 | () => props.tab,
134 | (tab) => {
135 | if (typeof tab === 'string') setCurrent(Object.keys(props.tabs).findIndex((v) => v === tab))
136 | else if (typeof tab === 'number') setCurrent(tab)
137 | },
138 | ),
139 | )
140 |
141 | createEffect(
142 | on(current, (current) => props.onChange?.([Object.keys(props.tabs)[current]!, current])),
143 | )
144 |
145 | return (
146 |
147 |
148 |
149 |
150 | {(label, i) => (
151 | setCurrent(i())}
155 | >
156 | {label}
157 |
158 | )}
159 |
160 |
161 |
162 | {([label, Comp], i) =>
163 | props.component ? (
164 | props.component([label, Comp], () => i() === current())
165 | ) : (
166 |
167 |
168 |
169 | )
170 | }
171 |
172 |
173 |
174 | )
175 | }
176 |
--------------------------------------------------------------------------------
/src/web/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import { css, styled, useTheme } from 'decorock'
2 | import {
3 | Component,
4 | createContext,
5 | createEffect,
6 | createSignal,
7 | For,
8 | JSX,
9 | Match,
10 | Show,
11 | Switch,
12 | useContext,
13 | } from 'solid-js'
14 | import { createStore } from 'solid-js/store'
15 |
16 | import { ipc } from '~/web/lib/ipc'
17 | import Icon from '~assets/icon-512x512.png'
18 | import IconCheck from '~icons/material-symbols/check-circle-outline'
19 | import IconClose from '~icons/material-symbols/close'
20 | import IconError from '~icons/material-symbols/error-circle-rounded-outline'
21 | import IconInfo from '~icons/material-symbols/info-outline'
22 |
23 | type ToastOption = {
24 | status: 'success' | 'error' | 'info'
25 | title: string
26 | description?: string
27 | duration?: number
28 | isClosable?: boolean
29 | }
30 |
31 | const ToastContext = createContext((option: ToastOption) => {})
32 |
33 | const ToastContainer = styled.div`
34 | position: fixed;
35 | z-index: 10;
36 | left: 0;
37 | display: flex;
38 | width: 100%;
39 | height: 100dvh;
40 | flex-direction: column-reverse;
41 | align-items: center;
42 | padding: 1rem;
43 | gap: 1rem;
44 | pointer-events: none;
45 | `
46 |
47 | const Toast: Component<
48 | ToastOption & {
49 | onHide: () => void
50 | }
51 | > = (props) => {
52 | const theme = useTheme()
53 | const [hide, setHide] = createSignal(false)
54 |
55 | const hideToast = () => {
56 | setHide(true)
57 | setTimeout(() => props.onHide(), 500)
58 | }
59 |
60 | createEffect(() => {
61 | if (props.duration !== -1) setTimeout(() => hideToast(), props.duration || 5000)
62 | })
63 |
64 | return (
65 |
85 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
{props.title}
110 |
111 | {props.description}
112 |
113 |
114 |
115 | hideToast()}
135 | >
136 |
137 |
138 |
139 |
140 | )
141 | }
142 |
143 | export const ToastProvider: Component<{ children: JSX.Element }> = (props) => {
144 | const [toasts, setToasts] = createStore<
145 | (ToastOption & {
146 | created_at: number
147 | })[]
148 | >([])
149 |
150 | const createToast = async (option: ToastOption) => {
151 | const created_at = new Date().getTime()
152 | if (!(await ipc.system.invoke('window/is-focused'))) {
153 | new Notification(option.title, {
154 | badge: 'Flat',
155 | icon: Icon,
156 | })
157 | }
158 | setToasts((prev) => [{ ...option, created_at }, ...prev])
159 | }
160 |
161 | return (
162 | <>
163 |
164 |
165 | {(option) => (
166 |
169 | setToasts((prev) => prev.filter((toast) => toast.created_at !== option.created_at))
170 | }
171 | />
172 | )}
173 |
174 |
175 | {props.children}
176 | >
177 | )
178 | }
179 |
180 | export const useToast = () => useContext(ToastContext)
181 |
--------------------------------------------------------------------------------
/src/web/components/updater.tsx:
--------------------------------------------------------------------------------
1 | import { useI18n } from '@solid-primitives/i18n'
2 | import { Component, onMount } from 'solid-js'
3 |
4 | import { useToast } from './ui/toast'
5 | import { ipc } from '../lib/ipc'
6 |
7 | export const Updater: Component = () => {
8 | const [t] = useI18n()
9 | const toast = useToast()
10 | onMount(() => {
11 | ipc.system.invoke('update/check').then((available) => {
12 | if (!available) return
13 | toast({
14 | title: t('system/update-available/title'),
15 | description: t('system/update-available/description'),
16 | status: 'info',
17 | isClosable: true,
18 | duration: -1,
19 | })
20 | })
21 | })
22 | return <>>
23 | }
24 |
--------------------------------------------------------------------------------
/src/web/global.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Inter&family=Noto+Sans+JP&family=Roboto+Mono&display=swap');
2 |
3 | *,
4 | ::before,
5 | ::after {
6 | box-sizing: border-box;
7 | padding: 0;
8 | margin: 0;
9 | font-family: inherit;
10 | }
11 |
12 | html {
13 | padding: 0;
14 | margin: 0;
15 | -webkit-tap-highlight-color: transparent;
16 | text-size-adjust: 100%;
17 | text-size-adjust: none;
18 | word-break: break-word;
19 | }
20 |
21 | body {
22 | padding: 0;
23 | margin: 0;
24 | font-family: 'Noto Sans JP', sans-serif;
25 | font-size: 16px;
26 | line-height: 1.5;
27 | overscroll-behavior: none;
28 | word-break: break-word;
29 | }
30 |
31 | #root {
32 | overflow: hidden;
33 | height: 100vh;
34 | }
35 |
36 | a {
37 | text-decoration: none;
38 | }
39 |
40 | @media (hover: hover) and (pointer: fine) {
41 | a:hover {
42 | text-decoration: none;
43 | }
44 | }
45 |
46 | img {
47 | max-width: 100%;
48 | height: auto;
49 | }
50 |
--------------------------------------------------------------------------------
/src/web/hooks/use-floating.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, createSignal, onCleanup, onMount } from 'solid-js'
2 | import { isServer } from 'solid-js/web'
3 |
4 | export const useFloating = (ref: () => HTMLElement, disable?: boolean | undefined) => {
5 | const [open, setOpen] = createSignal(false)
6 | const [cool, setCool] = createSignal(false)
7 |
8 | createEffect(() => {
9 | if (open()) {
10 | setCool(true)
11 | setTimeout(() => setCool(false), 50)
12 | }
13 | })
14 |
15 | const listener = (e: MouseEvent) => {
16 | if (cool()) return
17 | const isThis = ref() === e.target || ref().contains(e.target as Node)
18 | if (open() && !isThis && !disable) setOpen(false)
19 | }
20 | onMount(() => {
21 | if (!isServer) window.addEventListener('click', listener)
22 | })
23 | onCleanup(() => {
24 | if (!isServer) window.removeEventListener('click', listener)
25 | })
26 |
27 | return [open, setOpen] as const
28 | }
29 |
--------------------------------------------------------------------------------
/src/web/index.tsx:
--------------------------------------------------------------------------------
1 | /* @refresh reload */
2 | import { render } from 'solid-js/web'
3 |
4 | import { App } from './app'
5 | import { shell } from './lib/node/electron'
6 |
7 | window.addEventListener('click', (e) => {
8 | const el = e.target as HTMLAnchorElement
9 | if (el.tagName && el.tagName === 'A') {
10 | e.preventDefault()
11 | shell.openExternal(el.href)
12 | }
13 | })
14 |
15 | render(() => , document.getElementById('root') as HTMLElement)
16 |
--------------------------------------------------------------------------------
/src/web/lib/classnames.ts:
--------------------------------------------------------------------------------
1 | export const classnames = (...names: any[]) => {
2 | return names.filter((v) => typeof v === 'string').join(' ')
3 | }
4 |
--------------------------------------------------------------------------------
/src/web/lib/config.ts:
--------------------------------------------------------------------------------
1 | import { createEffect } from 'solid-js'
2 | import { createStore } from 'solid-js/store'
3 |
4 | import { ipc } from './ipc'
5 | import { path } from './node/path'
6 |
7 | export type Config = {
8 | 'system/lang': 'ja' | 'en'
9 | 'system/theme': 'light' | 'dark'
10 | 'gallery/dirs': string[]
11 | 'webui/git/commit': string
12 | 'webui/args/ckpt-dir': string
13 | 'webui/args/vae-dir': string
14 | 'webui/args/embeddings-dir': string
15 | 'webui/args/hypernetwork-dir': string
16 | 'webui/args/xformers': boolean
17 | 'webui/args/custom': string
18 | 'webui/settings/env': string
19 | }
20 |
21 | const defaultConfig: Config = {
22 | 'system/lang': 'ja',
23 | 'system/theme': 'dark',
24 | 'gallery/dirs': [],
25 | 'webui/git/commit': 'master',
26 | 'webui/args/ckpt-dir': path.join(
27 | await ipc.webui.invoke('webui/data-dir'),
28 | 'repository',
29 | 'models',
30 | 'Stable-diffusion',
31 | ),
32 | 'webui/args/vae-dir': '',
33 | 'webui/args/embeddings-dir': path.join(
34 | await ipc.webui.invoke('webui/data-dir'),
35 | 'repository',
36 | 'embeddings',
37 | ),
38 | 'webui/args/hypernetwork-dir': path.join(
39 | await ipc.webui.invoke('webui/data-dir'),
40 | 'repository',
41 | 'models',
42 | 'hypernetworks',
43 | ),
44 | 'webui/args/xformers': true,
45 | 'webui/args/custom': '',
46 | 'webui/settings/env': '',
47 | }
48 |
49 | const merge = (obj: any) => {
50 | const c: Record = {}
51 | for (const key of Object.keys(defaultConfig)) {
52 | const val = key in obj ? obj[key] : null
53 | const defaultVal = defaultConfig[key as keyof typeof defaultConfig]
54 | if (!val) c[key] = defaultVal
55 | else if (!Array.isArray(val) && Array.isArray(defaultVal)) c[key] = defaultVal
56 | else if (typeof val === 'object' && !Array.isArray(val)) c[key] = merge(val)
57 | else c[key] = val
58 | }
59 | return c as Config
60 | }
61 |
62 | const raw = localStorage.getItem('config') || ''
63 | let saved = {} as Config
64 | try {
65 | saved = JSON.parse(raw)
66 | } catch (error) {
67 | saved = defaultConfig
68 | }
69 | saved = merge(saved)
70 | localStorage.setItem('config', JSON.stringify(saved))
71 | export const [config, setConfig] = createStore(saved)
72 |
73 | createEffect(() => {
74 | const raw = JSON.stringify(config)
75 | ipc.system.invoke('config/save', JSON.parse(raw))
76 | localStorage.setItem('config', raw)
77 | })
78 |
--------------------------------------------------------------------------------
/src/web/lib/ipc.ts:
--------------------------------------------------------------------------------
1 | import { IpcClient } from '../../ipc/client'
2 |
3 | import type {
4 | ClientToServerEvents as CondaClientToServerEvents,
5 | ServerToClientEvents as CondaServerToClientEvents,
6 | } from '~/features/conda/ipc'
7 | import type {
8 | ClientToServerEvents as GalleryClientToServerEvents,
9 | ServerToClientEvents as GalleryServerToClientEvents,
10 | } from '~/features/gallery/ipc'
11 | import type {
12 | ClientToServerEvents as GitClientToServerEvents,
13 | ServerToClientEvents as GitServerToClientEvents,
14 | } from '~/features/git/ipc'
15 | import type {
16 | ClientToServerEvents as WebUIClientToServerEvents,
17 | ServerToClientEvents as WebUIServerToClientEvents,
18 | } from '~/features/stable-diffusion-webui/ipc'
19 | import type {
20 | ClientToServerEvents as SystemClientToServerEvents,
21 | ServerToClientEvents as SystemServerToClientEvents,
22 | } from '~/features/system/ipc/types'
23 |
24 | export const ipc = {
25 | system: new IpcClient('system'),
26 | conda: new IpcClient('conda'),
27 | gallery: new IpcClient('gallery'),
28 | webui: new IpcClient(
29 | 'stable-diffusion-webui',
30 | ),
31 | git: new IpcClient('git'),
32 | }
33 |
--------------------------------------------------------------------------------
/src/web/lib/node/electron.ts:
--------------------------------------------------------------------------------
1 | export const { shell, ipcRenderer } = window['__node_lib']['electron']
2 |
--------------------------------------------------------------------------------
/src/web/lib/node/path.ts:
--------------------------------------------------------------------------------
1 | export const path = window['__node_lib']['path']
2 |
--------------------------------------------------------------------------------
/src/web/lib/store-serialize.ts:
--------------------------------------------------------------------------------
1 | export const serialize = (obj: any) => JSON.parse(JSON.stringify(obj))
2 |
--------------------------------------------------------------------------------
/src/web/pages/gallery/config.tsx:
--------------------------------------------------------------------------------
1 | import { useI18n } from '@solid-primitives/i18n'
2 | import { css, styled } from 'decorock'
3 | import { Component, For, onMount } from 'solid-js'
4 | import { createStore } from 'solid-js/store'
5 |
6 | import { Button } from '~/web/components/ui/button'
7 | import { IconButton } from '~/web/components/ui/icon-button'
8 | import { Input } from '~/web/components/ui/input'
9 | import { Label } from '~/web/components/ui/label'
10 | import { config, setConfig } from '~/web/lib/config'
11 | import { ipc } from '~/web/lib/ipc'
12 | import AddIcon from '~icons/material-symbols/add'
13 | import RemoveIcon from '~icons/material-symbols/remove'
14 |
15 | const Container = styled.div`
16 | margin: 0.5rem 1rem;
17 | text-align: left;
18 | `
19 |
20 | export const Config: Component = () => {
21 | const [t] = useI18n()
22 | const [dirs, setDirs] = createStore([])
23 |
24 | onMount(() => setDirs(config['gallery/dirs']))
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 | {(path, i) => (
33 |
41 | setDirs(i(), e.currentTarget.value)} />
42 | {
44 | setDirs((dirs) => dirs.filter((_, i2) => i2 !== i()))
45 | }}
46 | >
47 |
48 |
49 |
50 | )}
51 |
52 |
53 |
{
55 | setDirs((paths) => [...paths, ''])
56 | }}
57 | >
58 |
59 |
60 |
61 |
62 |
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 |
166 |
{
176 | if (
177 | e.currentTarget.scrollHeight > 1000 &&
178 | e.currentTarget.scrollHeight - e.currentTarget.scrollTop ===
179 | e.currentTarget.clientHeight
180 | ) {
181 | fetch()
182 | }
183 | }}
184 | >
185 |
{(image) => }
186 |
187 |
192 |
193 |
194 |
195 |
196 |
197 |
198 | >
199 | )
200 | }
201 |
--------------------------------------------------------------------------------
/src/web/pages/gallery/index.tsx:
--------------------------------------------------------------------------------
1 | import { styled } from 'decorock'
2 |
3 | import { Config } from './config'
4 | import { Images } from './images'
5 |
6 | import { Tabs } from '~/web/components/ui/tabs'
7 |
8 | const Container = styled.div`
9 | display: grid;
10 | overflow: hidden;
11 | height: 100vh;
12 | margin: 0;
13 | grid-template-columns: 100%;
14 | grid-template-rows: 50px 1fr;
15 | `
16 |
17 | const TABS = {
18 | Images,
19 | Config,
20 | }
21 |
22 | export const Gallery = () => {
23 | return (
24 | <>
25 |
26 |
27 |
28 | >
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/web/pages/general/index.tsx:
--------------------------------------------------------------------------------
1 | import { useI18n } from '@solid-primitives/i18n'
2 | import Color from 'color'
3 | import { css, styled, useTheme } from 'decorock'
4 | import packageJson from 'package.json'
5 | import { createResource, createSignal, For } from 'solid-js'
6 |
7 | import { Modal } from '~/web/components/modal'
8 | import { Button } from '~/web/components/ui/button'
9 | import { Label } from '~/web/components/ui/label'
10 | import { Select } from '~/web/components/ui/select'
11 | import { VStack } from '~/web/components/ui/stack'
12 | import { Config, config, setConfig } from '~/web/lib/config'
13 | import { ipc } from '~/web/lib/ipc'
14 | import Icon from '~assets/icon-512x512.png'
15 | import IconUpdate from '~icons/material-symbols/update-rounded'
16 |
17 | const Container = styled.div`
18 | height: 100%;
19 | padding: 1rem;
20 |
21 | img {
22 | width: 96px;
23 | height: 96px;
24 | }
25 | `
26 |
27 | const Setting = styled.div`
28 | display: grid;
29 | align-items: center;
30 | justify-content: space-between;
31 | padding: 1rem;
32 | border-radius: 0.5rem;
33 | background-color: ${(p) => p.theme.colors.primary.fade(0.95)};
34 | grid-template-columns: 30% 70%;
35 | grid-template-rows: 100%;
36 | `
37 |
38 | export const General = () => {
39 | const [t, { locale }] = useI18n()
40 | const theme = useTheme()
41 | const [hasUpdate, { refetch }] = createResource(() => ipc.system.invoke('update/check'))
42 | const [isOpen, setIsOpen] = createSignal(false)
43 | return (
44 | <>
45 | setIsOpen(false)}>
46 | {t('system/update-installed/title')}
47 | {t('system/update-installed/description')}
48 |
49 |
50 | General
51 |
52 | div {
59 | p {
60 | color: ${theme.colors.primary.fade(0.2)};
61 | }
62 |
63 | & > div {
64 | margin-top: 0.5rem;
65 | }
66 |
67 | a {
68 | margin-right: 0.5rem;
69 | color: ${theme.name === 'light'
70 | ? Color('rgb(0, 255, 255)').darken(0.25)
71 | : Color('rgb(0, 255, 255)')};
72 |
73 | &:hover {
74 | text-decoration: underline;
75 | }
76 | }
77 | }
78 | `}
79 | >
80 |

81 |
88 |
89 |
90 |
91 |
104 |
105 |
106 | {packageJson.version}
107 |
108 | {hasUpdate() ? t('system/update-available/title') : t('system/update/latest')}
109 |
110 |
111 |
112 |
117 |
130 |
131 |
132 |
133 |
134 |
135 |
153 |
154 |
155 |
156 |
173 |
174 | >
175 | )
176 | }
177 |
--------------------------------------------------------------------------------
/src/web/pages/webui/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 Config: Component = () => {
8 | const theme = useTheme()
9 | const [ref, setRef] = createSignal()
10 |
11 | onMount(async () => {
12 | const value = await ipc.webui.invoke('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('config/save', json)
23 | } catch (_) {}
24 | })
25 | })
26 |
27 | return (
28 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/web/pages/webui/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from 'decorock'
2 | import { Accessor, createContext, createSignal, onCleanup, onMount, Setter } from 'solid-js'
3 |
4 | import { Config } from './config'
5 | import { Launcher } from './launcher'
6 | import { Settings } from './settings'
7 | import { UI } from './ui'
8 | import { UIConfig } from './ui-config'
9 |
10 | import { Tabs, TabPanel } from '~/web/components/ui/tabs'
11 | import { ipc } from '~/web/lib/ipc'
12 |
13 | type Context = {
14 | url: Accessor
15 | setUrl: Setter
16 | onLaunch: (url: string) => void
17 | }
18 |
19 | export const WebUIContext = createContext({} as Context)
20 |
21 | const TABS = {
22 | Launcher,
23 | UI,
24 | Settings,
25 | Config,
26 | UIConfig,
27 | }
28 |
29 | export const WebUI = () => {
30 | const [webUIUrl, setWebUIUrl] = createSignal('')
31 | const [current, setCurrent] = createSignal('Launcher')
32 |
33 | onMount(async () => {
34 | const port = await ipc.webui.invoke('webui/port')
35 | const url = port ? `http://localhost:${port}` : ''
36 | if (!url || url === webUIUrl()) return
37 | setWebUIUrl(url)
38 | })
39 |
40 | onMount(() => {
41 | ipc.webui.local.on('tab/change', setCurrent)
42 | })
43 | onCleanup(() => {
44 | ipc.webui.local.off('tab/change', setCurrent)
45 | })
46 |
47 | return (
48 | {
53 | setWebUIUrl(url)
54 | setCurrent('UI')
55 | },
56 | }}
57 | >
58 | setCurrent(label)}
62 | component={([label, Comp], isSelected) => {
63 | return (
64 |
71 |
72 |
73 | )
74 | }}
75 | />
76 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/src/web/pages/webui/launcher.tsx:
--------------------------------------------------------------------------------
1 | import { useI18n } from '@solid-primitives/i18n'
2 | import Color from 'color'
3 | import dayjs from 'dayjs'
4 | import { css, styled, useTheme } from 'decorock'
5 | import type { DefaultLogFields, LogResult } from 'simple-git'
6 | import { Component, createSignal, onCleanup, onMount, Show, useContext } from 'solid-js'
7 |
8 | import { WebUIContext } from '.'
9 |
10 | import { Log } from '~/web/components/log'
11 | import { AutoComplete } from '~/web/components/ui/auto-complete'
12 | import { Button } from '~/web/components/ui/button'
13 | import { IconButton } from '~/web/components/ui/icon-button'
14 | import { Label } from '~/web/components/ui/label'
15 | import { HStack } from '~/web/components/ui/stack'
16 | import { useToast } from '~/web/components/ui/toast'
17 | import { config, setConfig } from '~/web/lib/config'
18 | import { ipc } from '~/web/lib/ipc'
19 | import IconStartArrow from '~icons/material-symbols/play-arrow'
20 | import IconStop from '~icons/material-symbols/stop'
21 |
22 | const Container = styled.div`
23 | display: grid;
24 | overflow: auto;
25 | height: calc(100vh - 50px);
26 | grid-template-columns: 70% 30%;
27 | grid-template-rows: 1fr 150px;
28 | `
29 |
30 | const StyledIconButton = styled(IconButton)`
31 | padding: 0.5rem;
32 | border-radius: 0.25rem;
33 | font-size: 1.5rem;
34 | `
35 |
36 | const extractString = (str: string) => {
37 | const regex = /(['"])((?:\\\1|.)*?)\1/
38 | const match = str.match(regex)
39 | if (match) {
40 | return match[2]
41 | }
42 | return str
43 | }
44 |
45 | export const createArgs = () => {
46 | let result = ''
47 | for (const [key, val] of Object.entries(config)) {
48 | if (typeof val === 'string' && !val) continue
49 | if (!key.startsWith('webui/args')) continue
50 | const arg = key.split('/').slice(-1)[0]
51 | if (arg === 'custom') result += ` ${val}`
52 | else if (typeof val === 'boolean') result += val ? ` --${arg}` : ''
53 | else if (typeof val === 'string') result += ` --${arg} "${val}"`.replace(/\\/g, '\\\\')
54 | else result += ` --${arg} ${val}`
55 | }
56 | return result
57 | }
58 |
59 | export const createEnv = () => {
60 | const result: Record = {}
61 | const envs = config['webui/settings/env'].split(',')
62 | for (const v of envs) {
63 | if (!v.includes('=') || v.split('=').length < 2) continue
64 | const [key, value] = v.split('=')
65 | result[key!] = extractString(value!)
66 | }
67 | return result
68 | }
69 |
70 | export const Launcher: Component = () => {
71 | const theme = useTheme()
72 | const toast = useToast()
73 | const [t] = useI18n()
74 | const { url, setUrl, onLaunch } = useContext(WebUIContext)
75 | const [gitLog, setGitLog] = createSignal['all']>([])
76 | const [logs, setLogs] = createSignal([])
77 | const [installed, setInstalled] = createSignal(true)
78 | const [installing, setInstalling] = createSignal(false)
79 | const [running, setRunning] = createSignal(false)
80 |
81 | const onLog = (_: any, log: string) => {
82 | const logs = log.split('\n')
83 | setLogs((prev) => [...prev, ...logs])
84 |
85 | const re = /Running on local URL: (.*)/
86 | const urls = logs.map((v) => v.match(re)).filter(Boolean)
87 | if (urls.length > 0) {
88 | toast({
89 | title: t('webui/launcher/launched/title'),
90 | status: 'success',
91 | duration: 3000,
92 | isClosable: true,
93 | })
94 | const matches = urls[0] as RegExpMatchArray
95 | const newUrl = matches[1] as string
96 | if (newUrl == url()) return
97 | setTimeout(() => onLaunch(newUrl), 500)
98 | }
99 | }
100 |
101 | onMount(async () => {
102 | const installed = await ipc.webui.invoke('env/installed')
103 | setInstalled(installed)
104 | setLogs(await ipc.webui.invoke('webui/logs'))
105 | setRunning(await ipc.webui.invoke('webui/running'))
106 | if (installed) {
107 | const log = await ipc.webui.invoke('git/log')
108 | setGitLog(log.all)
109 | }
110 |
111 | ipc.webui.on('log', onLog)
112 | ipc.webui.on('env/uninstalled', () => setInstalled(false))
113 | })
114 |
115 | onCleanup(() => {
116 | ipc.webui.removeAllListeners('log')
117 | })
118 |
119 | const install = () => {
120 | setInstalling(true)
121 | setLogs([])
122 | ipc.webui.invoke('env/install').then(() => {
123 | setInstalling(false)
124 | setInstalled(true)
125 | toast({
126 | title: t('webui/launcher/install-success/title'),
127 | description: t('webui/launcher/install-success/description'),
128 | status: 'success',
129 | duration: 3000,
130 | isClosable: true,
131 | })
132 | })
133 | }
134 |
135 | return (
136 |
137 |
149 | {t('webui/launcher/not-installed/title')}
150 | {t('webui/launcher/not-installed/description')}
151 |
152 |
153 | }
154 | >
155 |
162 | {logs()}
163 |
164 |
165 |
176 |
177 |
188 |
189 |
190 |
{
192 | const i = gitLog().findIndex((v) => v.hash.startsWith(hash))
193 | return [
194 | 'master',
195 | ...gitLog()
196 | .slice(i === -1 ? 0 : i, 50)
197 | .map((v) => v.hash.slice(0, 6) + ` (${dayjs(v.date).format('YYYY MM/DD HH:mm')})`),
198 | ]
199 | }}
200 | value={config['webui/git/commit']}
201 | onInput={(value) => setConfig('webui/git/commit', value)}
202 | onChange={(value) => setConfig('webui/git/commit', value)}
203 | limit={50}
204 | />
205 |
206 |
209 |
210 |
216 |
229 | Status: {running() ? 'Running' : 'Stopped'}
230 |
231 |
232 | {
234 | setRunning(true)
235 | setLogs([])
236 | ipc.webui.invoke(
237 | 'webui/launch',
238 | createArgs(),
239 | createEnv(),
240 | config['webui/git/commit'].slice(0, 6),
241 | )
242 | ipc.webui.once('webui/close', () => {
243 | setUrl('')
244 | setRunning(false)
245 | })
246 | }}
247 | disabled={running() || !installed()}
248 | >
249 |
250 |
251 | {
253 | setUrl('')
254 | setRunning(false)
255 | ipc.webui.invoke('webui/stop')
256 | }}
257 | disabled={!running() || !installed()}
258 | >
259 |
260 |
261 |
264 |
265 |
266 |
267 | )
268 | }
269 |
--------------------------------------------------------------------------------
/src/web/pages/webui/settings.tsx:
--------------------------------------------------------------------------------
1 | import { useI18n } from '@solid-primitives/i18n'
2 | import { css, styled } from 'decorock'
3 | import { Component, createSignal, onCleanup, Show } from 'solid-js'
4 |
5 | import { Log } from '~/web/components/log'
6 | import { Modal } from '~/web/components/modal'
7 | import { Button } from '~/web/components/ui/button'
8 | import { CheckBox } from '~/web/components/ui/checkbox'
9 | import { Input } from '~/web/components/ui/input'
10 | import { Label } from '~/web/components/ui/label'
11 | import { HStack } from '~/web/components/ui/stack'
12 | import { config, setConfig } from '~/web/lib/config'
13 | import { ipc } from '~/web/lib/ipc'
14 |
15 | const Container = styled.div`
16 | height: 100%;
17 | margin: 0.5rem 1rem;
18 | text-align: left;
19 | `
20 |
21 | const UninstallEnv: Component<{ onClose: () => void }> = (props) => {
22 | const [t] = useI18n()
23 | const [uninstalling, setUninstalling] = createSignal(false)
24 | const [logs, setLogs] = createSignal([])
25 |
26 | const onLog = (_: any, log: string) => {
27 | setLogs((prev) => [...prev, ...log.split('\n')])
28 | }
29 |
30 | onCleanup(() => {
31 | ipc.webui.off('log', onLog)
32 | })
33 |
34 | return (
35 | <>
36 |
40 | {t('webui/launcher/uninstall-env/title')}
41 | {t('webui/launcher/uninstall-env/description')}
42 |
43 |
52 |
53 |
54 | >
55 | }
56 | >
57 | Uninstalling conda environment ...
58 |
63 | {logs()}
64 |
65 |
66 | >
67 | )
68 | }
69 |
70 | export const Settings: Component = () => {
71 | const [isOpen, setIsOpen] = createSignal(false)
72 | const [dialog, setDialog] = createSignal<'uninstall-env' | 'uninstall'>('uninstall-env')
73 | const [t] = useI18n()
74 |
75 | return (
76 |
77 |
78 | 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 |
39 | )
40 | }
41 |
42 | export const ThemeProvider: Component<{ children: JSX.Element }> = (props) => {
43 | createEffect(() => {
44 | setTheme({ ...themes[config['system/theme']] })
45 | })
46 |
47 | return (
48 | (p?.string ? p : p)}>
49 |
50 | {props.children}
51 |
52 | )
53 | }
54 |
55 | declare module 'decorock' {
56 | export interface DefaultTheme {
57 | name: string
58 | colors: {
59 | primary: Color
60 | secondary: Color
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ESNext",
4 | "target": "ESNext",
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ESNext",
9 | ],
10 | "strict": true,
11 | "esModuleInterop": true,
12 | "skipLibCheck": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "moduleResolution": "node",
15 | "allowUnusedLabels": false,
16 | "allowUnreachableCode": false,
17 | "exactOptionalPropertyTypes": true,
18 | "noFallthroughCasesInSwitch": true,
19 | "noImplicitOverride": true,
20 | "noPropertyAccessFromIndexSignature": true,
21 | "noUncheckedIndexedAccess": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "importsNotUsedAsValues": "error",
25 | "checkJs": true,
26 | "allowSyntheticDefaultImports": true,
27 | "resolveJsonModule": true,
28 | "noEmit": true,
29 | "jsxImportSource": "solid-js",
30 | "jsx": "preserve",
31 | "types": [
32 | "vite/client",
33 | "unplugin-icons/types/solid",
34 | "./env.d.ts"
35 | ],
36 | "baseUrl": "./",
37 | "paths": {
38 | "~/*": [
39 | "./src/*"
40 | ],
41 | "~i18n/*": [
42 | "./i18n/*"
43 | ],
44 | "~assets/*": [
45 | "./assets/*"
46 | ],
47 | "package.json": [
48 | "./package.json"
49 | ]
50 | },
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import Icons from 'unplugin-icons/vite'
3 | import { defineConfig } from 'vite'
4 | import monacoEditorPlugin from 'vite-plugin-monaco-editor'
5 | import solidPlugin from 'vite-plugin-solid'
6 |
7 | export default defineConfig({
8 | base: './',
9 | build: { target: 'esnext' },
10 | resolve: {
11 | alias: {
12 | '~': path.resolve(__dirname, 'src'),
13 | '~i18n': path.resolve(__dirname, 'i18n'),
14 | '~assets': path.resolve(__dirname, 'assets'),
15 | 'package.json': path.resolve(__dirname, 'package.json'),
16 | },
17 | },
18 | plugins: [
19 | (monacoEditorPlugin as any).default({}),
20 | Icons({
21 | compiler: 'solid',
22 | }),
23 | solidPlugin()
24 | ],
25 | })
26 |
--------------------------------------------------------------------------------