Compare commits
@@ -1,5 +1,608 @@
|
||||
# DEVLOG - ComfyUI-Workflow-Studio
|
||||
|
||||
## 2026-04-28: v0.3.3 Promptタブ — ワイルドカード支援パネル・Impact Packシンボリックリンク連携
|
||||
|
||||
### 概要
|
||||
|
||||
- Promptタブのレイアウトを3カラム構成に変更
|
||||
- 中央ペイン(Col2): Presets / Preset Manager をタブ切り替え化
|
||||
- 右ペイン(Col3): ワイルドカード支援パネルを新設
|
||||
- ワイルドカードファイル管理(txt/yaml)の CRUD API を追加
|
||||
- Settingsタブに Wildcard Integration セクションを追加(Impact Pack ジャンクション/シンボリックリンク連携)
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `py/config.py`
|
||||
|
||||
- `COMFYUI_ROOT = _COMFYUI_ROOT` を追加(外部モジュールから参照可能に)
|
||||
- `WILDCARD_DIR = DATA_DIR / "wildcard"` を追加
|
||||
|
||||
#### `py/services/wildcard_service.py` — 新規作成
|
||||
|
||||
- `WildcardService(wildcard_dir)` クラス
|
||||
- `list_wildcards()` — txt/yaml/yml ファイル一覧を返す
|
||||
- `get_content(filename)` — ファイル内容を取得
|
||||
- `save_file(filename, content)` — ファイルを保存(新規/上書き)
|
||||
- `delete_file(filename)` — ファイルを削除
|
||||
- `_safe_path(filename)` — `..` / スラッシュ / 拡張子チェックによるパストラバーサル防止
|
||||
- Impact Pack 連携メソッド
|
||||
- `find_impact_pack_wildcards(comfyui_root)` — `custom_nodes/` 以下で "impact-pack" ディレクトリを検索
|
||||
- `get_link_status(comfyui_root)` — インストール状況・リンク状態・パスを返す dict
|
||||
- `create_link(comfyui_root)` — 既存 WFS ファイルを Impact Pack dir へ移行後、ジャンクション/シンボリックリンクを作成
|
||||
- `remove_link()` — ジャンクション/シンボリックリンクを削除して通常ディレクトリに復元
|
||||
- プラットフォームヘルパー
|
||||
- `_is_junction(path)` — Windows の `GetFileAttributesW` + `FILE_ATTRIBUTE_REPARSE_POINT` でジャンクション検出(`os.path.islink()` では検出不可)
|
||||
- `_create_junction_or_symlink(link, target)` — Windows は `mklink /J`、他 OS は `os.symlink`
|
||||
- `_remove_junction_or_symlink(path)` — Windows は `rmdir`(ジャンクションを削除、中身に影響なし)、他は `os.unlink`
|
||||
- コンストラクタ: `_is_junction()` で事前確認し、ジャンクション/シンボリックリンクの場合は mkdir をスキップ
|
||||
|
||||
#### `py/routes/wildcard_routes.py` — 新規作成
|
||||
|
||||
- `GET /api/wfm/wildcards` — ファイル一覧
|
||||
- `GET /api/wfm/wildcards/content?filename=` — ファイル内容取得
|
||||
- `POST /api/wfm/wildcards/save` — ファイル保存
|
||||
- `POST /api/wfm/wildcards/delete` — ファイル削除
|
||||
- `GET /api/wfm/wildcards/link-status` — Impact Pack リンク状態取得
|
||||
- `POST /api/wfm/wildcards/create-link` — ジャンクション/シンボリックリンク作成(移行ファイルリストを返す)
|
||||
- `POST /api/wfm/wildcards/remove-link` — リンク解除
|
||||
|
||||
#### `py/wfm.py`
|
||||
|
||||
- `wildcard_routes.setup_routes(app)` を追加
|
||||
|
||||
#### `templates/index.html`
|
||||
|
||||
- Promptタブ Col2 をタブ切り替え構造(`.wfm-prompt-center-tabnav` + `.wfm-prompt-center-pane`)に変更
|
||||
- Promptタブ Col3 にワイルドカード支援パネル(`.wfm-wc-panel`)を新設
|
||||
- ツールバー: `{|}`, `|`, `__`, `:`, `;`, `$`, `<lora:>`, `[]`, `{n$|}`, ファイルピッカーボタン
|
||||
- プロンプトテキストエリア(`.wfm-wc-prompt-ta`)
|
||||
- ファイル管理セクション(ファイル一覧 + インラインエディタ)
|
||||
- Helpタブ: `wfm-help-prompt-8`、`wfm-help-prompt-9`、`wfm-help-settings-10` を追加
|
||||
|
||||
#### `static/js/prompt-tab.js`
|
||||
|
||||
- ワイルドカード API ヘルパー: `wcFetchFiles()`、`wcFetchContent()`、`wcSaveFile()`、`wcDeleteFile()`
|
||||
- `wcInsertAtCursor(textarea, open, close)` — 選択テキストをラップ、または `open+close` を挿入してカーソルを `open` の末尾に移動
|
||||
- `wcRenderFileList()`、`wcOpenEditor()`、`wcCloseEditor()`、`wcRefreshFiles()`、`wcUpdateFilePicker()`
|
||||
- `initPromptTab()` に追加:
|
||||
- 中央ペインのタブ切り替えハンドラ
|
||||
- ワイルドカードツールバーボタン(`data-wc-open` / `data-wc-close` 属性で制御)
|
||||
- Loraボタン: `<lora::1:LBW=;>` を挿入してカーソル位置調整
|
||||
- `{n$|}` ボタン: `prompt()` で n を入力し `{n$|}` を挿入
|
||||
- ファイルピッカー(ポップアップ型、クリックで `__filename__` 挿入)
|
||||
- ファイルマネージャのイベントハンドラ(新規作成・保存・削除)
|
||||
|
||||
#### `static/js/settings-tab.js`
|
||||
|
||||
- Data Management セクションの直後に **Wildcard Integration** `<details>` セクションを追加
|
||||
- セクション展開時に `GET /api/wfm/wildcards/link-status` を呼び出して動的 UI をレンダリング
|
||||
- Impact Pack 未インストール: インストール誘導メッセージ + GitHub リンク
|
||||
- インストール済み・リンクなし: Impact Pack パス表示 + 「リンクを作成」ボタン(移行ファイル名を表示)
|
||||
- リンク済み: リンク先パス表示 + 「リンクを解除」ボタン
|
||||
|
||||
#### `static/js/app.js`
|
||||
|
||||
- `wfm-help-prompt-8`、`wfm-help-prompt-9`、`wfm-help-settings-10` の ID マッピングを追加
|
||||
|
||||
#### `static/js/i18n.js`
|
||||
|
||||
- `helpPrompt8`、`helpPrompt9`、`helpSettings10` を EN/JA/ZH に追加
|
||||
|
||||
#### `static/css/main.css`
|
||||
|
||||
- `.wfm-prompt-center-tabnav`、`.wfm-prompt-center-tab`、`.wfm-prompt-center-pane` — 中央ペインタブ UI
|
||||
- `.wfm-wc-panel`、`.wfm-wc-toolbar`、`.wfm-wc-btn`、`.wfm-wc-prompt-ta` — ワイルドカードパネル
|
||||
- `.wfm-wc-file-picker-wrap`、`.wfm-wc-file-picker`、`.wfm-wc-picker-item` — ファイルピッカー
|
||||
- `.wfm-wc-files-section`、`.wfm-wc-file-list`、`.wfm-wc-file-item`、`.wfm-wc-editor` — ファイルマネージャ
|
||||
|
||||
---
|
||||
|
||||
## 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 データ保存先変更・エクスポート/インポート機能追加
|
||||
|
||||
### 概要
|
||||
|
||||
- プラグインデータの保存先を `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改善・バッジ保存修正・フォルダフィルター追加
|
||||
|
||||
### 概要
|
||||
- ワークフロー/モデル/ノードタブのサイドパネルを常時表示固定に変更
|
||||
- グリッドをページネーションからスクロール全件表示に変更
|
||||
- ワークフロータブのバッジが再起動後に消える問題を修正
|
||||
- バッジフィルターボタンをパレット定義バッジで統一(ワークフロータブ)
|
||||
- モデルタブにフォルダ(サブディレクトリ)フィルターを追加
|
||||
- モデルカードビューから拡張子・フォルダバッジを削除(詳細パネルで確認可能)
|
||||
- サムネイルカードサイズを 178×200px に調整、グリッドレイアウトを修正
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `py/services/workflow_service.py` — バッジ保存修正
|
||||
|
||||
**`save_metadata` のキーリストに `badges` を追加:**
|
||||
- 変更前: `("tags", "memo", "summary", "modelTypesOverride", "favorite")`
|
||||
- 変更後: `("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges")`
|
||||
|
||||
**`list_workflows` のレスポンスに `badges` フィールドを追加:**
|
||||
- `metadata` dict に `"badges": meta.get("badges", [])` を追加
|
||||
- これにより再起動後もバッジが保持される
|
||||
|
||||
#### `static/js/workflow-tab.js` — バッジフィルター・サイドパネル・スクロール
|
||||
|
||||
**バッジフィルターをパレット定義バッジで表示:**
|
||||
- `renderModelFilters()`: `getAllBadges()`(ワークフローに付いているバッジのみ)→ `getBadgePalette()`(パレット全バッジ)に変更
|
||||
- フィルターボタンにバッジカラーを適用
|
||||
|
||||
**サイドパネル常時表示化:**
|
||||
- `showSidePanel()`: `panel.style.display = "flex"` を削除
|
||||
- `closeSidePanel()`: `panel.style.display = "none"` を削除、タイトルをクリアするのみ
|
||||
- ×ボタンのイベントリスナー削除
|
||||
|
||||
**ページネーション廃止・スクロール全件表示:**
|
||||
- `WF_PER_PAGE` 定数削除
|
||||
- `updateWfPagination()` 関数削除
|
||||
- `renderGrid()`: ページスライス処理を削除、全件描画に変更
|
||||
|
||||
#### `static/js/models-tab.js` — サイドパネル・スクロール・フォルダフィルター・カードビュー
|
||||
|
||||
**サイドパネル常時表示化:**
|
||||
- `showSidePanel()`: `panel.style.display = "flex"` を削除
|
||||
- `closeSidePanel()`: `panel.style.display = "none"` を削除
|
||||
|
||||
**ページネーション廃止・スクロール全件表示:**
|
||||
- `MODELS_PER_PAGE` 定数削除、`updatePagination()` 関数削除
|
||||
- `renderThumbView()` / `renderCardView()` / `renderTableView()`: `totalPages` 引数削除、全件描画に変更
|
||||
|
||||
**フォルダフィルター追加:**
|
||||
- `state.dirFilter: ""` 追加
|
||||
- `filterModels()` に `dirFilter` による絞り込みを追加
|
||||
- `renderDirFilter()` 関数追加(現在のモデルタイプのサブディレクトリ一覧を `<select>` に反映)
|
||||
- モデルロード時・モデルタイプ切替時に呼び出し
|
||||
- `wfm-models-dir-filter` change イベントリスナー追加
|
||||
|
||||
**モデルカードビューからバッジ削除:**
|
||||
- `renderCardView()`: `dir` バッジ・`ext` バッジを削除。ユーザーバッジ・タグのみ表示
|
||||
|
||||
#### `static/js/nodes-tab.js` — サイドパネル・スクロール
|
||||
|
||||
**サイドパネル常時表示化:**
|
||||
- `showNodeSidePanel()`: `panel.style.display = "flex"` を削除
|
||||
- `closeNodeSidePanel()`: `panel.style.display = "none"` を削除
|
||||
|
||||
**ページネーション廃止・スクロール全件表示:**
|
||||
- `NODES_PER_PAGE` / `renderPagination()` / `clearPagination()` / `scrollGridToTop()` 削除
|
||||
- `renderNodeGrid()`: 全件描画に変更
|
||||
|
||||
#### `templates/index.html` — サイドパネル・ページネーション・フォルダフィルター
|
||||
|
||||
- 3タブ(ワークフロー/ノード/モデル)のサイドパネルから `style="display:none"` と×ボタンを削除
|
||||
- ページネーション用 `<div>` を3箇所から削除
|
||||
- モデルタブのツールバーに `<select id="wfm-models-dir-filter">` を追加(TagとGroupの間)
|
||||
|
||||
#### `static/css/main.css` — グリッド・カードサイズ・サイドパネル
|
||||
|
||||
**グリッド・カードサイズ変更:**
|
||||
- グリッド列幅: `161px` → `178px`
|
||||
- カード幅: `161px` → `178px`
|
||||
- サムネイル: `161×162px` → `178×200px`
|
||||
- `grid-auto-rows: max-content` / `align-items: start` / `justify-items: start` 追加(行高さ圧縮防止)
|
||||
|
||||
**カードビュー:**
|
||||
- グリッド列幅: `minmax(180px, 1fr)` → `repeat(auto-fill, 220px)` 固定幅に変更(横伸び防止)
|
||||
- カード: `flex-direction: row` / `min-height: 56px` / `width: 220px` 固定
|
||||
|
||||
**サイドパネル:**
|
||||
- `.wfm-side-panel-close` スタイル削除
|
||||
- `.wfm-side-panel-empty` クラス追加(選択前の空状態表示用)
|
||||
|
||||
#### `static/js/i18n.js` — フォルダフィルター翻訳追加
|
||||
|
||||
- `modelsAllDirs`: 英語 `"All Folders"` / 日本語 `"すべてのフォルダ"` / 中国語 `"所有文件夹"` を追加
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-04: v0.2.5 追加修正
|
||||
|
||||
### 概要
|
||||
- サイドパネルModels→By TypeでTextEncoderが表示されない問題を修正
|
||||
- サイドパネルのWorkflowsタブでも `.index.json` を非表示に
|
||||
- 未使用の `buildDropdown` 関数を削除
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `web/comfyui/node_sets_menu.js`
|
||||
|
||||
**TextEncoder取得ロジック修正:**
|
||||
- 変更前: `/api/wfm/models?type=textencoder`(存在しないエンドポイント)
|
||||
- 変更後: `DualCLIPLoader` → `CLIPLoader` の順に `/object_info/{cls}` を試して `clip_name1` を取得(`comfyui-client.js` の `fetchTextEncoders()` と同じロジック)
|
||||
|
||||
**`.index.json` 非表示:**
|
||||
- `loadWfData()`: `state.wfList = workflows.filter(w => w.filename !== ".index.json")` でサイドパネルのWorkflowsタブからも除外
|
||||
|
||||
**未使用関数削除:**
|
||||
- `buildDropdown()` 関数を削除(Category/Packageドロップダウンは個別実装に置き換え済みだったため不要)
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-03: v0.2.5 サイドパネル Category/Package サブタブ・テーマ設定・Workflow修正
|
||||
|
||||
### 概要
|
||||
- サイドパネル(Workflow Studio Library)のNodesタブに📂 Category / 🧩 Packageサブタブを追加
|
||||
- サイドパネルのヘッダーに⚙テーマ設定ボタンを追加(背景・文字・ボーダー色カスタマイズ)
|
||||
- ワークフロータブで `.index.json` を非表示に
|
||||
|
||||
### 変更内容
|
||||
|
||||
#### `web/comfyui/node_sets_menu.js` — Nodesサブタブ追加・テーマ設定
|
||||
|
||||
**Nodesタブのrow2サブタブ構成変更:**
|
||||
- 変更前: row2 = `☰ Sets`
|
||||
- 変更後: row2 = `☰ Sets` / `📂 Category` / `🧩 Package`
|
||||
|
||||
**state追加:**
|
||||
- `activeNodeCategory: ""` — Categoryサブタブで選択中のカテゴリ値
|
||||
- `activeNodePackage: ""` — Packageサブタブで選択中のパッケージ値
|
||||
- `objectInfo: {}` — `/object_info` APIから取得した全ノード情報(パッケージ判定に使用)
|
||||
|
||||
**`loadData()` 変更:**
|
||||
- `fetchObjectInfo()` を追加して `/object_info` を並行取得
|
||||
- `state.objectInfo` に保存
|
||||
|
||||
**新規関数:**
|
||||
- `fetchObjectInfo()` — `GET /object_info` で全ノード情報を取得
|
||||
- `extractPackageName(pythonModule)` — `python_module` からパッケージ名を抽出
|
||||
- `getNodeCategory(nodeType)` — `state.objectInfo` 優先でカテゴリを取得(LiteGraphフォールバック)
|
||||
- `getNodePackage(nodeType)` — `state.objectInfo[nodeType].python_module` からパッケージ名を取得
|
||||
- `renderNodesByCategory(container)` — カテゴリドロップダウンをcontent上部に挿入してリスト表示
|
||||
- `renderNodesByCategoryList(container)` — 選択カテゴリ+検索テキストでフィルタしてカード表示
|
||||
- `renderNodesByPackage(container)` — パッケージドロップダウンをcontent上部に挿入してリスト表示
|
||||
- `renderNodesByPackageList(container)` — 選択パッケージ+検索テキストでフィルタしてカード表示
|
||||
|
||||
**ドロップダウンのDOM挿入方式:**
|
||||
- `.wfm-nlp-filter-row` クラスのdivを `.wfm-nlp-content` の直前に `insertBefore` で挿入
|
||||
- `renderContent()` 冒頭で `panelEl.querySelectorAll(".wfm-nlp-filter-row").forEach(e => e.remove())` により他タブ移動時に削除
|
||||
|
||||
**テーマ設定:**
|
||||
- `THEME_KEY = "wfm_nlp_theme"` — localStorage保存キー
|
||||
- `THEME_VARS` — 設定可能な5変数: `--comfy-menu-bg`(背景)、`--comfy-input-bg`(サブヘッダーBG)、`--input-text`(文字色)、`--border-color`(ボーダー)、`--descrip-text`(補助テキスト色)
|
||||
- `loadTheme()` — localStorageから読み込み
|
||||
- `applyTheme(panel, theme)` — パネル要素にCSS変数をインラインsetPropertyで上書き
|
||||
- `buildThemePanel(panel)` — カラーピッカーUIを動的生成(ライブプレビュー・Save・Resetボタン付き)
|
||||
- パネルのHTMLに `.wfm-nlp-theme-btn`(⚙)と `.wfm-nlp-theme-panel` を追加
|
||||
- `createPanel()` 内でテーマボタンのイベントリスナーを登録、`applyTheme(panel, loadTheme())` で起動時に保存済みテーマを適用
|
||||
|
||||
#### `static/js/workflow-tab.js` — `.index.json` 非表示
|
||||
|
||||
- `filterWorkflows()` の先頭に `if (wf.filename === ".index.json") return false;` を追加
|
||||
|
||||
### 技術的な判断
|
||||
|
||||
- **`python_module` の取得元をAPIに変更:** `LiteGraph.registered_node_types` にはバックエンド由来の `python_module` が存在しないため、全ノードが `ComfyUI (Built-in)` 判定になる問題があった。`/object_info` APIを `loadData()` 時に並行取得することで正確なパッケージ判定を実現
|
||||
- **CSS変数のインライン上書き:** テーマ色はパネル要素自体に `style.setProperty` で適用。ComfyUI本体の `:root` 変数を書き換えず、パネルスコープ内のみに影響させる設計
|
||||
- **ドロップダウンのDOMインジェクション:** Category/Packageビューのドロップダウンは `renderContent` の都度生成・削除する方式を採用。タブ切替時のクリーンアップを `renderContent` 冒頭の `.wfm-nlp-filter-row` 削除処理で一元管理
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-29: v0.2.4 GenerateUIタブ レイアウト再設計
|
||||
|
||||
### 概要
|
||||
@@ -880,3 +1483,43 @@ GitHub公開・ComfyUI Manager登録・初回リリース
|
||||
- README.md 作成(スクリーンショット4枚付き)
|
||||
- ComfyUI Manager 登録PR: https://github.com/Comfy-Org/ComfyUI-Manager/pull/2706
|
||||
- GitHub Release v0.1.0: https://github.com/ketle-man/ComfyUI-Workflow-Studio/releases/tag/v0.1.0
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-13: Gallery タブ追加 & 全タブ Group UI 統一
|
||||
|
||||
### 概要
|
||||
- ギャラリータブ(Phase 1)実装:6800枚超の画像ブラウザ
|
||||
- Workflow / Nodes / Gallery の Group UI を Models タブ基準に統一
|
||||
- Settings タブを2カラムレイアウトに変更(テーマを右固定)
|
||||
|
||||
### Gallery タブ機能
|
||||
- **3カラム表示** — サムネイル / テーブル切り替え、Fav列を左端に
|
||||
- **サーバーサイドフィルタ** — グループ・お気に入り・タグによる server-side filtering
|
||||
- **マルチセレクト** — Ctrl+クリックで複数選択、Bulk Bar でまとめて操作
|
||||
- **グループ管理** — 作成・Rename・削除(Models パネルと同一UIパターン)
|
||||
- **os.scandir() + フォルダキャッシュ** — 6800枚環境でのパフォーマンス改善
|
||||
- **出力フォルダ設定** — Settings タブからギャラリー参照フォルダを変更可能
|
||||
|
||||
### Group UI 統一
|
||||
- **Workflow タブ** — `renderSideGroup()` を新設し Models 4 セクションパターンに変更(Current Groups / Add to Group / Create New Group / Manage Groups)。冗長なヘッダーブロック削除
|
||||
- **Nodes タブ** — `renderSideGroups()` を全面書き換え。`×` ボタン・"Add" ボタン・"Rename (✎)" 追加
|
||||
- **Gallery タブ** — `renderDetailGroup()` を新設、同パターン適用
|
||||
|
||||
### Settings 2カラムレイアウト
|
||||
- テーマパネルを右側(`float:right; width:50%`)に固定
|
||||
- 左側に UI言語〜保存ボタンを連続配置(ギャップなし)
|
||||
- `position:sticky; top:0` でテーマパネルをスクロールに追従
|
||||
|
||||
### 変更ファイル
|
||||
- `py/services/gallery_metadata.py` — `rename_group()`, `get_group_member_set()` 追加
|
||||
- `py/services/gallery_service.py` — `_FolderCache`, `_scan_folder()`, `group_filter` 対応
|
||||
- `py/routes/gallery_routes.py` — `PUT /wfm/gallery/groups/{name}` 追加
|
||||
- `static/js/gallery-tab.js` — マルチセレクト・サーバーサイドフィルタ・renderDetailGroup
|
||||
- `static/js/nodes-tab.js` — `renderSideGroups()` 全面書き換え
|
||||
- `static/js/workflow-tab.js` — `renderSideGroup()` 新設、旧ハンドラ削除
|
||||
- `static/js/settings-tab.js` — 2カラムレイアウト HTML 構造変更
|
||||
- `static/css/gallery-tab.css` — multi-selected・bulk-bar・セクションタイトルスタイル
|
||||
- `static/css/main.css` — Settings レイアウト CSS(float:right ベース 50:50)
|
||||
- `templates/index.html` — Gallery "Group"→"Groups"、各タブ static HTML → JS動的生成、Gallery ヘルプカード追加
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,9 +21,9 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Help & Support Tab | |
|
||||
| Gallery Tab | Help & Support Tab |
|
||||
|:---:|:---:|
|
||||
|  | |
|
||||
|  |  |
|
||||
|
||||
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|
||||
|:---:|:---:|
|
||||
@@ -50,19 +50,20 @@ 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
|
||||
|
||||
### Prompt Tab
|
||||
- **3-column layout** — AI Assistant, Presets editor, and Preset Manager displayed side-by-side
|
||||
- **3-column layout** — AI Assistant (left), Presets/Preset Manager tab-panel (center), Wildcard support (right)
|
||||
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
|
||||
- **Image attachment** — attach reference images for vision-capable models
|
||||
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
|
||||
@@ -71,17 +72,38 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
|
||||
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
|
||||
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
|
||||
- **Wildcard input toolbar** — one-click buttons to insert `{|}`, `{n$|}`, `__|__`, `<lora::1:LBW=;>` and other wildcard syntax; wraps selected text when applicable
|
||||
- **Wildcard file manager** — create, view, and edit `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; click a filename in the file picker to insert `__filename__` at cursor
|
||||
|
||||
### Settings Tab
|
||||
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
|
||||
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
|
||||
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
|
||||
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
|
||||
- **Workflows directory** — configure which folder to scan for workflows
|
||||
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
|
||||
- **Eagle connection** — set Eagle API endpoint for auto-save
|
||||
- **Ollama connection** — configure Ollama server URL
|
||||
- **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)
|
||||
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
|
||||
- **Language** — English / Japanese / Chinese
|
||||
|
||||
### 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
|
||||
- **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** — 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
|
||||
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
|
||||
@@ -100,13 +122,17 @@ 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.3)
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
|
||||
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
|
||||
- **Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets sub-tabs), ★ star shown for favorites in All view
|
||||
- **Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
|
||||
- **Category sub-tab** — dropdown to filter nodes by top-level category
|
||||
- **Package sub-tab** — dropdown to filter nodes by custom node package name
|
||||
- **Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs)
|
||||
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
|
||||
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
|
||||
@@ -115,6 +141,7 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
|
||||
- **Double-click** — load workflows or place nodes without dragging
|
||||
- **Search** — search within each sub-tab to quickly find items
|
||||
- **⚙ Theme settings** — customize panel background, sub-header background, text, border, and secondary text colors; saved to localStorage and applied on every open
|
||||
|
||||
### Help & Support Tab (v0.1.3)
|
||||
- **Feature list** — overview of all features organized by tab
|
||||
@@ -191,6 +218,53 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.3
|
||||
- **Wildcard support panel** — new right column in the Prompt tab with a one-click toolbar for inserting wildcard syntax (`{|}`, `{n$|}`, `__|__`, `:`, `;`, `$`, `<lora::1:LBW=;>`, `[]`, single/multi pick) and wrapping selected text; dedicated prompt textarea and wildcard file picker (click to insert `__filename__`)
|
||||
- **Wildcard file manager** — create, view, edit, and delete `.txt` / `.yaml` wildcard files stored in `user/default/Workflow-Studio/wildcard/`; editor opens inline with filename input and save/cancel controls
|
||||
- **Preset/Preset Manager tab switch** — the center pane of the Prompt tab is now a two-tab panel (Presets / Preset Manager) to reclaim horizontal space for the new wildcard panel
|
||||
- **Wildcard Integration setting** — new accordion section in Settings tab; auto-detects ComfyUI-Impact-Pack; when installed, provides a button to create a directory junction (Windows) or symlink (other OS) from the WFS wildcard directory to Impact Pack's `wildcards/` directory; existing WFS files are migrated automatically; a Remove Link button reverses the operation
|
||||
|
||||
### 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
|
||||
|
||||
### v0.2.7
|
||||
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
|
||||
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
|
||||
- **Settings 2-column layout** — Theme panel fixed on the right (50% width, sticky); all other settings flow continuously on the left with no visual gap
|
||||
- **Gallery output directory setting** — configure the output folder scanned by Gallery tab from Settings
|
||||
- **Help tab** — Gallery tab description card added
|
||||
|
||||
### v0.2.5
|
||||
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
|
||||
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
|
||||
- **Sidebar TextEncoder (By Type)** — fixed TextEncoder not appearing in Models → By Type; now uses `DualCLIPLoader` → `CLIPLoader` fallback to retrieve `clip_name1` list
|
||||
- **Hide `.index.json`** — excluded `.index.json` from both the Workflow tab and the Workflow Studio Library side panel
|
||||
|
||||
### v0.2.4
|
||||
- **GenerateUI tab redesign** — reorganized from 5 tabs (Prompt/Image/Model/Settings/Raw JSON) to 3 tabs (Input/Model/Settings); each tab shows a Raw JSON column (540px) for instant JSON preview and direct editing from any tab
|
||||
- **Input tab** — Prompt and Image stacked vertically in the left column
|
||||
@@ -321,7 +395,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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2100 900" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="2100" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="50%" stop-color="#16213e"/>
|
||||
<stop offset="100%" stop-color="#1a1a2e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wStroke" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4a9eff"/>
|
||||
<stop offset="100%" stop-color="#7b68ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sWave" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7b68ee"/>
|
||||
<stop offset="100%" stop-color="#4a9eff"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textGrad" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#e0e0ff"/>
|
||||
<stop offset="100%" stop-color="#b0b0d0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="2100" height="900" fill="url(#bg)"/>
|
||||
<!-- Subtle grid -->
|
||||
<line x1="0" y1="225" x2="2100" y2="225" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="450" x2="2100" y2="450" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="675" x2="2100" y2="675" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<!-- Decorative wave lines in background -->
|
||||
<path d="M0 750 Q262 700 525 750 Q787 800 1050 750 Q1312 700 1575 750 Q1837 800 2100 750" stroke="#4a9eff" stroke-opacity="0.08" stroke-width="2" fill="none"/>
|
||||
<path d="M0 780 Q262 730 525 780 Q787 830 1050 780 Q1312 730 1575 780 Q1837 830 2100 780" stroke="#7b68ee" stroke-opacity="0.06" stroke-width="2" fill="none"/>
|
||||
<!-- W+S Logo (left side) -->
|
||||
<g transform="translate(180, 180)">
|
||||
<!-- W -->
|
||||
<path d="M60 80 L120 280 L180 130 L240 280 L300 80" stroke="url(#wStroke)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- S wave -->
|
||||
<path d="M80 340 Q140 300 200 340 Q260 380 320 340" stroke="url(#sWave)" stroke-width="18" stroke-linecap="round" fill="none"/>
|
||||
</g>
|
||||
<!-- Title text -->
|
||||
<text x="720" y="380" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="120" font-weight="700" fill="url(#textGrad)" letter-spacing="-2">Workflow Studio</text>
|
||||
<!-- Subtitle -->
|
||||
<text x="720" y="470" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="42" fill="#8888aa" letter-spacing="1">Workflow Management & Generation UI for ComfyUI</text>
|
||||
<!-- Feature pills -->
|
||||
<g font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#a0a0c0">
|
||||
<rect x="720" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="820" y="559" text-anchor="middle" fill="#4a9eff">Workflows</text>
|
||||
<rect x="940" y="530" width="200" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1040" y="559" text-anchor="middle" fill="#7b68ee">GenerateUI</text>
|
||||
<rect x="1160" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1260" y="559" text-anchor="middle" fill="#4a9eff">AI Prompts</text>
|
||||
<rect x="1380" y="530" width="180" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
|
||||
<text x="1470" y="559" text-anchor="middle" fill="#7b68ee">Themes</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wStroke" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4a9eff"/>
|
||||
<stop offset="100%" stop-color="#7b68ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sWave" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#7b68ee"/>
|
||||
<stop offset="100%" stop-color="#4a9eff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="400" height="400" rx="48" fill="url(#bg)"/>
|
||||
<!-- Subtle grid pattern -->
|
||||
<line x1="0" y1="100" x2="400" y2="100" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="200" x2="400" y2="200" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="0" y1="300" x2="400" y2="300" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="100" y1="0" x2="100" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="200" y1="0" x2="200" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<line x1="300" y1="0" x2="300" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
|
||||
<!-- W letterform -->
|
||||
<path d="M80 110 L140 290 L200 150 L260 290 L320 110" stroke="url(#wStroke)" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<!-- S wave -->
|
||||
<path d="M100 330 Q150 295 200 330 Q250 365 300 330" stroke="url(#sWave)" stroke-width="20" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
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 |
+14
-4
@@ -10,13 +10,22 @@ 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"
|
||||
COMFYUI_ROOT = _COMFYUI_ROOT # exposed for external use
|
||||
|
||||
# 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():
|
||||
@@ -59,3 +68,4 @@ NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
|
||||
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
|
||||
PROMPTS_FILE = DATA_DIR / "prompts.json"
|
||||
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
|
||||
WILDCARD_DIR = DATA_DIR / "wildcard"
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Gallery Routes - ギャラリータブ用APIエンドポイント
|
||||
"""
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import DATA_DIR
|
||||
from ..services.gallery_service import GalleryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = GalleryService(DATA_DIR)
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/folders", list_folders)
|
||||
app.router.add_get("/wfm/gallery/images", list_images)
|
||||
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
|
||||
app.router.add_get("/wfm/gallery/groups", list_groups)
|
||||
app.router.add_post("/wfm/gallery/groups", create_group)
|
||||
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
|
||||
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
|
||||
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
|
||||
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
|
||||
# フォルダ・ファイル操作
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_folders(request: web.Request) -> web.Response:
|
||||
root = request.rel_url.query.get("root", "")
|
||||
if not root:
|
||||
return web.json_response({"error": "root parameter required"}, status=400)
|
||||
try:
|
||||
tree = _service.list_folder_tree(root)
|
||||
return web.json_response(tree)
|
||||
except Exception as e:
|
||||
logger.error("list_folders error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像一覧
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_images(request: web.Request) -> web.Response:
|
||||
folder = request.rel_url.query.get("folder", "")
|
||||
search = request.rel_url.query.get("search", "")
|
||||
sort_by = request.rel_url.query.get("sort", "date_desc")
|
||||
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
|
||||
tag_filter = request.rel_url.query.get("tag", "")
|
||||
group_filter = request.rel_url.query.get("group", "")
|
||||
|
||||
if not folder:
|
||||
return web.json_response({"error": "folder parameter required"}, status=400)
|
||||
|
||||
try:
|
||||
images = _service.list_images(
|
||||
folder,
|
||||
search=search,
|
||||
sort_by=sort_by,
|
||||
favorite_only=favorite_only,
|
||||
tag_filter=tag_filter,
|
||||
group_filter=group_filter,
|
||||
)
|
||||
return web.json_response({"images": images, "total": len(images)})
|
||||
except Exception as e:
|
||||
logger.error("list_images error: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_image_meta(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
meta = _service.get_image_metadata(path)
|
||||
return web.json_response(meta)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def get_image_workflow(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
try:
|
||||
wf = _service.extract_workflow_from_metadata(path)
|
||||
if wf is None:
|
||||
return web.json_response({"workflow": None, "has_workflow": False})
|
||||
return web.json_response({"workflow": wf, "has_workflow": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def serve_image(request: web.Request) -> web.Response:
|
||||
path = request.rel_url.query.get("path", "")
|
||||
if not path:
|
||||
return web.Response(status=400)
|
||||
|
||||
img_path = _service.serve_image(path)
|
||||
if img_path is None:
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(img_path))
|
||||
return web.FileResponse(img_path, headers={
|
||||
"Content-Type": content_type or "image/png",
|
||||
"Cache-Control": "max-age=3600",
|
||||
})
|
||||
|
||||
|
||||
async def save_image_meta(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
data = {k: v for k, v in body.items() if k != "path"}
|
||||
ok = _service.save_image_meta(img_path, data)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def toggle_favorite(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
if not img_path:
|
||||
return web.json_response({"error": "path required"}, status=400)
|
||||
new_val = _service.toggle_favorite(img_path)
|
||||
return web.json_response({"favorite": new_val})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_groups(request: web.Request) -> web.Response:
|
||||
return web.json_response({"groups": _service.list_groups()})
|
||||
|
||||
|
||||
async def create_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return web.json_response({"error": "name required"}, status=400)
|
||||
ok = _service.create_group(name)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Group already exists"}, status=409)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def rename_group(request: web.Request) -> web.Response:
|
||||
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
|
||||
try:
|
||||
old_name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
new_name = body.get("new_name", "").strip()
|
||||
if not old_name or not new_name:
|
||||
return web.json_response({"error": "old_name and new_name required"}, status=400)
|
||||
ok = _service.rename_group(old_name, new_name)
|
||||
if not ok:
|
||||
return web.json_response({"error": "Rename failed (not found or name conflict)"}, status=400)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def delete_group(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
ok = _service.delete_group(name)
|
||||
return web.json_response({"ok": ok})
|
||||
|
||||
|
||||
async def add_to_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
ok = _service.add_to_group(img_path, name)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def remove_from_group(request: web.Request) -> web.Response:
|
||||
try:
|
||||
name = request.match_info.get("name", "")
|
||||
body = await request.json()
|
||||
img_path = body.get("path", "")
|
||||
ok = _service.remove_from_group(img_path, name)
|
||||
return web.json_response({"ok": ok})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def list_group_images(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name", "")
|
||||
images = _service.list_images_in_group(name)
|
||||
return web.json_response({"images": images})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダ・ファイル操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
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
@@ -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."""
|
||||
@@ -20,6 +32,10 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_post("/api/wfm/settings", handle_post)
|
||||
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
|
||||
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
|
||||
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
|
||||
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
|
||||
app.router.add_get("/api/wfm/settings/export", handle_export)
|
||||
app.router.add_post("/api/wfm/settings/import", handle_import)
|
||||
|
||||
|
||||
async def handle_get(request: web.Request) -> web.Response:
|
||||
@@ -92,3 +108,129 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error setting workflows dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _get_comfyui_output_dir() -> str:
|
||||
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
|
||||
try:
|
||||
import folder_paths # type: ignore
|
||||
return str(folder_paths.get_output_directory())
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: config.pyのCOMFYUI_ROOTから推測
|
||||
from ..config import PLUGIN_DIR
|
||||
comfyui_root = PLUGIN_DIR.parent.parent
|
||||
output_dir = comfyui_root / "output"
|
||||
if output_dir.is_dir():
|
||||
return str(output_dir)
|
||||
return ""
|
||||
|
||||
|
||||
async def handle_get_output_dir(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/output-dir - Get gallery output directory."""
|
||||
try:
|
||||
server_settings = await asyncio.to_thread(_service.load)
|
||||
saved = server_settings.get("gallery_output_dir", "").strip()
|
||||
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
|
||||
return web.json_response({
|
||||
"current": saved or default_dir,
|
||||
"default": default_dir,
|
||||
"saved": saved,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error getting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_set_output_dir(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/output-dir - Save gallery output directory."""
|
||||
try:
|
||||
body = await request.json()
|
||||
new_dir = body.get("gallery_output_dir", "").strip()
|
||||
await asyncio.to_thread(_service.update, {"gallery_output_dir": new_dir})
|
||||
|
||||
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
|
||||
resolved = new_dir or default_dir
|
||||
|
||||
# GalleryServiceの許可ルートを更新
|
||||
from ..routes.gallery_routes import _service as gallery_service
|
||||
gallery_service.update_output_root(resolved)
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"current": resolved,
|
||||
"default": default_dir,
|
||||
"saved": new_dir,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Error setting output dir: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _load_data_file(filename: str):
|
||||
"""Load a single data file, return empty dict/list if missing."""
|
||||
path = DATA_DIR / filename
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_export_bundle() -> dict:
|
||||
"""Collect all data files into a single export bundle."""
|
||||
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
|
||||
for filename in _DATA_FILES:
|
||||
data = _load_data_file(filename)
|
||||
if data is not None:
|
||||
bundle[filename] = data
|
||||
return bundle
|
||||
|
||||
|
||||
async def handle_export(request: web.Request) -> web.Response:
|
||||
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
|
||||
try:
|
||||
bundle = await asyncio.to_thread(_build_export_bundle)
|
||||
body = json.dumps(bundle, ensure_ascii=False, indent=2)
|
||||
return web.Response(
|
||||
body=body.encode("utf-8"),
|
||||
content_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _apply_import_bundle(bundle: dict) -> dict:
|
||||
"""Write imported bundle back to individual data files. Returns summary."""
|
||||
summary = {"imported": [], "skipped": []}
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for filename in _DATA_FILES:
|
||||
if filename not in bundle:
|
||||
summary["skipped"].append(filename)
|
||||
continue
|
||||
try:
|
||||
path = DATA_DIR / filename
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
|
||||
summary["imported"].append(filename)
|
||||
except Exception as e:
|
||||
logger.error("Import failed for %s: %s", filename, e)
|
||||
summary["skipped"].append(filename)
|
||||
return summary
|
||||
|
||||
|
||||
async def handle_import(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
|
||||
try:
|
||||
bundle = await request.json()
|
||||
if not isinstance(bundle, dict):
|
||||
return web.json_response({"error": "Invalid bundle format"}, status=400)
|
||||
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
|
||||
return web.json_response({"status": "ok", **summary})
|
||||
except Exception as e:
|
||||
logger.error("Error importing data: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Wildcard file management API routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import DATA_DIR, COMFYUI_ROOT
|
||||
from ..services.wildcard_service import WildcardService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_service = WildcardService(DATA_DIR / "wildcard")
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/wildcards", handle_list)
|
||||
app.router.add_get("/api/wfm/wildcards/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/wildcards/save", handle_save)
|
||||
app.router.add_post("/api/wfm/wildcards/delete", handle_delete)
|
||||
app.router.add_get("/api/wfm/wildcards/link-status", handle_link_status)
|
||||
app.router.add_post("/api/wfm/wildcards/create-link", handle_create_link)
|
||||
app.router.add_post("/api/wfm/wildcards/remove-link", handle_remove_link)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.list_wildcards)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error listing wildcards: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_content(request: web.Request) -> web.Response:
|
||||
filename = request.rel_url.query.get("filename", "")
|
||||
try:
|
||||
content = await asyncio.to_thread(_service.get_content, filename)
|
||||
return web.json_response({"content": content})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except Exception as e:
|
||||
logger.error("Error getting wildcard content: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
content = body.get("content", "")
|
||||
result = await asyncio.to_thread(_service.save_file, filename, content)
|
||||
return web.json_response({"status": "ok", "file": result})
|
||||
except Exception as e:
|
||||
logger.error("Error saving wildcard: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
await asyncio.to_thread(_service.delete_file, filename)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
logger.error("Error deleting wildcard: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_link_status(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.get_link_status, COMFYUI_ROOT)
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
logger.error("Error getting wildcard link status: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_create_link(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await asyncio.to_thread(_service.create_link, COMFYUI_ROOT)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except RuntimeError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error creating wildcard link: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_remove_link(request: web.Request) -> web.Response:
|
||||
try:
|
||||
await asyncio.to_thread(_service.remove_link)
|
||||
return web.json_response({"status": "ok"})
|
||||
except RuntimeError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error removing wildcard link: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GalleryMetadataStore:
|
||||
"""
|
||||
gallery_metadata.json の構造:
|
||||
{
|
||||
"images": {
|
||||
"<abs_path>": {
|
||||
"favorite": bool,
|
||||
"tags": [str, ...],
|
||||
"memo": str,
|
||||
"groups": [str, ...]
|
||||
}
|
||||
},
|
||||
"groups": [
|
||||
{"name": str}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
self._data: dict = {"images": {}, "groups": []}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.file_path.exists():
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
self._data = {
|
||||
"images": loaded.get("images", {}),
|
||||
"groups": loaded.get("groups", []),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("GalleryMetadataStore: load error: %s", e)
|
||||
|
||||
def _save_to_disk(self):
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("GalleryMetadataStore: save error: %s", e)
|
||||
return False
|
||||
|
||||
def _normalize_path(self, image_path: str) -> str:
|
||||
return str(Path(image_path)).replace("\\", "/")
|
||||
|
||||
def get(self, image_path: str) -> dict:
|
||||
key = self._normalize_path(image_path)
|
||||
return dict(self._data["images"].get(key, {}))
|
||||
|
||||
def save(self, image_path: str, data: dict) -> bool:
|
||||
key = self._normalize_path(image_path)
|
||||
existing = self._data["images"].get(key, {})
|
||||
existing.update(data)
|
||||
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
|
||||
existing = {k: v for k, v in existing.items() if k in allowed}
|
||||
self._data["images"][key] = existing
|
||||
return self._save_to_disk()
|
||||
|
||||
# ── グループ管理 ──────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return list(self._data.get("groups", []))
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
groups = self._data.get("groups", [])
|
||||
if any(g["name"] == name for g in groups):
|
||||
return False
|
||||
groups.append({"name": name})
|
||||
self._data["groups"] = groups
|
||||
return self._save_to_disk()
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
|
||||
groups = self._data.get("groups", [])
|
||||
if not any(g["name"] == old_name for g in groups):
|
||||
return False
|
||||
if any(g["name"] == new_name for g in groups):
|
||||
return False # 新名前が既に存在
|
||||
# グループリスト更新
|
||||
for g in groups:
|
||||
if g["name"] == old_name:
|
||||
g["name"] = new_name
|
||||
break
|
||||
# 全画像のgroupsフィールドを更新
|
||||
for meta in self._data["images"].values():
|
||||
img_groups = meta.get("groups", [])
|
||||
if old_name in img_groups:
|
||||
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
|
||||
return self._save_to_disk()
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
|
||||
self._data["groups"] = groups
|
||||
# 画像のgroupsからも削除
|
||||
for meta in self._data["images"].values():
|
||||
if name in meta.get("groups", []):
|
||||
meta["groups"] = [g for g in meta["groups"] if g != name]
|
||||
return self._save_to_disk()
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
result = []
|
||||
for key, meta in self._data["images"].items():
|
||||
if group_name in meta.get("groups", []):
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def get_group_member_set(self, group_name: str) -> set:
|
||||
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
|
||||
return {
|
||||
key for key, meta in self._data["images"].items()
|
||||
if group_name in meta.get("groups", [])
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# サポートする画像拡張子
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
# メタデータ保存ファイル (gallery専用)
|
||||
from .gallery_metadata import GalleryMetadataStore
|
||||
|
||||
|
||||
# PNGチャンク1個の最大許容サイズ (32MB)
|
||||
_PNG_CHUNK_MAX = 32 * 1024 * 1024
|
||||
# JPEGを読む際の最大ファイルサイズ (64MB)
|
||||
_JPEG_READ_MAX = 64 * 1024 * 1024
|
||||
|
||||
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
class _FolderCache:
|
||||
"""フォルダ単位の画像スキャン結果キャッシュ。
|
||||
フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。"""
|
||||
|
||||
def __init__(self):
|
||||
# folder_path -> (mtime, scan_time, [(name, path_str, size, mtime_f), ...])
|
||||
self._cache: dict[str, tuple[float, float, list]] = {}
|
||||
|
||||
def get(self, folder: Path) -> list | None:
|
||||
key = str(folder)
|
||||
if key not in self._cache:
|
||||
return None
|
||||
cached_mtime, scan_time, entries = self._cache[key]
|
||||
# TTL チェック
|
||||
if time.monotonic() - scan_time > _CACHE_TTL:
|
||||
return None
|
||||
# フォルダ自体のmtimeチェック
|
||||
try:
|
||||
current_mtime = folder.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
if current_mtime != cached_mtime:
|
||||
return None
|
||||
return entries
|
||||
|
||||
def set(self, folder: Path, entries: list):
|
||||
key = str(folder)
|
||||
try:
|
||||
folder_mtime = folder.stat().st_mtime
|
||||
except OSError:
|
||||
folder_mtime = 0.0
|
||||
self._cache[key] = (folder_mtime, time.monotonic(), entries)
|
||||
|
||||
def invalidate(self, folder: Path):
|
||||
self._cache.pop(str(folder), None)
|
||||
|
||||
|
||||
class GalleryService:
|
||||
def __init__(self, data_dir: Path):
|
||||
self.data_dir = data_dir
|
||||
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
|
||||
self._allowed_root: Path | None = None
|
||||
self._folder_cache = _FolderCache()
|
||||
|
||||
def update_output_root(self, root_path: str) -> None:
|
||||
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
|
||||
p = Path(root_path).resolve() if root_path else None
|
||||
self._allowed_root = p
|
||||
|
||||
def _check_path_allowed(self, path: Path) -> bool:
|
||||
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
|
||||
if self._allowed_root is None:
|
||||
resolved = path.resolve()
|
||||
return resolved == path.resolve()
|
||||
try:
|
||||
path.resolve().relative_to(self._allowed_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_folder_tree(self, root_path: str) -> dict:
|
||||
"""outputフォルダのフォルダツリーを返す"""
|
||||
root = Path(root_path).resolve()
|
||||
if not root.is_dir():
|
||||
return {"error": f"Directory not found: {root_path}"}
|
||||
if self._allowed_root is None:
|
||||
self._allowed_root = root
|
||||
|
||||
def build_tree(path: Path, rel_base: Path) -> dict:
|
||||
rel = str(path.relative_to(rel_base)).replace("\\", "/")
|
||||
image_count = 0
|
||||
children = []
|
||||
try:
|
||||
with os.scandir(path) as it:
|
||||
for entry in it:
|
||||
if entry.is_file(follow_symlinks=True):
|
||||
if Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
|
||||
image_count += 1
|
||||
elif entry.is_dir(follow_symlinks=True) and not entry.name.startswith("."):
|
||||
children.append(entry.name)
|
||||
except PermissionError:
|
||||
pass
|
||||
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
|
||||
return {
|
||||
"name": path.name,
|
||||
"path": rel if rel != "." else "",
|
||||
"abs_path": str(path).replace("\\", "/"),
|
||||
"image_count": image_count,
|
||||
"children": children_nodes,
|
||||
}
|
||||
|
||||
return build_tree(root, root)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像一覧
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _scan_folder(self, folder: Path) -> list[tuple[str, str, int, float]]:
|
||||
"""os.scandir() で画像ファイルを1回のシステムコールで列挙。
|
||||
キャッシュがあればそれを返す。
|
||||
Returns: [(name, abs_path_str, size, mtime), ...]
|
||||
"""
|
||||
cached = self._folder_cache.get(folder)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with os.scandir(folder) as it:
|
||||
for entry in it:
|
||||
if not entry.is_file(follow_symlinks=True):
|
||||
continue
|
||||
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
st = entry.stat(follow_symlinks=True)
|
||||
entries.append((
|
||||
entry.name,
|
||||
str(Path(entry.path).resolve()).replace("\\", "/"),
|
||||
st.st_size,
|
||||
st.st_mtime,
|
||||
))
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
self._folder_cache.set(folder, entries)
|
||||
return entries
|
||||
|
||||
def list_images(
|
||||
self,
|
||||
folder_path: str,
|
||||
search: str = "",
|
||||
sort_by: str = "date_desc",
|
||||
favorite_only: bool = False,
|
||||
tag_filter: str = "",
|
||||
group_filter: str = "",
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
if not self._check_path_allowed(folder):
|
||||
logger.warning("list_images: path not allowed: %s", folder)
|
||||
return []
|
||||
|
||||
# グループフィルタ: サーバーサイドで先にパスSetを取得(転送量削減)
|
||||
group_member_set: set | None = None
|
||||
if group_filter:
|
||||
group_member_set = self.metadata_store.get_group_member_set(group_filter)
|
||||
|
||||
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
|
||||
raw_entries = self._scan_folder(folder)
|
||||
|
||||
results = []
|
||||
for name, abs_path, size, mtime in raw_entries:
|
||||
# グループフィルタ(サーバーサイド)
|
||||
if group_member_set is not None and abs_path not in group_member_set:
|
||||
continue
|
||||
|
||||
meta = self.metadata_store.get(abs_path)
|
||||
|
||||
# お気に入りフィルタ(サーバーサイド、早期スキップ)
|
||||
if favorite_only and not meta.get("favorite", False):
|
||||
continue
|
||||
|
||||
# タグフィルタ(サーバーサイド、早期スキップ)
|
||||
tags = meta.get("tags", [])
|
||||
if tag_filter and tag_filter not in tags:
|
||||
continue
|
||||
|
||||
ext = Path(name).suffix.lower()
|
||||
item = {
|
||||
"filename": name,
|
||||
"path": abs_path,
|
||||
"size": size,
|
||||
"mtime": mtime,
|
||||
"ext": ext,
|
||||
"favorite": meta.get("favorite", False),
|
||||
"tags": tags,
|
||||
"memo": meta.get("memo", ""),
|
||||
"groups": meta.get("groups", []),
|
||||
}
|
||||
|
||||
# 検索フィルタ
|
||||
if search:
|
||||
s = search.lower()
|
||||
if not (
|
||||
s in name.lower()
|
||||
or s in item["memo"].lower()
|
||||
or any(s in t.lower() for t in tags)
|
||||
):
|
||||
continue
|
||||
|
||||
results.append(item)
|
||||
|
||||
# ソート
|
||||
if sort_by == "name_asc":
|
||||
results.sort(key=lambda x: x["filename"].lower())
|
||||
elif sort_by == "name_desc":
|
||||
results.sort(key=lambda x: x["filename"].lower(), reverse=True)
|
||||
elif sort_by == "date_asc":
|
||||
results.sort(key=lambda x: x["mtime"])
|
||||
else: # date_desc (default)
|
||||
results.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像メタデータ
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_image_metadata(self, image_path: str) -> dict:
|
||||
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
|
||||
path = Path(image_path).resolve()
|
||||
if not path.is_file():
|
||||
return {"error": "File not found"}
|
||||
if not self._check_path_allowed(path):
|
||||
return {"error": "Access denied"}
|
||||
|
||||
embedded = {}
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".png":
|
||||
embedded = self._read_png_metadata(path)
|
||||
elif ext in {".jpg", ".jpeg"}:
|
||||
embedded = self._read_jpeg_metadata(path)
|
||||
|
||||
saved = self.metadata_store.get(str(path))
|
||||
|
||||
stat = path.stat()
|
||||
return {
|
||||
"filename": path.name,
|
||||
"path": str(path).replace("\\", "/"),
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"ext": ext,
|
||||
"embedded": embedded,
|
||||
"favorite": saved.get("favorite", False),
|
||||
"tags": saved.get("tags", []),
|
||||
"memo": saved.get("memo", ""),
|
||||
"groups": saved.get("groups", []),
|
||||
}
|
||||
|
||||
def _read_png_metadata(self, path: Path) -> dict:
|
||||
"""PNGのtEXtチャンクからメタデータを抽出"""
|
||||
result = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
sig = f.read(8)
|
||||
if sig != b"\x89PNG\r\n\x1a\n":
|
||||
return result
|
||||
while True:
|
||||
chunk_len_bytes = f.read(4)
|
||||
if len(chunk_len_bytes) < 4:
|
||||
break
|
||||
chunk_len = struct.unpack(">I", chunk_len_bytes)[0]
|
||||
chunk_type = f.read(4).decode("ascii", errors="ignore")
|
||||
if chunk_len > _PNG_CHUNK_MAX:
|
||||
f.seek(chunk_len + 4, 1)
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
continue
|
||||
chunk_data = f.read(chunk_len)
|
||||
f.read(4) # CRC
|
||||
|
||||
if chunk_type in ("tEXt", "iTXt"):
|
||||
try:
|
||||
if chunk_type == "tEXt":
|
||||
null_idx = chunk_data.index(b"\x00")
|
||||
key = chunk_data[:null_idx].decode("latin-1")
|
||||
value = chunk_data[null_idx + 1:].decode("latin-1")
|
||||
else:
|
||||
null_idx = chunk_data.index(b"\x00")
|
||||
key = chunk_data[:null_idx].decode("utf-8")
|
||||
rest = chunk_data[null_idx + 1:]
|
||||
rest = rest[2:]
|
||||
null_idx2 = rest.index(b"\x00")
|
||||
rest = rest[null_idx2 + 1:]
|
||||
null_idx3 = rest.index(b"\x00")
|
||||
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
|
||||
result[key] = value
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if chunk_type == "IEND":
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("PNG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_jpeg_metadata(self, path: Path) -> dict:
|
||||
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
|
||||
result = {}
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
read_size = min(file_size, _JPEG_READ_MAX)
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(read_size)
|
||||
idx = 0
|
||||
while idx < len(data) - 3:
|
||||
if data[idx] == 0xFF:
|
||||
marker = data[idx + 1]
|
||||
if marker == 0xFE: # COM
|
||||
length = struct.unpack(">H", data[idx + 2:idx + 4])[0]
|
||||
end = idx + 2 + length
|
||||
comment = data[idx + 4:end].decode("utf-8", errors="replace")
|
||||
result["Comment"] = comment
|
||||
break
|
||||
idx += 2
|
||||
else:
|
||||
idx += 1
|
||||
except Exception as e:
|
||||
logger.debug("JPEG metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
|
||||
"""ワークフローを抽出する。
|
||||
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
|
||||
"""
|
||||
path = Path(image_path).resolve()
|
||||
if not self._check_path_allowed(path):
|
||||
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
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# メタデータ保存
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
return self.metadata_store.save(image_path, data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
meta = self.metadata_store.get(image_path)
|
||||
new_val = not meta.get("favorite", False)
|
||||
self.metadata_store.save(image_path, {"favorite": new_val})
|
||||
return new_val
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# グループ管理
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_groups(self) -> list[dict]:
|
||||
return self.metadata_store.list_groups()
|
||||
|
||||
def create_group(self, name: str) -> bool:
|
||||
return self.metadata_store.create_group(name)
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str) -> bool:
|
||||
return self.metadata_store.rename_group(old_name, new_name)
|
||||
|
||||
def delete_group(self, name: str) -> bool:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = meta.get("groups", [])
|
||||
if group_name not in groups:
|
||||
groups.append(group_name)
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
meta = self.metadata_store.get(image_path)
|
||||
groups = [g for g in meta.get("groups", []) if g != group_name]
|
||||
return self.metadata_store.save(image_path, {"groups": groups})
|
||||
|
||||
def list_images_in_group(self, group_name: str) -> list[str]:
|
||||
return self.metadata_store.list_images_in_group(group_name)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 画像配信
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def 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()
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
return None
|
||||
if not self._check_path_allowed(p):
|
||||
logger.warning("serve_image: path not allowed: %s", p)
|
||||
return None
|
||||
return p
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Wildcard file management service."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"txt", "yaml", "yml"}
|
||||
|
||||
|
||||
class WildcardService:
|
||||
def __init__(self, wildcard_dir: Path):
|
||||
self.wildcard_dir = wildcard_dir
|
||||
# Only mkdir if it is a real directory (not a junction/symlink)
|
||||
if not self.wildcard_dir.exists() and not self._is_junction(self.wildcard_dir):
|
||||
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_wildcards(self) -> list[dict]:
|
||||
"""Return sorted list of wildcard files (follows junction/symlink)."""
|
||||
if not self.wildcard_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for path in sorted(self.wildcard_dir.iterdir()):
|
||||
if path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS:
|
||||
files.append({
|
||||
"name": path.stem,
|
||||
"filename": path.name,
|
||||
"ext": path.suffix.lstrip(".").lower(),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
return files
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def save_file(self, filename: str, content: str) -> dict:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"name": path.stem,
|
||||
"filename": path.name,
|
||||
"ext": path.suffix.lstrip(".").lower(),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
if not filename or ".." in filename or "/" in filename or "\\" in filename:
|
||||
return None
|
||||
ext = Path(filename).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.wildcard_dir / filename
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Impact Pack integration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def find_impact_pack_wildcards(comfyui_root: Path) -> Path | None:
|
||||
"""Return Impact Pack wildcards directory path, or None if not installed."""
|
||||
custom_nodes = comfyui_root / "custom_nodes"
|
||||
if not custom_nodes.is_dir():
|
||||
return None
|
||||
for d in custom_nodes.iterdir():
|
||||
if d.is_dir() and "impact-pack" in d.name.lower():
|
||||
wildcards = d / "wildcards"
|
||||
if wildcards.is_dir():
|
||||
return wildcards
|
||||
return None
|
||||
|
||||
def get_link_status(self, comfyui_root: Path) -> dict:
|
||||
"""Return current Impact Pack integration status."""
|
||||
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
|
||||
is_linked = self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()
|
||||
|
||||
resolved_target = None
|
||||
if is_linked:
|
||||
try:
|
||||
resolved_target = str(self.wildcard_dir.resolve())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"impact_pack_installed": impact_dir is not None,
|
||||
"impact_pack_wildcards_dir": str(impact_dir) if impact_dir else None,
|
||||
"wfs_wildcard_dir": str(self.wildcard_dir),
|
||||
"is_linked": is_linked,
|
||||
"link_target": resolved_target,
|
||||
}
|
||||
|
||||
def create_link(self, comfyui_root: Path) -> dict:
|
||||
"""Replace WFS wildcard dir with a junction/symlink → Impact Pack wildcards."""
|
||||
impact_dir = self.find_impact_pack_wildcards(comfyui_root)
|
||||
if impact_dir is None:
|
||||
raise RuntimeError("ComfyUI-Impact-Pack is not installed.")
|
||||
|
||||
if self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink():
|
||||
raise RuntimeError("Link already exists.")
|
||||
|
||||
# Migrate existing WFS files to Impact Pack dir
|
||||
migrated: list[str] = []
|
||||
if self.wildcard_dir.is_dir():
|
||||
for f in self.wildcard_dir.iterdir():
|
||||
if f.is_file():
|
||||
dest = impact_dir / f.name
|
||||
if not dest.exists():
|
||||
shutil.copy2(str(f), str(dest))
|
||||
migrated.append(f.name)
|
||||
shutil.rmtree(str(self.wildcard_dir))
|
||||
|
||||
# Create junction (Windows) or symlink (others)
|
||||
self._create_junction_or_symlink(self.wildcard_dir, impact_dir)
|
||||
logger.info("Wildcard link created: %s -> %s", self.wildcard_dir, impact_dir)
|
||||
return {"migrated_files": migrated}
|
||||
|
||||
def remove_link(self) -> None:
|
||||
"""Remove junction/symlink and restore WFS wildcard dir as a plain directory."""
|
||||
if not (self._is_junction(self.wildcard_dir) or self.wildcard_dir.is_symlink()):
|
||||
raise RuntimeError("No link to remove.")
|
||||
self._remove_junction_or_symlink(self.wildcard_dir)
|
||||
self.wildcard_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Wildcard link removed, restored plain dir: %s", self.wildcard_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Platform helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_junction(path: Path) -> bool:
|
||||
"""Return True if path is a Windows directory junction."""
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
|
||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
||||
return attrs != 0xFFFFFFFF and bool(attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _create_junction_or_symlink(link: Path, target: Path) -> None:
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"mklink /J failed: {result.stderr.strip()}")
|
||||
else:
|
||||
os.symlink(target, link, target_is_directory=True)
|
||||
|
||||
@staticmethod
|
||||
def _remove_junction_or_symlink(path: Path) -> None:
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "rmdir", str(path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"rmdir failed: {result.stderr.strip()}")
|
||||
else:
|
||||
os.unlink(str(path))
|
||||
@@ -112,6 +112,7 @@ class WorkflowService:
|
||||
"summary": meta.get("summary", ""),
|
||||
"modelTypesOverride": override_types,
|
||||
"favorite": bool(meta.get("favorite", False)),
|
||||
"badges": meta.get("badges", []),
|
||||
},
|
||||
"mtime": stat.st_mtime,
|
||||
"thumbnail": thumbnail,
|
||||
@@ -132,7 +133,7 @@ class WorkflowService:
|
||||
"""Save metadata (tags, memo, summary, etc.) for a workflow."""
|
||||
metadata = self._load_metadata()
|
||||
entry = metadata.get(filename, {})
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite"):
|
||||
for key in ("tags", "memo", "summary", "modelTypesOverride", "favorite", "badges"):
|
||||
if key in updates:
|
||||
entry[key] = updates[key]
|
||||
entry["updatedAt"] = self._now_iso()
|
||||
|
||||
@@ -61,7 +61,7 @@ class WorkflowStudio:
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
@@ -70,5 +70,7 @@ class WorkflowStudio:
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
models_routes.setup_routes(app)
|
||||
gallery_routes.setup_routes(app)
|
||||
wildcard_routes.setup_routes(app)
|
||||
|
||||
logger.info("Workflow Studio: Routes registered successfully")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow management and generation UI plugin for ComfyUI. Browse, organize, and execute workflows from a dedicated studio interface."
|
||||
version = "0.2.7"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "ketle-man"
|
||||
DisplayName = "Workflow Studio"
|
||||
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
|
||||
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
|
||||
@@ -0,0 +1,649 @@
|
||||
/* ============================================================
|
||||
Gallery Tab Styles
|
||||
============================================================ */
|
||||
|
||||
/* ── 3カラムレイアウト ──────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-layout {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--wfm-header-height, 96px));
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── 左: フォルダツリーパネル ──────────────────────────────── */
|
||||
|
||||
.wfm-gallery-tree-panel {
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
.wfm-gallery-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item:hover {
|
||||
background: var(--wfm-bg-hover, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item.selected {
|
||||
background: var(--wfm-accent, #4f8ef7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-item.selected .wfm-gallery-tree-count {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-arrow {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--wfm-text-secondary);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-count {
|
||||
font-size: 10px;
|
||||
background: var(--wfm-accent-muted, rgba(79,142,247,0.2));
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-tree-children {
|
||||
/* 子ノードコンテナ */
|
||||
}
|
||||
|
||||
/* ── 中: 画像一覧パネル ────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-list-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-grid {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* サムネイルビュー */
|
||||
.wfm-gallery-view-thumb {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card {
|
||||
position: relative;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover {
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card.selected {
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
box-shadow: 0 0 0 2px var(--wfm-accent, #4f8ef7);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card.multi-selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px #f59e0b;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--wfm-bg-hover, #111);
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wfm-bg-hover, #222);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* お気に入りボタン(サムネイルカード右上) */
|
||||
.wfm-gallery-thumb-fav-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-fav-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-fav-btn.active {
|
||||
color: #fbbf24;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-fav-btn:hover {
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* テーブルビューのお気に入りボタン */
|
||||
.wfm-gallery-table-fav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
padding: 2px 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-fav-btn:hover,
|
||||
.wfm-gallery-table-fav-btn.active {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.wfm-gallery-thumb-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* テーブルビュー */
|
||||
.wfm-gallery-view-table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-gallery-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
color: var(--wfm-text-secondary);
|
||||
font-weight: 600;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr:hover {
|
||||
background: var(--wfm-bg-hover, rgba(255,255,255,0.05));
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr.selected {
|
||||
background: rgba(79,142,247,0.15);
|
||||
}
|
||||
|
||||
.wfm-gallery-table tbody tr.multi-selected {
|
||||
background: rgba(245,158,11,0.15);
|
||||
}
|
||||
|
||||
.wfm-gallery-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wfm-gallery-table-name {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── タグバッジ共通 ────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: rgba(79,142,247,0.15);
|
||||
color: var(--wfm-accent, #4f8ef7);
|
||||
border: 1px solid rgba(79,142,247,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.wfm-gallery-tag-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── 右: 詳細パネル ────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-detail-panel {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-header {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-filename {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* プレビュー */
|
||||
.wfm-gallery-detail-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-detail-img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* タブコンテンツ */
|
||||
.wfm-gallery-detail-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.wfm-gallery-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-primary);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-gallery-info-section {
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-info-row {
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-primary);
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-gallery-memo {
|
||||
width: 100%;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
color: var(--wfm-text-primary);
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.wfm-gallery-memo:focus {
|
||||
outline: none;
|
||||
border-color: var(--wfm-accent, #4f8ef7);
|
||||
}
|
||||
|
||||
/* ── Metadataタブ(Workflow全幅表示) ─────────────────────── */
|
||||
|
||||
.wfm-gallery-meta-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wfm-gallery-meta-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfm-gallery-meta-status {
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-workflow-json {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--wfm-text-primary);
|
||||
background: var(--wfm-bg-secondary, #1a1a2e);
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* グループ */
|
||||
.wfm-gallery-current-groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.wfm-gallery-groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfm-gallery-group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
background: var(--wfm-bg-primary, #16213e);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-gallery-group-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--wfm-text-primary);
|
||||
}
|
||||
|
||||
/* ── 一括操作バー ──────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(245,158,11,0.12);
|
||||
border-bottom: 1px solid rgba(245,158,11,0.35);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wfm-gallery-bulk-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
white-space: nowrap;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ── ライトボックス ─────────────────────────────────────────── */
|
||||
|
||||
.wfm-gallery-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.9);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-img {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-footer {
|
||||
margin-top: 8px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-close {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wfm-gallery-lightbox-close:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
|
||||
|
||||
.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;
|
||||
}
|
||||
+490
-25
@@ -250,12 +250,15 @@ body {
|
||||
|
||||
.wfm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 161px);
|
||||
grid-template-columns: repeat(auto-fill, 178px);
|
||||
grid-auto-rows: max-content;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -263,8 +266,8 @@ body {
|
||||
============================================ */
|
||||
|
||||
.wfm-card {
|
||||
width: 161px;
|
||||
min-width: 161px;
|
||||
width: 178px;
|
||||
min-width: 178px;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
@@ -280,8 +283,8 @@ body {
|
||||
}
|
||||
|
||||
.wfm-card-thumb {
|
||||
width: 161px;
|
||||
height: 162px;
|
||||
width: 178px;
|
||||
height: 200px;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -732,7 +735,29 @@ body {
|
||||
|
||||
.wfm-settings-container {
|
||||
padding: 24px;
|
||||
max-width: 700px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wfm-settings-layout {
|
||||
/* layout wrapper — Theme floats right inside this */
|
||||
}
|
||||
|
||||
.wfm-settings-left-col {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-settings-right-col {
|
||||
float: right;
|
||||
width: 50%;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: calc(100vh - var(--wfm-header-height, 96px) - 48px);
|
||||
overflow-y: auto;
|
||||
margin-left: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -827,26 +852,38 @@ body {
|
||||
============================================ */
|
||||
|
||||
.wfm-view-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important;
|
||||
gap: 6px !important;
|
||||
grid-template-columns: repeat(auto-fill, 220px) !important;
|
||||
grid-auto-rows: max-content !important;
|
||||
gap: 8px !important;
|
||||
justify-items: stretch !important;
|
||||
align-items: start !important;
|
||||
}
|
||||
|
||||
.wfm-view-card .wfm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 220px !important;
|
||||
min-width: 220px !important;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.wfm-view-card .wfm-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wfm-view-card .wfm-fav-btn {
|
||||
flex-shrink: 0;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.wfm-view-card .wfm-card-title {
|
||||
margin-bottom: 4px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -878,6 +915,13 @@ body {
|
||||
.wfm-view-table {
|
||||
display: block !important;
|
||||
overflow-y: auto;
|
||||
justify-items: stretch !important;
|
||||
}
|
||||
|
||||
.wfm-view-table .wfm-card,
|
||||
.wfm-view-table .wfm-model-card {
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.wfm-table {
|
||||
@@ -1007,6 +1051,7 @@ body {
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
background: var(--wfm-surface);
|
||||
flex-shrink: 0;
|
||||
min-height: 37px;
|
||||
}
|
||||
|
||||
.wfm-side-panel-title {
|
||||
@@ -1018,18 +1063,13 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfm-side-panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
.wfm-side-panel-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wfm-side-panel-close:hover {
|
||||
color: var(--wfm-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Side Panel Tabs */
|
||||
@@ -1883,6 +1923,287 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Prompt Tab — Center column tabs
|
||||
============================================ */
|
||||
|
||||
.wfm-prompt-center-tabnav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-prompt-center-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.wfm-prompt-center-tab:hover {
|
||||
color: var(--wfm-text);
|
||||
}
|
||||
|
||||
.wfm-prompt-center-tab.active {
|
||||
color: var(--wfm-text);
|
||||
border-bottom-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-prompt-center-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Wildcard Panel
|
||||
============================================ */
|
||||
|
||||
.wfm-wc-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Input assistance section */
|
||||
.wfm-wc-input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wildcard syntax button toolbar */
|
||||
.wfm-wc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.wfm-wc-btn {
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wfm-wc-btn:hover {
|
||||
background: var(--wfm-surface-2, var(--wfm-surface));
|
||||
border-color: var(--wfm-primary);
|
||||
color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-wc-prompt-ta {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wfm-wc-prompt-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File picker popup */
|
||||
.wfm-wc-file-picker-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wfm-wc-file-picker {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
background: var(--wfm-bg);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
min-width: 160px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.wfm-wc-picker-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
color: var(--wfm-text);
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-wc-picker-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wfm-wc-picker-item:hover {
|
||||
background: var(--wfm-surface);
|
||||
color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
/* File manager section */
|
||||
.wfm-wc-files-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--wfm-border);
|
||||
padding-top: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wfm-wc-files-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wfm-wc-files-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--wfm-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.wfm-wc-file-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--wfm-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item:hover {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
|
||||
.wfm-wc-file-item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfm-wc-file-name {
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--wfm-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfm-wc-file-ext {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
background: var(--wfm-surface);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfm-wc-file-item:hover .wfm-wc-file-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wfm-wc-use-btn {
|
||||
font-size: 10px !important;
|
||||
color: var(--wfm-primary) !important;
|
||||
}
|
||||
|
||||
/* File editor */
|
||||
.wfm-wc-editor {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius);
|
||||
padding: 8px;
|
||||
background: var(--wfm-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-header {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-hint {
|
||||
font-size: 10px;
|
||||
color: var(--wfm-text-secondary);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-ta {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.wfm-wc-editor-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Image Input (I2I) Slots
|
||||
============================================ */
|
||||
@@ -2885,6 +3206,7 @@ body {
|
||||
#wfm-nodes-grid.wfm-view-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
/* Node card: package color left border, auto width */
|
||||
@@ -3194,3 +3516,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;
|
||||
}
|
||||
|
||||
+23
-4
@@ -90,7 +90,7 @@ function initTabs() {
|
||||
|
||||
function applyI18nToHtml() {
|
||||
// Tab labels
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
|
||||
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
|
||||
document.querySelectorAll(".wfm-tab").forEach((tab) => {
|
||||
const key = tabMap[tab.dataset.tab];
|
||||
if (key) tab.textContent = t(key);
|
||||
@@ -188,6 +188,14 @@ function applyI18nToHtml() {
|
||||
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
|
||||
});
|
||||
|
||||
// Gallery tab
|
||||
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
|
||||
gallerySideTabBtns.forEach(btn => {
|
||||
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
|
||||
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
|
||||
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
|
||||
});
|
||||
|
||||
// Prompt tab
|
||||
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
|
||||
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
|
||||
@@ -260,11 +268,13 @@ function applyI18nToHtml() {
|
||||
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
|
||||
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
|
||||
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
|
||||
"wfm-help-prompt-7": "helpPrompt7",
|
||||
"wfm-help-prompt-7": "helpPrompt7", "wfm-help-prompt-8": "helpPrompt8",
|
||||
"wfm-help-prompt-9": "helpPrompt9",
|
||||
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
|
||||
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
|
||||
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"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",
|
||||
@@ -272,14 +282,21 @@ 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",
|
||||
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
|
||||
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
|
||||
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
|
||||
"wfm-help-sidepanel-title": "helpSidepanelTitle",
|
||||
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
|
||||
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
|
||||
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
|
||||
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
|
||||
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
|
||||
"wfm-help-sidepanel-11": "helpSidepanel11",
|
||||
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
|
||||
"wfm-help-shortcuts-title": "helpShortcutsTitle",
|
||||
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
|
||||
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
|
||||
@@ -335,6 +352,7 @@ import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
|
||||
// Apply saved theme immediately to prevent flash of default theme
|
||||
applyTheme(getSavedTheme());
|
||||
@@ -349,6 +367,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initModelsTab();
|
||||
initGenerateTab();
|
||||
initPromptTab();
|
||||
initGalleryTab();
|
||||
|
||||
console.log("Workflow Studio: App initialized");
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+256
-71
@@ -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)
|
||||
|
||||
+204
-9
@@ -20,6 +20,7 @@ const LANGUAGES = {
|
||||
// -- Tabs --
|
||||
tabWorkflow: "Workflow",
|
||||
tabGenerate: "GenerateUI",
|
||||
tabGallery: "Gallery",
|
||||
tabSettings: "Settings",
|
||||
|
||||
// -- Workflow toolbar --
|
||||
@@ -167,6 +168,10 @@ const LANGUAGES = {
|
||||
tabNodes: "Nodes",
|
||||
nodesAllNodes: "All Nodes",
|
||||
nodesNodeSets: "Node Sets",
|
||||
nodesCategoryTab: "Category",
|
||||
nodesPackageTab: "Package",
|
||||
nodesSelectCategory: "Select a category...",
|
||||
nodesSelectPackage: "Select a package...",
|
||||
nodesSearchPlaceholder: "Search nodes...",
|
||||
nodesAllCategories: "All Categories",
|
||||
nodesAllPackages: "All Packages",
|
||||
@@ -251,6 +256,7 @@ const LANGUAGES = {
|
||||
modelsCopiedPath: "File path copied",
|
||||
modelsNoPreview: "No preview",
|
||||
modelsTagsHint: "(comma separated)",
|
||||
modelsAllDirs: "All Folders",
|
||||
modelsAllGroups: "All Groups",
|
||||
modelsCurrentGroups: "Current Groups",
|
||||
modelsNoGroup: "Not assigned to any group",
|
||||
@@ -267,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)",
|
||||
@@ -284,6 +312,17 @@ const LANGUAGES = {
|
||||
modelsSideInfo: "Info",
|
||||
modelsSideGroup: "Group",
|
||||
modelsSideCivitai: "CivitAI",
|
||||
gallerySideMetadata: "Metadata",
|
||||
|
||||
// -- Gallery --
|
||||
galleryAllTags: "All Tags",
|
||||
galleryFolders: "Folders",
|
||||
gallerySelectFolder: "Select a folder to browse images.",
|
||||
galleryNoImages: "No images found.",
|
||||
gallerySearchPlaceholder: "Search images...",
|
||||
galleryOutputDir: "Gallery Output Folder",
|
||||
galleryOutputDirLabel: "Output Folder Path",
|
||||
galleryOutputDirHint: "Set the ComfyUI output folder to browse in the Gallery tab. Leave blank to use ComfyUI's default output directory.",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "Help",
|
||||
@@ -316,6 +355,8 @@ const LANGUAGES = {
|
||||
helpPrompt5: "Preset Manager: browse all presets, favorites, and group-based filtering",
|
||||
helpPrompt6: "Group management: create groups, assign/remove presets, delete groups",
|
||||
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
|
||||
helpPrompt8: "Wildcard toolbar: one-click buttons to insert wildcard syntax ({|}, {n$|}, __|__, <lora:>, etc.); wraps selected text",
|
||||
helpPrompt9: "Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor",
|
||||
helpSettings1: "UI language and summary language",
|
||||
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
|
||||
helpSettings3: "Workflow data folder path",
|
||||
@@ -323,7 +364,9 @@ 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",
|
||||
helpSettings10: "Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (directory junction on Windows / symlink on other OS); existing files migrated automatically",
|
||||
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",
|
||||
@@ -332,17 +375,30 @@ 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)",
|
||||
helpGallery3: "Thumbnail and Table view modes; double-click any image to open fullscreen lightbox",
|
||||
helpGallery4: "Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name",
|
||||
helpGallery5: "Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All",
|
||||
helpGallery6: "Detail panel — Info tab: filename, size, date, tags (add/remove), memo with save",
|
||||
helpGallery7: "Detail panel — Metadata tab: embedded ComfyUI workflow JSON viewer with one-click clipboard copy",
|
||||
helpGallery8: "Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image",
|
||||
helpGallery9: "Supports PNG (tEXt/iTXt metadata), JPEG, WebP, GIF; PNG workflow extraction reads ComfyUI-embedded JSON",
|
||||
helpGallery10: "Folder tree: click a folder to load its images; expand subfolders with ▶ arrow",
|
||||
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
|
||||
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
|
||||
helpSidepanel2: "Workflows tab: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view",
|
||||
helpSidepanel3: "Nodes tab: All / ★ Favorites / Groups / Sets sub-tabs; ★ shown for favorites in All view",
|
||||
helpSidepanel3: "Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes by category or custom node package",
|
||||
helpSidepanel4: "Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs",
|
||||
helpSidepanel5: "Drag & drop workflows onto the canvas to load them",
|
||||
helpSidepanel6: "Drag & drop nodes/node sets onto the canvas to place them",
|
||||
@@ -351,6 +407,7 @@ const LANGUAGES = {
|
||||
helpSidepanel9: "Double-click items to load/place without dragging",
|
||||
helpSidepanel10: "Search within each sub-tab to quickly find items",
|
||||
helpSidepanel11: "Models tab: All / ★ Favorites / Groups / By Type sub-tabs",
|
||||
helpSidepanel12: "⚙ Theme button (header): customize panel background, sub-header background, text, border, and secondary text colors — changes are saved to localStorage and applied on next open",
|
||||
helpShortcutsTitle: "Keyboard Shortcuts",
|
||||
helpShortcuts1: "Escape: Close modal/panel",
|
||||
helpShortcuts2: "Enter: Confirm rename, send chat",
|
||||
@@ -404,6 +461,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",
|
||||
@@ -481,6 +546,7 @@ const LANGUAGES = {
|
||||
// -- Tabs --
|
||||
tabWorkflow: "ワークフロー",
|
||||
tabGenerate: "生成UI",
|
||||
tabGallery: "ギャラリー",
|
||||
tabSettings: "設定",
|
||||
|
||||
// -- Workflow toolbar --
|
||||
@@ -628,6 +694,10 @@ const LANGUAGES = {
|
||||
tabNodes: "ノード",
|
||||
nodesAllNodes: "全ノード",
|
||||
nodesNodeSets: "ノードセット",
|
||||
nodesCategoryTab: "カテゴリ",
|
||||
nodesPackageTab: "パッケージ",
|
||||
nodesSelectCategory: "カテゴリを選択...",
|
||||
nodesSelectPackage: "パッケージを選択...",
|
||||
nodesSearchPlaceholder: "ノードを検索...",
|
||||
nodesAllCategories: "全カテゴリ",
|
||||
nodesAllPackages: "全パッケージ",
|
||||
@@ -712,6 +782,7 @@ const LANGUAGES = {
|
||||
modelsCopiedPath: "ファイルパスをコピーしました",
|
||||
modelsNoPreview: "プレビューなし",
|
||||
modelsTagsHint: "(カンマ区切り)",
|
||||
modelsAllDirs: "すべてのフォルダ",
|
||||
modelsAllGroups: "すべてのグループ",
|
||||
modelsCurrentGroups: "所属グループ",
|
||||
modelsNoGroup: "グループに未所属",
|
||||
@@ -728,6 +799,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: "ハッシュ計算中...(大きいファイルは時間がかかります)",
|
||||
@@ -745,6 +838,17 @@ const LANGUAGES = {
|
||||
modelsSideInfo: "基本情報",
|
||||
modelsSideGroup: "グループ",
|
||||
modelsSideCivitai: "CivitAI",
|
||||
gallerySideMetadata: "メタデータ",
|
||||
|
||||
// -- Gallery --
|
||||
galleryAllTags: "すべてのタグ",
|
||||
galleryFolders: "フォルダ",
|
||||
gallerySelectFolder: "フォルダを選択して画像を表示します。",
|
||||
galleryNoImages: "画像が見つかりません。",
|
||||
gallerySearchPlaceholder: "画像を検索...",
|
||||
galleryOutputDir: "ギャラリー Outputフォルダ",
|
||||
galleryOutputDirLabel: "Outputフォルダのパス",
|
||||
galleryOutputDirHint: "ギャラリータブで参照するComfyUIのoutputフォルダを設定します。空欄の場合はComfyUIのデフォルトoutputディレクトリを使用します。",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "ヘルプ",
|
||||
@@ -777,6 +881,8 @@ const LANGUAGES = {
|
||||
helpPrompt5: "プリセットマネージャー:全プリセット一覧、お気に入り、グループ別フィルタリング",
|
||||
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
|
||||
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
|
||||
helpPrompt8: "ワイルドカードツールバー:{|}・{n$|}・__|__・<lora:>などの構文をワンクリック挿入。選択テキストのラップも対応",
|
||||
helpPrompt9: "ワイルドカードファイルマネージャー:.txt/.yamlファイルの作成・編集・削除。ファイル名をクリックすると__ファイル名__をカーソル位置に挿入",
|
||||
helpSettings1: "UI言語と要約言語の設定",
|
||||
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
|
||||
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
|
||||
@@ -784,7 +890,9 @@ const LANGUAGES = {
|
||||
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
|
||||
helpSettings6: "デフォルトワークフローの設定・解除",
|
||||
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
|
||||
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpSettings8: "データ管理:設定・メタデータ・プロンプトなど全データをJSONにエクスポート、またはインポートして復元。インポート後はComfyUIを再起動してください。",
|
||||
helpSettings9: "各セクションはアコーディオン式で折りたたみ可能",
|
||||
helpSettings10: "Wildcard Integration:WFSのワイルドカードディレクトリをComfyUI-Impact-Packのwildcardsディレクトリへリンク(Windowsはジャンクション、他OSはシンボリックリンク)。既存ファイルは自動で移行されます",
|
||||
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
|
||||
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
|
||||
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
|
||||
@@ -793,17 +901,30 @@ 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カラムレイアウト: フォルダツリー (左) | 画像グリッド (中央) | 詳細パネル (右)",
|
||||
helpGallery3: "サムネイル・テーブル表示モード切替;ダブルクリックで全画面ライトボックス表示",
|
||||
helpGallery4: "ツールバーフィルタ:お気に入り(★)、テキスト検索、タグフィルタ、グループフィルタ、日付・名前ソート",
|
||||
helpGallery5: "Ctrl+クリックで複数選択 — 一括操作:グループへ追加、一括お気に入り、一括お気に入り解除",
|
||||
helpGallery6: "詳細パネル — Infoタブ:ファイル名、サイズ、日付、タグ(追加/削除)、メモ(保存機能付き)",
|
||||
helpGallery7: "詳細パネル — Metadataタブ:埋め込みComfyUIワークフローJSONビューアとワンクリッククリップボードコピー",
|
||||
helpGallery8: "詳細パネル — Groupsタブ:画像ごとのグループ作成、割り当て、解除、名前変更、削除",
|
||||
helpGallery9: "PNG (tEXt/iTXt メタデータ), JPEG, WebP, GIF対応; PNGはComfyUIの埋め込みJSONを読み込んでワークフロー抽出可能",
|
||||
helpGallery10: "フォルダツリー:フォルダをクリックして画像を読み込む;▶矢印でサブフォルダを展開",
|
||||
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
|
||||
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
|
||||
helpSidepanel2: "Workflowsタブ:All / ★ Favorites / Groupsサブタブ、Allビューでお気に入りに★表示",
|
||||
helpSidepanel3: "Nodesタブ:All / ★ Favorites / Groups / Setsサブタブ、Allビューでお気に入りに★表示",
|
||||
helpSidepanel3: "Nodesタブ:All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Packageサブタブ;CategoryとPackageはドロップダウンでカテゴリ・カスタムノードパッケージ別に絞り込み可能",
|
||||
helpSidepanel4: "Promptsタブ:プロンプトプリセットの閲覧(All/お気に入り/カテゴリ サブタブ)",
|
||||
helpSidepanel5: "ワークフローをキャンバスにドラッグ&ドロップで読み込み",
|
||||
helpSidepanel6: "ノード/ノードセットをキャンバスにドラッグ&ドロップで配置",
|
||||
@@ -812,6 +933,7 @@ const LANGUAGES = {
|
||||
helpSidepanel9: "ダブルクリックでもアイテムの読み込み・配置が可能",
|
||||
helpSidepanel10: "各サブタブ内で検索してアイテムをすばやく検索",
|
||||
helpSidepanel11: "Modelsタブ:All / ★ Favorites / Groups / By Typeサブタブ",
|
||||
helpSidepanel12: "⚙ テーマボタン(ヘッダー):パネル背景・サブヘッダー背景・文字色・ボーダー・補助テキスト色をカスタマイズ可能、設定はlocalStorageに保存され次回起動時も反映",
|
||||
helpShortcutsTitle: "キーボード操作",
|
||||
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
|
||||
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
|
||||
@@ -865,6 +987,14 @@ const LANGUAGES = {
|
||||
eagleIntegration: "Eagle 連携 (オプション)",
|
||||
eagleUrl: "Eagle URL",
|
||||
eagleAutoSave: "生成画像をEagleに自動保存",
|
||||
dataManagement: "データ管理",
|
||||
dataManagementHint: "設定・メタデータ・プロンプトなど全データをJSONファイルにエクスポート、またはインポートして復元できます。インポート後はComfyUIを再起動してください。",
|
||||
exportData: "エクスポート",
|
||||
importData: "インポート",
|
||||
exportSuccess: "エクスポート完了",
|
||||
exportError: "エクスポート失敗",
|
||||
importSuccess: "インポート完了",
|
||||
importError: "インポート失敗",
|
||||
saveSettings: "設定を保存",
|
||||
settingsSaved: "設定を保存しました",
|
||||
ollamaSaved: "Ollama設定を保存しました",
|
||||
@@ -942,6 +1072,7 @@ const LANGUAGES = {
|
||||
// -- Tabs --
|
||||
tabWorkflow: "工作流",
|
||||
tabGenerate: "生成UI",
|
||||
tabGallery: "图库",
|
||||
tabSettings: "设置",
|
||||
|
||||
// -- Workflow toolbar --
|
||||
@@ -1085,6 +1216,10 @@ const LANGUAGES = {
|
||||
tabNodes: "节点",
|
||||
nodesAllNodes: "所有节点",
|
||||
nodesNodeSets: "节点集",
|
||||
nodesCategoryTab: "分类",
|
||||
nodesPackageTab: "包",
|
||||
nodesSelectCategory: "选择分类...",
|
||||
nodesSelectPackage: "选择包...",
|
||||
nodesSearchPlaceholder: "搜索节点...",
|
||||
nodesAllCategories: "所有分类",
|
||||
nodesAllPackages: "所有包",
|
||||
@@ -1169,6 +1304,7 @@ const LANGUAGES = {
|
||||
modelsCopiedPath: "文件路径已复制",
|
||||
modelsNoPreview: "无预览",
|
||||
modelsTagsHint: "(逗号分隔)",
|
||||
modelsAllDirs: "所有文件夹",
|
||||
modelsAllGroups: "所有分组",
|
||||
modelsCurrentGroups: "当前分组",
|
||||
modelsNoGroup: "未分配到任何分组",
|
||||
@@ -1185,6 +1321,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: "计算哈希中...(大文件可能需要较长时间)",
|
||||
@@ -1202,6 +1360,17 @@ const LANGUAGES = {
|
||||
modelsSideInfo: "基本信息",
|
||||
modelsSideGroup: "分组",
|
||||
modelsSideCivitai: "CivitAI",
|
||||
gallerySideMetadata: "元数据",
|
||||
|
||||
// -- Gallery --
|
||||
galleryAllTags: "所有标签",
|
||||
galleryFolders: "文件夹",
|
||||
gallerySelectFolder: "选择文件夹以浏览图片。",
|
||||
galleryNoImages: "未找到图片。",
|
||||
gallerySearchPlaceholder: "搜索图片...",
|
||||
galleryOutputDir: "图库输出文件夹",
|
||||
galleryOutputDirLabel: "输出文件夹路径",
|
||||
galleryOutputDirHint: "设置图库标签页浏览的ComfyUI输出文件夹。留空则使用ComfyUI默认输出目录。",
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "帮助",
|
||||
@@ -1234,6 +1403,8 @@ const LANGUAGES = {
|
||||
helpPrompt5: "预设管理器:浏览所有预设、收藏、按组筛选",
|
||||
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
|
||||
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
|
||||
helpPrompt8: "通配符工具栏:一键插入通配符语法({|}、{n$|}、__|__、<lora:>等);支持包裹选中文本",
|
||||
helpPrompt9: "通配符文件管理:创建/编辑/删除.txt/.yaml通配符文件;点击文件名在光标处插入__文件名__",
|
||||
helpSettings1: "UI语言和摘要语言设置",
|
||||
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
|
||||
helpSettings3: "工作流数据文件夹路径更改/重置",
|
||||
@@ -1241,7 +1412,9 @@ const LANGUAGES = {
|
||||
helpSettings5: "Ollama URL、默认模型、连接测试",
|
||||
helpSettings6: "默认工作流设置/取消",
|
||||
helpSettings7: "Eagle集成URL、自动保存开关",
|
||||
helpSettings8: "各部分可折叠展开",
|
||||
helpSettings8: "数据管理:将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入以恢复。导入后请重启ComfyUI。",
|
||||
helpSettings9: "各部分可折叠展开",
|
||||
helpSettings10: "Wildcard Integration:将WFS通配符目录链接到ComfyUI-Impact-Pack的wildcards目录(Windows为目录联结,其他OS为符号链接);现有文件自动迁移",
|
||||
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
|
||||
helpNodes2: "卡片和表格视图模式切换(带分页)",
|
||||
helpNodes3: "收藏节点,添加自定义标签进行整理",
|
||||
@@ -1250,17 +1423,30 @@ 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: "三列布局:文件夹树(左) | 图像网格(中) | 详情面板(右)",
|
||||
helpGallery3: "缩略图和表格视图模式;双击任何图像可在全屏灯箱中打开",
|
||||
helpGallery4: "工具栏过滤器:收藏 (★)、文本搜索、标签过滤、分组过滤、按日期/名称排序",
|
||||
helpGallery5: "使用 Ctrl+点击 多选 — 批量操作:添加到分组、全部收藏、取消全部收藏",
|
||||
helpGallery6: "详情面板 — 信息(Info)标签:文件名、大小、日期、标签(添加/移除)、包含保存功能的备注",
|
||||
helpGallery7: "详情面板 — 元数据(Metadata)标签:ComfyUI 嵌入的内置工作流 JSON 查看器及一键复制功能",
|
||||
helpGallery8: "详情面板 — 分组(Groups)标签:对每个图像进行组的创建、分配、移除、重命名和删除",
|
||||
helpGallery9: "支持 PNG(tEXt/iTXt 元数据)、JPEG、WebP、GIF;对于 PNG 会读取 ComfyUI 内置的 JSON 提取工作流",
|
||||
helpGallery10: "文件夹树:点击文件夹加载其包含的图像;使用 ▶ 箭头展开子文件夹",
|
||||
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
|
||||
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
|
||||
helpSidepanel2: "Workflows标签:All / ★ Favorites / Groups子标签,All视图中收藏项显示★",
|
||||
helpSidepanel3: "Nodes标签:All / ★ Favorites / Groups / Sets子标签,All视图中收藏项显示★",
|
||||
helpSidepanel3: "Nodes标签:All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package子标签;Category和Package可通过下拉菜单按分类或自定义节点包筛选节点",
|
||||
helpSidepanel4: "Prompts标签:浏览提示词预设(全部/收藏/分类 子标签)",
|
||||
helpSidepanel5: "将工作流拖放到画布上即可加载",
|
||||
helpSidepanel6: "将节点/节点集拖放到画布上即可放置",
|
||||
@@ -1269,6 +1455,7 @@ const LANGUAGES = {
|
||||
helpSidepanel9: "双击项目也可加载/放置",
|
||||
helpSidepanel10: "在各子标签内搜索快速查找项目",
|
||||
helpSidepanel11: "Models标签:All / ★ Favorites / Groups / By Type子标签",
|
||||
helpSidepanel12: "⚙ 主题按钮(标题栏):可自定义面板背景、子标题背景、文字颜色、边框及辅助文字颜色,设置保存至localStorage并在下次打开时生效",
|
||||
helpShortcutsTitle: "键盘操作",
|
||||
helpShortcuts1: "Escape:关闭弹窗/面板",
|
||||
helpShortcuts2: "Enter:确认重命名、发送聊天",
|
||||
@@ -1322,6 +1509,14 @@ const LANGUAGES = {
|
||||
eagleIntegration: "Eagle 集成 (可选)",
|
||||
eagleUrl: "Eagle URL",
|
||||
eagleAutoSave: "自动保存生成图片到Eagle",
|
||||
dataManagement: "数据管理",
|
||||
dataManagementHint: "将所有插件数据(设置、元数据、提示词等)导出为JSON文件,或导入之前导出的文件以恢复数据。导入后请重启ComfyUI以使更改生效。",
|
||||
exportData: "导出",
|
||||
importData: "导入",
|
||||
exportSuccess: "导出完成",
|
||||
exportError: "导出失败",
|
||||
importSuccess: "导入完成",
|
||||
importError: "导入失败",
|
||||
saveSettings: "保存设置",
|
||||
settingsSaved: "设置已保存",
|
||||
ollamaSaved: "Ollama设置已保存",
|
||||
|
||||
+463
-74
@@ -23,11 +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",
|
||||
@@ -36,7 +42,6 @@ const state = {
|
||||
currentPage: 0,
|
||||
};
|
||||
|
||||
const MODELS_PER_PAGE = 24;
|
||||
|
||||
const FETCH_MAP = {
|
||||
checkpoint: () => comfyUI.fetchCheckpoints(),
|
||||
@@ -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">×</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];
|
||||
@@ -257,6 +487,13 @@ function filterModels() {
|
||||
});
|
||||
}
|
||||
|
||||
if (state.dirFilter) {
|
||||
models = models.filter((m) => {
|
||||
const { dir } = parseModelPath(m);
|
||||
return dir === state.dirFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.groupFilter) {
|
||||
const members = state.modelGroups[state.groupFilter] || [];
|
||||
models = models.filter((m) => members.includes(m));
|
||||
@@ -421,14 +658,55 @@ 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() {
|
||||
const select = document.getElementById("wfm-models-dir-filter");
|
||||
if (!select) return;
|
||||
const dirs = [...new Set(
|
||||
getCurrentModels()
|
||||
.map((m) => parseModelPath(m).dir)
|
||||
.filter((d) => d !== "")
|
||||
)].sort();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllDirs")}</option>` +
|
||||
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
|
||||
select.value = state.dirFilter;
|
||||
}
|
||||
|
||||
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() : {};
|
||||
@@ -437,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 ──────────────────────────────────────────
|
||||
@@ -466,27 +746,23 @@ function renderModelGrid() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const start = state.currentPage * MODELS_PER_PAGE;
|
||||
const page = filtered.slice(start, start + MODELS_PER_PAGE);
|
||||
const totalPages = Math.ceil(filtered.length / MODELS_PER_PAGE);
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, page, totalPages);
|
||||
renderTableView(grid, filtered);
|
||||
} else if (state.viewMode === "card") {
|
||||
renderCardView(grid, page, totalPages);
|
||||
renderCardView(grid, filtered);
|
||||
} else {
|
||||
renderThumbView(grid, page, totalPages);
|
||||
renderThumbView(grid, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thumbnail View (same as Workflow tab) ─────────────────
|
||||
|
||||
function renderThumbView(grid, models, totalPages) {
|
||||
function renderThumbView(grid, models) {
|
||||
grid.innerHTML = "";
|
||||
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";
|
||||
@@ -494,6 +770,7 @@ function renderThumbView(grid, models, totalPages) {
|
||||
|
||||
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;
|
||||
|
||||
@@ -501,12 +778,14 @@ function renderThumbView(grid, models, totalPages) {
|
||||
<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");
|
||||
@@ -517,25 +796,37 @@ function renderThumbView(grid, models, totalPages) {
|
||||
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);
|
||||
});
|
||||
|
||||
updatePagination(totalPages);
|
||||
}
|
||||
|
||||
// ── Card View (no thumbnail, compact) ─────────────────────
|
||||
|
||||
function renderCardView(grid, models, totalPages) {
|
||||
function renderCardView(grid, models) {
|
||||
grid.innerHTML = "";
|
||||
models.forEach((modelName) => {
|
||||
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";
|
||||
@@ -543,47 +834,66 @@ function renderCardView(grid, models, totalPages) {
|
||||
|
||||
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">
|
||||
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
|
||||
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
|
||||
${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);
|
||||
});
|
||||
|
||||
updatePagination(totalPages);
|
||||
}
|
||||
|
||||
// ── Table View ────────────────────────────────────────────
|
||||
|
||||
function renderTableView(grid, models, totalPages) {
|
||||
function renderTableView(grid, models) {
|
||||
const rows = models
|
||||
.map((modelName) => {
|
||||
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>
|
||||
@@ -591,11 +901,14 @@ function renderTableView(grid, models, totalPages) {
|
||||
<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>
|
||||
@@ -603,42 +916,31 @@ function renderTableView(grid, models, totalPages) {
|
||||
<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);
|
||||
});
|
||||
});
|
||||
|
||||
updatePagination(totalPages);
|
||||
}
|
||||
|
||||
function updatePagination(totalPages) {
|
||||
const container = document.getElementById("wfm-models-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${state.currentPage + 1} / ${totalPages}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderModelGrid(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderModelGrid(); }
|
||||
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(mn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -755,6 +1057,7 @@ function openDetailModal(modelName) {
|
||||
await saveModelMetadata(modelName, { tags, memo, badges });
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
@@ -805,9 +1108,6 @@ function openDetailModal(modelName) {
|
||||
|
||||
function showSidePanel(modelName) {
|
||||
state.selectedModel = modelName;
|
||||
const panel = document.getElementById("wfm-models-side-panel");
|
||||
if (!panel) return;
|
||||
panel.style.display = "flex";
|
||||
|
||||
// Highlight selected
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
@@ -830,9 +1130,9 @@ function showSidePanel(modelName) {
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById("wfm-models-side-panel");
|
||||
if (panel) panel.style.display = "none";
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.remove("wfm-card-selected");
|
||||
});
|
||||
@@ -1004,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>
|
||||
`;
|
||||
@@ -1080,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 ───────────────────────────────
|
||||
@@ -1208,7 +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;
|
||||
}
|
||||
@@ -1219,11 +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);
|
||||
@@ -1321,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();
|
||||
}
|
||||
|
||||
@@ -1343,8 +1681,18 @@ export function initModelsTab() {
|
||||
state.searchText = "";
|
||||
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");
|
||||
@@ -1374,9 +1722,50 @@ export function initModelsTab() {
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// Group filter
|
||||
document.getElementById("wfm-models-group-filter")?.addEventListener("change", (e) => {
|
||||
state.groupFilter = e.target.value;
|
||||
// Dir filter
|
||||
document.getElementById("wfm-models-dir-filter")?.addEventListener("change", (e) => {
|
||||
state.dirFilter = e.target.value;
|
||||
state.currentPage = 0;
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -1419,14 +1808,14 @@ 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();
|
||||
});
|
||||
|
||||
// Side panel close
|
||||
document.getElementById("wfm-models-panel-close")?.addEventListener("click", closeSidePanel);
|
||||
|
||||
// Side panel tab switching
|
||||
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
|
||||
+127
-118
@@ -8,7 +8,6 @@ import { t } from "./i18n.js";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────
|
||||
|
||||
const NODES_PER_PAGE = 50;
|
||||
|
||||
const state = {
|
||||
allNodes: [],
|
||||
@@ -257,91 +256,50 @@ function renderNodeGrid() {
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
|
||||
clearPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderNodeTableView(grid, filtered);
|
||||
clearPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
// Card view with pagination
|
||||
const totalPages = Math.ceil(filtered.length / NODES_PER_PAGE);
|
||||
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
|
||||
if (state.currentPage < 0) state.currentPage = 0;
|
||||
|
||||
const start = state.currentPage * NODES_PER_PAGE;
|
||||
const pageItems = filtered.slice(start, start + NODES_PER_PAGE);
|
||||
|
||||
grid.innerHTML = "";
|
||||
pageItems.forEach(node => {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card wfm-node-card";
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
card.classList.add("wfm-card-selected");
|
||||
}
|
||||
card.dataset.nodeName = node.name;
|
||||
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
|
||||
filtered.forEach(node => grid.appendChild(createNodeCard(node)));
|
||||
}
|
||||
|
||||
const inputCount = Object.keys(node.input.required || {}).length +
|
||||
Object.keys(node.input.optional || {}).length;
|
||||
const outputCount = (node.output || []).length;
|
||||
const tags = (meta.tags || []).map(tg => `<span class="wfm-tag">#${escapeHtml(tg)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "\u2605" : "\u2606";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
function createNodeCard(node) {
|
||||
const meta = state.nodeMetadata[node.name] || {};
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card wfm-node-card";
|
||||
if (state.selectedNode && state.selectedNode.name === node.name) {
|
||||
card.classList.add("wfm-card-selected");
|
||||
}
|
||||
card.dataset.nodeName = node.name;
|
||||
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
|
||||
<div class="wfm-card-meta">
|
||||
${categoryBadgeHtml(node.category)}
|
||||
</div>
|
||||
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
|
||||
const tags = (meta.tags || []).map(tg => `<span class="wfm-tag">#${escapeHtml(tg)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "\u2605" : "\u2606";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
|
||||
<div class="wfm-card-meta">
|
||||
${categoryBadgeHtml(node.category)}
|
||||
</div>
|
||||
<button class="${favClass}" title="Favorite">${favStar}</button>`;
|
||||
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
|
||||
</div>
|
||||
<button class="${favClass}" title="Favorite">${favStar}</button>`;
|
||||
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
card.addEventListener("click", () => showNodeSidePanel(node));
|
||||
grid.appendChild(card);
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(node.name, e.currentTarget);
|
||||
});
|
||||
|
||||
renderPagination(filtered.length, totalPages);
|
||||
card.addEventListener("click", () => showNodeSidePanel(node));
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderPagination(totalItems, totalPages) {
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
|
||||
const start = state.currentPage * NODES_PER_PAGE + 1;
|
||||
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${start}-${end} / ${totalItems}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
}
|
||||
|
||||
function clearPagination() {
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (container) container.innerHTML = "";
|
||||
}
|
||||
|
||||
function scrollGridToTop() {
|
||||
document.getElementById("wfm-nodes-grid")?.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function renderNodeTableView(grid, filtered) {
|
||||
grid.innerHTML = "";
|
||||
@@ -399,9 +357,6 @@ async function toggleFavorite(nodeName, btnEl) {
|
||||
|
||||
function showNodeSidePanel(node) {
|
||||
state.selectedNode = node;
|
||||
const panel = document.getElementById("wfm-nodes-side-panel");
|
||||
if (!panel) return;
|
||||
panel.style.display = "flex";
|
||||
|
||||
// Highlight selected card
|
||||
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
|
||||
@@ -545,42 +500,61 @@ function renderSideGroups(node) {
|
||||
const el = document.getElementById("wfm-nodes-side-group");
|
||||
if (!el) return;
|
||||
|
||||
const groupNames = Object.keys(state.nodeGroups).sort();
|
||||
const memberOf = groupNames.filter(g => (state.nodeGroups[g] || []).includes(node.name));
|
||||
const allGroups = Object.keys(state.nodeGroups).sort();
|
||||
const memberOf = allGroups.filter(g => (state.nodeGroups[g] || []).includes(node.name));
|
||||
const available = allGroups.filter(g => !memberOf.includes(g));
|
||||
|
||||
let html = `<h4 style="margin:0 0 8px;">${t("nodesGroups")}</h4>`;
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map(g =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${available.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
|
||||
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input"
|
||||
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
|
||||
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-nodes-manage-group-select" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-nodes-rename-group-btn" class="wfm-btn wfm-btn-sm"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button id="wfm-nodes-delete-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Current groups
|
||||
if (memberOf.length) {
|
||||
html += memberOf.map(g =>
|
||||
`<div class="wfm-node-group-item">
|
||||
<span>${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-node-remove-group" data-group="${escapeHtml(g)}">${t("nodesRemoveGroup")}</button>
|
||||
</div>`
|
||||
).join("");
|
||||
} else {
|
||||
html += `<p style="color:var(--wfm-text-secondary);font-size:12px;">No groups assigned</p>`;
|
||||
}
|
||||
|
||||
// Assign to group
|
||||
const available = groupNames.filter(g => !memberOf.includes(g));
|
||||
html += `<div style="margin-top:12px;display:flex;gap:4px;">
|
||||
<select id="wfm-nodes-assign-group-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">${t("nodesAssignGroup")}</option>
|
||||
${available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-nodes-assign-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+</button>
|
||||
</div>`;
|
||||
|
||||
// Create new group
|
||||
html += `<div style="margin-top:8px;display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-nodes-new-group-input" class="wfm-input" style="flex:1;" placeholder="${t("nodesGroupName")}">
|
||||
<button id="wfm-nodes-create-group-btn" class="wfm-btn wfm-btn-sm">${t("nodesCreateGroup")}</button>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Event listeners
|
||||
// グループから除外
|
||||
el.querySelectorAll(".wfm-node-remove-group").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const group = btn.dataset.group;
|
||||
@@ -592,8 +566,9 @@ function renderSideGroups(node) {
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = document.getElementById("wfm-nodes-assign-group-select");
|
||||
// グループに追加
|
||||
el.querySelector("#wfm-nodes-assign-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-nodes-assign-group-select");
|
||||
const group = sel?.value;
|
||||
if (!group) return;
|
||||
if (!state.nodeGroups[group]) state.nodeGroups[group] = [];
|
||||
@@ -605,25 +580,61 @@ function renderSideGroups(node) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
|
||||
const input = document.getElementById("wfm-nodes-new-group-input");
|
||||
// グループ作成(作成後に現在のノードに追加)
|
||||
el.querySelector("#wfm-nodes-create-group-btn")?.addEventListener("click", async () => {
|
||||
const input = el.querySelector("#wfm-nodes-new-group-input");
|
||||
const name = input?.value?.trim();
|
||||
if (!name) return;
|
||||
if (!state.nodeGroups[name]) state.nodeGroups[name] = [];
|
||||
if (!state.nodeGroups[name].includes(node.name)) {
|
||||
state.nodeGroups[name].push(node.name);
|
||||
if (state.nodeGroups[name]) {
|
||||
showToast("Group already exists", "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[name] = [node.name];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
input.value = "";
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
});
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-nodes-rename-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-nodes-manage-group-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.nodeGroups[newName] !== undefined) {
|
||||
showToast("Group name already exists", "warning");
|
||||
return;
|
||||
}
|
||||
state.nodeGroups[newName] = state.nodeGroups[oldName];
|
||||
delete state.nodeGroups[oldName];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
el.querySelector("#wfm-nodes-delete-group-btn")?.addEventListener("click", async () => {
|
||||
const sel = el.querySelector("#wfm-nodes-manage-group-select");
|
||||
const name = sel?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
delete state.nodeGroups[name];
|
||||
await saveNodeGroups(state.nodeGroups);
|
||||
if (state.groupFilter === name) state.groupFilter = "";
|
||||
renderSideGroups(node);
|
||||
renderFilters();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
});
|
||||
}
|
||||
|
||||
function closeNodeSidePanel() {
|
||||
const panel = document.getElementById("wfm-nodes-side-panel");
|
||||
if (panel) panel.style.display = "none";
|
||||
state.selectedNode = null;
|
||||
const titleEl = document.getElementById("wfm-nodes-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
document.querySelectorAll("#wfm-nodes-grid .wfm-card, #wfm-nodes-grid tr").forEach(el => {
|
||||
el.classList.remove("wfm-card-selected");
|
||||
});
|
||||
@@ -1083,8 +1094,6 @@ export function initNodesTab() {
|
||||
loadNodesData();
|
||||
});
|
||||
|
||||
// Side panel close
|
||||
document.getElementById("wfm-nodes-panel-close")?.addEventListener("click", closeNodeSidePanel);
|
||||
|
||||
// Side panel tab switching
|
||||
document.querySelectorAll(".wfm-nodes-side-tab-btn").forEach(btn => {
|
||||
|
||||
@@ -593,6 +593,191 @@ function applyToGenerateUI(text) {
|
||||
showToast(t("appliedToGenerateUI"), "success");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard API helpers
|
||||
// ============================================
|
||||
|
||||
async function wcFetchFiles() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function wcFetchContent(filename) {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/wildcards/content?filename=${encodeURIComponent(filename)}`);
|
||||
const data = await res.json();
|
||||
return data.content ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function wcSaveFile(filename, content) {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename, content }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.status === "ok" ? data.file : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function wcDeleteFile(filename) {
|
||||
try {
|
||||
await fetch("/api/wfm/wildcards/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard state & helpers
|
||||
// ============================================
|
||||
|
||||
let wcFiles = [];
|
||||
let wcEditingFilename = null; // null = new file
|
||||
|
||||
// Insert text at cursor; if open+close provided wraps selection
|
||||
function wcInsertAtCursor(textarea, open, close = "") {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = textarea.value.substring(start, end);
|
||||
let inserted, cursorStart, cursorEnd;
|
||||
|
||||
if (selected && close) {
|
||||
inserted = open + selected + close;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart + selected.length;
|
||||
} else if (close) {
|
||||
inserted = open + close;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart;
|
||||
} else {
|
||||
inserted = open;
|
||||
cursorStart = start + open.length;
|
||||
cursorEnd = cursorStart;
|
||||
}
|
||||
|
||||
textarea.value =
|
||||
textarea.value.substring(0, start) +
|
||||
inserted +
|
||||
textarea.value.substring(end);
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file list rendering
|
||||
// ============================================
|
||||
|
||||
function wcRenderFileList() {
|
||||
const list = document.getElementById("wfm-wc-file-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (wcFiles.length === 0) {
|
||||
list.innerHTML = `<div class="wfm-pm-empty" style="font-size:12px;">No wildcard files yet.<br><small>Click "+ New" to create one.</small></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const f of wcFiles) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "wfm-wc-file-item";
|
||||
item.innerHTML = `
|
||||
<div class="wfm-wc-file-item-info">
|
||||
<span class="wfm-wc-file-name">${esc(f.name)}</span>
|
||||
<span class="wfm-wc-file-ext">.${esc(f.ext)}</span>
|
||||
</div>
|
||||
<div class="wfm-wc-file-item-actions">
|
||||
<button class="wfm-pm-action-btn wfm-wc-use-btn" title="Insert __${esc(f.name)}__ at cursor">Use</button>
|
||||
<button class="wfm-pm-action-btn wfm-wc-edit-btn" title="Edit file">✎</button>
|
||||
</div>
|
||||
`;
|
||||
// Insert reference into wildcard prompt
|
||||
item.querySelector(".wfm-wc-use-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.name}__`);
|
||||
});
|
||||
// Open editor
|
||||
item.querySelector(".wfm-wc-edit-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const content = await wcFetchContent(f.filename);
|
||||
wcOpenEditor(f.name, f.ext, content ?? "", f.filename);
|
||||
});
|
||||
// Click row = same as Use
|
||||
item.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.name}__`);
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file editor
|
||||
// ============================================
|
||||
|
||||
function wcOpenEditor(name, ext, content, existingFilename) {
|
||||
wcEditingFilename = existingFilename || null;
|
||||
|
||||
const editor = document.getElementById("wfm-wc-editor");
|
||||
const nameInput = document.getElementById("wfm-wc-editor-name");
|
||||
const extSelect = document.getElementById("wfm-wc-editor-ext");
|
||||
const contentTA = document.getElementById("wfm-wc-editor-content");
|
||||
const deleteBtn = document.getElementById("wfm-wc-editor-delete-btn");
|
||||
|
||||
if (!editor) return;
|
||||
nameInput.value = name || "";
|
||||
extSelect.value = ext || "txt";
|
||||
contentTA.value = content || "";
|
||||
deleteBtn.style.display = existingFilename ? "" : "none";
|
||||
editor.style.display = "";
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function wcCloseEditor() {
|
||||
wcEditingFilename = null;
|
||||
const editor = document.getElementById("wfm-wc-editor");
|
||||
if (editor) editor.style.display = "none";
|
||||
}
|
||||
|
||||
async function wcRefreshFiles() {
|
||||
wcFiles = await wcFetchFiles();
|
||||
wcRenderFileList();
|
||||
wcUpdateFilePicker();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wildcard file picker popup
|
||||
// ============================================
|
||||
|
||||
function wcUpdateFilePicker() {
|
||||
const picker = document.getElementById("wfm-wc-file-picker");
|
||||
if (!picker) return;
|
||||
picker.innerHTML = "";
|
||||
if (wcFiles.length === 0) {
|
||||
picker.innerHTML = `<div style="padding:8px;font-size:11px;color:var(--wfm-text-secondary);">No wildcard files</div>`;
|
||||
return;
|
||||
}
|
||||
for (const f of wcFiles) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "wfm-wc-picker-item";
|
||||
btn.textContent = `__${f.name}__`;
|
||||
btn.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) wcInsertAtCursor(ta, `__${f.name}__`);
|
||||
picker.style.display = "none";
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialize
|
||||
// ============================================
|
||||
@@ -853,4 +1038,151 @@ export function initPromptTab() {
|
||||
pmSearchText = e.target.value.trim();
|
||||
renderPresetManager();
|
||||
});
|
||||
|
||||
// ── Center column tab switching ──────────────────────────
|
||||
document.querySelectorAll(".wfm-prompt-center-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.dataset.ptab;
|
||||
document.querySelectorAll(".wfm-prompt-center-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.querySelectorAll(".wfm-prompt-center-pane").forEach(p => {
|
||||
p.style.display = p.id === `wfm-ptab-${target}` ? "" : "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Wildcard syntax buttons ──────────────────────────────
|
||||
document.querySelectorAll(".wfm-wc-btn[data-wc-open]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
const open = btn.dataset.wcOpen || "";
|
||||
const close = btn.dataset.wcClose || "";
|
||||
wcInsertAtCursor(ta, open, close);
|
||||
});
|
||||
});
|
||||
|
||||
// LoRA LBW template button
|
||||
document.getElementById("wfm-wc-lora-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
wcInsertAtCursor(ta, "<lora::1:LBW=;>");
|
||||
// Move cursor to after "lora:" for the name
|
||||
const pos = ta.selectionStart - "<lora::1:LBW=;>".length + 6;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
ta.focus();
|
||||
});
|
||||
|
||||
// n$${ } button — asks for n then inserts multi-pick template
|
||||
document.getElementById("wfm-wc-nset-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (!ta) return;
|
||||
const n = prompt("Number of items to pick (n):", "2");
|
||||
if (n === null) return;
|
||||
const num = parseInt(n, 10);
|
||||
if (isNaN(num) || num < 1) { showToast("Enter a positive integer", "error"); return; }
|
||||
const template = "{" + num + "$$|}";
|
||||
const start = ta.selectionStart;
|
||||
wcInsertAtCursor(ta, template);
|
||||
// Move cursor between $$ and |
|
||||
const inner = start + ("{" + num + "$$").length;
|
||||
ta.setSelectionRange(inner, inner);
|
||||
ta.focus();
|
||||
});
|
||||
|
||||
// File picker toggle
|
||||
const filePickBtn = document.getElementById("wfm-wc-filepick-btn");
|
||||
const filePicker = document.getElementById("wfm-wc-file-picker");
|
||||
filePickBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!filePicker) return;
|
||||
const isOpen = filePicker.style.display !== "none";
|
||||
filePicker.style.display = isOpen ? "none" : "";
|
||||
if (!isOpen) wcUpdateFilePicker();
|
||||
});
|
||||
document.addEventListener("click", () => {
|
||||
if (filePicker) filePicker.style.display = "none";
|
||||
});
|
||||
|
||||
// ── Wildcard prompt actions ──────────────────────────────
|
||||
document.getElementById("wfm-wc-copy-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
const text = ta?.value || "";
|
||||
if (!text.trim()) { showToast(t("noTextToCopy"), "error"); return; }
|
||||
navigator.clipboard.writeText(text).then(() => showToast(t("copiedToClipboard"), "success"));
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-pos-btn")?.addEventListener("click", () => {
|
||||
const src = document.getElementById("wfm-wc-prompt");
|
||||
const dst = document.getElementById("wfm-preset-pos");
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast("Sent to Positive Prompt", "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-to-neg-btn")?.addEventListener("click", () => {
|
||||
const src = document.getElementById("wfm-wc-prompt");
|
||||
const dst = document.getElementById("wfm-preset-neg");
|
||||
if (!src || !dst) return;
|
||||
dst.value = src.value;
|
||||
dst.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
showToast("Sent to Negative Prompt", "success");
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-clear-btn")?.addEventListener("click", () => {
|
||||
const ta = document.getElementById("wfm-wc-prompt");
|
||||
if (ta) { ta.value = ""; ta.focus(); }
|
||||
});
|
||||
|
||||
// ── Wildcard file manager ────────────────────────────────
|
||||
document.getElementById("wfm-wc-new-file-btn")?.addEventListener("click", () => {
|
||||
wcOpenEditor("", "txt", "", null);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-wc-files-refresh-btn")?.addEventListener("click", () => {
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
// Editor: save
|
||||
document.getElementById("wfm-wc-editor-save-btn")?.addEventListener("click", async () => {
|
||||
const nameInput = document.getElementById("wfm-wc-editor-name");
|
||||
const extSelect = document.getElementById("wfm-wc-editor-ext");
|
||||
const contentTA = document.getElementById("wfm-wc-editor-content");
|
||||
|
||||
const name = nameInput?.value.trim();
|
||||
const ext = extSelect?.value || "txt";
|
||||
const content = contentTA?.value || "";
|
||||
|
||||
if (!name) { showToast("Enter a filename", "error"); return; }
|
||||
if (!/^[\w\-. ]+$/.test(name)) { showToast("Filename can only contain letters, numbers, -, _, .", "error"); return; }
|
||||
|
||||
const filename = `${name}.${ext}`;
|
||||
const saved = await wcSaveFile(filename, content);
|
||||
if (saved) {
|
||||
showToast(`Saved: ${filename}`, "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
} else {
|
||||
showToast("Failed to save file", "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Editor: delete
|
||||
document.getElementById("wfm-wc-editor-delete-btn")?.addEventListener("click", async () => {
|
||||
if (!wcEditingFilename) return;
|
||||
if (!confirm(`Delete "${wcEditingFilename}"?`)) return;
|
||||
await wcDeleteFile(wcEditingFilename);
|
||||
showToast(`Deleted: ${wcEditingFilename}`, "success");
|
||||
wcCloseEditor();
|
||||
wcRefreshFiles();
|
||||
});
|
||||
|
||||
// Editor: cancel
|
||||
document.getElementById("wfm-wc-editor-cancel-btn")?.addEventListener("click", () => {
|
||||
wcCloseEditor();
|
||||
});
|
||||
|
||||
// Initial wildcard file load
|
||||
wcRefreshFiles();
|
||||
}
|
||||
|
||||
+253
-19
@@ -346,6 +346,12 @@ export async function initSettingsTab() {
|
||||
workflowsDirInfo = await res.json();
|
||||
} catch {}
|
||||
|
||||
// Load gallery output dir info
|
||||
let outputDirInfo = { current: "", default: "", saved: "" };
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir");
|
||||
outputDirInfo = await res.json();
|
||||
} catch {}
|
||||
|
||||
const uiLang = getLang();
|
||||
const summaryLang = getSummaryLang();
|
||||
@@ -353,26 +359,10 @@ export async function initSettingsTab() {
|
||||
container.innerHTML = `
|
||||
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
|
||||
|
||||
<!-- Language Settings -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("langLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-ui-lang">
|
||||
${buildLangOptions(getLanguageOptions(), uiLang)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("summaryLangLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-summary-lang">
|
||||
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
|
||||
</select>
|
||||
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
|
||||
⚠ ${t("summaryLangNote")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
<div class="wfm-settings-layout">
|
||||
|
||||
<!-- Right column: Theme (floated right) -->
|
||||
<div class="wfm-settings-right-col">
|
||||
<!-- Theme -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
|
||||
@@ -471,6 +461,30 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div><!-- /wfm-settings-right-col -->
|
||||
|
||||
<!-- Left column: all settings (Language + rest), flows left of floated Theme -->
|
||||
<div class="wfm-settings-left-col">
|
||||
<!-- Language Settings -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("langLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-ui-lang">
|
||||
${buildLangOptions(getLanguageOptions(), uiLang)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("summaryLangLabel")}</label>
|
||||
<select class="wfm-select" id="wfm-settings-summary-lang">
|
||||
${buildLangOptions(getSummaryLanguageOptions(), summaryLang)}
|
||||
</select>
|
||||
<small style="color:var(--wfm-warning);font-size:11px;display:block;margin-top:4px;">
|
||||
⚠ ${t("summaryLangNote")}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
@@ -492,6 +506,27 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Gallery Output Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("galleryOutputDirLabel")}</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" class="wfm-input" id="wfm-settings-output-dir"
|
||||
value="${serverSettings.gallery_output_dir || ""}"
|
||||
placeholder="${outputDirInfo.default}">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-output-dir-apply">${t("workflowsDirApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-output-dir-reset">${t("workflowsDirDefault")}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("galleryOutputDirHint")}
|
||||
</small>
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;">
|
||||
${t("workflowsDirCurrent")}: <code id="wfm-settings-output-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${outputDirInfo.current}</code>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ComfyUI Settings -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
|
||||
@@ -571,8 +606,33 @@ 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>
|
||||
|
||||
<!-- Wildcard Integration -->
|
||||
<details class="wfm-settings-section" id="wfm-wildcard-integration-section">
|
||||
<summary class="wfm-settings-summary">Wildcard Integration</summary>
|
||||
<div id="wfm-wildcard-link-status" style="font-size:13px;color:var(--wfm-text-secondary);">Loading...</div>
|
||||
</details>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
|
||||
|
||||
</div><!-- /wfm-settings-left-col -->
|
||||
|
||||
<div style="clear:both;"></div>
|
||||
</div><!-- /wfm-settings-layout -->
|
||||
`;
|
||||
|
||||
// --- Theme change handler ---
|
||||
@@ -828,6 +888,50 @@ export async function initSettingsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Gallery output dir handlers ---
|
||||
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
const currentEl = document.getElementById("wfm-settings-output-dir-current");
|
||||
const newDir = dirInput.value.trim();
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gallery_output_dir: newDir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
|
||||
} else {
|
||||
if (currentEl) currentEl.textContent = data.current;
|
||||
showToast(t("workflowsDirChanged"), "success");
|
||||
// gallery-tab に通知して再ロード
|
||||
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wfm-settings-output-dir-reset")?.addEventListener("click", async () => {
|
||||
const dirInput = document.getElementById("wfm-settings-output-dir");
|
||||
const currentEl = document.getElementById("wfm-settings-output-dir-current");
|
||||
dirInput.value = "";
|
||||
try {
|
||||
const res = await fetch("/api/wfm/settings/output-dir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gallery_output_dir: "" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (currentEl) currentEl.textContent = data.current;
|
||||
showToast(t("workflowsDirChanged"), "success");
|
||||
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
|
||||
} catch (err) {
|
||||
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Ollama model list loader ---
|
||||
async function loadOllamaModels() {
|
||||
const select = document.getElementById("wfm-settings-ollama-model");
|
||||
@@ -952,4 +1056,134 @@ export async function initSettingsTab() {
|
||||
if (savedUrl) {
|
||||
comfyUI.updateUrl(savedUrl);
|
||||
}
|
||||
|
||||
// --- Wildcard Integration ---
|
||||
async function wcLoadLinkStatus() {
|
||||
const el = document.getElementById("wfm-wildcard-link-status");
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/wildcards/link-status");
|
||||
const data = await res.json();
|
||||
wcRenderLinkStatus(el, data);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<span style="color:var(--wfm-error,#f44336)">Error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wcRenderLinkStatus(el, data) {
|
||||
if (!data.impact_pack_installed) {
|
||||
el.innerHTML = `
|
||||
<p style="margin:0 0 8px;">ComfyUI-Impact-Pack がインストールされていません。</p>
|
||||
<p style="margin:0;font-size:12px;color:var(--wfm-text-secondary);">
|
||||
ワイルドカードを Impact Pack と共有するには、先に
|
||||
<a href="https://github.com/ltdrdata/ComfyUI-Impact-Pack" target="_blank" rel="noopener"
|
||||
style="color:var(--wfm-accent);">ComfyUI-Impact-Pack</a>
|
||||
をインストールしてください。
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.is_linked) {
|
||||
el.innerHTML = `
|
||||
<p style="margin:0 0 4px;color:var(--wfm-success,#4caf50);">✓ リンク済み</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
|
||||
WFS wildcard → ${data.link_target || data.impact_pack_wildcards_dir}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-wc-remove-link">リンクを解除</button>
|
||||
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
|
||||
document.getElementById("wfm-wc-remove-link")?.addEventListener("click", async () => {
|
||||
const msg = document.getElementById("wfm-wc-link-msg");
|
||||
try {
|
||||
const r = await fetch("/api/wfm/wildcards/remove-link", { method: "POST" });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || r.statusText);
|
||||
msg.textContent = "リンクを解除しました。";
|
||||
msg.style.color = "var(--wfm-success,#4caf50)";
|
||||
setTimeout(wcLoadLinkStatus, 800);
|
||||
} catch (e) {
|
||||
msg.textContent = "エラー: " + e.message;
|
||||
msg.style.color = "var(--wfm-error,#f44336)";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<p style="margin:0 0 4px;">Impact Pack wildcards ディレクトリ:</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">${data.impact_pack_wildcards_dir}</p>
|
||||
<p style="margin:0 0 8px;font-size:12px;color:var(--wfm-text-secondary);">
|
||||
WFS の wildcard ディレクトリを Impact Pack の wildcards ディレクトリへのリンクに置き換えます。
|
||||
既存の WFS ワイルドカードファイルは Impact Pack ディレクトリへ移行されます。
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-wc-create-link">リンクを作成</button>
|
||||
<div id="wfm-wc-link-msg" style="font-size:12px;margin-top:8px;"></div>`;
|
||||
document.getElementById("wfm-wc-create-link")?.addEventListener("click", async () => {
|
||||
const msg = document.getElementById("wfm-wc-link-msg");
|
||||
try {
|
||||
const r = await fetch("/api/wfm/wildcards/create-link", { method: "POST" });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || r.statusText);
|
||||
const migrated = d.migrated_files?.length
|
||||
? ` (移行: ${d.migrated_files.join(", ")})`
|
||||
: "";
|
||||
msg.textContent = "リンクを作成しました。" + migrated;
|
||||
msg.style.color = "var(--wfm-success,#4caf50)";
|
||||
setTimeout(wcLoadLinkStatus, 800);
|
||||
} catch (e) {
|
||||
msg.textContent = "エラー: " + e.message;
|
||||
msg.style.color = "var(--wfm-error,#f44336)";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load wildcard link status when section is opened
|
||||
document.getElementById("wfm-wildcard-integration-section")?.addEventListener("toggle", (e) => {
|
||||
if (e.target.open) wcLoadLinkStatus();
|
||||
});
|
||||
|
||||
// --- 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 = "";
|
||||
});
|
||||
}
|
||||
|
||||
+155
-140
@@ -25,7 +25,6 @@ const state = {
|
||||
currentPage: 0,
|
||||
};
|
||||
|
||||
const WF_PER_PAGE = 24;
|
||||
|
||||
function getBadgePalette() {
|
||||
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
|
||||
@@ -245,12 +244,17 @@ function renderModelFilters() {
|
||||
const container = document.getElementById("wfm-model-filters");
|
||||
if (!container) return;
|
||||
|
||||
const badges = getAllBadges();
|
||||
const palette = getBadgePalette();
|
||||
const badges = Object.keys(palette).sort();
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="wfm-filter-btn ${state.activeBadge === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
|
||||
<button class="wfm-filter-btn ${state.activeBadge === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">★</button>
|
||||
${badges.map((b) => `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}">${escapeHtml(b)}</button>`).join("")}
|
||||
${badges.map((b) => {
|
||||
const color = palette[b] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}"${style}>${escapeHtml(b)}</button>`;
|
||||
}).join("")}
|
||||
`;
|
||||
|
||||
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
|
||||
@@ -265,6 +269,8 @@ function renderModelFilters() {
|
||||
|
||||
function filterWorkflows() {
|
||||
return state.workflows.filter((wf) => {
|
||||
if (wf.filename === ".index.json") return false;
|
||||
|
||||
if (state.activeBadge === "FAVORITE") {
|
||||
if (!wf.metadata.favorite) return false;
|
||||
} else if (state.activeBadge !== "ALL") {
|
||||
@@ -312,27 +318,16 @@ function renderGrid() {
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML =
|
||||
'<p class="wfm-placeholder">No workflows found</p>';
|
||||
updateWfPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, filtered);
|
||||
updateWfPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Thumbnail / Card view with pagination
|
||||
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
|
||||
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
|
||||
if (state.currentPage < 0) state.currentPage = 0;
|
||||
const start = state.currentPage * WF_PER_PAGE;
|
||||
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
|
||||
|
||||
updateWfPagination(totalPages);
|
||||
|
||||
grid.innerHTML = "";
|
||||
pageItems.forEach((wf) => {
|
||||
filtered.forEach((wf) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
card.dataset.filename = wf.filename;
|
||||
@@ -382,22 +377,6 @@ function renderGrid() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateWfPagination(totalPages) {
|
||||
const container = document.getElementById("wfm-workflow-pagination");
|
||||
if (!container) return;
|
||||
if (totalPages <= 1) { container.innerHTML = ""; return; }
|
||||
container.innerHTML = `
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">«</button>
|
||||
<span>${state.currentPage + 1} / ${totalPages}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderTableView(grid, filtered) {
|
||||
const table = document.createElement("table");
|
||||
@@ -484,7 +463,6 @@ async function showSidePanel(wf, cardEl) {
|
||||
|
||||
titleEl.textContent = wf.filename.replace(/\.json$/, "");
|
||||
contentEl.textContent = t("loading");
|
||||
panel.style.display = "flex";
|
||||
|
||||
state.selectedWf = wf;
|
||||
|
||||
@@ -504,7 +482,7 @@ async function showSidePanel(wf, cardEl) {
|
||||
sidePanelThumbUpdate(wf);
|
||||
|
||||
// Update group tab
|
||||
sidePanelGroupUpdate();
|
||||
renderSideGroup(wf);
|
||||
|
||||
const badgeEl = document.getElementById("wfm-json-format-badge");
|
||||
try {
|
||||
@@ -528,17 +506,17 @@ async function showSidePanel(wf, cardEl) {
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById("wfm-side-panel");
|
||||
if (panel) panel.style.display = "none";
|
||||
document.querySelectorAll(".wfm-card, .wfm-table-row").forEach((c) =>
|
||||
c.classList.remove("wfm-card-selected")
|
||||
);
|
||||
state.selectedWf = null;
|
||||
const titleEl = document.getElementById("wfm-side-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
const listLoadBtn = document.getElementById("wfm-list-load-btn");
|
||||
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
|
||||
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
|
||||
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
|
||||
sidePanelGroupUpdate();
|
||||
renderSideGroup(null);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -573,49 +551,156 @@ function sidePanelThumbUpdate(wf) {
|
||||
// Group Tab in Side Panel
|
||||
// ============================================
|
||||
|
||||
function sidePanelGroupUpdate() {
|
||||
const nameEl = document.getElementById("wfm-grp-prop-name");
|
||||
const groupEl = document.getElementById("wfm-grp-prop-group");
|
||||
const assignBtn = document.getElementById("wfm-assign-group-btn");
|
||||
const removeBtn = document.getElementById("wfm-remove-group-btn");
|
||||
function renderSideGroup(wf) {
|
||||
const el = document.getElementById("wfm-side-tab-group");
|
||||
if (!el) return;
|
||||
|
||||
if (!state.selectedWf) {
|
||||
if (nameEl) nameEl.textContent = t("noSelection");
|
||||
if (groupEl) groupEl.textContent = "";
|
||||
if (assignBtn) assignBtn.disabled = true;
|
||||
if (removeBtn) removeBtn.disabled = true;
|
||||
const allGroups = groups.groupNames();
|
||||
|
||||
if (!wf) {
|
||||
el.innerHTML = `<p style="padding:12px;color:var(--wfm-text-secondary);font-size:12px;">${t("noSelection")}</p>`;
|
||||
_refreshGroupFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = state.selectedWf.filename;
|
||||
if (nameEl) nameEl.textContent = filename.replace(/\.json$/, "");
|
||||
const grps = groups.groupsOf(filename);
|
||||
if (groupEl) groupEl.textContent = grps.length > 0 ? `${t("groupsLabel")}: ${grps.join(", ")}` : `${t("groupsLabel")}: ${t("groupsNone")}`;
|
||||
if (assignBtn) assignBtn.disabled = false;
|
||||
const filename = wf.filename;
|
||||
const memberOf = groups.groupsOf(filename);
|
||||
const available = allGroups.filter(g => !memberOf.includes(g));
|
||||
|
||||
const selGroup = document.getElementById("wfm-group-select")?.value;
|
||||
if (removeBtn) removeBtn.disabled = !selGroup || !grps.includes(selGroup);
|
||||
el.innerHTML = `
|
||||
<div style="padding:8px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map(g =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-wf-grp-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`
|
||||
).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-grp-assign-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${available.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-grp-assign-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
|
||||
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-grp-new-input" class="wfm-input"
|
||||
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
|
||||
<button id="wfm-grp-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-grp-manage-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="wfm-grp-rename-btn" class="wfm-btn wfm-btn-sm"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">✎</button>
|
||||
<button id="wfm-grp-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
|
||||
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// グループから除外
|
||||
el.querySelectorAll(".wfm-wf-grp-remove").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
groups.remove(filename, btn.dataset.group);
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Removed from "${btn.dataset.group}"`, "success");
|
||||
});
|
||||
});
|
||||
|
||||
// グループに追加
|
||||
el.querySelector("#wfm-grp-assign-btn")?.addEventListener("click", () => {
|
||||
const g = el.querySelector("#wfm-grp-assign-sel")?.value;
|
||||
if (!g) return;
|
||||
groups.assign(filename, g);
|
||||
renderSideGroup(wf);
|
||||
showToast(`Added to "${g}"`, "success");
|
||||
});
|
||||
|
||||
// グループ作成(作成後に現在のWFに追加)
|
||||
el.querySelector("#wfm-grp-create-btn")?.addEventListener("click", () => {
|
||||
const input = el.querySelector("#wfm-grp-new-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "warning");
|
||||
return;
|
||||
}
|
||||
groups.assign(filename, name);
|
||||
input.value = "";
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
});
|
||||
|
||||
// グループ名変更
|
||||
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
|
||||
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast("Rename failed", "error");
|
||||
return;
|
||||
}
|
||||
if (state.groupFilter === oldName) state.groupFilter = newName;
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Renamed to "${newName}"`, "success");
|
||||
});
|
||||
|
||||
// グループ削除
|
||||
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
|
||||
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
groups.deleteGroup(name);
|
||||
if (state.groupFilter === name) {
|
||||
state.groupFilter = "";
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
if (filterSel) filterSel.value = "";
|
||||
}
|
||||
renderSideGroup(wf);
|
||||
_refreshGroupFilter();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
});
|
||||
|
||||
_refreshGroupFilter();
|
||||
}
|
||||
|
||||
function refreshGroupSelects() {
|
||||
const sideSel = document.getElementById("wfm-group-select");
|
||||
/** ツールバーのグループフィルタセレクトのみ更新 */
|
||||
function _refreshGroupFilter() {
|
||||
const filterSel = document.getElementById("wfm-group-filter");
|
||||
if (!filterSel) return;
|
||||
const prevVal = filterSel.value;
|
||||
const grpNames = groups.groupNames();
|
||||
|
||||
[filterSel, sideSel].forEach((sel, i) => {
|
||||
if (!sel) return;
|
||||
const prevVal = sel.value;
|
||||
sel.innerHTML = i === 0
|
||||
? `<option value="">${t("allGroups")}</option>`
|
||||
: `<option value="">${t("selectGroup")}</option>`;
|
||||
grpNames.forEach((g) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (grpNames.includes(prevVal)) sel.value = prevVal;
|
||||
filterSel.innerHTML = `<option value="">${t("allGroups")}</option>`;
|
||||
grpNames.forEach(g => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
filterSel.appendChild(opt);
|
||||
});
|
||||
if (grpNames.includes(prevVal)) filterSel.value = prevVal;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -1088,9 +1173,6 @@ export function initWorkflowTab() {
|
||||
});
|
||||
}
|
||||
|
||||
// Side panel: close button
|
||||
document.getElementById("wfm-side-panel-close")?.addEventListener("click", closeSidePanel);
|
||||
|
||||
// Side panel: tab switching
|
||||
document.querySelectorAll(".wfm-side-tab-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -1124,74 +1206,7 @@ export function initWorkflowTab() {
|
||||
|
||||
// Group management
|
||||
groups.load();
|
||||
refreshGroupSelects();
|
||||
|
||||
// Add group
|
||||
document.getElementById("wfm-group-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-group-name-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (!groups.createGroup(name)) {
|
||||
showToast(`"${name}" already exists`, "error");
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
refreshGroupSelects();
|
||||
showToast(`Group "${name}" created`, "success");
|
||||
});
|
||||
|
||||
// Rename group
|
||||
document.getElementById("wfm-group-rename-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
const newName = prompt("New group name:", oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (!groups.renameGroup(oldName, newName)) {
|
||||
showToast("Rename failed", "error");
|
||||
return;
|
||||
}
|
||||
refreshGroupSelects();
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Group renamed to "${newName}"`, "success");
|
||||
});
|
||||
|
||||
// Delete group
|
||||
document.getElementById("wfm-group-delete-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const name = sel?.value;
|
||||
if (!name) return;
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
groups.deleteGroup(name);
|
||||
refreshGroupSelects();
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Group "${name}" deleted`, "success");
|
||||
});
|
||||
|
||||
// Assign to group
|
||||
document.getElementById("wfm-assign-group-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const groupName = sel?.value;
|
||||
if (!groupName || !state.selectedWf) return;
|
||||
groups.assign(state.selectedWf.filename, groupName);
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Added to "${groupName}"`, "success");
|
||||
});
|
||||
|
||||
// Remove from group
|
||||
document.getElementById("wfm-remove-group-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-group-select");
|
||||
const groupName = sel?.value;
|
||||
if (!groupName || !state.selectedWf) return;
|
||||
groups.remove(state.selectedWf.filename, groupName);
|
||||
sidePanelGroupUpdate();
|
||||
showToast(`Removed from "${groupName}"`, "success");
|
||||
});
|
||||
|
||||
// Update remove button state when group select changes
|
||||
document.getElementById("wfm-group-select")?.addEventListener("change", () => {
|
||||
sidePanelGroupUpdate();
|
||||
});
|
||||
_refreshGroupFilter();
|
||||
|
||||
// Toolbar: group filter dropdown
|
||||
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
|
||||
|
||||
+330
-92
@@ -6,6 +6,7 @@
|
||||
<title>Workflow Studio</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/wfm_static/favicon.svg">
|
||||
<link rel="stylesheet" href="/wfm_static/css/main.css">
|
||||
<link rel="stylesheet" href="/wfm_static/css/gallery-tab.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@@ -19,6 +20,7 @@
|
||||
<button class="wfm-tab" data-tab="models">Models</button>
|
||||
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
|
||||
<button class="wfm-tab" data-tab="prompt">Prompt</button>
|
||||
<button class="wfm-tab" data-tab="gallery">Gallery</button>
|
||||
<button class="wfm-tab" data-tab="settings">Settings</button>
|
||||
<button class="wfm-tab" data-tab="help">Help</button>
|
||||
</nav>
|
||||
@@ -59,7 +61,6 @@
|
||||
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="View settings">⚙</button>
|
||||
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
|
||||
</div>
|
||||
<div id="wfm-workflow-pagination" class="wfm-pagination-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-workflow-area">
|
||||
@@ -67,10 +68,9 @@
|
||||
<p class="wfm-placeholder">Loading workflows...</p>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div id="wfm-side-panel" class="wfm-side-panel" style="display:none;">
|
||||
<div id="wfm-side-panel" class="wfm-side-panel">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-side-panel-title" class="wfm-side-panel-title"></span>
|
||||
<button id="wfm-side-panel-close" class="wfm-side-panel-close">×</button>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
|
||||
@@ -98,35 +98,8 @@
|
||||
<pre id="wfm-json-content" class="wfm-json-content"></pre>
|
||||
</div>
|
||||
<!-- Group Tab -->
|
||||
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;">
|
||||
<div class="wfm-group-panel">
|
||||
<div class="wfm-group-section">
|
||||
<div class="wfm-group-section-title">Properties</div>
|
||||
<div id="wfm-grp-prop-name" class="wfm-group-prop">No selection</div>
|
||||
<div id="wfm-grp-prop-group" class="wfm-group-prop"></div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<div class="wfm-group-section">
|
||||
<div class="wfm-group-section-title">Group Management</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<input type="text" id="wfm-group-name-input" class="wfm-input" placeholder="Group name" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
|
||||
<button id="wfm-group-add-btn" class="wfm-btn wfm-btn-sm" title="Create group">Add</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<select id="wfm-group-select" class="wfm-select" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
|
||||
<option value="">Select group</option>
|
||||
</select>
|
||||
<button id="wfm-group-rename-btn" class="wfm-btn wfm-btn-sm" title="Rename group">✎</button>
|
||||
<button id="wfm-group-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete group" style="padding:4px 8px;font-size:12px;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<div class="wfm-group-section">
|
||||
<div class="wfm-group-section-title">Workflow Actions</div>
|
||||
<button id="wfm-assign-group-btn" class="wfm-btn wfm-btn-sm" style="width:100%;margin-bottom:4px;" disabled>Add to Group</button>
|
||||
<button id="wfm-remove-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="width:100%;margin-bottom:4px;font-size:12px;" disabled>Remove from Group</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;overflow-y:auto;">
|
||||
<!-- JS動的生成: renderSideGroup() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +129,6 @@
|
||||
</div>
|
||||
<span id="wfm-nodes-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-nodes-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
<div id="wfm-nodes-pagination" class="wfm-pagination-inline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,10 +138,9 @@
|
||||
<p class="wfm-placeholder" id="wfm-nodes-placeholder">Click to load nodes...</p>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div id="wfm-nodes-side-panel" class="wfm-side-panel" style="display:none;">
|
||||
<div id="wfm-nodes-side-panel" class="wfm-side-panel">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-nodes-panel-title" class="wfm-side-panel-title"></span>
|
||||
<button id="wfm-nodes-panel-close" class="wfm-side-panel-close">×</button>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-nodes-side-tab-btn active" data-side-tab="details">Details</button>
|
||||
@@ -219,7 +190,13 @@
|
||||
<button class="wfm-filter-btn" id="wfm-models-fav-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
|
||||
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
|
||||
<select id="wfm-models-dir-filter" class="wfm-select wfm-toolbar-select"><option value="">All Folders</option></select>
|
||||
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
|
||||
<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>
|
||||
@@ -230,8 +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>
|
||||
<div id="wfm-models-pagination" class="wfm-pagination-inline"></div>
|
||||
<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 -->
|
||||
@@ -240,10 +219,9 @@
|
||||
<p class="wfm-placeholder" id="wfm-models-placeholder">Click Refresh to load models...</p>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div id="wfm-models-side-panel" class="wfm-side-panel" style="display:none;">
|
||||
<div id="wfm-models-side-panel" class="wfm-side-panel">
|
||||
<div class="wfm-side-panel-header">
|
||||
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
|
||||
<button id="wfm-models-panel-close" class="wfm-side-panel-close">×</button>
|
||||
</div>
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-models-side-tab-btn active" data-side-tab="info">Info</button>
|
||||
@@ -352,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>
|
||||
@@ -364,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;">
|
||||
@@ -426,61 +434,139 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 2: Preset Editor -->
|
||||
<!-- Col 2: Presets + Preset Manager (tabbed) -->
|
||||
<div class="wfm-prompt-split-col">
|
||||
<div class="wfm-prompt-split-header">Presets</div>
|
||||
<div class="wfm-preset-panel">
|
||||
<div style="display:flex;gap:6px;margin-bottom:10px;align-items:center;">
|
||||
<select id="wfm-preset-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">New Preset (none selected)</option>
|
||||
</select>
|
||||
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">GenUI Set</button>
|
||||
<!-- Center column tab navigation -->
|
||||
<div class="wfm-prompt-center-tabnav">
|
||||
<button class="wfm-prompt-center-tab active" data-ptab="presets">Presets</button>
|
||||
<button class="wfm-prompt-center-tab" data-ptab="manager">Preset Manager</button>
|
||||
</div>
|
||||
|
||||
<!-- Presets pane -->
|
||||
<div id="wfm-ptab-presets" class="wfm-prompt-center-pane">
|
||||
<div class="wfm-preset-panel">
|
||||
<div style="display:flex;gap:6px;margin-bottom:10px;align-items:center;">
|
||||
<select id="wfm-preset-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">New Preset (none selected)</option>
|
||||
</select>
|
||||
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">GenUI Set</button>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Preset Name</label>
|
||||
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Category</label>
|
||||
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
|
||||
</div>
|
||||
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
|
||||
</div>
|
||||
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
|
||||
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
|
||||
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px; margin-top:4px;">
|
||||
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">Select group...</option>
|
||||
</select>
|
||||
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
|
||||
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
|
||||
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Preset Name</label>
|
||||
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
|
||||
</div>
|
||||
<div class="wfm-form-group">
|
||||
<label>Category</label>
|
||||
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
|
||||
</div>
|
||||
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
|
||||
</div>
|
||||
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
|
||||
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
|
||||
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:4px; margin-top:4px;">
|
||||
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
|
||||
<option value="">Select group...</option>
|
||||
</select>
|
||||
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
|
||||
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
|
||||
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
|
||||
</div>
|
||||
|
||||
<!-- Preset Manager pane -->
|
||||
<div id="wfm-ptab-manager" class="wfm-prompt-center-pane" style="display:none;">
|
||||
<div class="wfm-preset-manager">
|
||||
<div class="wfm-pm-tabs">
|
||||
<button class="wfm-pm-tab active" data-pmtab="all">All</button>
|
||||
<button class="wfm-pm-tab" data-pmtab="favorites">★</button>
|
||||
<button class="wfm-pm-tab" data-pmtab="groups">Groups</button>
|
||||
</div>
|
||||
<div class="wfm-pm-search">
|
||||
<input type="text" id="wfm-pm-search-input" class="wfm-input" placeholder="Search presets...">
|
||||
</div>
|
||||
<div id="wfm-pm-list" class="wfm-pm-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Col 3: Preset Manager (list, favorites, groups) -->
|
||||
<!-- Col 3: Wildcard support -->
|
||||
<div class="wfm-prompt-split-col">
|
||||
<div class="wfm-prompt-split-header">Preset Manager</div>
|
||||
<div class="wfm-preset-manager">
|
||||
<div class="wfm-pm-tabs">
|
||||
<button class="wfm-pm-tab active" data-pmtab="all">All</button>
|
||||
<button class="wfm-pm-tab" data-pmtab="favorites">★</button>
|
||||
<button class="wfm-pm-tab" data-pmtab="groups">Groups</button>
|
||||
<div class="wfm-prompt-split-header">Wildcard</div>
|
||||
<div class="wfm-wc-panel">
|
||||
|
||||
<!-- Input assistance section -->
|
||||
<div class="wfm-wc-input-section">
|
||||
<!-- Wildcard syntax buttons -->
|
||||
<div class="wfm-wc-toolbar">
|
||||
<button class="wfm-wc-btn" data-wc-open="{" data-wc-close="}" title="Random pick set e.g. {cat|dog}">{ }</button>
|
||||
<button class="wfm-wc-btn" data-wc-open="|" title="Separator">|</button>
|
||||
<button class="wfm-wc-btn" data-wc-open="__" data-wc-close="__" title="Wildcard file reference e.g. __animals__">__</button>
|
||||
<button class="wfm-wc-btn" data-wc-open=":" title="Colon">:</button>
|
||||
<button class="wfm-wc-btn" data-wc-open=";" title="Semicolon">;</button>
|
||||
<button class="wfm-wc-btn" data-wc-open="$$" title="Multi-pick e.g. {2$$cat|dog|bird}">$$</button>
|
||||
<button class="wfm-wc-btn" data-wc-open="[" data-wc-close="]" title="Bracket set e.g. [cat|dog]">[ ]</button>
|
||||
<button class="wfm-wc-btn" id="wfm-wc-lora-btn" title="LoRA with LBW e.g. <lora:name:1:LBW=...;...>"><lora></button>
|
||||
<button class="wfm-wc-btn" id="wfm-wc-nset-btn" title="Multi-pick set e.g. {2$$cat|dog|bird}">n$${ }</button>
|
||||
<div class="wfm-wc-file-picker-wrap">
|
||||
<button class="wfm-wc-btn" id="wfm-wc-filepick-btn" title="Insert wildcard file reference">📂 File</button>
|
||||
<div id="wfm-wc-file-picker" class="wfm-wc-file-picker" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Prompt textarea -->
|
||||
<textarea id="wfm-wc-prompt" class="wfm-textarea wfm-wc-prompt-ta" rows="4" placeholder="Compose wildcard prompt here... e.g. a photo of {cat|dog}, __colors__ background"></textarea>
|
||||
<!-- Action buttons -->
|
||||
<div class="wfm-wc-prompt-actions">
|
||||
<button id="wfm-wc-copy-btn" class="wfm-btn wfm-btn-sm">Copy</button>
|
||||
<button id="wfm-wc-to-pos-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" title="Send to Preset Positive Prompt">→ Pos</button>
|
||||
<button id="wfm-wc-to-neg-btn" class="wfm-btn wfm-btn-sm" title="Send to Preset Negative Prompt">→ Neg</button>
|
||||
<button id="wfm-wc-clear-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-pm-search">
|
||||
<input type="text" id="wfm-pm-search-input" class="wfm-input" placeholder="Search presets...">
|
||||
|
||||
<!-- Wildcard file manager section -->
|
||||
<div class="wfm-wc-files-section">
|
||||
<div class="wfm-wc-files-header">
|
||||
<span class="wfm-wc-files-title">Wildcard Files</span>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button id="wfm-wc-new-file-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New</button>
|
||||
<button id="wfm-wc-files-refresh-btn" class="wfm-btn wfm-btn-sm" title="Reload file list">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- File list -->
|
||||
<div id="wfm-wc-file-list" class="wfm-wc-file-list">
|
||||
<p class="wfm-placeholder" style="padding:12px;font-size:12px;">Loading...</p>
|
||||
</div>
|
||||
<!-- Editor (shown when a file is selected or New is clicked) -->
|
||||
<div id="wfm-wc-editor" class="wfm-wc-editor" style="display:none;">
|
||||
<div class="wfm-wc-editor-header">
|
||||
<input type="text" id="wfm-wc-editor-name" class="wfm-input" placeholder="filename (no extension)" style="flex:1;min-width:0;">
|
||||
<select id="wfm-wc-editor-ext" class="wfm-select" style="width:70px;">
|
||||
<option value="txt">txt</option>
|
||||
<option value="yaml">yaml</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wfm-wc-editor-hint">
|
||||
txt: one item per line | yaml: key: [item1, item2, ...]
|
||||
</div>
|
||||
<textarea id="wfm-wc-editor-content" class="wfm-textarea wfm-wc-editor-ta" rows="8" placeholder="one item per line (txt) or YAML format (yaml)"></textarea>
|
||||
<div class="wfm-wc-editor-actions">
|
||||
<button id="wfm-wc-editor-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Save</button>
|
||||
<button id="wfm-wc-editor-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="display:none;">Delete</button>
|
||||
<button id="wfm-wc-editor-cancel-btn" class="wfm-btn wfm-btn-sm" style="margin-left:auto;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wfm-pm-list" class="wfm-pm-list"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -531,9 +617,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>
|
||||
|
||||
@@ -547,6 +634,8 @@
|
||||
<li id="wfm-help-prompt-5">Preset Manager: browse all presets, favorites, and group-based filtering</li>
|
||||
<li id="wfm-help-prompt-6">Group management: create groups, assign/remove presets, delete groups</li>
|
||||
<li id="wfm-help-prompt-7">GenUI Set: apply preset prompts directly to GenerateUI</li>
|
||||
<li id="wfm-help-prompt-8">Wildcard toolbar: one-click buttons to insert wildcard syntax ({|}, {n$|}, __|__, <lora:>, etc.); wraps selected text</li>
|
||||
<li id="wfm-help-prompt-9">Wildcard file manager: create/edit/delete .txt/.yaml wildcard files; click filename to insert __filename__ at cursor</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -560,7 +649,9 @@
|
||||
<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>
|
||||
<li id="wfm-help-settings-10">Wildcard Integration: link WFS wildcard dir to ComfyUI-Impact-Pack wildcards (junction/symlink); existing files migrated automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -576,18 +667,37 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-gallery-title">Gallery Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-gallery-1">Browse images in the ComfyUI output folder — set the folder path in Settings > Gallery Output Folder</li>
|
||||
<li id="wfm-help-gallery-2">3-column layout: folder tree (left) | image grid (center) | detail panel (right)</li>
|
||||
<li id="wfm-help-gallery-3">Thumbnail and Table view modes; double-click any image to open fullscreen lightbox</li>
|
||||
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name</li>
|
||||
<li id="wfm-help-gallery-5">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>
|
||||
|
||||
<div class="wfm-help-card">
|
||||
<h3>Models Tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching</li>
|
||||
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
|
||||
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
|
||||
<li id="wfm-help-models-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>
|
||||
|
||||
@@ -596,7 +706,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-1">Open from the Workflow Studio Library button in the ComfyUI toolbar</li>
|
||||
<li id="wfm-help-sidepanel-2">Workflows tab: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view</li>
|
||||
<li id="wfm-help-sidepanel-3">Nodes tab: All / ★ Favorites / Groups / Sets sub-tabs; ★ shown for favorites in All view</li>
|
||||
<li id="wfm-help-sidepanel-3">Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes</li>
|
||||
<li id="wfm-help-sidepanel-11">Models tab: All / ★ Favorites / Groups / By Type sub-tabs</li>
|
||||
<li id="wfm-help-sidepanel-4">Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs</li>
|
||||
<li id="wfm-help-sidepanel-5">Drag & drop workflows onto the canvas to load them</li>
|
||||
@@ -605,6 +715,7 @@
|
||||
<li id="wfm-help-sidepanel-8">Copy individual positive (P) or negative (N) prompts from sidebar items</li>
|
||||
<li id="wfm-help-sidepanel-9">Double-click items to load/place without dragging</li>
|
||||
<li id="wfm-help-sidepanel-10">Search within each sub-tab to quickly find items</li>
|
||||
<li id="wfm-help-sidepanel-12">⚙ Theme button (header): customize panel background, text, border colors — saved to localStorage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -659,6 +770,133 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Gallery Tab -->
|
||||
<section id="wfm-tab-gallery" class="wfm-tab-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="wfm-toolbar">
|
||||
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<button id="wfm-gallery-fav-btn" class="wfm-filter-btn" title="Favorites">★</button>
|
||||
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
|
||||
<select id="wfm-gallery-tag-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
<select id="wfm-gallery-group-filter" class="wfm-select wfm-toolbar-select">
|
||||
<option value="">All Groups</option>
|
||||
</select>
|
||||
<select id="wfm-gallery-sort" class="wfm-select wfm-toolbar-select">
|
||||
<option value="date_desc">Date (Newest)</option>
|
||||
<option value="date_asc">Date (Oldest)</option>
|
||||
<option value="name_asc">Name A-Z</option>
|
||||
<option value="name_desc">Name Z-A</option>
|
||||
</select>
|
||||
<div class="wfm-view-btns">
|
||||
<button class="wfm-view-btn active" data-gallery-view="thumb" title="Thumbnail">⊞</button>
|
||||
<button class="wfm-view-btn" data-gallery-view="table" title="Table">≡</button>
|
||||
</div>
|
||||
<span id="wfm-gallery-count" class="wfm-nodes-count"></span>
|
||||
<button id="wfm-gallery-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
</div>
|
||||
<!-- 複数選択時の一括操作バー -->
|
||||
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
|
||||
<span id="wfm-gallery-bulk-count" class="wfm-gallery-bulk-count"></span>
|
||||
<button id="wfm-gallery-bulk-deselect" class="wfm-btn wfm-btn-sm">Deselect All</button>
|
||||
<select id="wfm-gallery-bulk-group-select" class="wfm-select" style="font-size:12px;padding:3px 6px;">
|
||||
<option value="">Add to Group...</option>
|
||||
</select>
|
||||
<button id="wfm-gallery-bulk-group-add" class="wfm-btn wfm-btn-sm wfm-btn-primary">Add</button>
|
||||
<button id="wfm-gallery-bulk-fav" class="wfm-btn wfm-btn-sm">★ Favorite All</button>
|
||||
<button id="wfm-gallery-bulk-unfav" class="wfm-btn wfm-btn-sm">☆ Unfavorite All</button>
|
||||
<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>
|
||||
|
||||
<!-- 3カラムレイアウト -->
|
||||
<div class="wfm-gallery-layout">
|
||||
<!-- 左: フォルダツリー -->
|
||||
<div id="wfm-gallery-tree-panel" class="wfm-gallery-tree-panel">
|
||||
<div class="wfm-gallery-tree-header">
|
||||
<span class="wfm-gallery-tree-title">Folders</span>
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中: 画像一覧 -->
|
||||
<div id="wfm-gallery-list-panel" class="wfm-gallery-list-panel">
|
||||
<div id="wfm-gallery-grid" class="wfm-gallery-grid wfm-gallery-view-thumb">
|
||||
<p class="wfm-placeholder" id="wfm-gallery-list-placeholder">Select a folder to browse images.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右: 詳細情報サイドパネル -->
|
||||
<div id="wfm-gallery-detail-panel" class="wfm-gallery-detail-panel">
|
||||
<div class="wfm-gallery-detail-header">
|
||||
<span id="wfm-gallery-detail-filename" class="wfm-gallery-detail-filename"></span>
|
||||
</div>
|
||||
<!-- サムネイルプレビュー -->
|
||||
<div id="wfm-gallery-detail-preview" class="wfm-gallery-detail-preview">
|
||||
<span class="wfm-placeholder">No selection</span>
|
||||
</div>
|
||||
<!-- タブナビ -->
|
||||
<div class="wfm-side-tab-nav">
|
||||
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="info">Info</button>
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="meta">Metadata</button>
|
||||
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="group">Groups</button>
|
||||
</div>
|
||||
<!-- Info タブ -->
|
||||
<div id="wfm-gallery-detail-info" class="wfm-gallery-detail-tab-content active">
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-info-name"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-info-size"></div>
|
||||
<div class="wfm-gallery-info-row" id="wfm-gallery-info-date"></div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<!-- タグ -->
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-section-title">Tags</div>
|
||||
<div id="wfm-gallery-tags-display" class="wfm-gallery-tags-display"></div>
|
||||
<div style="display:flex;gap:4px;margin-top:4px;">
|
||||
<input type="text" id="wfm-gallery-tag-input" class="wfm-input" placeholder="Add tag..." style="flex:1;min-width:0;font-size:12px;padding:4px 6px;">
|
||||
<button id="wfm-gallery-tag-add-btn" class="wfm-btn wfm-btn-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="wfm-group-divider">
|
||||
<!-- メモ -->
|
||||
<div class="wfm-gallery-info-section">
|
||||
<div class="wfm-gallery-section-title">Memo</div>
|
||||
<textarea id="wfm-gallery-memo" class="wfm-gallery-memo" placeholder="Write a memo..." rows="3"></textarea>
|
||||
<button id="wfm-gallery-memo-save-btn" class="wfm-btn wfm-btn-sm" style="margin-top:4px;width:100%;">Save Memo</button>
|
||||
</div>
|
||||
<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;">
|
||||
<div class="wfm-gallery-meta-toolbar">
|
||||
<span id="wfm-gallery-meta-status" class="wfm-gallery-meta-status"></span>
|
||||
<button id="wfm-gallery-copy-workflow-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Copy embedded workflow JSON">Copy Workflow</button>
|
||||
</div>
|
||||
<pre id="wfm-gallery-workflow-json" class="wfm-gallery-workflow-json">No workflow found in this image.</pre>
|
||||
</div>
|
||||
|
||||
<!-- Group タブ -->
|
||||
<div id="wfm-gallery-detail-group" class="wfm-gallery-detail-tab-content" style="display:none;overflow-y:auto;">
|
||||
<!-- JS動的生成: renderDetailGroup() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Workflow Detail Modal -->
|
||||
|
||||
+346
-23
@@ -24,7 +24,10 @@ const state = {
|
||||
topTab: "workflows", // "workflows" | "nodes" | "prompts" | "models"
|
||||
// Node sub-tabs (existing)
|
||||
activeTab: "all", // all | favorites | groups
|
||||
activeTab2: null, // 2nd row: "sets" (or null when 1st row active)
|
||||
activeTab2: null, // 2nd row: "sets" | "category" | "package" (or null when 1st row active)
|
||||
activeNodeCategory: "", // selected category value
|
||||
activeNodePackage: "", // selected package value
|
||||
objectInfo: {}, // raw /object_info data for package lookup
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
@@ -79,13 +82,28 @@ const fetchGroups = async () => {
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const fetchObjectInfo = async () => {
|
||||
try {
|
||||
const res = await fetch(`${window.location.origin}/object_info`);
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const extractPackageName = (pythonModule) => {
|
||||
if (!pythonModule || pythonModule === "nodes") return "ComfyUI (Built-in)";
|
||||
const parts = pythonModule.split(".");
|
||||
if (parts[0] === "custom_nodes" && parts.length > 1) return parts[1];
|
||||
return pythonModule;
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
const [metadata, nodeSets, groups, objectInfo] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(), fetchObjectInfo(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
state.objectInfo = objectInfo;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
@@ -149,7 +167,17 @@ const fetchModelList = async (type) => {
|
||||
vae: () => fetch(`${comfyBase}/object_info/VAELoader`).then(r => r.json()).then(d => d?.VAELoader?.input?.required?.vae_name?.[0] || []),
|
||||
controlnet: () => fetch(`${comfyBase}/object_info/ControlNetLoader`).then(r => r.json()).then(d => d?.ControlNetLoader?.input?.required?.control_net_name?.[0] || []),
|
||||
unet: () => fetch(`${comfyBase}/object_info/UNETLoader`).then(r => r.json()).then(d => d?.UNETLoader?.input?.required?.unet_name?.[0] || []),
|
||||
textencoder: () => fetch(`${comfyBase}/object_info/CLIPLoader`).then(r => r.json()).then(d => d?.CLIPLoader?.input?.required?.clip_name1?.[0] || []),
|
||||
textencoder: async () => {
|
||||
const base = window.location.origin;
|
||||
for (const cls of ["DualCLIPLoader", "CLIPLoader"]) {
|
||||
try {
|
||||
const d = await fetch(`${base}/object_info/${cls}`).then(r => r.json());
|
||||
const list = d?.[cls]?.input?.required?.clip_name1?.[0];
|
||||
if (list?.length) return list;
|
||||
} catch {}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
hypernetwork:() => fetch(`${comfyBase}/object_info/HypernetworkLoader`).then(r => r.json()).then(d => d?.HypernetworkLoader?.input?.required?.hypernetwork_name?.[0] || []),
|
||||
embedding: () => fetch(`${comfyBase}/embeddings`).then(r => r.json()).then(d => Array.isArray(d) ? d : []),
|
||||
};
|
||||
@@ -199,7 +227,7 @@ const fetchWorkflowRaw = async (filename) => {
|
||||
|
||||
const loadWfData = async () => {
|
||||
const workflows = await fetchWorkflows();
|
||||
state.wfList = workflows;
|
||||
state.wfList = workflows.filter(w => w.filename !== ".index.json");
|
||||
|
||||
// Extract favorites
|
||||
state.wfFavorites = workflows.filter(w => w.metadata?.favorite);
|
||||
@@ -496,6 +524,76 @@ let panelEl = null;
|
||||
|
||||
const PANEL_ID = "wfm-node-library-panel";
|
||||
|
||||
// ============================================
|
||||
// Theme
|
||||
// ============================================
|
||||
|
||||
const THEME_KEY = "wfm_nlp_theme";
|
||||
|
||||
const THEME_VARS = [
|
||||
{ key: "bg", label: "Background", cssVar: "--comfy-menu-bg", default: "#1e1e1e" },
|
||||
{ key: "input", label: "Sub-header BG", cssVar: "--comfy-input-bg", default: "#2a2a2a" },
|
||||
{ key: "text", label: "Text", cssVar: "--input-text", default: "#dddddd" },
|
||||
{ key: "border", label: "Border", cssVar: "--border-color", default: "#4e4e4e" },
|
||||
{ key: "desc", label: "Secondary text", cssVar: "--descrip-text", default: "#888888" },
|
||||
];
|
||||
|
||||
function loadTheme() {
|
||||
try { return JSON.parse(localStorage.getItem(THEME_KEY)) || {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function applyTheme(panel, theme) {
|
||||
THEME_VARS.forEach(({ key, cssVar, default: def }) => {
|
||||
panel.style.setProperty(cssVar, theme[key] || def);
|
||||
});
|
||||
}
|
||||
|
||||
function buildThemePanel(panel) {
|
||||
const themePanel = panel.querySelector(".wfm-nlp-theme-panel");
|
||||
if (!themePanel) return;
|
||||
const theme = loadTheme();
|
||||
themePanel.innerHTML = `
|
||||
<div class="wfm-nlp-theme-title">Panel Theme</div>
|
||||
${THEME_VARS.map(({ key, label, default: def }) => `
|
||||
<div class="wfm-nlp-theme-row">
|
||||
<span class="wfm-nlp-theme-label">${label}</span>
|
||||
<input type="color" class="wfm-nlp-theme-color" data-key="${key}"
|
||||
value="${theme[key] || def}">
|
||||
</div>`).join("")}
|
||||
<div class="wfm-nlp-theme-actions">
|
||||
<button class="wfm-nlp-theme-reset-btn">Reset</button>
|
||||
<button class="wfm-nlp-theme-save-btn">Save</button>
|
||||
</div>`;
|
||||
|
||||
themePanel.querySelector(".wfm-nlp-theme-save-btn").addEventListener("click", () => {
|
||||
const saved = {};
|
||||
themePanel.querySelectorAll(".wfm-nlp-theme-color").forEach(input => {
|
||||
saved[input.dataset.key] = input.value;
|
||||
});
|
||||
localStorage.setItem(THEME_KEY, JSON.stringify(saved));
|
||||
applyTheme(panel, saved);
|
||||
themePanel.style.display = "none";
|
||||
});
|
||||
|
||||
themePanel.querySelector(".wfm-nlp-theme-reset-btn").addEventListener("click", () => {
|
||||
localStorage.removeItem(THEME_KEY);
|
||||
THEME_VARS.forEach(({ cssVar, default: def }) => {
|
||||
panel.style.setProperty(cssVar, def);
|
||||
});
|
||||
themePanel.style.display = "none";
|
||||
});
|
||||
|
||||
// Live preview on color change
|
||||
themePanel.querySelectorAll(".wfm-nlp-theme-color").forEach(input => {
|
||||
input.addEventListener("input", () => {
|
||||
panel.style.setProperty(
|
||||
THEME_VARS.find(v => v.key === input.dataset.key).cssVar,
|
||||
input.value
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const createPanel = () => {
|
||||
if (panelEl) return panelEl;
|
||||
|
||||
@@ -504,9 +602,11 @@ const createPanel = () => {
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">Workflow Studio Library</span>
|
||||
<button class="wfm-nlp-theme-btn" title="Theme settings">⚙</button>
|
||||
<button class="wfm-nlp-refresh" title="Refresh">↻</button>
|
||||
<button class="wfm-nlp-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-theme-panel" style="display:none;"></div>
|
||||
<div class="wfm-nlp-tabs">
|
||||
<button class="wfm-nlp-tab wfm-nlp-top-tab active" data-toptab="workflows">Workflows</button>
|
||||
<button class="wfm-nlp-tab wfm-nlp-top-tab" data-toptab="nodes">Nodes</button>
|
||||
@@ -584,7 +684,24 @@ const createPanel = () => {
|
||||
showToast("Library refreshed", "success");
|
||||
});
|
||||
|
||||
// Theme button
|
||||
panel.querySelector(".wfm-nlp-theme-btn").addEventListener("click", () => {
|
||||
const themePanel = panel.querySelector(".wfm-nlp-theme-panel");
|
||||
if (!themePanel) return;
|
||||
const isOpen = themePanel.style.display !== "none";
|
||||
if (isOpen) {
|
||||
themePanel.style.display = "none";
|
||||
} else {
|
||||
buildThemePanel(panel);
|
||||
themePanel.style.display = "block";
|
||||
}
|
||||
});
|
||||
|
||||
injectStyles();
|
||||
|
||||
// Apply saved theme
|
||||
applyTheme(panel, loadTheme());
|
||||
|
||||
rebuildSubTabs();
|
||||
return panel;
|
||||
};
|
||||
@@ -715,6 +832,8 @@ const rebuildSubTabs = () => {
|
||||
];
|
||||
const row2Tabs = [
|
||||
{ key: "sets", label: "\u2630 Sets" },
|
||||
{ key: "category", label: "\ud83d\udcc2 Category" },
|
||||
{ key: "package", label: "\ud83e\udde9 Package" },
|
||||
];
|
||||
|
||||
const activeKey = state.activeTab2 || state.activeTab;
|
||||
@@ -757,6 +876,9 @@ const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
// Remove filter dropdowns from previous category/package views
|
||||
panelEl.querySelectorAll(".wfm-nlp-filter-row").forEach(e => e.remove());
|
||||
|
||||
if (state.topTab === "models") {
|
||||
if (!state.modelsLoaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
@@ -802,6 +924,8 @@ const renderContent = () => {
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
case "category": renderNodesByCategory(content); break;
|
||||
case "package": renderNodesByPackage(content); break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1200,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;
|
||||
}
|
||||
@@ -1223,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";
|
||||
@@ -1245,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);
|
||||
@@ -1583,6 +1710,133 @@ const renderGroups = (container) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render – Nodes by Category / Package
|
||||
// ============================================
|
||||
|
||||
const getNodeCategory = (nodeType) => {
|
||||
const info = state.objectInfo[nodeType];
|
||||
if (info?.category) return info.category.split("/")[0] || "uncategorized";
|
||||
// Fallback: LiteGraph type string often contains category as prefix
|
||||
const def = typeof LiteGraph !== "undefined"
|
||||
? LiteGraph.registered_node_types[nodeType]
|
||||
: null;
|
||||
const cat = def?.category || "";
|
||||
return cat.split("/")[0] || "uncategorized";
|
||||
};
|
||||
|
||||
const getNodePackage = (nodeType) => {
|
||||
const info = state.objectInfo[nodeType];
|
||||
if (!info) return "ComfyUI (Built-in)";
|
||||
return extractPackageName(info.python_module || "");
|
||||
};
|
||||
|
||||
const renderNodesByCategory = (container) => {
|
||||
const registered = typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types : {};
|
||||
const nodeNames = Object.keys(registered).sort();
|
||||
|
||||
// Build category list
|
||||
const catSet = new Set();
|
||||
nodeNames.forEach(n => catSet.add(getNodeCategory(n)));
|
||||
const categories = [...catSet].sort();
|
||||
|
||||
// Insert dropdown above content (remove old one first)
|
||||
container.parentNode.querySelectorAll(".wfm-nlp-filter-row").forEach(e => e.remove());
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "wfm-nlp-filter-row";
|
||||
wrap.style.cssText = "padding:6px 8px;border-bottom:1px solid var(--border-color,#4e4e4e);flex-shrink:0;";
|
||||
const sel = document.createElement("select");
|
||||
sel.style.cssText = "width:100%;padding:4px 6px;background:var(--comfy-input-bg,#2a2a2a);border:1px solid var(--border-color,#4e4e4e);border-radius:3px;color:var(--input-text,#ddd);font-size:12px;";
|
||||
sel.innerHTML = `<option value="">-- All Categories --</option>` +
|
||||
categories.map(c => `<option value="${esc(c)}"${c === state.activeNodeCategory ? " selected" : ""}>${esc(c)}</option>`).join("");
|
||||
sel.addEventListener("change", () => {
|
||||
state.activeNodeCategory = sel.value;
|
||||
renderNodesByCategoryList(container);
|
||||
});
|
||||
wrap.appendChild(sel);
|
||||
container.parentNode.insertBefore(wrap, container);
|
||||
|
||||
renderNodesByCategoryList(container);
|
||||
};
|
||||
|
||||
const renderNodesByCategoryList = (container) => {
|
||||
const registered = typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types : {};
|
||||
let nodeNames = Object.keys(registered).sort();
|
||||
|
||||
if (state.activeNodeCategory) {
|
||||
nodeNames = nodeNames.filter(n => getNodeCategory(n) === state.activeNodeCategory);
|
||||
}
|
||||
if (state.searchText) {
|
||||
nodeNames = nodeNames.filter(n => n.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
if (nodeNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">No nodes found</div>`;
|
||||
return;
|
||||
}
|
||||
for (const name of nodeNames) {
|
||||
const isFav = state.metadata[name]?.favorite;
|
||||
const label = isFav ? `<span class="wfm-nlp-fav-star">\u2605</span>${esc(name)}` : esc(name);
|
||||
const el = createDraggableItem(label, "single", { classType: name });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(name));
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNodesByPackage = (container) => {
|
||||
const registered = typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types : {};
|
||||
const nodeNames = Object.keys(registered).sort();
|
||||
|
||||
// Build package list
|
||||
const pkgSet = new Set();
|
||||
nodeNames.forEach(n => pkgSet.add(getNodePackage(n)));
|
||||
const packages = [...pkgSet].sort();
|
||||
|
||||
// Insert dropdown above content (remove old one first)
|
||||
container.parentNode.querySelectorAll(".wfm-nlp-filter-row").forEach(e => e.remove());
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "wfm-nlp-filter-row";
|
||||
wrap.style.cssText = "padding:6px 8px;border-bottom:1px solid var(--border-color,#4e4e4e);flex-shrink:0;";
|
||||
const sel = document.createElement("select");
|
||||
sel.style.cssText = "width:100%;padding:4px 6px;background:var(--comfy-input-bg,#2a2a2a);border:1px solid var(--border-color,#4e4e4e);border-radius:3px;color:var(--input-text,#ddd);font-size:12px;";
|
||||
sel.innerHTML = `<option value="">-- All Packages --</option>` +
|
||||
packages.map(p => `<option value="${esc(p)}"${p === state.activeNodePackage ? " selected" : ""}>${esc(p)}</option>`).join("");
|
||||
sel.addEventListener("change", () => {
|
||||
state.activeNodePackage = sel.value;
|
||||
renderNodesByPackageList(container);
|
||||
});
|
||||
wrap.appendChild(sel);
|
||||
container.parentNode.insertBefore(wrap, container);
|
||||
|
||||
renderNodesByPackageList(container);
|
||||
};
|
||||
|
||||
const renderNodesByPackageList = (container) => {
|
||||
const registered = typeof LiteGraph !== "undefined" ? LiteGraph.registered_node_types : {};
|
||||
let nodeNames = Object.keys(registered).sort();
|
||||
|
||||
if (state.activeNodePackage) {
|
||||
nodeNames = nodeNames.filter(n => getNodePackage(n) === state.activeNodePackage);
|
||||
}
|
||||
if (state.searchText) {
|
||||
nodeNames = nodeNames.filter(n => n.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
if (nodeNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">No nodes found</div>`;
|
||||
return;
|
||||
}
|
||||
for (const name of nodeNames) {
|
||||
const isFav = state.metadata[name]?.favorite;
|
||||
const label = isFav ? `<span class="wfm-nlp-fav-star">\u2605</span>${esc(name)}` : esc(name);
|
||||
const el = createDraggableItem(label, "single", { classType: name });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(name));
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Draggable item factory (nodes)
|
||||
// ============================================
|
||||
@@ -2062,6 +2316,75 @@ const injectStyles = () => {
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: var(--descrip-text, #aaa);
|
||||
}
|
||||
.wfm-nlp-theme-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wfm-nlp-theme-btn:hover { opacity: 1; }
|
||||
.wfm-nlp-theme-panel {
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
padding: 10px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-theme-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--input-text, #ddd);
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wfm-nlp-theme-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wfm-nlp-theme-label {
|
||||
font-size: 11px;
|
||||
color: var(--descrip-text, #aaa);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-nlp-theme-color {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-color, #555);
|
||||
border-radius: 3px;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.wfm-nlp-theme-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wfm-nlp-theme-save-btn, .wfm-nlp-theme-reset-btn {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color, #555);
|
||||
}
|
||||
.wfm-nlp-theme-save-btn {
|
||||
background: #4a9eff;
|
||||
color: #fff;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
.wfm-nlp-theme-save-btn:hover { background: #3a8eef; }
|
||||
.wfm-nlp-theme-reset-btn {
|
||||
background: none;
|
||||
color: var(--descrip-text, #aaa);
|
||||
}
|
||||
.wfm-nlp-theme-reset-btn:hover { color: var(--input-text, #ddd); }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Node Library Sidebar Panel
|
||||
*
|
||||
* Fixed sidebar panel injected into ComfyUI DOM.
|
||||
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
|
||||
* Items are draggable onto the canvas or click-to-place.
|
||||
*/
|
||||
console.log("[WFM] node_sets_menu.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] node_sets_menu.js: app imported");
|
||||
|
||||
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
const state = {
|
||||
visible: false,
|
||||
activeTab: "favorites", // favorites | sets | groups
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
metadata: {},
|
||||
searchText: "",
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API
|
||||
// ============================================
|
||||
|
||||
const fetchNodeSets = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/groups");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
for (const [name, meta] of Object.entries(metadata)) {
|
||||
if (name === "_groups") continue;
|
||||
if (meta.favorite) {
|
||||
state.favorites.push({ name, display_name: name });
|
||||
}
|
||||
}
|
||||
state.loaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas placement helpers
|
||||
// ============================================
|
||||
|
||||
const getCanvasDropPos = (e) => {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return [100, 100];
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
|
||||
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const placeSingleNode = (classType, pos) => {
|
||||
const graph = app.graph;
|
||||
if (!graph) return;
|
||||
const node = LiteGraph.createNode(classType);
|
||||
if (!node) {
|
||||
showToast(`Unknown node: ${classType}`, "error");
|
||||
return;
|
||||
}
|
||||
node.pos = pos || [100, 100];
|
||||
graph.add(node);
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: ${node.title || classType}`, "success");
|
||||
};
|
||||
|
||||
const placeNodeSet = (nodeSet, pos) => {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas) return;
|
||||
|
||||
const nodes = nodeSet.nodes || [];
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
|
||||
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
|
||||
|
||||
const positions = nodes.map(n => n.rel_pos || [0, 0]);
|
||||
const minX = Math.min(...positions.map(p => p[0]));
|
||||
const minY = Math.min(...positions.map(p => p[1]));
|
||||
|
||||
const createdNodes = [];
|
||||
for (const nodeDef of nodes) {
|
||||
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
|
||||
const node = LiteGraph.createNode(nodeDef.class_type);
|
||||
if (!node) { createdNodes.push(null); continue; }
|
||||
const rp = nodeDef.rel_pos || [0, 0];
|
||||
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
|
||||
if (nodeDef.title) node.title = nodeDef.title;
|
||||
if (nodeDef.widget_values && node.widgets) {
|
||||
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
|
||||
const w = node.widgets.find(ww => ww.name === k);
|
||||
if (w) w.value = v;
|
||||
}
|
||||
}
|
||||
graph.add(node);
|
||||
createdNodes.push(node);
|
||||
}
|
||||
|
||||
for (const link of (nodeSet.links || [])) {
|
||||
const from = createdNodes[link.from_node];
|
||||
const to = createdNodes[link.to_node];
|
||||
if (from && to) {
|
||||
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setDirty(true, true);
|
||||
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Drag & Drop onto canvas
|
||||
// ============================================
|
||||
|
||||
let dropHandlerInstalled = false;
|
||||
|
||||
const installCanvasDropHandler = () => {
|
||||
if (dropHandlerInstalled) return;
|
||||
dropHandlerInstalled = true;
|
||||
|
||||
const canvasEl = document.getElementById("graph-canvas");
|
||||
if (!canvasEl) return;
|
||||
|
||||
canvasEl.addEventListener("dragover", (e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener("drop", (e) => {
|
||||
const raw = e.dataTransfer.getData("application/x-wfm-node");
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
const pos = getCanvasDropPos(e);
|
||||
|
||||
if (data.type === "single") {
|
||||
placeSingleNode(data.classType, pos);
|
||||
} else if (data.type === "set") {
|
||||
const set = state.nodeSets.find(s => s.id === data.setId);
|
||||
if (set) placeNodeSet(set, pos);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Sidebar Panel DOM
|
||||
// ============================================
|
||||
|
||||
let panelEl = null;
|
||||
|
||||
const PANEL_ID = "wfm-node-library-panel";
|
||||
|
||||
const createPanel = () => {
|
||||
if (panelEl) return panelEl;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = PANEL_ID;
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">Node Library</span>
|
||||
<button class="wfm-nlp-refresh" title="Refresh">↻</button>
|
||||
<button class="wfm-nlp-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-tabs">
|
||||
<button class="wfm-nlp-tab active" data-tab="favorites">★ Favorites</button>
|
||||
<button class="wfm-nlp-tab" data-tab="sets">☰ Sets</button>
|
||||
<button class="wfm-nlp-tab" data-tab="groups">📁 Groups</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-search">
|
||||
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
|
||||
</div>
|
||||
<div class="wfm-nlp-content"></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(panel);
|
||||
panelEl = panel;
|
||||
|
||||
// Tab switching
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab = btn.dataset.tab;
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
// Search
|
||||
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
|
||||
state.searchText = e.target.value.toLowerCase().trim();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
// Refresh
|
||||
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
|
||||
await loadData();
|
||||
renderContent();
|
||||
showToast("Node Library refreshed", "success");
|
||||
});
|
||||
|
||||
injectStyles();
|
||||
return panel;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render content
|
||||
// ============================================
|
||||
|
||||
const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
if (!state.loaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state.activeTab) {
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFavorites = (container) => {
|
||||
let items = state.favorites;
|
||||
if (state.searchText) {
|
||||
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const node of items) {
|
||||
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeSingleNode(node.name);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSets = (container) => {
|
||||
let items = state.nodeSets;
|
||||
if (state.searchText) {
|
||||
items = items.filter(s =>
|
||||
s.name.toLowerCase().includes(state.searchText) ||
|
||||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const set of items) {
|
||||
const count = (set.nodes || []).length;
|
||||
const el = createDraggableItem(
|
||||
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
|
||||
"set",
|
||||
{ setId: set.id },
|
||||
set.description
|
||||
);
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeNodeSet(set);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (container) => {
|
||||
const groupNames = Object.keys(state.groups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let filtered = groupNames;
|
||||
if (state.searchText) {
|
||||
filtered = groupNames.filter(g =>
|
||||
g.toLowerCase().includes(state.searchText) ||
|
||||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const groupName of filtered) {
|
||||
const nodes = state.groups[groupName] || [];
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.length}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
const list = section.querySelector(".wfm-nlp-group-list");
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
section.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "wfm-nlp-group-list";
|
||||
for (const nodeName of nodes) {
|
||||
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
|
||||
list.appendChild(el);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Draggable item factory
|
||||
// ============================================
|
||||
|
||||
const createDraggableItem = (label, type, data, subtitle) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.draggable = true;
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-label">${label}</div>
|
||||
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
|
||||
`;
|
||||
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
|
||||
export const togglePanel = async () => {
|
||||
const panel = createPanel();
|
||||
state.visible = !state.visible;
|
||||
panel.style.display = state.visible ? "flex" : "none";
|
||||
|
||||
if (state.visible && !state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
|
||||
installCanvasDropHandler();
|
||||
};
|
||||
|
||||
export const getNodeSetsIcon = () => `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<path d="M17.5 14v7M14 17.5h7"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Toast
|
||||
// ============================================
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
const existing = document.getElementById("wfm-ns-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-ns-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)" },
|
||||
error: { bg: "rgba(255,71,87,0.95)" },
|
||||
info: { bg: "rgba(74,158,255,0.95)" },
|
||||
};
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
|
||||
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
|
||||
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
};
|
||||
|
||||
const esc = (s) => s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : "";
|
||||
|
||||
// ============================================
|
||||
// Save selected nodes as Node Set (context menu)
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for name
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
// Build node list with relative positions
|
||||
// Use the top-left of the bounding box as origin
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
// Extract links between selected nodes
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
// Resolve link data
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
// or object with .origin_id, .origin_slot, etc.
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
// Only include if both source and target are in the selection
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save via API
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
// Refresh sidebar if open
|
||||
if (state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Register context menu item on all nodes
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.NodeSetContextMenu",
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
|
||||
// Add separator + menu item
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Styles
|
||||
// ============================================
|
||||
|
||||
const injectStyles = () => {
|
||||
if (document.getElementById("wfm-nlp-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wfm-nlp-styles";
|
||||
style.textContent = `
|
||||
#${PANEL_ID} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--comfy-menu-bg, #1e1e1e);
|
||||
border-left: 1px solid var(--border-color, #4e4e4e);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--input-text, #ddd);
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.wfm-nlp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wfm-nlp-close:hover { opacity: 1; }
|
||||
.wfm-nlp-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-nlp-tab:hover { opacity: 0.9; }
|
||||
.wfm-nlp-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
border-radius: 4px;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wfm-nlp-search-input:focus {
|
||||
border-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.wfm-nlp-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--descrip-text, #999);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wfm-nlp-item {
|
||||
padding: 7px 12px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
.wfm-nlp-item:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wfm-nlp-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #888);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-nlp-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--comfy-input-bg, #444);
|
||||
border-radius: 3px;
|
||||
color: var(--descrip-text, #aaa);
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.wfm-nlp-group-section {
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
}
|
||||
.wfm-nlp-group-header {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.wfm-nlp-group-header:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-group-header::before {
|
||||
content: "\\25BC";
|
||||
font-size: 8px;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wfm-nlp-group-header.collapsed::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.wfm-nlp-group-list {
|
||||
padding-left: 8px;
|
||||
}
|
||||
.wfm-nlp-group-list .wfm-nlp-item {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wfm-nlp-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wfm-nlp-refresh:hover { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Context Menu — Save selected nodes as Node Set
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
if (state.loaded) { await loadData(); renderContent(); }
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
export { saveSelectedAsNodeSet };
|
||||
@@ -0,0 +1,565 @@
|
||||
console.log("[WFM] top_menu_extension.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] app imported:", !!app);
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
console.log("[WFM] node_sets_menu imports loaded");
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
const WFM_PATH = "/wfm";
|
||||
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
|
||||
const MAX_ATTACH_ATTEMPTS = 120;
|
||||
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
const openWorkflowStudio = (event) => {
|
||||
const url = `${window.location.origin}${WFM_PATH}`;
|
||||
|
||||
if (event.shiftKey) {
|
||||
window.open(url, "_blank", NEW_WINDOW_FEATURES);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas Snapshot - Export workflow as PNG thumbnail
|
||||
// ============================================
|
||||
|
||||
const getWorkflowFilename = () => {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
return `wf_${stamp}.json`;
|
||||
};
|
||||
|
||||
// ---- PNG tEXt chunk embedding helpers ----
|
||||
|
||||
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
|
||||
const joinArrayBuffer = (...bufs) => {
|
||||
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
|
||||
bufs.reduce((offset, buf) => {
|
||||
result.set(buf, offset);
|
||||
return offset + buf.byteLength;
|
||||
}, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
let _crcTable = null;
|
||||
const crc32 = (data) => {
|
||||
if (!_crcTable) {
|
||||
_crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
_crcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
};
|
||||
|
||||
const embedWorkflowInPng = async (blob, workflowJson) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const typedArr = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// Create tEXt chunk: "tEXtworkflow\0{json}"
|
||||
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
|
||||
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
|
||||
|
||||
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
|
||||
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
|
||||
|
||||
return new Blob([result], { type: "image/png" });
|
||||
};
|
||||
|
||||
// ---- Canvas capture ----
|
||||
|
||||
const captureCanvasSnapshot = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas || !graph._nodes?.length) {
|
||||
reject(new Error("No workflow loaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize workflow data before changing canvas state
|
||||
const workflowJson = JSON.stringify(graph.serialize());
|
||||
|
||||
// Calculate bounds of all nodes
|
||||
const bounds = graph._nodes.reduce(
|
||||
(p, n) => {
|
||||
if (n.pos[0] < p[0]) p[0] = n.pos[0];
|
||||
if (n.pos[1] < p[1]) p[1] = n.pos[1];
|
||||
const b = n.getBounding();
|
||||
const r = n.pos[0] + b[2];
|
||||
const bot = n.pos[1] + b[3];
|
||||
if (r > p[2]) p[2] = r;
|
||||
if (bot > p[3]) p[3] = bot;
|
||||
return p;
|
||||
},
|
||||
[99999, 99999, -99999, -99999]
|
||||
);
|
||||
|
||||
// Add padding
|
||||
const padding = 80;
|
||||
bounds[0] -= padding;
|
||||
bounds[1] -= padding;
|
||||
bounds[2] += padding;
|
||||
bounds[3] += padding;
|
||||
|
||||
// Save current canvas state
|
||||
const savedState = {
|
||||
scale: canvas.ds.scale,
|
||||
width: canvas.canvas.width,
|
||||
height: canvas.canvas.height,
|
||||
offset: [...canvas.ds.offset],
|
||||
transform: canvas.canvas.getContext("2d").getTransform(),
|
||||
};
|
||||
|
||||
// Set canvas to render full workflow
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.ds.scale = 1;
|
||||
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
|
||||
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
|
||||
canvas.ds.offset = [-bounds[0], -bounds[1]];
|
||||
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Render
|
||||
canvas.draw(true, true);
|
||||
|
||||
// Capture to blob
|
||||
canvas.canvas.toBlob(async (rawBlob) => {
|
||||
// Restore canvas state
|
||||
canvas.ds.scale = savedState.scale;
|
||||
canvas.canvas.width = savedState.width;
|
||||
canvas.canvas.height = savedState.height;
|
||||
canvas.ds.offset = savedState.offset;
|
||||
canvas.canvas.getContext("2d").setTransform(savedState.transform);
|
||||
canvas.draw(true, true);
|
||||
|
||||
if (!rawBlob) {
|
||||
reject(new Error("Failed to generate canvas image"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Embed workflow JSON into PNG tEXt chunk
|
||||
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
|
||||
resolve(blob);
|
||||
} catch (embedErr) {
|
||||
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
|
||||
resolve(rawBlob);
|
||||
}
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveCanvasToWorkflowStudio = async () => {
|
||||
const filename = getWorkflowFilename();
|
||||
|
||||
try {
|
||||
showNotification("Capturing workflow image...", "info");
|
||||
const blob = await captureCanvasSnapshot();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("filename", filename);
|
||||
fd.append("image", blob, "canvas.png");
|
||||
|
||||
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
|
||||
} catch (err) {
|
||||
showNotification("Error: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const showNotification = (message, type = "info") => {
|
||||
// Try using ComfyUI's built-in toast if available
|
||||
try {
|
||||
if (app.ui?.dialog) {
|
||||
// Fallback: use a simple temporary notification
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Simple toast notification
|
||||
const existing = document.getElementById("wfm-snapshot-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-snapshot-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
|
||||
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
|
||||
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
|
||||
};
|
||||
const c = colors[type] || colors.info;
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed",
|
||||
bottom: "24px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
padding: "10px 24px",
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
||||
transition: "opacity 0.3s ease",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
if (type !== "info") {
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
} else {
|
||||
// Info messages will be replaced by success/error
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Version Detection & UI
|
||||
// ============================================
|
||||
|
||||
const getComfyUIFrontendVersion = async () => {
|
||||
try {
|
||||
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
|
||||
return window["__COMFYUI_FRONTEND_VERSION__"];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/system_stats");
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.system?.comfyui_frontend_version) {
|
||||
return data.system.comfyui_frontend_version;
|
||||
}
|
||||
|
||||
if (data?.system?.required_frontend_version) {
|
||||
return data.system.required_frontend_version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to fetch system_stats:", error);
|
||||
}
|
||||
|
||||
return "0.0.0";
|
||||
};
|
||||
|
||||
const parseVersion = (versionStr) => {
|
||||
if (!versionStr || typeof versionStr !== "string") {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
|
||||
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const compareVersions = (version1, version2) => {
|
||||
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
|
||||
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1[i] > v2[i]) return 1;
|
||||
if (v1[i] < v2[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const supportsActionBarButtons = async () => {
|
||||
const version = await getComfyUIFrontendVersion();
|
||||
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
|
||||
};
|
||||
|
||||
const getWfmIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
|
||||
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
const getSnapshotIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M8 5 L9 3 L15 3 L16 5"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Button Creation
|
||||
// ============================================
|
||||
|
||||
const createTopMenuButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "wfm",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
|
||||
button.element.title = BUTTON_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getWfmIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", openWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createSnapshotButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "snapshot",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
|
||||
button.element.title = SNAPSHOT_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getSnapshotIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
|
||||
nsButtons.forEach((button) => {
|
||||
button.classList.add("wfm-node-sets-btn");
|
||||
button.innerHTML = getNodeSetsIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user