Compare commits

..
2 Commits
Author SHA1 Message Date
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
17 changed files with 2672 additions and 56 deletions
+84
View File
@@ -1,5 +1,89 @@
# DEVLOG - ComfyUI-Workflow-Studio
## 2026-03-28: v0.2.2 Modelsタブ・CivitAI連携
### 概要
- ComfyUIにインストールされたモデル(Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder)を一覧・管理する「Models」タブを新規追加
- CivitAI API連携(SHA256ハッシュによるモデル情報取得、一括取得)
- モデルグループ管理、プレビュー画像表示・変更、メタデータ永続化
- ノードカードビュー改善、全タブのページネーションをツールバーに移動
### 新規ファイル
#### `py/services/models_service.py` — モデルメタデータサービス
- `_get_model_dirs()`: ComfyUIの`folder_paths.get_folder_paths()`でモデルディレクトリを解決
- `ModelsService`: メタデータのCRUD(お気に入り、タグ、メモ、SHA256)
- `find_preview_image()`: `{stem}.preview.png`等のプレビュー画像を自動検出
- `get_model_groups()` / `save_model_groups()`: グループの永続化(`model_metadata.json`の`_groups`キー)
#### `py/services/civitai_service.py` — CivitAI API連携
- `calculate_sha256()`: モデルファイルのSHA256ハッシュ計算
- `fetch_by_hash()`: CivitAI APIからモデル情報取得(キャッシュ付き)
- `batch_fetch()`: 複数モデルの一括取得(プログレスコールバック対応)
- `_extract_info()`: APIレスポンスから必要フィールドを抽出(modelName, baseModel, trainedWords, images等)
- キャッシュ: `civitai_cache.json`に保存
#### `py/routes/models_routes.py` — モデル管理APIルート
- `GET/POST /api/wfm/models/metadata` — メタデータCRUD
- `GET /api/wfm/models/preview` — プレビュー画像配信
- `GET/POST /api/wfm/models/groups` — グループ管理
- `POST /api/wfm/models/civitai/fetch` — 個別CivitAI取得
- `GET /api/wfm/models/civitai/cache` — CivitAIキャッシュ取得
- `POST /api/wfm/models/civitai/batch` — SSEストリーミング一括取得
- `POST /api/wfm/models/change-preview` — プレビュー画像アップロード
- `GET /api/wfm/models/filepath` — モデルファイルのフルパス取得
#### `static/js/models-tab.js` — フロントエンドモジュール
- state管理: `modelsByType`, `modelMetadata`, `modelGroups`, `civitaiCache`, `activeModelType`
- サブタブ切り替え(6タイプ)、サムネイル/カード/テーブル表示
- サイドパネル: Info(ファイルパス表示、タグ、メモ)、Group管理、CivitAI情報表示
- 詳細モーダル: プレビュー画像、CivitAI情報、サムネイル変更
- 一括CivitAI取得ボタン(SSEプログレス付き)
- テーブルビューにメモ列表示
### 変更ファイル
#### `py/config.py`
- `MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"` 追加
#### `py/wfm.py`
- `models_routes`のインポートとルート登録追加
#### `templates/index.html`
- Modelsタブボタン・セクション追加(サブタブナビ、ツールバー、グリッド、サイドパネル)
- ヘルプタブにModels Tabセクション追加
#### `static/js/app.js`
- `models-tab.js`のimportと`initModelsTab()`呼び出し追加
- i18nマッピングにModelsタブ・ヘルプ用キー追加
#### `static/js/nodes-tab.js`
- カードビュー: パッケージバッジ削除、カード左端にパッケージ色のボーダー追加
- カードビュー: 入出力カウント表示を削除
- ページネーションをツールバーに移動
#### `static/js/workflow-tab.js`
- ページネーションをツールバーに移動
- 1ページ24件表示
#### `static/css/main.css`
- `.wfm-grid`: `grid-template-columns: repeat(auto-fill, 161px)`、カード161×162px固定
- `.wfm-node-card`: 左ボーダー3pxスタイル追加
- `.wfm-pagination-inline`: ツールバー内ページネーション
- サブタブナビ(`.wfm-models-type-nav`)、テーブル列幅調整
#### `static/js/i18n.js` — 翻訳追加(EN/JA/ZH)
- Modelsタブ関連: ~50キー(モデルタイプ、フィルタ、サイドパネル、CivitAI、グループ等)
- ヘルプModelsセクション: `helpModels1`〜`helpModels7`
- ファイルパス関連: `modelsFilePath`, `modelsCopyPath`, `modelsCopiedPath`
### 技術的な判断
- **プレビュー画像判定**: HEADリクエストではなく`img.onload/onerror`パターンを採用(404コンソールスパム回避)
- **CivitAI一括取得**: ブロッキングリクエストではなくSSE(Server-Sent Events)でリアルタイムプログレス表示
- **aiohttp HEAD自動登録**: `add_get`が自動でHEADも登録するため、明示的なHEADルート追加は不要(重複RuntimeError回避)
- **テーブルビューメモ列**: `max-width: 200px`+`text-overflow: ellipsis`で長文を省略表示
- **ファイルパス表示**: バックエンドAPIでフルパスを解決し、クリックでクリップボードコピー
## 2026-03-27: テーマカスタマイズ機能・設定タブ改善
### 概要
+37 -5
View File
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.2.1-green)
![Version](https://img.shields.io/badge/version-0.2.2-green)
## Screenshots
@@ -17,9 +17,13 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|:---:|:---:|
| ![Prompt](docs/screenshot_prompt.png) | ![Settings](docs/screenshot_settings.png) |
| Nodes Tab | Help & Support Tab |
| Nodes Tab | Models Tab |
|:---:|:---:|
| ![Nodes](docs/screenshot_nodes.png) | ![Help](docs/screenshot_help.png) |
| ![Nodes](docs/screenshot_nodes.png) | ![Models](docs/screenshot_models.png) |
| Help & Support Tab | |
|:---:|:---:|
| ![Help](docs/screenshot_help.png) | |
| Workflow Studio Library (ComfyUI) | ComfyUI Integration |
|:---:|:---:|
@@ -83,6 +87,18 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
- **Node Sets** — save multiple nodes + connections as reusable sets from the ComfyUI canvas
- **Right-click context menu** — "Save as Node Set" option on any node in ComfyUI
### Models Tab (v0.2.2)
- **Model Browser** — browse all installed ComfyUI models (Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder) 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
- **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
- **Group management** — create, rename, delete groups and assign/remove models
- **Table view memo** — memo column displayed in table view for quick reference
- **Preview images** — auto-detect `{model_stem}.preview.png` next to model files
### Workflow Studio Library (ComfyUI Side Panel) (v0.1.8)
- **Workflows tab** — browse favorite workflows, filter by model type, filter by group
- **Nodes tab** — browse favorite nodes, node sets, and node groups
@@ -169,6 +185,18 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
## Changelog
### 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
@@ -239,6 +267,7 @@ ComfyUI-Workflow-Studio/
│ ├── routes/
│ │ ├── workflow_routes.py # Workflow CRUD & analysis API
│ │ ├── nodes_routes.py # Nodes metadata & node sets API
│ │ ├── models_routes.py # Model metadata, preview, CivitAI API
│ │ ├── prompts_routes.py # Prompt presets CRUD API
│ │ ├── settings_routes.py # Settings API
│ │ ├── ollama_routes.py # Ollama proxy API
@@ -246,12 +275,14 @@ ComfyUI-Workflow-Studio/
│ └── services/
│ ├── workflow_service.py # Workflow file operations
│ ├── nodes_service.py # Node metadata & node sets
│ ├── models_service.py # Model metadata & preview images
│ ├── civitai_service.py # CivitAI API integration & cache
│ ├── prompts_service.py # Prompt presets persistence
│ ├── workflow_analyzer.py # Model/node detection
│ ├── settings_service.py # Settings persistence
│ └── png_extractor.py # PNG metadata extraction
├── templates/
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Help)
│ └── index.html # SPA template (Workflow/GenerateUI/Prompt/Settings/Nodes/Models/Help)
├── static/
│ ├── favicon.svg # Browser tab icon (W+S Wave)
│ ├── css/main.css # Styles
@@ -263,6 +294,7 @@ ComfyUI-Workflow-Studio/
│ ├── settings-tab.js # Settings panel
│ ├── comfyui-client.js # ComfyUI WebSocket/API client
│ ├── nodes-tab.js # Node browser & node sets
│ ├── models-tab.js # Model browser & CivitAI integration
│ ├── comfyui-workflow.js # UI-to-API format conversion
│ ├── comfyui-editor.js # Dynamic parameter editor
│ ├── json-highlight.js # JSON syntax highlighting
@@ -270,7 +302,7 @@ ComfyUI-Workflow-Studio/
├── web/comfyui/
│ ├── top_menu_extension.js # ComfyUI menu bar integration
│ └── node_sets_menu.js # Workflow Studio Library side panel
└── data/ # Metadata & settings storage (incl. prompts.json)
└── data/ # Metadata & settings storage (incl. prompts.json, model_metadata.json, civitai_cache.json)
```
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

+1
View File
@@ -58,3 +58,4 @@ SETTINGS_FILE = DATA_DIR / "settings.json"
NODE_METADATA_FILE = DATA_DIR / "node_metadata.json"
NODE_SETS_FILE = DATA_DIR / "node_sets.json"
PROMPTS_FILE = DATA_DIR / "prompts.json"
MODEL_METADATA_FILE = DATA_DIR / "model_metadata.json"
+388
View File
@@ -0,0 +1,388 @@
"""Model metadata API routes."""
import asyncio
import logging
import mimetypes
from aiohttp import web
from ..services.models_service import ModelsService
from ..services.civitai_service import CivitaiService
logger = logging.getLogger(__name__)
_service = ModelsService()
_civitai = CivitaiService()
def setup_routes(app: web.Application):
"""Register all model management API routes."""
app.router.add_get("/api/wfm/models/metadata", handle_get_metadata)
app.router.add_post("/api/wfm/models/metadata", handle_save_metadata)
app.router.add_get("/api/wfm/models/preview", handle_get_preview)
app.router.add_get("/api/wfm/models/groups", handle_get_groups)
app.router.add_post("/api/wfm/models/groups", handle_save_groups)
app.router.add_post("/api/wfm/models/civitai/fetch", handle_civitai_fetch)
app.router.add_get("/api/wfm/models/civitai/cache", handle_civitai_cache)
app.router.add_post("/api/wfm/models/civitai/batch", handle_civitai_batch)
app.router.add_post("/api/wfm/models/change-preview", handle_change_preview)
app.router.add_get("/api/wfm/models/filepath", handle_get_filepath)
# ── Model Metadata ─────────────────────────────────────────
async def handle_get_metadata(request: web.Request) -> web.Response:
"""GET /api/wfm/models/metadata"""
try:
result = await asyncio.to_thread(_service.get_all_metadata)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model metadata: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_metadata(request: web.Request) -> web.Response:
"""POST /api/wfm/models/metadata"""
try:
body = await request.json()
model_name = body.get("modelName", "")
if not model_name:
return web.json_response({"error": "modelName is required"}, status=400)
updates = {k: v for k, v in body.items() if k != "modelName"}
result = await asyncio.to_thread(_service.update_metadata, model_name, updates)
return web.json_response({"status": "ok", "metadata": result})
except Exception as e:
logger.error("Error saving model metadata: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── Model Groups ──────────────────────────────────────────
async def handle_get_groups(request: web.Request) -> web.Response:
"""GET /api/wfm/models/groups"""
try:
result = await asyncio.to_thread(_service.get_model_groups)
return web.json_response(result)
except Exception as e:
logger.error("Error loading model groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_save_groups(request: web.Request) -> web.Response:
"""POST /api/wfm/models/groups"""
try:
groups = await request.json()
result = await asyncio.to_thread(_service.save_model_groups, groups)
return web.json_response({"status": "ok", "groups": result})
except Exception as e:
logger.error("Error saving model groups: %s", e)
return web.json_response({"error": str(e)}, status=500)
# ── CivitAI Integration ──────────────────────────────────
async def handle_civitai_fetch(request: web.Request) -> web.Response:
"""POST /api/wfm/models/civitai/fetch
Body: { "type": "checkpoint", "name": "model.safetensors" }
Calculates SHA256 hash and fetches model info from CivitAI.
"""
try:
body = await request.json()
model_type = body.get("type", "")
model_name = body.get("name", "")
if not model_type or not model_name:
return web.json_response({"error": "type and name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
# Resolve file path
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
file_path = None
for d in dirs:
candidate = d / model_name
if candidate.is_file():
file_path = candidate
break
if not file_path:
return web.json_response({"error": "Model file not found"}, status=404)
# Check if we already have a cached hash in metadata
meta = _service.get_all_metadata()
model_meta = meta.get(model_name, {})
sha256 = model_meta.get("sha256")
if not sha256:
# Calculate hash (can be slow for large files)
sha256 = await asyncio.to_thread(
CivitaiService.calculate_sha256, file_path
)
if not sha256:
return web.json_response({"error": "Failed to calculate hash"}, status=500)
# Cache the hash in model metadata
await asyncio.to_thread(
_service.update_metadata, model_name, {"sha256": sha256}
)
# Fetch from CivitAI
info = await asyncio.to_thread(_civitai.fetch_by_hash, sha256)
if not info:
return web.json_response({
"status": "not_found",
"sha256": sha256,
"message": "Model not found on CivitAI"
})
return web.json_response({
"status": "ok",
"sha256": sha256,
"civitai": info,
})
except Exception as e:
logger.error("CivitAI fetch error: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_civitai_cache(request: web.Request) -> web.Response:
"""GET /api/wfm/models/civitai/cache - Return all cached CivitAI data."""
try:
result = await asyncio.to_thread(_civitai.get_all_cached)
return web.json_response(result)
except Exception as e:
logger.error("Error loading CivitAI cache: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_civitai_batch(request: web.Request) -> web.Response:
"""POST /api/wfm/models/civitai/batch - SSE stream for batch CivitAI fetch.
Body: { "type": "checkpoint", "models": ["model1.safetensors", ...] }
Streams SSE events: progress, result, done.
"""
import json as _json
try:
body = await request.json()
except Exception:
return web.Response(status=400, text="Invalid JSON body")
model_type = body.get("type", "")
model_names = body.get("models", [])
if not model_type or not model_names:
return web.Response(status=400, text="type and models required")
# Resolve file paths
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
model_files = []
for name in model_names:
if ".." in name:
continue
for d in dirs:
p = d / name
if p.is_file():
model_files.append((name, p))
break
if not model_files:
return web.Response(status=404, text="No model files found")
# Set up SSE response
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
def send_sse(event, data):
"""Format SSE message (returns bytes)."""
msg = f"event: {event}\ndata: {_json.dumps(data, ensure_ascii=False)}\n\n"
return msg.encode("utf-8")
# Run batch fetch in thread with progress callback via queue
import queue
progress_q = queue.Queue()
def on_progress(current, total, model_name, status):
progress_q.put({"current": current, "total": total, "model": model_name, "status": status})
# Start batch in background thread
loop = asyncio.get_event_loop()
fetch_task = loop.run_in_executor(None, _civitai.batch_fetch, model_files, on_progress)
# Stream progress events
import time as _time
while not fetch_task.done():
# Drain queue
while not progress_q.empty():
try:
p = progress_q.get_nowait()
await response.write(send_sse("progress", p))
except queue.Empty:
break
await asyncio.sleep(0.1)
# Drain remaining events
while not progress_q.empty():
try:
p = progress_q.get_nowait()
await response.write(send_sse("progress", p))
except queue.Empty:
break
# Get results and update metadata
results = fetch_task.result()
meta_updates = {}
for model_name, result in results.items():
sha256 = result.get("sha256")
if sha256:
meta_updates[model_name] = sha256
# Bulk update sha256 in metadata
if meta_updates:
all_meta = _service.get_all_metadata()
for mname, sha in meta_updates.items():
if mname not in all_meta:
all_meta[mname] = {"tags": [], "favorite": False, "memo": ""}
all_meta[mname]["sha256"] = sha
_service._save_metadata(all_meta)
# Send final result
summary = {
"total": len(model_files),
"found": sum(1 for r in results.values() if r.get("civitai")),
"not_found": sum(1 for r in results.values() if r.get("sha256") and not r.get("civitai")),
"errors": sum(1 for r in results.values() if r.get("error")),
"hashes": {name: r.get("sha256") for name, r in results.items() if r.get("sha256")},
}
await response.write(send_sse("done", summary))
await response.write_eof()
return response
# ── Model Preview Image ───────────────────────────────────
async def handle_get_preview(request: web.Request) -> web.Response:
"""GET /api/wfm/models/preview?type=checkpoint&name=model.safetensors"""
model_type = request.query.get("type", "")
model_name = request.query.get("name", "")
if not model_type or not model_name:
return web.Response(status=400, text="type and name are required")
# Security: reject path traversal
if ".." in model_name:
return web.Response(status=400, text="Invalid model name")
try:
preview_path = await asyncio.to_thread(
_service.find_preview_image, model_type, model_name
)
if not preview_path:
return web.Response(status=404)
content_type, _ = mimetypes.guess_type(str(preview_path))
return web.FileResponse(
preview_path,
headers={"Content-Type": content_type or "image/png",
"Cache-Control": "public, max-age=3600"},
)
except Exception as e:
logger.error("Error serving model preview: %s", e)
return web.Response(status=500, text=str(e))
async def handle_change_preview(request: web.Request) -> web.Response:
"""POST /api/wfm/models/change-preview - Upload/change model preview image.
Multipart form: type, name, file
Saves as {model_stem}.preview.png next to the model file.
"""
from pathlib import Path as _Path
try:
reader = await request.multipart()
model_type = None
model_name = None
image_data = None
while True:
part = await reader.next()
if part is None:
break
if part.name == "type":
model_type = (await part.read()).decode("utf-8")
elif part.name == "name":
model_name = (await part.read()).decode("utf-8")
elif part.name == "file":
image_data = await part.read()
if not model_type or not model_name:
return web.json_response({"error": "type and name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
if image_data is None:
return web.json_response({"error": "file required"}, status=400)
# Resolve model file path
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
model_path = None
for d in dirs:
candidate = d / model_name
if candidate.is_file():
model_path = candidate
break
if not model_path:
return web.json_response({"error": "Model file not found"}, status=404)
# Save as {stem}.preview.png next to model file
preview_path = model_path.parent / (model_path.stem + ".preview.png")
def _write():
with open(preview_path, "wb") as f:
f.write(image_data)
await asyncio.to_thread(_write)
logger.info("Saved preview image: %s", preview_path)
return web.json_response({"status": "ok"})
except Exception as e:
logger.error("Error changing model preview: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def handle_get_filepath(request: web.Request) -> web.Response:
"""GET /api/wfm/models/filepath?type=checkpoint&name=model.safetensors
Returns the full file path of the model.
"""
model_type = request.query.get("type", "")
model_name = request.query.get("name", "")
if not model_type or not model_name:
return web.json_response({"error": "type and name required"}, status=400)
if ".." in model_name:
return web.json_response({"error": "Invalid model name"}, status=400)
try:
from ..services.models_service import _get_model_dirs
dirs = _get_model_dirs(model_type)
for d in dirs:
candidate = d / model_name
if candidate.is_file():
return web.json_response({"path": str(candidate)})
return web.json_response({"error": "Model file not found"}, status=404)
except Exception as e:
logger.error("Error resolving model filepath: %s", e)
return web.json_response({"error": str(e)}, status=500)
+206
View File
@@ -0,0 +1,206 @@
"""CivitAI API integration service."""
import hashlib
import json
import logging
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from ..config import DATA_DIR
logger = logging.getLogger(__name__)
CIVITAI_API_BASE = "https://civitai.com/api/v1"
CIVITAI_CACHE_FILE = DATA_DIR / "civitai_cache.json"
class CivitaiService:
"""Fetch and cache CivitAI model metadata."""
def __init__(self):
self._cache = None
def _load_cache(self):
if self._cache is not None:
return self._cache
if CIVITAI_CACHE_FILE.exists():
try:
with open(CIVITAI_CACHE_FILE, "r", encoding="utf-8") as f:
self._cache = json.load(f)
return self._cache
except Exception:
pass
self._cache = {}
return self._cache
def _save_cache(self):
CIVITAI_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CIVITAI_CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(self._cache, f, ensure_ascii=False, indent=2)
def get_cached(self, sha256_hash):
"""Return cached CivitAI data for a given hash, or None."""
cache = self._load_cache()
return cache.get(sha256_hash)
def get_all_cached(self):
"""Return the full cache dict."""
return self._load_cache()
@staticmethod
def calculate_sha256(file_path, chunk_size=65536):
"""Calculate SHA256 hash of a file."""
h = hashlib.sha256()
path = Path(file_path)
if not path.is_file():
return None
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def fetch_by_hash(self, sha256_hash):
"""Fetch model version info from CivitAI by SHA256 hash.
Returns dict with model info, or None if not found.
Caches successful results.
"""
# Check cache first
cache = self._load_cache()
if sha256_hash in cache:
return cache[sha256_hash]
url = f"{CIVITAI_API_BASE}/model-versions/by-hash/{sha256_hash}"
try:
req = Request(url, headers={"User-Agent": "ComfyUI-Workflow-Studio/1.0"})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not data or "id" not in data:
return None
# Extract useful fields
info = self._extract_info(data)
cache[sha256_hash] = info
self._save_cache()
return info
except HTTPError as e:
if e.code == 404:
logger.debug("CivitAI: model not found for hash %s", sha256_hash[:16])
return None
logger.warning("CivitAI API error: %s %s", e.code, e.reason)
return None
except (URLError, Exception) as e:
logger.warning("CivitAI request failed: %s", e)
return None
@staticmethod
def _extract_info(data):
"""Extract relevant fields from CivitAI API response."""
model = data.get("model", {})
images = data.get("images", [])
files = data.get("files", [])
# Get primary file info
primary_file = None
for f in files:
if f.get("primary"):
primary_file = f
break
if not primary_file and files:
primary_file = files[0]
# Get image URLs (first 5)
image_urls = []
for img in images[:5]:
url = img.get("url", "")
if url:
# Optimize for thumbnail
optimized = url.replace("/original=true", "/width=450,optimized=true")
image_urls.append(optimized)
return {
"versionId": data.get("id"),
"modelId": model.get("id"),
"modelName": model.get("name", ""),
"versionName": data.get("name", ""),
"type": model.get("type", ""),
"description": data.get("description") or model.get("description", ""),
"tags": model.get("tags", []),
"nsfw": model.get("nsfw", False),
"creator": data.get("creator", {}).get("username", ""),
"images": image_urls,
"trainedWords": data.get("trainedWords", []),
"baseModel": data.get("baseModel", ""),
"fileSize": primary_file.get("sizeKB", 0) if primary_file else 0,
"downloadUrl": data.get("downloadUrl", ""),
"modelUrl": f"https://civitai.com/models/{model.get('id', '')}?modelVersionId={data.get('id', '')}",
}
def batch_fetch(self, model_files, progress_callback=None):
"""Batch fetch CivitAI info for multiple model files.
Args:
model_files: list of (model_name, file_path) tuples
progress_callback: fn(current, total, model_name, status) called per model
Returns: dict of { model_name: { sha256, civitai_info_or_none } }
"""
import time
results = {}
total = len(model_files)
cache = self._load_cache()
for i, (model_name, file_path) in enumerate(model_files):
if progress_callback:
progress_callback(i, total, model_name, "hashing")
# Calculate hash
sha256 = self.calculate_sha256(file_path)
if not sha256:
results[model_name] = {"sha256": None, "civitai": None, "error": "hash_failed"}
continue
results[model_name] = {"sha256": sha256, "civitai": None}
# Check cache
if sha256 in cache:
results[model_name]["civitai"] = cache[sha256]
if progress_callback:
progress_callback(i, total, model_name, "cached")
continue
# Fetch from API
if progress_callback:
progress_callback(i, total, model_name, "fetching")
info = self.fetch_by_hash(sha256)
if info:
results[model_name]["civitai"] = info
if progress_callback:
status = "found" if info else "not_found"
progress_callback(i, total, model_name, status)
# Rate limit: small delay between API calls
time.sleep(0.5)
if progress_callback:
progress_callback(total, total, "", "done")
return results
def clear_cache(self, sha256_hash=None):
"""Clear cache for a specific hash or all."""
cache = self._load_cache()
if sha256_hash:
cache.pop(sha256_hash, None)
else:
cache.clear()
self._save_cache()
+146
View File
@@ -0,0 +1,146 @@
"""Model metadata management service."""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from ..config import MODEL_METADATA_FILE
logger = logging.getLogger(__name__)
# Preview image extensions to search for (in priority order)
_PREVIEW_EXTENSIONS = [".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp",
".png", ".jpg", ".jpeg", ".webp"]
# ComfyUI model type → folder_paths key mapping
MODEL_TYPE_FOLDER_KEYS = {
"checkpoint": "checkpoints",
"lora": "loras",
"vae": "vae",
"controlnet": "controlnet",
"unet": "diffusion_models",
"textencoder": "text_encoders",
}
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"]
entry["updatedAt"] = self._now_iso()
data[model_name] = entry
self._save_metadata(data)
return entry
def get_model_groups(self):
data = self._load_metadata()
return data.get("_groups", {})
def save_model_groups(self, groups):
data = self._load_metadata()
data["_groups"] = groups
self._save_metadata(data)
return groups
def find_preview_image(self, model_type, model_name):
"""Find preview image for a model file.
Searches all configured directories for the model type
(via ComfyUI's folder_paths, which includes extra_model_paths.yaml).
Looks for files like:
modelname.preview.png, modelname.png, etc.
next to the model file.
Returns: absolute Path to preview image, or None.
"""
dirs = _get_model_dirs(model_type)
if not dirs:
logger.debug("Preview: no dirs for model_type=%s", model_type)
return None
for type_dir in dirs:
# model_name can include subdirectory (e.g., "subdir/model.safetensors")
model_path = type_dir / model_name
if not model_path.is_file():
continue
stem = model_path.stem
parent = model_path.parent
for ext in _PREVIEW_EXTENSIONS:
preview = parent / (stem + ext)
if preview.is_file():
logger.debug("Preview found: %s", preview)
return preview
logger.debug("Preview: no preview for %s (stem=%s, dir=%s)",
model_name, stem, parent)
return None
logger.debug("Preview: model file not found in any dir: %s", model_name)
return None
+2 -1
View File
@@ -61,7 +61,7 @@ class WorkflowStudio:
app.router.add_get("/wfm", serve_index_page)
# API routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes
workflow_routes.setup_routes(app)
settings_routes.setup_routes(app)
@@ -69,5 +69,6 @@ class WorkflowStudio:
eagle_routes.setup_routes(app)
nodes_routes.setup_routes(app)
prompts_routes.setup_routes(app)
models_routes.setup_routes(app)
logger.info("Workflow Studio: Routes registered successfully")
+176 -3
View File
@@ -250,7 +250,7 @@ body {
.wfm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fill, 161px);
gap: 10px;
padding: 10px;
flex: 1;
@@ -263,6 +263,8 @@ body {
============================================ */
.wfm-card {
width: 161px;
min-width: 161px;
background: var(--wfm-surface);
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius);
@@ -278,8 +280,8 @@ body {
}
.wfm-card-thumb {
width: 100%;
aspect-ratio: 1 / 1;
width: 161px;
height: 162px;
background: var(--wfm-bg-tertiary);
display: flex;
align-items: center;
@@ -2727,6 +2729,13 @@ body {
gap: 8px;
}
/* Node card: package color left border, auto width */
.wfm-node-card {
width: auto;
min-width: auto;
border-left-width: 3px !important;
}
/* Node table row selection highlight */
.wfm-nodes-table-row {
cursor: pointer;
@@ -2863,3 +2872,167 @@ body {
flex: 1;
overflow-y: auto;
}
/* ========================================
Models Tab
======================================== */
.wfm-models-type-nav {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.wfm-models-type-btn {
padding: 6px 16px;
border: 1px solid var(--wfm-border);
background: var(--wfm-bg-secondary);
cursor: pointer;
border-radius: var(--wfm-radius-sm);
font-size: 13px;
color: var(--wfm-text);
transition: background 0.15s, color 0.15s;
}
.wfm-models-type-btn:hover {
background: var(--wfm-surface-hover);
}
.wfm-models-type-btn.active {
background: var(--wfm-accent);
color: #fff;
border-color: var(--wfm-accent);
}
/* Model card (card view, no thumbnail) */
.wfm-model-card {
position: relative;
}
.wfm-model-card .wfm-card-body {
position: relative;
}
/* Badge: subdirectory (italic, muted) */
.wfm-badge-dir {
opacity: 0.7;
font-style: italic;
}
/* Favorite button in model card (active state color) */
.wfm-model-side-fav.wfm-model-fav-active {
color: #f5a623;
}
/* Model table */
.wfm-models-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.wfm-models-table th,
.wfm-models-table td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid var(--wfm-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.wfm-models-table th {
background: var(--wfm-bg-secondary);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.wfm-models-table-row {
cursor: pointer;
transition: background 0.1s;
}
.wfm-models-table-row:hover {
background: var(--wfm-surface-hover);
}
.wfm-models-table-row.wfm-card-selected {
background: var(--wfm-accent-light, rgba(var(--wfm-accent-rgb, 99, 102, 241), 0.1));
}
.wfm-models-table-fav {
cursor: pointer;
text-align: center !important;
width: 30px;
}
.wfm-table-th-subdir,
.wfm-table-td-subdir {
width: 80px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wfm-table-th-ext,
.wfm-table-td-ext {
width: 60px;
max-width: 60px;
}
.wfm-table-td-memo {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wfm-text-muted, #888);
font-size: 0.9em;
}
/* Textarea (shared - used by models memo and workflow detail) */
.wfm-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--wfm-border);
border-radius: var(--wfm-radius-sm);
background: var(--wfm-bg-secondary);
color: var(--wfm-text);
font-size: 13px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.wfm-textarea:focus {
outline: none;
border-color: var(--wfm-accent);
}
/* Clickable model name (copy to clipboard) */
.wfm-model-name-copy {
cursor: pointer;
transition: color 0.15s;
}
.wfm-model-name-copy:hover {
color: var(--wfm-accent);
}
/* Inline pagination (in toolbar) */
.wfm-pagination-inline {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--wfm-text-secondary);
}
/* Side panel content for models */
.wfm-models-side-content {
flex: 1;
overflow-y: auto;
}
+32 -1
View File
@@ -90,7 +90,7 @@ function initTabs() {
function applyI18nToHtml() {
// Tab labels
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
const tabMap = { workflow: "tabWorkflow", nodes: "tabNodes", models: "tabModels", generate: "tabGenerate", prompt: "tabPrompt", settings: "tabSettings", help: "tabHelp" };
document.querySelectorAll(".wfm-tab").forEach((tab) => {
const key = tabMap[tab.dataset.tab];
if (key) tab.textContent = t(key);
@@ -163,6 +163,31 @@ function applyI18nToHtml() {
const nodesPlaceholder = document.getElementById("wfm-nodes-placeholder");
if (nodesPlaceholder) nodesPlaceholder.textContent = t("nodesClickToLoad");
// Models tab
const modelsTypeBtns = document.querySelectorAll(".wfm-models-type-btn");
modelsTypeBtns.forEach(btn => {
const key = "models" + btn.dataset.modelType.charAt(0).toUpperCase() + btn.dataset.modelType.slice(1);
const label = t(key);
if (label && label !== key) btn.textContent = label;
});
const modelsSearch = document.getElementById("wfm-models-search");
if (modelsSearch) modelsSearch.placeholder = t("modelsSearchPlaceholder");
const modelsRefreshBtn = document.getElementById("wfm-models-refresh-btn");
if (modelsRefreshBtn) modelsRefreshBtn.textContent = t("modelsRefresh");
const modelsPlaceholder = document.getElementById("wfm-models-placeholder");
if (modelsPlaceholder) modelsPlaceholder.textContent = t("modelsClickToLoad");
const modelsTagFilter = document.getElementById("wfm-models-tag-filter");
if (modelsTagFilter) {
const firstOpt = modelsTagFilter.querySelector("option[value='']");
if (firstOpt) firstOpt.textContent = t("modelsAllTags");
}
const modelsSideTabBtns = document.querySelectorAll(".wfm-models-side-tab-btn");
modelsSideTabBtns.forEach(btn => {
if (btn.dataset.sideTab === "info") btn.textContent = t("modelsSideInfo");
if (btn.dataset.sideTab === "group") btn.textContent = t("modelsSideGroup");
if (btn.dataset.sideTab === "civitai") btn.textContent = t("modelsSideCivitai");
});
// Prompt tab
const assistantHeader = document.querySelector(".wfm-prompt-split-left .wfm-prompt-split-header");
if (assistantHeader) assistantHeader.textContent = t("assistantSubtab");
@@ -242,6 +267,10 @@ function applyI18nToHtml() {
"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-sidepanel-title": "helpSidepanelTitle",
"wfm-help-sidepanel-1": "helpSidepanel1", "wfm-help-sidepanel-2": "helpSidepanel2",
"wfm-help-sidepanel-3": "helpSidepanel3", "wfm-help-sidepanel-4": "helpSidepanel4",
@@ -302,6 +331,7 @@ import { initNodesTab } from "./nodes-tab.js";
import { initGenerateTab } from "./generate-tab.js";
import { initPromptTab } from "./prompt-tab.js";
import { initSettingsTab, applyTheme, getSavedTheme } from "./settings-tab.js";
import { initModelsTab } from "./models-tab.js";
// Apply saved theme immediately to prevent flash of default theme
applyTheme(getSavedTheme());
@@ -313,6 +343,7 @@ document.addEventListener("DOMContentLoaded", () => {
initSettingsTab(); // Settings first (applies saved URL)
initWorkflowTab();
initNodesTab();
initModelsTab();
initGenerateTab();
initPromptTab();
+222
View File
@@ -204,6 +204,73 @@ const LANGUAGES = {
nodesConnectToComfyUI: "Connect to ComfyUI to load nodes. Check Settings tab.",
nodesClickToLoad: "Click to load nodes...",
// -- Models tab --
tabModels: "Models",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "Search models...",
modelsRefresh: "Refresh",
modelsClickToLoad: "Click Refresh to load models...",
modelsLoading: "Loading models...",
modelsLoadError: "Error loading models",
modelsNoModels: "No models found",
modelsAllTags: "All Tags",
modelsFileName: "File Name",
modelsSubdir: "Subdirectory",
modelsExt: "Extension",
modelsTags: "Tags",
modelsTagsPlaceholder: "tag1, tag2, ...",
modelsModelType: "Model Type",
modelsFavorite: "Favorite",
modelsMemo: "Memo",
modelsMemoPlaceholder: "Notes about this model...",
modelsSave: "Save",
modelsSaved: "Metadata saved",
modelsCopyName: "Click to copy model name",
modelsCopiedName: "Model name copied",
modelsFilePath: "File Path",
modelsCopyPath: "Click to copy file path",
modelsCopiedPath: "File path copied",
modelsNoPreview: "No preview",
modelsTagsHint: "(comma separated)",
modelsAllGroups: "All Groups",
modelsCurrentGroups: "Current Groups",
modelsNoGroup: "Not assigned to any group",
modelsAssignGroup: "Assign to Group",
modelsNoGroupAvailable: "No groups available",
modelsAdd: "Add",
modelsCreateGroup: "Create New Group",
modelsGroupName: "Group name",
modelsCreate: "Create",
modelsManageGroups: "Manage Groups",
modelsRename: "Rename",
modelsDelete: "Delete",
modelsRemoveFromGroup: "Remove from group",
modelsGroupExists: "Group already exists",
modelsRenamePrompt: "Enter new group name:",
modelsDeleteGroupConfirm: "Delete group \"{name}\"?",
civitaiFetchDesc: "Fetch model information from CivitAI using SHA256 hash.",
civitaiFetch: "Fetch from CivitAI",
civitaiHashing: "Calculating hash... (may take a while for large files)",
civitaiFound: "CivitAI info loaded",
civitaiNotFound: "Model not found on CivitAI",
civitaiError: "Error fetching from CivitAI",
civitaiTriggerWords: "Trigger Words",
civitaiCopyWord: "Click to copy",
civitaiWordCopied: "Trigger word copied",
civitaiRefresh: "Refresh",
civitaiBatchAllCached: "All models already have CivitAI info",
civitaiHashing2: "Hashing...",
civitaiFetching: "Fetching...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found} found, ${notFound} not found`,
modelsSideInfo: "Info",
modelsSideGroup: "Group",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "Help",
helpManualTitle: "Features",
@@ -247,6 +314,13 @@ const LANGUAGES = {
helpNodes4: "Create, rename, delete node groups and assign/remove nodes",
helpNodes5: "Side panel: Details (class name, package, category, description, flags), I/O Spec (inputs/outputs with types and defaults), Groups management",
helpNodes6: "Node Sets: create reusable node collections with descriptions, tags, and link definitions between nodes",
helpModels1: "Browse installed models: Checkpoint, LoRA, VAE, ControlNet, UNET, TextEncoder with sub-tab switching",
helpModels2: "Thumbnail, Card, and Table view modes with pagination",
helpModels3: "Search, tag filter, group filter, and favorites filter",
helpModels4: "Side panel: Info (file path, tags, memo), Groups management, CivitAI integration",
helpModels5: "CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type",
helpModels6: "Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change",
helpModels7: "Group management: create, rename, delete groups and assign/remove models",
helpSidepanelTitle: "Workflow Studio Library (ComfyUI Side Panel)",
helpSidepanel1: "Open from the Workflow Studio Library button in the ComfyUI toolbar",
helpSidepanel2: "Workflows tab: browse favorite workflows, filter by model type, and filter by group",
@@ -572,6 +646,73 @@ const LANGUAGES = {
nodesConnectToComfyUI: "ComfyUIに接続してノードを読み込みます。設定タブを確認してください。",
nodesClickToLoad: "クリックしてノードを読み込み...",
// -- Models tab --
tabModels: "モデル",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "モデルを検索...",
modelsRefresh: "更新",
modelsClickToLoad: "更新ボタンでモデルを読み込み...",
modelsLoading: "モデルを読み込み中...",
modelsLoadError: "モデルの読み込みエラー",
modelsNoModels: "モデルが見つかりません",
modelsAllTags: "すべてのタグ",
modelsFileName: "ファイル名",
modelsSubdir: "サブディレクトリ",
modelsExt: "拡張子",
modelsTags: "タグ",
modelsTagsPlaceholder: "タグ1, タグ2, ...",
modelsModelType: "モデルタイプ",
modelsFavorite: "お気に入り",
modelsMemo: "メモ",
modelsMemoPlaceholder: "このモデルについてのメモ...",
modelsSave: "保存",
modelsSaved: "メタデータを保存しました",
modelsCopyName: "クリックでモデル名をコピー",
modelsCopiedName: "モデル名をコピーしました",
modelsFilePath: "ファイルパス",
modelsCopyPath: "クリックでファイルパスをコピー",
modelsCopiedPath: "ファイルパスをコピーしました",
modelsNoPreview: "プレビューなし",
modelsTagsHint: "(カンマ区切り)",
modelsAllGroups: "すべてのグループ",
modelsCurrentGroups: "所属グループ",
modelsNoGroup: "グループに未所属",
modelsAssignGroup: "グループに追加",
modelsNoGroupAvailable: "グループがありません",
modelsAdd: "追加",
modelsCreateGroup: "新規グループ作成",
modelsGroupName: "グループ名",
modelsCreate: "作成",
modelsManageGroups: "グループ管理",
modelsRename: "名前変更",
modelsDelete: "削除",
modelsRemoveFromGroup: "グループから除外",
modelsGroupExists: "同名のグループが既に存在します",
modelsRenamePrompt: "新しいグループ名を入力:",
modelsDeleteGroupConfirm: "グループ「{name}」を削除しますか?",
civitaiFetchDesc: "SHA256ハッシュを使用してCivitAIからモデル情報を取得します。",
civitaiFetch: "CivitAIから取得",
civitaiHashing: "ハッシュ計算中...(大きいファイルは時間がかかります)",
civitaiFound: "CivitAI情報を取得しました",
civitaiNotFound: "CivitAIにモデルが見つかりません",
civitaiError: "CivitAIからの取得エラー",
civitaiTriggerWords: "トリガーワード",
civitaiCopyWord: "クリックでコピー",
civitaiWordCopied: "トリガーワードをコピーしました",
civitaiRefresh: "更新",
civitaiBatchAllCached: "すべてのモデルのCivitAI情報は取得済みです",
civitaiHashing2: "ハッシュ計算中...",
civitaiFetching: "取得中...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}件取得, ${notFound}件未登録`,
modelsSideInfo: "基本情報",
modelsSideGroup: "グループ",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "ヘルプ",
helpManualTitle: "機能一覧",
@@ -615,6 +756,13 @@ const LANGUAGES = {
helpNodes4: "ノードグループの作成・名前変更・削除、ノードの割り当て・解除",
helpNodes5: "サイドパネル:詳細(クラス名、パッケージ、カテゴリ、説明、フラグ)、I/O仕様(入出力の型・デフォルト値)、グループ管理",
helpNodes6: "ノードセット:再利用可能なノードコレクションの作成(説明、タグ、ノード間リンク定義付き)",
helpModels1: "インストール済みモデルの閲覧:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoderをサブタブで切り替え",
helpModels2: "サムネイル / カード / テーブル表示モード切り替え(ページネーション付き)",
helpModels3: "検索、タグフィルタ、グループフィルタ、お気に入りフィルタ",
helpModels4: "サイドパネル:基本情報(ファイルパス、タグ、メモ)、グループ管理、CivitAI連携",
helpModels5: "CivitAI連携:SHA256ハッシュでモデル情報取得、モデルタイプごとの一括取得",
helpModels6: "詳細モーダル:プレビュー画像、CivitAI情報(ベースモデル、トリガーワード、タグ)、サムネイル変更",
helpModels7: "グループ管理:グループの作成・名前変更・削除、モデルの割り当て・解除",
helpSidepanelTitle: "Workflow Studio Library(ComfyUIサイドパネル)",
helpSidepanel1: "ComfyUIツールバーのWorkflow Studio Libraryボタンから開く",
helpSidepanel2: "Workflowsタブ:お気に入りワークフロー閲覧、モデル種別フィルタ、グループフィルタ",
@@ -936,6 +1084,73 @@ const LANGUAGES = {
nodesConnectToComfyUI: "连接ComfyUI以加载节点。请检查设置选项卡。",
nodesClickToLoad: "点击加载节点...",
// -- Models tab --
tabModels: "模型",
modelsCheckpoint: "Checkpoint",
modelsLora: "LoRA",
modelsVae: "VAE",
modelsControlnet: "ControlNet",
modelsUnet: "UNET",
modelsTextencoder: "TextEncoder",
modelsSearchPlaceholder: "搜索模型...",
modelsRefresh: "刷新",
modelsClickToLoad: "点击刷新加载模型...",
modelsLoading: "正在加载模型...",
modelsLoadError: "加载模型出错",
modelsNoModels: "未找到模型",
modelsAllTags: "所有标签",
modelsFileName: "文件名",
modelsSubdir: "子目录",
modelsExt: "扩展名",
modelsTags: "标签",
modelsTagsPlaceholder: "标签1, 标签2, ...",
modelsModelType: "模型类型",
modelsFavorite: "收藏",
modelsMemo: "备注",
modelsMemoPlaceholder: "关于此模型的备注...",
modelsSave: "保存",
modelsSaved: "元数据已保存",
modelsCopyName: "点击复制模型名称",
modelsCopiedName: "模型名称已复制",
modelsFilePath: "文件路径",
modelsCopyPath: "点击复制文件路径",
modelsCopiedPath: "文件路径已复制",
modelsNoPreview: "无预览",
modelsTagsHint: "(逗号分隔)",
modelsAllGroups: "所有分组",
modelsCurrentGroups: "当前分组",
modelsNoGroup: "未分配到任何分组",
modelsAssignGroup: "分配到分组",
modelsNoGroupAvailable: "没有可用的分组",
modelsAdd: "添加",
modelsCreateGroup: "创建新分组",
modelsGroupName: "分组名称",
modelsCreate: "创建",
modelsManageGroups: "分组管理",
modelsRename: "重命名",
modelsDelete: "删除",
modelsRemoveFromGroup: "从分组移除",
modelsGroupExists: "同名分组已存在",
modelsRenamePrompt: "输入新分组名称:",
modelsDeleteGroupConfirm: "删除分组「{name}」?",
civitaiFetchDesc: "使用SHA256哈希从CivitAI获取模型信息。",
civitaiFetch: "从CivitAI获取",
civitaiHashing: "计算哈希中...(大文件可能需要较长时间)",
civitaiFound: "已加载CivitAI信息",
civitaiNotFound: "在CivitAI上未找到该模型",
civitaiError: "从CivitAI获取时出错",
civitaiTriggerWords: "触发词",
civitaiCopyWord: "点击复制",
civitaiWordCopied: "触发词已复制",
civitaiRefresh: "刷新",
civitaiBatchAllCached: "所有模型的CivitAI信息已获取",
civitaiHashing2: "计算哈希中...",
civitaiFetching: "获取中...",
civitaiBatchDone: (found, notFound) => `CivitAI: ${found}个已找到, ${notFound}个未找到`,
modelsSideInfo: "基本信息",
modelsSideGroup: "分组",
modelsSideCivitai: "CivitAI",
// -- Help & Support --
tabHelp: "帮助",
helpManualTitle: "功能列表",
@@ -979,6 +1194,13 @@ const LANGUAGES = {
helpNodes4: "创建、重命名、删除节点组,分配/移除节点",
helpNodes5: "侧面板:详情(类名、包、分类、描述、标志)、I/O规格(输入输出类型和默认值)、组管理",
helpNodes6: "节点集:创建可复用的节点集合(含描述、标签、节点间链接定义)",
helpModels1: "浏览已安装模型:Checkpoint、LoRA、VAE、ControlNet、UNET、TextEncoder,通过子标签切换",
helpModels2: "缩略图、卡片和表格视图模式切换(带分页)",
helpModels3: "搜索、标签筛选、分组筛选、收藏筛选",
helpModels4: "侧面板:基本信息(文件路径、标签、备注)、分组管理、CivitAI集成",
helpModels5: "CivitAI集成:通过SHA256哈希获取模型信息,按模型类型批量获取",
helpModels6: "详情弹窗:预览图片、CivitAI信息(基础模型、触发词、标签)、缩略图更改",
helpModels7: "分组管理:创建、重命名、删除分组,分配/移除模型",
helpSidepanelTitle: "Workflow Studio Library(ComfyUI侧面板)",
helpSidepanel1: "从ComfyUI工具栏的Workflow Studio Library按钮打开",
helpSidepanel2: "Workflows标签:浏览收藏工作流、按模型类型筛选、按分组筛选",
File diff suppressed because it is too large Load Diff
+20 -40
View File
@@ -257,13 +257,13 @@ function renderNodeGrid() {
if (filtered.length === 0) {
grid.innerHTML = `<p class="wfm-placeholder">${t("noNodesFound")}</p>`;
removePagination();
clearPagination();
return;
}
if (state.viewMode === "table") {
renderNodeTableView(grid, filtered);
removePagination();
clearPagination();
return;
}
@@ -284,6 +284,7 @@ function renderNodeGrid() {
card.classList.add("wfm-card-selected");
}
card.dataset.nodeName = node.name;
card.style.borderLeft = `3px solid ${packageColor(node.package)}`;
const inputCount = Object.keys(node.input.required || {}).length +
Object.keys(node.input.optional || {}).length;
@@ -296,10 +297,8 @@ function renderNodeGrid() {
<div class="wfm-card-body">
<div class="wfm-card-title" title="${escapeHtml(node.name)}">${escapeHtml(node.display_name)}</div>
<div class="wfm-card-meta">
${packageBadgeHtml(node.package)}
${categoryBadgeHtml(node.category)}
</div>
<div class="wfm-node-card-io">In: ${inputCount} / Out: ${outputCount}</div>
${tags ? `<div class="wfm-card-tags">${tags}</div>` : ""}
</div>
<button class="${favClass}" title="Favorite">${favStar}</button>`;
@@ -316,47 +315,28 @@ function renderNodeGrid() {
}
function renderPagination(totalItems, totalPages) {
removePagination();
if (totalPages <= 1) return;
const container = document.getElementById("wfm-nodes-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
const grid = document.getElementById("wfm-nodes-grid");
if (!grid) return;
const pager = document.createElement("div");
pager.id = "wfm-nodes-pagination";
pager.className = "wfm-pagination";
const prevBtn = document.createElement("button");
prevBtn.className = "wfm-btn wfm-btn-sm";
prevBtn.textContent = "\u25C0";
prevBtn.disabled = state.currentPage === 0;
prevBtn.addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
});
const nextBtn = document.createElement("button");
nextBtn.className = "wfm-btn wfm-btn-sm";
nextBtn.textContent = "\u25B6";
nextBtn.disabled = state.currentPage >= totalPages - 1;
nextBtn.addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
});
const info = document.createElement("span");
info.className = "wfm-pagination-info";
const start = state.currentPage * NODES_PER_PAGE + 1;
const end = Math.min((state.currentPage + 1) * NODES_PER_PAGE, totalItems);
info.textContent = `${start}-${end} / ${totalItems}`;
pager.appendChild(prevBtn);
pager.appendChild(info);
pager.appendChild(nextBtn);
grid.parentElement.insertBefore(pager, grid.nextSibling);
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${start}-${end} / ${totalItems}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
});
}
function removePagination() {
document.getElementById("wfm-nodes-pagination")?.remove();
function clearPagination() {
const container = document.getElementById("wfm-nodes-pagination");
if (container) container.innerHTML = "";
}
function scrollGridToTop() {
+6 -4
View File
@@ -259,18 +259,20 @@ function applyCustomOverrides(custom) {
const tileSize = s + gap;
let tiledSvg;
if (gap > 0) {
// Wrap: set viewBox to include gap as transparent margin
// Replace the root <svg> width/height/viewBox to add padding
// 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
// 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">`;
<svg width="${s}" height="${s}" x="0" y="0" viewBox="${origViewBox}">`;
}
) + '</svg>';
} else {
+35 -2
View File
@@ -21,12 +21,15 @@ const state = {
viewMode: localStorage.getItem("wfm_default_view") || "thumb", // thumb | card | table
selectedWf: null,
groupFilter: "", // empty = all groups
currentPage: 0,
badgeColors: (() => {
try { return JSON.parse(localStorage.getItem("wfm_badge_colors") || "{}"); }
catch { return {}; }
})(),
};
const WF_PER_PAGE = 24;
const BADGE_DEFAULT_COLORS = {
"SD1.5": "#e67e22",
SDXL: "#2ecc71",
@@ -242,6 +245,7 @@ function renderModelFilters() {
container.querySelectorAll(".wfm-filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.activeModel = btn.dataset.filter;
state.currentPage = 0;
renderModelFilters();
renderGrid();
});
@@ -297,17 +301,27 @@ function renderGrid() {
if (filtered.length === 0) {
grid.innerHTML =
'<p class="wfm-placeholder">No workflows found</p>';
updateWfPagination(0);
return;
}
if (state.viewMode === "table") {
renderTableView(grid, filtered);
updateWfPagination(0);
return;
}
// Thumbnail / Card view
// Thumbnail / Card view with pagination
const totalPages = Math.ceil(filtered.length / WF_PER_PAGE);
if (state.currentPage >= totalPages) state.currentPage = totalPages - 1;
if (state.currentPage < 0) state.currentPage = 0;
const start = state.currentPage * WF_PER_PAGE;
const pageItems = filtered.slice(start, start + WF_PER_PAGE);
updateWfPagination(totalPages);
grid.innerHTML = "";
filtered.forEach((wf) => {
pageItems.forEach((wf) => {
const card = document.createElement("div");
card.className = "wfm-card";
card.dataset.filename = wf.filename;
@@ -357,6 +371,23 @@ function renderGrid() {
}
}
function updateWfPagination(totalPages) {
const container = document.getElementById("wfm-workflow-pagination");
if (!container) return;
if (totalPages <= 1) { container.innerHTML = ""; return; }
container.innerHTML = `
<button class="wfm-btn wfm-btn-sm" ${state.currentPage === 0 ? "disabled" : ""} data-page="prev">&laquo;</button>
<span>${state.currentPage + 1} / ${totalPages}</span>
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">&raquo;</button>
`;
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
});
container.querySelector('[data-page="next"]').addEventListener("click", () => {
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
});
}
function renderTableView(grid, filtered) {
const table = document.createElement("table");
table.className = "wfm-table";
@@ -1070,6 +1101,7 @@ export function initWorkflowTab() {
if (searchInput) {
searchInput.addEventListener("input", () => {
state.searchText = searchInput.value;
state.currentPage = 0;
renderGrid();
});
}
@@ -1205,6 +1237,7 @@ export function initWorkflowTab() {
// Toolbar: group filter dropdown
document.getElementById("wfm-group-filter")?.addEventListener("change", (e) => {
state.groupFilter = e.target.value;
state.currentPage = 0;
renderGrid();
});
+82
View File
@@ -16,6 +16,7 @@
<nav class="wfm-tabs">
<button class="wfm-tab active" data-tab="workflow">Workflow</button>
<button class="wfm-tab" data-tab="nodes">Nodes</button>
<button class="wfm-tab" data-tab="models">Models</button>
<button class="wfm-tab" data-tab="generate">GenerateUI</button>
<button class="wfm-tab" data-tab="prompt">Prompt</button>
<button class="wfm-tab" data-tab="settings">Settings</button>
@@ -58,6 +59,7 @@
<button id="wfm-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">&#9881;</button>
<div id="wfm-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
</div>
<div id="wfm-workflow-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
<div class="wfm-workflow-area">
@@ -154,6 +156,7 @@
</div>
<span id="wfm-nodes-count" class="wfm-nodes-count"></span>
<button id="wfm-nodes-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<div id="wfm-nodes-pagination" class="wfm-pagination-inline"></div>
</div>
</div>
@@ -193,6 +196,72 @@
</div>
</section>
<!-- Models Tab -->
<section id="wfm-tab-models" class="wfm-tab-content">
<div class="wfm-toolbar">
<!-- Row 1: Model type sub-tabs -->
<div class="wfm-toolbar-row" style="gap:8px;">
<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>
</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-group-filter" class="wfm-select wfm-toolbar-select"><option value="">All Groups</option></select>
<div class="wfm-view-btns">
<button class="wfm-view-btn active" data-models-view="thumb" title="Thumbnail">&#8862;</button>
<button class="wfm-view-btn" data-models-view="card" title="Card">&#9776;</button>
<button class="wfm-view-btn" data-models-view="table" title="Table">&#8801;</button>
</div>
<span id="wfm-models-count" class="wfm-nodes-count"></span>
<button id="wfm-models-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
<button id="wfm-models-civitai-batch-btn" class="wfm-btn wfm-btn-sm" title="Fetch CivitAI info for all models">CivitAI</button>
<span id="wfm-models-civitai-progress" style="font-size:12px;color:var(--wfm-text-secondary);"></span>
<div class="wfm-badge-settings-wrap">
<button id="wfm-models-badge-settings-btn" class="wfm-btn wfm-btn-sm" title="Badge color settings">&#9881;</button>
<div id="wfm-models-badge-settings-panel" class="wfm-badge-settings-panel" style="display:none;"></div>
</div>
<div id="wfm-models-pagination" class="wfm-pagination-inline"></div>
</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" style="display:none;">
<div class="wfm-side-panel-header">
<span id="wfm-models-panel-title" class="wfm-side-panel-title"></span>
<button id="wfm-models-panel-close" class="wfm-side-panel-close">&times;</button>
</div>
<div class="wfm-side-tab-nav">
<button class="wfm-models-side-tab-btn active" data-side-tab="info">Info</button>
<button class="wfm-models-side-tab-btn" data-side-tab="group">Group</button>
<button class="wfm-models-side-tab-btn" data-side-tab="civitai">CivitAI</button>
</div>
<!-- Info Tab -->
<div id="wfm-models-side-info" class="wfm-models-side-content active" style="overflow-y:auto;padding:12px;">
</div>
<!-- Group Tab -->
<div id="wfm-models-side-group" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
<!-- CivitAI Tab -->
<div id="wfm-models-side-civitai" class="wfm-models-side-content" style="display:none;overflow-y:auto;padding:12px;">
</div>
</div>
</div>
</section>
<!-- GenerateUI Tab -->
<section id="wfm-tab-generate" class="wfm-tab-content">
<!-- Header Row -->
@@ -488,6 +557,19 @@
</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 with sub-tab switching</li>
<li id="wfm-help-models-2">Thumbnail, Card, and Table view modes with pagination</li>
<li id="wfm-help-models-3">Search, tag filter, group filter, and favorites filter</li>
<li id="wfm-help-models-4">Side panel: Info (file path, tags, memo), Groups management, CivitAI integration</li>
<li id="wfm-help-models-5">CivitAI integration: fetch model info by SHA256 hash, batch fetch per model type</li>
<li id="wfm-help-models-6">Detail modal: preview image, CivitAI info (base model, trained words, tags), thumbnail change</li>
<li id="wfm-help-models-7">Group management: create, rename, delete groups and assign/remove models</li>
</ul>
</div>
<div class="wfm-help-card">
<h3 id="wfm-help-sidepanel-title">Workflow Studio Library (ComfyUI Side Panel)</h3>
<ul>