Compare commits

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 10:11:29 +09:00
ketle-manandClaude Sonnet 4.6 f25c042c39 v0.2.8: Move data dir to user/default/Workflow-Studio, add export/import
- py/config.py: DATA_DIR now resolves to ComfyUI/user/default/Workflow-Studio/
  with fallback to data/ when user/default/ does not exist
- py/routes/settings_routes.py: add GET /api/wfm/settings/export and
  POST /api/wfm/settings/import endpoints for full data bundle backup
- static/js/settings-tab.js: add Data Management section (export/import UI)
- static/js/i18n.js: add 8 new keys in EN/JA/ZH
- templates/index.html, static/js/app.js: add helpSettings8/9 to Help tab
- README.md, DEVLOG.md: updated for v0.2.8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:11:40 +09:00
ketle-manandClaude Sonnet 4.6 1c9ec7ac19 Fix: Gallery tab auto-selects root folder on load
Previously the gallery showed "Select a folder to browse images."
until the user manually clicked a folder. Now the root folder is
automatically selected when the folder tree loads.

Also update docs/screenshot_gallery.png and screenshot_settings.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:09:12 +09:00
ketle-manandClaude Sonnet 4.6 817b76ed5d Merge feature/gallery-tab into main (v0.2.7)
Gallery tab, Group UI unification across all tabs, Settings 2-column layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:57:23 +09:00
ketle-manandClaude Sonnet 4.6 34c6e8d49e v0.2.7: Gallery tab, Group UI unification, Settings 2-column layout
- Gallery tab: image browser with Thumbnail/Table views, multi-select,
  server-side group/tag/favorite filtering, group rename support,
  os.scandir() + mtime folder cache for 6,000+ image libraries
- Group UI unified across Workflow, Nodes, Gallery tabs to match Models
  panel pattern (Current Groups / Add to Group / Create / Manage with Rename)
- Settings 2-column layout: Theme panel fixed on right (50% width, sticky);
  all other settings flow continuously on left without visual gap
- Gallery output directory configurable from Settings tab
- Help tab: Gallery tab documentation card added
- README and DEVLOG updated for v0.2.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:57:16 +09:00
ketle-manandClaude Sonnet 4.6 f9f83ed9b2 Settings: 2-column layout with Theme panel fixed on the right
- Left column (480px): Language, Workflow folder, Gallery output, ComfyUI,
  Ollama, Default workflow, Eagle, Save button
- Right column (flex:1, sticky top): Theme selector + customizer only,
  max-height with overflow-y:auto so it stays visible while scrolling left col
- Add .wfm-settings-layout, .wfm-settings-left-col, .wfm-settings-right-col
  CSS classes; remove max-width:700px constraint from container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:56:29 +09:00
ketle-manandClaude Sonnet 4.6 a6100add6c Help: add Gallery tab documentation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:52:04 +09:00
ketle-manandClaude Sonnet 4.6 5108f710a6 Unify group UI labels across Workflow, Nodes, Gallery tabs
- Workflow Groups tab: remove redundant "Groups" label and workflow name
  (already shown in side panel header); start directly with Current Groups
- Nodes Groups tab: rename "Groups" section header to "Current Groups"
- Gallery Group tab button: rename "Group" → "Groups"
- Gallery section titles: remove text-transform:uppercase + letter-spacing,
  match Models style (13px font-weight:600, title-case labels)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:49:25 +09:00
ketle-manandClaude Sonnet 4.6 6756708f34 Unify group UI to Models standard across Nodes and Workflow tabs
Nodes (renderSideGroups):
- Replace "Remove from Group" text buttons with × icon buttons
- Rename "Create Group" button to "Create", create also assigns node to new group
- Add "Manage Groups" section with Rename (✎) and Delete (×) buttons
- Use el.querySelector() scope to avoid global ID conflicts on re-render

