Compare commits

...
4 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 4.6 0886ea6e2b v0.3.2: Checkpoint Batch for GenerateUI & Seed layout fix
- Add Checkpoint Batch panel to GenerateUI right column: checkbox ON/OFF,
  include/exclude subfolder filters (comma-separated), real-time model count
  preview, amber progress bar with model name and index display
- Refactor handleGenerate into _coreGenerate (throws) + _runBatchGenerate loop;
  Stop button now aborts both single generation and batch loop
- Fix Seed row layout: seed input and mode selector now stacked vertically
  so neither is clipped in narrow right panels
- Update Help tab, DEVLOG, README for v0.3.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:14:55 +09:00
ketle-manandClaude Sonnet 4.6 226fc885d7 v0.3.1: Gallery folder/file operations, tree state persistence, workflow auto-save
- Gallery: add folder create/delete and file delete/move (single & multi-select)
- Gallery: preserve folder tree expanded state across operations
- Gallery: improve workflow metadata extraction (workflow > prompt > gallery_metadata.json)
- Generate tab: auto-save workflow metadata to gallery on image generation
- Help tab: update Gallery section to reflect new v0.3.1 features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:38:33 +09:00
ketle-manandClaude Sonnet 4.6 8d48c2276f v0.3.0: Cross-type group filter & sidebar group display fix
- models-tab.js: group filter dropdown now shows groups from all model
  types with [TypeLabel] prefix; selecting a group auto-switches type
- node_sets_menu.js: fix TypeError in renderModelGroups caused by v0.2.9
  per-type group format; add type label badge to each group header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:23:50 +09:00
ketle-manandClaude Sonnet 4.6 918ab54004 v0.2.9: Model Enable/Disable, per-type groups, multi-select & bulk delete
- Toggle model visibility by renaming file extension (.disabled suffix)
  per card (⏸ button), per group (Enable All / Disable All), or via
  status filter (All / Enabled / Disabled)
- Groups are now scoped per model type; old flat groups auto-migrated
- Multi-select mode with bulk action bar: add/remove from groups,
  permanently delete model files + preview + sidecar (.json/.info)
