Compare commits
@@ -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完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
|
||||
@@ -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,9 +17,17 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| ComfyUI Integration |
|
||||
|:---:|
|
||||
|  |
|
||||
| Nodes Tab | Models Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Gallery Tab | Help & Support Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,10 +36,10 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
### Workflow Tab
|
||||
- **Thumbnail / Card / Table views** — switch between view modes to browse your workflow library
|
||||
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
|
||||
- **Tag-based filtering** — filter by base model (SD1.5, SDXL, etc.) and custom groups
|
||||
- **Badge filtering** — filter by user-defined badges (free labels you assign to each workflow)
|
||||
- **Search** — full-text search across workflow names and metadata
|
||||
- **Side panel tabs** — Thumbnail preview, JSON viewer with syntax highlighting, and Group management
|
||||
- **Batch analysis** — auto-detect checkpoint, model type, prompt, and I/O node counts
|
||||
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
|
||||
- **AI summary** — generate workflow descriptions using Ollama
|
||||
- **Import / Export** — import workflows from files or clipboard, open in ComfyUI directly
|
||||
- **Default view setting** — persist your preferred view mode (Thumbnail / Card / Table)
|
||||
@@ -42,29 +50,94 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
|
||||
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
|
||||
|
||||
### GenerateUI Tab
|
||||
- **Auto-generated parameter UI** — prompt, model, sampler, image, and other settings extracted from the workflow
|
||||
### GenerateUI Tab (v0.2.4)
|
||||
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
|
||||
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
|
||||
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; Raw JSON on the right
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
|
||||
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
|
||||
- **Seed control** — randomize, lock, or manually set seeds
|
||||
- **Raw JSON editor** — view and edit the API-format JSON with syntax highlighting
|
||||
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
|
||||
- **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)
|
||||
- **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
|
||||
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
|
||||
- **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
|
||||
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
|
||||
- **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
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
|
||||
- **Language** — English / Japanese / Chinese
|
||||
|
||||
### Gallery Tab (v0.2.7)
|
||||
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
|
||||
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
|
||||
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch tag/group/favorite operations
|
||||
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
|
||||
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
|
||||
- **Favorites** — star images inline without reopening the detail panel
|
||||
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **Performance** — with folder-level mtime cache (30s TTL) for fast incremental refresh
|
||||
|
||||
### Nodes Tab (v0.1.7)
|
||||
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
|
||||
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
|
||||
- **Package badges** — color-coded badges generated from package names
|
||||
- **Node detail panel** — view I/O specifications, edit tags, manage groups
|
||||
- **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
|
||||
|
||||
### Models Tab (v0.2.3)
|
||||
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding) 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
|
||||
- **User-defined badges** — assign free-label badges to models; badge colors shared with the Workflow tab palette
|
||||
- **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
|
||||
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
|
||||
- **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.2.5)
|
||||
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
|
||||
- **Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
|
||||
- **Category sub-tab** — dropdown to filter nodes by top-level category
|
||||
- **Package sub-tab** — dropdown to filter nodes by custom node package name
|
||||
- **Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs)
|
||||
- **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
|
||||
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
|
||||
|
||||
### Help & Support Tab (v0.1.3)
|
||||
- **Feature list** — overview of all features organized by tab
|
||||
- **Tips** — quick tips for drag & drop import, favorites, and default workflow
|
||||
- **Support links** — GitHub repository and Ko-fi donation page
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
@@ -77,7 +150,7 @@ Search for **Workflow Studio** in ComfyUI Manager and install.
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/your-username/ComfyUI-Workflow-Studio.git
|
||||
git clone https://github.com/ketle-man/ComfyUI-Workflow-Studio.git
|
||||
```
|
||||
|
||||
Restart ComfyUI after installation.
|
||||
@@ -135,6 +208,94 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.2.8
|
||||
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
|
||||
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
|
||||
|
||||
### v0.2.7
|
||||
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
|
||||
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
|
||||
- **Settings 2-column layout** — Theme panel fixed on the right (50% width, sticky); all other settings flow continuously on the left with no visual gap
|
||||
- **Gallery output directory setting** — configure the output folder scanned by Gallery tab from Settings
|
||||
- **Help tab** — Gallery tab description card added
|
||||
|
||||
### v0.2.5
|
||||
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
|
||||
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
|
||||
- **Sidebar TextEncoder (By Type)** — fixed TextEncoder not appearing in Models → By Type; now uses `DualCLIPLoader` → `CLIPLoader` fallback to retrieve `clip_name1` list
|
||||
- **Hide `.index.json`** — excluded `.index.json` from both the Workflow tab and the Workflow Studio Library side panel
|
||||
|
||||
### v0.2.4
|
||||
- **GenerateUI tab redesign** — reorganized from 5 tabs (Prompt/Image/Model/Settings/Raw JSON) to 3 tabs (Input/Model/Settings); each tab shows a Raw JSON column (540px) for instant JSON preview and direct editing from any tab
|
||||
- **Input tab** — Prompt and Image stacked vertically in the left column
|
||||
- **Settings tab** — KSampler and LatentImage stacked vertically in the left column
|
||||
- **Raw JSON tab removed** — Raw JSON is now always visible as a right-column panel within each tab
|
||||
|
||||
### v0.2.3
|
||||
- **Badge system unified** — Workflow and Models tabs now share a single badge palette (`⚙ Badge` button); workflow auto-analysis model type system removed in favor of user-defined free-label badges
|
||||
- **GenUI Model button** — apply any model from the Models tab directly to the corresponding node in GenerateUI's current workflow (available in detail modal and side panel; Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)
|
||||
- **Sidebar Models tab** — added All / ★ Favorites / Groups / By Type sub-tabs to the Models section of Workflow Studio Library
|
||||
- **Sidebar All views** — ★ star displayed for favorite items in Workflows and Nodes All sub-tabs
|
||||
- **Sidebar panel width** — widened from 280px to 310px
|
||||
|
||||
### 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
|
||||
- **Workflow drag & drop** — drag workflows from the side panel onto the canvas to load them instantly
|
||||
- **Help tab update** — added Nodes Tab and WF & Node Library sections to the feature list
|
||||
|
||||
### v0.1.7
|
||||
- **Nodes tab** — browse, search, and filter all installed ComfyUI nodes with Card/Table views, pagination (50 nodes/page), package color badges, and node detail side panel with I/O specs
|
||||
- **Node Sets** — save selected nodes + connections from ComfyUI canvas as reusable sets via right-click context menu
|
||||
- **Node Library side panel** — accessible from ComfyUI top bar with Favorites, Sets, and Groups tabs, drag & drop placement
|
||||
- **3 top bar buttons** — Workflow Studio, Canvas Snapshot, and Node Library buttons in ComfyUI's action bar
|
||||
|
||||
### v0.1.6
|
||||
- **Security fix** — path traversal vulnerability in `workflow_service.py` (reported via ComfyUI-Manager PR review)
|
||||
|
||||
### v0.1.5
|
||||
- **Theme system** — 13 built-in themes selectable from Settings tab with instant preview (Deep Ocean Dark, Pop & Vibrant, Light Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro 8-bit, Pastel Dream, Brutalism, Earthy, Material UI, Monotone + Accent, Corporate Trust)
|
||||
- Theme preference persisted in localStorage and restored on page load (no flash)
|
||||
- Special CSS effects per theme: neon glow (Cyberpunk), backdrop blur (Glassmorphism), dual shadow (Neumorphism), pixel borders (Retro/Brutalism)
|
||||
|
||||
### v0.1.4
|
||||
- **App format support** — detect `.app.json` workflows (ComfyUI App mode), show "App Format" badge, block loading in GenerateUI with guidance message
|
||||
- **Preset clipboard copy** — added PP Copy / NP Copy buttons to copy positive/negative prompts to clipboard
|
||||
- **Analysis bugfix** — fixed workflow analysis crash when `widgets_values` contains non-string values (e.g. integers)
|
||||
|
||||
### v0.1.3
|
||||
- Added **Help & Support tab** — feature list, tips, and support links (GitHub, Ko-fi)
|
||||
- Multi-language support (EN/JA/ZH) for all help content
|
||||
|
||||
### v0.1.2
|
||||
- Added **Canvas Snapshot** button to ComfyUI top bar — capture workflow canvas as PNG thumbnail with embedded workflow metadata
|
||||
- Added **Thumbnail tab** to the workflow side panel for quick visual preview
|
||||
@@ -154,18 +315,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
|
||||
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
|
||||
├── static/
|
||||
│ ├── favicon.svg # Browser tab icon (W+S Wave)
|
||||
│ ├── css/main.css # Styles
|
||||
@@ -176,13 +346,16 @@ ComfyUI-Workflow-Studio/
|
||||
│ ├── prompt-tab.js # AI assistant & presets
|
||||
│ ├── 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
|
||||
└── data/ # Metadata & settings storage
|
||||
│ ├── top_menu_extension.js # ComfyUI menu bar integration
|
||||
│ └── node_sets_menu.js # Workflow Studio Library side panel
|
||||
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2100 900" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="2100" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="50%" stop-color="#16213e"/>
|
||||
<stop offset="100%" stop-color="#1a1a2e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wStroke" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4a9eff"/>
|
||||
<stop offset="100%" stop-color="#7b68ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sWave" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7b68ee"/>
|
||||
<stop offset="100%" stop-color="#4a9eff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textGrad" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e0e0ff"/>
|
||||
<stop offset="100%" stop-color="#b0b0d0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="2100" height="900" fill="url(#bg)"/>
|
||||
<!-- Subtle grid -->
|
||||
<line x1="0" y1="225" x2="2100" y2="225" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="450" x2="2100" y2="450" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="675" x2="2100" y2="675" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<!-- Decorative wave lines in background -->
|
||||
<path d="M0 750 Q262 700 525 750 Q787 800 1050 750 Q1312 700 1575 750 Q1837 800 2100 750" stroke="#4a9eff" stroke-opacity="0.08" stroke-width="2" fill="none"/>
|
||||
<path d="M0 780 Q262 730 525 780 Q787 830 1050 780 Q1312 730 1575 780 Q1837 830 2100 780" stroke="#7b68ee" stroke-opacity="0.06" stroke-width="2" fill="none"/>
|
||||
<!-- W+S Logo (left side) -->
|
||||
<g transform="translate(180, 180)">
|
||||
<!-- W -->
|
||||
<path d="M60 80 L120 280 L180 130 L240 280 L300 80" stroke="url(#wStroke)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- S wave -->
|
||||
<path d="M80 340 Q140 300 200 340 Q260 380 320 340" stroke="url(#sWave)" stroke-width="18" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<!-- Title text -->
|
||||
<text x="720" y="380" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="120" font-weight="700" fill="url(#textGrad)" letter-spacing="-2">Workflow Studio</text>
|
||||
<!-- Subtitle -->
|
||||
<text x="720" y="470" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="42" fill="#8888aa" letter-spacing="1">Workflow Management & Generation UI for ComfyUI</text>
|
||||
<!-- Feature pills -->
|
||||
<g font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#a0a0c0">
|
||||
<rect x="720" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="820" y="559" text-anchor="middle" fill="#4a9eff">Workflows</text>
|
||||
<rect x="940" y="530" width="200" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1040" y="559" text-anchor="middle" fill="#7b68ee">GenerateUI</text>
|
||||
<rect x="1160" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1260" y="559" text-anchor="middle" fill="#4a9eff">AI Prompts</text>
|
||||
<rect x="1380" y="530" width="180" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1470" y="559" text-anchor="middle" fill="#7b68ee">Themes</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wStroke" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4a9eff"/>
|
||||
<stop offset="100%" stop-color="#7b68ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sWave" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7b68ee"/>
|
||||
<stop offset="100%" stop-color="#4a9eff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="400" height="400" rx="48" fill="url(#bg)"/>
|
||||
<!-- Subtle grid pattern -->
|
||||
<line x1="0" y1="100" x2="400" y2="100" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="200" x2="400" y2="200" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="300" x2="400" y2="300" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="100" y1="0" x2="100" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="200" y1="0" x2="200" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="300" y1="0" x2="300" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<!-- W letterform -->
|
||||
<path d="M80 110 L140 290 L200 150 L260 290 L320 110" stroke="url(#wStroke)" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- S wave -->
|
||||
<path d="M100 330 Q150 295 200 330 Q250 365 300 330" stroke="url(#sWave)" stroke-width="20" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,461 @@
|
||||
/* ============================================
|
||||
Workflow Studio - Theme Definitions
|
||||
============================================
|
||||
|
||||
各テーマは :root の CSS変数を上書きすることで適用される。
|
||||
body の font-family も各テーマで変更する。
|
||||
|
||||
現在のデフォルトテーマ (Deep Ocean Dark) の変数:
|
||||
--wfm-bg: #1a1a2e
|
||||
--wfm-bg-secondary: #16213e
|
||||
--wfm-bg-tertiary: #0f3460
|
||||
--wfm-surface: #1e2a4a
|
||||
--wfm-surface-hover: #253358
|
||||
--wfm-border: #2a3a5c
|
||||
--wfm-text: #e0e0e0
|
||||
--wfm-text-secondary: #a0a0b0
|
||||
--wfm-primary: #4a9eff
|
||||
--wfm-primary-hover: #3a8eef
|
||||
--wfm-danger: #ff4757
|
||||
--wfm-success: #2ed573
|
||||
--wfm-warning: #ffa502
|
||||
--wfm-accent: #7c5cfc
|
||||
--wfm-radius: 8px
|
||||
--wfm-radius-sm: 4px
|
||||
--wfm-shadow: 0 2px 8px rgba(0,0,0,0.3)
|
||||
============================================ */
|
||||
|
||||
|
||||
/* ============================================
|
||||
1. Pop & Vibrant (ポップ調)
|
||||
明るく楽しく、クリエイティビティを刺激するデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="pop-vibrant"] {
|
||||
--wfm-bg: #F8F9FA;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #EEF0F2;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #F0F4FF;
|
||||
--wfm-border: #E0E0E0;
|
||||
--wfm-text: #2D2D2D;
|
||||
--wfm-text-secondary: #666666;
|
||||
--wfm-primary: #FF6B6B;
|
||||
--wfm-primary-hover: #FF5252;
|
||||
--wfm-danger: #FF4757;
|
||||
--wfm-success: #2ED573;
|
||||
--wfm-warning: #FFA502;
|
||||
--wfm-accent: #00D2D3;
|
||||
--wfm-radius: 16px;
|
||||
--wfm-radius-sm: 8px;
|
||||
--wfm-shadow: 0 4px 16px rgba(255, 107, 107, 0.15);
|
||||
/* 追加テーマ変数 */
|
||||
--wfm-tag-sdxl: #FF6B6B;
|
||||
--wfm-tag-sd15: #00D2D3;
|
||||
--wfm-tag-flux: #FFC048;
|
||||
--wfm-tag-default: #A55EEA;
|
||||
}
|
||||
|
||||
[data-theme="pop-vibrant"] body {
|
||||
font-family: 'Zen Maru Gothic', 'Rounded Mplus 1c', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
2. Light Minimalist (クリーン&ライト)
|
||||
現代的でノイズのない、コンテンツ集中型デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="light-minimalist"] {
|
||||
--wfm-bg: #FFFFFF;
|
||||
--wfm-bg-secondary: #F5F5F5;
|
||||
--wfm-bg-tertiary: #FAFAFA;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #F0F4FF;
|
||||
--wfm-border: #EEEEEE;
|
||||
--wfm-text: #333333;
|
||||
--wfm-text-secondary: #888888;
|
||||
--wfm-primary: #4A9EFF;
|
||||
--wfm-primary-hover: #3A8EEF;
|
||||
--wfm-danger: #EF4444;
|
||||
--wfm-success: #22C55E;
|
||||
--wfm-warning: #F59E0B;
|
||||
--wfm-accent: #6366F1;
|
||||
--wfm-radius: 8px;
|
||||
--wfm-radius-sm: 4px;
|
||||
--wfm-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="light-minimalist"] body {
|
||||
font-family: 'Inter', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
3. Cyberpunk / Hacker (サイバーパンク)
|
||||
SF映画のようなハッカー感・ギーク感
|
||||
============================================ */
|
||||
|
||||
[data-theme="cyberpunk"] {
|
||||
--wfm-bg: #000000;
|
||||
--wfm-bg-secondary: #0A0A0A;
|
||||
--wfm-bg-tertiary: #111111;
|
||||
--wfm-surface: #0D0D0D;
|
||||
--wfm-surface-hover: #1A1A1A;
|
||||
--wfm-border: #00FF4130;
|
||||
--wfm-text: #00FF41;
|
||||
--wfm-text-secondary: #00CC33;
|
||||
--wfm-primary: #FF00FF;
|
||||
--wfm-primary-hover: #CC00CC;
|
||||
--wfm-danger: #FF073A;
|
||||
--wfm-success: #00FF41;
|
||||
--wfm-warning: #FFD300;
|
||||
--wfm-accent: #00FFFF;
|
||||
--wfm-radius: 0px;
|
||||
--wfm-radius-sm: 0px;
|
||||
--wfm-shadow: 0 0 12px rgba(0, 255, 65, 0.3);
|
||||
/* ネオン発光ボーダー用 */
|
||||
--wfm-glow-primary: 0 0 8px rgba(255, 0, 255, 0.6);
|
||||
--wfm-glow-accent: 0 0 8px rgba(0, 255, 255, 0.6);
|
||||
/* JSONパネル用 */
|
||||
--wfm-code-text: #00FF41;
|
||||
--wfm-code-bg: #000000;
|
||||
}
|
||||
|
||||
[data-theme="cyberpunk"] body {
|
||||
font-family: 'Space Mono', 'Fira Code', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
4. Glassmorphism (グラスモーフィズム)
|
||||
半透明の洗練された未来的デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="glassmorphism"] {
|
||||
--wfm-bg: #1A0533;
|
||||
--wfm-bg-secondary: rgba(255, 255, 255, 0.08);
|
||||
--wfm-bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||
--wfm-surface: rgba(255, 255, 255, 0.10);
|
||||
--wfm-surface-hover: rgba(255, 255, 255, 0.16);
|
||||
--wfm-border: rgba(255, 255, 255, 0.18);
|
||||
--wfm-text: #F0F0F0;
|
||||
--wfm-text-secondary: #C0C0D0;
|
||||
--wfm-primary: #7B68EE;
|
||||
--wfm-primary-hover: #6A5ACD;
|
||||
--wfm-danger: #FF6B6B;
|
||||
--wfm-success: #69DB7C;
|
||||
--wfm-warning: #FFD43B;
|
||||
--wfm-accent: #DA77F2;
|
||||
--wfm-radius: 12px;
|
||||
--wfm-radius-sm: 8px;
|
||||
--wfm-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
/* Glassmorphism用追加変数 */
|
||||
--wfm-glass-blur: blur(12px);
|
||||
--wfm-glass-bg: rgba(255, 255, 255, 0.10);
|
||||
/* 背景メッシュグラデーション */
|
||||
--wfm-mesh-gradient: linear-gradient(135deg, #1A0533 0%, #0D1B3E 25%, #1A0533 50%, #2D1054 75%, #0D1B3E 100%);
|
||||
}
|
||||
|
||||
[data-theme="glassmorphism"] body {
|
||||
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
5. Neumorphism (ニューモーフィズム)
|
||||
柔らかく立体的な凸凹表現
|
||||
============================================ */
|
||||
|
||||
[data-theme="neumorphism"] {
|
||||
--wfm-bg: #D1D9E6;
|
||||
--wfm-bg-secondary: #D1D9E6;
|
||||
--wfm-bg-tertiary: #C8D0DD;
|
||||
--wfm-surface: #D1D9E6;
|
||||
--wfm-surface-hover: #D8E0ED;
|
||||
--wfm-border: transparent;
|
||||
--wfm-text: #4A5568;
|
||||
--wfm-text-secondary: #718096;
|
||||
--wfm-primary: #6C8EBF;
|
||||
--wfm-primary-hover: #5A7CAD;
|
||||
--wfm-danger: #E57373;
|
||||
--wfm-success: #81C784;
|
||||
--wfm-warning: #FFB74D;
|
||||
--wfm-accent: #7986CB;
|
||||
--wfm-radius: 12px;
|
||||
--wfm-radius-sm: 8px;
|
||||
/* ニューモーフィズム用の光と影 */
|
||||
--wfm-shadow: 6px 6px 12px #B8C0CC, -6px -6px 12px #EAF2FF;
|
||||
--wfm-shadow-inset: inset 4px 4px 8px #B8C0CC, inset -4px -4px 8px #EAF2FF;
|
||||
}
|
||||
|
||||
[data-theme="neumorphism"] body {
|
||||
font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
6. Retro 8-bit / Pixel (レトロゲーム調)
|
||||
遊び心満載のファミコン風デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="retro-pixel"] {
|
||||
--wfm-bg: #2C2C34;
|
||||
--wfm-bg-secondary: #3A3A44;
|
||||
--wfm-bg-tertiary: #24242C;
|
||||
--wfm-surface: #3A3A44;
|
||||
--wfm-surface-hover: #4A4A54;
|
||||
--wfm-border: #000000;
|
||||
--wfm-text: #F0F0E0;
|
||||
--wfm-text-secondary: #B0B0A0;
|
||||
--wfm-primary: #5B8FD4;
|
||||
--wfm-primary-hover: #4A7EC3;
|
||||
--wfm-danger: #D94848;
|
||||
--wfm-success: #48A848;
|
||||
--wfm-warning: #D4A017;
|
||||
--wfm-accent: #D45BD4;
|
||||
--wfm-radius: 0px;
|
||||
--wfm-radius-sm: 0px;
|
||||
/* 太い黒ボーダー + ベタ塗り影 */
|
||||
--wfm-shadow: 4px 4px 0px #000000;
|
||||
--wfm-border-width: 3px;
|
||||
}
|
||||
|
||||
[data-theme="retro-pixel"] body {
|
||||
font-family: 'DotGothic16', 'Press Start 2P', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
7. Pastel Dream (パステルドリーム)
|
||||
柔らかく優しい、目に優しいデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="pastel-dream"] {
|
||||
--wfm-bg: #F0E6FF;
|
||||
--wfm-bg-secondary: #E8F5E9;
|
||||
--wfm-bg-tertiary: #F3E5F5;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #FFF3E0;
|
||||
--wfm-border: #E1BEE7;
|
||||
--wfm-text: #5D4E75;
|
||||
--wfm-text-secondary: #8E7CA8;
|
||||
--wfm-primary: #B39DDB;
|
||||
--wfm-primary-hover: #9575CD;
|
||||
--wfm-danger: #F48FB1;
|
||||
--wfm-success: #A5D6A7;
|
||||
--wfm-warning: #FFE082;
|
||||
--wfm-accent: #80DEEA;
|
||||
--wfm-radius: 20px;
|
||||
--wfm-radius-sm: 12px;
|
||||
--wfm-shadow: 0 4px 16px rgba(179, 157, 219, 0.2);
|
||||
/* パステルタグカラー */
|
||||
--wfm-tag-sdxl: #FFAB91;
|
||||
--wfm-tag-sd15: #A5D6A7;
|
||||
--wfm-tag-flux: #90CAF9;
|
||||
--wfm-tag-default: #CE93D8;
|
||||
}
|
||||
|
||||
[data-theme="pastel-dream"] body {
|
||||
font-family: 'M PLUS Rounded 1c', 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
8. Brutalism / High Contrast (ブルータリズム)
|
||||
大胆で前衛的なハイコントラストデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="brutalism"] {
|
||||
--wfm-bg: #FFFFFF;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #F0F0F0;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #CCFF00;
|
||||
--wfm-border: #000000;
|
||||
--wfm-text: #000000;
|
||||
--wfm-text-secondary: #333333;
|
||||
--wfm-primary: #CCFF00;
|
||||
--wfm-primary-hover: #BBEE00;
|
||||
--wfm-danger: #FF0000;
|
||||
--wfm-success: #00FF00;
|
||||
--wfm-warning: #FFFF00;
|
||||
--wfm-accent: #CCFF00;
|
||||
--wfm-radius: 0px;
|
||||
--wfm-radius-sm: 0px;
|
||||
/* 極太ボーダー + ベタ影 */
|
||||
--wfm-shadow: 6px 6px 0px #000000;
|
||||
--wfm-border-width: 3px;
|
||||
}
|
||||
|
||||
[data-theme="brutalism"] body {
|
||||
font-family: 'Arial Black', 'Impact', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
9. Earthy / Nature (アースカラー)
|
||||
落ち着いたオーガニックなナチュラルデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="earthy"] {
|
||||
--wfm-bg: #F5E6D3;
|
||||
--wfm-bg-secondary: #EDE0D4;
|
||||
--wfm-bg-tertiary: #E8D5C0;
|
||||
--wfm-surface: #FFF8F0;
|
||||
--wfm-surface-hover: #F5EDE4;
|
||||
--wfm-border: #D4B896;
|
||||
--wfm-text: #3E2723;
|
||||
--wfm-text-secondary: #6D4C41;
|
||||
--wfm-primary: #8D6E63;
|
||||
--wfm-primary-hover: #795548;
|
||||
--wfm-danger: #C0392B;
|
||||
--wfm-success: #6B8E23;
|
||||
--wfm-warning: #D4A017;
|
||||
--wfm-accent: #BF6830;
|
||||
--wfm-radius: 8px;
|
||||
--wfm-radius-sm: 4px;
|
||||
--wfm-shadow: 0 2px 8px rgba(62, 39, 35, 0.12);
|
||||
/* アースカラータグ */
|
||||
--wfm-tag-sdxl: #6B8E23;
|
||||
--wfm-tag-sd15: #BF6830;
|
||||
--wfm-tag-flux: #D4A017;
|
||||
--wfm-tag-default: #8D6E63;
|
||||
}
|
||||
|
||||
[data-theme="earthy"] body {
|
||||
font-family: 'Noto Sans JP', 'Noto Serif JP', Georgia, serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
10. Material UI (マテリアルデザイン風)
|
||||
Googleライクな直感的デザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="material"] {
|
||||
--wfm-bg: #FAFAFA;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #F5F5F5;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #E8EAF6;
|
||||
--wfm-border: #E0E0E0;
|
||||
--wfm-text: #212121;
|
||||
--wfm-text-secondary: #757575;
|
||||
--wfm-primary: #3F51B5;
|
||||
--wfm-primary-hover: #303F9F;
|
||||
--wfm-danger: #F44336;
|
||||
--wfm-success: #4CAF50;
|
||||
--wfm-warning: #FF9800;
|
||||
--wfm-accent: #FF4081;
|
||||
--wfm-radius: 4px;
|
||||
--wfm-radius-sm: 2px;
|
||||
/* マテリアルシャドウ (Elevation 2) */
|
||||
--wfm-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||
}
|
||||
|
||||
[data-theme="material"] body {
|
||||
font-family: 'Roboto', 'Noto Sans JP', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
11. Monotone + One Accent (モノトーン+ワンアクセント)
|
||||
スタイリッシュなプロツール感
|
||||
============================================ */
|
||||
|
||||
[data-theme="monotone-accent"] {
|
||||
--wfm-bg: #F7F7F7;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #EBEBEB;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #F0F0F0;
|
||||
--wfm-border: #D0D0D0;
|
||||
--wfm-text: #1A1A1A;
|
||||
--wfm-text-secondary: #808080;
|
||||
--wfm-primary: #FF6B35;
|
||||
--wfm-primary-hover: #E85D2A;
|
||||
--wfm-danger: #CC0000;
|
||||
--wfm-success: #555555;
|
||||
--wfm-warning: #888888;
|
||||
--wfm-accent: #FF6B35;
|
||||
--wfm-radius: 6px;
|
||||
--wfm-radius-sm: 3px;
|
||||
--wfm-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="monotone-accent"] body {
|
||||
font-family: 'Helvetica Neue', 'Arial', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
12. Corporate Trust (コーポレート・トラスト)
|
||||
信頼感と堅牢性のあるBtoB向けデザイン
|
||||
============================================ */
|
||||
|
||||
[data-theme="corporate"] {
|
||||
--wfm-bg: #F0F4F8;
|
||||
--wfm-bg-secondary: #FFFFFF;
|
||||
--wfm-bg-tertiary: #E2E8F0;
|
||||
--wfm-surface: #FFFFFF;
|
||||
--wfm-surface-hover: #EBF0F6;
|
||||
--wfm-border: #CBD5E1;
|
||||
--wfm-text: #1E293B;
|
||||
--wfm-text-secondary: #64748B;
|
||||
--wfm-primary: #2563EB;
|
||||
--wfm-primary-hover: #1D4ED8;
|
||||
--wfm-danger: #DC2626;
|
||||
--wfm-success: #16A34A;
|
||||
--wfm-warning: #D97706;
|
||||
--wfm-accent: #7C3AED;
|
||||
--wfm-radius: 4px;
|
||||
--wfm-radius-sm: 2px;
|
||||
--wfm-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="corporate"] body {
|
||||
font-family: 'Noto Sans JP', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
テーマ適用方法のメモ
|
||||
============================================
|
||||
|
||||
HTMLの <html> タグに data-theme 属性を設定して切り替える:
|
||||
|
||||
<html data-theme="cyberpunk">
|
||||
<html data-theme="pastel-dream">
|
||||
<html data-theme="brutalism">
|
||||
...
|
||||
|
||||
JavaScriptでの切り替え例:
|
||||
|
||||
document.documentElement.setAttribute('data-theme', 'cyberpunk');
|
||||
|
||||
テーマをリセットしてデフォルトに戻す場合:
|
||||
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
|
||||
============================================
|
||||
|
||||
テーマ一覧:
|
||||
|
||||
| ID | 名前 | 系統 |
|
||||
|-------------------|-------------------------------|----------|
|
||||
| (default) | Deep Ocean Dark | ダーク |
|
||||
| pop-vibrant | Pop & Vibrant | ライト |
|
||||
| light-minimalist | Light Minimalist | ライト |
|
||||
| cyberpunk | Cyberpunk / Hacker | ダーク |
|
||||
| glassmorphism | Glassmorphism | ダーク |
|
||||
| neumorphism | Neumorphism | ライト |
|
||||
| retro-pixel | Retro 8-bit / Pixel | ダーク |
|
||||
| pastel-dream | Pastel Dream | ライト |
|
||||
| brutalism | Brutalism / High Contrast | ライト |
|
||||
| earthy | Earthy / Nature | ライト |
|
||||
| material | Material UI | ライト |
|
||||
| monotone-accent | Monotone + One Accent | ライト |
|
||||
| corporate | Corporate Trust | ライト |
|
||||
|
||||
============================================ */
|
||||
@@ -10,13 +10,21 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
|
||||
# Directory paths
|
||||
TEMPLATES_DIR = PLUGIN_DIR / "templates"
|
||||
STATIC_DIR = PLUGIN_DIR / "static"
|
||||
DATA_DIR = PLUGIN_DIR / "data"
|
||||
|
||||
# Default workflows directory: ComfyUI's user/default/workflows
|
||||
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
|
||||
# ComfyUI root is custom_nodes/../../ = two levels up
|
||||
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
|
||||
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
|
||||
|
||||
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
|
||||
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
|
||||
if _COMFYUI_USER_DEFAULT.is_dir():
|
||||
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
|
||||
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
|
||||
else:
|
||||
DATA_DIR = PLUGIN_DIR / "data"
|
||||
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
|
||||
|
||||
# Default workflows directory: ComfyUI's user/default/workflows
|
||||
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
|
||||
|
||||
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
|
||||
if _COMFYUI_WORKFLOWS.is_dir():
|
||||
@@ -55,3 +63,7 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Metadata file path
|
||||
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,219 @@
|
||||
"""
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import DATA_DIR
|
||||
from ..services.gallery_service import GalleryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = GalleryService(DATA_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/folders", list_folders)
|
||||
app.router.add_get("/wfm/gallery/images", list_images)
|
||||
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
|
||||
app.router.add_get("/wfm/gallery/groups", list_groups)
|
||||
app.router.add_post("/wfm/gallery/groups", create_group)
|
||||
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
|
||||
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
|
||||
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_folders(request: web.Request) -> web.Response:
|
||||
root = request.rel_url.query.get("root", "")
|
||||
if not root:
|
||||
return web.json_response({"error": "root parameter required"}, status=400)
|
||||
try:
|
||||
tree = _service.list_folder_tree(root)
|
||||
return web.json_response(tree)
|
||||
except Exception as e:
|
||||
logger.error("list_folders error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像一覧
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_images(request: web.Request) -> web.Response:
|
||||
folder = request.rel_url.query.get("folder", "")
|
||||
search = request.rel_url.query.get("search", "")
|
||||
sort_by = request.rel_url.query.get("sort", "date_desc")
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
|
||||
if not folder:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
|
||||
try:
|
||||
images = _service.list_images(
|
||||
folder,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except Exception as e:
|
||||
logger.error("list_images error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_image_meta(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
meta = _service.get_image_metadata(path)
|
||||
return web.json_response(meta)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_workflow(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
wf = _service.extract_workflow_from_metadata(path)
|
||||
if wf is None:
|
||||
return web.json_response({"workflow": None, "has_workflow": False})
|
||||
return web.json_response({"workflow": wf, "has_workflow": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def serve_image(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
|
||||
img_path = _service.serve_image(path)
|
||||
if img_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(img_path))
|
||||
return web.FileResponse(img_path, headers={
|
||||
"Content-Type": content_type or "image/png",
|
||||
"Cache-Control": "max-age=3600",
|
||||
})
|
||||
|
||||
|
||||
async def save_image_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
data = {k: v for k, v in body.items() if k != "path"}
|
||||
ok = _service.save_image_meta(img_path, data)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
new_val = _service.toggle_favorite(img_path)
|
||||
return web.json_response({"favorite": new_val})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_groups(request: web.Request) -> web.Response:
|
||||
return web.json_response({"groups": _service.list_groups()})
|
||||
|
||||
|
||||
async def create_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
ok = _service.create_group(name)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Group already exists"}, status=409)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
|
||||
try:
|
||||
old_name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
new_name = body.get("new_name", "").strip()
|
||||
if not old_name or not new_name:
|
||||
return web.json_response({"error": "old_name and new_name required"}, status=400)
|
||||
ok = _service.rename_group(old_name, new_name)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Rename failed (not found or name conflict)"}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_group(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
ok = _service.delete_group(name)
|
||||
return web.json_response({"ok": ok})
|
||||
|
||||
|
||||
async def add_to_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
ok = _service.add_to_group(img_path, name)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def remove_from_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
ok = _service.remove_from_group(img_path, name)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def list_group_images(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
images = _service.list_images_in_group(name)
|
||||
return web.json_response({"images": images})
|
||||
@@ -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,150 @@
|
||||
"""Node metadata and node sets API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.nodes_service import NodesService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = NodesService()
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register all node management API routes."""
|
||||
# Node metadata
|
||||
app.router.add_get("/api/wfm/nodes/metadata", handle_get_metadata)
|
||||
app.router.add_post("/api/wfm/nodes/metadata", handle_save_metadata)
|
||||
# Node groups
|
||||
app.router.add_get("/api/wfm/nodes/groups", handle_get_groups)
|
||||
app.router.add_post("/api/wfm/nodes/groups", handle_save_groups)
|
||||
# Node sets
|
||||
app.router.add_get("/api/wfm/node-sets", handle_list_sets)
|
||||
app.router.add_post("/api/wfm/node-sets", handle_create_set)
|
||||
app.router.add_post("/api/wfm/node-sets/update", handle_update_set)
|
||||
app.router.add_post("/api/wfm/node-sets/delete", handle_delete_set)
|
||||
app.router.add_get("/api/wfm/node-sets/export", handle_export_set)
|
||||
|
||||
|
||||
# ── Node Metadata ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_metadata(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/nodes/metadata"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_all_metadata)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading node 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/nodes/metadata"""
|
||||
try:
|
||||
body = await request.json()
|
||||
node_name = body.get("nodeName", "")
|
||||
if not node_name:
|
||||
return web.json_response({"error": "nodeName is required"}, status=400)
|
||||
updates = {k: v for k, v in body.items() if k != "nodeName"}
|
||||
result = await asyncio.to_thread(_service.update_node_metadata, node_name, updates)
|
||||
return web.json_response({"status": "ok", "metadata": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving node metadata: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node Groups ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_get_groups(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/nodes/groups"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_node_groups)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error loading node 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/nodes/groups"""
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(_service.save_node_groups, body)
|
||||
return web.json_response({"status": "ok", "groups": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving node groups: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ── Node Sets ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def handle_list_sets(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/node-sets"""
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_node_sets)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing node sets: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_create_set(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/node-sets"""
|
||||
try:
|
||||
body = await request.json()
|
||||
result = await asyncio.to_thread(_service.create_node_set, body)
|
||||
return web.json_response({"status": "ok", "nodeSet": result})
|
||||
except Exception as e:
|
||||
logger.error("Error creating node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_update_set(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/node-sets/update"""
|
||||
try:
|
||||
body = await request.json()
|
||||
set_id = body.get("id", "")
|
||||
if not set_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_node_set, set_id, updates)
|
||||
if result is None:
|
||||
return web.json_response({"error": "node set not found"}, status=404)
|
||||
return web.json_response({"status": "ok", "nodeSet": result})
|
||||
except Exception as e:
|
||||
logger.error("Error updating node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_set(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/node-sets/delete"""
|
||||
try:
|
||||
body = await request.json()
|
||||
set_id = body.get("id", "")
|
||||
if not set_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
await asyncio.to_thread(_service.delete_node_set, set_id)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting node set: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_export_set(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/node-sets/export?id=xxx"""
|
||||
try:
|
||||
set_id = request.query.get("id", "")
|
||||
if not set_id:
|
||||
return web.json_response({"error": "id is required"}, status=400)
|
||||
result = await asyncio.to_thread(_service.export_node_set_json, set_id)
|
||||
if result is None:
|
||||
return web.json_response({"error": "node set not found"}, status=404)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting node set: %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)
|
||||
@@ -1,18 +1,30 @@
|
||||
"""Settings API routes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SettingsService()
|
||||
|
||||
# Data files included in export/import
|
||||
_DATA_FILES = [
|
||||
"settings.json",
|
||||
"metadata.json",
|
||||
"node_metadata.json",
|
||||
"node_sets.json",
|
||||
"prompts.json",
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
]
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register settings API routes."""
|
||||
@@ -20,6 +32,10 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings", handle_post)
|
||||
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
|
||||
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
|
||||
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -92,3 +108,129 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error setting workflows dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _get_comfyui_output_dir() -> str:
|
||||
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
return str(folder_paths.get_output_directory())
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: config.pyのCOMFYUI_ROOTから推測
|
||||
from ..config import PLUGIN_DIR
|
||||
comfyui_root = PLUGIN_DIR.parent.parent
|
||||
output_dir = comfyui_root / "output"
|
||||
if output_dir.is_dir():
|
||||
return str(output_dir)
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_get_output_dir(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/output-dir - Get gallery output directory."""
|
||||
try:
|
||||
server_settings = await asyncio.to_thread(_service.load)
|
||||
saved = server_settings.get("gallery_output_dir", "").strip()
|
||||
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
|
||||
return web.json_response({
|
||||
"current": saved or default_dir,
|
||||
"default": default_dir,
|
||||
"saved": saved,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_set_output_dir(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/output-dir - Save gallery output directory."""
|
||||
try:
|
||||
body = await request.json()
|
||||
new_dir = body.get("gallery_output_dir", "").strip()
|
||||
await asyncio.to_thread(_service.update, {"gallery_output_dir": new_dir})
|
||||
|
||||
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
|
||||
resolved = new_dir or default_dir
|
||||
|
||||
# GalleryServiceの許可ルートを更新
|
||||
from ..routes.gallery_routes import _service as gallery_service
|
||||
gallery_service.update_output_root(resolved)
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"current": resolved,
|
||||
"default": default_dir,
|
||||
"saved": new_dir,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error setting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_data_file(filename: str):
|
||||
"""Load a single data file, return empty dict/list if missing."""
|
||||
path = DATA_DIR / filename
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_export_bundle() -> dict:
|
||||
"""Collect all data files into a single export bundle."""
|
||||
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
|
||||
for filename in _DATA_FILES:
|
||||
data = _load_data_file(filename)
|
||||
if data is not None:
|
||||
bundle[filename] = data
|
||||
return bundle
|
||||
|
||||
|
||||
async def handle_export(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
|
||||
try:
|
||||
bundle = await asyncio.to_thread(_build_export_bundle)
|
||||
body = json.dumps(bundle, ensure_ascii=False, indent=2)
|
||||
return web.Response(
|
||||
body=body.encode("utf-8"),
|
||||
content_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _apply_import_bundle(bundle: dict) -> dict:
|
||||
"""Write imported bundle back to individual data files. Returns summary."""
|
||||
summary = {"imported": [], "skipped": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for filename in _DATA_FILES:
|
||||
if filename not in bundle:
|
||||
summary["skipped"].append(filename)
|
||||
continue
|
||||
try:
|
||||
path = DATA_DIR / filename
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
|
||||
summary["imported"].append(filename)
|
||||
except Exception as e:
|
||||
logger.error("Import failed for %s: %s", filename, e)
|
||||
summary["skipped"].append(filename)
|
||||
return summary
|
||||
|
||||
|
||||
async def handle_import(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
|
||||
try:
|
||||
bundle = await request.json()
|
||||
if not isinstance(bundle, dict):
|
||||
return web.json_response({"error": "Invalid bundle format"}, status=400)
|
||||
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
|
||||
return web.json_response({"status": "ok", **summary})
|
||||
except Exception as e:
|
||||
logger.error("Error importing data: %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,125 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GalleryMetadataStore:
|
||||
"""
|
||||
gallery_metadata.json の構造:
|
||||
{
|
||||
"images": {
|
||||
"<abs_path>": {
|
||||
"favorite": bool,
|
||||
"tags": [str, ...],
|
||||
"memo": str,
|
||||
"groups": [str, ...]
|
||||
}
|
||||
},
|
||||
"groups": [
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
self._data = {
|
||||
"images": loaded.get("images", {}),
|
||||
"groups": loaded.get("groups", []),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
|
||||
def _save_to_disk(self):
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
|
||||
|
||||
# PNGチャンク1個の最大許容サイズ (32MB)
|
||||
_PNG_CHUNK_MAX = 32 * 1024 * 1024
|
||||
# JPEGを読む際の最大ファイルサイズ (64MB)
|
||||
_JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
"""フォルダ単位の画像スキャン結果キャッシュ。
|
||||
フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。"""
|
||||
|
||||
def __init__(self):
|
||||
# folder_path -> (mtime, scan_time, [(name, path_str, size, mtime_f), ...])
|
||||
self._cache: dict[str, tuple[float, float, list]] = {}
|
||||
|
||||
def get(self, folder: Path) -> list | None:
|
||||
key = str(folder)
|
||||
if key not in self._cache:
|
||||
return None
|
||||
cached_mtime, scan_time, entries = self._cache[key]
|
||||
# TTL チェック
|
||||
if time.monotonic() - scan_time > _CACHE_TTL:
|
||||
return None
|
||||
# フォルダ自体のmtimeチェック
|
||||
try:
|
||||
current_mtime = folder.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
if current_mtime != cached_mtime:
|
||||
return None
|
||||
return entries
|
||||
|
||||
def set(self, folder: Path, entries: list):
|
||||
key = str(folder)
|
||||
try:
|
||||
folder_mtime = folder.stat().st_mtime
|
||||
except OSError:
|
||||
folder_mtime = 0.0
|
||||
self._cache[key] = (folder_mtime, time.monotonic(), entries)
|
||||
|
||||
def invalidate(self, folder: Path):
|
||||
self._cache.pop(str(folder), None)
|
||||
|
||||
|
||||
class GalleryService:
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
resolved = path.resolve()
|
||||
return resolved == path.resolve()
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_folder_tree(self, root_path: str) -> dict:
|
||||
"""outputフォルダのフォルダツリーを返す"""
|
||||
root = Path(root_path).resolve()
|
||||
if not root.is_dir():
|
||||
return {"error": f"Directory not found: {root_path}"}
|
||||
if self._allowed_root is None:
|
||||
self._allowed_root = root
|
||||
|
||||
def build_tree(path: Path, rel_base: Path) -> dict:
|
||||
rel = str(path.relative_to(rel_base)).replace("\\", "/")
|
||||
image_count = 0
|
||||
children = []
|
||||
try:
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
if entry.is_file(follow_symlinks=True):
|
||||
if Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
|
||||
image_count += 1
|
||||
elif entry.is_dir(follow_symlinks=True) and not entry.name.startswith("."):
|
||||
children.append(entry.name)
|
||||
except PermissionError:
|
||||
pass
|
||||
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": rel if rel != "." else "",
|
||||
"abs_path": str(path).replace("\\", "/"),
|
||||
"image_count": image_count,
|
||||
"children": children_nodes,
|
||||
}
|
||||
|
||||
return build_tree(root, root)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像一覧
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _scan_folder(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.scandir() で画像ファイルを1回のシステムコールで列挙。
|
||||
キャッシュがあればそれを返す。
|
||||
Returns: [(name, abs_path_str, size, mtime), ...]
|
||||
"""
|
||||
cached = self._folder_cache.get(folder)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with os.scandir(folder) as it:
|
||||
for entry in it:
|
||||
if not entry.is_file(follow_symlinks=True):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=True)
|
||||
entries.append((
|
||||
entry.name,
|
||||
str(Path(entry.path).resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
self._folder_cache.set(folder, entries)
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
self,
|
||||
folder_path: str,
|
||||
search: str = "",
|
||||
sort_by: str = "date_desc",
|
||||
favorite_only: bool = False,
|
||||
tag_filter: str = "",
|
||||
group_filter: str = "",
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
if not self._check_path_allowed(folder):
|
||||
logger.warning("list_images: path not allowed: %s", folder)
|
||||
return []
|
||||
|
||||
# グループフィルタ: サーバーサイドで先にパスSetを取得(転送量削減)
|
||||
group_member_set: set | None = None
|
||||
if group_filter:
|
||||
group_member_set = self.metadata_store.get_group_member_set(group_filter)
|
||||
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
|
||||
results = []
|
||||
for name, abs_path, size, mtime in raw_entries:
|
||||
# グループフィルタ(サーバーサイド)
|
||||
if group_member_set is not None and abs_path not in group_member_set:
|
||||
continue
|
||||
|
||||
meta = self.metadata_store.get(abs_path)
|
||||
|
||||
# お気に入りフィルタ(サーバーサイド、早期スキップ)
|
||||
if favorite_only and not meta.get("favorite", False):
|
||||
continue
|
||||
|
||||
# タグフィルタ(サーバーサイド、早期スキップ)
|
||||
tags = meta.get("tags", [])
|
||||
if tag_filter and tag_filter not in tags:
|
||||
continue
|
||||
|
||||
ext = Path(name).suffix.lower()
|
||||
item = {
|
||||
"filename": name,
|
||||
"path": abs_path,
|
||||
"size": size,
|
||||
"mtime": mtime,
|
||||
"ext": ext,
|
||||
"favorite": meta.get("favorite", False),
|
||||
"tags": tags,
|
||||
"memo": meta.get("memo", ""),
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ
|
||||
if search:
|
||||
s = search.lower()
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
):
|
||||
continue
|
||||
|
||||
results.append(item)
|
||||
|
||||
# ソート
|
||||
if sort_by == "name_asc":
|
||||
results.sort(key=lambda x: x["filename"].lower())
|
||||
elif sort_by == "name_desc":
|
||||
results.sort(key=lambda x: x["filename"].lower(), reverse=True)
|
||||
elif sort_by == "date_asc":
|
||||
results.sort(key=lambda x: x["mtime"])
|
||||
else: # date_desc (default)
|
||||
results.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_image_metadata(self, image_path: str) -> dict:
|
||||
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
|
||||
path = Path(image_path).resolve()
|
||||
if not path.is_file():
|
||||
return {"error": "File not found"}
|
||||
if not self._check_path_allowed(path):
|
||||
return {"error": "Access denied"}
|
||||
|
||||
embedded = {}
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(path)
|
||||
elif ext in {".jpg", ".jpeg"}:
|
||||
embedded = self._read_jpeg_metadata(path)
|
||||
|
||||
saved = self.metadata_store.get(str(path))
|
||||
|
||||
stat = path.stat()
|
||||
return {
|
||||
"filename": path.name,
|
||||
"path": str(path).replace("\\", "/"),
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"ext": ext,
|
||||
"embedded": embedded,
|
||||
"favorite": saved.get("favorite", False),
|
||||
"tags": saved.get("tags", []),
|
||||
"memo": saved.get("memo", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
"""PNGのtEXtチャンクからメタデータを抽出"""
|
||||
result = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
sig = f.read(8)
|
||||
if sig != b"\x89PNG\r\n\x1a\n":
|
||||
return result
|
||||
while True:
|
||||
chunk_len_bytes = f.read(4)
|
||||
if len(chunk_len_bytes) < 4:
|
||||
break
|
||||
chunk_len = struct.unpack(">I", chunk_len_bytes)[0]
|
||||
chunk_type = f.read(4).decode("ascii", errors="ignore")
|
||||
if chunk_len > _PNG_CHUNK_MAX:
|
||||
f.seek(chunk_len + 4, 1)
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
continue
|
||||
chunk_data = f.read(chunk_len)
|
||||
f.read(4) # CRC
|
||||
|
||||
if chunk_type in ("tEXt", "iTXt"):
|
||||
try:
|
||||
if chunk_type == "tEXt":
|
||||
null_idx = chunk_data.index(b"\x00")
|
||||
key = chunk_data[:null_idx].decode("latin-1")
|
||||
value = chunk_data[null_idx + 1:].decode("latin-1")
|
||||
else:
|
||||
null_idx = chunk_data.index(b"\x00")
|
||||
key = chunk_data[:null_idx].decode("utf-8")
|
||||
rest = chunk_data[null_idx + 1:]
|
||||
rest = rest[2:]
|
||||
null_idx2 = rest.index(b"\x00")
|
||||
rest = rest[null_idx2 + 1:]
|
||||
null_idx3 = rest.index(b"\x00")
|
||||
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
|
||||
result[key] = value
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("PNG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_jpeg_metadata(self, path: Path) -> dict:
|
||||
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
|
||||
result = {}
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
read_size = min(file_size, _JPEG_READ_MAX)
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(read_size)
|
||||
idx = 0
|
||||
while idx < len(data) - 3:
|
||||
if data[idx] == 0xFF:
|
||||
marker = data[idx + 1]
|
||||
if marker == 0xFE: # COM
|
||||
length = struct.unpack(">H", data[idx + 2:idx + 4])[0]
|
||||
end = idx + 2 + length
|
||||
comment = data[idx + 4:end].decode("utf-8", errors="replace")
|
||||
result["Comment"] = comment
|
||||
break
|
||||
idx += 2
|
||||
else:
|
||||
idx += 1
|
||||
except Exception as e:
|
||||
logger.debug("JPEG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
|
||||
"""PNG埋め込みメタデータからComfyUIワークフローを抽出"""
|
||||
path = Path(image_path).resolve()
|
||||
if path.suffix.lower() != ".png":
|
||||
return None
|
||||
if not self._check_path_allowed(path):
|
||||
return None
|
||||
embedded = self._read_png_metadata(path)
|
||||
workflow_str = embedded.get("workflow") or embedded.get("Workflow")
|
||||
if workflow_str:
|
||||
try:
|
||||
return json.loads(workflow_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# メタデータ保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
return self.metadata_store.save(image_path, data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
meta = self.metadata_store.get(image_path)
|
||||
new_val = not meta.get("favorite", False)
|
||||
self.metadata_store.save(image_path, {"favorite": new_val})
|
||||
return new_val
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return self.metadata_store.list_groups()
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
return self.metadata_store.create_group(name)
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
return self.metadata_store.rename_group(old_name, new_name)
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = meta.get("groups", [])
|
||||
if group_name not in groups:
|
||||
groups.append(group_name)
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = [g for g in meta.get("groups", []) if g != group_name]
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
return self.metadata_store.list_images_in_group(group_name)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像配信
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def serve_image(self, image_path: str):
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)"""
|
||||
p = Path(image_path).resolve()
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
return None
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_image: path not allowed: %s", p)
|
||||
return None
|
||||
return p
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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",
|
||||
"hypernetwork": "hypernetworks",
|
||||
"embedding": "embeddings",
|
||||
}
|
||||
|
||||
|
||||
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"]
|
||||
if "badges" in updates:
|
||||
entry["badges"] = updates["badges"]
|
||||
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,146 @@
|
||||
"""Node metadata and node sets management service."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..config import NODE_METADATA_FILE, NODE_SETS_FILE
|
||||
|
||||
|
||||
class NodesService:
|
||||
"""Manages user-defined node metadata (tags, favorites, groups) and node sets."""
|
||||
|
||||
def __init__(self):
|
||||
self.node_metadata_file = NODE_METADATA_FILE
|
||||
self.node_sets_file = NODE_SETS_FILE
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
# ── Node Metadata ──────────────────────────────────────────
|
||||
|
||||
def _load_node_metadata(self):
|
||||
if self.node_metadata_file.exists():
|
||||
try:
|
||||
with open(self.node_metadata_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_node_metadata(self, data):
|
||||
self.node_metadata_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.node_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_node_metadata()
|
||||
|
||||
def update_node_metadata(self, node_name, updates):
|
||||
data = self._load_node_metadata()
|
||||
if node_name not in data:
|
||||
data[node_name] = {"tags": [], "favorite": False}
|
||||
entry = data[node_name]
|
||||
if "tags" in updates:
|
||||
entry["tags"] = updates["tags"]
|
||||
if "favorite" in updates:
|
||||
entry["favorite"] = updates["favorite"]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
data[node_name] = entry
|
||||
self._save_node_metadata(data)
|
||||
return entry
|
||||
|
||||
# ── Node Groups ────────────────────────────────────────────
|
||||
|
||||
def get_node_groups(self):
|
||||
data = self._load_node_metadata()
|
||||
return data.get("_groups", {})
|
||||
|
||||
def save_node_groups(self, groups):
|
||||
data = self._load_node_metadata()
|
||||
data["_groups"] = groups
|
||||
self._save_node_metadata(data)
|
||||
return groups
|
||||
|
||||
# ── Node Sets ──────────────────────────────────────────────
|
||||
|
||||
def _load_node_sets(self):
|
||||
if self.node_sets_file.exists():
|
||||
try:
|
||||
with open(self.node_sets_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _save_node_sets(self, sets):
|
||||
self.node_sets_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.node_sets_file, "w", encoding="utf-8") as f:
|
||||
json.dump(sets, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def list_node_sets(self):
|
||||
return self._load_node_sets()
|
||||
|
||||
def create_node_set(self, set_data):
|
||||
sets = self._load_node_sets()
|
||||
set_data["id"] = str(uuid.uuid4())
|
||||
set_data["created_at"] = self._now_iso()
|
||||
sets.append(set_data)
|
||||
self._save_node_sets(sets)
|
||||
return set_data
|
||||
|
||||
def update_node_set(self, set_id, updates):
|
||||
sets = self._load_node_sets()
|
||||
for s in sets:
|
||||
if s["id"] == set_id:
|
||||
for key in ("name", "description", "tags", "nodes", "links"):
|
||||
if key in updates:
|
||||
s[key] = updates[key]
|
||||
s["updated_at"] = self._now_iso()
|
||||
self._save_node_sets(sets)
|
||||
return s
|
||||
return None
|
||||
|
||||
def delete_node_set(self, set_id):
|
||||
sets = self._load_node_sets()
|
||||
sets = [s for s in sets if s["id"] != set_id]
|
||||
self._save_node_sets(sets)
|
||||
|
||||
def export_node_set_json(self, set_id):
|
||||
"""Export a node set as ComfyUI-compatible partial workflow JSON."""
|
||||
sets = self._load_node_sets()
|
||||
target = None
|
||||
for s in sets:
|
||||
if s["id"] == set_id:
|
||||
target = s
|
||||
break
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
nodes = target.get("nodes", [])
|
||||
links = target.get("links", [])
|
||||
|
||||
# Build prompt-format dict
|
||||
prompt = {}
|
||||
base_id = 100
|
||||
for i, node in enumerate(nodes):
|
||||
node_id = str(base_id + i)
|
||||
inputs = {}
|
||||
# Apply links: set linked inputs as [source_node_id, slot]
|
||||
for link in links:
|
||||
if link.get("to_node") == i:
|
||||
input_name = link.get("to_input", "")
|
||||
from_id = str(base_id + link["from_node"])
|
||||
inputs[input_name] = [from_id, link.get("from_slot", 0)]
|
||||
prompt[node_id] = {
|
||||
"class_type": node["class_type"],
|
||||
"inputs": inputs,
|
||||
}
|
||||
if node.get("title"):
|
||||
prompt[node_id]["_meta"] = {"title": node["title"]}
|
||||
|
||||
return {
|
||||
"name": target.get("name", ""),
|
||||
"prompt": prompt,
|
||||
"node_count": len(nodes),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -7,7 +7,8 @@ import re
|
||||
def _model_name_from_ui_node(node):
|
||||
"""Extract model filename from UI format node (lowercase)."""
|
||||
widgets = node.get("widgets_values", [])
|
||||
return (widgets[0] if widgets else "").lower()
|
||||
val = widgets[0] if widgets else ""
|
||||
return val.lower() if isinstance(val, str) else ""
|
||||
|
||||
|
||||
def _model_name_from_api_node(node):
|
||||
@@ -109,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,
|
||||
}
|
||||
|
||||
@@ -42,8 +42,23 @@ class WorkflowService:
|
||||
"""Validate filename has no path traversal."""
|
||||
if not fname or "/" in fname or "\\" in fname:
|
||||
return False
|
||||
if fname in (".", "..") or "\x00" in fname:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _safe_path(self, filename):
|
||||
"""Resolve path and ensure it stays within workflows_dir.
|
||||
|
||||
Raises ValueError if path traversal is detected.
|
||||
"""
|
||||
if not self._validate_filename(filename):
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
resolved = (self.workflows_dir / filename).resolve()
|
||||
workflows_resolved = self.workflows_dir.resolve()
|
||||
if not str(resolved).startswith(str(workflows_resolved) + os.sep) and resolved != workflows_resolved:
|
||||
raise ValueError(f"Path traversal detected: {filename}")
|
||||
return resolved
|
||||
|
||||
def _now_iso(self):
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -69,6 +84,7 @@ class WorkflowService:
|
||||
"modelTypes": [],
|
||||
"inputs": {"prompts": 0, "images": 0, "videos": 0},
|
||||
"outputs": {"images": 0, "videos": 0},
|
||||
"format": "unknown",
|
||||
}
|
||||
|
||||
stat = fpath.stat()
|
||||
@@ -96,6 +112,7 @@ class WorkflowService:
|
||||
"summary": meta.get("summary", ""),
|
||||
"modelTypesOverride": override_types,
|
||||
"favorite": bool(meta.get("favorite", False)),
|
||||
"badges": meta.get("badges", []),
|
||||
},
|
||||
"mtime": stat.st_mtime,
|
||||
"thumbnail": thumbnail,
|
||||
@@ -106,7 +123,7 @@ class WorkflowService:
|
||||
|
||||
def get_raw(self, filename):
|
||||
"""Get raw workflow JSON content."""
|
||||
fpath = self.workflows_dir / filename
|
||||
fpath = self._safe_path(filename)
|
||||
if not fpath.is_file():
|
||||
return None
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
@@ -116,7 +133,7 @@ class WorkflowService:
|
||||
"""Save metadata (tags, memo, summary, etc.) for a workflow."""
|
||||
metadata = self._load_metadata()
|
||||
entry = metadata.get(filename, {})
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite"):
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges"):
|
||||
if key in updates:
|
||||
entry[key] = updates[key]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
@@ -132,10 +149,16 @@ class WorkflowService:
|
||||
ext = os.path.splitext(original_name)[1].lower()
|
||||
base = os.path.splitext(original_name)[0]
|
||||
|
||||
if not self._validate_filename(original_name):
|
||||
results.append(
|
||||
{"name": original_name, "status": "error", "message": "Invalid filename"}
|
||||
)
|
||||
continue
|
||||
|
||||
if ext == ".json":
|
||||
try:
|
||||
wf_data = json.loads(file_data.decode("utf-8"))
|
||||
out_path = self.workflows_dir / original_name
|
||||
out_path = self._safe_path(original_name)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(wf_data, f, ensure_ascii=False, indent=2)
|
||||
results.append({"name": original_name, "status": "success"})
|
||||
@@ -158,13 +181,13 @@ class WorkflowService:
|
||||
try:
|
||||
# Save PNG as thumbnail
|
||||
with open(
|
||||
self.workflows_dir / original_name, "wb"
|
||||
self._safe_path(original_name), "wb"
|
||||
) as f:
|
||||
f.write(file_data)
|
||||
# Save extracted workflow as JSON
|
||||
json_name = base + ".json"
|
||||
with open(
|
||||
self.workflows_dir / json_name, "w", encoding="utf-8"
|
||||
self._safe_path(json_name), "w", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(wf_data, f, ensure_ascii=False, indent=2)
|
||||
results.append({"name": json_name, "status": "success"})
|
||||
@@ -190,8 +213,8 @@ class WorkflowService:
|
||||
def rename(self, old_name, new_stem):
|
||||
"""Rename workflow and its associated thumbnail."""
|
||||
new_name = new_stem + ".json"
|
||||
old_json = self.workflows_dir / old_name
|
||||
new_json = self.workflows_dir / new_name
|
||||
old_json = self._safe_path(old_name)
|
||||
new_json = self._safe_path(new_name)
|
||||
|
||||
if not old_json.is_file():
|
||||
return {"error": "file not found"}, 404
|
||||
@@ -203,8 +226,8 @@ class WorkflowService:
|
||||
# Rename associated thumbnail
|
||||
old_stem = old_name[:-5] if old_name.endswith(".json") else old_name
|
||||
for ext in (".png", ".webp"):
|
||||
old_thumb = self.workflows_dir / (old_stem + ext)
|
||||
new_thumb = self.workflows_dir / (new_stem + ext)
|
||||
old_thumb = self._safe_path(old_stem + ext)
|
||||
new_thumb = self._safe_path(new_stem + ext)
|
||||
if old_thumb.is_file():
|
||||
os.rename(old_thumb, new_thumb)
|
||||
|
||||
@@ -218,13 +241,13 @@ class WorkflowService:
|
||||
|
||||
def delete(self, filename):
|
||||
"""Delete workflow JSON and associated thumbnail."""
|
||||
json_path = self.workflows_dir / filename
|
||||
json_path = self._safe_path(filename)
|
||||
if json_path.is_file():
|
||||
os.remove(json_path)
|
||||
|
||||
base = filename[:-5] if filename.endswith(".json") else filename
|
||||
for ext in (".png", ".webp"):
|
||||
thumb_path = self.workflows_dir / (base + ext)
|
||||
thumb_path = self._safe_path(base + ext)
|
||||
if thumb_path.is_file():
|
||||
os.remove(thumb_path)
|
||||
|
||||
@@ -235,7 +258,7 @@ class WorkflowService:
|
||||
|
||||
def analyze(self, filename):
|
||||
"""Re-analyze a workflow and save results to metadata."""
|
||||
fpath = self.workflows_dir / filename
|
||||
fpath = self._safe_path(filename)
|
||||
if not fpath.is_file():
|
||||
return None
|
||||
|
||||
@@ -280,6 +303,7 @@ class WorkflowService:
|
||||
|
||||
def change_thumbnail(self, filename, image_data, original_image_name):
|
||||
"""Change workflow thumbnail image."""
|
||||
self._safe_path(filename) # validate filename
|
||||
orig_name_lower = original_image_name.lower()
|
||||
if orig_name_lower.endswith(".webp"):
|
||||
new_ext = ".webp"
|
||||
@@ -292,15 +316,15 @@ class WorkflowService:
|
||||
|
||||
# Backup existing thumbnails
|
||||
for ext in (".png", ".webp", ".jpg", ".jpeg"):
|
||||
old_thumb = self.workflows_dir / (stem + ext)
|
||||
old_thumb = self._safe_path(stem + ext)
|
||||
if old_thumb.is_file():
|
||||
backup = self.workflows_dir / ("old_" + stem + ext)
|
||||
backup = self._safe_path("old_" + stem + ext)
|
||||
if backup.is_file():
|
||||
os.remove(backup)
|
||||
os.rename(old_thumb, backup)
|
||||
|
||||
# Save new thumbnail
|
||||
new_thumb_path = self.workflows_dir / (stem + new_ext)
|
||||
new_thumb_path = self._safe_path(stem + new_ext)
|
||||
with open(new_thumb_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
|
||||
@@ -61,11 +61,15 @@ class WorkflowStudio:
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_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)
|
||||
gallery_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
|
||||
version = "0.2.7"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "ketle-man"
|
||||
DisplayName = "Workflow Studio"
|
||||
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
|
||||
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
|
||||
@@ -0,0 +1,594 @@
|
||||
/* ============================================================
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── 左: フォルダツリーパネル ──────────────────────────────── */
|
||||
|
||||
.wfm-gallery-tree-panel {
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
|
||||
.wfm-gallery-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item:hover {
|
||||
background: var(--wfm-bg-hover, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item.selected {
|
||||
background: var(--wfm-accent, #4f8ef7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item.selected .wfm-gallery-tree-count {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-arrow {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-count {
|
||||
font-size: 10px;
|
||||
background: var(--wfm-accent-muted, rgba(79,142,247,0.2));
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-children {
|
||||
/* 子ノードコンテナ */
|
||||
}
|
||||
|
||||
/* ── 中: 画像一覧パネル ────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-list-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-grid {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* サムネイルビュー */
|
||||
.wfm-gallery-view-thumb {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card {
|
||||
position: relative;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover {
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card.selected {
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
box-shadow: 0 0 0 2px var(--wfm-accent, #4f8ef7);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card.multi-selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px #f59e0b;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wfm-bg-hover, #222);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* お気に入りボタン(サムネイルカード右上) */
|
||||
.wfm-gallery-thumb-fav-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-fav-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-fav-btn.active {
|
||||
color: #fbbf24;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-fav-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* テーブルビューのお気に入りボタン */
|
||||
.wfm-gallery-table-fav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
padding: 2px 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-fav-btn:hover,
|
||||
.wfm-gallery-table-fav-btn.active {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* テーブルビュー */
|
||||
.wfm-gallery-view-table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-gallery-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr:hover {
|
||||
background: var(--wfm-bg-hover, rgba(255,255,255,0.05));
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr.selected {
|
||||
background: rgba(79,142,247,0.15);
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr.multi-selected {
|
||||
background: rgba(245,158,11,0.15);
|
||||
}
|
||||
|
||||
.wfm-gallery-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── タグバッジ共通 ────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.wfm-gallery-tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── 右: 詳細パネル ────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-detail-panel {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-header {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-filename {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* プレビュー */
|
||||
.wfm-gallery-detail-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* タブコンテンツ */
|
||||
.wfm-gallery-detail-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.wfm-gallery-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-gallery-info-section {
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-info-row {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-gallery-memo {
|
||||
width: 100%;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.wfm-gallery-memo:focus {
|
||||
outline: none;
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
}
|
||||
|
||||
/* ── Metadataタブ(Workflow全幅表示) ─────────────────────── */
|
||||
|
||||
.wfm-gallery-meta-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-gallery-meta-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-gallery-meta-status {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-workflow-json {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--wfm-text-primary);
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* グループ */
|
||||
.wfm-gallery-current-groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-gallery-groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-group-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text-primary);
|
||||
}
|
||||
|
||||
/* ── 一括操作バー ──────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(245,158,11,0.12);
|
||||
border-bottom: 1px solid rgba(245,158,11,0.35);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-bulk-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ── ライトボックス ─────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.9);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-img {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-footer {
|
||||
margin-top: 8px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-close {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-close:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings" };
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
|
||||
document.querySelectorAll(".wfm-tab").forEach((tab) => {
|
||||
const key = tabMap[tab.dataset.tab];
|
||||
if (key) tab.textContent = t(key);
|
||||
@@ -142,6 +142,60 @@ function applyI18nToHtml() {
|
||||
const copyBtn = document.getElementById("wfm-json-copy-btn");
|
||||
if (copyBtn) copyBtn.textContent = t("copy");
|
||||
|
||||
// Nodes tab
|
||||
const nodesSubBtns = document.querySelectorAll(".wfm-nodes-subview-btn");
|
||||
nodesSubBtns.forEach(btn => {
|
||||
if (btn.dataset.subview === "browser") btn.textContent = t("nodesAllNodes");
|
||||
if (btn.dataset.subview === "sets") btn.textContent = t("nodesNodeSets");
|
||||
});
|
||||
const nodesSearch = document.getElementById("wfm-nodes-search");
|
||||
if (nodesSearch) nodesSearch.placeholder = t("nodesSearchPlaceholder");
|
||||
const nodesRefreshBtn = document.getElementById("wfm-nodes-refresh-btn");
|
||||
if (nodesRefreshBtn) nodesRefreshBtn.textContent = t("nodesRefresh");
|
||||
const nodeSetCreateBtn = document.getElementById("wfm-node-set-create-btn");
|
||||
if (nodeSetCreateBtn) nodeSetCreateBtn.textContent = t("nodesCreateSet");
|
||||
const nodesSideTabBtns = document.querySelectorAll(".wfm-nodes-side-tab-btn");
|
||||
nodesSideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.sideTab === "details") btn.textContent = t("nodesDetails");
|
||||
if (btn.dataset.sideTab === "io") btn.textContent = t("nodesIoSpec");
|
||||
if (btn.dataset.sideTab === "nodegroup") btn.textContent = t("nodesGroups");
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
// Gallery tab
|
||||
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
|
||||
gallerySideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
|
||||
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
});
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
@@ -163,10 +217,12 @@ 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");
|
||||
if (presetCopyPosBtn) presetCopyPosBtn.textContent = t("copyPositivePrompt");
|
||||
const presetCopyNegBtn = document.getElementById("wfm-preset-copy-neg-btn");
|
||||
if (presetCopyNegBtn) presetCopyNegBtn.textContent = t("copyNegativePrompt");
|
||||
|
||||
// Group panel labels
|
||||
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
|
||||
@@ -193,6 +249,68 @@ function applyI18nToHtml() {
|
||||
|
||||
const removeBtn = document.getElementById("wfm-remove-group-btn");
|
||||
if (removeBtn) removeBtn.textContent = t("removeFromGroup");
|
||||
|
||||
// Help & Support tab
|
||||
const helpManualTitle = document.getElementById("wfm-help-manual-title");
|
||||
if (helpManualTitle) helpManualTitle.textContent = t("helpManualTitle");
|
||||
|
||||
const helpIdMap = {
|
||||
"wfm-help-about-title": "helpAboutTitle",
|
||||
"wfm-help-about-desc": "helpAboutDesc",
|
||||
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
|
||||
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
|
||||
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
|
||||
"wfm-help-wf-7": "helpWf7",
|
||||
"wfm-help-gen-1": "helpGen1", "wfm-help-gen-2": "helpGen2",
|
||||
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
|
||||
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
|
||||
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
|
||||
"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-settings-9": "helpSettings9",
|
||||
"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-models-8": "helpModels8",
|
||||
"wfm-help-models-9": "helpModels9",
|
||||
"wfm-help-gallery-title": "helpGalleryTitle",
|
||||
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
|
||||
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
|
||||
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
|
||||
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
|
||||
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
|
||||
"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-8": "helpSidepanel8",
|
||||
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
|
||||
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
|
||||
"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",
|
||||
"wfm-help-trouble-title": "helpTroubleTitle",
|
||||
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
|
||||
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
|
||||
"wfm-help-support-title": "helpSupportTitle",
|
||||
"wfm-help-support-desc": "helpSupportDesc",
|
||||
"wfm-help-github-desc": "helpGithubDesc",
|
||||
"wfm-help-kofi-desc": "helpKofiDesc",
|
||||
"wfm-help-thanks": "helpThanks",
|
||||
};
|
||||
for (const [id, key] of Object.entries(helpIdMap)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = t(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -227,9 +345,15 @@ function initModal() {
|
||||
// ============================================
|
||||
|
||||
import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab } from "./settings-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyI18nToHtml();
|
||||
@@ -237,8 +361,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initModal();
|
||||
initSettingsTab(); // Settings first (applies saved URL)
|
||||
initWorkflowTab();
|
||||
initNodesTab();
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
initGalleryTab();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -70,13 +70,19 @@ export const comfyUI = {
|
||||
});
|
||||
},
|
||||
|
||||
// Model fetching
|
||||
// Node info fetching
|
||||
async fetchObjectInfo(nodeClass) {
|
||||
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
async fetchAllObjectInfo() {
|
||||
const res = await fetch(`${this.baseUrl}/object_info`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
async _fetchModelList(nodeClasses, inputKey) {
|
||||
for (const cls of nodeClasses) {
|
||||
try {
|
||||
@@ -128,6 +134,25 @@ export const comfyUI = {
|
||||
);
|
||||
},
|
||||
|
||||
async fetchHypernetworks() {
|
||||
return this._fetchModelList(
|
||||
["HypernetworkLoader"],
|
||||
"hypernetwork_name"
|
||||
);
|
||||
},
|
||||
|
||||
async fetchEmbeddings() {
|
||||
// ComfyUI exposes embeddings via /embeddings endpoint
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/embeddings`);
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
},
|
||||
|
||||
async fetchSamplers() {
|
||||
return this._fetchModelList(["KSampler"], "sampler_name");
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ export const comfyEditor = {
|
||||
this.renderImageTab(analysis, "wfm-gen-image-fields");
|
||||
this.renderModelTab(analysis, "wfm-gen-model-fields");
|
||||
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
|
||||
_syncRawJson();
|
||||
},
|
||||
|
||||
renderPromptTab(analysis, containerId) {
|
||||
@@ -208,9 +209,9 @@ export const comfyEditor = {
|
||||
const latent = analysis.latent_nodes?.[0];
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin-bottom:12px;">KSampler</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:0;">
|
||||
<div style="padding-bottom:14px;border-bottom:1px solid var(--wfm-border);margin-bottom:14px;">
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
|
||||
${sampler ? `
|
||||
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}">
|
||||
<div class="wfm-form-group">
|
||||
@@ -245,7 +246,7 @@ export const comfyEditor = {
|
||||
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin-bottom:12px;">Latent Image</h3>
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">Latent Image</h3>
|
||||
${latent ? `
|
||||
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
|
||||
<div class="wfm-form-group">
|
||||
|
||||
@@ -250,9 +250,14 @@ function _flattenSubgraphs(workflow) {
|
||||
}
|
||||
|
||||
export const comfyWorkflow = {
|
||||
detectFormat(workflow) {
|
||||
detectFormat(workflow, filename) {
|
||||
if (!workflow || typeof workflow !== "object") return "unknown";
|
||||
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";
|
||||
|
||||
@@ -72,13 +72,16 @@ function updateStatus(connected) {
|
||||
|
||||
export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
let apiWorkflow = workflow;
|
||||
const format = comfyWorkflow.detectFormat(workflow);
|
||||
const format = comfyWorkflow.detectFormat(workflow, filename);
|
||||
|
||||
if (format === "ui") {
|
||||
if (format === "app") {
|
||||
showToast(t("appFormatNotSupported"), "error");
|
||||
return false;
|
||||
} else if (format === "ui") {
|
||||
apiWorkflow = await comfyWorkflow.convertUiToApi(workflow);
|
||||
} else if (format === "unknown") {
|
||||
showToast("Unknown workflow format", "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
comfyUI.currentWorkflow = apiWorkflow;
|
||||
@@ -107,6 +110,7 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
|
||||
if (genBtn) genBtn.disabled = !comfyUI.connected;
|
||||
|
||||
showToast(`Workflow loaded: ${filename || ""}`, "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -242,6 +246,16 @@ export async function initGenerateTab() {
|
||||
showToast("Interrupted", "info");
|
||||
});
|
||||
|
||||
// Move shared Raw JSON widget into the active tab's rawjson-col
|
||||
function moveRawJsonToTab(tabKey) {
|
||||
const widget = document.getElementById("wfm-gen-rawjson-widget");
|
||||
const col = document.getElementById(`wfm-gen-rawjson-col-${tabKey}`);
|
||||
if (widget && col) {
|
||||
col.appendChild(widget);
|
||||
widget.style.display = "flex";
|
||||
}
|
||||
}
|
||||
|
||||
// Subtab navigation
|
||||
document.querySelectorAll(".wfm-gen-subtab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -250,10 +264,14 @@ export async function initGenerateTab() {
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".wfm-gen-subtab-content").forEach((c) => c.classList.remove("active"));
|
||||
document.getElementById(`wfm-gen-subtab-${target}`)?.classList.add("active");
|
||||
moveRawJsonToTab(target);
|
||||
});
|
||||
});
|
||||
|
||||
// Raw JSON apply
|
||||
// Initial placement: move to first active tab (input)
|
||||
moveRawJsonToTab("input");
|
||||
|
||||
// Raw JSON apply (always-visible panel in right column)
|
||||
document.getElementById("wfm-gen-apply-raw-btn")?.addEventListener("click", async () => {
|
||||
const textarea = document.getElementById("wfm-gen-raw-json");
|
||||
if (!textarea) return;
|
||||
|
||||
@@ -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,32 +673,50 @@ 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);
|
||||
});
|
||||
|
||||
// Save preset
|
||||
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", () => {
|
||||
// Copy positive prompt to clipboard
|
||||
document.getElementById("wfm-preset-copy-pos-btn")?.addEventListener("click", () => {
|
||||
const text = presetPos?.value || "";
|
||||
if (!text.trim()) {
|
||||
showToast(t("noTextToCopy"), "error");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(t("copiedToClipboard"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// Copy negative prompt to clipboard
|
||||
document.getElementById("wfm-preset-copy-neg-btn")?.addEventListener("click", () => {
|
||||
const text = presetNeg?.value || "";
|
||||
if (!text.trim()) {
|
||||
showToast(t("noTextToCopy"), "error");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(t("copiedToClipboard"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// 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 || "";
|
||||
|
||||
@@ -415,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
|
||||
@@ -465,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) || "{}");
|
||||
@@ -50,6 +56,282 @@ function buildLangOptions(optionsMap, selectedValue) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Theme definitions with swatch colors for preview
|
||||
const THEMES = [
|
||||
{ id: "", key: "themeDefault", colors: ["#1a1a2e", "#16213e", "#4a9eff", "#7c5cfc"] },
|
||||
{ id: "pop-vibrant", key: "themePopVibrant", colors: ["#F8F9FA", "#FFFFFF", "#FF6B6B", "#00D2D3"] },
|
||||
{ id: "light-minimalist", key: "themeLightMinimalist", colors: ["#FFFFFF", "#F5F5F5", "#4A9EFF", "#6366F1"] },
|
||||
{ id: "cyberpunk", key: "themeCyberpunk", colors: ["#000000", "#0A0A0A", "#FF00FF", "#00FFFF"] },
|
||||
{ id: "glassmorphism", key: "themeGlassmorphism", colors: ["#1A0533", "#0D1B3E", "#7B68EE", "#DA77F2"] },
|
||||
{ id: "neumorphism", key: "themeNeumorphism", colors: ["#D1D9E6", "#D1D9E6", "#6C8EBF", "#7986CB"] },
|
||||
{ id: "retro-pixel", key: "themeRetroPixel", colors: ["#2C2C34", "#3A3A44", "#5B8FD4", "#D45BD4"] },
|
||||
{ id: "pastel-dream", key: "themePastelDream", colors: ["#F0E6FF", "#E8F5E9", "#B39DDB", "#80DEEA"] },
|
||||
{ id: "brutalism", key: "themeBrutalism", colors: ["#FFFFFF", "#FFFFFF", "#CCFF00", "#000000"] },
|
||||
{ id: "earthy", key: "themeEarthy", colors: ["#F5E6D3", "#EDE0D4", "#8D6E63", "#BF6830"] },
|
||||
{ id: "material", key: "themeMaterial", colors: ["#FAFAFA", "#FFFFFF", "#3F51B5", "#FF4081"] },
|
||||
{ id: "monotone-accent", key: "themeMonotoneAccent", colors: ["#F7F7F7", "#FFFFFF", "#FF6B35", "#1A1A1A"] },
|
||||
{ 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() {
|
||||
return loadLocalSettings().theme || "";
|
||||
}
|
||||
|
||||
export async function initSettingsTab() {
|
||||
const container = document.querySelector("#wfm-tab-settings .wfm-settings-container");
|
||||
if (!container) return;
|
||||
@@ -64,6 +346,12 @@ export async function initSettingsTab() {
|
||||
workflowsDirInfo = await res.json();
|
||||
} catch {}
|
||||
|
||||
// Load gallery output dir info
|
||||
let outputDirInfo = { current: "", default: "", saved: "" };
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
outputDirInfo = await res.json();
|
||||
} catch {}
|
||||
|
||||
const uiLang = getLang();
|
||||
const summaryLang = getSummaryLang();
|
||||
@@ -71,9 +359,115 @@ export async function initSettingsTab() {
|
||||
container.innerHTML = `
|
||||
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
|
||||
|
||||
<div class="wfm-settings-layout">
|
||||
|
||||
<!-- Right column: Theme (floated right) -->
|
||||
<div class="wfm-settings-right-col">
|
||||
<!-- Theme -->
|
||||
<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}">
|
||||
<div class="wfm-theme-swatch">
|
||||
${th.colors.map(c => `<span style="background:${c};"></span>`).join("")}
|
||||
</div>
|
||||
<div class="wfm-theme-card-label">${t(th.key)}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</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>
|
||||
|
||||
</div><!-- /wfm-settings-right-col -->
|
||||
|
||||
<!-- Left column: all settings (Language + rest), flows left of floated Theme -->
|
||||
<div class="wfm-settings-left-col">
|
||||
<!-- 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">
|
||||
@@ -89,11 +483,11 @@ export async function initSettingsTab() {
|
||||
⚠ ${t("summaryLangNote")}
|
||||
</small>
|
||||
</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;">
|
||||
@@ -110,11 +504,32 @@ 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>
|
||||
|
||||
<!-- Gallery Output Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("galleryOutputDirLabel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-output-dir"
|
||||
value="${serverSettings.gallery_output_dir || ""}"
|
||||
placeholder="${outputDirInfo.default}">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-output-dir-apply">${t("workflowsDirApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-output-dir-reset">${t("workflowsDirDefault")}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("galleryOutputDirHint")}
|
||||
</small>
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;">
|
||||
${t("workflowsDirCurrent")}: <code id="wfm-settings-output-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${outputDirInfo.current}</code>
|
||||
</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;">
|
||||
@@ -128,11 +543,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"
|
||||
@@ -153,11 +568,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>
|
||||
@@ -167,11 +582,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;">
|
||||
@@ -189,12 +604,226 @@ export async function initSettingsTab() {
|
||||
${t("eagleAutoSave")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Data Management -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
|
||||
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
|
||||
${t("importData")}
|
||||
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
|
||||
|
||||
</div><!-- /wfm-settings-left-col -->
|
||||
|
||||
<div style="clear:both;"></div>
|
||||
</div><!-- /wfm-settings-layout -->
|
||||
`;
|
||||
|
||||
// --- Theme change handler ---
|
||||
document.getElementById("wfm-theme-grid")?.addEventListener("click", (e) => {
|
||||
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);
|
||||
// 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");
|
||||
});
|
||||
|
||||
// --- Language change handlers ---
|
||||
document.getElementById("wfm-settings-ui-lang")?.addEventListener("change", (e) => {
|
||||
const lang = e.target.value;
|
||||
@@ -253,6 +882,50 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Gallery output dir handlers ---
|
||||
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
const currentEl = document.getElementById("wfm-settings-output-dir-current");
|
||||
const newDir = dirInput.value.trim();
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gallery_output_dir: newDir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
|
||||
} else {
|
||||
if (currentEl) currentEl.textContent = data.current;
|
||||
showToast(t("workflowsDirChanged"), "success");
|
||||
// gallery-tab に通知して再ロード
|
||||
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-output-dir-reset")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
const currentEl = document.getElementById("wfm-settings-output-dir-current");
|
||||
dirInput.value = "";
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gallery_output_dir: "" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (currentEl) currentEl.textContent = data.current;
|
||||
showToast(t("workflowsDirChanged"), "success");
|
||||
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Ollama model list loader ---
|
||||
async function loadOllamaModels() {
|
||||
const select = document.getElementById("wfm-settings-ollama-model");
|
||||
@@ -361,6 +1034,7 @@ export async function initSettingsTab() {
|
||||
eagleAutoSave: document.getElementById("wfm-settings-eagle-auto-save")?.checked || false,
|
||||
uiLang: getLang(),
|
||||
summaryLang: getSummaryLang(),
|
||||
theme: document.documentElement.getAttribute("data-theme") || "",
|
||||
};
|
||||
|
||||
saveLocalSettings(patch);
|
||||
@@ -376,4 +1050,51 @@ export async function initSettingsTab() {
|
||||
if (savedUrl) {
|
||||
comfyUI.updateUrl(savedUrl);
|
||||
}
|
||||
|
||||
// --- Data Export ---
|
||||
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/export");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "wfm-data-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
statusEl.textContent = t("exportSuccess");
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("exportError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Data Import ---
|
||||
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const bundle = JSON.parse(text);
|
||||
const res = await fetch("/api/wfm/settings/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || res.statusText);
|
||||
const imported = result.imported?.join(", ") || "-";
|
||||
statusEl.textContent = t("importSuccess") + ": " + imported;
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("importError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
// Reset file input so same file can be re-selected
|
||||
e.target.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { loadWorkflowIntoEditor } from "./generate-tab.js";
|
||||
import { t, getSummaryPrompt } from "./i18n.js";
|
||||
import { highlightJSON } from "./json-highlight.js";
|
||||
import { openBadgeEditModal } from "./models-tab.js";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
@@ -16,26 +17,31 @@ import { highlightJSON } from "./json-highlight.js";
|
||||
|
||||
const state = {
|
||||
workflows: [],
|
||||
activeModel: "ALL",
|
||||
activeBadge: "ALL",
|
||||
searchText: "",
|
||||
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
|
||||
selectedWf: null,
|
||||
groupFilter: "", // empty = all groups
|
||||
badgeColors: (() => {
|
||||
try { return JSON.parse(localStorage.getItem("wfm_badge_colors") || "{}"); }
|
||||
catch { return {}; }
|
||||
})(),
|
||||
currentPage: 0,
|
||||
};
|
||||
|
||||
const BADGE_DEFAULT_COLORS = {
|
||||
"SD1.5": "#e67e22",
|
||||
SDXL: "#2ecc71",
|
||||
Flux: "#9b59b6",
|
||||
Flux2: "#8e44ad",
|
||||
SD3: "#3498db",
|
||||
Qwen: "#e74c3c",
|
||||
"Z-IMAGE": "#1abc9c",
|
||||
};
|
||||
|
||||
function getBadgePalette() {
|
||||
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
function saveBadgePalette(palette) {
|
||||
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Open in ComfyUI
|
||||
@@ -214,15 +220,22 @@ async function getRawWorkflow(filename) {
|
||||
// Rendering Helpers
|
||||
// ============================================
|
||||
|
||||
function badgeHtml(type) {
|
||||
const color = state.badgeColors[type] || BADGE_DEFAULT_COLORS[type] || "#0077ff";
|
||||
return `<span class="wfm-badge wfm-badge-model" style="background:${color}">${type}</span>`;
|
||||
function badgeHtml(label) {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function getAllModelTypes() {
|
||||
function wfBadgesHtml(wf) {
|
||||
const badges = wf.metadata?.badges || [];
|
||||
return badges.map(label => badgeHtml(label)).join("");
|
||||
}
|
||||
|
||||
function getAllBadges() {
|
||||
const set = new Set();
|
||||
state.workflows.forEach((wf) =>
|
||||
(wf.analysis.modelTypes || []).forEach((t) => set.add(t))
|
||||
(wf.metadata?.badges || []).forEach((b) => set.add(b))
|
||||
);
|
||||
return [...set].sort();
|
||||
}
|
||||
@@ -231,17 +244,23 @@ function renderModelFilters() {
|
||||
const container = document.getElementById("wfm-model-filters");
|
||||
if (!container) return;
|
||||
|
||||
const types = getAllModelTypes();
|
||||
const palette = getBadgePalette();
|
||||
const badges = Object.keys(palette).sort();
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="wfm-filter-btn ${state.activeModel === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
|
||||
<button class="wfm-filter-btn ${state.activeModel === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">★</button>
|
||||
${types.map((t) => `<button class="wfm-filter-btn ${state.activeModel === t ? "active" : ""}" data-filter="${t}">${t}</button>`).join("")}
|
||||
<button class="wfm-filter-btn ${state.activeBadge === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
|
||||
<button class="wfm-filter-btn ${state.activeBadge === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">★</button>
|
||||
${badges.map((b) => {
|
||||
const color = palette[b] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}"${style}>${escapeHtml(b)}</button>`;
|
||||
}).join("")}
|
||||
`;
|
||||
|
||||
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeModel = btn.dataset.filter;
|
||||
state.activeBadge = btn.dataset.filter;
|
||||
state.currentPage = 0;
|
||||
renderModelFilters();
|
||||
renderGrid();
|
||||
});
|
||||
@@ -250,10 +269,12 @@ function renderModelFilters() {
|
||||
|
||||
function filterWorkflows() {
|
||||
return state.workflows.filter((wf) => {
|
||||
if (state.activeModel === "FAVORITE") {
|
||||
if (wf.filename === ".index.json") return false;
|
||||
|
||||
if (state.activeBadge === "FAVORITE") {
|
||||
if (!wf.metadata.favorite) return false;
|
||||
} else if (state.activeModel !== "ALL") {
|
||||
if (!(wf.analysis.modelTypes || []).includes(state.activeModel))
|
||||
} else if (state.activeBadge !== "ALL") {
|
||||
if (!(wf.metadata?.badges || []).includes(state.activeBadge))
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -305,14 +326,13 @@ function renderGrid() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Thumbnail / Card view
|
||||
grid.innerHTML = "";
|
||||
filtered.forEach((wf) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
card.dataset.filename = wf.filename;
|
||||
const hasThumb = !!wf.thumbnail;
|
||||
const badges = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join("");
|
||||
const badges = wfBadgesHtml(wf);
|
||||
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
|
||||
const favStar = wf.metadata.favorite ? "\u2605" : "\u2606";
|
||||
const favClass = wf.metadata.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
@@ -357,6 +377,7 @@ function renderGrid() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function renderTableView(grid, filtered) {
|
||||
const table = document.createElement("table");
|
||||
table.className = "wfm-table";
|
||||
@@ -376,7 +397,7 @@ function renderTableView(grid, filtered) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "wfm-table-row";
|
||||
tr.dataset.filename = wf.filename;
|
||||
const badges = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join("");
|
||||
const badges = wfBadgesHtml(wf);
|
||||
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
|
||||
const thumbHtml = wf.thumbnail
|
||||
? `<img src="${wf.thumbnail}" class="wfm-table-thumb" loading="lazy" />`
|
||||
@@ -442,7 +463,6 @@ async function showSidePanel(wf, cardEl) {
|
||||
|
||||
titleEl.textContent = wf.filename.replace(/\.json$/, "");
|
||||
contentEl.textContent = t("loading");
|
||||
panel.style.display = "flex";
|
||||
|
||||
state.selectedWf = wf;
|
||||
|
||||
@@ -462,14 +482,15 @@ async function showSidePanel(wf, cardEl) {
|
||||
sidePanelThumbUpdate(wf);
|
||||
|
||||
// Update group tab
|
||||
sidePanelGroupUpdate();
|
||||
renderSideGroup(wf);
|
||||
|
||||
const badgeEl = document.getElementById("wfm-json-format-badge");
|
||||
try {
|
||||
const data = await getRawWorkflow(wf.filename);
|
||||
if (badgeEl) {
|
||||
const fmt = comfyWorkflow.detectFormat(data);
|
||||
badgeEl.textContent = fmt === "ui" ? t("uiFormat") : t("apiFormat");
|
||||
const fmt = comfyWorkflow.detectFormat(data, wf.filename);
|
||||
const formatLabels = { ui: t("uiFormat"), api: t("apiFormat"), app: t("appFormat") };
|
||||
badgeEl.textContent = formatLabels[fmt] || fmt;
|
||||
badgeEl.className = "wfm-format-badge wfm-format-badge--" + fmt;
|
||||
}
|
||||
const jsonStr = JSON.stringify(data, null, 2);
|
||||
@@ -485,17 +506,17 @@ async function showSidePanel(wf, cardEl) {
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById("wfm-side-panel");
|
||||
if (panel) panel.style.display = "none";
|
||||
document.querySelectorAll(".wfm-card, .wfm-table-row").forEach((c) =>
|
||||
c.classList.remove("wfm-card-selected")
|
||||
);
|
||||
state.selectedWf = null;
|
||||
const titleEl = document.getElementById("wfm-side-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
const listLoadBtn = document.getElementById("wfm-list-load-btn");
|
||||
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
|
||||
sidePanelGroupUpdate();
|
||||
renderSideGroup(null);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -518,7 +539,7 @@ function sidePanelThumbUpdate(wf) {
|
||||
nameEl.textContent = wf ? wf.filename.replace(/\.json$/, "") : "";
|
||||
}
|
||||
if (metaEl && wf) {
|
||||
const badges = (wf.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
|
||||
const badges = wfBadgesHtml(wf);
|
||||
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
|
||||
metaEl.innerHTML = `${badges} <span style="margin-left:4px;">${io}</span>`;
|
||||
} else if (metaEl) {
|
||||
@@ -530,60 +551,166 @@ function sidePanelThumbUpdate(wf) {
|
||||
// Group Tab in Side Panel
|
||||
// ============================================
|
||||
|
||||
function sidePanelGroupUpdate() {
|
||||
const nameEl = document.getElementById("wfm-grp-prop-name");
|
||||
const groupEl = document.getElementById("wfm-grp-prop-group");
|
||||
const assignBtn = document.getElementById("wfm-assign-group-btn");
|
||||
const removeBtn = document.getElementById("wfm-remove-group-btn");
|
||||
function renderSideGroup(wf) {
|
||||
const el = document.getElementById("wfm-side-tab-group");
|
||||
if (!el) return;
|
||||
|
||||
if (!state.selectedWf) {
|
||||
if (nameEl) nameEl.textContent = t("noSelection");
|
||||
if (groupEl) groupEl.textContent = "";
|
||||
if (assignBtn) assignBtn.disabled = true;
|
||||
if (removeBtn) removeBtn.disabled = true;
|
||||
const allGroups = groups.groupNames();
|
||||
|
||||
if (!wf) {
|
||||
el.innerHTML = `<p style="padding:12px;color:var(--wfm-text-secondary);font-size:12px;">${t("noSelection")}</p>`;
|
||||
_refreshGroupFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = state.selectedWf.filename;
|
||||
if (nameEl) nameEl.textContent = filename.replace(/\.json$/, "");
|
||||
const grps = groups.groupsOf(filename);
|
||||
if (groupEl) groupEl.textContent = grps.length > 0 ? `${t("groupsLabel")}: ${grps.join(", ")}` : `${t("groupsLabel")}: ${t("groupsNone")}`;
|
||||
if (assignBtn) assignBtn.disabled = false;
|
||||
const filename = wf.filename;
|
||||
const memberOf = groups.groupsOf(filename);
|
||||
const available = allGroups.filter(g => !memberOf.includes(g));
|
||||
|
||||
const selGroup = document.getElementById("wfm-group-select")?.value;
|
||||
if (removeBtn) removeBtn.disabled = !selGroup || !grps.includes(selGroup);
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div style="padding:8px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map(g =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-wf-grp-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-grp-assign-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${available.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-grp-assign-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
|
||||
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-grp-new-input" class="wfm-input"
|
||||
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
|
||||
<button id="wfm-grp-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-grp-manage-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-grp-rename-btn" class="wfm-btn wfm-btn-sm"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button id="wfm-grp-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function refreshGroupSelects() {
|
||||
const sideSel = document.getElementById("wfm-group-select");
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
const grpNames = groups.groupNames();
|
||||
|
||||
[filterSel, sideSel].forEach((sel, i) => {
|
||||
if (!sel) return;
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = i === 0
|
||||
? `<option value="">${t("allGroups")}</option>`
|
||||
: `<option value="">${t("selectGroup")}</option>`;
|
||||
grpNames.forEach((g) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
sel.appendChild(opt);
|
||||
// グループから除外
|
||||
el.querySelectorAll(".wfm-wf-grp-remove").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
groups.remove(filename, btn.dataset.group);
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Removed from "${btn.dataset.group}"`, "success");
|
||||
});
|
||||
if (grpNames.includes(prevVal)) sel.value = prevVal;
|
||||
});
|
||||
|
||||
// グループに追加
|
||||
el.querySelector("#wfm-grp-assign-btn")?.addEventListener("click", () => {
|
||||
const g = el.querySelector("#wfm-grp-assign-sel")?.value;
|
||||
if (!g) return;
|
||||
groups.assign(filename, g);
|
||||
renderSideGroup(wf);
|
||||
showToast(`Added to "${g}"`, "success");
|
||||
});
|
||||
|
||||
// グループ作成(作成後に現在のWFに追加)
|
||||
el.querySelector("#wfm-grp-create-btn")?.addEventListener("click", () => {
|
||||
const input = el.querySelector("#wfm-grp-new-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "warning");
|
||||
return;
|
||||
}
|
||||
groups.assign(filename, name);
|
||||
input.value = "";
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
});
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
|
||||
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast("Rename failed", "error");
|
||||
return;
|
||||
}
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
|
||||
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
groups.deleteGroup(name);
|
||||
if (state.groupFilter === name) {
|
||||
state.groupFilter = "";
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
if (filterSel) filterSel.value = "";
|
||||
}
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
});
|
||||
|
||||
_refreshGroupFilter();
|
||||
}
|
||||
|
||||
/** ツールバーのグループフィルタセレクトのみ更新 */
|
||||
function _refreshGroupFilter() {
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
if (!filterSel) return;
|
||||
const prevVal = filterSel.value;
|
||||
const grpNames = groups.groupNames();
|
||||
filterSel.innerHTML = `<option value="">${t("allGroups")}</option>`;
|
||||
grpNames.forEach(g => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
filterSel.appendChild(opt);
|
||||
});
|
||||
if (grpNames.includes(prevVal)) filterSel.value = prevVal;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Badge Color Settings
|
||||
// View Settings Panel
|
||||
// ============================================
|
||||
|
||||
function renderBadgeSettings() {
|
||||
function renderViewSettings() {
|
||||
const panel = document.getElementById("wfm-badge-settings-panel");
|
||||
if (!panel) return;
|
||||
|
||||
// === Default View section ===
|
||||
const currentView = localStorage.getItem("wfm_default_view") || "thumb";
|
||||
const viewOptions = [
|
||||
{ value: "thumb", label: t("viewThumbnail") },
|
||||
@@ -597,57 +724,20 @@ function renderBadgeSettings() {
|
||||
</label>`
|
||||
).join("");
|
||||
|
||||
// === Badge Colors section ===
|
||||
const types = getAllModelTypes();
|
||||
const allKnown = new Set([...Object.keys(BADGE_DEFAULT_COLORS), ...types]);
|
||||
const rows = [...allKnown].sort().map((type) => {
|
||||
const color = state.badgeColors[type] || BADGE_DEFAULT_COLORS[type] || "#0077ff";
|
||||
return `<div class="wfm-badge-color-row" data-type="${type}">
|
||||
<span class="wfm-badge wfm-badge-model" style="background:${color}">${type}</span>
|
||||
<input type="color" class="wfm-badge-color-input" value="${color}" data-type="${type}" title="Change color for ${type}" />
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-badge-settings-title">${t("defaultView")}</div>
|
||||
<div class="wfm-settings-radio-group">${viewRadios}</div>
|
||||
<hr style="border:none;border-top:1px solid var(--wfm-border);margin:8px 0;">
|
||||
<div class="wfm-badge-settings-title">${t("badgeColors")}</div>
|
||||
${rows}
|
||||
<button id="wfm-badge-color-reset" class="wfm-btn wfm-btn-sm" style="margin-top:6px;width:100%;">${t("resetColors")}</button>`;
|
||||
<div class="wfm-settings-radio-group">${viewRadios}</div>`;
|
||||
|
||||
// Default view change
|
||||
panel.querySelectorAll('input[name="wfm-default-view"]').forEach((radio) => {
|
||||
radio.addEventListener("change", () => {
|
||||
localStorage.setItem("wfm_default_view", radio.value);
|
||||
state.viewMode = radio.value;
|
||||
// Update active state on view mode buttons
|
||||
document.querySelectorAll(".wfm-view-btn").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.view === radio.value);
|
||||
});
|
||||
renderGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Badge color change
|
||||
panel.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
|
||||
input.addEventListener("input", (e) => {
|
||||
const type = e.target.dataset.type;
|
||||
const color = e.target.value;
|
||||
state.badgeColors[type] = color;
|
||||
localStorage.setItem("wfm_badge_colors", JSON.stringify(state.badgeColors));
|
||||
const badge = e.target.closest(".wfm-badge-color-row")?.querySelector(".wfm-badge");
|
||||
if (badge) badge.style.background = color;
|
||||
renderGrid();
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelector("#wfm-badge-color-reset")?.addEventListener("click", () => {
|
||||
state.badgeColors = {};
|
||||
localStorage.removeItem("wfm_badge_colors");
|
||||
renderBadgeSettings();
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -672,10 +762,21 @@ async function saveModalMeta(wf) {
|
||||
|
||||
function openDetailModal(wf) {
|
||||
const meta = wf.metadata;
|
||||
const types = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join(" ");
|
||||
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
|
||||
const overrideText = (meta.modelTypesOverride || []).join(", ");
|
||||
const isFav = !!meta.favorite;
|
||||
const palette = getBadgePalette();
|
||||
const allBadgeLabels = Object.keys(palette).sort();
|
||||
const activeBadges = meta.badges || [];
|
||||
const badgeCheckboxes = allBadgeLabels.length === 0
|
||||
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
|
||||
: allBadgeLabels.map((label) => {
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
const checked = activeBadges.includes(label) ? " checked" : "";
|
||||
return `<label class="wfm-badge-check-label">
|
||||
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
|
||||
<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
|
||||
const thumbSrc = wf.thumbnail || "";
|
||||
const html = `
|
||||
@@ -688,23 +789,9 @@ function openDetailModal(wf) {
|
||||
<div class="wfm-modal-two-col">
|
||||
<div class="wfm-modal-left">
|
||||
<section>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<h4>${t("analysis")}</h4>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-analyze">${t("analyze")}</button>
|
||||
</div>
|
||||
<div id="wfm-detail-analysis" class="wfm-detail-analysis">
|
||||
<div>${types || `<span style="color:var(--wfm-text-secondary)">${t("noModelDetected")}</span>`}</div>
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;">${io}</div>
|
||||
</div>
|
||||
<div id="wfm-detail-override-row" style="margin-top:8px;">
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<span style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">${t("modelCategory")}</span>
|
||||
<input type="text" class="wfm-input" id="wfm-detail-model-override"
|
||||
value="${overrideText}"
|
||||
placeholder="${t("modelOverridePlaceholder")}" style="flex:1;font-size:12px;padding:4px 6px;min-width:0;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-detail-override-save">${t("save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-detail-badge-manage">⚙ ${t("badgeManage")}</button></h4>
|
||||
<div id="wfm-detail-badge-checkboxes" class="wfm-badge-checkboxes">${badgeCheckboxes}</div>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-detail-badges-save" style="margin-top:6px;">${t("save")}</button>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("tags")} <span style="font-weight:normal;font-size:11px;">${t("tagsHint")}</span></h4>
|
||||
@@ -812,51 +899,40 @@ function openDetailModal(wf) {
|
||||
document.getElementById(id)?.addEventListener("blur", () => saveModalMeta(wf));
|
||||
});
|
||||
|
||||
// Analyze button
|
||||
document.getElementById("wfm-detail-analyze")?.addEventListener("click", async () => {
|
||||
const btn = document.getElementById("wfm-detail-analyze");
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("analyzing");
|
||||
try {
|
||||
const res = await fetch("/api/wfm/workflows/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: wf.filename }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
wf.analysis = data.analysis;
|
||||
const cached = state.workflows.find((w) => w.filename === wf.filename);
|
||||
if (cached) cached.analysis = data.analysis;
|
||||
// Re-render analysis section
|
||||
const analysisEl = document.getElementById("wfm-detail-analysis");
|
||||
if (analysisEl) {
|
||||
const newTypes = (data.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
|
||||
const newIo = `P:${data.analysis.inputs?.prompts || 0} I:${data.analysis.inputs?.images || 0} → ${data.analysis.outputs?.images || 0}`;
|
||||
analysisEl.innerHTML = `<div>${newTypes || `<span style="color:var(--wfm-text-secondary)">${t("noModelDetected")}</span>`}</div>
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;">${newIo}</div>`;
|
||||
}
|
||||
renderGrid();
|
||||
showToast(t("analysisComplete"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("analyzeError") + ": " + err.message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("analyze");
|
||||
}
|
||||
// Badge manage button
|
||||
document.getElementById("wfm-detail-badge-manage")?.addEventListener("click", () => {
|
||||
openBadgeEditModal(() => {
|
||||
// Rebuild checkboxes after palette changes
|
||||
const palette = getBadgePalette();
|
||||
const allLabels = Object.keys(palette).sort();
|
||||
const currentBadges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map(cb => cb.value);
|
||||
const el = document.getElementById("wfm-detail-badge-checkboxes");
|
||||
if (!el) return;
|
||||
el.innerHTML = allLabels.length === 0
|
||||
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
|
||||
: allLabels.map((label) => {
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
const checked = currentBadges.includes(label) ? " checked" : "";
|
||||
return `<label class="wfm-badge-check-label">
|
||||
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
|
||||
<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
});
|
||||
});
|
||||
|
||||
// Model type override save
|
||||
document.getElementById("wfm-detail-override-save")?.addEventListener("click", async () => {
|
||||
const overrideStr = document.getElementById("wfm-detail-model-override")?.value || "";
|
||||
const modelTypesOverride = overrideStr.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
// Badge save
|
||||
document.getElementById("wfm-detail-badges-save")?.addEventListener("click", async () => {
|
||||
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map(cb => cb.value);
|
||||
try {
|
||||
await saveMetadata(wf.filename, { modelTypesOverride });
|
||||
wf.metadata.modelTypesOverride = modelTypesOverride;
|
||||
await saveMetadata(wf.filename, { badges });
|
||||
wf.metadata.badges = badges;
|
||||
const cached = state.workflows.find((w) => w.filename === wf.filename);
|
||||
if (cached) cached.metadata.modelTypesOverride = modelTypesOverride;
|
||||
if (cached) cached.metadata.badges = badges;
|
||||
renderGrid();
|
||||
showToast(t("overrideSaved"), "success");
|
||||
renderModelFilters();
|
||||
showToast(t("modelsSaved"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("saveError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -866,10 +942,10 @@ function openDetailModal(wf) {
|
||||
document.getElementById("wfm-detail-load").addEventListener("click", async () => {
|
||||
try {
|
||||
const wfData = await getRawWorkflow(wf.filename);
|
||||
await loadWorkflowIntoEditor(wfData, wf.filename);
|
||||
const loaded = await loadWorkflowIntoEditor(wfData, wf.filename);
|
||||
if (loaded === false) return;
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
closeModal();
|
||||
showToast(`${t("loaded")}: ${wf.filename}`, "success");
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -1002,6 +1078,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) {
|
||||
@@ -1058,6 +1145,7 @@ export function initWorkflowTab() {
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
state.searchText = searchInput.value;
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
}
|
||||
@@ -1085,9 +1173,6 @@ export function initWorkflowTab() {
|
||||
});
|
||||
}
|
||||
|
||||
// Side panel: close button
|
||||
document.getElementById("wfm-side-panel-close")?.addEventListener("click", closeSidePanel);
|
||||
|
||||
// Side panel: tab switching
|
||||
document.querySelectorAll(".wfm-side-tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -1121,78 +1206,12 @@ export function initWorkflowTab() {
|
||||
|
||||
// Group management
|
||||
groups.load();
|
||||
refreshGroupSelects();
|
||||
|
||||
// Add group
|
||||
document.getElementById("wfm-group-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-group-name-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "error");
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
refreshGroupSelects();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
});
|
||||
|
||||
// Rename group
|
||||
document.getElementById("wfm-group-rename-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt("New group name:", oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast("Rename failed", "error");
|
||||
return;
|
||||
}
|
||||
refreshGroupSelects();
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Group renamed to "${newName}"`, "success");
|
||||
});
|
||||
|
||||
// Delete group
|
||||
document.getElementById("wfm-group-delete-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const name = sel?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
groups.deleteGroup(name);
|
||||
refreshGroupSelects();
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
});
|
||||
|
||||
// Assign to group
|
||||
document.getElementById("wfm-assign-group-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const groupName = sel?.value;
|
||||
if (!groupName || !state.selectedWf) return;
|
||||
groups.assign(state.selectedWf.filename, groupName);
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Added to "${groupName}"`, "success");
|
||||
});
|
||||
|
||||
// Remove from group
|
||||
document.getElementById("wfm-remove-group-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const groupName = sel?.value;
|
||||
if (!groupName || !state.selectedWf) return;
|
||||
groups.remove(state.selectedWf.filename, groupName);
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Removed from "${groupName}"`, "success");
|
||||
});
|
||||
|
||||
// Update remove button state when group select changes
|
||||
document.getElementById("wfm-group-select")?.addEventListener("change", () => {
|
||||
sidePanelGroupUpdate();
|
||||
});
|
||||
_refreshGroupFilter();
|
||||
|
||||
// Toolbar: group filter dropdown
|
||||
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
|
||||
state.groupFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderGrid();
|
||||
});
|
||||
|
||||
@@ -1201,9 +1220,9 @@ export function initWorkflowTab() {
|
||||
if (!state.selectedWf) return;
|
||||
try {
|
||||
const wfData = await getRawWorkflow(state.selectedWf.filename);
|
||||
await loadWorkflowIntoEditor(wfData, state.selectedWf.filename);
|
||||
const loaded = await loadWorkflowIntoEditor(wfData, state.selectedWf.filename);
|
||||
if (loaded === false) return;
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
showToast(`${t("loaded")}: ${state.selectedWf.filename}`, "success");
|
||||
} catch (err) {
|
||||
showToast(t("loadError") + ": " + err.message, "error");
|
||||
}
|
||||
@@ -1226,7 +1245,7 @@ export function initWorkflowTab() {
|
||||
showToast("Refreshed", "success");
|
||||
});
|
||||
|
||||
// Toolbar: Badge color settings
|
||||
// Toolbar: ⚙ settings button (view settings panel)
|
||||
const badgeSettingsBtn = document.getElementById("wfm-badge-settings-btn");
|
||||
const badgeSettingsPanel = document.getElementById("wfm-badge-settings-panel");
|
||||
if (badgeSettingsBtn && badgeSettingsPanel) {
|
||||
@@ -1237,7 +1256,7 @@ export function initWorkflowTab() {
|
||||
badgeSettingsPanel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
renderBadgeSettings();
|
||||
renderViewSettings();
|
||||
badgeSettingsPanel.style.display = "";
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
@@ -1247,15 +1266,9 @@ export function initWorkflowTab() {
|
||||
});
|
||||
}
|
||||
|
||||
// Toolbar: Reanalyze All button
|
||||
document.getElementById("wfm-reanalyze-btn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await reanalyzeAll();
|
||||
showToast(`Reanalyzed ${result.count ?? 0} workflows`, "success");
|
||||
await loadWorkflows();
|
||||
} catch (err) {
|
||||
showToast("Reanalyze error: " + err.message, "error");
|
||||
}
|
||||
// Toolbar: ⚙ Badge button
|
||||
document.getElementById("wfm-badge-btn")?.addEventListener("click", () => {
|
||||
openBadgeEditModal(() => renderGrid());
|
||||
});
|
||||
|
||||
// Initial load
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>Workflow Studio</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/wfm_static/favicon.svg">
|
||||
<link rel="stylesheet" href="/wfm_static/css/main.css">
|
||||
<link rel="stylesheet" href="/wfm_static/css/gallery-tab.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -15,9 +16,13 @@
|
||||
</div>
|
||||
<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="gallery">Gallery</button>
|
||||
<button class="wfm-tab" data-tab="settings">Settings</button>
|
||||
<button class="wfm-tab" data-tab="help">Help</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -47,13 +52,13 @@
|
||||
<button class="wfm-view-btn" data-view="table" title="Table">≡</button>
|
||||
</div>
|
||||
<button id="wfm-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<button id="wfm-reanalyze-btn" class="wfm-btn wfm-btn-sm" title="Re-analyze all workflows">Reanalyze All</button>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;">
|
||||
Import
|
||||
<input type="file" id="wfm-import-input" multiple accept=".json,.png" style="display:none">
|
||||
</label>
|
||||
<button id="wfm-badge-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">⚙ Badge</button>
|
||||
<div class="wfm-badge-settings-wrap">
|
||||
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">⚙</button>
|
||||
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="View settings">⚙</button>
|
||||
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,10 +68,9 @@
|
||||
<p class="wfm-placeholder">Loading workflows...</p>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div id="wfm-side-panel" class="wfm-side-panel" style="display:none;">
|
||||
<div id="wfm-side-panel" class="wfm-side-panel">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-side-panel-title" class="wfm-side-panel-title"></span>
|
||||
<button id="wfm-side-panel-close" class="wfm-side-panel-close">×</button>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
|
||||
@@ -94,35 +98,136 @@
|
||||
<pre id="wfm-json-content" class="wfm-json-content"></pre>
|
||||
</div>
|
||||
<!-- Group Tab -->
|
||||
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;">
|
||||
<div class="wfm-group-panel">
|
||||
<div class="wfm-group-section">
|
||||
<div class="wfm-group-section-title">Properties</div>
|
||||
<div id="wfm-grp-prop-name" class="wfm-group-prop">No selection</div>
|
||||
<div id="wfm-grp-prop-group" class="wfm-group-prop"></div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<div class="wfm-group-section">
|
||||
<div class="wfm-group-section-title">Group Management</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<input type="text" id="wfm-group-name-input" class="wfm-input" placeholder="Group name" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
|
||||
<button id="wfm-group-add-btn" class="wfm-btn wfm-btn-sm" title="Create group">Add</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<select id="wfm-group-select" class="wfm-select" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
|
||||
<option value="">Select group</option>
|
||||
</select>
|
||||
<button id="wfm-group-rename-btn" class="wfm-btn wfm-btn-sm" title="Rename group">✎</button>
|
||||
<button id="wfm-group-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete group" style="padding:4px 8px;font-size:12px;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<div class="wfm-group-section">
|
||||
<div class="wfm-group-section-title">Workflow Actions</div>
|
||||
<button id="wfm-assign-group-btn" class="wfm-btn wfm-btn-sm" style="width:100%;margin-bottom:4px;" disabled>Add to Group</button>
|
||||
<button id="wfm-remove-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="width:100%;margin-bottom:4px;font-size:12px;" disabled>Remove from Group</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;overflow-y:auto;">
|
||||
<!-- JS動的生成: renderSideGroup() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Nodes Tab -->
|
||||
<section id="wfm-tab-nodes" class="wfm-tab-content">
|
||||
<!-- Sub-view toggle + Toolbar -->
|
||||
<div class="wfm-toolbar">
|
||||
<div class="wfm-toolbar-row" style="gap:8px;">
|
||||
<div class="wfm-nodes-subview-nav">
|
||||
<button class="wfm-nodes-subview-btn active" data-subview="browser">All Nodes</button>
|
||||
<button class="wfm-nodes-subview-btn" data-subview="sets">Node Sets</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Browser toolbar -->
|
||||
<div id="wfm-nodes-browser-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
|
||||
<button class="wfm-filter-btn" id="wfm-nodes-fav-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-nodes-search" class="wfm-search-input" placeholder="Search nodes...">
|
||||
<select id="wfm-nodes-category-filter" class="wfm-select wfm-toolbar-select"><option value="">All Categories</option></select>
|
||||
<select id="wfm-nodes-package-filter" class="wfm-select wfm-toolbar-select"><option value="">All Packages</option></select>
|
||||
<select id="wfm-nodes-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
|
||||
<select id="wfm-nodes-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-nodes-view="card" title="Card">☰</button>
|
||||
<button class="wfm-view-btn" data-nodes-view="table" title="Table">≡</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Browser sub-view -->
|
||||
<div id="wfm-nodes-browser" class="wfm-workflow-area">
|
||||
<div id="wfm-nodes-grid" class="wfm-grid wfm-view-card">
|
||||
<p class="wfm-placeholder" id="wfm-nodes-placeholder">Click to load nodes...</p>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div id="wfm-nodes-side-panel" class="wfm-side-panel">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-nodes-panel-title" class="wfm-side-panel-title"></span>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-nodes-side-tab-btn active" data-side-tab="details">Details</button>
|
||||
<button class="wfm-nodes-side-tab-btn" data-side-tab="io">I/O Spec</button>
|
||||
<button class="wfm-nodes-side-tab-btn" data-side-tab="nodegroup">Groups</button>
|
||||
</div>
|
||||
<div id="wfm-nodes-side-details" class="wfm-nodes-side-content active" style="overflow-y:auto;padding:12px;">
|
||||
</div>
|
||||
<div id="wfm-nodes-side-io" class="wfm-nodes-side-content" style="display:none;overflow-y:auto;padding:12px;">
|
||||
</div>
|
||||
<div id="wfm-nodes-side-group" class="wfm-nodes-side-content" style="display:none;overflow-y:auto;padding:12px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Sets sub-view -->
|
||||
<div id="wfm-nodes-sets" class="wfm-workflow-area" style="display:none;">
|
||||
<div style="padding:8px 0;">
|
||||
<button id="wfm-node-set-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Create Node Set</button>
|
||||
</div>
|
||||
<div id="wfm-node-sets-grid" class="wfm-grid wfm-view-card">
|
||||
<p class="wfm-placeholder">No node sets yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Models Tab -->
|
||||
<section id="wfm-tab-models" class="wfm-tab-content">
|
||||
<div class="wfm-toolbar">
|
||||
<!-- Row 1: Model type sub-tabs + badge filter -->
|
||||
<div class="wfm-toolbar-row" style="gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<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>
|
||||
<button class="wfm-models-type-btn" data-model-type="hypernetwork">Hypernetwork</button>
|
||||
<button class="wfm-models-type-btn" data-model-type="embedding">Embedding</button>
|
||||
</div>
|
||||
<div class="wfm-models-type-divider"></div>
|
||||
<div id="wfm-models-badge-filter-bar" class="wfm-badge-filter-bar"></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-dir-filter" class="wfm-select wfm-toolbar-select"><option value="">All Folders</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>
|
||||
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">⚙ Badge</button>
|
||||
</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">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
|
||||
</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>
|
||||
@@ -144,53 +249,57 @@
|
||||
|
||||
<!-- Editor Layout -->
|
||||
<div class="wfm-gen-layout">
|
||||
<!-- Left Column: Parameter Editor -->
|
||||
<!-- Left Column: Parameter Tabs -->
|
||||
<div class="wfm-gen-left">
|
||||
<!-- Subtab Navigation -->
|
||||
<!-- Tab Navigation -->
|
||||
<div class="wfm-gen-subtab-nav">
|
||||
<button class="wfm-gen-subtab-btn active" data-subtab="prompt">Prompt</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="image">Image</button>
|
||||
<button class="wfm-gen-subtab-btn active" data-subtab="input">Input</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="model">Model</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="settings">Settings</button>
|
||||
<button class="wfm-gen-subtab-btn" data-subtab="raw">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Subtab: Prompt -->
|
||||
<div id="wfm-gen-subtab-prompt" class="wfm-gen-subtab-content active">
|
||||
<div id="wfm-gen-prompt-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to edit prompts</p>
|
||||
<!-- Tab: Input — left sub-col: Prompt(top)+Image(bottom), right sub-col: Raw JSON -->
|
||||
<div id="wfm-gen-subtab-input" class="wfm-gen-subtab-content active">
|
||||
<div class="wfm-gen-tab-cols">
|
||||
<div class="wfm-gen-params-col">
|
||||
<div class="wfm-gen-params-section">
|
||||
<h3 class="wfm-gen-col-title">Prompt</h3>
|
||||
<div id="wfm-gen-prompt-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to edit prompts</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-gen-params-section wfm-gen-params-section--top-border">
|
||||
<h3 class="wfm-gen-col-title">Image</h3>
|
||||
<div id="wfm-gen-image-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to set input images</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-input"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtab: Image Input -->
|
||||
<div id="wfm-gen-subtab-image" class="wfm-gen-subtab-content">
|
||||
<div id="wfm-gen-image-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to set input images</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtab: Model -->
|
||||
<!-- Tab: Model — left sub-col: model selects, right sub-col: Raw JSON -->
|
||||
<div id="wfm-gen-subtab-model" class="wfm-gen-subtab-content">
|
||||
<div id="wfm-gen-model-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to edit models</p>
|
||||
<div class="wfm-gen-tab-cols">
|
||||
<div class="wfm-gen-params-col wfm-gen-params-col--scroll">
|
||||
<div id="wfm-gen-model-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to edit models</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-model"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtab: Settings -->
|
||||
<!-- Tab: Settings — left sub-col: KSampler(top)+LatentImage(bottom), right sub-col: Raw JSON -->
|
||||
<div id="wfm-gen-subtab-settings" class="wfm-gen-subtab-content">
|
||||
<div id="wfm-gen-settings-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to edit settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtab: Raw JSON -->
|
||||
<div id="wfm-gen-subtab-raw" class="wfm-gen-subtab-content">
|
||||
<div style="margin-bottom:8px;">
|
||||
<button id="wfm-gen-apply-raw-btn" class="wfm-btn wfm-btn-sm">Apply JSON</button>
|
||||
</div>
|
||||
<div class="wfm-json-container" style="height:500px;">
|
||||
<pre id="wfm-gen-raw-json-highlight" class="wfm-json-highlight"></pre>
|
||||
<textarea id="wfm-gen-raw-json" class="wfm-json-editor" spellcheck="false" placeholder="Paste workflow JSON here..."></textarea>
|
||||
<div class="wfm-gen-tab-cols">
|
||||
<div class="wfm-gen-params-col wfm-gen-params-col--scroll">
|
||||
<div id="wfm-gen-settings-fields">
|
||||
<p class="wfm-placeholder">Load a workflow to edit settings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-settings"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +324,7 @@
|
||||
<!-- Seed -->
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
|
||||
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
|
||||
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:140px;">
|
||||
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:130px;">
|
||||
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:auto;">
|
||||
<option value="random">Random</option>
|
||||
<option value="fixed">Fixed</option>
|
||||
@@ -232,14 +341,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON shared widget (moved into active tab's rawjson-col by JS) -->
|
||||
<div id="wfm-gen-rawjson-widget" style="display:none;flex-direction:column;height:100%;">
|
||||
<div class="wfm-gen-rawjson-header">
|
||||
<span class="wfm-gen-rawjson-label">Raw JSON</span>
|
||||
<button id="wfm-gen-apply-raw-btn" class="wfm-btn wfm-btn-sm">Apply</button>
|
||||
</div>
|
||||
<div class="wfm-json-container wfm-gen-rawjson-editor">
|
||||
<pre id="wfm-gen-raw-json-highlight" class="wfm-json-highlight"></pre>
|
||||
<textarea id="wfm-gen-raw-json" class="wfm-json-editor" spellcheck="false" placeholder="Paste workflow JSON here..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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;">
|
||||
@@ -275,30 +396,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>Category</label>
|
||||
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
|
||||
</div>
|
||||
<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" rows="4" placeholder="Positive prompt..."></textarea>
|
||||
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<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>
|
||||
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;">Save Preset</button>
|
||||
<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>
|
||||
@@ -309,6 +461,307 @@
|
||||
<div class="wfm-settings-container">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Help & Support Tab -->
|
||||
<section id="wfm-tab-help" class="wfm-tab-content">
|
||||
<div class="wfm-help-container">
|
||||
<!-- Help Section -->
|
||||
<div class="wfm-help-section">
|
||||
<h2 class="wfm-help-section-title" id="wfm-help-manual-title">Features</h2>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-about-title">About</h3>
|
||||
<p id="wfm-help-about-desc">A centralized control UI for managing ComfyUI workflow JSON files, generating images via API, assisting with prompt creation, and browsing/organizing nodes.</p>
|
||||
<ul>
|
||||
<li id="wfm-help-about-1">Install via ComfyUI Manager or git clone to custom_nodes</li>
|
||||
<li id="wfm-help-about-2">Launch from the Workflow Studio button in the ComfyUI toolbar, or open http://127.0.0.1:8188/wfm</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>Workflow Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-wf-1">Switch between Thumbnail, Card, and Table view modes</li>
|
||||
<li id="wfm-help-wf-2">Search, category filter, favorites filter, and group filter</li>
|
||||
<li id="wfm-help-wf-3">Create, rename, delete groups and assign/remove workflows</li>
|
||||
<li id="wfm-help-wf-4">Import JSON/PNG files via button or drag & drop</li>
|
||||
<li id="wfm-help-wf-5">Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management</li>
|
||||
<li id="wfm-help-wf-6">Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete</li>
|
||||
<li id="wfm-help-wf-7">Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>GenerateUI Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gen-1">Connect/disconnect ComfyUI and check connection status</li>
|
||||
<li id="wfm-help-gen-2">Load workflows from Workflow tab (App format .app.json not supported)</li>
|
||||
<li id="wfm-help-gen-3">3-tab layout — Input (Prompt + Image), Model, Settings — each with a Raw JSON column for instant preview and direct editing</li>
|
||||
<li id="wfm-help-gen-4">Input tab: Prompt (top) and Image drag-and-drop (bottom) in the left column</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler (top) and LatentImage (bottom) in the left column</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow</li>
|
||||
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement</li>
|
||||
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
|
||||
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>Prompt Tab</h3>
|
||||
<ul>
|
||||
<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, 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>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>Settings Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-settings-1">UI language and summary language</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">Data Management: export all plugin data to JSON / import to restore</li>
|
||||
<li id="wfm-help-settings-9">All sections are collapsible accordion panels</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>Nodes Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-nodes-1">Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites</li>
|
||||
<li id="wfm-help-nodes-2">Card and Table view modes with pagination</li>
|
||||
<li id="wfm-help-nodes-3">Favorite nodes and add custom tags for organization</li>
|
||||
<li id="wfm-help-nodes-4">Create, rename, delete node groups and assign/remove nodes</li>
|
||||
<li id="wfm-help-nodes-5">Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management</li>
|
||||
<li id="wfm-help-nodes-6">Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-gallery-title">Gallery Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-1">Browse images in the ComfyUI output folder — set the folder path in Settings > Gallery Output Folder</li>
|
||||
<li id="wfm-help-gallery-2">3-column layout: folder tree (left) | image grid (center) | detail panel (right)</li>
|
||||
<li id="wfm-help-gallery-3">Thumbnail and Table view modes; double-click any image to open fullscreen lightbox</li>
|
||||
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name</li>
|
||||
<li id="wfm-help-gallery-5">Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All</li>
|
||||
<li id="wfm-help-gallery-6">Detail panel — Info tab: filename, size, date, tags (add/remove), memo with save</li>
|
||||
<li id="wfm-help-gallery-7">Detail panel — Metadata tab: embedded ComfyUI workflow JSON viewer with one-click clipboard copy</li>
|
||||
<li id="wfm-help-gallery-8">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image</li>
|
||||
<li id="wfm-help-gallery-9">Supports PNG (tEXt/iTXt metadata), JPEG, WebP, GIF; PNG workflow extraction reads ComfyUI-embedded JSON</li>
|
||||
<li id="wfm-help-gallery-10">Folder tree: click a folder to load its images; expand subfolders with ▶ arrow</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>Models Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding 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>
|
||||
<li id="wfm-help-models-8">GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)</li>
|
||||
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab</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: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view</li>
|
||||
<li id="wfm-help-sidepanel-3">Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes</li>
|
||||
<li id="wfm-help-sidepanel-11">Models tab: All / ★ Favorites / Groups / By Type sub-tabs</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>
|
||||
<li id="wfm-help-sidepanel-12">⚙ Theme button (header): customize panel background, text, border colors — saved to localStorage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-shortcuts-title">Keyboard Shortcuts</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-shortcuts-1">Escape: Close modal/panel</li>
|
||||
<li id="wfm-help-shortcuts-2">Enter: Confirm rename, send chat</li>
|
||||
<li id="wfm-help-shortcuts-3">Shift+Enter: New line in chat</li>
|
||||
<li id="wfm-help-shortcuts-4">Double-click: Open detail modal</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-trouble-title">Troubleshooting</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-trouble-1">Cannot connect to ComfyUI: Check if ComfyUI can generate images, verify URL in Settings, try refreshing or restarting</li>
|
||||
<li id="wfm-help-trouble-2">Cannot connect to Ollama: Use connection test button, check if Ollama is running and models are downloaded</li>
|
||||
<li id="wfm-help-trouble-3">Workflows not showing: Click refresh button, check if loadable in ComfyUI, try restarting browser/ComfyUI</li>
|
||||
<li id="wfm-help-trouble-4">Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support Section -->
|
||||
<div class="wfm-help-section">
|
||||
<h2 class="wfm-help-section-title" id="wfm-help-support-title">Support</h2>
|
||||
|
||||
<div class="wfm-help-card wfm-help-support-card">
|
||||
<p id="wfm-help-support-desc">If you find this plugin useful, please consider supporting its development!</p>
|
||||
|
||||
<div class="wfm-help-links">
|
||||
<a href="https://github.com/ketle-man/ComfyUI-Workflow-Studio" target="_blank" rel="noopener noreferrer" class="wfm-help-link wfm-help-link-github">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
<div>
|
||||
<div class="wfm-help-link-title">GitHub</div>
|
||||
<div class="wfm-help-link-desc" id="wfm-help-github-desc">Report issues, request features, and contribute</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="https://ko-fi.com/ninnin90033" target="_blank" rel="noopener noreferrer" class="wfm-help-link wfm-help-link-kofi">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z"/></svg>
|
||||
<div>
|
||||
<div class="wfm-help-link-title">Ko-fi</div>
|
||||
<div class="wfm-help-link-desc" id="wfm-help-kofi-desc">Buy me a coffee to support development</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="wfm-help-thanks" id="wfm-help-thanks">Thank you for using Workflow Studio!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Gallery Tab -->
|
||||
<section id="wfm-tab-gallery" class="wfm-tab-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="wfm-toolbar">
|
||||
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<button id="wfm-gallery-fav-btn" class="wfm-filter-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
|
||||
<select id="wfm-gallery-tag-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
<select id="wfm-gallery-group-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Groups</option>
|
||||
</select>
|
||||
<select id="wfm-gallery-sort" class="wfm-select wfm-toolbar-select">
|
||||
<option value="date_desc">Date (Newest)</option>
|
||||
<option value="date_asc">Date (Oldest)</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
</select>
|
||||
<div class="wfm-view-btns">
|
||||
<button class="wfm-view-btn active" data-gallery-view="thumb" title="Thumbnail">⊞</button>
|
||||
<button class="wfm-view-btn" data-gallery-view="table" title="Table">≡</button>
|
||||
</div>
|
||||
<span id="wfm-gallery-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-gallery-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
</div>
|
||||
<!-- 複数選択時の一括操作バー -->
|
||||
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
|
||||
<span id="wfm-gallery-bulk-count" class="wfm-gallery-bulk-count"></span>
|
||||
<button id="wfm-gallery-bulk-deselect" class="wfm-btn wfm-btn-sm">Deselect All</button>
|
||||
<select id="wfm-gallery-bulk-group-select" class="wfm-select" style="font-size:12px;padding:3px 6px;">
|
||||
<option value="">Add to Group...</option>
|
||||
</select>
|
||||
<button id="wfm-gallery-bulk-group-add" class="wfm-btn wfm-btn-sm wfm-btn-primary">Add</button>
|
||||
<button id="wfm-gallery-bulk-fav" class="wfm-btn wfm-btn-sm">★ Favorite All</button>
|
||||
<button id="wfm-gallery-bulk-unfav" class="wfm-btn wfm-btn-sm">☆ Unfavorite All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3カラムレイアウト -->
|
||||
<div class="wfm-gallery-layout">
|
||||
<!-- 左: フォルダツリー -->
|
||||
<div id="wfm-gallery-tree-panel" class="wfm-gallery-tree-panel">
|
||||
<div class="wfm-gallery-tree-header">
|
||||
<span class="wfm-gallery-tree-title">Folders</span>
|
||||
</div>
|
||||
<div id="wfm-gallery-tree" class="wfm-gallery-tree">
|
||||
<p class="wfm-placeholder" id="wfm-gallery-tree-placeholder">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中: 画像一覧 -->
|
||||
<div id="wfm-gallery-list-panel" class="wfm-gallery-list-panel">
|
||||
<div id="wfm-gallery-grid" class="wfm-gallery-grid wfm-gallery-view-thumb">
|
||||
<p class="wfm-placeholder" id="wfm-gallery-list-placeholder">Select a folder to browse images.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右: 詳細情報サイドパネル -->
|
||||
<div id="wfm-gallery-detail-panel" class="wfm-gallery-detail-panel">
|
||||
<div class="wfm-gallery-detail-header">
|
||||
<span id="wfm-gallery-detail-filename" class="wfm-gallery-detail-filename"></span>
|
||||
</div>
|
||||
<!-- サムネイルプレビュー -->
|
||||
<div id="wfm-gallery-detail-preview" class="wfm-gallery-detail-preview">
|
||||
<span class="wfm-placeholder">No selection</span>
|
||||
</div>
|
||||
<!-- タブナビ -->
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="info">Info</button>
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="meta">Metadata</button>
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="group">Groups</button>
|
||||
</div>
|
||||
<!-- Info タブ -->
|
||||
<div id="wfm-gallery-detail-info" class="wfm-gallery-detail-tab-content active">
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-info-name"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-info-size"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-info-date"></div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<!-- タグ -->
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-section-title">Tags</div>
|
||||
<div id="wfm-gallery-tags-display" class="wfm-gallery-tags-display"></div>
|
||||
<div style="display:flex;gap:4px;margin-top:4px;">
|
||||
<input type="text" id="wfm-gallery-tag-input" class="wfm-input" placeholder="Add tag..." style="flex:1;min-width:0;font-size:12px;padding:4px 6px;">
|
||||
<button id="wfm-gallery-tag-add-btn" class="wfm-btn wfm-btn-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<!-- メモ -->
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-section-title">Memo</div>
|
||||
<textarea id="wfm-gallery-memo" class="wfm-gallery-memo" placeholder="Write a memo..." rows="3"></textarea>
|
||||
<button id="wfm-gallery-memo-save-btn" class="wfm-btn wfm-btn-sm" style="margin-top:4px;width:100%;">Save Memo</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata タブ -->
|
||||
<div id="wfm-gallery-detail-meta" class="wfm-gallery-detail-tab-content wfm-gallery-meta-tab" style="display:none;">
|
||||
<div class="wfm-gallery-meta-toolbar">
|
||||
<span id="wfm-gallery-meta-status" class="wfm-gallery-meta-status"></span>
|
||||
<button id="wfm-gallery-copy-workflow-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Copy embedded workflow JSON">Copy Workflow</button>
|
||||
</div>
|
||||
<pre id="wfm-gallery-workflow-json" class="wfm-gallery-workflow-json">No workflow found in this image.</pre>
|
||||
</div>
|
||||
|
||||
<!-- Group タブ -->
|
||||
<div id="wfm-gallery-detail-group" class="wfm-gallery-detail-tab-content" style="display:none;overflow-y:auto;">
|
||||
<!-- JS動的生成: renderDetailGroup() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Workflow Detail Modal -->
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Node Library Sidebar Panel
|
||||
*
|
||||
* Fixed sidebar panel injected into ComfyUI DOM.
|
||||
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
|
||||
* 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 = "Node Library – Browse & drag nodes onto canvas";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
const state = {
|
||||
visible: false,
|
||||
activeTab: "favorites", // favorites | sets | groups
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
metadata: {},
|
||||
searchText: "",
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API
|
||||
// ============================================
|
||||
|
||||
const fetchNodeSets = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/groups");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
for (const [name, meta] of Object.entries(metadata)) {
|
||||
if (name === "_groups") continue;
|
||||
if (meta.favorite) {
|
||||
state.favorites.push({ name, display_name: name });
|
||||
}
|
||||
}
|
||||
state.loaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas placement helpers
|
||||
// ============================================
|
||||
|
||||
const getCanvasDropPos = (e) => {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return [100, 100];
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
|
||||
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const placeSingleNode = (classType, pos) => {
|
||||
const graph = app.graph;
|
||||
if (!graph) return;
|
||||
const node = LiteGraph.createNode(classType);
|
||||
if (!node) {
|
||||
showToast(`Unknown node: ${classType}`, "error");
|
||||
return;
|
||||
}
|
||||
node.pos = pos || [100, 100];
|
||||
graph.add(node);
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: ${node.title || classType}`, "success");
|
||||
};
|
||||
|
||||
const placeNodeSet = (nodeSet, pos) => {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas) return;
|
||||
|
||||
const nodes = nodeSet.nodes || [];
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
|
||||
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
|
||||
|
||||
const positions = nodes.map(n => n.rel_pos || [0, 0]);
|
||||
const minX = Math.min(...positions.map(p => p[0]));
|
||||
const minY = Math.min(...positions.map(p => p[1]));
|
||||
|
||||
const createdNodes = [];
|
||||
for (const nodeDef of nodes) {
|
||||
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
|
||||
const node = LiteGraph.createNode(nodeDef.class_type);
|
||||
if (!node) { createdNodes.push(null); continue; }
|
||||
const rp = nodeDef.rel_pos || [0, 0];
|
||||
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
|
||||
if (nodeDef.title) node.title = nodeDef.title;
|
||||
if (nodeDef.widget_values && node.widgets) {
|
||||
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
|
||||
const w = node.widgets.find(ww => ww.name === k);
|
||||
if (w) w.value = v;
|
||||
}
|
||||
}
|
||||
graph.add(node);
|
||||
createdNodes.push(node);
|
||||
}
|
||||
|
||||
for (const link of (nodeSet.links || [])) {
|
||||
const from = createdNodes[link.from_node];
|
||||
const to = createdNodes[link.to_node];
|
||||
if (from && to) {
|
||||
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setDirty(true, true);
|
||||
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Drag & Drop onto canvas
|
||||
// ============================================
|
||||
|
||||
let dropHandlerInstalled = false;
|
||||
|
||||
const installCanvasDropHandler = () => {
|
||||
if (dropHandlerInstalled) return;
|
||||
dropHandlerInstalled = true;
|
||||
|
||||
const canvasEl = document.getElementById("graph-canvas");
|
||||
if (!canvasEl) return;
|
||||
|
||||
canvasEl.addEventListener("dragover", (e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener("drop", (e) => {
|
||||
const raw = e.dataTransfer.getData("application/x-wfm-node");
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
const pos = getCanvasDropPos(e);
|
||||
|
||||
if (data.type === "single") {
|
||||
placeSingleNode(data.classType, pos);
|
||||
} else if (data.type === "set") {
|
||||
const set = state.nodeSets.find(s => s.id === data.setId);
|
||||
if (set) placeNodeSet(set, pos);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Sidebar Panel DOM
|
||||
// ============================================
|
||||
|
||||
let panelEl = null;
|
||||
|
||||
const PANEL_ID = "wfm-node-library-panel";
|
||||
|
||||
const createPanel = () => {
|
||||
if (panelEl) return panelEl;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = PANEL_ID;
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">Node 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 active" data-tab="favorites">★ Favorites</button>
|
||||
<button class="wfm-nlp-tab" data-tab="sets">☰ Sets</button>
|
||||
<button class="wfm-nlp-tab" data-tab="groups">📁 Groups</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-search">
|
||||
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
|
||||
</div>
|
||||
<div class="wfm-nlp-content"></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(panel);
|
||||
panelEl = panel;
|
||||
|
||||
// Tab switching
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab = btn.dataset.tab;
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
// Search
|
||||
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
|
||||
state.searchText = e.target.value.toLowerCase().trim();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
// Refresh
|
||||
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
|
||||
await loadData();
|
||||
renderContent();
|
||||
showToast("Node Library refreshed", "success");
|
||||
});
|
||||
|
||||
injectStyles();
|
||||
return panel;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render content
|
||||
// ============================================
|
||||
|
||||
const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
if (!state.loaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state.activeTab) {
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFavorites = (container) => {
|
||||
let items = state.favorites;
|
||||
if (state.searchText) {
|
||||
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const node of items) {
|
||||
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeSingleNode(node.name);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSets = (container) => {
|
||||
let items = state.nodeSets;
|
||||
if (state.searchText) {
|
||||
items = items.filter(s =>
|
||||
s.name.toLowerCase().includes(state.searchText) ||
|
||||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const set of items) {
|
||||
const count = (set.nodes || []).length;
|
||||
const el = createDraggableItem(
|
||||
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
|
||||
"set",
|
||||
{ setId: set.id },
|
||||
set.description
|
||||
);
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeNodeSet(set);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (container) => {
|
||||
const groupNames = Object.keys(state.groups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let filtered = groupNames;
|
||||
if (state.searchText) {
|
||||
filtered = groupNames.filter(g =>
|
||||
g.toLowerCase().includes(state.searchText) ||
|
||||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const groupName of filtered) {
|
||||
const nodes = state.groups[groupName] || [];
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.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";
|
||||
for (const nodeName of nodes) {
|
||||
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
|
||||
list.appendChild(el);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Draggable item factory
|
||||
// ============================================
|
||||
|
||||
const createDraggableItem = (label, type, data, subtitle) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.draggable = true;
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-label">${label}</div>
|
||||
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
|
||||
`;
|
||||
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
|
||||
export const togglePanel = async () => {
|
||||
const panel = createPanel();
|
||||
state.visible = !state.visible;
|
||||
panel.style.display = state.visible ? "flex" : "none";
|
||||
|
||||
if (state.visible && !state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
|
||||
installCanvasDropHandler();
|
||||
};
|
||||
|
||||
export const getNodeSetsIcon = () => `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<path d="M17.5 14v7M14 17.5h7"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Toast
|
||||
// ============================================
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
const existing = document.getElementById("wfm-ns-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-ns-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)" },
|
||||
error: { bg: "rgba(255,71,87,0.95)" },
|
||||
info: { bg: "rgba(74,158,255,0.95)" },
|
||||
};
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
|
||||
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
|
||||
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
};
|
||||
|
||||
const esc = (s) => s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : "";
|
||||
|
||||
// ============================================
|
||||
// Save selected nodes as Node Set (context menu)
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for name
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
// Build node list with relative positions
|
||||
// Use the top-left of the bounding box as origin
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
// Extract links between selected nodes
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
// Resolve link data
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
// or object with .origin_id, .origin_slot, etc.
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
// Only include if both source and target are in the selection
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save via API
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
// Refresh sidebar if open
|
||||
if (state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Register context menu item on all nodes
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.NodeSetContextMenu",
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
|
||||
// Add separator + menu item
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Styles
|
||||
// ============================================
|
||||
|
||||
const injectStyles = () => {
|
||||
if (document.getElementById("wfm-nlp-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wfm-nlp-styles";
|
||||
style.textContent = `
|
||||
#${PANEL_ID} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--comfy-menu-bg, #1e1e1e);
|
||||
border-left: 1px solid var(--border-color, #4e4e4e);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--input-text, #ddd);
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.wfm-nlp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wfm-nlp-close:hover { opacity: 1; }
|
||||
.wfm-nlp-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-nlp-tab:hover { opacity: 0.9; }
|
||||
.wfm-nlp-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
border-radius: 4px;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wfm-nlp-search-input:focus {
|
||||
border-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.wfm-nlp-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--descrip-text, #999);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wfm-nlp-item {
|
||||
padding: 7px 12px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
.wfm-nlp-item:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wfm-nlp-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #888);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-nlp-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--comfy-input-bg, #444);
|
||||
border-radius: 3px;
|
||||
color: var(--descrip-text, #aaa);
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.wfm-nlp-group-section {
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
}
|
||||
.wfm-nlp-group-header {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.wfm-nlp-group-header:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-group-header::before {
|
||||
content: "\\25BC";
|
||||
font-size: 8px;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wfm-nlp-group-header.collapsed::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.wfm-nlp-group-list {
|
||||
padding-left: 8px;
|
||||
}
|
||||
.wfm-nlp-group-list .wfm-nlp-item {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wfm-nlp-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wfm-nlp-refresh:hover { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Context Menu — Save selected nodes as Node Set
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
if (state.loaded) { await loadData(); renderContent(); }
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
export { saveSelectedAsNodeSet };
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
@@ -167,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");
|
||||
@@ -383,15 +477,47 @@ const createSnapshotButton = async () => {
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
if (!settingsGroup?.element?.parentElement) {
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI settings button group.");
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,109 +527,128 @@ const attachTopMenuButton = async (attempt = 0) => {
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton);
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
};
|
||||
|
||||
const createExtensionObject = (useActionBar) => {
|
||||
const extensionObj = {
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
async setup() {
|
||||
if (!useActionBar) {
|
||||
console.log("Workflow Studio: using legacy button attachment (frontend version < 1.33.9)");
|
||||
await attachTopMenuButton();
|
||||
} else {
|
||||
console.log("Workflow Studio: using actionBarButtons API (frontend version >= 1.33.9)");
|
||||
}
|
||||
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
// WFM launch button
|
||||
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";
|
||||
}
|
||||
});
|
||||
|
||||
// Snapshot button
|
||||
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";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
};
|
||||
|
||||
if (useActionBar) {
|
||||
extensionObj.actionBarButtons = [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
];
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
|
||||
return extensionObj;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const useActionBar = await supportsActionBarButtons();
|
||||
const extensionObj = createExtensionObject(useActionBar);
|
||||
app.registerExtension(extensionObj);
|
||||
})();
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
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";
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
console.log("[WFM] top_menu_extension.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] app imported:", !!app);
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
console.log("[WFM] node_sets_menu imports loaded");
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
const WFM_PATH = "/wfm";
|
||||
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
|
||||
const MAX_ATTACH_ATTEMPTS = 120;
|
||||
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
const openWorkflowStudio = (event) => {
|
||||
const url = `${window.location.origin}${WFM_PATH}`;
|
||||
|
||||
if (event.shiftKey) {
|
||||
window.open(url, "_blank", NEW_WINDOW_FEATURES);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas Snapshot - Export workflow as PNG thumbnail
|
||||
// ============================================
|
||||
|
||||
const getWorkflowFilename = () => {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
return `wf_${stamp}.json`;
|
||||
};
|
||||
|
||||
// ---- PNG tEXt chunk embedding helpers ----
|
||||
|
||||
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
|
||||
const joinArrayBuffer = (...bufs) => {
|
||||
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
|
||||
bufs.reduce((offset, buf) => {
|
||||
result.set(buf, offset);
|
||||
return offset + buf.byteLength;
|
||||
}, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
let _crcTable = null;
|
||||
const crc32 = (data) => {
|
||||
if (!_crcTable) {
|
||||
_crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
_crcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
};
|
||||
|
||||
const embedWorkflowInPng = async (blob, workflowJson) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const typedArr = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// Create tEXt chunk: "tEXtworkflow\0{json}"
|
||||
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
|
||||
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
|
||||
|
||||
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
|
||||
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
|
||||
|
||||
return new Blob([result], { type: "image/png" });
|
||||
};
|
||||
|
||||
// ---- Canvas capture ----
|
||||
|
||||
const captureCanvasSnapshot = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas || !graph._nodes?.length) {
|
||||
reject(new Error("No workflow loaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize workflow data before changing canvas state
|
||||
const workflowJson = JSON.stringify(graph.serialize());
|
||||
|
||||
// Calculate bounds of all nodes
|
||||
const bounds = graph._nodes.reduce(
|
||||
(p, n) => {
|
||||
if (n.pos[0] < p[0]) p[0] = n.pos[0];
|
||||
if (n.pos[1] < p[1]) p[1] = n.pos[1];
|
||||
const b = n.getBounding();
|
||||
const r = n.pos[0] + b[2];
|
||||
const bot = n.pos[1] + b[3];
|
||||
if (r > p[2]) p[2] = r;
|
||||
if (bot > p[3]) p[3] = bot;
|
||||
return p;
|
||||
},
|
||||
[99999, 99999, -99999, -99999]
|
||||
);
|
||||
|
||||
// Add padding
|
||||
const padding = 80;
|
||||
bounds[0] -= padding;
|
||||
bounds[1] -= padding;
|
||||
bounds[2] += padding;
|
||||
bounds[3] += padding;
|
||||
|
||||
// Save current canvas state
|
||||
const savedState = {
|
||||
scale: canvas.ds.scale,
|
||||
width: canvas.canvas.width,
|
||||
height: canvas.canvas.height,
|
||||
offset: [...canvas.ds.offset],
|
||||
transform: canvas.canvas.getContext("2d").getTransform(),
|
||||
};
|
||||
|
||||
// Set canvas to render full workflow
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.ds.scale = 1;
|
||||
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
|
||||
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
|
||||
canvas.ds.offset = [-bounds[0], -bounds[1]];
|
||||
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Render
|
||||
canvas.draw(true, true);
|
||||
|
||||
// Capture to blob
|
||||
canvas.canvas.toBlob(async (rawBlob) => {
|
||||
// Restore canvas state
|
||||
canvas.ds.scale = savedState.scale;
|
||||
canvas.canvas.width = savedState.width;
|
||||
canvas.canvas.height = savedState.height;
|
||||
canvas.ds.offset = savedState.offset;
|
||||
canvas.canvas.getContext("2d").setTransform(savedState.transform);
|
||||
canvas.draw(true, true);
|
||||
|
||||
if (!rawBlob) {
|
||||
reject(new Error("Failed to generate canvas image"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Embed workflow JSON into PNG tEXt chunk
|
||||
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
|
||||
resolve(blob);
|
||||
} catch (embedErr) {
|
||||
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
|
||||
resolve(rawBlob);
|
||||
}
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveCanvasToWorkflowStudio = async () => {
|
||||
const filename = getWorkflowFilename();
|
||||
|
||||
try {
|
||||
showNotification("Capturing workflow image...", "info");
|
||||
const blob = await captureCanvasSnapshot();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("filename", filename);
|
||||
fd.append("image", blob, "canvas.png");
|
||||
|
||||
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
|
||||
} catch (err) {
|
||||
showNotification("Error: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const showNotification = (message, type = "info") => {
|
||||
// Try using ComfyUI's built-in toast if available
|
||||
try {
|
||||
if (app.ui?.dialog) {
|
||||
// Fallback: use a simple temporary notification
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Simple toast notification
|
||||
const existing = document.getElementById("wfm-snapshot-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-snapshot-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
|
||||
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
|
||||
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
|
||||
};
|
||||
const c = colors[type] || colors.info;
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed",
|
||||
bottom: "24px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
padding: "10px 24px",
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
||||
transition: "opacity 0.3s ease",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
if (type !== "info") {
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
} else {
|
||||
// Info messages will be replaced by success/error
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Version Detection & UI
|
||||
// ============================================
|
||||
|
||||
const getComfyUIFrontendVersion = async () => {
|
||||
try {
|
||||
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
|
||||
return window["__COMFYUI_FRONTEND_VERSION__"];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/system_stats");
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.system?.comfyui_frontend_version) {
|
||||
return data.system.comfyui_frontend_version;
|
||||
}
|
||||
|
||||
if (data?.system?.required_frontend_version) {
|
||||
return data.system.required_frontend_version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to fetch system_stats:", error);
|
||||
}
|
||||
|
||||
return "0.0.0";
|
||||
};
|
||||
|
||||
const parseVersion = (versionStr) => {
|
||||
if (!versionStr || typeof versionStr !== "string") {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
|
||||
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const compareVersions = (version1, version2) => {
|
||||
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
|
||||
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1[i] > v2[i]) return 1;
|
||||
if (v1[i] < v2[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const supportsActionBarButtons = async () => {
|
||||
const version = await getComfyUIFrontendVersion();
|
||||
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
|
||||
};
|
||||
|
||||
const getWfmIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
|
||||
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
const getSnapshotIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M8 5 L9 3 L15 3 L16 5"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Button Creation
|
||||
// ============================================
|
||||
|
||||
const createTopMenuButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "wfm",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
|
||||
button.element.title = BUTTON_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getWfmIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", openWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createSnapshotButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "snapshot",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
|
||||
button.element.title = SNAPSHOT_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getSnapshotIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
});
|
||||