Compare commits

...
25 Commits
Author SHA1 Message Date
ketle-manandClaude Opus 4.6 7cc0713b29 Add Models tab with CivitAI integration, model browser, and management features (v0.2.2)
- Models tab: browse Checkpoint/LoRA/VAE/ControlNet/UNET/TextEncoder with sub-tabs
- CivitAI integration: fetch model info by SHA256 hash, batch fetch with SSE progress
- Model groups, metadata persistence (favorites, tags, memo), preview images
- Table view memo column, file path display in side panel
- Node card redesign: left border color-coding by package
- Toolbar pagination for all tabs, updated Help tab and documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:46:13 +09:00
ketle-manandClaude Opus 4.6 4a890d1312 Fix SVG pattern disappearing when gap > 0 for non-standard viewBox
SVGs with non-default viewBox (e.g. "0 -960 960 960") lost their content
when wrapped in a nested SVG for gap spacing, because the inner SVG did
not inherit the original viewBox. Now extracted and preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 09:49:15 +09:00
ketle-manandClaude Opus 4.6 6a465ee231 Add theme customization: color overrides, background patterns, font selection (v0.2.1)
- Theme customizer panel with 6 color pickers for live CSS variable overrides
- Background patterns: stripes (H/V/D), polka dot, checkerboard, custom SVG tiling
- SVG pattern controls: color (with style injection for full recoloring), opacity, scale, gap
- Font selector: 16 Google Fonts including Japanese display fonts, dynamically loaded
- Settings tab sections converted to collapsible accordion panels (details/summary)
- Fix customizer reset not clearing colors (localStorage spread merge issue)
- Help tab Settings section updated with new feature descriptions
- i18n: EN/JA/ZH translations for all new UI labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 09:21:36 +09:00
ketle-manandClaude Opus 4.6 29f1ff9f58 Fix topbar button icons not displaying due to Vue re-render
The three WFS buttons (Workflow Studio, Snapshot, Library) showed blank
icons because Vue re-rendered the DOM after SVG injection, reverting to
unsupported icon-[mdi--*] classes. Use MutationObserver to re-apply SVGs
whenever the container DOM changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 00:22:25 +09:00
ketle-manandClaude Opus 4.6 2be048f13c Add DEVLOG entry for v0.2.0 prompt presets feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:25:11 +09:00
ketle-manandClaude Opus 4.6 4ffbcbb932 Add prompt presets feature with sidebar integration and Preset Manager (v0.2.0)
- WFS_PromptText custom node with positive/negative prompt outputs
- Prompts tab in Workflow Studio Library sidebar (All/Favorites/Categories)
- Drag & drop presets to canvas, P/N copy buttons in sidebar
- 3-column Prompt tab layout: AI Assistant | Presets | Preset Manager
- Preset Manager with favorites, groups, search
- Backend API for presets (migrated from localStorage)
- Renamed sidebar to "Workflow Studio Library"
- Updated Help tab, README, and screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:16:55 +09:00
ketle-manandClaude Opus 4.6 463fc3f2c8 Add missing format field to analysis fallback in list_workflows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:21:02 +09:00
ketle-manandClaude Opus 4.6 7ce331ca81 Improve App format detection from filename-only to structure-based
- Detect App format by checking for `definitions` key (subgraphs) or
  `extra.linearMode === true` in both Python analyzer and JS detectFormat
- Filename `.app.json` kept as fallback only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:18:46 +09:00
ketle-manandClaude Opus 4.6 30305cc7c0 Improve side panel UI, add save dialog, fix group cleanup (v0.1.9)
- Restructure side panel sub-tabs into 2 rows: Workflows/Favorites/Groups + Model Type; Nodes/Favorites/Groups + Sets
- Add filename edit dialog for canvas snapshot save instead of auto-naming
- Show API/App format badges on workflow items in side panel
- Auto-cleanup deleted workflows from groups on data load
- Add workflow format detection (ui/api/app) to analyzer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:05:05 +09:00
ketle-manandClaude Opus 4.6 5e9e0e07d0 Add WF & Node Library side panel with workflow drag & drop (v0.1.8)
Extend the Node Library side panel to "WF & Node Library" with two-level
tabs (Workflows / Nodes). Workflows tab adds favorites, model type filter,
and group filter with drag & drop loading onto the ComfyUI canvas.
Also adds Nodes Tab and side panel sections to the Help tab feature list
with EN/JA/ZH translations, and updates README with new screenshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 11:47:18 +09:00
ketle-manandClaude Opus 4.6 6ab44039cb Add Nodes tab with node browser and node sets (v0.1.7)
Update DEVLOG and README with v0.1.7 features, changelog,
and project structure. Change pagination to 50 nodes per page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:46:12 +09:00
ketle-manandClaude Opus 4.6 a6f45ef6a5 Fix duplicate const declaration in node_sets_menu.js that silently broke ESM loading
Remove duplicate saveSelectedAsNodeSet definition and redundant
WorkflowStudio.NodeSetContextMenu registration. Add export keyword
to the remaining definition. Remove debug console.log lines from
top_menu_extension.js. Also includes Nodes tab UI improvements
(pagination, card layout, table row selection).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:27:56 +09:00
ketle-manandClaude Opus 4.6 c3c0a41ccf Add Nodes tab for browsing, organizing, and managing ComfyUI nodes
- Node Browser: fetch all nodes from /object_info, card/table views,
  filter by category/package/tag/group/favorites, full-text search
- Side panel with node details, I/O spec table, tag editor, group management
- Node Sets: save multi-node combinations, copy to clipboard as workflow JSON
- Backend: nodes_service.py for metadata/groups/sets persistence,
  nodes_routes.py with 9 API endpoints
- i18n: EN/JA/ZH translations for all new UI elements
- Lazy loading on first tab click to avoid slowing initial page load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:14:30 +09:00
ketle-manandClaude Opus 4.6 41ce0128e4 Update DEVLOG for v0.1.6 path traversal security fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:31:12 +09:00
ketle-manandClaude Opus 4.6 42ffd8d4ce Fix path traversal vulnerability in workflow_service.py
Add _safe_path() method that validates filenames and ensures resolved
paths stay within workflows_dir. Apply validation to all public methods:
import_files, get_raw, rename, delete, analyze, change_thumbnail.

Addresses review feedback from ComfyUI-Manager PR #2706.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:29:29 +09:00
ketle-manandClaude Opus 4.6 fc82c0fed0 Update screenshots with default theme (Deep Ocean Dark)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:28:00 +09:00
ketle-manandClaude Opus 4.6 27bab40f78 v0.1.5: Add theme system with 13 built-in themes
Add theme selection UI to Settings tab with visual swatch previews.
13 themes: Deep Ocean Dark (default), Pop & Vibrant, Light Minimalist,
Cyberpunk, Glassmorphism, Neumorphism, Retro 8-bit, Pastel Dream,
Brutalism, Earthy, Material UI, Monotone + Accent, Corporate Trust.

Theme-specific effects: neon glow borders (Cyberpunk), backdrop blur
(Glassmorphism), dual shadow (Neumorphism), pixel borders (Retro/Brutalism).
Theme persisted in localStorage and restored before DOM render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:23:37 +09:00
ketle-manandClaude Opus 4.6 11071e1c2b Update Help tab with comprehensive feature details and new sections
Add About, Keyboard Shortcuts, and Troubleshooting sections.
Expand Workflow/GenerateUI/Prompt/Settings descriptions with full
feature coverage including App format support and clipboard copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 09:50:16 +09:00
ketle-manandClaude Opus 4.6 5add20c9be Fix App format load showing success message instead of error only
loadWorkflowIntoEditor now returns false when blocked, callers skip
tab switch and success toast accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 09:03:05 +09:00
ketle-manandClaude Opus 4.6 8a4d5c5fa8 v0.1.4: App format support, preset clipboard copy, analysis bugfix
- Detect .app.json workflows (ComfyUI App mode) with badge and GenerateUI block
- Add PP Copy / NP Copy buttons for preset prompt clipboard copy
- Fix workflow analysis crash on non-string widgets_values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 08:50:39 +09:00
ketle-manandClaude Opus 4.6 1dcecc93cd v0.1.3: Add Help & Support tab with feature list and support links
- Add Help tab with feature overview cards for each tab (Workflow, GenerateUI, Prompt, Settings)
- Add Tips section and Support section with GitHub and Ko-fi links
- Add i18n translations for help content (EN/JA/ZH)
- Update README and DEVLOG for v0.1.3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:17:08 +09:00
ketle-manandClaude Opus 4.6 adc59bf6ad Update DEVLOG: Canvas Snapshot, Thumbnail preview, v0.1.2 release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:22:00 +09:00
ketle-manandClaude Opus 4.6 a5e481a6a5 v0.1.2: Add Canvas Snapshot and Thumbnail preview
- Add canvas snapshot button (camera icon) to ComfyUI top bar
- Capture workflow canvas as PNG with embedded workflow metadata (tEXt chunk)
- Auto-import captured snapshots to Workflow tab with thumbnails
- Add Thumbnail tab to workflow side panel for visual preview
- Add Thumbnail section to workflow detail modal
- Update README with v0.1.2 features, screenshots, and acknowledgements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:19:45 +09:00
ketle-manandClaude Opus 4.6 635522a689 v0.1.1: Add favicon and split Prompt tab into side-by-side layout
- Add W+S Wave SVG favicon matching the launch button design
- Replace Prompt tab's subtab switching with left/right split panel
  (AI Assistant + Presets displayed simultaneously)
