Compare commits

...
9 Commits
Author SHA1 Message Date
ketle-manandClaude Sonnet 4.6 40955f2822 Improve UI layout, fix badge persistence, add folder filter (v0.2.6)
- Fix workflow badge not saved/loaded after restart (workflow_service.py)
- Fix workflow badge filter buttons to show palette-defined badges with colors
- Make side panels always visible (remove toggle) in Workflow/Model/Node tabs
- Replace pagination with full scroll display in all three tabs
- Add folder (subdir) filter to Models tab toolbar
- Remove dir/ext badges from Model card view (visible in side panel)
- Resize thumbnail cards to 178x200px, fix grid layout crush with grid-auto-rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:12:31 +09:00
ketle-manandClaude Sonnet 4.6 de6da654ca Add sidebar Category/Package tabs, theme settings, and bug fixes (v0.2.5)
- Sidebar Nodes tab: add Category and Package sub-tabs with dropdown filtering
  - Category: filter nodes by top-level category via /object_info
  - Package: filter nodes by custom node package name (DualCLIPLoader/CLIPLoader fallback)
- Sidebar theme settings: add gear button to customize panel colors (bg, text, border, etc.) with live preview; saved to localStorage
- Sidebar Workflows: hide .index.json from workflow list
- Sidebar Models By Type: fix TextEncoder not appearing (use DualCLIPLoader→CLIPLoader fallback for clip_name1)
- Workflow tab: hide .index.json from workflow list
- Help tab: update Workflow Studio Library section with new features
- Remove unused buildDropdown function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:07:49 +09:00
ketle-manandClaude Sonnet 4.6 e7fdfc41ad Redesign GenerateUI tab layout: 3-tab structure with inline Raw JSON (v0.2.4)
- 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 preview and direct editing from any tab
- Input tab: Prompt (top) + Image (bottom) stacked vertically in left column
- Model tab: model selectors with filter in left column
- Settings tab: KSampler (top) + LatentImage (bottom) stacked vertically in left column
- Raw JSON widget shared across tabs via DOM appendChild on tab switch
- Right column slimmed to 280px: Generate/Seed/Results only
- Docs: DEVLOG, README, i18n help keys, index.html help tab updated for v0.2.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:01:45 +09:00
ketle-manandClaude Sonnet 4.6 fe47460e00 Unify badge system, add GenUI Model button, expand sidebar Models tab (v0.2.3)
- Badge system: Workflow and Models tabs share a single palette (wfm_models_badge_palette); workflow auto-analysis model type system removed in favor of user-defined free-label badges
- GenUI Model button: apply models directly to GenerateUI's current workflow from detail modal and side panel (Checkpoint/LoRA/VAE/ControlNet/UNET/TextEncoder)
- Sidebar Models tab: All / Favorites / Groups / By Type sub-tabs added to Workflow Studio Library
- Sidebar All views: star (★) shown for favorites in Workflows and Nodes All sub-tabs
- Sidebar panel width: 280px → 310px
- Docs: DEVLOG, README, i18n help keys, index.html help tab updated for v0.2.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 17:12:28 +09:00
ketle-manandClaude Opus 4.6 7cc0713b29 Add Models tab with CivitAI integration, model browser, and management features (v0.2.2)
- Models tab: browse Checkpoint/LoRA/VAE/ControlNet/UNET/TextEncoder with sub-tabs
- CivitAI integration: fetch model info by SHA256 hash, batch fetch with SSE progress
- Model groups, metadata persistence (favorites, tags, memo), preview images
- Table view memo column, file path display in side panel
- Node card redesign: left border color-coding by package
- Toolbar pagination for all tabs, updated Help tab and documentation

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 00:22:25 +09:00
ketle-manandClaude Opus 4.6 2be048f13c Add DEVLOG entry for v0.2.0 prompt presets feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:25:11 +09:00
24 changed files with 5794 additions and 502 deletions
+561
View File
@@ -1,5 +1,566 @@
# DEVLOG - ComfyUI-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タブ レイアウト再設計
### 概要
- GenerateUIのサブタブを5タブ(Prompt/Image/Model/Settings/RawJSON)→ 3タブ(Input/Model/Settings)に整理
- 各タブ内にRaw JSONエディタを右列として常時表示。どのタブでもJSON変更をリアルタイム確認・直接編集・Apply可能
- Raw JSONタブを廃止(独立タブとしての Raw JSON は不要)
- 右列(生成コントロール)はGenerate/Seed/結果画像のみにスリム化
### 変更内容
#### `templates/index.html` — タブ構成刷新
- **Inputタブ**: 左列=Prompt(上段)+ Image(下段)の縦2段、右列=Raw JSON(540px)
- **Modelタブ**: 左列=モデル選択フォーム(スクロール)、右列=Raw JSON(540px)
- **Settingsタブ**: 左列=KSampler(上段)+ LatentImage(下段)の縦2段、右列=Raw JSON(540px)
- Raw JSONのDOMノード(`wfm-gen-rawjson-widget`)はページ内に1つ定義し、タブ切替時にJSで対応する列へ移動
- 右列(`.wfm-gen-right`): 280px幅、Generate/Stop/Progress/Seed+結果画像のみ
#### `static/js/generate-tab.js` — タブ切替時にRaw JSONを移動
- `moveRawJsonToTab(tabKey)` 追加: `wfm-gen-rawjson-widget` を `wfm-gen-rawjson-col-{tabKey}` へ `appendChild`
- 初期表示(Input)とタブクリック時に自動移動
#### `static/js/comfyui-editor.js` — Settings縦2段化
- `renderSettingsTab`: KSampler / LatentImageを横2列グリッド → 縦2段(`flex-direction:column`)に変更
- `renderAll` の末尾に `_syncRawJson()` 追加(ワークフロー読込直後にRaw JSONが即時反映)
#### `static/css/main.css` — 新レイアウト用スタイル
- `.wfm-gen-tab-cols`: タブ内2列レイアウト(flex行)
- `.wfm-gen-params-col`: 左サブ列(flex:1、縦積み)
- `.wfm-gen-params-col--scroll`: Model/Settings用スクロール変種
- `.wfm-gen-params-section`: Input用上下2段の各セクション(flex:1、overflow-y:auto)
- `.wfm-gen-rawjson-col`: Raw JSON列(540px固定幅)
- `#wfm-gen-rawjson-widget` / `.wfm-gen-rawjson-header` / `.wfm-gen-rawjson-editor`: Raw JSONウィジェットスタイル
### 技術的な判断
- **Raw JSONをDOMで移動**: タブごとにtextareaのIDは1つである必要があるため、DOMノードをappendChildで移動する方式を採用。イベントリスナーはノードに付いたまま引き継がれる
- **Raw JSON列幅540px**: テキストエディタとして十分な視認性を確保しつつ、パラメータ列にも十分な幅を残す設定
---
## 2026-03-29: v0.2.3 バッジ統一・GenUI Model・サイドパネルModels拡充
### 概要
- ワークフローとモデルのバッジ体系を統一(共有パレット `wfm_models_badge_palette`、自動解析廃止)
- ModelsタブにGenUI Modelボタン追加(詳細モーダル・サイドパネル両対応)
- ComfyUIサイドパネルのModelsタブにFavorites/Groups/By Typeサブタブを追加
- サイドパネルのWorkflows・Nodesサブタブ名を「All」に統一、お気に入りに★表示
- サイドパネル幅を280px→310pxに拡張
### 変更内容
#### `web/comfyui/node_sets_menu.js` — サイドパネルModels拡充
- **Modelsタブのサブタブ構成を2段に変更:**
- row1: All / ★ Favorites / 📁 Groups
- row2: ◦ By Type
- `state.modelSubTab2` で行2の選択を管理
- **`renderModelFavorites(container)`** — 全モデルを取得し `state.modelMetadata[name]?.favorite === true` のものを表示
- **`renderModelGroups(container)`** — `state.modelGroups` からコラプス式セクションを生成
- **Workflowsサブタブ名:** `"Workflows"` → `"All"`
- **Nodesサブタブ名:** `"Nodes"` → `"All"`
- **Allビューでお気に入りに★表示:**
- `createDraggableWfItem`: `wf.metadata?.favorite` に応じて `<span class="wfm-nlp-fav-star">★</span>` を追加
- `renderAllNodes`: `state.metadata[name]?.favorite` に応じて★を表示
- **`.wfm-nlp-fav-star`** スタイル追加(`color: #f5c518; font-size: 11px; margin-right: 3px`)
- **サイドパネル幅:** `width: 280px` → `width: 310px`
- **`getWfModelTypes` → `getWfBadges`** にリネーム(`wf.metadata?.badges` ベースに変更)
- **`wfModelTypes` state → `wfBadgeTypes`** にリネーム
- `renderWfModelType` がバッジ表示に `state.wfBadgeTypes` を使用
#### `static/js/models-tab.js` — GenUI Modelボタン・バッジ表示改善
- **型バッジの削除:** ThumbView・CardView・SidePanelの `renderSideInfo` から `typeBadge = badgeHtml(typeLabel)` を削除。ユーザー定義バッジのみ表示
- **`GENUI_TYPE_MAP` 追加:**
```javascript
const GENUI_TYPE_MAP = {
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
lora: { key: "loras", inputKey: "lora_name" },
vae: { key: "vaes", inputKey: "vae_name" },
controlnet: { key: "controlNets", inputKey: "control_net_name" },
unet: { key: "diffusionModels", inputKey: "unet_name" },
textencoder: { key: "textEncoders", inputKey: "clip_name1" },
};
```
hypernetwork / embedding は対象外
- **`applyToGenUI(modelName, modelType)`** 追加:
- `comfyUI.currentWorkflow` からターゲット `inputKey` を持つノードを検索
- `node.inputs[inputKey] = modelName` で直接セット
- `<select id="wfm-model-${key}">` の表示値も同期
- `<textarea id="wfm-gen-raw-json">` も同期
- 成功トースト表示
- **詳細モーダルの actions に「GenUI Model」ボタン追加**(`GENUI_TYPE_MAP` に対応するタイプのみ表示)
- **サイドパネルの Save ボタン右隣に「GenUI Model」ボタン追加**(同上)
- **`export function openBadgeEditModal(onPaletteChange = null)`** — workflow-tab.js から呼び出せるよう export 化
- **`bindBadgeModalEvents(onPaletteChange)`** — パレット変更後に `renderBadgeFilter()`, `renderModelGrid()`, `onPaletteChange()` を実行
#### `static/js/workflow-tab.js` — バッジ体系をモデルタブと統一
- **`import { openBadgeEditModal } from "./models-tab.js"`** 追加
- **自動解析によるバッジ体系を廃止:**
- `state.badgeColors`, `BADGE_DEFAULT_COLORS` 削除
- 「Analysis」「Override」セクションを詳細モーダルから完全削除
- `getAllModelTypes()` → `getAllBadges()` (`wf.metadata?.badges` ベース)
- `state.activeModel` → `state.activeBadge`
- **共有パレット関数を追加:** `getBadgePalette()`, `saveBadgePalette()` (`wfm_models_badge_palette` localStorage キー共有)
- **`badgeHtml(label)`** が共有パレットを参照するように変更
- **`wfBadgesHtml(wf)`** ヘルパー追加(`wf.metadata?.badges` から表示用バッジHTML生成)
- **`filterWorkflows()`** が `wf.metadata?.badges` でフィルタリング
- **詳細モーダルにバッジチェックボックスUI追加**(Modelsタブと同じ管理UI)
- **⚙ボタン** → `renderViewSettings()`(ビュー設定のみ)
- **「⚙ Badge」ボタン追加** → `openBadgeEditModal(() => renderGrid())`
#### `templates/index.html` — Workflowツールバー更新
- `<button id="wfm-reanalyze-btn">Reanalyze All</button>` 削除
- `<button id="wfm-badge-btn">⚙ Badge</button>` 追加
- ⚙設定ボタンの title を "View settings" に変更
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
- **GenUI Model関連(3言語):**
- `modelsGenUIBtn`: "GenUI Model"
- `modelsGenUITitle`: ボタンのツールチップ
- `modelsGenUIUnsupported`: 非対応タイプへの警告
- `modelsGenUINoWorkflow`: GenUIにワークフローが未読み込みの警告
- `modelsGenUINoNode(type)`: 対応ノードが見つからない場合の警告(アロー関数)
### 技術的な判断
- **バッジ統一の方針:** 自動解析モデルタイプはユーザー操作が必要なオーバーライドが多く UX が複雑だったため廃止。完全にユーザー定義バッジへ移行し、ワークフロー/モデル間で同じパレットを共有する設計に統一
- **GenUI連携方式:** `comfyUI.currentWorkflow` を直接ミューテーション。`<select>` 要素の DOM 同期も行うことで視覚的なフィードバックを確保
- **`openBadgeEditModal` のexport:** モデルタブとワークフロータブが同じパレット管理 UI を共有するため、`export function` に変更。`onPaletteChange` コールバックで各タブが独立してグリッドを再描画
---
## 2026-03-28: v0.2.2 Modelsタブ・CivitAI連携
### 概要
- ComfyUIにインストールされたモデル(Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder)を一覧・管理する「Models」タブを新規追加
- CivitAI API連携(SHA256ハッシュによるモデル情報取得、一括取得)
- モデルグループ管理、プレビュー画像表示・変更、メタデータ永続化
- ノードカードビュー改善、全タブのページネーションをツールバーに移動
### 新規ファイル
#### `py/services/models_service.py` — モデルメタデータサービス
- `_get_model_dirs()`: ComfyUIの`folder_paths.get_folder_paths()`でモデルディレクトリを解決
- `ModelsService`: メタデータのCRUD(お気に入り、タグ、メモ、SHA256)
- `find_preview_image()`: `{stem}.preview.png`等のプレビュー画像を自動検出
- `get_model_groups()` / `save_model_groups()`: グループの永続化(`model_metadata.json`の`_groups`キー)
#### `py/services/civitai_service.py` — CivitAI API連携
- `calculate_sha256()`: モデルファイルのSHA256ハッシュ計算
- `fetch_by_hash()`: CivitAI APIからモデル情報取得(キャッシュ付き)
- `batch_fetch()`: 複数モデルの一括取得(プログレスコールバック対応)
- `_extract_info()`: APIレスポンスから必要フィールドを抽出(modelName, baseModel, trainedWords, images等)
- キャッシュ: `civitai_cache.json`に保存
#### `py/routes/models_routes.py` — モデル管理APIルート
- `GET/POST /api/wfm/models/metadata` — メタデータCRUD
- `GET /api/wfm/models/preview` — プレビュー画像配信
- `GET/POST /api/wfm/models/groups` — グループ管理
- `POST /api/wfm/models/civitai/fetch` — 個別CivitAI取得
- `GET /api/wfm/models/civitai/cache` — CivitAIキャッシュ取得
- `POST /api/wfm/models/civitai/batch` — SSEストリーミング一括取得
- `POST /api/wfm/models/change-preview` — プレビュー画像アップロード
- `GET /api/wfm/models/filepath` — モデルファイルのフルパス取得
#### `static/js/models-tab.js` — フロントエンドモジュール
- state管理: `modelsByType`, `modelMetadata`, `modelGroups`, `civitaiCache`, `activeModelType`
- サブタブ切り替え(6タイプ)、サムネイル/カード/テーブル表示
- サイドパネル: Info(ファイルパス表示、タグ、メモ)、Group管理、CivitAI情報表示
- 詳細モーダル: プレビュー画像、CivitAI情報、サムネイル変更
- 一括CivitAI取得ボタン(SSEプログレス付き)
- テーブルビューにメモ列表示
### 変更ファイル
#### `py/config.py`
- `MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"` 追加
#### `py/wfm.py`
- `models_routes`のインポートとルート登録追加
#### `templates/index.html`
- Modelsタブボタン・セクション追加(サブタブナビ、ツールバー、グリッド、サイドパネル)
- ヘルプタブにModels Tabセクション追加
#### `static/js/app.js`
- `models-tab.js`のimportと`initModelsTab()`呼び出し追加
- i18nマッピングにModelsタブ・ヘルプ用キー追加
#### `static/js/nodes-tab.js`
- カードビュー: パッケージバッジ削除、カード左端にパッケージ色のボーダー追加
- カードビュー: 入出力カウント表示を削除
- ページネーションをツールバーに移動
#### `static/js/workflow-tab.js`
- ページネーションをツールバーに移動
- 1ページ24件表示
#### `static/css/main.css`
- `.wfm-grid`: `grid-template-columns: repeat(auto-fill, 161px)`、カード161×162px固定
- `.wfm-node-card`: 左ボーダー3pxスタイル追加
- `.wfm-pagination-inline`: ツールバー内ページネーション
- サブタブナビ(`.wfm-models-type-nav`)、テーブル列幅調整
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
- Modelsタブ関連: ~50キー(モデルタイプ、フィルタ、サイドパネル、CivitAI、グループ等)
- ヘルプModelsセクション: `helpModels1`〜`helpModels7`
- ファイルパス関連: `modelsFilePath`, `modelsCopyPath`, `modelsCopiedPath`
### 技術的な判断
- **プレビュー画像判定**: HEADリクエストではなく`img.onload/onerror`パターンを採用(404コンソールスパム回避)
- **CivitAI一括取得**: ブロッキングリクエストではなくSSE(Server-Sent Events)でリアルタイムプログレス表示
- **aiohttp HEAD自動登録**: `add_get`が自動でHEADも登録するため、明示的なHEADルート追加は不要(重複RuntimeError回避)
- **テーブルビューメモ列**: `max-width: 200px`+`text-overflow: ellipsis`で長文を省略表示
- **ファイルパス表示**: バックエンドAPIでフルパスを解決し、クリックでクリップボードコピー
## 2026-03-27: テーマカスタマイズ機能・設定タブ改善
### 概要
- テーマのカラーカスタマイズ、背景パターン、フォント選択機能を追加
- 設定タブの各セクションをアコーディオン式折りたたみに変更
- ヘルプタブのSettings Tab説明を更新
### 変更内容
#### `static/js/settings-tab.js` — テーマカスタマイズ機能追加
- **カラーオーバーライド:** 6つのCSS変数(背景、サブ背景、サーフェス、テキスト、プライマリ、アクセント)をカラーピッカーでリアルタイム調整、localStorageに保存
- **背景パターン:** 7種類(なし、横/縦/斜めストライプ、ポルカドット、チェック、SVGファイル)を選択可能
- パターンオプション: 色、不透明度、サイズ、間隔(gap)スライダー
- SVGタイリング: FileReader APIでクライアント側読込、色の置換(fill/stroke属性+`<style>`要素注入)、`::after`疑似要素でopacity制御、gap対応はviewBox拡張ラッパーSVG方式
- **フォント選択:** 16種のGoogle Fonts(日本語デザインフォント含む)を動的ロード、クリックで即時プレビュー
- ゴシック系: Noto Sans JP, Zen丸ゴシック, M PLUS Rounded, 小杉丸ゴシック, さわらびゴシック, BIZ UDPゴシック
- デザイン系: ドットゴシック16, はちまるポップ, デラゴシック, レゲエ One, ロックンロール, ステッキ, トレイン One
- 等幅: Space Mono, Fira Code
- **保存ロジック修正:** `saveLocalSettings()`のスプレッドマージでは`delete`したキーが消えない問題を修正、リセット時は`localStorage.setItem()`で直接上書き
- **SVGリカラー強化:** `<style>`ブロックをSVGに注入し、fill属性がない要素にも色を適用
#### `static/css/main.css` — UIスタイル追加
- **設定アコーディオン:** `<details>`/`<summary>`要素用のスタイル(`.wfm-settings-section`, `.wfm-settings-summary`)— 三角矢印回転アニメーション付き
- **テーマカスタマイザー:** カラーピッカーグリッド、パターン選択グリッド(プレビューSVG付き)、パターンオプション行、フォント選択グリッド
- **パターンプレビュー改善:** SVGプレビューのコントラストを大幅強化(60x60サイズ、太い線、高opacity、青系前景色)
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
- テーマカスタマイズ関連: カラー調整、背景パターン、パターンオプション(色、不透明度、サイズ、間隔)、フォント
- フォント名: 16種のフォントラベル
- ヘルプSettings項目: helpSettings7, helpSettings8追加
#### `static/js/app.js` — i18nマッピング追加
- `helpSettings7`, `helpSettings8` のHTML id→i18nキーマッピングを追加
#### `templates/index.html` — ヘルプタブ更新
- Settings Tabセクション: テーマカスタマイズ機能の説明を追加(項目2)、Eagle連携を項目7に、アコーディオン折りたたみを項目8に追加
### 技術的な判断
- **SVGカラー変更:** 属性置換だけでは不十分なSVG(CSSクラスやデフォルト黒を使用)に対応するため、`<style>`ブロック注入方式を採用
- **SVG間隔(gap):** viewBoxを拡張するラッパーSVGでタイル間に余白を作成
- **フォント読込:** Google Fonts CDNから動的にlinkタグを挿入、一度読み込んだフォントはSetで管理し重複読込を防止
- **アコーディオン:** `<details>`/`<summary>`ネイティブHTML要素を使用、JSなしで開閉動作
## 2026-03-26: トップバーボタンアイコン修正・Appモードバッジ調査
### 概要
- トップバーの3つのボタン(Workflow Studio / Snapshot / Library)のアイコンが表示されない問題を修正
- ComfyUI Appモード(linearMode)のワークフローJSON構造を調査、バッジ表示対応は保留
### 変更内容
#### `web/comfyui/top_menu_extension.js` — アイコン置換ロジック修正
- **問題:** `setup()` 内の `replaceButtonIcon()` が `requestAnimationFrame` でボタンを見つけてSVGを注入するが、ComfyUIのVueフレームワークが再レンダリングして元の `<i class="icon-[mdi--...]">` に戻してしまう。リトライ条件が `wfmButtons.length === 0`(ボタン未検出時のみ)のため、一度見つかった後は再注入されなかった
- **修正:** `MutationObserver` でボタンの親コンテナ(`.actionbar-container`)を監視し、DOMが変更されるたびにSVGアイコンを再注入するように変更
- `applyButtonIcon()`: ボタンに既にSVGがあればスキップ(無限ループ防止)、なければSVG注入
- `waitAndObserve()`: ボタンがDOMに現れるまで `requestAnimationFrame` で待機し、見つかったらアイコン置換+`MutationObserver`監視開始
### Appモードバッジ表示について(保留)
- ComfyUI Appモード(BETA機能)のワークフローJSON構造を調査
- `extra.linearMode: true` と `extra.linearData` (inputs/outputs配列)でappモード情報が格納される
- appモード削除後も `linearData`/`linearMode` キーは残り中身が空配列になるだけ、拡張子 `.app.json` も維持される
- **結論:** Appモード自体がBETA機能のため、バッジ表示対応は現状維持とする
## 2026-03-25: v0.2.0 プロンプトプリセット機能・Workflow Studio Library
### 概要
- プロンプトプリセット機能を追加:サイドパネルからドラッグ&ドロップでWFS_PromptTextカスタムノードを作成
- Workflow Studio SPAのPromptタブを3カラムレイアウトに刷新(AIアシスタント|プリセット編集|プリセットマネージャー)
- プリセットのバックエンドAPI化(localStorage→サーバーサイド保存)
- サイドパネル名を「WF & Node Library」→「Workflow Studio Library」に変更
### 変更内容
#### `py/nodes/prompt_text.py` — WFS_PromptTextカスタムノード(新規)
- **WFS_PromptText**: ポジティブ/ネガティブの2つのSTRING入力とSTRING出力を持つカスタムノード
- `INPUT_TYPES`: `positive`(multiline), `negative`(multiline)
- `RETURN_TYPES`: `("STRING", "STRING")`, `RETURN_NAMES`: `("positive", "negative")`
- `CATEGORY`: `"Workflow Studio"`
#### `py/nodes/__init__.py` — パッケージ初期化(新規)
- 空ファイル(Pythonパッケージ認識用)
#### `__init__.py` — カスタムノード登録
- `_NODE_MODULES` に `WFS_PromptText` を追加(分離読み込みパターン)
- `NODE_DISPLAY_NAME_MAPPINGS` に `"Prompt Text (WFS)"` を追加
#### `py/services/prompts_service.py` — プリセットCRUDサービス(新規)
- `list_prompts()`: 全プリセット一覧取得
- `create_prompt(data)`: 新規作成(name, text, negText, category, tags, favorite)
- `update_prompt(id, updates)`: 更新(name, text, negText, category, tags, favorite)
- `delete_prompt(id)`: 削除
- `list_categories()`: カテゴリ一覧取得
- データ永続化: `data/prompts.json`
#### `py/routes/prompts_routes.py` — プリセットAPIエンドポイント(新規)
- `GET /api/wfm/prompts` — 一覧取得
- `POST /api/wfm/prompts` — 新規作成
- `POST /api/wfm/prompts/update` — 更新
- `POST /api/wfm/prompts/delete` — 削除
#### `py/config.py` — 設定追加
- `PROMPTS_FILE = DATA_DIR / "prompts.json"` を追加
#### `py/wfm.py` — ルート登録
- `prompts_routes` のインポートと `prompts_routes.setup_routes(app)` を追加
#### `web/comfyui/node_sets_menu.js` — サイドパネルにPromptsタブ追加
- **トップタブ:** Workflows / Nodes / **Prompts** の3タブ構成
- **Promptsサブタブ:** All / ★ Favorites / 📁 Categories
- **State追加:** `promptSubTab`, `promptList`, `promptFavorites`, `promptCategories`, `promptLoaded`
- **API関数:** `fetchPrompts()`, `loadPromptData()`
- **レンダリング:** `renderPromptAll()`, `renderPromptFavorites()`, `renderPromptCategories()`
- **ドラッグ&ドロップ:** `application/x-wfm-prompt` MIMEタイプ、`placePromptNode(posText, negText, promptName, pos)` でWFS_PromptTextノード作成
- **P/Nコピーボタン:** 各プリセットアイテムにポジティブ(P)・ネガティブ(N)の個別コピーボタン(Nはテキスト空の場合非表示)
- **パネル名変更:** ツールチップ・タイトルを「Workflow Studio Library」に更新
- **CSS追加:** `.wfm-nlp-copy-btns`, `.wfm-nlp-copy-pos`(緑ホバー), `.wfm-nlp-copy-neg`(赤ホバー)
#### `templates/index.html` — Promptタブ3カラムレイアウト
- 2カラム(AI Assistant | Presets)→ 3カラム(AI Assistant | Presets | Preset Manager)に変更
- **Presetsパネル:** Deleteボタン削除、Apply→「GenUI Set」、Category入力欄追加、下部にグループ管理行(Select group + Add to Group + + Group + Del Group)
- **Preset Managerパネル:** All / ★ / Groupsタブ、検索、スクロール可能なリスト
- **ヘルプタブ:** helpPrompt5〜7追加、helpSidepanel4・7〜10追加(Promptsタブ・ドラッグ&ドロップ・P/Nコピー)
- サイドパネルタイトルを「Workflow Studio Library」に更新
#### `static/css/main.css` — スタイル追加(約150行)
- `.wfm-prompt-split-3col` — 3カラムFlexboxレイアウト
- `.wfm-prompt-split-col` — 各カラムのスタイル
- `.wfm-preset-manager` — プリセットマネージャーパネル
- `.wfm-pm-tabs`, `.wfm-pm-tab` — マネージャータブ
- `.wfm-pm-list`, `.wfm-pm-item` — アイテムリスト
- `.wfm-pm-item-actions`, `.wfm-pm-action-btn` — お気に入り(★)・削除(✕)ボタン
- `.wfm-pm-group-header` — グループヘッダー
#### `static/js/prompt-tab.js` — 全面リライト
- **API化:** `fetchPresets()`, `apiCreatePreset()`, `apiUpdatePreset()`, `apiDeletePreset()`
- **localStorage移行:** `migrateLocalStoragePresets()` — 一回限りの自動移行
- **プリセット管理:** `renderPresetSelect()`, `renderGroupSelect()`, `selectPresetInEditor(preset)`
- **Preset Manager:** `renderPresetManager()`, `renderPmAll()`, `renderPmFavorites()`, `renderPmGroups()`
- **`createPmItem()`:** お気に入りトグル(★)と削除(✕)ボタン(コピーボタンなし — Presetsパネル側にPP/NP Copyあり)
- **グループ管理:** Presetsパネル下部のnew-group, add-to-group, del-groupボタン
- **Save:** API経由でnegTextフィールドを含むプリセットの作成/更新
#### `static/js/i18n.js` — 翻訳更新
- `applyPreset` → `"GenUI Set"`(英/日/中 全3言語)
- `deletePreset` キー削除(全3言語)
- ヘルプ: `helpPrompt5`〜`helpPrompt7`, `helpSidepanel8`〜`helpSidepanel10` 追加(全3言語)
- サイドパネルタイトル: `helpSidepanelTitle` → `"Workflow Studio Library"` に更新(全3言語)
#### `static/js/app.js` — i18nマッピング更新
- `helpIdMap` に `helpPrompt5`〜`7`, `helpSidepanel8`〜`10` を追加
- `deletePreset` 適用コード削除
#### `README.md` — v0.2.0更新
- バージョンバッジ: `0.1.9` → `0.2.0`
- Prompt Tab: 3カラムレイアウト、Preset Manager、グループ管理、GenUI Set
- Workflow Studio Library: Promptsタブ、ドラッグ&ドロップ、P/Nコピー
- Project Structure: `py/nodes/`, `prompts_routes.py`, `prompts_service.py` 追加
- Changelog: v0.2.0エントリ追加
- スクリーンショット全8枚差し替え
## 2026-03-24: v0.1.9 サイドパネルUI改善・保存ダイアログ・バグ修正
### 概要
+76 -14
View File
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.2.0-green)
![Version](https://img.shields.io/badge/version-0.2.5-green)
## Screenshots
@@ -17,9 +17,13 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| Nodes Tab | Help & Support Tab |
| Nodes Tab | Models Tab |
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Help](docs/screenshot_help.png) |
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| Help & Support Tab | |
|:---:|:---:|
| ![Help](docs/screenshot_help.png) | |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
@@ -32,10 +36,10 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
### Workflow Tab
- **Thumbnail / Card / Table views** — switch between view modes to browse your workflow library
- **Thumbnail side panel** — preview workflow canvas snapshots in the side panel
- **Tag-based filtering** — filter by base model (SD1.5, SDXL, etc.) and custom groups
- **Badge filtering** — filter by user-defined badges (free labels you assign to each workflow)
- **Search** — full-text search across workflow names and metadata
- **Side panel tabs** — Thumbnail preview, JSON viewer with syntax highlighting, and Group management
- **Batch analysis** — auto-detect checkpoint, model type, prompt, and I/O node counts
- **Badge management** — add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)
- **AI summary** — generate workflow descriptions using Ollama
- **Import / Export** — import workflows from files or clipboard, open in ComfyUI directly
- **Default view setting** — persist your preferred view mode (Thumbnail / Card / Table)
@@ -46,11 +50,14 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Embedded workflow metadata** — workflow JSON is embedded in the PNG (tEXt chunk), compatible with ComfyUI's drag-and-drop import
- **Auto-import** — the captured workflow is automatically imported and appears in the Workflow tab
### GenerateUI Tab
- **Auto-generated parameter UI** — prompt, model, sampler, image, and other settings extracted from the workflow
### GenerateUI Tab (v0.2.4)
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; Raw JSON on the right
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds
- **Raw JSON editor** — view and edit the API-format JSON with syntax highlighting
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
@@ -66,11 +73,13 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
### Settings Tab
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
- **Workflows directory** — configure which folder to scan for workflows
- **Eagle connection** — set Eagle API endpoint for auto-save
- **Ollama connection** — configure Ollama server URL
- **Badge display** — toggle metadata badges on workflow cards
- **Default workflow** — set a workflow to auto-load on startup
- **Language** — English / Japanese / Chinese
### Nodes Tab (v0.1.7)
@@ -81,9 +90,26 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
### Workflow Studio Library (ComfyUI Side Panel) (v0.1.8)
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group
- **Nodes tab** — browse favorite nodes, node sets, and node groups
### Models Tab (v0.2.3)
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding) with sub-tab switching
- **Thumbnail / Card / Table views** — switch between view modes with pagination (24 items per page)
- **Search & Filter** — full-text search, filter by tags, groups, and favorites
- **User-defined badges** — assign free-label badges to models; badge colors shared with the Workflow tab palette
- **Side panel tabs** — Info (file path display with click-to-copy, tags, memo), Groups management, CivitAI integration
- **CivitAI integration** — fetch model metadata by SHA256 hash, view base model, trained words, tags, and model page link
- **Batch CivitAI fetch** — one-click batch fetch for all models of a type with SSE progress streaming
- **Detail modal** — preview image, CivitAI info, thumbnail change via file upload
- **GenUI Model button** — apply the selected model directly to the corresponding node in GenerateUI's current workflow (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder)
- **Group management** — create, rename, delete groups and assign/remove models
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
- **Nodes tab** — browse favorite nodes (All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs), ★ star shown for favorites in All view
- **Category sub-tab** — dropdown to filter nodes by top-level category
- **Package sub-tab** — dropdown to filter nodes by custom node package name
- **Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs)
- **Prompts tab** — browse prompt presets with All / Favorites / Categories sub-tabs
- **Drag & drop workflows** — drag a workflow onto the canvas to load it
- **Drag & drop nodes** — drag nodes/node sets onto the canvas to place them
@@ -91,6 +117,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
@@ -167,6 +194,37 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### v0.2.5
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
- **Sidebar TextEncoder (By Type)** — fixed TextEncoder not appearing in Models → By Type; now uses `DualCLIPLoader` → `CLIPLoader` fallback to retrieve `clip_name1` list
- **Hide `.index.json`** — excluded `.index.json` from both the Workflow tab and the Workflow Studio Library side panel
### v0.2.4
- **GenerateUI tab redesign** — reorganized from 5 tabs (Prompt/Image/Model/Settings/Raw JSON) to 3 tabs (Input/Model/Settings); each tab shows a Raw JSON column (540px) for instant JSON preview and direct editing from any tab
- **Input tab** — Prompt and Image stacked vertically in the left column
- **Settings tab** — KSampler and LatentImage stacked vertically in the left column
- **Raw JSON tab removed** — Raw JSON is now always visible as a right-column panel within each tab
### v0.2.3
- **Badge system unified** — Workflow and Models tabs now share a single badge palette (`⚙ Badge` button); workflow auto-analysis model type system removed in favor of user-defined free-label badges
- **GenUI Model button** — apply any model from the Models tab directly to the corresponding node in GenerateUI's current workflow (available in detail modal and side panel; Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)
- **Sidebar Models tab** — added All / ★ Favorites / Groups / By Type sub-tabs to the Models section of Workflow Studio Library
- **Sidebar All views** — ★ star displayed for favorite items in Workflows and Nodes All sub-tabs
- **Sidebar panel width** — widened from 280px to 310px
### v0.2.2
- **Models tab** — browse, search, and manage all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) with Thumbnail/Card/Table views
- **CivitAI integration** — fetch model metadata by SHA256 hash (individual or batch per model type) with SSE progress streaming
- **Model groups** — create, rename, delete groups and assign/remove models for organization
- **Model metadata** — persistent favorites, tags, memo, and SHA256 hash per model
- **Preview images** — auto-detect and display preview images, change via file upload in detail modal
- **Table view memo column** — memo displayed in table view with compact subdir/extension columns
- **Side panel file path** — display full file path with click-to-copy in model info panel
- **Node card redesign** — removed package badge, added left border color-coding by package
- **Toolbar pagination** — pagination controls moved to toolbar for all tabs (Models, Workflows, Nodes)
- **Help tab updated** — Models Tab section added to feature list
### v0.2.0
- **Prompt presets in sidebar** — added Prompts tab to Workflow Studio Library (ComfyUI side panel) with All / Favorites / Categories sub-tabs
- **WFS_PromptText custom node** — drag prompt presets onto the canvas to create nodes with positive/negative prompt outputs
@@ -237,6 +295,7 @@ ComfyUI-Workflow-Studio/
│ ├── routes/
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
│ │ ├── prompts_routes.py # Prompt presets CRUD API
│ │ ├── settings_routes.py # Settings API
│ │ ├── ollama_routes.py # Ollama proxy API
@@ -244,12 +303,14 @@ ComfyUI-Workflow-Studio/
│ └── services/
│ ├── workflow_service.py # Workflow file operations
│ ├── nodes_service.py # Node metadata & node sets
│ ├── models_service.py # Model metadata & preview images
│ ├── civitai_service.py # CivitAI API integration & cache
│ ├── prompts_service.py # Prompt presets persistence
│ ├── workflow_analyzer.py # Model/node detection
│ ├── settings_service.py # Settings persistence
│ └── png_extractor.py # PNG metadata extraction
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Help)
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
├── static/
│ ├── favicon.svg # Browser tab icon (W+S Wave)
│ ├── css/main.css # Styles
@@ -261,6 +322,7 @@ ComfyUI-Workflow-Studio/
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
│ ├── models-tab.js # Model browser & CivitAI integration
│ ├── comfyui-workflow.js # UI-to-API format conversion
│ ├── comfyui-editor.js # Dynamic parameter editor
│ ├── json-highlight.js # JSON syntax highlighting
@@ -268,7 +330,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)
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
```
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

+1
View File
@@ -58,3 +58,4 @@ SETTINGS_FILE = DATA_DIR / "settings.json"
NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
+388
View File
@@ -0,0 +1,388 @@
"""Model metadata API routes."""
import asyncio
import logging
import mimetypes
from aiohttp import web
from ..services.models_service import ModelsService
from ..services.civitai_service import CivitaiService
logger = logging.getLogger(__name__)
_service = ModelsService()
_civitai = CivitaiService()
def setup_routes(app: web.Application):
"""Register all model management API routes."""
app.router.add_get("/api/wfm/models/metadata", handle_get_metadata)
app.router.add_post("/api/wfm/models/metadata", handle_save_metadata)
app.router.add_get("/api/wfm/models/preview", handle_get_preview)
app.router.add_get("/api/wfm/models/groups", handle_get_groups)
app.router.add_post("/api/wfm/models/groups", handle_save_groups)
app.router.add_post("/api/wfm/models/civitai/fetch", handle_civitai_fetch)
app.router.add_get("/api/wfm/models/civitai/cache", handle_civitai_cache)
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
# ── Model Metadata ─────────────────────────────────────────
async def handle_get_metadata(request: web.Request) -> web.Response:
"""GET /api/wfm/models/metadata"""
try:
result = await asyncio.to_thread(_service.get_all_metadata)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model metadata: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_metadata(request: web.Request) -> web.Response:
"""POST /api/wfm/models/metadata"""
try:
body = await request.json()
model_name = body.get("modelName", "")
if not model_name:
return web.json_response({"error": "modelName is required"}, status=400)
updates = {k: v for k, v in body.items() if k != "modelName"}
result = await asyncio.to_thread(_service.update_metadata, model_name, updates)
return web.json_response({"status": "ok", "metadata": result})
except Exception as e:
logger.error("Error saving model metadata: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model Groups ──────────────────────────────────────────
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/models/groups"""
try:
result = await asyncio.to_thread(_service.get_model_groups)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups"""
try:
groups = await request.json()
result = await asyncio.to_thread(_service.save_model_groups, groups)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── CivitAI Integration ──────────────────────────────────
async def handle_civitai_fetch(request: web.Request) -> web.Response:
"""POST /api/wfm/models/civitai/fetch
Body: { "type": "checkpoint", "name": "model.safetensors" }
Calculates SHA256 hash and fetches model info from CivitAI.
"""
try:
body = await request.json()
model_type = body.get("type", "")
model_name = body.get("name", "")
if not model_type or not model_name:
return web.json_response({"error": "type and name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
# Resolve file path
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
file_path = None
for d in dirs:
candidate = d / model_name
if candidate.is_file():
file_path = candidate
break
if not file_path:
return web.json_response({"error": "Model file not found"}, status=404)
# Check if we already have a cached hash in metadata
meta = _service.get_all_metadata()
model_meta = meta.get(model_name, {})
sha256 = model_meta.get("sha256")
if not sha256:
# Calculate hash (can be slow for large files)
sha256 = await asyncio.to_thread(
CivitaiService.calculate_sha256, file_path
)
if not sha256:
return web.json_response({"error": "Failed to calculate hash"}, status=500)
# Cache the hash in model metadata
await asyncio.to_thread(
_service.update_metadata, model_name, {"sha256": sha256}
)
# Fetch from CivitAI
info = await asyncio.to_thread(_civitai.fetch_by_hash, sha256)
if not info:
return web.json_response({
"status": "not_found",
"sha256": sha256,
"message": "Model not found on CivitAI"
})
return web.json_response({
"status": "ok",
"sha256": sha256,
"civitai": info,
})
except Exception as e:
logger.error("CivitAI fetch error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_civitai_cache(request: web.Request) -> web.Response:
"""GET /api/wfm/models/civitai/cache - Return all cached CivitAI data."""
try:
result = await asyncio.to_thread(_civitai.get_all_cached)
return web.json_response(result)
except Exception as e:
logger.error("Error loading CivitAI cache: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_civitai_batch(request: web.Request) -> web.Response:
"""POST /api/wfm/models/civitai/batch - SSE stream for batch CivitAI fetch.
Body: { "type": "checkpoint", "models": ["model1.safetensors", ...] }
Streams SSE events: progress, result, done.
"""
import json as _json
try:
body = await request.json()
except Exception:
return web.Response(status=400, text="Invalid JSON body")
model_type = body.get("type", "")
model_names = body.get("models", [])
if not model_type or not model_names:
return web.Response(status=400, text="type and models required")
# Resolve file paths
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
model_files = []
for name in model_names:
if ".." in name:
continue
for d in dirs:
p = d / name
if p.is_file():
model_files.append((name, p))
break
if not model_files:
return web.Response(status=404, text="No model files found")
# Set up SSE response
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
def send_sse(event, data):
"""Format SSE message (returns bytes)."""
msg = f"event: {event}\ndata: {_json.dumps(data, ensure_ascii=False)}\n\n"
return msg.encode("utf-8")
# Run batch fetch in thread with progress callback via queue
import queue
progress_q = queue.Queue()
def on_progress(current, total, model_name, status):
progress_q.put({"current": current, "total": total, "model": model_name, "status": status})
# Start batch in background thread
loop = asyncio.get_event_loop()
fetch_task = loop.run_in_executor(None, _civitai.batch_fetch, model_files, on_progress)
# Stream progress events
import time as _time
while not fetch_task.done():
# Drain queue
while not progress_q.empty():
try:
p = progress_q.get_nowait()
await response.write(send_sse("progress", p))
except queue.Empty:
break
await asyncio.sleep(0.1)
# Drain remaining events
while not progress_q.empty():
try:
p = progress_q.get_nowait()
await response.write(send_sse("progress", p))
except queue.Empty:
break
# Get results and update metadata
results = fetch_task.result()
meta_updates = {}
for model_name, result in results.items():
sha256 = result.get("sha256")
if sha256:
meta_updates[model_name] = sha256
# Bulk update sha256 in metadata
if meta_updates:
all_meta = _service.get_all_metadata()
for mname, sha in meta_updates.items():
if mname not in all_meta:
all_meta[mname] = {"tags": [], "favorite": False, "memo": ""}
all_meta[mname]["sha256"] = sha
_service._save_metadata(all_meta)
# Send final result
summary = {
"total": len(model_files),
"found": sum(1 for r in results.values() if r.get("civitai")),
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
"errors": sum(1 for r in results.values() if r.get("error")),
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
}
await response.write(send_sse("done", summary))
await response.write_eof()
return response
# ── Model Preview Image ───────────────────────────────────
async def handle_get_preview(request: web.Request) -> web.Response:
"""GET /api/wfm/models/preview?type=checkpoint&name=model.safetensors"""
model_type = request.query.get("type", "")
model_name = request.query.get("name", "")
if not model_type or not model_name:
return web.Response(status=400, text="type and name are required")
# Security: reject path traversal
if ".." in model_name:
return web.Response(status=400, text="Invalid model name")
try:
preview_path = await asyncio.to_thread(
_service.find_preview_image, model_type, model_name
)
if not preview_path:
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(preview_path))
return web.FileResponse(
preview_path,
headers={"Content-Type": content_type or "image/png",
"Cache-Control": "public, max-age=3600"},
)
except Exception as e:
logger.error("Error serving model preview: %s", e)
return web.Response(status=500, text=str(e))
async def handle_change_preview(request: web.Request) -> web.Response:
"""POST /api/wfm/models/change-preview - Upload/change model preview image.
Multipart form: type, name, file
Saves as {model_stem}.preview.png next to the model file.
"""
from pathlib import Path as _Path
try:
reader = await request.multipart()
model_type = None
model_name = None
image_data = None
while True:
part = await reader.next()
if part is None:
break
if part.name == "type":
model_type = (await part.read()).decode("utf-8")
elif part.name == "name":
model_name = (await part.read()).decode("utf-8")
elif part.name == "file":
image_data = await part.read()
if not model_type or not model_name:
return web.json_response({"error": "type and name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if image_data is None:
return web.json_response({"error": "file required"}, status=400)
# Resolve model file path
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
model_path = None
for d in dirs:
candidate = d / model_name
if candidate.is_file():
model_path = candidate
break
if not model_path:
return web.json_response({"error": "Model file not found"}, status=404)
# Save as {stem}.preview.png next to model file
preview_path = model_path.parent / (model_path.stem + ".preview.png")
def _write():
with open(preview_path, "wb") as f:
f.write(image_data)
await asyncio.to_thread(_write)
logger.info("Saved preview image: %s", preview_path)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error changing model preview: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_filepath(request: web.Request) -> web.Response:
"""GET /api/wfm/models/filepath?type=checkpoint&name=model.safetensors
Returns the full file path of the model.
"""
model_type = request.query.get("type", "")
model_name = request.query.get("name", "")
if not model_type or not model_name:
return web.json_response({"error": "type and name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
try:
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
for d in dirs:
candidate = d / model_name
if candidate.is_file():
return web.json_response({"path": str(candidate)})
return web.json_response({"error": "Model file not found"}, status=404)
except Exception as e:
logger.error("Error resolving model filepath: %s", e)
return web.json_response({"error": str(e)}, status=500)
+206
View File
@@ -0,0 +1,206 @@
"""CivitAI API integration service."""
import hashlib
import json
import logging
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from ..config import DATA_DIR
logger = logging.getLogger(__name__)
CIVITAI_API_BASE = "https://civitai.com/api/v1"
CIVITAI_CACHE_FILE = DATA_DIR / "civitai_cache.json"
class CivitaiService:
"""Fetch and cache CivitAI model metadata."""
def __init__(self):
self._cache = None
def _load_cache(self):
if self._cache is not None:
return self._cache
if CIVITAI_CACHE_FILE.exists():
try:
with open(CIVITAI_CACHE_FILE, "r", encoding="utf-8") as f:
self._cache = json.load(f)
return self._cache
except Exception:
pass
self._cache = {}
return self._cache
def _save_cache(self):
CIVITAI_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CIVITAI_CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(self._cache, f, ensure_ascii=False, indent=2)
def get_cached(self, sha256_hash):
"""Return cached CivitAI data for a given hash, or None."""
cache = self._load_cache()
return cache.get(sha256_hash)
def get_all_cached(self):
"""Return the full cache dict."""
return self._load_cache()
@staticmethod
def calculate_sha256(file_path, chunk_size=65536):
"""Calculate SHA256 hash of a file."""
h = hashlib.sha256()
path = Path(file_path)
if not path.is_file():
return None
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def fetch_by_hash(self, sha256_hash):
"""Fetch model version info from CivitAI by SHA256 hash.
Returns dict with model info, or None if not found.
Caches successful results.
"""
# Check cache first
cache = self._load_cache()
if sha256_hash in cache:
return cache[sha256_hash]
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash}"
try:
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not data or "id" not in data:
return None
# Extract useful fields
info = self._extract_info(data)
cache[sha256_hash] = info
self._save_cache()
return info
except HTTPError as e:
if e.code == 404:
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
return None
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
return None
except (URLError, Exception) as e:
logger.warning("CivitAI request failed: %s", e)
return None
@staticmethod
def _extract_info(data):
"""Extract relevant fields from CivitAI API response."""
model = data.get("model", {})
images = data.get("images", [])
files = data.get("files", [])
# Get primary file info
primary_file = None
for f in files:
if f.get("primary"):
primary_file = f
break
if not primary_file and files:
primary_file = files[0]
# Get image URLs (first 5)
image_urls = []
for img in images[:5]:
url = img.get("url", "")
if url:
# Optimize for thumbnail
optimized = url.replace("/original=true", "/width=450,optimized=true")
image_urls.append(optimized)
return {
"versionId": data.get("id"),
"modelId": model.get("id"),
"modelName": model.get("name", ""),
"versionName": data.get("name", ""),
"type": model.get("type", ""),
"description": data.get("description") or model.get("description", ""),
"tags": model.get("tags", []),
"nsfw": model.get("nsfw", False),
"creator": data.get("creator", {}).get("username", ""),
"images": image_urls,
"trainedWords": data.get("trainedWords", []),
"baseModel": data.get("baseModel", ""),
"fileSize": primary_file.get("sizeKB", 0) if primary_file else 0,
"downloadUrl": data.get("downloadUrl", ""),
"modelUrl": f"https://civitai.com/models/{model.get('id', '')}?modelVersionId={data.get('id', '')}",
}
def batch_fetch(self, model_files, progress_callback=None):
"""Batch fetch CivitAI info for multiple model files.
Args:
model_files: list of (model_name, file_path) tuples
progress_callback: fn(current, total, model_name, status) called per model
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
"""
import time
results = {}
total = len(model_files)
cache = self._load_cache()
for i, (model_name, file_path) in enumerate(model_files):
if progress_callback:
progress_callback(i, total, model_name, "hashing")
# Calculate hash
sha256 = self.calculate_sha256(file_path)
if not sha256:
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
continue
results[model_name] = {"sha256": sha256, "civitai": None}
# Check cache
if sha256 in cache:
results[model_name]["civitai"] = cache[sha256]
if progress_callback:
progress_callback(i, total, model_name, "cached")
continue
# Fetch from API
if progress_callback:
progress_callback(i, total, model_name, "fetching")
info = self.fetch_by_hash(sha256)
if info:
results[model_name]["civitai"] = info
if progress_callback:
status = "found" if info else "not_found"
progress_callback(i, total, model_name, status)
# Rate limit: small delay between API calls
time.sleep(0.5)
if progress_callback:
progress_callback(total, total, "", "done")
return results
def clear_cache(self, sha256_hash=None):
"""Clear cache for a specific hash or all."""
cache = self._load_cache()
if sha256_hash:
cache.pop(sha256_hash, None)
else:
cache.clear()
self._save_cache()
+150
View File
@@ -0,0 +1,150 @@
"""Model metadata management service."""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from ..config import MODEL_METADATA_FILE
logger = logging.getLogger(__name__)
# Preview image extensions to search for (in priority order)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
"lora": "loras",
"vae": "vae",
"controlnet": "controlnet",
"unet": "diffusion_models",
"textencoder": "text_encoders",
"hypernetwork": "hypernetworks",
"embedding": "embeddings",
}
def _get_model_dirs(model_type):
"""Get all model directories for a type using ComfyUI's folder_paths.
Returns a list of Path objects for all configured model directories
(includes extra_model_paths.yaml settings).
Falls back to plugin-relative path if folder_paths is unavailable.
"""
folder_key = MODEL_TYPE_FOLDER_KEYS.get(model_type)
if not folder_key:
return []
try:
import folder_paths # type: ignore # ComfyUI module
paths = folder_paths.get_folder_paths(folder_key)
result = [Path(p) for p in paths if Path(p).is_dir()]
if result:
return result
except Exception as e:
logger.debug("folder_paths unavailable (%s), using fallback", e)
# Fallback: custom_nodes/../../models/{folder_key}
plugin_dir = Path(__file__).resolve().parent.parent.parent
models_dir = plugin_dir.parent.parent / "models" / folder_key
if models_dir.is_dir():
return [models_dir]
return []
class ModelsService:
"""Manages user-defined model metadata (favorites, tags, memo)."""
def __init__(self):
self.metadata_file = MODEL_METADATA_FILE
def _now_iso(self):
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _load_metadata(self):
if self.metadata_file.exists():
try:
with open(self.metadata_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_metadata(self, data):
self.metadata_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.metadata_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_all_metadata(self):
return self._load_metadata()
def update_metadata(self, model_name, updates):
data = self._load_metadata()
if model_name not in data:
data[model_name] = {"tags": [], "favorite": False, "memo": ""}
entry = data[model_name]
if "tags" in updates:
entry["tags"] = updates["tags"]
if "favorite" in updates:
entry["favorite"] = updates["favorite"]
if "memo" in updates:
entry["memo"] = updates["memo"]
if "sha256" in updates:
entry["sha256"] = updates["sha256"]
if "badges" in updates:
entry["badges"] = updates["badges"]
entry["updatedAt"] = self._now_iso()
data[model_name] = entry
self._save_metadata(data)
return entry
def get_model_groups(self):
data = self._load_metadata()
return data.get("_groups", {})
def save_model_groups(self, groups):
data = self._load_metadata()
data["_groups"] = groups
self._save_metadata(data)
return groups
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
Searches all configured directories for the model type
(via ComfyUI's folder_paths, which includes extra_model_paths.yaml).
Looks for files like:
modelname.preview.png, modelname.png, etc.
next to the model file.
Returns: absolute Path to preview image, or None.
"""
dirs = _get_model_dirs(model_type)
if not dirs:
logger.debug("Preview: no dirs for model_type=%s", model_type)
return None
for type_dir in dirs:
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
model_path = type_dir / model_name
if not model_path.is_file():
continue
stem = model_path.stem
parent = model_path.parent
for ext in _PREVIEW_EXTENSIONS:
preview = parent / (stem + ext)
if preview.is_file():
logger.debug("Preview found: %s", preview)
return preview
logger.debug("Preview: no preview for %s (stem=%s, dir=%s)",
model_name, stem, parent)
return None
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
+2 -1
View File
@@ -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()
+2 -1
View File
@@ -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
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
@@ -69,5 +69,6 @@ class WorkflowStudio:
eagle_routes.setup_routes(app)
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+16
View File
@@ -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.6"
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"
+646 -35
View File
@@ -250,12 +250,15 @@ body {
.wfm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
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,6 +266,8 @@ body {
============================================ */
.wfm-card {
width: 178px;
min-width: 178px;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
@@ -278,8 +283,8 @@ body {
}
.wfm-card-thumb {
width: 100%;
aspect-ratio: 1 / 1;
width: 178px;
height: 200px;
background: var(--wfm-bg-tertiary);
display: flex;
align-items: center;
@@ -503,11 +508,12 @@ body {
}
.wfm-gen-right {
width: 360px;
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
border-left: 1px solid var(--wfm-border);
padding: 12px;
gap: 12px;
}
@@ -544,13 +550,55 @@ body {
.wfm-gen-subtab-content {
display: none;
padding: 12px 16px;
overflow-y: auto;
flex: 1;
overflow: hidden;
}
.wfm-gen-subtab-content.active {
display: block;
display: flex;
}
/* ── Tab inner layout: params col + rawjson col ── */
.wfm-gen-tab-cols {
display: flex;
flex: 1;
overflow: hidden;
}
/* Left sub-column: parameter controls */
.wfm-gen-params-col {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--wfm-border);
}
/* Scrollable variant (Model / Settings single-section) */
.wfm-gen-params-col--scroll {
overflow-y: auto;
padding: 12px 14px;
}
/* Stacked sections inside params col (Input: Prompt on top, Image below) */
.wfm-gen-params-section {
flex: 1;
overflow-y: auto;
padding: 10px 14px;
min-height: 0;
}
.wfm-gen-params-section--top-border {
border-top: 1px solid var(--wfm-border);
}
/* Right sub-column: Raw JSON */
.wfm-gen-rawjson-col {
width: 540px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wfm-gen-status {
@@ -570,13 +618,6 @@ body {
color: var(--wfm-danger);
}
.wfm-gen-panel {
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
padding: 12px;
}
.wfm-gen-progress-container {
display: flex;
align-items: center;
@@ -607,8 +648,56 @@ body {
text-align: right;
}
.wfm-gen-result {
/* Raw JSON widget (shared, moved between tabs by JS) */
#wfm-gen-rawjson-widget {
flex: 1;
min-height: 0;
}
.wfm-gen-rawjson-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--wfm-bg-secondary);
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
}
.wfm-gen-rawjson-label {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wfm-gen-rawjson-editor {
flex: 1 !important;
height: auto !important;
}
.wfm-gen-col-title {
font-size: 12px;
font-weight: 600;
margin: 0 0 10px 0;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--wfm-border);
padding-bottom: 6px;
}
/* Generation panel in right column */
.wfm-gen-panel {
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
padding: 12px;
flex-shrink: 0;
}
.wfm-gen-result {
display: flex;
flex-direction: column;
align-items: center;
@@ -741,26 +830,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;
}
@@ -792,6 +893,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 {
@@ -921,6 +1029,7 @@ body {
border-bottom: 1px solid var(--wfm-border);
background: var(--wfm-surface);
flex-shrink: 0;
min-height: 37px;
}
.wfm-side-panel-title {
@@ -932,18 +1041,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 */
@@ -1193,6 +1297,79 @@ body {
margin-left: auto;
}
/* ============================================
Badge Filter Bar (model type row)
============================================ */
.wfm-models-type-divider {
width: 1px;
height: 20px;
background: var(--wfm-border);
flex-shrink: 0;
}
.wfm-badge-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.wfm-badge-filter-btn {
padding: 2px 10px;
font-size: 11px;
border-radius: 10px;
border: 1px solid transparent;
background: var(--wfm-bg-tertiary);
color: var(--wfm-text-secondary);
cursor: pointer;
transition: opacity 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.wfm-badge-filter-btn:hover {
opacity: 0.85;
}
.wfm-badge-filter-btn.active {
box-shadow: 0 0 0 2px var(--wfm-primary);
opacity: 1;
}
/* ============================================
Badge checkbox list in modal
============================================ */
.wfm-badge-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 0;
}
.wfm-badge-check-label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.wfm-badge-check-label input[type="checkbox"] {
display: none;
}
.wfm-badge-check-label input[type="checkbox"]:not(:checked) + .wfm-badge {
opacity: 0.45;
}
.wfm-badge-clickable {
cursor: pointer;
}
.wfm-badge-clickable:hover {
opacity: 0.8;
}
/* ============================================
Modal Detail - Two Column Layout
============================================ */
@@ -1818,7 +1995,7 @@ body {
}
.wfm-side-thumb-img-wrap {
width: 100%;
width: 90%;
aspect-ratio: 1 / 1;
background: var(--wfm-bg);
border: 1px solid var(--wfm-border);
@@ -1827,6 +2004,7 @@ body {
align-items: center;
justify-content: center;
overflow: hidden;
align-self: center;
}
.wfm-side-thumb-img-wrap img {
@@ -2392,6 +2570,267 @@ body {
width: 100%;
}
/* ============================================
Settings Accordion (details/summary)
============================================ */
.wfm-settings-section {
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
margin-bottom: 12px;
overflow: hidden;
}
.wfm-settings-summary {
display: flex;
align-items: center;
padding: 12px 16px;
font-size: 15px;
font-weight: 600;
color: var(--wfm-text);
background: var(--wfm-surface);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
list-style: none;
transition: background 0.15s ease;
}
.wfm-settings-summary:hover {
background: var(--wfm-surface-hover);
}
.wfm-settings-summary::-webkit-details-marker {
display: none;
}
.wfm-settings-summary::before {
content: "\25B6";
font-size: 10px;
margin-right: 10px;
transition: transform 0.2s ease;
color: var(--wfm-text-secondary);
}
.wfm-settings-section[open] > .wfm-settings-summary::before {
transform: rotate(90deg);
}
.wfm-settings-section > *:not(summary) {
padding: 0 16px;
}
.wfm-settings-section > *:nth-child(2) {
padding-top: 12px;
}
.wfm-settings-section > *:last-child {
padding-bottom: 16px;
}
/* ============================================
Theme Customizer
============================================ */
.wfm-customizer-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
margin-top: 12px;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
background: var(--wfm-surface);
color: var(--wfm-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.wfm-customizer-toggle:hover {
color: var(--wfm-primary);
border-color: var(--wfm-primary);
}
.wfm-customizer-toggle .arrow {
transition: transform 0.2s ease;
font-size: 10px;
}
.wfm-customizer-toggle.open .arrow {
transform: rotate(90deg);
}
.wfm-customizer-panel {
display: none;
margin-top: 12px;
padding: 16px;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
background: var(--wfm-bg-secondary);
}
.wfm-customizer-panel.open {
display: block;
}
.wfm-customizer-section {
margin-bottom: 16px;
}
.wfm-customizer-section:last-child {
margin-bottom: 0;
}
.wfm-customizer-section h4 {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
color: var(--wfm-text);
}
.wfm-color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.wfm-color-item {
display: flex;
align-items: center;
gap: 8px;
}
.wfm-color-item label {
font-size: 11px;
color: var(--wfm-text-secondary);
min-width: 60px;
flex-shrink: 0;
}
.wfm-color-item input[type="color"] {
width: 32px;
height: 24px;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
padding: 1px;
cursor: pointer;
background: transparent;
}
.wfm-color-item input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.wfm-color-item input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
/* Pattern selector */
.wfm-pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 6px;
}
.wfm-pattern-item {
width: 100%;
aspect-ratio: 1;
border: 2px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.wfm-pattern-item:hover {
border-color: var(--wfm-primary);
transform: scale(1.05);
}
.wfm-pattern-item.active {
border-color: var(--wfm-primary);
box-shadow: 0 0 0 2px var(--wfm-primary);
}
.wfm-pattern-item-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
font-size: 9px;
text-align: center;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 1px 2px;
line-height: 1.2;
}
/* Pattern options row */
.wfm-pattern-options {
display: flex;
gap: 12px;
margin-top: 10px;
align-items: center;
flex-wrap: wrap;
}
.wfm-pattern-options label {
font-size: 11px;
color: var(--wfm-text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.wfm-pattern-options input[type="range"] {
width: 80px;
accent-color: var(--wfm-primary);
}
.wfm-pattern-options input[type="color"] {
width: 24px;
height: 20px;
border: 1px solid var(--wfm-border);
border-radius: 3px;
padding: 1px;
cursor: pointer;
background: transparent;
}
.wfm-pattern-options input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.wfm-pattern-options input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
/* SVG upload button */
.wfm-svg-upload-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--wfm-text-secondary);
background: var(--wfm-surface);
}
.wfm-svg-upload-btn:hover {
color: var(--wfm-primary);
}
/* Font selector */
.wfm-font-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 6px;
}
.wfm-font-item {
padding: 8px 10px;
border: 2px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
overflow: hidden;
}
.wfm-font-item:hover {
border-color: var(--wfm-primary);
}
.wfm-font-item.active {
border-color: var(--wfm-primary);
box-shadow: 0 0 0 2px var(--wfm-primary);
}
.wfm-font-preview {
font-size: 12px;
white-space: nowrap;
color: var(--wfm-text);
}
/* Customizer action buttons */
.wfm-customizer-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--wfm-border);
}
/* ============================================
Nodes Tab
============================================ */
@@ -2464,6 +2903,14 @@ 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 */
.wfm-node-card {
width: auto;
min-width: auto;
border-left-width: 3px !important;
}
/* Node table row selection highlight */
@@ -2602,3 +3049,167 @@ body {
flex: 1;
overflow-y: auto;
}
/* ========================================
Models Tab
======================================== */
.wfm-models-type-nav {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.wfm-models-type-btn {
padding: 6px 16px;
border: 1px solid var(--wfm-border);
background: var(--wfm-bg-secondary);
cursor: pointer;
border-radius: var(--wfm-radius-sm);
font-size: 13px;
color: var(--wfm-text);
transition: background 0.15s, color 0.15s;
}
.wfm-models-type-btn:hover {
background: var(--wfm-surface-hover);
}
.wfm-models-type-btn.active {
background: var(--wfm-accent);
color: #fff;
border-color: var(--wfm-accent);
}
/* Model card (card view, no thumbnail) */
.wfm-model-card {
position: relative;
}
.wfm-model-card .wfm-card-body {
position: relative;
}
/* Badge: subdirectory (italic, muted) */
.wfm-badge-dir {
opacity: 0.7;
font-style: italic;
}
/* Favorite button in model card (active state color) */
.wfm-model-side-fav.wfm-model-fav-active {
color: #f5a623;
}
/* Model table */
.wfm-models-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.wfm-models-table th,
.wfm-models-table td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid var(--wfm-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.wfm-models-table th {
background: var(--wfm-bg-secondary);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.wfm-models-table-row {
cursor: pointer;
transition: background 0.1s;
}
.wfm-models-table-row:hover {
background: var(--wfm-surface-hover);
}
.wfm-models-table-row.wfm-card-selected {
background: var(--wfm-accent-light, rgba(var(--wfm-accent-rgb, 99, 102, 241), 0.1));
}
.wfm-models-table-fav {
cursor: pointer;
text-align: center !important;
width: 30px;
}
.wfm-table-th-subdir,
.wfm-table-td-subdir {
width: 80px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-table-th-ext,
.wfm-table-td-ext {
width: 60px;
max-width: 60px;
}
.wfm-table-td-memo {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wfm-text-muted, #888);
font-size: 0.9em;
}
/* Textarea (shared - used by models memo and workflow detail) */
.wfm-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
background: var(--wfm-bg-secondary);
color: var(--wfm-text);
font-size: 13px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.wfm-textarea:focus {
outline: none;
border-color: var(--wfm-accent);
}
/* Clickable model name (copy to clipboard) */
.wfm-model-name-copy {
cursor: pointer;
transition: color 0.15s;
}
.wfm-model-name-copy:hover {
color: var(--wfm-accent);
}
/* Inline pagination (in toolbar) */
.wfm-pagination-inline {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--wfm-text-secondary);
}
/* Side panel content for models */
.wfm-models-side-content {
flex: 1;
overflow-y: auto;
}
+37 -2
View File
@@ -90,7 +90,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -163,6 +163,31 @@ function applyI18nToHtml() {
const nodesPlaceholder = document.getElementById("wfm-nodes-placeholder");
if (nodesPlaceholder) nodesPlaceholder.textContent = t("nodesClickToLoad");
// Models tab
const modelsTypeBtns = document.querySelectorAll(".wfm-models-type-btn");
modelsTypeBtns.forEach(btn => {
const key = "models" + btn.dataset.modelType.charAt(0).toUpperCase() + btn.dataset.modelType.slice(1);
const label = t(key);
if (label && label !== key) btn.textContent = label;
});
const modelsSearch = document.getElementById("wfm-models-search");
if (modelsSearch) modelsSearch.placeholder = t("modelsSearchPlaceholder");
const modelsRefreshBtn = document.getElementById("wfm-models-refresh-btn");
if (modelsRefreshBtn) modelsRefreshBtn.textContent = t("modelsRefresh");
const modelsPlaceholder = document.getElementById("wfm-models-placeholder");
if (modelsPlaceholder) modelsPlaceholder.textContent = t("modelsClickToLoad");
const modelsTagFilter = document.getElementById("wfm-models-tag-filter");
if (modelsTagFilter) {
const firstOpt = modelsTagFilter.querySelector("option[value='']");
if (firstOpt) firstOpt.textContent = t("modelsAllTags");
}
const modelsSideTabBtns = document.querySelectorAll(".wfm-models-side-tab-btn");
modelsSideTabBtns.forEach(btn => {
if (btn.dataset.sideTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.sideTab === "group") btn.textContent = t("modelsSideGroup");
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
});
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
@@ -227,10 +252,11 @@ function applyI18nToHtml() {
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
"wfm-help-wf-7": "helpWf7", "wfm-help-wf-8": "helpWf8",
"wfm-help-wf-7": "helpWf7",
"wfm-help-gen-1": "helpGen1", "wfm-help-gen-2": "helpGen2",
"wfm-help-gen-3": "helpGen3", "wfm-help-gen-4": "helpGen4",
"wfm-help-gen-5": "helpGen5", "wfm-help-gen-6": "helpGen6", "wfm-help-gen-7": "helpGen7",
"wfm-help-gen-8": "helpGen8", "wfm-help-gen-9": "helpGen9", "wfm-help-gen-10": "helpGen10",
"wfm-help-prompt-1": "helpPrompt1", "wfm-help-prompt-2": "helpPrompt2",
"wfm-help-prompt-3": "helpPrompt3", "wfm-help-prompt-4": "helpPrompt4",
"wfm-help-prompt-5": "helpPrompt5", "wfm-help-prompt-6": "helpPrompt6",
@@ -238,15 +264,22 @@ function applyI18nToHtml() {
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
"wfm-help-models-1": "helpModels1", "wfm-help-models-2": "helpModels2",
"wfm-help-models-3": "helpModels3", "wfm-help-models-4": "helpModels4",
"wfm-help-models-5": "helpModels5", "wfm-help-models-6": "helpModels6",
"wfm-help-models-7": "helpModels7", "wfm-help-models-8": "helpModels8",
"wfm-help-models-9": "helpModels9",
"wfm-help-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-12": "helpSidepanel12",
"wfm-help-shortcuts-title": "helpShortcutsTitle",
"wfm-help-shortcuts-1": "helpShortcuts1", "wfm-help-shortcuts-2": "helpShortcuts2",
"wfm-help-shortcuts-3": "helpShortcuts3", "wfm-help-shortcuts-4": "helpShortcuts4",
@@ -301,6 +334,7 @@ import { initNodesTab } from "./nodes-tab.js";
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";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
@@ -312,6 +346,7 @@ document.addEventListener("DOMContentLoaded", () => {
initSettingsTab(); // Settings first (applies saved URL)
initWorkflowTab();
initNodesTab();
initModelsTab();
initGenerateTab();
initPromptTab();
+19
View File
@@ -134,6 +134,25 @@ export const comfyUI = {
);
},
async fetchHypernetworks() {
return this._fetchModelList(
["HypernetworkLoader"],
"hypernetwork_name"
);
},
async fetchEmbeddings() {
// ComfyUI exposes embeddings via /embeddings endpoint
try {
const res = await fetch(`${this.baseUrl}/embeddings`);
if (res.ok) {
const list = await res.json();
return Array.isArray(list) ? list : [];
}
} catch {}
return [];
},
async fetchSamplers() {
return this._fetchModelList(["KSampler"], "sampler_name");
},
+5 -4
View File
@@ -61,6 +61,7 @@ export const comfyEditor = {
this.renderImageTab(analysis, "wfm-gen-image-fields");
this.renderModelTab(analysis, "wfm-gen-model-fields");
this.renderSettingsTab(analysis, "wfm-gen-settings-fields");
_syncRawJson();
},
renderPromptTab(analysis, containerId) {
@@ -208,9 +209,9 @@ export const comfyEditor = {
const latent = analysis.latent_nodes?.[0];
el.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div>
<h3 style="font-size:14px;margin-bottom:12px;">KSampler</h3>
<div style="display:flex;flex-direction:column;gap:0;">
<div style="padding-bottom:14px;border-bottom:1px solid var(--wfm-border);margin-bottom:14px;">
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">KSampler</h3>
${sampler ? `
<input type="hidden" id="wfm-settings-sampler-id" value="${sampler.id}">
<div class="wfm-form-group">
@@ -245,7 +246,7 @@ export const comfyEditor = {
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
</div>
<div>
<h3 style="font-size:14px;margin-bottom:12px;">Latent Image</h3>
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">Latent Image</h3>
${latent ? `
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
<div class="wfm-form-group">
+15 -1
View File
@@ -246,6 +246,16 @@ export async function initGenerateTab() {
showToast("Interrupted", "info");
});
// Move shared Raw JSON widget into the active tab's rawjson-col
function moveRawJsonToTab(tabKey) {
const widget = document.getElementById("wfm-gen-rawjson-widget");
const col = document.getElementById(`wfm-gen-rawjson-col-${tabKey}`);
if (widget && col) {
col.appendChild(widget);
widget.style.display = "flex";
}
}
// Subtab navigation
document.querySelectorAll(".wfm-gen-subtab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -254,10 +264,14 @@ export async function initGenerateTab() {
btn.classList.add("active");
document.querySelectorAll(".wfm-gen-subtab-content").forEach((c) => c.classList.remove("active"));
document.getElementById(`wfm-gen-subtab-${target}`)?.classList.add("active");
moveRawJsonToTab(target);
});
});
// Raw JSON apply
// Initial placement: move to first active tab (input)
moveRawJsonToTab("input");
// Raw JSON apply (always-visible panel in right column)
document.getElementById("wfm-gen-apply-raw-btn")?.addEventListener("click", async () => {
const textarea = document.getElementById("wfm-gen-raw-json");
if (!textarea) return;
+471 -45
View File
@@ -31,6 +31,13 @@ const LANGUAGES = {
import: "Import",
badgeColors: "Badge Colors",
resetColors: "Reset",
badgeManage: "Manage Badges",
badgeAdd: "Add",
badgeDelete: "Delete",
badgeNone: "No badges defined yet.",
badgeNoneHint: "No badges defined. Click \u2699 Manage Badges to add.",
badgeNewLabel: "Badge label...",
badgeColorHint: "Change color",
defaultView: "Default View",
viewThumbnail: "Thumbnail",
viewCard: "Card",
@@ -160,6 +167,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",
@@ -204,6 +215,81 @@ const LANGUAGES = {
nodesConnectToComfyUI: "Connect to ComfyUI to load nodes. Check Settings tab.",
nodesClickToLoad: "Click to load nodes...",
// -- Models tab --
tabModels: "Models",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "Search models...",
modelsRefresh: "Refresh",
modelsClickToLoad: "Click Refresh to load models...",
modelsLoading: "Loading models...",
modelsLoadError: "Error loading models",
modelsNoModels: "No models found",
modelsAllTags: "All Tags",
modelsFileName: "File Name",
modelsSubdir: "Subdirectory",
modelsExt: "Extension",
modelsTags: "Tags",
modelsTagsPlaceholder: "tag1, tag2, ...",
modelsModelType: "Model Type",
modelsBadges: "Badges",
modelsInfo: "Info",
modelsFavorite: "Favorite",
modelsMemo: "Memo",
modelsMemoPlaceholder: "Notes about this model...",
modelsSave: "Save",
modelsSaved: "Metadata saved",
modelsGenUIBtn: "GenUI Model",
modelsGenUITitle: "Set this model in GenerateUI",
modelsGenUIUnsupported: "This model type cannot be set in GenerateUI",
modelsGenUINoWorkflow: "No workflow loaded in GenerateUI",
modelsGenUINoNode: (type) => `No ${type} node found in current workflow`,
modelsCopyName: "Click to copy model name",
modelsCopiedName: "Model name copied",
modelsFilePath: "File Path",
modelsCopyPath: "Click to copy file path",
modelsCopiedPath: "File path copied",
modelsNoPreview: "No preview",
modelsTagsHint: "(comma separated)",
modelsAllDirs: "All Folders",
modelsAllGroups: "All Groups",
modelsCurrentGroups: "Current Groups",
modelsNoGroup: "Not assigned to any group",
modelsAssignGroup: "Assign to Group",
modelsNoGroupAvailable: "No groups available",
modelsAdd: "Add",
modelsCreateGroup: "Create New Group",
modelsGroupName: "Group name",
modelsCreate: "Create",
modelsManageGroups: "Manage Groups",
modelsRename: "Rename",
modelsDelete: "Delete",
modelsRemoveFromGroup: "Remove from group",
modelsGroupExists: "Group already exists",
modelsRenamePrompt: "Enter new group name:",
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
civitaiFetch: "Fetch from CivitAI",
civitaiHashing: "Calculating hash... (may take a while for large files)",
civitaiFound: "CivitAI info loaded",
civitaiNotFound: "Model not found on CivitAI",
civitaiError: "Error fetching from CivitAI",
civitaiTriggerWords: "Trigger Words",
civitaiCopyWord: "Click to copy",
civitaiWordCopied: "Trigger word copied",
civitaiRefresh: "Refresh",
civitaiBatchAllCached: "All models already have CivitAI info",
civitaiHashing2: "Hashing...",
civitaiFetching: "Fetching...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found} found, ${notFound} not found`,
modelsSideInfo: "Info",
modelsSideGroup: "Group",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "Help",
helpManualTitle: "Features",
@@ -216,16 +302,18 @@ const LANGUAGES = {
helpWf3: "Create, rename, delete groups and assign/remove workflows",
helpWf4: "Import JSON/PNG files via button or drag & drop",
helpWf5: "Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management",
helpWf6: "Detail modal: rename, favorite, thumbnail change, analysis, category override, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete",
helpWf7: "Customize badge colors per category",
helpWf8: "Batch re-analysis of all workflow I/O (P: prompt nodes, I: image nodes, output: save nodes)",
helpWf6: "Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete",
helpWf7: "Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)",
helpGen1: "Connect/disconnect ComfyUI and check connection status",
helpGen2: "Load workflows from Workflow tab (App format .app.json not supported)",
helpGen3: "Edit parameters: Prompt, Image (drag & drop), Model (checkpoint/VAE/LoRA/ControlNet with filter), Settings (steps/CFG/sampler/scheduler/resolution), Raw JSON",
helpGen4: "Seed control: random, fixed, increment, decrement",
helpGen5: "Generate, progress display, and stop",
helpGen6: "View results with main image and thumbnail history",
helpGen7: "Eagle integration: auto-save generated images to Eagle",
helpGen3: "3-tab layout — Input (Prompt + Image), Model, Settings — each with a Raw JSON column for instant preview and direct editing",
helpGen4: "Input tab: Prompt (top) and Image drag-and-drop (bottom) in the left column",
helpGen5: "Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter",
helpGen6: "Settings tab: KSampler (top) and LatentImage (bottom) in the left column",
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow",
helpGen8: "Seed control: random, fixed, increment, decrement",
helpGen9: "Generate, progress display, stop, and view results",
helpGen10: "Eagle integration: auto-save generated images to Eagle",
helpPrompt1: "AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)",
helpPrompt2: "Apply AI responses to GenerateUI prompts",
helpPrompt3: "Translation: JA\u2194EN, ZH\u2194EN",
@@ -234,21 +322,32 @@ const LANGUAGES = {
helpPrompt6: "Group management: create groups, assign/remove presets, delete groups",
helpPrompt7: "GenUI Set: apply preset prompts directly to GenerateUI",
helpSettings1: "UI language and summary language",
helpSettings2: "Workflow data folder path",
helpSettings3: "ComfyUI connection URL and test",
helpSettings4: "Ollama URL, default model, and connection test",
helpSettings5: "Default workflow setting",
helpSettings6: "Eagle integration URL and auto-save toggle",
helpSettings2: "Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)",
helpSettings3: "Workflow data folder path",
helpSettings4: "ComfyUI connection URL and test",
helpSettings5: "Ollama URL, default model, and connection test",
helpSettings6: "Default workflow setting",
helpSettings7: "Eagle integration URL and auto-save toggle",
helpSettings8: "All sections are collapsible accordion panels",
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
helpNodes2: "Card and Table view modes with pagination",
helpNodes3: "Favorite nodes and add custom tags for organization",
helpNodes4: "Create, rename, delete node groups and assign/remove nodes",
helpNodes5: "Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management",
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching",
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
helpModels3: "Search, tag filter, group filter, and favorites filter",
helpModels4: "Side panel: Info (file path, tags, memo), Groups management, CivitAI integration",
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type",
helpModels6: "Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change",
helpModels7: "Group management: create, rename, delete groups and assign/remove models",
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",
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
helpSidepanel2: "Workflows tab: browse favorite workflows, filter by model type, and filter by group",
helpSidepanel3: "Nodes tab: browse favorite nodes, node sets, and node groups",
helpSidepanel2: "Workflows tab: All / ★ Favorites / Groups 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",
@@ -256,6 +355,8 @@ const LANGUAGES = {
helpSidepanel8: "Copy individual positive (P) or negative (N) prompts from sidebar items",
helpSidepanel9: "Double-click items to load/place without dragging",
helpSidepanel10: "Search within each sub-tab to quickly find items",
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",
@@ -327,6 +428,47 @@ const LANGUAGES = {
themeMaterial: "Material UI",
themeMonotoneAccent: "Monotone + Accent",
themeCorporate: "Corporate Trust",
// -- Theme Customizer --
themeCustomize: "Customize",
themeCustomizeColors: "Color Override",
themeCustomizeBgPattern: "Background Pattern",
themeColorBg: "Background",
themeColorBgSec: "Secondary BG",
themeColorSurface: "Surface",
themeColorText: "Text",
themeColorPrimary: "Primary",
themeColorAccent: "Accent",
themePatternNone: "None",
themePatternStripeH: "Stripe H",
themePatternStripeV: "Stripe V",
themePatternStripeD: "Stripe D",
themePatternDot: "Polka Dot",
themePatternCheck: "Checkerboard",
themePatternSvg: "SVG File",
themePatternColor: "Pattern Color",
themePatternOpacity: "Opacity",
themePatternScale: "Scale",
themePatternGap: "Gap",
themeCustomizerReset: "Reset Customization",
themeCustomizerApply: "Apply & Save",
// -- Font --
themeCustomizeFont: "Font",
themeFontDefault: "System Default",
themeFontNotoSans: "Noto Sans JP",
themeFontZenMaru: "Zen Maru Gothic",
themeFontMPlusRound: "M PLUS Rounded",
themeFontKosugiMaru: "Kosugi Maru",
themeFontSawarabi: "Sawarabi Gothic",
themeFontBizUdp: "BIZ UDPGothic",
themeFontDotGothic: "DotGothic16",
themeFontHachiMaru: "Hachi Maru Pop",
themeFontDelaGothic: "Dela Gothic One",
themeFontReggaeOne: "Reggae One",
themeFontRocknRoll: "RocknRoll One",
themeFontStick: "Stick",
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
},
ja: {
@@ -356,6 +498,13 @@ const LANGUAGES = {
import: "インポート",
badgeColors: "バッジ色",
resetColors: "リセット",
badgeManage: "バッジ管理",
badgeAdd: "追加",
badgeDelete: "削除",
badgeNone: "バッジが未定義です。",
badgeNoneHint: "バッジが未定義です。⚙ バッジ管理から追加できます。",
badgeNewLabel: "バッジ名...",
badgeColorHint: "色を変更",
defaultView: "デフォルト表示",
viewThumbnail: "サムネイル",
viewCard: "カード",
@@ -485,6 +634,10 @@ const LANGUAGES = {
tabNodes: "ノード",
nodesAllNodes: "全ノード",
nodesNodeSets: "ノードセット",
nodesCategoryTab: "カテゴリ",
nodesPackageTab: "パッケージ",
nodesSelectCategory: "カテゴリを選択...",
nodesSelectPackage: "パッケージを選択...",
nodesSearchPlaceholder: "ノードを検索...",
nodesAllCategories: "全カテゴリ",
nodesAllPackages: "全パッケージ",
@@ -529,6 +682,81 @@ const LANGUAGES = {
nodesConnectToComfyUI: "ComfyUIに接続してノードを読み込みます。設定タブを確認してください。",
nodesClickToLoad: "クリックしてノードを読み込み...",
// -- Models tab --
tabModels: "モデル",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "モデルを検索...",
modelsRefresh: "更新",
modelsClickToLoad: "更新ボタンでモデルを読み込み...",
modelsLoading: "モデルを読み込み中...",
modelsLoadError: "モデルの読み込みエラー",
modelsNoModels: "モデルが見つかりません",
modelsAllTags: "すべてのタグ",
modelsFileName: "ファイル名",
modelsSubdir: "サブディレクトリ",
modelsExt: "拡張子",
modelsTags: "タグ",
modelsTagsPlaceholder: "タグ1, タグ2, ...",
modelsModelType: "モデルタイプ",
modelsBadges: "バッジ",
modelsInfo: "情報",
modelsFavorite: "お気に入り",
modelsMemo: "メモ",
modelsMemoPlaceholder: "このモデルについてのメモ...",
modelsSave: "保存",
modelsSaved: "メタデータを保存しました",
modelsGenUIBtn: "GenUI Model",
modelsGenUITitle: "GenerateUIにこのモデルをセット",
modelsGenUIUnsupported: "このモデルタイプはGenerateUIに設定できません",
modelsGenUINoWorkflow: "GenerateUIにワークフローが読み込まれていません",
modelsGenUINoNode: (type) => `現在のワークフローに${type}ノードが見つかりません`,
modelsCopyName: "クリックでモデル名をコピー",
modelsCopiedName: "モデル名をコピーしました",
modelsFilePath: "ファイルパス",
modelsCopyPath: "クリックでファイルパスをコピー",
modelsCopiedPath: "ファイルパスをコピーしました",
modelsNoPreview: "プレビューなし",
modelsTagsHint: "(カンマ区切り)",
modelsAllDirs: "すべてのフォルダ",
modelsAllGroups: "すべてのグループ",
modelsCurrentGroups: "所属グループ",
modelsNoGroup: "グループに未所属",
modelsAssignGroup: "グループに追加",
modelsNoGroupAvailable: "グループがありません",
modelsAdd: "追加",
modelsCreateGroup: "新規グループ作成",
modelsGroupName: "グループ名",
modelsCreate: "作成",
modelsManageGroups: "グループ管理",
modelsRename: "名前変更",
modelsDelete: "削除",
modelsRemoveFromGroup: "グループから除外",
modelsGroupExists: "同名のグループが既に存在します",
modelsRenamePrompt: "新しいグループ名を入力:",
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
civitaiFetch: "CivitAIから取得",
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
civitaiFound: "CivitAI情報を取得しました",
civitaiNotFound: "CivitAIにモデルが見つかりません",
civitaiError: "CivitAIからの取得エラー",
civitaiTriggerWords: "トリガーワード",
civitaiCopyWord: "クリックでコピー",
civitaiWordCopied: "トリガーワードをコピーしました",
civitaiRefresh: "更新",
civitaiBatchAllCached: "すべてのモデルのCivitAI情報は取得済みです",
civitaiHashing2: "ハッシュ計算中...",
civitaiFetching: "取得中...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}件取得, ${notFound}件未登録`,
modelsSideInfo: "基本情報",
modelsSideGroup: "グループ",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "ヘルプ",
helpManualTitle: "機能一覧",
@@ -541,16 +769,18 @@ const LANGUAGES = {
helpWf3: "グループの作成・名前変更・削除、ワークフローの割り当て・解除",
helpWf4: "JSON/PNGファイルのインポート(ボタンまたはドラッグ&ドロップ)",
helpWf5: "サイドパネル:サムネイル、JSONビューア(フォーマットバッジ:UI/API/App形式)、クリップボードコピー、グループ管理",
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、分析、カテゴリオーバーライド、タグ、メモ、AI要約、ComfyUIで開く、生成UIに読み込み、デフォルト設定、削除",
helpWf7: "カテゴリごとのバッジ色カスタマイズ",
helpWf8: "全ワークフローの入出力一括再分析(P:プロンプト入力、I:画像入力、出力:saveノード)",
helpWf6: "詳細モーダル:名前変更、お気に入り、サムネイル変更、バッジ付与、タグ、メモ、AI要約、ComfyUIで開く、生成UIに読み込み、デフォルト設定、削除",
helpWf7: "バッジ管理:バッジの追加・名前変更・削除、カスタム色設定(Modelsタブと共有)— ⚙ Badgeボタン",
helpGen1: "ComfyUIの接続・切断、接続状態の確認",
helpGen2: "Workflowタブからワークフローを読み込み(App形式 .app.json は非対応)",
helpGen3: "パラメータ編集:プロンプト、画像(ドラッグ&ドロップ)、モデル(checkpoint/VAE/LoRA/ControlNet、フィルタ検索)、設定(Steps/CFG/サンプラー/スケジューラ/解像度)、Raw JSON",
helpGen4: "シード管理:ランダム / 固定 / 増加 / 減少",
helpGen5: "画像生成、進捗表示、生成停止",
helpGen6: "メイン画像とサムネイル履歴で結果表示",
helpGen7: "Eagle連携:生成画像をEagleへ自動保存",
helpGen3: "3タブ構成 — Input(プロンプト+画像)、Model、Settings — 各タブにRaw JSON列を常時表示",
helpGen4: "Inputタブ:左列にPrompt(上段)+ Image ドラッグ&ドロップ(下段)",
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA/ControlNet/UNET/TextEncoderのセレクタ(フィルタ検索付き)",
helpGen6: "Settingsタブ:左列にKSampler(上段)+ LatentImage(下段)",
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映",
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
helpGen9: "画像生成、進捗表示、生成停止、結果表示",
helpGen10: "Eagle連携:生成画像をEagleへ自動保存",
helpPrompt1: "AIアシスタント(Ollama):モデル選択、チャットでプロンプト相談、画像添付(ビジョンモデル対応)",
helpPrompt2: "AIの応答をGenerateUIのプロンプトに適用",
helpPrompt3: "翻訳機能:日⇔英、中⇔英",
@@ -559,21 +789,32 @@ const LANGUAGES = {
helpPrompt6: "グループ管理:グループ作成、プリセットの割り当て・解除、グループ削除",
helpPrompt7: "GenUI Set:プリセットのプロンプトをGenerateUIに直接適用",
helpSettings1: "UI言語と要約言語の設定",
helpSettings2: "ワークフローデータフォルダのパス変更・リセット",
helpSettings3: "ComfyUI接続URL設定と接続テスト",
helpSettings4: "Ollama URL、デフォルトモデル、接続テスト",
helpSettings5: "デフォルトワークフローの設定・解除",
helpSettings6: "Eagle連携URL、自動保存の有効/無効",
helpSettings2: "テーマ選択(13種プリセット)+カスタマイズ:カラー調整、背景パターン(ストライプ、ポルカドット、チェック、SVGタイリング)、フォント選択(日本語デザインフォント含む16種のGoogle Fonts)",
helpSettings3: "ワークフローデータフォルダのパス変更・リセット",
helpSettings4: "ComfyUI接続URL設定と接続テスト",
helpSettings5: "Ollama URL、デフォルトモデル、接続テスト",
helpSettings6: "デフォルトワークフローの設定・解除",
helpSettings7: "Eagle連携URL、自動保存の有効/無効",
helpSettings8: "各セクションはアコーディオン式で折りたたみ可能",
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
helpNodes4: "ノードグループの作成・名前変更・削除、ノードの割り当て・解除",
helpNodes5: "サイドパネル:詳細(クラス名、パッケージ、カテゴリ、説明、フラグ)、I/O仕様(入出力の型・デフォルト値)、グループ管理",
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embeddingをサブタブで切り替え",
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpModels8: "GenUI Modelボタン:選択モデルをGenerateUIの現在のワークフローの該当ノードに直接適用(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "ユーザー定義バッジ:自由ラベルのバッジをモデルに付与(Workflowタブと色パレットを共有)",
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
helpSidepanel2: "Workflowsタブ:お気に入りワークフロー閲覧、モデル種別フィルタ、グループフィルタ",
helpSidepanel3: "Nodesタブ:お気に入りノード、ノードセット、ノードグループの閲覧",
helpSidepanel2: "Workflowsタブ:All / ★ Favorites / Groupsサブタブ、Allビューでお気に入りに★表示",
helpSidepanel3: "Nodesタブ:All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Packageサブタブ;CategoryとPackageはドロップダウンでカテゴリ・カスタムノードパッケージ別に絞り込み可能",
helpSidepanel4: "Promptsタブ:プロンプトプリセットの閲覧(All/お気に入り/カテゴリ サブタブ)",
helpSidepanel5: "ワークフローをキャンバスにドラッグ&ドロップで読み込み",
helpSidepanel6: "ノード/ノードセットをキャンバスにドラッグ&ドロップで配置",
@@ -581,6 +822,8 @@ const LANGUAGES = {
helpSidepanel8: "サイドバーアイテムからポジティブ(P)/ネガティブ(N)プロンプトを個別コピー",
helpSidepanel9: "ダブルクリックでもアイテムの読み込み・配置が可能",
helpSidepanel10: "各サブタブ内で検索してアイテムをすばやく検索",
helpSidepanel11: "Modelsタブ:All / ★ Favorites / Groups / By Typeサブタブ",
helpSidepanel12: "⚙ テーマボタン(ヘッダー):パネル背景・サブヘッダー背景・文字色・ボーダー・補助テキスト色をカスタマイズ可能、設定はlocalStorageに保存され次回起動時も反映",
helpShortcutsTitle: "キーボード操作",
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
@@ -652,6 +895,47 @@ const LANGUAGES = {
themeMaterial: "Material UI (マテリアル)",
themeMonotoneAccent: "Monotone + Accent",
themeCorporate: "Corporate Trust (企業向け)",
// -- Theme Customizer --
themeCustomize: "カスタマイズ",
themeCustomizeColors: "カラー調整",
themeCustomizeBgPattern: "背景パターン",
themeColorBg: "背景",
themeColorBgSec: "サブ背景",
themeColorSurface: "サーフェス",
themeColorText: "テキスト",
themeColorPrimary: "プライマリ",
themeColorAccent: "アクセント",
themePatternNone: "なし",
themePatternStripeH: "横ストライプ",
themePatternStripeV: "縦ストライプ",
themePatternStripeD: "斜めストライプ",
themePatternDot: "ポルカドット",
themePatternCheck: "チェック",
themePatternSvg: "SVGファイル",
themePatternColor: "パターン色",
themePatternOpacity: "不透明度",
themePatternScale: "サイズ",
themePatternGap: "間隔",
themeCustomizerReset: "カスタマイズをリセット",
themeCustomizerApply: "適用して保存",
// -- Font --
themeCustomizeFont: "フォント",
themeFontDefault: "システム標準",
themeFontNotoSans: "Noto Sans JP",
themeFontZenMaru: "Zen丸ゴシック",
themeFontMPlusRound: "M PLUS 丸ゴシック",
themeFontKosugiMaru: "小杉丸ゴシック",
themeFontSawarabi: "さわらびゴシック",
themeFontBizUdp: "BIZ UDPゴシック",
themeFontDotGothic: "ドットゴシック16",
themeFontHachiMaru: "はちまるポップ",
themeFontDelaGothic: "デラゴシック",
themeFontReggaeOne: "レゲエ One",
themeFontRocknRoll: "ロックンロール",
themeFontStick: "ステッキ",
themeFontTrainOne: "トレイン One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
},
zh: {
@@ -681,6 +965,13 @@ const LANGUAGES = {
import: "导入",
badgeColors: "标签颜色",
resetColors: "重置",
badgeManage: "管理标签",
badgeAdd: "添加",
badgeDelete: "删除",
badgeNone: "尚未定义标签。",
badgeNoneHint: "尚未定义标签。点击 ⚙ 管理标签来添加。",
badgeNewLabel: "标签名称...",
badgeColorHint: "更改颜色",
defaultView: "默认视图",
viewThumbnail: "缩略图",
viewCard: "卡片",
@@ -806,6 +1097,10 @@ const LANGUAGES = {
tabNodes: "节点",
nodesAllNodes: "所有节点",
nodesNodeSets: "节点集",
nodesCategoryTab: "分类",
nodesPackageTab: "包",
nodesSelectCategory: "选择分类...",
nodesSelectPackage: "选择包...",
nodesSearchPlaceholder: "搜索节点...",
nodesAllCategories: "所有分类",
nodesAllPackages: "所有包",
@@ -850,6 +1145,81 @@ const LANGUAGES = {
nodesConnectToComfyUI: "连接ComfyUI以加载节点。请检查设置选项卡。",
nodesClickToLoad: "点击加载节点...",
// -- Models tab --
tabModels: "模型",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "搜索模型...",
modelsRefresh: "刷新",
modelsClickToLoad: "点击刷新加载模型...",
modelsLoading: "正在加载模型...",
modelsLoadError: "加载模型出错",
modelsNoModels: "未找到模型",
modelsAllTags: "所有标签",
modelsFileName: "文件名",
modelsSubdir: "子目录",
modelsExt: "扩展名",
modelsTags: "标签",
modelsTagsPlaceholder: "标签1, 标签2, ...",
modelsModelType: "模型类型",
modelsBadges: "标签",
modelsInfo: "信息",
modelsFavorite: "收藏",
modelsMemo: "备注",
modelsMemoPlaceholder: "关于此模型的备注...",
modelsSave: "保存",
modelsSaved: "元数据已保存",
modelsGenUIBtn: "GenUI Model",
modelsGenUITitle: "在GenerateUI中设置此模型",
modelsGenUIUnsupported: "此模型类型无法在GenerateUI中设置",
modelsGenUINoWorkflow: "GenerateUI中未加载工作流",
modelsGenUINoNode: (type) => `当前工作流中未找到${type}节点`,
modelsCopyName: "点击复制模型名称",
modelsCopiedName: "模型名称已复制",
modelsFilePath: "文件路径",
modelsCopyPath: "点击复制文件路径",
modelsCopiedPath: "文件路径已复制",
modelsNoPreview: "无预览",
modelsTagsHint: "(逗号分隔)",
modelsAllDirs: "所有文件夹",
modelsAllGroups: "所有分组",
modelsCurrentGroups: "当前分组",
modelsNoGroup: "未分配到任何分组",
modelsAssignGroup: "分配到分组",
modelsNoGroupAvailable: "没有可用的分组",
modelsAdd: "添加",
modelsCreateGroup: "创建新分组",
modelsGroupName: "分组名称",
modelsCreate: "创建",
modelsManageGroups: "分组管理",
modelsRename: "重命名",
modelsDelete: "删除",
modelsRemoveFromGroup: "从分组移除",
modelsGroupExists: "同名分组已存在",
modelsRenamePrompt: "输入新分组名称:",
modelsDeleteGroupConfirm: "删除分组「{name}」?",
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
civitaiFetch: "从CivitAI获取",
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
civitaiFound: "已加载CivitAI信息",
civitaiNotFound: "在CivitAI上未找到该模型",
civitaiError: "从CivitAI获取时出错",
civitaiTriggerWords: "触发词",
civitaiCopyWord: "点击复制",
civitaiWordCopied: "触发词已复制",
civitaiRefresh: "刷新",
civitaiBatchAllCached: "所有模型的CivitAI信息已获取",
civitaiHashing2: "计算哈希中...",
civitaiFetching: "获取中...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}个已找到, ${notFound}个未找到`,
modelsSideInfo: "基本信息",
modelsSideGroup: "分组",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "帮助",
helpManualTitle: "功能列表",
@@ -862,16 +1232,18 @@ const LANGUAGES = {
helpWf3: "创建、重命名、删除分组,分配/移除工作流",
helpWf4: "通过按钮或拖放导入JSON/PNG文件",
helpWf5: "侧面板:缩略图、JSON查看器(格式徽章:UI/API/App)、剪贴板复制、分组管理",
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、分析、分类覆盖、标签、备注、AI摘要、在ComfyUI中打开、加载到生成UI、设为默认、删除",
helpWf7: "按分类自定义徽章颜色",
helpWf8: "批量重新分析所有工作流I/O(P:提示词节点、I:图片节点、输出:保存节点)",
helpWf6: "详情弹窗:重命名、收藏、缩略图更换、徽章分配、标签、备注、AI摘要、在ComfyUI中打开、加载到生成UI、设为默认、删除",
helpWf7: "徽章管理:添加、重命名、删除徽章并自定义颜色(与Models标签共享)— ⚙ Badge按钮",
helpGen1: "连接/断开ComfyUI,检查连接状态",
helpGen2: "从工作流标签页加载工作流(不支持App格式.app.json)",
helpGen3: "编辑参数:提示词、图片(拖放)、模型(checkpoint/VAE/LoRA/ControlNet含筛选)、设置(Steps/CFG/采样器/调度器/分辨率)、Raw JSON",
helpGen4: "种子管理:随机/固定/递增/递减",
helpGen5: "生成图片、进度显示、停止生成",
helpGen6: "主图和缩略图历史查看结果",
helpGen7: "Eagle集成:自动保存生成图片到Eagle",
helpGen3: "3标签布局 — Input(提示词+图片)、Model、Settings — 每个标签含Raw JSON列实时预览",
helpGen4: "Input标签:左列为Prompt(上)+ Image拖放(下)",
helpGen5: "Model标签:Checkpoint/VAE/LoRA/ControlNet/UNET/TextEncoder选择器(含筛选)",
helpGen6: "Settings标签:左列为KSampler(上)+ LatentImage(下)",
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流",
helpGen8: "种子管理:随机/固定/递增/递减",
helpGen9: "生成图片、进度显示、停止生成、查看结果",
helpGen10: "Eagle集成:自动保存生成图片到Eagle",
helpPrompt1: "AI助手(Ollama):模型选择、聊天咨询提示词、图片附件(视觉模型)",
helpPrompt2: "将AI回复应用到生成UI的提示词",
helpPrompt3: "翻译功能:日\u2194英、中\u2194英",
@@ -880,21 +1252,32 @@ const LANGUAGES = {
helpPrompt6: "组管理:创建组、分配/移除预设、删除组",
helpPrompt7: "GenUI Set:将预设提示词直接应用到生成UI",
helpSettings1: "UI语言和摘要语言设置",
helpSettings2: "工作流数据文件夹路径更改/重置",
helpSettings3: "ComfyUI连接URL设置和测试",
helpSettings4: "Ollama URL、默认模型、连接测试",
helpSettings5: "默认工作流设置/取消",
helpSettings6: "Eagle集成URL、自动保存开关",
helpSettings2: "主题选择(13种预设)+ 自定义:颜色调整、背景图案(条纹、波点、棋盘格、SVG平铺)、字体选择(含日文显示字体在内的16种Google Fonts)",
helpSettings3: "工作流数据文件夹路径更改/重置",
helpSettings4: "ComfyUI连接URL设置和测试",
helpSettings5: "Ollama URL、默认模型、连接测试",
helpSettings6: "默认工作流设置/取消",
helpSettings7: "Eagle集成URL、自动保存开关",
helpSettings8: "各部分可折叠展开",
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
helpNodes2: "卡片和表格视图模式切换(带分页)",
helpNodes3: "收藏节点,添加自定义标签进行整理",
helpNodes4: "创建、重命名、删除节点组,分配/移除节点",
helpNodes5: "侧面板:详情(类名、包、分类、描述、标志)、I/O规格(输入输出类型和默认值)、组管理",
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder、Hypernetwork、Embedding,通过子标签切换",
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpModels8: "GenUI Model按钮:将选中模型直接应用到GenerateUI当前工作流的对应节点(Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)",
helpModels9: "用户自定义徽章:为模型添加自由标签徽章(颜色调色板与Workflow标签共享)",
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
helpSidepanel2: "Workflows标签:浏览收藏工作流、按模型类型筛选、按分组筛选",
helpSidepanel3: "Nodes标签:浏览收藏节点、节点集、节点组",
helpSidepanel2: "Workflows标签:All / ★ Favorites / Groups子标签,All视图中收藏项显示★",
helpSidepanel3: "Nodes标签:All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package子标签;Category和Package可通过下拉菜单按分类或自定义节点包筛选节点",
helpSidepanel4: "Prompts标签:浏览提示词预设(全部/收藏/分类 子标签)",
helpSidepanel5: "将工作流拖放到画布上即可加载",
helpSidepanel6: "将节点/节点集拖放到画布上即可放置",
@@ -902,6 +1285,8 @@ const LANGUAGES = {
helpSidepanel8: "从侧边栏项目中单独复制正面(P)或负面(N)提示词",
helpSidepanel9: "双击项目也可加载/放置",
helpSidepanel10: "在各子标签内搜索快速查找项目",
helpSidepanel11: "Models标签:All / ★ Favorites / Groups / By Type子标签",
helpSidepanel12: "⚙ 主题按钮(标题栏):可自定义面板背景、子标题背景、文字颜色、边框及辅助文字颜色,设置保存至localStorage并在下次打开时生效",
helpShortcutsTitle: "键盘操作",
helpShortcuts1: "Escape:关闭弹窗/面板",
helpShortcuts2: "Enter:确认重命名、发送聊天",
@@ -973,6 +1358,47 @@ const LANGUAGES = {
themeMaterial: "Material UI (材料)",
themeMonotoneAccent: "Monotone + Accent",
themeCorporate: "Corporate Trust (企业)",
// -- Theme Customizer --
themeCustomize: "自定义",
themeCustomizeColors: "颜色调整",
themeCustomizeBgPattern: "背景图案",
themeColorBg: "背景",
themeColorBgSec: "次要背景",
themeColorSurface: "表面",
themeColorText: "文字",
themeColorPrimary: "主色",
themeColorAccent: "强调色",
themePatternNone: "无",
themePatternStripeH: "横条纹",
themePatternStripeV: "竖条纹",
themePatternStripeD: "斜条纹",
themePatternDot: "波点",
themePatternCheck: "棋盘格",
themePatternSvg: "SVG文件",
themePatternColor: "图案颜色",
themePatternOpacity: "不透明度",
themePatternScale: "大小",
themePatternGap: "间距",
themeCustomizerReset: "重置自定义",
themeCustomizerApply: "应用并保存",
// -- Font --
themeCustomizeFont: "字体",
themeFontDefault: "系统默认",
themeFontNotoSans: "Noto Sans JP",
themeFontZenMaru: "Zen丸哥特",
themeFontMPlusRound: "M PLUS 圆体",
themeFontKosugiMaru: "小杉圆体",
themeFontSawarabi: "蕨类哥特",
themeFontBizUdp: "BIZ UDP哥特",
themeFontDotGothic: "点阵哥特16",
themeFontHachiMaru: "Hachi Maru Pop",
themeFontDelaGothic: "Dela Gothic One",
themeFontReggaeOne: "Reggae One",
themeFontRocknRoll: "RocknRoll One",
themeFontStick: "Stick",
themeFontTrainOne: "Train One",
themeFontSpaceMono: "Space Mono",
themeFontFiraCode: "Fira Code",
},
};
File diff suppressed because it is too large Load Diff
+30 -97
View File
@@ -8,7 +8,6 @@ import { t } from "./i18n.js";
// ── State ─────────────────────────────────────────────────
const NODES_PER_PAGE = 50;
const state = {
allNodes: [],
@@ -257,111 +256,50 @@ function renderNodeGrid() {
if (filtered.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
removePagination();
return;
}
if (state.viewMode === "table") {
renderNodeTableView(grid, filtered);
removePagination();
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;
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">
${packageBadgeHtml(node.package)}
${categoryBadgeHtml(node.category)}
</div>
<div class="wfm-node-card-io">In: ${inputCount} / Out: ${outputCount}</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) {
removePagination();
if (totalPages <= 1) return;
const grid = document.getElementById("wfm-nodes-grid");
if (!grid) return;
const pager = document.createElement("div");
pager.id = "wfm-nodes-pagination";
pager.className = "wfm-pagination";
const prevBtn = document.createElement("button");
prevBtn.className = "wfm-btn wfm-btn-sm";
prevBtn.textContent = "\u25C0";
prevBtn.disabled = state.currentPage === 0;
prevBtn.addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
});
const nextBtn = document.createElement("button");
nextBtn.className = "wfm-btn wfm-btn-sm";
nextBtn.textContent = "\u25B6";
nextBtn.disabled = state.currentPage >= totalPages - 1;
nextBtn.addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
});
const info = document.createElement("span");
info.className = "wfm-pagination-info";
const start = state.currentPage * NODES_PER_PAGE + 1;
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
info.textContent = `${start}-${end} / ${totalItems}`;
pager.appendChild(prevBtn);
pager.appendChild(info);
pager.appendChild(nextBtn);
grid.parentElement.insertBefore(pager, grid.nextSibling);
}
function removePagination() {
document.getElementById("wfm-nodes-pagination")?.remove();
}
function scrollGridToTop() {
document.getElementById("wfm-nodes-grid")?.scrollTo(0, 0);
}
function renderNodeTableView(grid, filtered) {
grid.innerHTML = "";
@@ -419,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 => {
@@ -641,9 +576,9 @@ function renderSideGroups(node) {
}
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");
});
@@ -1103,8 +1038,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 => {
+540 -22
View File
@@ -8,6 +8,12 @@ import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions
const SETTINGS_KEY = "wfm_settings";
function rgbToHex(rgb) {
const m = rgb.match(/(\d+)/g);
if (!m || m.length < 3) return "#000000";
return "#" + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, "0")).join("");
}
function loadLocalSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
@@ -67,12 +73,259 @@ const THEMES = [
{ id: "corporate", key: "themeCorporate", colors: ["#F0F4F8", "#FFFFFF", "#2563EB", "#7C3AED"] },
];
// --- Background pattern definitions ---
const BG_PATTERNS = [
{ id: "none", key: "themePatternNone" },
{ id: "stripe-h", key: "themePatternStripeH" },
{ id: "stripe-v", key: "themePatternStripeV" },
{ id: "stripe-d", key: "themePatternStripeD" },
{ id: "dot", key: "themePatternDot" },
{ id: "check", key: "themePatternCheck" },
{ id: "svg", key: "themePatternSvg" },
];
// Font definitions: id, label key, CSS value, Google Fonts family name (if web font)
const FONT_OPTIONS = [
{ id: "", key: "themeFontDefault", css: "", google: "" },
{ id: "noto-sans-jp", key: "themeFontNotoSans", css: "'Noto Sans JP', sans-serif", google: "Noto+Sans+JP:wght@400;700" },
{ id: "zen-maru", key: "themeFontZenMaru", css: "'Zen Maru Gothic', sans-serif", google: "Zen+Maru+Gothic:wght@400;700" },
{ id: "m-plus-rounded", key: "themeFontMPlusRound", css: "'M PLUS Rounded 1c', sans-serif", google: "M+PLUS+Rounded+1c:wght@400;700" },
{ id: "kosugi-maru", key: "themeFontKosugiMaru", css: "'Kosugi Maru', sans-serif", google: "Kosugi+Maru" },
{ id: "sawarabi-gothic",key: "themeFontSawarabi", css: "'Sawarabi Gothic', sans-serif", google: "Sawarabi+Gothic" },
{ id: "biz-udp", key: "themeFontBizUdp", css: "'BIZ UDPGothic', sans-serif", google: "BIZ+UDPGothic:wght@400;700" },
{ id: "dot-gothic", key: "themeFontDotGothic", css: "'DotGothic16', monospace", google: "DotGothic16" },
{ id: "hachi-maru", key: "themeFontHachiMaru", css: "'Hachi Maru Pop', cursive", google: "Hachi+Maru+Pop" },
{ id: "dela-gothic", key: "themeFontDelaGothic", css: "'Dela Gothic One', sans-serif", google: "Dela+Gothic+One" },
{ id: "reggae-one", key: "themeFontReggaeOne", css: "'Reggae One', cursive", google: "Reggae+One" },
{ id: "rocknroll", key: "themeFontRocknRoll", css: "'RocknRoll One', sans-serif", google: "RocknRoll+One" },
{ id: "stick", key: "themeFontStick", css: "'Stick', sans-serif", google: "Stick" },
{ id: "train-one", key: "themeFontTrainOne", css: "'Train One', cursive", google: "Train+One" },
{ id: "space-mono", key: "themeFontSpaceMono", css: "'Space Mono', monospace", google: "Space+Mono:wght@400;700" },
{ id: "fira-code", key: "themeFontFiraCode", css: "'Fira Code', monospace", google: "Fira+Code:wght@400;700" },
];
// Load Google Font dynamically
const _loadedFonts = new Set();
function loadGoogleFont(googleFamily) {
if (!googleFamily || _loadedFonts.has(googleFamily)) return;
_loadedFonts.add(googleFamily);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?family=${googleFamily}&display=swap`;
document.head.appendChild(link);
}
function applyCustomFont(fontId) {
const font = FONT_OPTIONS.find(f => f.id === fontId);
let styleEl = document.getElementById("wfm-custom-font-style");
if (!font || !font.css) {
if (styleEl) styleEl.remove();
return;
}
if (font.google) loadGoogleFont(font.google);
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "wfm-custom-font-style";
document.head.appendChild(styleEl);
}
styleEl.textContent = `body, .wfm-input, .wfm-select, .wfm-textarea, button { font-family: ${font.css} !important; }`;
}
// Color customization targets
const COLOR_TARGETS = [
{ varName: "--wfm-bg", key: "themeColorBg" },
{ varName: "--wfm-bg-secondary", key: "themeColorBgSec" },
{ varName: "--wfm-surface", key: "themeColorSurface" },
{ varName: "--wfm-text", key: "themeColorText" },
{ varName: "--wfm-primary", key: "themeColorPrimary" },
{ varName: "--wfm-accent", key: "themeColorAccent" },
];
function generatePatternCSS(patternId, color, opacity, scale) {
const o = opacity ?? 0.1;
const s = scale ?? 20;
const c = color || "#ffffff";
// Convert hex to rgba
const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
const rgba = `rgba(${r},${g},${b},${o})`;
switch (patternId) {
case "stripe-h":
return `repeating-linear-gradient(0deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
case "stripe-v":
return `repeating-linear-gradient(90deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
case "stripe-d":
return `repeating-linear-gradient(45deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
case "dot": {
const half = s / 2;
const dotR = Math.max(1, s * 0.12);
return `radial-gradient(circle ${dotR}px at ${half}px ${half}px, ${rgba} 100%, transparent 100%)`;
}
case "check": {
const half = s / 2;
return `linear-gradient(45deg, ${rgba} 25%, transparent 25%, transparent 75%, ${rgba} 75%), ` +
`linear-gradient(45deg, ${rgba} 25%, transparent 25%, transparent 75%, ${rgba} 75%)`;
}
default:
return "";
}
}
function generatePatternBgSize(patternId, scale) {
const s = scale ?? 20;
if (patternId === "dot") return `${s}px ${s}px`;
if (patternId === "check") {
const half = s / 2;
return `${s}px ${s}px`;
}
return "";
}
function generatePatternBgPosition(patternId, scale) {
const s = scale ?? 20;
if (patternId === "check") {
const half = s / 2;
return `0 0, ${half}px ${half}px`;
}
return "";
}
function generatePatternPreviewSVG(patternId) {
const fg = "#8cb4ff";
const bg = "#1a1a2e";
const encode = (svg) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
switch (patternId) {
case "none":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><line x1="10" y1="10" x2="50" y2="50" stroke="#555" stroke-width="2"/><line x1="50" y1="10" x2="10" y2="50" stroke="#555" stroke-width="2"/></svg>`);
case "stripe-h":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect y="4" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="16" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="28" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="40" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="52" width="60" height="4" fill="${fg}" opacity="0.7"/></svg>`);
case "stripe-v":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="4" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="16" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="28" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="40" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="52" width="4" height="60" fill="${fg}" opacity="0.7"/></svg>`);
case "stripe-d":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><line x1="-5" y1="7" x2="53" y2="-5" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="19" x2="65" y2="7" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="-5" y1="31" x2="53" y2="19" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="43" x2="65" y2="31" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="-5" y1="55" x2="53" y2="43" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="67" x2="65" y2="55" stroke="${fg}" stroke-width="3" opacity="0.7"/></svg>`);
case "dot":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><circle cx="10" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="30" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="50" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="20" cy="25" r="4" fill="${fg}" opacity="0.8"/><circle cx="40" cy="25" r="4" fill="${fg}" opacity="0.8"/><circle cx="10" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="30" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="50" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="20" cy="55" r="4" fill="${fg}" opacity="0.8"/><circle cx="40" cy="55" r="4" fill="${fg}" opacity="0.8"/></svg>`);
case "check":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="0" y="0" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="15" y="15" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="30" y="0" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="45" y="15" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="0" y="30" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="15" y="45" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="30" y="30" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="45" y="45" width="15" height="15" fill="${fg}" opacity="0.35"/></svg>`);
case "svg":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="10" y="10" width="40" height="40" rx="4" fill="none" stroke="${fg}" stroke-width="2" stroke-dasharray="6 3" opacity="0.6"/><text x="30" y="36" text-anchor="middle" fill="${fg}" font-size="20" font-weight="bold" opacity="0.8">+</text></svg>`);
default:
return "";
}
}
// Apply custom overrides (colors + pattern) on top of the current theme
function applyCustomOverrides(custom) {
const root = document.documentElement;
// Remove old custom style element
let styleEl = document.getElementById("wfm-custom-theme-style");
// Color overrides
if (custom && custom.colors) {
for (const [varName, value] of Object.entries(custom.colors)) {
if (value) root.style.setProperty(varName, value);
}
}
// Pattern
if (custom && custom.pattern && custom.pattern.id && custom.pattern.id !== "none") {
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "wfm-custom-theme-style";
document.head.appendChild(styleEl);
}
const p = custom.pattern;
if (p.id === "svg" && p.svgData) {
const s = p.scale ?? 40;
const o = p.opacity ?? 1;
const c = p.color || null;
const gap = p.gap ?? 0;
// Recolor SVG: inject a global style override into the SVG
let svgStr = p.svgData;
if (c) {
// 1. Replace fill/stroke attributes (but not "none")
svgStr = svgStr
.replace(/fill="(?!none)[^"]*"/gi, `fill="${c}"`)
.replace(/stroke="(?!none)[^"]*"/gi, `stroke="${c}"`);
// 2. Replace inline style fill/stroke
svgStr = svgStr
.replace(/fill:\s*(?!none)[^;"]+/gi, `fill:${c}`)
.replace(/stroke:\s*(?!none)[^;"]+/gi, `stroke:${c}`);
// 3. Inject a <style> block for elements with no explicit fill/stroke
const styleBlock = `<style>*:not([fill="none"]){fill:${c}!important}*:not([stroke="none"])[stroke]{stroke:${c}!important}</style>`;
svgStr = svgStr.replace(/<svg([^>]*)>/i, `<svg$1>${styleBlock}`);
}
// Extract original viewBox/width/height, then create wrapper SVG with padding
const tileSize = s + gap;
let tiledSvg;
if (gap > 0) {
// Extract the original viewBox to preserve it in the inner SVG
const vbMatch = svgStr.match(/viewBox="([^"]*)"/i);
const origViewBox = vbMatch ? vbMatch[1] : `0 0 ${s} ${s}`;
// Wrap: outer SVG with tileSize, inner SVG with original viewBox
tiledSvg = svgStr.replace(
/<svg([^>]*)>/i,
(match, attrs) => {
// Remove existing width/height/viewBox from outer
let clean = attrs
.replace(/\s*width="[^"]*"/gi, '')
.replace(/\s*height="[^"]*"/gi, '')
.replace(/\s*viewBox="[^"]*"/gi, '');
return `<svg${clean} width="${tileSize}" height="${tileSize}" viewBox="0 0 ${tileSize} ${tileSize}">
<svg width="${s}" height="${s}" x="0" y="0" viewBox="${origViewBox}">`;
}
) + '</svg>';
} else {
tiledSvg = svgStr;
}
const encoded = `url("data:image/svg+xml,${encodeURIComponent(tiledSvg)}")`;
styleEl.textContent = `body::after { content: ""; position: fixed; inset: 0; z-index: -1; background-image: ${encoded}; background-size: ${tileSize}px ${tileSize}px; background-repeat: repeat; opacity: ${o}; pointer-events: none; }`;
} else if (p.id !== "svg") {
const bg = generatePatternCSS(p.id, p.color, p.opacity, p.scale);
const bgSize = generatePatternBgSize(p.id, p.scale);
const bgPos = generatePatternBgPosition(p.id, p.scale);
let css = `body { background-image: ${bg} !important;`;
if (bgSize) css += ` background-size: ${bgSize} !important;`;
if (bgPos) css += ` background-position: ${bgPos} !important;`;
css += ` }`;
styleEl.textContent = css;
}
} else {
if (styleEl) styleEl.remove();
}
// Font
if (custom && custom.fontId) {
applyCustomFont(custom.fontId);
} else {
const fontEl = document.getElementById("wfm-custom-font-style");
if (fontEl) fontEl.remove();
}
}
function clearCustomOverrides() {
const root = document.documentElement;
for (const ct of COLOR_TARGETS) {
root.style.removeProperty(ct.varName);
}
const styleEl = document.getElementById("wfm-custom-theme-style");
if (styleEl) styleEl.remove();
const fontEl = document.getElementById("wfm-custom-font-style");
if (fontEl) fontEl.remove();
}
export function applyTheme(themeId) {
if (themeId) {
document.documentElement.setAttribute("data-theme", themeId);
} else {
document.documentElement.removeAttribute("data-theme");
}
// Apply saved custom overrides
const settings = loadLocalSettings();
clearCustomOverrides();
if (settings.themeCustom) {
applyCustomOverrides(settings.themeCustom);
}
}
export function getSavedTheme() {
@@ -101,8 +354,8 @@ export async function initSettingsTab() {
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
<!-- Language Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("langLabel")} / ${t("summaryLangLabel")}</h3>
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
@@ -118,11 +371,11 @@ export async function initSettingsTab() {
⚠ ${t("summaryLangNote")}
</small>
</div>
</div>
</details>
<!-- Theme -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("themeLabel")}</h3>
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
<div class="wfm-theme-grid" id="wfm-theme-grid">
${THEMES.map(th => `
<div class="wfm-theme-card ${(settings.theme || "") === th.id ? "active" : ""}" data-theme-id="${th.id}">
@@ -133,11 +386,94 @@ export async function initSettingsTab() {
</div>
`).join("")}
</div>
</div>
<!-- Theme Customizer -->
<button class="wfm-customizer-toggle" id="wfm-customizer-toggle">
<span class="arrow">&#9654;</span> ${t("themeCustomize")}
</button>
<div class="wfm-customizer-panel" id="wfm-customizer-panel">
<!-- Color overrides -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeColors")}</h4>
<div class="wfm-color-grid" id="wfm-color-grid">
${COLOR_TARGETS.map(ct => {
const saved = settings.themeCustom?.colors?.[ct.varName] || "";
return `<div class="wfm-color-item">
<label>${t(ct.key)}</label>
<input type="color" data-var="${ct.varName}" value="${saved || getComputedStyle(document.documentElement).getPropertyValue(ct.varName).trim()}" title="${ct.varName}">
</div>`;
}).join("")}
</div>
</div>
<!-- Background pattern -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeBgPattern")}</h4>
<div class="wfm-pattern-grid" id="wfm-pattern-grid">
${BG_PATTERNS.map(p => {
const active = (settings.themeCustom?.pattern?.id || "none") === p.id ? "active" : "";
const preview = generatePatternPreviewSVG(p.id);
const bgStyle = preview
? `background:url('${preview}') no-repeat center/cover;`
: "background:#333;";
return `<div class="wfm-pattern-item ${active} ${p.id === 'svg' ? 'wfm-svg-upload-btn' : ''}" data-pattern-id="${p.id}" style="${bgStyle}">
<span class="wfm-pattern-item-label">${t(p.key)}</span>
</div>`;
}).join("")}
</div>
<input type="file" id="wfm-svg-upload" accept=".svg" style="display:none;">
<!-- Pattern options -->
<div class="wfm-pattern-options" id="wfm-pattern-options" style="${(!settings.themeCustom?.pattern?.id || settings.themeCustom?.pattern?.id === 'none') ? 'display:none;' : ''}">
<label>
${t("themePatternColor")}
<input type="color" id="wfm-pattern-color" value="${settings.themeCustom?.pattern?.color || '#ffffff'}">
</label>
<label>
${t("themePatternOpacity")}
<input type="range" id="wfm-pattern-opacity" min="0.02" max="0.5" step="0.02" value="${settings.themeCustom?.pattern?.opacity ?? 0.1}">
<span id="wfm-pattern-opacity-val">${settings.themeCustom?.pattern?.opacity ?? 0.1}</span>
</label>
<label>
${t("themePatternScale")}
<input type="range" id="wfm-pattern-scale" min="8" max="80" step="2" value="${settings.themeCustom?.pattern?.scale ?? 20}">
<span id="wfm-pattern-scale-val">${settings.themeCustom?.pattern?.scale ?? 20}px</span>
</label>
<label>
${t("themePatternGap")}
<input type="range" id="wfm-pattern-gap" min="0" max="60" step="2" value="${settings.themeCustom?.pattern?.gap ?? 0}">
<span id="wfm-pattern-gap-val">${settings.themeCustom?.pattern?.gap ?? 0}px</span>
</label>
</div>
<div id="wfm-svg-name" style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;${settings.themeCustom?.pattern?.id === 'svg' ? '' : 'display:none;'}">
${settings.themeCustom?.pattern?.svgName ? `SVG: ${settings.themeCustom.pattern.svgName}` : ''}
</div>
</div>
<!-- Font -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeFont")}</h4>
<div class="wfm-font-grid" id="wfm-font-grid">
${FONT_OPTIONS.map(f => {
const active = (settings.themeCustom?.fontId || "") === f.id ? "active" : "";
return `<div class="wfm-font-item ${active}" data-font-id="${f.id}" title="${f.css || 'System Default'}">
<span class="wfm-font-preview" style="${f.css ? `font-family:${f.css};` : ''}">${t(f.key)}</span>
</div>`;
}).join("")}
</div>
</div>
<!-- Actions -->
<div class="wfm-customizer-actions">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-customizer-apply">${t("themeCustomizerApply")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-customizer-reset">${t("themeCustomizerReset")}</button>
</div>
</div>
</details>
<!-- Workflow Data Folder -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("workflowsDir")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
<div class="wfm-form-group">
<label>${t("workflowsDirLabel")}</label>
<div style="display:flex;gap:8px;">
@@ -154,11 +490,11 @@ export async function initSettingsTab() {
${t("workflowsDirCurrent")}: <code id="wfm-settings-workflows-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${workflowsDirInfo.current}</code>
</div>
</div>
</div>
</details>
<!-- ComfyUI Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("comfyuiConnection")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
<div class="wfm-form-group">
<label>${t("comfyuiUrl")}</label>
<div style="display:flex;gap:8px;">
@@ -172,11 +508,11 @@ export async function initSettingsTab() {
</small>
</div>
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
</div>
</details>
<!-- Ollama Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("ollamaSettings")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
<div class="wfm-form-group">
<label>${t("ollamaUrl")}</label>
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
@@ -197,11 +533,11 @@ export async function initSettingsTab() {
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
</div>
</div>
</details>
<!-- Default Workflow -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("defaultWorkflow")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("defaultWorkflow")}</summary>
<div class="wfm-form-group">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;" id="wfm-settings-default-wf-name">${settings.defaultWorkflow || t("defaultWorkflowNone")}</span>
@@ -211,11 +547,11 @@ export async function initSettingsTab() {
${t("defaultWorkflowHint")}
</small>
</div>
</div>
</details>
<!-- Eagle Integration (optional) -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("eagleIntegration")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("eagleIntegration")}</summary>
<div class="wfm-form-group">
<label>${t("eagleUrl")}</label>
<div style="display:flex;gap:8px;">
@@ -233,7 +569,7 @@ export async function initSettingsTab() {
${t("eagleAutoSave")}
</label>
</div>
</div>
</details>
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
@@ -244,11 +580,193 @@ export async function initSettingsTab() {
const card = e.target.closest(".wfm-theme-card");
if (!card) return;
const themeId = card.dataset.themeId;
// Clear custom overrides when switching theme base
clearCustomOverrides();
const cur = loadLocalSettings();
delete cur.themeCustom;
cur.theme = themeId;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyTheme(themeId);
saveLocalSettings({ theme: themeId });
// Update active state
document.querySelectorAll(".wfm-theme-card").forEach(c => c.classList.remove("active"));
card.classList.add("active");
// Update color pickers to reflect new theme's computed values
requestAnimationFrame(() => {
document.querySelectorAll("#wfm-color-grid input[type=color]").forEach(inp => {
const v = getComputedStyle(document.documentElement).getPropertyValue(inp.dataset.var).trim();
if (v) inp.value = v.startsWith("#") ? v : rgbToHex(v);
});
});
showToast(t("settingsSaved"), "success");
});
// --- Theme Customizer handlers ---
// State for live editing
let customizerState = {
colors: { ...(settings.themeCustom?.colors || {}) },
pattern: { ...(settings.themeCustom?.pattern || { id: "none", color: "#ffffff", opacity: 0.1, scale: 20 }) },
fontId: settings.themeCustom?.fontId || "",
};
// Toggle panel
document.getElementById("wfm-customizer-toggle")?.addEventListener("click", () => {
const toggle = document.getElementById("wfm-customizer-toggle");
const panel = document.getElementById("wfm-customizer-panel");
toggle.classList.toggle("open");
panel.classList.toggle("open");
});
// Live color change
document.getElementById("wfm-color-grid")?.addEventListener("input", (e) => {
if (e.target.type !== "color") return;
const varName = e.target.dataset.var;
const value = e.target.value;
customizerState.colors[varName] = value;
document.documentElement.style.setProperty(varName, value);
});
// Pattern selection
document.getElementById("wfm-pattern-grid")?.addEventListener("click", (e) => {
const item = e.target.closest(".wfm-pattern-item");
if (!item) return;
const patternId = item.dataset.patternId;
// SVG file upload
if (patternId === "svg") {
document.getElementById("wfm-svg-upload")?.click();
return;
}
customizerState.pattern.id = patternId;
// Update active state
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
item.classList.add("active");
// Show/hide options
const optionsEl = document.getElementById("wfm-pattern-options");
const svgNameEl = document.getElementById("wfm-svg-name");
if (patternId === "none") {
optionsEl.style.display = "none";
svgNameEl.style.display = "none";
} else {
optionsEl.style.display = "";
svgNameEl.style.display = "none";
}
// Live preview
applyCustomOverrides(customizerState);
});
// SVG file upload handler
document.getElementById("wfm-svg-upload")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const svgData = ev.target.result;
customizerState.pattern.id = "svg";
customizerState.pattern.svgData = svgData;
customizerState.pattern.svgName = file.name;
// Update active state
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
document.querySelector('.wfm-pattern-item[data-pattern-id="svg"]')?.classList.add("active");
// Show options + name
document.getElementById("wfm-pattern-options").style.display = "";
const svgNameEl = document.getElementById("wfm-svg-name");
svgNameEl.textContent = `SVG: ${file.name}`;
svgNameEl.style.display = "";
applyCustomOverrides(customizerState);
};
reader.readAsText(file);
e.target.value = "";
});
// Pattern option changes (live)
document.getElementById("wfm-pattern-color")?.addEventListener("input", (e) => {
customizerState.pattern.color = e.target.value;
applyCustomOverrides(customizerState);
});
document.getElementById("wfm-pattern-opacity")?.addEventListener("input", (e) => {
const v = parseFloat(e.target.value);
customizerState.pattern.opacity = v;
document.getElementById("wfm-pattern-opacity-val").textContent = v;
applyCustomOverrides(customizerState);
});
document.getElementById("wfm-pattern-scale")?.addEventListener("input", (e) => {
const v = parseInt(e.target.value);
customizerState.pattern.scale = v;
document.getElementById("wfm-pattern-scale-val").textContent = v + "px";
applyCustomOverrides(customizerState);
});
document.getElementById("wfm-pattern-gap")?.addEventListener("input", (e) => {
const v = parseInt(e.target.value);
customizerState.pattern.gap = v;
document.getElementById("wfm-pattern-gap-val").textContent = v + "px";
applyCustomOverrides(customizerState);
});
// Font selection
document.getElementById("wfm-font-grid")?.addEventListener("click", (e) => {
const item = e.target.closest(".wfm-font-item");
if (!item) return;
const fontId = item.dataset.fontId;
customizerState.fontId = fontId;
// Load web font for preview
const font = FONT_OPTIONS.find(f => f.id === fontId);
if (font?.google) loadGoogleFont(font.google);
applyCustomFont(fontId);
// Update active state
document.querySelectorAll(".wfm-font-item").forEach(i => i.classList.remove("active"));
item.classList.add("active");
});
// Apply & Save
document.getElementById("wfm-customizer-apply")?.addEventListener("click", () => {
// Clean empty color overrides
const cleanColors = {};
for (const [k, v] of Object.entries(customizerState.colors)) {
if (v) cleanColors[k] = v;
}
const custom = {
colors: Object.keys(cleanColors).length ? cleanColors : undefined,
pattern: customizerState.pattern.id !== "none" ? { ...customizerState.pattern } : undefined,
fontId: customizerState.fontId || undefined,
};
saveLocalSettings({ themeCustom: (custom.colors || custom.pattern || custom.fontId) ? custom : undefined });
showToast(t("settingsSaved"), "success");
});
// Reset
document.getElementById("wfm-customizer-reset")?.addEventListener("click", () => {
clearCustomOverrides();
customizerState = {
colors: {},
pattern: { id: "none", color: "#ffffff", opacity: 0.1, scale: 20, gap: 0 },
fontId: "",
};
// Remove themeCustom from localStorage directly (spread merge won't delete keys)
const cur = loadLocalSettings();
delete cur.themeCustom;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyTheme(cur.theme || "");
// Reset UI
requestAnimationFrame(() => {
document.querySelectorAll("#wfm-color-grid input[type=color]").forEach(inp => {
const v = getComputedStyle(document.documentElement).getPropertyValue(inp.dataset.var).trim();
if (v) inp.value = v.startsWith("#") ? v : rgbToHex(v);
});
});
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
document.querySelector('.wfm-pattern-item[data-pattern-id="none"]')?.classList.add("active");
document.getElementById("wfm-pattern-options").style.display = "none";
document.getElementById("wfm-svg-name").style.display = "none";
// Reset font UI
document.querySelectorAll(".wfm-font-item").forEach(i => i.classList.remove("active"));
document.querySelector('.wfm-font-item[data-font-id=""]')?.classList.add("active");
showToast(t("settingsSaved"), "success");
});
+110 -149
View File
@@ -9,6 +9,7 @@ import { comfyWorkflow } from "./comfyui-workflow.js";
import { loadWorkflowIntoEditor } from "./generate-tab.js";
import { t, getSummaryPrompt } from "./i18n.js";
import { highlightJSON } from "./json-highlight.js";
import { openBadgeEditModal } from "./models-tab.js";
// ============================================
// State
@@ -16,26 +17,31 @@ import { highlightJSON } from "./json-highlight.js";
const state = {
workflows: [],
activeModel: "ALL",
activeBadge: "ALL",
searchText: "",
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
selectedWf: null,
groupFilter: "", // empty = all groups
badgeColors: (() => {
try { return JSON.parse(localStorage.getItem("wfm_badge_colors") || "{}"); }
catch { return {}; }
})(),
currentPage: 0,
};
const BADGE_DEFAULT_COLORS = {
"SD1.5": "#e67e22",
SDXL: "#2ecc71",
Flux: "#9b59b6",
Flux2: "#8e44ad",
SD3: "#3498db",
Qwen: "#e74c3c",
"Z-IMAGE": "#1abc9c",
};
function getBadgePalette() {
try { return JSON.parse(localStorage.getItem("wfm_models_badge_palette") || "{}"); }
catch { return {}; }
}
function saveBadgePalette(palette) {
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ============================================
// Open in ComfyUI
@@ -214,15 +220,22 @@ async function getRawWorkflow(filename) {
// Rendering Helpers
// ============================================
function badgeHtml(type) {
const color = state.badgeColors[type] || BADGE_DEFAULT_COLORS[type] || "#0077ff";
return `<span class="wfm-badge wfm-badge-model" style="background:${color}">${type}</span>`;
function badgeHtml(label) {
const palette = getBadgePalette();
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
}
function getAllModelTypes() {
function wfBadgesHtml(wf) {
const badges = wf.metadata?.badges || [];
return badges.map(label => badgeHtml(label)).join("");
}
function getAllBadges() {
const set = new Set();
state.workflows.forEach((wf) =>
(wf.analysis.modelTypes || []).forEach((t) => set.add(t))
(wf.metadata?.badges || []).forEach((b) => set.add(b))
);
return [...set].sort();
}
@@ -231,17 +244,23 @@ function renderModelFilters() {
const container = document.getElementById("wfm-model-filters");
if (!container) return;
const types = getAllModelTypes();
const palette = getBadgePalette();
const badges = Object.keys(palette).sort();
container.innerHTML = `
<button class="wfm-filter-btn ${state.activeModel === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
<button class="wfm-filter-btn ${state.activeModel === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">&#9733;</button>
${types.map((t) => `<button class="wfm-filter-btn ${state.activeModel === t ? "active" : ""}" data-filter="${t}">${t}</button>`).join("")}
<button class="wfm-filter-btn ${state.activeBadge === "ALL" ? "active" : ""}" data-filter="ALL">ALL</button>
<button class="wfm-filter-btn ${state.activeBadge === "FAVORITE" ? "active" : ""}" data-filter="FAVORITE">&#9733;</button>
${badges.map((b) => {
const color = palette[b] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
return `<button class="wfm-filter-btn ${state.activeBadge === b ? "active" : ""}" data-filter="${escapeHtml(b)}"${style}>${escapeHtml(b)}</button>`;
}).join("")}
`;
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.activeModel = btn.dataset.filter;
state.activeBadge = btn.dataset.filter;
state.currentPage = 0;
renderModelFilters();
renderGrid();
});
@@ -250,10 +269,12 @@ function renderModelFilters() {
function filterWorkflows() {
return state.workflows.filter((wf) => {
if (state.activeModel === "FAVORITE") {
if (wf.filename === ".index.json") return false;
if (state.activeBadge === "FAVORITE") {
if (!wf.metadata.favorite) return false;
} else if (state.activeModel !== "ALL") {
if (!(wf.analysis.modelTypes || []).includes(state.activeModel))
} else if (state.activeBadge !== "ALL") {
if (!(wf.metadata?.badges || []).includes(state.activeBadge))
return false;
}
@@ -305,14 +326,13 @@ function renderGrid() {
return;
}
// Thumbnail / Card view
grid.innerHTML = "";
filtered.forEach((wf) => {
const card = document.createElement("div");
card.className = "wfm-card";
card.dataset.filename = wf.filename;
const hasThumb = !!wf.thumbnail;
const badges = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join("");
const badges = wfBadgesHtml(wf);
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
const favStar = wf.metadata.favorite ? "\u2605" : "\u2606";
const favClass = wf.metadata.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
@@ -357,6 +377,7 @@ function renderGrid() {
}
}
function renderTableView(grid, filtered) {
const table = document.createElement("table");
table.className = "wfm-table";
@@ -376,7 +397,7 @@ function renderTableView(grid, filtered) {
const tr = document.createElement("tr");
tr.className = "wfm-table-row";
tr.dataset.filename = wf.filename;
const badges = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join("");
const badges = wfBadgesHtml(wf);
const tags = (wf.metadata.tags || []).map((t) => `<span class="wfm-tag">#${t}</span>`).join("");
const thumbHtml = wf.thumbnail
? `<img src="${wf.thumbnail}" class="wfm-table-thumb" loading="lazy" />`
@@ -442,7 +463,6 @@ async function showSidePanel(wf, cardEl) {
titleEl.textContent = wf.filename.replace(/\.json$/, "");
contentEl.textContent = t("loading");
panel.style.display = "flex";
state.selectedWf = wf;
@@ -486,12 +506,12 @@ 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");
@@ -519,7 +539,7 @@ function sidePanelThumbUpdate(wf) {
nameEl.textContent = wf ? wf.filename.replace(/\.json$/, "") : "";
}
if (metaEl && wf) {
const badges = (wf.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
const badges = wfBadgesHtml(wf);
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
metaEl.innerHTML = `${badges} <span style="margin-left:4px;">${io}</span>`;
} else if (metaEl) {
@@ -577,14 +597,13 @@ function refreshGroupSelects() {
}
// ============================================
// Badge Color Settings
// View Settings Panel
// ============================================
function renderBadgeSettings() {
function renderViewSettings() {
const panel = document.getElementById("wfm-badge-settings-panel");
if (!panel) return;
// === Default View section ===
const currentView = localStorage.getItem("wfm_default_view") || "thumb";
const viewOptions = [
{ value: "thumb", label: t("viewThumbnail") },
@@ -598,57 +617,20 @@ function renderBadgeSettings() {
</label>`
).join("");
// === Badge Colors section ===
const types = getAllModelTypes();
const allKnown = new Set([...Object.keys(BADGE_DEFAULT_COLORS), ...types]);
const rows = [...allKnown].sort().map((type) => {
const color = state.badgeColors[type] || BADGE_DEFAULT_COLORS[type] || "#0077ff";
return `<div class="wfm-badge-color-row" data-type="${type}">
<span class="wfm-badge wfm-badge-model" style="background:${color}">${type}</span>
<input type="color" class="wfm-badge-color-input" value="${color}" data-type="${type}" title="Change color for ${type}" />
</div>`;
}).join("");
panel.innerHTML = `
<div class="wfm-badge-settings-title">${t("defaultView")}</div>
<div class="wfm-settings-radio-group">${viewRadios}</div>
<hr style="border:none;border-top:1px solid var(--wfm-border);margin:8px 0;">
<div class="wfm-badge-settings-title">${t("badgeColors")}</div>
${rows}
<button id="wfm-badge-color-reset" class="wfm-btn wfm-btn-sm" style="margin-top:6px;width:100%;">${t("resetColors")}</button>`;
<div class="wfm-settings-radio-group">${viewRadios}</div>`;
// Default view change
panel.querySelectorAll('input[name="wfm-default-view"]').forEach((radio) => {
radio.addEventListener("change", () => {
localStorage.setItem("wfm_default_view", radio.value);
state.viewMode = radio.value;
// Update active state on view mode buttons
document.querySelectorAll(".wfm-view-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.view === radio.value);
});
renderGrid();
});
});
// Badge color change
panel.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
input.addEventListener("input", (e) => {
const type = e.target.dataset.type;
const color = e.target.value;
state.badgeColors[type] = color;
localStorage.setItem("wfm_badge_colors", JSON.stringify(state.badgeColors));
const badge = e.target.closest(".wfm-badge-color-row")?.querySelector(".wfm-badge");
if (badge) badge.style.background = color;
renderGrid();
});
});
panel.querySelector("#wfm-badge-color-reset")?.addEventListener("click", () => {
state.badgeColors = {};
localStorage.removeItem("wfm_badge_colors");
renderBadgeSettings();
renderGrid();
});
}
// ============================================
@@ -673,10 +655,21 @@ async function saveModalMeta(wf) {
function openDetailModal(wf) {
const meta = wf.metadata;
const types = (wf.analysis.modelTypes || []).map((t) => badgeHtml(t)).join(" ");
const io = `P:${wf.analysis.inputs?.prompts || 0} I:${wf.analysis.inputs?.images || 0} → ${wf.analysis.outputs?.images || 0}`;
const overrideText = (meta.modelTypesOverride || []).join(", ");
const isFav = !!meta.favorite;
const palette = getBadgePalette();
const allBadgeLabels = Object.keys(palette).sort();
const activeBadges = meta.badges || [];
const badgeCheckboxes = allBadgeLabels.length === 0
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
: allBadgeLabels.map((label) => {
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
const checked = activeBadges.includes(label) ? " checked" : "";
return `<label class="wfm-badge-check-label">
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>
</label>`;
}).join("");
const thumbSrc = wf.thumbnail || "";
const html = `
@@ -689,23 +682,9 @@ function openDetailModal(wf) {
<div class="wfm-modal-two-col">
<div class="wfm-modal-left">
<section>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<h4>${t("analysis")}</h4>
<button class="wfm-btn wfm-btn-sm" id="wfm-detail-analyze">${t("analyze")}</button>
</div>
<div id="wfm-detail-analysis" class="wfm-detail-analysis">
<div>${types || `<span style="color:var(--wfm-text-secondary)">${t("noModelDetected")}</span>`}</div>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;">${io}</div>
</div>
<div id="wfm-detail-override-row" style="margin-top:8px;">
<div style="display:flex;gap:4px;align-items:center;">
<span style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">${t("modelCategory")}</span>
<input type="text" class="wfm-input" id="wfm-detail-model-override"
value="${overrideText}"
placeholder="${t("modelOverridePlaceholder")}" style="flex:1;font-size:12px;padding:4px 6px;min-width:0;">
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-detail-override-save">${t("save")}</button>
</div>
</div>
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-detail-badge-manage">&#9881; ${t("badgeManage")}</button></h4>
<div id="wfm-detail-badge-checkboxes" class="wfm-badge-checkboxes">${badgeCheckboxes}</div>
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-detail-badges-save" style="margin-top:6px;">${t("save")}</button>
</section>
<section>
<h4>${t("tags")} <span style="font-weight:normal;font-size:11px;">${t("tagsHint")}</span></h4>
@@ -813,51 +792,40 @@ function openDetailModal(wf) {
document.getElementById(id)?.addEventListener("blur", () => saveModalMeta(wf));
});
// Analyze button
document.getElementById("wfm-detail-analyze")?.addEventListener("click", async () => {
const btn = document.getElementById("wfm-detail-analyze");
btn.disabled = true;
btn.textContent = t("analyzing");
try {
const res = await fetch("/api/wfm/workflows/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: wf.filename }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
wf.analysis = data.analysis;
const cached = state.workflows.find((w) => w.filename === wf.filename);
if (cached) cached.analysis = data.analysis;
// Re-render analysis section
const analysisEl = document.getElementById("wfm-detail-analysis");
if (analysisEl) {
const newTypes = (data.analysis.modelTypes || []).map((mt) => badgeHtml(mt)).join(" ");
const newIo = `P:${data.analysis.inputs?.prompts || 0} I:${data.analysis.inputs?.images || 0} → ${data.analysis.outputs?.images || 0}`;
analysisEl.innerHTML = `<div>${newTypes || `<span style="color:var(--wfm-text-secondary)">${t("noModelDetected")}</span>`}</div>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:4px;">${newIo}</div>`;
}
renderGrid();
showToast(t("analysisComplete"), "success");
} catch (err) {
showToast(t("analyzeError") + ": " + err.message, "error");
} finally {
btn.disabled = false;
btn.textContent = t("analyze");
}
// Badge manage button
document.getElementById("wfm-detail-badge-manage")?.addEventListener("click", () => {
openBadgeEditModal(() => {
// Rebuild checkboxes after palette changes
const palette = getBadgePalette();
const allLabels = Object.keys(palette).sort();
const currentBadges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map(cb => cb.value);
const el = document.getElementById("wfm-detail-badge-checkboxes");
if (!el) return;
el.innerHTML = allLabels.length === 0
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
: allLabels.map((label) => {
const color = palette[label] || "";
const style = color ? ` style="background:${color};color:#fff;"` : "";
const checked = currentBadges.includes(label) ? " checked" : "";
return `<label class="wfm-badge-check-label">
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>
</label>`;
}).join("");
});
});
// Model type override save
document.getElementById("wfm-detail-override-save")?.addEventListener("click", async () => {
const overrideStr = document.getElementById("wfm-detail-model-override")?.value || "";
const modelTypesOverride = overrideStr.split(",").map((t) => t.trim()).filter(Boolean);
// Badge save
document.getElementById("wfm-detail-badges-save")?.addEventListener("click", async () => {
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map(cb => cb.value);
try {
await saveMetadata(wf.filename, { modelTypesOverride });
wf.metadata.modelTypesOverride = modelTypesOverride;
await saveMetadata(wf.filename, { badges });
wf.metadata.badges = badges;
const cached = state.workflows.find((w) => w.filename === wf.filename);
if (cached) cached.metadata.modelTypesOverride = modelTypesOverride;
if (cached) cached.metadata.badges = badges;
renderGrid();
showToast(t("overrideSaved"), "success");
renderModelFilters();
showToast(t("modelsSaved"), "success");
} catch (err) {
showToast(t("saveError") + ": " + err.message, "error");
}
@@ -1070,6 +1038,7 @@ export function initWorkflowTab() {
if (searchInput) {
searchInput.addEventListener("input", () => {
state.searchText = searchInput.value;
state.currentPage = 0;
renderGrid();
});
}
@@ -1097,9 +1066,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", () => {
@@ -1205,6 +1171,7 @@ export function initWorkflowTab() {
// Toolbar: group filter dropdown
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
state.currentPage = 0;
renderGrid();
});
@@ -1238,7 +1205,7 @@ export function initWorkflowTab() {
showToast("Refreshed", "success");
});
// Toolbar: Badge color settings
// Toolbar: ⚙ settings button (view settings panel)
const badgeSettingsBtn = document.getElementById("wfm-badge-settings-btn");
const badgeSettingsPanel = document.getElementById("wfm-badge-settings-panel");
if (badgeSettingsBtn && badgeSettingsPanel) {
@@ -1249,7 +1216,7 @@ export function initWorkflowTab() {
badgeSettingsPanel.style.display = "none";
return;
}
renderBadgeSettings();
renderViewSettings();
badgeSettingsPanel.style.display = "";
});
document.addEventListener("click", (e) => {
@@ -1259,15 +1226,9 @@ export function initWorkflowTab() {
});
}
// Toolbar: Reanalyze All button
document.getElementById("wfm-reanalyze-btn")?.addEventListener("click", async () => {
try {
const result = await reanalyzeAll();
showToast(`Reanalyzed ${result.count ?? 0} workflows`, "success");
await loadWorkflows();
} catch (err) {
showToast("Reanalyze error: " + err.message, "error");
}
// Toolbar: ⚙ Badge button
document.getElementById("wfm-badge-btn")?.addEventListener("click", () => {
openBadgeEditModal(() => renderGrid());
});
// Initial load
+157 -55
View File
@@ -16,6 +16,7 @@
<nav class="wfm-tabs">
<button class="wfm-tab active" data-tab="workflow">Workflow</button>
<button class="wfm-tab" data-tab="nodes">Nodes</button>
<button class="wfm-tab" data-tab="models">Models</button>
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
<button class="wfm-tab" data-tab="prompt">Prompt</button>
<button class="wfm-tab" data-tab="settings">Settings</button>
@@ -49,13 +50,13 @@
<button class="wfm-view-btn" data-view="table" title="Table">&#8801;</button>
</div>
<button id="wfm-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-reanalyze-btn" class="wfm-btn wfm-btn-sm" title="Re-analyze all workflows">Reanalyze All</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;">
Import
<input type="file" id="wfm-import-input" multiple accept=".json,.png" style="display:none">
</label>
<button id="wfm-badge-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">&#9881; Badge</button>
<div class="wfm-badge-settings-wrap">
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">&#9881;</button>
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="View settings">&#9881;</button>
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
</div>
</div>
@@ -65,10 +66,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">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
@@ -163,10 +163,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">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-nodes-side-tab-btn active" data-side-tab="details">Details</button>
@@ -193,6 +192,72 @@
</div>
</section>
<!-- Models Tab -->
<section id="wfm-tab-models" class="wfm-tab-content">
<div class="wfm-toolbar">
<!-- Row 1: Model type sub-tabs + badge filter -->
<div class="wfm-toolbar-row" style="gap:8px;flex-wrap:wrap;align-items:center;">
<div class="wfm-models-type-nav">
<button class="wfm-models-type-btn active" data-model-type="checkpoint">Checkpoint</button>
<button class="wfm-models-type-btn" data-model-type="lora">LoRA</button>
<button class="wfm-models-type-btn" data-model-type="vae">VAE</button>
<button class="wfm-models-type-btn" data-model-type="controlnet">ControlNet</button>
<button class="wfm-models-type-btn" data-model-type="unet">UNET</button>
<button class="wfm-models-type-btn" data-model-type="textencoder">TextEncoder</button>
<button class="wfm-models-type-btn" data-model-type="hypernetwork">Hypernetwork</button>
<button class="wfm-models-type-btn" data-model-type="embedding">Embedding</button>
</div>
<div class="wfm-models-type-divider"></div>
<div id="wfm-models-badge-filter-bar" class="wfm-badge-filter-bar"></div>
</div>
<!-- Row 2: Search / filter toolbar -->
<div id="wfm-models-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
<button class="wfm-filter-btn" id="wfm-models-fav-btn" title="Favorites">&#9733;</button>
<input type="text" id="wfm-models-search" class="wfm-search-input" placeholder="Search models...">
<select id="wfm-models-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
<select id="wfm-models-dir-filter" class="wfm-select wfm-toolbar-select"><option value="">All Folders</option></select>
<select id="wfm-models-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-models-view="card" title="Card">&#9776;</button>
<button class="wfm-view-btn" data-models-view="table" title="Table">&#8801;</button>
</div>
<span id="wfm-models-count" class="wfm-nodes-count"></span>
<button id="wfm-models-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge settings">&#9881; Badge</button>
</div>
</div>
<!-- Grid + Side Panel -->
<div class="wfm-workflow-area">
<div id="wfm-models-grid" class="wfm-grid wfm-view-thumb">
<p class="wfm-placeholder" id="wfm-models-placeholder">Click Refresh to load models...</p>
</div>
<!-- Side Panel -->
<div id="wfm-models-side-panel" class="wfm-side-panel">
<div class="wfm-side-panel-header">
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-models-side-tab-btn active" data-side-tab="info">Info</button>
<button class="wfm-models-side-tab-btn" data-side-tab="group">Group</button>
<button class="wfm-models-side-tab-btn" data-side-tab="civitai">CivitAI</button>
</div>
<!-- Info Tab -->
<div id="wfm-models-side-info" class="wfm-models-side-content active" style="overflow-y:auto;padding:12px;">
</div>
<!-- Group Tab -->
<div id="wfm-models-side-group" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
<!-- CivitAI Tab -->
<div id="wfm-models-side-civitai" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
</div>
</div>
</section>
<!-- GenerateUI Tab -->
<section id="wfm-tab-generate" class="wfm-tab-content">
<!-- Header Row -->
@@ -209,53 +274,57 @@
<!-- Editor Layout -->
<div class="wfm-gen-layout">
<!-- Left Column: Parameter Editor -->
<!-- Left Column: Parameter Tabs -->
<div class="wfm-gen-left">
<!-- Subtab Navigation -->
<!-- Tab Navigation -->
<div class="wfm-gen-subtab-nav">
<button class="wfm-gen-subtab-btn active" data-subtab="prompt">Prompt</button>
<button class="wfm-gen-subtab-btn" data-subtab="image">Image</button>
<button class="wfm-gen-subtab-btn active" data-subtab="input">Input</button>
<button class="wfm-gen-subtab-btn" data-subtab="model">Model</button>
<button class="wfm-gen-subtab-btn" data-subtab="settings">Settings</button>
<button class="wfm-gen-subtab-btn" data-subtab="raw">Raw JSON</button>
</div>
<!-- Subtab: Prompt -->
<div id="wfm-gen-subtab-prompt" class="wfm-gen-subtab-content active">
<div id="wfm-gen-prompt-fields">
<p class="wfm-placeholder">Load a workflow to edit prompts</p>
<!-- Tab: Input — left sub-col: Prompt(top)+Image(bottom), right sub-col: Raw JSON -->
<div id="wfm-gen-subtab-input" class="wfm-gen-subtab-content active">
<div class="wfm-gen-tab-cols">
<div class="wfm-gen-params-col">
<div class="wfm-gen-params-section">
<h3 class="wfm-gen-col-title">Prompt</h3>
<div id="wfm-gen-prompt-fields">
<p class="wfm-placeholder">Load a workflow to edit prompts</p>
</div>
</div>
<div class="wfm-gen-params-section wfm-gen-params-section--top-border">
<h3 class="wfm-gen-col-title">Image</h3>
<div id="wfm-gen-image-fields">
<p class="wfm-placeholder">Load a workflow to set input images</p>
</div>
</div>
</div>
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-input"></div>
</div>
</div>
<!-- Subtab: Image Input -->
<div id="wfm-gen-subtab-image" class="wfm-gen-subtab-content">
<div id="wfm-gen-image-fields">
<p class="wfm-placeholder">Load a workflow to set input images</p>
</div>
</div>
<!-- Subtab: Model -->
<!-- Tab: Model — left sub-col: model selects, right sub-col: Raw JSON -->
<div id="wfm-gen-subtab-model" class="wfm-gen-subtab-content">
<div id="wfm-gen-model-fields">
<p class="wfm-placeholder">Load a workflow to edit models</p>
<div class="wfm-gen-tab-cols">
<div class="wfm-gen-params-col wfm-gen-params-col--scroll">
<div id="wfm-gen-model-fields">
<p class="wfm-placeholder">Load a workflow to edit models</p>
</div>
</div>
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-model"></div>
</div>
</div>
<!-- Subtab: Settings -->
<!-- Tab: Settings — left sub-col: KSampler(top)+LatentImage(bottom), right sub-col: Raw JSON -->
<div id="wfm-gen-subtab-settings" class="wfm-gen-subtab-content">
<div id="wfm-gen-settings-fields">
<p class="wfm-placeholder">Load a workflow to edit settings</p>
</div>
</div>
<!-- Subtab: Raw JSON -->
<div id="wfm-gen-subtab-raw" class="wfm-gen-subtab-content">
<div style="margin-bottom:8px;">
<button id="wfm-gen-apply-raw-btn" class="wfm-btn wfm-btn-sm">Apply JSON</button>
</div>
<div class="wfm-json-container" style="height:500px;">
<pre id="wfm-gen-raw-json-highlight" class="wfm-json-highlight"></pre>
<textarea id="wfm-gen-raw-json" class="wfm-json-editor" spellcheck="false" placeholder="Paste workflow JSON here..."></textarea>
<div class="wfm-gen-tab-cols">
<div class="wfm-gen-params-col wfm-gen-params-col--scroll">
<div id="wfm-gen-settings-fields">
<p class="wfm-placeholder">Load a workflow to edit settings</p>
</div>
</div>
<div class="wfm-gen-rawjson-col" id="wfm-gen-rawjson-col-settings"></div>
</div>
</div>
</div>
@@ -280,7 +349,7 @@
<!-- Seed -->
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:140px;">
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:130px;">
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:auto;">
<option value="random">Random</option>
<option value="fixed">Fixed</option>
@@ -297,6 +366,18 @@
</div>
</div>
</div>
<!-- Raw JSON shared widget (moved into active tab's rawjson-col by JS) -->
<div id="wfm-gen-rawjson-widget" style="display:none;flex-direction:column;height:100%;">
<div class="wfm-gen-rawjson-header">
<span class="wfm-gen-rawjson-label">Raw JSON</span>
<button id="wfm-gen-apply-raw-btn" class="wfm-btn wfm-btn-sm">Apply</button>
</div>
<div class="wfm-json-container wfm-gen-rawjson-editor">
<pre id="wfm-gen-raw-json-highlight" class="wfm-json-highlight"></pre>
<textarea id="wfm-gen-raw-json" class="wfm-json-editor" spellcheck="false" placeholder="Paste workflow JSON here..."></textarea>
</div>
</div>
</section>
<!-- Prompt Tab -->
@@ -430,9 +511,8 @@
<li id="wfm-help-wf-3">Create, rename, delete groups and assign/remove workflows</li>
<li id="wfm-help-wf-4">Import JSON/PNG files via button or drag &amp; drop</li>
<li id="wfm-help-wf-5">Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management</li>
<li id="wfm-help-wf-6">Detail modal: rename, favorite, thumbnail change, analysis, category override, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete</li>
<li id="wfm-help-wf-7">Customize badge colors per category</li>
<li id="wfm-help-wf-8">Batch re-analysis of all workflow I/O (P: prompt nodes, I: image nodes, output: save nodes)</li>
<li id="wfm-help-wf-6">Detail modal: rename, favorite, thumbnail change, badge assignment, tags, memo, AI summary, open in ComfyUI, load in GenerateUI, set as default, delete</li>
<li id="wfm-help-wf-7">Badge management: add, rename, delete badges with custom colors shared with the Models tab (⚙ Badge button)</li>
</ul>
</div>
@@ -441,11 +521,14 @@
<ul>
<li id="wfm-help-gen-1">Connect/disconnect ComfyUI and check connection status</li>
<li id="wfm-help-gen-2">Load workflows from Workflow tab (App format .app.json not supported)</li>
<li id="wfm-help-gen-3">Edit parameters: Prompt, Image (drag &amp; drop), Model (checkpoint/VAE/LoRA/ControlNet with filter), Settings (steps/CFG/sampler/scheduler/resolution), Raw JSON</li>
<li id="wfm-help-gen-4">Seed control: random, fixed, increment, decrement</li>
<li id="wfm-help-gen-5">Generate, progress display, and stop</li>
<li id="wfm-help-gen-6">View results with main image and thumbnail history</li>
<li id="wfm-help-gen-7">Eagle integration: auto-save generated images to Eagle</li>
<li id="wfm-help-gen-3">3-tab layout — Input (Prompt + Image), Model, Settings — each with a Raw JSON column for instant preview and direct editing</li>
<li id="wfm-help-gen-4">Input tab: Prompt (top) and Image drag-and-drop (bottom) in the left column</li>
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter</li>
<li id="wfm-help-gen-6">Settings tab: KSampler (top) and LatentImage (bottom) in the left column</li>
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow</li>
<li id="wfm-help-gen-8">Seed control: random, fixed, increment, decrement</li>
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
</ul>
</div>
@@ -466,11 +549,13 @@
<h3>Settings Tab</h3>
<ul>
<li id="wfm-help-settings-1">UI language and summary language</li>
<li id="wfm-help-settings-2">Workflow data folder path</li>
<li id="wfm-help-settings-3">ComfyUI connection URL and test</li>
<li id="wfm-help-settings-4">Ollama URL, default model, and connection test</li>
<li id="wfm-help-settings-5">Default workflow setting</li>
<li id="wfm-help-settings-6">Eagle integration URL and auto-save toggle</li>
<li id="wfm-help-settings-2">Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)</li>
<li id="wfm-help-settings-3">Workflow data folder path</li>
<li id="wfm-help-settings-4">ComfyUI connection URL and test</li>
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
<li id="wfm-help-settings-6">Default workflow setting</li>
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
<li id="wfm-help-settings-8">All sections are collapsible accordion panels</li>
</ul>
</div>
@@ -486,12 +571,28 @@
</ul>
</div>
<div class="wfm-help-card">
<h3>Models Tab</h3>
<ul>
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching</li>
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
<li id="wfm-help-models-8">GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)</li>
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-sidepanel-title">Workflow Studio Library (ComfyUI Side Panel)</h3>
<ul>
<li id="wfm-help-sidepanel-1">Open from the Workflow Studio Library button in the ComfyUI toolbar</li>
<li id="wfm-help-sidepanel-2">Workflows tab: browse favorite workflows, filter by model type, and filter by group</li>
<li id="wfm-help-sidepanel-3">Nodes tab: browse favorite nodes, node sets, and node groups</li>
<li id="wfm-help-sidepanel-2">Workflows tab: All / ★ Favorites / Groups sub-tabs; ★ shown for favorites in All view</li>
<li id="wfm-help-sidepanel-3">Nodes tab: All / ★ Favorites / Groups / Sets / 📂 Category / 🧩 Package sub-tabs; Category and Package show a dropdown to filter nodes</li>
<li id="wfm-help-sidepanel-11">Models tab: All / ★ Favorites / Groups / By Type sub-tabs</li>
<li id="wfm-help-sidepanel-4">Prompts tab: browse prompt presets with All/Favorites/Categories sub-tabs</li>
<li id="wfm-help-sidepanel-5">Drag &amp; drop workflows onto the canvas to load them</li>
<li id="wfm-help-sidepanel-6">Drag &amp; drop nodes/node sets onto the canvas to place them</li>
@@ -499,6 +600,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>
File diff suppressed because it is too large Load Diff
+43 -44
View File
@@ -605,51 +605,50 @@ app.registerExtension({
};
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);
const applyButtonIcon = (button, className, getIconFn, extraStyles) => {
if (button.querySelector("svg")) return;
button.classList.add(className);
button.innerHTML = getIconFn();
button.style.borderRadius = "4px";
button.style.padding = "6px";
if (extraStyles) Object.assign(button.style, extraStyles);
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
};
requestAnimationFrame(replaceButtonIcon);
const buttonConfigs = [
{ tooltip: BUTTON_TOOLTIP, className: "wfm-top-menu-button", getIcon: getWfmIcon, styles: { backgroundColor: "var(--primary-bg)" } },
{ tooltip: SNAPSHOT_TOOLTIP, className: "wfm-snapshot-button", getIcon: getSnapshotIcon, styles: null },
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
];
const replaceAllIcons = () => {
for (const cfg of buttonConfigs) {
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
applyButtonIcon(btn, cfg.className, cfg.getIcon, cfg.styles);
});
}
};
const waitAndObserve = () => {
const firstBtn = document.querySelector(`button[aria-label="${BUTTON_TOOLTIP}"]`);
if (!firstBtn) {
requestAnimationFrame(waitAndObserve);
return;
}
replaceAllIcons();
const container = firstBtn.closest(".actionbar-container") || firstBtn.parentElement;
if (container) {
new MutationObserver(() => replaceAllIcons()).observe(container, {
childList: true,
subtree: true,
});
}
};
requestAnimationFrame(waitAndObserve);
},
});