Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc0713b29 | ||
|
|
4a890d1312 |
@@ -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: テーマカスタマイズ機能・設定タブ改善
|
||||
|
||||
### 概要
|
||||
|
||||
@@ -5,7 +5,7 @@ A comprehensive workflow management and generation UI plugin for [ComfyUI](https
|
||||
Browse, organize, and execute workflows directly from a dedicated studio interface — without switching between windows or manually editing JSON.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -17,9 +17,13 @@ Browse, organize, and execute workflows directly from a dedicated studio interfa
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Nodes Tab | Help & Support Tab |
|
||||
| Nodes Tab | Models Tab |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Help & Support Tab | |
|
||||
|:---:|:---:|
|
||||
|  | |
|
||||
|
||||
| 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 |
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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">«</button>
|
||||
<span>${start}-${end} / ${totalItems}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderNodeGrid(); scrollGridToTop(); }
|
||||
});
|
||||
}
|
||||
|
||||
function removePagination() {
|
||||
document.getElementById("wfm-nodes-pagination")?.remove();
|
||||
function clearPagination() {
|
||||
const container = document.getElementById("wfm-nodes-pagination");
|
||||
if (container) container.innerHTML = "";
|
||||
}
|
||||
|
||||
function scrollGridToTop() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">«</button>
|
||||
<span>${state.currentPage + 1} / ${totalPages}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" ${state.currentPage >= totalPages - 1 ? "disabled" : ""} data-page="next">»</button>
|
||||
`;
|
||||
container.querySelector('[data-page="prev"]').addEventListener("click", () => {
|
||||
if (state.currentPage > 0) { state.currentPage--; renderGrid(); }
|
||||
});
|
||||
container.querySelector('[data-page="next"]').addEventListener("click", () => {
|
||||
if (state.currentPage < totalPages - 1) { state.currentPage++; renderGrid(); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderTableView(grid, filtered) {
|
||||
const table = document.createElement("table");
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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">⚙</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">★</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">⊞</button>
|
||||
<button class="wfm-view-btn" data-models-view="card" title="Card">☰</button>
|
||||
<button class="wfm-view-btn" data-models-view="table" title="Table">≡</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">⚙</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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user