- Update screenshot, README, and DEVLOG

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 09:04:47 +09:00
ketle-manandClaude Opus 4.6 3184060063 Update DEVLOG: JSON highlight, ZH translation, v0.1.0 release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:20:39 +09:00
43 changed files with 11117 additions and 329 deletions
+187
View File
@@ -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完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
+658 -5
View File
@@ -1,5 +1,616 @@
# DEVLOG - ComfyUI-Workflow-Studio
## 2026-03-28: v0.2.2 Modelsタブ・CivitAI連携
### 概要
- ComfyUIにインストールされたモデル(Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder)を一覧・管理する「Models」タブを新規追加
- CivitAI API連携(SHA256ハッシュによるモデル情報取得、一括取得)
- モデルグループ管理、プレビュー画像表示・変更、メタデータ永続化
- ノードカードビュー改善、全タブのページネーションをツールバーに移動
### 新規ファイル
#### `py/services/models_service.py` — モデルメタデータサービス
- `_get_model_dirs()`: ComfyUIの`folder_paths.get_folder_paths()`でモデルディレクトリを解決
- `ModelsService`: メタデータのCRUD(お気に入り、タグ、メモ、SHA256)
- `find_preview_image()`: `{stem}.preview.png`等のプレビュー画像を自動検出
- `get_model_groups()` / `save_model_groups()`: グループの永続化(`model_metadata.json`の`_groups`キー)
#### `py/services/civitai_service.py` — CivitAI API連携
- `calculate_sha256()`: モデルファイルのSHA256ハッシュ計算
- `fetch_by_hash()`: CivitAI APIからモデル情報取得(キャッシュ付き)
- `batch_fetch()`: 複数モデルの一括取得(プログレスコールバック対応)
- `_extract_info()`: APIレスポンスから必要フィールドを抽出(modelName, baseModel, trainedWords, images等)
- キャッシュ: `civitai_cache.json`に保存
#### `py/routes/models_routes.py` — モデル管理APIルート
- `GET/POST /api/wfm/models/metadata` — メタデータCRUD
- `GET /api/wfm/models/preview` — プレビュー画像配信
- `GET/POST /api/wfm/models/groups` — グループ管理
- `POST /api/wfm/models/civitai/fetch` — 個別CivitAI取得
- `GET /api/wfm/models/civitai/cache` — CivitAIキャッシュ取得
- `POST /api/wfm/models/civitai/batch` — SSEストリーミング一括取得
- `POST /api/wfm/models/change-preview` — プレビュー画像アップロード
- `GET /api/wfm/models/filepath` — モデルファイルのフルパス取得
#### `static/js/models-tab.js` — フロントエンドモジュール
- state管理: `modelsByType`, `modelMetadata`, `modelGroups`, `civitaiCache`, `activeModelType`
- サブタブ切り替え(6タイプ)、サムネイル/カード/テーブル表示
- サイドパネル: Info(ファイルパス表示、タグ、メモ)、Group管理、CivitAI情報表示
- 詳細モーダル: プレビュー画像、CivitAI情報、サムネイル変更
- 一括CivitAI取得ボタン(SSEプログレス付き)
- テーブルビューにメモ列表示
### 変更ファイル
#### `py/config.py`
- `MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"` 追加
#### `py/wfm.py`
- `models_routes`のインポートとルート登録追加
#### `templates/index.html`
- Modelsタブボタン・セクション追加(サブタブナビ、ツールバー、グリッド、サイドパネル)
- ヘルプタブにModels Tabセクション追加
#### `static/js/app.js`
- `models-tab.js`のimportと`initModelsTab()`呼び出し追加
- i18nマッピングにModelsタブ・ヘルプ用キー追加
#### `static/js/nodes-tab.js`
- カードビュー: パッケージバッジ削除、カード左端にパッケージ色のボーダー追加
- カードビュー: 入出力カウント表示を削除
- ページネーションをツールバーに移動
#### `static/js/workflow-tab.js`
- ページネーションをツールバーに移動
- 1ページ24件表示
#### `static/css/main.css`
- `.wfm-grid`: `grid-template-columns: repeat(auto-fill, 161px)`、カード161×162px固定
- `.wfm-node-card`: 左ボーダー3pxスタイル追加
- `.wfm-pagination-inline`: ツールバー内ページネーション
- サブタブナビ(`.wfm-models-type-nav`)、テーブル列幅調整
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
- Modelsタブ関連: ~50キー(モデルタイプ、フィルタ、サイドパネル、CivitAI、グループ等)
- ヘルプModelsセクション: `helpModels1`〜`helpModels7`
- ファイルパス関連: `modelsFilePath`, `modelsCopyPath`, `modelsCopiedPath`
### 技術的な判断
- **プレビュー画像判定**: HEADリクエストではなく`img.onload/onerror`パターンを採用(404コンソールスパム回避)
- **CivitAI一括取得**: ブロッキングリクエストではなくSSE(Server-Sent Events)でリアルタイムプログレス表示
- **aiohttp HEAD自動登録**: `add_get`が自動でHEADも登録するため、明示的なHEADルート追加は不要(重複RuntimeError回避)
- **テーブルビューメモ列**: `max-width: 200px`+`text-overflow: ellipsis`で長文を省略表示
- **ファイルパス表示**: バックエンドAPIでフルパスを解決し、クリックでクリップボードコピー
## 2026-03-27: テーマカスタマイズ機能・設定タブ改善
### 概要
- テーマのカラーカスタマイズ、背景パターン、フォント選択機能を追加
- 設定タブの各セクションをアコーディオン式折りたたみに変更
- ヘルプタブのSettings Tab説明を更新
### 変更内容
#### `static/js/settings-tab.js` — テーマカスタマイズ機能追加
- **カラーオーバーライド:** 6つのCSS変数(背景、サブ背景、サーフェス、テキスト、プライマリ、アクセント)をカラーピッカーでリアルタイム調整、localStorageに保存
- **背景パターン:** 7種類(なし、横/縦/斜めストライプ、ポルカドット、チェック、SVGファイル)を選択可能
- パターンオプション: 色、不透明度、サイズ、間隔(gap)スライダー
- SVGタイリング: FileReader APIでクライアント側読込、色の置換(fill/stroke属性+`<style>`要素注入)、`::after`疑似要素でopacity制御、gap対応はviewBox拡張ラッパーSVG方式
- **フォント選択:** 16種のGoogle Fonts(日本語デザインフォント含む)を動的ロード、クリックで即時プレビュー
- ゴシック系: Noto Sans JP, Zen丸ゴシック, M PLUS Rounded, 小杉丸ゴシック, さわらびゴシック, BIZ UDPゴシック
- デザイン系: ドットゴシック16, はちまるポップ, デラゴシック, レゲエ One, ロックンロール, ステッキ, トレイン One
- 等幅: Space Mono, Fira Code
- **保存ロジック修正:** `saveLocalSettings()`のスプレッドマージでは`delete`したキーが消えない問題を修正、リセット時は`localStorage.setItem()`で直接上書き
- **SVGリカラー強化:** `<style>`ブロックをSVGに注入し、fill属性がない要素にも色を適用
#### `static/css/main.css` — UIスタイル追加
- **設定アコーディオン:** `<details>`/`<summary>`要素用のスタイル(`.wfm-settings-section`, `.wfm-settings-summary`)— 三角矢印回転アニメーション付き
- **テーマカスタマイザー:** カラーピッカーグリッド、パターン選択グリッド(プレビューSVG付き)、パターンオプション行、フォント選択グリッド
- **パターンプレビュー改善:** SVGプレビューのコントラストを大幅強化(60x60サイズ、太い線、高opacity、青系前景色)
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
- テーマカスタマイズ関連: カラー調整、背景パターン、パターンオプション(色、不透明度、サイズ、間隔)、フォント
- フォント名: 16種のフォントラベル
- ヘルプSettings項目: helpSettings7, helpSettings8追加
#### `static/js/app.js` — i18nマッピング追加
- `helpSettings7`, `helpSettings8` のHTML id→i18nキーマッピングを追加
#### `templates/index.html` — ヘルプタブ更新
- Settings Tabセクション: テーマカスタマイズ機能の説明を追加(項目2)、Eagle連携を項目7に、アコーディオン折りたたみを項目8に追加
### 技術的な判断
- **SVGカラー変更:** 属性置換だけでは不十分なSVG(CSSクラスやデフォルト黒を使用)に対応するため、`<style>`ブロック注入方式を採用
- **SVG間隔(gap):** viewBoxを拡張するラッパーSVGでタイル間に余白を作成
- **フォント読込:** Google Fonts CDNから動的にlinkタグを挿入、一度読み込んだフォントはSetで管理し重複読込を防止
- **アコーディオン:** `<details>`/`<summary>`ネイティブHTML要素を使用、JSなしで開閉動作
## 2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査
### 概要
- トップバーの3つのボタン(Workflow Studio / Snapshot / Library)のアイコンが表示されない問題を修正
- ComfyUI Appモード(linearMode)のワークフローJSON構造を調査、バッジ表示対応は保留
### 変更内容
#### `web/comfyui/top_menu_extension.js` — アイコン置換ロジック修正
- **問題:** `setup()` 内の `replaceButtonIcon()` が `requestAnimationFrame` でボタンを見つけてSVGを注入するが、ComfyUIのVueフレームワークが再レンダリングして元の `<i class="icon-[mdi--...]">` に戻してしまう。リトライ条件が `wfmButtons.length === 0`(ボタン未検出時のみ)のため、一度見つかった後は再注入されなかった
- **修正:** `MutationObserver` でボタンの親コンテナ(`.actionbar-container`)を監視し、DOMが変更されるたびにSVGアイコンを再注入するように変更
- `applyButtonIcon()`: ボタンに既にSVGがあればスキップ(無限ループ防止)、なければSVG注入
- `waitAndObserve()`: ボタンがDOMに現れるまで `requestAnimationFrame` で待機し、見つかったらアイコン置換+`MutationObserver`監視開始
### Appモードバッジ表示について(保留)
- ComfyUI Appモード(BETA機能)のワークフローJSON構造を調査
- `extra.linearMode: true` と `extra.linearData` (inputs/outputs配列)でappモード情報が格納される
- appモード削除後も `linearData`/`linearMode` キーは残り中身が空配列になるだけ、拡張子 `.app.json` も維持される
- **結論:** Appモード自体がBETA機能のため、バッジ表示対応は現状維持とする
## 2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library
### 概要
- プロンプトプリセット機能を追加:サイドパネルからドラッグ&ドロップでWFS_PromptTextカスタムノードを作成
- Workflow Studio SPAのPromptタブを3カラムレイアウトに刷新(AIアシスタント|プリセット編集|プリセットマネージャー)
- プリセットのバックエンドAPI化(localStorage→サーバーサイド保存)
- サイドパネル名を「WF & Node Library」→「Workflow Studio Library」に変更
### 変更内容
#### `py/nodes/prompt_text.py` — WFS_PromptTextカスタムノード(新規)
- **WFS_PromptText**: ポジティブ/ネガティブの2つのSTRING入力とSTRING出力を持つカスタムノード
- `INPUT_TYPES`: `positive`(multiline), `negative`(multiline)
- `RETURN_TYPES`: `("STRING", "STRING")`, `RETURN_NAMES`: `("positive", "negative")`
- `CATEGORY`: `"Workflow Studio"`
#### `py/nodes/__init__.py` — パッケージ初期化(新規)
- 空ファイル(Pythonパッケージ認識用)
#### `__init__.py` — カスタムノード登録
- `_NODE_MODULES` に `WFS_PromptText` を追加(分離読み込みパターン)
- `NODE_DISPLAY_NAME_MAPPINGS` に `"Prompt Text (WFS)"` を追加
#### `py/services/prompts_service.py` — プリセットCRUDサービス(新規)
- `list_prompts()`: 全プリセット一覧取得
- `create_prompt(data)`: 新規作成(name, text, negText, category, tags, favorite)
- `update_prompt(id, updates)`: 更新(name, text, negText, category, tags, favorite)
- `delete_prompt(id)`: 削除
- `list_categories()`: カテゴリ一覧取得
- データ永続化: `data/prompts.json`
#### `py/routes/prompts_routes.py` — プリセットAPIエンドポイント(新規)
- `GET /api/wfm/prompts` — 一覧取得
- `POST /api/wfm/prompts` — 新規作成
- `POST /api/wfm/prompts/update` — 更新
- `POST /api/wfm/prompts/delete` — 削除
#### `py/config.py` — 設定追加
- `PROMPTS_FILE = DATA_DIR / "prompts.json"` を追加
#### `py/wfm.py` — ルート登録
- `prompts_routes` のインポートと `prompts_routes.setup_routes(app)` を追加
#### `web/comfyui/node_sets_menu.js` — サイドパネルにPromptsタブ追加
- **トップタブ:** Workflows / Nodes / **Prompts** の3タブ構成
- **Promptsサブタブ:** All / ★ Favorites / 📁 Categories
- **State追加:** `promptSubTab`, `promptList`, `promptFavorites`, `promptCategories`, `promptLoaded`
- **API関数:** `fetchPrompts()`, `loadPromptData()`
- **レンダリング:** `renderPromptAll()`, `renderPromptFavorites()`, `renderPromptCategories()`
- **ドラッグ&ドロップ:** `application/x-wfm-prompt` MIMEタイプ、`placePromptNode(posText, negText, promptName, pos)` でWFS_PromptTextノード作成
- **P/Nコピーボタン:** 各プリセットアイテムにポジティブ(P)・ネガティブ(N)の個別コピーボタン(Nはテキスト空の場合非表示)
- **パネル名変更:** ツールチップ・タイトルを「Workflow Studio Library」に更新
- **CSS追加:** `.wfm-nlp-copy-btns`, `.wfm-nlp-copy-pos`(緑ホバー), `.wfm-nlp-copy-neg`(赤ホバー)
#### `templates/index.html` — Promptタブ3カラムレイアウト
- 2カラム(AI Assistant | Presets)→ 3カラム(AI Assistant | Presets | Preset Manager)に変更
- **Presetsパネル:** Deleteボタン削除、Apply→「GenUI Set」、Category入力欄追加、下部にグループ管理行(Select group + Add to Group + + Group + Del Group)
- **Preset Managerパネル:** All / ★ / Groupsタブ、検索、スクロール可能なリスト
- **ヘルプタブ:** helpPrompt5〜7追加、helpSidepanel4・7〜10追加(Promptsタブ・ドラッグ&ドロップ・P/Nコピー)
- サイドパネルタイトルを「Workflow Studio Library」に更新
#### `static/css/main.css` — スタイル追加(約150行)
- `.wfm-prompt-split-3col` — 3カラムFlexboxレイアウト
- `.wfm-prompt-split-col` — 各カラムのスタイル
- `.wfm-preset-manager` — プリセットマネージャーパネル
- `.wfm-pm-tabs`, `.wfm-pm-tab` — マネージャータブ
- `.wfm-pm-list`, `.wfm-pm-item` — アイテムリスト
- `.wfm-pm-item-actions`, `.wfm-pm-action-btn` — お気に入り(★)・削除(✕)ボタン
- `.wfm-pm-group-header` — グループヘッダー
#### `static/js/prompt-tab.js` — 全面リライト
- **API化:** `fetchPresets()`, `apiCreatePreset()`, `apiUpdatePreset()`, `apiDeletePreset()`
- **localStorage移行:** `migrateLocalStoragePresets()` — 一回限りの自動移行
- **プリセット管理:** `renderPresetSelect()`, `renderGroupSelect()`, `selectPresetInEditor(preset)`
- **Preset Manager:** `renderPresetManager()`, `renderPmAll()`, `renderPmFavorites()`, `renderPmGroups()`
- **`createPmItem()`:** お気に入りトグル(★)と削除(✕)ボタン(コピーボタンなし — Presetsパネル側にPP/NP Copyあり)
- **グループ管理:** Presetsパネル下部のnew-group, add-to-group, del-groupボタン
- **Save:** API経由でnegTextフィールドを含むプリセットの作成/更新
#### `static/js/i18n.js` — 翻訳更新
- `applyPreset` → `"GenUI Set"`(英/日/中 全3言語)
- `deletePreset` キー削除(全3言語)
- ヘルプ: `helpPrompt5`〜`helpPrompt7`, `helpSidepanel8`〜`helpSidepanel10` 追加(全3言語)
- サイドパネルタイトル: `helpSidepanelTitle` → `"Workflow Studio Library"` に更新(全3言語)
#### `static/js/app.js` — i18nマッピング更新
- `helpIdMap` に `helpPrompt5`〜`7`, `helpSidepanel8`〜`10` を追加
- `deletePreset` 適用コード削除
#### `README.md` — v0.2.0更新
- バージョンバッジ: `0.1.9` → `0.2.0`
- Prompt Tab: 3カラムレイアウト、Preset Manager、グループ管理、GenUI Set
- Workflow Studio Library: Promptsタブ、ドラッグ&ドロップ、P/Nコピー
- Project Structure: `py/nodes/`, `prompts_routes.py`, `prompts_service.py` 追加
- Changelog: v0.2.0エントリ追加
- スクリーンショット全8枚差し替え
## 2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正
### 概要
- サイドパネルのサブタブを2段構成に変更、全ワークフロー/全ノード表示タブを追加
- キャンバススナップショット保存時にファイル名編集ダイアログを表示
- API/App形式バッジをワークフロー一覧に表示
- グループ内の削除済みワークフロー(Not found)を自動クリーンアップ
### 変更内容
#### `web/comfyui/node_sets_menu.js` — サイドパネルサブタブ2段化
- **Workflows サブタブ:**
- 1段目: Workflows(全WF一覧)/ ★ Favorites / 📁 Groups
- 2段目: ◦ Model Type
- **Nodes サブタブ:**
- 1段目: Nodes(全ノード一覧)/ ✳ Favorites / 📁 Groups
- 2段目: ☰ Sets
- **State 変更:** `wfSubTab2`, `activeTab2` を追加(2段目タブの排他制御用)
- **新レンダリング関数:**
- `renderWfAll()` — 全ワークフロー一覧表示
- `renderWfFavorites()` — お気に入りワークフロー一覧(タブとして復活)
- `renderAllNodes()` — `LiteGraph.registered_node_types` から全ノード一覧表示
- **API/App形式バッジ:** `createDraggableWfItem()` でAPI形式(赤)・App形式(オレンジ)のバッジをファイル名前に表示
- **グループ自動クリーンアップ:** `loadWfData()` でワークフロー一覧取得時に、存在しないファイルをグループから自動除去し localStorage を更新
- **Not found 表示削除:** `renderWfGroups()` から削除済みワークフローの灰色表示コードを除去
- **CSS 追加:**
- `.wfm-nlp-subtabs-row2` — 2段目サブタブ行
- `.wfm-nlp-fmt-badge`, `.wfm-nlp-fmt-api`, `.wfm-nlp-fmt-app` — 形式バッジスタイル
#### `web/comfyui/top_menu_extension.js` — 保存ダイアログ追加
- **`showSaveDialog(defaultName)`** — モーダルダイアログでファイル名を編集可能に
- タイムスタンプ形式のデフォルト名がプレースホルダーとして全選択状態で表示
- Enter/Saveボタンで確定、Escape/Cancel/オーバーレイクリックでキャンセル
- `.json` 拡張子は自動付与
- **`saveCanvasToWorkflowStudio()`** — 自動保存からダイアログ経由の保存に変更
#### `py/services/workflow_analyzer.py` — ワークフロー形式検出
- `analyze_workflow()` の返却値に `format` フィールドを追加
- `"app"`: `definitions`(サブグラフ)キー存在 or `extra.linearMode === true`、フォールバックで `.app.json` ファイル名
- `"ui"`: `nodes` 配列 + `links` 存在(App特徴なし)
- `"api"`: 全トップレベル値に `class_type` 存在
- `"unknown"`: 判定不能
#### `static/js/comfyui-workflow.js` — フロントエンド形式検出も構造ベースに変更
- `detectFormat()` をファイル名依存から `definitions` / `extra.linearMode` による構造判定に変更
#### `static/js/workflow-tab.js` — グループクリーンアップ
- `loadWorkflows()` でワークフロー取得後にグループの不要エントリを自動削除
---
## 2026-03-24: v0.1.8 WF & Node Library サイドパネル拡張
### 概要
- ComfyUI キャンバス右側の「Node Library」サイドパネルを「WF & Node Library」に拡張
- ワークフローをサイドパネルからキャンバスにドラッグ&ドロップで読み込み可能に
- ヘルプタブに Nodes Tab / WF & Node Library の機能一覧を追加
### 変更内容
#### `web/comfyui/node_sets_menu.js` — サイドパネル全面改修
- **パネル名変更:** "Node Library" → "WF & Node Library"
- **2階層タブ構造:** トップレベル(Workflows / Nodes)+ サブタブ
- Workflows サブタブ: Favorites(お気に入り)、Model Type(モデル種別)、Groups(グループ)
- Nodes サブタブ: 既存の Favorites / Sets / Groups をそのまま維持
- **State 拡張:** `topTab`, `wfSubTab`, `wfList`, `wfFavorites`, `wfModelTypes`, `wfGroups`, `wfLoaded` を追加
- **API 追加:**
- `fetchWorkflows()` — `GET /api/wfm/workflows`(既存エンドポイント流用)
- `fetchWorkflowRaw(filename)` — `GET /api/wfm/workflows/raw`(既存エンドポイント流用)
- `loadWfData()` — ワークフロー一覧取得 + favorites/modelTypes/groups 抽出
- **ワークフロー読み込み:**
- `loadWorkflowOnCanvas(filename)` — raw JSON 取得 → `app.loadGraphData(data)` でキャンバスに読み込み
- 新 MIME タイプ `application/x-wfm-workflow` によるドラッグ&ドロップ
- ダブルクリックでもワークフロー読み込み可能
- **ドロップハンドラー拡張:** `installCanvasDropHandler()` で `application/x-wfm-workflow` を追加受付
- **レンダリング関数追加:**
- `createDraggableWfItem(wf)` — ワークフロー名 + モデルタイプバッジ表示
- `renderWfFavorites()` — お気に入りワークフロー一覧
- `renderWfModelType()` — モデルタイプ別折りたたみセクション(初期: 折りたたみ状態)
- `renderWfGroups()` — localStorage グループ別折りたたみセクション(初期: 折りたたみ状態)
- `matchesWfSearch()` — filename, tags, memo, summary に対する検索フィルタ
- **折りたたみ初期状態:** Model Type / Groups(WF・Node 両方)を初期状態で折りたたみ表示に変更
- **CSS 追加:**
- `.wfm-nlp-subtabs` — サブタブ行(背景色で視覚区別)
- `.wfm-nlp-top-tab` — トップタブ(大きめフォント 12px, font-weight 600)
- `.wfm-nlp-sub-tab` — サブタブ(小さめフォント 10px)
- `.wfm-nlp-item-label` に `text-overflow: ellipsis` 追加(長いファイル名対応)
- **tooltip 更新:** `NODE_SETS_TOOLTIP` → `"WF & Node Library – Browse & drag workflows/nodes onto canvas"`
#### `web/comfyui/top_menu_extension.js` — 変更なし
- `NODE_SETS_TOOLTIP` を import しているため、tooltip 変更が自動反映
#### ヘルプタブ更新
- **`templates/index.html`:**
- Nodes Tab カード追加(6項目: ノードブラウザ、表示モード、お気に入り/タグ、グループ、サイドパネル、ノードセット)
- WF & Node Library カード追加(7項目: ボタン起動、WFタブ、Nodesタブ、WFドラッグ、ノードドラッグ、ダブルクリック、検索)
- About 説明文にノード管理を追記
- **`static/js/i18n.js`:**
- EN/JA/ZH に `helpNodes1`〜`helpNodes6`(6キー × 3言語)追加
- EN/JA/ZH に `helpSidepanel1`〜`helpSidepanel7` + `helpSidepanelTitle`(8キー × 3言語)追加
- 各言語の `helpAboutDesc` にノード管理の記述を追記
- **`static/js/app.js`:**
- `helpIdMap` に Nodes(6エントリ)+ Sidepanel(8エントリ)のマッピング追加
#### README.md 更新
- バージョンバッジ: `0.1.7` → `0.1.8`
- Screenshots セクション: Nodes Tab + WF & Node Library の行を追加(3行→4行、4枚→6枚)
- ComfyUI Integration 画像をツールバー3ボタンにフォーカスした画像に差し替え
- Features セクション: 新セクション「WF & Node Library (ComfyUI Side Panel) (v0.1.8)」追加
- Changelog: v0.1.8 エントリ追加、v0.1.7 のサイドパネル記載を元に戻し
- Project Structure: `node_sets_menu.js` の説明を更新
#### スクリーンショット追加
- `docs/screenshot_nodes.png` — Nodes タブ(カードビュー + サイドパネル)
- `docs/screenshot_wf_node_library.png` — ComfyUI 上の WF & Node Library サイドパネル
- `docs/screenshot_comfyui_topbar.png` — ツールバー3ボタンにフォーカスした画像に差し替え
### バックエンド変更
- なし(既存の `/api/wfm/workflows` と `/api/wfm/workflows/raw` を流用)
---
## 2026-03-23: v0.1.7 Nodes タブ追加
### 概要
- ComfyUI にインストールされた全ノードをブラウジング・整理・管理する「Nodes」タブを追加
- ノードセット機能:複数ノード+接続情報をセットとして保存し、再利用可能に
### 変更内容
#### 新規ファイル
- **`py/services/nodes_service.py`** — ノードメタデータ(お気に入り、タグ、グループ)とノードセットの CRUD サービス
- **`py/routes/nodes_routes.py`** — 9つの API エンドポイント(metadata, groups, node-sets の GET/POST/update/delete/export)
- **`static/js/nodes-tab.js`** — Nodes タブのメインロジック(Node Browser + Node Sets サブビュー)
- **`web/comfyui/node_sets_menu.js`** — ComfyUI キャンバス内サイドパネル(Favorites/Sets/Groups タブ、ドラッグ&ドロップ配置)
#### 既存ファイル修正
- **`py/wfm.py`** — `nodes_routes.setup_routes(app)` 追加
- **`py/config.py`** — `NODE_METADATA_FILE`, `NODE_SETS_FILE` 定数追加
- **`static/js/app.js`** — Nodes タブ登録、`initNodesTab()` 呼び出し
- **`static/js/i18n.js`** — EN/JA/ZH に Nodes タブ関連 ~35 キー追加
- **`static/js/comfyui-client.js`** — `fetchAllObjectInfo()` メソッド追加
- **`static/css/main.css`** — ノードタブ固有スタイル(カード、テーブル、バッジ、ページネーション等)
- **`templates/index.html`** — Nodes タブの HTML 構造追加
- **`web/comfyui/top_menu_extension.js`** — Node Library ボタン+右クリック "Save as Node Set" コンテキストメニュー追加
### 機能詳細
#### Node Browser(Sub-view 1)
- ComfyUI `/object_info` API から全ノード取得(遅延読み込み)
- Card / Table の2ビュー切り替え
- フィルタ: カテゴリ、パッケージ、タグ、グループ、お気に入り
- 全文検索: name, display_name, description, search_aliases, tags
- パッケージ名カラーバッジ(ハッシュベース色生成)
- サイドパネル: ノード詳細(I/O仕様テーブル、タグ編集、グループ管理)
- 1ページ50ノード表示(ページネーション)
#### Node Sets(Sub-view 2)
- ComfyUI キャンバスで選択したノード+接続をセットとして保存
- 右クリックコンテキストメニュー "Save as Node Set" から保存
- セット一覧表示(名前、説明、ノード数、タグ)
- 作成・編集・削除
- ComfyUI 互換 JSON としてクリップボードにコピー
#### ComfyUI トップバー統合
- 3ボタン: Workflow Studio / Snapshot / Node Library
- Node Library ボタンでサイドパネル開閉
### バグ修正
- `node_sets_menu.js` で `const saveSelectedAsNodeSet` が二重定義されていた ESM SyntaxError を修正(Vite preload wrapper により完全にサイレントだった)
- デバッグ用 `console.log` 削除
---
## 2026-03-23: v0.1.6 パストラバーサル脆弱性修正
### 概要
- ComfyUI-Manager PR #2706 のレビューで指摘されたセキュリティ脆弱性を修正
- `workflow_service.py` で任意のファイルパスへのアクセスが可能だった問題を解消
### 背景
- ltdrdata(ComfyUI-Manager メンテナー)から `import_files` メソッド内のパス構築が安全でないとの指摘
- `original_name` に `../../etc/passwd` のようなパストラバーサル文字列を送ることで、`workflows_dir` 外のファイルを読み書きできる状態だった
### 変更内容
#### `py/services/workflow_service.py`
- **`_validate_filename()` 強化** — `"."`, `".."`, null バイト (`\x00`) のチェックを追加
- **`_safe_path()` メソッド新規追加** — ファイル名バリデーション + `resolve()` でパスを正規化し、`workflows_dir` 配下にあることを検証。違反時は `ValueError` を送出
- **全公開メソッドに `_safe_path()` 適用:**
- `import_files` — ループ冒頭で `_validate_filename` チェック追加、パス構築を `_safe_path` に変更
- `get_raw` — `_safe_path` に変更
- `rename` — old/new 両パスを `_safe_path` に変更
- `delete` — `_safe_path` に変更
- `analyze` — `_safe_path` に変更
- `change_thumbnail` — `_safe_path` に変更
---
## 2026-03-22: v0.1.5 テーマシステム追加
### 概要
- 13種類のビルトインテーマを設定タブから切り替え可能に
- テーマごとにカラー・フォント・角丸・影・特殊効果を変更
- 選択テーマはlocalStorageに保存、ページ読み込み時にフラッシュなく復元
### 変更内容
#### テーマ定義(CSS)
- `static/css/main.css` — 12テーマの`[data-theme="xxx"]`変数定義を末尾に追加
- Pop & Vibrant / Light Minimalist / Cyberpunk / Glassmorphism / Neumorphism / Retro 8-bit / Pastel Dream / Brutalism / Earthy / Material UI / Monotone + Accent / Corporate Trust
- テーマ固有の特殊効果CSS:
- Cyberpunk: ネオン発光ボーダー(`box-shadow`)、JSON構文ハイライト色変更
- Glassmorphism: `backdrop-filter: blur(12px)` + メッシュグラデーション背景
- Neumorphism: ダブルシャドウ(凸凹表現)、insetシャドウボタン
- Retro-pixel / Brutalism: 太い黒ボーダー(`border: 3px solid #000`)
- `.wfm-theme-grid` / `.wfm-theme-card` / `.wfm-theme-swatch` — テーマ選択UIスタイル
#### テーマ選択UI(JavaScript)
- `static/js/settings-tab.js`:
- `THEMES` 配列: テーマID・i18nキー・プレビュー用4色スウォッチを定義
- `applyTheme(themeId)` — `<html>`の`data-theme`属性を設定/解除
- `getSavedTheme()` — localStorageから保存テーマを取得
- 設定タブにカラースウォッチ付きグリッドUIを生成(言語設定の直下)
- クリックで即座にテーマ適用 + localStorage保存 + activeクラス更新
- `static/js/app.js`:
- `applyTheme` / `getSavedTheme` をインポート
- `DOMContentLoaded`の前にテーマ復元を実行(テーマフラッシュ防止)
#### 多言語対応
- `static/js/i18n.js` — 3言語(EN/JA/ZH)に `themeLabel` + 13テーマ名の翻訳キー追加
#### その他
- `templates/index.html` — ヘルプページのGitHubリンクURL修正
- `README.md` — v0.1.5更新、テーマ機能記載、git clone URL修正
### スクリーンショット
| Workflow Tab (Pastel Dream) | Settings Tab (Theme) |
|:---:|:---:|
| ![Workflow](docs/screenshot_workflow.png) | ![Settings](docs/screenshot_settings.png) |
---
## 2026-03-22: v0.1.4 App形式対応・プリセットコピー・分析バグ修正
### 概要
- ComfyUI App形式(`.app.json`)ワークフローの識別・表示対応
- プリセットタブにポジティブ/ネガティブプロンプトのクリップボードコピーボタン追加
- ワークフロー分析のクラッシュバグ修正
### 変更内容
#### App形式ワークフロー対応
- **背景:** ComfyUIの最新アップデートでAppモード(ノードを簡略化したWebアプリ風UI)が追加され、`.app.json`拡張子で保存される
- **対応:**
- `static/js/comfyui-workflow.js` — `detectFormat()`にファイル名ベースのApp形式判定を追加
- `static/js/workflow-tab.js` — サイドパネルJSONタブのフォーマットバッジに「App形式」表示を追加
- `static/js/generate-tab.js` — App形式ワークフローの生成UI読み込みをブロックし、「ComfyUIで開く」への誘導メッセージを表示
- `static/css/main.css` — `.wfm-format-badge--app` スタイル追加(オレンジ色)
- `static/js/i18n.js` — `appFormat` / `appFormatNotSupported` 翻訳キー追加(EN/JA/ZH)
#### プリセット クリップボードコピーボタン
- **課題:** プリセットのプロンプトをComfyUI本体で使う際に手動コピーが必要だった
- **対応:**
- `templates/index.html` — プリセット保存ボタン行に「PP コピー」「NP コピー」ボタンを追加(flex:2/2/1比率)
- `static/js/prompt-tab.js` — クリップボードコピー処理(`navigator.clipboard.writeText`)追加
- `static/js/app.js` — コピーボタンのi18n適用
- `static/js/i18n.js` — `copyPositivePrompt` / `copyNegativePrompt` / `copiedToClipboard` / `noTextToCopy` 翻訳キー追加
#### ワークフロー分析バグ修正
- **問題:** UI形式ワークフローの`widgets_values[0]`が整数(例: EmptyLatentImageの`768`)の場合、`.lower()`で`AttributeError`が発生し、例外キャッチにより全分析結果がゼロになる
- **修正:** `py/services/workflow_analyzer.py` — `_model_name_from_ui_node()`に`isinstance(val, str)`型チェックを追加
---
## 2026-03-21: v0.1.3 ヘルプ&サポートタブ追加
### 概要
- ヘルプ&サポートタブを新規追加(機能一覧 + サポートリンク)
### 変更内容
#### ヘルプ&サポートタブ
- **課題:** プラグインの機能概要を確認する場所がなく、サポートへの導線もなかった
- **対応:** 新しいメインタブ「Help」を追加し、機能一覧とサポートセクションを設置
##### 機能一覧セクション
- 各タブ(Workflow / GenerateUI / Prompt / Settings)の主要機能をカード形式で表示
- Tipsセクション(ドラッグ&ドロップ、お気に入り、デフォルトワークフロー)
##### サポートセクション
- GitHubリンク(バグ報告・機能リクエスト・コントリビュート)
- Ko-fiリンク(開発支援)
- SVGアイコン付きのリンクカード
#### 変更ファイル
- `templates/index.html` — Helpタブボタン追加、ヘルプ&サポートセクションのHTML構造
- `static/js/i18n.js` — 3言語(EN/JA/ZH)のヘルプ関連翻訳キー30項目追加
- `static/js/app.js` — タブマップにhelp追加、`applyI18nToHtml()`にヘルプタブのi18n適用ロジック追加
- `static/css/main.css` — `.wfm-help-*` スタイル追加(コンテナ、セクション、カード、リンク)
---
## 2026-03-19: v0.1.2 リリース
### 概要
- キャンバススナップショット機能追加(ComfyUIトップバーにカメラボタン)
- ワークフローサイドパネル・モーダルにサムネイルプレビュー追加
### 変更内容
#### Canvas Snapshot(キャンバススナップショット)
- **課題:** ワークフローのキャンバス画像を保存するにはComfyUIのエクスポート機能を使い手動でファイル管理する必要があった
- **対応:** ComfyUIトップバーにカメラボタンを追加し、ワンクリックでキャンバスをPNGキャプチャ→ワークフローデータフォルダに自動保存
- `web/comfyui/top_menu_extension.js`:
- カメラアイコンボタン追加(actionBarButtons API / legacy ComfyButtonGroup 両対応)
- `captureCanvasSnapshot()` — LiteGraphキャンバスの状態保存→ノード範囲計算→レンダリング→Blob化→復元
- PNG tEXtチャンク埋め込み (`n2b`, `joinArrayBuffer`, `crc32`, `embedWorkflowInPng`) — ComfyUIのドラッグ&ドロップインポート互換
- タイムスタンプファイル名 `wf_YYYYMMDDHHmmss.json` 形式で自動生成
- `py/routes/workflow_routes.py`:
- `POST /api/wfm/workflows/save-canvas-image` ルート追加
- `import_files` を利用してPNGからワークフローJSON抽出+サムネイル保存+自動インポート
- 参考実装: [pythongosssss/ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) の workflowImage.js
#### サムネイルプレビュー
- `templates/index.html` — サイドパネルにThumbnailタブ追加(デフォルトアクティブ)
- `static/js/workflow-tab.js`:
- `sidePanelThumbUpdate()` — 選択ワークフローのサムネイル・メタ情報表示
- モーダル上部にサムネイルセクション追加
- サムネイル変更時にサイドパネル・モーダル両方を同期更新
- `static/css/main.css` — `.wfm-side-thumb-*` / `.wfm-modal-thumb-*` スタイル追加
### スクリーンショット
| Workflow Tab | ComfyUI Top Bar |
|:---:|:---:|
| ![Workflow](docs/screenshot_workflow.png) | ![TopBar](docs/screenshot_comfyui_topbar.png) |
### リリース
- GitHub Release v0.1.2: https://github.com/ketle-man/ComfyUI-Workflow-Studio/releases/tag/v0.1.2
---
## 2026-03-19: v0.1.1 リリース
### 概要
- ファビコン追加
- プロンプトタブをサブタブ切り替え方式から左右2分割レイアウトに変更
### 変更内容
#### ファビコン追加
- `static/favicon.svg` — 起動ボタンと同じ W + S Wave デザインの SVG ファビコンを新規作成
- `templates/index.html` — `<link rel="icon">` タグ追加
#### プロンプトタブ 2分割レイアウト
- **課題:** Assistant / Presets のサブタブ切り替え方式では画面幅が広い場合に視点移動が大きく使いづらい
- **対応:** サブタブナビゲーションを廃止し、左パネル(AIアシスタント)・右パネル(プリセット)の同時表示レイアウトに変更
- `templates/index.html` — サブタブ構造を `.wfm-prompt-split` 左右分割構造に変更
- `static/css/main.css` — `.wfm-prompt-subtab-*` スタイルを `.wfm-prompt-split` / `.wfm-prompt-split-header` に置換
- `static/js/prompt-tab.js` — サブタブ切り替えイベントリスナー削除
- `static/js/app.js` — 翻訳対象をサブタブボタンからパネルヘッダーに変更
### スクリーンショット
![Prompt Tab](docs/screenshot_prompt.png)
---
## 2026-03-18: プロジェクトリネーム & アイコン適用
### 概要
@@ -79,13 +690,55 @@ ComfyUI-Workflow-Studio/
│ ├── config.py # パス設定
│ ├── routes/ # APIルート
│ └── services/ # ビジネスロジック
├── templates/index.html # SPA (Workflow/GenerateUI/Prompt/Settings)
├── templates/index.html # SPA (Workflow/GenerateUI/Prompt/Settings/Help)
├── static/ # CSS/JS
├── web/comfyui/ # ComfyUIメニュー拡張
└── data/ # ワークフロー・メタデータ・設定
```
### 次のステップ
- Promptタブの実装完了 (CSS・app.js登録)
- GitHub公開準備
- ComfyUI Manager登録
---
## 2026-03-18: JSONシンタックスハイライト
### 概要
ワークフロータブ・生成UIタブのJSON表示にシンタックスハイライトを追加
### 色分け (eagle_comic_creater_webと同じOne Atomテーマ風)
- 黄 `#e5c07b` — name, scheduler
- ピンク `#c678dd` — title
- 緑 `#98c379` — width, height
- 水色 `#61afef` — text, prompt
- 赤 `#e06c75` — image, file
- ベース `#abb2bf` — その他
### 変更ファイル
- `static/js/json-highlight.js` — 新規: `highlightJSON`, `syncJsonHighlight`, `syncScroll`
- `static/js/workflow-tab.js` — サイドパネルJSON表示に `highlightJSON` 適用
- `templates/index.html` — Raw JSONを `<pre>` + `<textarea>` レイヤー構造に変更
- `static/js/generate-tab.js` — ハイライト同期、input/scrollイベント追加
- `static/js/comfyui-editor.js` — `_syncRawJson` にハイライト同期追加
- `static/css/main.css` — `.wfm-json-container`, `.wfm-json-highlight`, `.json-key-*` スタイル追加
---
## 2026-03-18: プロンプトタブ 中国語↔英語翻訳追加
### 概要
プロンプトタブの翻訳機能に ZH→EN / EN→ZH を追加
### 変更ファイル
- `templates/index.html` — `ZH→EN` / `EN→ZH` ボタン追加
- `static/js/prompt-tab.js` — `sendTranslate` に `zh2en` / `en2zh` プロンプト追加、イベントリスナー追加
---
## 2026-03-18: v0.1.0 リリース
### 概要
GitHub公開・ComfyUI Manager登録・初回リリース
### 実施内容
- GitHub リポジトリ作成: https://github.com/ketle-man/ComfyUI-Workflow-Studio
- README.md 作成(スクリーンショット4枚付き)
- ComfyUI Manager 登録PR: https://github.com/Comfy-Org/ComfyUI-Manager/pull/2706
- GitHub Release v0.1.0: https://github.com/ketle-man/ComfyUI-Workflow-Studio/releases/tag/v0.1.0
+164 -11
View File
@@ -5,6 +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.
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.2.2-green)
## Screenshots
@@ -16,20 +17,39 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| Nodes Tab | Models Tab |
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| Help & Support Tab | |
|:---:|:---:|
| ![Help](docs/screenshot_help.png) | |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
| ![Workflow Studio Library](docs/screenshot_wf_node_library.png) | ![ComfyUI Top Bar](docs/screenshot_comfyui_topbar.png) |
---
## Features
### 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
- **Search** — full-text search across workflow names and metadata
- **Side panel** — view workflow JSON with syntax highlighting, format badge (UI/API), and copy button
- **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
- **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)
### Canvas Snapshot (v0.1.2)
- **One-click capture** — click the camera button in ComfyUI's top bar to snapshot the current workflow canvas
- **Auto-save as thumbnail** — the snapshot is saved directly to the workflow data folder as a PNG thumbnail
- **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
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
@@ -39,19 +59,62 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
### Prompt Tab
- **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
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
- **Workflows directory** — configure which folder to scan for workflows
- **Eagle connection** — set Eagle API endpoint for auto-save
- **Ollama connection** — configure Ollama server URL
- **Badge display** — toggle metadata badges on workflow cards
- **Default workflow** — set a workflow to auto-load on startup
- **Language** — English / Japanese / Chinese
### Nodes Tab (v0.1.7)
- **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.2)
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with sub-tab switching
- **Thumbnail / Card / Table views** — switch between view modes with pagination (24 items per page)
- **Search & Filter** — full-text search, filter by tags, groups, and favorites
- **Side panel tabs** — Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
- **Group management** — create, rename, delete groups and assign/remove models
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
### Workflow Studio Library (ComfyUI Side Panel) (v0.1.8)
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group
- **Nodes tab** — browse favorite nodes, node sets, and node groups
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
- **Double-click** — load workflows or place nodes without dragging
- **Search** — search within each sub-tab to quickly find items
### 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
@@ -64,7 +127,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.
@@ -75,18 +138,22 @@ Restart ComfyUI after installation.
### Launch
Click the **WS** button in the ComfyUI top menu bar, or navigate to:
Click the **W** button in the ComfyUI top menu bar, or navigate to:
```
http://127.0.0.1:8188/wfm
```
> **Tip:** Shift+Click the WS button to open in a new window.
> **Tip:** Shift+Click the W button to open in a new window.
### Canvas Snapshot
Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture the current workflow canvas as a thumbnail. The image is automatically saved to the workflow data folder and appears in Workflow Studio's workflow list.
### Quick Start
1. **Workflow Tab** — Your workflows from `ComfyUI/user/default/workflows/` are automatically listed
2. **Click a workflow** — View JSON details and metadata in the side panel
2. **Click a workflow** — View thumbnail, JSON details, and metadata in the side panel
3. **Load in GenerateUI** — Click the button to load a workflow into the generation interface
4. **Adjust parameters** — Modify prompts, models, seeds, and settings via the auto-generated UI
5. **Generate** — Hit the Generate button to queue the prompt
@@ -116,6 +183,77 @@ http://127.0.0.1:8188/wfm
---
## Changelog
### v0.2.2
- **Models tab** — browse, search, and manage all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with Thumbnail/Card/Table views
- **CivitAI integration** — fetch model metadata by SHA256 hash (individual or batch per model type) with SSE progress streaming
- **Model groups** — create, rename, delete groups and assign/remove models for organization
- **Model metadata** — persistent favorites, tags, memo, and SHA256 hash per model
- **Preview images** — auto-detect and display preview images, change via file upload in detail modal
- **Table view memo column** — memo displayed in table view with compact subdir/extension columns
- **Side panel file path** — display full file path with click-to-copy in model info panel
- **Node card redesign** — removed package badge, added left border color-coding by package
- **Toolbar pagination** — pagination controls moved to toolbar for all tabs (Models, Workflows, Nodes)
- **Help tab updated** — Models Tab section added to feature list
### v0.2.0
- **Prompt presets in sidebar** — added Prompts tab to Workflow Studio Library (ComfyUI side panel) with All / Favorites / Categories sub-tabs
- **WFS_PromptText custom node** — drag prompt presets onto the canvas to create nodes with positive/negative prompt outputs
- **Preset Manager** — 3-column layout in Prompt tab: AI Assistant | Presets editor | Preset Manager (All / Favorites / Groups)
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
- **GenUI Set** — renamed "Apply" button to clarify its purpose (applies presets to GenerateUI)
- **Sidebar P/N copy buttons** — copy positive or negative prompts individually from sidebar items
- **Panel renamed** — "WF & Node Library" renamed to "Workflow Studio Library"
- **Backend API for presets** — presets migrated from localStorage to server-side API with one-time migration
- **Help tab updated** — Prompt Tab and Workflow Studio Library sections reflect new features
### v0.1.9
- **Side panel 2-row sub-tabs** — Workflows: row 1 (Workflows / Favorites / Groups), row 2 (Model Type); Nodes: row 1 (Nodes / Favorites / Groups), row 2 (Sets)
- **Save dialog** — canvas snapshot now shows a filename edit dialog instead of auto-naming
- **API/App format badge** — workflow items in side panel show red (API) or orange (APP) badge
- **Group auto-cleanup** — deleted workflows are automatically removed from groups on refresh
### v0.1.8
- **WF & Node Library side panel** — renamed from "Node Library", added two top-level tabs (Workflows / Nodes) accessible from ComfyUI top bar
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group with collapsible sections
- **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
- Added **Thumbnail section** to the workflow detail modal
- Snapshot images are auto-imported as workflows with thumbnails
### v0.1.1
- Initial feature set: Workflow management, GenerateUI, Prompt assistant, Settings
---
## Project Structure
```
@@ -124,19 +262,29 @@ 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
│ └── js/
│ ├── app.js # App initialization & routing
@@ -145,13 +293,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/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
```
---
@@ -165,5 +316,7 @@ MIT License
## Acknowledgements
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI) by comfyanonymous
- [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss — Canvas snapshot and PNG workflow embedding implementation reference
- [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager) — Plugin architecture and UI pattern reference
- [Ollama](https://ollama.com/) for local LLM inference
- [Eagle](https://eagle.cool/) for image management
+16 -1
View File
@@ -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"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 148 KiB

+461
View File
@@ -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 | ライト |
============================================ */
+4
View File
@@ -55,3 +55,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"
View File
+31
View File
@@ -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)
+388
View File
@@ -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)
+150
View File
@@ -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)
+84
View File
@@ -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)
+41
View File
@@ -27,6 +27,9 @@ def setup_routes(app: web.Application):
app.router.add_post(
"/api/wfm/workflows/change-thumbnail", handle_change_thumbnail
)
app.router.add_post(
"/api/wfm/workflows/save-canvas-image", handle_save_canvas_image
)
async def handle_list(request: web.Request) -> web.Response:
@@ -157,6 +160,44 @@ async def handle_reanalyze_all(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
async def handle_save_canvas_image(request: web.Request) -> web.Response:
"""POST /api/wfm/workflows/save-canvas-image - Import canvas PNG as workflow + thumbnail."""
try:
reader = await request.multipart()
filename = None
image_data = None
while True:
part = await reader.next()
if part is None:
break
if part.name == "filename":
filename = (await part.read()).decode("utf-8")
elif part.name == "image":
image_data = await part.read()
if not filename or not _service._validate_filename(filename):
return web.json_response({"error": "invalid filename"}, status=400)
if image_data is None:
return web.json_response({"error": "image required"}, status=400)
# Use import_files to extract workflow from PNG and save both JSON + thumbnail
stem = filename[:-5] if filename.endswith(".json") else filename
png_name = stem + ".png"
results = await asyncio.to_thread(
_service.import_files, [(png_name, image_data)]
)
if results and results[0].get("status") == "success":
return web.json_response({"status": "ok", "filename": results[0].get("name", filename)})
else:
error_msg = results[0].get("message", "Import failed") if results else "Import failed"
return web.json_response({"error": error_msg}, status=400)
except Exception as e:
logger.error("Error saving canvas image: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_change_thumbnail(request: web.Request) -> web.Response:
"""POST /api/wfm/workflows/change-thumbnail - Change workflow thumbnail."""
try:
+206
View File
@@ -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()
+146
View File
@@ -0,0 +1,146 @@
"""Model metadata management service."""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from ..config import MODEL_METADATA_FILE
logger = logging.getLogger(__name__)
# Preview image extensions to search for (in priority order)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
"lora": "loras",
"vae": "vae",
"controlnet": "controlnet",
"unet": "diffusion_models",
"textencoder": "text_encoders",
}
def _get_model_dirs(model_type):
"""Get all model directories for a type using ComfyUI's folder_paths.
Returns a list of Path objects for all configured model directories
(includes extra_model_paths.yaml settings).
Falls back to plugin-relative path if folder_paths is unavailable.
"""
folder_key = MODEL_TYPE_FOLDER_KEYS.get(model_type)
if not folder_key:
return []
try:
import folder_paths # type: ignore # ComfyUI module
paths = folder_paths.get_folder_paths(folder_key)
result = [Path(p) for p in paths if Path(p).is_dir()]
if result:
return result
except Exception as e:
logger.debug("folder_paths unavailable (%s), using fallback", e)
# Fallback: custom_nodes/../../models/{folder_key}
plugin_dir = Path(__file__).resolve().parent.parent.parent
models_dir = plugin_dir.parent.parent / "models" / folder_key
if models_dir.is_dir():
return [models_dir]
return []
class ModelsService:
"""Manages user-defined model metadata (favorites, tags, memo)."""
def __init__(self):
self.metadata_file = MODEL_METADATA_FILE
def _now_iso(self):
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _load_metadata(self):
if self.metadata_file.exists():
try:
with open(self.metadata_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_metadata(self, data):
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.metadata_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_all_metadata(self):
return self._load_metadata()
def update_metadata(self, model_name, updates):
data = self._load_metadata()
if model_name not in data:
data[model_name] = {"tags": [], "favorite": False, "memo": ""}
entry = data[model_name]
if "tags" in updates:
entry["tags"] = updates["tags"]
if "favorite" in updates:
entry["favorite"] = updates["favorite"]
if "memo" in updates:
entry["memo"] = updates["memo"]
if "sha256" in updates:
entry["sha256"] = updates["sha256"]
entry["updatedAt"] = self._now_iso()
data[model_name] = entry
self._save_metadata(data)
return entry
def get_model_groups(self):
data = self._load_metadata()
return data.get("_groups", {})
def save_model_groups(self, groups):
data = self._load_metadata()
data["_groups"] = groups
self._save_metadata(data)
return groups
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
Searches all configured directories for the model type
(via ComfyUI's folder_paths, which includes extra_model_paths.yaml).
Looks for files like:
modelname.preview.png, modelname.png, etc.
next to the model file.
Returns: absolute Path to preview image, or None.
"""
dirs = _get_model_dirs(model_type)
if not dirs:
logger.debug("Preview: no dirs for model_type=%s", model_type)
return None
for type_dir in dirs:
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
model_path = type_dir / model_name
if not model_path.is_file():
continue
stem = model_path.stem
parent = model_path.parent
for ext in _PREVIEW_EXTENSIONS:
preview = parent / (stem + ext)
if preview.is_file():
logger.debug("Preview found: %s", preview)
return preview
logger.debug("Preview: no preview for %s (stem=%s, dir=%s)",
model_name, stem, parent)
return None
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
+146
View File
@@ -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),
}
+89
View File
@@ -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)
+24 -1
View File
@@ -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,
}
+37 -14
View File
@@ -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()
@@ -106,7 +122,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:
@@ -132,10 +148,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 +180,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 +212,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 +225,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 +240,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 +257,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 +302,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 +315,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)
+4 -1
View File
@@ -61,11 +61,14 @@ class WorkflowStudio:
app.router.add_get("/wfm", serve_index_page)
# API routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
ollama_routes.setup_routes(app)
eagle_routes.setup_routes(app)
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+1406 -30
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4a9eff" stroke-linecap="round" stroke-linejoin="round">
<rect width="24" height="24" rx="4" fill="#1a1a2e" stroke="none"/>
<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>

After

Width:  |  Height:  |  Size: 346 B

+115 -8
View File
@@ -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", settings: "tabSettings", help: "tabHelp" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -142,11 +142,57 @@ function applyI18nToHtml() {
const copyBtn = document.getElementById("wfm-json-copy-btn");
if (copyBtn) copyBtn.textContent = t("copy");
// Prompt tab
document.querySelectorAll(".wfm-prompt-subtab-btn").forEach((btn) => {
if (btn.dataset.promptSubtab === "assistant") btn.textContent = t("assistantSubtab");
if (btn.dataset.promptSubtab === "presets") btn.textContent = t("presetsSubtab");
// 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");
});
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
const presetsHeader = document.querySelector(".wfm-prompt-split-right .wfm-prompt-split-header");
if (presetsHeader) presetsHeader.textContent = t("presetsSubtab");
const ollamaRefreshBtn = document.getElementById("wfm-ollama-refresh-btn");
if (ollamaRefreshBtn) ollamaRefreshBtn.textContent = t("refresh");
const ollamaTestBtn = document.getElementById("wfm-ollama-test-btn");
@@ -163,10 +209,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 +241,58 @@ 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-wf-8": "helpWf8",
"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-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
"wfm-help-prompt-7": "helpPrompt7",
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
"wfm-help-models-7": "helpModels7",
"wfm-help-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
"wfm-help-shortcuts-title": "helpShortcutsTitle",
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
"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 +327,14 @@ 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";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
document.addEventListener("DOMContentLoaded", () => {
applyI18nToHtml();
@@ -237,6 +342,8 @@ document.addEventListener("DOMContentLoaded", () => {
initModal();
initSettingsTab(); // Settings first (applies saved URL)
initWorkflowTab();
initNodesTab();
initModelsTab();
initGenerateTab();
initPromptTab();
+7 -1
View File
@@ -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 {
+7 -2
View File
@@ -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";
+7 -3
View File
@@ -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;
}
// ============================================
+763 -8
View File
@@ -43,6 +43,8 @@ const LANGUAGES = {
// -- Side panel --
uiFormat: "UI Format",
apiFormat: "API Format",
appFormat: "App Format",
appFormatNotSupported: "App format workflows cannot be loaded in GenerateUI. Please use 'Open in ComfyUI' instead.",
noSelection: "No selection",
groupsLabel: "Groups",
groupsNone: "none",
@@ -145,12 +147,207 @@ const LANGUAGES = {
presetName: "Preset Name",
positivePrompt: "Positive Prompt",
negativePrompt: "Negative Prompt",
savePreset: "Save Preset",
applyPreset: "Apply",
deletePreset: "Delete",
savePreset: "Save",
copyPositivePrompt: "PP Copy",
copyNegativePrompt: "NP Copy",
copiedToClipboard: "Copied to clipboard",
noTextToCopy: "No text to copy",
applyPreset: "GenUI Set",
assistantSubtab: "AI Assistant",
presetsSubtab: "Presets",
// -- Nodes tab --
tabNodes: "Nodes",
nodesAllNodes: "All Nodes",
nodesNodeSets: "Node Sets",
nodesSearchPlaceholder: "Search nodes...",
nodesAllCategories: "All Categories",
nodesAllPackages: "All Packages",
nodesAllTags: "All Tags",
nodesAllGroups: "All Groups",
noNodesFound: "No nodes found",
nodesDetails: "Details",
nodesIoSpec: "I/O Spec",
nodesGroups: "Groups",
nodesInputs: "Inputs",
nodesOutputs: "Outputs",
nodesRequired: "Required",
nodesOptional: "Optional",
nodesClassName: "Class Name",
nodesPackage: "Package",
nodesCategory: "Category",
nodesDescription: "Description",
nodesExperimental: "Experimental",
nodesDeprecated: "Deprecated",
nodesOutputNode: "Output Node",
nodesEditTags: "Tags (comma separated)",
nodesCreateSet: "Create Node Set",
nodesEditSet: "Edit",
nodesSetName: "Set Name",
nodesSetDescription: "Description",
nodesSetNodes: "Nodes",
nodesAddNode: "Add Node",
nodesSetLinks: "Links",
nodesAddLink: "Add Link",
nodesCopyToClipboard: "Copy to Clipboard",
nodesCopied: "Node set copied to clipboard",
nodesCopyClassName: "Copy class name",
nodesCopiedClassName: "Class name copied!",
nodesDeleteSet: "Delete",
nodesDeleteSetConfirm: "Delete this node set?",
nodesSetUpdated: "Node set updated",
nodesRefresh: "Refresh",
nodesCreateGroup: "Create Group",
nodesGroupName: "Group Name",
nodesAssignGroup: "Assign to Group",
nodesRemoveGroup: "Remove from Group",
nodesConnectToComfyUI: "Connect to ComfyUI to load nodes. Check Settings tab.",
nodesClickToLoad: "Click to load nodes...",
// -- Models tab --
tabModels: "Models",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "Search models...",
modelsRefresh: "Refresh",
modelsClickToLoad: "Click Refresh to load models...",
modelsLoading: "Loading models...",
modelsLoadError: "Error loading models",
modelsNoModels: "No models found",
modelsAllTags: "All Tags",
modelsFileName: "File Name",
modelsSubdir: "Subdirectory",
modelsExt: "Extension",
modelsTags: "Tags",
modelsTagsPlaceholder: "tag1, tag2, ...",
modelsModelType: "Model Type",
modelsFavorite: "Favorite",
modelsMemo: "Memo",
modelsMemoPlaceholder: "Notes about this model...",
modelsSave: "Save",
modelsSaved: "Metadata saved",
modelsCopyName: "Click to copy model name",
modelsCopiedName: "Model name copied",
modelsFilePath: "File Path",
modelsCopyPath: "Click to copy file path",
modelsCopiedPath: "File path copied",
modelsNoPreview: "No preview",
modelsTagsHint: "(comma separated)",
modelsAllGroups: "All Groups",
modelsCurrentGroups: "Current Groups",
modelsNoGroup: "Not assigned to any group",
modelsAssignGroup: "Assign to Group",
modelsNoGroupAvailable: "No groups available",
modelsAdd: "Add",
modelsCreateGroup: "Create New Group",
modelsGroupName: "Group name",
modelsCreate: "Create",
modelsManageGroups: "Manage Groups",
modelsRename: "Rename",
modelsDelete: "Delete",
modelsRemoveFromGroup: "Remove from group",
modelsGroupExists: "Group already exists",
modelsRenamePrompt: "Enter new group name:",
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
civitaiFetch: "Fetch from CivitAI",
civitaiHashing: "Calculating hash... (may take a while for large files)",
civitaiFound: "CivitAI info loaded",
civitaiNotFound: "Model not found on CivitAI",
civitaiError: "Error fetching from CivitAI",
civitaiTriggerWords: "Trigger Words",
civitaiCopyWord: "Click to copy",
civitaiWordCopied: "Trigger word copied",
civitaiRefresh: "Refresh",
civitaiBatchAllCached: "All models already have CivitAI info",
civitaiHashing2: "Hashing...",
civitaiFetching: "Fetching...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found} found, ${notFound} not found`,
modelsSideInfo: "Info",
modelsSideGroup: "Group",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "Help",
helpManualTitle: "Features",
helpAboutTitle: "About",
helpAboutDesc: "A centralized control UI for managing ComfyUI workflow JSON files, generating images via API, assisting with prompt creation, and browsing/organizing nodes.",
helpAbout1: "Install via ComfyUI Manager or git clone to custom_nodes",
helpAbout2: "Launch from the Workflow Studio button in the ComfyUI toolbar, or open http://127.0.0.1:8188/wfm",
helpWf1: "Switch between Thumbnail, Card, and Table view modes",
helpWf2: "Search, category filter, favorites filter, and group filter",
helpWf3: "Create, rename, delete groups and assign/remove workflows",
helpWf4: "Import JSON/PNG files via button or drag & drop",
helpWf5: "Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management",
helpWf6: "Detail modal: rename, favorite, thumbnail change, analysis, category override, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete",
helpWf7: "Customize badge colors per category",
helpWf8: "Batch re-analysis of all workflow I/O (P: prompt nodes, I: image nodes, output: save nodes)",
helpGen1: "Connect/disconnect ComfyUI and check connection status",
helpGen2: "Load workflows from Workflow tab (App format .app.json not supported)",
helpGen3: "Edit parameters: Prompt, Image (drag & drop), Model (checkpoint/VAE/LoRA/ControlNet with filter), Settings (steps/CFG/sampler/scheduler/resolution), Raw JSON",
helpGen4: "Seed control: random, fixed, increment, decrement",
helpGen5: "Generate, progress display, and stop",
helpGen6: "View results with main image and thumbnail history",
helpGen7: "Eagle integration: auto-save generated images to Eagle",
helpPrompt1: "AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)",
helpPrompt2: "Apply AI responses to GenerateUI prompts",
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
helpPrompt4: "Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)",
helpPrompt5: "Preset Manager: browse all presets, favorites, and group-based filtering",
helpPrompt6: "Group management: create groups, assign/remove presets, delete groups",
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
helpSettings1: "UI language and summary language",
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
helpSettings3: "Workflow data folder path",
helpSettings4: "ComfyUI connection URL and test",
helpSettings5: "Ollama URL, default model, and connection test",
helpSettings6: "Default workflow setting",
helpSettings7: "Eagle integration URL and auto-save toggle",
helpSettings8: "All sections are collapsible accordion panels",
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
helpNodes2: "Card and Table view modes with pagination",
helpNodes3: "Favorite nodes and add custom tags for organization",
helpNodes4: "Create, rename, delete node groups and assign/remove nodes",
helpNodes5: "Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management",
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder with sub-tab switching",
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
helpModels3: "Search, tag filter, group filter, and favorites filter",
helpModels4: "Side panel: Info (file path, tags, memo), Groups management, CivitAI integration",
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type",
helpModels6: "Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change",
helpModels7: "Group management: create, rename, delete groups and assign/remove models",
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
helpSidepanel2: "Workflows tab: browse favorite workflows, filter by model type, and filter by group",
helpSidepanel3: "Nodes tab: browse favorite nodes, node sets, and node groups",
helpSidepanel4: "Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs",
helpSidepanel5: "Drag & drop workflows onto the canvas to load them",
helpSidepanel6: "Drag & drop nodes/node sets onto the canvas to place them",
helpSidepanel7: "Drag & drop prompt presets to create WFS_PromptText nodes with positive/negative prompts",
helpSidepanel8: "Copy individual positive (P) or negative (N) prompts from sidebar items",
helpSidepanel9: "Double-click items to load/place without dragging",
helpSidepanel10: "Search within each sub-tab to quickly find items",
helpShortcutsTitle: "Keyboard Shortcuts",
helpShortcuts1: "Escape: Close modal/panel",
helpShortcuts2: "Enter: Confirm rename, send chat",
helpShortcuts3: "Shift+Enter: New line in chat",
helpShortcuts4: "Double-click: Open detail modal",
helpTroubleTitle: "Troubleshooting",
helpTrouble1: "Cannot connect to ComfyUI: Check if ComfyUI can generate images, verify URL in Settings, try refreshing or restarting",
helpTrouble2: "Cannot connect to Ollama: Use connection test button, check if Ollama is running and models are downloaded",
helpTrouble3: "Workflows not showing: Click refresh button, check if loadable in ComfyUI, try restarting browser/ComfyUI",
helpTrouble4: "Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting",
helpSupportTitle: "Support",
helpSupportDesc: "If you find this plugin useful, please consider supporting its development!",
helpGithubDesc: "Report issues, request features, and contribute",
helpKofiDesc: "Buy me a coffee to support development",
helpThanks: "Thank you for using Workflow Studio!",
// -- Settings --
workflowsDir: "Workflow Data Folder",
workflowsDirLabel: "Folder Path",
@@ -191,6 +388,62 @@ const LANGUAGES = {
saveSettings: "Save Settings",
settingsSaved: "Settings saved",
ollamaSaved: "Ollama settings saved",
// -- Theme --
themeLabel: "Theme",
themeDefault: "Deep Ocean Dark (Default)",
themePopVibrant: "Pop & Vibrant",
themeLightMinimalist: "Light Minimalist",
themeCyberpunk: "Cyberpunk / Hacker",
themeGlassmorphism: "Glassmorphism",
themeNeumorphism: "Neumorphism",
themeRetroPixel: "Retro 8-bit / Pixel",
themePastelDream: "Pastel Dream",
themeBrutalism: "Brutalism",
themeEarthy: "Earthy / Nature",
themeMaterial: "Material UI",
themeMonotoneAccent: "Monotone + Accent",
themeCorporate: "Corporate Trust",
// -- Theme Customizer --
themeCustomize: "Customize",
themeCustomizeColors: "Color Override",
themeCustomizeBgPattern: "Background Pattern",
themeColorBg: "Background",
themeColorBgSec: "Secondary BG",
themeColorSurface: "Surface",
themeColorText: "Text",
themeColorPrimary: "Primary",
themeColorAccent: "Accent",
themePatternNone: "None",
themePatternStripeH: "Stripe H",
themePatternStripeV: "Stripe V",
themePatternStripeD: "Stripe D",
themePatternDot: "Polka Dot",
themePatternCheck: "Checkerboard",
themePatternSvg: "SVG File",
themePatternColor: "Pattern Color",
themePatternOpacity: "Opacity",
themePatternScale: "Scale",
themePatternGap: "Gap",
themeCustomizerReset: "Reset Customization",
themeCustomizerApply: "Apply & Save",
// -- Font --
themeCustomizeFont: "Font",
themeFontDefault: "System Default",
themeFontNotoSans: "Noto Sans JP",
themeFontZenMaru: "Zen Maru Gothic",
themeFontMPlusRound: "M PLUS Rounded",
themeFontKosugiMaru: "Kosugi Maru",
themeFontSawarabi: "Sawarabi Gothic",
themeFontBizUdp: "BIZ UDPGothic",
themeFontDotGothic: "DotGothic16",
themeFontHachiMaru: "Hachi Maru Pop",
themeFontDelaGothic: "Dela Gothic One",
themeFontReggaeOne: "Reggae One",
themeFontRocknRoll: "RocknRoll One",
themeFontStick: "Stick",
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
},
ja: {
@@ -232,6 +485,8 @@ const LANGUAGES = {
// -- Side panel --
uiFormat: "UI形式",
apiFormat: "API形式",
appFormat: "App形式",
appFormatNotSupported: "App形式のワークフローは生成UIに読み込めません。「ComfyUIで開く」をご利用ください。",
noSelection: "未選択",
groupsLabel: "グループ",
groupsNone: "なし",
@@ -334,12 +589,207 @@ const LANGUAGES = {
presetName: "プリセット名",
positivePrompt: "ポジティブプロンプト",
negativePrompt: "ネガティブプロンプト",
savePreset: "プリセット保存",
applyPreset: "適用",
deletePreset: "削除",
savePreset: "保存",
copyPositivePrompt: "PP コピー",
copyNegativePrompt: "NP コピー",
copiedToClipboard: "クリップボードにコピーしました",
noTextToCopy: "コピーするテキストがありません",
applyPreset: "GenUI Set",
assistantSubtab: "AIアシスタント",
presetsSubtab: "プリセット",
// -- Nodes tab --
tabNodes: "ノード",
nodesAllNodes: "全ノード",
nodesNodeSets: "ノードセット",
nodesSearchPlaceholder: "ノードを検索...",
nodesAllCategories: "全カテゴリ",
nodesAllPackages: "全パッケージ",
nodesAllTags: "全タグ",
nodesAllGroups: "全グループ",
noNodesFound: "ノードが見つかりません",
nodesDetails: "詳細",
nodesIoSpec: "I/O仕様",
nodesGroups: "グループ",
nodesInputs: "入力",
nodesOutputs: "出力",
nodesRequired: "必須",
nodesOptional: "任意",
nodesClassName: "クラス名",
nodesPackage: "パッケージ",
nodesCategory: "カテゴリ",
nodesDescription: "説明",
nodesExperimental: "実験的",
nodesDeprecated: "非推奨",
nodesOutputNode: "出力ノード",
nodesEditTags: "タグ(カンマ区切り)",
nodesCreateSet: "ノードセット作成",
nodesEditSet: "編集",
nodesSetName: "セット名",
nodesSetDescription: "説明",
nodesSetNodes: "ノード",
nodesAddNode: "ノード追加",
nodesSetLinks: "接続",
nodesAddLink: "接続追加",
nodesCopyToClipboard: "クリップボードにコピー",
nodesCopied: "ノードセットをクリップボードにコピーしました",
nodesCopyClassName: "クラス名をコピー",
nodesCopiedClassName: "クラス名をコピーしました!",
nodesDeleteSet: "削除",
nodesDeleteSetConfirm: "このノードセットを削除しますか?",
nodesSetUpdated: "ノードセットを更新しました",
nodesRefresh: "更新",
nodesCreateGroup: "グループ作成",
nodesGroupName: "グループ名",
nodesAssignGroup: "グループに追加",
nodesRemoveGroup: "グループから削除",
nodesConnectToComfyUI: "ComfyUIに接続してノードを読み込みます。設定タブを確認してください。",
nodesClickToLoad: "クリックしてノードを読み込み...",
// -- Models tab --
tabModels: "モデル",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "モデルを検索...",
modelsRefresh: "更新",
modelsClickToLoad: "更新ボタンでモデルを読み込み...",
modelsLoading: "モデルを読み込み中...",
modelsLoadError: "モデルの読み込みエラー",
modelsNoModels: "モデルが見つかりません",
modelsAllTags: "すべてのタグ",
modelsFileName: "ファイル名",
modelsSubdir: "サブディレクトリ",
modelsExt: "拡張子",
modelsTags: "タグ",
modelsTagsPlaceholder: "タグ1, タグ2, ...",
modelsModelType: "モデルタイプ",
modelsFavorite: "お気に入り",
modelsMemo: "メモ",
modelsMemoPlaceholder: "このモデルについてのメモ...",
modelsSave: "保存",
modelsSaved: "メタデータを保存しました",
modelsCopyName: "クリックでモデル名をコピー",
modelsCopiedName: "モデル名をコピーしました",
modelsFilePath: "ファイルパス",
modelsCopyPath: "クリックでファイルパスをコピー",
modelsCopiedPath: "ファイルパスをコピーしました",
modelsNoPreview: "プレビューなし",
modelsTagsHint: "(カンマ区切り)",
modelsAllGroups: "すべてのグループ",
modelsCurrentGroups: "所属グループ",
modelsNoGroup: "グループに未所属",
modelsAssignGroup: "グループに追加",
modelsNoGroupAvailable: "グループがありません",
modelsAdd: "追加",
modelsCreateGroup: "新規グループ作成",
modelsGroupName: "グループ名",
modelsCreate: "作成",
modelsManageGroups: "グループ管理",
modelsRename: "名前変更",
modelsDelete: "削除",
modelsRemoveFromGroup: "グループから除外",
modelsGroupExists: "同名のグループが既に存在します",
modelsRenamePrompt: "新しいグループ名を入力:",
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
civitaiFetch: "CivitAIから取得",
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
civitaiFound: "CivitAI情報を取得しました",
civitaiNotFound: "CivitAIにモデルが見つかりません",
civitaiError: "CivitAIからの取得エラー",
civitaiTriggerWords: "トリガーワード",
civitaiCopyWord: "クリックでコピー",
civitaiWordCopied: "トリガーワードをコピーしました",
civitaiRefresh: "更新",
civitaiBatchAllCached: "すべてのモデルのCivitAI情報は取得済みです",
civitaiHashing2: "ハッシュ計算中...",
civitaiFetching: "取得中...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}件取得, ${notFound}件未登録`,
modelsSideInfo: "基本情報",
modelsSideGroup: "グループ",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "ヘルプ",
helpManualTitle: "機能一覧",
helpAboutTitle: "概要",
helpAboutDesc: "ComfyUIのワークフローJSONファイルの管理、API接続による画像生成、プロンプト作成支援、ノードの閲覧・整理を行う集中コントロールUIです。",
helpAbout1: "ComfyUIマネージャーからインストール、またはcustom_nodesフォルダへgit clone",
helpAbout2: "ComfyUIツールバーのWorkflow Studioボタンから起動、またはhttp://127.0.0.1:8188/wfmを開く",
helpWf1: "サムネイル / カード / テーブルの表示モード切り替え",
helpWf2: "テキスト検索、カテゴリフィルタ、お気に入りフィルタ、グループフィルタ",
helpWf3: "グループの作成・名前変更・削除、ワークフローの割り当て・解除",
helpWf4: "JSON/PNGファイルのインポート(ボタンまたはドラッグ&ドロップ)",
helpWf5: "サイドパネル:サムネイル、JSONビューア(フォーマットバッジ:UI/API/App形式)、クリップボードコピー、グループ管理",
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、分析、カテゴリオーバーライド、タグ、メモ、AI要約、ComfyUIで開く、生成UIに読み込み、デフォルト設定、削除",
helpWf7: "カテゴリごとのバッジ色カスタマイズ",
helpWf8: "全ワークフローの入出力一括再分析(P:プロンプト入力、I:画像入力、出力:saveノード)",
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
helpGen2: "Workflowタブからワークフローを読み込み(App形式 .app.json は非対応)",
helpGen3: "パラメータ編集:プロンプト、画像(ドラッグ&ドロップ)、モデル(checkpoint/VAE/LoRA/ControlNet、フィルタ検索)、設定(Steps/CFG/サンプラー/スケジューラ/解像度)、Raw JSON",
helpGen4: "シード管理:ランダム / 固定 / 増加 / 減少",
helpGen5: "画像生成、進捗表示、生成停止",
helpGen6: "メイン画像とサムネイル履歴で結果表示",
helpGen7: "Eagle連携:生成画像をEagleへ自動保存",
helpPrompt1: "AIアシスタント(Ollama):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応)",
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
helpPrompt4: "プリセット管理:作成・編集、ポジティブ/ネガティブプロンプトのクリップボードコピー(PP/NP)",
helpPrompt5: "プリセットマネージャー:全プリセット一覧、お気に入り、グループ別フィルタリング",
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
helpSettings1: "UI言語と要約言語の設定",
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
helpSettings4: "ComfyUI接続URL設定と接続テスト",
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
helpSettings6: "デフォルトワークフローの設定・解除",
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
helpNodes4: "ノードグループの作成・名前変更・削除、ノードの割り当て・解除",
helpNodes5: "サイドパネル:詳細(クラス名、パッケージ、カテゴリ、説明、フラグ)、I/O仕様(入出力の型・デフォルト値)、グループ管理",
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoderをサブタブで切り替え",
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
helpSidepanel2: "Workflowsタブ:お気に入りワークフロー閲覧、モデル種別フィルタ、グループフィルタ",
helpSidepanel3: "Nodesタブ:お気に入りノード、ノードセット、ノードグループの閲覧",
helpSidepanel4: "Promptsタブ:プロンプトプリセットの閲覧(All/お気に入り/カテゴリ サブタブ)",
helpSidepanel5: "ワークフローをキャンバスにドラッグ&ドロップで読み込み",
helpSidepanel6: "ノード/ノードセットをキャンバスにドラッグ&ドロップで配置",
helpSidepanel7: "プロンプトプリセットをドラッグ&ドロップでWFS_PromptTextノードを作成(ポジティブ/ネガティブ対応)",
helpSidepanel8: "サイドバーアイテムからポジティブ(P)/ネガティブ(N)プロンプトを個別コピー",
helpSidepanel9: "ダブルクリックでもアイテムの読み込み・配置が可能",
helpSidepanel10: "各サブタブ内で検索してアイテムをすばやく検索",
helpShortcutsTitle: "キーボード操作",
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
helpShortcuts3: "Shift+Enter:チャットで改行",
helpShortcuts4: "ダブルクリック:詳細モーダルを開く",
helpTroubleTitle: "トラブルシューティング",
helpTrouble1: "ComfyUIに接続できない:ComfyUIで画像生成できるか確認、設定タブでURL確認、ブラウザ更新・再起動",
helpTrouble2: "Ollamaに接続できない:接続テストボタンで確認、Ollamaの起動・モデルのダウンロードを確認",
helpTrouble3: "ワークフローが表示されない:更新ボタン押下、ComfyUIで読み込めるか確認、ブラウザ・ComfyUI再起動",
helpTrouble4: "画像生成が失敗する:指定モデルの存在確認、ComfyUI接続確認、再起動",
helpSupportTitle: "サポート",
helpSupportDesc: "このプラグインが役に立ったら、開発を支援していただけると嬉しいです!",
helpGithubDesc: "バグ報告、機能リクエスト、コントリビュート",
helpKofiDesc: "コーヒーを奢って開発を応援",
helpThanks: "Workflow Studioをご利用いただきありがとうございます!",
// -- Settings --
workflowsDir: "ワークフローデータフォルダ",
workflowsDirLabel: "フォルダパス",
@@ -380,6 +830,62 @@ const LANGUAGES = {
saveSettings: "設定を保存",
settingsSaved: "設定を保存しました",
ollamaSaved: "Ollama設定を保存しました",
// -- Theme --
themeLabel: "テーマ",
themeDefault: "Deep Ocean Dark (デフォルト)",
themePopVibrant: "Pop & Vibrant (ポップ調)",
themeLightMinimalist: "Light Minimalist (クリーン)",
themeCyberpunk: "Cyberpunk / Hacker",
themeGlassmorphism: "Glassmorphism (グラス)",
themeNeumorphism: "Neumorphism (ニューモ)",
themeRetroPixel: "Retro 8-bit (レトロ)",
themePastelDream: "Pastel Dream (パステル)",
themeBrutalism: "Brutalism (ブルータリズム)",
themeEarthy: "Earthy / Nature (アース)",
themeMaterial: "Material UI (マテリアル)",
themeMonotoneAccent: "Monotone + Accent",
themeCorporate: "Corporate Trust (企業向け)",
// -- Theme Customizer --
themeCustomize: "カスタマイズ",
themeCustomizeColors: "カラー調整",
themeCustomizeBgPattern: "背景パターン",
themeColorBg: "背景",
themeColorBgSec: "サブ背景",
themeColorSurface: "サーフェス",
themeColorText: "テキスト",
themeColorPrimary: "プライマリ",
themeColorAccent: "アクセント",
themePatternNone: "なし",
themePatternStripeH: "横ストライプ",
themePatternStripeV: "縦ストライプ",
themePatternStripeD: "斜めストライプ",
themePatternDot: "ポルカドット",
themePatternCheck: "チェック",
themePatternSvg: "SVGファイル",
themePatternColor: "パターン色",
themePatternOpacity: "不透明度",
themePatternScale: "サイズ",
themePatternGap: "間隔",
themeCustomizerReset: "カスタマイズをリセット",
themeCustomizerApply: "適用して保存",
// -- Font --
themeCustomizeFont: "フォント",
themeFontDefault: "システム標準",
themeFontNotoSans: "Noto Sans JP",
themeFontZenMaru: "Zen丸ゴシック",
themeFontMPlusRound: "M PLUS 丸ゴシック",
themeFontKosugiMaru: "小杉丸ゴシック",
themeFontSawarabi: "さわらびゴシック",
themeFontBizUdp: "BIZ UDPゴシック",
themeFontDotGothic: "ドットゴシック16",
themeFontHachiMaru: "はちまるポップ",
themeFontDelaGothic: "デラゴシック",
themeFontReggaeOne: "レゲエ One",
themeFontRocknRoll: "ロックンロール",
themeFontStick: "ステッキ",
themeFontTrainOne: "トレイン One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
},
zh: {
@@ -421,6 +927,8 @@ const LANGUAGES = {
// -- Side panel --
uiFormat: "UI格式",
apiFormat: "API格式",
appFormat: "App格式",
appFormatNotSupported: "App格式的工作流无法在生成UI中加载。请使用「在ComfyUI中打开」。",
noSelection: "未选择",
groupsLabel: "分组",
groupsNone: "无",
@@ -524,11 +1032,202 @@ const LANGUAGES = {
positivePrompt: "正面提示词",
negativePrompt: "负面提示词",
savePreset: "保存预设",
applyPreset: "应用",
deletePreset: "删除",
applyPreset: "GenUI Set",
assistantSubtab: "AI助手",
presetsSubtab: "预设",
// -- Nodes tab --
tabNodes: "节点",
nodesAllNodes: "所有节点",
nodesNodeSets: "节点集",
nodesSearchPlaceholder: "搜索节点...",
nodesAllCategories: "所有分类",
nodesAllPackages: "所有包",
nodesAllTags: "所有标签",
nodesAllGroups: "所有分组",
noNodesFound: "未找到节点",
nodesDetails: "详情",
nodesIoSpec: "I/O规格",
nodesGroups: "分组",
nodesInputs: "输入",
nodesOutputs: "输出",
nodesRequired: "必需",
nodesOptional: "可选",
nodesClassName: "类名",
nodesPackage: "包",
nodesCategory: "分类",
nodesDescription: "描述",
nodesExperimental: "实验性",
nodesDeprecated: "已弃用",
nodesOutputNode: "输出节点",
nodesEditTags: "标签(逗号分隔)",
nodesCreateSet: "创建节点集",
nodesEditSet: "编辑",
nodesSetName: "集名称",
nodesSetDescription: "描述",
nodesSetNodes: "节点",
nodesAddNode: "添加节点",
nodesSetLinks: "连接",
nodesAddLink: "添加连接",
nodesCopyToClipboard: "复制到剪贴板",
nodesCopied: "节点集已复制到剪贴板",
nodesCopyClassName: "复制类名",
nodesCopiedClassName: "类名已复制!",
nodesDeleteSet: "删除",
nodesDeleteSetConfirm: "删除此节点集?",
nodesSetUpdated: "节点集已更新",
nodesRefresh: "刷新",
nodesCreateGroup: "创建分组",
nodesGroupName: "分组名称",
nodesAssignGroup: "添加到分组",
nodesRemoveGroup: "从分组移除",
nodesConnectToComfyUI: "连接ComfyUI以加载节点。请检查设置选项卡。",
nodesClickToLoad: "点击加载节点...",
// -- Models tab --
tabModels: "模型",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "搜索模型...",
modelsRefresh: "刷新",
modelsClickToLoad: "点击刷新加载模型...",
modelsLoading: "正在加载模型...",
modelsLoadError: "加载模型出错",
modelsNoModels: "未找到模型",
modelsAllTags: "所有标签",
modelsFileName: "文件名",
modelsSubdir: "子目录",
modelsExt: "扩展名",
modelsTags: "标签",
modelsTagsPlaceholder: "标签1, 标签2, ...",
modelsModelType: "模型类型",
modelsFavorite: "收藏",
modelsMemo: "备注",
modelsMemoPlaceholder: "关于此模型的备注...",
modelsSave: "保存",
modelsSaved: "元数据已保存",
modelsCopyName: "点击复制模型名称",
modelsCopiedName: "模型名称已复制",
modelsFilePath: "文件路径",
modelsCopyPath: "点击复制文件路径",
modelsCopiedPath: "文件路径已复制",
modelsNoPreview: "无预览",
modelsTagsHint: "(逗号分隔)",
modelsAllGroups: "所有分组",
modelsCurrentGroups: "当前分组",
modelsNoGroup: "未分配到任何分组",
modelsAssignGroup: "分配到分组",
modelsNoGroupAvailable: "没有可用的分组",
modelsAdd: "添加",
modelsCreateGroup: "创建新分组",
modelsGroupName: "分组名称",
modelsCreate: "创建",
modelsManageGroups: "分组管理",
modelsRename: "重命名",
modelsDelete: "删除",
modelsRemoveFromGroup: "从分组移除",
modelsGroupExists: "同名分组已存在",
modelsRenamePrompt: "输入新分组名称:",
modelsDeleteGroupConfirm: "删除分组「{name}」?",
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
civitaiFetch: "从CivitAI获取",
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
civitaiFound: "已加载CivitAI信息",
civitaiNotFound: "在CivitAI上未找到该模型",
civitaiError: "从CivitAI获取时出错",
civitaiTriggerWords: "触发词",
civitaiCopyWord: "点击复制",
civitaiWordCopied: "触发词已复制",
civitaiRefresh: "刷新",
civitaiBatchAllCached: "所有模型的CivitAI信息已获取",
civitaiHashing2: "计算哈希中...",
civitaiFetching: "获取中...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}个已找到, ${notFound}个未找到`,
modelsSideInfo: "基本信息",
modelsSideGroup: "分组",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "帮助",
helpManualTitle: "功能列表",
helpAboutTitle: "概述",
helpAboutDesc: "用于管理ComfyUI工作流JSON文件、通过API生成图片、辅助提示词创作、浏览和整理节点的集中控制UI。",
helpAbout1: "通过ComfyUI Manager安装,或git clone到custom_nodes文件夹",
helpAbout2: "从ComfyUI工具栏的Workflow Studio按钮启动,或打开http://127.0.0.1:8188/wfm",
helpWf1: "切换缩略图/卡片/表格视图模式",
helpWf2: "文本搜索、分类筛选、收藏筛选、分组筛选",
helpWf3: "创建、重命名、删除分组,分配/移除工作流",
helpWf4: "通过按钮或拖放导入JSON/PNG文件",
helpWf5: "侧面板:缩略图、JSON查看器(格式徽章:UI/API/App)、剪贴板复制、分组管理",
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、分析、分类覆盖、标签、备注、AI摘要、在ComfyUI中打开、加载到生成UI、设为默认、删除",
helpWf7: "按分类自定义徽章颜色",
helpWf8: "批量重新分析所有工作流I/O(P:提示词节点、I:图片节点、输出:保存节点)",
helpGen1: "连接/断开ComfyUI,检查连接状态",
helpGen2: "从工作流标签页加载工作流(不支持App格式.app.json)",
helpGen3: "编辑参数:提示词、图片(拖放)、模型(checkpoint/VAE/LoRA/ControlNet含筛选)、设置(Steps/CFG/采样器/调度器/分辨率)、Raw JSON",
helpGen4: "种子管理:随机/固定/递增/递减",
helpGen5: "生成图片、进度显示、停止生成",
helpGen6: "主图和缩略图历史查看结果",
helpGen7: "Eagle集成:自动保存生成图片到Eagle",
helpPrompt1: "AI助手(Ollama):模型选择、聊天咨询提示词、图片附件(视觉模型)",
helpPrompt2: "将AI回复应用到生成UI的提示词",
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
helpPrompt4: "预设管理:创建、编辑、正面/负面提示词剪贴板复制(PP/NP)",
helpPrompt5: "预设管理器:浏览所有预设、收藏、按组筛选",
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
helpSettings1: "UI语言和摘要语言设置",
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
helpSettings3: "工作流数据文件夹路径更改/重置",
helpSettings4: "ComfyUI连接URL设置和测试",
helpSettings5: "Ollama URL、默认模型、连接测试",
helpSettings6: "默认工作流设置/取消",
helpSettings7: "Eagle集成URL、自动保存开关",
helpSettings8: "各部分可折叠展开",
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
helpNodes2: "卡片和表格视图模式切换(带分页)",
helpNodes3: "收藏节点,添加自定义标签进行整理",
helpNodes4: "创建、重命名、删除节点组,分配/移除节点",
helpNodes5: "侧面板:详情(类名、包、分类、描述、标志)、I/O规格(输入输出类型和默认值)、组管理",
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder,通过子标签切换",
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
helpSidepanel2: "Workflows标签:浏览收藏工作流、按模型类型筛选、按分组筛选",
helpSidepanel3: "Nodes标签:浏览收藏节点、节点集、节点组",
helpSidepanel4: "Prompts标签:浏览提示词预设(全部/收藏/分类 子标签)",
helpSidepanel5: "将工作流拖放到画布上即可加载",
helpSidepanel6: "将节点/节点集拖放到画布上即可放置",
helpSidepanel7: "拖放提示词预设可创建WFS_PromptText节点(含正面/负面提示词)",
helpSidepanel8: "从侧边栏项目中单独复制正面(P)或负面(N)提示词",
helpSidepanel9: "双击项目也可加载/放置",
helpSidepanel10: "在各子标签内搜索快速查找项目",
helpShortcutsTitle: "键盘操作",
helpShortcuts1: "Escape:关闭弹窗/面板",
helpShortcuts2: "Enter:确认重命名、发送聊天",
helpShortcuts3: "Shift+Enter:聊天中换行",
helpShortcuts4: "双击:打开详情弹窗",
helpTroubleTitle: "故障排除",
helpTrouble1: "无法连接ComfyUI:确认ComfyUI能否生成图片、在设置中验证URL、尝试刷新或重启",
helpTrouble2: "无法连接Ollama:使用连接测试按钮、确认Ollama运行中且已下载模型",
helpTrouble3: "工作流不显示:点击刷新按钮、确认能在ComfyUI中加载、尝试重启浏览器/ComfyUI",
helpTrouble4: "图片生成失败:确认指定模型存在、验证ComfyUI连接、尝试重启",
helpSupportTitle: "支持",
helpSupportDesc: "如果您觉得这个插件有用,请考虑支持它的开发!",
helpGithubDesc: "报告问题、请求功能和贡献代码",
helpKofiDesc: "请我喝杯咖啡来支持开发",
helpThanks: "感谢您使用Workflow Studio!",
// -- Settings --
workflowsDir: "工作流数据文件夹",
workflowsDirLabel: "文件夹路径",
@@ -569,6 +1268,62 @@ const LANGUAGES = {
saveSettings: "保存设置",
settingsSaved: "设置已保存",
ollamaSaved: "Ollama设置已保存",
// -- Theme --
themeLabel: "主题",
themeDefault: "Deep Ocean Dark (默认)",
themePopVibrant: "Pop & Vibrant (活力)",
themeLightMinimalist: "Light Minimalist (简约)",
themeCyberpunk: "Cyberpunk / Hacker",
themeGlassmorphism: "Glassmorphism (毛玻璃)",
themeNeumorphism: "Neumorphism (新拟态)",
themeRetroPixel: "Retro 8-bit (复古像素)",
themePastelDream: "Pastel Dream (粉彩)",
themeBrutalism: "Brutalism (粗野主义)",
themeEarthy: "Earthy / Nature (自然)",
themeMaterial: "Material UI (材料)",
themeMonotoneAccent: "Monotone + Accent",
themeCorporate: "Corporate Trust (企业)",
// -- Theme Customizer --
themeCustomize: "自定义",
themeCustomizeColors: "颜色调整",
themeCustomizeBgPattern: "背景图案",
themeColorBg: "背景",
themeColorBgSec: "次要背景",
themeColorSurface: "表面",
themeColorText: "文字",
themeColorPrimary: "主色",
themeColorAccent: "强调色",
themePatternNone: "无",
themePatternStripeH: "横条纹",
themePatternStripeV: "竖条纹",
themePatternStripeD: "斜条纹",
themePatternDot: "波点",
themePatternCheck: "棋盘格",
themePatternSvg: "SVG文件",
themePatternColor: "图案颜色",
themePatternOpacity: "不透明度",
themePatternScale: "大小",
themePatternGap: "间距",
themeCustomizerReset: "重置自定义",
themeCustomizerApply: "应用并保存",
// -- Font --
themeCustomizeFont: "字体",
themeFontDefault: "系统默认",
themeFontNotoSans: "Noto Sans JP",
themeFontZenMaru: "Zen丸哥特",
themeFontMPlusRound: "M PLUS 圆体",
themeFontKosugiMaru: "小杉圆体",
themeFontSawarabi: "蕨类哥特",
themeFontBizUdp: "BIZ UDP哥特",
themeFontDotGothic: "点阵哥特16",
themeFontHachiMaru: "Hachi Maru Pop",
themeFontDelaGothic: "Dela Gothic One",
themeFontReggaeOne: "Reggae One",
themeFontRocknRoll: "RocknRoll One",
themeFontStick: "Stick",
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
},
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+460 -84
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
}
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;">&#9733;</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">&#9733;</button>
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">&#10005;</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,53 +593,11 @@ 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
// ============================================
export function initPromptTab() {
// Subtab switching
document.querySelectorAll(".wfm-prompt-subtab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".wfm-prompt-subtab-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const tab = btn.dataset.promptSubtab;
document.querySelectorAll(".wfm-prompt-subtab-content").forEach((c) => {
c.classList.toggle("active", c.id === `wfm-prompt-subtab-${tab}`);
});
});
});
// Ollama model refresh
async function refreshModels() {
const select = document.getElementById("wfm-ollama-model");
@@ -389,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 || "";
@@ -427,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
@@ -477,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();
});
}
+594 -18
View File
@@ -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;
@@ -72,8 +354,8 @@ export async function initSettingsTab() {
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
<!-- Language Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("langLabel")} / ${t("summaryLangLabel")}</h3>
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
@@ -89,11 +371,109 @@ export async function initSettingsTab() {
⚠ ${t("summaryLangNote")}
</small>
</div>
</div>
</details>
<!-- 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">&#9654;</span> ${t("themeCustomize")}
</button>
<div class="wfm-customizer-panel" id="wfm-customizer-panel">
<!-- Color overrides -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeColors")}</h4>
<div class="wfm-color-grid" id="wfm-color-grid">
${COLOR_TARGETS.map(ct => {
const saved = settings.themeCustom?.colors?.[ct.varName] || "";
return `<div class="wfm-color-item">
<label>${t(ct.key)}</label>
<input type="color" data-var="${ct.varName}" value="${saved || getComputedStyle(document.documentElement).getPropertyValue(ct.varName).trim()}" title="${ct.varName}">
</div>`;
}).join("")}
</div>
</div>
<!-- Background pattern -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeBgPattern")}</h4>
<div class="wfm-pattern-grid" id="wfm-pattern-grid">
${BG_PATTERNS.map(p => {
const active = (settings.themeCustom?.pattern?.id || "none") === p.id ? "active" : "";
const preview = generatePatternPreviewSVG(p.id);
const bgStyle = preview
? `background:url('${preview}') no-repeat center/cover;`
: "background:#333;";
return `<div class="wfm-pattern-item ${active} ${p.id === 'svg' ? 'wfm-svg-upload-btn' : ''}" data-pattern-id="${p.id}" style="${bgStyle}">
<span class="wfm-pattern-item-label">${t(p.key)}</span>
</div>`;
}).join("")}
</div>
<input type="file" id="wfm-svg-upload" accept=".svg" style="display:none;">
<!-- Pattern options -->
<div class="wfm-pattern-options" id="wfm-pattern-options" style="${(!settings.themeCustom?.pattern?.id || settings.themeCustom?.pattern?.id === 'none') ? 'display:none;' : ''}">
<label>
${t("themePatternColor")}
<input type="color" id="wfm-pattern-color" value="${settings.themeCustom?.pattern?.color || '#ffffff'}">
</label>
<label>
${t("themePatternOpacity")}
<input type="range" id="wfm-pattern-opacity" min="0.02" max="0.5" step="0.02" value="${settings.themeCustom?.pattern?.opacity ?? 0.1}">
<span id="wfm-pattern-opacity-val">${settings.themeCustom?.pattern?.opacity ?? 0.1}</span>
</label>
<label>
${t("themePatternScale")}
<input type="range" id="wfm-pattern-scale" min="8" max="80" step="2" value="${settings.themeCustom?.pattern?.scale ?? 20}">
<span id="wfm-pattern-scale-val">${settings.themeCustom?.pattern?.scale ?? 20}px</span>
</label>
<label>
${t("themePatternGap")}
<input type="range" id="wfm-pattern-gap" min="0" max="60" step="2" value="${settings.themeCustom?.pattern?.gap ?? 0}">
<span id="wfm-pattern-gap-val">${settings.themeCustom?.pattern?.gap ?? 0}px</span>
</label>
</div>
<div id="wfm-svg-name" style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;${settings.themeCustom?.pattern?.id === 'svg' ? '' : 'display:none;'}">
${settings.themeCustom?.pattern?.svgName ? `SVG: ${settings.themeCustom.pattern.svgName}` : ''}
</div>
</div>
<!-- Font -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeFont")}</h4>
<div class="wfm-font-grid" id="wfm-font-grid">
${FONT_OPTIONS.map(f => {
const active = (settings.themeCustom?.fontId || "") === f.id ? "active" : "";
return `<div class="wfm-font-item ${active}" data-font-id="${f.id}" title="${f.css || 'System Default'}">
<span class="wfm-font-preview" style="${f.css ? `font-family:${f.css};` : ''}">${t(f.key)}</span>
</div>`;
}).join("")}
</div>
</div>
<!-- Actions -->
<div class="wfm-customizer-actions">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-customizer-apply">${t("themeCustomizerApply")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-customizer-reset">${t("themeCustomizerReset")}</button>
</div>
</div>
</details>
<!-- Workflow Data Folder -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("workflowsDir")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
<div class="wfm-form-group">
<label>${t("workflowsDirLabel")}</label>
<div style="display:flex;gap:8px;">
@@ -110,11 +490,11 @@ export async function initSettingsTab() {
${t("workflowsDirCurrent")}: <code id="wfm-settings-workflows-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${workflowsDirInfo.current}</code>
</div>
</div>
</div>
</details>
<!-- ComfyUI Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("comfyuiConnection")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
<div class="wfm-form-group">
<label>${t("comfyuiUrl")}</label>
<div style="display:flex;gap:8px;">
@@ -128,11 +508,11 @@ export async function initSettingsTab() {
</small>
</div>
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
</div>
</details>
<!-- Ollama Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("ollamaSettings")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
<div class="wfm-form-group">
<label>${t("ollamaUrl")}</label>
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
@@ -153,11 +533,11 @@ export async function initSettingsTab() {
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
</div>
</div>
</details>
<!-- Default Workflow -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("defaultWorkflow")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("defaultWorkflow")}</summary>
<div class="wfm-form-group">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;" id="wfm-settings-default-wf-name">${settings.defaultWorkflow || t("defaultWorkflowNone")}</span>
@@ -167,11 +547,11 @@ export async function initSettingsTab() {
${t("defaultWorkflowHint")}
</small>
</div>
</div>
</details>
<!-- Eagle Integration (optional) -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("eagleIntegration")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("eagleIntegration")}</summary>
<div class="wfm-form-group">
<label>${t("eagleUrl")}</label>
<div style="display:flex;gap:8px;">
@@ -189,12 +569,207 @@ export async function initSettingsTab() {
${t("eagleAutoSave")}
</label>
</div>
</div>
</details>
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
`;
// --- 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;
@@ -361,6 +936,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);
+99 -8
View File
@@ -21,12 +21,15 @@ const state = {
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
selectedWf: null,
groupFilter: "", // empty = all groups
currentPage: 0,
badgeColors: (() => {
try { return JSON.parse(localStorage.getItem("wfm_badge_colors") || "{}"); }
catch { return {}; }
})(),
};
const WF_PER_PAGE = 24;
const BADGE_DEFAULT_COLORS = {
"SD1.5": "#e67e22",
SDXL: "#2ecc71",
@@ -242,6 +245,7 @@ function renderModelFilters() {
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.activeModel = btn.dataset.filter;
state.currentPage = 0;
renderModelFilters();
renderGrid();
});
@@ -297,17 +301,27 @@ function renderGrid() {
if (filtered.length === 0) {
grid.innerHTML =
'<p class="wfm-placeholder">No workflows found</p>';
updateWfPagination(0);
return;
}
if (state.viewMode === "table") {
renderTableView(grid, filtered);
updateWfPagination(0);
return;
}
// Thumbnail / Card view
// Thumbnail / Card view with pagination
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
if (state.currentPage < 0) state.currentPage = 0;
const start = state.currentPage * WF_PER_PAGE;
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
updateWfPagination(totalPages);
grid.innerHTML = "";
filtered.forEach((wf) => {
pageItems.forEach((wf) => {
const card = document.createElement("div");
card.className = "wfm-card";
card.dataset.filename = wf.filename;
@@ -357,6 +371,23 @@ function renderGrid() {
}
}
function updateWfPagination(totalPages) {
const container = document.getElementById("wfm-workflow-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${state.currentPage + 1} / ${totalPages}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
});
}
function renderTableView(grid, filtered) {
const table = document.createElement("table");
table.className = "wfm-table";
@@ -458,6 +489,9 @@ async function showSidePanel(wf, cardEl) {
);
if (cardEl) cardEl.classList.add("wfm-card-selected");
// Update thumbnail tab
sidePanelThumbUpdate(wf);
// Update group tab
sidePanelGroupUpdate();
@@ -465,8 +499,9 @@ async function showSidePanel(wf, cardEl) {
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);
@@ -495,6 +530,34 @@ function closeSidePanel() {
sidePanelGroupUpdate();
}
// ============================================
// Thumbnail Tab in Side Panel
// ============================================
function sidePanelThumbUpdate(wf) {
const imgWrap = document.getElementById("wfm-side-thumb-img-wrap");
const nameEl = document.getElementById("wfm-side-thumb-name");
const metaEl = document.getElementById("wfm-side-thumb-meta");
if (!imgWrap) return;
if (wf && wf.thumbnail) {
imgWrap.innerHTML = `<img src="${wf.thumbnail}" alt="${wf.filename}" />`;
} else {
imgWrap.innerHTML = `<span class="wfm-side-thumb-placeholder">${t("noImage")}</span>`;
}
if (nameEl) {
nameEl.textContent = wf ? wf.filename.replace(/\.json$/, "") : "";
}
if (metaEl && wf) {
const badges = (wf.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
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) {
metaEl.innerHTML = "";
}
}
// ============================================
// Group Tab in Side Panel
// ============================================
@@ -646,7 +709,14 @@ function openDetailModal(wf) {
const overrideText = (meta.modelTypesOverride || []).join(", ");
const isFav = !!meta.favorite;
const thumbSrc = wf.thumbnail || "";
const html = `
<div class="wfm-modal-thumb-section">
${thumbSrc
? `<img src="${thumbSrc}" class="wfm-modal-thumb-img" />`
: `<div class="wfm-modal-thumb-placeholder">${t("noImage")}</div>`
}
</div>
<div class="wfm-modal-two-col">
<div class="wfm-modal-left">
<section>
@@ -828,10 +898,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");
}
@@ -893,6 +963,14 @@ function openDetailModal(wf) {
const cached = state.workflows.find((w) => w.filename === wf.filename);
if (cached) cached.thumbnail = newUrl;
renderGrid();
sidePanelThumbUpdate(wf);
// Update modal thumbnail if visible
const modalThumbImg = document.querySelector(".wfm-modal-thumb-img");
if (modalThumbImg) modalThumbImg.src = newUrl;
const modalThumbPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
if (modalThumbPlaceholder) {
modalThumbPlaceholder.outerHTML = `<img src="${newUrl}" class="wfm-modal-thumb-img" />`;
}
showToast(t("thumbnailChanged"), "success");
} catch (err) {
showToast(t("thumbnailError") + ": " + err.message, "error");
@@ -956,6 +1034,17 @@ function openDetailModal(wf) {
async function loadWorkflows() {
try {
state.workflows = await fetchWorkflows();
// Clean up stale group entries (deleted workflows)
const validFiles = new Set(state.workflows.map(w => w.filename));
let groupsDirty = false;
for (const g of Object.keys(groups.data)) {
const before = groups.data[g].length;
groups.data[g] = groups.data[g].filter(fn => validFiles.has(fn));
if (groups.data[g].length !== before) groupsDirty = true;
}
if (groupsDirty) groups.save();
renderModelFilters();
renderGrid();
} catch (err) {
@@ -1012,6 +1101,7 @@ export function initWorkflowTab() {
if (searchInput) {
searchInput.addEventListener("input", () => {
state.searchText = searchInput.value;
state.currentPage = 0;
renderGrid();
});
}
@@ -1147,6 +1237,7 @@ export function initWorkflowTab() {
// Toolbar: group filter dropdown
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
state.currentPage = 0;
renderGrid();
});
@@ -1155,9 +1246,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");
}
+402 -61
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
@@ -14,9 +15,12 @@
</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="settings">Settings</button>
<button class="wfm-tab" data-tab="help">Help</button>
</nav>
</header>
@@ -55,6 +59,7 @@
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">&#9881;</button>
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
</div>
<div id="wfm-workflow-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
<div class="wfm-workflow-area">
@@ -68,11 +73,24 @@
<button id="wfm-side-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-side-tab-btn active" data-side-tab="json">JSON</button>
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
<button class="wfm-side-tab-btn" data-side-tab="json">JSON</button>
<button class="wfm-side-tab-btn" data-side-tab="group">Group</button>
</div>
<!-- Thumbnail Tab -->
<div id="wfm-side-tab-thumb" class="wfm-side-tab-content active">
<div class="wfm-side-thumb-container">
<div id="wfm-side-thumb-img-wrap" class="wfm-side-thumb-img-wrap">
<span class="wfm-side-thumb-placeholder">No thumbnail</span>
</div>
<div class="wfm-side-thumb-info">
<div id="wfm-side-thumb-name" class="wfm-side-thumb-name"></div>
<div id="wfm-side-thumb-meta" class="wfm-side-thumb-meta"></div>
</div>
</div>
</div>
<!-- JSON Tab -->
<div id="wfm-side-tab-json" class="wfm-side-tab-content active">
<div id="wfm-side-tab-json" class="wfm-side-tab-content" style="display:none;">
<div class="wfm-side-json-meta">
<span id="wfm-json-format-badge" class="wfm-format-badge"></span>
<button id="wfm-json-copy-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Copy</button>
@@ -114,6 +132,136 @@
</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">&#9733;</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">&#9776;</button>
<button class="wfm-view-btn" data-nodes-view="table" title="Table">&#8801;</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 id="wfm-nodes-pagination" class="wfm-pagination-inline"></div>
</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" style="display:none;">
<div class="wfm-side-panel-header">
<span id="wfm-nodes-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-nodes-panel-close" class="wfm-side-panel-close">&times;</button>
</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 -->
<div class="wfm-toolbar-row" style="gap:8px;">
<div class="wfm-models-type-nav">
<button class="wfm-models-type-btn active" data-model-type="checkpoint">Checkpoint</button>
<button class="wfm-models-type-btn" data-model-type="lora">LoRA</button>
<button class="wfm-models-type-btn" data-model-type="vae">VAE</button>
<button class="wfm-models-type-btn" data-model-type="controlnet">ControlNet</button>
<button class="wfm-models-type-btn" data-model-type="unet">UNET</button>
<button class="wfm-models-type-btn" data-model-type="textencoder">TextEncoder</button>
</div>
</div>
<!-- Row 2: Search / filter toolbar -->
<div id="wfm-models-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
<button class="wfm-filter-btn" id="wfm-models-fav-btn" title="Favorites">&#9733;</button>
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-models-view="card" title="Card">&#9776;</button>
<button class="wfm-view-btn" data-models-view="table" title="Table">&#8801;</button>
</div>
<span id="wfm-models-count" class="wfm-nodes-count"></span>
<button id="wfm-models-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
<div class="wfm-badge-settings-wrap">
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">&#9881;</button>
<div id="wfm-models-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
</div>
<div id="wfm-models-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
<!-- Grid + Side Panel -->
<div class="wfm-workflow-area">
<div id="wfm-models-grid" class="wfm-grid wfm-view-thumb">
<p class="wfm-placeholder" id="wfm-models-placeholder">Click Refresh to load models...</p>
</div>
<!-- Side Panel -->
<div id="wfm-models-side-panel" class="wfm-side-panel" style="display:none;">
<div class="wfm-side-panel-header">
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-models-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-models-side-tab-btn active" data-side-tab="info">Info</button>
<button class="wfm-models-side-tab-btn" data-side-tab="group">Group</button>
<button class="wfm-models-side-tab-btn" data-side-tab="civitai">CivitAI</button>
</div>
<!-- Info Tab -->
<div id="wfm-models-side-info" class="wfm-models-side-content active" style="overflow-y:auto;padding:12px;">
</div>
<!-- Group Tab -->
<div id="wfm-models-side-group" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
<!-- CivitAI Tab -->
<div id="wfm-models-side-civitai" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
</div>
</div>
</section>
<!-- GenerateUI Tab -->
<section id="wfm-tab-generate" class="wfm-tab-content">
<!-- Header Row -->
@@ -222,71 +370,101 @@
<!-- Prompt Tab -->
<section id="wfm-tab-prompt" class="wfm-tab-content">
<div class="wfm-prompt-subtab-nav">
<button class="wfm-prompt-subtab-btn active" data-prompt-subtab="assistant">Assistant</button>
<button class="wfm-prompt-subtab-btn" data-prompt-subtab="presets">Presets</button>
</div>
<!-- Assistant Subtab -->
<div id="wfm-prompt-subtab-assistant" class="wfm-prompt-subtab-content active">
<div class="wfm-ollama-assistant">
<div class="wfm-ollama-controls">
<select id="wfm-ollama-model" class="wfm-select" style="flex:1;min-width:120px;">
<option value="">Loading models...</option>
</select>
<button id="wfm-ollama-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-ollama-test-btn" class="wfm-btn wfm-btn-sm">Test</button>
<span id="wfm-ollama-status" class="wfm-ollama-status">Disconnected</span>
</div>
<div class="wfm-ollama-controls" style="margin-top:6px;">
<button id="wfm-ollama-attach-btn" class="wfm-btn wfm-btn-sm">Attach Image</button>
<input type="file" id="wfm-ollama-file-input" accept="image/*" style="display:none">
<button id="wfm-ollama-clear-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Clear Chat</button>
</div>
<div id="wfm-ollama-attachments" class="wfm-ollama-attachments"></div>
<div id="wfm-ollama-chat" class="wfm-ollama-chat">
<div class="wfm-ollama-welcome">
<p>AI Assistant (Ollama)</p>
<p>Attach images, generate prompts, translate, or ask for improvements.</p>
<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;">
<option value="">Loading models...</option>
</select>
<button id="wfm-ollama-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-ollama-test-btn" class="wfm-btn wfm-btn-sm">Test</button>
<span id="wfm-ollama-status" class="wfm-ollama-status">Disconnected</span>
</div>
<div class="wfm-ollama-controls" style="margin-top:6px;">
<button id="wfm-ollama-attach-btn" class="wfm-btn wfm-btn-sm">Attach Image</button>
<input type="file" id="wfm-ollama-file-input" accept="image/*" style="display:none">
<button id="wfm-ollama-clear-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Clear Chat</button>
</div>
<div id="wfm-ollama-attachments" class="wfm-ollama-attachments"></div>
<div id="wfm-ollama-chat" class="wfm-ollama-chat">
<div class="wfm-ollama-welcome">
<p>AI Assistant (Ollama)</p>
<p>Attach images, generate prompts, translate, or ask for improvements.</p>
</div>
</div>
<div class="wfm-ollama-translate-row">
<span style="font-size:12px;color:var(--wfm-text-secondary);">Translate:</span>
<button id="wfm-ollama-ja2en-btn" class="wfm-btn wfm-btn-sm">JA→EN</button>
<button id="wfm-ollama-en2ja-btn" class="wfm-btn wfm-btn-sm">EN→JA</button>
<button id="wfm-ollama-zh2en-btn" class="wfm-btn wfm-btn-sm">ZH→EN</button>
<button id="wfm-ollama-en2zh-btn" class="wfm-btn wfm-btn-sm">EN→ZH</button>
</div>
<div class="wfm-ollama-input-wrap">
<textarea id="wfm-ollama-input" class="wfm-textarea" rows="3" placeholder="Ask AI... (Enter to send, Shift+Enter for newline)"></textarea>
<button id="wfm-ollama-send-btn" class="wfm-btn wfm-btn-primary" style="width:72px;align-self:flex-end;">Send</button>
</div>
</div>
<div class="wfm-ollama-translate-row">
<span style="font-size:12px;color:var(--wfm-text-secondary);">Translate:</span>
<button id="wfm-ollama-ja2en-btn" class="wfm-btn wfm-btn-sm">JA→EN</button>
<button id="wfm-ollama-en2ja-btn" class="wfm-btn wfm-btn-sm">EN→JA</button>
<button id="wfm-ollama-zh2en-btn" class="wfm-btn wfm-btn-sm">ZH→EN</button>
<button id="wfm-ollama-en2zh-btn" class="wfm-btn wfm-btn-sm">EN→ZH</button>
</div>
<div class="wfm-ollama-input-wrap">
<textarea id="wfm-ollama-input" class="wfm-textarea" rows="3" placeholder="Ask AI... (Enter to send, Shift+Enter for newline)"></textarea>
<button id="wfm-ollama-send-btn" class="wfm-btn wfm-btn-primary" style="width:72px;align-self:flex-end;">Send</button>
</div>
<!-- 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">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" style="flex:1;" placeholder="Positive prompt..."></textarea>
</div>
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Negative Prompt</label>
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
</div>
<div style="display:flex; gap:4px;">
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
</div>
<div style="display:flex; gap:4px; margin-top:4px;">
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
<option value="">Select group...</option>
</select>
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
</div>
</div>
</div>
</div>
<!-- Presets Subtab -->
<div id="wfm-prompt-subtab-presets" class="wfm-prompt-subtab-content">
<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>
<!-- 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">&#9733;</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 class="wfm-form-group">
<label>Preset Name</label>
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
</div>
<div class="wfm-form-group">
<label>Positive Prompt</label>
<textarea id="wfm-preset-pos" class="wfm-textarea" rows="4" placeholder="Positive prompt..."></textarea>
</div>
<div class="wfm-form-group">
<label>Negative Prompt</label>
<textarea id="wfm-preset-neg" class="wfm-textarea" rows="3" placeholder="Negative prompt..."></textarea>
</div>
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;">Save Preset</button>
</div>
</div>
</section>
@@ -296,6 +474,169 @@
<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 &amp; 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, analysis, category override, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete</li>
<li id="wfm-help-wf-7">Customize badge colors per category</li>
<li id="wfm-help-wf-8">Batch re-analysis of all workflow I/O (P: prompt nodes, I: image nodes, output: save nodes)</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">Edit parameters: Prompt, Image (drag &amp; drop), Model (checkpoint/VAE/LoRA/ControlNet with filter), Settings (steps/CFG/sampler/scheduler/resolution), Raw JSON</li>
<li id="wfm-help-gen-4">Seed control: random, fixed, increment, decrement</li>
<li id="wfm-help-gen-5">Generate, progress display, and stop</li>
<li id="wfm-help-gen-6">View results with main image and thumbnail history</li>
<li id="wfm-help-gen-7">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&#8596;EN, ZH&#8596;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">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>Models Tab</h3>
<ul>
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder with sub-tab switching</li>
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-sidepanel-title">Workflow Studio Library (ComfyUI Side Panel)</h3>
<ul>
<li id="wfm-help-sidepanel-1">Open from the Workflow Studio Library button in the ComfyUI toolbar</li>
<li id="wfm-help-sidepanel-2">Workflows tab: browse favorite workflows, filter by model type, and filter by group</li>
<li id="wfm-help-sidepanel-3">Nodes tab: browse favorite nodes, node sets, and node groups</li>
<li id="wfm-help-sidepanel-4">Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs</li>
<li id="wfm-help-sidepanel-5">Drag &amp; drop workflows onto the canvas to load them</li>
<li id="wfm-help-sidepanel-6">Drag &amp; drop nodes/node sets onto the canvas to place them</li>
<li id="wfm-help-sidepanel-7">Drag &amp; drop prompt presets to create WFS_PromptText nodes with positive/negative prompts</li>
<li id="wfm-help-sidepanel-8">Copy individual positive (P) or negative (N) prompts from sidebar items</li>
<li id="wfm-help-sidepanel-9">Double-click items to load/place without dragging</li>
<li id="wfm-help-sidepanel-10">Search within each sub-tab to quickly find items</li>
</ul>
</div>
<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>
</main>
<!-- Workflow Detail Modal -->
File diff suppressed because it is too large Load Diff
+519 -73
View File
@@ -1,6 +1,8 @@
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";
const WFM_PATH = "/wfm";
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
const MAX_ATTACH_ATTEMPTS = 120;
@@ -19,6 +21,332 @@ const openWorkflowStudio = (event) => {
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 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 defaultFilename = getWorkflowFilename();
const filename = await showSaveDialog(defaultFilename);
if (!filename) return; // cancelled
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__"]) {
@@ -87,6 +415,20 @@ const getWfmIcon = () => {
`;
};
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");
@@ -111,15 +453,71 @@ const createTopMenuButton = async () => {
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;
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;
}
@@ -128,81 +526,129 @@ 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);
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;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
const buttons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
buttons.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";
}
});
if (buttons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
}
};
requestAnimationFrame(replaceButtonIcon);
},
};
if (useActionBar) {
extensionObj.actionBarButtons = [
{
icon: "icon-[mdi--file-document-multiple] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openWorkflowStudio,
},
];
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);
},
});