Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc0713b29 | ||
|
|
4a890d1312 | ||
|
|
6a465ee231 | ||
|
|
29f1ff9f58 | ||
|
|
2be048f13c | ||
|
|
4ffbcbb932 | ||
|
|
463fc3f2c8 | ||
|
|
7ce331ca81 | ||
|
|
30305cc7c0 |
@@ -0,0 +1,187 @@
|
||||
# ComfyUI-Workflow-Studio 開発ガイドライン
|
||||
|
||||
## プロジェクト概要
|
||||
ComfyUI用ワークフロー管理プラグイン。aiohttp (ComfyUI PromptServer) 上でSPAを提供する。
|
||||
|
||||
## アーキテクチャ
|
||||
- エントリポイント: `__init__.py` → `py/wfm.py` (WorkflowStudio.add_routes)
|
||||
- バックエンド: `py/routes/` (aiohttp ルート), `py/services/` (ビジネスロジック)
|
||||
- フロントエンド: `web/comfyui/` (ComfyUI拡張), `static/` (SPA), `templates/` (Jinja2)
|
||||
- データ: `data/` (settings.json, metadata.json, node_metadata.json)
|
||||
- i18n: 英語 / 日本語 / 中国語
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI カスタムノード開発ルール
|
||||
|
||||
### NODE_CLASS_MAPPINGS キーは永久に変更不可
|
||||
`NODE_CLASS_MAPPINGS` のキー文字列は、保存済みワークフローとノードを紐付ける公開識別子。
|
||||
一度公開したキーを変更すると、ユーザーの既存ワークフローが壊れる。
|
||||
将来ノードを追加する場合は、キー名を慎重に決定すること。
|
||||
- 名前空間衝突を防ぐため、パック名をプレフィックスに付ける (例: `"WFS_MyNode"`)
|
||||
- リネーム時は旧キーをエイリアスとして残す:
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WFS_NewName": MyNodeClass, # 新名
|
||||
"WFS_OldName": MyNodeClass, # エイリアス(後方互換)
|
||||
}
|
||||
```
|
||||
|
||||
### ワークフローJSON: ウィジェット値は位置インデックスで格納される
|
||||
ワークフローJSONはウィジェットの値を「名前」ではなく「位置インデックス」で保存する。
|
||||
入力の追加・並べ替えは、保存済みワークフローの値を別パラメータにずらす破壊的変更になる。
|
||||
ワークフロー解析・編集コード (`workflow_analyzer.py`, `workflow_service.py`) を変更する際は、
|
||||
このインデックスマッピングを必ず考慮すること。
|
||||
|
||||
**INPUT_TYPES変更時のチェックリスト:**
|
||||
- ウィジェット追加: 末尾に追加が最も安全。中間挿入は全保存済みJSONの更新が必要
|
||||
- ウィジェット削除: 後続のインデックスが全てずれる。全保存済みJSONの`widgets_values`を修正
|
||||
- 並べ替え: `widgets_values`の順序も合わせて更新
|
||||
- `INPUT_TYPES`を変更するコミットは、影響を受けるワークフローJSONも同時にコミットする
|
||||
|
||||
### ワークフローJSON整合性ルール
|
||||
- `last_node_id` は最大ノードID以上であること(低いと重複IDが発生し無言で壊れる)
|
||||
- `last_link_id` は最大リンクID以上であること
|
||||
- リンクは3箇所で整合する必要がある: `links[]`配列、ソースノードの`outputs[slot].links`、ターゲットノードの`inputs[slot].link`
|
||||
- いずれかが不一致だと接続が無言で消える
|
||||
|
||||
### IS_CHANGED によるキャッシュ制御
|
||||
ComfyUIはキャッシュ付きデータフローグラフ実行エンジン。入力が変わらないと判断したノードはスキップされる。
|
||||
- 毎回実行が必要なノード: `IS_CHANGED` で `float("NaN")` を返す
|
||||
- 本番用: 入力に基づく決定論的なハッシュを返す
|
||||
- 注意: `IS_CHANGED` はスロット接続された入力に `None` を受け取ることがある。`None`チェックを必ず入れる
|
||||
|
||||
### VALIDATE_INPUTS の落とし穴
|
||||
スロット接続された入力は検証時に `None` として渡される(実行時には実値が来る)。
|
||||
```python
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, text=None, **kwargs):
|
||||
if text is None:
|
||||
return True # スロット接続 — 実行時に実値が来る
|
||||
if isinstance(text, str) and text.strip() == "":
|
||||
return "Text input cannot be empty"
|
||||
return True
|
||||
```
|
||||
|
||||
### ノードの安全な分離読み込み
|
||||
1つの壊れた依存が全ノードを道連れにしないよう、個別にtry/exceptで読み込む:
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
for name, (mod_path, cls_name) in _NODE_MODULES.items():
|
||||
try:
|
||||
mod = importlib.import_module(mod_path, package=__name__)
|
||||
NODE_CLASS_MAPPINGS[name] = getattr(mod, cls_name)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Failed to load '{name}': {e}")
|
||||
```
|
||||
|
||||
### Hidden Inputs (UNIQUE_ID, PROMPT, EXTRA_PNGINFO)
|
||||
```python
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID", # キャッシュの名前空間キーに使用
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO", # PNG出力にメタデータ埋め込み
|
||||
}
|
||||
```
|
||||
`UNIQUE_ID`を使わずモジュールレベルキャッシュを共有すると、同一ノードの複数インスタンスが干渉する。
|
||||
|
||||
### Lazy Inputs & 条件付き実行
|
||||
不要な上流実行を防ぐ:
|
||||
```python
|
||||
# INPUT_TYPESで宣言
|
||||
"optional": {"heavy_data": ("IMAGE", {"lazy": True})}
|
||||
|
||||
# 必要な入力だけリクエスト
|
||||
def check_lazy_status(self, **kwargs):
|
||||
needed = []
|
||||
if self.needs_heavy_data:
|
||||
needed.append("heavy_data")
|
||||
return needed # 空リスト = 上流実行をスキップ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 開発時の注意事項
|
||||
|
||||
### Python変更後は必ずComfyUIを再起動
|
||||
ComfyUIは起動時に `custom_nodes/` を一度だけインポートする。
|
||||
Pythonファイルの変更はComfyUIプロセスの完全再起動まで反映されない。
|
||||
|
||||
### __pycache__ のクリア
|
||||
ディレクトリ間のコピーやブランチ切り替え後、古いバイトコードが残ることがある。
|
||||
動作がおかしい場合は `__pycache__/` を削除してから再起動する。
|
||||
|
||||
### 依存パッケージのインストール先
|
||||
Windows Portable版のComfyUIは `python_embeded/` 内の組み込みPythonを使用する。
|
||||
システムのPythonにインストールしても認識されない。
|
||||
```
|
||||
python_embeded/python.exe -m pip install package_name
|
||||
```
|
||||
torch関連パッケージはCUDA index URLを指定:
|
||||
```
|
||||
python_embeded/python.exe -m pip install torchaudio --index-url https://download.pytorch.org/whl/cu130
|
||||
```
|
||||
インストール後は必ず `torch.cuda.is_available()` で確認する。
|
||||
|
||||
### デプロイ
|
||||
ソースリポジトリと `custom_nodes/` のランタイムディレクトリは分離する。
|
||||
`custom_nodes/` 内で直接編集せず、同期スクリプトでパッケージ全体をデプロイする。
|
||||
部分的な同期は原因不明のエラーを引き起こす。
|
||||
デプロイ後はハッシュ比較でファイル同一性を検証する。
|
||||
|
||||
### prestartup_script.py
|
||||
環境変数の設定は `__init__.py` では遅い。`prestartup_script.py` はノードインポート前に実行される唯一の場所:
|
||||
```python
|
||||
# custom_nodes/ComfyUI-Workflow-Studio/prestartup_script.py
|
||||
import os
|
||||
os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
```
|
||||
|
||||
### 依存パッケージの競合防止
|
||||
- 依存はゆるくピン留め: `transformers>=4.40,<6.0` (厳密な `==` は避ける)
|
||||
- torchは `requirements.txt` にピン留めしない(ComfyUI本体が管理)
|
||||
- バージョン依存のインポートは `try/except` でフォールバック
|
||||
|
||||
### テスト戦略 (ComfyUI不要のテスト)
|
||||
1. `ast.parse()` で構文チェック (0.1秒)
|
||||
2. ビジネスロジックを `execute()` から分離し pytest でテスト (1-2秒)
|
||||
3. ComfyUI統合テストは最終確認のみ (30-60秒)
|
||||
|
||||
### ファイルパスの扱い
|
||||
出力・一時ファイルは `folder_paths.get_output_directory()` / `folder_paths.get_temp_directory()` を使用。
|
||||
相対パスはComfyUIの起動方法によって解決先が変わる。
|
||||
|
||||
### Windows固有の注意
|
||||
- PowerShellのデフォルトエンコーディングはUTF-8を壊す。ファイル書き込みはPython経由で行う
|
||||
- Windows MAX_PATH (260文字) 制限: 浅いパス (`C:\ComfyUI`) にインストールするか、LongPathsEnabled レジストリを有効化
|
||||
- HuggingFaceのシンボリックリンクは開発者モードなしだと無言でファイルが複製される (`HF_HOME`を短いパスに設定)
|
||||
|
||||
### バウンドキャッシュ
|
||||
モジュールレベルのキャッシュは `OrderedDict` + 最大サイズ制限で実装する。
|
||||
無制限キャッシュは長時間セッションでメモリを食い潰す。
|
||||
|
||||
---
|
||||
|
||||
## サイレント障害の診断
|
||||
|
||||
| 症状 | 原因 | 対処 |
|
||||
|------|------|------|
|
||||
| 編集後も動作が変わらない | 古いコード | ComfyUI再起動 + `__pycache__/` 削除 |
|
||||
| 変更したのにノードが実行されない | キャッシュ | `IS_CHANGED` で `float("NaN")` を返す |
|
||||
| ワークフロー読込で値がおかしい | UIマッピングずれ | ウィジェット位置が保存JSONと一致するか確認 |
|
||||
| ノードが赤い "missing" 表示 | タイプ名不一致 | `NODE_CLASS_MAPPINGS` のキーと大文字小文字を確認 |
|
||||
| 接続線が消える | リンクID不整合 | links[], outputs[].links, inputs[].link の3箇所を検証 |
|
||||
| 重複ノード動作 | `last_node_id` が低すぎる | 最大ノードID + 1 に設定 |
|
||||
| CUDAが使えなくなった | CPU版wheelのインストール | `torch.cuda.is_available()` 確認、CUDA index URLで再インストール |
|
||||
| ヘッドレスモードで失敗 | UI前提のコード | `{"ui": {...}, "result": (...)}` で分離 |
|
||||
|
||||
---
|
||||
|
||||
## コミット前チェックリスト
|
||||
1. 全ファイルを `custom_nodes/` に同期(部分同期しない)
|
||||
2. 両方の `__pycache__/` をクリア
|
||||
3. 全 .py ファイルのハッシュ比較で同一性を確認
|
||||
4. `ast.parse()` で全 .py ファイルの構文チェック
|
||||
5. `INPUT_TYPES` 変更時は `widgets_values` も同時更新
|
||||
6. 削除した依存への参照が残っていないか grep で確認
|
||||
7. ComfyUI完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
|
||||
@@ -1,5 +1,300 @@
|
||||
# DEVLOG - ComfyUI-Workflow-Studio
|
||||
|
||||
## 2026-03-28: v0.2.2 Modelsタブ・CivitAI連携
|
||||
|
||||
### 概要
|
||||
- ComfyUIにインストールされたモデル(Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder)を一覧・管理する「Models」タブを新規追加
|
||||
- CivitAI API連携(SHA256ハッシュによるモデル情報取得、一括取得)
|
||||
- モデルグループ管理、プレビュー画像表示・変更、メタデータ永続化
|
||||
- ノードカードビュー改善、全タブのページネーションをツールバーに移動
|
||||
|
||||
### 新規ファイル
|
||||
|
||||
#### `py/services/models_service.py` — モデルメタデータサービス
|
||||
- `_get_model_dirs()`: ComfyUIの`folder_paths.get_folder_paths()`でモデルディレクトリを解決
|
||||
- `ModelsService`: メタデータのCRUD(お気に入り、タグ、メモ、SHA256)
|
||||
- `find_preview_image()`: `{stem}.preview.png`等のプレビュー画像を自動検出
|
||||
- `get_model_groups()` / `save_model_groups()`: グループの永続化(`model_metadata.json`の`_groups`キー)
|
||||
|
||||
#### `py/services/civitai_service.py` — CivitAI API連携
|
||||
- `calculate_sha256()`: モデルファイルのSHA256ハッシュ計算
|
||||
- `fetch_by_hash()`: CivitAI APIからモデル情報取得(キャッシュ付き)
|
||||
- `batch_fetch()`: 複数モデルの一括取得(プログレスコールバック対応)
|
||||
- `_extract_info()`: APIレスポンスから必要フィールドを抽出(modelName, baseModel, trainedWords, images等)
|
||||
- キャッシュ: `civitai_cache.json`に保存
|
||||
|
||||
#### `py/routes/models_routes.py` — モデル管理APIルート
|
||||
- `GET/POST /api/wfm/models/metadata` — メタデータCRUD
|
||||
- `GET /api/wfm/models/preview` — プレビュー画像配信
|
||||
- `GET/POST /api/wfm/models/groups` — グループ管理
|
||||
- `POST /api/wfm/models/civitai/fetch` — 個別CivitAI取得
|
||||
- `GET /api/wfm/models/civitai/cache` — CivitAIキャッシュ取得
|
||||
- `POST /api/wfm/models/civitai/batch` — SSEストリーミング一括取得
|
||||
- `POST /api/wfm/models/change-preview` — プレビュー画像アップロード
|
||||
- `GET /api/wfm/models/filepath` — モデルファイルのフルパス取得
|
||||
|
||||
#### `static/js/models-tab.js` — フロントエンドモジュール
|
||||
- state管理: `modelsByType`, `modelMetadata`, `modelGroups`, `civitaiCache`, `activeModelType`
|
||||
- サブタブ切り替え(6タイプ)、サムネイル/カード/テーブル表示
|
||||
- サイドパネル: Info(ファイルパス表示、タグ、メモ)、Group管理、CivitAI情報表示
|
||||
- 詳細モーダル: プレビュー画像、CivitAI情報、サムネイル変更
|
||||
- 一括CivitAI取得ボタン(SSEプログレス付き)
|
||||
- テーブルビューにメモ列表示
|
||||
|
||||
### 変更ファイル
|
||||
|
||||
#### `py/config.py`
|
||||
- `MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"` 追加
|
||||
|
||||
#### `py/wfm.py`
|
||||
- `models_routes`のインポートとルート登録追加
|
||||
|
||||
#### `templates/index.html`
|
||||
- Modelsタブボタン・セクション追加(サブタブナビ、ツールバー、グリッド、サイドパネル)
|
||||
- ヘルプタブにModels Tabセクション追加
|
||||
|
||||
#### `static/js/app.js`
|
||||
- `models-tab.js`のimportと`initModelsTab()`呼び出し追加
|
||||
- i18nマッピングにModelsタブ・ヘルプ用キー追加
|
||||
|
||||
#### `static/js/nodes-tab.js`
|
||||
- カードビュー: パッケージバッジ削除、カード左端にパッケージ色のボーダー追加
|
||||
- カードビュー: 入出力カウント表示を削除
|
||||
- ページネーションをツールバーに移動
|
||||
|
||||
#### `static/js/workflow-tab.js`
|
||||
- ページネーションをツールバーに移動
|
||||
- 1ページ24件表示
|
||||
|
||||
#### `static/css/main.css`
|
||||
- `.wfm-grid`: `grid-template-columns: repeat(auto-fill, 161px)`、カード161×162px固定
|
||||
- `.wfm-node-card`: 左ボーダー3pxスタイル追加
|
||||
- `.wfm-pagination-inline`: ツールバー内ページネーション
|
||||
- サブタブナビ(`.wfm-models-type-nav`)、テーブル列幅調整
|
||||
|
||||
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
|
||||
- Modelsタブ関連: ~50キー(モデルタイプ、フィルタ、サイドパネル、CivitAI、グループ等)
|
||||
- ヘルプModelsセクション: `helpModels1`〜`helpModels7`
|
||||
- ファイルパス関連: `modelsFilePath`, `modelsCopyPath`, `modelsCopiedPath`
|
||||
|
||||
### 技術的な判断
|
||||
- **プレビュー画像判定**: HEADリクエストではなく`img.onload/onerror`パターンを採用(404コンソールスパム回避)
|
||||
- **CivitAI一括取得**: ブロッキングリクエストではなくSSE(Server-Sent Events)でリアルタイムプログレス表示
|
||||
- **aiohttp HEAD自動登録**: `add_get`が自動でHEADも登録するため、明示的なHEADルート追加は不要(重複RuntimeError回避)
|
||||
- **テーブルビューメモ列**: `max-width: 200px`+`text-overflow: ellipsis`で長文を省略表示
|
||||
- **ファイルパス表示**: バックエンドAPIでフルパスを解決し、クリックでクリップボードコピー
|
||||
|
||||
## 2026-03-27: テーマカスタマイズ機能・設定タブ改善
|
||||
|
||||
### 概要
|
||||
- テーマのカラーカスタマイズ、背景パターン、フォント選択機能を追加
|
||||
- 設定タブの各セクションをアコーディオン式折りたたみに変更
|
||||
- ヘルプタブのSettings Tab説明を更新
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `static/js/settings-tab.js` — テーマカスタマイズ機能追加
|
||||
- **カラーオーバーライド:** 6つのCSS変数(背景、サブ背景、サーフェス、テキスト、プライマリ、アクセント)をカラーピッカーでリアルタイム調整、localStorageに保存
|
||||
- **背景パターン:** 7種類(なし、横/縦/斜めストライプ、ポルカドット、チェック、SVGファイル)を選択可能
|
||||
- パターンオプション: 色、不透明度、サイズ、間隔(gap)スライダー
|
||||
- SVGタイリング: FileReader APIでクライアント側読込、色の置換(fill/stroke属性+`<style>`要素注入)、`::after`疑似要素でopacity制御、gap対応はviewBox拡張ラッパーSVG方式
|
||||
- **フォント選択:** 16種のGoogle Fonts(日本語デザインフォント含む)を動的ロード、クリックで即時プレビュー
|
||||
- ゴシック系: Noto Sans JP, Zen丸ゴシック, M PLUS Rounded, 小杉丸ゴシック, さわらびゴシック, BIZ UDPゴシック
|
||||
- デザイン系: ドットゴシック16, はちまるポップ, デラゴシック, レゲエ One, ロックンロール, ステッキ, トレイン One
|
||||
- 等幅: Space Mono, Fira Code
|
||||
- **保存ロジック修正:** `saveLocalSettings()`のスプレッドマージでは`delete`したキーが消えない問題を修正、リセット時は`localStorage.setItem()`で直接上書き
|
||||
- **SVGリカラー強化:** `<style>`ブロックをSVGに注入し、fill属性がない要素にも色を適用
|
||||
|
||||
#### `static/css/main.css` — UIスタイル追加
|
||||
- **設定アコーディオン:** `<details>`/`<summary>`要素用のスタイル(`.wfm-settings-section`, `.wfm-settings-summary`)— 三角矢印回転アニメーション付き
|
||||
- **テーマカスタマイザー:** カラーピッカーグリッド、パターン選択グリッド(プレビューSVG付き)、パターンオプション行、フォント選択グリッド
|
||||
- **パターンプレビュー改善:** SVGプレビューのコントラストを大幅強化(60x60サイズ、太い線、高opacity、青系前景色)
|
||||
|
||||
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
|
||||
- テーマカスタマイズ関連: カラー調整、背景パターン、パターンオプション(色、不透明度、サイズ、間隔)、フォント
|
||||
- フォント名: 16種のフォントラベル
|
||||
- ヘルプSettings項目: helpSettings7, helpSettings8追加
|
||||
|
||||
#### `static/js/app.js` — i18nマッピング追加
|
||||
- `helpSettings7`, `helpSettings8` のHTML id→i18nキーマッピングを追加
|
||||
|
||||
#### `templates/index.html` — ヘルプタブ更新
|
||||
- Settings Tabセクション: テーマカスタマイズ機能の説明を追加(項目2)、Eagle連携を項目7に、アコーディオン折りたたみを項目8に追加
|
||||
|
||||
### 技術的な判断
|
||||
- **SVGカラー変更:** 属性置換だけでは不十分なSVG(CSSクラスやデフォルト黒を使用)に対応するため、`<style>`ブロック注入方式を採用
|
||||
- **SVG間隔(gap):** viewBoxを拡張するラッパーSVGでタイル間に余白を作成
|
||||
- **フォント読込:** Google Fonts CDNから動的にlinkタグを挿入、一度読み込んだフォントはSetで管理し重複読込を防止
|
||||
- **アコーディオン:** `<details>`/`<summary>`ネイティブHTML要素を使用、JSなしで開閉動作
|
||||
|
||||
## 2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査
|
||||
|
||||
### 概要
|
||||
- トップバーの3つのボタン(Workflow Studio / Snapshot / Library)のアイコンが表示されない問題を修正
|
||||
- ComfyUI Appモード(linearMode)のワークフローJSON構造を調査、バッジ表示対応は保留
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `web/comfyui/top_menu_extension.js` — アイコン置換ロジック修正
|
||||
- **問題:** `setup()` 内の `replaceButtonIcon()` が `requestAnimationFrame` でボタンを見つけてSVGを注入するが、ComfyUIのVueフレームワークが再レンダリングして元の `<i class="icon-[mdi--...]">` に戻してしまう。リトライ条件が `wfmButtons.length === 0`(ボタン未検出時のみ)のため、一度見つかった後は再注入されなかった
|
||||
- **修正:** `MutationObserver` でボタンの親コンテナ(`.actionbar-container`)を監視し、DOMが変更されるたびにSVGアイコンを再注入するように変更
|
||||
- `applyButtonIcon()`: ボタンに既にSVGがあればスキップ(無限ループ防止)、なければSVG注入
|
||||
- `waitAndObserve()`: ボタンがDOMに現れるまで `requestAnimationFrame` で待機し、見つかったらアイコン置換+`MutationObserver`監視開始
|
||||
|
||||
### Appモードバッジ表示について(保留)
|
||||
- ComfyUI Appモード(BETA機能)のワークフローJSON構造を調査
|
||||
- `extra.linearMode: true` と `extra.linearData` (inputs/outputs配列)でappモード情報が格納される
|
||||
- appモード削除後も `linearData`/`linearMode` キーは残り中身が空配列になるだけ、拡張子 `.app.json` も維持される
|
||||
- **結論:** Appモード自体がBETA機能のため、バッジ表示対応は現状維持とする
|
||||
|
||||
## 2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library
|
||||
|
||||
### 概要
|
||||
- プロンプトプリセット機能を追加:サイドパネルからドラッグ&ドロップでWFS_PromptTextカスタムノードを作成
|
||||
- Workflow Studio SPAのPromptタブを3カラムレイアウトに刷新(AIアシスタント|プリセット編集|プリセットマネージャー)
|
||||
- プリセットのバックエンドAPI化(localStorage→サーバーサイド保存)
|
||||
- サイドパネル名を「WF & Node Library」→「Workflow Studio Library」に変更
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `py/nodes/prompt_text.py` — WFS_PromptTextカスタムノード(新規)
|
||||
- **WFS_PromptText**: ポジティブ/ネガティブの2つのSTRING入力とSTRING出力を持つカスタムノード
|
||||
- `INPUT_TYPES`: `positive`(multiline), `negative`(multiline)
|
||||
- `RETURN_TYPES`: `("STRING", "STRING")`, `RETURN_NAMES`: `("positive", "negative")`
|
||||
- `CATEGORY`: `"Workflow Studio"`
|
||||
|
||||
#### `py/nodes/__init__.py` — パッケージ初期化(新規)
|
||||
- 空ファイル(Pythonパッケージ認識用)
|
||||
|
||||
#### `__init__.py` — カスタムノード登録
|
||||
- `_NODE_MODULES` に `WFS_PromptText` を追加(分離読み込みパターン)
|
||||
- `NODE_DISPLAY_NAME_MAPPINGS` に `"Prompt Text (WFS)"` を追加
|
||||
|
||||
#### `py/services/prompts_service.py` — プリセットCRUDサービス(新規)
|
||||
- `list_prompts()`: 全プリセット一覧取得
|
||||
- `create_prompt(data)`: 新規作成(name, text, negText, category, tags, favorite)
|
||||
- `update_prompt(id, updates)`: 更新(name, text, negText, category, tags, favorite)
|
||||
- `delete_prompt(id)`: 削除
|
||||
- `list_categories()`: カテゴリ一覧取得
|
||||
- データ永続化: `data/prompts.json`
|
||||
|
||||
#### `py/routes/prompts_routes.py` — プリセットAPIエンドポイント(新規)
|
||||
- `GET /api/wfm/prompts` — 一覧取得
|
||||
- `POST /api/wfm/prompts` — 新規作成
|
||||
- `POST /api/wfm/prompts/update` — 更新
|
||||
- `POST /api/wfm/prompts/delete` — 削除
|
||||
|
||||
#### `py/config.py` — 設定追加
|
||||
- `PROMPTS_FILE = DATA_DIR / "prompts.json"` を追加
|
||||
|
||||
#### `py/wfm.py` — ルート登録
|
||||
- `prompts_routes` のインポートと `prompts_routes.setup_routes(app)` を追加
|
||||
|
||||
#### `web/comfyui/node_sets_menu.js` — サイドパネルにPromptsタブ追加
|
||||
- **トップタブ:** Workflows / Nodes / **Prompts** の3タブ構成
|
||||
- **Promptsサブタブ:** All / ★ Favorites / 📁 Categories
|
||||
- **State追加:** `promptSubTab`, `promptList`, `promptFavorites`, `promptCategories`, `promptLoaded`
|
||||
- **API関数:** `fetchPrompts()`, `loadPromptData()`
|
||||
- **レンダリング:** `renderPromptAll()`, `renderPromptFavorites()`, `renderPromptCategories()`
|
||||
- **ドラッグ&ドロップ:** `application/x-wfm-prompt` MIMEタイプ、`placePromptNode(posText, negText, promptName, pos)` でWFS_PromptTextノード作成
|
||||
- **P/Nコピーボタン:** 各プリセットアイテムにポジティブ(P)・ネガティブ(N)の個別コピーボタン(Nはテキスト空の場合非表示)
|
||||
- **パネル名変更:** ツールチップ・タイトルを「Workflow Studio Library」に更新
|
||||
- **CSS追加:** `.wfm-nlp-copy-btns`, `.wfm-nlp-copy-pos`(緑ホバー), `.wfm-nlp-copy-neg`(赤ホバー)
|
||||
|
||||
#### `templates/index.html` — Promptタブ3カラムレイアウト
|
||||
- 2カラム(AI Assistant | Presets)→ 3カラム(AI Assistant | Presets | Preset Manager)に変更
|
||||
- **Presetsパネル:** Deleteボタン削除、Apply→「GenUI Set」、Category入力欄追加、下部にグループ管理行(Select group + Add to Group + + Group + Del Group)
|
||||
- **Preset Managerパネル:** All / ★ / Groupsタブ、検索、スクロール可能なリスト
|
||||
- **ヘルプタブ:** helpPrompt5〜7追加、helpSidepanel4・7〜10追加(Promptsタブ・ドラッグ&ドロップ・P/Nコピー)
|
||||
- サイドパネルタイトルを「Workflow Studio Library」に更新
|
||||
|
||||
#### `static/css/main.css` — スタイル追加(約150行)
|
||||
- `.wfm-prompt-split-3col` — 3カラムFlexboxレイアウト
|
||||
- `.wfm-prompt-split-col` — 各カラムのスタイル
|
||||
- `.wfm-preset-manager` — プリセットマネージャーパネル
|
||||
- `.wfm-pm-tabs`, `.wfm-pm-tab` — マネージャータブ
|
||||
- `.wfm-pm-list`, `.wfm-pm-item` — アイテムリスト
|
||||
- `.wfm-pm-item-actions`, `.wfm-pm-action-btn` — お気に入り(★)・削除(✕)ボタン
|
||||
- `.wfm-pm-group-header` — グループヘッダー
|
||||
|
||||
#### `static/js/prompt-tab.js` — 全面リライト
|
||||
- **API化:** `fetchPresets()`, `apiCreatePreset()`, `apiUpdatePreset()`, `apiDeletePreset()`
|
||||
- **localStorage移行:** `migrateLocalStoragePresets()` — 一回限りの自動移行
|
||||
- **プリセット管理:** `renderPresetSelect()`, `renderGroupSelect()`, `selectPresetInEditor(preset)`
|
||||
- **Preset Manager:** `renderPresetManager()`, `renderPmAll()`, `renderPmFavorites()`, `renderPmGroups()`
|
||||
- **`createPmItem()`:** お気に入りトグル(★)と削除(✕)ボタン(コピーボタンなし — Presetsパネル側にPP/NP Copyあり)
|
||||
- **グループ管理:** Presetsパネル下部のnew-group, add-to-group, del-groupボタン
|
||||
- **Save:** API経由でnegTextフィールドを含むプリセットの作成/更新
|
||||
|
||||
#### `static/js/i18n.js` — 翻訳更新
|
||||
- `applyPreset` → `"GenUI Set"`(英/日/中 全3言語)
|
||||
- `deletePreset` キー削除(全3言語)
|
||||
- ヘルプ: `helpPrompt5`〜`helpPrompt7`, `helpSidepanel8`〜`helpSidepanel10` 追加(全3言語)
|
||||
- サイドパネルタイトル: `helpSidepanelTitle` → `"Workflow Studio Library"` に更新(全3言語)
|
||||
|
||||
#### `static/js/app.js` — i18nマッピング更新
|
||||
- `helpIdMap` に `helpPrompt5`〜`7`, `helpSidepanel8`〜`10` を追加
|
||||
- `deletePreset` 適用コード削除
|
||||
|
||||
#### `README.md` — v0.2.0更新
|
||||
- バージョンバッジ: `0.1.9` → `0.2.0`
|
||||
- Prompt Tab: 3カラムレイアウト、Preset Manager、グループ管理、GenUI Set
|
||||
- Workflow Studio Library: Promptsタブ、ドラッグ&ドロップ、P/Nコピー
|
||||
- Project Structure: `py/nodes/`, `prompts_routes.py`, `prompts_service.py` 追加
|
||||
- Changelog: v0.2.0エントリ追加
|
||||
- スクリーンショット全8枚差し替え
|
||||
|
||||
## 2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正
|
||||
|
||||
### 概要
|
||||
- サイドパネルのサブタブを2段構成に変更、全ワークフロー/全ノード表示タブを追加
|
||||
- キャンバススナップショット保存時にファイル名編集ダイアログを表示
|
||||
- API/App形式バッジをワークフロー一覧に表示
|
||||
- グループ内の削除済みワークフロー(Not found)を自動クリーンアップ
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `web/comfyui/node_sets_menu.js` — サイドパネルサブタブ2段化
|
||||
- **Workflows サブタブ:**
|
||||
- 1段目: Workflows(全WF一覧)/ ★ Favorites / 📁 Groups
|
||||
- 2段目: ◦ Model Type
|
||||
- **Nodes サブタブ:**
|
||||
- 1段目: Nodes(全ノード一覧)/ ✳ Favorites / 📁 Groups
|
||||
- 2段目: ☰ Sets
|
||||
- **State 変更:** `wfSubTab2`, `activeTab2` を追加(2段目タブの排他制御用)
|
||||
- **新レンダリング関数:**
|
||||
- `renderWfAll()` — 全ワークフロー一覧表示
|
||||
- `renderWfFavorites()` — お気に入りワークフロー一覧(タブとして復活)
|
||||
- `renderAllNodes()` — `LiteGraph.registered_node_types` から全ノード一覧表示
|
||||
- **API/App形式バッジ:** `createDraggableWfItem()` でAPI形式(赤)・App形式(オレンジ)のバッジをファイル名前に表示
|
||||
- **グループ自動クリーンアップ:** `loadWfData()` でワークフロー一覧取得時に、存在しないファイルをグループから自動除去し localStorage を更新
|
||||
- **Not found 表示削除:** `renderWfGroups()` から削除済みワークフローの灰色表示コードを除去
|
||||
- **CSS 追加:**
|
||||
- `.wfm-nlp-subtabs-row2` — 2段目サブタブ行
|
||||
- `.wfm-nlp-fmt-badge`, `.wfm-nlp-fmt-api`, `.wfm-nlp-fmt-app` — 形式バッジスタイル
|
||||
|
||||
#### `web/comfyui/top_menu_extension.js` — 保存ダイアログ追加
|
||||
- **`showSaveDialog(defaultName)`** — モーダルダイアログでファイル名を編集可能に
|
||||
- タイムスタンプ形式のデフォルト名がプレースホルダーとして全選択状態で表示
|
||||
- Enter/Saveボタンで確定、Escape/Cancel/オーバーレイクリックでキャンセル
|
||||
- `.json` 拡張子は自動付与
|
||||
- **`saveCanvasToWorkflowStudio()`** — 自動保存からダイアログ経由の保存に変更
|
||||
|
||||
#### `py/services/workflow_analyzer.py` — ワークフロー形式検出
|
||||
- `analyze_workflow()` の返却値に `format` フィールドを追加
|
||||
- `"app"`: `definitions`(サブグラフ)キー存在 or `extra.linearMode === true`、フォールバックで `.app.json` ファイル名
|
||||
- `"ui"`: `nodes` 配列 + `links` 存在(App特徴なし)
|
||||
- `"api"`: 全トップレベル値に `class_type` 存在
|
||||
- `"unknown"`: 判定不能
|
||||
|
||||
#### `static/js/comfyui-workflow.js` — フロントエンド形式検出も構造ベースに変更
|
||||
- `detectFormat()` をファイル名依存から `definitions` / `extra.linearMode` による構造判定に変更
|
||||
|
||||
#### `static/js/workflow-tab.js` — グループクリーンアップ
|
||||
- `loadWorkflows()` でワークフロー取得後にグループの不要エントリを自動削除
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-24: v0.1.8 WF & Node Library サイドパネル拡張
|
||||
|
||||
### 概要
|
||||
|
||||
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
|
||||
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -17,13 +17,17 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Nodes Tab | Help & Support Tab |
|
||||
| Nodes Tab | Models Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| WF & Node Library (ComfyUI) | ComfyUI Integration |
|
||||
| Help & Support Tab | |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|  | |
|
||||
|
||||
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -55,20 +59,24 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
|
||||
### Prompt Tab
|
||||
- **Split-panel layout** — AI assistant and presets displayed side-by-side for simultaneous use
|
||||
- **3-column layout** — AI Assistant, Presets editor, and Preset Manager displayed side-by-side
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
|
||||
- **Prompt presets** — save/load reusable prompt templates (positive & negative)
|
||||
- **Clipboard copy** — copy positive/negative prompts individually for use in ComfyUI
|
||||
- **Apply to GenerateUI** — send prompts directly to the generation interface
|
||||
- **Prompt presets** — save/load reusable prompt templates (positive & negative) with category support
|
||||
- **Preset Manager** — browse all presets, favorites, and group-based filtering with search
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
|
||||
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
|
||||
|
||||
### Settings Tab
|
||||
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
|
||||
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
|
||||
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
|
||||
- **Workflows directory** — configure which folder to scan for workflows
|
||||
- **Eagle connection** — set Eagle API endpoint for auto-save
|
||||
- **Ollama connection** — configure Ollama server URL
|
||||
- **Badge display** — toggle metadata badges on workflow cards
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Language** — English / Japanese / Chinese
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
@@ -79,11 +87,26 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
|
||||
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
|
||||
|
||||
### WF & Node Library (ComfyUI Side Panel) (v0.1.8)
|
||||
### Models Tab (v0.2.2)
|
||||
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with sub-tab switching
|
||||
- **Thumbnail / Card / Table views** — switch between view modes with pagination (24 items per page)
|
||||
- **Search & Filter** — full-text search, filter by tags, groups, and favorites
|
||||
- **Side panel tabs** — Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
|
||||
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link
|
||||
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
|
||||
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
|
||||
- **Group management** — create, rename, delete groups and assign/remove models
|
||||
- **Table view memo** — memo column displayed in table view for quick reference
|
||||
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.1.8)
|
||||
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group
|
||||
- **Nodes tab** — browse favorite nodes, node sets, and node groups
|
||||
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
|
||||
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
|
||||
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
|
||||
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
|
||||
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
|
||||
- **Double-click** — load workflows or place nodes without dragging
|
||||
- **Search** — search within each sub-tab to quickly find items
|
||||
|
||||
@@ -162,6 +185,35 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.2.2
|
||||
- **Models tab** — browse, search, and manage all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with Thumbnail/Card/Table views
|
||||
- **CivitAI integration** — fetch model metadata by SHA256 hash (individual or batch per model type) with SSE progress streaming
|
||||
- **Model groups** — create, rename, delete groups and assign/remove models for organization
|
||||
- **Model metadata** — persistent favorites, tags, memo, and SHA256 hash per model
|
||||
- **Preview images** — auto-detect and display preview images, change via file upload in detail modal
|
||||
- **Table view memo column** — memo displayed in table view with compact subdir/extension columns
|
||||
- **Side panel file path** — display full file path with click-to-copy in model info panel
|
||||
- **Node card redesign** — removed package badge, added left border color-coding by package
|
||||
- **Toolbar pagination** — pagination controls moved to toolbar for all tabs (Models, Workflows, Nodes)
|
||||
- **Help tab updated** — Models Tab section added to feature list
|
||||
|
||||
### v0.2.0
|
||||
- **Prompt presets in sidebar** — added Prompts tab to Workflow Studio Library (ComfyUI side panel) with All / Favorites / Categories sub-tabs
|
||||
- **WFS_PromptText custom node** — drag prompt presets onto the canvas to create nodes with positive/negative prompt outputs
|
||||
- **Preset Manager** — 3-column layout in Prompt tab: AI Assistant | Presets editor | Preset Manager (All / Favorites / Groups)
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **GenUI Set** — renamed "Apply" button to clarify its purpose (applies presets to GenerateUI)
|
||||
- **Sidebar P/N copy buttons** — copy positive or negative prompts individually from sidebar items
|
||||
- **Panel renamed** — "WF & Node Library" renamed to "Workflow Studio Library"
|
||||
- **Backend API for presets** — presets migrated from localStorage to server-side API with one-time migration
|
||||
- **Help tab updated** — Prompt Tab and Workflow Studio Library sections reflect new features
|
||||
|
||||
### v0.1.9
|
||||
- **Side panel 2-row sub-tabs** — Workflows: row 1 (Workflows / Favorites / Groups), row 2 (Model Type); Nodes: row 1 (Nodes / Favorites / Groups), row 2 (Sets)
|
||||
- **Save dialog** — canvas snapshot now shows a filename edit dialog instead of auto-naming
|
||||
- **API/App format badge** — workflow items in side panel show red (API) or orange (APP) badge
|
||||
- **Group auto-cleanup** — deleted workflows are automatically removed from groups on refresh
|
||||
|
||||
### v0.1.8
|
||||
- **WF & Node Library side panel** — renamed from "Node Library", added two top-level tabs (Workflows / Nodes) accessible from ComfyUI top bar
|
||||
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group with collapsible sections
|
||||
@@ -210,20 +262,27 @@ ComfyUI-Workflow-Studio/
|
||||
├── py/
|
||||
│ ├── wfm.py # Main class & route registration
|
||||
│ ├── config.py # Path configuration
|
||||
│ ├── nodes/
|
||||
│ │ └── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
|
||||
│ ├── routes/
|
||||
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
|
||||
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
|
||||
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
|
||||
│ │ ├── prompts_routes.py # Prompt presets CRUD API
|
||||
│ │ ├── settings_routes.py # Settings API
|
||||
│ │ ├── ollama_routes.py # Ollama proxy API
|
||||
│ │ └── eagle_routes.py # Eagle integration API
|
||||
│ └── services/
|
||||
│ ├── workflow_service.py # Workflow file operations
|
||||
│ ├── nodes_service.py # Node metadata & node sets
|
||||
│ ├── models_service.py # Model metadata & preview images
|
||||
│ ├── civitai_service.py # CivitAI API integration & cache
|
||||
│ ├── prompts_service.py # Prompt presets persistence
|
||||
│ ├── workflow_analyzer.py # Model/node detection
|
||||
│ ├── settings_service.py # Settings persistence
|
||||
│ └── png_extractor.py # PNG metadata extraction
|
||||
├── templates/
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Help)
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
|
||||
├── static/
|
||||
│ ├── favicon.svg # Browser tab icon (W+S Wave)
|
||||
│ ├── css/main.css # Styles
|
||||
@@ -235,14 +294,15 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── settings-tab.js # Settings panel
|
||||
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
|
||||
│ ├── nodes-tab.js # Node browser & node sets
|
||||
│ ├── models-tab.js # Model browser & CivitAI integration
|
||||
│ ├── comfyui-workflow.js # UI-to-API format conversion
|
||||
│ ├── comfyui-editor.js # Dynamic parameter editor
|
||||
│ ├── json-highlight.js # JSON syntax highlighting
|
||||
│ └── i18n.js # Internationalization
|
||||
├── web/comfyui/
|
||||
│ ├── top_menu_extension.js # ComfyUI menu bar integration
|
||||
│ └── node_sets_menu.js # WF & Node Library side panel
|
||||
└── data/ # Metadata & settings storage
|
||||
│ └── node_sets_menu.js # Workflow Studio Library side panel
|
||||
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
from .py.wfm import WorkflowStudio
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
# Register routes on import
|
||||
WorkflowStudio.add_routes()
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
|
||||
# Register custom nodes (isolated loading)
|
||||
_NODE_MODULES = {
|
||||
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText"),
|
||||
}
|
||||
|
||||
for _name, (_mod_path, _cls_name) in _NODE_MODULES.items():
|
||||
try:
|
||||
import importlib
|
||||
_mod = importlib.import_module(_mod_path, package=__name__)
|
||||
NODE_CLASS_MAPPINGS[_name] = getattr(_mod, _cls_name)
|
||||
NODE_DISPLAY_NAME_MAPPINGS[_name] = "Prompt Text (WFS)"
|
||||
except Exception as _e:
|
||||
print(f"[WARNING] Workflow Studio: Failed to load '{_name}': {_e}")
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 975 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 148 KiB |
@@ -57,3 +57,5 @@ METADATA_FILE = DATA_DIR / "metadata.json"
|
||||
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||
NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
|
||||
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""WFS_PromptText – Prompt preset node with positive/negative STRING outputs."""
|
||||
|
||||
|
||||
class WFS_PromptText:
|
||||
"""Outputs positive and negative prompt strings."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"positive": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"placeholder": "Positive prompt...",
|
||||
}),
|
||||
"negative": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"placeholder": "Negative prompt...",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("positive", "negative")
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "Workflow Studio"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def execute(self, positive, negative):
|
||||
return (positive, negative)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Model metadata API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.models_service import ModelsService
|
||||
from ..services.civitai_service import CivitaiService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = ModelsService()
|
||||
_civitai = CivitaiService()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all model management API routes."""
|
||||
app.router.add_get("/api/wfm/models/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/models/metadata", handle_save_metadata)
|
||||
app.router.add_get("/api/wfm/models/preview", handle_get_preview)
|
||||
app.router.add_get("/api/wfm/models/groups", handle_get_groups)
|
||||
app.router.add_post("/api/wfm/models/groups", handle_save_groups)
|
||||
app.router.add_post("/api/wfm/models/civitai/fetch", handle_civitai_fetch)
|
||||
app.router.add_get("/api/wfm/models/civitai/cache", handle_civitai_cache)
|
||||
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
|
||||
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
|
||||
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
|
||||
|
||||
|
||||
# ── Model Metadata ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_metadata(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/metadata"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_all_metadata)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading model metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_metadata(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/metadata"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_name = body.get("modelName", "")
|
||||
if not model_name:
|
||||
return web.json_response({"error": "modelName is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "modelName"}
|
||||
result = await asyncio.to_thread(_service.update_metadata, model_name, updates)
|
||||
return web.json_response({"status": "ok", "metadata": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving model metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Model Groups ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_groups(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/groups"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_model_groups)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading model groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_groups(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/groups"""
|
||||
try:
|
||||
groups = await request.json()
|
||||
result = await asyncio.to_thread(_service.save_model_groups, groups)
|
||||
return web.json_response({"status": "ok", "groups": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving model groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── CivitAI Integration ──────────────────────────────────
|
||||
|
||||
|
||||
async def handle_civitai_fetch(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/civitai/fetch
|
||||
|
||||
Body: { "type": "checkpoint", "name": "model.safetensors" }
|
||||
Calculates SHA256 hash and fetches model info from CivitAI.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
model_type = body.get("type", "")
|
||||
model_name = body.get("name", "")
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "type and name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
|
||||
# Resolve file path
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
file_path = None
|
||||
for d in dirs:
|
||||
candidate = d / model_name
|
||||
if candidate.is_file():
|
||||
file_path = candidate
|
||||
break
|
||||
|
||||
if not file_path:
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
|
||||
# Check if we already have a cached hash in metadata
|
||||
meta = _service.get_all_metadata()
|
||||
model_meta = meta.get(model_name, {})
|
||||
sha256 = model_meta.get("sha256")
|
||||
|
||||
if not sha256:
|
||||
# Calculate hash (can be slow for large files)
|
||||
sha256 = await asyncio.to_thread(
|
||||
CivitaiService.calculate_sha256, file_path
|
||||
)
|
||||
if not sha256:
|
||||
return web.json_response({"error": "Failed to calculate hash"}, status=500)
|
||||
# Cache the hash in model metadata
|
||||
await asyncio.to_thread(
|
||||
_service.update_metadata, model_name, {"sha256": sha256}
|
||||
)
|
||||
|
||||
# Fetch from CivitAI
|
||||
info = await asyncio.to_thread(_civitai.fetch_by_hash, sha256)
|
||||
if not info:
|
||||
return web.json_response({
|
||||
"status": "not_found",
|
||||
"sha256": sha256,
|
||||
"message": "Model not found on CivitAI"
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"sha256": sha256,
|
||||
"civitai": info,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("CivitAI fetch error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_civitai_cache(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/civitai/cache - Return all cached CivitAI data."""
|
||||
try:
|
||||
result = await asyncio.to_thread(_civitai.get_all_cached)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading CivitAI cache: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_civitai_batch(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/civitai/batch - SSE stream for batch CivitAI fetch.
|
||||
|
||||
Body: { "type": "checkpoint", "models": ["model1.safetensors", ...] }
|
||||
Streams SSE events: progress, result, done.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.Response(status=400, text="Invalid JSON body")
|
||||
|
||||
model_type = body.get("type", "")
|
||||
model_names = body.get("models", [])
|
||||
if not model_type or not model_names:
|
||||
return web.Response(status=400, text="type and models required")
|
||||
|
||||
# Resolve file paths
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
model_files = []
|
||||
for name in model_names:
|
||||
if ".." in name:
|
||||
continue
|
||||
for d in dirs:
|
||||
p = d / name
|
||||
if p.is_file():
|
||||
model_files.append((name, p))
|
||||
break
|
||||
|
||||
if not model_files:
|
||||
return web.Response(status=404, text="No model files found")
|
||||
|
||||
# Set up SSE response
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
def send_sse(event, data):
|
||||
"""Format SSE message (returns bytes)."""
|
||||
msg = f"event: {event}\ndata: {_json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
return msg.encode("utf-8")
|
||||
|
||||
# Run batch fetch in thread with progress callback via queue
|
||||
import queue
|
||||
progress_q = queue.Queue()
|
||||
|
||||
def on_progress(current, total, model_name, status):
|
||||
progress_q.put({"current": current, "total": total, "model": model_name, "status": status})
|
||||
|
||||
# Start batch in background thread
|
||||
loop = asyncio.get_event_loop()
|
||||
fetch_task = loop.run_in_executor(None, _civitai.batch_fetch, model_files, on_progress)
|
||||
|
||||
# Stream progress events
|
||||
import time as _time
|
||||
|
||||
while not fetch_task.done():
|
||||
# Drain queue
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Drain remaining events
|
||||
while not progress_q.empty():
|
||||
try:
|
||||
p = progress_q.get_nowait()
|
||||
await response.write(send_sse("progress", p))
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get results and update metadata
|
||||
results = fetch_task.result()
|
||||
meta_updates = {}
|
||||
for model_name, result in results.items():
|
||||
sha256 = result.get("sha256")
|
||||
if sha256:
|
||||
meta_updates[model_name] = sha256
|
||||
|
||||
# Bulk update sha256 in metadata
|
||||
if meta_updates:
|
||||
all_meta = _service.get_all_metadata()
|
||||
for mname, sha in meta_updates.items():
|
||||
if mname not in all_meta:
|
||||
all_meta[mname] = {"tags": [], "favorite": False, "memo": ""}
|
||||
all_meta[mname]["sha256"] = sha
|
||||
_service._save_metadata(all_meta)
|
||||
|
||||
# Send final result
|
||||
summary = {
|
||||
"total": len(model_files),
|
||||
"found": sum(1 for r in results.values() if r.get("civitai")),
|
||||
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
|
||||
"errors": sum(1 for r in results.values() if r.get("error")),
|
||||
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
|
||||
}
|
||||
await response.write(send_sse("done", summary))
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
|
||||
# ── Model Preview Image ───────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_preview(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/preview?type=checkpoint&name=model.safetensors"""
|
||||
model_type = request.query.get("type", "")
|
||||
model_name = request.query.get("name", "")
|
||||
if not model_type or not model_name:
|
||||
return web.Response(status=400, text="type and name are required")
|
||||
|
||||
# Security: reject path traversal
|
||||
if ".." in model_name:
|
||||
return web.Response(status=400, text="Invalid model name")
|
||||
|
||||
try:
|
||||
preview_path = await asyncio.to_thread(
|
||||
_service.find_preview_image, model_type, model_name
|
||||
)
|
||||
if not preview_path:
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(preview_path))
|
||||
return web.FileResponse(
|
||||
preview_path,
|
||||
headers={"Content-Type": content_type or "image/png",
|
||||
"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error serving model preview: %s", e)
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
|
||||
async def handle_change_preview(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/models/change-preview - Upload/change model preview image.
|
||||
|
||||
Multipart form: type, name, file
|
||||
Saves as {model_stem}.preview.png next to the model file.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
model_type = None
|
||||
model_name = None
|
||||
image_data = None
|
||||
|
||||
while True:
|
||||
part = await reader.next()
|
||||
if part is None:
|
||||
break
|
||||
if part.name == "type":
|
||||
model_type = (await part.read()).decode("utf-8")
|
||||
elif part.name == "name":
|
||||
model_name = (await part.read()).decode("utf-8")
|
||||
elif part.name == "file":
|
||||
image_data = await part.read()
|
||||
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "type and name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
if image_data is None:
|
||||
return web.json_response({"error": "file required"}, status=400)
|
||||
|
||||
# Resolve model file path
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
model_path = None
|
||||
for d in dirs:
|
||||
candidate = d / model_name
|
||||
if candidate.is_file():
|
||||
model_path = candidate
|
||||
break
|
||||
|
||||
if not model_path:
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
|
||||
# Save as {stem}.preview.png next to model file
|
||||
preview_path = model_path.parent / (model_path.stem + ".preview.png")
|
||||
|
||||
def _write():
|
||||
with open(preview_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
await asyncio.to_thread(_write)
|
||||
logger.info("Saved preview image: %s", preview_path)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error changing model preview: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_filepath(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/models/filepath?type=checkpoint&name=model.safetensors
|
||||
|
||||
Returns the full file path of the model.
|
||||
"""
|
||||
model_type = request.query.get("type", "")
|
||||
model_name = request.query.get("name", "")
|
||||
if not model_type or not model_name:
|
||||
return web.json_response({"error": "type and name required"}, status=400)
|
||||
if ".." in model_name:
|
||||
return web.json_response({"error": "Invalid model name"}, status=400)
|
||||
|
||||
try:
|
||||
from ..services.models_service import _get_model_dirs
|
||||
dirs = _get_model_dirs(model_type)
|
||||
for d in dirs:
|
||||
candidate = d / model_name
|
||||
if candidate.is_file():
|
||||
return web.json_response({"path": str(candidate)})
|
||||
return web.json_response({"error": "Model file not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("Error resolving model filepath: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Prompt presets API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.prompts_service import PromptsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = PromptsService()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all prompt management API routes."""
|
||||
app.router.add_get("/api/wfm/prompts", handle_list)
|
||||
app.router.add_post("/api/wfm/prompts", handle_create)
|
||||
app.router.add_post("/api/wfm/prompts/update", handle_update)
|
||||
app.router.add_post("/api/wfm/prompts/delete", handle_delete)
|
||||
|
||||
|
||||
# ── List ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/prompts"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_prompts)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing prompts: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Create ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_create(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/prompts"""
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(_service.create_prompt, body)
|
||||
return web.json_response({"status": "ok", "prompt": result})
|
||||
except Exception as e:
|
||||
logger.error("Error creating prompt: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Update ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_update(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/prompts/update"""
|
||||
try:
|
||||
body = await request.json()
|
||||
prompt_id = body.get("id", "")
|
||||
if not prompt_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "id"}
|
||||
result = await asyncio.to_thread(_service.update_prompt, prompt_id, updates)
|
||||
if result is None:
|
||||
return web.json_response({"error": "prompt not found"}, status=404)
|
||||
return web.json_response({"status": "ok", "prompt": result})
|
||||
except Exception as e:
|
||||
logger.error("Error updating prompt: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/prompts/delete"""
|
||||
try:
|
||||
body = await request.json()
|
||||
prompt_id = body.get("id", "")
|
||||
if not prompt_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
await asyncio.to_thread(_service.delete_prompt, prompt_id)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting prompt: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""CivitAI API integration service."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from ..config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CIVITAI_API_BASE = "https://civitai.com/api/v1"
|
||||
CIVITAI_CACHE_FILE = DATA_DIR / "civitai_cache.json"
|
||||
|
||||
|
||||
class CivitaiService:
|
||||
"""Fetch and cache CivitAI model metadata."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache = None
|
||||
|
||||
def _load_cache(self):
|
||||
if self._cache is not None:
|
||||
return self._cache
|
||||
if CIVITAI_CACHE_FILE.exists():
|
||||
try:
|
||||
with open(CIVITAI_CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
self._cache = json.load(f)
|
||||
return self._cache
|
||||
except Exception:
|
||||
pass
|
||||
self._cache = {}
|
||||
return self._cache
|
||||
|
||||
def _save_cache(self):
|
||||
CIVITAI_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CIVITAI_CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_cached(self, sha256_hash):
|
||||
"""Return cached CivitAI data for a given hash, or None."""
|
||||
cache = self._load_cache()
|
||||
return cache.get(sha256_hash)
|
||||
|
||||
def get_all_cached(self):
|
||||
"""Return the full cache dict."""
|
||||
return self._load_cache()
|
||||
|
||||
@staticmethod
|
||||
def calculate_sha256(file_path, chunk_size=65536):
|
||||
"""Calculate SHA256 hash of a file."""
|
||||
h = hashlib.sha256()
|
||||
path = Path(file_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def fetch_by_hash(self, sha256_hash):
|
||||
"""Fetch model version info from CivitAI by SHA256 hash.
|
||||
|
||||
Returns dict with model info, or None if not found.
|
||||
Caches successful results.
|
||||
"""
|
||||
# Check cache first
|
||||
cache = self._load_cache()
|
||||
if sha256_hash in cache:
|
||||
return cache[sha256_hash]
|
||||
|
||||
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash}"
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if not data or "id" not in data:
|
||||
return None
|
||||
|
||||
# Extract useful fields
|
||||
info = self._extract_info(data)
|
||||
cache[sha256_hash] = info
|
||||
self._save_cache()
|
||||
return info
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
|
||||
return None
|
||||
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
|
||||
return None
|
||||
except (URLError, Exception) as e:
|
||||
logger.warning("CivitAI request failed: %s", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_info(data):
|
||||
"""Extract relevant fields from CivitAI API response."""
|
||||
model = data.get("model", {})
|
||||
images = data.get("images", [])
|
||||
files = data.get("files", [])
|
||||
|
||||
# Get primary file info
|
||||
primary_file = None
|
||||
for f in files:
|
||||
if f.get("primary"):
|
||||
primary_file = f
|
||||
break
|
||||
if not primary_file and files:
|
||||
primary_file = files[0]
|
||||
|
||||
# Get image URLs (first 5)
|
||||
image_urls = []
|
||||
for img in images[:5]:
|
||||
url = img.get("url", "")
|
||||
if url:
|
||||
# Optimize for thumbnail
|
||||
optimized = url.replace("/original=true", "/width=450,optimized=true")
|
||||
image_urls.append(optimized)
|
||||
|
||||
return {
|
||||
"versionId": data.get("id"),
|
||||
"modelId": model.get("id"),
|
||||
"modelName": model.get("name", ""),
|
||||
"versionName": data.get("name", ""),
|
||||
"type": model.get("type", ""),
|
||||
"description": data.get("description") or model.get("description", ""),
|
||||
"tags": model.get("tags", []),
|
||||
"nsfw": model.get("nsfw", False),
|
||||
"creator": data.get("creator", {}).get("username", ""),
|
||||
"images": image_urls,
|
||||
"trainedWords": data.get("trainedWords", []),
|
||||
"baseModel": data.get("baseModel", ""),
|
||||
"fileSize": primary_file.get("sizeKB", 0) if primary_file else 0,
|
||||
"downloadUrl": data.get("downloadUrl", ""),
|
||||
"modelUrl": f"https://civitai.com/models/{model.get('id', '')}?modelVersionId={data.get('id', '')}",
|
||||
}
|
||||
|
||||
def batch_fetch(self, model_files, progress_callback=None):
|
||||
"""Batch fetch CivitAI info for multiple model files.
|
||||
|
||||
Args:
|
||||
model_files: list of (model_name, file_path) tuples
|
||||
progress_callback: fn(current, total, model_name, status) called per model
|
||||
|
||||
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
|
||||
"""
|
||||
import time
|
||||
|
||||
results = {}
|
||||
total = len(model_files)
|
||||
cache = self._load_cache()
|
||||
|
||||
for i, (model_name, file_path) in enumerate(model_files):
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "hashing")
|
||||
|
||||
# Calculate hash
|
||||
sha256 = self.calculate_sha256(file_path)
|
||||
if not sha256:
|
||||
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
|
||||
continue
|
||||
|
||||
results[model_name] = {"sha256": sha256, "civitai": None}
|
||||
|
||||
# Check cache
|
||||
if sha256 in cache:
|
||||
results[model_name]["civitai"] = cache[sha256]
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "cached")
|
||||
continue
|
||||
|
||||
# Fetch from API
|
||||
if progress_callback:
|
||||
progress_callback(i, total, model_name, "fetching")
|
||||
|
||||
info = self.fetch_by_hash(sha256)
|
||||
if info:
|
||||
results[model_name]["civitai"] = info
|
||||
|
||||
if progress_callback:
|
||||
status = "found" if info else "not_found"
|
||||
progress_callback(i, total, model_name, status)
|
||||
|
||||
# Rate limit: small delay between API calls
|
||||
time.sleep(0.5)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(total, total, "", "done")
|
||||
|
||||
return results
|
||||
|
||||
def clear_cache(self, sha256_hash=None):
|
||||
"""Clear cache for a specific hash or all."""
|
||||
cache = self._load_cache()
|
||||
if sha256_hash:
|
||||
cache.pop(sha256_hash, None)
|
||||
else:
|
||||
cache.clear()
|
||||
self._save_cache()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Model metadata management service."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import MODEL_METADATA_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Preview image extensions to search for (in priority order)
|
||||
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
|
||||
".png", ".jpg", ".jpeg", ".webp"]
|
||||
|
||||
# ComfyUI model type → folder_paths key mapping
|
||||
MODEL_TYPE_FOLDER_KEYS = {
|
||||
"checkpoint": "checkpoints",
|
||||
"lora": "loras",
|
||||
"vae": "vae",
|
||||
"controlnet": "controlnet",
|
||||
"unet": "diffusion_models",
|
||||
"textencoder": "text_encoders",
|
||||
}
|
||||
|
||||
|
||||
def _get_model_dirs(model_type):
|
||||
"""Get all model directories for a type using ComfyUI's folder_paths.
|
||||
|
||||
Returns a list of Path objects for all configured model directories
|
||||
(includes extra_model_paths.yaml settings).
|
||||
Falls back to plugin-relative path if folder_paths is unavailable.
|
||||
"""
|
||||
folder_key = MODEL_TYPE_FOLDER_KEYS.get(model_type)
|
||||
if not folder_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
import folder_paths # type: ignore # ComfyUI module
|
||||
paths = folder_paths.get_folder_paths(folder_key)
|
||||
result = [Path(p) for p in paths if Path(p).is_dir()]
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug("folder_paths unavailable (%s), using fallback", e)
|
||||
|
||||
# Fallback: custom_nodes/../../models/{folder_key}
|
||||
plugin_dir = Path(__file__).resolve().parent.parent.parent
|
||||
models_dir = plugin_dir.parent.parent / "models" / folder_key
|
||||
if models_dir.is_dir():
|
||||
return [models_dir]
|
||||
return []
|
||||
|
||||
|
||||
class ModelsService:
|
||||
"""Manages user-defined model metadata (favorites, tags, memo)."""
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_file = MODEL_METADATA_FILE
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def _load_metadata(self):
|
||||
if self.metadata_file.exists():
|
||||
try:
|
||||
with open(self.metadata_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_metadata(self, data):
|
||||
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.metadata_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_all_metadata(self):
|
||||
return self._load_metadata()
|
||||
|
||||
def update_metadata(self, model_name, updates):
|
||||
data = self._load_metadata()
|
||||
if model_name not in data:
|
||||
data[model_name] = {"tags": [], "favorite": False, "memo": ""}
|
||||
entry = data[model_name]
|
||||
if "tags" in updates:
|
||||
entry["tags"] = updates["tags"]
|
||||
if "favorite" in updates:
|
||||
entry["favorite"] = updates["favorite"]
|
||||
if "memo" in updates:
|
||||
entry["memo"] = updates["memo"]
|
||||
if "sha256" in updates:
|
||||
entry["sha256"] = updates["sha256"]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
data[model_name] = entry
|
||||
self._save_metadata(data)
|
||||
return entry
|
||||
|
||||
def get_model_groups(self):
|
||||
data = self._load_metadata()
|
||||
return data.get("_groups", {})
|
||||
|
||||
def save_model_groups(self, groups):
|
||||
data = self._load_metadata()
|
||||
data["_groups"] = groups
|
||||
self._save_metadata(data)
|
||||
return groups
|
||||
|
||||
def find_preview_image(self, model_type, model_name):
|
||||
"""Find preview image for a model file.
|
||||
|
||||
Searches all configured directories for the model type
|
||||
(via ComfyUI's folder_paths, which includes extra_model_paths.yaml).
|
||||
|
||||
Looks for files like:
|
||||
modelname.preview.png, modelname.png, etc.
|
||||
next to the model file.
|
||||
|
||||
Returns: absolute Path to preview image, or None.
|
||||
"""
|
||||
dirs = _get_model_dirs(model_type)
|
||||
if not dirs:
|
||||
logger.debug("Preview: no dirs for model_type=%s", model_type)
|
||||
return None
|
||||
|
||||
for type_dir in dirs:
|
||||
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
|
||||
model_path = type_dir / model_name
|
||||
if not model_path.is_file():
|
||||
continue
|
||||
|
||||
stem = model_path.stem
|
||||
parent = model_path.parent
|
||||
|
||||
for ext in _PREVIEW_EXTENSIONS:
|
||||
preview = parent / (stem + ext)
|
||||
if preview.is_file():
|
||||
logger.debug("Preview found: %s", preview)
|
||||
return preview
|
||||
|
||||
logger.debug("Preview: no preview for %s (stem=%s, dir=%s)",
|
||||
model_name, stem, parent)
|
||||
return None
|
||||
|
||||
logger.debug("Preview: model file not found in any dir: %s", model_name)
|
||||
return None
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Prompt presets management service."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..config import PROMPTS_FILE
|
||||
|
||||
|
||||
class PromptsService:
|
||||
"""Manages prompt presets (CRUD, categories, favorites)."""
|
||||
|
||||
def __init__(self):
|
||||
self.prompts_file = PROMPTS_FILE
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ── Persistence ─────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
if self.prompts_file.exists():
|
||||
try:
|
||||
with open(self.prompts_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _save(self, prompts):
|
||||
self.prompts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.prompts_file, "w", encoding="utf-8") as f:
|
||||
json.dump(prompts, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── List ────────────────────────────────────────────────
|
||||
|
||||
def list_prompts(self):
|
||||
return self._load()
|
||||
|
||||
# ── Create ──────────────────────────────────────────────
|
||||
|
||||
def create_prompt(self, data):
|
||||
prompts = self._load()
|
||||
prompt = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": data.get("name", "Untitled"),
|
||||
"text": data.get("text", ""),
|
||||
"negText": data.get("negText", ""),
|
||||
"category": data.get("category", ""),
|
||||
"tags": data.get("tags", []),
|
||||
"favorite": data.get("favorite", False),
|
||||
"created_at": self._now_iso(),
|
||||
"updated_at": self._now_iso(),
|
||||
}
|
||||
prompts.append(prompt)
|
||||
self._save(prompts)
|
||||
return prompt
|
||||
|
||||
# ── Update ──────────────────────────────────────────────
|
||||
|
||||
def update_prompt(self, prompt_id, updates):
|
||||
prompts = self._load()
|
||||
for p in prompts:
|
||||
if p["id"] == prompt_id:
|
||||
for key in ("name", "text", "negText", "category", "tags", "favorite"):
|
||||
if key in updates:
|
||||
p[key] = updates[key]
|
||||
p["updated_at"] = self._now_iso()
|
||||
self._save(prompts)
|
||||
return p
|
||||
return None
|
||||
|
||||
# ── Delete ──────────────────────────────────────────────
|
||||
|
||||
def delete_prompt(self, prompt_id):
|
||||
prompts = self._load()
|
||||
prompts = [p for p in prompts if p["id"] != prompt_id]
|
||||
self._save(prompts)
|
||||
|
||||
# ── Categories (derived) ────────────────────────────────
|
||||
|
||||
def list_categories(self):
|
||||
prompts = self._load()
|
||||
cats = set()
|
||||
for p in prompts:
|
||||
c = p.get("category", "").strip()
|
||||
if c:
|
||||
cats.add(c)
|
||||
return sorted(cats)
|
||||
@@ -110,8 +110,30 @@ def analyze_workflow(workflow_data, filename=""):
|
||||
if ntype in ("SaveVideo", "VH_VideoCombine"):
|
||||
outputs["videos"] += 1
|
||||
|
||||
# Detect workflow format
|
||||
if raw_nodes is not None:
|
||||
# App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
extra = workflow_data.get("extra", {})
|
||||
if (
|
||||
"definitions" in workflow_data
|
||||
or extra.get("linearMode") is True
|
||||
):
|
||||
wf_format = "app"
|
||||
elif filename and filename.lower().endswith(".app.json"):
|
||||
wf_format = "app"
|
||||
else:
|
||||
wf_format = "ui"
|
||||
else:
|
||||
# Check if all top-level values have class_type (API format)
|
||||
has_class = any(
|
||||
isinstance(v, dict) and "class_type" in v
|
||||
for v in workflow_data.values()
|
||||
)
|
||||
wf_format = "api" if has_class else "unknown"
|
||||
|
||||
return {
|
||||
"modelTypes": sorted(model_types),
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"format": wf_format,
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ class WorkflowService:
|
||||
"modelTypes": [],
|
||||
"inputs": {"prompts": 0, "images": 0, "videos": 0},
|
||||
"outputs": {"images": 0, "videos": 0},
|
||||
"format": "unknown",
|
||||
}
|
||||
|
||||
stat = fpath.stat()
|
||||
|
||||
@@ -61,12 +61,14 @@ class WorkflowStudio:
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
ollama_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
models_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -250,7 +250,7 @@ body {
|
||||
|
||||
.wfm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, 161px);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
@@ -263,6 +263,8 @@ body {
|
||||
============================================ */
|
||||
|
||||
.wfm-card {
|
||||
width: 161px;
|
||||
min-width: 161px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
@@ -278,8 +280,8 @@ body {
|
||||
}
|
||||
|
||||
.wfm-card-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 161px;
|
||||
height: 162px;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1340,6 +1342,156 @@ body {
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* 3-column layout */
|
||||
.wfm-prompt-split-3col .wfm-prompt-split-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-prompt-split-3col .wfm-prompt-split-col + .wfm-prompt-split-col {
|
||||
border-left: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* Preset Manager panel */
|
||||
.wfm-preset-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.wfm-pm-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-pm-tab {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wfm-pm-tab:hover { color: var(--wfm-text); }
|
||||
.wfm-pm-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-pm-search {
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-pm-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.wfm-pm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.wfm-pm-item:hover {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
.wfm-pm-item.active {
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
border-left: 3px solid var(--wfm-primary);
|
||||
}
|
||||
.wfm-pm-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wfm-pm-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-pm-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wfm-pm-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.wfm-pm-item:hover .wfm-pm-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.wfm-pm-action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wfm-pm-action-btn:hover {
|
||||
background: var(--wfm-surface-2, var(--wfm-surface));
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
.wfm-pm-action-btn.fav-active {
|
||||
color: #ffd700;
|
||||
}
|
||||
.wfm-pm-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wfm-pm-group-header {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
background: var(--wfm-surface);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wfm-pm-group-header:hover {
|
||||
background: var(--wfm-surface-2, var(--wfm-surface));
|
||||
}
|
||||
.wfm-pm-group-header::before {
|
||||
content: "\25BC";
|
||||
font-size: 8px;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wfm-pm-group-header.collapsed::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.wfm-pm-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--wfm-surface);
|
||||
border-radius: 3px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-prompt-split-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
@@ -2242,6 +2394,267 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Settings Accordion (details/summary)
|
||||
============================================ */
|
||||
|
||||
.wfm-settings-section {
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-settings-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text);
|
||||
background: var(--wfm-surface);
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.wfm-settings-summary:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
.wfm-settings-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.wfm-settings-summary::before {
|
||||
content: "\25B6";
|
||||
font-size: 10px;
|
||||
margin-right: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
.wfm-settings-section[open] > .wfm-settings-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.wfm-settings-section > *:not(summary) {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.wfm-settings-section > *:nth-child(2) {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.wfm-settings-section > *:last-child {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Theme Customizer
|
||||
============================================ */
|
||||
|
||||
.wfm-customizer-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.wfm-customizer-toggle:hover {
|
||||
color: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-customizer-toggle .arrow {
|
||||
transition: transform 0.2s ease;
|
||||
font-size: 10px;
|
||||
}
|
||||
.wfm-customizer-toggle.open .arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.wfm-customizer-panel {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
background: var(--wfm-bg-secondary);
|
||||
}
|
||||
.wfm-customizer-panel.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-customizer-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.wfm-customizer-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.wfm-customizer-section h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.wfm-color-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.wfm-color-item label {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-color-item input[type="color"] {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.wfm-color-item input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.wfm-color-item input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Pattern selector */
|
||||
.wfm-pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.wfm-pattern-item {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-pattern-item:hover {
|
||||
border-color: var(--wfm-primary);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.wfm-pattern-item.active {
|
||||
border-color: var(--wfm-primary);
|
||||
box-shadow: 0 0 0 2px var(--wfm-primary);
|
||||
}
|
||||
.wfm-pattern-item-label {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
padding: 1px 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Pattern options row */
|
||||
.wfm-pattern-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wfm-pattern-options label {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.wfm-pattern-options input[type="range"] {
|
||||
width: 80px;
|
||||
accent-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-pattern-options input[type="color"] {
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.wfm-pattern-options input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
.wfm-pattern-options input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* SVG upload button */
|
||||
.wfm-svg-upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: var(--wfm-text-secondary);
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
.wfm-svg-upload-btn:hover {
|
||||
color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
/* Font selector */
|
||||
.wfm-font-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.wfm-font-item {
|
||||
padding: 8px 10px;
|
||||
border: 2px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-font-item:hover {
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
.wfm-font-item.active {
|
||||
border-color: var(--wfm-primary);
|
||||
box-shadow: 0 0 0 2px var(--wfm-primary);
|
||||
}
|
||||
.wfm-font-preview {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
/* Customizer action buttons */
|
||||
.wfm-customizer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Nodes Tab
|
||||
============================================ */
|
||||
@@ -2316,6 +2729,13 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Node card: package color left border, auto width */
|
||||
.wfm-node-card {
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
border-left-width: 3px !important;
|
||||
}
|
||||
|
||||
/* Node table row selection highlight */
|
||||
.wfm-nodes-table-row {
|
||||
cursor: pointer;
|
||||
@@ -2452,3 +2872,167 @@ body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Models Tab
|
||||
======================================== */
|
||||
|
||||
.wfm-models-type-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-models-type-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--wfm-text);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-models-type-btn:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.wfm-models-type-btn.active {
|
||||
background: var(--wfm-accent);
|
||||
color: #fff;
|
||||
border-color: var(--wfm-accent);
|
||||
}
|
||||
|
||||
/* Model card (card view, no thumbnail) */
|
||||
.wfm-model-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wfm-model-card .wfm-card-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Badge: subdirectory (italic, muted) */
|
||||
.wfm-badge-dir {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Favorite button in model card (active state color) */
|
||||
.wfm-model-side-fav.wfm-model-fav-active {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
/* Model table */
|
||||
.wfm-models-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wfm-models-table th,
|
||||
.wfm-models-table td {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.wfm-models-table th {
|
||||
background: var(--wfm-bg-secondary);
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-models-table-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-models-table-row:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
.wfm-models-table-row.wfm-card-selected {
|
||||
background: var(--wfm-accent-light, rgba(var(--wfm-accent-rgb, 99, 102, 241), 0.1));
|
||||
}
|
||||
|
||||
.wfm-models-table-fav {
|
||||
cursor: pointer;
|
||||
text-align: center !important;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.wfm-table-th-subdir,
|
||||
.wfm-table-td-subdir {
|
||||
width: 80px;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-table-th-ext,
|
||||
.wfm-table-td-ext {
|
||||
width: 60px;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.wfm-table-td-memo {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text-muted, #888);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Textarea (shared - used by models memo and workflow detail) */
|
||||
.wfm-textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
background: var(--wfm-bg-secondary);
|
||||
color: var(--wfm-text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wfm-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--wfm-accent);
|
||||
}
|
||||
|
||||
/* Clickable model name (copy to clipboard) */
|
||||
.wfm-model-name-copy {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-model-name-copy:hover {
|
||||
color: var(--wfm-accent);
|
||||
}
|
||||
|
||||
/* Inline pagination (in toolbar) */
|
||||
.wfm-pagination-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
/* Side panel content for models */
|
||||
.wfm-models-side-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
|
||||
document.querySelectorAll(".wfm-tab").forEach((tab) => {
|
||||
const key = tabMap[tab.dataset.tab];
|
||||
if (key) tab.textContent = t(key);
|
||||
@@ -163,6 +163,31 @@ function applyI18nToHtml() {
|
||||
const nodesPlaceholder = document.getElementById("wfm-nodes-placeholder");
|
||||
if (nodesPlaceholder) nodesPlaceholder.textContent = t("nodesClickToLoad");
|
||||
|
||||
// Models tab
|
||||
const modelsTypeBtns = document.querySelectorAll(".wfm-models-type-btn");
|
||||
modelsTypeBtns.forEach(btn => {
|
||||
const key = "models" + btn.dataset.modelType.charAt(0).toUpperCase() + btn.dataset.modelType.slice(1);
|
||||
const label = t(key);
|
||||
if (label && label !== key) btn.textContent = label;
|
||||
});
|
||||
const modelsSearch = document.getElementById("wfm-models-search");
|
||||
if (modelsSearch) modelsSearch.placeholder = t("modelsSearchPlaceholder");
|
||||
const modelsRefreshBtn = document.getElementById("wfm-models-refresh-btn");
|
||||
if (modelsRefreshBtn) modelsRefreshBtn.textContent = t("modelsRefresh");
|
||||
const modelsPlaceholder = document.getElementById("wfm-models-placeholder");
|
||||
if (modelsPlaceholder) modelsPlaceholder.textContent = t("modelsClickToLoad");
|
||||
const modelsTagFilter = document.getElementById("wfm-models-tag-filter");
|
||||
if (modelsTagFilter) {
|
||||
const firstOpt = modelsTagFilter.querySelector("option[value='']");
|
||||
if (firstOpt) firstOpt.textContent = t("modelsAllTags");
|
||||
}
|
||||
const modelsSideTabBtns = document.querySelectorAll(".wfm-models-side-tab-btn");
|
||||
modelsSideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.sideTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.sideTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
|
||||
});
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
@@ -184,8 +209,6 @@ function applyI18nToHtml() {
|
||||
if (en2jaBtn) en2jaBtn.textContent = t("en2ja");
|
||||
const presetApplyBtn = document.getElementById("wfm-preset-apply-btn");
|
||||
if (presetApplyBtn) presetApplyBtn.textContent = t("applyPreset");
|
||||
const presetDeleteBtn = document.getElementById("wfm-preset-delete-btn");
|
||||
if (presetDeleteBtn) presetDeleteBtn.textContent = t("deletePreset");
|
||||
const presetSaveBtn = document.getElementById("wfm-preset-save-btn");
|
||||
if (presetSaveBtn) presetSaveBtn.textContent = t("savePreset");
|
||||
const presetCopyPosBtn = document.getElementById("wfm-preset-copy-pos-btn");
|
||||
@@ -235,17 +258,25 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
|
||||
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
|
||||
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
|
||||
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
|
||||
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
|
||||
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
|
||||
"wfm-help-models-7": "helpModels7",
|
||||
"wfm-help-sidepanel-title": "helpSidepanelTitle",
|
||||
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
|
||||
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
|
||||
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
|
||||
"wfm-help-sidepanel-7": "helpSidepanel7",
|
||||
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
|
||||
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
|
||||
"wfm-help-shortcuts-title": "helpShortcutsTitle",
|
||||
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
|
||||
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
|
||||
@@ -300,6 +331,7 @@ import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
@@ -311,6 +343,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initSettingsTab(); // Settings first (applies saved URL)
|
||||
initWorkflowTab();
|
||||
initNodesTab();
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
|
||||
|
||||
@@ -252,9 +252,12 @@ function _flattenSubgraphs(workflow) {
|
||||
export const comfyWorkflow = {
|
||||
detectFormat(workflow, filename) {
|
||||
if (!workflow || typeof workflow !== "object") return "unknown";
|
||||
// App format: detected by .app.json filename extension
|
||||
if (filename && /\.app\.json$/i.test(filename)) return "app";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) return "ui";
|
||||
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
|
||||
// App format: UI-based structure with definitions (subgraphs) or linearMode
|
||||
if (workflow.definitions || workflow.extra?.linearMode === true) return "app";
|
||||
if (filename && /\.app\.json$/i.test(filename)) return "app";
|
||||
return "ui";
|
||||
}
|
||||
// API format: top-level keys are node IDs with class_type
|
||||
const keys = Object.keys(workflow);
|
||||
if (keys.length > 0 && keys.every((k) => workflow[k]?.class_type)) return "api";
|
||||
|
||||
@@ -152,8 +152,7 @@ const LANGUAGES = {
|
||||
copyNegativePrompt: "NP Copy",
|
||||
copiedToClipboard: "Copied to clipboard",
|
||||
noTextToCopy: "No text to copy",
|
||||
applyPreset: "Apply",
|
||||
deletePreset: "Delete",
|
||||
applyPreset: "GenUI Set",
|
||||
assistantSubtab: "AI Assistant",
|
||||
presetsSubtab: "Presets",
|
||||
|
||||
@@ -205,6 +204,73 @@ const LANGUAGES = {
|
||||
nodesConnectToComfyUI: "Connect to ComfyUI to load nodes. Check Settings tab.",
|
||||
nodesClickToLoad: "Click to load nodes...",
|
||||
|
||||
// -- Models tab --
|
||||
tabModels: "Models",
|
||||
modelsCheckpoint: "Checkpoint",
|
||||
modelsLora: "LoRA",
|
||||
modelsVae: "VAE",
|
||||
modelsControlnet: "ControlNet",
|
||||
modelsUnet: "UNET",
|
||||
modelsTextencoder: "TextEncoder",
|
||||
modelsSearchPlaceholder: "Search models...",
|
||||
modelsRefresh: "Refresh",
|
||||
modelsClickToLoad: "Click Refresh to load models...",
|
||||
modelsLoading: "Loading models...",
|
||||
modelsLoadError: "Error loading models",
|
||||
modelsNoModels: "No models found",
|
||||
modelsAllTags: "All Tags",
|
||||
modelsFileName: "File Name",
|
||||
modelsSubdir: "Subdirectory",
|
||||
modelsExt: "Extension",
|
||||
modelsTags: "Tags",
|
||||
modelsTagsPlaceholder: "tag1, tag2, ...",
|
||||
modelsModelType: "Model Type",
|
||||
modelsFavorite: "Favorite",
|
||||
modelsMemo: "Memo",
|
||||
modelsMemoPlaceholder: "Notes about this model...",
|
||||
modelsSave: "Save",
|
||||
modelsSaved: "Metadata saved",
|
||||
modelsCopyName: "Click to copy model name",
|
||||
modelsCopiedName: "Model name copied",
|
||||
modelsFilePath: "File Path",
|
||||
modelsCopyPath: "Click to copy file path",
|
||||
modelsCopiedPath: "File path copied",
|
||||
modelsNoPreview: "No preview",
|
||||
modelsTagsHint: "(comma separated)",
|
||||
modelsAllGroups: "All Groups",
|
||||
modelsCurrentGroups: "Current Groups",
|
||||
modelsNoGroup: "Not assigned to any group",
|
||||
modelsAssignGroup: "Assign to Group",
|
||||
modelsNoGroupAvailable: "No groups available",
|
||||
modelsAdd: "Add",
|
||||
modelsCreateGroup: "Create New Group",
|
||||
modelsGroupName: "Group name",
|
||||
modelsCreate: "Create",
|
||||
modelsManageGroups: "Manage Groups",
|
||||
modelsRename: "Rename",
|
||||
modelsDelete: "Delete",
|
||||
modelsRemoveFromGroup: "Remove from group",
|
||||
modelsGroupExists: "Group already exists",
|
||||
modelsRenamePrompt: "Enter new group name:",
|
||||
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
|
||||
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
|
||||
civitaiFetch: "Fetch from CivitAI",
|
||||
civitaiHashing: "Calculating hash... (may take a while for large files)",
|
||||
civitaiFound: "CivitAI info loaded",
|
||||
civitaiNotFound: "Model not found on CivitAI",
|
||||
civitaiError: "Error fetching from CivitAI",
|
||||
civitaiTriggerWords: "Trigger Words",
|
||||
civitaiCopyWord: "Click to copy",
|
||||
civitaiWordCopied: "Trigger word copied",
|
||||
civitaiRefresh: "Refresh",
|
||||
civitaiBatchAllCached: "All models already have CivitAI info",
|
||||
civitaiHashing2: "Hashing...",
|
||||
civitaiFetching: "Fetching...",
|
||||
civitaiBatchDone: (found, notFound) => `CivitAI: ${found} found, ${notFound} not found`,
|
||||
modelsSideInfo: "Info",
|
||||
modelsSideGroup: "Group",
|
||||
modelsSideCivitai: "CivitAI",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "Help",
|
||||
helpManualTitle: "Features",
|
||||
@@ -230,27 +296,42 @@ const LANGUAGES = {
|
||||
helpPrompt1: "AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)",
|
||||
helpPrompt2: "Apply AI responses to GenerateUI prompts",
|
||||
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
|
||||
helpPrompt4: "Preset management: create, edit, delete, apply, clipboard copy (PP/NP)",
|
||||
helpPrompt4: "Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)",
|
||||
helpPrompt5: "Preset Manager: browse all presets, favorites, and group-based filtering",
|
||||
helpPrompt6: "Group management: create groups, assign/remove presets, delete groups",
|
||||
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
|
||||
helpSettings1: "UI language and summary language",
|
||||
helpSettings2: "Workflow data folder path",
|
||||
helpSettings3: "ComfyUI connection URL and test",
|
||||
helpSettings4: "Ollama URL, default model, and connection test",
|
||||
helpSettings5: "Default workflow setting",
|
||||
helpSettings6: "Eagle integration URL and auto-save toggle",
|
||||
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
|
||||
helpSettings3: "Workflow data folder path",
|
||||
helpSettings4: "ComfyUI connection URL and test",
|
||||
helpSettings5: "Ollama URL, default model, and connection test",
|
||||
helpSettings6: "Default workflow setting",
|
||||
helpSettings7: "Eagle integration URL and auto-save toggle",
|
||||
helpSettings8: "All sections are collapsible accordion panels",
|
||||
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
|
||||
helpNodes2: "Card and Table view modes with pagination",
|
||||
helpNodes3: "Favorite nodes and add custom tags for organization",
|
||||
helpNodes4: "Create, rename, delete node groups and assign/remove nodes",
|
||||
helpNodes5: "Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management",
|
||||
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
|
||||
helpSidepanelTitle: "WF & Node Library (ComfyUI Side Panel)",
|
||||
helpSidepanel1: "Open from the WF & Node Library button in the ComfyUI toolbar",
|
||||
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder with sub-tab switching",
|
||||
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
|
||||
helpModels3: "Search, tag filter, group filter, and favorites filter",
|
||||
helpModels4: "Side panel: Info (file path, tags, memo), Groups management, CivitAI integration",
|
||||
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type",
|
||||
helpModels6: "Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change",
|
||||
helpModels7: "Group management: create, rename, delete groups and assign/remove models",
|
||||
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
|
||||
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
|
||||
helpSidepanel2: "Workflows tab: browse favorite workflows, filter by model type, and filter by group",
|
||||
helpSidepanel3: "Nodes tab: browse favorite nodes, node sets, and node groups",
|
||||
helpSidepanel4: "Drag & drop workflows onto the canvas to load them",
|
||||
helpSidepanel5: "Drag & drop nodes/node sets onto the canvas to place them",
|
||||
helpSidepanel6: "Double-click items to load/place without dragging",
|
||||
helpSidepanel7: "Search within each sub-tab to quickly find items",
|
||||
helpSidepanel4: "Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs",
|
||||
helpSidepanel5: "Drag & drop workflows onto the canvas to load them",
|
||||
helpSidepanel6: "Drag & drop nodes/node sets onto the canvas to place them",
|
||||
helpSidepanel7: "Drag & drop prompt presets to create WFS_PromptText nodes with positive/negative prompts",
|
||||
helpSidepanel8: "Copy individual positive (P) or negative (N) prompts from sidebar items",
|
||||
helpSidepanel9: "Double-click items to load/place without dragging",
|
||||
helpSidepanel10: "Search within each sub-tab to quickly find items",
|
||||
helpShortcutsTitle: "Keyboard Shortcuts",
|
||||
helpShortcuts1: "Escape: Close modal/panel",
|
||||
helpShortcuts2: "Enter: Confirm rename, send chat",
|
||||
@@ -322,6 +403,47 @@ const LANGUAGES = {
|
||||
themeMaterial: "Material UI",
|
||||
themeMonotoneAccent: "Monotone + Accent",
|
||||
themeCorporate: "Corporate Trust",
|
||||
// -- Theme Customizer --
|
||||
themeCustomize: "Customize",
|
||||
themeCustomizeColors: "Color Override",
|
||||
themeCustomizeBgPattern: "Background Pattern",
|
||||
themeColorBg: "Background",
|
||||
themeColorBgSec: "Secondary BG",
|
||||
themeColorSurface: "Surface",
|
||||
themeColorText: "Text",
|
||||
themeColorPrimary: "Primary",
|
||||
themeColorAccent: "Accent",
|
||||
themePatternNone: "None",
|
||||
themePatternStripeH: "Stripe H",
|
||||
themePatternStripeV: "Stripe V",
|
||||
themePatternStripeD: "Stripe D",
|
||||
themePatternDot: "Polka Dot",
|
||||
themePatternCheck: "Checkerboard",
|
||||
themePatternSvg: "SVG File",
|
||||
themePatternColor: "Pattern Color",
|
||||
themePatternOpacity: "Opacity",
|
||||
themePatternScale: "Scale",
|
||||
themePatternGap: "Gap",
|
||||
themeCustomizerReset: "Reset Customization",
|
||||
themeCustomizerApply: "Apply & Save",
|
||||
// -- Font --
|
||||
themeCustomizeFont: "Font",
|
||||
themeFontDefault: "System Default",
|
||||
themeFontNotoSans: "Noto Sans JP",
|
||||
themeFontZenMaru: "Zen Maru Gothic",
|
||||
themeFontMPlusRound: "M PLUS Rounded",
|
||||
themeFontKosugiMaru: "Kosugi Maru",
|
||||
themeFontSawarabi: "Sawarabi Gothic",
|
||||
themeFontBizUdp: "BIZ UDPGothic",
|
||||
themeFontDotGothic: "DotGothic16",
|
||||
themeFontHachiMaru: "Hachi Maru Pop",
|
||||
themeFontDelaGothic: "Dela Gothic One",
|
||||
themeFontReggaeOne: "Reggae One",
|
||||
themeFontRocknRoll: "RocknRoll One",
|
||||
themeFontStick: "Stick",
|
||||
themeFontTrainOne: "Train One",
|
||||
themeFontSpaceMono: "Space Mono",
|
||||
themeFontFiraCode: "Fira Code",
|
||||
},
|
||||
|
||||
ja: {
|
||||
@@ -472,8 +594,7 @@ const LANGUAGES = {
|
||||
copyNegativePrompt: "NP コピー",
|
||||
copiedToClipboard: "クリップボードにコピーしました",
|
||||
noTextToCopy: "コピーするテキストがありません",
|
||||
applyPreset: "適用",
|
||||
deletePreset: "削除",
|
||||
applyPreset: "GenUI Set",
|
||||
assistantSubtab: "AIアシスタント",
|
||||
presetsSubtab: "プリセット",
|
||||
|
||||
@@ -525,6 +646,73 @@ const LANGUAGES = {
|
||||
nodesConnectToComfyUI: "ComfyUIに接続してノードを読み込みます。設定タブを確認してください。",
|
||||
nodesClickToLoad: "クリックしてノードを読み込み...",
|
||||
|
||||
// -- Models tab --
|
||||
tabModels: "モデル",
|
||||
modelsCheckpoint: "Checkpoint",
|
||||
modelsLora: "LoRA",
|
||||
modelsVae: "VAE",
|
||||
modelsControlnet: "ControlNet",
|
||||
modelsUnet: "UNET",
|
||||
modelsTextencoder: "TextEncoder",
|
||||
modelsSearchPlaceholder: "モデルを検索...",
|
||||
modelsRefresh: "更新",
|
||||
modelsClickToLoad: "更新ボタンでモデルを読み込み...",
|
||||
modelsLoading: "モデルを読み込み中...",
|
||||
modelsLoadError: "モデルの読み込みエラー",
|
||||
modelsNoModels: "モデルが見つかりません",
|
||||
modelsAllTags: "すべてのタグ",
|
||||
modelsFileName: "ファイル名",
|
||||
modelsSubdir: "サブディレクトリ",
|
||||
modelsExt: "拡張子",
|
||||
modelsTags: "タグ",
|
||||
modelsTagsPlaceholder: "タグ1, タグ2, ...",
|
||||
modelsModelType: "モデルタイプ",
|
||||
modelsFavorite: "お気に入り",
|
||||
modelsMemo: "メモ",
|
||||
modelsMemoPlaceholder: "このモデルについてのメモ...",
|
||||
modelsSave: "保存",
|
||||
modelsSaved: "メタデータを保存しました",
|
||||
modelsCopyName: "クリックでモデル名をコピー",
|
||||
modelsCopiedName: "モデル名をコピーしました",
|
||||
modelsFilePath: "ファイルパス",
|
||||
modelsCopyPath: "クリックでファイルパスをコピー",
|
||||
modelsCopiedPath: "ファイルパスをコピーしました",
|
||||
modelsNoPreview: "プレビューなし",
|
||||
modelsTagsHint: "(カンマ区切り)",
|
||||
modelsAllGroups: "すべてのグループ",
|
||||
modelsCurrentGroups: "所属グループ",
|
||||
modelsNoGroup: "グループに未所属",
|
||||
modelsAssignGroup: "グループに追加",
|
||||
modelsNoGroupAvailable: "グループがありません",
|
||||
modelsAdd: "追加",
|
||||
modelsCreateGroup: "新規グループ作成",
|
||||
modelsGroupName: "グループ名",
|
||||
modelsCreate: "作成",
|
||||
modelsManageGroups: "グループ管理",
|
||||
modelsRename: "名前変更",
|
||||
modelsDelete: "削除",
|
||||
modelsRemoveFromGroup: "グループから除外",
|
||||
modelsGroupExists: "同名のグループが既に存在します",
|
||||
modelsRenamePrompt: "新しいグループ名を入力:",
|
||||
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
|
||||
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
|
||||
civitaiFetch: "CivitAIから取得",
|
||||
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
|
||||
civitaiFound: "CivitAI情報を取得しました",
|
||||
civitaiNotFound: "CivitAIにモデルが見つかりません",
|
||||
civitaiError: "CivitAIからの取得エラー",
|
||||
civitaiTriggerWords: "トリガーワード",
|
||||
civitaiCopyWord: "クリックでコピー",
|
||||
civitaiWordCopied: "トリガーワードをコピーしました",
|
||||
civitaiRefresh: "更新",
|
||||
civitaiBatchAllCached: "すべてのモデルのCivitAI情報は取得済みです",
|
||||
civitaiHashing2: "ハッシュ計算中...",
|
||||
civitaiFetching: "取得中...",
|
||||
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}件取得, ${notFound}件未登録`,
|
||||
modelsSideInfo: "基本情報",
|
||||
modelsSideGroup: "グループ",
|
||||
modelsSideCivitai: "CivitAI",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "ヘルプ",
|
||||
helpManualTitle: "機能一覧",
|
||||
@@ -550,27 +738,42 @@ const LANGUAGES = {
|
||||
helpPrompt1: "AIアシスタント(Ollama):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応)",
|
||||
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
|
||||
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
|
||||
helpPrompt4: "プリセット管理:作成・編集・削除・適用、ポジティブ/ネガティブプロンプトのクリップボードコピー",
|
||||
helpPrompt4: "プリセット管理:作成・編集、ポジティブ/ネガティブプロンプトのクリップボードコピー(PP/NP)",
|
||||
helpPrompt5: "プリセットマネージャー:全プリセット一覧、お気に入り、グループ別フィルタリング",
|
||||
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
|
||||
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
|
||||
helpSettings1: "UI言語と要約言語の設定",
|
||||
helpSettings2: "ワークフローデータフォルダのパス変更・リセット",
|
||||
helpSettings3: "ComfyUI接続URL設定と接続テスト",
|
||||
helpSettings4: "Ollama URL、デフォルトモデル、接続テスト",
|
||||
helpSettings5: "デフォルトワークフローの設定・解除",
|
||||
helpSettings6: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
|
||||
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
|
||||
helpSettings4: "ComfyUI接続URL設定と接続テスト",
|
||||
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
|
||||
helpSettings6: "デフォルトワークフローの設定・解除",
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
|
||||
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
|
||||
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
|
||||
helpNodes4: "ノードグループの作成・名前変更・削除、ノードの割り当て・解除",
|
||||
helpNodes5: "サイドパネル:詳細(クラス名、パッケージ、カテゴリ、説明、フラグ)、I/O仕様(入出力の型・デフォルト値)、グループ管理",
|
||||
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
|
||||
helpSidepanelTitle: "WF & Node Library(ComfyUIサイドパネル)",
|
||||
helpSidepanel1: "ComfyUIツールバーのWF & Node Libraryボタンから開く",
|
||||
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoderをサブタブで切り替え",
|
||||
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
|
||||
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
|
||||
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
|
||||
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
|
||||
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
|
||||
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
|
||||
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
|
||||
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
|
||||
helpSidepanel2: "Workflowsタブ:お気に入りワークフロー閲覧、モデル種別フィルタ、グループフィルタ",
|
||||
helpSidepanel3: "Nodesタブ:お気に入りノード、ノードセット、ノードグループの閲覧",
|
||||
helpSidepanel4: "ワークフローをキャンバスにドラッグ&ドロップで読み込み",
|
||||
helpSidepanel5: "ノード/ノードセットをキャンバスにドラッグ&ドロップで配置",
|
||||
helpSidepanel6: "ダブルクリックでもアイテムの読み込み・配置が可能",
|
||||
helpSidepanel7: "各サブタブ内で検索してアイテムをすばやく検索",
|
||||
helpSidepanel4: "Promptsタブ:プロンプトプリセットの閲覧(All/お気に入り/カテゴリ サブタブ)",
|
||||
helpSidepanel5: "ワークフローをキャンバスにドラッグ&ドロップで読み込み",
|
||||
helpSidepanel6: "ノード/ノードセットをキャンバスにドラッグ&ドロップで配置",
|
||||
helpSidepanel7: "プロンプトプリセットをドラッグ&ドロップでWFS_PromptTextノードを作成(ポジティブ/ネガティブ対応)",
|
||||
helpSidepanel8: "サイドバーアイテムからポジティブ(P)/ネガティブ(N)プロンプトを個別コピー",
|
||||
helpSidepanel9: "ダブルクリックでもアイテムの読み込み・配置が可能",
|
||||
helpSidepanel10: "各サブタブ内で検索してアイテムをすばやく検索",
|
||||
helpShortcutsTitle: "キーボード操作",
|
||||
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
|
||||
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
|
||||
@@ -642,6 +845,47 @@ const LANGUAGES = {
|
||||
themeMaterial: "Material UI (マテリアル)",
|
||||
themeMonotoneAccent: "Monotone + Accent",
|
||||
themeCorporate: "Corporate Trust (企業向け)",
|
||||
// -- Theme Customizer --
|
||||
themeCustomize: "カスタマイズ",
|
||||
themeCustomizeColors: "カラー調整",
|
||||
themeCustomizeBgPattern: "背景パターン",
|
||||
themeColorBg: "背景",
|
||||
themeColorBgSec: "サブ背景",
|
||||
themeColorSurface: "サーフェス",
|
||||
themeColorText: "テキスト",
|
||||
themeColorPrimary: "プライマリ",
|
||||
themeColorAccent: "アクセント",
|
||||
themePatternNone: "なし",
|
||||
themePatternStripeH: "横ストライプ",
|
||||
themePatternStripeV: "縦ストライプ",
|
||||
themePatternStripeD: "斜めストライプ",
|
||||
themePatternDot: "ポルカドット",
|
||||
themePatternCheck: "チェック",
|
||||
themePatternSvg: "SVGファイル",
|
||||
themePatternColor: "パターン色",
|
||||
themePatternOpacity: "不透明度",
|
||||
themePatternScale: "サイズ",
|
||||
themePatternGap: "間隔",
|
||||
themeCustomizerReset: "カスタマイズをリセット",
|
||||
themeCustomizerApply: "適用して保存",
|
||||
// -- Font --
|
||||
themeCustomizeFont: "フォント",
|
||||
themeFontDefault: "システム標準",
|
||||
themeFontNotoSans: "Noto Sans JP",
|
||||
themeFontZenMaru: "Zen丸ゴシック",
|
||||
themeFontMPlusRound: "M PLUS 丸ゴシック",
|
||||
themeFontKosugiMaru: "小杉丸ゴシック",
|
||||
themeFontSawarabi: "さわらびゴシック",
|
||||
themeFontBizUdp: "BIZ UDPゴシック",
|
||||
themeFontDotGothic: "ドットゴシック16",
|
||||
themeFontHachiMaru: "はちまるポップ",
|
||||
themeFontDelaGothic: "デラゴシック",
|
||||
themeFontReggaeOne: "レゲエ One",
|
||||
themeFontRocknRoll: "ロックンロール",
|
||||
themeFontStick: "ステッキ",
|
||||
themeFontTrainOne: "トレイン One",
|
||||
themeFontSpaceMono: "Space Mono",
|
||||
themeFontFiraCode: "Fira Code",
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -788,8 +1032,7 @@ const LANGUAGES = {
|
||||
positivePrompt: "正面提示词",
|
||||
negativePrompt: "负面提示词",
|
||||
savePreset: "保存预设",
|
||||
applyPreset: "应用",
|
||||
deletePreset: "删除",
|
||||
applyPreset: "GenUI Set",
|
||||
assistantSubtab: "AI助手",
|
||||
presetsSubtab: "预设",
|
||||
|
||||
@@ -841,6 +1084,73 @@ const LANGUAGES = {
|
||||
nodesConnectToComfyUI: "连接ComfyUI以加载节点。请检查设置选项卡。",
|
||||
nodesClickToLoad: "点击加载节点...",
|
||||
|
||||
// -- Models tab --
|
||||
tabModels: "模型",
|
||||
modelsCheckpoint: "Checkpoint",
|
||||
modelsLora: "LoRA",
|
||||
modelsVae: "VAE",
|
||||
modelsControlnet: "ControlNet",
|
||||
modelsUnet: "UNET",
|
||||
modelsTextencoder: "TextEncoder",
|
||||
modelsSearchPlaceholder: "搜索模型...",
|
||||
modelsRefresh: "刷新",
|
||||
modelsClickToLoad: "点击刷新加载模型...",
|
||||
modelsLoading: "正在加载模型...",
|
||||
modelsLoadError: "加载模型出错",
|
||||
modelsNoModels: "未找到模型",
|
||||
modelsAllTags: "所有标签",
|
||||
modelsFileName: "文件名",
|
||||
modelsSubdir: "子目录",
|
||||
modelsExt: "扩展名",
|
||||
modelsTags: "标签",
|
||||
modelsTagsPlaceholder: "标签1, 标签2, ...",
|
||||
modelsModelType: "模型类型",
|
||||
modelsFavorite: "收藏",
|
||||
modelsMemo: "备注",
|
||||
modelsMemoPlaceholder: "关于此模型的备注...",
|
||||
modelsSave: "保存",
|
||||
modelsSaved: "元数据已保存",
|
||||
modelsCopyName: "点击复制模型名称",
|
||||
modelsCopiedName: "模型名称已复制",
|
||||
modelsFilePath: "文件路径",
|
||||
modelsCopyPath: "点击复制文件路径",
|
||||
modelsCopiedPath: "文件路径已复制",
|
||||
modelsNoPreview: "无预览",
|
||||
modelsTagsHint: "(逗号分隔)",
|
||||
modelsAllGroups: "所有分组",
|
||||
modelsCurrentGroups: "当前分组",
|
||||
modelsNoGroup: "未分配到任何分组",
|
||||
modelsAssignGroup: "分配到分组",
|
||||
modelsNoGroupAvailable: "没有可用的分组",
|
||||
modelsAdd: "添加",
|
||||
modelsCreateGroup: "创建新分组",
|
||||
modelsGroupName: "分组名称",
|
||||
modelsCreate: "创建",
|
||||
modelsManageGroups: "分组管理",
|
||||
modelsRename: "重命名",
|
||||
modelsDelete: "删除",
|
||||
modelsRemoveFromGroup: "从分组移除",
|
||||
modelsGroupExists: "同名分组已存在",
|
||||
modelsRenamePrompt: "输入新分组名称:",
|
||||
modelsDeleteGroupConfirm: "删除分组「{name}」?",
|
||||
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
|
||||
civitaiFetch: "从CivitAI获取",
|
||||
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
|
||||
civitaiFound: "已加载CivitAI信息",
|
||||
civitaiNotFound: "在CivitAI上未找到该模型",
|
||||
civitaiError: "从CivitAI获取时出错",
|
||||
civitaiTriggerWords: "触发词",
|
||||
civitaiCopyWord: "点击复制",
|
||||
civitaiWordCopied: "触发词已复制",
|
||||
civitaiRefresh: "刷新",
|
||||
civitaiBatchAllCached: "所有模型的CivitAI信息已获取",
|
||||
civitaiHashing2: "计算哈希中...",
|
||||
civitaiFetching: "获取中...",
|
||||
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}个已找到, ${notFound}个未找到`,
|
||||
modelsSideInfo: "基本信息",
|
||||
modelsSideGroup: "分组",
|
||||
modelsSideCivitai: "CivitAI",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "帮助",
|
||||
helpManualTitle: "功能列表",
|
||||
@@ -866,27 +1176,42 @@ const LANGUAGES = {
|
||||
helpPrompt1: "AI助手(Ollama):模型选择、聊天咨询提示词、图片附件(视觉模型)",
|
||||
helpPrompt2: "将AI回复应用到生成UI的提示词",
|
||||
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
|
||||
helpPrompt4: "预设管理:创建、编辑、删除、应用、剪贴板复制(PP/NP)",
|
||||
helpPrompt4: "预设管理:创建、编辑、正面/负面提示词剪贴板复制(PP/NP)",
|
||||
helpPrompt5: "预设管理器:浏览所有预设、收藏、按组筛选",
|
||||
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
|
||||
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
|
||||
helpSettings1: "UI语言和摘要语言设置",
|
||||
helpSettings2: "工作流数据文件夹路径更改/重置",
|
||||
helpSettings3: "ComfyUI连接URL设置和测试",
|
||||
helpSettings4: "Ollama URL、默认模型、连接测试",
|
||||
helpSettings5: "默认工作流设置/取消",
|
||||
helpSettings6: "Eagle集成URL、自动保存开关",
|
||||
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
|
||||
helpSettings3: "工作流数据文件夹路径更改/重置",
|
||||
helpSettings4: "ComfyUI连接URL设置和测试",
|
||||
helpSettings5: "Ollama URL、默认模型、连接测试",
|
||||
helpSettings6: "默认工作流设置/取消",
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "各部分可折叠展开",
|
||||
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
|
||||
helpNodes2: "卡片和表格视图模式切换(带分页)",
|
||||
helpNodes3: "收藏节点,添加自定义标签进行整理",
|
||||
helpNodes4: "创建、重命名、删除节点组,分配/移除节点",
|
||||
helpNodes5: "侧面板:详情(类名、包、分类、描述、标志)、I/O规格(输入输出类型和默认值)、组管理",
|
||||
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
|
||||
helpSidepanelTitle: "WF & Node Library(ComfyUI侧面板)",
|
||||
helpSidepanel1: "从ComfyUI工具栏的WF & Node Library按钮打开",
|
||||
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder,通过子标签切换",
|
||||
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
|
||||
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
|
||||
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
|
||||
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
|
||||
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
|
||||
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
|
||||
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
|
||||
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
|
||||
helpSidepanel2: "Workflows标签:浏览收藏工作流、按模型类型筛选、按分组筛选",
|
||||
helpSidepanel3: "Nodes标签:浏览收藏节点、节点集、节点组",
|
||||
helpSidepanel4: "将工作流拖放到画布上即可加载",
|
||||
helpSidepanel5: "将节点/节点集拖放到画布上即可放置",
|
||||
helpSidepanel6: "双击项目也可加载/放置",
|
||||
helpSidepanel7: "在各子标签内搜索快速查找项目",
|
||||
helpSidepanel4: "Prompts标签:浏览提示词预设(全部/收藏/分类 子标签)",
|
||||
helpSidepanel5: "将工作流拖放到画布上即可加载",
|
||||
helpSidepanel6: "将节点/节点集拖放到画布上即可放置",
|
||||
helpSidepanel7: "拖放提示词预设可创建WFS_PromptText节点(含正面/负面提示词)",
|
||||
helpSidepanel8: "从侧边栏项目中单独复制正面(P)或负面(N)提示词",
|
||||
helpSidepanel9: "双击项目也可加载/放置",
|
||||
helpSidepanel10: "在各子标签内搜索快速查找项目",
|
||||
helpShortcutsTitle: "键盘操作",
|
||||
helpShortcuts1: "Escape:关闭弹窗/面板",
|
||||
helpShortcuts2: "Enter:确认重命名、发送聊天",
|
||||
@@ -958,6 +1283,47 @@ const LANGUAGES = {
|
||||
themeMaterial: "Material UI (材料)",
|
||||
themeMonotoneAccent: "Monotone + Accent",
|
||||
themeCorporate: "Corporate Trust (企业)",
|
||||
// -- Theme Customizer --
|
||||
themeCustomize: "自定义",
|
||||
themeCustomizeColors: "颜色调整",
|
||||
themeCustomizeBgPattern: "背景图案",
|
||||
themeColorBg: "背景",
|
||||
themeColorBgSec: "次要背景",
|
||||
themeColorSurface: "表面",
|
||||
themeColorText: "文字",
|
||||
themeColorPrimary: "主色",
|
||||
themeColorAccent: "强调色",
|
||||
themePatternNone: "无",
|
||||
themePatternStripeH: "横条纹",
|
||||
themePatternStripeV: "竖条纹",
|
||||
themePatternStripeD: "斜条纹",
|
||||
themePatternDot: "波点",
|
||||
themePatternCheck: "棋盘格",
|
||||
themePatternSvg: "SVG文件",
|
||||
themePatternColor: "图案颜色",
|
||||
themePatternOpacity: "不透明度",
|
||||
themePatternScale: "大小",
|
||||
themePatternGap: "间距",
|
||||
themeCustomizerReset: "重置自定义",
|
||||
themeCustomizerApply: "应用并保存",
|
||||
// -- Font --
|
||||
themeCustomizeFont: "字体",
|
||||
themeFontDefault: "系统默认",
|
||||
themeFontNotoSans: "Noto Sans JP",
|
||||
themeFontZenMaru: "Zen丸哥特",
|
||||
themeFontMPlusRound: "M PLUS 圆体",
|
||||
themeFontKosugiMaru: "小杉圆体",
|
||||
themeFontSawarabi: "蕨类哥特",
|
||||
themeFontBizUdp: "BIZ UDP哥特",
|
||||
themeFontDotGothic: "点阵哥特16",
|
||||
themeFontHachiMaru: "Hachi Maru Pop",
|
||||
themeFontDelaGothic: "Dela Gothic One",
|
||||
themeFontReggaeOne: "Reggae One",
|
||||
themeFontRocknRoll: "RocknRoll One",
|
||||
themeFontStick: "Stick",
|
||||
themeFontTrainOne: "Train One",
|
||||
themeFontSpaceMono: "Space Mono",
|
||||
themeFontFiraCode: "Fira Code",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -257,13 +257,13 @@ function renderNodeGrid() {
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
|
||||
removePagination();
|
||||
clearPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderNodeTableView(grid, filtered);
|
||||
removePagination();
|
||||
clearPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ function renderNodeGrid() {
|
||||
card.classList.add("wfm-card-selected");
|
||||
}
|
||||
card.dataset.nodeName = node.name;
|
||||
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
|
||||
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
@@ -296,10 +297,8 @@ function renderNodeGrid() {
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
|
||||
<div class="wfm-card-meta">
|
||||
${packageBadgeHtml(node.package)}
|
||||
${categoryBadgeHtml(node.category)}
|
||||
</div>
|
||||
<div class="wfm-node-card-io">In: ${inputCount} / Out: ${outputCount}</div>
|
||||
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
|
||||
</div>
|
||||
<button class="${favClass}" title="Favorite">${favStar}</button>`;
|
||||
@@ -316,47 +315,28 @@ function renderNodeGrid() {
|
||||
}
|
||||
|
||||
function renderPagination(totalItems, totalPages) {
|
||||
removePagination();
|
||||
if (totalPages <= 1) return;
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
|
||||
const grid = document.getElementById("wfm-nodes-grid");
|
||||
if (!grid) return;
|
||||
|
||||
const pager = document.createElement("div");
|
||||
pager.id = "wfm-nodes-pagination";
|
||||
pager.className = "wfm-pagination";
|
||||
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.className = "wfm-btn wfm-btn-sm";
|
||||
prevBtn.textContent = "\u25C0";
|
||||
prevBtn.disabled = state.currentPage === 0;
|
||||
prevBtn.addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.className = "wfm-btn wfm-btn-sm";
|
||||
nextBtn.textContent = "\u25B6";
|
||||
nextBtn.disabled = state.currentPage >= totalPages - 1;
|
||||
nextBtn.addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
|
||||
const info = document.createElement("span");
|
||||
info.className = "wfm-pagination-info";
|
||||
const start = state.currentPage * NODES_PER_PAGE + 1;
|
||||
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
|
||||
info.textContent = `${start}-${end} / ${totalItems}`;
|
||||
|
||||
pager.appendChild(prevBtn);
|
||||
pager.appendChild(info);
|
||||
pager.appendChild(nextBtn);
|
||||
|
||||
grid.parentElement.insertBefore(pager, grid.nextSibling);
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${start}-${end} / ${totalItems}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
}
|
||||
|
||||
function removePagination() {
|
||||
document.getElementById("wfm-nodes-pagination")?.remove();
|
||||
function clearPagination() {
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (container) container.innerHTML = "";
|
||||
}
|
||||
|
||||
function scrollGridToTop() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Prompt Tab - Ollama AI Assistant + Prompt Presets
|
||||
* Prompt Tab - Ollama AI Assistant + Prompt Presets + Preset Manager
|
||||
*/
|
||||
|
||||
import { showToast } from "./app.js";
|
||||
@@ -14,9 +14,335 @@ const ollamaState = {
|
||||
attachedImage: null,
|
||||
};
|
||||
|
||||
// Preset data (loaded from API, with localStorage migration)
|
||||
let promptPresets = [];
|
||||
let pmActiveTab = "all"; // "all" | "favorites" | "groups"
|
||||
let pmSearchText = "";
|
||||
let pmSelectedId = null; // currently selected preset id
|
||||
let pmGroups = {}; // { groupName: [presetId, ...] }
|
||||
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
|
||||
|
||||
// ============================================
|
||||
// Preset API helpers
|
||||
// ============================================
|
||||
|
||||
async function fetchPresets() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function apiCreatePreset(data) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await res.json();
|
||||
return result.status === "ok" ? result.prompt : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function apiUpdatePreset(id, updates) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
});
|
||||
const result = await res.json();
|
||||
return result.status === "ok" ? result.prompt : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function apiDeletePreset(id) {
|
||||
try {
|
||||
await fetch("/api/wfm/prompts/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Migrate localStorage presets to API
|
||||
// ============================================
|
||||
|
||||
const PRESETS_KEY = "wfm_prompt_presets";
|
||||
|
||||
let promptPresets = [];
|
||||
async function migrateLocalStoragePresets() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PRESETS_KEY);
|
||||
if (!raw) return;
|
||||
const local = JSON.parse(raw);
|
||||
if (!Array.isArray(local) || local.length === 0) return;
|
||||
|
||||
// Only migrate if API has no presets yet
|
||||
const existing = await fetchPresets();
|
||||
if (existing.length > 0) {
|
||||
localStorage.removeItem(PRESETS_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of local) {
|
||||
await apiCreatePreset({
|
||||
name: p.name || "Untitled",
|
||||
text: p.posText || p.text || "",
|
||||
category: "",
|
||||
tags: [],
|
||||
favorite: false,
|
||||
});
|
||||
// Note: negText is stored separately - we combine it
|
||||
if (p.negText) {
|
||||
// Create a separate entry for negative prompt reference
|
||||
// or store in the text field with marker
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(PRESETS_KEY);
|
||||
} catch { /* ignore migration errors */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Preset data management
|
||||
// ============================================
|
||||
|
||||
async function loadAllPresets() {
|
||||
await migrateLocalStoragePresets();
|
||||
promptPresets = await fetchPresets();
|
||||
|
||||
// Load groups from localStorage
|
||||
try {
|
||||
pmGroups = JSON.parse(localStorage.getItem(PM_GROUPS_KEY) || "{}");
|
||||
} catch { pmGroups = {}; }
|
||||
|
||||
// Clean stale entries from groups
|
||||
const validIds = new Set(promptPresets.map(p => p.id));
|
||||
for (const g of Object.keys(pmGroups)) {
|
||||
pmGroups[g] = (pmGroups[g] || []).filter(id => validIds.has(id));
|
||||
if (pmGroups[g].length === 0) delete pmGroups[g];
|
||||
}
|
||||
saveGroups();
|
||||
|
||||
renderPresetSelect();
|
||||
renderGroupSelect();
|
||||
renderPresetManager();
|
||||
}
|
||||
|
||||
function saveGroups() {
|
||||
localStorage.setItem(PM_GROUPS_KEY, JSON.stringify(pmGroups));
|
||||
renderGroupSelect();
|
||||
}
|
||||
|
||||
function renderGroupSelect() {
|
||||
const select = document.getElementById("wfm-preset-group-select");
|
||||
if (!select) return;
|
||||
const prevVal = select.value;
|
||||
select.innerHTML = `<option value="">Select group...</option>`;
|
||||
for (const g of Object.keys(pmGroups).sort()) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if (prevVal && pmGroups[prevVal]) select.value = prevVal;
|
||||
}
|
||||
|
||||
function renderPresetSelect() {
|
||||
const select = document.getElementById("wfm-preset-select");
|
||||
if (!select) return;
|
||||
const prevVal = select.value;
|
||||
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
|
||||
promptPresets.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
// Restore selection if still valid
|
||||
if (prevVal && promptPresets.find(p => p.id === prevVal)) {
|
||||
select.value = prevVal;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPresetInEditor(preset) {
|
||||
const presetSelect = document.getElementById("wfm-preset-select");
|
||||
const presetName = document.getElementById("wfm-preset-name");
|
||||
const presetCategory = document.getElementById("wfm-preset-category");
|
||||
const presetPos = document.getElementById("wfm-preset-pos");
|
||||
const presetNeg = document.getElementById("wfm-preset-neg");
|
||||
|
||||
if (presetSelect) presetSelect.value = preset ? preset.id : "";
|
||||
if (presetName) presetName.value = preset ? preset.name : "";
|
||||
if (presetCategory) presetCategory.value = preset ? (preset.category || "") : "";
|
||||
if (presetPos) presetPos.value = preset ? (preset.text || preset.posText || "") : "";
|
||||
if (presetNeg) presetNeg.value = preset ? (preset.negText || "") : "";
|
||||
|
||||
pmSelectedId = preset ? preset.id : null;
|
||||
renderPresetManager();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Preset Manager rendering
|
||||
// ============================================
|
||||
|
||||
function esc(s) {
|
||||
return s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") : "";
|
||||
}
|
||||
|
||||
function renderPresetManager() {
|
||||
const container = document.getElementById("wfm-pm-list");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
|
||||
switch (pmActiveTab) {
|
||||
case "all": renderPmAll(container); break;
|
||||
case "favorites": renderPmFavorites(container); break;
|
||||
case "groups": renderPmGroups(container); break;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesSearch(p) {
|
||||
if (!pmSearchText) return true;
|
||||
const s = pmSearchText.toLowerCase();
|
||||
return (p.name || "").toLowerCase().includes(s) ||
|
||||
(p.text || "").toLowerCase().includes(s) ||
|
||||
(p.category || "").toLowerCase().includes(s) ||
|
||||
(p.tags || []).some(t => t.toLowerCase().includes(s));
|
||||
}
|
||||
|
||||
function createPmItem(preset) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-pm-item" + (pmSelectedId === preset.id ? " active" : "");
|
||||
|
||||
const previewText = (preset.text || "").length > 50
|
||||
? preset.text.substring(0, 50) + "..."
|
||||
: (preset.text || "");
|
||||
|
||||
const catBadge = preset.category
|
||||
? `<span style="font-size:9px;color:var(--wfm-primary);margin-left:4px;">[${esc(preset.category)}]</span>`
|
||||
: "";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-pm-item-body">
|
||||
<div class="wfm-pm-item-name">${preset.favorite ? '<span style="color:#ffd700;">★</span> ' : ""}${esc(preset.name)}${catBadge}</div>
|
||||
<div class="wfm-pm-item-sub">${esc(previewText)}</div>
|
||||
</div>
|
||||
<div class="wfm-pm-item-actions">
|
||||
<button class="wfm-pm-action-btn pm-fav-btn${preset.favorite ? " fav-active" : ""}" title="Favorite">★</button>
|
||||
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click to select in editor
|
||||
el.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-pm-item-actions")) return;
|
||||
selectPresetInEditor(preset);
|
||||
});
|
||||
|
||||
// Favorite toggle
|
||||
el.querySelector(".pm-fav-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const updated = await apiUpdatePreset(preset.id, { favorite: !preset.favorite });
|
||||
if (updated) {
|
||||
const idx = promptPresets.findIndex(p => p.id === preset.id);
|
||||
if (idx >= 0) promptPresets[idx] = updated;
|
||||
renderPresetManager();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete
|
||||
el.querySelector(".pm-del-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${preset.name}"?`)) return;
|
||||
await apiDeletePreset(preset.id);
|
||||
promptPresets = promptPresets.filter(p => p.id !== preset.id);
|
||||
if (pmSelectedId === preset.id) {
|
||||
pmSelectedId = null;
|
||||
selectPresetInEditor(null);
|
||||
}
|
||||
renderPresetSelect();
|
||||
renderPresetManager();
|
||||
showToast(t("deleted"), "success");
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderPmAll(container) {
|
||||
const items = promptPresets.filter(matchesSearch);
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-pm-empty">${promptPresets.length === 0 ? "No presets yet.<br><small>Create one in the Presets panel</small>" : "No matches"}</div>`;
|
||||
return;
|
||||
}
|
||||
for (const p of items) {
|
||||
container.appendChild(createPmItem(p));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPmFavorites(container) {
|
||||
const favs = promptPresets.filter(p => p.favorite);
|
||||
const items = favs.filter(matchesSearch);
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-pm-empty">${favs.length === 0 ? "No favorites yet.<br><small>Star presets to add them here</small>" : "No matches"}</div>`;
|
||||
return;
|
||||
}
|
||||
for (const p of items) {
|
||||
container.appendChild(createPmItem(p));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPmGroups(container) {
|
||||
const groupNames = Object.keys(pmGroups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-pm-empty">No groups yet.<br><small>Create groups from the Presets panel below</small></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const groupName of groupNames) {
|
||||
const ids = pmGroups[groupName] || [];
|
||||
const presets = ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
|
||||
|
||||
const section = document.createElement("div");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-pm-group-header collapsed";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-pm-badge">${presets.length}</span>`;
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.style.display = "none";
|
||||
|
||||
header.addEventListener("click", () => {
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
|
||||
for (const p of presets) {
|
||||
const item = createPmItem(p);
|
||||
// Add remove-from-group button
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "wfm-pm-action-btn";
|
||||
removeBtn.title = "Remove from group";
|
||||
removeBtn.textContent = "\u2796";
|
||||
removeBtn.style.fontSize = "10px";
|
||||
removeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
|
||||
if (pmGroups[groupName].length === 0) delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
});
|
||||
item.querySelector(".wfm-pm-item-actions").prepend(removeBtn);
|
||||
list.appendChild(item);
|
||||
}
|
||||
section.appendChild(header);
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Ollama API helpers
|
||||
@@ -221,7 +547,7 @@ async function sendTranslate(direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = { ja2en: "JA→EN", en2ja: "EN→JA", zh2en: "ZH→EN", en2zh: "EN→ZH" };
|
||||
const labels = { ja2en: "JA\u2192EN", en2ja: "EN\u2192JA", zh2en: "ZH\u2192EN", en2zh: "EN\u2192ZH" };
|
||||
addChatMessage("user", `[${labels[direction] || direction}]\n${text}`);
|
||||
input.value = "";
|
||||
|
||||
@@ -232,9 +558,9 @@ async function sendTranslate(direction) {
|
||||
try {
|
||||
const prompts = {
|
||||
ja2en: `Translate the following Japanese text into English. Output only the translated text, nothing else.\n\n${text}`,
|
||||
en2ja: `以下の英語のテキストを日本語に翻訳してください。翻訳結果のみを出力し、他の説明は不要です。\n\n${text}`,
|
||||
en2ja: `\u4ee5\u4e0b\u306e\u82f1\u8a9e\u306e\u30c6\u30ad\u30b9\u30c8\u3092\u65e5\u672c\u8a9e\u306b\u7ffb\u8a33\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u7ffb\u8a33\u7d50\u679c\u306e\u307f\u3092\u51fa\u529b\u3057\u3001\u4ed6\u306e\u8aac\u660e\u306f\u4e0d\u8981\u3067\u3059\u3002\n\n${text}`,
|
||||
zh2en: `Translate the following Chinese text into English. Output only the translated text, nothing else.\n\n${text}`,
|
||||
en2zh: `请将以下英文翻译成中文。只输出翻译结果,不需要其他说明。\n\n${text}`,
|
||||
en2zh: `\u8bf7\u5c06\u4ee5\u4e0b\u82f1\u6587\u7ffb\u8bd1\u6210\u4e2d\u6587\u3002\u53ea\u8f93\u51fa\u7ffb\u8bd1\u7ed3\u679c\uff0c\u4e0d\u9700\u8981\u5176\u4ed6\u8bf4\u660e\u3002\n\n${text}`,
|
||||
};
|
||||
const prompt = prompts[direction];
|
||||
|
||||
@@ -267,36 +593,6 @@ function applyToGenerateUI(text) {
|
||||
showToast(t("appliedToGenerateUI"), "success");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Presets
|
||||
// ============================================
|
||||
|
||||
function loadPresets() {
|
||||
try {
|
||||
promptPresets = JSON.parse(localStorage.getItem(PRESETS_KEY) || "[]");
|
||||
} catch {
|
||||
promptPresets = [];
|
||||
}
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
function savePresets() {
|
||||
localStorage.setItem(PRESETS_KEY, JSON.stringify(promptPresets));
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
function renderPresets() {
|
||||
const select = document.getElementById("wfm-preset-select");
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
|
||||
promptPresets.forEach((p, i) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = i.toString();
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialize
|
||||
// ============================================
|
||||
@@ -377,27 +673,20 @@ export function initPromptTab() {
|
||||
document.getElementById("wfm-ollama-zh2en-btn")?.addEventListener("click", () => sendTranslate("zh2en"));
|
||||
document.getElementById("wfm-ollama-en2zh-btn")?.addEventListener("click", () => sendTranslate("en2zh"));
|
||||
|
||||
// --- Presets ---
|
||||
loadPresets();
|
||||
// --- Presets (API-based) ---
|
||||
loadAllPresets();
|
||||
|
||||
const presetSelect = document.getElementById("wfm-preset-select");
|
||||
const presetName = document.getElementById("wfm-preset-name");
|
||||
const presetCategory = document.getElementById("wfm-preset-category");
|
||||
const presetPos = document.getElementById("wfm-preset-pos");
|
||||
const presetNeg = document.getElementById("wfm-preset-neg");
|
||||
|
||||
// Load preset on selection
|
||||
presetSelect?.addEventListener("change", () => {
|
||||
const idx = presetSelect.value;
|
||||
if (idx !== "" && promptPresets[idx]) {
|
||||
const p = promptPresets[idx];
|
||||
if (presetName) presetName.value = p.name;
|
||||
if (presetPos) presetPos.value = p.posText || p.text || "";
|
||||
if (presetNeg) presetNeg.value = p.negText || "";
|
||||
} else {
|
||||
if (presetName) presetName.value = "";
|
||||
if (presetPos) presetPos.value = "";
|
||||
if (presetNeg) presetNeg.value = "";
|
||||
}
|
||||
const id = presetSelect.value;
|
||||
const p = promptPresets.find(pp => pp.id === id);
|
||||
selectPresetInEditor(p || null);
|
||||
});
|
||||
|
||||
// Copy positive prompt to clipboard
|
||||
@@ -424,9 +713,10 @@ export function initPromptTab() {
|
||||
});
|
||||
});
|
||||
|
||||
// Save preset
|
||||
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", () => {
|
||||
// Save preset (create or update via API)
|
||||
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", async () => {
|
||||
const name = presetName?.value.trim();
|
||||
const category = presetCategory?.value.trim() || "";
|
||||
const pos = presetPos?.value || "";
|
||||
const neg = presetNeg?.value || "";
|
||||
|
||||
@@ -439,32 +729,39 @@ export function initPromptTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
const existIdx = promptPresets.findIndex((p) => p.name === name);
|
||||
if (existIdx >= 0) {
|
||||
promptPresets[existIdx].posText = pos;
|
||||
promptPresets[existIdx].negText = neg;
|
||||
const selectedId = presetSelect?.value;
|
||||
const existing = selectedId ? promptPresets.find(p => p.id === selectedId) : null;
|
||||
|
||||
if (existing) {
|
||||
// Update
|
||||
const updated = await apiUpdatePreset(existing.id, {
|
||||
name, text: pos, negText: neg, category,
|
||||
});
|
||||
if (updated) {
|
||||
const idx = promptPresets.findIndex(p => p.id === existing.id);
|
||||
if (idx >= 0) promptPresets[idx] = updated;
|
||||
showToast(t("presetSaved"), "success");
|
||||
}
|
||||
} else {
|
||||
promptPresets.push({ name, posText: pos, negText: neg });
|
||||
// Create
|
||||
const created = await apiCreatePreset({
|
||||
name, text: pos, negText: neg, category,
|
||||
tags: [], favorite: false,
|
||||
});
|
||||
if (created) {
|
||||
promptPresets.push(created);
|
||||
pmSelectedId = created.id;
|
||||
showToast(t("presetSaved"), "success");
|
||||
}
|
||||
}
|
||||
savePresets();
|
||||
showToast(t("presetSaved"), "success");
|
||||
|
||||
// Select newly saved
|
||||
const newIdx = promptPresets.findIndex((p) => p.name === name);
|
||||
if (presetSelect && newIdx >= 0) presetSelect.value = newIdx.toString();
|
||||
});
|
||||
renderPresetSelect();
|
||||
renderPresetManager();
|
||||
|
||||
// Delete preset
|
||||
document.getElementById("wfm-preset-delete-btn")?.addEventListener("click", () => {
|
||||
const idx = presetSelect?.value;
|
||||
if (idx === "" || !promptPresets[idx]) return;
|
||||
if (!confirm(t("deleteConfirm", promptPresets[idx].name))) return;
|
||||
promptPresets.splice(idx, 1);
|
||||
savePresets();
|
||||
if (presetName) presetName.value = "";
|
||||
if (presetPos) presetPos.value = "";
|
||||
if (presetNeg) presetNeg.value = "";
|
||||
showToast(t("deleted"), "success");
|
||||
// Re-select
|
||||
if (pmSelectedId && presetSelect) {
|
||||
presetSelect.value = pmSelectedId;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply preset to GenerateUI
|
||||
@@ -489,4 +786,71 @@ export function initPromptTab() {
|
||||
}
|
||||
if (applied) showToast(t("appliedToGenerateUI"), "success");
|
||||
});
|
||||
|
||||
// --- Group management (Presets side) ---
|
||||
document.getElementById("wfm-preset-new-group-btn")?.addEventListener("click", () => {
|
||||
const name = prompt("Group name:");
|
||||
if (!name || !name.trim()) return;
|
||||
const key = name.trim();
|
||||
if (pmGroups[key]) {
|
||||
showToast("Group already exists", "error");
|
||||
return;
|
||||
}
|
||||
pmGroups[key] = [];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(`Group "${key}" created`, "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-preset-add-to-group-btn")?.addEventListener("click", () => {
|
||||
const groupSelect = document.getElementById("wfm-preset-group-select");
|
||||
const groupName = groupSelect?.value;
|
||||
if (!groupName) {
|
||||
showToast("Select a group first", "error");
|
||||
return;
|
||||
}
|
||||
const id = presetSelect?.value;
|
||||
if (!id) {
|
||||
showToast("Select a preset first", "error");
|
||||
return;
|
||||
}
|
||||
if (!pmGroups[groupName]) pmGroups[groupName] = [];
|
||||
if (pmGroups[groupName].includes(id)) {
|
||||
showToast("Already in this group", "info");
|
||||
return;
|
||||
}
|
||||
pmGroups[groupName].push(id);
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast("Added to group", "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-preset-del-group-btn")?.addEventListener("click", () => {
|
||||
const groupSelect = document.getElementById("wfm-preset-group-select");
|
||||
const groupName = groupSelect?.value;
|
||||
if (!groupName) {
|
||||
showToast("Select a group first", "error");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${groupName}"?`)) return;
|
||||
delete pmGroups[groupName];
|
||||
saveGroups();
|
||||
renderPresetManager();
|
||||
showToast(`Group "${groupName}" deleted`, "success");
|
||||
});
|
||||
|
||||
// --- Preset Manager ---
|
||||
document.querySelectorAll(".wfm-pm-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
pmActiveTab = btn.dataset.pmtab;
|
||||
document.querySelectorAll(".wfm-pm-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderPresetManager();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-pm-search-input")?.addEventListener("input", (e) => {
|
||||
pmSearchText = e.target.value.trim();
|
||||
renderPresetManager();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions
|
||||
|
||||
const SETTINGS_KEY = "wfm_settings";
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
const m = rgb.match(/(\d+)/g);
|
||||
if (!m || m.length < 3) return "#000000";
|
||||
return "#" + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function loadLocalSettings() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||
@@ -67,12 +73,259 @@ const THEMES = [
|
||||
{ id: "corporate", key: "themeCorporate", colors: ["#F0F4F8", "#FFFFFF", "#2563EB", "#7C3AED"] },
|
||||
];
|
||||
|
||||
// --- Background pattern definitions ---
|
||||
const BG_PATTERNS = [
|
||||
{ id: "none", key: "themePatternNone" },
|
||||
{ id: "stripe-h", key: "themePatternStripeH" },
|
||||
{ id: "stripe-v", key: "themePatternStripeV" },
|
||||
{ id: "stripe-d", key: "themePatternStripeD" },
|
||||
{ id: "dot", key: "themePatternDot" },
|
||||
{ id: "check", key: "themePatternCheck" },
|
||||
{ id: "svg", key: "themePatternSvg" },
|
||||
];
|
||||
|
||||
// Font definitions: id, label key, CSS value, Google Fonts family name (if web font)
|
||||
const FONT_OPTIONS = [
|
||||
{ id: "", key: "themeFontDefault", css: "", google: "" },
|
||||
{ id: "noto-sans-jp", key: "themeFontNotoSans", css: "'Noto Sans JP', sans-serif", google: "Noto+Sans+JP:wght@400;700" },
|
||||
{ id: "zen-maru", key: "themeFontZenMaru", css: "'Zen Maru Gothic', sans-serif", google: "Zen+Maru+Gothic:wght@400;700" },
|
||||
{ id: "m-plus-rounded", key: "themeFontMPlusRound", css: "'M PLUS Rounded 1c', sans-serif", google: "M+PLUS+Rounded+1c:wght@400;700" },
|
||||
{ id: "kosugi-maru", key: "themeFontKosugiMaru", css: "'Kosugi Maru', sans-serif", google: "Kosugi+Maru" },
|
||||
{ id: "sawarabi-gothic",key: "themeFontSawarabi", css: "'Sawarabi Gothic', sans-serif", google: "Sawarabi+Gothic" },
|
||||
{ id: "biz-udp", key: "themeFontBizUdp", css: "'BIZ UDPGothic', sans-serif", google: "BIZ+UDPGothic:wght@400;700" },
|
||||
{ id: "dot-gothic", key: "themeFontDotGothic", css: "'DotGothic16', monospace", google: "DotGothic16" },
|
||||
{ id: "hachi-maru", key: "themeFontHachiMaru", css: "'Hachi Maru Pop', cursive", google: "Hachi+Maru+Pop" },
|
||||
{ id: "dela-gothic", key: "themeFontDelaGothic", css: "'Dela Gothic One', sans-serif", google: "Dela+Gothic+One" },
|
||||
{ id: "reggae-one", key: "themeFontReggaeOne", css: "'Reggae One', cursive", google: "Reggae+One" },
|
||||
{ id: "rocknroll", key: "themeFontRocknRoll", css: "'RocknRoll One', sans-serif", google: "RocknRoll+One" },
|
||||
{ id: "stick", key: "themeFontStick", css: "'Stick', sans-serif", google: "Stick" },
|
||||
{ id: "train-one", key: "themeFontTrainOne", css: "'Train One', cursive", google: "Train+One" },
|
||||
{ id: "space-mono", key: "themeFontSpaceMono", css: "'Space Mono', monospace", google: "Space+Mono:wght@400;700" },
|
||||
{ id: "fira-code", key: "themeFontFiraCode", css: "'Fira Code', monospace", google: "Fira+Code:wght@400;700" },
|
||||
];
|
||||
|
||||
// Load Google Font dynamically
|
||||
const _loadedFonts = new Set();
|
||||
function loadGoogleFont(googleFamily) {
|
||||
if (!googleFamily || _loadedFonts.has(googleFamily)) return;
|
||||
_loadedFonts.add(googleFamily);
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${googleFamily}&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function applyCustomFont(fontId) {
|
||||
const font = FONT_OPTIONS.find(f => f.id === fontId);
|
||||
let styleEl = document.getElementById("wfm-custom-font-style");
|
||||
if (!font || !font.css) {
|
||||
if (styleEl) styleEl.remove();
|
||||
return;
|
||||
}
|
||||
if (font.google) loadGoogleFont(font.google);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = "wfm-custom-font-style";
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = `body, .wfm-input, .wfm-select, .wfm-textarea, button { font-family: ${font.css} !important; }`;
|
||||
}
|
||||
|
||||
// Color customization targets
|
||||
const COLOR_TARGETS = [
|
||||
{ varName: "--wfm-bg", key: "themeColorBg" },
|
||||
{ varName: "--wfm-bg-secondary", key: "themeColorBgSec" },
|
||||
{ varName: "--wfm-surface", key: "themeColorSurface" },
|
||||
{ varName: "--wfm-text", key: "themeColorText" },
|
||||
{ varName: "--wfm-primary", key: "themeColorPrimary" },
|
||||
{ varName: "--wfm-accent", key: "themeColorAccent" },
|
||||
];
|
||||
|
||||
function generatePatternCSS(patternId, color, opacity, scale) {
|
||||
const o = opacity ?? 0.1;
|
||||
const s = scale ?? 20;
|
||||
const c = color || "#ffffff";
|
||||
// Convert hex to rgba
|
||||
const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
|
||||
const rgba = `rgba(${r},${g},${b},${o})`;
|
||||
|
||||
switch (patternId) {
|
||||
case "stripe-h":
|
||||
return `repeating-linear-gradient(0deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
|
||||
case "stripe-v":
|
||||
return `repeating-linear-gradient(90deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
|
||||
case "stripe-d":
|
||||
return `repeating-linear-gradient(45deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
|
||||
case "dot": {
|
||||
const half = s / 2;
|
||||
const dotR = Math.max(1, s * 0.12);
|
||||
return `radial-gradient(circle ${dotR}px at ${half}px ${half}px, ${rgba} 100%, transparent 100%)`;
|
||||
}
|
||||
case "check": {
|
||||
const half = s / 2;
|
||||
return `linear-gradient(45deg, ${rgba} 25%, transparent 25%, transparent 75%, ${rgba} 75%), ` +
|
||||
`linear-gradient(45deg, ${rgba} 25%, transparent 25%, transparent 75%, ${rgba} 75%)`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function generatePatternBgSize(patternId, scale) {
|
||||
const s = scale ?? 20;
|
||||
if (patternId === "dot") return `${s}px ${s}px`;
|
||||
if (patternId === "check") {
|
||||
const half = s / 2;
|
||||
return `${s}px ${s}px`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function generatePatternBgPosition(patternId, scale) {
|
||||
const s = scale ?? 20;
|
||||
if (patternId === "check") {
|
||||
const half = s / 2;
|
||||
return `0 0, ${half}px ${half}px`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function generatePatternPreviewSVG(patternId) {
|
||||
const fg = "#8cb4ff";
|
||||
const bg = "#1a1a2e";
|
||||
const encode = (svg) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
switch (patternId) {
|
||||
case "none":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><line x1="10" y1="10" x2="50" y2="50" stroke="#555" stroke-width="2"/><line x1="50" y1="10" x2="10" y2="50" stroke="#555" stroke-width="2"/></svg>`);
|
||||
case "stripe-h":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect y="4" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="16" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="28" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="40" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="52" width="60" height="4" fill="${fg}" opacity="0.7"/></svg>`);
|
||||
case "stripe-v":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="4" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="16" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="28" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="40" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="52" width="4" height="60" fill="${fg}" opacity="0.7"/></svg>`);
|
||||
case "stripe-d":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><line x1="-5" y1="7" x2="53" y2="-5" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="19" x2="65" y2="7" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="-5" y1="31" x2="53" y2="19" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="43" x2="65" y2="31" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="-5" y1="55" x2="53" y2="43" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="67" x2="65" y2="55" stroke="${fg}" stroke-width="3" opacity="0.7"/></svg>`);
|
||||
case "dot":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><circle cx="10" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="30" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="50" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="20" cy="25" r="4" fill="${fg}" opacity="0.8"/><circle cx="40" cy="25" r="4" fill="${fg}" opacity="0.8"/><circle cx="10" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="30" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="50" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="20" cy="55" r="4" fill="${fg}" opacity="0.8"/><circle cx="40" cy="55" r="4" fill="${fg}" opacity="0.8"/></svg>`);
|
||||
case "check":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="0" y="0" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="15" y="15" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="30" y="0" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="45" y="15" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="0" y="30" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="15" y="45" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="30" y="30" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="45" y="45" width="15" height="15" fill="${fg}" opacity="0.35"/></svg>`);
|
||||
case "svg":
|
||||
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="10" y="10" width="40" height="40" rx="4" fill="none" stroke="${fg}" stroke-width="2" stroke-dasharray="6 3" opacity="0.6"/><text x="30" y="36" text-anchor="middle" fill="${fg}" font-size="20" font-weight="bold" opacity="0.8">+</text></svg>`);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom overrides (colors + pattern) on top of the current theme
|
||||
function applyCustomOverrides(custom) {
|
||||
const root = document.documentElement;
|
||||
// Remove old custom style element
|
||||
let styleEl = document.getElementById("wfm-custom-theme-style");
|
||||
|
||||
// Color overrides
|
||||
if (custom && custom.colors) {
|
||||
for (const [varName, value] of Object.entries(custom.colors)) {
|
||||
if (value) root.style.setProperty(varName, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern
|
||||
if (custom && custom.pattern && custom.pattern.id && custom.pattern.id !== "none") {
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.id = "wfm-custom-theme-style";
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
const p = custom.pattern;
|
||||
if (p.id === "svg" && p.svgData) {
|
||||
const s = p.scale ?? 40;
|
||||
const o = p.opacity ?? 1;
|
||||
const c = p.color || null;
|
||||
const gap = p.gap ?? 0;
|
||||
// Recolor SVG: inject a global style override into the SVG
|
||||
let svgStr = p.svgData;
|
||||
if (c) {
|
||||
// 1. Replace fill/stroke attributes (but not "none")
|
||||
svgStr = svgStr
|
||||
.replace(/fill="(?!none)[^"]*"/gi, `fill="${c}"`)
|
||||
.replace(/stroke="(?!none)[^"]*"/gi, `stroke="${c}"`);
|
||||
// 2. Replace inline style fill/stroke
|
||||
svgStr = svgStr
|
||||
.replace(/fill:\s*(?!none)[^;"]+/gi, `fill:${c}`)
|
||||
.replace(/stroke:\s*(?!none)[^;"]+/gi, `stroke:${c}`);
|
||||
// 3. Inject a <style> block for elements with no explicit fill/stroke
|
||||
const styleBlock = `<style>*:not([fill="none"]){fill:${c}!important}*:not([stroke="none"])[stroke]{stroke:${c}!important}</style>`;
|
||||
svgStr = svgStr.replace(/<svg([^>]*)>/i, `<svg$1>${styleBlock}`);
|
||||
}
|
||||
// Extract original viewBox/width/height, then create wrapper SVG with padding
|
||||
const tileSize = s + gap;
|
||||
let tiledSvg;
|
||||
if (gap > 0) {
|
||||
// Extract the original viewBox to preserve it in the inner SVG
|
||||
const vbMatch = svgStr.match(/viewBox="([^"]*)"/i);
|
||||
const origViewBox = vbMatch ? vbMatch[1] : `0 0 ${s} ${s}`;
|
||||
// Wrap: outer SVG with tileSize, inner SVG with original viewBox
|
||||
tiledSvg = svgStr.replace(
|
||||
/<svg([^>]*)>/i,
|
||||
(match, attrs) => {
|
||||
// Remove existing width/height/viewBox from outer
|
||||
let clean = attrs
|
||||
.replace(/\s*width="[^"]*"/gi, '')
|
||||
.replace(/\s*height="[^"]*"/gi, '')
|
||||
.replace(/\s*viewBox="[^"]*"/gi, '');
|
||||
return `<svg${clean} width="${tileSize}" height="${tileSize}" viewBox="0 0 ${tileSize} ${tileSize}">
|
||||
<svg width="${s}" height="${s}" x="0" y="0" viewBox="${origViewBox}">`;
|
||||
}
|
||||
) + '</svg>';
|
||||
} else {
|
||||
tiledSvg = svgStr;
|
||||
}
|
||||
const encoded = `url("data:image/svg+xml,${encodeURIComponent(tiledSvg)}")`;
|
||||
styleEl.textContent = `body::after { content: ""; position: fixed; inset: 0; z-index: -1; background-image: ${encoded}; background-size: ${tileSize}px ${tileSize}px; background-repeat: repeat; opacity: ${o}; pointer-events: none; }`;
|
||||
} else if (p.id !== "svg") {
|
||||
const bg = generatePatternCSS(p.id, p.color, p.opacity, p.scale);
|
||||
const bgSize = generatePatternBgSize(p.id, p.scale);
|
||||
const bgPos = generatePatternBgPosition(p.id, p.scale);
|
||||
let css = `body { background-image: ${bg} !important;`;
|
||||
if (bgSize) css += ` background-size: ${bgSize} !important;`;
|
||||
if (bgPos) css += ` background-position: ${bgPos} !important;`;
|
||||
css += ` }`;
|
||||
styleEl.textContent = css;
|
||||
}
|
||||
} else {
|
||||
if (styleEl) styleEl.remove();
|
||||
}
|
||||
|
||||
// Font
|
||||
if (custom && custom.fontId) {
|
||||
applyCustomFont(custom.fontId);
|
||||
} else {
|
||||
const fontEl = document.getElementById("wfm-custom-font-style");
|
||||
if (fontEl) fontEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function clearCustomOverrides() {
|
||||
const root = document.documentElement;
|
||||
for (const ct of COLOR_TARGETS) {
|
||||
root.style.removeProperty(ct.varName);
|
||||
}
|
||||
const styleEl = document.getElementById("wfm-custom-theme-style");
|
||||
if (styleEl) styleEl.remove();
|
||||
const fontEl = document.getElementById("wfm-custom-font-style");
|
||||
if (fontEl) fontEl.remove();
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
// Apply saved custom overrides
|
||||
const settings = loadLocalSettings();
|
||||
clearCustomOverrides();
|
||||
if (settings.themeCustom) {
|
||||
applyCustomOverrides(settings.themeCustom);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSavedTheme() {
|
||||
@@ -101,8 +354,8 @@ export async function initSettingsTab() {
|
||||
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
|
||||
|
||||
<!-- Language Settings -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("langLabel")} / ${t("summaryLangLabel")}</h3>
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("langLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-ui-lang">
|
||||
@@ -118,11 +371,11 @@ export async function initSettingsTab() {
|
||||
⚠ ${t("summaryLangNote")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Theme -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("themeLabel")}</h3>
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
|
||||
<div class="wfm-theme-grid" id="wfm-theme-grid">
|
||||
${THEMES.map(th => `
|
||||
<div class="wfm-theme-card ${(settings.theme || "") === th.id ? "active" : ""}" data-theme-id="${th.id}">
|
||||
@@ -133,11 +386,94 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Customizer -->
|
||||
<button class="wfm-customizer-toggle" id="wfm-customizer-toggle">
|
||||
<span class="arrow">▶</span> ${t("themeCustomize")}
|
||||
</button>
|
||||
<div class="wfm-customizer-panel" id="wfm-customizer-panel">
|
||||
<!-- Color overrides -->
|
||||
<div class="wfm-customizer-section">
|
||||
<h4>${t("themeCustomizeColors")}</h4>
|
||||
<div class="wfm-color-grid" id="wfm-color-grid">
|
||||
${COLOR_TARGETS.map(ct => {
|
||||
const saved = settings.themeCustom?.colors?.[ct.varName] || "";
|
||||
return `<div class="wfm-color-item">
|
||||
<label>${t(ct.key)}</label>
|
||||
<input type="color" data-var="${ct.varName}" value="${saved || getComputedStyle(document.documentElement).getPropertyValue(ct.varName).trim()}" title="${ct.varName}">
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background pattern -->
|
||||
<div class="wfm-customizer-section">
|
||||
<h4>${t("themeCustomizeBgPattern")}</h4>
|
||||
<div class="wfm-pattern-grid" id="wfm-pattern-grid">
|
||||
${BG_PATTERNS.map(p => {
|
||||
const active = (settings.themeCustom?.pattern?.id || "none") === p.id ? "active" : "";
|
||||
const preview = generatePatternPreviewSVG(p.id);
|
||||
const bgStyle = preview
|
||||
? `background:url('${preview}') no-repeat center/cover;`
|
||||
: "background:#333;";
|
||||
return `<div class="wfm-pattern-item ${active} ${p.id === 'svg' ? 'wfm-svg-upload-btn' : ''}" data-pattern-id="${p.id}" style="${bgStyle}">
|
||||
<span class="wfm-pattern-item-label">${t(p.key)}</span>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<input type="file" id="wfm-svg-upload" accept=".svg" style="display:none;">
|
||||
|
||||
<!-- Pattern options -->
|
||||
<div class="wfm-pattern-options" id="wfm-pattern-options" style="${(!settings.themeCustom?.pattern?.id || settings.themeCustom?.pattern?.id === 'none') ? 'display:none;' : ''}">
|
||||
<label>
|
||||
${t("themePatternColor")}
|
||||
<input type="color" id="wfm-pattern-color" value="${settings.themeCustom?.pattern?.color || '#ffffff'}">
|
||||
</label>
|
||||
<label>
|
||||
${t("themePatternOpacity")}
|
||||
<input type="range" id="wfm-pattern-opacity" min="0.02" max="0.5" step="0.02" value="${settings.themeCustom?.pattern?.opacity ?? 0.1}">
|
||||
<span id="wfm-pattern-opacity-val">${settings.themeCustom?.pattern?.opacity ?? 0.1}</span>
|
||||
</label>
|
||||
<label>
|
||||
${t("themePatternScale")}
|
||||
<input type="range" id="wfm-pattern-scale" min="8" max="80" step="2" value="${settings.themeCustom?.pattern?.scale ?? 20}">
|
||||
<span id="wfm-pattern-scale-val">${settings.themeCustom?.pattern?.scale ?? 20}px</span>
|
||||
</label>
|
||||
<label>
|
||||
${t("themePatternGap")}
|
||||
<input type="range" id="wfm-pattern-gap" min="0" max="60" step="2" value="${settings.themeCustom?.pattern?.gap ?? 0}">
|
||||
<span id="wfm-pattern-gap-val">${settings.themeCustom?.pattern?.gap ?? 0}px</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-svg-name" style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;${settings.themeCustom?.pattern?.id === 'svg' ? '' : 'display:none;'}">
|
||||
${settings.themeCustom?.pattern?.svgName ? `SVG: ${settings.themeCustom.pattern.svgName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font -->
|
||||
<div class="wfm-customizer-section">
|
||||
<h4>${t("themeCustomizeFont")}</h4>
|
||||
<div class="wfm-font-grid" id="wfm-font-grid">
|
||||
${FONT_OPTIONS.map(f => {
|
||||
const active = (settings.themeCustom?.fontId || "") === f.id ? "active" : "";
|
||||
return `<div class="wfm-font-item ${active}" data-font-id="${f.id}" title="${f.css || 'System Default'}">
|
||||
<span class="wfm-font-preview" style="${f.css ? `font-family:${f.css};` : ''}">${t(f.key)}</span>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="wfm-customizer-actions">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-customizer-apply">${t("themeCustomizerApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-customizer-reset">${t("themeCustomizerReset")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("workflowsDir")}</h3>
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("workflowsDirLabel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
@@ -154,11 +490,11 @@ export async function initSettingsTab() {
|
||||
${t("workflowsDirCurrent")}: <code id="wfm-settings-workflows-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${workflowsDirInfo.current}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ComfyUI Settings -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("comfyuiConnection")}</h3>
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("comfyuiUrl")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
@@ -172,11 +508,11 @@ export async function initSettingsTab() {
|
||||
</small>
|
||||
</div>
|
||||
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Ollama Settings -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("ollamaSettings")}</h3>
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("ollamaUrl")}</label>
|
||||
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
|
||||
@@ -197,11 +533,11 @@ export async function initSettingsTab() {
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
|
||||
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Default Workflow -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("defaultWorkflow")}</h3>
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("defaultWorkflow")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="font-size:13px;" id="wfm-settings-default-wf-name">${settings.defaultWorkflow || t("defaultWorkflowNone")}</span>
|
||||
@@ -211,11 +547,11 @@ export async function initSettingsTab() {
|
||||
${t("defaultWorkflowHint")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Eagle Integration (optional) -->
|
||||
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
|
||||
<h3 style="font-size:15px;margin-bottom:12px;">${t("eagleIntegration")}</h3>
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("eagleIntegration")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("eagleUrl")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
@@ -233,7 +569,7 @@ export async function initSettingsTab() {
|
||||
${t("eagleAutoSave")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
|
||||
@@ -244,11 +580,193 @@ export async function initSettingsTab() {
|
||||
const card = e.target.closest(".wfm-theme-card");
|
||||
if (!card) return;
|
||||
const themeId = card.dataset.themeId;
|
||||
// Clear custom overrides when switching theme base
|
||||
clearCustomOverrides();
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.themeCustom;
|
||||
cur.theme = themeId;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyTheme(themeId);
|
||||
saveLocalSettings({ theme: themeId });
|
||||
// Update active state
|
||||
document.querySelectorAll(".wfm-theme-card").forEach(c => c.classList.remove("active"));
|
||||
card.classList.add("active");
|
||||
// Update color pickers to reflect new theme's computed values
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll("#wfm-color-grid input[type=color]").forEach(inp => {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(inp.dataset.var).trim();
|
||||
if (v) inp.value = v.startsWith("#") ? v : rgbToHex(v);
|
||||
});
|
||||
});
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// --- Theme Customizer handlers ---
|
||||
// State for live editing
|
||||
let customizerState = {
|
||||
colors: { ...(settings.themeCustom?.colors || {}) },
|
||||
pattern: { ...(settings.themeCustom?.pattern || { id: "none", color: "#ffffff", opacity: 0.1, scale: 20 }) },
|
||||
fontId: settings.themeCustom?.fontId || "",
|
||||
};
|
||||
|
||||
// Toggle panel
|
||||
document.getElementById("wfm-customizer-toggle")?.addEventListener("click", () => {
|
||||
const toggle = document.getElementById("wfm-customizer-toggle");
|
||||
const panel = document.getElementById("wfm-customizer-panel");
|
||||
toggle.classList.toggle("open");
|
||||
panel.classList.toggle("open");
|
||||
});
|
||||
|
||||
// Live color change
|
||||
document.getElementById("wfm-color-grid")?.addEventListener("input", (e) => {
|
||||
if (e.target.type !== "color") return;
|
||||
const varName = e.target.dataset.var;
|
||||
const value = e.target.value;
|
||||
customizerState.colors[varName] = value;
|
||||
document.documentElement.style.setProperty(varName, value);
|
||||
});
|
||||
|
||||
// Pattern selection
|
||||
document.getElementById("wfm-pattern-grid")?.addEventListener("click", (e) => {
|
||||
const item = e.target.closest(".wfm-pattern-item");
|
||||
if (!item) return;
|
||||
const patternId = item.dataset.patternId;
|
||||
|
||||
// SVG file upload
|
||||
if (patternId === "svg") {
|
||||
document.getElementById("wfm-svg-upload")?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
customizerState.pattern.id = patternId;
|
||||
// Update active state
|
||||
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
|
||||
item.classList.add("active");
|
||||
|
||||
// Show/hide options
|
||||
const optionsEl = document.getElementById("wfm-pattern-options");
|
||||
const svgNameEl = document.getElementById("wfm-svg-name");
|
||||
if (patternId === "none") {
|
||||
optionsEl.style.display = "none";
|
||||
svgNameEl.style.display = "none";
|
||||
} else {
|
||||
optionsEl.style.display = "";
|
||||
svgNameEl.style.display = "none";
|
||||
}
|
||||
|
||||
// Live preview
|
||||
applyCustomOverrides(customizerState);
|
||||
});
|
||||
|
||||
// SVG file upload handler
|
||||
document.getElementById("wfm-svg-upload")?.addEventListener("change", (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const svgData = ev.target.result;
|
||||
customizerState.pattern.id = "svg";
|
||||
customizerState.pattern.svgData = svgData;
|
||||
customizerState.pattern.svgName = file.name;
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
|
||||
document.querySelector('.wfm-pattern-item[data-pattern-id="svg"]')?.classList.add("active");
|
||||
|
||||
// Show options + name
|
||||
document.getElementById("wfm-pattern-options").style.display = "";
|
||||
const svgNameEl = document.getElementById("wfm-svg-name");
|
||||
svgNameEl.textContent = `SVG: ${file.name}`;
|
||||
svgNameEl.style.display = "";
|
||||
|
||||
applyCustomOverrides(customizerState);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
// Pattern option changes (live)
|
||||
document.getElementById("wfm-pattern-color")?.addEventListener("input", (e) => {
|
||||
customizerState.pattern.color = e.target.value;
|
||||
applyCustomOverrides(customizerState);
|
||||
});
|
||||
document.getElementById("wfm-pattern-opacity")?.addEventListener("input", (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
customizerState.pattern.opacity = v;
|
||||
document.getElementById("wfm-pattern-opacity-val").textContent = v;
|
||||
applyCustomOverrides(customizerState);
|
||||
});
|
||||
document.getElementById("wfm-pattern-scale")?.addEventListener("input", (e) => {
|
||||
const v = parseInt(e.target.value);
|
||||
customizerState.pattern.scale = v;
|
||||
document.getElementById("wfm-pattern-scale-val").textContent = v + "px";
|
||||
applyCustomOverrides(customizerState);
|
||||
});
|
||||
document.getElementById("wfm-pattern-gap")?.addEventListener("input", (e) => {
|
||||
const v = parseInt(e.target.value);
|
||||
customizerState.pattern.gap = v;
|
||||
document.getElementById("wfm-pattern-gap-val").textContent = v + "px";
|
||||
applyCustomOverrides(customizerState);
|
||||
});
|
||||
|
||||
// Font selection
|
||||
document.getElementById("wfm-font-grid")?.addEventListener("click", (e) => {
|
||||
const item = e.target.closest(".wfm-font-item");
|
||||
if (!item) return;
|
||||
const fontId = item.dataset.fontId;
|
||||
customizerState.fontId = fontId;
|
||||
// Load web font for preview
|
||||
const font = FONT_OPTIONS.find(f => f.id === fontId);
|
||||
if (font?.google) loadGoogleFont(font.google);
|
||||
applyCustomFont(fontId);
|
||||
// Update active state
|
||||
document.querySelectorAll(".wfm-font-item").forEach(i => i.classList.remove("active"));
|
||||
item.classList.add("active");
|
||||
});
|
||||
|
||||
// Apply & Save
|
||||
document.getElementById("wfm-customizer-apply")?.addEventListener("click", () => {
|
||||
// Clean empty color overrides
|
||||
const cleanColors = {};
|
||||
for (const [k, v] of Object.entries(customizerState.colors)) {
|
||||
if (v) cleanColors[k] = v;
|
||||
}
|
||||
const custom = {
|
||||
colors: Object.keys(cleanColors).length ? cleanColors : undefined,
|
||||
pattern: customizerState.pattern.id !== "none" ? { ...customizerState.pattern } : undefined,
|
||||
fontId: customizerState.fontId || undefined,
|
||||
};
|
||||
saveLocalSettings({ themeCustom: (custom.colors || custom.pattern || custom.fontId) ? custom : undefined });
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Reset
|
||||
document.getElementById("wfm-customizer-reset")?.addEventListener("click", () => {
|
||||
clearCustomOverrides();
|
||||
customizerState = {
|
||||
colors: {},
|
||||
pattern: { id: "none", color: "#ffffff", opacity: 0.1, scale: 20, gap: 0 },
|
||||
fontId: "",
|
||||
};
|
||||
// Remove themeCustom from localStorage directly (spread merge won't delete keys)
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.themeCustom;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyTheme(cur.theme || "");
|
||||
|
||||
// Reset UI
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll("#wfm-color-grid input[type=color]").forEach(inp => {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(inp.dataset.var).trim();
|
||||
if (v) inp.value = v.startsWith("#") ? v : rgbToHex(v);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
|
||||
document.querySelector('.wfm-pattern-item[data-pattern-id="none"]')?.classList.add("active");
|
||||
document.getElementById("wfm-pattern-options").style.display = "none";
|
||||
document.getElementById("wfm-svg-name").style.display = "none";
|
||||
// Reset font UI
|
||||
document.querySelectorAll(".wfm-font-item").forEach(i => i.classList.remove("active"));
|
||||
document.querySelector('.wfm-font-item[data-font-id=""]')?.classList.add("active");
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@ const state = {
|
||||
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
|
||||
selectedWf: null,
|
||||
groupFilter: "", // empty = all groups
|
||||
currentPage: 0,
|
||||
badgeColors: (() => {
|
||||
try { return JSON.parse(localStorage.getItem("wfm_badge_colors") || "{}"); }
|
||||
catch { return {}; }
|
||||
})(),
|
||||
};
|
||||
|
||||
const WF_PER_PAGE = 24;
|
||||
|
||||
const BADGE_DEFAULT_COLORS = {
|
||||
"SD1.5": "#e67e22",
|
||||
SDXL: "#2ecc71",
|
||||
@@ -242,6 +245,7 @@ function renderModelFilters() {
|
||||
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeModel = btn.dataset.filter;
|
||||
state.currentPage = 0;
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
});
|
||||
@@ -297,17 +301,27 @@ function renderGrid() {
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML =
|
||||
'<p class="wfm-placeholder">No workflows found</p>';
|
||||
updateWfPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, filtered);
|
||||
updateWfPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Thumbnail / Card view
|
||||
// Thumbnail / Card view with pagination
|
||||
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
|
||||
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
|
||||
if (state.currentPage < 0) state.currentPage = 0;
|
||||
const start = state.currentPage * WF_PER_PAGE;
|
||||
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
|
||||
|
||||
updateWfPagination(totalPages);
|
||||
|
||||
grid.innerHTML = "";
|
||||
filtered.forEach((wf) => {
|
||||
pageItems.forEach((wf) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
card.dataset.filename = wf.filename;
|
||||
@@ -357,6 +371,23 @@ function renderGrid() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateWfPagination(totalPages) {
|
||||
const container = document.getElementById("wfm-workflow-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${state.currentPage + 1} / ${totalPages}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderTableView(grid, filtered) {
|
||||
const table = document.createElement("table");
|
||||
table.className = "wfm-table";
|
||||
@@ -1003,6 +1034,17 @@ function openDetailModal(wf) {
|
||||
async function loadWorkflows() {
|
||||
try {
|
||||
state.workflows = await fetchWorkflows();
|
||||
|
||||
// Clean up stale group entries (deleted workflows)
|
||||
const validFiles = new Set(state.workflows.map(w => w.filename));
|
||||
let groupsDirty = false;
|
||||
for (const g of Object.keys(groups.data)) {
|
||||
const before = groups.data[g].length;
|
||||
groups.data[g] = groups.data[g].filter(fn => validFiles.has(fn));
|
||||
if (groups.data[g].length !== before) groupsDirty = true;
|
||||
}
|
||||
if (groupsDirty) groups.save();
|
||||
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
} catch (err) {
|
||||
@@ -1059,6 +1101,7 @@ export function initWorkflowTab() {
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
state.searchText = searchInput.value;
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
@@ -1194,6 +1237,7 @@ export function initWorkflowTab() {
|
||||
// Toolbar: group filter dropdown
|
||||
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
|
||||
state.groupFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<nav class="wfm-tabs">
|
||||
<button class="wfm-tab active" data-tab="workflow">Workflow</button>
|
||||
<button class="wfm-tab" data-tab="nodes">Nodes</button>
|
||||
<button class="wfm-tab" data-tab="models">Models</button>
|
||||
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
|
||||
<button class="wfm-tab" data-tab="prompt">Prompt</button>
|
||||
<button class="wfm-tab" data-tab="settings">Settings</button>
|
||||
@@ -58,6 +59,7 @@
|
||||
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">⚙</button>
|
||||
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
|
||||
</div>
|
||||
<div id="wfm-workflow-pagination" class="wfm-pagination-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-workflow-area">
|
||||
@@ -154,6 +156,7 @@
|
||||
</div>
|
||||
<span id="wfm-nodes-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-nodes-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<div id="wfm-nodes-pagination" class="wfm-pagination-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -193,6 +196,72 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Models Tab -->
|
||||
<section id="wfm-tab-models" class="wfm-tab-content">
|
||||
<div class="wfm-toolbar">
|
||||
<!-- Row 1: Model type sub-tabs -->
|
||||
<div class="wfm-toolbar-row" style="gap:8px;">
|
||||
<div class="wfm-models-type-nav">
|
||||
<button class="wfm-models-type-btn active" data-model-type="checkpoint">Checkpoint</button>
|
||||
<button class="wfm-models-type-btn" data-model-type="lora">LoRA</button>
|
||||
<button class="wfm-models-type-btn" data-model-type="vae">VAE</button>
|
||||
<button class="wfm-models-type-btn" data-model-type="controlnet">ControlNet</button>
|
||||
<button class="wfm-models-type-btn" data-model-type="unet">UNET</button>
|
||||
<button class="wfm-models-type-btn" data-model-type="textencoder">TextEncoder</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: Search / filter toolbar -->
|
||||
<div id="wfm-models-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-filter-btn" id="wfm-models-fav-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
|
||||
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
|
||||
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
|
||||
<div class="wfm-view-btns">
|
||||
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">⊞</button>
|
||||
<button class="wfm-view-btn" data-models-view="card" title="Card">☰</button>
|
||||
<button class="wfm-view-btn" data-models-view="table" title="Table">≡</button>
|
||||
</div>
|
||||
<span id="wfm-models-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-models-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
|
||||
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
|
||||
<div class="wfm-badge-settings-wrap">
|
||||
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">⚙</button>
|
||||
<div id="wfm-models-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
|
||||
</div>
|
||||
<div id="wfm-models-pagination" class="wfm-pagination-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid + Side Panel -->
|
||||
<div class="wfm-workflow-area">
|
||||
<div id="wfm-models-grid" class="wfm-grid wfm-view-thumb">
|
||||
<p class="wfm-placeholder" id="wfm-models-placeholder">Click Refresh to load models...</p>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div id="wfm-models-side-panel" class="wfm-side-panel" style="display:none;">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
|
||||
<button id="wfm-models-panel-close" class="wfm-side-panel-close">×</button>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-models-side-tab-btn active" data-side-tab="info">Info</button>
|
||||
<button class="wfm-models-side-tab-btn" data-side-tab="group">Group</button>
|
||||
<button class="wfm-models-side-tab-btn" data-side-tab="civitai">CivitAI</button>
|
||||
</div>
|
||||
<!-- Info Tab -->
|
||||
<div id="wfm-models-side-info" class="wfm-models-side-content active" style="overflow-y:auto;padding:12px;">
|
||||
</div>
|
||||
<!-- Group Tab -->
|
||||
<div id="wfm-models-side-group" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
|
||||
</div>
|
||||
<!-- CivitAI Tab -->
|
||||
<div id="wfm-models-side-civitai" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GenerateUI Tab -->
|
||||
<section id="wfm-tab-generate" class="wfm-tab-content">
|
||||
<!-- Header Row -->
|
||||
@@ -301,10 +370,10 @@
|
||||
|
||||
<!-- Prompt Tab -->
|
||||
<section id="wfm-tab-prompt" class="wfm-tab-content">
|
||||
<div class="wfm-prompt-split">
|
||||
<!-- Left: Assistant -->
|
||||
<div class="wfm-prompt-split-left">
|
||||
<div class="wfm-prompt-split-header">Assistant</div>
|
||||
<div class="wfm-prompt-split wfm-prompt-split-3col">
|
||||
<!-- Col 1: AI Assistant -->
|
||||
<div class="wfm-prompt-split-col">
|
||||
<div class="wfm-prompt-split-header">AI Assistant</div>
|
||||
<div class="wfm-ollama-assistant">
|
||||
<div class="wfm-ollama-controls">
|
||||
<select id="wfm-ollama-model" class="wfm-select" style="flex:1;min-width:120px;">
|
||||
@@ -340,34 +409,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Presets -->
|
||||
<div class="wfm-prompt-split-right">
|
||||
<!-- Col 2: Preset Editor -->
|
||||
<div class="wfm-prompt-split-col">
|
||||
<div class="wfm-prompt-split-header">Presets</div>
|
||||
<div class="wfm-preset-panel">
|
||||
<div style="display:flex;gap:6px;margin-bottom:10px;align-items:center;">
|
||||
<select id="wfm-preset-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">New Preset (none selected)</option>
|
||||
</select>
|
||||
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Apply to GenerateUI</button>
|
||||
<button id="wfm-preset-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger">Delete</button>
|
||||
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">GenUI Set</button>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Preset Name</label>
|
||||
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="wfm-preset-pos" class="wfm-textarea" rows="4" placeholder="Positive prompt..."></textarea>
|
||||
<label>Category</label>
|
||||
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
|
||||
</div>
|
||||
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="wfm-preset-neg" class="wfm-textarea" rows="3" placeholder="Negative prompt..."></textarea>
|
||||
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
|
||||
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
|
||||
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px; margin-top:4px;">
|
||||
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">Select group...</option>
|
||||
</select>
|
||||
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
|
||||
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
|
||||
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 3: Preset Manager (list, favorites, groups) -->
|
||||
<div class="wfm-prompt-split-col">
|
||||
<div class="wfm-prompt-split-header">Preset Manager</div>
|
||||
<div class="wfm-preset-manager">
|
||||
<div class="wfm-pm-tabs">
|
||||
<button class="wfm-pm-tab active" data-pmtab="all">All</button>
|
||||
<button class="wfm-pm-tab" data-pmtab="favorites">★</button>
|
||||
<button class="wfm-pm-tab" data-pmtab="groups">Groups</button>
|
||||
</div>
|
||||
<div class="wfm-pm-search">
|
||||
<input type="text" id="wfm-pm-search-input" class="wfm-input" placeholder="Search presets...">
|
||||
</div>
|
||||
<div id="wfm-pm-list" class="wfm-pm-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +524,10 @@
|
||||
<li id="wfm-help-prompt-1">AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)</li>
|
||||
<li id="wfm-help-prompt-2">Apply AI responses to GenerateUI prompts</li>
|
||||
<li id="wfm-help-prompt-3">Translation: JA↔EN, ZH↔EN</li>
|
||||
<li id="wfm-help-prompt-4">Preset management: create, edit, delete, apply, clipboard copy (PP/NP)</li>
|
||||
<li id="wfm-help-prompt-4">Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)</li>
|
||||
<li id="wfm-help-prompt-5">Preset Manager: browse all presets, favorites, and group-based filtering</li>
|
||||
<li id="wfm-help-prompt-6">Group management: create groups, assign/remove presets, delete groups</li>
|
||||
<li id="wfm-help-prompt-7">GenUI Set: apply preset prompts directly to GenerateUI</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -436,11 +535,13 @@
|
||||
<h3>Settings Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-1">UI language and summary language</li>
|
||||
<li id="wfm-help-settings-2">Workflow data folder path</li>
|
||||
<li id="wfm-help-settings-3">ComfyUI connection URL and test</li>
|
||||
<li id="wfm-help-settings-4">Ollama URL, default model, and connection test</li>
|
||||
<li id="wfm-help-settings-5">Default workflow setting</li>
|
||||
<li id="wfm-help-settings-6">Eagle integration URL and auto-save toggle</li>
|
||||
<li id="wfm-help-settings-2">Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)</li>
|
||||
<li id="wfm-help-settings-3">Workflow data folder path</li>
|
||||
<li id="wfm-help-settings-4">ComfyUI connection URL and test</li>
|
||||
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
|
||||
<li id="wfm-help-settings-6">Default workflow setting</li>
|
||||
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
|
||||
<li id="wfm-help-settings-8">All sections are collapsible accordion panels</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -457,15 +558,31 @@
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-sidepanel-title">WF & Node Library (ComfyUI Side Panel)</h3>
|
||||
<h3>Models Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-1">Open from the WF & Node Library button in the ComfyUI toolbar</li>
|
||||
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder with sub-tab switching</li>
|
||||
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
|
||||
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
|
||||
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
|
||||
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
|
||||
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
|
||||
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-sidepanel-title">Workflow Studio Library (ComfyUI Side Panel)</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-1">Open from the Workflow Studio Library button in the ComfyUI toolbar</li>
|
||||
<li id="wfm-help-sidepanel-2">Workflows tab: browse favorite workflows, filter by model type, and filter by group</li>
|
||||
<li id="wfm-help-sidepanel-3">Nodes tab: browse favorite nodes, node sets, and node groups</li>
|
||||
<li id="wfm-help-sidepanel-4">Drag & drop workflows onto the canvas to load them</li>
|
||||
<li id="wfm-help-sidepanel-5">Drag & drop nodes/node sets onto the canvas to place them</li>
|
||||
<li id="wfm-help-sidepanel-6">Double-click items to load/place without dragging</li>
|
||||
<li id="wfm-help-sidepanel-7">Search within each sub-tab to quickly find items</li>
|
||||
<li id="wfm-help-sidepanel-4">Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs</li>
|
||||
<li id="wfm-help-sidepanel-5">Drag & drop workflows onto the canvas to load them</li>
|
||||
<li id="wfm-help-sidepanel-6">Drag & drop nodes/node sets onto the canvas to place them</li>
|
||||
<li id="wfm-help-sidepanel-7">Drag & drop prompt presets to create WFS_PromptText nodes with positive/negative prompts</li>
|
||||
<li id="wfm-help-sidepanel-8">Copy individual positive (P) or negative (N) prompts from sidebar items</li>
|
||||
<li id="wfm-help-sidepanel-9">Double-click items to load/place without dragging</li>
|
||||
<li id="wfm-help-sidepanel-10">Search within each sub-tab to quickly find items</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* WF & Node Library Sidebar Panel
|
||||
* Workflow Studio Library Sidebar Panel
|
||||
*
|
||||
* Fixed sidebar panel injected into ComfyUI DOM.
|
||||
* Two top-level tabs: Workflows / Nodes
|
||||
* Three top-level tabs: Workflows / Nodes / Prompts
|
||||
* Workflows sub-tabs: Favorites, Model Type, Groups
|
||||
* Nodes sub-tabs: Favorites, Sets, Groups
|
||||
* Prompts sub-tabs: Favorites, Categories
|
||||
* Items are draggable onto the canvas or click-to-place.
|
||||
*/
|
||||
console.log("[WFM] node_sets_menu.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] node_sets_menu.js: app imported");
|
||||
|
||||
export const NODE_SETS_TOOLTIP = "WF & Node Library \u2013 Browse & drag workflows/nodes onto canvas";
|
||||
export const NODE_SETS_TOOLTIP = "Workflow Studio Library \u2013 Browse & drag workflows/nodes/prompts onto canvas";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -20,21 +21,29 @@ export const NODE_SETS_TOOLTIP = "WF & Node Library \u2013 Browse & drag workflo
|
||||
const state = {
|
||||
visible: false,
|
||||
// Top-level tab
|
||||
topTab: "workflows", // "workflows" | "nodes"
|
||||
topTab: "workflows", // "workflows" | "nodes" | "prompts"
|
||||
// Node sub-tabs (existing)
|
||||
activeTab: "favorites", // favorites | sets | groups
|
||||
activeTab: "all", // all | favorites | groups
|
||||
activeTab2: null, // 2nd row: "sets" (or null when 1st row active)
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
metadata: {},
|
||||
loaded: false,
|
||||
// Workflow sub-tabs (new)
|
||||
wfSubTab: "wf-favorites", // "wf-favorites" | "wf-modeltype" | "wf-groups"
|
||||
wfSubTab: "wf-all", // "wf-all" | "wf-favorites" | "wf-groups"
|
||||
wfSubTab2: null, // 2nd row: "wf-modeltype" (or null when 1st row active)
|
||||
wfList: [], // full workflow array from API
|
||||
wfFavorites: [], // metadata.favorite === true
|
||||
wfModelTypes: [], // unique model type strings
|
||||
wfGroups: {}, // from localStorage "wfm_groups"
|
||||
wfLoaded: false,
|
||||
// Prompts sub-tabs
|
||||
promptSubTab: "prompt-all", // "prompt-all" | "prompt-favorites" | "prompt-categories"
|
||||
promptList: [], // full prompt array from API
|
||||
promptFavorites: [], // favorite === true
|
||||
promptCategories: [], // unique category strings
|
||||
promptLoaded: false,
|
||||
// Shared
|
||||
searchText: "",
|
||||
};
|
||||
@@ -83,6 +92,31 @@ const loadData = async () => {
|
||||
state.loaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API – Prompts
|
||||
// ============================================
|
||||
|
||||
const fetchPrompts = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/prompts");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const loadPromptData = async () => {
|
||||
const prompts = await fetchPrompts();
|
||||
state.promptList = prompts;
|
||||
state.promptFavorites = prompts.filter(p => p.favorite);
|
||||
|
||||
const catSet = new Set();
|
||||
for (const p of prompts) {
|
||||
const c = (p.category || "").trim();
|
||||
if (c) catSet.add(c);
|
||||
}
|
||||
state.promptCategories = [...catSet].sort();
|
||||
state.promptLoaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API – Workflows
|
||||
// ============================================
|
||||
@@ -118,11 +152,23 @@ const loadWfData = async () => {
|
||||
}
|
||||
state.wfModelTypes = [...typeSet].sort();
|
||||
|
||||
// Load groups from localStorage
|
||||
// Load groups from localStorage and clean up stale entries
|
||||
try {
|
||||
state.wfGroups = JSON.parse(localStorage.getItem("wfm_groups") || "{}");
|
||||
} catch { state.wfGroups = {}; }
|
||||
|
||||
// Remove filenames that no longer exist from groups
|
||||
const validFiles = new Set(workflows.map(w => w.filename));
|
||||
let groupsDirty = false;
|
||||
for (const groupName of Object.keys(state.wfGroups)) {
|
||||
const before = state.wfGroups[groupName].length;
|
||||
state.wfGroups[groupName] = state.wfGroups[groupName].filter(fn => validFiles.has(fn));
|
||||
if (state.wfGroups[groupName].length !== before) groupsDirty = true;
|
||||
}
|
||||
if (groupsDirty) {
|
||||
localStorage.setItem("wfm_groups", JSON.stringify(state.wfGroups));
|
||||
}
|
||||
|
||||
state.wfLoaded = true;
|
||||
};
|
||||
|
||||
@@ -198,6 +244,37 @@ const placeNodeSet = (nodeSet, pos) => {
|
||||
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Prompt → Text node placement
|
||||
// ============================================
|
||||
|
||||
const placePromptNode = (posText, negText, promptName, pos) => {
|
||||
const graph = app.graph;
|
||||
if (!graph) return;
|
||||
|
||||
const node = LiteGraph.createNode("WFS_PromptText");
|
||||
if (!node) {
|
||||
showToast("WFS_PromptText node not found. Please restart ComfyUI.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
node.pos = pos || [100, 100];
|
||||
if (promptName) node.title = promptName;
|
||||
|
||||
graph.add(node);
|
||||
|
||||
// Set positive and negative widget values
|
||||
if (node.widgets) {
|
||||
const posWidget = node.widgets.find(w => w.name === "positive");
|
||||
if (posWidget) posWidget.value = posText || "";
|
||||
const negWidget = node.widgets.find(w => w.name === "negative");
|
||||
if (negWidget) negWidget.value = negText || "";
|
||||
}
|
||||
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: ${promptName || "Prompt"}`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Workflow canvas loading
|
||||
// ============================================
|
||||
@@ -233,13 +310,24 @@ const installCanvasDropHandler = () => {
|
||||
|
||||
canvasEl.addEventListener("dragover", (e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-wfm-node") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-workflow")) {
|
||||
e.dataTransfer.types.includes("application/x-wfm-workflow") ||
|
||||
e.dataTransfer.types.includes("application/x-wfm-prompt")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener("drop", (e) => {
|
||||
// Handle prompt drop
|
||||
const promptRaw = e.dataTransfer.getData("application/x-wfm-prompt");
|
||||
if (promptRaw) {
|
||||
e.preventDefault();
|
||||
const data = JSON.parse(promptRaw);
|
||||
const pos = getCanvasDropPos(e);
|
||||
placePromptNode(data.text, data.negText, data.name, pos);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle workflow drop
|
||||
const wfRaw = e.dataTransfer.getData("application/x-wfm-workflow");
|
||||
if (wfRaw) {
|
||||
@@ -281,15 +369,17 @@ const createPanel = () => {
|
||||
panel.id = PANEL_ID;
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">WF & Node Library</span>
|
||||
<span class="wfm-nlp-title">Workflow Studio Library</span>
|
||||
<button class="wfm-nlp-refresh" title="Refresh">↻</button>
|
||||
<button class="wfm-nlp-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-tabs">
|
||||
<button class="wfm-nlp-tab wfm-nlp-top-tab active" data-toptab="workflows">Workflows</button>
|
||||
<button class="wfm-nlp-tab wfm-nlp-top-tab" data-toptab="nodes">Nodes</button>
|
||||
<button class="wfm-nlp-tab wfm-nlp-top-tab" data-toptab="prompts">Prompts</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-subtabs"></div>
|
||||
<div class="wfm-nlp-subtabs wfm-nlp-subtabs-row2"></div>
|
||||
<div class="wfm-nlp-search">
|
||||
<input type="text" class="wfm-nlp-search-input" placeholder="Search workflows...">
|
||||
</div>
|
||||
@@ -309,7 +399,8 @@ const createPanel = () => {
|
||||
const searchInput = panel.querySelector(".wfm-nlp-search-input");
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
searchInput.placeholder = tab === "workflows" ? "Search workflows..." : "Search nodes...";
|
||||
const placeholders = { workflows: "Search workflows...", nodes: "Search nodes...", prompts: "Search prompts..." };
|
||||
searchInput.placeholder = placeholders[tab] || "Search...";
|
||||
}
|
||||
panel.querySelectorAll(".wfm-nlp-top-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
@@ -319,6 +410,8 @@ const createPanel = () => {
|
||||
await loadWfData();
|
||||
} else if (tab === "nodes" && !state.loaded) {
|
||||
await loadData();
|
||||
} else if (tab === "prompts" && !state.promptLoaded) {
|
||||
await loadPromptData();
|
||||
}
|
||||
|
||||
rebuildSubTabs();
|
||||
@@ -339,6 +432,8 @@ const createPanel = () => {
|
||||
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
|
||||
if (state.topTab === "workflows") {
|
||||
await loadWfData();
|
||||
} else if (state.topTab === "prompts") {
|
||||
await loadPromptData();
|
||||
} else {
|
||||
await loadData();
|
||||
}
|
||||
@@ -356,48 +451,118 @@ const createPanel = () => {
|
||||
// ============================================
|
||||
|
||||
const rebuildSubTabs = () => {
|
||||
const container = panelEl?.querySelector(".wfm-nlp-subtabs");
|
||||
if (!container) return;
|
||||
const row1 = panelEl?.querySelector(".wfm-nlp-subtabs:not(.wfm-nlp-subtabs-row2)");
|
||||
const row2 = panelEl?.querySelector(".wfm-nlp-subtabs-row2");
|
||||
if (!row1 || !row2) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
row1.innerHTML = "";
|
||||
row2.innerHTML = "";
|
||||
|
||||
if (state.topTab === "workflows") {
|
||||
const tabs = [
|
||||
const updateAllActive = () => {
|
||||
row1.querySelectorAll(".wfm-nlp-sub-tab").forEach(b => b.classList.remove("active"));
|
||||
row2.querySelectorAll(".wfm-nlp-sub-tab").forEach(b => b.classList.remove("active"));
|
||||
};
|
||||
|
||||
if (state.topTab === "prompts") {
|
||||
const row1Tabs = [
|
||||
{ key: "prompt-all", label: "All" },
|
||||
{ key: "prompt-favorites", label: "\u2605 Favorites" },
|
||||
{ key: "prompt-categories", label: "\ud83d\udcc1 Categories" },
|
||||
];
|
||||
|
||||
const activeKey = state.promptSubTab;
|
||||
|
||||
for (const t of row1Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
|
||||
btn.dataset.subtab = t.key;
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.promptSubTab = t.key;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
row1.appendChild(btn);
|
||||
}
|
||||
// No row2 tabs for prompts
|
||||
} else if (state.topTab === "workflows") {
|
||||
const row1Tabs = [
|
||||
{ key: "wf-all", label: "Workflows" },
|
||||
{ key: "wf-favorites", label: "\u2605 Favorites" },
|
||||
{ key: "wf-modeltype", label: "\u25a6 Model Type" },
|
||||
{ key: "wf-groups", label: "\ud83d\udcc1 Groups" },
|
||||
];
|
||||
for (const t of tabs) {
|
||||
const row2Tabs = [
|
||||
{ key: "wf-modeltype", label: "\u25a6 Model Type" },
|
||||
];
|
||||
|
||||
const activeKey = state.wfSubTab2 || state.wfSubTab;
|
||||
|
||||
for (const t of row1Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (state.wfSubTab === t.key ? " active" : "");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
|
||||
btn.dataset.subtab = t.key;
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.wfSubTab = t.key;
|
||||
container.querySelectorAll(".wfm-nlp-sub-tab").forEach(b => b.classList.remove("active"));
|
||||
state.wfSubTab2 = null;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
container.appendChild(btn);
|
||||
row1.appendChild(btn);
|
||||
}
|
||||
for (const t of row2Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
|
||||
btn.dataset.subtab = t.key;
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.wfSubTab2 = t.key;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
row2.appendChild(btn);
|
||||
}
|
||||
} else {
|
||||
const tabs = [
|
||||
const row1Tabs = [
|
||||
{ key: "all", label: "Nodes" },
|
||||
{ key: "favorites", label: "\u2733 Favorites" },
|
||||
{ key: "sets", label: "\u2630 Sets" },
|
||||
{ key: "groups", label: "\ud83d\udcc1 Groups" },
|
||||
];
|
||||
for (const t of tabs) {
|
||||
const row2Tabs = [
|
||||
{ key: "sets", label: "\u2630 Sets" },
|
||||
];
|
||||
|
||||
const activeKey = state.activeTab2 || state.activeTab;
|
||||
|
||||
for (const t of row1Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (state.activeTab === t.key ? " active" : "");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
|
||||
btn.dataset.subtab = t.key;
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab = t.key;
|
||||
container.querySelectorAll(".wfm-nlp-sub-tab").forEach(b => b.classList.remove("active"));
|
||||
state.activeTab2 = null;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
container.appendChild(btn);
|
||||
row1.appendChild(btn);
|
||||
}
|
||||
for (const t of row2Tabs) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-nlp-tab wfm-nlp-sub-tab" + (activeKey === t.key ? " active" : "");
|
||||
btn.dataset.subtab = t.key;
|
||||
btn.textContent = t.label;
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab2 = t.key;
|
||||
updateAllActive();
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
row2.appendChild(btn);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -410,12 +575,24 @@ const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
if (state.topTab === "workflows") {
|
||||
if (state.topTab === "prompts") {
|
||||
if (!state.promptLoaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
switch (state.promptSubTab) {
|
||||
case "prompt-all": renderPromptAll(content); break;
|
||||
case "prompt-favorites": renderPromptFavorites(content); break;
|
||||
case "prompt-categories": renderPromptCategories(content); break;
|
||||
}
|
||||
} else if (state.topTab === "workflows") {
|
||||
if (!state.wfLoaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
switch (state.wfSubTab) {
|
||||
const key = state.wfSubTab2 || state.wfSubTab;
|
||||
switch (key) {
|
||||
case "wf-all": renderWfAll(content); break;
|
||||
case "wf-favorites": renderWfFavorites(content); break;
|
||||
case "wf-modeltype": renderWfModelType(content); break;
|
||||
case "wf-groups": renderWfGroups(content); break;
|
||||
@@ -425,7 +602,9 @@ const renderContent = () => {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
switch (state.activeTab) {
|
||||
const key = state.activeTab2 || state.activeTab;
|
||||
switch (key) {
|
||||
case "all": renderAllNodes(content); break;
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
@@ -447,12 +626,16 @@ const createDraggableWfItem = (wf) => {
|
||||
const displayName = wf.filename.replace(/\.json$/i, "");
|
||||
const types = getWfModelTypes(wf);
|
||||
const badge = types.length ? types.join(", ") : "";
|
||||
const fmt = wf.analysis?.format || "";
|
||||
const fmtBadge = (fmt === "api" || fmt === "app")
|
||||
? `<span class="wfm-nlp-fmt-badge wfm-nlp-fmt-${fmt}">${fmt.toUpperCase()}</span>`
|
||||
: "";
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.draggable = true;
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-label">${esc(displayName)}</div>
|
||||
<div class="wfm-nlp-item-label">${fmtBadge}${esc(displayName)}</div>
|
||||
${badge ? `<div class="wfm-nlp-item-sub">${esc(badge)}</div>` : ""}
|
||||
`;
|
||||
|
||||
@@ -480,6 +663,20 @@ const matchesWfSearch = (wf) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderWfAll = (container) => {
|
||||
let items = state.wfList.filter(matchesWfSearch);
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">No matches</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const wf of items) {
|
||||
container.appendChild(createDraggableWfItem(wf));
|
||||
}
|
||||
};
|
||||
|
||||
const renderWfFavorites = (container) => {
|
||||
let items = state.wfFavorites.filter(matchesWfSearch);
|
||||
|
||||
@@ -587,13 +784,6 @@ const renderWfGroups = (container) => {
|
||||
const wf = state.wfList.find(w => w.filename === fn);
|
||||
if (wf) {
|
||||
list.appendChild(createDraggableWfItem(wf));
|
||||
} else {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.style.opacity = "0.4";
|
||||
el.innerHTML = `<div class="wfm-nlp-item-label">${esc(fn.replace(/\.json$/i, ""))}</div>
|
||||
<div class="wfm-nlp-item-sub">Not found</div>`;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
section.appendChild(list);
|
||||
@@ -605,10 +795,215 @@ const renderWfGroups = (container) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render – Prompt sub-tabs
|
||||
// ============================================
|
||||
|
||||
const matchesPromptSearch = (p) => {
|
||||
if (!state.searchText) return true;
|
||||
const s = state.searchText;
|
||||
if ((p.name || "").toLowerCase().includes(s)) return true;
|
||||
if ((p.text || "").toLowerCase().includes(s)) return true;
|
||||
if ((p.category || "").toLowerCase().includes(s)) return true;
|
||||
if ((p.tags || []).some(t => t.toLowerCase().includes(s))) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const createDraggablePromptItem = (prompt) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item wfm-nlp-prompt-item";
|
||||
el.draggable = true;
|
||||
|
||||
const previewText = (prompt.text || "").length > 60
|
||||
? prompt.text.substring(0, 60) + "..."
|
||||
: (prompt.text || "");
|
||||
|
||||
const hasNeg = (prompt.negText || "").trim();
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-row">
|
||||
<div class="wfm-nlp-item-body">
|
||||
<div class="wfm-nlp-item-label">${prompt.favorite ? '<span style="color:#ffd700;margin-right:3px;">\u2605</span>' : ""}${esc(prompt.name)}</div>
|
||||
<div class="wfm-nlp-item-sub">${esc(previewText)}</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-copy-btns">
|
||||
<button class="wfm-nlp-copy-btn wfm-nlp-copy-pos" title="Copy Positive">P</button>
|
||||
${hasNeg ? '<button class="wfm-nlp-copy-btn wfm-nlp-copy-neg" title="Copy Negative">N</button>' : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Copy positive
|
||||
el.querySelector(".wfm-nlp-copy-pos").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(prompt.text || "").then(() => {
|
||||
showToast("Positive copied", "success");
|
||||
}).catch(() => {
|
||||
showToast("Failed to copy", "error");
|
||||
});
|
||||
});
|
||||
|
||||
// Copy negative
|
||||
if (hasNeg) {
|
||||
el.querySelector(".wfm-nlp-copy-neg").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(prompt.negText || "").then(() => {
|
||||
showToast("Negative copied", "success");
|
||||
}).catch(() => {
|
||||
showToast("Failed to copy", "error");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-prompt",
|
||||
JSON.stringify({ id: prompt.id, text: prompt.text, negText: prompt.negText || "", name: prompt.name }));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
el.addEventListener("dragend", () => el.classList.remove("dragging"));
|
||||
|
||||
// Double-click to place on canvas
|
||||
el.addEventListener("dblclick", () => placePromptNode(prompt.text, prompt.negText || "", prompt.name));
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
const renderPromptAll = (container) => {
|
||||
let items = state.promptList.filter(matchesPromptSearch);
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.promptList.length === 0
|
||||
? 'No prompts yet.<br><small>Create prompts in Workflow Studio</small>'
|
||||
: 'No matches'}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const p of items) {
|
||||
container.appendChild(createDraggablePromptItem(p));
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptFavorites = (container) => {
|
||||
let items = state.promptFavorites.filter(matchesPromptSearch);
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.promptFavorites.length === 0
|
||||
? 'No favorite prompts.<br><small>Star prompts to add them here</small>'
|
||||
: 'No matches'}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const p of items) {
|
||||
container.appendChild(createDraggablePromptItem(p));
|
||||
}
|
||||
};
|
||||
|
||||
const renderPromptCategories = (container) => {
|
||||
container.innerHTML = "";
|
||||
|
||||
if (state.promptCategories.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No categories.<br><small>Set categories when creating prompts</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cat of state.promptCategories) {
|
||||
const prompts = state.promptList
|
||||
.filter(p => (p.category || "").trim() === cat)
|
||||
.filter(matchesPromptSearch);
|
||||
|
||||
if (state.searchText && prompts.length === 0) continue;
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header collapsed";
|
||||
header.innerHTML = `<span>${esc(cat)}</span> <span class="wfm-nlp-badge">${prompts.length}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
const list = section.querySelector(".wfm-nlp-group-list");
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
section.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "wfm-nlp-group-list";
|
||||
list.style.display = "none";
|
||||
for (const p of prompts) {
|
||||
list.appendChild(createDraggablePromptItem(p));
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// Uncategorized prompts
|
||||
const uncategorized = state.promptList
|
||||
.filter(p => !(p.category || "").trim())
|
||||
.filter(matchesPromptSearch);
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header collapsed";
|
||||
header.innerHTML = `<span>Uncategorized</span> <span class="wfm-nlp-badge">${uncategorized.length}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
const list = section.querySelector(".wfm-nlp-group-list");
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
section.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "wfm-nlp-group-list";
|
||||
list.style.display = "none";
|
||||
for (const p of uncategorized) {
|
||||
list.appendChild(createDraggablePromptItem(p));
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
if (container.children.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">No matches</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render – Node sub-tabs (existing)
|
||||
// ============================================
|
||||
|
||||
const renderAllNodes = (container) => {
|
||||
const registered = typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types : {};
|
||||
let nodeNames = Object.keys(registered).sort();
|
||||
|
||||
if (state.searchText) {
|
||||
nodeNames = nodeNames.filter(n => n.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
if (nodeNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">No matches</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const name of nodeNames) {
|
||||
const el = createDraggableItem(name, "single", { classType: name });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(name));
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFavorites = (container) => {
|
||||
let items = state.favorites;
|
||||
if (state.searchText) {
|
||||
@@ -753,6 +1148,8 @@ export const togglePanel = async () => {
|
||||
await loadWfData();
|
||||
} else if (state.topTab === "nodes" && !state.loaded) {
|
||||
await loadData();
|
||||
} else if (state.topTab === "prompts" && !state.promptLoaded) {
|
||||
await loadPromptData();
|
||||
}
|
||||
renderContent();
|
||||
}
|
||||
@@ -980,6 +1377,9 @@ const injectStyles = () => {
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-subtabs-row2 {
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
}
|
||||
.wfm-nlp-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
@@ -1035,6 +1435,24 @@ const injectStyles = () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-nlp-fmt-badge {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wfm-nlp-fmt-api {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
.wfm-nlp-fmt-app {
|
||||
background: #e67e22;
|
||||
color: #fff;
|
||||
}
|
||||
.wfm-nlp-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
@@ -1086,6 +1504,47 @@ const injectStyles = () => {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wfm-nlp-refresh:hover { opacity: 1; }
|
||||
.wfm-nlp-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.wfm-nlp-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wfm-nlp-copy-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.wfm-nlp-prompt-item:hover .wfm-nlp-copy-btns {
|
||||
opacity: 1;
|
||||
}
|
||||
.wfm-nlp-copy-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #555);
|
||||
color: var(--descrip-text, #888);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.wfm-nlp-copy-pos:hover {
|
||||
background: rgba(46,213,115,0.25);
|
||||
color: #2ed573;
|
||||
border-color: #2ed573;
|
||||
}
|
||||
.wfm-nlp-copy-neg:hover {
|
||||
background: rgba(255,71,87,0.25);
|
||||
color: #ff4757;
|
||||
border-color: #ff4757;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
@@ -168,8 +168,101 @@ const captureCanvasSnapshot = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const showSaveDialog = (defaultName) => {
|
||||
return new Promise((resolve) => {
|
||||
// Remove existing dialog if any
|
||||
const existing = document.getElementById("wfm-save-dialog-overlay");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "wfm-save-dialog-overlay";
|
||||
Object.assign(overlay.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: "99999",
|
||||
});
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
Object.assign(dialog.style, {
|
||||
background: "var(--comfy-menu-bg, #2a2a2a)",
|
||||
border: "1px solid var(--border-color, #4e4e4e)",
|
||||
borderRadius: "8px",
|
||||
padding: "16px 20px",
|
||||
minWidth: "360px",
|
||||
maxWidth: "480px",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
|
||||
color: "var(--input-text, #ddd)",
|
||||
fontFamily: "sans-serif",
|
||||
});
|
||||
|
||||
const stem = defaultName.replace(/\.json$/, "");
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div style="font-size:14px;font-weight:bold;margin-bottom:12px;">Save Workflow</div>
|
||||
<label style="font-size:12px;color:var(--descrip-text,#999);display:block;margin-bottom:4px;">Filename</label>
|
||||
<input type="text" id="wfm-save-dialog-input" value="${stem}" style="
|
||||
width:100%;padding:8px 10px;font-size:13px;
|
||||
background:var(--comfy-input-bg,#1a1a1a);
|
||||
border:1px solid var(--border-color,#4e4e4e);
|
||||
border-radius:4px;color:var(--input-text,#ddd);
|
||||
outline:none;box-sizing:border-box;
|
||||
" />
|
||||
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
|
||||
<button id="wfm-save-dialog-cancel" style="
|
||||
padding:6px 16px;font-size:12px;border-radius:4px;cursor:pointer;
|
||||
background:transparent;border:1px solid var(--border-color,#4e4e4e);
|
||||
color:var(--input-text,#ddd);
|
||||
">Cancel</button>
|
||||
<button id="wfm-save-dialog-ok" style="
|
||||
padding:6px 16px;font-size:12px;border-radius:4px;cursor:pointer;
|
||||
background:var(--p-button-background,#4a9eff);border:none;
|
||||
color:#fff;font-weight:bold;
|
||||
">Save</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = dialog.querySelector("#wfm-save-dialog-input");
|
||||
const okBtn = dialog.querySelector("#wfm-save-dialog-ok");
|
||||
const cancelBtn = dialog.querySelector("#wfm-save-dialog-cancel");
|
||||
|
||||
const cleanup = () => overlay.remove();
|
||||
|
||||
const confirm = () => {
|
||||
const val = input.value.trim();
|
||||
cleanup();
|
||||
if (val) {
|
||||
resolve(val.endsWith(".json") ? val : val + ".json");
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => { cleanup(); resolve(null); };
|
||||
|
||||
okBtn.addEventListener("click", confirm);
|
||||
cancelBtn.addEventListener("click", cancel);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) cancel(); });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); confirm(); }
|
||||
if (e.key === "Escape") cancel();
|
||||
});
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
};
|
||||
|
||||
const saveCanvasToWorkflowStudio = async () => {
|
||||
const filename = getWorkflowFilename();
|
||||
const defaultFilename = getWorkflowFilename();
|
||||
const filename = await showSaveDialog(defaultFilename);
|
||||
if (!filename) return; // cancelled
|
||||
|
||||
try {
|
||||
showNotification("Capturing workflow image...", "info");
|
||||
@@ -512,51 +605,50 @@ app.registerExtension({
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
|
||||
nsButtons.forEach((button) => {
|
||||
button.classList.add("wfm-node-sets-btn");
|
||||
button.innerHTML = getNodeSetsIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
const applyButtonIcon = (button, className, getIconFn, extraStyles) => {
|
||||
if (button.querySelector("svg")) return;
|
||||
button.classList.add(className);
|
||||
button.innerHTML = getIconFn();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
if (extraStyles) Object.assign(button.style, extraStyles);
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
|
||||
const buttonConfigs = [
|
||||
{ tooltip: BUTTON_TOOLTIP, className: "wfm-top-menu-button", getIcon: getWfmIcon, styles: { backgroundColor: "var(--primary-bg)" } },
|
||||
{ tooltip: SNAPSHOT_TOOLTIP, className: "wfm-snapshot-button", getIcon: getSnapshotIcon, styles: null },
|
||||
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
|
||||
];
|
||||
|
||||
const replaceAllIcons = () => {
|
||||
for (const cfg of buttonConfigs) {
|
||||
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
|
||||
applyButtonIcon(btn, cfg.className, cfg.getIcon, cfg.styles);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const waitAndObserve = () => {
|
||||
const firstBtn = document.querySelector(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
if (!firstBtn) {
|
||||
requestAnimationFrame(waitAndObserve);
|
||||
return;
|
||||
}
|
||||
replaceAllIcons();
|
||||
|
||||
const container = firstBtn.closest(".actionbar-container") || firstBtn.parentElement;
|
||||
if (container) {
|
||||
new MutationObserver(() => replaceAllIcons()).observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(waitAndObserve);
|
||||
},
|
||||
});
|
||||
|
||||