- Fix: subdirectory model disable path-doubling error
- Fix: Hypernetwork tab showing C/O/M/B from new ComfyUI COMBO format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 10:11:29 +09:00
17 changed files with 2103 additions and 165 deletions
+266
View File
@@ -1,5 +1,271 @@
# DEVLOG - ComfyUI-Workflow-Studio
## 2026-04-28: v0.3.2 生成UIタブ — Checkpoint Batch・Seed UIレイアウト修正
### 概要
- 生成UIタブの右パネルに **Checkpoint Batch** 機能を追加
- Seed行のレイアウトを2段構成に変更(seed値とモード選択が見切れる問題を解消)
### 変更内容
#### `templates/index.html` — UI要素追加・修正
**Checkpoint Batch パネル追加:**
- 右パネル(Seed行の下・結果表示の上)に `wfm-ckpt-batch-panel` を追加
- チェックボックス ON/OFF でバッチ設定欄を展開/折りたたみ
- Include Folders テキスト入力 — カンマ区切りでサブフォルダ名を指定。空欄でcheckpointフォルダ内の全モデルを対象
- Exclude Folders テキスト入力 — カンマ区切りで除外するサブフォルダ名を指定
- 対象モデル数プレビュー表示 (`wfm-ckpt-batch-info`)
- バッチ進捗エリア — 現在のモデル名、インデックス/総数、アンバー色プログレスバー (`wfm-ckpt-batch-bar`)
**Seed レイアウト修正:**
- 横並び1行(`flex-direction: row`)→ 縦2行(`flex-direction: column`)に変更
- 1行目: `Seed:` ラベル + 数値入力(`flex:1` で横幅いっぱい)
- 2行目: モード選択セレクト(`width:100%`)
**ヘルプタブ:**
- GenerateUI タブ説明に gen-11(Checkpoint Batch の説明)を追加
- gen-8(Seed control)に2段レイアウト化の補足を追加
#### `static/js/generate-tab.js` — バッチロジック追加・生成処理リファクタリング
**新規追加:**
- `_ckptBatch` — `{ aborted: false }` バッチ中断フラグ
- `_parseFolderList(str)` — カンマ区切り文字列をトリム・小文字化して配列に変換
- `_getModelFolder(modelPath)` — パスの最初の `/` より前を抽出してフォルダ名を取得。`\\` を `/` に正規化、ルート直下は `""` を返す
- `_filterCheckpoints(checkpoints, includeStr, excludeStr)` — include/exclude フィルタを適用したチェックポイントリストを返す。大文字小文字を区別しない
- `_updateBatchInfo()` — チェックポイントリストのフィルタ済み件数を `wfm-ckpt-batch-info` に即時反映
- `initCheckpointBatch()` — チェックボックス・include/exclude 入力のイベントリスナーを登録
- `_coreGenerate(silent)` — 1回の生成コア処理(`silent=true` のとき完了トーストを省略)。エラー時はスローする
- `_runBatchGenerate()` — チェックポイントリストをループし `_coreGenerate` を順番に呼び出す。各反復でワークフロー内の全checkpointノードの `ckpt_name` を書き換える。エラーは件数カウントして継続。完了後に結果サマリをトースト表示
**変更:**
- `handleGenerate()` — バッチが有効な場合は `_runBatchGenerate()` を、無効の場合は `_coreGenerate(false)` を呼び出すよう分岐。ボタン管理(disabled / Stop 表示)を共通 try/finally で処理
- interrupt ボタンのハンドラ — `_ckptBatch.aborted = true` を設定してから `comfyUI.interrupt()` を呼び出し、単発生成とバッチの両方を停止できるように
- Refresh Models ボタンのハンドラ — モデルリスト再取得後に `_updateBatchInfo()` を呼び出してバッチ件数を更新
- `initGenerateTab()` — 末尾で `initCheckpointBatch()` を呼び出し。初期接続成功時に `_updateBatchInfo()` を呼び出し
---
## 2026-04-27: v0.3.1 ギャラリー拡張(フォルダ/ファイル操作・ワークフロー保存)
### 概要
- ギャラリータブにフォルダ作成・削除機能を追加
- ギャラリータブにファイル削除・移動機能を追加(単体・複数選択対応)
- 操作後もフォルダツリーの展開状態を維持するよう改善
- 生成UIタブで生成した画像のワークフローをギャラリーメタデータに自動保存
- MetadataタブのワークフローJSON表示をComfyUI生成画像全般(`prompt`キー)に対応
### 変更内容
#### `py/services/gallery_metadata.py` — メタデータ操作追加
- `delete(image_path)` 追加 — 画像削除時にメタデータエントリを除去
- `rename_path(old_path, new_path)` 追加 — ファイル移動後のパスキー付け替え
- `allowed` セットに `"workflow"` を追加 — 生成UIからのワークフロー保存を受け付けるように
#### `py/services/gallery_service.py` — ファイル・フォルダ操作追加
- `create_folder(parent_path, name)` — 無効文字チェック付きでサブフォルダを作成。作成後にフォルダキャッシュを無効化
- `delete_folder(folder_path)` — `shutil.rmtree` で再帰削除。ルートフォルダは保護
- `delete_images(paths)` — 複数画像ファイルを削除。各ファイルのメタデータ削除・キャッシュ無効化も実行
- `move_images(paths, dest_folder)` — 複数画像を別フォルダへ移動。ファイル名衝突時は連番サフィックス付与。メタデータのパスキーを `rename_path` で引き継ぎ
- `extract_workflow_from_metadata` 修正 — PNG埋め込みの参照キーを `workflow` → `workflow / prompt` の順に拡張。どちらも存在しない場合は `gallery_metadata.json` の `workflow` フィールドへフォールバック
#### `py/routes/gallery_routes.py` — エンドポイント追加
- `POST /wfm/gallery/folder` — フォルダ作成
- `DELETE /wfm/gallery/folder` — フォルダ削除
- `POST /wfm/gallery/images/delete` — 画像削除(単体・複数)
- `POST /wfm/gallery/images/move` — 画像移動(単体・複数)
#### `static/js/gallery-tab.js` — フロントエンド拡張
**API定数追加:**
- `folderCreate`, `folderDelete`, `imagesDelete`, `imagesMove`
**state追加:**
- `folderTree` — ツリー全体データを保持(移動先フォルダ一覧の生成に使用)
**フォルダツリー展開状態の保持:**
- `_getExpandedPaths()` — 再構築前に展開済みフォルダのパスを `Set` で収集
- `_restoreTreeState(expandedPaths, selectedPath)` — 再構築後に展開状態・選択ハイライトを復元。階層の浅い順に展開することで子ノードのDOM存在を保証
- `renderTreeNode` — 各アイテムに `data-path` 属性を付与(復元の識別子)
- `loadFolderTree` — 初回ロード判定(`isFirstLoad`)を追加。2回目以降は `_restoreTreeState` を呼び出して展開状態を維持
**フォルダ操作:**
- `createFolder()` — `prompt()` でフォルダ名入力後、API呼び出し・ツリー再読み込み
- `deleteFolder()` — 確認ダイアログ後、再帰削除・currentFolder リセット・ツリー再読み込み
- フォルダラベルクリック時に Delete Folder ボタンの `disabled` 状態を更新(ルート選択時は削除不可)
**ファイル削除・移動:**
- `performDeleteImages(paths)` — 削除後に `state.images` / `state.selectedImages` を即時更新。詳細パネルの選択中画像が削除された場合はパネルをリセット
- `performMoveImages(paths, dest)` — 移動後に同様に state を即時更新
- `flattenFolderTree(node)` — ツリーデータをフラットリストに変換(移動先選択に使用)
- `openMoveModal(paths)` — 移動先フォルダ選択モーダルを動的生成。現在フォルダを除く全フォルダをドロップダウン表示
- 詳細パネルの Move To... / Delete ボタンを画像選択時に有効化
#### `templates/index.html` — UI要素追加
- フォルダツリーヘッダーに「+ New」「Del」ボタン(`wfm-gallery-tree-actions`)
- 詳細パネル Info タブに「Move To...」「Delete」ボタン(`wfm-gallery-file-actions`)
- 一括バーに「Move To...」「Delete」ボタン
- ヘルプ項目を10項目から11項目に更新
#### `static/css/gallery-tab.css` — スタイル追加
- `.wfm-gallery-tree-header` — `justify-content: space-between` でタイトルとボタンを両端配置
- `.wfm-gallery-tree-actions` — フォルダ操作ボタンのフレックスコンテナ
- `.wfm-gallery-file-actions` — 詳細パネルのファイル操作ボタン
- `.wfm-gallery-move-modal` / `.wfm-gallery-move-modal-title` / `.wfm-gallery-move-dest-sel` / `.wfm-gallery-move-modal-footer` — 移動先選択モーダル
#### `static/js/generate-tab.js` — ワークフロー自動保存
- `_outputDir` モジュール変数 — outputパスをキャッシュ
- `_fetchOutputDir()` — `/api/wfm/settings/output-dir` からoutputパスを取得
- `saveGeneratedImagesMeta(images, workflow)` — 生成完了後、各画像のフルパスを組み立てて `/wfm/gallery/image/meta` にワークフローを保存。`type !== "output"` の一時ファイルはスキップ
- `initGenerateTab` — 初期化時に `_fetchOutputDir()` を実行。`wfm-output-dir-changed` イベントで設定変更時にパスを同期
- `handleGenerate` — 生成成功後に `saveGeneratedImagesMeta` を非同期呼び出し
### 技術的な判断
- **ツリー展開状態の復元**: `innerHTML = ""` による完全再構築を維持しつつ、展開パスを `Set` で保存して復元する方式を採用。差分DOMパッチより実装が単純で、フォルダ追加・削除後のツリー構造変化にも対応できる
- **PNG `prompt` キーの参照**: ComfyUIのSaveImageノードはデフォルトでPNGに `prompt` キー(API形式)のみを埋め込む。`workflow` キー(UI形式)が埋め込まれるのは特定の操作時のみ。`prompt` を参照することで既存の全ComfyUI生成画像に対応
- **フォールバック優先順位**: PNG埋め込み(`workflow` > `prompt`)> `gallery_metadata.json` の順とし、ComfyUI本体が埋め込んだデータを最優先に
---
## 2026-04-18: v0.3.0 モデルグループ表示改善(全タイプ横断・サイドパネル修正)
### 概要
- モデルタブのグループフィルタードロップダウンを全タイプ横断表示に改善
- ComfyUIサイドパネルのModel Groupsビューを v0.2.9 のタイプ別形式に対応させバグ修正
### 変更内容
#### `static/js/models-tab.js` — グループフィルター全タイプ横断表示
- `state.allModelGroups` 追加 — 全タイプのグループを `{ type: { groupName: [models] } }` 形式で保持
- `fetchAllModelGroups()` 追加 — `type` パラメータなしで全タイプのグループをAPIから一括取得
- `renderGroupFilter()` 改善 — ドロップダウンオプションを `[Checkpoint] GroupName` / `[LoRA] GroupName` 形式で全タイプ分表示。値は `type::groupName` としてエンコード
- グループフィルター `change` ハンドラ更新 — 選択したグループが現在と異なるタイプの場合、モデルタイプタブを自動切り替えしてモデルを再ロード
- `saveModelGroups()` — 保存時に `state.allModelGroups[type]` も更新
- `loadModelsForCurrentType()` — ロード時に `state.allModelGroups[type]` を同期
- `loadMetadataAndModels()` — 初回ロード時に `fetchAllModelGroups()` で全タイプのグループを一括取得
#### `web/comfyui/node_sets_menu.js` — サイドパネルのModel Groupsビュー修正
- `renderModelGroups()` を新タイプ別形式 `{ type: { groupName: [...] } }` に対応
- 旧: `groups[groupName]` が配列を期待 → `TypeError: .filter is not a function` でクラッシュ
- 新: `flatGroups = [{modelType, groupName, members}]` にフラット展開してから描画
- グループヘッダーに `[Checkpoint]` / `[LoRA]` 等のタイプラベルを追加(`wfm-nlp-model-type-badge`クラス)
- `typeOf[name] || modelType` — モデルリスト未取得時のフォールバックをグループのモデルタイプに変更
---
## 2026-04-18: v0.2.9 モデル有効/無効・複数選択・一括削除
### 概要
- モデルファイルの拡張子リネーム(`.disabled`サフィックス)でComfyUIへの表示を制御
- モデル種別ごとのグループスコープ(checkpointグループはcheckpointタブにのみ表示)
- 複数選択モードと一括グループ操作・ファイル削除機能を追加
### 変更内容
#### `py/services/models_service.py` — 有効/無効・削除ロジック追加
- `_DISABLED_SUFFIX = ".disabled"` 定数追加
- `_SIDECAR_EXTENSIONS` — 削除対象の付属ファイル拡張子リスト(`.preview.png` 〜 `.webp` + `.json`, `.civitai.info`, `.info`)
- `find_model_file(model_type, model_name)` — 有効・無効両状態を検索、`(Path, is_enabled)` を返す
- `enable_model(model_type, model_name)` — `.disabled` → 元の拡張子にリネーム
- `disable_model(model_type, model_name)` — 元の拡張子 → `.disabled` にリネーム(`path.name` ベースでサブディレクトリ二重化バグを回避)
- `scan_disabled_models(model_type)` — `rglob("*.disabled")` でスキャン、正規化名リストを返す
- `get_model_groups(model_type=None)` — `model_type` パラメータ追加。旧フラット形式 `{ "groupName": [...] }` を `_groups_legacy` に退避し、`{ "_groups": { "checkpoint": {...} } }` 形式へ自動マイグレーション
- `save_model_groups(groups, model_type)` — `model_type` パラメータ追加、種別ごとに保存
- `delete_model(model_type, model_name)` — モデルファイル(有効・無効どちらも対応)を削除。`path.name` から `.disabled` を除去してstemを正しく計算し、全サイドカーファイルを削除。メタデータエントリも削除
#### `py/routes/models_routes.py` — エンドポイント追加・変更
**新エンドポイント:**
- `GET /api/wfm/models/disabled?type=` — 指定タイプの無効モデル名リストを返す
- `POST /api/wfm/models/toggle` — `{ model_type, model_name, enabled }` でモデル1件の有効/無効を切り替え
- `POST /api/wfm/models/group-toggle` — `{ model_type, group_name, enabled }` でグループ内全モデルを一括切り替え
- `POST /api/wfm/models/delete` — `{ model_type, model_names: [] }` で複数モデルを一括削除
**変更:**
- `GET /api/wfm/models/groups` — `?type=` クエリパラメータ追加
- `POST /api/wfm/models/groups` — ボディを `{ "model_type": ..., "groups": {...} }` 形式に変更
#### `static/js/comfyui-client.js` — Hypernetworkバグ修正
- `_fetchModelList` で新ComfyUI APIフォーマット `["COMBO", {"values":[...]}]` に対応
- `Array.isArray(first)` チェックを追加、文字列 `"COMBO"` をスプレッドしてしまう問題を解消
#### `static/js/models-tab.js` — フロントエンド全体
**state追加:**
- `disabledModels: {}` — タイプ別の無効モデルSet
- `statusFilter: "all"` — "all" / "enabled" / "disabled"
- `selectMode: false`, `selectedModels: new Set()` — 複数選択状態
**新関数:**
- `isModelDisabled()`, `fetchDisabledModels()`, `toggleModelEnable()`, `toggleGroupEnable()` — 有効/無効操作
- `toggleSelectMode()` — 選択モードのON/OFF切り替え、ボタンテキスト更新
- `toggleModelSelection(modelName)` — DOM直接更新(再レンダリングなし)
- `clearSelection()` — 選択リセット
- `renderBulkActionBar()` — グループ選択・追加/削除・新規作成・削除ボタンを描画
- `bulkAddToGroup()`, `bulkRemoveFromGroup()` — 一括グループ操作
- `bulkDeleteModels()` — `confirm()` で確認後 `/api/wfm/models/delete` を呼び出し、ローカルstateを即時更新
**各ビュー:**
- ThumbView / CardView: 無効オーバーレイ・⏸ボタン・selectMode時チェックボックスオーバーレイ追加
- TableView: チェックボックス列・`wfm-table-td-check` 追加
- `renderSideGroup()`: "全て有効化" / "全て無効化" ボタン追加
**タイプ切り替え:**
- `groupFilter`, `statusFilter`, `selectMode`, `selectedModels` をリセット
#### `templates/index.html` — UI要素追加
- `<select id="wfm-models-status-filter">` — All / Enabled / Disabled フィルター
- `<button id="wfm-models-select-btn">` — 選択モード切り替え
- `<div id="wfm-models-bulk-bar">` — 一括操作バー(初期非表示)
- `<li id="wfm-help-models-11">` — ヘルプ項目追加
#### `static/css/main.css` — スタイル追加
- `.wfm-model-disabled` / `.wfm-disabled-overlay` — 無効状態の見た目
- `.wfm-toggle-btn` / `.wfm-toggle-btn.wfm-toggle-disabled` — ⏸ボタン
- `.wfm-badge-disabled` — 無効バッジ
- `.wfm-select-check` / `.wfm-select-check.checked` — チェックボックス
- `.wfm-card-checked` — 選択済みカードのアウトライン
- `.wfm-bulk-bar` / `.wfm-bulk-count` / `.wfm-bulk-sep` / `.wfm-table-td-check` — 一括操作バー
#### `static/js/i18n.js` — 新キー追加(3言語)
- 有効/無効操作: `modelEnable`, `modelDisable`, `modelDisabled`, `modelStatusAll`, `modelStatusEnabled`, `modelStatusDisabled`, `modelGroupEnableAll`, `modelGroupDisableAll`, `modelToggleError`, `modelStatusWarning`
- 複数選択: `modelSelectMode`, `modelSelectExit`, `modelSelected`, `modelBulkAddGroup`, `modelBulkRemoveGroup`, `modelBulkCreateAdd`, `modelBulkAddDone`, `modelBulkRemoveDone`
- ファイル削除: `modelBulkDelete`, `modelBulkDeleteConfirm`, `modelBulkDeleteDone`, `modelBulkDeleteError`
- ヘルプ: `helpModels11`(複数選択・一括削除の説明)、`helpModels3` 更新(ステータスフィルター追記)
### 技術的な判断
- **拡張子リネーム方式**: ComfyUIはスキャン時に認識拡張子のみ列挙するため、`.disabled`サフィックスを付けるだけでモデルを隠せる。ファイル移動や専用DBを使わず最もシンプルな実装
- **DOM直接更新(選択モード)**: 選択トグルのたびに`renderModelGrid()`を呼ぶと大量再レンダリングが起きる。`querySelectorAll("[data-model-name]")`で対象要素を絞り`classList.toggle()`のみ実行
- **stemバグの根本原因**: `path.stem`は最後のサフィックスのみ除去する。`model.safetensors.disabled`の`stem`は`model.safetensors`になるため、`model.safetensors.png`を探してしまう。`.disabled`を先に除去してから`Path.stem`を計算する必要がある
---
## 2026-04-14: v0.2.8 データ保存先変更・エクスポート/インポート機能追加
### 概要
+38 -7
View File
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.2.8-green)
![Version](https://img.shields.io/badge/version-0.3.2-green)
## Screenshots
@@ -50,14 +50,15 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
### GenerateUI Tab (v0.2.4)
### GenerateUI Tab (v0.3.2)
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; Raw JSON on the right
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
- **Checkpoint Batch** — enable via checkbox in the right panel to sequentially generate with every checkpoint model; filter by include folders (comma-separated subfolder names, empty = all) and/or exclude folders; Stop button aborts after the current generation completes; amber progress bar tracks per-model progress
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
@@ -85,16 +86,20 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
- **Language** — English / Japanese / Chinese
### Gallery Tab (v0.2.7)
### Gallery Tab (v0.3.1)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch tag/group/favorite operations
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To... and Delete from the multi-select bar
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations (group, favorite, move, delete)
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
- **Favorites** — star images inline without reopening the detail panel
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
- **Output folder configurable** — set the scanned output folder from Settings tab
- **Performance** — with folder-level mtime cache (30s TTL) for fast incremental refresh
- **Performance** — folder-level mtime cache (30s TTL) for fast incremental refresh; tree expansion state preserved across folder operations
### Nodes Tab (v0.1.7)
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
@@ -114,9 +119,11 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
- **Group management** — create, rename, delete groups and assign/remove models
- **Group management** — create, rename, delete groups and assign/remove models; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab)
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
- **Enable / Disable models** — hide models from ComfyUI by renaming the file extension (`.disabled` suffix); toggle per card (⏸ button), per group (Enable All / Disable All), or filter by status (All / Enabled / Disabled)
- **Multi-select & bulk operations** — enter selection mode to check multiple models; bulk action bar supports add/remove from groups and permanent file deletion (model file + preview images + sidecar files such as `.json` / `.info`)
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
@@ -208,6 +215,30 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### v0.3.2
- **Checkpoint Batch** — new panel in the GenerateUI right column (below Seed, above results); enable via checkbox to auto-generate once per checkpoint model; filter included/excluded subfolders by comma-separated names (empty = all checkpoints); real-time preview of matched model count; amber progress bar shows current model name and index; Stop button aborts the loop after the current generation completes; failed models are counted and reported in the summary toast
- **Seed layout fix** — seed value input and mode selector (Random / Fixed / Increment / Decrement) now stacked vertically so neither is clipped in narrow panels
### v0.3.1
- **Folder operations** — create subfolders and delete folders (with all contents) directly from the folder tree header buttons
- **File operations** — move or delete individual images from the detail panel; bulk move and bulk delete from the multi-select bar
- **Tree state preserved** — folder expansion state is maintained after create/delete/move/refresh operations
- **Workflow viewer improved** — Metadata tab now reads both `prompt` (API format, the default for most ComfyUI-generated images) and `workflow` (UI format) keys from PNG metadata, covering virtually all ComfyUI output
- **Workflow auto-save** — after each generation in the Generate UI tab, the current workflow is automatically saved to gallery metadata for the generated images; shown in the Metadata tab even if PNG embedding is absent
### v0.3.0
- **Cross-type group filter** — the group filter dropdown in the Models tab now shows groups from all model types simultaneously, each prefixed with a type label (`[Checkpoint]`, `[LoRA]`, etc.); selecting a group automatically switches to the corresponding model type
- **Sidebar group display fixed** — the ComfyUI sidebar "Model Groups" view now correctly renders per-type groups (introduced in v0.2.9) with a type label on each group header; fixes a crash where `.filter is not a function` was thrown on the new nested group structure
### v0.2.9
- **Model Enable/Disable** — hide models from ComfyUI by renaming the file extension to `.disabled`; toggle per model (⏸ button on each card/row), per group (Enable All / Disable All buttons in the Group panel), or filter the grid by status (All / Enabled only / Disabled only). Changes take effect after ComfyUI refresh.
- **Per-type model groups** — groups are now scoped to each model type; checkpoint groups only appear in the Checkpoint tab, LoRA groups only in the LoRA tab, etc. Existing flat groups are automatically migrated.
- **Multi-select mode** — click the Select button in the toolbar to enter selection mode; check individual models across Thumbnail / Card / Table views
- **Bulk group operations** — with models selected, add or remove them from any group in one click, or create a new group and add them simultaneously
- **Bulk file delete** — permanently delete selected model files along with all associated preview images and sidecar files (`.json`, `.civitai.info`, `.info`); requires confirmation before deleting
- **Bug fix** — models in subdirectories could not be disabled due to a path-doubling error; fixed by computing the rename target from `path.name` instead of `model_name`
- **Bug fix** — Hypernetwork tab showed `C`, `O`, `M`, `B` as fake model items; caused by the new ComfyUI API format returning `"COMBO"` as the first element; fixed in `comfyui-client.js`
### v0.2.8
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
+63
View File
@@ -30,6 +30,11 @@ def setup_routes(app: web.Application):
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
# フォルダ・ファイル操作
app.router.add_post("/wfm/gallery/folder", create_folder_route)
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
app.router.add_post("/wfm/gallery/images/move", move_images_route)
# ──────────────────────────────────────────────────────────────
@@ -217,3 +222,61 @@ async def list_group_images(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
images = _service.list_images_in_group(name)
return web.json_response({"images": images})
# ──────────────────────────────────────────────────────────────
# フォルダ・ファイル操作
# ──────────────────────────────────────────────────────────────
async def create_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/folder - 新規フォルダ作成"""
try:
body = await request.json()
parent = body.get("parent", "")
name = body.get("name", "").strip()
if not parent or not name:
return web.json_response({"ok": False, "error": "parent and name required"}, status=400)
result = _service.create_folder(parent, name)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_folder_route(request: web.Request) -> web.Response:
"""DELETE /wfm/gallery/folder - フォルダ削除"""
try:
body = await request.json()
folder = body.get("path", "")
if not folder:
return web.json_response({"ok": False, "error": "path required"}, status=400)
result = _service.delete_folder(folder)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/delete - 画像削除(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
if not paths:
return web.json_response({"deleted": [], "errors": ["paths required"]}, status=400)
result = _service.delete_images(paths)
return web.json_response(result)
except Exception as e:
return web.json_response({"deleted": [], "errors": [str(e)]}, status=500)
async def move_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/move - 画像移動(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
dest = body.get("dest", "")
if not paths or not dest:
return web.json_response({"moved": [], "errors": ["paths and dest required"]}, status=400)
result = _service.move_images(paths, dest)
return web.json_response(result)
except Exception as e:
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
+148 -5
View File
@@ -27,6 +27,10 @@ def setup_routes(app: web.Application):
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)
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
# ── Model Metadata ─────────────────────────────────────────
@@ -61,9 +65,10 @@ async def handle_save_metadata(request: web.Request) -> web.Response:
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/models/groups"""
"""GET /api/wfm/models/groups?type=checkpoint"""
model_type = request.query.get("type", "")
try:
result = await asyncio.to_thread(_service.get_model_groups)
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
@@ -71,10 +76,17 @@ async def handle_get_groups(request: web.Request) -> web.Response:
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups"""
"""POST /api/wfm/models/groups
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
"""
try:
groups = await request.json()
result = await asyncio.to_thread(_service.save_model_groups, groups)
body = await request.json()
model_type = body.get("model_type", "")
groups = body.get("groups", {})
if not model_type:
return web.json_response({"error": "model_type required"}, status=400)
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
@@ -386,3 +398,134 @@ async def handle_get_filepath(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error resolving model filepath: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model Enable / Disable ────────────────────────────────
async def handle_get_disabled(request: web.Request) -> web.Response:
"""GET /api/wfm/models/disabled?type=checkpoint
Returns list of disabled model names (normalized, without .disabled suffix).
"""
model_type = request.query.get("type", "")
if not model_type:
return web.json_response({"error": "type required"}, status=400)
try:
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
return web.json_response(result)
except Exception as e:
logger.error("Error scanning disabled models: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_model(request: web.Request) -> web.Response:
"""POST /api/wfm/models/toggle
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
Renames the model file to add/remove .disabled suffix.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_name = body.get("model_name", "")
enabled = body.get("enabled", True)
if not model_type or not model_name:
return web.json_response({"error": "model_type and model_name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
return web.json_response({"status": "ok", "enabled": enabled})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except OSError as e:
logger.error("OS error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
except Exception as e:
logger.error("Error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_group(request: web.Request) -> web.Response:
"""POST /api/wfm/models/group-toggle
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
Enables or disables all models belonging to the specified group.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
group_name = body.get("group_name", "")
enabled = body.get("enabled", True)
if not model_type or not group_name:
return web.json_response({"error": "model_type and group_name required"}, status=400)
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
members = groups.get(group_name, [])
ok_list = []
error_list = []
for model_name in members:
if ".." in model_name:
continue
try:
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
ok_list.append(model_name)
except Exception as e:
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({
"status": "ok",
"enabled": enabled,
"ok": ok_list,
"errors": error_list,
})
except Exception as e:
logger.error("Error toggling group: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_models(request: web.Request) -> web.Response:
"""POST /api/wfm/models/delete
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
Deletes each model file and associated preview/sidecar files, then removes metadata.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_names = body.get("model_names", [])
if not model_type or not isinstance(model_names, list) or not model_names:
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
ok_list = []
error_list = []
for model_name in model_names:
if ".." in str(model_name):
error_list.append({"model": model_name, "error": "Invalid model name"})
continue
try:
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
ok_list.append({"model": model_name, "deleted": result["deleted"]})
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
except FileNotFoundError as e:
error_list.append({"model": model_name, "error": str(e)})
except Exception as e:
logger.error("Error deleting model %s: %s", model_name, e)
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
except Exception as e:
logger.error("Error in delete_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
+18 -1
View File
@@ -64,7 +64,7 @@ class GalleryMetadataStore:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups"}
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
@@ -123,3 +123,20 @@ class GalleryMetadataStore:
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
def delete(self, image_path: str) -> bool:
"""画像のメタデータエントリを削除する"""
key = self._normalize_path(image_path)
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
def rename_path(self, old_path: str, new_path: str) -> bool:
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
old_key = self._normalize_path(old_path)
new_key = self._normalize_path(new_path)
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
+126 -11
View File
@@ -350,20 +350,35 @@ class GalleryService:
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""PNG埋め込みメタデータからComfyUIワークフローを抽出"""
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
"""
path = Path(image_path).resolve()
if path.suffix.lower() != ".png":
return None
if not self._check_path_allowed(path):
return None
embedded = self._read_png_metadata(path)
workflow_str = embedded.get("workflow") or embedded.get("Workflow")
if workflow_str:
try:
return json.loads(workflow_str)
except json.JSONDecodeError:
pass
return None
if path.suffix.lower() == ".png":
embedded = self._read_png_metadata(path)
# 1. workflow キー (ComfyUI UI形式)
for key in ("workflow", "Workflow"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
for key in ("prompt", "Prompt"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 3. gallery_metadata.json に保存されたworkflow
saved = self.metadata_store.get(str(path))
return saved.get("workflow") or None
# ──────────────────────────────────────────────────────────────
# メタデータ保存
@@ -414,6 +429,106 @@ class GalleryService:
# 画像配信
# ──────────────────────────────────────────────────────────────
def create_folder(self, parent_path: str, name: str) -> dict:
"""選択フォルダ内に新しいサブフォルダを作成する"""
parent = Path(parent_path).resolve()
if not parent.is_dir():
return {"ok": False, "error": "Parent directory not found"}
if not self._check_path_allowed(parent):
return {"ok": False, "error": "Access denied"}
name = name.strip()
invalid_chars = set(r'\/:*?"<>|')
if not name or any(c in invalid_chars for c in name):
return {"ok": False, "error": "Invalid folder name"}
new_folder = parent / name
if new_folder.exists():
return {"ok": False, "error": "Folder already exists"}
try:
new_folder.mkdir()
self._folder_cache.invalidate(parent)
return {"ok": True, "path": str(new_folder).replace("\\", "/")}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_folder(self, folder_path: str) -> dict:
"""フォルダを再帰的に削除する"""
import shutil
folder = Path(folder_path).resolve()
if not folder.is_dir():
return {"ok": False, "error": "Directory not found"}
if not self._check_path_allowed(folder):
return {"ok": False, "error": "Access denied"}
if self._allowed_root and folder == self._allowed_root:
return {"ok": False, "error": "Cannot delete root folder"}
try:
parent = folder.parent
shutil.rmtree(folder)
self._folder_cache.invalidate(parent)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_images(self, paths: list) -> dict:
"""画像ファイルを削除する(複数対応)"""
deleted = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
try:
p.unlink()
self.metadata_store.delete(str(p))
self._folder_cache.invalidate(p.parent)
deleted.append(str(p).replace("\\", "/"))
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"deleted": deleted, "errors": errors}
def move_images(self, paths: list, dest_folder: str) -> dict:
"""画像ファイルを別フォルダへ移動する(複数対応)"""
dest = Path(dest_folder).resolve()
if not dest.is_dir():
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
if not self._check_path_allowed(dest):
return {"ok": False, "error": "Access denied", "moved": [], "errors": []}
moved = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
dest_path = dest / p.name
counter = 1
while dest_path.exists():
dest_path = dest / f"{p.stem}_{counter}{p.suffix}"
counter += 1
try:
p.rename(dest_path)
old_str = str(p).replace("\\", "/")
new_str = str(dest_path).replace("\\", "/")
self.metadata_store.rename_path(old_str, new_str)
self._folder_cache.invalidate(p.parent)
self._folder_cache.invalidate(dest)
moved.append({"from": old_str, "to": new_str, "filename": dest_path.name})
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"moved": moved, "errors": errors}
def serve_image(self, image_path: str):
"""画像のPathオブジェクトを返す(ルートで使用)"""
p = Path(image_path).resolve()
+130 -4
View File
@@ -13,6 +13,15 @@ logger = logging.getLogger(__name__)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# All sidecar extensions to delete when a model is deleted
_SIDECAR_EXTENSIONS = [
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp",
".json", ".civitai.info", ".info",
]
_DISABLED_SUFFIX = ".disabled"
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
@@ -100,16 +109,88 @@ class ModelsService:
self._save_metadata(data)
return entry
def get_model_groups(self):
def get_model_groups(self, model_type=None):
data = self._load_metadata()
return data.get("_groups", {})
raw = data.get("_groups", {})
def save_model_groups(self, groups):
# Migrate old flat format { "groupName": [...] } → per-type { "checkpoint": {...} }
if raw and not any(k in MODEL_TYPE_FOLDER_KEYS for k in raw):
logger.info("Migrating groups to per-type format (old data moved to _groups_legacy)")
data["_groups_legacy"] = raw
data["_groups"] = {}
self._save_metadata(data)
raw = {}
if model_type:
return raw.get(model_type, {})
return raw
def save_model_groups(self, groups, model_type):
data = self._load_metadata()
data["_groups"] = groups
if not isinstance(data.get("_groups"), dict):
data["_groups"] = {}
data["_groups"][model_type] = groups
self._save_metadata(data)
return groups
def find_model_file(self, model_type, model_name):
"""Find a model file, checking both enabled and disabled states.
Returns (Path, is_enabled) or (None, None) if not found.
"""
dirs = _get_model_dirs(model_type)
for d in dirs:
enabled = d / model_name
if enabled.is_file():
return enabled, True
disabled = d / (model_name + _DISABLED_SUFFIX)
if disabled.is_file():
return disabled, False
return None, None
def enable_model(self, model_type, model_name):
"""Rename model.safetensors.disabled → model.safetensors."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if is_enabled:
return
target = path.parent / path.name[: -len(_DISABLED_SUFFIX)]
path.rename(target)
logger.info("Enabled model: %s", model_name)
def disable_model(self, model_type, model_name):
"""Rename model.safetensors → model.safetensors.disabled."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if not is_enabled:
return
target = path.parent / (path.name + _DISABLED_SUFFIX)
path.rename(target)
logger.info("Disabled model: %s", model_name)
def scan_disabled_models(self, model_type):
"""Scan directories for .disabled files.
Returns list of normalized model names (without .disabled suffix).
"""
dirs = _get_model_dirs(model_type)
disabled = []
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
try:
rel = str(f.relative_to(d))
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
disabled.append(rel.replace("\\", "/"))
except ValueError:
pass
return disabled
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
@@ -148,3 +229,48 @@ class ModelsService:
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
def delete_model(self, model_type, model_name):
"""Delete a model file and all associated preview/sidecar files.
Also removes the model's metadata entry.
Returns dict with deleted file paths.
"""
if ".." in model_name:
raise ValueError(f"Invalid model name: {model_name}")
path, _ = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
deleted = []
parent = path.parent
# Compute stem from the original (non-disabled) filename.
# path.name for a disabled file is e.g. "model.safetensors.disabled"
# so we strip the .disabled suffix first, then take Path.stem.
orig_name = path.name
if orig_name.endswith(_DISABLED_SUFFIX):
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
stem = Path(orig_name).stem # "model.safetensors" -> "model"
# Delete the model file itself
path.unlink()
deleted.append(str(path))
logger.info("Deleted model file: %s", path)
# Delete all sidecar files (previews, metadata, info)
for ext in _SIDECAR_EXTENSIONS:
sidecar = parent / (stem + ext)
if sidecar.is_file():
sidecar.unlink()
deleted.append(str(sidecar))
logger.info("Deleted sidecar: %s", sidecar)
# Remove metadata entry
data = self._load_metadata()
if model_name in data:
del data[model_name]
self._save_metadata(data)
return {"deleted": deleted}
+55
View File
@@ -27,10 +27,12 @@
.wfm-gallery-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 4px;
}
.wfm-gallery-tree-title {
@@ -39,6 +41,13 @@
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1;
}
.wfm-gallery-tree-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
@@ -592,3 +601,49 @@
.wfm-gallery-lightbox-close:hover {
background: rgba(255,255,255,0.2);
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
display: flex;
gap: 6px;
padding-top: 4px;
}
.wfm-gallery-file-actions .wfm-btn {
flex: 1;
}
/* ── 移動先選択モーダル ──────────────────────────────────────── */
.wfm-gallery-move-modal {
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 8px;
padding: 20px;
min-width: 320px;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 12px;
}
.wfm-gallery-move-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--wfm-text-primary);
}
.wfm-gallery-move-dest-sel {
width: 100%;
padding: 6px 8px;
font-size: 13px;
}
.wfm-gallery-move-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
+143
View File
@@ -3235,3 +3235,146 @@ body {
flex: 1;
overflow-y: auto;
}
/* ============================================
Model Enable / Disable
============================================ */
/* Disabled model: gray out the card */
.wfm-model-disabled {
opacity: 0.45;
filter: grayscale(60%);
}
.wfm-model-disabled:hover {
opacity: 0.65;
}
/* Overlay label on thumb */
.wfm-disabled-overlay {
position: absolute;
bottom: 4px;
left: 4px;
background: rgba(0,0,0,0.65);
color: #ff6b6b;
font-size: 10px;
font-weight: 700;
padding: 2px 5px;
border-radius: 3px;
letter-spacing: 0.5px;
pointer-events: none;
}
/* Toggle button – thumb/card view (absolute bottom-right) */
.wfm-toggle-btn {
position: absolute;
bottom: 6px;
right: 6px;
background: none;
border: none;
font-size: 13px;
color: var(--wfm-text-secondary);
cursor: pointer;
padding: 2px;
line-height: 1;
opacity: 0.4;
transition: opacity 0.15s, color 0.15s;
}
.wfm-toggle-btn:hover {
opacity: 1;
color: var(--wfm-primary);
}
.wfm-toggle-btn.wfm-toggle-disabled {
color: #ff6b6b;
opacity: 0.7;
}
/* Card view: fav and toggle are flex siblings */
.wfm-view-card .wfm-toggle-btn {
position: static;
flex-shrink: 0;
margin-right: 4px;
}
/* Table view toggle cell */
.wfm-table-td-toggle {
text-align: center;
width: 40px;
}
.wfm-table-td-toggle .wfm-toggle-btn {
position: static;
opacity: 0.5;
}
/* Disabled badge in card title */
.wfm-badge-disabled {
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
font-size: 10px;
font-weight: 700;
border: 1px solid rgba(255, 107, 107, 0.4);
}
/* ── Multi-select ─────────────────────────────────────── */
.wfm-select-check {
position: absolute;
top: 6px;
left: 6px;
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid rgba(255, 255, 255, 0.6);
background: rgba(0, 0, 0, 0.45);
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: transparent;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.wfm-select-check.checked {
background: #6c8ef5;
border-color: #6c8ef5;
color: #fff;
}
.wfm-select-check.checked::after {
content: "✓";
}
.wfm-view-thumb .wfm-model-card.wfm-card-checked,
.wfm-view-card .wfm-model-card.wfm-card-checked {
outline: 2px solid #6c8ef5;
outline-offset: -2px;
}
.wfm-table-td-check {
width: 28px;
text-align: center;
padding: 0 4px;
}
.wfm-table-td-check .wfm-select-check {
position: static;
}
.wfm-bulk-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(108, 142, 245, 0.12);
border-bottom: 1px solid rgba(108, 142, 245, 0.3);
flex-wrap: wrap;
}
.wfm-bulk-count {
font-size: 13px;
color: #a0a8c0;
margin-right: 4px;
}
.wfm-bulk-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.15);
margin: 0 4px;
}
+2 -1
View File
@@ -281,7 +281,8 @@ function applyI18nToHtml() {
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
"wfm-help-models-9": "helpModels9",
"wfm-help-models-9": "helpModels9", "wfm-help-models-10": "helpModels10",
"wfm-help-models-11": "helpModels11",
"wfm-help-gallery-title": "helpGalleryTitle",
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
+7 -1
View File
@@ -88,7 +88,13 @@ export const comfyUI = {
try {
const info = await this.fetchObjectInfo(cls);
if (info?.[cls]?.input?.required?.[inputKey]) {
return info[cls].input.required[inputKey][0] || [];
const inputDef = info[cls].input.required[inputKey];
const first = inputDef[0];
if (Array.isArray(first)) return first;
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
return inputDef[1].values;
}
}
} catch {}
}
+267 -2
View File
@@ -22,6 +22,10 @@ const API = {
groupDelete: (name) => `/wfm/gallery/groups/${encodeURIComponent(name)}`,
groupAdd: (name) => `/wfm/gallery/groups/${encodeURIComponent(name)}/add`,
groupRemove: (name) => `/wfm/gallery/groups/${encodeURIComponent(name)}/remove`,
folderCreate: `/wfm/gallery/folder`,
folderDelete: `/wfm/gallery/folder`,
imagesDelete: `/wfm/gallery/images/delete`,
imagesMove: `/wfm/gallery/images/move`,
};
// ── 状態 ─────────────────────────────────────────────────────
@@ -40,6 +44,7 @@ const state = {
groups: [],
embeddedWorkflow: null, // 選択画像のworkflow JSON
selectedImages: new Set(), // 複数選択中のパス Set
folderTree: null, // フォルダツリー全体(移動先選択に使用)
};
// ── ヘルパー ──────────────────────────────────────────────────
@@ -62,9 +67,49 @@ function formatDate(mtime) {
// ── フォルダツリー ────────────────────────────────────────────
function _getExpandedPaths() {
const expanded = new Set();
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
const arrow = item.querySelector(".wfm-gallery-tree-arrow");
if (arrow && arrow.dataset.expanded === "true") {
expanded.add(item.dataset.path);
}
});
return expanded;
}
function _restoreTreeState(expandedPaths, selectedPath) {
// 親→子の順で展開するため、階層の浅い順にソート
const sorted = [...expandedPaths].sort(
(a, b) => a.split("/").length - b.split("/").length
);
for (const path of sorted) {
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
if (item.dataset.path !== path) return;
const arrow = item.querySelector(".wfm-gallery-tree-arrow");
if (arrow && arrow.dataset.expanded !== "true" && arrow.style.visibility !== "hidden") {
arrow.click();
}
});
}
// 選択状態を復元(labelクリックは画像リロードを伴うのでハイライトのみ)
if (selectedPath) {
document.querySelectorAll(".wfm-gallery-tree-item").forEach(item => {
if (item.dataset.path === selectedPath) {
document.querySelectorAll(".wfm-gallery-tree-item.selected").forEach(el => el.classList.remove("selected"));
item.classList.add("selected");
}
});
}
}
async function loadFolderTree() {
if (!state.outputRoot) return;
// 再構築前に展開状態を保存
const expandedPaths = _getExpandedPaths();
const isFirstLoad = expandedPaths.size === 0 && !state.currentFolder;
const tree = document.getElementById("wfm-gallery-tree");
tree.innerHTML = `<p class="wfm-placeholder">${t("loading")}</p>`;
@@ -74,12 +119,17 @@ async function loadFolderTree() {
tree.innerHTML = `<p class="wfm-placeholder">${data.error}</p>`;
return;
}
state.folderTree = data;
tree.innerHTML = "";
renderTreeNode(data, tree, 0, true);
// root フォルダを自動選択(フォルダ未選択の場合のみ)
if (!state.currentFolder) {
if (isFirstLoad) {
// 初回のみ root を自動選択
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
if (firstLabel) firstLabel.click();
} else {
// 展開状態と選択ハイライトを復元
_restoreTreeState(expandedPaths, state.currentFolder);
}
} catch (e) {
tree.innerHTML = `<p class="wfm-placeholder">Error: ${e.message}</p>`;
@@ -90,6 +140,7 @@ function renderTreeNode(node, container, depth, isRoot) {
const item = document.createElement("div");
item.className = "wfm-gallery-tree-item";
item.style.paddingLeft = `${depth * 12}px`;
item.dataset.path = node.abs_path;
const hasChildren = node.children && node.children.length > 0;
const absPath = node.abs_path;
@@ -126,6 +177,11 @@ function renderTreeNode(node, container, depth, isRoot) {
state.selectedImages.clear();
updateBulkBar();
loadImages();
// Delete Folder ボタン: rootは削除不可
const delBtn = document.getElementById("wfm-gallery-folder-delete-btn");
if (delBtn) {
delBtn.disabled = (absPath === state.outputRoot.replace(/\\/g, "/") || absPath === state.outputRoot);
}
});
// 矢印クリックで子ノード展開/折りたたみ
@@ -424,6 +480,12 @@ function updateTagFilter(images) {
// ── 詳細パネル ────────────────────────────────────────────────
async function loadImageDetail(img) {
// ファイル操作ボタンを有効化
const moveBtn = document.getElementById("wfm-gallery-img-move-btn");
const delBtn = document.getElementById("wfm-gallery-img-delete-btn");
if (moveBtn) moveBtn.disabled = false;
if (delBtn) delBtn.disabled = false;
// プレビュー
const preview = document.getElementById("wfm-gallery-detail-preview");
preview.innerHTML = `<img src="${API.serveImage(img.path)}" class="wfm-gallery-detail-img" alt="${img.filename}" title="Double-click to enlarge">`;
@@ -772,6 +834,181 @@ function escapeHtml(str) {
.replace(/"/g, "&quot;");
}
// ── フォルダ作成・削除 ────────────────────────────────────────
async function createFolder() {
if (!state.currentFolder) {
showToast("Please select a parent folder first", "error");
return;
}
const name = prompt("New folder name:");
if (!name || !name.trim()) return;
try {
const res = await fetch(API.folderCreate, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent: state.currentFolder, name: name.trim() }),
});
const data = await res.json();
if (!data.ok) {
showToast(`Error: ${data.error}`, "error");
return;
}
showToast(`Folder "${name.trim()}" created`, "success");
await loadFolderTree();
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
async function deleteFolder() {
if (!state.currentFolder) return;
if (!confirm(`Delete folder "${state.currentFolder.split("/").pop()}" and all its contents?`)) return;
try {
const res = await fetch(API.folderDelete, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: state.currentFolder }),
});
const data = await res.json();
if (!data.ok) {
showToast(`Error: ${data.error}`, "error");
return;
}
showToast("Folder deleted", "success");
state.currentFolder = "";
state.images = [];
document.getElementById("wfm-gallery-grid").innerHTML = `<p class="wfm-placeholder">Select a folder to browse images.</p>`;
await loadFolderTree();
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
// ── ファイル削除・移動 ────────────────────────────────────────
async function performDeleteImages(paths) {
try {
const res = await fetch(API.imagesDelete, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths }),
});
const data = await res.json();
if (data.deleted && data.deleted.length > 0) {
showToast(`Deleted ${data.deleted.length} image(s)`, "success");
// 削除された画像をstateから除去
const deletedSet = new Set(data.deleted);
state.images = state.images.filter(img => !deletedSet.has(img.path));
state.selectedImages = new Set([...state.selectedImages].filter(p => !deletedSet.has(p)));
if (state.selectedImage && deletedSet.has(state.selectedImage.path)) {
state.selectedImage = null;
document.getElementById("wfm-gallery-detail-preview").innerHTML = `<span class="wfm-placeholder">No selection</span>`;
document.getElementById("wfm-gallery-detail-filename").textContent = "";
const moveBtn = document.getElementById("wfm-gallery-img-move-btn");
const delBtn = document.getElementById("wfm-gallery-img-delete-btn");
if (moveBtn) moveBtn.disabled = true;
if (delBtn) delBtn.disabled = true;
}
updateBulkBar();
renderImages();
// フォルダツリーのカウントを更新
loadFolderTree();
}
if (data.errors && data.errors.length > 0) {
showToast(`Errors: ${data.errors.join(", ")}`, "error");
}
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
async function performMoveImages(paths, destFolder) {
try {
const res = await fetch(API.imagesMove, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths, dest: destFolder }),
});
const data = await res.json();
if (data.moved && data.moved.length > 0) {
showToast(`Moved ${data.moved.length} image(s)`, "success");
const movedSet = new Set(data.moved.map(m => m.from));
state.images = state.images.filter(img => !movedSet.has(img.path));
state.selectedImages = new Set([...state.selectedImages].filter(p => !movedSet.has(p)));
if (state.selectedImage && movedSet.has(state.selectedImage.path)) {
state.selectedImage = null;
document.getElementById("wfm-gallery-detail-preview").innerHTML = `<span class="wfm-placeholder">No selection</span>`;
document.getElementById("wfm-gallery-detail-filename").textContent = "";
const moveBtn = document.getElementById("wfm-gallery-img-move-btn");
const delBtn = document.getElementById("wfm-gallery-img-delete-btn");
if (moveBtn) moveBtn.disabled = true;
if (delBtn) delBtn.disabled = true;
}
updateBulkBar();
renderImages();
loadFolderTree();
}
if (data.errors && data.errors.length > 0) {
showToast(`Errors: ${data.errors.join(", ")}`, "error");
}
if (data.error) {
showToast(`Error: ${data.error}`, "error");
}
} catch (e) {
showToast(`Error: ${e.message}`, "error");
}
}
// ── フォルダツリーを平坦なリストに変換 ──────────────────────────
function flattenFolderTree(node, result = []) {
if (!node) return result;
result.push({ name: node.name, abs_path: node.abs_path });
if (node.children) {
node.children.forEach(child => flattenFolderTree(child, result));
}
return result;
}
// ── 移動先選択モーダル ────────────────────────────────────────
function openMoveModal(paths) {
const allFolders = flattenFolderTree(state.folderTree);
const destinations = allFolders.filter(f => f.abs_path !== state.currentFolder);
if (destinations.length === 0) {
showToast("No other folders available. Create a subfolder first.", "error");
return;
}
const overlay = document.createElement("div");
overlay.className = "wfm-gallery-lightbox";
overlay.innerHTML = `
<div class="wfm-gallery-move-modal">
<div class="wfm-gallery-move-modal-title">Move ${paths.length} image(s) to folder:</div>
<select id="wfm-gallery-move-dest-sel" class="wfm-select wfm-gallery-move-dest-sel">
${destinations.map(f => `<option value="${escapeHtml(f.abs_path)}">${escapeHtml(f.name)}</option>`).join("")}
</select>
<div class="wfm-gallery-move-modal-footer">
<button id="wfm-gallery-move-confirm" class="wfm-btn wfm-btn-primary">Move</button>
<button id="wfm-gallery-move-cancel" class="wfm-btn">Cancel</button>
</div>
</div>
`;
overlay.querySelector("#wfm-gallery-move-cancel").addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector("#wfm-gallery-move-confirm").addEventListener("click", async () => {
const dest = overlay.querySelector("#wfm-gallery-move-dest-sel").value;
if (!dest) return;
overlay.remove();
await performMoveImages(paths, dest);
});
document.body.appendChild(overlay);
}
// ── 一括操作 ─────────────────────────────────────────────────
async function bulkAddToGroup(groupName) {
@@ -998,6 +1235,34 @@ function bindEvents() {
bulkSetFavorite(false);
});
document.getElementById("wfm-gallery-bulk-move")?.addEventListener("click", () => {
if (state.selectedImages.size === 0) return;
openMoveModal([...state.selectedImages]);
});
document.getElementById("wfm-gallery-bulk-delete")?.addEventListener("click", () => {
if (state.selectedImages.size === 0) return;
const count = state.selectedImages.size;
if (!confirm(`Delete ${count} selected image(s)? This cannot be undone.`)) return;
performDeleteImages([...state.selectedImages]);
});
// フォルダ作成・削除
document.getElementById("wfm-gallery-folder-create-btn")?.addEventListener("click", createFolder);
document.getElementById("wfm-gallery-folder-delete-btn")?.addEventListener("click", deleteFolder);
// 詳細パネル: 単体ファイル操作
document.getElementById("wfm-gallery-img-move-btn")?.addEventListener("click", () => {
if (!state.selectedImage) return;
openMoveModal([state.selectedImage.path]);
});
document.getElementById("wfm-gallery-img-delete-btn")?.addEventListener("click", () => {
if (!state.selectedImage) return;
if (!confirm(`Delete "${state.selectedImage.filename}"? This cannot be undone.`)) return;
performDeleteImages([state.selectedImage.path]);
});
// 詳細タブ切り替え
document.querySelectorAll(".wfm-gallery-detail-tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
+256 -71
View File
@@ -50,6 +50,42 @@ async function saveToEagle(imageUrl, name, tags = []) {
}
}
// ============================================
// Gallery Metadata - ワークフロー保存
// ============================================
let _outputDir = "";
async function _fetchOutputDir() {
try {
const res = await fetch("/api/wfm/settings/output-dir");
if (res.ok) {
const data = await res.json();
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
}
} catch {}
}
async function saveGeneratedImagesMeta(images, workflow) {
if (!_outputDir) await _fetchOutputDir();
if (!_outputDir) return;
for (const img of images) {
if (img.type !== "output") continue;
const parts = [_outputDir];
if (img.subfolder) parts.push(img.subfolder);
parts.push(img.filename);
const path = parts.join("/");
try {
await fetch("/wfm/gallery/image/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, workflow }),
});
} catch {}
}
}
// ============================================
// Status & Connection
// ============================================
@@ -114,7 +150,199 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
}
// ============================================
// Generation
// Checkpoint Batch
// ============================================
const _ckptBatch = { aborted: false };
function _parseFolderList(str) {
return str.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
}
function _getModelFolder(modelPath) {
const normalized = modelPath.replace(/\\/g, "/");
const idx = normalized.indexOf("/");
return idx === -1 ? "" : normalized.substring(0, idx).toLowerCase();
}
function _filterCheckpoints(checkpoints, includeStr, excludeStr) {
const inc = _parseFolderList(includeStr);
const exc = _parseFolderList(excludeStr);
return checkpoints.filter((m) => {
const folder = _getModelFolder(m);
if (inc.length > 0 && !inc.includes(folder)) return false;
if (exc.length > 0 && exc.includes(folder)) return false;
return true;
});
}
function _updateBatchInfo() {
const infoEl = document.getElementById("wfm-ckpt-batch-info");
if (!infoEl) return;
const inc = document.getElementById("wfm-ckpt-batch-include")?.value || "";
const exc = document.getElementById("wfm-ckpt-batch-exclude")?.value || "";
const n = _filterCheckpoints(comfyEditor.models.checkpoints, inc, exc).length;
infoEl.textContent = `${n} checkpoint${n !== 1 ? "s" : ""} will be processed`;
}
function initCheckpointBatch() {
const checkbox = document.getElementById("wfm-ckpt-batch-enabled");
const body = document.getElementById("wfm-ckpt-batch-body");
checkbox?.addEventListener("change", () => {
if (body) body.style.display = checkbox.checked ? "block" : "none";
if (checkbox.checked) _updateBatchInfo();
});
document.getElementById("wfm-ckpt-batch-include")?.addEventListener("input", _updateBatchInfo);
document.getElementById("wfm-ckpt-batch-exclude")?.addEventListener("input", _updateBatchInfo);
}
// ============================================
// Generation (core — throws on error)
// ============================================
async function _coreGenerate(silent = false) {
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
}
}
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
if (images.length > 0) {
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
}
if (!silent) showToast("Generation complete", "success");
}
// ============================================
// Batch generation loop
// ============================================
async function _runBatchGenerate() {
const ckptNodes = comfyUI.currentAnalysis?.checkpoint_nodes || [];
if (ckptNodes.length === 0) {
showToast("No checkpoint node found in workflow", "error");
return;
}
const inc = document.getElementById("wfm-ckpt-batch-include")?.value || "";
const exc = document.getElementById("wfm-ckpt-batch-exclude")?.value || "";
const list = _filterCheckpoints(comfyEditor.models.checkpoints, inc, exc);
if (list.length === 0) {
showToast("No checkpoints match the folder filter", "error");
return;
}
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
const batchCount = document.getElementById("wfm-ckpt-batch-count");
const batchBar = document.getElementById("wfm-ckpt-batch-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
if (batchProgress) batchProgress.style.display = "block";
let completed = 0;
let failed = 0;
for (let i = 0; i < list.length; i++) {
if (_ckptBatch.aborted) break;
const model = list[i];
if (batchCurrentName) batchCurrentName.textContent = model;
if (batchCount) batchCount.textContent = `${i + 1} / ${list.length}`;
if (batchBar) batchBar.style.width = `${((i / list.length) * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `[${i + 1}/${list.length}] Loading...`;
for (const node of ckptNodes) {
if (comfyUI.currentWorkflow?.[node.id]) {
comfyUI.currentWorkflow[node.id].inputs.ckpt_name = model;
}
}
try {
await _coreGenerate(true);
completed++;
} catch (err) {
if (_ckptBatch.aborted) break;
failed++;
showToast(`[${i + 1}/${list.length}] Failed: ${err.message}`, "error");
}
}
if (batchBar) batchBar.style.width = "100%";
if (batchCurrentName) batchCurrentName.textContent = _ckptBatch.aborted ? "Stopped" : "Done";
if (_ckptBatch.aborted) {
showToast(`Batch stopped — ${completed} completed, ${failed} failed`, "info");
} else {
showToast(
`Batch complete: ${completed}/${list.length}${failed > 0 ? ` (${failed} failed)` : ""}`,
failed > 0 ? "error" : "success"
);
}
}
// ============================================
// Generate entry point
// ============================================
async function handleGenerate() {
@@ -124,84 +352,29 @@ async function handleGenerate() {
}
if (comfyUI.generating) return;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
// Sync prompts to workflow
comfyEditor.syncToWorkflow();
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
genBtn.disabled = true;
if (interruptBtn) interruptBtn.style.display = "inline-block";
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
_ckptBatch.aborted = false;
const batchEnabled = document.getElementById("wfm-ckpt-batch-enabled")?.checked;
try {
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
// Update seed display
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
// Display results
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
// Thumbnails for multiple images
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
if (batchEnabled) {
await _runBatchGenerate();
} else {
try {
await _coreGenerate(false);
} catch (err) {
const progressText = document.getElementById("wfm-gen-progress-text");
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
}
}
// Eagle auto-save
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
showToast("Generation complete", "success");
} catch (err) {
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
} finally {
genBtn.disabled = false;
if (interruptBtn) interruptBtn.style.display = "none";
@@ -235,13 +408,15 @@ export async function initGenerateTab() {
comfyEditor.renderAll(comfyUI.currentAnalysis, comfyUI.currentWorkflow);
}
showToast("Model lists refreshed", "success");
_updateBatchInfo();
});
// Generate button
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", handleGenerate);
// Interrupt button
// Interrupt button (stops both single generation and batch loop)
document.getElementById("wfm-gen-interrupt-btn")?.addEventListener("click", async () => {
_ckptBatch.aborted = true;
await comfyUI.interrupt();
showToast("Interrupted", "info");
});
@@ -303,11 +478,21 @@ export async function initGenerateTab() {
} catch {}
}
// outputDir を事前取得(生成後の保存に使用)
_fetchOutputDir();
window.addEventListener("wfm-output-dir-changed", (e) => {
_outputDir = (e.detail?.path || "").replace(/\\/g, "/").replace(/\/$/, "");
});
// Checkpoint batch UI
initCheckpointBatch();
// Auto-connect on init
const connected = await comfyUI.checkConnection();
updateStatus(connected);
if (connected) {
await comfyEditor.loadModelLists();
_updateBatchInfo();
}
// Auto-load default workflow (doesn't require ComfyUI connection)
+75 -3
View File
@@ -273,6 +273,28 @@ const LANGUAGES = {
modelsGroupExists: "Group already exists",
modelsRenamePrompt: "Enter new group name:",
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
modelEnable: "Enable",
modelDisable: "Disable",
modelDisabled: "Disabled",
modelStatusAll: "All",
modelStatusEnabled: "Enabled only",
modelStatusDisabled: "Disabled only",
modelGroupEnableAll: "Enable All",
modelGroupDisableAll: "Disable All",
modelToggleError: "Failed to toggle model",
modelStatusWarning: "Changes take effect after ComfyUI refresh",
modelSelectMode: "Select",
modelSelectExit: "Exit Select",
modelSelected: "selected",
modelBulkAddGroup: "Add to Group",
modelBulkRemoveGroup: "Remove from Group",
modelBulkCreateAdd: "Create & Add",
modelBulkAddDone: "models added to group",
modelBulkRemoveDone: "models removed from group",
modelBulkDelete: "Delete Files",
modelBulkDeleteConfirm: "Permanently delete {count} model file(s) and their preview images? This cannot be undone.",
modelBulkDeleteDone: "model file(s) deleted",
modelBulkDeleteError: "Failed to delete some models",
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
civitaiFetch: "Fetch from CivitAI",
civitaiHashing: "Calculating hash... (may take a while for large files)",
@@ -350,13 +372,15 @@ const LANGUAGES = {
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, Hypernetwork, Embedding with sub-tab switching",
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
helpModels3: "Search, tag filter, group filter, and favorites filter",
helpModels3: "Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)",
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",
helpModels8: "GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "User-defined badges: assign free-label badges with custom colors shared with the Workflow tab",
helpModels10: "Enable/Disable models: toggle visibility in ComfyUI by renaming file extension (.disabled suffix). Disable per model (⏸ button on each card) or per group (Enable All / Disable All in Group panel). Changes take effect after ComfyUI refresh.",
helpModels11: "Multi-select & bulk operations: click Select to enter selection mode, check multiple models, then use the bulk action bar to add/remove from groups or permanently delete files (model file + preview images + sidecar files such as .json/.info).",
helpGalleryTitle: "Gallery Tab",
helpGallery1: "Browse images in the ComfyUI output folder — set the folder path in Settings > Gallery Output Folder",
helpGallery2: "3-column layout: folder tree (left) | image grid (center) | detail panel (right)",
@@ -772,6 +796,28 @@ const LANGUAGES = {
modelsGroupExists: "同名のグループが既に存在します",
modelsRenamePrompt: "新しいグループ名を入力:",
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
modelEnable: "有効化",
modelDisable: "無効化",
modelDisabled: "無効",
modelStatusAll: "すべて",
modelStatusEnabled: "有効のみ",
modelStatusDisabled: "無効のみ",
modelGroupEnableAll: "全て有効化",
modelGroupDisableAll: "全て無効化",
modelToggleError: "切り替えに失敗しました",
modelStatusWarning: "ComfyUIをリフレッシュ後に反映されます",
modelSelectMode: "選択",
modelSelectExit: "選択終了",
modelSelected: "件選択中",
modelBulkAddGroup: "グループに追加",
modelBulkRemoveGroup: "グループから削除",
modelBulkCreateAdd: "作成して追加",
modelBulkAddDone: "件をグループに追加しました",
modelBulkRemoveDone: "件をグループから削除しました",
modelBulkDelete: "ファイルを削除",
modelBulkDeleteConfirm: "{count}件のモデルファイルとプレビュー画像を完全に削除しますか?この操作は取り消せません。",
modelBulkDeleteDone: "件のモデルファイルを削除しました",
modelBulkDeleteError: "一部のモデルの削除に失敗しました",
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
civitaiFetch: "CivitAIから取得",
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
@@ -849,13 +895,15 @@ const LANGUAGES = {
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embeddingをサブタブで切り替え",
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ、ステータスフィルタ(すべて / 有効のみ / 無効のみ)",
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpModels8: "GenUI Modelボタン:選択モデルをGenerateUIの現在のワークフローの該当ノードに直接適用(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "ユーザー定義バッジ:自由ラベルのバッジをモデルに付与(Workflowタブと色パレットを共有)",
helpModels10: "モデルの有効/無効切り替え:拡張子リネーム(.disabledサフィックス)でComfyUIへの表示を制御。各カードの⏸ボタンで単体操作、グループパネルの「全て有効化 / 全て無効化」でグループ一括操作。変更はComfyUIリフレッシュ後に反映。",
helpModels11: "複数選択と一括操作:「選択」ボタンで選択モードに入り複数モデルにチェックを入れると、一括操作バーからグループへの追加/削除、またはファイルの完全削除(モデルファイル+プレビュー画像+.json/.infoなどのサイドカーファイル)が行えます。",
helpGalleryTitle: "Galleryタブ",
helpGallery1: "ComfyUIの出力フォルダ内画像を閲覧 — Settings > Gallery Output Folder でフォルダパスを設定可能",
helpGallery2: "3カラムレイアウト: フォルダツリー (左) | 画像グリッド (中央) | 詳細パネル (右)",
@@ -1267,6 +1315,28 @@ const LANGUAGES = {
modelsGroupExists: "同名分组已存在",
modelsRenamePrompt: "输入新分组名称:",
modelsDeleteGroupConfirm: "删除分组「{name}」?",
modelEnable: "启用",
modelDisable: "禁用",
modelDisabled: "已禁用",
modelStatusAll: "全部",
modelStatusEnabled: "仅启用",
modelStatusDisabled: "仅禁用",
modelGroupEnableAll: "全部启用",
modelGroupDisableAll: "全部禁用",
modelToggleError: "切换失败",
modelStatusWarning: "刷新ComfyUI后生效",
modelSelectMode: "选择",
modelSelectExit: "退出选择",
modelSelected: "个已选中",
modelBulkAddGroup: "添加到分组",
modelBulkRemoveGroup: "从分组移除",
modelBulkCreateAdd: "创建并添加",
modelBulkAddDone: "个模型已添加到分组",
modelBulkRemoveDone: "个模型已从分组移除",
modelBulkDelete: "删除文件",
modelBulkDeleteConfirm: "永久删除 {count} 个模型文件及其预览图?此操作无法撤销。",
modelBulkDeleteDone: "个模型文件已删除",
modelBulkDeleteError: "部分模型删除失败",
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
civitaiFetch: "从CivitAI获取",
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
@@ -1344,13 +1414,15 @@ const LANGUAGES = {
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embedding,通过子标签切换",
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选、状态筛选(全部 / 仅启用 / 仅禁用)",
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpModels8: "GenUI Model按钮:将选中模型直接应用到GenerateUI当前工作流的对应节点(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "用户自定义徽章:为模型添加自由标签徽章(颜色调色板与Workflow标签共享)",
helpModels10: "模型启用/禁用:通过重命名文件扩展名(添加.disabled后缀)控制ComfyUI中的显示。点击每个卡片上的⏸按钮单独操作,或在分组面板中使用「全部启用/全部禁用」进行批量操作。更改在刷新ComfyUI后生效。",
helpModels11: "多选与批量操作:点击「选择」按钮进入选择模式,勾选多个模型后,可通过批量操作栏将其添加/移出分组,或永久删除文件(模型文件+预览图+.json/.info等附属文件)。",
helpGalleryTitle: "图库标签页 (Gallery)",
helpGallery1: "浏览 ComfyUI 输出文件夹中的图像 — 可以在 设置 > 图库输出文件夹 中设置文件夹路径",
helpGallery2: "三列布局:文件夹树(左) | 图像网格(中) | 详情面板(右)",
+422 -29
View File
@@ -23,12 +23,17 @@ const state = {
},
modelMetadata: {},
modelGroups: {},
allModelGroups: {}, // { type: { groupName: [models] } } — all types
civitaiCache: {},
disabledModels: {}, // { type: Set<modelName> }
selectMode: false,
selectedModels: new Set(),
searchText: "",
tagFilter: "",
badgeFilter: "",
dirFilter: "",
groupFilter: "",
statusFilter: "all", // "all" | "enabled" | "disabled"
showFavoritesOnly: false,
viewMode: localStorage.getItem("wfm_models_view") || "thumb",
activeModelType: "checkpoint",
@@ -200,6 +205,225 @@ function modelBadgesHtml(modelName, clickable = false) {
// ── API ───────────────────────────────────────────────────
// ── Multi-select & Bulk Group Operations ─────────────────
function toggleSelectMode() {
state.selectMode = !state.selectMode;
if (!state.selectMode) state.selectedModels.clear();
const btn = document.getElementById("wfm-models-select-btn");
if (btn) {
btn.classList.toggle("active", state.selectMode);
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
}
renderModelGrid();
renderBulkActionBar();
}
function toggleModelSelection(modelName) {
if (state.selectedModels.has(modelName)) {
state.selectedModels.delete(modelName);
} else {
state.selectedModels.add(modelName);
}
// Update DOM directly (avoid full re-render)
document.querySelectorAll("[data-model-name]").forEach((el) => {
if (el.dataset.modelName !== modelName) return;
const checked = state.selectedModels.has(modelName);
el.classList.toggle("wfm-card-checked", checked);
const c = el.querySelector(".wfm-select-check");
if (c) c.classList.toggle("checked", checked);
});
renderBulkActionBar();
}
function clearSelection() {
state.selectedModels.clear();
renderModelGrid();
renderBulkActionBar();
}
function renderBulkActionBar() {
const bar = document.getElementById("wfm-models-bulk-bar");
if (!bar) return;
if (!state.selectMode || state.selectedModels.size === 0) {
bar.style.display = "none";
return;
}
const count = state.selectedModels.size;
const groupNames = Object.keys(state.modelGroups).sort();
const noGroups = groupNames.length === 0;
const groupOptions = noGroups
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
bar.style.display = "flex";
bar.innerHTML = `
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
<select id="wfm-bulk-group-select" class="wfm-select" style="font-size:12px;">${groupOptions}</select>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
<span class="wfm-bulk-sep">|</span>
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input" style="width:120px;font-size:12px;" placeholder="${t("modelsGroupName")}">
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-clear-btn" title="Clear selection">&times;</button>
<span class="wfm-bulk-sep" style="margin-left:8px;"></span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
`;
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkAddToGroup(g);
});
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
const g = document.getElementById("wfm-bulk-group-select")?.value;
if (g) bulkRemoveFromGroup(g);
});
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-bulk-new-group-input");
const name = input?.value.trim();
if (!name) return;
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
bulkAddToGroup(name);
});
document.getElementById("wfm-bulk-clear-btn")?.addEventListener("click", clearSelection);
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
}
async function bulkAddToGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) groups[groupName] = [];
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
groups[groupName] = [...groups[groupName], ...toAdd];
await saveModelGroups(groups);
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
renderBulkActionBar();
}
async function bulkRemoveFromGroup(groupName) {
const groups = { ...state.modelGroups };
if (!groups[groupName]) return;
const before = groups[groupName].length;
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
const removed = before - groups[groupName].length;
if (groups[groupName].length === 0) delete groups[groupName];
await saveModelGroups(groups);
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
renderModelGrid();
renderBulkActionBar();
}
async function bulkDeleteModels() {
const count = state.selectedModels.size;
if (count === 0) return;
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
if (!window.confirm(msg)) return;
const model_names = [...state.selectedModels];
try {
const res = await fetch("/api/wfm/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
});
const data = await res.json();
const okCount = (data.ok || []).length;
const errCount = (data.errors || []).length;
if (errCount > 0) {
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
}
if (okCount > 0) {
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
// Remove deleted models from local state
model_names.forEach((mn) => {
const list = state.modelsByType[state.activeModelType];
const idx = list.indexOf(mn);
if (idx !== -1) list.splice(idx, 1);
delete state.modelMetadata[mn];
const ds = state.disabledModels[state.activeModelType];
if (ds) ds.delete(mn);
state.selectedModels.delete(mn);
});
state.loaded[state.activeModelType] = false;
renderModelGrid();
renderBulkActionBar();
}
} catch (err) {
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
}
}
// ── Enable / Disable helpers ──────────────────────────────
function isModelDisabled(modelName) {
const s = state.disabledModels[state.activeModelType];
return s ? s.has(modelName) : false;
}
async function fetchDisabledModels(type) {
try {
const res = await fetch(`/api/wfm/models/disabled?type=${encodeURIComponent(type)}`);
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function toggleModelEnable(modelName) {
const nowDisabled = isModelDisabled(modelName);
const newEnabled = nowDisabled;
try {
const res = await fetch("/api/wfm/models/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_type: state.activeModelType,
model_name: modelName,
enabled: newEnabled,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "toggle failed");
}
const s = state.disabledModels[state.activeModelType] || new Set();
if (newEnabled) s.delete(modelName);
else s.add(modelName);
state.disabledModels[state.activeModelType] = s;
showToast(t("modelStatusWarning"), "info");
renderModelGrid();
if (state.selectedModel === modelName) renderSideInfo(modelName);
} catch (err) {
showToast(t("modelToggleError") + ": " + err.message, "error");
}
}
async function toggleGroupEnable(groupName, enable) {
try {
const res = await fetch("/api/wfm/models/group-toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_type: state.activeModelType,
group_name: groupName,
enabled: enable,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "group toggle failed");
}
const data = await res.json();
const members = state.modelGroups[groupName] || [];
const s = state.disabledModels[state.activeModelType] || new Set();
members.forEach((m) => { if (enable) s.delete(m); else s.add(m); });
state.disabledModels[state.activeModelType] = s;
const errCount = data.errors?.length || 0;
if (errCount > 0) showToast(`${errCount} ${t("modelToggleError")}`, "warning");
showToast(t("modelStatusWarning"), "info");
renderModelGrid();
} catch (err) {
showToast(t("modelToggleError") + ": " + err.message, "error");
}
}
async function fetchModelMetadata() {
try {
const res = await fetch("/api/wfm/models/metadata");
@@ -236,6 +460,12 @@ function getCurrentModels() {
function filterModels() {
let models = getCurrentModels();
if (state.statusFilter === "enabled") {
models = models.filter((m) => !isModelDisabled(m));
} else if (state.statusFilter === "disabled") {
models = models.filter((m) => isModelDisabled(m));
}
if (state.showFavoritesOnly) {
models = models.filter((m) => {
const meta = state.modelMetadata[m];
@@ -428,11 +658,31 @@ function renderTagFilter() {
function renderGroupFilter() {
const select = document.getElementById("wfm-models-group-filter");
if (!select) return;
const groups = Object.keys(state.modelGroups).sort();
// Collect groups across all types with type labels
const options = [];
const typeOrder = Object.keys(TYPE_LABELS);
typeOrder.forEach((type) => {
const groups = state.allModelGroups[type];
if (!groups) return;
const names = Object.keys(groups).sort();
names.forEach((g) => {
options.push({ type, name: g });
});
});
const currentValue = state.groupFilter
? `${state.activeModelType}::${state.groupFilter}`
: "";
select.innerHTML =
`<option value="">${t("modelsAllGroups")}</option>` +
groups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
select.value = state.groupFilter;
options.map(({ type, name }) => {
const typeLabel = TYPE_LABELS[type] || type;
const value = `${type}::${name}`;
const isActive = value === currentValue;
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>[${typeLabel}] ${escapeHtml(name)}</option>`;
}).join("");
}
function renderDirFilter() {
@@ -450,6 +700,13 @@ function renderDirFilter() {
}
async function fetchModelGroups() {
try {
const res = await fetch(`/api/wfm/models/groups?type=${encodeURIComponent(state.activeModelType)}`);
return res.ok ? await res.json() : {};
} catch { return {}; }
}
async function fetchAllModelGroups() {
try {
const res = await fetch("/api/wfm/models/groups");
return res.ok ? await res.json() : {};
@@ -458,12 +715,14 @@ async function fetchModelGroups() {
async function saveModelGroups(groups) {
state.modelGroups = groups;
state.allModelGroups[state.activeModelType] = groups;
await fetch("/api/wfm/models/groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(groups),
body: JSON.stringify({ model_type: state.activeModelType, groups }),
});
renderGroupFilter();
renderBulkActionBar();
}
// ── Render: Grid ──────────────────────────────────────────
@@ -503,6 +762,7 @@ function renderThumbView(grid, models) {
models.forEach((modelName) => {
const meta = state.modelMetadata[modelName] || {};
const { name } = parseModelPath(modelName);
const disabled = isModelDisabled(modelName);
const userBadges = modelBadgesHtml(modelName);
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
const favStar = meta.favorite ? "\u2605" : "\u2606";
@@ -510,6 +770,7 @@ function renderThumbView(grid, models) {
const card = document.createElement("div");
card.className = "wfm-card";
if (disabled) card.classList.add("wfm-model-disabled");
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
card.dataset.modelName = modelName;
@@ -517,12 +778,14 @@ function renderThumbView(grid, models) {
<div class="wfm-card-thumb">
<img style="display:none" />
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
</div>
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
</div>
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>`;
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
// Load preview without 404 console spam
const img = card.querySelector(".wfm-card-thumb img");
@@ -533,11 +796,24 @@ function renderThumbView(grid, models) {
e.stopPropagation();
toggleFavorite(modelName);
});
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => {
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
openDetailModal(modelName);
toggleModelEnable(modelName);
});
if (state.selectMode) {
const isChecked = state.selectedModels.has(modelName);
card.classList.toggle("wfm-card-checked", isChecked);
const checkEl = document.createElement("div");
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
card.appendChild(checkEl);
card.addEventListener("click", (e) => {
if (e.target.closest(".wfm-fav-btn, .wfm-toggle-btn")) return;
toggleModelSelection(modelName);
});
} else {
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
}
grid.appendChild(card);
});
}
@@ -550,6 +826,7 @@ function renderCardView(grid, models) {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const disabled = isModelDisabled(modelName);
const userBadges = modelBadgesHtml(modelName);
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
const favStar = meta.favorite ? "\u2605" : "\u2606";
@@ -557,27 +834,42 @@ function renderCardView(grid, models) {
const card = document.createElement("div");
card.className = "wfm-card wfm-model-card";
if (disabled) card.classList.add("wfm-model-disabled");
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
card.dataset.modelName = modelName;
card.innerHTML = `
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(name)}</div>
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(name)}${disabled ? ` <span class="wfm-badge wfm-badge-disabled">${t("modelDisabled")}</span>` : ""}</div>
<div class="wfm-card-meta">
${userBadges} ${tagsHtml}
</div>
</div>
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>`;
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(modelName);
});
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => {
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
openDetailModal(modelName);
toggleModelEnable(modelName);
});
if (state.selectMode) {
const isChecked = state.selectedModels.has(modelName);
card.classList.toggle("wfm-card-checked", isChecked);
const checkEl = document.createElement("div");
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
card.appendChild(checkEl);
card.addEventListener("click", (e) => {
if (e.target.closest(".wfm-fav-btn, .wfm-toggle-btn")) return;
toggleModelSelection(modelName);
});
} else {
card.addEventListener("click", () => showSidePanel(modelName));
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
}
grid.appendChild(card);
});
}
@@ -590,10 +882,18 @@ function renderTableView(grid, models) {
const meta = state.modelMetadata[modelName] || {};
const { dir, name } = parseModelPath(modelName);
const ext = getExtension(name);
const disabled = isModelDisabled(modelName);
const isChecked = state.selectMode && state.selectedModels.has(modelName);
const favIcon = meta.favorite ? "&#9733;" : "&#9734;";
const tagsStr = (meta.tags || []).join(", ");
const memo = meta.memo || "";
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}" data-model-name="${escapeHtml(modelName)}">
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
const toggleIcon = disabled ? "▶" : "⏸";
const checkCell = state.selectMode
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
: "";
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
${checkCell}
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
<td title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
@@ -601,11 +901,14 @@ function renderTableView(grid, models) {
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
<td>${escapeHtml(tagsStr)}</td>
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
</tr>`;
})
.join("");
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
${checkTh}
<th style="width:30px;">&#9733;</th>
<th style="width:40px;"></th>
<th>${t("modelsFileName")}</th>
@@ -613,23 +916,31 @@ function renderTableView(grid, models) {
<th class="wfm-table-th-ext">${t("modelsExt")}</th>
<th>${t("modelsTags")}</th>
<th>${t("modelsMemo")}</th>
<th style="width:50px;"></th>
</tr></thead><tbody>${rows}</tbody></table>`;
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
const mn = row.dataset.modelName;
// Load preview image without 404 console spam
const img = row.querySelector(".wfm-table-thumb");
if (img) loadPreviewImage(img, null, mn);
row.addEventListener("click", () => showSidePanel(mn));
row.addEventListener("dblclick", (e) => {
e.stopPropagation();
openDetailModal(mn);
});
if (state.selectMode) {
row.addEventListener("click", (e) => {
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn")) return;
toggleModelSelection(mn);
});
} else {
row.addEventListener("click", () => showSidePanel(mn));
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
}
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
e.stopPropagation();
toggleFavorite(mn);
});
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
e.stopPropagation();
toggleModelEnable(mn);
});
});
}
@@ -993,6 +1304,10 @@ function renderSideGroup(modelName) {
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
</div>
<div style="display:flex;gap:4px;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
</div>
</div>
</div>
`;
@@ -1069,6 +1384,22 @@ function renderSideGroup(modelName) {
renderModelGrid();
});
});
// Enable all models in selected group
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
});
// Disable all models in selected group
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-models-group-manage-select");
const g = sel?.value;
if (!g) return;
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
});
}
// ── Side Panel: CivitAI Tab ───────────────────────────────
@@ -1197,8 +1528,12 @@ async function loadModelsForCurrentType() {
const placeholder = document.getElementById("wfm-models-placeholder");
if (state.loaded[type] && state.modelsByType[type].length > 0) {
// Reload groups for this type (groups are per-type)
state.modelGroups = await fetchModelGroups();
state.allModelGroups[type] = state.modelGroups;
renderTagFilter();
renderDirFilter();
renderGroupFilter();
renderModelGrid();
return;
}
@@ -1209,12 +1544,24 @@ async function loadModelsForCurrentType() {
const fetchFn = FETCH_MAP[type];
if (!fetchFn) throw new Error("Unknown model type: " + type);
const models = await fetchFn();
state.modelsByType[type] = models || [];
const [models, disabledList, groups] = await Promise.all([
fetchFn(), fetchDisabledModels(type), fetchModelGroups(),
]);
const disabledSet = new Set(Array.isArray(disabledList) ? disabledList : []);
state.disabledModels[type] = disabledSet;
state.modelGroups = groups;
state.allModelGroups[type] = groups;
// Merge enabled + disabled into one list (dedup)
// Guard: ensure models is an array (ComfyUI may return non-array on edge cases)
const enabledList = Array.isArray(models) ? models : [];
const allModels = [...new Set([...enabledList, ...disabledSet])];
state.modelsByType[type] = allModels;
state.loaded[type] = true;
renderTagFilter();
renderDirFilter();
renderGroupFilter();
renderModelGrid();
} catch (err) {
console.error("Failed to load models:", err);
@@ -1312,13 +1659,13 @@ async function batchFetchCivitai() {
}
async function loadMetadataAndModels() {
const [metadata, groups, civitaiCache] = await Promise.all([
fetchModelMetadata(), fetchModelGroups(), fetchCivitaiCache()
const [metadata, civitaiCache, allGroups] = await Promise.all([
fetchModelMetadata(), fetchCivitaiCache(), fetchAllModelGroups()
]);
state.modelMetadata = metadata;
state.modelGroups = groups;
state.civitaiCache = civitaiCache;
renderGroupFilter();
state.allModelGroups = allGroups;
// groups are loaded per-type inside loadModelsForCurrentType
await loadModelsForCurrentType();
}
@@ -1335,8 +1682,17 @@ export function initModelsTab() {
state.tagFilter = "";
state.badgeFilter = "";
state.dirFilter = "";
state.groupFilter = "";
state.statusFilter = "all";
state.currentPage = 0;
state.selectedModel = null;
const statusFilter = document.getElementById("wfm-models-status-filter");
if (statusFilter) statusFilter.value = "all";
state.selectMode = false;
state.selectedModels.clear();
const selectBtn = document.getElementById("wfm-models-select-btn");
if (selectBtn) { selectBtn.classList.remove("active"); selectBtn.textContent = t("modelSelectMode"); }
renderBulkActionBar();
closeSidePanel();
const searchInput = document.getElementById("wfm-models-search");
@@ -1373,9 +1729,43 @@ export function initModelsTab() {
renderModelGrid();
});
// Group filter
document.getElementById("wfm-models-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
// Group filter — value is "type::groupName" or "" for all
document.getElementById("wfm-models-group-filter")?.addEventListener("change", async (e) => {
const value = e.target.value;
if (!value) {
state.groupFilter = "";
} else {
const sepIdx = value.indexOf("::");
const type = value.substring(0, sepIdx);
const groupName = value.substring(sepIdx + 2);
// Auto-switch model type if different
if (type !== state.activeModelType) {
document.querySelectorAll(".wfm-models-type-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.modelType === type);
});
state.activeModelType = type;
state.searchText = "";
state.tagFilter = "";
state.badgeFilter = "";
state.dirFilter = "";
state.statusFilter = "all";
state.selectedModel = null;
const statusFilter = document.getElementById("wfm-models-status-filter");
if (statusFilter) statusFilter.value = "all";
const searchInput = document.getElementById("wfm-models-search");
if (searchInput) searchInput.value = "";
closeSidePanel();
await loadModelsForCurrentType();
}
state.groupFilter = groupName;
}
state.currentPage = 0;
renderModelGrid();
});
// Status filter
document.getElementById("wfm-models-status-filter")?.addEventListener("change", (e) => {
state.statusFilter = e.target.value;
state.currentPage = 0;
renderModelGrid();
});
@@ -1418,6 +1808,9 @@ export function initModelsTab() {
loadModelsForCurrentType();
});
// Select mode
document.getElementById("wfm-models-select-btn")?.addEventListener("click", toggleSelectMode);
// CivitAI batch fetch
document.getElementById("wfm-models-civitai-batch-btn")?.addEventListener("click", () => {
batchFetchCivitai();
+66 -12
View File
@@ -192,6 +192,11 @@
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
<select id="wfm-models-dir-filter" class="wfm-select wfm-toolbar-select"><option value="">All Folders</option></select>
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
<select id="wfm-models-status-filter" class="wfm-select wfm-toolbar-select">
<option value="all">All</option>
<option value="enabled">Enabled only</option>
<option value="disabled">Disabled only</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>
@@ -202,7 +207,10 @@
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">&#9881; Badge</button>
<button id="wfm-models-select-btn" class="wfm-btn wfm-btn-sm" title="Multi-select models">Select</button>
</div>
<!-- Bulk action bar (shown in select mode) -->
<div id="wfm-models-bulk-bar" class="wfm-bulk-bar" style="display:none;"></div>
</div>
<!-- Grid + Side Panel -->
@@ -322,10 +330,12 @@
</div>
<!-- Seed -->
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:130px;">
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:auto;">
<div style="display:flex;flex-direction:column;gap:4px;margin-top:8px;">
<div style="display:flex;gap:8px;align-items:center;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="flex:1;min-width:0;">
</div>
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:100%;">
<option value="random">Random</option>
<option value="fixed">Fixed</option>
<option value="increment">Increment</option>
@@ -334,6 +344,34 @@
</div>
</div>
<!-- Checkpoint Batch Panel -->
<div class="wfm-gen-panel" id="wfm-ckpt-batch-panel">
<div style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="wfm-ckpt-batch-enabled" style="width:14px;height:14px;cursor:pointer;accent-color:var(--wfm-primary);">
<label for="wfm-ckpt-batch-enabled" style="font-weight:600;font-size:13px;cursor:pointer;">Checkpoint Batch</label>
</div>
<div id="wfm-ckpt-batch-body" style="display:none;margin-top:8px;">
<div style="margin-bottom:6px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:3px;">Include Folders (empty = all, comma-separated)</div>
<input type="text" id="wfm-ckpt-batch-include" class="wfm-input" placeholder="e.g. sdxl, flux">
</div>
<div style="margin-bottom:6px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:3px;">Exclude Folders (comma-separated)</div>
<input type="text" id="wfm-ckpt-batch-exclude" class="wfm-input" placeholder="e.g. old, test">
</div>
<div id="wfm-ckpt-batch-info" style="font-size:11px;color:var(--wfm-text-secondary);"></div>
<div id="wfm-ckpt-batch-progress" style="margin-top:8px;display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:11px;">
<span id="wfm-ckpt-batch-current-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:75%;color:var(--wfm-text-primary);"></span>
<span id="wfm-ckpt-batch-count" style="color:var(--wfm-text-secondary);white-space:nowrap;margin-left:8px;"></span>
</div>
<div class="wfm-gen-progress-track">
<div id="wfm-ckpt-batch-bar" class="wfm-gen-progress-bar" style="background:var(--wfm-warning,#f59e0b);"></div>
</div>
</div>
</div>
</div>
<!-- Result Display -->
<div class="wfm-gen-result">
<img id="wfm-gen-result-img" style="display:none;max-width:100%;border-radius:6px;">
@@ -501,9 +539,10 @@
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter</li>
<li id="wfm-help-gen-6">Settings tab: KSampler (top) and LatentImage (bottom) in the left column</li>
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow</li>
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement</li>
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement — seed input and mode selector stacked vertically for readability</li>
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
<li id="wfm-help-gen-11">Checkpoint Batch: enable via checkbox in the right panel to auto-generate once per checkpoint model; filter by include folders (comma-separated), exclude folders, or leave blank to use all checkpoints; Stop button aborts the loop after the current generation; per-model progress bar shown in amber</li>
</ul>
</div>
@@ -554,12 +593,13 @@
<li id="wfm-help-gallery-2">3-column layout: folder tree (left) | image grid (center) | detail panel (right)</li>
<li id="wfm-help-gallery-3">Thumbnail and Table view modes; double-click any image to open fullscreen lightbox</li>
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name</li>
<li id="wfm-help-gallery-5">Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All</li>
<li id="wfm-help-gallery-6">Detail panel — Info tab: filename, size, date, tags (add/remove), memo with save</li>
<li id="wfm-help-gallery-7">Detail panel — Metadata tab: embedded ComfyUI workflow JSON viewer with one-click clipboard copy</li>
<li id="wfm-help-gallery-8">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image</li>
<li id="wfm-help-gallery-9">Supports PNG (tEXt/iTXt metadata), JPEG, WebP, GIF; PNG workflow extraction reads ComfyUI-embedded JSON</li>
<li id="wfm-help-gallery-10">Folder tree: click a folder to load its images; expand subfolders with ▶ arrow</li>
<li id="wfm-help-gallery-5">Folder tree: click to load images, expand with ▶; "+ New" creates a subfolder inside the selected folder, "Del" deletes it with all contents</li>
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All, Move To..., Delete</li>
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, date, tags (add/remove), memo; "Move To..." moves the image to another folder, "Delete" removes it permanently</li>
<li id="wfm-help-gallery-8">Detail panel — Metadata tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; one-click clipboard copy</li>
<li id="wfm-help-gallery-9">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image</li>
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the Metadata tab</li>
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF; workflow extraction reads ComfyUI prompt and workflow keys embedded in PNG metadata</li>
</ul>
</div>
@@ -568,13 +608,15 @@
<ul>
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching</li>
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)</li>
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
<li id="wfm-help-models-8">GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)</li>
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab</li>
<li id="wfm-help-models-10">Enable/Disable models: toggle visibility in ComfyUI by renaming file extension (.disabled suffix). Disable per model (⏸ button on each card) or per group (Enable All / Disable All in Group panel). Changes take effect after ComfyUI refresh.</li>
<li id="wfm-help-models-11">Multi-select &amp; bulk operations: click Select to enter selection mode, check multiple models, then use the bulk action bar to add/remove from groups or permanently delete files (model file + preview images + sidecar files such as .json/.info).</li>
</ul>
</div>
@@ -684,6 +726,8 @@
<button id="wfm-gallery-bulk-group-add" class="wfm-btn wfm-btn-sm wfm-btn-primary">Add</button>
<button id="wfm-gallery-bulk-fav" class="wfm-btn wfm-btn-sm">★ Favorite All</button>
<button id="wfm-gallery-bulk-unfav" class="wfm-btn wfm-btn-sm">☆ Unfavorite All</button>
<button id="wfm-gallery-bulk-move" class="wfm-btn wfm-btn-sm">Move To...</button>
<button id="wfm-gallery-bulk-delete" class="wfm-btn wfm-btn-sm wfm-btn-danger">Delete</button>
</div>
</div>
@@ -693,6 +737,10 @@
<div id="wfm-gallery-tree-panel" class="wfm-gallery-tree-panel">
<div class="wfm-gallery-tree-header">
<span class="wfm-gallery-tree-title">Folders</span>
<div class="wfm-gallery-tree-actions">
<button id="wfm-gallery-folder-create-btn" class="wfm-btn wfm-btn-sm" title="Create subfolder in selected folder">+ New</button>
<button id="wfm-gallery-folder-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected folder" disabled>Del</button>
</div>
</div>
<div id="wfm-gallery-tree" class="wfm-gallery-tree">
<p class="wfm-placeholder" id="wfm-gallery-tree-placeholder">Loading...</p>
@@ -745,6 +793,12 @@
<textarea id="wfm-gallery-memo" class="wfm-gallery-memo" placeholder="Write a memo..." rows="3"></textarea>
<button id="wfm-gallery-memo-save-btn" class="wfm-btn wfm-btn-sm" style="margin-top:4px;width:100%;">Save Memo</button>
</div>
<hr class="wfm-group-divider">
<!-- ファイル操作 -->
<div class="wfm-gallery-info-section wfm-gallery-file-actions">
<button id="wfm-gallery-img-move-btn" class="wfm-btn wfm-btn-sm" disabled>Move To...</button>
<button id="wfm-gallery-img-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
</div>
</div>
<!-- Metadata タブ -->
<div id="wfm-gallery-detail-meta" class="wfm-gallery-detail-tab-content wfm-gallery-meta-tab" style="display:none;">
+21 -18
View File
@@ -1324,10 +1324,21 @@ const renderModelFavorites = async (container) => {
const renderModelGroups = async (container) => {
container.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
const groups = state.modelGroups;
const groupNames = Object.keys(groups);
// allGroups is { type: { groupName: [modelName, ...] } }
const allGroups = state.modelGroups;
if (groupNames.length === 0) {
// Flatten into [{ modelType, groupName, members }]
const flatGroups = [];
for (const [modelType, groups] of Object.entries(allGroups)) {
if (typeof groups !== "object" || Array.isArray(groups)) continue;
for (const [groupName, members] of Object.entries(groups)) {
if (Array.isArray(members) && members.length > 0) {
flatGroups.push({ modelType, groupName, members });
}
}
}
if (flatGroups.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">No groups found.</div>`;
return;
}
@@ -1347,18 +1358,18 @@ const renderModelGroups = async (container) => {
container.innerHTML = "";
let hasAny = false;
for (const groupName of groupNames) {
const members = (groups[groupName] || []).filter(name => matchesModelSearch(name));
if (state.searchText && members.length === 0) continue;
if ((groups[groupName] || []).length === 0) continue;
for (const { modelType, groupName, members } of flatGroups) {
const filtered = members.filter(name => matchesModelSearch(name));
if (state.searchText && filtered.length === 0) continue;
hasAny = true;
const section = document.createElement("div");
section.className = "wfm-nlp-group-section";
const typeLabel = MODEL_TYPE_LABELS[modelType] || modelType;
const header = document.createElement("div");
header.className = "wfm-nlp-group-header collapsed";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${members.length}</span>`;
header.innerHTML = `<span class="wfm-nlp-model-type-badge">[${esc(typeLabel)}]</span> <span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${filtered.length}</span>`;
header.addEventListener("click", () => {
const listEl = section.querySelector(".wfm-nlp-group-list");
listEl.style.display = listEl.style.display === "none" ? "block" : "none";
@@ -1369,17 +1380,9 @@ const renderModelGroups = async (container) => {
const listEl = document.createElement("div");
listEl.className = "wfm-nlp-group-list";
listEl.style.display = "none";
for (const name of members) {
const type = typeOf[name] || "checkpoint";
for (const name of filtered) {
const type = typeOf[name] || modelType;
const item = createModelItem(name, type);
const typeLabel = MODEL_TYPE_LABELS[type] || type;
const typeBadgeEl = item.querySelector(".wfm-nlp-item-body");
if (typeBadgeEl) {
const tb = document.createElement("span");
tb.className = "wfm-nlp-model-type-badge";
tb.textContent = typeLabel;
typeBadgeEl.prepend(tb);
}
listEl.appendChild(item);
}
section.appendChild(listEl);