Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918ab54004 | ||
|
|
f25c042c39 | ||
|
|
1c9ec7ac19 |
@@ -1,5 +1,166 @@
|
||||
# DEVLOG - ComfyUI-Workflow-Studio
|
||||
|
||||
## 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 データ保存先変更・エクスポート/インポート機能追加
|
||||
|
||||
### 概要
|
||||
|
||||
- プラグインデータの保存先を `custom_nodes/ComfyUI-Workflow-Studio/data/` から `ComfyUI/user/default/Workflow-Studio/` に変更
|
||||
- `user/default/` が存在しない環境(Portable版・Dockerなど)では従来の `data/` にフォールバック
|
||||
- 設定タブに「データ管理」セクションを追加(エクスポート/インポート機能)
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `py/config.py` — DATA_DIR 保存先変更
|
||||
|
||||
**`DATA_DIR` の解決ロジックを変更:**
|
||||
|
||||
- 変更前: `DATA_DIR = PLUGIN_DIR / "data"` (固定)
|
||||
- 変更後: `user/default/` が存在すれば `DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"`、存在しなければ `PLUGIN_DIR / "data"` にフォールバック
|
||||
- `_COMFYUI_ROOT` の定義位置を `DATA_DIR` の前に移動(`_COMFYUI_WORKFLOWS` と共用)
|
||||
|
||||
#### `py/routes/settings_routes.py` — エクスポート/インポートAPI追加
|
||||
|
||||
**新エンドポイント:**
|
||||
|
||||
- `GET /api/wfm/settings/export` — `settings.json`, `metadata.json`, `node_metadata.json`, `node_sets.json`, `prompts.json`, `model_metadata.json`, `gallery_metadata.json` の7ファイルを1つのJSONバンドルとして返す(`Content-Disposition: attachment` でDL)
|
||||
- `POST /api/wfm/settings/import` — JSONバンドルを受け取り、含まれるファイルを個別に書き戻す。存在しないキーはスキップ
|
||||
|
||||
**新定数:**
|
||||
|
||||
- `_DATA_FILES` — エクスポート/インポート対象ファイル名のリスト
|
||||
|
||||
#### `static/js/settings-tab.js` — データ管理UIセクション追加
|
||||
|
||||
**Eagle連携セクションの直下に `<!-- Data Management -->` セクションを追加:**
|
||||
|
||||
- エクスポートボタン: クリックで `wfm-data-export.json` をダウンロード
|
||||
- インポートボタン: `<label>` でファイル選択ダイアログを開く(`.json` 限定)
|
||||
- ステータス表示: 成功・失敗をインラインで表示(成功: green / 失敗: red)
|
||||
- 同一ファイルの再インポートができるよう、インポート後にファイル入力をリセット
|
||||
|
||||
#### `static/js/i18n.js` — 新キー追加(3言語)
|
||||
|
||||
**追加キー(英語・日本語・中国語):**
|
||||
|
||||
- `dataManagement`, `dataManagementHint`, `exportData`, `importData`
|
||||
- `exportSuccess`, `exportError`, `importSuccess`, `importError`
|
||||
|
||||
#### `templates/index.html` / `static/js/app.js` / `static/js/i18n.js` — ヘルプタブ更新
|
||||
|
||||
- `wfm-help-settings-8` をデータ管理の説明に変更
|
||||
- `wfm-help-settings-9`(アコーディオン説明)を追加(HTML・app.jsマッピング・i18n 3言語)
|
||||
|
||||
### 移行手順(既存ユーザー向け)
|
||||
|
||||
1. 設定タブ →「データ管理」→「エクスポート」で `wfm-data-export.json` を保存
|
||||
2. ComfyUIを再起動(新パスで空の状態で起動)
|
||||
3. 設定タブ →「データ管理」→「インポート」でエクスポートしたファイルを選択
|
||||
4. ComfyUIを再起動して反映
|
||||
|
||||
または手動で `custom_nodes/ComfyUI-Workflow-Studio/data/` の内容を `ComfyUI/user/default/Workflow-Studio/` にコピーしてから再起動。
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-06: v0.2.6 UI改善・バッジ保存修正・フォルダフィルター追加
|
||||
|
||||
### 概要
|
||||
|
||||
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
|
||||
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -82,6 +82,7 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Eagle connection** — set Eagle API endpoint for auto-save
|
||||
- **Ollama connection** — configure Ollama server URL
|
||||
- **Default workflow** — set a workflow to auto-load on startup
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
|
||||
- **Language** — English / Japanese / Chinese
|
||||
|
||||
### Gallery Tab (v0.2.7)
|
||||
@@ -113,9 +114,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
|
||||
@@ -207,6 +210,19 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### 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
|
||||
|
||||
### v0.2.7
|
||||
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
|
||||
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
|
||||
@@ -350,7 +366,7 @@ ComfyUI-Workflow-Studio/
|
||||
├── web/comfyui/
|
||||
│ ├── top_menu_extension.js # ComfyUI menu bar integration
|
||||
│ └── node_sets_menu.js # Workflow Studio Library side panel
|
||||
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
|
||||
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
+12
-4
@@ -10,13 +10,21 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
|
||||
# Directory paths
|
||||
TEMPLATES_DIR = PLUGIN_DIR / "templates"
|
||||
STATIC_DIR = PLUGIN_DIR / "static"
|
||||
DATA_DIR = PLUGIN_DIR / "data"
|
||||
|
||||
# Default workflows directory: ComfyUI's user/default/workflows
|
||||
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
|
||||
# ComfyUI root is custom_nodes/../../ = two levels up
|
||||
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
|
||||
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
|
||||
|
||||
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
|
||||
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
|
||||
if _COMFYUI_USER_DEFAULT.is_dir():
|
||||
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
|
||||
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
|
||||
else:
|
||||
DATA_DIR = PLUGIN_DIR / "data"
|
||||
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
|
||||
|
||||
# Default workflows directory: ComfyUI's user/default/workflows
|
||||
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
|
||||
|
||||
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
|
||||
if _COMFYUI_WORKFLOWS.is_dir():
|
||||
|
||||
+148
-5
@@ -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)
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
"""Settings API routes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = SettingsService()
|
||||
|
||||
# Data files included in export/import
|
||||
_DATA_FILES = [
|
||||
"settings.json",
|
||||
"metadata.json",
|
||||
"node_metadata.json",
|
||||
"node_sets.json",
|
||||
"prompts.json",
|
||||
"model_metadata.json",
|
||||
"gallery_metadata.json",
|
||||
]
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register settings API routes."""
|
||||
@@ -22,6 +34,8 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
|
||||
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -151,3 +165,72 @@ async def handle_set_output_dir(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error setting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_data_file(filename: str):
|
||||
"""Load a single data file, return empty dict/list if missing."""
|
||||
path = DATA_DIR / filename
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_export_bundle() -> dict:
|
||||
"""Collect all data files into a single export bundle."""
|
||||
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
|
||||
for filename in _DATA_FILES:
|
||||
data = _load_data_file(filename)
|
||||
if data is not None:
|
||||
bundle[filename] = data
|
||||
return bundle
|
||||
|
||||
|
||||
async def handle_export(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
|
||||
try:
|
||||
bundle = await asyncio.to_thread(_build_export_bundle)
|
||||
body = json.dumps(bundle, ensure_ascii=False, indent=2)
|
||||
return web.Response(
|
||||
body=body.encode("utf-8"),
|
||||
content_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _apply_import_bundle(bundle: dict) -> dict:
|
||||
"""Write imported bundle back to individual data files. Returns summary."""
|
||||
summary = {"imported": [], "skipped": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for filename in _DATA_FILES:
|
||||
if filename not in bundle:
|
||||
summary["skipped"].append(filename)
|
||||
continue
|
||||
try:
|
||||
path = DATA_DIR / filename
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
|
||||
summary["imported"].append(filename)
|
||||
except Exception as e:
|
||||
logger.error("Import failed for %s: %s", filename, e)
|
||||
summary["skipped"].append(filename)
|
||||
return summary
|
||||
|
||||
|
||||
async def handle_import(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
|
||||
try:
|
||||
bundle = await request.json()
|
||||
if not isinstance(bundle, dict):
|
||||
return web.json_response({"error": "Invalid bundle format"}, status=400)
|
||||
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
|
||||
return web.json_response({"status": "ok", **summary})
|
||||
except Exception as e:
|
||||
logger.error("Error importing data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+3
-1
@@ -273,6 +273,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9",
|
||||
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
|
||||
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
|
||||
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
|
||||
@@ -280,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",
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ async function loadFolderTree() {
|
||||
}
|
||||
tree.innerHTML = "";
|
||||
renderTreeNode(data, tree, 0, true);
|
||||
// root フォルダを自動選択(フォルダ未選択の場合のみ)
|
||||
if (!state.currentFolder) {
|
||||
const firstLabel = tree.querySelector(".wfm-gallery-tree-label");
|
||||
if (firstLabel) firstLabel.click();
|
||||
}
|
||||
} catch (e) {
|
||||
tree.innerHTML = `<p class="wfm-placeholder">Error: ${e.message}</p>`;
|
||||
}
|
||||
|
||||
+105
-6
@@ -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)",
|
||||
@@ -340,7 +362,8 @@ const LANGUAGES = {
|
||||
helpSettings5: "Ollama URL, default model, and connection test",
|
||||
helpSettings6: "Default workflow setting",
|
||||
helpSettings7: "Eagle integration URL and auto-save toggle",
|
||||
helpSettings8: "All sections are collapsible accordion panels",
|
||||
helpSettings8: "Data Management: export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import to restore. After importing, restart ComfyUI.",
|
||||
helpSettings9: "All sections are collapsible accordion panels",
|
||||
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
|
||||
helpNodes2: "Card and Table view modes with pagination",
|
||||
helpNodes3: "Favorite nodes and add custom tags for organization",
|
||||
@@ -349,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)",
|
||||
@@ -433,6 +458,14 @@ const LANGUAGES = {
|
||||
eagleIntegration: "Eagle Integration (Optional)",
|
||||
eagleUrl: "Eagle URL",
|
||||
eagleAutoSave: "Auto-save generated images to Eagle",
|
||||
dataManagement: "Data Management",
|
||||
dataManagementHint: "Export all plugin data (settings, metadata, prompts, etc.) to a JSON file, or import a previously exported file to restore data. After importing, restart ComfyUI to apply changes.",
|
||||
exportData: "Export",
|
||||
importData: "Import",
|
||||
exportSuccess: "Export complete",
|
||||
exportError: "Export failed",
|
||||
importSuccess: "Import complete",
|
||||
importError: "Import failed",
|
||||
saveSettings: "Save Settings",
|
||||
settingsSaved: "Settings saved",
|
||||
ollamaSaved: "Ollama settings saved",
|
||||
@@ -763,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: "ハッシュ計算中...(大きいファイルは時間がかかります)",
|
||||
@@ -830,7 +885,8 @@ const LANGUAGES = {
|
||||
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
|
||||
helpSettings6: "デフォルトワークフローの設定・解除",
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
|
||||
helpSettings9: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
|
||||
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
|
||||
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
|
||||
@@ -839,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カラムレイアウト: フォルダツリー (左) | 画像グリッド (中央) | 詳細パネル (右)",
|
||||
@@ -923,6 +981,14 @@ const LANGUAGES = {
|
||||
eagleIntegration: "Eagle 連携 (オプション)",
|
||||
eagleUrl: "Eagle URL",
|
||||
eagleAutoSave: "生成画像をEagleに自動保存",
|
||||
dataManagement: "データ管理",
|
||||
dataManagementHint: "設定・メタデータ・プロンプトなど全データをJSONファイルにエクスポート、またはインポートして復元できます。インポート後はComfyUIを再起動してください。",
|
||||
exportData: "エクスポート",
|
||||
importData: "インポート",
|
||||
exportSuccess: "エクスポート完了",
|
||||
exportError: "エクスポート失敗",
|
||||
importSuccess: "インポート完了",
|
||||
importError: "インポート失敗",
|
||||
saveSettings: "設定を保存",
|
||||
settingsSaved: "設定を保存しました",
|
||||
ollamaSaved: "Ollama設定を保存しました",
|
||||
@@ -1249,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: "计算哈希中...(大文件可能需要较长时间)",
|
||||
@@ -1316,7 +1404,8 @@ const LANGUAGES = {
|
||||
helpSettings5: "Ollama URL、默认模型、连接测试",
|
||||
helpSettings6: "默认工作流设置/取消",
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "各部分可折叠展开",
|
||||
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
|
||||
helpSettings9: "各部分可折叠展开",
|
||||
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
|
||||
helpNodes2: "卡片和表格视图模式切换(带分页)",
|
||||
helpNodes3: "收藏节点,添加自定义标签进行整理",
|
||||
@@ -1325,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: "三列布局:文件夹树(左) | 图像网格(中) | 详情面板(右)",
|
||||
@@ -1409,6 +1500,14 @@ const LANGUAGES = {
|
||||
eagleIntegration: "Eagle 集成 (可选)",
|
||||
eagleUrl: "Eagle URL",
|
||||
eagleAutoSave: "自动保存生成图片到Eagle",
|
||||
dataManagement: "数据管理",
|
||||
dataManagementHint: "将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入之前导出的文件以恢复数据。导入后请重启ComfyUI以使更改生效。",
|
||||
exportData: "导出",
|
||||
importData: "导入",
|
||||
exportSuccess: "导出完成",
|
||||
exportError: "导出失败",
|
||||
importSuccess: "导入完成",
|
||||
importError: "导入失败",
|
||||
saveSettings: "保存设置",
|
||||
settingsSaved: "设置已保存",
|
||||
ollamaSaved: "Ollama设置已保存",
|
||||
|
||||
+358
-24
@@ -24,11 +24,15 @@ const state = {
|
||||
modelMetadata: {},
|
||||
modelGroups: {},
|
||||
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 +204,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">×</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 +459,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];
|
||||
@@ -451,7 +680,7 @@ function renderDirFilter() {
|
||||
|
||||
async function fetchModelGroups() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/groups");
|
||||
const res = await fetch(`/api/wfm/models/groups?type=${encodeURIComponent(state.activeModelType)}`);
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
@@ -461,9 +690,10 @@ async function saveModelGroups(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 +733,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 +741,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 +749,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 +767,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 +797,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 +805,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 +853,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 ? "★" : "☆";
|
||||
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 +872,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;">★</th>
|
||||
<th style="width:40px;"></th>
|
||||
<th>${t("modelsFileName")}</th>
|
||||
@@ -613,23 +887,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 +1275,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 +1355,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 +1499,11 @@ 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();
|
||||
renderTagFilter();
|
||||
renderDirFilter();
|
||||
renderGroupFilter();
|
||||
renderModelGrid();
|
||||
return;
|
||||
}
|
||||
@@ -1209,12 +1514,23 @@ 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;
|
||||
|
||||
// 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 +1628,12 @@ async function batchFetchCivitai() {
|
||||
}
|
||||
|
||||
async function loadMetadataAndModels() {
|
||||
const [metadata, groups, civitaiCache] = await Promise.all([
|
||||
fetchModelMetadata(), fetchModelGroups(), fetchCivitaiCache()
|
||||
const [metadata, civitaiCache] = await Promise.all([
|
||||
fetchModelMetadata(), fetchCivitaiCache()
|
||||
]);
|
||||
state.modelMetadata = metadata;
|
||||
state.modelGroups = groups;
|
||||
state.civitaiCache = civitaiCache;
|
||||
renderGroupFilter();
|
||||
// groups are loaded per-type inside loadModelsForCurrentType
|
||||
await loadModelsForCurrentType();
|
||||
}
|
||||
|
||||
@@ -1335,8 +1650,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");
|
||||
@@ -1380,6 +1704,13 @@ export function initModelsTab() {
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// Status filter
|
||||
document.getElementById("wfm-models-status-filter")?.addEventListener("change", (e) => {
|
||||
state.statusFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// Favorites filter
|
||||
const favBtn = document.getElementById("wfm-models-fav-btn");
|
||||
if (favBtn) {
|
||||
@@ -1418,6 +1749,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();
|
||||
|
||||
@@ -606,6 +606,20 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Data Management -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
|
||||
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
|
||||
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
|
||||
${t("importData")}
|
||||
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
|
||||
</details>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
|
||||
|
||||
@@ -1036,4 +1050,51 @@ export async function initSettingsTab() {
|
||||
if (savedUrl) {
|
||||
comfyUI.updateUrl(savedUrl);
|
||||
}
|
||||
|
||||
// --- Data Export ---
|
||||
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/export");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "wfm-data-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
statusEl.textContent = t("exportSuccess");
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("exportError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Data Import ---
|
||||
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
|
||||
const statusEl = document.getElementById("wfm-settings-data-status");
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const bundle = JSON.parse(text);
|
||||
const res = await fetch("/api/wfm/settings/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || res.statusText);
|
||||
const imported = result.imported?.join(", ") || "-";
|
||||
statusEl.textContent = t("importSuccess") + ": " + imported;
|
||||
statusEl.style.color = "var(--wfm-success, #4caf50)";
|
||||
} catch (e) {
|
||||
statusEl.textContent = t("importError") + ": " + e.message;
|
||||
statusEl.style.color = "var(--wfm-error, #f44336)";
|
||||
}
|
||||
// Reset file input so same file can be re-selected
|
||||
e.target.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
+13
-2
@@ -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">⊞</button>
|
||||
<button class="wfm-view-btn" data-models-view="card" title="Card">☰</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">⚙ 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 -->
|
||||
@@ -530,7 +538,8 @@
|
||||
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
|
||||
<li id="wfm-help-settings-6">Default workflow setting</li>
|
||||
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
|
||||
<li id="wfm-help-settings-8">All sections are collapsible accordion panels</li>
|
||||
<li id="wfm-help-settings-8">Data Management: export all plugin data to JSON / import to restore</li>
|
||||
<li id="wfm-help-settings-9">All sections are collapsible accordion panels</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -567,13 +576,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 & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user