Workflow (renderSideGroup — new, replaces sidePanelGroupUpdate + refreshGroupSelects):
- Rewrite Group tab as fully dynamic JS-rendered panel matching Models layout
- Sections: Current Groups (× remove), Add to Group, Create New Group, Manage Groups
- Create also assigns current workflow to new group
- _refreshGroupFilter() handles toolbar filter select updates separately
- Remove all static HTML from Group tab in index.html (JS-rendered container only)
- Remove old event listeners (wfm-group-add-btn, assign-btn, remove-btn, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:13:40 +09:00
ketle-manandClaude Sonnet 4.6 e0cc44e56d Gallery: unify group UI to Models standard + performance improvements
Backend:
- gallery_metadata.py: add rename_group() + get_group_member_set() for fast server-side filtering
- gallery_service.py: replace stat()/iterdir() with os.scandir() (single syscall for size+mtime),
  add folder-level in-memory cache (invalidates on folder mtime change or 30s TTL),
  add server-side group_filter to list_images() using set lookup (eliminates full transfer on group filter)
- gallery_routes.py: add PUT /wfm/gallery/groups/{name} rename endpoint,
  pass group_filter param to list_images()

Frontend:
- gallery-tab.js: group filter now server-side (no more full JSON transfer on group switch),
  replace static group panel with renderDetailGroup() — Models-style dynamic UI:
  Current Groups (×remove), Add to Group (assign select), Create Group (creates+assigns),
  Manage Groups (rename via prompt + delete), add API.groupRename, add escapeHtml()
- templates/index.html: Gallery Group tab replaced with JS-rendered container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:55:07 +09:00
ketle-manandClaude Sonnet 4.6 3ce51dd5f6 Gallery: remove tree toggle, add group filter, multi-select with bulk operations
- Remove folder tree collapse toggle (panel is always visible)
- Remove output path display from tree panel
- Add group filter select in toolbar (client-side filtering)
- Add multi-select: Ctrl+click on thumbnails and table rows (amber highlight)
- Add bulk operation bar: Deselect All, Add to Group, Favorite All, Unfavorite All
- Move Fav column to leftmost position in table view (matches other tabs)
- Extract toggleFavoriteInPlace() to avoid full re-render on favorite toggle
- Remove CSS transition from tree panel (performance improvement)
- renderGroupSelect() now also populates bulk group select and group filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:08:04 +09:00
ketle-manandClaude Sonnet 4.6 43d7e03d72 Security: fix path traversal, PNG chunk OOM, JPEG read limit in gallery
- GalleryService._check_path_allowed(): resolve() + relative_to(allowed_root)
  blocks access to any path outside the configured output directory
- allowed_root set on first list_folder_tree() call; updated on settings change
  via settings_routes -> gallery_service.update_output_root()
- _read_png_metadata: skip chunks > 32MB to prevent OOM on crafted PNGs
- _read_jpeg_metadata: cap file read at 64MB; stop after first COM segment
- serve_image / get_image_metadata / list_images / extract_workflow:
  all resolve() paths and check against allowed_root before opening

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:01:49 +09:00
ketle-manandClaude Sonnet 4.6 370bc6aa6f Gallery: move tree toggle to fixed left bar, workflow-only metadata view
- Folder tree toggle button moved to independent 20px bar at far left
  always visible regardless of tree panel state (panel hides/shows entirely)
- Metadata tab now shows workflow JSON only in full-height <pre> block
  with toolbar (status label + Copy Workflow button) at top
- Removed old metadata key/value row rendering, replaced with renderWorkflowJson()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:49:39 +09:00
ketle-manandClaude Sonnet 4.6 ae85378e34 Fix gallery: tree collapse button visibility, split Metadata into separate tab
- Tree panel: overflow:visible on panel, ov:hidden only on tree scroll area
  so the toggle button remains clickable at 32px collapsed width
- Detail panel: Metadata moved from Info tab into its own 'Metadata' tab
  (Info / Metadata / Group — 3 tabs)
- JS toggleTreePanel sets explicit max-width to prevent layout bleed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:36:27 +09:00
ketle-manandClaude Sonnet 4.6 8bdb487a83 Fix Gallery tab: settings output path, tree toggle, card favorites, remove thumb filename
1. Output folder setting moved to Settings tab (Workflow Data Folder section)
   - New API: GET/POST /api/wfm/settings/output-dir
   - Auto-detects ComfyUI default output via folder_paths
   - gallery-tab.js reads from settings API on init
   - Settings change dispatches wfm-output-dir-changed event to live-reload tree

2. Fix folder tree collapse/expand toggle
   - Replaced CSS .collapsed class with direct JS style control
   - Expand now correctly restores tree, output label, and title

3. Favorite toggle moved to thumbnail card (hover button, top-right)
   - Removed favorite button from detail panel Info tab
   - Added ★/☆ toggle button on both thumb cards and table rows
   - Detail panel no longer has updateFavBtn / toggleFavorite logic

4. Remove filename text from thumbnail cards (shown in tooltip on hover)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:15:26 +09:00
ketle-manandClaude Sonnet 4.6 cc9a961a50 Add Gallery tab: 3-column image browser for ComfyUI output folder (Phase 1)
- New Gallery tab with folder tree / image grid / detail panel layout
- Backend: gallery_service.py (folder tree, image list, PNG metadata extraction, groups)
- Backend: gallery_metadata.py (persistent metadata store for tags, memo, favorites, groups)
- Backend: gallery_routes.py (REST API endpoints)
- Frontend: gallery-tab.js (folder tree, thumbnail/table view, lightbox, tag/memo/group management)
- Frontend: gallery-tab.css (3-column layout, thumb cards, lightbox, detail panel)
- i18n: EN/JA/ZH keys for Gallery tab
- PNG embedded workflow extraction with clipboard copy support

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

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

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

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

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

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

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

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

Addresses review feedback from ComfyUI-Manager PR #2706.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 08:50:39 +09:00
54 changed files with 18763 additions and 793 deletions
+187
View File
@@ -0,0 +1,187 @@
# ComfyUI-Workflow-Studio 開発ガイドライン
## プロジェクト概要
ComfyUI用ワークフロー管理プラグイン。aiohttp (ComfyUI PromptServer) 上でSPAを提供する。
## アーキテクチャ
- エントリポイント: `__init__.py` → `py/wfm.py` (WorkflowStudio.add_routes)
- バックエンド: `py/routes/` (aiohttp ルート), `py/services/` (ビジネスロジック)
- フロントエンド: `web/comfyui/` (ComfyUI拡張), `static/` (SPA), `templates/` (Jinja2)
- データ: `data/` (settings.json, metadata.json, node_metadata.json)
- i18n: 英語 / 日本語 / 中国語
---
## ComfyUI カスタムノード開発ルール
### NODE_CLASS_MAPPINGS キーは永久に変更不可
`NODE_CLASS_MAPPINGS` のキー文字列は、保存済みワークフローとノードを紐付ける公開識別子。
一度公開したキーを変更すると、ユーザーの既存ワークフローが壊れる。
将来ノードを追加する場合は、キー名を慎重に決定すること。
- 名前空間衝突を防ぐため、パック名をプレフィックスに付ける (例: `"WFS_MyNode"`)
- リネーム時は旧キーをエイリアスとして残す:
```python
NODE_CLASS_MAPPINGS = {
"WFS_NewName": MyNodeClass, # 新名
"WFS_OldName": MyNodeClass, # エイリアス(後方互換)
}
```
### ワークフローJSON: ウィジェット値は位置インデックスで格納される
ワークフローJSONはウィジェットの値を「名前」ではなく「位置インデックス」で保存する。
入力の追加・並べ替えは、保存済みワークフローの値を別パラメータにずらす破壊的変更になる。
ワークフロー解析・編集コード (`workflow_analyzer.py`, `workflow_service.py`) を変更する際は、
このインデックスマッピングを必ず考慮すること。
**INPUT_TYPES変更時のチェックリスト:**
- ウィジェット追加: 末尾に追加が最も安全。中間挿入は全保存済みJSONの更新が必要
- ウィジェット削除: 後続のインデックスが全てずれる。全保存済みJSONの`widgets_values`を修正
- 並べ替え: `widgets_values`の順序も合わせて更新
- `INPUT_TYPES`を変更するコミットは、影響を受けるワークフローJSONも同時にコミットする
### ワークフローJSON整合性ルール
- `last_node_id` は最大ノードID以上であること(低いと重複IDが発生し無言で壊れる)
- `last_link_id` は最大リンクID以上であること
- リンクは3箇所で整合する必要がある: `links[]`配列、ソースノードの`outputs[slot].links`、ターゲットノードの`inputs[slot].link`
- いずれかが不一致だと接続が無言で消える
### IS_CHANGED によるキャッシュ制御
ComfyUIはキャッシュ付きデータフローグラフ実行エンジン。入力が変わらないと判断したノードはスキップされる。
- 毎回実行が必要なノード: `IS_CHANGED` で `float("NaN")` を返す
- 本番用: 入力に基づく決定論的なハッシュを返す
- 注意: `IS_CHANGED` はスロット接続された入力に `None` を受け取ることがある。`None`チェックを必ず入れる
### VALIDATE_INPUTS の落とし穴
スロット接続された入力は検証時に `None` として渡される(実行時には実値が来る)。
```python
@classmethod
def VALIDATE_INPUTS(cls, text=None, **kwargs):
if text is None:
return True # スロット接続 — 実行時に実値が来る
if isinstance(text, str) and text.strip() == "":
return "Text input cannot be empty"
return True
```
### ノードの安全な分離読み込み
1つの壊れた依存が全ノードを道連れにしないよう、個別にtry/exceptで読み込む:
```python
NODE_CLASS_MAPPINGS = {}
for name, (mod_path, cls_name) in _NODE_MODULES.items():
try:
mod = importlib.import_module(mod_path, package=__name__)
NODE_CLASS_MAPPINGS[name] = getattr(mod, cls_name)
except Exception as e:
print(f"[WARNING] Failed to load '{name}': {e}")
```
### Hidden Inputs (UNIQUE_ID, PROMPT, EXTRA_PNGINFO)
```python
"hidden": {
"unique_id": "UNIQUE_ID", # キャッシュの名前空間キーに使用
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO", # PNG出力にメタデータ埋め込み
}
```
`UNIQUE_ID`を使わずモジュールレベルキャッシュを共有すると、同一ノードの複数インスタンスが干渉する。
### Lazy Inputs & 条件付き実行
不要な上流実行を防ぐ:
```python
# INPUT_TYPESで宣言
"optional": {"heavy_data": ("IMAGE", {"lazy": True})}
# 必要な入力だけリクエスト
def check_lazy_status(self, **kwargs):
needed = []
if self.needs_heavy_data:
needed.append("heavy_data")
return needed # 空リスト = 上流実行をスキップ
```
---
## 開発時の注意事項
### Python変更後は必ずComfyUIを再起動
ComfyUIは起動時に `custom_nodes/` を一度だけインポートする。
Pythonファイルの変更はComfyUIプロセスの完全再起動まで反映されない。
### __pycache__ のクリア
ディレクトリ間のコピーやブランチ切り替え後、古いバイトコードが残ることがある。
動作がおかしい場合は `__pycache__/` を削除してから再起動する。
### 依存パッケージのインストール先
Windows Portable版のComfyUIは `python_embeded/` 内の組み込みPythonを使用する。
システムのPythonにインストールしても認識されない。
```
python_embeded/python.exe -m pip install package_name
```
torch関連パッケージはCUDA index URLを指定:
```
python_embeded/python.exe -m pip install torchaudio --index-url https://download.pytorch.org/whl/cu130
```
インストール後は必ず `torch.cuda.is_available()` で確認する。
### デプロイ
ソースリポジトリと `custom_nodes/` のランタイムディレクトリは分離する。
`custom_nodes/` 内で直接編集せず、同期スクリプトでパッケージ全体をデプロイする。
部分的な同期は原因不明のエラーを引き起こす。
デプロイ後はハッシュ比較でファイル同一性を検証する。
### prestartup_script.py
環境変数の設定は `__init__.py` では遅い。`prestartup_script.py` はノードインポート前に実行される唯一の場所:
```python
# custom_nodes/ComfyUI-Workflow-Studio/prestartup_script.py
import os
os.environ["HF_HOME"] = "D:\\hf_cache"
```
### 依存パッケージの競合防止
- 依存はゆるくピン留め: `transformers>=4.40,<6.0` (厳密な `==` は避ける)
- torchは `requirements.txt` にピン留めしない(ComfyUI本体が管理)
- バージョン依存のインポートは `try/except` でフォールバック
### テスト戦略 (ComfyUI不要のテスト)
1. `ast.parse()` で構文チェック (0.1秒)
2. ビジネスロジックを `execute()` から分離し pytest でテスト (1-2秒)
3. ComfyUI統合テストは最終確認のみ (30-60秒)
### ファイルパスの扱い
出力・一時ファイルは `folder_paths.get_output_directory()` / `folder_paths.get_temp_directory()` を使用。
相対パスはComfyUIの起動方法によって解決先が変わる。
### Windows固有の注意
- PowerShellのデフォルトエンコーディングはUTF-8を壊す。ファイル書き込みはPython経由で行う
- Windows MAX_PATH (260文字) 制限: 浅いパス (`C:\ComfyUI`) にインストールするか、LongPathsEnabled レジストリを有効化
- HuggingFaceのシンボリックリンクは開発者モードなしだと無言でファイルが複製される (`HF_HOME`を短いパスに設定)
### バウンドキャッシュ
モジュールレベルのキャッシュは `OrderedDict` + 最大サイズ制限で実装する。
無制限キャッシュは長時間セッションでメモリを食い潰す。
---
## サイレント障害の診断
| 症状 | 原因 | 対処 |
|------|------|------|
| 編集後も動作が変わらない | 古いコード | ComfyUI再起動 + `__pycache__/` 削除 |
| 変更したのにノードが実行されない | キャッシュ | `IS_CHANGED` で `float("NaN")` を返す |
| ワークフロー読込で値がおかしい | UIマッピングずれ | ウィジェット位置が保存JSONと一致するか確認 |
| ノードが赤い "missing" 表示 | タイプ名不一致 | `NODE_CLASS_MAPPINGS` のキーと大文字小文字を確認 |
| 接続線が消える | リンクID不整合 | links[], outputs[].links, inputs[].link の3箇所を検証 |
| 重複ノード動作 | `last_node_id` が低すぎる | 最大ノードID + 1 に設定 |
| CUDAが使えなくなった | CPU版wheelのインストール | `torch.cuda.is_available()` 確認、CUDA index URLで再インストール |
| ヘッドレスモードで失敗 | UI前提のコード | `{"ui": {...}, "result": (...)}` で分離 |
---
## コミット前チェックリスト
1. 全ファイルを `custom_nodes/` に同期(部分同期しない)
2. 両方の `__pycache__/` をクリア
3. 全 .py ファイルのハッシュ比較で同一性を確認
4. `ast.parse()` で全 .py ファイルの構文チェック
5. `INPUT_TYPES` 変更時は `widgets_values` も同時更新
6. 削除した依存への参照が残っていないか grep で確認
7. ComfyUI完全再起動 → ワークフロー読み込み → エンドツーエンド実行確認
+1203
View File
File diff suppressed because it is too large Load Diff
+212 -17
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.1.3-green)
![Version](https://img.shields.io/badge/version-0.3.2-green)
## Screenshots
@@ -17,9 +17,17 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| Help & Support Tab | ComfyUI Integration |
| Nodes Tab | Models Tab |
|:---:|:---:|
| ![Help](docs/screenshot_help.png) | ![ComfyUI Top Bar](docs/screenshot_comfyui_topbar.png) |
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| Gallery Tab | Help & Support Tab |
|:---:|:---:|
| ![Gallery](docs/screenshot_gallery.png) | ![Help](docs/screenshot_help.png) |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
| ![Workflow Studio Library](docs/screenshot_wf_node_library.png) | ![ComfyUI Top Bar](docs/screenshot_comfyui_topbar.png) |
---
@@ -28,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)
@@ -42,29 +50,96 @@ 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.3.2)
- **3-tab layout** — Input / Model / Settings tabs, each with a Raw JSON column on the right for instant preview and direct editing
- **Input tab** — Prompt (top) and Image drag-and-drop (bottom) in the left column; Raw JSON (540px) in the right column
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, UNET, TextEncoder selectors with filter; Raw JSON on the right
- **Settings tab** — KSampler (top) and LatentImage (bottom) in the left column; Raw JSON on the right
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow
- **One-click generation** — queue prompts to ComfyUI without leaving the studio
- **Seed control** — randomize, lock, or manually set seeds
- **Raw JSON editor** — view and edit the API-format JSON with syntax highlighting
- **Seed control** — randomize, lock, or manually set seeds; seed input and mode selector stacked vertically for readability
- **Checkpoint Batch** — enable via checkbox in the right panel to sequentially generate with every checkpoint model; filter by include folders (comma-separated subfolder names, empty = all) and/or exclude folders; Stop button aborts after the current generation completes; amber progress bar tracks per-model progress
- **UI-to-API conversion** — automatic conversion supporting subgraphs (nested workflows), COMBO types, and display-only node exclusion
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
### Prompt Tab
- **Split-panel layout** — AI assistant and presets displayed side-by-side for simultaneous use
- **3-column layout** — AI Assistant, Presets editor, and Preset Manager displayed side-by-side
- **AI chat assistant** — powered by [Ollama](https://ollama.com/), generate and refine prompts interactively
- **Image attachment** — attach reference images for vision-capable models
- **Translation** — JA/EN/ZH translation buttons for multilingual prompt creation
- **Prompt presets** — save/load reusable prompt templates (positive & negative)
- **Apply to GenerateUI** — send prompts directly to the generation interface
- **Prompt presets** — save/load reusable prompt templates (positive & negative) with category support
- **Preset Manager** — browse all presets, favorites, and group-based filtering with search
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
- **Clipboard copy** — copy positive/negative prompts individually (PP Copy / NP Copy)
- **GenUI Set** — apply preset prompts directly to the GenerateUI interface
### Settings Tab
- **2-column layout** — left column for all settings; right column shows the Theme panel fixed in place (sticky)
- **Collapsible sections** — all settings organized in accordion panels for a clean layout
- **Theme selection** — 13 built-in themes with visual swatch preview (Dark, Pop, Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro Pixel, Pastel, Brutalism, Earthy, Material, Monotone, Corporate)
- **Theme customization** — override colors (background, surface, text, primary, accent), add background patterns (horizontal/vertical/diagonal stripes, polka dot, checkerboard, custom SVG tiling with color/opacity/scale/gap controls), and select from 16 fonts including Japanese display fonts (Google Fonts)
- **Workflows directory** — configure which folder to scan for workflows
- **Gallery output directory** — configure which output folder the Gallery tab scans for images
- **Eagle connection** — set Eagle API endpoint for auto-save
- **Ollama connection** — configure Ollama server URL
- **Badge display** — toggle metadata badges on workflow cards
- **Default workflow** — set a workflow to auto-load on startup
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling)
- **Language** — English / Japanese / Chinese
### Gallery Tab (v0.3.1)
- **Image browser** — browse ComfyUI output images (Thumbnail / Table views) with server-side scanning optimized for 6,000+ image libraries
- **Thumbnail / Table views** — switch view modes; Favorites column shown leftmost in Table view
- **Folder management** — create subfolders ("+ New") or delete the selected folder with all contents ("Del") from the folder tree header
- **File operations** — move or delete individual images from the detail panel's Info tab; bulk Move To... and Delete from the multi-select bar
- **Multi-select** — Ctrl+click to select multiple images; Bulk Bar appears for batch operations (group, favorite, move, delete)
- **Server-side filtering** — filter by group, favorites, or tags with fast server-side set lookup (no full rescan)
- **Group management** — create, rename, delete groups and assign/remove images using the same 4-section panel as Models tab
- **Favorites** — star images inline without reopening the detail panel
- **Detail panel** — view filename, path, tags, groups, and metadata in a slide-out panel
- **Workflow viewer** — Metadata tab displays workflow JSON from PNG embedded data (`prompt` / `workflow` keys) or from workflow saved by the Generate UI tab
- **Workflow auto-save** — images generated from the Generate UI tab have their workflow automatically saved to gallery metadata
- **Output folder configurable** — set the scanned output folder from Settings tab
- **Performance** — folder-level mtime cache (30s TTL) for fast incremental refresh; tree expansion state preserved across folder operations
### Nodes Tab (v0.1.7)
- **Node Browser** — browse all installed ComfyUI nodes from `/object_info` API with Card/Table views
- **Search & Filter** — full-text search, filter by category, package, tags, groups, and favorites
- **Package badges** — color-coded badges generated from package names
- **Node detail panel** — view I/O specifications, edit tags, manage groups
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
### Models Tab (v0.2.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; groups are scoped per model type (checkpoint groups only appear in the Checkpoint tab)
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
- **Enable / Disable models** — hide models from ComfyUI by renaming the file extension (`.disabled` suffix); toggle per card (⏸ button), per group (Enable All / Disable All), or filter by status (All / Enabled / Disabled)
- **Multi-select & bulk operations** — enter selection mode to check multiple models; bulk action bar supports add/remove from groups and permanent file deletion (model file + preview images + sidecar files such as `.json` / `.info`)
### Workflow Studio Library (ComfyUI Side Panel) (v0.2.5)
- **Workflows tab** — browse favorite workflows (All / ★ Favorites / Groups / By Badge sub-tabs), ★ star shown for favorites in All view
- **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
- **Drag & drop prompts** — drag a preset onto the canvas to create a WFS_PromptText node with positive/negative prompts
- **Copy prompts** — copy individual positive (P) or negative (N) prompts from sidebar items
- **Double-click** — load workflows or place nodes without dragging
- **Search** — search within each sub-tab to quickly find items
- **⚙ 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
- **Tips** — quick tips for drag & drop import, favorites, and default workflow
@@ -82,7 +157,7 @@ Search for **Workflow Studio** in ComfyUI Manager and install.
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/your-username/ComfyUI-Workflow-Studio.git
git clone https://github.com/ketle-man/ComfyUI-Workflow-Studio.git
```
Restart ComfyUI after installation.
@@ -140,6 +215,114 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### v0.3.2
- **Checkpoint Batch** — new panel in the GenerateUI right column (below Seed, above results); enable via checkbox to auto-generate once per checkpoint model; filter included/excluded subfolders by comma-separated names (empty = all checkpoints); real-time preview of matched model count; amber progress bar shows current model name and index; Stop button aborts the loop after the current generation completes; failed models are counted and reported in the summary toast
- **Seed layout fix** — seed value input and mode selector (Random / Fixed / Increment / Decrement) now stacked vertically so neither is clipped in narrow panels
### v0.3.1
- **Folder operations** — create subfolders and delete folders (with all contents) directly from the folder tree header buttons
- **File operations** — move or delete individual images from the detail panel; bulk move and bulk delete from the multi-select bar
- **Tree state preserved** — folder expansion state is maintained after create/delete/move/refresh operations
- **Workflow viewer improved** — Metadata tab now reads both `prompt` (API format, the default for most ComfyUI-generated images) and `workflow` (UI format) keys from PNG metadata, covering virtually all ComfyUI output
- **Workflow auto-save** — after each generation in the Generate UI tab, the current workflow is automatically saved to gallery metadata for the generated images; shown in the Metadata tab even if PNG embedding is absent
### v0.3.0
- **Cross-type group filter** — the group filter dropdown in the Models tab now shows groups from all model types simultaneously, each prefixed with a type label (`[Checkpoint]`, `[LoRA]`, etc.); selecting a group automatically switches to the corresponding model type
- **Sidebar group display fixed** — the ComfyUI sidebar "Model Groups" view now correctly renders per-type groups (introduced in v0.2.9) with a type label on each group header; fixes a crash where `.filter is not a function` was thrown on the new nested group structure
### v0.2.9
- **Model Enable/Disable** — hide models from ComfyUI by renaming the file extension to `.disabled`; toggle per model (⏸ button on each card/row), per group (Enable All / Disable All buttons in the Group panel), or filter the grid by status (All / Enabled only / Disabled only). Changes take effect after ComfyUI refresh.
- **Per-type model groups** — groups are now scoped to each model type; checkpoint groups only appear in the Checkpoint tab, LoRA groups only in the LoRA tab, etc. Existing flat groups are automatically migrated.
- **Multi-select mode** — click the Select button in the toolbar to enter selection mode; check individual models across Thumbnail / Card / Table views
- **Bulk group operations** — with models selected, add or remove them from any group in one click, or create a new group and add them simultaneously
- **Bulk file delete** — permanently delete selected model files along with all associated preview images and sidecar files (`.json`, `.civitai.info`, `.info`); requires confirmation before deleting
- **Bug fix** — models in subdirectories could not be disabled due to a path-doubling error; fixed by computing the rename target from `path.name` instead of `model_name`
- **Bug fix** — Hypernetwork tab showed `C`, `O`, `M`, `B` as fake model items; caused by the new ComfyUI API format returning `"COMBO"` as the first element; fixed in `comfyui-client.js`
### v0.2.8
- **Data storage moved** — plugin data (settings, metadata, prompts, etc.) is now stored in `ComfyUI/user/default/Workflow-Studio/` instead of `custom_nodes/ComfyUI-Workflow-Studio/data/`; falls back to the old `data/` directory if `user/default/` does not exist
- **Data Management section** — new Settings tab section to export all plugin data to a single JSON file and import it back; useful for migration, backup, and reinstallation
### v0.2.7
- **Gallery tab** — new image browser for ComfyUI output folder; Thumbnail/Table views, multi-select with Bulk Bar, server-side group/tag/favorite filtering, group management (rename support), performance-optimized with + mtime folder cache
- **Group UI unified** — Workflow, Nodes, and Gallery group panels rebuilt to match the Models tab 4-section pattern (Current Groups / Add to Group / Create New Group / Manage Groups); Rename support added to Workflow and Nodes tabs
- **Settings 2-column layout** — Theme panel fixed on the right (50% width, sticky); all other settings flow continuously on the left with no visual gap
- **Gallery output directory setting** — configure the output folder scanned by Gallery tab from Settings
- **Help tab** — Gallery tab description card added
### v0.2.5
- **Sidebar Category/Package sub-tabs** — added 📂 Category and 🧩 Package sub-tabs to the Nodes tab in Workflow Studio Library; each shows a dropdown to filter nodes by category or custom node package (reads `/object_info` for accurate package detection)
- **Sidebar theme settings** — added ⚙ button in the panel header to customize background, sub-header background, text, border, and secondary text colors with live preview; settings saved to localStorage
- **Sidebar TextEncoder (By Type)** — fixed TextEncoder not appearing in Models → By Type; now uses `DualCLIPLoader` → `CLIPLoader` fallback to retrieve `clip_name1` list
- **Hide `.index.json`** — excluded `.index.json` from both the Workflow tab and the Workflow Studio Library side panel
### v0.2.4
- **GenerateUI tab redesign** — reorganized from 5 tabs (Prompt/Image/Model/Settings/Raw JSON) to 3 tabs (Input/Model/Settings); each tab shows a Raw JSON column (540px) for instant JSON preview and direct editing from any tab
- **Input tab** — Prompt and Image stacked vertically in the left column
- **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
- **Preset Manager** — 3-column layout in Prompt tab: AI Assistant | Presets editor | Preset Manager (All / Favorites / Groups)
- **Group management** — create groups, assign/remove presets, delete groups from the Presets panel
- **GenUI Set** — renamed "Apply" button to clarify its purpose (applies presets to GenerateUI)
- **Sidebar P/N copy buttons** — copy positive or negative prompts individually from sidebar items
- **Panel renamed** — "WF & Node Library" renamed to "Workflow Studio Library"
- **Backend API for presets** — presets migrated from localStorage to server-side API with one-time migration
- **Help tab updated** — Prompt Tab and Workflow Studio Library sections reflect new features
### v0.1.9
- **Side panel 2-row sub-tabs** — Workflows: row 1 (Workflows / Favorites / Groups), row 2 (Model Type); Nodes: row 1 (Nodes / Favorites / Groups), row 2 (Sets)
- **Save dialog** — canvas snapshot now shows a filename edit dialog instead of auto-naming
- **API/App format badge** — workflow items in side panel show red (API) or orange (APP) badge
- **Group auto-cleanup** — deleted workflows are automatically removed from groups on refresh
### v0.1.8
- **WF & Node Library side panel** — renamed from "Node Library", added two top-level tabs (Workflows / Nodes) accessible from ComfyUI top bar
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group with collapsible sections
- **Workflow drag & drop** — drag workflows from the side panel onto the canvas to load them instantly
- **Help tab update** — added Nodes Tab and WF & Node Library sections to the feature list
### v0.1.7
- **Nodes tab** — browse, search, and filter all installed ComfyUI nodes with Card/Table views, pagination (50 nodes/page), package color badges, and node detail side panel with I/O specs
- **Node Sets** — save selected nodes + connections from ComfyUI canvas as reusable sets via right-click context menu
- **Node Library side panel** — accessible from ComfyUI top bar with Favorites, Sets, and Groups tabs, drag & drop placement
- **3 top bar buttons** — Workflow Studio, Canvas Snapshot, and Node Library buttons in ComfyUI's action bar
### v0.1.6
- **Security fix** — path traversal vulnerability in `workflow_service.py` (reported via ComfyUI-Manager PR review)
### v0.1.5
- **Theme system** — 13 built-in themes selectable from Settings tab with instant preview (Deep Ocean Dark, Pop & Vibrant, Light Minimalist, Cyberpunk, Glassmorphism, Neumorphism, Retro 8-bit, Pastel Dream, Brutalism, Earthy, Material UI, Monotone + Accent, Corporate Trust)
- Theme preference persisted in localStorage and restored on page load (no flash)
- Special CSS effects per theme: neon glow (Cyberpunk), backdrop blur (Glassmorphism), dual shadow (Neumorphism), pixel borders (Retro/Brutalism)
### v0.1.4
- **App format support** — detect `.app.json` workflows (ComfyUI App mode), show "App Format" badge, block loading in GenerateUI with guidance message
- **Preset clipboard copy** — added PP Copy / NP Copy buttons to copy positive/negative prompts to clipboard
- **Analysis bugfix** — fixed workflow analysis crash when `widgets_values` contains non-string values (e.g. integers)
### v0.1.3
- Added **Help & Support tab** — feature list, tips, and support links (GitHub, Ko-fi)
- Multi-language support (EN/JA/ZH) for all help content
@@ -163,18 +346,27 @@ ComfyUI-Workflow-Studio/
├── py/
│ ├── wfm.py # Main class & route registration
│ ├── config.py # Path configuration
│ ├── nodes/
│ │ └── prompt_text.py # WFS_PromptText custom node (positive/negative prompt)
│ ├── routes/
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
│ │ ├── prompts_routes.py # Prompt presets CRUD API
│ │ ├── settings_routes.py # Settings API
│ │ ├── ollama_routes.py # Ollama proxy API
│ │ └── eagle_routes.py # Eagle integration API
│ └── services/
│ ├── workflow_service.py # Workflow file operations
│ ├── nodes_service.py # Node metadata & node sets
│ ├── models_service.py # Model metadata & preview images
│ ├── civitai_service.py # CivitAI API integration & cache
│ ├── prompts_service.py # Prompt presets persistence
│ ├── workflow_analyzer.py # Model/node detection
│ ├── settings_service.py # Settings persistence
│ └── png_extractor.py # PNG metadata extraction
├── templates/
│ └── index.html # SPA template (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
@@ -185,13 +377,16 @@ ComfyUI-Workflow-Studio/
│ ├── prompt-tab.js # AI assistant & presets
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
│ ├── models-tab.js # Model browser & CivitAI integration
│ ├── comfyui-workflow.js # UI-to-API format conversion
│ ├── comfyui-editor.js # Dynamic parameter editor
│ ├── json-highlight.js # JSON syntax highlighting
│ └── i18n.js # Internationalization
├── web/comfyui/
│ └── top_menu_extension.js # ComfyUI menu bar integration
└── data/ # Metadata & settings storage
│ ├── top_menu_extension.js # ComfyUI menu bar integration
│ └── node_sets_menu.js # Workflow Studio Library side panel
└── data/ # Fallback data dir (used when ComfyUI user/default/ is not found)
```
---
+16 -1
View File
@@ -1,9 +1,24 @@
from .py.wfm import WorkflowStudio
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web/comfyui"
# Register routes on import
WorkflowStudio.add_routes()
__all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]
# Register custom nodes (isolated loading)
_NODE_MODULES = {
"WFS_PromptText": (".py.nodes.prompt_text", "WFS_PromptText"),
}
for _name, (_mod_path, _cls_name) in _NODE_MODULES.items():
try:
import importlib
_mod = importlib.import_module(_mod_path, package=__name__)
NODE_CLASS_MAPPINGS[_name] = getattr(_mod, _cls_name)
NODE_DISPLAY_NAME_MAPPINGS[_name] = "Prompt Text (WFS)"
except Exception as _e:
print(f"[WARNING] Workflow Studio: Failed to load '{_name}': {_e}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2100 900" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="2100" y2="900" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="50%" stop-color="#16213e"/>
<stop offset="100%" stop-color="#1a1a2e"/>
</linearGradient>
<linearGradient id="wStroke" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4a9eff"/>
<stop offset="100%" stop-color="#7b68ee"/>
</linearGradient>
<linearGradient id="sWave" x1="0" y1="0" x2="600" y2="600" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7b68ee"/>
<stop offset="100%" stop-color="#4a9eff"/>
</linearGradient>
<linearGradient id="textGrad" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#e0e0ff"/>
<stop offset="100%" stop-color="#b0b0d0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="2100" height="900" fill="url(#bg)"/>
<!-- Subtle grid -->
<line x1="0" y1="225" x2="2100" y2="225" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="450" x2="2100" y2="450" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="675" x2="2100" y2="675" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<!-- Decorative wave lines in background -->
<path d="M0 750 Q262 700 525 750 Q787 800 1050 750 Q1312 700 1575 750 Q1837 800 2100 750" stroke="#4a9eff" stroke-opacity="0.08" stroke-width="2" fill="none"/>
<path d="M0 780 Q262 730 525 780 Q787 830 1050 780 Q1312 730 1575 780 Q1837 830 2100 780" stroke="#7b68ee" stroke-opacity="0.06" stroke-width="2" fill="none"/>
<!-- W+S Logo (left side) -->
<g transform="translate(180, 180)">
<!-- W -->
<path d="M60 80 L120 280 L180 130 L240 280 L300 80" stroke="url(#wStroke)" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- S wave -->
<path d="M80 340 Q140 300 200 340 Q260 380 320 340" stroke="url(#sWave)" stroke-width="18" stroke-linecap="round" fill="none"/>
</g>
<!-- Title text -->
<text x="720" y="380" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="120" font-weight="700" fill="url(#textGrad)" letter-spacing="-2">Workflow Studio</text>
<!-- Subtitle -->
<text x="720" y="470" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="42" fill="#8888aa" letter-spacing="1">Workflow Management &amp; Generation UI for ComfyUI</text>
<!-- Feature pills -->
<g font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="28" fill="#a0a0c0">
<rect x="720" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
<text x="820" y="559" text-anchor="middle" fill="#4a9eff">Workflows</text>
<rect x="940" y="530" width="200" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
<text x="1040" y="559" text-anchor="middle" fill="#7b68ee">GenerateUI</text>
<rect x="1160" y="530" width="200" height="44" rx="22" fill="#4a9eff" fill-opacity="0.12" stroke="#4a9eff" stroke-opacity="0.3" stroke-width="1"/>
<text x="1260" y="559" text-anchor="middle" fill="#4a9eff">AI Prompts</text>
<rect x="1380" y="530" width="180" height="44" rx="22" fill="#7b68ee" fill-opacity="0.12" stroke="#7b68ee" stroke-opacity="0.3" stroke-width="1"/>
<text x="1470" y="559" text-anchor="middle" fill="#7b68ee">Themes</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="100%" stop-color="#16213e"/>
</linearGradient>
<linearGradient id="wStroke" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4a9eff"/>
<stop offset="100%" stop-color="#7b68ee"/>
</linearGradient>
<linearGradient id="sWave" x1="0" y1="0" x2="400" y2="400" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#7b68ee"/>
<stop offset="100%" stop-color="#4a9eff"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="400" height="400" rx="48" fill="url(#bg)"/>
<!-- Subtle grid pattern -->
<line x1="0" y1="100" x2="400" y2="100" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="200" x2="400" y2="200" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="0" y1="300" x2="400" y2="300" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="100" y1="0" x2="100" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="200" y1="0" x2="200" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<line x1="300" y1="0" x2="300" y2="400" stroke="#ffffff" stroke-opacity="0.03" stroke-width="1"/>
<!-- W letterform -->
<path d="M80 110 L140 290 L200 150 L260 290 L320 110" stroke="url(#wStroke)" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- S wave -->
<path d="M100 330 Q150 295 200 330 Q250 365 300 330" stroke="url(#sWave)" stroke-width="20" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 148 KiB

+461
View File
@@ -0,0 +1,461 @@
/* ============================================
Workflow Studio - Theme Definitions
============================================
各テーマは :root の CSS変数を上書きすることで適用される。
body の font-family も各テーマで変更する。
現在のデフォルトテーマ (Deep Ocean Dark) の変数:
--wfm-bg: #1a1a2e
--wfm-bg-secondary: #16213e
--wfm-bg-tertiary: #0f3460
--wfm-surface: #1e2a4a
--wfm-surface-hover: #253358
--wfm-border: #2a3a5c
--wfm-text: #e0e0e0
--wfm-text-secondary: #a0a0b0
--wfm-primary: #4a9eff
--wfm-primary-hover: #3a8eef
--wfm-danger: #ff4757
--wfm-success: #2ed573
--wfm-warning: #ffa502
--wfm-accent: #7c5cfc
--wfm-radius: 8px
--wfm-radius-sm: 4px
--wfm-shadow: 0 2px 8px rgba(0,0,0,0.3)
============================================ */
/* ============================================
1. Pop & Vibrant (ポップ調)
明るく楽しく、クリエイティビティを刺激するデザイン
============================================ */
[data-theme="pop-vibrant"] {
--wfm-bg: #F8F9FA;
--wfm-bg-secondary: #FFFFFF;
--wfm-bg-tertiary: #EEF0F2;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #F0F4FF;
--wfm-border: #E0E0E0;
--wfm-text: #2D2D2D;
--wfm-text-secondary: #666666;
--wfm-primary: #FF6B6B;
--wfm-primary-hover: #FF5252;
--wfm-danger: #FF4757;
--wfm-success: #2ED573;
--wfm-warning: #FFA502;
--wfm-accent: #00D2D3;
--wfm-radius: 16px;
--wfm-radius-sm: 8px;
--wfm-shadow: 0 4px 16px rgba(255, 107, 107, 0.15);
/* 追加テーマ変数 */
--wfm-tag-sdxl: #FF6B6B;
--wfm-tag-sd15: #00D2D3;
--wfm-tag-flux: #FFC048;
--wfm-tag-default: #A55EEA;
}
[data-theme="pop-vibrant"] body {
font-family: 'Zen Maru Gothic', 'Rounded Mplus 1c', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ============================================
2. Light Minimalist (クリーン&ライト)
現代的でノイズのない、コンテンツ集中型デザイン
============================================ */
[data-theme="light-minimalist"] {
--wfm-bg: #FFFFFF;
--wfm-bg-secondary: #F5F5F5;
--wfm-bg-tertiary: #FAFAFA;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #F0F4FF;
--wfm-border: #EEEEEE;
--wfm-text: #333333;
--wfm-text-secondary: #888888;
--wfm-primary: #4A9EFF;
--wfm-primary-hover: #3A8EEF;
--wfm-danger: #EF4444;
--wfm-success: #22C55E;
--wfm-warning: #F59E0B;
--wfm-accent: #6366F1;
--wfm-radius: 8px;
--wfm-radius-sm: 4px;
--wfm-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
[data-theme="light-minimalist"] body {
font-family: 'Inter', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ============================================
3. Cyberpunk / Hacker (サイバーパンク)
SF映画のようなハッカー感・ギーク感
============================================ */
[data-theme="cyberpunk"] {
--wfm-bg: #000000;
--wfm-bg-secondary: #0A0A0A;
--wfm-bg-tertiary: #111111;
--wfm-surface: #0D0D0D;
--wfm-surface-hover: #1A1A1A;
--wfm-border: #00FF4130;
--wfm-text: #00FF41;
--wfm-text-secondary: #00CC33;
--wfm-primary: #FF00FF;
--wfm-primary-hover: #CC00CC;
--wfm-danger: #FF073A;
--wfm-success: #00FF41;
--wfm-warning: #FFD300;
--wfm-accent: #00FFFF;
--wfm-radius: 0px;
--wfm-radius-sm: 0px;
--wfm-shadow: 0 0 12px rgba(0, 255, 65, 0.3);
/* ネオン発光ボーダー用 */
--wfm-glow-primary: 0 0 8px rgba(255, 0, 255, 0.6);
--wfm-glow-accent: 0 0 8px rgba(0, 255, 255, 0.6);
/* JSONパネル用 */
--wfm-code-text: #00FF41;
--wfm-code-bg: #000000;
}
[data-theme="cyberpunk"] body {
font-family: 'Space Mono', 'Fira Code', 'Courier New', monospace;
}
/* ============================================
4. Glassmorphism (グラスモーフィズム)
半透明の洗練された未来的デザイン
============================================ */
[data-theme="glassmorphism"] {
--wfm-bg: #1A0533;
--wfm-bg-secondary: rgba(255, 255, 255, 0.08);
--wfm-bg-tertiary: rgba(255, 255, 255, 0.04);
--wfm-surface: rgba(255, 255, 255, 0.10);
--wfm-surface-hover: rgba(255, 255, 255, 0.16);
--wfm-border: rgba(255, 255, 255, 0.18);
--wfm-text: #F0F0F0;
--wfm-text-secondary: #C0C0D0;
--wfm-primary: #7B68EE;
--wfm-primary-hover: #6A5ACD;
--wfm-danger: #FF6B6B;
--wfm-success: #69DB7C;
--wfm-warning: #FFD43B;
--wfm-accent: #DA77F2;
--wfm-radius: 12px;
--wfm-radius-sm: 8px;
--wfm-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
/* Glassmorphism用追加変数 */
--wfm-glass-blur: blur(12px);
--wfm-glass-bg: rgba(255, 255, 255, 0.10);
/* 背景メッシュグラデーション */
--wfm-mesh-gradient: linear-gradient(135deg, #1A0533 0%, #0D1B3E 25%, #1A0533 50%, #2D1054 75%, #0D1B3E 100%);
}
[data-theme="glassmorphism"] body {
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
}
/* ============================================
5. Neumorphism (ニューモーフィズム)
柔らかく立体的な凸凹表現
============================================ */
[data-theme="neumorphism"] {
--wfm-bg: #D1D9E6;
--wfm-bg-secondary: #D1D9E6;
--wfm-bg-tertiary: #C8D0DD;
--wfm-surface: #D1D9E6;
--wfm-surface-hover: #D8E0ED;
--wfm-border: transparent;
--wfm-text: #4A5568;
--wfm-text-secondary: #718096;
--wfm-primary: #6C8EBF;
--wfm-primary-hover: #5A7CAD;
--wfm-danger: #E57373;
--wfm-success: #81C784;
--wfm-warning: #FFB74D;
--wfm-accent: #7986CB;
--wfm-radius: 12px;
--wfm-radius-sm: 8px;
/* ニューモーフィズム用の光と影 */
--wfm-shadow: 6px 6px 12px #B8C0CC, -6px -6px 12px #EAF2FF;
--wfm-shadow-inset: inset 4px 4px 8px #B8C0CC, inset -4px -4px 8px #EAF2FF;
}
[data-theme="neumorphism"] body {
font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ============================================
6. Retro 8-bit / Pixel (レトロゲーム調)
遊び心満載のファミコン風デザイン
============================================ */
[data-theme="retro-pixel"] {
--wfm-bg: #2C2C34;
--wfm-bg-secondary: #3A3A44;
--wfm-bg-tertiary: #24242C;
--wfm-surface: #3A3A44;
--wfm-surface-hover: #4A4A54;
--wfm-border: #000000;
--wfm-text: #F0F0E0;
--wfm-text-secondary: #B0B0A0;
--wfm-primary: #5B8FD4;
--wfm-primary-hover: #4A7EC3;
--wfm-danger: #D94848;
--wfm-success: #48A848;
--wfm-warning: #D4A017;
--wfm-accent: #D45BD4;
--wfm-radius: 0px;
--wfm-radius-sm: 0px;
/* 太い黒ボーダー + ベタ塗り影 */
--wfm-shadow: 4px 4px 0px #000000;
--wfm-border-width: 3px;
}
[data-theme="retro-pixel"] body {
font-family: 'DotGothic16', 'Press Start 2P', 'Courier New', monospace;
}
/* ============================================
7. Pastel Dream (パステルドリーム)
柔らかく優しい、目に優しいデザイン
============================================ */
[data-theme="pastel-dream"] {
--wfm-bg: #F0E6FF;
--wfm-bg-secondary: #E8F5E9;
--wfm-bg-tertiary: #F3E5F5;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #FFF3E0;
--wfm-border: #E1BEE7;
--wfm-text: #5D4E75;
--wfm-text-secondary: #8E7CA8;
--wfm-primary: #B39DDB;
--wfm-primary-hover: #9575CD;
--wfm-danger: #F48FB1;
--wfm-success: #A5D6A7;
--wfm-warning: #FFE082;
--wfm-accent: #80DEEA;
--wfm-radius: 20px;
--wfm-radius-sm: 12px;
--wfm-shadow: 0 4px 16px rgba(179, 157, 219, 0.2);
/* パステルタグカラー */
--wfm-tag-sdxl: #FFAB91;
--wfm-tag-sd15: #A5D6A7;
--wfm-tag-flux: #90CAF9;
--wfm-tag-default: #CE93D8;
}
[data-theme="pastel-dream"] body {
font-family: 'M PLUS Rounded 1c', 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ============================================
8. Brutalism / High Contrast (ブルータリズム)
大胆で前衛的なハイコントラストデザイン
============================================ */
[data-theme="brutalism"] {
--wfm-bg: #FFFFFF;
--wfm-bg-secondary: #FFFFFF;
--wfm-bg-tertiary: #F0F0F0;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #CCFF00;
--wfm-border: #000000;
--wfm-text: #000000;
--wfm-text-secondary: #333333;
--wfm-primary: #CCFF00;
--wfm-primary-hover: #BBEE00;
--wfm-danger: #FF0000;
--wfm-success: #00FF00;
--wfm-warning: #FFFF00;
--wfm-accent: #CCFF00;
--wfm-radius: 0px;
--wfm-radius-sm: 0px;
/* 極太ボーダー + ベタ影 */
--wfm-shadow: 6px 6px 0px #000000;
--wfm-border-width: 3px;
}
[data-theme="brutalism"] body {
font-family: 'Arial Black', 'Impact', 'Helvetica Neue', sans-serif;
}
/* ============================================
9. Earthy / Nature (アースカラー)
落ち着いたオーガニックなナチュラルデザイン
============================================ */
[data-theme="earthy"] {
--wfm-bg: #F5E6D3;
--wfm-bg-secondary: #EDE0D4;
--wfm-bg-tertiary: #E8D5C0;
--wfm-surface: #FFF8F0;
--wfm-surface-hover: #F5EDE4;
--wfm-border: #D4B896;
--wfm-text: #3E2723;
--wfm-text-secondary: #6D4C41;
--wfm-primary: #8D6E63;
--wfm-primary-hover: #795548;
--wfm-danger: #C0392B;
--wfm-success: #6B8E23;
--wfm-warning: #D4A017;
--wfm-accent: #BF6830;
--wfm-radius: 8px;
--wfm-radius-sm: 4px;
--wfm-shadow: 0 2px 8px rgba(62, 39, 35, 0.12);
/* アースカラータグ */
--wfm-tag-sdxl: #6B8E23;
--wfm-tag-sd15: #BF6830;
--wfm-tag-flux: #D4A017;
--wfm-tag-default: #8D6E63;
}
[data-theme="earthy"] body {
font-family: 'Noto Sans JP', 'Noto Serif JP', Georgia, serif;
}
/* ============================================
10. Material UI (マテリアルデザイン風)
Googleライクな直感的デザイン
============================================ */
[data-theme="material"] {
--wfm-bg: #FAFAFA;
--wfm-bg-secondary: #FFFFFF;
--wfm-bg-tertiary: #F5F5F5;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #E8EAF6;
--wfm-border: #E0E0E0;
--wfm-text: #212121;
--wfm-text-secondary: #757575;
--wfm-primary: #3F51B5;
--wfm-primary-hover: #303F9F;
--wfm-danger: #F44336;
--wfm-success: #4CAF50;
--wfm-warning: #FF9800;
--wfm-accent: #FF4081;
--wfm-radius: 4px;
--wfm-radius-sm: 2px;
/* マテリアルシャドウ (Elevation 2) */
--wfm-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
[data-theme="material"] body {
font-family: 'Roboto', 'Noto Sans JP', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ============================================
11. Monotone + One Accent (モノトーン+ワンアクセント)
スタイリッシュなプロツール感
============================================ */
[data-theme="monotone-accent"] {
--wfm-bg: #F7F7F7;
--wfm-bg-secondary: #FFFFFF;
--wfm-bg-tertiary: #EBEBEB;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #F0F0F0;
--wfm-border: #D0D0D0;
--wfm-text: #1A1A1A;
--wfm-text-secondary: #808080;
--wfm-primary: #FF6B35;
--wfm-primary-hover: #E85D2A;
--wfm-danger: #CC0000;
--wfm-success: #555555;
--wfm-warning: #888888;
--wfm-accent: #FF6B35;
--wfm-radius: 6px;
--wfm-radius-sm: 3px;
--wfm-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
[data-theme="monotone-accent"] body {
font-family: 'Helvetica Neue', 'Arial', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ============================================
12. Corporate Trust (コーポレート・トラスト)
信頼感と堅牢性のあるBtoB向けデザイン
============================================ */
[data-theme="corporate"] {
--wfm-bg: #F0F4F8;
--wfm-bg-secondary: #FFFFFF;
--wfm-bg-tertiary: #E2E8F0;
--wfm-surface: #FFFFFF;
--wfm-surface-hover: #EBF0F6;
--wfm-border: #CBD5E1;
--wfm-text: #1E293B;
--wfm-text-secondary: #64748B;
--wfm-primary: #2563EB;
--wfm-primary-hover: #1D4ED8;
--wfm-danger: #DC2626;
--wfm-success: #16A34A;
--wfm-warning: #D97706;
--wfm-accent: #7C3AED;
--wfm-radius: 4px;
--wfm-radius-sm: 2px;
--wfm-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}
[data-theme="corporate"] body {
font-family: 'Noto Sans JP', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* ============================================
テーマ適用方法のメモ
============================================
HTMLの <html> タグに data-theme 属性を設定して切り替える:
<html data-theme="cyberpunk">
<html data-theme="pastel-dream">
<html data-theme="brutalism">
...
JavaScriptでの切り替え例:
document.documentElement.setAttribute('data-theme', 'cyberpunk');
テーマをリセットしてデフォルトに戻す場合:
document.documentElement.removeAttribute('data-theme');
============================================
テーマ一覧:
| ID | 名前 | 系統 |
|-------------------|-------------------------------|----------|
| (default) | Deep Ocean Dark | ダーク |
| pop-vibrant | Pop & Vibrant | ライト |
| light-minimalist | Light Minimalist | ライト |
| cyberpunk | Cyberpunk / Hacker | ダーク |
| glassmorphism | Glassmorphism | ダーク |
| neumorphism | Neumorphism | ライト |
| retro-pixel | Retro 8-bit / Pixel | ダーク |
| pastel-dream | Pastel Dream | ライト |
| brutalism | Brutalism / High Contrast | ライト |
| earthy | Earthy / Nature | ライト |
| material | Material UI | ライト |
| monotone-accent | Monotone + One Accent | ライト |
| corporate | Corporate Trust | ライト |
============================================ */
+16 -4
View File
@@ -10,13 +10,21 @@ PLUGIN_DIR = Path(__file__).resolve().parent.parent
# Directory paths
TEMPLATES_DIR = PLUGIN_DIR / "templates"
STATIC_DIR = PLUGIN_DIR / "static"
DATA_DIR = PLUGIN_DIR / "data"
# Default workflows directory: ComfyUI's user/default/workflows
# PLUGIN_DIR is custom_nodes/ComfyUI-Workflow-Studio
# ComfyUI root is custom_nodes/../../ = two levels up
_COMFYUI_ROOT = PLUGIN_DIR.parent.parent
_COMFYUI_WORKFLOWS = _COMFYUI_ROOT / "user" / "default" / "workflows"
# Data directory: prefer ComfyUI's user/default/Workflow-Studio/, fallback to plugin data/
_COMFYUI_USER_DEFAULT = _COMFYUI_ROOT / "user" / "default"
if _COMFYUI_USER_DEFAULT.is_dir():
DATA_DIR = _COMFYUI_USER_DEFAULT / "Workflow-Studio"
logger.info("Workflow Studio: Using user data dir: %s", DATA_DIR)
else:
DATA_DIR = PLUGIN_DIR / "data"
logger.info("Workflow Studio: user/default not found, using plugin data dir: %s", DATA_DIR)
# Default workflows directory: ComfyUI's user/default/workflows
_COMFYUI_WORKFLOWS = _COMFYUI_USER_DEFAULT / "workflows"
# Use ComfyUI workflows dir if it exists, otherwise fallback to plugin data dir
if _COMFYUI_WORKFLOWS.is_dir():
@@ -55,3 +63,7 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
# Metadata file path
METADATA_FILE = DATA_DIR / "metadata.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
View File
+31
View File
@@ -0,0 +1,31 @@
"""WFS_PromptText – Prompt preset node with positive/negative STRING outputs."""
class WFS_PromptText:
"""Outputs positive and negative prompt strings."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"positive": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Positive prompt...",
}),
"negative": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Negative prompt...",
}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("positive", "negative")
FUNCTION = "execute"
CATEGORY = "Workflow Studio"
OUTPUT_NODE = False
def execute(self, positive, negative):
return (positive, negative)
+282
View File
@@ -0,0 +1,282 @@
"""
Gallery Routes - ギャラリータブ用APIエンドポイント
"""
import json
import mimetypes
import logging
from pathlib import Path
from aiohttp import web
from ..config import DATA_DIR
from ..services.gallery_service import GalleryService
logger = logging.getLogger(__name__)
_service = GalleryService(DATA_DIR)
def setup_routes(app: web.Application):
app.router.add_get("/wfm/gallery/folders", list_folders)
app.router.add_get("/wfm/gallery/images", list_images)
app.router.add_get("/wfm/gallery/image/meta", get_image_meta)
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
app.router.add_get("/wfm/gallery/image/serve", serve_image)
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
app.router.add_post("/wfm/gallery/image/favorite", toggle_favorite)
app.router.add_get("/wfm/gallery/groups", list_groups)
app.router.add_post("/wfm/gallery/groups", create_group)
app.router.add_put("/wfm/gallery/groups/{name}", rename_group)
app.router.add_delete("/wfm/gallery/groups/{name}", delete_group)
app.router.add_post("/wfm/gallery/groups/{name}/add", add_to_group)
app.router.add_post("/wfm/gallery/groups/{name}/remove", remove_from_group)
app.router.add_get("/wfm/gallery/groups/{name}/images", list_group_images)
# フォルダ・ファイル操作
app.router.add_post("/wfm/gallery/folder", create_folder_route)
app.router.add_delete("/wfm/gallery/folder", delete_folder_route)
app.router.add_post("/wfm/gallery/images/delete", delete_images_route)
app.router.add_post("/wfm/gallery/images/move", move_images_route)
# ──────────────────────────────────────────────────────────────
# フォルダツリー
# ──────────────────────────────────────────────────────────────
async def list_folders(request: web.Request) -> web.Response:
root = request.rel_url.query.get("root", "")
if not root:
return web.json_response({"error": "root parameter required"}, status=400)
try:
tree = _service.list_folder_tree(root)
return web.json_response(tree)
except Exception as e:
logger.error("list_folders error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# 画像一覧
# ──────────────────────────────────────────────────────────────
async def list_images(request: web.Request) -> web.Response:
folder = request.rel_url.query.get("folder", "")
search = request.rel_url.query.get("search", "")
sort_by = request.rel_url.query.get("sort", "date_desc")
favorite_only = request.rel_url.query.get("favorite", "false") == "true"
tag_filter = request.rel_url.query.get("tag", "")
group_filter = request.rel_url.query.get("group", "")
if not folder:
return web.json_response({"error": "folder parameter required"}, status=400)
try:
images = _service.list_images(
folder,
search=search,
sort_by=sort_by,
favorite_only=favorite_only,
tag_filter=tag_filter,
group_filter=group_filter,
)
return web.json_response({"images": images, "total": len(images)})
except Exception as e:
logger.error("list_images error: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
async def get_image_meta(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.json_response({"error": "path required"}, status=400)
try:
meta = _service.get_image_metadata(path)
return web.json_response(meta)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def get_image_workflow(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.json_response({"error": "path required"}, status=400)
try:
wf = _service.extract_workflow_from_metadata(path)
if wf is None:
return web.json_response({"workflow": None, "has_workflow": False})
return web.json_response({"workflow": wf, "has_workflow": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def serve_image(request: web.Request) -> web.Response:
path = request.rel_url.query.get("path", "")
if not path:
return web.Response(status=400)
img_path = _service.serve_image(path)
if img_path is None:
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(img_path))
return web.FileResponse(img_path, headers={
"Content-Type": content_type or "image/png",
"Cache-Control": "max-age=3600",
})
async def save_image_meta(request: web.Request) -> web.Response:
try:
body = await request.json()
img_path = body.get("path", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
data = {k: v for k, v in body.items() if k != "path"}
ok = _service.save_image_meta(img_path, data)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def toggle_favorite(request: web.Request) -> web.Response:
try:
body = await request.json()
img_path = body.get("path", "")
if not img_path:
return web.json_response({"error": "path required"}, status=400)
new_val = _service.toggle_favorite(img_path)
return web.json_response({"favorite": new_val})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
async def list_groups(request: web.Request) -> web.Response:
return web.json_response({"groups": _service.list_groups()})
async def create_group(request: web.Request) -> web.Response:
try:
body = await request.json()
name = body.get("name", "").strip()
if not name:
return web.json_response({"error": "name required"}, status=400)
ok = _service.create_group(name)
if not ok:
return web.json_response({"error": "Group already exists"}, status=409)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def rename_group(request: web.Request) -> web.Response:
"""PUT /wfm/gallery/groups/{name} - グループ名変更"""
try:
old_name = request.match_info.get("name", "")
body = await request.json()
new_name = body.get("new_name", "").strip()
if not old_name or not new_name:
return web.json_response({"error": "old_name and new_name required"}, status=400)
ok = _service.rename_group(old_name, new_name)
if not ok:
return web.json_response({"error": "Rename failed (not found or name conflict)"}, status=400)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def delete_group(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
ok = _service.delete_group(name)
return web.json_response({"ok": ok})
async def add_to_group(request: web.Request) -> web.Response:
try:
name = request.match_info.get("name", "")
body = await request.json()
img_path = body.get("path", "")
ok = _service.add_to_group(img_path, name)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def remove_from_group(request: web.Request) -> web.Response:
try:
name = request.match_info.get("name", "")
body = await request.json()
img_path = body.get("path", "")
ok = _service.remove_from_group(img_path, name)
return web.json_response({"ok": ok})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def list_group_images(request: web.Request) -> web.Response:
name = request.match_info.get("name", "")
images = _service.list_images_in_group(name)
return web.json_response({"images": images})
# ──────────────────────────────────────────────────────────────
# フォルダ・ファイル操作
# ──────────────────────────────────────────────────────────────
async def create_folder_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/folder - 新規フォルダ作成"""
try:
body = await request.json()
parent = body.get("parent", "")
name = body.get("name", "").strip()
if not parent or not name:
return web.json_response({"ok": False, "error": "parent and name required"}, status=400)
result = _service.create_folder(parent, name)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_folder_route(request: web.Request) -> web.Response:
"""DELETE /wfm/gallery/folder - フォルダ削除"""
try:
body = await request.json()
folder = body.get("path", "")
if not folder:
return web.json_response({"ok": False, "error": "path required"}, status=400)
result = _service.delete_folder(folder)
return web.json_response(result, status=200 if result["ok"] else 400)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/delete - 画像削除(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
if not paths:
return web.json_response({"deleted": [], "errors": ["paths required"]}, status=400)
result = _service.delete_images(paths)
return web.json_response(result)
except Exception as e:
return web.json_response({"deleted": [], "errors": [str(e)]}, status=500)
async def move_images_route(request: web.Request) -> web.Response:
"""POST /wfm/gallery/images/move - 画像移動(単体・複数)"""
try:
body = await request.json()
paths = body.get("paths", [])
dest = body.get("dest", "")
if not paths or not dest:
return web.json_response({"moved": [], "errors": ["paths and dest required"]}, status=400)
result = _service.move_images(paths, dest)
return web.json_response(result)
except Exception as e:
return web.json_response({"moved": [], "errors": [str(e)]}, status=500)
+531
View File
@@ -0,0 +1,531 @@
"""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)
app.router.add_get("/api/wfm/models/disabled", handle_get_disabled)
app.router.add_post("/api/wfm/models/toggle", handle_toggle_model)
app.router.add_post("/api/wfm/models/group-toggle", handle_toggle_group)
app.router.add_post("/api/wfm/models/delete", handle_delete_models)
# ── Model Metadata ─────────────────────────────────────────
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?type=checkpoint"""
model_type = request.query.get("type", "")
try:
result = await asyncio.to_thread(_service.get_model_groups, model_type or None)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups
Body: { "model_type": "checkpoint", "groups": { "groupName": [...] } }
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
groups = body.get("groups", {})
if not model_type:
return web.json_response({"error": "model_type required"}, status=400)
result = await asyncio.to_thread(_service.save_model_groups, groups, model_type)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
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)
# ── Model Enable / Disable ────────────────────────────────
async def handle_get_disabled(request: web.Request) -> web.Response:
"""GET /api/wfm/models/disabled?type=checkpoint
Returns list of disabled model names (normalized, without .disabled suffix).
"""
model_type = request.query.get("type", "")
if not model_type:
return web.json_response({"error": "type required"}, status=400)
try:
result = await asyncio.to_thread(_service.scan_disabled_models, model_type)
return web.json_response(result)
except Exception as e:
logger.error("Error scanning disabled models: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_model(request: web.Request) -> web.Response:
"""POST /api/wfm/models/toggle
Body: { "model_type": "checkpoint", "model_name": "v1-5.safetensors", "enabled": false }
Renames the model file to add/remove .disabled suffix.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_name = body.get("model_name", "")
enabled = body.get("enabled", True)
if not model_type or not model_name:
return web.json_response({"error": "model_type and model_name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
return web.json_response({"status": "ok", "enabled": enabled})
except FileNotFoundError as e:
return web.json_response({"error": str(e)}, status=404)
except OSError as e:
logger.error("OS error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
except Exception as e:
logger.error("Error toggling model: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_toggle_group(request: web.Request) -> web.Response:
"""POST /api/wfm/models/group-toggle
Body: { "model_type": "checkpoint", "group_name": "MyGroup", "enabled": true }
Enables or disables all models belonging to the specified group.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
group_name = body.get("group_name", "")
enabled = body.get("enabled", True)
if not model_type or not group_name:
return web.json_response({"error": "model_type and group_name required"}, status=400)
groups = await asyncio.to_thread(_service.get_model_groups, model_type)
members = groups.get(group_name, [])
ok_list = []
error_list = []
for model_name in members:
if ".." in model_name:
continue
try:
if enabled:
await asyncio.to_thread(_service.enable_model, model_type, model_name)
else:
await asyncio.to_thread(_service.disable_model, model_type, model_name)
ok_list.append(model_name)
except Exception as e:
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({
"status": "ok",
"enabled": enabled,
"ok": ok_list,
"errors": error_list,
})
except Exception as e:
logger.error("Error toggling group: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_models(request: web.Request) -> web.Response:
"""POST /api/wfm/models/delete
Body: { "model_type": "checkpoint", "model_names": ["a.safetensors", "b.safetensors"] }
Deletes each model file and associated preview/sidecar files, then removes metadata.
"""
try:
body = await request.json()
model_type = body.get("model_type", "")
model_names = body.get("model_names", [])
if not model_type or not isinstance(model_names, list) or not model_names:
return web.json_response({"error": "model_type and model_names[] required"}, status=400)
ok_list = []
error_list = []
for model_name in model_names:
if ".." in str(model_name):
error_list.append({"model": model_name, "error": "Invalid model name"})
continue
try:
result = await asyncio.to_thread(_service.delete_model, model_type, model_name)
ok_list.append({"model": model_name, "deleted": result["deleted"]})
logger.info("Deleted model: %s (%s files)", model_name, len(result["deleted"]))
except FileNotFoundError as e:
error_list.append({"model": model_name, "error": str(e)})
except Exception as e:
logger.error("Error deleting model %s: %s", model_name, e)
error_list.append({"model": model_name, "error": str(e)})
return web.json_response({"status": "ok", "ok": ok_list, "errors": error_list})
except Exception as e:
logger.error("Error in delete_models: %s", e)
return web.json_response({"error": str(e)}, status=500)
+150
View File
@@ -0,0 +1,150 @@
"""Node metadata and node sets API routes."""
import asyncio
import logging
from aiohttp import web
from ..services.nodes_service import NodesService
logger = logging.getLogger(__name__)
_service = NodesService()
def setup_routes(app: web.Application):
"""Register all node management API routes."""
# Node metadata
app.router.add_get("/api/wfm/nodes/metadata", handle_get_metadata)
app.router.add_post("/api/wfm/nodes/metadata", handle_save_metadata)
# Node groups
app.router.add_get("/api/wfm/nodes/groups", handle_get_groups)
app.router.add_post("/api/wfm/nodes/groups", handle_save_groups)
# Node sets
app.router.add_get("/api/wfm/node-sets", handle_list_sets)
app.router.add_post("/api/wfm/node-sets", handle_create_set)
app.router.add_post("/api/wfm/node-sets/update", handle_update_set)
app.router.add_post("/api/wfm/node-sets/delete", handle_delete_set)
app.router.add_get("/api/wfm/node-sets/export", handle_export_set)
# ── Node Metadata ──────────────────────────────────────────
async def handle_get_metadata(request: web.Request) -> web.Response:
"""GET /api/wfm/nodes/metadata"""
try:
result = await asyncio.to_thread(_service.get_all_metadata)
return web.json_response(result)
except Exception as e:
logger.error("Error loading node metadata: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_metadata(request: web.Request) -> web.Response:
"""POST /api/wfm/nodes/metadata"""
try:
body = await request.json()
node_name = body.get("nodeName", "")
if not node_name:
return web.json_response({"error": "nodeName is required"}, status=400)
updates = {k: v for k, v in body.items() if k != "nodeName"}
result = await asyncio.to_thread(_service.update_node_metadata, node_name, updates)
return web.json_response({"status": "ok", "metadata": result})
except Exception as e:
logger.error("Error saving node metadata: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Node Groups ────────────────────────────────────────────
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/nodes/groups"""
try:
result = await asyncio.to_thread(_service.get_node_groups)
return web.json_response(result)
except Exception as e:
logger.error("Error loading node groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/nodes/groups"""
try:
body = await request.json()
result = await asyncio.to_thread(_service.save_node_groups, body)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving node groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Node Sets ──────────────────────────────────────────────
async def handle_list_sets(request: web.Request) -> web.Response:
"""GET /api/wfm/node-sets"""
try:
result = await asyncio.to_thread(_service.list_node_sets)
return web.json_response(result)
except Exception as e:
logger.error("Error listing node sets: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_create_set(request: web.Request) -> web.Response:
"""POST /api/wfm/node-sets"""
try:
body = await request.json()
result = await asyncio.to_thread(_service.create_node_set, body)
return web.json_response({"status": "ok", "nodeSet": result})
except Exception as e:
logger.error("Error creating node set: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_update_set(request: web.Request) -> web.Response:
"""POST /api/wfm/node-sets/update"""
try:
body = await request.json()
set_id = body.get("id", "")
if not set_id:
return web.json_response({"error": "id is required"}, status=400)
updates = {k: v for k, v in body.items() if k != "id"}
result = await asyncio.to_thread(_service.update_node_set, set_id, updates)
if result is None:
return web.json_response({"error": "node set not found"}, status=404)
return web.json_response({"status": "ok", "nodeSet": result})
except Exception as e:
logger.error("Error updating node set: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_delete_set(request: web.Request) -> web.Response:
"""POST /api/wfm/node-sets/delete"""
try:
body = await request.json()
set_id = body.get("id", "")
if not set_id:
return web.json_response({"error": "id is required"}, status=400)
await asyncio.to_thread(_service.delete_node_set, set_id)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error deleting node set: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_export_set(request: web.Request) -> web.Response:
"""GET /api/wfm/node-sets/export?id=xxx"""
try:
set_id = request.query.get("id", "")
if not set_id:
return web.json_response({"error": "id is required"}, status=400)
result = await asyncio.to_thread(_service.export_node_set_json, set_id)
if result is None:
return web.json_response({"error": "node set not found"}, status=404)
return web.json_response(result)
except Exception as e:
logger.error("Error exporting node set: %s", e)
return web.json_response({"error": str(e)}, status=500)
+84
View File
@@ -0,0 +1,84 @@
"""Prompt presets API routes."""
import asyncio
import logging
from aiohttp import web
from ..services.prompts_service import PromptsService
logger = logging.getLogger(__name__)
_service = PromptsService()
def setup_routes(app: web.Application):
"""Register all prompt management API routes."""
app.router.add_get("/api/wfm/prompts", handle_list)
app.router.add_post("/api/wfm/prompts", handle_create)
app.router.add_post("/api/wfm/prompts/update", handle_update)
app.router.add_post("/api/wfm/prompts/delete", handle_delete)
# ── List ──────────────────────────────────────────────────
async def handle_list(request: web.Request) -> web.Response:
"""GET /api/wfm/prompts"""
try:
result = await asyncio.to_thread(_service.list_prompts)
return web.json_response(result)
except Exception as e:
logger.error("Error listing prompts: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Create ────────────────────────────────────────────────
async def handle_create(request: web.Request) -> web.Response:
"""POST /api/wfm/prompts"""
try:
body = await request.json()
result = await asyncio.to_thread(_service.create_prompt, body)
return web.json_response({"status": "ok", "prompt": result})
except Exception as e:
logger.error("Error creating prompt: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Update ────────────────────────────────────────────────
async def handle_update(request: web.Request) -> web.Response:
"""POST /api/wfm/prompts/update"""
try:
body = await request.json()
prompt_id = body.get("id", "")
if not prompt_id:
return web.json_response({"error": "id is required"}, status=400)
updates = {k: v for k, v in body.items() if k != "id"}
result = await asyncio.to_thread(_service.update_prompt, prompt_id, updates)
if result is None:
return web.json_response({"error": "prompt not found"}, status=404)
return web.json_response({"status": "ok", "prompt": result})
except Exception as e:
logger.error("Error updating prompt: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Delete ────────────────────────────────────────────────
async def handle_delete(request: web.Request) -> web.Response:
"""POST /api/wfm/prompts/delete"""
try:
body = await request.json()
prompt_id = body.get("id", "")
if not prompt_id:
return web.json_response({"error": "id is required"}, status=400)
await asyncio.to_thread(_service.delete_prompt, prompt_id)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error deleting prompt: %s", e)
return web.json_response({"error": str(e)}, status=500)
+143 -1
View File
@@ -1,18 +1,30 @@
"""Settings API routes."""
import asyncio
import json
import logging
from pathlib import Path
from aiohttp import web
from ..services.settings_service import SettingsService
from ..config import DEFAULT_WORKFLOWS_DIR
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR
logger = logging.getLogger(__name__)
_service = SettingsService()
# Data files included in export/import
_DATA_FILES = [
"settings.json",
"metadata.json",
"node_metadata.json",
"node_sets.json",
"prompts.json",
"model_metadata.json",
"gallery_metadata.json",
]
def setup_routes(app: web.Application):
"""Register settings API routes."""
@@ -20,6 +32,10 @@ def setup_routes(app: web.Application):
app.router.add_post("/api/wfm/settings", handle_post)
app.router.add_get("/api/wfm/settings/workflows-dir", handle_get_workflows_dir)
app.router.add_post("/api/wfm/settings/workflows-dir", handle_set_workflows_dir)
app.router.add_get("/api/wfm/settings/output-dir", handle_get_output_dir)
app.router.add_post("/api/wfm/settings/output-dir", handle_set_output_dir)
app.router.add_get("/api/wfm/settings/export", handle_export)
app.router.add_post("/api/wfm/settings/import", handle_import)
async def handle_get(request: web.Request) -> web.Response:
@@ -92,3 +108,129 @@ async def handle_set_workflows_dir(request: web.Request) -> web.Response:
except Exception as e:
logger.error("Error setting workflows dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _get_comfyui_output_dir() -> str:
"""ComfyUIのデフォルトoutputフォルダを取得する。"""
try:
import folder_paths # type: ignore
return str(folder_paths.get_output_directory())
except Exception:
pass
# fallback: config.pyのCOMFYUI_ROOTから推測
from ..config import PLUGIN_DIR
comfyui_root = PLUGIN_DIR.parent.parent
output_dir = comfyui_root / "output"
if output_dir.is_dir():
return str(output_dir)
return ""
async def handle_get_output_dir(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/output-dir - Get gallery output directory."""
try:
server_settings = await asyncio.to_thread(_service.load)
saved = server_settings.get("gallery_output_dir", "").strip()
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
return web.json_response({
"current": saved or default_dir,
"default": default_dir,
"saved": saved,
})
except Exception as e:
logger.error("Error getting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_set_output_dir(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/output-dir - Save gallery output directory."""
try:
body = await request.json()
new_dir = body.get("gallery_output_dir", "").strip()
await asyncio.to_thread(_service.update, {"gallery_output_dir": new_dir})
default_dir = await asyncio.to_thread(_get_comfyui_output_dir)
resolved = new_dir or default_dir
# GalleryServiceの許可ルートを更新
from ..routes.gallery_routes import _service as gallery_service
gallery_service.update_output_root(resolved)
return web.json_response({
"status": "ok",
"current": resolved,
"default": default_dir,
"saved": new_dir,
})
except Exception as e:
logger.error("Error setting output dir: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _load_data_file(filename: str):
"""Load a single data file, return empty dict/list if missing."""
path = DATA_DIR / filename
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _build_export_bundle() -> dict:
"""Collect all data files into a single export bundle."""
bundle = {"__version": 1, "__source": "ComfyUI-Workflow-Studio"}
for filename in _DATA_FILES:
data = _load_data_file(filename)
if data is not None:
bundle[filename] = data
return bundle
async def handle_export(request: web.Request) -> web.Response:
"""GET /api/wfm/settings/export - Download all data as a single JSON bundle."""
try:
bundle = await asyncio.to_thread(_build_export_bundle)
body = json.dumps(bundle, ensure_ascii=False, indent=2)
return web.Response(
body=body.encode("utf-8"),
content_type="application/json",
headers={"Content-Disposition": 'attachment; filename="wfm-data-export.json"'},
)
except Exception as e:
logger.error("Error exporting data: %s", e)
return web.json_response({"error": str(e)}, status=500)
def _apply_import_bundle(bundle: dict) -> dict:
"""Write imported bundle back to individual data files. Returns summary."""
summary = {"imported": [], "skipped": []}
DATA_DIR.mkdir(parents=True, exist_ok=True)
for filename in _DATA_FILES:
if filename not in bundle:
summary["skipped"].append(filename)
continue
try:
path = DATA_DIR / filename
with open(path, "w", encoding="utf-8") as f:
json.dump(bundle[filename], f, ensure_ascii=False, indent=2)
summary["imported"].append(filename)
except Exception as e:
logger.error("Import failed for %s: %s", filename, e)
summary["skipped"].append(filename)
return summary
async def handle_import(request: web.Request) -> web.Response:
"""POST /api/wfm/settings/import - Upload and restore a JSON bundle."""
try:
bundle = await request.json()
if not isinstance(bundle, dict):
return web.json_response({"error": "Invalid bundle format"}, status=400)
summary = await asyncio.to_thread(_apply_import_bundle, bundle)
return web.json_response({"status": "ok", **summary})
except Exception as e:
logger.error("Error importing data: %s", e)
return web.json_response({"error": str(e)}, status=500)
+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()
+142
View File
@@ -0,0 +1,142 @@
"""
Gallery Metadata Store - ギャラリー画像のメタデータ永続化
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class GalleryMetadataStore:
"""
gallery_metadata.json の構造:
{
"images": {
"<abs_path>": {
"favorite": bool,
"tags": [str, ...],
"memo": str,
"groups": [str, ...]
}
},
"groups": [
{"name": str}
]
}
"""
def __init__(self, file_path: Path):
self.file_path = file_path
self._data: dict = {"images": {}, "groups": []}
self._load()
def _load(self):
if self.file_path.exists():
try:
with open(self.file_path, "r", encoding="utf-8") as f:
loaded = json.load(f)
self._data = {
"images": loaded.get("images", {}),
"groups": loaded.get("groups", []),
}
except Exception as e:
logger.warning("GalleryMetadataStore: load error: %s", e)
def _save_to_disk(self):
try:
self.file_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
logger.error("GalleryMetadataStore: save error: %s", e)
return False
def _normalize_path(self, image_path: str) -> str:
return str(Path(image_path)).replace("\\", "/")
def get(self, image_path: str) -> dict:
key = self._normalize_path(image_path)
return dict(self._data["images"].get(key, {}))
def save(self, image_path: str, data: dict) -> bool:
key = self._normalize_path(image_path)
existing = self._data["images"].get(key, {})
existing.update(data)
allowed = {"favorite", "tags", "memo", "groups", "workflow"}
existing = {k: v for k, v in existing.items() if k in allowed}
self._data["images"][key] = existing
return self._save_to_disk()
# ── グループ管理 ──────────────────────────────────────────────
def list_groups(self) -> list[dict]:
return list(self._data.get("groups", []))
def create_group(self, name: str) -> bool:
groups = self._data.get("groups", [])
if any(g["name"] == name for g in groups):
return False
groups.append({"name": name})
self._data["groups"] = groups
return self._save_to_disk()
def rename_group(self, old_name: str, new_name: str) -> bool:
"""グループ名を変更し、全画像のgroupsフィールドも更新する"""
groups = self._data.get("groups", [])
if not any(g["name"] == old_name for g in groups):
return False
if any(g["name"] == new_name for g in groups):
return False # 新名前が既に存在
# グループリスト更新
for g in groups:
if g["name"] == old_name:
g["name"] = new_name
break
# 全画像のgroupsフィールドを更新
for meta in self._data["images"].values():
img_groups = meta.get("groups", [])
if old_name in img_groups:
meta["groups"] = [new_name if g == old_name else g for g in img_groups]
return self._save_to_disk()
def delete_group(self, name: str) -> bool:
groups = [g for g in self._data.get("groups", []) if g["name"] != name]
self._data["groups"] = groups
# 画像のgroupsからも削除
for meta in self._data["images"].values():
if name in meta.get("groups", []):
meta["groups"] = [g for g in meta["groups"] if g != name]
return self._save_to_disk()
def list_images_in_group(self, group_name: str) -> list[str]:
result = []
for key, meta in self._data["images"].items():
if group_name in meta.get("groups", []):
result.append(key)
return result
def get_group_member_set(self, group_name: str) -> set:
"""グループメンバーのパスをsetで返す(高速フィルタ用)"""
return {
key for key, meta in self._data["images"].items()
if group_name in meta.get("groups", [])
}
def delete(self, image_path: str) -> bool:
"""画像のメタデータエントリを削除する"""
key = self._normalize_path(image_path)
if key in self._data["images"]:
del self._data["images"][key]
return self._save_to_disk()
return True
def rename_path(self, old_path: str, new_path: str) -> bool:
"""画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ)"""
old_key = self._normalize_path(old_path)
new_key = self._normalize_path(new_path)
if old_key in self._data["images"]:
self._data["images"][new_key] = self._data["images"].pop(old_key)
return self._save_to_disk()
return True
+542
View File
@@ -0,0 +1,542 @@
"""
Gallery Service - outputフォルダの画像管理、メタデータ閲覧
"""
import json
import logging
import mimetypes
import os
import re
import struct
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# サポートする画像拡張子
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
# メタデータ保存ファイル (gallery専用)
from .gallery_metadata import GalleryMetadataStore
# PNGチャンク1個の最大許容サイズ (32MB)
_PNG_CHUNK_MAX = 32 * 1024 * 1024
# JPEGを読む際の最大ファイルサイズ (64MB)
_JPEG_READ_MAX = 64 * 1024 * 1024
# フォルダキャッシュのTTL秒(フォルダmtimeが同じでも念のため)
_CACHE_TTL = 30.0
class _FolderCache:
"""フォルダ単位の画像スキャン結果キャッシュ。
フォルダのmtimeが変わった場合、またはTTL超過時に再スキャンする。"""
def __init__(self):
# folder_path -> (mtime, scan_time, [(name, path_str, size, mtime_f), ...])
self._cache: dict[str, tuple[float, float, list]] = {}
def get(self, folder: Path) -> list | None:
key = str(folder)
if key not in self._cache:
return None
cached_mtime, scan_time, entries = self._cache[key]
# TTL チェック
if time.monotonic() - scan_time > _CACHE_TTL:
return None
# フォルダ自体のmtimeチェック
try:
current_mtime = folder.stat().st_mtime
except OSError:
return None
if current_mtime != cached_mtime:
return None
return entries
def set(self, folder: Path, entries: list):
key = str(folder)
try:
folder_mtime = folder.stat().st_mtime
except OSError:
folder_mtime = 0.0
self._cache[key] = (folder_mtime, time.monotonic(), entries)
def invalidate(self, folder: Path):
self._cache.pop(str(folder), None)
class GalleryService:
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.metadata_store = GalleryMetadataStore(data_dir / "gallery_metadata.json")
self._allowed_root: Path | None = None
self._folder_cache = _FolderCache()
def update_output_root(self, root_path: str) -> None:
"""許可するルートパスを更新する(Settings変更時に呼ぶ)"""
p = Path(root_path).resolve() if root_path else None
self._allowed_root = p
def _check_path_allowed(self, path: Path) -> bool:
"""パスが許可ルート配下かチェック(パストラバーサル防止)"""
if self._allowed_root is None:
resolved = path.resolve()
return resolved == path.resolve()
try:
path.resolve().relative_to(self._allowed_root)
return True
except ValueError:
return False
# ──────────────────────────────────────────────────────────────
# フォルダツリー
# ──────────────────────────────────────────────────────────────
def list_folder_tree(self, root_path: str) -> dict:
"""outputフォルダのフォルダツリーを返す"""
root = Path(root_path).resolve()
if not root.is_dir():
return {"error": f"Directory not found: {root_path}"}
if self._allowed_root is None:
self._allowed_root = root
def build_tree(path: Path, rel_base: Path) -> dict:
rel = str(path.relative_to(rel_base)).replace("\\", "/")
image_count = 0
children = []
try:
with os.scandir(path) as it:
for entry in it:
if entry.is_file(follow_symlinks=True):
if Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
image_count += 1
elif entry.is_dir(follow_symlinks=True) and not entry.name.startswith("."):
children.append(entry.name)
except PermissionError:
pass
children_nodes = [build_tree(path / name, rel_base) for name in sorted(children)]
return {
"name": path.name,
"path": rel if rel != "." else "",
"abs_path": str(path).replace("\\", "/"),
"image_count": image_count,
"children": children_nodes,
}
return build_tree(root, root)
# ──────────────────────────────────────────────────────────────
# 画像一覧
# ──────────────────────────────────────────────────────────────
def _scan_folder(self, folder: Path) -> list[tuple[str, str, int, float]]:
"""os.scandir() で画像ファイルを1回のシステムコールで列挙。
キャッシュがあればそれを返す。
Returns: [(name, abs_path_str, size, mtime), ...]
"""
cached = self._folder_cache.get(folder)
if cached is not None:
return cached
entries = []
try:
with os.scandir(folder) as it:
for entry in it:
if not entry.is_file(follow_symlinks=True):
continue
if Path(entry.name).suffix.lower() not in IMAGE_EXTENSIONS:
continue
try:
st = entry.stat(follow_symlinks=True)
entries.append((
entry.name,
str(Path(entry.path).resolve()).replace("\\", "/"),
st.st_size,
st.st_mtime,
))
except OSError:
continue
except PermissionError:
pass
self._folder_cache.set(folder, entries)
return entries
def list_images(
self,
folder_path: str,
search: str = "",
sort_by: str = "date_desc",
favorite_only: bool = False,
tag_filter: str = "",
group_filter: str = "",
) -> list[dict]:
"""指定フォルダ内の画像一覧を返す(サブフォルダなし)"""
folder = Path(folder_path).resolve()
if not folder.is_dir():
return []
if not self._check_path_allowed(folder):
logger.warning("list_images: path not allowed: %s", folder)
return []
# グループフィルタ: サーバーサイドで先にパスSetを取得(転送量削減)
group_member_set: set | None = None
if group_filter:
group_member_set = self.metadata_store.get_group_member_set(group_filter)
# os.scandir() でファイル情報を一括取得(stat()の個別呼び出しを排除)
raw_entries = self._scan_folder(folder)
results = []
for name, abs_path, size, mtime in raw_entries:
# グループフィルタ(サーバーサイド)
if group_member_set is not None and abs_path not in group_member_set:
continue
meta = self.metadata_store.get(abs_path)
# お気に入りフィルタ(サーバーサイド、早期スキップ)
if favorite_only and not meta.get("favorite", False):
continue
# タグフィルタ(サーバーサイド、早期スキップ)
tags = meta.get("tags", [])
if tag_filter and tag_filter not in tags:
continue
ext = Path(name).suffix.lower()
item = {
"filename": name,
"path": abs_path,
"size": size,
"mtime": mtime,
"ext": ext,
"favorite": meta.get("favorite", False),
"tags": tags,
"memo": meta.get("memo", ""),
"groups": meta.get("groups", []),
}
# 検索フィルタ
if search:
s = search.lower()
if not (
s in name.lower()
or s in item["memo"].lower()
or any(s in t.lower() for t in tags)
):
continue
results.append(item)
# ソート
if sort_by == "name_asc":
results.sort(key=lambda x: x["filename"].lower())
elif sort_by == "name_desc":
results.sort(key=lambda x: x["filename"].lower(), reverse=True)
elif sort_by == "date_asc":
results.sort(key=lambda x: x["mtime"])
else: # date_desc (default)
results.sort(key=lambda x: x["mtime"], reverse=True)
return results
# ──────────────────────────────────────────────────────────────
# 画像メタデータ
# ──────────────────────────────────────────────────────────────
def get_image_metadata(self, image_path: str) -> dict:
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
path = Path(image_path).resolve()
if not path.is_file():
return {"error": "File not found"}
if not self._check_path_allowed(path):
return {"error": "Access denied"}
embedded = {}
ext = path.suffix.lower()
if ext == ".png":
embedded = self._read_png_metadata(path)
elif ext in {".jpg", ".jpeg"}:
embedded = self._read_jpeg_metadata(path)
saved = self.metadata_store.get(str(path))
stat = path.stat()
return {
"filename": path.name,
"path": str(path).replace("\\", "/"),
"size": stat.st_size,
"mtime": stat.st_mtime,
"ext": ext,
"embedded": embedded,
"favorite": saved.get("favorite", False),
"tags": saved.get("tags", []),
"memo": saved.get("memo", ""),
"groups": saved.get("groups", []),
}
def _read_png_metadata(self, path: Path) -> dict:
"""PNGのtEXtチャンクからメタデータを抽出"""
result = {}
try:
with open(path, "rb") as f:
sig = f.read(8)
if sig != b"\x89PNG\r\n\x1a\n":
return result
while True:
chunk_len_bytes = f.read(4)
if len(chunk_len_bytes) < 4:
break
chunk_len = struct.unpack(">I", chunk_len_bytes)[0]
chunk_type = f.read(4).decode("ascii", errors="ignore")
if chunk_len > _PNG_CHUNK_MAX:
f.seek(chunk_len + 4, 1)
if chunk_type == "IEND":
break
continue
chunk_data = f.read(chunk_len)
f.read(4) # CRC
if chunk_type in ("tEXt", "iTXt"):
try:
if chunk_type == "tEXt":
null_idx = chunk_data.index(b"\x00")
key = chunk_data[:null_idx].decode("latin-1")
value = chunk_data[null_idx + 1:].decode("latin-1")
else:
null_idx = chunk_data.index(b"\x00")
key = chunk_data[:null_idx].decode("utf-8")
rest = chunk_data[null_idx + 1:]
rest = rest[2:]
null_idx2 = rest.index(b"\x00")
rest = rest[null_idx2 + 1:]
null_idx3 = rest.index(b"\x00")
value = rest[null_idx3 + 1:].decode("utf-8", errors="replace")
result[key] = value
except (ValueError, UnicodeDecodeError):
pass
if chunk_type == "IEND":
break
except Exception as e:
logger.debug("PNG metadata read error: %s", e)
return result
def _read_jpeg_metadata(self, path: Path) -> dict:
"""JPEGのEXIF/commentからメタデータを抽出(簡易)"""
result = {}
try:
file_size = path.stat().st_size
read_size = min(file_size, _JPEG_READ_MAX)
with open(path, "rb") as f:
data = f.read(read_size)
idx = 0
while idx < len(data) - 3:
if data[idx] == 0xFF:
marker = data[idx + 1]
if marker == 0xFE: # COM
length = struct.unpack(">H", data[idx + 2:idx + 4])[0]
end = idx + 2 + length
comment = data[idx + 4:end].decode("utf-8", errors="replace")
result["Comment"] = comment
break
idx += 2
else:
idx += 1
except Exception as e:
logger.debug("JPEG metadata read error: %s", e)
return result
def extract_workflow_from_metadata(self, image_path: str) -> dict | None:
"""ワークフローを抽出する。
優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow]
"""
path = Path(image_path).resolve()
if not self._check_path_allowed(path):
return None
if path.suffix.lower() == ".png":
embedded = self._read_png_metadata(path)
# 1. workflow キー (ComfyUI UI形式)
for key in ("workflow", "Workflow"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 2. prompt キー (ComfyUI API形式 — 大多数の生成画像)
for key in ("prompt", "Prompt"):
s = embedded.get(key)
if s:
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# 3. gallery_metadata.json に保存されたworkflow
saved = self.metadata_store.get(str(path))
return saved.get("workflow") or None
# ──────────────────────────────────────────────────────────────
# メタデータ保存
# ──────────────────────────────────────────────────────────────
def save_image_meta(self, image_path: str, data: dict) -> bool:
return self.metadata_store.save(image_path, data)
def toggle_favorite(self, image_path: str) -> bool:
meta = self.metadata_store.get(image_path)
new_val = not meta.get("favorite", False)
self.metadata_store.save(image_path, {"favorite": new_val})
return new_val
# ──────────────────────────────────────────────────────────────
# グループ管理
# ──────────────────────────────────────────────────────────────
def list_groups(self) -> list[dict]:
return self.metadata_store.list_groups()
def create_group(self, name: str) -> bool:
return self.metadata_store.create_group(name)
def rename_group(self, old_name: str, new_name: str) -> bool:
return self.metadata_store.rename_group(old_name, new_name)
def delete_group(self, name: str) -> bool:
return self.metadata_store.delete_group(name)
def add_to_group(self, image_path: str, group_name: str) -> bool:
meta = self.metadata_store.get(image_path)
groups = meta.get("groups", [])
if group_name not in groups:
groups.append(group_name)
return self.metadata_store.save(image_path, {"groups": groups})
return True
def remove_from_group(self, image_path: str, group_name: str) -> bool:
meta = self.metadata_store.get(image_path)
groups = [g for g in meta.get("groups", []) if g != group_name]
return self.metadata_store.save(image_path, {"groups": groups})
def list_images_in_group(self, group_name: str) -> list[str]:
return self.metadata_store.list_images_in_group(group_name)
# ──────────────────────────────────────────────────────────────
# 画像配信
# ──────────────────────────────────────────────────────────────
def create_folder(self, parent_path: str, name: str) -> dict:
"""選択フォルダ内に新しいサブフォルダを作成する"""
parent = Path(parent_path).resolve()
if not parent.is_dir():
return {"ok": False, "error": "Parent directory not found"}
if not self._check_path_allowed(parent):
return {"ok": False, "error": "Access denied"}
name = name.strip()
invalid_chars = set(r'\/:*?"<>|')
if not name or any(c in invalid_chars for c in name):
return {"ok": False, "error": "Invalid folder name"}
new_folder = parent / name
if new_folder.exists():
return {"ok": False, "error": "Folder already exists"}
try:
new_folder.mkdir()
self._folder_cache.invalidate(parent)
return {"ok": True, "path": str(new_folder).replace("\\", "/")}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_folder(self, folder_path: str) -> dict:
"""フォルダを再帰的に削除する"""
import shutil
folder = Path(folder_path).resolve()
if not folder.is_dir():
return {"ok": False, "error": "Directory not found"}
if not self._check_path_allowed(folder):
return {"ok": False, "error": "Access denied"}
if self._allowed_root and folder == self._allowed_root:
return {"ok": False, "error": "Cannot delete root folder"}
try:
parent = folder.parent
shutil.rmtree(folder)
self._folder_cache.invalidate(parent)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
def delete_images(self, paths: list) -> dict:
"""画像ファイルを削除する(複数対応)"""
deleted = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
try:
p.unlink()
self.metadata_store.delete(str(p))
self._folder_cache.invalidate(p.parent)
deleted.append(str(p).replace("\\", "/"))
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"deleted": deleted, "errors": errors}
def move_images(self, paths: list, dest_folder: str) -> dict:
"""画像ファイルを別フォルダへ移動する(複数対応)"""
dest = Path(dest_folder).resolve()
if not dest.is_dir():
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
if not self._check_path_allowed(dest):
return {"ok": False, "error": "Access denied", "moved": [], "errors": []}
moved = []
errors = []
for img_path in paths:
p = Path(img_path).resolve()
if not p.is_file():
errors.append(f"{Path(img_path).name}: not found")
continue
if p.suffix.lower() not in IMAGE_EXTENSIONS:
errors.append(f"{p.name}: not an image")
continue
if not self._check_path_allowed(p):
errors.append(f"{p.name}: access denied")
continue
dest_path = dest / p.name
counter = 1
while dest_path.exists():
dest_path = dest / f"{p.stem}_{counter}{p.suffix}"
counter += 1
try:
p.rename(dest_path)
old_str = str(p).replace("\\", "/")
new_str = str(dest_path).replace("\\", "/")
self.metadata_store.rename_path(old_str, new_str)
self._folder_cache.invalidate(p.parent)
self._folder_cache.invalidate(dest)
moved.append({"from": old_str, "to": new_str, "filename": dest_path.name})
except Exception as e:
errors.append(f"{p.name}: {e}")
return {"moved": moved, "errors": errors}
def serve_image(self, image_path: str):
"""画像のPathオブジェクトを返す(ルートで使用)"""
p = Path(image_path).resolve()
if not p.is_file():
return None
if p.suffix.lower() not in IMAGE_EXTENSIONS:
return None
if not self._check_path_allowed(p):
logger.warning("serve_image: path not allowed: %s", p)
return None
return p
+276
View File
@@ -0,0 +1,276 @@
"""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"]
# All sidecar extensions to delete when a model is deleted
_SIDECAR_EXTENSIONS = [
".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp",
".json", ".civitai.info", ".info",
]
_DISABLED_SUFFIX = ".disabled"
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
"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, model_type=None):
data = self._load_metadata()
raw = data.get("_groups", {})
# Migrate old flat format { "groupName": [...] } → per-type { "checkpoint": {...} }
if raw and not any(k in MODEL_TYPE_FOLDER_KEYS for k in raw):
logger.info("Migrating groups to per-type format (old data moved to _groups_legacy)")
data["_groups_legacy"] = raw
data["_groups"] = {}
self._save_metadata(data)
raw = {}
if model_type:
return raw.get(model_type, {})
return raw
def save_model_groups(self, groups, model_type):
data = self._load_metadata()
if not isinstance(data.get("_groups"), dict):
data["_groups"] = {}
data["_groups"][model_type] = groups
self._save_metadata(data)
return groups
def find_model_file(self, model_type, model_name):
"""Find a model file, checking both enabled and disabled states.
Returns (Path, is_enabled) or (None, None) if not found.
"""
dirs = _get_model_dirs(model_type)
for d in dirs:
enabled = d / model_name
if enabled.is_file():
return enabled, True
disabled = d / (model_name + _DISABLED_SUFFIX)
if disabled.is_file():
return disabled, False
return None, None
def enable_model(self, model_type, model_name):
"""Rename model.safetensors.disabled → model.safetensors."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if is_enabled:
return
target = path.parent / path.name[: -len(_DISABLED_SUFFIX)]
path.rename(target)
logger.info("Enabled model: %s", model_name)
def disable_model(self, model_type, model_name):
"""Rename model.safetensors → model.safetensors.disabled."""
path, is_enabled = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
if not is_enabled:
return
target = path.parent / (path.name + _DISABLED_SUFFIX)
path.rename(target)
logger.info("Disabled model: %s", model_name)
def scan_disabled_models(self, model_type):
"""Scan directories for .disabled files.
Returns list of normalized model names (without .disabled suffix).
"""
dirs = _get_model_dirs(model_type)
disabled = []
for d in dirs:
if not d.is_dir():
continue
for f in d.rglob("*"):
if f.is_file() and f.name.endswith(_DISABLED_SUFFIX):
try:
rel = str(f.relative_to(d))
if rel.endswith(_DISABLED_SUFFIX):
rel = rel[: -len(_DISABLED_SUFFIX)]
disabled.append(rel.replace("\\", "/"))
except ValueError:
pass
return disabled
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
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
def delete_model(self, model_type, model_name):
"""Delete a model file and all associated preview/sidecar files.
Also removes the model's metadata entry.
Returns dict with deleted file paths.
"""
if ".." in model_name:
raise ValueError(f"Invalid model name: {model_name}")
path, _ = self.find_model_file(model_type, model_name)
if path is None:
raise FileNotFoundError(f"Model not found: {model_name}")
deleted = []
parent = path.parent
# Compute stem from the original (non-disabled) filename.
# path.name for a disabled file is e.g. "model.safetensors.disabled"
# so we strip the .disabled suffix first, then take Path.stem.
orig_name = path.name
if orig_name.endswith(_DISABLED_SUFFIX):
orig_name = orig_name[: -len(_DISABLED_SUFFIX)]
stem = Path(orig_name).stem # "model.safetensors" -> "model"
# Delete the model file itself
path.unlink()
deleted.append(str(path))
logger.info("Deleted model file: %s", path)
# Delete all sidecar files (previews, metadata, info)
for ext in _SIDECAR_EXTENSIONS:
sidecar = parent / (stem + ext)
if sidecar.is_file():
sidecar.unlink()
deleted.append(str(sidecar))
logger.info("Deleted sidecar: %s", sidecar)
# Remove metadata entry
data = self._load_metadata()
if model_name in data:
del data[model_name]
self._save_metadata(data)
return {"deleted": deleted}
+146
View File
@@ -0,0 +1,146 @@
"""Node metadata and node sets management service."""
import json
import uuid
from datetime import datetime, timezone
from ..config import NODE_METADATA_FILE, NODE_SETS_FILE
class NodesService:
"""Manages user-defined node metadata (tags, favorites, groups) and node sets."""
def __init__(self):
self.node_metadata_file = NODE_METADATA_FILE
self.node_sets_file = NODE_SETS_FILE
def _now_iso(self):
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# ── Node Metadata ──────────────────────────────────────────
def _load_node_metadata(self):
if self.node_metadata_file.exists():
try:
with open(self.node_metadata_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_node_metadata(self, data):
self.node_metadata_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.node_metadata_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_all_metadata(self):
return self._load_node_metadata()
def update_node_metadata(self, node_name, updates):
data = self._load_node_metadata()
if node_name not in data:
data[node_name] = {"tags": [], "favorite": False}
entry = data[node_name]
if "tags" in updates:
entry["tags"] = updates["tags"]
if "favorite" in updates:
entry["favorite"] = updates["favorite"]
entry["updatedAt"] = self._now_iso()
data[node_name] = entry
self._save_node_metadata(data)
return entry
# ── Node Groups ────────────────────────────────────────────
def get_node_groups(self):
data = self._load_node_metadata()
return data.get("_groups", {})
def save_node_groups(self, groups):
data = self._load_node_metadata()
data["_groups"] = groups
self._save_node_metadata(data)
return groups
# ── Node Sets ──────────────────────────────────────────────
def _load_node_sets(self):
if self.node_sets_file.exists():
try:
with open(self.node_sets_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def _save_node_sets(self, sets):
self.node_sets_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.node_sets_file, "w", encoding="utf-8") as f:
json.dump(sets, f, ensure_ascii=False, indent=2)
def list_node_sets(self):
return self._load_node_sets()
def create_node_set(self, set_data):
sets = self._load_node_sets()
set_data["id"] = str(uuid.uuid4())
set_data["created_at"] = self._now_iso()
sets.append(set_data)
self._save_node_sets(sets)
return set_data
def update_node_set(self, set_id, updates):
sets = self._load_node_sets()
for s in sets:
if s["id"] == set_id:
for key in ("name", "description", "tags", "nodes", "links"):
if key in updates:
s[key] = updates[key]
s["updated_at"] = self._now_iso()
self._save_node_sets(sets)
return s
return None
def delete_node_set(self, set_id):
sets = self._load_node_sets()
sets = [s for s in sets if s["id"] != set_id]
self._save_node_sets(sets)
def export_node_set_json(self, set_id):
"""Export a node set as ComfyUI-compatible partial workflow JSON."""
sets = self._load_node_sets()
target = None
for s in sets:
if s["id"] == set_id:
target = s
break
if target is None:
return None
nodes = target.get("nodes", [])
links = target.get("links", [])
# Build prompt-format dict
prompt = {}
base_id = 100
for i, node in enumerate(nodes):
node_id = str(base_id + i)
inputs = {}
# Apply links: set linked inputs as [source_node_id, slot]
for link in links:
if link.get("to_node") == i:
input_name = link.get("to_input", "")
from_id = str(base_id + link["from_node"])
inputs[input_name] = [from_id, link.get("from_slot", 0)]
prompt[node_id] = {
"class_type": node["class_type"],
"inputs": inputs,
}
if node.get("title"):
prompt[node_id]["_meta"] = {"title": node["title"]}
return {
"name": target.get("name", ""),
"prompt": prompt,
"node_count": len(nodes),
}
+89
View File
@@ -0,0 +1,89 @@
"""Prompt presets management service."""
import json
import uuid
from datetime import datetime, timezone
from ..config import PROMPTS_FILE
class PromptsService:
"""Manages prompt presets (CRUD, categories, favorites)."""
def __init__(self):
self.prompts_file = PROMPTS_FILE
def _now_iso(self):
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# ── Persistence ─────────────────────────────────────────
def _load(self):
if self.prompts_file.exists():
try:
with open(self.prompts_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def _save(self, prompts):
self.prompts_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.prompts_file, "w", encoding="utf-8") as f:
json.dump(prompts, f, ensure_ascii=False, indent=2)
# ── List ────────────────────────────────────────────────
def list_prompts(self):
return self._load()
# ── Create ──────────────────────────────────────────────
def create_prompt(self, data):
prompts = self._load()
prompt = {
"id": str(uuid.uuid4()),
"name": data.get("name", "Untitled"),
"text": data.get("text", ""),
"negText": data.get("negText", ""),
"category": data.get("category", ""),
"tags": data.get("tags", []),
"favorite": data.get("favorite", False),
"created_at": self._now_iso(),
"updated_at": self._now_iso(),
}
prompts.append(prompt)
self._save(prompts)
return prompt
# ── Update ──────────────────────────────────────────────
def update_prompt(self, prompt_id, updates):
prompts = self._load()
for p in prompts:
if p["id"] == prompt_id:
for key in ("name", "text", "negText", "category", "tags", "favorite"):
if key in updates:
p[key] = updates[key]
p["updated_at"] = self._now_iso()
self._save(prompts)
return p
return None
# ── Delete ──────────────────────────────────────────────
def delete_prompt(self, prompt_id):
prompts = self._load()
prompts = [p for p in prompts if p["id"] != prompt_id]
self._save(prompts)
# ── Categories (derived) ────────────────────────────────
def list_categories(self):
prompts = self._load()
cats = set()
for p in prompts:
c = p.get("category", "").strip()
if c:
cats.add(c)
return sorted(cats)
+24 -1
View File
@@ -7,7 +7,8 @@ import re
def _model_name_from_ui_node(node):
"""Extract model filename from UI format node (lowercase)."""
widgets = node.get("widgets_values", [])
return (widgets[0] if widgets else "").lower()
val = widgets[0] if widgets else ""
return val.lower() if isinstance(val, str) else ""
def _model_name_from_api_node(node):
@@ -109,8 +110,30 @@ def analyze_workflow(workflow_data, filename=""):
if ntype in ("SaveVideo", "VH_VideoCombine"):
outputs["videos"] += 1
# Detect workflow format
if raw_nodes is not None:
# App format: UI-based structure with definitions (subgraphs) or linearMode
extra = workflow_data.get("extra", {})
if (
"definitions" in workflow_data
or extra.get("linearMode") is True
):
wf_format = "app"
elif filename and filename.lower().endswith(".app.json"):
wf_format = "app"
else:
wf_format = "ui"
else:
# Check if all top-level values have class_type (API format)
has_class = any(
isinstance(v, dict) and "class_type" in v
for v in workflow_data.values()
)
wf_format = "api" if has_class else "unknown"
return {
"modelTypes": sorted(model_types),
"inputs": inputs,
"outputs": outputs,
"format": wf_format,
}
+39 -15
View File
@@ -42,8 +42,23 @@ class WorkflowService:
"""Validate filename has no path traversal."""
if not fname or "/" in fname or "\\" in fname:
return False
if fname in (".", "..") or "\x00" in fname:
return False
return True
def _safe_path(self, filename):
"""Resolve path and ensure it stays within workflows_dir.
Raises ValueError if path traversal is detected.
"""
if not self._validate_filename(filename):
raise ValueError(f"Invalid filename: {filename}")
resolved = (self.workflows_dir / filename).resolve()
workflows_resolved = self.workflows_dir.resolve()
if not str(resolved).startswith(str(workflows_resolved) + os.sep) and resolved != workflows_resolved:
raise ValueError(f"Path traversal detected: {filename}")
return resolved
def _now_iso(self):
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -69,6 +84,7 @@ class WorkflowService:
"modelTypes": [],
"inputs": {"prompts": 0, "images": 0, "videos": 0},
"outputs": {"images": 0, "videos": 0},
"format": "unknown",
}
stat = fpath.stat()
@@ -96,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,
@@ -106,7 +123,7 @@ class WorkflowService:
def get_raw(self, filename):
"""Get raw workflow JSON content."""
fpath = self.workflows_dir / filename
fpath = self._safe_path(filename)
if not fpath.is_file():
return None
with open(fpath, "r", encoding="utf-8") as f:
@@ -116,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()
@@ -132,10 +149,16 @@ class WorkflowService:
ext = os.path.splitext(original_name)[1].lower()
base = os.path.splitext(original_name)[0]
if not self._validate_filename(original_name):
results.append(
{"name": original_name, "status": "error", "message": "Invalid filename"}
)
continue
if ext == ".json":
try:
wf_data = json.loads(file_data.decode("utf-8"))
out_path = self.workflows_dir / original_name
out_path = self._safe_path(original_name)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(wf_data, f, ensure_ascii=False, indent=2)
results.append({"name": original_name, "status": "success"})
@@ -158,13 +181,13 @@ class WorkflowService:
try:
# Save PNG as thumbnail
with open(
self.workflows_dir / original_name, "wb"
self._safe_path(original_name), "wb"
) as f:
f.write(file_data)
# Save extracted workflow as JSON
json_name = base + ".json"
with open(
self.workflows_dir / json_name, "w", encoding="utf-8"
self._safe_path(json_name), "w", encoding="utf-8"
) as f:
json.dump(wf_data, f, ensure_ascii=False, indent=2)
results.append({"name": json_name, "status": "success"})
@@ -190,8 +213,8 @@ class WorkflowService:
def rename(self, old_name, new_stem):
"""Rename workflow and its associated thumbnail."""
new_name = new_stem + ".json"
old_json = self.workflows_dir / old_name
new_json = self.workflows_dir / new_name
old_json = self._safe_path(old_name)
new_json = self._safe_path(new_name)
if not old_json.is_file():
return {"error": "file not found"}, 404
@@ -203,8 +226,8 @@ class WorkflowService:
# Rename associated thumbnail
old_stem = old_name[:-5] if old_name.endswith(".json") else old_name
for ext in (".png", ".webp"):
old_thumb = self.workflows_dir / (old_stem + ext)
new_thumb = self.workflows_dir / (new_stem + ext)
old_thumb = self._safe_path(old_stem + ext)
new_thumb = self._safe_path(new_stem + ext)
if old_thumb.is_file():
os.rename(old_thumb, new_thumb)
@@ -218,13 +241,13 @@ class WorkflowService:
def delete(self, filename):
"""Delete workflow JSON and associated thumbnail."""
json_path = self.workflows_dir / filename
json_path = self._safe_path(filename)
if json_path.is_file():
os.remove(json_path)
base = filename[:-5] if filename.endswith(".json") else filename
for ext in (".png", ".webp"):
thumb_path = self.workflows_dir / (base + ext)
thumb_path = self._safe_path(base + ext)
if thumb_path.is_file():
os.remove(thumb_path)
@@ -235,7 +258,7 @@ class WorkflowService:
def analyze(self, filename):
"""Re-analyze a workflow and save results to metadata."""
fpath = self.workflows_dir / filename
fpath = self._safe_path(filename)
if not fpath.is_file():
return None
@@ -280,6 +303,7 @@ class WorkflowService:
def change_thumbnail(self, filename, image_data, original_image_name):
"""Change workflow thumbnail image."""
self._safe_path(filename) # validate filename
orig_name_lower = original_image_name.lower()
if orig_name_lower.endswith(".webp"):
new_ext = ".webp"
@@ -292,15 +316,15 @@ class WorkflowService:
# Backup existing thumbnails
for ext in (".png", ".webp", ".jpg", ".jpeg"):
old_thumb = self.workflows_dir / (stem + ext)
old_thumb = self._safe_path(stem + ext)
if old_thumb.is_file():
backup = self.workflows_dir / ("old_" + stem + ext)
backup = self._safe_path("old_" + stem + ext)
if backup.is_file():
os.remove(backup)
os.rename(old_thumb, backup)
# Save new thumbnail
new_thumb_path = self.workflows_dir / (stem + new_ext)
new_thumb_path = self._safe_path(stem + new_ext)
with open(new_thumb_path, "wb") as f:
f.write(image_data)
+5 -1
View File
@@ -61,11 +61,15 @@ class WorkflowStudio:
app.router.add_get("/wfm", serve_index_page)
# API routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
ollama_routes.setup_routes(app)
eagle_routes.setup_routes(app)
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
gallery_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.7"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
[project.urls]
Repository = "https://github.com/ketle-man/ComfyUI-Workflow-Studio"
[tool.comfy]
PublisherId = "ketle-man"
DisplayName = "Workflow Studio"
Icon = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/icon.svg"
Banner = "https://raw.githubusercontent.com/ketle-man/ComfyUI-Workflow-Studio/main/assets/banner.svg"
+649
View File
@@ -0,0 +1,649 @@
/* ============================================================
Gallery Tab Styles
============================================================ */
/* ── 3カラムレイアウト ──────────────────────────────────────── */
.wfm-gallery-layout {
display: flex;
height: calc(100vh - var(--wfm-header-height, 96px));
overflow: hidden;
gap: 0;
}
/* ── 左: フォルダツリーパネル ──────────────────────────────── */
.wfm-gallery-tree-panel {
width: 250px;
min-width: 250px;
max-width: 250px;
border-right: 1px solid var(--wfm-border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wfm-bg-secondary, #1a1a2e);
}
.wfm-gallery-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 4px;
}
.wfm-gallery-tree-title {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1;
}
.wfm-gallery-tree-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.wfm-gallery-tree {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
}
.wfm-gallery-tree-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
color: var(--wfm-text-primary);
-webkit-user-select: none;
user-select: none;
transition: background 0.1s;
}
.wfm-gallery-tree-item:hover {
background: var(--wfm-bg-hover, rgba(255,255,255,0.06));
}
.wfm-gallery-tree-item.selected {
background: var(--wfm-accent, #4f8ef7);
color: #fff;
}
.wfm-gallery-tree-item.selected .wfm-gallery-tree-count {
background: rgba(255,255,255,0.25);
}
.wfm-gallery-tree-arrow {
font-size: 10px;
width: 14px;
flex-shrink: 0;
cursor: pointer;
color: var(--wfm-text-secondary);
transition: transform 0.15s;
}
.wfm-gallery-tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.wfm-gallery-tree-count {
font-size: 10px;
background: var(--wfm-accent-muted, rgba(79,142,247,0.2));
color: var(--wfm-accent, #4f8ef7);
border-radius: 8px;
padding: 0 5px;
line-height: 1.4;
flex-shrink: 0;
}
.wfm-gallery-tree-children {
/* 子ノードコンテナ */
}
/* ── 中: 画像一覧パネル ────────────────────────────────────── */
.wfm-gallery-list-panel {
flex: 1;
overflow-y: auto;
min-width: 0;
border-right: 1px solid var(--wfm-border);
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-grid {
padding: 8px;
}
/* サムネイルビュー */
.wfm-gallery-view-thumb {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
align-items: start;
}
.wfm-gallery-thumb-card {
position: relative;
background: var(--wfm-bg-secondary, #1a1a2e);
border: 1px solid var(--wfm-border);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.wfm-gallery-thumb-card:hover {
border-color: var(--wfm-accent, #4f8ef7);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.wfm-gallery-thumb-card.selected {
border-color: var(--wfm-accent, #4f8ef7);
box-shadow: 0 0 0 2px var(--wfm-accent, #4f8ef7);
}
.wfm-gallery-thumb-card.multi-selected {
border-color: #f59e0b;
box-shadow: 0 0 0 2px #f59e0b;
}
.wfm-gallery-thumb-img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: var(--wfm-bg-hover, #111);
}
.wfm-gallery-thumb-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--wfm-bg-hover, #222);
color: var(--wfm-text-secondary);
font-size: 24px;
}
/* お気に入りボタン(サムネイルカード右上) */
.wfm-gallery-thumb-fav-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 13px;
line-height: 1;
padding: 0;
cursor: pointer;
color: rgba(255,255,255,0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.wfm-gallery-thumb-card:hover .wfm-gallery-thumb-fav-btn {
opacity: 1;
}
.wfm-gallery-thumb-fav-btn.active {
color: #fbbf24;
opacity: 1;
}
.wfm-gallery-thumb-fav-btn:hover {
background: rgba(0,0,0,0.75);
color: #fbbf24;
}
/* テーブルビューのお気に入りボタン */
.wfm-gallery-table-fav-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: rgba(255,255,255,0.3);
padding: 2px 4px;
transition: color 0.15s;
}
.wfm-gallery-table-fav-btn:hover,
.wfm-gallery-table-fav-btn.active {
color: #fbbf24;
}
.wfm-gallery-thumb-tags {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px 6px;
}
/* テーブルビュー */
.wfm-gallery-view-table {
display: block;
}
.wfm-gallery-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.wfm-gallery-table thead th {
position: sticky;
top: 0;
background: var(--wfm-bg-secondary, #1a1a2e);
color: var(--wfm-text-secondary);
font-weight: 600;
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid var(--wfm-border);
z-index: 1;
}
.wfm-gallery-table tbody tr {
cursor: pointer;
transition: background 0.1s;
}
.wfm-gallery-table tbody tr:hover {
background: var(--wfm-bg-hover, rgba(255,255,255,0.05));
}
.wfm-gallery-table tbody tr.selected {
background: rgba(79,142,247,0.15);
}
.wfm-gallery-table tbody tr.multi-selected {
background: rgba(245,158,11,0.15);
}
.wfm-gallery-table td {
padding: 4px 8px;
border-bottom: 1px solid var(--wfm-border);
color: var(--wfm-text-primary);
vertical-align: middle;
}
.wfm-gallery-table-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 3px;
display: block;
}
.wfm-gallery-table-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── タグバッジ共通 ────────────────────────────────────────── */
.wfm-gallery-tag-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(79,142,247,0.15);
color: var(--wfm-accent, #4f8ef7);
border: 1px solid rgba(79,142,247,0.3);
border-radius: 10px;
padding: 1px 7px;
font-size: 11px;
white-space: nowrap;
}
.wfm-gallery-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 12px;
line-height: 1;
opacity: 0.7;
margin-left: 1px;
}
.wfm-gallery-tag-remove:hover {
opacity: 1;
}
/* ── 右: 詳細パネル ────────────────────────────────────────── */
.wfm-gallery-detail-panel {
width: 280px;
min-width: 280px;
max-width: 280px;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wfm-bg-secondary, #1a1a2e);
}
.wfm-gallery-detail-header {
padding: 6px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
}
.wfm-gallery-detail-filename {
font-size: 12px;
font-weight: 600;
color: var(--wfm-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* プレビュー */
.wfm-gallery-detail-preview {
width: 100%;
aspect-ratio: 1 / 1;
max-height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--wfm-bg-primary, #16213e);
border-bottom: 1px solid var(--wfm-border);
overflow: hidden;
flex-shrink: 0;
}
.wfm-gallery-detail-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
cursor: pointer;
}
/* タブコンテンツ */
.wfm-gallery-detail-tab-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.wfm-gallery-section-title {
font-size: 13px;
font-weight: 600;
color: var(--wfm-text-primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.wfm-gallery-info-section {
margin-bottom: 4px;
padding: 6px 0;
}
.wfm-gallery-info-row {
font-size: 12px;
color: var(--wfm-text-primary);
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-gallery-tags-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.wfm-gallery-memo {
width: 100%;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
color: var(--wfm-text-primary);
font-size: 12px;
padding: 6px 8px;
resize: vertical;
box-sizing: border-box;
font-family: inherit;
}
.wfm-gallery-memo:focus {
outline: none;
border-color: var(--wfm-accent, #4f8ef7);
}
/* ── Metadataタブ(Workflow全幅表示) ─────────────────────── */
.wfm-gallery-meta-tab {
display: flex;
flex-direction: column;
padding: 0 !important;
overflow: hidden;
height: 100%;
}
.wfm-gallery-meta-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid var(--wfm-border);
flex-shrink: 0;
background: var(--wfm-bg-primary, #16213e);
gap: 8px;
}
.wfm-gallery-meta-status {
font-size: 11px;
color: var(--wfm-text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-gallery-workflow-json {
flex: 1;
margin: 0;
padding: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 11px;
line-height: 1.5;
color: var(--wfm-text-primary);
background: var(--wfm-bg-secondary, #1a1a2e);
overflow: auto;
white-space: pre;
word-break: normal;
border: none;
}
/* グループ */
.wfm-gallery-current-groups {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
font-size: 12px;
color: var(--wfm-text-secondary);
}
.wfm-gallery-groups-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.wfm-gallery-group-row {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 4px;
font-size: 12px;
}
.wfm-gallery-group-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wfm-text-primary);
}
/* ── 一括操作バー ──────────────────────────────────────────── */
.wfm-gallery-bulk-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: rgba(245,158,11,0.12);
border-bottom: 1px solid rgba(245,158,11,0.35);
flex-shrink: 0;
flex-wrap: wrap;
}
.wfm-gallery-bulk-count {
font-size: 12px;
font-weight: 600;
color: #f59e0b;
white-space: nowrap;
margin-right: 4px;
}
/* ── ライトボックス ─────────────────────────────────────────── */
.wfm-gallery-lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.9);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.wfm-gallery-lightbox-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
max-width: 90vw;
max-height: 90vh;
}
.wfm-gallery-lightbox-img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
cursor: default;
}
.wfm-gallery-lightbox-footer {
margin-top: 8px;
color: rgba(255,255,255,0.7);
font-size: 13px;
text-align: center;
}
.wfm-gallery-lightbox-close {
position: absolute;
top: -12px;
right: -12px;
background: rgba(0,0,0,0.7);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
border-radius: 50%;
width: 28px;
height: 28px;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.wfm-gallery-lightbox-close:hover {
background: rgba(255,255,255,0.2);
}
/* ── ファイル操作ボタン (詳細パネル) ─────────────────────────── */
.wfm-gallery-file-actions {
display: flex;
gap: 6px;
padding-top: 4px;
}
.wfm-gallery-file-actions .wfm-btn {
flex: 1;
}
/* ── 移動先選択モーダル ──────────────────────────────────────── */
.wfm-gallery-move-modal {
background: var(--wfm-bg-primary, #16213e);
border: 1px solid var(--wfm-border);
border-radius: 8px;
padding: 20px;
min-width: 320px;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 12px;
}
.wfm-gallery-move-modal-title {
font-size: 14px;
font-weight: 600;
color: var(--wfm-text-primary);
}
.wfm-gallery-move-dest-sel {
width: 100%;
padding: 6px 8px;
font-size: 13px;
}
.wfm-gallery-move-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
+1561 -36
View File
File diff suppressed because it is too large Load Diff
+109 -7
View File
@@ -90,7 +90,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", gallery: "tabGallery", settings: "tabSettings", help: "tabHelp" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -142,6 +142,60 @@ function applyI18nToHtml() {
const copyBtn = document.getElementById("wfm-json-copy-btn");
if (copyBtn) copyBtn.textContent = t("copy");
// Nodes tab
const nodesSubBtns = document.querySelectorAll(".wfm-nodes-subview-btn");
nodesSubBtns.forEach(btn => {
if (btn.dataset.subview === "browser") btn.textContent = t("nodesAllNodes");
if (btn.dataset.subview === "sets") btn.textContent = t("nodesNodeSets");
});
const nodesSearch = document.getElementById("wfm-nodes-search");
if (nodesSearch) nodesSearch.placeholder = t("nodesSearchPlaceholder");
const nodesRefreshBtn = document.getElementById("wfm-nodes-refresh-btn");
if (nodesRefreshBtn) nodesRefreshBtn.textContent = t("nodesRefresh");
const nodeSetCreateBtn = document.getElementById("wfm-node-set-create-btn");
if (nodeSetCreateBtn) nodeSetCreateBtn.textContent = t("nodesCreateSet");
const nodesSideTabBtns = document.querySelectorAll(".wfm-nodes-side-tab-btn");
nodesSideTabBtns.forEach(btn => {
if (btn.dataset.sideTab === "details") btn.textContent = t("nodesDetails");
if (btn.dataset.sideTab === "io") btn.textContent = t("nodesIoSpec");
if (btn.dataset.sideTab === "nodegroup") btn.textContent = t("nodesGroups");
});
const nodesPlaceholder = document.getElementById("wfm-nodes-placeholder");
if (nodesPlaceholder) nodesPlaceholder.textContent = t("nodesClickToLoad");
// Models tab
const modelsTypeBtns = document.querySelectorAll(".wfm-models-type-btn");
modelsTypeBtns.forEach(btn => {
const key = "models" + btn.dataset.modelType.charAt(0).toUpperCase() + btn.dataset.modelType.slice(1);
const label = t(key);
if (label && label !== key) btn.textContent = label;
});
const modelsSearch = document.getElementById("wfm-models-search");
if (modelsSearch) modelsSearch.placeholder = t("modelsSearchPlaceholder");
const modelsRefreshBtn = document.getElementById("wfm-models-refresh-btn");
if (modelsRefreshBtn) modelsRefreshBtn.textContent = t("modelsRefresh");
const modelsPlaceholder = document.getElementById("wfm-models-placeholder");
if (modelsPlaceholder) modelsPlaceholder.textContent = t("modelsClickToLoad");
const modelsTagFilter = document.getElementById("wfm-models-tag-filter");
if (modelsTagFilter) {
const firstOpt = modelsTagFilter.querySelector("option[value='']");
if (firstOpt) firstOpt.textContent = t("modelsAllTags");
}
const modelsSideTabBtns = document.querySelectorAll(".wfm-models-side-tab-btn");
modelsSideTabBtns.forEach(btn => {
if (btn.dataset.sideTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.sideTab === "group") btn.textContent = t("modelsSideGroup");
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
});
// Gallery tab
const gallerySideTabBtns = document.querySelectorAll(".wfm-gallery-detail-tab-btn");
gallerySideTabBtns.forEach(btn => {
if (btn.dataset.detailTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.detailTab === "meta") btn.textContent = t("gallerySideMetadata");
if (btn.dataset.detailTab === "group") btn.textContent = t("modelsSideGroup");
});
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
@@ -163,10 +217,12 @@ function applyI18nToHtml() {
if (en2jaBtn) en2jaBtn.textContent = t("en2ja");
const presetApplyBtn = document.getElementById("wfm-preset-apply-btn");
if (presetApplyBtn) presetApplyBtn.textContent = t("applyPreset");
const presetDeleteBtn = document.getElementById("wfm-preset-delete-btn");
if (presetDeleteBtn) presetDeleteBtn.textContent = t("deletePreset");
const presetSaveBtn = document.getElementById("wfm-preset-save-btn");
if (presetSaveBtn) presetSaveBtn.textContent = t("savePreset");
const presetCopyPosBtn = document.getElementById("wfm-preset-copy-pos-btn");
if (presetCopyPosBtn) presetCopyPosBtn.textContent = t("copyPositivePrompt");
const presetCopyNegBtn = document.getElementById("wfm-preset-copy-neg-btn");
if (presetCopyNegBtn) presetCopyNegBtn.textContent = t("copyNegativePrompt");
// Group panel labels
const propTitle = document.querySelector("#wfm-side-tab-group .wfm-group-section-title");
@@ -199,16 +255,53 @@ function applyI18nToHtml() {
if (helpManualTitle) helpManualTitle.textContent = t("helpManualTitle");
const helpIdMap = {
"wfm-help-about-title": "helpAboutTitle",
"wfm-help-about-desc": "helpAboutDesc",
"wfm-help-about-1": "helpAbout1", "wfm-help-about-2": "helpAbout2",
"wfm-help-wf-1": "helpWf1", "wfm-help-wf-2": "helpWf2", "wfm-help-wf-3": "helpWf3",
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5",
"wfm-help-wf-4": "helpWf4", "wfm-help-wf-5": "helpWf5", "wfm-help-wf-6": "helpWf6",
"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",
"wfm-help-prompt-7": "helpPrompt7",
"wfm-help-settings-1": "helpSettings1", "wfm-help-settings-2": "helpSettings2",
"wfm-help-settings-3": "helpSettings3", "wfm-help-settings-4": "helpSettings4",
"wfm-help-tips-title": "helpTipsTitle",
"wfm-help-tips-1": "helpTips1", "wfm-help-tips-2": "helpTips2", "wfm-help-tips-3": "helpTips3",
"wfm-help-settings-5": "helpSettings5", "wfm-help-settings-6": "helpSettings6",
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
"wfm-help-settings-9": "helpSettings9",
"wfm-help-nodes-1": "helpNodes1", "wfm-help-nodes-2": "helpNodes2",
"wfm-help-nodes-3": "helpNodes3", "wfm-help-nodes-4": "helpNodes4",
"wfm-help-nodes-5": "helpNodes5", "wfm-help-nodes-6": "helpNodes6",
"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-models-10": "helpModels10",
"wfm-help-models-11": "helpModels11",
"wfm-help-gallery-title": "helpGalleryTitle",
"wfm-help-gallery-1": "helpGallery1", "wfm-help-gallery-2": "helpGallery2",
"wfm-help-gallery-3": "helpGallery3", "wfm-help-gallery-4": "helpGallery4",
"wfm-help-gallery-5": "helpGallery5", "wfm-help-gallery-6": "helpGallery6",
"wfm-help-gallery-7": "helpGallery7", "wfm-help-gallery-8": "helpGallery8",
"wfm-help-gallery-9": "helpGallery9", "wfm-help-gallery-10": "helpGallery10",
"wfm-help-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
"wfm-help-sidepanel-5": "helpSidepanel5", "wfm-help-sidepanel-6": "helpSidepanel6",
"wfm-help-sidepanel-7": "helpSidepanel7", "wfm-help-sidepanel-8": "helpSidepanel8",
"wfm-help-sidepanel-9": "helpSidepanel9", "wfm-help-sidepanel-10": "helpSidepanel10",
"wfm-help-sidepanel-11": "helpSidepanel11", "wfm-help-sidepanel-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",
"wfm-help-trouble-title": "helpTroubleTitle",
"wfm-help-trouble-1": "helpTrouble1", "wfm-help-trouble-2": "helpTrouble2",
"wfm-help-trouble-3": "helpTrouble3", "wfm-help-trouble-4": "helpTrouble4",
"wfm-help-support-title": "helpSupportTitle",
"wfm-help-support-desc": "helpSupportDesc",
"wfm-help-github-desc": "helpGithubDesc",
@@ -253,9 +346,15 @@ function initModal() {
// ============================================
import { initWorkflowTab } from "./workflow-tab.js";
import { initNodesTab } from "./nodes-tab.js";
import { initGenerateTab } from "./generate-tab.js";
import { initPromptTab } from "./prompt-tab.js";
import { initSettingsTab } from "./settings-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
import { initModelsTab } from "./models-tab.js";
import { initGalleryTab } from "./gallery-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
document.addEventListener("DOMContentLoaded", () => {
applyI18nToHtml();
@@ -263,8 +362,11 @@ document.addEventListener("DOMContentLoaded", () => {
initModal();
initSettingsTab(); // Settings first (applies saved URL)
initWorkflowTab();
initNodesTab();
initModelsTab();
initGenerateTab();
initPromptTab();
initGalleryTab();
console.log("Workflow Studio: App initialized");
});
+33 -2
View File
@@ -70,19 +70,31 @@ export const comfyUI = {
});
},
// Model fetching
// Node info fetching
async fetchObjectInfo(nodeClass) {
const res = await fetch(`${this.baseUrl}/object_info/${nodeClass}`);
if (!res.ok) return null;
return await res.json();
},
async fetchAllObjectInfo() {
const res = await fetch(`${this.baseUrl}/object_info`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
},
async _fetchModelList(nodeClasses, inputKey) {
for (const cls of nodeClasses) {
try {
const info = await this.fetchObjectInfo(cls);
if (info?.[cls]?.input?.required?.[inputKey]) {
return info[cls].input.required[inputKey][0] || [];
const inputDef = info[cls].input.required[inputKey];
const first = inputDef[0];
if (Array.isArray(first)) return first;
// Newer ComfyUI format: ["COMBO", { "values": [...] }]
if (typeof first === "string" && Array.isArray(inputDef[1]?.values)) {
return inputDef[1].values;
}
}
} catch {}
}
@@ -128,6 +140,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">
+7 -2
View File
@@ -250,9 +250,14 @@ function _flattenSubgraphs(workflow) {
}
export const comfyWorkflow = {
detectFormat(workflow) {
detectFormat(workflow, filename) {
if (!workflow || typeof workflow !== "object") return "unknown";
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) return "ui";
if (Array.isArray(workflow.nodes) && workflow.links !== undefined) {
// App format: UI-based structure with definitions (subgraphs) or linearMode
if (workflow.definitions || workflow.extra?.linearMode === true) return "app";
if (filename && /\.app\.json$/i.test(filename)) return "app";
return "ui";
}
// API format: top-level keys are node IDs with class_type
const keys = Object.keys(workflow);
if (keys.length > 0 && keys.every((k) => workflow[k]?.class_type)) return "api";
File diff suppressed because it is too large Load Diff
+278 -75
View File
@@ -50,6 +50,42 @@ async function saveToEagle(imageUrl, name, tags = []) {
}
}
// ============================================
// Gallery Metadata - ワークフロー保存
// ============================================
let _outputDir = "";
async function _fetchOutputDir() {
try {
const res = await fetch("/api/wfm/settings/output-dir");
if (res.ok) {
const data = await res.json();
_outputDir = (data.current || "").replace(/\\/g, "/").replace(/\/$/, "");
}
} catch {}
}
async function saveGeneratedImagesMeta(images, workflow) {
if (!_outputDir) await _fetchOutputDir();
if (!_outputDir) return;
for (const img of images) {
if (img.type !== "output") continue;
const parts = [_outputDir];
if (img.subfolder) parts.push(img.subfolder);
parts.push(img.filename);
const path = parts.join("/");
try {
await fetch("/wfm/gallery/image/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, workflow }),
});
} catch {}
}
}
// ============================================
// Status & Connection
// ============================================
@@ -72,13 +108,16 @@ function updateStatus(connected) {
export async function loadWorkflowIntoEditor(workflow, filename) {
let apiWorkflow = workflow;
const format = comfyWorkflow.detectFormat(workflow);
const format = comfyWorkflow.detectFormat(workflow, filename);
if (format === "ui") {
if (format === "app") {
showToast(t("appFormatNotSupported"), "error");
return false;
} else if (format === "ui") {
apiWorkflow = await comfyWorkflow.convertUiToApi(workflow);
} else if (format === "unknown") {
showToast("Unknown workflow format", "error");
return;
return false;
}
comfyUI.currentWorkflow = apiWorkflow;
@@ -107,10 +146,203 @@ export async function loadWorkflowIntoEditor(workflow, filename) {
if (genBtn) genBtn.disabled = !comfyUI.connected;
showToast(`Workflow loaded: ${filename || ""}`, "success");
return true;
}
// ============================================
// Generation
// Checkpoint Batch
// ============================================
const _ckptBatch = { aborted: false };
function _parseFolderList(str) {
return str.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
}
function _getModelFolder(modelPath) {
const normalized = modelPath.replace(/\\/g, "/");
const idx = normalized.indexOf("/");
return idx === -1 ? "" : normalized.substring(0, idx).toLowerCase();
}
function _filterCheckpoints(checkpoints, includeStr, excludeStr) {
const inc = _parseFolderList(includeStr);
const exc = _parseFolderList(excludeStr);
return checkpoints.filter((m) => {
const folder = _getModelFolder(m);
if (inc.length > 0 && !inc.includes(folder)) return false;
if (exc.length > 0 && exc.includes(folder)) return false;
return true;
});
}
function _updateBatchInfo() {
const infoEl = document.getElementById("wfm-ckpt-batch-info");
if (!infoEl) return;
const inc = document.getElementById("wfm-ckpt-batch-include")?.value || "";
const exc = document.getElementById("wfm-ckpt-batch-exclude")?.value || "";
const n = _filterCheckpoints(comfyEditor.models.checkpoints, inc, exc).length;
infoEl.textContent = `${n} checkpoint${n !== 1 ? "s" : ""} will be processed`;
}
function initCheckpointBatch() {
const checkbox = document.getElementById("wfm-ckpt-batch-enabled");
const body = document.getElementById("wfm-ckpt-batch-body");
checkbox?.addEventListener("change", () => {
if (body) body.style.display = checkbox.checked ? "block" : "none";
if (checkbox.checked) _updateBatchInfo();
});
document.getElementById("wfm-ckpt-batch-include")?.addEventListener("input", _updateBatchInfo);
document.getElementById("wfm-ckpt-batch-exclude")?.addEventListener("input", _updateBatchInfo);
}
// ============================================
// Generation (core — throws on error)
// ============================================
async function _coreGenerate(silent = false) {
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
}
}
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
if (images.length > 0) {
saveGeneratedImagesMeta(images, { ...comfyUI.currentWorkflow }).catch(() => {});
}
if (!silent) showToast("Generation complete", "success");
}
// ============================================
// Batch generation loop
// ============================================
async function _runBatchGenerate() {
const ckptNodes = comfyUI.currentAnalysis?.checkpoint_nodes || [];
if (ckptNodes.length === 0) {
showToast("No checkpoint node found in workflow", "error");
return;
}
const inc = document.getElementById("wfm-ckpt-batch-include")?.value || "";
const exc = document.getElementById("wfm-ckpt-batch-exclude")?.value || "";
const list = _filterCheckpoints(comfyEditor.models.checkpoints, inc, exc);
if (list.length === 0) {
showToast("No checkpoints match the folder filter", "error");
return;
}
const batchProgress = document.getElementById("wfm-ckpt-batch-progress");
const batchCurrentName = document.getElementById("wfm-ckpt-batch-current-name");
const batchCount = document.getElementById("wfm-ckpt-batch-count");
const batchBar = document.getElementById("wfm-ckpt-batch-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
if (batchProgress) batchProgress.style.display = "block";
let completed = 0;
let failed = 0;
for (let i = 0; i < list.length; i++) {
if (_ckptBatch.aborted) break;
const model = list[i];
if (batchCurrentName) batchCurrentName.textContent = model;
if (batchCount) batchCount.textContent = `${i + 1} / ${list.length}`;
if (batchBar) batchBar.style.width = `${((i / list.length) * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `[${i + 1}/${list.length}] Loading...`;
for (const node of ckptNodes) {
if (comfyUI.currentWorkflow?.[node.id]) {
comfyUI.currentWorkflow[node.id].inputs.ckpt_name = model;
}
}
try {
await _coreGenerate(true);
completed++;
} catch (err) {
if (_ckptBatch.aborted) break;
failed++;
showToast(`[${i + 1}/${list.length}] Failed: ${err.message}`, "error");
}
}
if (batchBar) batchBar.style.width = "100%";
if (batchCurrentName) batchCurrentName.textContent = _ckptBatch.aborted ? "Stopped" : "Done";
if (_ckptBatch.aborted) {
showToast(`Batch stopped — ${completed} completed, ${failed} failed`, "info");
} else {
showToast(
`Batch complete: ${completed}/${list.length}${failed > 0 ? ` (${failed} failed)` : ""}`,
failed > 0 ? "error" : "success"
);
}
}
// ============================================
// Generate entry point
// ============================================
async function handleGenerate() {
@@ -120,84 +352,29 @@ async function handleGenerate() {
}
if (comfyUI.generating) return;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const progressBar = document.getElementById("wfm-gen-progress-bar");
const progressText = document.getElementById("wfm-gen-progress-text");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
const resultImg = document.getElementById("wfm-gen-result-img");
const resultThumbs = document.getElementById("wfm-gen-result-thumbs");
// Sync prompts to workflow
comfyEditor.syncToWorkflow();
const seedMode = document.getElementById("wfm-gen-seed-mode")?.value || "random";
const seedValue = parseInt(document.getElementById("wfm-gen-seed-value")?.value) || -1;
const genBtn = document.getElementById("wfm-gen-generate-btn");
const interruptBtn = document.getElementById("wfm-gen-interrupt-btn");
genBtn.disabled = true;
if (interruptBtn) interruptBtn.style.display = "inline-block";
if (progressBar) progressBar.style.width = "0%";
if (progressText) progressText.textContent = "Starting...";
_ckptBatch.aborted = false;
const batchEnabled = document.getElementById("wfm-ckpt-batch-enabled")?.checked;
try {
const { images, seed } = await comfyUI.generate(
{ ...comfyUI.currentWorkflow },
{
seedMode,
seedValue,
onProgress: (pct) => {
if (progressBar) progressBar.style.width = `${(pct * 100).toFixed(1)}%`;
if (progressText) progressText.textContent = `${(pct * 100).toFixed(0)}%`;
},
}
);
// Update seed display
const seedEl = document.getElementById("wfm-gen-seed-value");
if (seedEl) seedEl.value = seed;
if (progressText) progressText.textContent = `Done (${images.length} image${images.length !== 1 ? "s" : ""})`;
if (progressBar) progressBar.style.width = "100%";
// Display results
if (images.length > 0) {
const blob = await comfyUI.getImageBlob(images[0]);
const url = URL.createObjectURL(blob);
if (resultImg) {
resultImg.src = url;
resultImg.style.display = "block";
}
// Thumbnails for multiple images
if (resultThumbs && images.length > 1) {
resultThumbs.innerHTML = "";
for (let i = 0; i < images.length; i++) {
const b = i === 0 ? blob : await comfyUI.getImageBlob(images[i]);
const u = i === 0 ? url : URL.createObjectURL(b);
const thumb = document.createElement("img");
thumb.src = u;
thumb.className = `wfm-gen-thumb ${i === 0 ? "active" : ""}`;
thumb.addEventListener("click", () => {
resultImg.src = u;
resultThumbs.querySelectorAll(".wfm-gen-thumb").forEach((t) => t.classList.remove("active"));
thumb.classList.add("active");
});
resultThumbs.appendChild(thumb);
}
if (batchEnabled) {
await _runBatchGenerate();
} else {
try {
await _coreGenerate(false);
} catch (err) {
const progressText = document.getElementById("wfm-gen-progress-text");
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
}
}
// Eagle auto-save
if (getEagleSettings().autoSave && images.length > 0) {
for (const img of images) {
const viewUrl = `/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${encodeURIComponent(img.type || "output")}`;
saveToEagle(viewUrl, img.filename);
}
}
showToast("Generation complete", "success");
} catch (err) {
if (progressText) progressText.textContent = "Error";
showToast("Generation error: " + err.message, "error");
} finally {
genBtn.disabled = false;
if (interruptBtn) interruptBtn.style.display = "none";
@@ -231,17 +408,29 @@ export async function initGenerateTab() {
comfyEditor.renderAll(comfyUI.currentAnalysis, comfyUI.currentWorkflow);
}
showToast("Model lists refreshed", "success");
_updateBatchInfo();
});
// Generate button
document.getElementById("wfm-gen-generate-btn")?.addEventListener("click", handleGenerate);
// Interrupt button
// Interrupt button (stops both single generation and batch loop)
document.getElementById("wfm-gen-interrupt-btn")?.addEventListener("click", async () => {
_ckptBatch.aborted = true;
await comfyUI.interrupt();
showToast("Interrupted", "info");
});
// 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", () => {
@@ -250,10 +439,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;
@@ -285,11 +478,21 @@ export async function initGenerateTab() {
} catch {}
}
// outputDir を事前取得(生成後の保存に使用)
_fetchOutputDir();
window.addEventListener("wfm-output-dir-changed", (e) => {
_outputDir = (e.detail?.path || "").replace(/\\/g, "/").replace(/\/$/, "");
});
// Checkpoint batch UI
initCheckpointBatch();
// Auto-connect on init
const connected = await comfyUI.checkConnection();
updateStatus(connected);
if (connected) {
await comfyEditor.loadModelLists();
_updateBatchInfo();
}
// Auto-load default workflow (doesn't require ComfyUI connection)
+979 -71
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+460 -72
View File
@@ -1,5 +1,5 @@
/**
* Prompt Tab - Ollama AI Assistant + Prompt Presets
* Prompt Tab - Ollama AI Assistant + Prompt Presets + Preset Manager
*/
import { showToast } from "./app.js";
@@ -14,9 +14,335 @@ const ollamaState = {
attachedImage: null,
};
// Preset data (loaded from API, with localStorage migration)
let promptPresets = [];
let pmActiveTab = "all"; // "all" | "favorites" | "groups"
let pmSearchText = "";
let pmSelectedId = null; // currently selected preset id
let pmGroups = {}; // { groupName: [presetId, ...] }
const PM_GROUPS_KEY = "wfm_prompt_preset_groups";
// ============================================
// Preset API helpers
// ============================================
async function fetchPresets() {
try {
const res = await fetch("/api/wfm/prompts");
return res.ok ? await res.json() : [];
} catch { return []; }
}
async function apiCreatePreset(data) {
try {
const res = await fetch("/api/wfm/prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await res.json();
return result.status === "ok" ? result.prompt : null;
} catch { return null; }
}
async function apiUpdatePreset(id, updates) {
try {
const res = await fetch("/api/wfm/prompts/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...updates }),
});
const result = await res.json();
return result.status === "ok" ? result.prompt : null;
} catch { return null; }
}
async function apiDeletePreset(id) {
try {
await fetch("/api/wfm/prompts/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
} catch { /* ignore */ }
}
// ============================================
// Migrate localStorage presets to API
// ============================================
const PRESETS_KEY = "wfm_prompt_presets";
let promptPresets = [];
async function migrateLocalStoragePresets() {
try {
const raw = localStorage.getItem(PRESETS_KEY);
if (!raw) return;
const local = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
// Only migrate if API has no presets yet
const existing = await fetchPresets();
if (existing.length > 0) {
localStorage.removeItem(PRESETS_KEY);
return;
}
for (const p of local) {
await apiCreatePreset({
name: p.name || "Untitled",
text: p.posText || p.text || "",
category: "",
tags: [],
favorite: false,
});
// Note: negText is stored separately - we combine it
if (p.negText) {
// Create a separate entry for negative prompt reference
// or store in the text field with marker
}
}
localStorage.removeItem(PRESETS_KEY);
} catch { /* ignore migration errors */ }
}
// ============================================
// Preset data management
// ============================================
async function loadAllPresets() {
await migrateLocalStoragePresets();
promptPresets = await fetchPresets();
// Load groups from localStorage
try {
pmGroups = JSON.parse(localStorage.getItem(PM_GROUPS_KEY) || "{}");
} catch { pmGroups = {}; }
// Clean stale entries from groups
const validIds = new Set(promptPresets.map(p => p.id));
for (const g of Object.keys(pmGroups)) {
pmGroups[g] = (pmGroups[g] || []).filter(id => validIds.has(id));
if (pmGroups[g].length === 0) delete pmGroups[g];
}
saveGroups();
renderPresetSelect();
renderGroupSelect();
renderPresetManager();
}
function saveGroups() {
localStorage.setItem(PM_GROUPS_KEY, JSON.stringify(pmGroups));
renderGroupSelect();
}
function renderGroupSelect() {
const select = document.getElementById("wfm-preset-group-select");
if (!select) return;
const prevVal = select.value;
select.innerHTML = `<option value="">Select group...</option>`;
for (const g of Object.keys(pmGroups).sort()) {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
select.appendChild(opt);
}
if (prevVal && pmGroups[prevVal]) select.value = prevVal;
}
function renderPresetSelect() {
const select = document.getElementById("wfm-preset-select");
if (!select) return;
const prevVal = select.value;
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
promptPresets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
select.appendChild(opt);
});
// Restore selection if still valid
if (prevVal && promptPresets.find(p => p.id === prevVal)) {
select.value = prevVal;
}
}
function selectPresetInEditor(preset) {
const presetSelect = document.getElementById("wfm-preset-select");
const presetName = document.getElementById("wfm-preset-name");
const presetCategory = document.getElementById("wfm-preset-category");
const presetPos = document.getElementById("wfm-preset-pos");
const presetNeg = document.getElementById("wfm-preset-neg");
if (presetSelect) presetSelect.value = preset ? preset.id : "";
if (presetName) presetName.value = preset ? preset.name : "";
if (presetCategory) presetCategory.value = preset ? (preset.category || "") : "";
if (presetPos) presetPos.value = preset ? (preset.text || preset.posText || "") : "";
if (presetNeg) presetNeg.value = preset ? (preset.negText || "") : "";
pmSelectedId = preset ? preset.id : null;
renderPresetManager();
}
// ============================================
// Preset Manager rendering
// ============================================
function esc(s) {
return s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") : "";
}
function renderPresetManager() {
const container = document.getElementById("wfm-pm-list");
if (!container) return;
container.innerHTML = "";
switch (pmActiveTab) {
case "all": renderPmAll(container); break;
case "favorites": renderPmFavorites(container); break;
case "groups": renderPmGroups(container); break;
}
}
function matchesSearch(p) {
if (!pmSearchText) return true;
const s = pmSearchText.toLowerCase();
return (p.name || "").toLowerCase().includes(s) ||
(p.text || "").toLowerCase().includes(s) ||
(p.category || "").toLowerCase().includes(s) ||
(p.tags || []).some(t => t.toLowerCase().includes(s));
}
function createPmItem(preset) {
const el = document.createElement("div");
el.className = "wfm-pm-item" + (pmSelectedId === preset.id ? " active" : "");
const previewText = (preset.text || "").length > 50
? preset.text.substring(0, 50) + "..."
: (preset.text || "");
const catBadge = preset.category
? `<span style="font-size:9px;color:var(--wfm-primary);margin-left:4px;">[${esc(preset.category)}]</span>`
: "";
el.innerHTML = `
<div class="wfm-pm-item-body">
<div class="wfm-pm-item-name">${preset.favorite ? '<span style="color:#ffd700;">&#9733;</span> ' : ""}${esc(preset.name)}${catBadge}</div>
<div class="wfm-pm-item-sub">${esc(previewText)}</div>
</div>
<div class="wfm-pm-item-actions">
<button class="wfm-pm-action-btn pm-fav-btn${preset.favorite ? " fav-active" : ""}" title="Favorite">&#9733;</button>
<button class="wfm-pm-action-btn pm-del-btn" title="Delete" style="color:var(--wfm-danger);">&#10005;</button>
</div>
`;
// Click to select in editor
el.addEventListener("click", (e) => {
if (e.target.closest(".wfm-pm-item-actions")) return;
selectPresetInEditor(preset);
});
// Favorite toggle
el.querySelector(".pm-fav-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const updated = await apiUpdatePreset(preset.id, { favorite: !preset.favorite });
if (updated) {
const idx = promptPresets.findIndex(p => p.id === preset.id);
if (idx >= 0) promptPresets[idx] = updated;
renderPresetManager();
}
});
// Delete
el.querySelector(".pm-del-btn").addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Delete "${preset.name}"?`)) return;
await apiDeletePreset(preset.id);
promptPresets = promptPresets.filter(p => p.id !== preset.id);
if (pmSelectedId === preset.id) {
pmSelectedId = null;
selectPresetInEditor(null);
}
renderPresetSelect();
renderPresetManager();
showToast(t("deleted"), "success");
});
return el;
}
function renderPmAll(container) {
const items = promptPresets.filter(matchesSearch);
if (items.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">${promptPresets.length === 0 ? "No presets yet.<br><small>Create one in the Presets panel</small>" : "No matches"}</div>`;
return;
}
for (const p of items) {
container.appendChild(createPmItem(p));
}
}
function renderPmFavorites(container) {
const favs = promptPresets.filter(p => p.favorite);
const items = favs.filter(matchesSearch);
if (items.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">${favs.length === 0 ? "No favorites yet.<br><small>Star presets to add them here</small>" : "No matches"}</div>`;
return;
}
for (const p of items) {
container.appendChild(createPmItem(p));
}
}
function renderPmGroups(container) {
const groupNames = Object.keys(pmGroups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-pm-empty">No groups yet.<br><small>Create groups from the Presets panel below</small></div>`;
return;
}
for (const groupName of groupNames) {
const ids = pmGroups[groupName] || [];
const presets = ids.map(id => promptPresets.find(p => p.id === id)).filter(Boolean);
const section = document.createElement("div");
const header = document.createElement("div");
header.className = "wfm-pm-group-header collapsed";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-pm-badge">${presets.length}</span>`;
const list = document.createElement("div");
list.style.display = "none";
header.addEventListener("click", () => {
list.style.display = list.style.display === "none" ? "block" : "none";
header.classList.toggle("collapsed");
});
for (const p of presets) {
const item = createPmItem(p);
// Add remove-from-group button
const removeBtn = document.createElement("button");
removeBtn.className = "wfm-pm-action-btn";
removeBtn.title = "Remove from group";
removeBtn.textContent = "\u2796";
removeBtn.style.fontSize = "10px";
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
pmGroups[groupName] = pmGroups[groupName].filter(id => id !== p.id);
if (pmGroups[groupName].length === 0) delete pmGroups[groupName];
saveGroups();
renderPresetManager();
});
item.querySelector(".wfm-pm-item-actions").prepend(removeBtn);
list.appendChild(item);
}
section.appendChild(header);
section.appendChild(list);
container.appendChild(section);
}
}
// ============================================
// Ollama API helpers
@@ -221,7 +547,7 @@ async function sendTranslate(direction) {
return;
}
const labels = { ja2en: "JA→EN", en2ja: "EN→JA", zh2en: "ZH→EN", en2zh: "EN→ZH" };
const labels = { ja2en: "JA\u2192EN", en2ja: "EN\u2192JA", zh2en: "ZH\u2192EN", en2zh: "EN\u2192ZH" };
addChatMessage("user", `[${labels[direction] || direction}]\n${text}`);
input.value = "";
@@ -232,9 +558,9 @@ async function sendTranslate(direction) {
try {
const prompts = {
ja2en: `Translate the following Japanese text into English. Output only the translated text, nothing else.\n\n${text}`,
en2ja: `以下の英語のテキストを日本語に翻訳してください。翻訳結果のみを出力し、他の説明は不要です。\n\n${text}`,
en2ja: `\u4ee5\u4e0b\u306e\u82f1\u8a9e\u306e\u30c6\u30ad\u30b9\u30c8\u3092\u65e5\u672c\u8a9e\u306b\u7ffb\u8a33\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u7ffb\u8a33\u7d50\u679c\u306e\u307f\u3092\u51fa\u529b\u3057\u3001\u4ed6\u306e\u8aac\u660e\u306f\u4e0d\u8981\u3067\u3059\u3002\n\n${text}`,
zh2en: `Translate the following Chinese text into English. Output only the translated text, nothing else.\n\n${text}`,
en2zh: `请将以下英文翻译成中文。只输出翻译结果,不需要其他说明。\n\n${text}`,
en2zh: `\u8bf7\u5c06\u4ee5\u4e0b\u82f1\u6587\u7ffb\u8bd1\u6210\u4e2d\u6587\u3002\u53ea\u8f93\u51fa\u7ffb\u8bd1\u7ed3\u679c\uff0c\u4e0d\u9700\u8981\u5176\u4ed6\u8bf4\u660e\u3002\n\n${text}`,
};
const prompt = prompts[direction];
@@ -267,36 +593,6 @@ function applyToGenerateUI(text) {
showToast(t("appliedToGenerateUI"), "success");
}
// ============================================
// Presets
// ============================================
function loadPresets() {
try {
promptPresets = JSON.parse(localStorage.getItem(PRESETS_KEY) || "[]");
} catch {
promptPresets = [];
}
renderPresets();
}
function savePresets() {
localStorage.setItem(PRESETS_KEY, JSON.stringify(promptPresets));
renderPresets();
}
function renderPresets() {
const select = document.getElementById("wfm-preset-select");
if (!select) return;
select.innerHTML = `<option value="">${t("newPreset")}</option>`;
promptPresets.forEach((p, i) => {
const opt = document.createElement("option");
opt.value = i.toString();
opt.textContent = p.name;
select.appendChild(opt);
});
}
// ============================================
// Initialize
// ============================================
@@ -377,32 +673,50 @@ export function initPromptTab() {
document.getElementById("wfm-ollama-zh2en-btn")?.addEventListener("click", () => sendTranslate("zh2en"));
document.getElementById("wfm-ollama-en2zh-btn")?.addEventListener("click", () => sendTranslate("en2zh"));
// --- Presets ---
loadPresets();
// --- Presets (API-based) ---
loadAllPresets();
const presetSelect = document.getElementById("wfm-preset-select");
const presetName = document.getElementById("wfm-preset-name");
const presetCategory = document.getElementById("wfm-preset-category");
const presetPos = document.getElementById("wfm-preset-pos");
const presetNeg = document.getElementById("wfm-preset-neg");
// Load preset on selection
presetSelect?.addEventListener("change", () => {
const idx = presetSelect.value;
if (idx !== "" && promptPresets[idx]) {
const p = promptPresets[idx];
if (presetName) presetName.value = p.name;
if (presetPos) presetPos.value = p.posText || p.text || "";
if (presetNeg) presetNeg.value = p.negText || "";
} else {
if (presetName) presetName.value = "";
if (presetPos) presetPos.value = "";
if (presetNeg) presetNeg.value = "";
}
const id = presetSelect.value;
const p = promptPresets.find(pp => pp.id === id);
selectPresetInEditor(p || null);
});
// Save preset
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", () => {
// Copy positive prompt to clipboard
document.getElementById("wfm-preset-copy-pos-btn")?.addEventListener("click", () => {
const text = presetPos?.value || "";
if (!text.trim()) {
showToast(t("noTextToCopy"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("copiedToClipboard"), "success");
});
});
// Copy negative prompt to clipboard
document.getElementById("wfm-preset-copy-neg-btn")?.addEventListener("click", () => {
const text = presetNeg?.value || "";
if (!text.trim()) {
showToast(t("noTextToCopy"), "error");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast(t("copiedToClipboard"), "success");
});
});
// Save preset (create or update via API)
document.getElementById("wfm-preset-save-btn")?.addEventListener("click", async () => {
const name = presetName?.value.trim();
const category = presetCategory?.value.trim() || "";
const pos = presetPos?.value || "";
const neg = presetNeg?.value || "";
@@ -415,32 +729,39 @@ export function initPromptTab() {
return;
}
const existIdx = promptPresets.findIndex((p) => p.name === name);
if (existIdx >= 0) {
promptPresets[existIdx].posText = pos;
promptPresets[existIdx].negText = neg;
const selectedId = presetSelect?.value;
const existing = selectedId ? promptPresets.find(p => p.id === selectedId) : null;
if (existing) {
// Update
const updated = await apiUpdatePreset(existing.id, {
name, text: pos, negText: neg, category,
});
if (updated) {
const idx = promptPresets.findIndex(p => p.id === existing.id);
if (idx >= 0) promptPresets[idx] = updated;
showToast(t("presetSaved"), "success");
}
} else {
promptPresets.push({ name, posText: pos, negText: neg });
// Create
const created = await apiCreatePreset({
name, text: pos, negText: neg, category,
tags: [], favorite: false,
});
if (created) {
promptPresets.push(created);
pmSelectedId = created.id;
showToast(t("presetSaved"), "success");
}
}
savePresets();
showToast(t("presetSaved"), "success");
// Select newly saved
const newIdx = promptPresets.findIndex((p) => p.name === name);
if (presetSelect && newIdx >= 0) presetSelect.value = newIdx.toString();
});
renderPresetSelect();
renderPresetManager();
// Delete preset
document.getElementById("wfm-preset-delete-btn")?.addEventListener("click", () => {
const idx = presetSelect?.value;
if (idx === "" || !promptPresets[idx]) return;
if (!confirm(t("deleteConfirm", promptPresets[idx].name))) return;
promptPresets.splice(idx, 1);
savePresets();
if (presetName) presetName.value = "";
if (presetPos) presetPos.value = "";
if (presetNeg) presetNeg.value = "";
showToast(t("deleted"), "success");
// Re-select
if (pmSelectedId && presetSelect) {
presetSelect.value = pmSelectedId;
}
});
// Apply preset to GenerateUI
@@ -465,4 +786,71 @@ export function initPromptTab() {
}
if (applied) showToast(t("appliedToGenerateUI"), "success");
});
// --- Group management (Presets side) ---
document.getElementById("wfm-preset-new-group-btn")?.addEventListener("click", () => {
const name = prompt("Group name:");
if (!name || !name.trim()) return;
const key = name.trim();
if (pmGroups[key]) {
showToast("Group already exists", "error");
return;
}
pmGroups[key] = [];
saveGroups();
renderPresetManager();
showToast(`Group "${key}" created`, "success");
});
document.getElementById("wfm-preset-add-to-group-btn")?.addEventListener("click", () => {
const groupSelect = document.getElementById("wfm-preset-group-select");
const groupName = groupSelect?.value;
if (!groupName) {
showToast("Select a group first", "error");
return;
}
const id = presetSelect?.value;
if (!id) {
showToast("Select a preset first", "error");
return;
}
if (!pmGroups[groupName]) pmGroups[groupName] = [];
if (pmGroups[groupName].includes(id)) {
showToast("Already in this group", "info");
return;
}
pmGroups[groupName].push(id);
saveGroups();
renderPresetManager();
showToast("Added to group", "success");
});
document.getElementById("wfm-preset-del-group-btn")?.addEventListener("click", () => {
const groupSelect = document.getElementById("wfm-preset-group-select");
const groupName = groupSelect?.value;
if (!groupName) {
showToast("Select a group first", "error");
return;
}
if (!confirm(`Delete group "${groupName}"?`)) return;
delete pmGroups[groupName];
saveGroups();
renderPresetManager();
showToast(`Group "${groupName}" deleted`, "success");
});
// --- Preset Manager ---
document.querySelectorAll(".wfm-pm-tab").forEach(btn => {
btn.addEventListener("click", () => {
pmActiveTab = btn.dataset.pmtab;
document.querySelectorAll(".wfm-pm-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderPresetManager();
});
});
document.getElementById("wfm-pm-search-input")?.addEventListener("input", (e) => {
pmSearchText = e.target.value.trim();
renderPresetManager();
});
}
+739 -18
View File
@@ -8,6 +8,12 @@ import { t, getLang, getSummaryLang, setLang, setSummaryLang, getLanguageOptions
const SETTINGS_KEY = "wfm_settings";
function rgbToHex(rgb) {
const m = rgb.match(/(\d+)/g);
if (!m || m.length < 3) return "#000000";
return "#" + m.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, "0")).join("");
}
function loadLocalSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
@@ -50,6 +56,282 @@ function buildLangOptions(optionsMap, selectedValue) {
.join("");
}
// Theme definitions with swatch colors for preview
const THEMES = [
{ id: "", key: "themeDefault", colors: ["#1a1a2e", "#16213e", "#4a9eff", "#7c5cfc"] },
{ id: "pop-vibrant", key: "themePopVibrant", colors: ["#F8F9FA", "#FFFFFF", "#FF6B6B", "#00D2D3"] },
{ id: "light-minimalist", key: "themeLightMinimalist", colors: ["#FFFFFF", "#F5F5F5", "#4A9EFF", "#6366F1"] },
{ id: "cyberpunk", key: "themeCyberpunk", colors: ["#000000", "#0A0A0A", "#FF00FF", "#00FFFF"] },
{ id: "glassmorphism", key: "themeGlassmorphism", colors: ["#1A0533", "#0D1B3E", "#7B68EE", "#DA77F2"] },
{ id: "neumorphism", key: "themeNeumorphism", colors: ["#D1D9E6", "#D1D9E6", "#6C8EBF", "#7986CB"] },
{ id: "retro-pixel", key: "themeRetroPixel", colors: ["#2C2C34", "#3A3A44", "#5B8FD4", "#D45BD4"] },
{ id: "pastel-dream", key: "themePastelDream", colors: ["#F0E6FF", "#E8F5E9", "#B39DDB", "#80DEEA"] },
{ id: "brutalism", key: "themeBrutalism", colors: ["#FFFFFF", "#FFFFFF", "#CCFF00", "#000000"] },
{ id: "earthy", key: "themeEarthy", colors: ["#F5E6D3", "#EDE0D4", "#8D6E63", "#BF6830"] },
{ id: "material", key: "themeMaterial", colors: ["#FAFAFA", "#FFFFFF", "#3F51B5", "#FF4081"] },
{ id: "monotone-accent", key: "themeMonotoneAccent", colors: ["#F7F7F7", "#FFFFFF", "#FF6B35", "#1A1A1A"] },
{ id: "corporate", key: "themeCorporate", colors: ["#F0F4F8", "#FFFFFF", "#2563EB", "#7C3AED"] },
];
// --- Background pattern definitions ---
const BG_PATTERNS = [
{ id: "none", key: "themePatternNone" },
{ id: "stripe-h", key: "themePatternStripeH" },
{ id: "stripe-v", key: "themePatternStripeV" },
{ id: "stripe-d", key: "themePatternStripeD" },
{ id: "dot", key: "themePatternDot" },
{ id: "check", key: "themePatternCheck" },
{ id: "svg", key: "themePatternSvg" },
];
// Font definitions: id, label key, CSS value, Google Fonts family name (if web font)
const FONT_OPTIONS = [
{ id: "", key: "themeFontDefault", css: "", google: "" },
{ id: "noto-sans-jp", key: "themeFontNotoSans", css: "'Noto Sans JP', sans-serif", google: "Noto+Sans+JP:wght@400;700" },
{ id: "zen-maru", key: "themeFontZenMaru", css: "'Zen Maru Gothic', sans-serif", google: "Zen+Maru+Gothic:wght@400;700" },
{ id: "m-plus-rounded", key: "themeFontMPlusRound", css: "'M PLUS Rounded 1c', sans-serif", google: "M+PLUS+Rounded+1c:wght@400;700" },
{ id: "kosugi-maru", key: "themeFontKosugiMaru", css: "'Kosugi Maru', sans-serif", google: "Kosugi+Maru" },
{ id: "sawarabi-gothic",key: "themeFontSawarabi", css: "'Sawarabi Gothic', sans-serif", google: "Sawarabi+Gothic" },
{ id: "biz-udp", key: "themeFontBizUdp", css: "'BIZ UDPGothic', sans-serif", google: "BIZ+UDPGothic:wght@400;700" },
{ id: "dot-gothic", key: "themeFontDotGothic", css: "'DotGothic16', monospace", google: "DotGothic16" },
{ id: "hachi-maru", key: "themeFontHachiMaru", css: "'Hachi Maru Pop', cursive", google: "Hachi+Maru+Pop" },
{ id: "dela-gothic", key: "themeFontDelaGothic", css: "'Dela Gothic One', sans-serif", google: "Dela+Gothic+One" },
{ id: "reggae-one", key: "themeFontReggaeOne", css: "'Reggae One', cursive", google: "Reggae+One" },
{ id: "rocknroll", key: "themeFontRocknRoll", css: "'RocknRoll One', sans-serif", google: "RocknRoll+One" },
{ id: "stick", key: "themeFontStick", css: "'Stick', sans-serif", google: "Stick" },
{ id: "train-one", key: "themeFontTrainOne", css: "'Train One', cursive", google: "Train+One" },
{ id: "space-mono", key: "themeFontSpaceMono", css: "'Space Mono', monospace", google: "Space+Mono:wght@400;700" },
{ id: "fira-code", key: "themeFontFiraCode", css: "'Fira Code', monospace", google: "Fira+Code:wght@400;700" },
];
// Load Google Font dynamically
const _loadedFonts = new Set();
function loadGoogleFont(googleFamily) {
if (!googleFamily || _loadedFonts.has(googleFamily)) return;
_loadedFonts.add(googleFamily);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?family=${googleFamily}&display=swap`;
document.head.appendChild(link);
}
function applyCustomFont(fontId) {
const font = FONT_OPTIONS.find(f => f.id === fontId);
let styleEl = document.getElementById("wfm-custom-font-style");
if (!font || !font.css) {
if (styleEl) styleEl.remove();
return;
}
if (font.google) loadGoogleFont(font.google);
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "wfm-custom-font-style";
document.head.appendChild(styleEl);
}
styleEl.textContent = `body, .wfm-input, .wfm-select, .wfm-textarea, button { font-family: ${font.css} !important; }`;
}
// Color customization targets
const COLOR_TARGETS = [
{ varName: "--wfm-bg", key: "themeColorBg" },
{ varName: "--wfm-bg-secondary", key: "themeColorBgSec" },
{ varName: "--wfm-surface", key: "themeColorSurface" },
{ varName: "--wfm-text", key: "themeColorText" },
{ varName: "--wfm-primary", key: "themeColorPrimary" },
{ varName: "--wfm-accent", key: "themeColorAccent" },
];
function generatePatternCSS(patternId, color, opacity, scale) {
const o = opacity ?? 0.1;
const s = scale ?? 20;
const c = color || "#ffffff";
// Convert hex to rgba
const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
const rgba = `rgba(${r},${g},${b},${o})`;
switch (patternId) {
case "stripe-h":
return `repeating-linear-gradient(0deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
case "stripe-v":
return `repeating-linear-gradient(90deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
case "stripe-d":
return `repeating-linear-gradient(45deg, ${rgba} 0px, ${rgba} 1px, transparent 1px, transparent ${s}px)`;
case "dot": {
const half = s / 2;
const dotR = Math.max(1, s * 0.12);
return `radial-gradient(circle ${dotR}px at ${half}px ${half}px, ${rgba} 100%, transparent 100%)`;
}
case "check": {
const half = s / 2;
return `linear-gradient(45deg, ${rgba} 25%, transparent 25%, transparent 75%, ${rgba} 75%), ` +
`linear-gradient(45deg, ${rgba} 25%, transparent 25%, transparent 75%, ${rgba} 75%)`;
}
default:
return "";
}
}
function generatePatternBgSize(patternId, scale) {
const s = scale ?? 20;
if (patternId === "dot") return `${s}px ${s}px`;
if (patternId === "check") {
const half = s / 2;
return `${s}px ${s}px`;
}
return "";
}
function generatePatternBgPosition(patternId, scale) {
const s = scale ?? 20;
if (patternId === "check") {
const half = s / 2;
return `0 0, ${half}px ${half}px`;
}
return "";
}
function generatePatternPreviewSVG(patternId) {
const fg = "#8cb4ff";
const bg = "#1a1a2e";
const encode = (svg) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
switch (patternId) {
case "none":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><line x1="10" y1="10" x2="50" y2="50" stroke="#555" stroke-width="2"/><line x1="50" y1="10" x2="10" y2="50" stroke="#555" stroke-width="2"/></svg>`);
case "stripe-h":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect y="4" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="16" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="28" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="40" width="60" height="4" fill="${fg}" opacity="0.7"/><rect y="52" width="60" height="4" fill="${fg}" opacity="0.7"/></svg>`);
case "stripe-v":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="4" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="16" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="28" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="40" width="4" height="60" fill="${fg}" opacity="0.7"/><rect x="52" width="4" height="60" fill="${fg}" opacity="0.7"/></svg>`);
case "stripe-d":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><line x1="-5" y1="7" x2="53" y2="-5" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="19" x2="65" y2="7" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="-5" y1="31" x2="53" y2="19" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="43" x2="65" y2="31" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="-5" y1="55" x2="53" y2="43" stroke="${fg}" stroke-width="3" opacity="0.7"/><line x1="7" y1="67" x2="65" y2="55" stroke="${fg}" stroke-width="3" opacity="0.7"/></svg>`);
case "dot":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><circle cx="10" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="30" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="50" cy="10" r="4" fill="${fg}" opacity="0.8"/><circle cx="20" cy="25" r="4" fill="${fg}" opacity="0.8"/><circle cx="40" cy="25" r="4" fill="${fg}" opacity="0.8"/><circle cx="10" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="30" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="50" cy="40" r="4" fill="${fg}" opacity="0.8"/><circle cx="20" cy="55" r="4" fill="${fg}" opacity="0.8"/><circle cx="40" cy="55" r="4" fill="${fg}" opacity="0.8"/></svg>`);
case "check":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="0" y="0" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="15" y="15" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="30" y="0" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="45" y="15" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="0" y="30" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="15" y="45" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="30" y="30" width="15" height="15" fill="${fg}" opacity="0.35"/><rect x="45" y="45" width="15" height="15" fill="${fg}" opacity="0.35"/></svg>`);
case "svg":
return encode(`<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60"><rect width="60" height="60" fill="${bg}"/><rect x="10" y="10" width="40" height="40" rx="4" fill="none" stroke="${fg}" stroke-width="2" stroke-dasharray="6 3" opacity="0.6"/><text x="30" y="36" text-anchor="middle" fill="${fg}" font-size="20" font-weight="bold" opacity="0.8">+</text></svg>`);
default:
return "";
}
}
// Apply custom overrides (colors + pattern) on top of the current theme
function applyCustomOverrides(custom) {
const root = document.documentElement;
// Remove old custom style element
let styleEl = document.getElementById("wfm-custom-theme-style");
// Color overrides
if (custom && custom.colors) {
for (const [varName, value] of Object.entries(custom.colors)) {
if (value) root.style.setProperty(varName, value);
}
}
// Pattern
if (custom && custom.pattern && custom.pattern.id && custom.pattern.id !== "none") {
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = "wfm-custom-theme-style";
document.head.appendChild(styleEl);
}
const p = custom.pattern;
if (p.id === "svg" && p.svgData) {
const s = p.scale ?? 40;
const o = p.opacity ?? 1;
const c = p.color || null;
const gap = p.gap ?? 0;
// Recolor SVG: inject a global style override into the SVG
let svgStr = p.svgData;
if (c) {
// 1. Replace fill/stroke attributes (but not "none")
svgStr = svgStr
.replace(/fill="(?!none)[^"]*"/gi, `fill="${c}"`)
.replace(/stroke="(?!none)[^"]*"/gi, `stroke="${c}"`);
// 2. Replace inline style fill/stroke
svgStr = svgStr
.replace(/fill:\s*(?!none)[^;"]+/gi, `fill:${c}`)
.replace(/stroke:\s*(?!none)[^;"]+/gi, `stroke:${c}`);
// 3. Inject a <style> block for elements with no explicit fill/stroke
const styleBlock = `<style>*:not([fill="none"]){fill:${c}!important}*:not([stroke="none"])[stroke]{stroke:${c}!important}</style>`;
svgStr = svgStr.replace(/<svg([^>]*)>/i, `<svg$1>${styleBlock}`);
}
// Extract original viewBox/width/height, then create wrapper SVG with padding
const tileSize = s + gap;
let tiledSvg;
if (gap > 0) {
// Extract the original viewBox to preserve it in the inner SVG
const vbMatch = svgStr.match(/viewBox="([^"]*)"/i);
const origViewBox = vbMatch ? vbMatch[1] : `0 0 ${s} ${s}`;
// Wrap: outer SVG with tileSize, inner SVG with original viewBox
tiledSvg = svgStr.replace(
/<svg([^>]*)>/i,
(match, attrs) => {
// Remove existing width/height/viewBox from outer
let clean = attrs
.replace(/\s*width="[^"]*"/gi, '')
.replace(/\s*height="[^"]*"/gi, '')
.replace(/\s*viewBox="[^"]*"/gi, '');
return `<svg${clean} width="${tileSize}" height="${tileSize}" viewBox="0 0 ${tileSize} ${tileSize}">
<svg width="${s}" height="${s}" x="0" y="0" viewBox="${origViewBox}">`;
}
) + '</svg>';
} else {
tiledSvg = svgStr;
}
const encoded = `url("data:image/svg+xml,${encodeURIComponent(tiledSvg)}")`;
styleEl.textContent = `body::after { content: ""; position: fixed; inset: 0; z-index: -1; background-image: ${encoded}; background-size: ${tileSize}px ${tileSize}px; background-repeat: repeat; opacity: ${o}; pointer-events: none; }`;
} else if (p.id !== "svg") {
const bg = generatePatternCSS(p.id, p.color, p.opacity, p.scale);
const bgSize = generatePatternBgSize(p.id, p.scale);
const bgPos = generatePatternBgPosition(p.id, p.scale);
let css = `body { background-image: ${bg} !important;`;
if (bgSize) css += ` background-size: ${bgSize} !important;`;
if (bgPos) css += ` background-position: ${bgPos} !important;`;
css += ` }`;
styleEl.textContent = css;
}
} else {
if (styleEl) styleEl.remove();
}
// Font
if (custom && custom.fontId) {
applyCustomFont(custom.fontId);
} else {
const fontEl = document.getElementById("wfm-custom-font-style");
if (fontEl) fontEl.remove();
}
}
function clearCustomOverrides() {
const root = document.documentElement;
for (const ct of COLOR_TARGETS) {
root.style.removeProperty(ct.varName);
}
const styleEl = document.getElementById("wfm-custom-theme-style");
if (styleEl) styleEl.remove();
const fontEl = document.getElementById("wfm-custom-font-style");
if (fontEl) fontEl.remove();
}
export function applyTheme(themeId) {
if (themeId) {
document.documentElement.setAttribute("data-theme", themeId);
} else {
document.documentElement.removeAttribute("data-theme");
}
// Apply saved custom overrides
const settings = loadLocalSettings();
clearCustomOverrides();
if (settings.themeCustom) {
applyCustomOverrides(settings.themeCustom);
}
}
export function getSavedTheme() {
return loadLocalSettings().theme || "";
}
export async function initSettingsTab() {
const container = document.querySelector("#wfm-tab-settings .wfm-settings-container");
if (!container) return;
@@ -64,6 +346,12 @@ export async function initSettingsTab() {
workflowsDirInfo = await res.json();
} catch {}
// Load gallery output dir info
let outputDirInfo = { current: "", default: "", saved: "" };
try {
const res = await fetch("/api/wfm/settings/output-dir");
outputDirInfo = await res.json();
} catch {}
const uiLang = getLang();
const summaryLang = getSummaryLang();
@@ -71,9 +359,115 @@ export async function initSettingsTab() {
container.innerHTML = `
<h2 style="font-size:18px;margin-bottom:20px;">${t("settingsTitle")}</h2>
<div class="wfm-settings-layout">
<!-- Right column: Theme (floated right) -->
<div class="wfm-settings-right-col">
<!-- Theme -->
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("themeLabel")}</summary>
<div class="wfm-theme-grid" id="wfm-theme-grid">
${THEMES.map(th => `
<div class="wfm-theme-card ${(settings.theme || "") === th.id ? "active" : ""}" data-theme-id="${th.id}">
<div class="wfm-theme-swatch">
${th.colors.map(c => `<span style="background:${c};"></span>`).join("")}
</div>
<div class="wfm-theme-card-label">${t(th.key)}</div>
</div>
`).join("")}
</div>
<!-- Theme Customizer -->
<button class="wfm-customizer-toggle" id="wfm-customizer-toggle">
<span class="arrow">&#9654;</span> ${t("themeCustomize")}
</button>
<div class="wfm-customizer-panel" id="wfm-customizer-panel">
<!-- Color overrides -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeColors")}</h4>
<div class="wfm-color-grid" id="wfm-color-grid">
${COLOR_TARGETS.map(ct => {
const saved = settings.themeCustom?.colors?.[ct.varName] || "";
return `<div class="wfm-color-item">
<label>${t(ct.key)}</label>
<input type="color" data-var="${ct.varName}" value="${saved || getComputedStyle(document.documentElement).getPropertyValue(ct.varName).trim()}" title="${ct.varName}">
</div>`;
}).join("")}
</div>
</div>
<!-- Background pattern -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeBgPattern")}</h4>
<div class="wfm-pattern-grid" id="wfm-pattern-grid">
${BG_PATTERNS.map(p => {
const active = (settings.themeCustom?.pattern?.id || "none") === p.id ? "active" : "";
const preview = generatePatternPreviewSVG(p.id);
const bgStyle = preview
? `background:url('${preview}') no-repeat center/cover;`
: "background:#333;";
return `<div class="wfm-pattern-item ${active} ${p.id === 'svg' ? 'wfm-svg-upload-btn' : ''}" data-pattern-id="${p.id}" style="${bgStyle}">
<span class="wfm-pattern-item-label">${t(p.key)}</span>
</div>`;
}).join("")}
</div>
<input type="file" id="wfm-svg-upload" accept=".svg" style="display:none;">
<!-- Pattern options -->
<div class="wfm-pattern-options" id="wfm-pattern-options" style="${(!settings.themeCustom?.pattern?.id || settings.themeCustom?.pattern?.id === 'none') ? 'display:none;' : ''}">
<label>
${t("themePatternColor")}
<input type="color" id="wfm-pattern-color" value="${settings.themeCustom?.pattern?.color || '#ffffff'}">
</label>
<label>
${t("themePatternOpacity")}
<input type="range" id="wfm-pattern-opacity" min="0.02" max="0.5" step="0.02" value="${settings.themeCustom?.pattern?.opacity ?? 0.1}">
<span id="wfm-pattern-opacity-val">${settings.themeCustom?.pattern?.opacity ?? 0.1}</span>
</label>
<label>
${t("themePatternScale")}
<input type="range" id="wfm-pattern-scale" min="8" max="80" step="2" value="${settings.themeCustom?.pattern?.scale ?? 20}">
<span id="wfm-pattern-scale-val">${settings.themeCustom?.pattern?.scale ?? 20}px</span>
</label>
<label>
${t("themePatternGap")}
<input type="range" id="wfm-pattern-gap" min="0" max="60" step="2" value="${settings.themeCustom?.pattern?.gap ?? 0}">
<span id="wfm-pattern-gap-val">${settings.themeCustom?.pattern?.gap ?? 0}px</span>
</label>
</div>
<div id="wfm-svg-name" style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;${settings.themeCustom?.pattern?.id === 'svg' ? '' : 'display:none;'}">
${settings.themeCustom?.pattern?.svgName ? `SVG: ${settings.themeCustom.pattern.svgName}` : ''}
</div>
</div>
<!-- Font -->
<div class="wfm-customizer-section">
<h4>${t("themeCustomizeFont")}</h4>
<div class="wfm-font-grid" id="wfm-font-grid">
${FONT_OPTIONS.map(f => {
const active = (settings.themeCustom?.fontId || "") === f.id ? "active" : "";
return `<div class="wfm-font-item ${active}" data-font-id="${f.id}" title="${f.css || 'System Default'}">
<span class="wfm-font-preview" style="${f.css ? `font-family:${f.css};` : ''}">${t(f.key)}</span>
</div>`;
}).join("")}
</div>
</div>
<!-- Actions -->
<div class="wfm-customizer-actions">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-customizer-apply">${t("themeCustomizerApply")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-customizer-reset">${t("themeCustomizerReset")}</button>
</div>
</div>
</details>
</div><!-- /wfm-settings-right-col -->
<!-- Left column: all settings (Language + rest), flows left of floated Theme -->
<div class="wfm-settings-left-col">
<!-- Language Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("langLabel")} / ${t("summaryLangLabel")}</h3>
<details class="wfm-settings-section" open>
<summary class="wfm-settings-summary">${t("langLabel")} / ${t("summaryLangLabel")}</summary>
<div class="wfm-form-group">
<label>${t("langLabel")}</label>
<select class="wfm-select" id="wfm-settings-ui-lang">
@@ -89,11 +483,11 @@ export async function initSettingsTab() {
⚠ ${t("summaryLangNote")}
</small>
</div>
</div>
</details>
<!-- Workflow Data Folder -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("workflowsDir")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
<div class="wfm-form-group">
<label>${t("workflowsDirLabel")}</label>
<div style="display:flex;gap:8px;">
@@ -110,11 +504,32 @@ 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>
<!-- Gallery Output Folder -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("galleryOutputDir")}</summary>
<div class="wfm-form-group">
<label>${t("galleryOutputDirLabel")}</label>
<div style="display:flex;gap:8px;">
<input type="text" class="wfm-input" id="wfm-settings-output-dir"
value="${serverSettings.gallery_output_dir || ""}"
placeholder="${outputDirInfo.default}">
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-settings-output-dir-apply">${t("workflowsDirApply")}</button>
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-output-dir-reset">${t("workflowsDirDefault")}</button>
</div>
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
${t("galleryOutputDirHint")}
</small>
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-top:6px;">
${t("workflowsDirCurrent")}: <code id="wfm-settings-output-dir-current" style="color:var(--wfm-primary);word-break:break-all;">${outputDirInfo.current}</code>
</div>
</div>
</details>
<!-- ComfyUI Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("comfyuiConnection")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("comfyuiConnection")}</summary>
<div class="wfm-form-group">
<label>${t("comfyuiUrl")}</label>
<div style="display:flex;gap:8px;">
@@ -128,11 +543,11 @@ export async function initSettingsTab() {
</small>
</div>
<div id="wfm-settings-url-status" style="font-size:12px;margin-top:4px;"></div>
</div>
</details>
<!-- Ollama Settings -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("ollamaSettings")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("ollamaSettings")}</summary>
<div class="wfm-form-group">
<label>${t("ollamaUrl")}</label>
<input type="text" class="wfm-input" id="wfm-settings-ollama-url"
@@ -153,11 +568,11 @@ export async function initSettingsTab() {
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-ollama-test">${t("testConnection")}</button>
<span id="wfm-settings-ollama-status" style="font-size:12px;line-height:28px;"></span>
</div>
</div>
</details>
<!-- Default Workflow -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("defaultWorkflow")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("defaultWorkflow")}</summary>
<div class="wfm-form-group">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;" id="wfm-settings-default-wf-name">${settings.defaultWorkflow || t("defaultWorkflowNone")}</span>
@@ -167,11 +582,11 @@ export async function initSettingsTab() {
${t("defaultWorkflowHint")}
</small>
</div>
</div>
</details>
<!-- Eagle Integration (optional) -->
<div style="border:1px solid var(--wfm-border);border-radius:var(--wfm-radius);padding:16px;margin-bottom:20px;">
<h3 style="font-size:15px;margin-bottom:12px;">${t("eagleIntegration")}</h3>
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("eagleIntegration")}</summary>
<div class="wfm-form-group">
<label>${t("eagleUrl")}</label>
<div style="display:flex;gap:8px;">
@@ -189,12 +604,226 @@ export async function initSettingsTab() {
${t("eagleAutoSave")}
</label>
</div>
</div>
</details>
<!-- Data Management -->
<details class="wfm-settings-section">
<summary class="wfm-settings-summary">${t("dataManagement")}</summary>
<p style="font-size:12px;color:var(--wfm-text-secondary);margin-bottom:12px;">${t("dataManagementHint")}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-export">${t("exportData")}</button>
<label class="wfm-btn wfm-btn-sm" style="cursor:pointer;margin:0;">
${t("importData")}
<input type="file" id="wfm-settings-import-file" accept=".json" style="display:none;">
</label>
</div>
<div id="wfm-settings-data-status" style="font-size:12px;margin-top:8px;"></div>
</details>
<!-- Save Button -->
<button class="wfm-btn wfm-btn-primary" id="wfm-settings-save" style="min-width:120px;">${t("saveSettings")}</button>
</div><!-- /wfm-settings-left-col -->
<div style="clear:both;"></div>
</div><!-- /wfm-settings-layout -->
`;
// --- Theme change handler ---
document.getElementById("wfm-theme-grid")?.addEventListener("click", (e) => {
const card = e.target.closest(".wfm-theme-card");
if (!card) return;
const themeId = card.dataset.themeId;
// Clear custom overrides when switching theme base
clearCustomOverrides();
const cur = loadLocalSettings();
delete cur.themeCustom;
cur.theme = themeId;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyTheme(themeId);
// Update active state
document.querySelectorAll(".wfm-theme-card").forEach(c => c.classList.remove("active"));
card.classList.add("active");
// Update color pickers to reflect new theme's computed values
requestAnimationFrame(() => {
document.querySelectorAll("#wfm-color-grid input[type=color]").forEach(inp => {
const v = getComputedStyle(document.documentElement).getPropertyValue(inp.dataset.var).trim();
if (v) inp.value = v.startsWith("#") ? v : rgbToHex(v);
});
});
showToast(t("settingsSaved"), "success");
});
// --- Theme Customizer handlers ---
// State for live editing
let customizerState = {
colors: { ...(settings.themeCustom?.colors || {}) },
pattern: { ...(settings.themeCustom?.pattern || { id: "none", color: "#ffffff", opacity: 0.1, scale: 20 }) },
fontId: settings.themeCustom?.fontId || "",
};
// Toggle panel
document.getElementById("wfm-customizer-toggle")?.addEventListener("click", () => {
const toggle = document.getElementById("wfm-customizer-toggle");
const panel = document.getElementById("wfm-customizer-panel");
toggle.classList.toggle("open");
panel.classList.toggle("open");
});
// Live color change
document.getElementById("wfm-color-grid")?.addEventListener("input", (e) => {
if (e.target.type !== "color") return;
const varName = e.target.dataset.var;
const value = e.target.value;
customizerState.colors[varName] = value;
document.documentElement.style.setProperty(varName, value);
});
// Pattern selection
document.getElementById("wfm-pattern-grid")?.addEventListener("click", (e) => {
const item = e.target.closest(".wfm-pattern-item");
if (!item) return;
const patternId = item.dataset.patternId;
// SVG file upload
if (patternId === "svg") {
document.getElementById("wfm-svg-upload")?.click();
return;
}
customizerState.pattern.id = patternId;
// Update active state
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
item.classList.add("active");
// Show/hide options
const optionsEl = document.getElementById("wfm-pattern-options");
const svgNameEl = document.getElementById("wfm-svg-name");
if (patternId === "none") {
optionsEl.style.display = "none";
svgNameEl.style.display = "none";
} else {
optionsEl.style.display = "";
svgNameEl.style.display = "none";
}
// Live preview
applyCustomOverrides(customizerState);
});
// SVG file upload handler
document.getElementById("wfm-svg-upload")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const svgData = ev.target.result;
customizerState.pattern.id = "svg";
customizerState.pattern.svgData = svgData;
customizerState.pattern.svgName = file.name;
// Update active state
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
document.querySelector('.wfm-pattern-item[data-pattern-id="svg"]')?.classList.add("active");
// Show options + name
document.getElementById("wfm-pattern-options").style.display = "";
const svgNameEl = document.getElementById("wfm-svg-name");
svgNameEl.textContent = `SVG: ${file.name}`;
svgNameEl.style.display = "";
applyCustomOverrides(customizerState);
};
reader.readAsText(file);
e.target.value = "";
});
// Pattern option changes (live)
document.getElementById("wfm-pattern-color")?.addEventListener("input", (e) => {
customizerState.pattern.color = e.target.value;
applyCustomOverrides(customizerState);
});
document.getElementById("wfm-pattern-opacity")?.addEventListener("input", (e) => {
const v = parseFloat(e.target.value);
customizerState.pattern.opacity = v;
document.getElementById("wfm-pattern-opacity-val").textContent = v;
applyCustomOverrides(customizerState);
});
document.getElementById("wfm-pattern-scale")?.addEventListener("input", (e) => {
const v = parseInt(e.target.value);
customizerState.pattern.scale = v;
document.getElementById("wfm-pattern-scale-val").textContent = v + "px";
applyCustomOverrides(customizerState);
});
document.getElementById("wfm-pattern-gap")?.addEventListener("input", (e) => {
const v = parseInt(e.target.value);
customizerState.pattern.gap = v;
document.getElementById("wfm-pattern-gap-val").textContent = v + "px";
applyCustomOverrides(customizerState);
});
// Font selection
document.getElementById("wfm-font-grid")?.addEventListener("click", (e) => {
const item = e.target.closest(".wfm-font-item");
if (!item) return;
const fontId = item.dataset.fontId;
customizerState.fontId = fontId;
// Load web font for preview
const font = FONT_OPTIONS.find(f => f.id === fontId);
if (font?.google) loadGoogleFont(font.google);
applyCustomFont(fontId);
// Update active state
document.querySelectorAll(".wfm-font-item").forEach(i => i.classList.remove("active"));
item.classList.add("active");
});
// Apply & Save
document.getElementById("wfm-customizer-apply")?.addEventListener("click", () => {
// Clean empty color overrides
const cleanColors = {};
for (const [k, v] of Object.entries(customizerState.colors)) {
if (v) cleanColors[k] = v;
}
const custom = {
colors: Object.keys(cleanColors).length ? cleanColors : undefined,
pattern: customizerState.pattern.id !== "none" ? { ...customizerState.pattern } : undefined,
fontId: customizerState.fontId || undefined,
};
saveLocalSettings({ themeCustom: (custom.colors || custom.pattern || custom.fontId) ? custom : undefined });
showToast(t("settingsSaved"), "success");
});
// Reset
document.getElementById("wfm-customizer-reset")?.addEventListener("click", () => {
clearCustomOverrides();
customizerState = {
colors: {},
pattern: { id: "none", color: "#ffffff", opacity: 0.1, scale: 20, gap: 0 },
fontId: "",
};
// Remove themeCustom from localStorage directly (spread merge won't delete keys)
const cur = loadLocalSettings();
delete cur.themeCustom;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
applyTheme(cur.theme || "");
// Reset UI
requestAnimationFrame(() => {
document.querySelectorAll("#wfm-color-grid input[type=color]").forEach(inp => {
const v = getComputedStyle(document.documentElement).getPropertyValue(inp.dataset.var).trim();
if (v) inp.value = v.startsWith("#") ? v : rgbToHex(v);
});
});
document.querySelectorAll(".wfm-pattern-item").forEach(i => i.classList.remove("active"));
document.querySelector('.wfm-pattern-item[data-pattern-id="none"]')?.classList.add("active");
document.getElementById("wfm-pattern-options").style.display = "none";
document.getElementById("wfm-svg-name").style.display = "none";
// Reset font UI
document.querySelectorAll(".wfm-font-item").forEach(i => i.classList.remove("active"));
document.querySelector('.wfm-font-item[data-font-id=""]')?.classList.add("active");
showToast(t("settingsSaved"), "success");
});
// --- Language change handlers ---
document.getElementById("wfm-settings-ui-lang")?.addEventListener("change", (e) => {
const lang = e.target.value;
@@ -253,6 +882,50 @@ export async function initSettingsTab() {
}
});
// --- Gallery output dir handlers ---
document.getElementById("wfm-settings-output-dir-apply")?.addEventListener("click", async () => {
const dirInput = document.getElementById("wfm-settings-output-dir");
const currentEl = document.getElementById("wfm-settings-output-dir-current");
const newDir = dirInput.value.trim();
try {
const res = await fetch("/api/wfm/settings/output-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gallery_output_dir: newDir }),
});
const data = await res.json();
if (data.error) {
showToast(`${t("workflowsDirError")}: ${data.error}`, "error");
} else {
if (currentEl) currentEl.textContent = data.current;
showToast(t("workflowsDirChanged"), "success");
// gallery-tab に通知して再ロード
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
}
} catch (err) {
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
}
});
document.getElementById("wfm-settings-output-dir-reset")?.addEventListener("click", async () => {
const dirInput = document.getElementById("wfm-settings-output-dir");
const currentEl = document.getElementById("wfm-settings-output-dir-current");
dirInput.value = "";
try {
const res = await fetch("/api/wfm/settings/output-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gallery_output_dir: "" }),
});
const data = await res.json();
if (currentEl) currentEl.textContent = data.current;
showToast(t("workflowsDirChanged"), "success");
window.dispatchEvent(new CustomEvent("wfm-output-dir-changed", { detail: { path: data.current } }));
} catch (err) {
showToast(`${t("workflowsDirError")}: ${err.message}`, "error");
}
});
// --- Ollama model list loader ---
async function loadOllamaModels() {
const select = document.getElementById("wfm-settings-ollama-model");
@@ -361,6 +1034,7 @@ export async function initSettingsTab() {
eagleAutoSave: document.getElementById("wfm-settings-eagle-auto-save")?.checked || false,
uiLang: getLang(),
summaryLang: getSummaryLang(),
theme: document.documentElement.getAttribute("data-theme") || "",
};
saveLocalSettings(patch);
@@ -376,4 +1050,51 @@ export async function initSettingsTab() {
if (savedUrl) {
comfyUI.updateUrl(savedUrl);
}
// --- Data Export ---
document.getElementById("wfm-settings-export")?.addEventListener("click", async () => {
const statusEl = document.getElementById("wfm-settings-data-status");
try {
const res = await fetch("/api/wfm/settings/export");
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "wfm-data-export.json";
a.click();
URL.revokeObjectURL(url);
statusEl.textContent = t("exportSuccess");
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("exportError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
});
// --- Data Import ---
document.getElementById("wfm-settings-import-file")?.addEventListener("change", async (e) => {
const statusEl = document.getElementById("wfm-settings-data-status");
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const bundle = JSON.parse(text);
const res = await fetch("/api/wfm/settings/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bundle),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || res.statusText);
const imported = result.imported?.join(", ") || "-";
statusEl.textContent = t("importSuccess") + ": " + imported;
statusEl.style.color = "var(--wfm-success, #4caf50)";
} catch (e) {
statusEl.textContent = t("importError") + ": " + e.message;
statusEl.style.color = "var(--wfm-error, #f44336)";
}
// Reset file input so same file can be re-selected
e.target.value = "";
});
}
+273 -260
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;
@@ -462,14 +482,15 @@ async function showSidePanel(wf, cardEl) {
sidePanelThumbUpdate(wf);
// Update group tab
sidePanelGroupUpdate();
renderSideGroup(wf);
const badgeEl = document.getElementById("wfm-json-format-badge");
try {
const data = await getRawWorkflow(wf.filename);
if (badgeEl) {
const fmt = comfyWorkflow.detectFormat(data);
badgeEl.textContent = fmt === "ui" ? t("uiFormat") : t("apiFormat");
const fmt = comfyWorkflow.detectFormat(data, wf.filename);
const formatLabels = { ui: t("uiFormat"), api: t("apiFormat"), app: t("appFormat") };
badgeEl.textContent = formatLabels[fmt] || fmt;
badgeEl.className = "wfm-format-badge wfm-format-badge--" + fmt;
}
const jsonStr = JSON.stringify(data, null, 2);
@@ -485,17 +506,17 @@ async function showSidePanel(wf, cardEl) {
}
function closeSidePanel() {
const panel = document.getElementById("wfm-side-panel");
if (panel) panel.style.display = "none";
document.querySelectorAll(".wfm-card, .wfm-table-row").forEach((c) =>
c.classList.remove("wfm-card-selected")
);
state.selectedWf = null;
const titleEl = document.getElementById("wfm-side-panel-title");
if (titleEl) titleEl.textContent = "";
const listLoadBtn = document.getElementById("wfm-list-load-btn");
if (listLoadBtn) { listLoadBtn.disabled = true; listLoadBtn.title = t("selectCardFirst"); }
const listOpenComfyBtn = document.getElementById("wfm-list-open-comfyui-btn");
if (listOpenComfyBtn) { listOpenComfyBtn.disabled = true; listOpenComfyBtn.title = t("selectCardFirst"); }
sidePanelGroupUpdate();
renderSideGroup(null);
}
// ============================================
@@ -518,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) {
@@ -530,60 +551,166 @@ function sidePanelThumbUpdate(wf) {
// Group Tab in Side Panel
// ============================================
function sidePanelGroupUpdate() {
const nameEl = document.getElementById("wfm-grp-prop-name");
const groupEl = document.getElementById("wfm-grp-prop-group");
const assignBtn = document.getElementById("wfm-assign-group-btn");
const removeBtn = document.getElementById("wfm-remove-group-btn");
function renderSideGroup(wf) {
const el = document.getElementById("wfm-side-tab-group");
if (!el) return;
if (!state.selectedWf) {
if (nameEl) nameEl.textContent = t("noSelection");
if (groupEl) groupEl.textContent = "";
if (assignBtn) assignBtn.disabled = true;
if (removeBtn) removeBtn.disabled = true;
const allGroups = groups.groupNames();
if (!wf) {
el.innerHTML = `<p style="padding:12px;color:var(--wfm-text-secondary);font-size:12px;">${t("noSelection")}</p>`;
_refreshGroupFilter();
return;
}
const filename = state.selectedWf.filename;
if (nameEl) nameEl.textContent = filename.replace(/\.json$/, "");
const grps = groups.groupsOf(filename);
if (groupEl) groupEl.textContent = grps.length > 0 ? `${t("groupsLabel")}: ${grps.join(", ")}` : `${t("groupsLabel")}: ${t("groupsNone")}`;
if (assignBtn) assignBtn.disabled = false;
const filename = wf.filename;
const memberOf = groups.groupsOf(filename);
const available = allGroups.filter(g => !memberOf.includes(g));
const selGroup = document.getElementById("wfm-group-select")?.value;
if (removeBtn) removeBtn.disabled = !selGroup || !grps.includes(selGroup);
}
el.innerHTML = `
<div style="padding:8px;">
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
${memberOf.length === 0
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
: memberOf.map(g =>
`<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
<span style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(g)}</span>
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-wf-grp-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">&times;</button>
</div>`
).join("")}
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-grp-assign-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${available.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: available.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-grp-assign-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary"
${available.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
</div>
</div>
<div style="margin-bottom:12px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
<div style="display:flex;gap:4px;">
<input type="text" id="wfm-grp-new-input" class="wfm-input"
style="flex:1;font-size:12px;padding:3px 6px;" placeholder="${t("modelsGroupName")}">
<button id="wfm-grp-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsCreate")}</button>
</div>
</div>
<div style="border-top:1px solid var(--wfm-border);padding-top:10px;margin-top:4px;">
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
<div style="display:flex;gap:4px;">
<select id="wfm-grp-manage-sel" class="wfm-select" style="flex:1;font-size:12px;padding:3px 6px;">
${allGroups.length === 0
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
: allGroups.map(g => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
</select>
<button id="wfm-grp-rename-btn" class="wfm-btn wfm-btn-sm"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsRename")}">&#9998;</button>
<button id="wfm-grp-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger"
${allGroups.length === 0 ? "disabled" : ""} title="${t("modelsDelete")}">&times;</button>
</div>
</div>
</div>
`;
function refreshGroupSelects() {
const sideSel = document.getElementById("wfm-group-select");
const filterSel = document.getElementById("wfm-group-filter");
const grpNames = groups.groupNames();
[filterSel, sideSel].forEach((sel, i) => {
if (!sel) return;
const prevVal = sel.value;
sel.innerHTML = i === 0
? `<option value="">${t("allGroups")}</option>`
: `<option value="">${t("selectGroup")}</option>`;
grpNames.forEach((g) => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
sel.appendChild(opt);
// グループから除外
el.querySelectorAll(".wfm-wf-grp-remove").forEach(btn => {
btn.addEventListener("click", () => {
groups.remove(filename, btn.dataset.group);
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Removed from "${btn.dataset.group}"`, "success");
});
if (grpNames.includes(prevVal)) sel.value = prevVal;
});
// グループに追加
el.querySelector("#wfm-grp-assign-btn")?.addEventListener("click", () => {
const g = el.querySelector("#wfm-grp-assign-sel")?.value;
if (!g) return;
groups.assign(filename, g);
renderSideGroup(wf);
showToast(`Added to "${g}"`, "success");
});
// グループ作成(作成後に現在のWFに追加)
el.querySelector("#wfm-grp-create-btn")?.addEventListener("click", () => {
const input = el.querySelector("#wfm-grp-new-input");
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "warning");
return;
}
groups.assign(filename, name);
input.value = "";
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" created`, "success");
});
// グループ名変更
el.querySelector("#wfm-grp-rename-btn")?.addEventListener("click", () => {
const oldName = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!oldName) return;
const newName = prompt(`Rename group "${oldName}" to:`, oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
return;
}
if (state.groupFilter === oldName) state.groupFilter = newName;
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Renamed to "${newName}"`, "success");
});
// グループ削除
el.querySelector("#wfm-grp-delete-btn")?.addEventListener("click", () => {
const name = el.querySelector("#wfm-grp-manage-sel")?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
if (state.groupFilter === name) {
state.groupFilter = "";
const filterSel = document.getElementById("wfm-group-filter");
if (filterSel) filterSel.value = "";
}
renderSideGroup(wf);
_refreshGroupFilter();
showToast(`Group "${name}" deleted`, "success");
});
_refreshGroupFilter();
}
/** ツールバーのグループフィルタセレクトのみ更新 */
function _refreshGroupFilter() {
const filterSel = document.getElementById("wfm-group-filter");
if (!filterSel) return;
const prevVal = filterSel.value;
const grpNames = groups.groupNames();
filterSel.innerHTML = `<option value="">${t("allGroups")}</option>`;
grpNames.forEach(g => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
filterSel.appendChild(opt);
});
if (grpNames.includes(prevVal)) filterSel.value = prevVal;
}
// ============================================
// 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") },
@@ -597,57 +724,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();
});
}
// ============================================
@@ -672,10 +762,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 = `
@@ -688,23 +789,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>
@@ -812,51 +899,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");
}
@@ -866,10 +942,10 @@ function openDetailModal(wf) {
document.getElementById("wfm-detail-load").addEventListener("click", async () => {
try {
const wfData = await getRawWorkflow(wf.filename);
await loadWorkflowIntoEditor(wfData, wf.filename);
const loaded = await loadWorkflowIntoEditor(wfData, wf.filename);
if (loaded === false) return;
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
closeModal();
showToast(`${t("loaded")}: ${wf.filename}`, "success");
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
@@ -1002,6 +1078,17 @@ function openDetailModal(wf) {
async function loadWorkflows() {
try {
state.workflows = await fetchWorkflows();
// Clean up stale group entries (deleted workflows)
const validFiles = new Set(state.workflows.map(w => w.filename));
let groupsDirty = false;
for (const g of Object.keys(groups.data)) {
const before = groups.data[g].length;
groups.data[g] = groups.data[g].filter(fn => validFiles.has(fn));
if (groups.data[g].length !== before) groupsDirty = true;
}
if (groupsDirty) groups.save();
renderModelFilters();
renderGrid();
} catch (err) {
@@ -1058,6 +1145,7 @@ export function initWorkflowTab() {
if (searchInput) {
searchInput.addEventListener("input", () => {
state.searchText = searchInput.value;
state.currentPage = 0;
renderGrid();
});
}
@@ -1085,9 +1173,6 @@ export function initWorkflowTab() {
});
}
// Side panel: close button
document.getElementById("wfm-side-panel-close")?.addEventListener("click", closeSidePanel);
// Side panel: tab switching
document.querySelectorAll(".wfm-side-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -1121,78 +1206,12 @@ export function initWorkflowTab() {
// Group management
groups.load();
refreshGroupSelects();
// Add group
document.getElementById("wfm-group-add-btn")?.addEventListener("click", () => {
const input = document.getElementById("wfm-group-name-input");
const name = input?.value.trim();
if (!name) return;
if (!groups.createGroup(name)) {
showToast(`"${name}" already exists`, "error");
return;
}
input.value = "";
refreshGroupSelects();
showToast(`Group "${name}" created`, "success");
});
// Rename group
document.getElementById("wfm-group-rename-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const oldName = sel?.value;
if (!oldName) return;
const newName = prompt("New group name:", oldName);
if (!newName || newName === oldName) return;
if (!groups.renameGroup(oldName, newName)) {
showToast("Rename failed", "error");
return;
}
refreshGroupSelects();
sidePanelGroupUpdate();
showToast(`Group renamed to "${newName}"`, "success");
});
// Delete group
document.getElementById("wfm-group-delete-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const name = sel?.value;
if (!name) return;
if (!confirm(`Delete group "${name}"?`)) return;
groups.deleteGroup(name);
refreshGroupSelects();
sidePanelGroupUpdate();
showToast(`Group "${name}" deleted`, "success");
});
// Assign to group
document.getElementById("wfm-assign-group-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const groupName = sel?.value;
if (!groupName || !state.selectedWf) return;
groups.assign(state.selectedWf.filename, groupName);
sidePanelGroupUpdate();
showToast(`Added to "${groupName}"`, "success");
});
// Remove from group
document.getElementById("wfm-remove-group-btn")?.addEventListener("click", () => {
const sel = document.getElementById("wfm-group-select");
const groupName = sel?.value;
if (!groupName || !state.selectedWf) return;
groups.remove(state.selectedWf.filename, groupName);
sidePanelGroupUpdate();
showToast(`Removed from "${groupName}"`, "success");
});
// Update remove button state when group select changes
document.getElementById("wfm-group-select")?.addEventListener("change", () => {
sidePanelGroupUpdate();
});
_refreshGroupFilter();
// Toolbar: group filter dropdown
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
state.currentPage = 0;
renderGrid();
});
@@ -1201,9 +1220,9 @@ export function initWorkflowTab() {
if (!state.selectedWf) return;
try {
const wfData = await getRawWorkflow(state.selectedWf.filename);
await loadWorkflowIntoEditor(wfData, state.selectedWf.filename);
const loaded = await loadWorkflowIntoEditor(wfData, state.selectedWf.filename);
if (loaded === false) return;
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
showToast(`${t("loaded")}: ${state.selectedWf.filename}`, "success");
} catch (err) {
showToast(t("loadError") + ": " + err.message, "error");
}
@@ -1226,7 +1245,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) {
@@ -1237,7 +1256,7 @@ export function initWorkflowTab() {
badgeSettingsPanel.style.display = "none";
return;
}
renderBadgeSettings();
renderViewSettings();
badgeSettingsPanel.style.display = "";
});
document.addEventListener("click", (e) => {
@@ -1247,15 +1266,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
+521 -104
View File
@@ -6,6 +6,7 @@
<title>Workflow Studio</title>
<link rel="icon" type="image/svg+xml" href="/wfm_static/favicon.svg">
<link rel="stylesheet" href="/wfm_static/css/main.css">
<link rel="stylesheet" href="/wfm_static/css/gallery-tab.css">
</head>
<body>
<!-- Header -->
@@ -15,8 +16,11 @@
</div>
<nav class="wfm-tabs">
<button class="wfm-tab active" data-tab="workflow">Workflow</button>
<button class="wfm-tab" data-tab="nodes">Nodes</button>
<button class="wfm-tab" data-tab="models">Models</button>
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
<button class="wfm-tab" data-tab="prompt">Prompt</button>
<button class="wfm-tab" data-tab="gallery">Gallery</button>
<button class="wfm-tab" data-tab="settings">Settings</button>
<button class="wfm-tab" data-tab="help">Help</button>
</nav>
@@ -48,13 +52,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>
@@ -64,10 +68,9 @@
<p class="wfm-placeholder">Loading workflows...</p>
</div>
<!-- Side Panel -->
<div id="wfm-side-panel" class="wfm-side-panel" style="display:none;">
<div id="wfm-side-panel" class="wfm-side-panel">
<div class="wfm-side-panel-header">
<span id="wfm-side-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-side-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-side-tab-btn active" data-side-tab="thumb">Thumbnail</button>
@@ -95,35 +98,144 @@
<pre id="wfm-json-content" class="wfm-json-content"></pre>
</div>
<!-- Group Tab -->
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;">
<div class="wfm-group-panel">
<div class="wfm-group-section">
<div class="wfm-group-section-title">Properties</div>
<div id="wfm-grp-prop-name" class="wfm-group-prop">No selection</div>
<div id="wfm-grp-prop-group" class="wfm-group-prop"></div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-group-section">
<div class="wfm-group-section-title">Group Management</div>
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input type="text" id="wfm-group-name-input" class="wfm-input" placeholder="Group name" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
<button id="wfm-group-add-btn" class="wfm-btn wfm-btn-sm" title="Create group">Add</button>
</div>
<div style="display:flex;gap:4px;margin-bottom:6px;">
<select id="wfm-group-select" class="wfm-select" style="flex:1;min-width:0;padding:4px 6px;font-size:12px;">
<option value="">Select group</option>
</select>
<button id="wfm-group-rename-btn" class="wfm-btn wfm-btn-sm" title="Rename group">&#9998;</button>
<button id="wfm-group-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete group" style="padding:4px 8px;font-size:12px;">&times;</button>
</div>
</div>
<hr class="wfm-group-divider">
<div class="wfm-group-section">
<div class="wfm-group-section-title">Workflow Actions</div>
<button id="wfm-assign-group-btn" class="wfm-btn wfm-btn-sm" style="width:100%;margin-bottom:4px;" disabled>Add to Group</button>
<button id="wfm-remove-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" style="width:100%;margin-bottom:4px;font-size:12px;" disabled>Remove from Group</button>
</div>
</div>
<div id="wfm-side-tab-group" class="wfm-side-tab-content" style="display:none;overflow-y:auto;">
<!-- JS動的生成: renderSideGroup() -->
</div>
</div>
</div>
</section>
<!-- Nodes Tab -->
<section id="wfm-tab-nodes" class="wfm-tab-content">
<!-- Sub-view toggle + Toolbar -->
<div class="wfm-toolbar">
<div class="wfm-toolbar-row" style="gap:8px;">
<div class="wfm-nodes-subview-nav">
<button class="wfm-nodes-subview-btn active" data-subview="browser">All Nodes</button>
<button class="wfm-nodes-subview-btn" data-subview="sets">Node Sets</button>
</div>
</div>
<!-- Browser toolbar -->
<div id="wfm-nodes-browser-toolbar" class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;">
<button class="wfm-filter-btn" id="wfm-nodes-fav-btn" title="Favorites">&#9733;</button>
<input type="text" id="wfm-nodes-search" class="wfm-search-input" placeholder="Search nodes...">
<select id="wfm-nodes-category-filter" class="wfm-select wfm-toolbar-select"><option value="">All Categories</option></select>
<select id="wfm-nodes-package-filter" class="wfm-select wfm-toolbar-select"><option value="">All Packages</option></select>
<select id="wfm-nodes-tag-filter" class="wfm-select wfm-toolbar-select"><option value="">All Tags</option></select>
<select id="wfm-nodes-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-nodes-view="card" title="Card">&#9776;</button>
<button class="wfm-view-btn" data-nodes-view="table" title="Table">&#8801;</button>
</div>
<span id="wfm-nodes-count" class="wfm-nodes-count"></span>
<button id="wfm-nodes-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
</div>
</div>
<!-- Browser sub-view -->
<div id="wfm-nodes-browser" class="wfm-workflow-area">
<div id="wfm-nodes-grid" class="wfm-grid wfm-view-card">
<p class="wfm-placeholder" id="wfm-nodes-placeholder">Click to load nodes...</p>
</div>
<!-- Side Panel -->
<div id="wfm-nodes-side-panel" class="wfm-side-panel">
<div class="wfm-side-panel-header">
<span id="wfm-nodes-panel-title" class="wfm-side-panel-title"></span>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-nodes-side-tab-btn active" data-side-tab="details">Details</button>
<button class="wfm-nodes-side-tab-btn" data-side-tab="io">I/O Spec</button>
<button class="wfm-nodes-side-tab-btn" data-side-tab="nodegroup">Groups</button>
</div>
<div id="wfm-nodes-side-details" class="wfm-nodes-side-content active" style="overflow-y:auto;padding:12px;">
</div>
<div id="wfm-nodes-side-io" class="wfm-nodes-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
<div id="wfm-nodes-side-group" class="wfm-nodes-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
</div>
</div>
<!-- Node Sets sub-view -->
<div id="wfm-nodes-sets" class="wfm-workflow-area" style="display:none;">
<div style="padding:8px 0;">
<button id="wfm-node-set-create-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Create Node Set</button>
</div>
<div id="wfm-node-sets-grid" class="wfm-grid wfm-view-card">
<p class="wfm-placeholder">No node sets yet.</p>
</div>
</div>
</section>
<!-- Models Tab -->
<section id="wfm-tab-models" class="wfm-tab-content">
<div class="wfm-toolbar">
<!-- Row 1: Model type sub-tabs + 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>
<select id="wfm-models-status-filter" class="wfm-select wfm-toolbar-select">
<option value="all">All</option>
<option value="enabled">Enabled only</option>
<option value="disabled">Disabled only</option>
</select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-models-view="card" title="Card">&#9776;</button>
<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>
<button id="wfm-models-select-btn" class="wfm-btn wfm-btn-sm" title="Multi-select models">Select</button>
</div>
<!-- Bulk action bar (shown in select mode) -->
<div id="wfm-models-bulk-bar" class="wfm-bulk-bar" style="display:none;"></div>
</div>
<!-- Grid + Side Panel -->
<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>
@@ -145,53 +257,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>
@@ -214,10 +330,12 @@
</div>
<!-- Seed -->
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="width:140px;">
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:auto;">
<div style="display:flex;flex-direction:column;gap:4px;margin-top:8px;">
<div style="display:flex;gap:8px;align-items:center;">
<label style="font-size:12px;color:var(--wfm-text-secondary);white-space:nowrap;">Seed:</label>
<input type="number" id="wfm-gen-seed-value" class="wfm-input" value="-1" style="flex:1;min-width:0;">
</div>
<select id="wfm-gen-seed-mode" class="wfm-select" style="width:100%;">
<option value="random">Random</option>
<option value="fixed">Fixed</option>
<option value="increment">Increment</option>
@@ -226,6 +344,34 @@
</div>
</div>
<!-- Checkpoint Batch Panel -->
<div class="wfm-gen-panel" id="wfm-ckpt-batch-panel">
<div style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="wfm-ckpt-batch-enabled" style="width:14px;height:14px;cursor:pointer;accent-color:var(--wfm-primary);">
<label for="wfm-ckpt-batch-enabled" style="font-weight:600;font-size:13px;cursor:pointer;">Checkpoint Batch</label>
</div>
<div id="wfm-ckpt-batch-body" style="display:none;margin-top:8px;">
<div style="margin-bottom:6px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:3px;">Include Folders (empty = all, comma-separated)</div>
<input type="text" id="wfm-ckpt-batch-include" class="wfm-input" placeholder="e.g. sdxl, flux">
</div>
<div style="margin-bottom:6px;">
<div style="font-size:11px;color:var(--wfm-text-secondary);margin-bottom:3px;">Exclude Folders (comma-separated)</div>
<input type="text" id="wfm-ckpt-batch-exclude" class="wfm-input" placeholder="e.g. old, test">
</div>
<div id="wfm-ckpt-batch-info" style="font-size:11px;color:var(--wfm-text-secondary);"></div>
<div id="wfm-ckpt-batch-progress" style="margin-top:8px;display:none;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:11px;">
<span id="wfm-ckpt-batch-current-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:75%;color:var(--wfm-text-primary);"></span>
<span id="wfm-ckpt-batch-count" style="color:var(--wfm-text-secondary);white-space:nowrap;margin-left:8px;"></span>
</div>
<div class="wfm-gen-progress-track">
<div id="wfm-ckpt-batch-bar" class="wfm-gen-progress-bar" style="background:var(--wfm-warning,#f59e0b);"></div>
</div>
</div>
</div>
</div>
<!-- Result Display -->
<div class="wfm-gen-result">
<img id="wfm-gen-result-img" style="display:none;max-width:100%;border-radius:6px;">
@@ -233,14 +379,26 @@
</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 -->
<section id="wfm-tab-prompt" class="wfm-tab-content">
<div class="wfm-prompt-split">
<!-- Left: Assistant -->
<div class="wfm-prompt-split-left">
<div class="wfm-prompt-split-header">Assistant</div>
<div class="wfm-prompt-split wfm-prompt-split-3col">
<!-- Col 1: AI Assistant -->
<div class="wfm-prompt-split-col">
<div class="wfm-prompt-split-header">AI Assistant</div>
<div class="wfm-ollama-assistant">
<div class="wfm-ollama-controls">
<select id="wfm-ollama-model" class="wfm-select" style="flex:1;min-width:120px;">
@@ -276,30 +434,61 @@
</div>
</div>
<!-- Right: Presets -->
<div class="wfm-prompt-split-right">
<!-- Col 2: Preset Editor -->
<div class="wfm-prompt-split-col">
<div class="wfm-prompt-split-header">Presets</div>
<div class="wfm-preset-panel">
<div style="display:flex;gap:6px;margin-bottom:10px;align-items:center;">
<select id="wfm-preset-select" class="wfm-select" style="flex:1;">
<option value="">New Preset (none selected)</option>
</select>
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">Apply to GenerateUI</button>
<button id="wfm-preset-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger">Delete</button>
<button id="wfm-preset-apply-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">GenUI Set</button>
</div>
<div class="wfm-form-group">
<label>Preset Name</label>
<input type="text" id="wfm-preset-name" class="wfm-input" placeholder="Enter preset name...">
</div>
<div class="wfm-form-group">
<label>Category</label>
<input type="text" id="wfm-preset-category" class="wfm-input" placeholder="e.g. Portrait, Landscape...">
</div>
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Positive Prompt</label>
<textarea id="wfm-preset-pos" class="wfm-textarea" rows="4" placeholder="Positive prompt..."></textarea>
<textarea id="wfm-preset-pos" class="wfm-textarea" style="flex:1;" placeholder="Positive prompt..."></textarea>
</div>
<div class="wfm-form-group">
<div class="wfm-form-group" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<label>Negative Prompt</label>
<textarea id="wfm-preset-neg" class="wfm-textarea" rows="3" placeholder="Negative prompt..."></textarea>
<textarea id="wfm-preset-neg" class="wfm-textarea" style="flex:1;" placeholder="Negative prompt..."></textarea>
</div>
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="width:100%;">Save Preset</button>
<div style="display:flex; gap:4px;">
<button id="wfm-preset-copy-pos-btn" class="wfm-btn" style="flex:2;" title="Copy Positive Prompt">PP Copy</button>
<button id="wfm-preset-copy-neg-btn" class="wfm-btn" style="flex:2;" title="Copy Negative Prompt">NP Copy</button>
<button id="wfm-preset-save-btn" class="wfm-btn wfm-btn-primary" style="flex:1;">Save</button>
</div>
<div style="display:flex; gap:4px; margin-top:4px;">
<select id="wfm-preset-group-select" class="wfm-select" style="flex:1;">
<option value="">Select group...</option>
</select>
<button id="wfm-preset-add-to-group-btn" class="wfm-btn wfm-btn-sm">Add to Group</button>
<button id="wfm-preset-new-group-btn" class="wfm-btn wfm-btn-sm">+ Group</button>
<button id="wfm-preset-del-group-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected group">Del Group</button>
</div>
</div>
</div>
<!-- Col 3: Preset Manager (list, favorites, groups) -->
<div class="wfm-prompt-split-col">
<div class="wfm-prompt-split-header">Preset Manager</div>
<div class="wfm-preset-manager">
<div class="wfm-pm-tabs">
<button class="wfm-pm-tab active" data-pmtab="all">All</button>
<button class="wfm-pm-tab" data-pmtab="favorites">&#9733;</button>
<button class="wfm-pm-tab" data-pmtab="groups">Groups</button>
</div>
<div class="wfm-pm-search">
<input type="text" id="wfm-pm-search-input" class="wfm-input" placeholder="Search presets...">
</div>
<div id="wfm-pm-list" class="wfm-pm-list"></div>
</div>
</div>
</div>
@@ -318,53 +507,154 @@
<div class="wfm-help-section">
<h2 class="wfm-help-section-title" id="wfm-help-manual-title">Features</h2>
<div class="wfm-help-card">
<h3 id="wfm-help-about-title">About</h3>
<p id="wfm-help-about-desc">A centralized control UI for managing ComfyUI workflow JSON files, generating images via API, assisting with prompt creation, and browsing/organizing nodes.</p>
<ul>
<li id="wfm-help-about-1">Install via ComfyUI Manager or git clone to custom_nodes</li>
<li id="wfm-help-about-2">Launch from the Workflow Studio button in the ComfyUI toolbar, or open http://127.0.0.1:8188/wfm</li>
</ul>
</div>
<div class="wfm-help-card">
<h3>Workflow Tab</h3>
<ul>
<li id="wfm-help-wf-1">Browse, search, and filter your workflow collection</li>
<li id="wfm-help-wf-2">Import workflows from JSON or PNG files</li>
<li id="wfm-help-wf-3">Organize workflows into groups</li>
<li id="wfm-help-wf-4">View thumbnails, JSON details, and properties</li>
<li id="wfm-help-wf-5">Click a card to open the detail modal for analysis, tagging, and summarization</li>
<li id="wfm-help-wf-1">Switch between Thumbnail, Card, and Table view modes</li>
<li id="wfm-help-wf-2">Search, category filter, favorites filter, and group filter</li>
<li id="wfm-help-wf-3">Create, rename, delete groups and assign/remove workflows</li>
<li id="wfm-help-wf-4">Import JSON/PNG files via button or drag &amp; drop</li>
<li id="wfm-help-wf-5">Side panel: thumbnail, JSON viewer (format badge: UI/API/App), clipboard copy, group management</li>
<li id="wfm-help-wf-6">Detail modal: rename, favorite, thumbnail change, 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>
<div class="wfm-help-card">
<h3>GenerateUI Tab</h3>
<ul>
<li id="wfm-help-gen-1">Load a workflow and edit parameters (prompts, images, models, settings)</li>
<li id="wfm-help-gen-2">Connect to ComfyUI and generate images directly</li>
<li id="wfm-help-gen-3">Control seed behavior (random, fixed, increment, decrement)</li>
<li id="wfm-help-gen-4">View generated results with thumbnail history</li>
<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">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 — seed input and mode selector stacked vertically for readability</li>
<li id="wfm-help-gen-9">Generate, progress display, stop, and view results</li>
<li id="wfm-help-gen-10">Eagle integration: auto-save generated images to Eagle</li>
<li id="wfm-help-gen-11">Checkpoint Batch: enable via checkbox in the right panel to auto-generate once per checkpoint model; filter by include folders (comma-separated), exclude folders, or leave blank to use all checkpoints; Stop button aborts the loop after the current generation; per-model progress bar shown in amber</li>
</ul>
</div>
<div class="wfm-help-card">
<h3>Prompt Tab</h3>
<ul>
<li id="wfm-help-prompt-1">Chat with AI assistant (Ollama) for prompt ideas and improvements</li>
<li id="wfm-help-prompt-2">Translate prompts between Japanese, English, and Chinese</li>
<li id="wfm-help-prompt-3">Save and manage prompt presets</li>
<li id="wfm-help-prompt-4">Apply presets directly to GenerateUI</li>
<li id="wfm-help-prompt-1">AI assistant (Ollama): model selection, chat for prompt consultation, image attachment (vision models)</li>
<li id="wfm-help-prompt-2">Apply AI responses to GenerateUI prompts</li>
<li id="wfm-help-prompt-3">Translation: JA&#8596;EN, ZH&#8596;EN</li>
<li id="wfm-help-prompt-4">Preset management: create, edit, positive/negative prompt clipboard copy (PP/NP)</li>
<li id="wfm-help-prompt-5">Preset Manager: browse all presets, favorites, and group-based filtering</li>
<li id="wfm-help-prompt-6">Group management: create groups, assign/remove presets, delete groups</li>
<li id="wfm-help-prompt-7">GenUI Set: apply preset prompts directly to GenerateUI</li>
</ul>
</div>
<div class="wfm-help-card">
<h3>Settings Tab</h3>
<ul>
<li id="wfm-help-settings-1">Configure ComfyUI connection URL</li>
<li id="wfm-help-settings-2">Set up Ollama API for AI assistant features</li>
<li id="wfm-help-settings-3">Change UI language and summary language</li>
<li id="wfm-help-settings-4">Configure workflow data folder and Eagle integration</li>
<li id="wfm-help-settings-1">UI language and summary language</li>
<li id="wfm-help-settings-2">Theme selection (13 presets) with customization: color overrides, background patterns (stripes, polka dot, checkerboard, SVG tiling), and font selection (16 Google Fonts including Japanese display fonts)</li>
<li id="wfm-help-settings-3">Workflow data folder path</li>
<li id="wfm-help-settings-4">ComfyUI connection URL and test</li>
<li id="wfm-help-settings-5">Ollama URL, default model, and connection test</li>
<li id="wfm-help-settings-6">Default workflow setting</li>
<li id="wfm-help-settings-7">Eagle integration URL and auto-save toggle</li>
<li id="wfm-help-settings-8">Data Management: export all plugin data to JSON / import to restore</li>
<li id="wfm-help-settings-9">All sections are collapsible accordion panels</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-tips-title">Tips</h3>
<h3>Nodes Tab</h3>
<ul>
<li id="wfm-help-tips-1">Drag &amp; drop PNG files with embedded workflow data to import them</li>
<li id="wfm-help-tips-2">Use the star button to mark favorite workflows for quick access</li>
<li id="wfm-help-tips-3">Set a default workflow to auto-load on startup from the detail modal</li>
<li id="wfm-help-nodes-1">Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites</li>
<li id="wfm-help-nodes-2">Card and Table view modes with pagination</li>
<li id="wfm-help-nodes-3">Favorite nodes and add custom tags for organization</li>
<li id="wfm-help-nodes-4">Create, rename, delete node groups and assign/remove nodes</li>
<li id="wfm-help-nodes-5">Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management</li>
<li id="wfm-help-nodes-6">Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-gallery-title">Gallery Tab</h3>
<ul>
<li id="wfm-help-gallery-1">Browse images in the ComfyUI output folder — set the folder path in Settings &gt; Gallery Output Folder</li>
<li id="wfm-help-gallery-2">3-column layout: folder tree (left) | image grid (center) | detail panel (right)</li>
<li id="wfm-help-gallery-3">Thumbnail and Table view modes; double-click any image to open fullscreen lightbox</li>
<li id="wfm-help-gallery-4">Toolbar filters: favorites (★), text search, tag filter, group filter, sort by date/name</li>
<li id="wfm-help-gallery-5">Folder tree: click to load images, expand with ▶; "+ New" creates a subfolder inside the selected folder, "Del" deletes it with all contents</li>
<li id="wfm-help-gallery-6">Multi-select with Ctrl+click — bulk operations: Add to Group, Favorite All, Unfavorite All, Move To..., Delete</li>
<li id="wfm-help-gallery-7">Detail panel — Info tab: filename, size, date, tags (add/remove), memo; "Move To..." moves the image to another folder, "Delete" removes it permanently</li>
<li id="wfm-help-gallery-8">Detail panel — Metadata tab: displays workflow JSON from PNG embedded data (prompt / workflow keys) or from metadata saved by the Generate UI tab; one-click clipboard copy</li>
<li id="wfm-help-gallery-9">Detail panel — Groups tab: create, assign, remove, rename, and delete groups per image</li>
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the Metadata tab</li>
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF; workflow extraction reads ComfyUI prompt and workflow keys embedded in PNG metadata</li>
</ul>
</div>
<div class="wfm-help-card">
<h3>Models Tab</h3>
<ul>
<li id="wfm-help-models-1">Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder, Hypernetwork, Embedding with sub-tab switching</li>
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, favorites filter, and status filter (All / Enabled only / Disabled only)</li>
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
<li id="wfm-help-models-8">GenUI Model button: apply the selected model to the corresponding node in GenerateUI's current workflow (Checkpoint / LoRA / VAE / ControlNet / UNET / TextEncoder)</li>
<li id="wfm-help-models-9">User-defined badges: assign free-label badges with custom colors shared with the Workflow tab</li>
<li id="wfm-help-models-10">Enable/Disable models: toggle visibility in ComfyUI by renaming file extension (.disabled suffix). Disable per model (⏸ button on each card) or per group (Enable All / Disable All in Group panel). Changes take effect after ComfyUI refresh.</li>
<li id="wfm-help-models-11">Multi-select &amp; bulk operations: click Select to enter selection mode, check multiple models, then use the bulk action bar to add/remove from groups or permanently delete files (model file + preview images + sidecar files such as .json/.info).</li>
</ul>
</div>
<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: 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>
<li id="wfm-help-sidepanel-7">Drag &amp; drop prompt presets to create WFS_PromptText nodes with positive/negative prompts</li>
<li id="wfm-help-sidepanel-8">Copy individual positive (P) or negative (N) prompts from sidebar items</li>
<li id="wfm-help-sidepanel-9">Double-click items to load/place without dragging</li>
<li id="wfm-help-sidepanel-10">Search within each sub-tab to quickly find items</li>
<li id="wfm-help-sidepanel-12">⚙ Theme button (header): customize panel background, text, border colors — saved to localStorage</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-shortcuts-title">Keyboard Shortcuts</h3>
<ul>
<li id="wfm-help-shortcuts-1">Escape: Close modal/panel</li>
<li id="wfm-help-shortcuts-2">Enter: Confirm rename, send chat</li>
<li id="wfm-help-shortcuts-3">Shift+Enter: New line in chat</li>
<li id="wfm-help-shortcuts-4">Double-click: Open detail modal</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-trouble-title">Troubleshooting</h3>
<ul>
<li id="wfm-help-trouble-1">Cannot connect to ComfyUI: Check if ComfyUI can generate images, verify URL in Settings, try refreshing or restarting</li>
<li id="wfm-help-trouble-2">Cannot connect to Ollama: Use connection test button, check if Ollama is running and models are downloaded</li>
<li id="wfm-help-trouble-3">Workflows not showing: Click refresh button, check if loadable in ComfyUI, try restarting browser/ComfyUI</li>
<li id="wfm-help-trouble-4">Image generation fails: Check if specified models exist in ComfyUI, verify connection, try restarting</li>
</ul>
</div>
</div>
@@ -377,7 +667,7 @@
<p id="wfm-help-support-desc">If you find this plugin useful, please consider supporting its development!</p>
<div class="wfm-help-links">
<a href="https://github.com/statsu-11/ComfyUI-Workflow-Studio" target="_blank" rel="noopener noreferrer" class="wfm-help-link wfm-help-link-github">
<a href="https://github.com/ketle-man/ComfyUI-Workflow-Studio" target="_blank" rel="noopener noreferrer" class="wfm-help-link wfm-help-link-github">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<div>
<div class="wfm-help-link-title">GitHub</div>
@@ -399,6 +689,133 @@
</div>
</div>
</section>
<!-- Gallery Tab -->
<section id="wfm-tab-gallery" class="wfm-tab-content">
<!-- Toolbar -->
<div class="wfm-toolbar">
<div class="wfm-toolbar-row" style="gap:6px;flex-wrap:wrap;align-items:center;">
<button id="wfm-gallery-fav-btn" class="wfm-filter-btn" title="Favorites">&#9733;</button>
<input type="text" id="wfm-gallery-search" class="wfm-search-input" placeholder="Search images...">
<select id="wfm-gallery-tag-filter" class="wfm-select wfm-toolbar-select">
<option value="">All Tags</option>
</select>
<select id="wfm-gallery-group-filter" class="wfm-select wfm-toolbar-select">
<option value="">All Groups</option>
</select>
<select id="wfm-gallery-sort" class="wfm-select wfm-toolbar-select">
<option value="date_desc">Date (Newest)</option>
<option value="date_asc">Date (Oldest)</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
</select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-gallery-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-gallery-view="table" title="Table">&#8801;</button>
</div>
<span id="wfm-gallery-count" class="wfm-nodes-count"></span>
<button id="wfm-gallery-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
</div>
<!-- 複数選択時の一括操作バー -->
<div id="wfm-gallery-bulk-bar" class="wfm-gallery-bulk-bar" style="display:none;">
<span id="wfm-gallery-bulk-count" class="wfm-gallery-bulk-count"></span>
<button id="wfm-gallery-bulk-deselect" class="wfm-btn wfm-btn-sm">Deselect All</button>
<select id="wfm-gallery-bulk-group-select" class="wfm-select" style="font-size:12px;padding:3px 6px;">
<option value="">Add to Group...</option>
</select>
<button id="wfm-gallery-bulk-group-add" class="wfm-btn wfm-btn-sm wfm-btn-primary">Add</button>
<button id="wfm-gallery-bulk-fav" class="wfm-btn wfm-btn-sm">★ Favorite All</button>
<button id="wfm-gallery-bulk-unfav" class="wfm-btn wfm-btn-sm">☆ Unfavorite All</button>
<button id="wfm-gallery-bulk-move" class="wfm-btn wfm-btn-sm">Move To...</button>
<button id="wfm-gallery-bulk-delete" class="wfm-btn wfm-btn-sm wfm-btn-danger">Delete</button>
</div>
</div>
<!-- 3カラムレイアウト -->
<div class="wfm-gallery-layout">
<!-- 左: フォルダツリー -->
<div id="wfm-gallery-tree-panel" class="wfm-gallery-tree-panel">
<div class="wfm-gallery-tree-header">
<span class="wfm-gallery-tree-title">Folders</span>
<div class="wfm-gallery-tree-actions">
<button id="wfm-gallery-folder-create-btn" class="wfm-btn wfm-btn-sm" title="Create subfolder in selected folder">+ New</button>
<button id="wfm-gallery-folder-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" title="Delete selected folder" disabled>Del</button>
</div>
</div>
<div id="wfm-gallery-tree" class="wfm-gallery-tree">
<p class="wfm-placeholder" id="wfm-gallery-tree-placeholder">Loading...</p>
</div>
</div>
<!-- 中: 画像一覧 -->
<div id="wfm-gallery-list-panel" class="wfm-gallery-list-panel">
<div id="wfm-gallery-grid" class="wfm-gallery-grid wfm-gallery-view-thumb">
<p class="wfm-placeholder" id="wfm-gallery-list-placeholder">Select a folder to browse images.</p>
</div>
</div>
<!-- 右: 詳細情報サイドパネル -->
<div id="wfm-gallery-detail-panel" class="wfm-gallery-detail-panel">
<div class="wfm-gallery-detail-header">
<span id="wfm-gallery-detail-filename" class="wfm-gallery-detail-filename"></span>
</div>
<!-- サムネイルプレビュー -->
<div id="wfm-gallery-detail-preview" class="wfm-gallery-detail-preview">
<span class="wfm-placeholder">No selection</span>
</div>
<!-- タブナビ -->
<div class="wfm-side-tab-nav">
<button class="wfm-gallery-detail-tab-btn active" data-detail-tab="info">Info</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="meta">Metadata</button>
<button class="wfm-gallery-detail-tab-btn" data-detail-tab="group">Groups</button>
</div>
<!-- Info タブ -->
<div id="wfm-gallery-detail-info" class="wfm-gallery-detail-tab-content active">
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-info-row" id="wfm-gallery-info-name"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-size"></div>
<div class="wfm-gallery-info-row" id="wfm-gallery-info-date"></div>
</div>
<hr class="wfm-group-divider">
<!-- タグ -->
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-section-title">Tags</div>
<div id="wfm-gallery-tags-display" class="wfm-gallery-tags-display"></div>
<div style="display:flex;gap:4px;margin-top:4px;">
<input type="text" id="wfm-gallery-tag-input" class="wfm-input" placeholder="Add tag..." style="flex:1;min-width:0;font-size:12px;padding:4px 6px;">
<button id="wfm-gallery-tag-add-btn" class="wfm-btn wfm-btn-sm">Add</button>
</div>
</div>
<hr class="wfm-group-divider">
<!-- メモ -->
<div class="wfm-gallery-info-section">
<div class="wfm-gallery-section-title">Memo</div>
<textarea id="wfm-gallery-memo" class="wfm-gallery-memo" placeholder="Write a memo..." rows="3"></textarea>
<button id="wfm-gallery-memo-save-btn" class="wfm-btn wfm-btn-sm" style="margin-top:4px;width:100%;">Save Memo</button>
</div>
<hr class="wfm-group-divider">
<!-- ファイル操作 -->
<div class="wfm-gallery-info-section wfm-gallery-file-actions">
<button id="wfm-gallery-img-move-btn" class="wfm-btn wfm-btn-sm" disabled>Move To...</button>
<button id="wfm-gallery-img-delete-btn" class="wfm-btn wfm-btn-sm wfm-btn-danger" disabled>Delete</button>
</div>
</div>
<!-- Metadata タブ -->
<div id="wfm-gallery-detail-meta" class="wfm-gallery-detail-tab-content wfm-gallery-meta-tab" style="display:none;">
<div class="wfm-gallery-meta-toolbar">
<span id="wfm-gallery-meta-status" class="wfm-gallery-meta-status"></span>
<button id="wfm-gallery-copy-workflow-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary" disabled title="Copy embedded workflow JSON">Copy Workflow</button>
</div>
<pre id="wfm-gallery-workflow-json" class="wfm-gallery-workflow-json">No workflow found in this image.</pre>
</div>
<!-- Group タブ -->
<div id="wfm-gallery-detail-group" class="wfm-gallery-detail-tab-content" style="display:none;overflow-y:auto;">
<!-- JS動的生成: renderDetailGroup() -->
</div>
</div>
</div>
</section>
</main>
<!-- Workflow Detail Modal -->
File diff suppressed because it is too large Load Diff
+818
View File
@@ -0,0 +1,818 @@
/**
* Node Library Sidebar Panel
*
* Fixed sidebar panel injected into ComfyUI DOM.
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
* Items are draggable onto the canvas or click-to-place.
*/
console.log("[WFM] node_sets_menu.js loading...");
import { app } from "../../scripts/app.js";
console.log("[WFM] node_sets_menu.js: app imported");
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
// ============================================
// State
// ============================================
const state = {
visible: false,
activeTab: "favorites", // favorites | sets | groups
favorites: [], // [{name, display_name}]
nodeSets: [],
groups: {}, // {groupName: [nodeName, ...]}
metadata: {},
searchText: "",
loaded: false,
};
// ============================================
// API
// ============================================
const fetchNodeSets = async () => {
try {
const res = await fetch("/api/wfm/node-sets");
return res.ok ? await res.json() : [];
} catch { return []; }
};
const fetchMetadata = async () => {
try {
const res = await fetch("/api/wfm/nodes/metadata");
return res.ok ? await res.json() : {};
} catch { return {}; }
};
const fetchGroups = async () => {
try {
const res = await fetch("/api/wfm/nodes/groups");
return res.ok ? await res.json() : {};
} catch { return {}; }
};
const loadData = async () => {
const [metadata, nodeSets, groups] = await Promise.all([
fetchMetadata(), fetchNodeSets(), fetchGroups(),
]);
state.metadata = metadata;
state.nodeSets = nodeSets;
state.groups = groups;
// Extract favorites from metadata
state.favorites = [];
for (const [name, meta] of Object.entries(metadata)) {
if (name === "_groups") continue;
if (meta.favorite) {
state.favorites.push({ name, display_name: name });
}
}
state.loaded = true;
};
// ============================================
// Canvas placement helpers
// ============================================
const getCanvasDropPos = (e) => {
const canvas = app.canvas;
if (!canvas) return [100, 100];
const rect = canvas.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
return [x, y];
};
const placeSingleNode = (classType, pos) => {
const graph = app.graph;
if (!graph) return;
const node = LiteGraph.createNode(classType);
if (!node) {
showToast(`Unknown node: ${classType}`, "error");
return;
}
node.pos = pos || [100, 100];
graph.add(node);
app.canvas.setDirty(true, true);
showToast(`Placed: ${node.title || classType}`, "success");
};
const placeNodeSet = (nodeSet, pos) => {
const graph = app.graph;
const canvas = app.canvas;
if (!graph || !canvas) return;
const nodes = nodeSet.nodes || [];
if (nodes.length === 0) return;
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
const positions = nodes.map(n => n.rel_pos || [0, 0]);
const minX = Math.min(...positions.map(p => p[0]));
const minY = Math.min(...positions.map(p => p[1]));
const createdNodes = [];
for (const nodeDef of nodes) {
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
const node = LiteGraph.createNode(nodeDef.class_type);
if (!node) { createdNodes.push(null); continue; }
const rp = nodeDef.rel_pos || [0, 0];
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
if (nodeDef.title) node.title = nodeDef.title;
if (nodeDef.widget_values && node.widgets) {
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
const w = node.widgets.find(ww => ww.name === k);
if (w) w.value = v;
}
}
graph.add(node);
createdNodes.push(node);
}
for (const link of (nodeSet.links || [])) {
const from = createdNodes[link.from_node];
const to = createdNodes[link.to_node];
if (from && to) {
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
}
}
canvas.setDirty(true, true);
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
};
// ============================================
// Drag & Drop onto canvas
// ============================================
let dropHandlerInstalled = false;
const installCanvasDropHandler = () => {
if (dropHandlerInstalled) return;
dropHandlerInstalled = true;
const canvasEl = document.getElementById("graph-canvas");
if (!canvasEl) return;
canvasEl.addEventListener("dragover", (e) => {
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
});
canvasEl.addEventListener("drop", (e) => {
const raw = e.dataTransfer.getData("application/x-wfm-node");
if (!raw) return;
e.preventDefault();
const data = JSON.parse(raw);
const pos = getCanvasDropPos(e);
if (data.type === "single") {
placeSingleNode(data.classType, pos);
} else if (data.type === "set") {
const set = state.nodeSets.find(s => s.id === data.setId);
if (set) placeNodeSet(set, pos);
}
});
};
// ============================================
// Sidebar Panel DOM
// ============================================
let panelEl = null;
const PANEL_ID = "wfm-node-library-panel";
const createPanel = () => {
if (panelEl) return panelEl;
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="wfm-nlp-header">
<span class="wfm-nlp-title">Node Library</span>
<button class="wfm-nlp-refresh" title="Refresh">&#8635;</button>
<button class="wfm-nlp-close" title="Close">&times;</button>
</div>
<div class="wfm-nlp-tabs">
<button class="wfm-nlp-tab active" data-tab="favorites">&#9733; Favorites</button>
<button class="wfm-nlp-tab" data-tab="sets">&#9776; Sets</button>
<button class="wfm-nlp-tab" data-tab="groups">&#128193; Groups</button>
</div>
<div class="wfm-nlp-search">
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
</div>
<div class="wfm-nlp-content"></div>
`;
document.body.appendChild(panel);
panelEl = panel;
// Tab switching
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
btn.addEventListener("click", () => {
state.activeTab = btn.dataset.tab;
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderContent();
});
});
// Close
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
// Search
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
state.searchText = e.target.value.toLowerCase().trim();
renderContent();
});
// Refresh
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
await loadData();
renderContent();
showToast("Node Library refreshed", "success");
});
injectStyles();
return panel;
};
// ============================================
// Render content
// ============================================
const renderContent = () => {
const content = panelEl?.querySelector(".wfm-nlp-content");
if (!content) return;
if (!state.loaded) {
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
return;
}
switch (state.activeTab) {
case "favorites": renderFavorites(content); break;
case "sets": renderSets(content); break;
case "groups": renderGroups(content); break;
}
};
const renderFavorites = (container) => {
let items = state.favorites;
if (state.searchText) {
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
}
if (items.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
</div>`;
return;
}
container.innerHTML = "";
for (const node of items) {
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
el.addEventListener("dblclick", () => {
placeSingleNode(node.name);
});
container.appendChild(el);
}
};
const renderSets = (container) => {
let items = state.nodeSets;
if (state.searchText) {
items = items.filter(s =>
s.name.toLowerCase().includes(state.searchText) ||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
);
}
if (items.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
</div>`;
return;
}
container.innerHTML = "";
for (const set of items) {
const count = (set.nodes || []).length;
const el = createDraggableItem(
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
"set",
{ setId: set.id },
set.description
);
el.addEventListener("dblclick", () => {
placeNodeSet(set);
});
container.appendChild(el);
}
};
const renderGroups = (container) => {
const groupNames = Object.keys(state.groups).sort();
if (groupNames.length === 0) {
container.innerHTML = `<div class="wfm-nlp-empty">
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
</div>`;
return;
}
let filtered = groupNames;
if (state.searchText) {
filtered = groupNames.filter(g =>
g.toLowerCase().includes(state.searchText) ||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
);
}
container.innerHTML = "";
for (const groupName of filtered) {
const nodes = state.groups[groupName] || [];
const section = document.createElement("div");
section.className = "wfm-nlp-group-section";
const header = document.createElement("div");
header.className = "wfm-nlp-group-header";
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.length}</span>`;
header.addEventListener("click", () => {
const list = section.querySelector(".wfm-nlp-group-list");
list.style.display = list.style.display === "none" ? "block" : "none";
header.classList.toggle("collapsed");
});
section.appendChild(header);
const list = document.createElement("div");
list.className = "wfm-nlp-group-list";
for (const nodeName of nodes) {
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
list.appendChild(el);
}
section.appendChild(list);
container.appendChild(section);
}
};
// ============================================
// Draggable item factory
// ============================================
const createDraggableItem = (label, type, data, subtitle) => {
const el = document.createElement("div");
el.className = "wfm-nlp-item";
el.draggable = true;
el.innerHTML = `
<div class="wfm-nlp-item-label">${label}</div>
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
`;
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
el.classList.add("dragging");
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
});
return el;
};
// ============================================
// Toggle & exports
// ============================================
export const togglePanel = async () => {
const panel = createPanel();
state.visible = !state.visible;
panel.style.display = state.visible ? "flex" : "none";
if (state.visible && !state.loaded) {
await loadData();
renderContent();
}
installCanvasDropHandler();
};
export const getNodeSetsIcon = () => `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<path d="M17.5 14v7M14 17.5h7"/>
</svg>
`;
// ============================================
// Toast
// ============================================
const showToast = (message, type = "info") => {
const existing = document.getElementById("wfm-ns-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "wfm-ns-toast";
const colors = {
success: { bg: "rgba(46,213,115,0.95)" },
error: { bg: "rgba(255,71,87,0.95)" },
info: { bg: "rgba(74,158,255,0.95)" },
};
Object.assign(toast.style, {
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
});
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
};
const esc = (s) => s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") : "";
// ============================================
// Save selected nodes as Node Set (context menu)
// ============================================
const saveSelectedAsNodeSet = async () => {
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
if (selectedNodes.length === 0) {
showToast("Select at least one node first", "error");
return;
}
// Prompt for name
const name = prompt("Node Set name:");
if (!name || !name.trim()) return;
// Build node list with relative positions
// Use the top-left of the bounding box as origin
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
const nodeIdToIndex = new Map();
const nodes = [];
selectedNodes.forEach((node, i) => {
nodeIdToIndex.set(node.id, i);
nodes.push({
class_type: node.type || node.comfyClass || "",
title: node.title || "",
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
});
});
// Extract links between selected nodes
const links = [];
const graph = app.graph;
for (const node of selectedNodes) {
if (!node.inputs) continue;
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
const input = node.inputs[slotIdx];
if (input.link == null) continue;
// Resolve link data
let linkData = null;
if (graph.links instanceof Map) {
linkData = graph.links.get(input.link);
} else if (graph.links) {
linkData = graph.links[input.link];
}
if (!linkData) continue;
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
// or object with .origin_id, .origin_slot, etc.
const originId = linkData.origin_id ?? linkData[1];
const originSlot = linkData.origin_slot ?? linkData[2];
const targetSlot = linkData.target_slot ?? linkData[4];
// Only include if both source and target are in the selection
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
links.push({
from_node: nodeIdToIndex.get(originId),
from_slot: originSlot,
to_node: nodeIdToIndex.get(node.id),
to_slot: targetSlot,
});
}
}
}
// Save via API
try {
const res = await fetch("/api/wfm/node-sets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
});
const result = await res.json();
if (result.status === "ok") {
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
// Refresh sidebar if open
if (state.loaded) {
await loadData();
renderContent();
}
} else {
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
}
} catch (err) {
showToast("Failed to save: " + err.message, "error");
}
};
// Register context menu item on all nodes
app.registerExtension({
name: "WorkflowStudio.NodeSetContextMenu",
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
// Add separator + menu item
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
});
// ============================================
// Styles
// ============================================
const injectStyles = () => {
if (document.getElementById("wfm-nlp-styles")) return;
const style = document.createElement("style");
style.id = "wfm-nlp-styles";
style.textContent = `
#${PANEL_ID} {
position: fixed;
right: 0;
top: 0;
width: 280px;
height: 100vh;
background: var(--comfy-menu-bg, #1e1e1e);
border-left: 1px solid var(--border-color, #4e4e4e);
z-index: 9999;
display: none;
flex-direction: column;
font-family: sans-serif;
font-size: 13px;
color: var(--input-text, #ddd);
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
}
.wfm-nlp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-title {
font-weight: 600;
font-size: 14px;
flex: 1;
}
.wfm-nlp-close {
background: none;
border: none;
color: var(--input-text, #ddd);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
opacity: 0.6;
}
.wfm-nlp-close:hover { opacity: 1; }
.wfm-nlp-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-tab {
flex: 1;
padding: 8px 4px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--input-text, #ddd);
font-size: 11px;
cursor: pointer;
opacity: 0.6;
transition: all 0.15s;
white-space: nowrap;
}
.wfm-nlp-tab:hover { opacity: 0.9; }
.wfm-nlp-tab.active {
opacity: 1;
border-bottom-color: var(--p-button-background, #4a9eff);
}
.wfm-nlp-search {
padding: 8px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.wfm-nlp-search-input {
width: 100%;
padding: 6px 8px;
background: var(--comfy-input-bg, #2a2a2a);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
color: var(--input-text, #ddd);
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.wfm-nlp-search-input:focus {
border-color: var(--p-button-background, #4a9eff);
}
.wfm-nlp-content {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.wfm-nlp-empty {
padding: 24px 16px;
text-align: center;
color: var(--descrip-text, #999);
font-size: 12px;
line-height: 1.6;
}
.wfm-nlp-item {
padding: 7px 12px;
cursor: grab;
border-bottom: 1px solid var(--border-color, #3a3a3a);
transition: background 0.12s;
user-select: none;
}
.wfm-nlp-item:hover {
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-item.dragging {
opacity: 0.5;
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-item-label {
font-size: 12px;
line-height: 1.3;
}
.wfm-nlp-item-sub {
font-size: 10px;
color: var(--descrip-text, #888);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wfm-nlp-badge {
display: inline-block;
font-size: 10px;
padding: 1px 5px;
background: var(--comfy-input-bg, #444);
border-radius: 3px;
color: var(--descrip-text, #aaa);
margin-left: 4px;
vertical-align: middle;
}
.wfm-nlp-group-section {
border-bottom: 1px solid var(--border-color, #3a3a3a);
}
.wfm-nlp-group-header {
padding: 8px 12px;
cursor: pointer;
font-weight: 500;
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.12s;
}
.wfm-nlp-group-header:hover {
background: var(--comfy-input-bg, #333);
}
.wfm-nlp-group-header::before {
content: "\\25BC";
font-size: 8px;
margin-right: 6px;
transition: transform 0.15s;
}
.wfm-nlp-group-header.collapsed::before {
transform: rotate(-90deg);
}
.wfm-nlp-group-list {
padding-left: 8px;
}
.wfm-nlp-group-list .wfm-nlp-item {
padding: 5px 12px;
font-size: 11px;
}
.wfm-nlp-refresh {
background: none;
border: none;
color: var(--input-text, #ddd);
font-size: 16px;
cursor: pointer;
opacity: 0.7;
}
.wfm-nlp-refresh:hover { opacity: 1; }
`;
document.head.appendChild(style);
};
// ============================================
// Context Menu — Save selected nodes as Node Set
// ============================================
const saveSelectedAsNodeSet = async () => {
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
if (selectedNodes.length === 0) {
showToast("Select at least one node first", "error");
return;
}
const name = prompt("Node Set name:");
if (!name || !name.trim()) return;
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
const nodeIdToIndex = new Map();
const nodes = [];
selectedNodes.forEach((node, i) => {
nodeIdToIndex.set(node.id, i);
nodes.push({
class_type: node.type || node.comfyClass || "",
title: node.title || "",
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
});
});
const links = [];
const graph = app.graph;
for (const node of selectedNodes) {
if (!node.inputs) continue;
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
const input = node.inputs[slotIdx];
if (input.link == null) continue;
let linkData = null;
if (graph.links instanceof Map) {
linkData = graph.links.get(input.link);
} else if (graph.links) {
linkData = graph.links[input.link];
}
if (!linkData) continue;
const originId = linkData.origin_id ?? linkData[1];
const originSlot = linkData.origin_slot ?? linkData[2];
const targetSlot = linkData.target_slot ?? linkData[4];
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
links.push({
from_node: nodeIdToIndex.get(originId),
from_slot: originSlot,
to_node: nodeIdToIndex.get(node.id),
to_slot: targetSlot,
});
}
}
}
try {
const res = await fetch("/api/wfm/node-sets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
});
const result = await res.json();
if (result.status === "ok") {
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
if (state.loaded) { await loadData(); renderContent(); }
} else {
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
}
} catch (err) {
showToast("Failed to save: " + err.message, "error");
}
};
export { saveSelectedAsNodeSet };
+247 -102
View File
@@ -1,4 +1,5 @@
import { app } from "../../scripts/app.js";
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
@@ -167,8 +168,101 @@ const captureCanvasSnapshot = () => {
});
};
const showSaveDialog = (defaultName) => {
return new Promise((resolve) => {
// Remove existing dialog if any
const existing = document.getElementById("wfm-save-dialog-overlay");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "wfm-save-dialog-overlay";
Object.assign(overlay.style, {
position: "fixed",
inset: "0",
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: "99999",
});
const dialog = document.createElement("div");
Object.assign(dialog.style, {
background: "var(--comfy-menu-bg, #2a2a2a)",
border: "1px solid var(--border-color, #4e4e4e)",
borderRadius: "8px",
padding: "16px 20px",
minWidth: "360px",
maxWidth: "480px",
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
color: "var(--input-text, #ddd)",
fontFamily: "sans-serif",
});
const stem = defaultName.replace(/\.json$/, "");
dialog.innerHTML = `
<div style="font-size:14px;font-weight:bold;margin-bottom:12px;">Save Workflow</div>
<label style="font-size:12px;color:var(--descrip-text,#999);display:block;margin-bottom:4px;">Filename</label>
<input type="text" id="wfm-save-dialog-input" value="${stem}" style="
width:100%;padding:8px 10px;font-size:13px;
background:var(--comfy-input-bg,#1a1a1a);
border:1px solid var(--border-color,#4e4e4e);
border-radius:4px;color:var(--input-text,#ddd);
outline:none;box-sizing:border-box;
" />
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
<button id="wfm-save-dialog-cancel" style="
padding:6px 16px;font-size:12px;border-radius:4px;cursor:pointer;
background:transparent;border:1px solid var(--border-color,#4e4e4e);
color:var(--input-text,#ddd);
">Cancel</button>
<button id="wfm-save-dialog-ok" style="
padding:6px 16px;font-size:12px;border-radius:4px;cursor:pointer;
background:var(--p-button-background,#4a9eff);border:none;
color:#fff;font-weight:bold;
">Save</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const input = dialog.querySelector("#wfm-save-dialog-input");
const okBtn = dialog.querySelector("#wfm-save-dialog-ok");
const cancelBtn = dialog.querySelector("#wfm-save-dialog-cancel");
const cleanup = () => overlay.remove();
const confirm = () => {
const val = input.value.trim();
cleanup();
if (val) {
resolve(val.endsWith(".json") ? val : val + ".json");
} else {
resolve(null);
}
};
const cancel = () => { cleanup(); resolve(null); };
okBtn.addEventListener("click", confirm);
cancelBtn.addEventListener("click", cancel);
overlay.addEventListener("click", (e) => { if (e.target === overlay) cancel(); });
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); confirm(); }
if (e.key === "Escape") cancel();
});
input.focus();
input.select();
});
};
const saveCanvasToWorkflowStudio = async () => {
const filename = getWorkflowFilename();
const defaultFilename = getWorkflowFilename();
const filename = await showSaveDialog(defaultFilename);
if (!filename) return; // cancelled
try {
showNotification("Capturing workflow image...", "info");
@@ -383,15 +477,47 @@ const createSnapshotButton = async () => {
return button;
};
const createNodeSetsButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "node-sets",
tooltip: NODE_SETS_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
});
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
button.element.title = NODE_SETS_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getNodeSetsIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", (e) => {
e.stopPropagation();
togglePanel();
});
return button;
};
const attachTopMenuButton = async (attempt = 0) => {
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
return;
}
// Try settingsGroup first (legacy ComfyUI)
const settingsGroup = app.menu?.settingsGroup;
if (!settingsGroup?.element?.parentElement) {
// Fallback: find the legacy topbar container used by newer ComfyUI
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
if (attempt >= MAX_ATTACH_ATTEMPTS) {
console.warn("Workflow Studio: unable to locate the ComfyUI settings button group.");
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
return;
}
@@ -401,109 +527,128 @@ const attachTopMenuButton = async (attempt = 0) => {
const wfmButton = await createTopMenuButton();
const snapshotButton = await createSnapshotButton();
const nodeSetsButton = await createNodeSetsButton();
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton);
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
settingsGroup.element.before(buttonGroup.element);
};
const createExtensionObject = (useActionBar) => {
const extensionObj = {
name: "WorkflowStudio.TopMenu",
async setup() {
if (!useActionBar) {
console.log("Workflow Studio: using legacy button attachment (frontend version < 1.33.9)");
await attachTopMenuButton();
} else {
console.log("Workflow Studio: using actionBarButtons API (frontend version >= 1.33.9)");
}
const injectStyles = () => {
const styleId = "wfm-top-menu-button-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
// WFM launch button
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";
}
});
// Snapshot button
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";
}
});
if (wfmButtons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
}
};
requestAnimationFrame(replaceButtonIcon);
},
};
if (useActionBar) {
extensionObj.actionBarButtons = [
{
icon: "icon-[mdi--file-document-multiple] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openWorkflowStudio,
},
{
icon: "icon-[mdi--camera] size-4",
tooltip: SNAPSHOT_TOOLTIP,
onClick: saveCanvasToWorkflowStudio,
},
];
if (settingsGroup?.element?.parentElement) {
settingsGroup.element.before(buttonGroup.element);
} else if (legacyContainer) {
legacyContainer.prepend(buttonGroup.element);
}
return extensionObj;
};
(async () => {
const useActionBar = await supportsActionBarButtons();
const extensionObj = createExtensionObject(useActionBar);
app.registerExtension(extensionObj);
})();
app.registerExtension({
name: "WorkflowStudio.TopMenu",
actionBarButtons: [
{
icon: "icon-[mdi--file-document-multiple] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openWorkflowStudio,
},
{
icon: "icon-[mdi--camera] size-4",
tooltip: SNAPSHOT_TOOLTIP,
onClick: saveCanvasToWorkflowStudio,
},
{
icon: "icon-[mdi--view-grid-plus] size-4",
tooltip: NODE_SETS_TOOLTIP,
onClick: () => togglePanel(),
},
],
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
async setup() {
const injectStyles = () => {
const styleId = "wfm-top-menu-button-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const applyButtonIcon = (button, className, getIconFn, extraStyles) => {
if (button.querySelector("svg")) return;
button.classList.add(className);
button.innerHTML = getIconFn();
button.style.borderRadius = "4px";
button.style.padding = "6px";
if (extraStyles) Object.assign(button.style, extraStyles);
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
};
const buttonConfigs = [
{ tooltip: BUTTON_TOOLTIP, className: "wfm-top-menu-button", getIcon: getWfmIcon, styles: { backgroundColor: "var(--primary-bg)" } },
{ tooltip: SNAPSHOT_TOOLTIP, className: "wfm-snapshot-button", getIcon: getSnapshotIcon, styles: null },
{ tooltip: NODE_SETS_TOOLTIP, className: "wfm-node-sets-btn", getIcon: getNodeSetsIcon, styles: null },
];
const replaceAllIcons = () => {
for (const cfg of buttonConfigs) {
document.querySelectorAll(`button[aria-label="${cfg.tooltip}"]`).forEach((btn) => {
applyButtonIcon(btn, cfg.className, cfg.getIcon, cfg.styles);
});
}
};
const waitAndObserve = () => {
const firstBtn = document.querySelector(`button[aria-label="${BUTTON_TOOLTIP}"]`);
if (!firstBtn) {
requestAnimationFrame(waitAndObserve);
return;
}
replaceAllIcons();
const container = firstBtn.closest(".actionbar-container") || firstBtn.parentElement;
if (container) {
new MutationObserver(() => replaceAllIcons()).observe(container, {
childList: true,
subtree: true,
});
}
};
requestAnimationFrame(waitAndObserve);
},
});
+565
View File
@@ -0,0 +1,565 @@
console.log("[WFM] top_menu_extension.js loading...");
import { app } from "../../scripts/app.js";
console.log("[WFM] app imported:", !!app);
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
console.log("[WFM] node_sets_menu imports loaded");
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
const WFM_PATH = "/wfm";
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
const MAX_ATTACH_ATTEMPTS = 120;
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const openWorkflowStudio = (event) => {
const url = `${window.location.origin}${WFM_PATH}`;
if (event.shiftKey) {
window.open(url, "_blank", NEW_WINDOW_FEATURES);
return;
}
window.open(url, "_blank");
};
// ============================================
// Canvas Snapshot - Export workflow as PNG thumbnail
// ============================================
const getWorkflowFilename = () => {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
return `wf_${stamp}.json`;
};
// ---- PNG tEXt chunk embedding helpers ----
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
const joinArrayBuffer = (...bufs) => {
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
bufs.reduce((offset, buf) => {
result.set(buf, offset);
return offset + buf.byteLength;
}, 0);
return result;
};
let _crcTable = null;
const crc32 = (data) => {
if (!_crcTable) {
_crcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
_crcTable[n] = c;
}
}
let crc = 0 ^ -1;
for (let i = 0; i < data.byteLength; i++) {
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
};
const embedWorkflowInPng = async (blob, workflowJson) => {
const buffer = await blob.arrayBuffer();
const typedArr = new Uint8Array(buffer);
const view = new DataView(buffer);
// Create tEXt chunk: "tEXtworkflow\0{json}"
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
const ihdrDataLen = view.getUint32(8);
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
return new Blob([result], { type: "image/png" });
};
// ---- Canvas capture ----
const captureCanvasSnapshot = () => {
return new Promise((resolve, reject) => {
try {
const graph = app.graph;
const canvas = app.canvas;
if (!graph || !canvas || !graph._nodes?.length) {
reject(new Error("No workflow loaded"));
return;
}
// Serialize workflow data before changing canvas state
const workflowJson = JSON.stringify(graph.serialize());
// Calculate bounds of all nodes
const bounds = graph._nodes.reduce(
(p, n) => {
if (n.pos[0] < p[0]) p[0] = n.pos[0];
if (n.pos[1] < p[1]) p[1] = n.pos[1];
const b = n.getBounding();
const r = n.pos[0] + b[2];
const bot = n.pos[1] + b[3];
if (r > p[2]) p[2] = r;
if (bot > p[3]) p[3] = bot;
return p;
},
[99999, 99999, -99999, -99999]
);
// Add padding
const padding = 80;
bounds[0] -= padding;
bounds[1] -= padding;
bounds[2] += padding;
bounds[3] += padding;
// Save current canvas state
const savedState = {
scale: canvas.ds.scale,
width: canvas.canvas.width,
height: canvas.canvas.height,
offset: [...canvas.ds.offset],
transform: canvas.canvas.getContext("2d").getTransform(),
};
// Set canvas to render full workflow
const dpr = window.devicePixelRatio || 1;
canvas.ds.scale = 1;
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
canvas.ds.offset = [-bounds[0], -bounds[1]];
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
// Render
canvas.draw(true, true);
// Capture to blob
canvas.canvas.toBlob(async (rawBlob) => {
// Restore canvas state
canvas.ds.scale = savedState.scale;
canvas.canvas.width = savedState.width;
canvas.canvas.height = savedState.height;
canvas.ds.offset = savedState.offset;
canvas.canvas.getContext("2d").setTransform(savedState.transform);
canvas.draw(true, true);
if (!rawBlob) {
reject(new Error("Failed to generate canvas image"));
return;
}
try {
// Embed workflow JSON into PNG tEXt chunk
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
resolve(blob);
} catch (embedErr) {
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
resolve(rawBlob);
}
}, "image/png");
} catch (err) {
reject(err);
}
});
};
const saveCanvasToWorkflowStudio = async () => {
const filename = getWorkflowFilename();
try {
showNotification("Capturing workflow image...", "info");
const blob = await captureCanvasSnapshot();
const fd = new FormData();
fd.append("filename", filename);
fd.append("image", blob, "canvas.png");
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
method: "POST",
body: fd,
});
const data = await res.json();
if (data.error) {
throw new Error(data.error);
}
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
} catch (err) {
showNotification("Error: " + err.message, "error");
}
};
const showNotification = (message, type = "info") => {
// Try using ComfyUI's built-in toast if available
try {
if (app.ui?.dialog) {
// Fallback: use a simple temporary notification
}
} catch (e) { /* ignore */ }
// Simple toast notification
const existing = document.getElementById("wfm-snapshot-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.id = "wfm-snapshot-toast";
const colors = {
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
};
const c = colors[type] || colors.info;
Object.assign(toast.style, {
position: "fixed",
bottom: "24px",
left: "50%",
transform: "translateX(-50%)",
padding: "10px 24px",
background: c.bg,
color: c.color,
borderRadius: "8px",
fontSize: "14px",
fontWeight: "500",
zIndex: "99999",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
transition: "opacity 0.3s ease",
whiteSpace: "nowrap",
});
toast.textContent = message;
document.body.appendChild(toast);
if (type !== "info") {
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, 3000);
} else {
// Info messages will be replaced by success/error
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}
}, 10000);
}
};
// ============================================
// Version Detection & UI
// ============================================
const getComfyUIFrontendVersion = async () => {
try {
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
return window["__COMFYUI_FRONTEND_VERSION__"];
}
} catch (error) {
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
}
try {
const response = await fetch("/system_stats");
const data = await response.json();
if (data?.system?.comfyui_frontend_version) {
return data.system.comfyui_frontend_version;
}
if (data?.system?.required_frontend_version) {
return data.system.required_frontend_version;
}
} catch (error) {
console.warn("Workflow Studio: unable to fetch system_stats:", error);
}
return "0.0.0";
};
const parseVersion = (versionStr) => {
if (!versionStr || typeof versionStr !== "string") {
return [0, 0, 0];
}
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts;
};
const compareVersions = (version1, version2) => {
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1;
if (v1[i] < v2[i]) return -1;
}
return 0;
};
const supportsActionBarButtons = async () => {
const version = await getComfyUIFrontendVersion();
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
};
const getWfmIcon = () => {
return `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
</svg>
`;
};
const getSnapshotIcon = () => {
return `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M8 5 L9 3 L15 3 L16 5"/>
</svg>
`;
};
// ============================================
// Button Creation
// ============================================
const createTopMenuButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "wfm",
tooltip: BUTTON_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
});
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
button.element.title = BUTTON_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getWfmIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", openWorkflowStudio);
return button;
};
const createSnapshotButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "snapshot",
tooltip: SNAPSHOT_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse",
});
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
button.element.title = SNAPSHOT_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getSnapshotIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
return button;
};
const createNodeSetsButton = async () => {
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
const button = new ComfyButton({
icon: "node-sets",
tooltip: NODE_SETS_TOOLTIP,
app,
enabled: true,
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
});
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
button.element.title = NODE_SETS_TOOLTIP;
if (button.iconElement) {
button.iconElement.innerHTML = getNodeSetsIcon();
button.iconElement.style.width = "1.2rem";
button.iconElement.style.height = "1.2rem";
}
button.element.addEventListener("click", (e) => {
e.stopPropagation();
togglePanel();
});
return button;
};
const attachTopMenuButton = async (attempt = 0) => {
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
return;
}
// Try settingsGroup first (legacy ComfyUI)
const settingsGroup = app.menu?.settingsGroup;
// Fallback: find the legacy topbar container used by newer ComfyUI
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
if (attempt >= MAX_ATTACH_ATTEMPTS) {
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
return;
}
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
return;
}
const wfmButton = await createTopMenuButton();
const snapshotButton = await createSnapshotButton();
const nodeSetsButton = await createNodeSetsButton();
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
if (settingsGroup?.element?.parentElement) {
settingsGroup.element.before(buttonGroup.element);
} else if (legacyContainer) {
legacyContainer.prepend(buttonGroup.element);
}
};
app.registerExtension({
name: "WorkflowStudio.TopMenu",
actionBarButtons: [
{
icon: "icon-[mdi--file-document-multiple] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openWorkflowStudio,
},
{
icon: "icon-[mdi--camera] size-4",
tooltip: SNAPSHOT_TOOLTIP,
onClick: saveCanvasToWorkflowStudio,
},
{
icon: "icon-[mdi--view-grid-plus] size-4",
tooltip: NODE_SETS_TOOLTIP,
onClick: () => togglePanel(),
},
],
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
origGetExtraMenuOptions?.apply(this, arguments);
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
options.push(null);
options.push({
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
callback: saveSelectedAsNodeSet,
});
};
},
async setup() {
const injectStyles = () => {
const styleId = "wfm-top-menu-button-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
background-color: var(--primary-hover-bg) !important;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
transition: all 0.2s ease;
border: 1px solid transparent;
}
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
background-color: var(--primary-hover-bg) !important;
}
`;
document.head.appendChild(style);
};
injectStyles();
const replaceButtonIcon = () => {
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
wfmButtons.forEach((button) => {
button.classList.add("wfm-top-menu-button");
button.innerHTML = getWfmIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
button.style.backgroundColor = "var(--primary-bg)";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
snapButtons.forEach((button) => {
button.classList.add("wfm-snapshot-button");
button.innerHTML = getSnapshotIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
nsButtons.forEach((button) => {
button.classList.add("wfm-node-sets-btn");
button.innerHTML = getNodeSetsIcon();
button.style.borderRadius = "4px";
button.style.padding = "6px";
const svg = button.querySelector("svg");
if (svg) {
svg.style.width = "20px";
svg.style.height = "20px";
}
});
if (wfmButtons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
}
};
requestAnimationFrame(replaceButtonIcon);
},
});