Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9681e76848 | ||
|
|
e9fbb6dd39 | ||
|
|
f858c5138a | ||
|
|
7d9c498234 | ||
|
|
d43b35dde3 | ||
|
|
b79f4e8fd0 | ||
|
|
87c53ef977 | ||
|
|
8b75d2a8d0 | ||
|
|
7aa2535964 | ||
|
|
7769de6fbb | ||
|
|
7eafe9a684 | ||
|
|
c9089c2892 | ||
|
|
9b3532704a | ||
|
|
f6f4b2c95c | ||
|
|
ac6a4f2dd6 | ||
|
|
e6c503ef75 | ||
|
|
e21f5596d8 | ||
|
|
61bf2c83da | ||
|
|
d000219b86 | ||
|
|
52ab637bd0 | ||
|
|
5e5709594b | ||
|
|
b20519c092 | ||
|
|
9d952c4b6e | ||
|
|
8248ff8ac7 |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Users/statsu-11/.local/bin/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# graphify: keep graph.json/graphify-out/ out of Claude Code's automatic
|
||||
# context so re-running `graphify extract` doesn't churn the prompt cache
|
||||
# on every rebuild. These are still tracked by git (see .gitignore).
|
||||
graph.json
|
||||
graphify-out/
|
||||
@@ -0,0 +1,11 @@
|
||||
# ComfyUI-Workflow-Studio - Unsloth backend (AI TOOL tab / sidebar A tab)
|
||||
#
|
||||
# Unsloth Desktop requires an API key even for local access
|
||||
# (Settings -> API in the Unsloth app, shown only once when issued).
|
||||
#
|
||||
# 1. Copy this file to ".env" (same directory as this example file)
|
||||
# 2. Paste your key after the "=" below (do NOT add a leading #, or the
|
||||
# line becomes a comment and the key will not be loaded)
|
||||
# 3. Restart ComfyUI
|
||||
|
||||
UNSLOTH_API_KEY=
|
||||
@@ -5,3 +5,12 @@ __pycache__/
|
||||
.venv
|
||||
*.egg-info/
|
||||
data/
|
||||
|
||||
# graphify: keep the knowledge graph itself under version control,
|
||||
# but exclude the run-by-run cost/token log, the local extraction
|
||||
# cache (both churn every rebuild and have no value to other clones),
|
||||
# and the machine-local interpreter/scan-root pointers.
|
||||
graphify-out/cost.json
|
||||
graphify-out/cache/
|
||||
graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Private/unpublished planning notes — never committed to git, must not leak
|
||||
# into the published graphify-out/ knowledge graph either.
|
||||
QWEN_CHAT_INTEGRATION_PLAN.md
|
||||
@@ -199,3 +199,13 @@ os.environ["HF_HOME"] = "D:\\hf_cache"
|
||||
5. `gh release create vX.Y.Z` でリリースを作成(リリースノート本文に変更点を記載する — v0.3.90でREADME.mdのChangelogセクションは廃止したため、以後のバージョン履歴はGitHub Releaseのリリースノートのみで管理する)
|
||||
|
||||
`pyproject.toml` のバージョン変更が GitHub Actions のトリガー(`paths: pyproject.toml`)になっており、ComfyUI Registry への自動公開もここで行われる。バージョン更新を忘れると Registry に反映されない。
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
|
||||
@@ -12,7 +12,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
|
||||
- Batch generation across models, samplers, prompts, and workflows
|
||||
- Image Feeder for continuous folder-based generation
|
||||
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Checkpoint/VAE/Prompt/KSampler
|
||||
- Lab: experimental I2I batch generation with independent per-iteration keyframes for Model (Checkpoint/LoRA Single/VAE)/Prompt/KSampler
|
||||
|
||||
**📚 Workflow Studio Library** — a multi-function side panel for smooth ComfyUI integration
|
||||
|
||||
@@ -22,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -95,7 +95,8 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Emphasis weight editing** (v0.3.70) — select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust `(text:weight)` by ±0.05, A1111/native-ComfyUI style; parentheses are removed automatically when the weight returns to 1.0
|
||||
- **Mask slot for inpainting** (v0.3.71) — LoadImage slots whose MASK output feeds an inpaint node (e.g. `VAEEncodeForInpaint`) show an additional Mask drop zone; when a mask is set, image + mask are composited into a single RGBA upload (mask baked into the alpha channel) matching ComfyUI's native alpha→MASK extraction — no extra nodes required
|
||||
- **Placeholder Image + Clear** (v0.3.97) — each LoadImage card gets a **Clear** button (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself) and a **Placeholder** button; a collapsible **Placeholder Image** section (collapsed by default) at the top of the Image sub-panel configures one shared default — **Color** mode (set Width/Height/Color, generates a solid-color PNG on the fly) or **Image** mode (drop one default image, reused as-is) — remembered across sessions; clicking a card's own Placeholder button immediately replaces just that card's image with the configured default, so multiple LoadImage cards can each be replaced individually with one click
|
||||
- **Model tab** — Checkpoint, VAE, LoRA, ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device), **Hypernetwork** (with Strength field) selectors with filter; Raw JSON on the right
|
||||
- **Model tab** — Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), and Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — single clip: type + device; dual clip: two clip selectors + type + device) are always shown; **ControlNet** and **Hypernetwork** (with Strength field) are collapsed by default (v0.3.98) since they're used less often — click their header to expand; **LoRA** (Single/Stack tabs) has its own column; all selectors have a filter; Raw JSON on the right
|
||||
- **Highlight color** (v0.3.98) — the label of each model type above, plus the LoRA column header, turns a distinct color (default bright green, customizable in the Settings tab) when the loaded workflow actually contains that type of node; stays visible even while ControlNet/Hypernetwork are collapsed, so relevance is visible without expanding
|
||||
- **LoRA Stack Group toggle** (v0.3.92) — for `Lora Loader (LoraManager)` nodes, a **Use Stack Group** checkbox next to the Single/Stack tabs switches what the Stack tab shows and edits: OFF (the default whenever a workflow is loaded) displays and edits the LoRAs actually configured in the workflow's own Lora Manager node — correctly showing every LoRA it holds, not just the first one; ON switches to the LoRA group registered in the Models tab instead; toggling either direction clears the node's LoRA list first, then loads it from the newly selected source, and switching back to OFF restores exactly the list that was present when the workflow was loaded
|
||||
- **Settings tab** — KSampler and Latent Image side by side at 50% width each; Raw JSON on the right
|
||||
- **Always-visible Raw JSON** — edit the API-format JSON directly from any tab with syntax highlighting; Apply button reloads the workflow; built-in **search bar** (always shown) finds all matches as you type with count display (`3/12`); navigate with ↑/↓ buttons or Enter / Shift+Enter; Escape or ✕ clears; current match highlighted in orange, other matches in yellow
|
||||
@@ -145,18 +146,18 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.3.97)</h3></summary>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.4.3)</h3></summary>
|
||||
|
||||
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
|
||||
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA Single/VAE) / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
|
||||
|
||||
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 4 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
|
||||
- **Per-column keyframes** — Checkpoint, VAE, Prompt, KSampler each hold an independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it; lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
|
||||
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 3 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
|
||||
- **Per-column keyframes** — Checkpoint, LoRA, VAE, Prompt, and KSampler each hold their own fully independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it; lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
|
||||
- **Model column — Checkpoint / LoRA Single / VAE** (v0.4.2, redesigned v0.4.3) — Checkpoint and VAE (and now LoRA) share one visual "Model" column; a `[C][L][V]` toggle in its header picks which of the three independent keyframe lists the shared cell list, its own +/-, and its edit modal currently operate on — each behaves exactly like its own column (same as Prompt/KSampler), just sharing the Model column's screen space, so adding, removing, editing, reverting, or bypassing a keyframe while viewing one never touches the other two. Each toggle button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. A field left blank in a later keyframe's edit modal shows "(inherits: ...)" instead of "(workflow default)" when an earlier keyframe in that same list already set something. LoRA support is Single only (no Stack, matching the experimental scope of this tab); if a LoRA is set but the loaded workflow has no LoRA node at all, Run warns once and skips it rather than auto-inserting one — unlike VAE (which does auto-inject a VAELoader when missing), wiring in a LoraLoader would require rewiring the Checkpoint's model/clip outputs through it
|
||||
- **Wildcards** (v0.3.93) — Prompt keyframes' Positive/Negative text supports the same A1111-style `__name__` syntax as the main GenerateUI prompt fields; each keyframe is expanded fresh right before that iteration runs, so reusing the same keyframe across iterations can still draw a different random line each time
|
||||
- **Checkpoint / VAE cell filtering** (v0.3.88) — a Filter... box above the value dropdown narrows the list live, same as GenerateUI's Model tab; overlay ✕ clear button matches the Models/Nodes/Workflow/Gallery tab search boxes
|
||||
- **Prompt cell extras** (v0.3.88, refined in v0.3.89, v0.3.93, and v0.3.97) — **Get from GenerateUI** fills Positive/Negative from the workflow currently loaded in GenerateUI; **Get from Image** extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); **Get from Previous** (v0.3.97) copies both Positive and Negative wholesale from the keyframe immediately before this one in the same column (e.g. editing #3 copies #2's prompt; not shown on keyframe #1, which has no earlier row); **Clear** (v0.3.97) empties both fields; **Style** row — an **Apply** button merges whichever style is currently selected in GenerateUI's own top-bar Style dropdown into the Positive/Negative text below, comma-separated after any existing text (never clears it) and works regardless of that top-bar's own Style enable checkbox; a checkbox next to it tracks on/off and shows the applied style's name beside itself (not on the button) — unchecking only clears that name label, the already-merged text stays and is yours to edit/delete by hand
|
||||
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 shows the currently loaded workflow's own live setting (Checkpoint/VAE/Prompt/KSampler) instead of a blank placeholder, both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it — even unchanged — locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI
|
||||
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only, LoRA is never live-reflected since there's no reliable way to infer "the" active LoRA back out of a workflow node — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it — even unchanged — locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI
|
||||
- **Bypass checkbox** (v0.3.97) — available on every keyframe including #1; skips that row entirely when picking which setting applies at a given iteration, as if it didn't exist, so whatever was active before it keeps running instead; Lab-internal only, never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (mutually exclusive with Revert); the cell shows an amber "⏭ Bypassed" label, taking priority over both the Revert and live-reflect display
|
||||
- **T2I workflow support** (v0.3.90) — a **T2I workflow (no source image)** checkbox in the Image pane skips the source-image requirement and never touches a LoadImage node, so workflows with no image input (e.g. plain `EmptyLatentImage` txt2img) can also use the Checkpoint/VAE/Prompt/KSampler keyframes; the Image drop zone and "Use generated image for next" are greyed out (not cleared) while it's on; saved with the plan as `t2i_mode`
|
||||
- **T2I workflow support** (v0.3.90) — a **T2I workflow (no source image)** checkbox in the Image pane skips the source-image requirement and never touches a LoadImage node, so workflows with no image input (e.g. plain `EmptyLatentImage` txt2img) can also use the Model/Prompt/KSampler keyframes; the Image drop zone and "Use generated image for next" are greyed out (not cleared) while it's on; saved with the plan as `t2i_mode`
|
||||
- **Use generated image for next** — optional checkbox that chains iterations: from iteration 2 onward the previous iteration's first output image is fed back in as the I2I source instead of the original dropped image, using ComfyUI's `"name [type]"` annotated-filename reference (no re-upload needed); disabled together with the Image drop zone in T2I mode
|
||||
- **Workflow recall** (v0.3.90) — every saved plan records the filename of the workflow that was loaded in GenerateUI at save time (`workflow_filename` in the Plan JSON); after loading a plan, an **Open Workflow** button appears next to it — click to load that exact workflow file back into GenerateUI (asks for confirmation first, since it replaces whatever is currently loaded)
|
||||
- **Plan files** — Plan Save (silently overwrites the loaded plan) / Save As (always asks for a new name) / Plan Clear; plans are stored as `ws_labplan_<name>.json` in `user/default/Workflow-Studio/lab_plan/` (the `ws_labplan_` prefix is added automatically, v0.3.90 — type just the plain name when prompted), alongside an auto-generated `ws_labplan_<name>.png` index image (a 3-per-row contact sheet of up to 9 result thumbnails)
|
||||
@@ -224,6 +225,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Data Management** — export all plugin data (settings, metadata, prompts, etc.) to a single JSON file; import to restore data (useful when migrating or reinstalling); API keys are excluded from exports for security
|
||||
- **Text Size** — one slider (10–28 px) adjusts font size for all prompt and chat textareas at once: Generate UI positive/negative prompts, AI Assistant chat input, Preset prompts, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings
|
||||
- **RAW JSON Colors** — customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI: Default Text (base), Name/Scheduler (yellow), Title (pink), Width/Height (green), Prompt/Text (cyan), Image/File (red); changes apply immediately on color pick; Reset Defaults restores the original scheme; saved to `localStorage` under `wfm_settings.jsonColors` and applied on startup
|
||||
- **GenerateUI Model Tab Highlight** (v0.3.98) — pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it; saved to `localStorage` under `wfm_settings.modelTabActiveColor`
|
||||
- **Wildcard Integration** — link the WFS wildcard directory to ComfyUI-Impact-Pack's `wildcards/` directory (directory junction on Windows, symlink on other OS); existing WFS wildcard files are automatically migrated; requires [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack)
|
||||
- **G'MIC-Qt Integration** — set the path to `gmic_qt.exe` for the Image Edit Filter tool; download G'MIC-Qt Standalone from `gmic.eu/download.html` (Windows: `gmic_qt-win64.zip`), extract anywhere, then enter the full path to `gmic_qt.exe` and click Save
|
||||
- **Language** — English / Japanese / Chinese
|
||||
@@ -355,7 +357,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Mask** — 7 sub-tools for painting masks onto a dedicated mask layer; click **M** in the Layers header to add a mask layer (Mask tool activates automatically); clicking a mask layer in the list also switches to the Mask tool; **Tool Options bar**: sub-tool buttons (Paint / Color / Alpha / Text / Vector / Shape / SAM3), Invert checkbox, Overlay color picker (default red), Blur slider (0–50 px); the overlay is only shown for the currently active mask layer, other mask layers stay hidden until selected (v0.3.70); SAM3 requires Mask Editor One (disabled when not installed); Color / Alpha / Text / Vector / Shape work without Mask Editor One; **Properties pane — Paint**: Mode (Add / Erase), Size (1–200 px), Hardness (0–100%); **MASK EDITOR ONE section** (Paint sub-tool, visible when installed): Select opens the ABR stamp-brush library; ✕ clears back to Circle; when an image brush is active, Hardness is disabled and Spacing (5–100%), Angle (0–359°), Sz Jitter (0–100%), Rot. Jitter (on/off) appear; **Edit in Mask Editor One →** (v0.4.0) — sends the current canvas composite (plus the active mask layer's content, if one is selected) to a MaskEditorOne node on the ComfyUI canvas — the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph (same selection logic as **Send to Workflow**) — and opens its full interactive editor (paint, SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; clicking Apply there imports the result back here as a new mask layer; requires Workflow Studio to have been launched from ComfyUI's top-menu button and at least one MaskEditorOne node to exist on the ComfyUI canvas; **Properties pane — Color**: click canvas to flood-select pixels by color similarity; Mode (Add / Subtract), Tolerance (0–255), Feather % (0–100); **Properties pane — Alpha**: extract the active layer's transparency as a mask; Threshold, Invert checkbox, Extract Alpha button; **Properties pane — Text**: click canvas to open a stamp overlay — enter text, set Font / Size / Bold / Italic / Align / Mode (Add / Erase), Stamp or Ctrl+Enter to apply; multi-line supported; **Properties pane — Vector**: click to add Catmull-Rom spline points; Enter (or click first point) closes and fills the polygon; Backspace removes the last point; Esc resets; Mode (Add / Erase); **Properties pane — Shape**: drag to draw Rect or Ellipse; hold Shift for square/circle; blue dashed outline preview while dragging; Mode (Add / Erase); **Properties pane — SAM3**: enter a text prompt, set Max candidates (3/6/9/12), click Segment; select candidate thumbnails (multi-select), Apply Selected (N) applies with current Mode; **Mask layer A/S operation**: A/S toggle — Add (green) composites normally, Subtract (red) cuts the mask from below; **✂ clipping mask button** clips the layer below to the painted area; brush cursor shown as size-accurate circle; strokes can start from outside the canvas margin and continue past the edge
|
||||
- **Blur** — **Whole Blur**: Gaussian blur to the entire active layer with intensity slider (1–50 px); **Whole Mosaic**: pixelation mosaic with block-size slider (5–100 px); **Rect Blur / Rect Mosaic**: enable the toggle then drag a rectangle on the canvas to apply blur or mosaic to that region only (blue preview for blur, orange for mosaic); Rect Blur and Rect Mosaic are mutually exclusive; all operations support Undo
|
||||
- **BG Remove** — remove the background from the active layer; **Lightweight (@imgly)**: runs entirely in the browser via CDN (no server required; ~40 MB model downloaded on first use); **BiRefNet**: high-quality removal via Mask Editor One's Python backend (requires Mask Editor One installed and `birefnet.safetensors` in `ComfyUI/models/background_removal/`); **New Layer** option (default on) adds the result as a new layer above the original; when off, replaces the active layer in-place
|
||||
- **Filter (G'MIC)** — apply G'MIC-Qt filter effects to the active image layer; click **Edit with G'MIC** to launch G'MIC-Qt GUI; select a filter and click OK — a "G'MIC-Qt filter output" window appears; close it to save the result back as the active layer; requires G'MIC-Qt Standalone installed and path configured in Settings → G'MIC-Qt Executable Path
|
||||
@@ -363,7 +365,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Layer panel** — layer list with visibility (👁/🚫), lock (🔒/🔓), and clipping-mask (✂) toggles; opacity slider; type icons (🖼 image / T text / ✏ draw / ⬚ mask); Layer 1 is automatically locked on first image load
|
||||
- **Layer lock** — locked layers show an orange bounding box and 🔒 icon on the canvas; move/resize/rotate are disabled while locked; click the 🔓 button in the layer row to unlock
|
||||
- **Text quality** — text layers are rendered at their measured bounding-box size; resizing with the Select tool regenerates the canvas at the new display resolution so text stays sharp
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Send to ComfyUI** (uploads to ComfyUI input folder for use in Load Image nodes)
|
||||
- **Export** — Save PNG (download composite locally); **Save to Gallery** (saves to Gallery root folder with a timestamped default name `wfs-image-YYYYMMDDHHmmss`); **Save to Input** (uploads to ComfyUI input folder for use in Load Image nodes); **Send to Workflow** (v0.4.0) — uploads the same way, then writes the resulting filename directly into the `image` widget of the node currently selected on the ComfyUI canvas (falls back to the first LoadImage-type node found); requires Workflow Studio to have been launched from ComfyUI's top-menu button so the ComfyUI tab is reachable
|
||||
- **Canvas navigation** — scroll-wheel zoom, Space + drag to pan; zoom indicator in the bottom bar
|
||||
- **Undo / Redo** — Ctrl+Z / Ctrl+Y (or toolbar buttons); keyboard shortcuts: V (Select), B (Draw), T (Text), S (Shape), Delete (remove selected layer when 2+ exist)
|
||||
|
||||
@@ -373,8 +375,9 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
<summary><h3>AI TOOL Tab (v0.3.14)</h3></summary>
|
||||
|
||||
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio and Lemonade use `/v1/chat/completions`
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
- **Reliability fix** (v0.3.98) — switched from a raw single-string prompt to a system-role chat request (Ollama `/api/chat`, LM Studio/Lemonade `/v1/chat/completions` with a `system` message) so small local models follow the "translation only" instruction more reliably; output is cleaned of common LLM preambles/quotes, and a warning toast appears if the model appears to have echoed the source text unchanged instead of translating it
|
||||
- **Chat pane** (v0.3.40) — multi-turn conversation with the LLM; full conversation history sent each turn for context; Enter to send, Shift+Enter for a newline; Clear button resets history; Ollama uses `/api/chat`, LM Studio, Lemonade, and Unsloth use `/v1/chat/completions`
|
||||
- **Chat pane — image generation via Tool Calling** (v0.3.73) — if the selected model supports tool calling and the user asks for an image, a `generate_image` tool is invoked and the request runs through the same generation pipeline as the GenerateUI Generate button, with the result shown inline in the chat; an "Allow image generation" checkbox in the input row toggles this on/off (default on); the workflow used is the one currently loaded in GenerateUI, or a dedicated saved workflow configured in the Settings pane; models verified to work well for tool calling and vision: **Gemma 4:e4b**, **Qwen 3.5:9b**, **Ministral 3:3b** (Ollama)
|
||||
- **TOOLS pane (VLM)** — drop an image into the 110px drop zone, select a task (Describe Image / Create Prompt / Create Tags), and click Run to analyze with a vision model; result shown in the output area with a Copy button
|
||||
- **TOOLS pane — shared Chat attachment** (v0.3.74) — an image dropped in the TOOLS drop zone is also usable as the Chat pane's attachment: it's sent to the LLM as vision input, and used as the base image when `generate_image` runs image-to-image; a ✕ button clears it (from either the TOOLS preview or the Chat attachment indicator); it stays attached across turns until explicitly cleared
|
||||
@@ -382,11 +385,13 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Chat pane — image-to-image (I2I)** (v0.3.74) — when an image is attached and `generate_image` is invoked, the attachment is uploaded and swapped into the target workflow's LoadImage node instead of running text-to-image; the target workflow is the one currently loaded in GenerateUI, or a dedicated I2I workflow configured in the Settings pane
|
||||
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
|
||||
- **Chat pane — Skills** (v0.3.79) — select a `.md` skill file from a dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); a pencil button opens an in-pane manager (list + inline editor, same pattern as the Prompt tab's wildcard file manager) to create, edit, or delete skill files stored in `user/default/Workflow-Studio/ai_skills/`; a file's `---`-delimited frontmatter (`name`/`description`) is shown in the dropdown; two starter skills are bundled — **SVG Icon Generator** (explicitly decomposes the subject into parts before drawing, which markedly improves recognizability from small local models) and **Skill Creator** (interviews you conversationally and drafts a new skill as a ```skill code block; a "Save as new skill" button appears under such replies and opens the manager pre-filled with the draft)
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade / Unsloth), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API always requires an API key, even for local access; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
|
||||
- **Settings pane — Chat Image Generation** (v0.3.73) — "Use dedicated workflow" checkbox lets Chat-triggered image generation use a saved workflow of your choice instead of the one currently loaded in GenerateUI, without disturbing what's shown there (same pattern as Image Edit's Inpaint dedicated workflow)
|
||||
- **Settings pane — Chat I2I Generation** (v0.3.74) — same "Use dedicated workflow" pattern as above, but for image-to-image generation when an image is attached; independent of the text-to-image dedicated workflow setting
|
||||
- **Settings pane — Generation** (v0.3.98) — **Thinking mode** toggle (off by default; sends Ollama's `think` flag, and regardless of backend, any `<think>...</think>` block is stripped from the output when off so reasoning models don't clutter Translation/Chat/TOOLS results) and **Max tokens** (Ollama: `options.num_predict`; LM Studio/Lemonade/Unsloth: `max_tokens`; `0` = unlimited)
|
||||
- **Settings shared** — settings saved to `localStorage` under `wfm_ai_settings`; shared with the Library panel's AI TOOL tab so configuration is consistent across both interfaces
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API (`/v1/chat/completions`, `/v1/models`); VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade)
|
||||
- **Backend support** — Ollama (`/api/generate` for text, `/api/chat` for conversations, `/api/tags` for model list); LM Studio and Lemonade via the OpenAI-compatible API directly from the browser (`/v1/chat/completions`, `/v1/models`); Unsloth via the same OpenAI-compatible API but relayed through a server-side proxy that attaches the `Authorization: Bearer` header; VLM images sent as base64 (`images:[]` for Ollama, `image_url` content block for LM Studio/Lemonade/Unsloth)
|
||||
- **URL security** — backend URL validated via `new URL()` to enforce `http://` or `https://` scheme
|
||||
- **Lemonade's other endpoints (not integrated)** — Lemonade Server also exposes OpenAI-compatible image generation (`/v1/images/generations`), text-to-speech (`/v1/audio/speech`), and transcription (`/v1/audio/transcriptions`) endpoints; these are not wired up — image generation would duplicate the existing ComfyUI-workflow-based `generate_image` tool call, and audio/TTS has no corresponding UI yet, so both are future considerations rather than a natural extension of the current backend switch
|
||||
|
||||
@@ -403,7 +408,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **M — Models tab** — browse installed models (All / ★ Favorites / Groups / By Type sub-tabs); LoRA groups show an **All N LoRAs** item — drag to canvas to place a `Lora Loader (LoraManager)` node with all LoRAs pre-loaded
|
||||
- **P — Prompts tab** — browse prompt presets with All / ★ Favorites / Categories sub-tabs; **Groups sub-tab** (row 2) — view presets by group (shared with the Batch tab's `wfm_prompt_preset_groups`)
|
||||
- **I — Information tab** — drop a ComfyUI-generated PNG/WebP or workflow JSON in the side panel to view its metadata; detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format supported); supports `UnetLoaderGGUF` and `QuadrupleCLIPLoader` node types; preview area fixed at 110px
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **A — AI TOOL tab** — Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations (full history sent each turn); TOOLS includes VLM image analysis and wildcard generation; settings (backend, URL, model) shared with the SPA AI TOOL tab via `localStorage`
|
||||
- **model sub-tab** — Checkpoint, VAE, Diffusion Model, and Text Encoder; drag items to canvas to place the corresponding loader node (Checkpoint → `CheckpointLoaderSimple`, VAE → `VAELoader`, Diffusion Model → `UNETLoader`, Text Encoder → `CLIPLoader`); double-click also places at canvas center
|
||||
- **lora sub-tab** — detects LoRAs from `LoraLoader`, `LoraLoaderModelOnly`, and `Lora Loader (LoraManager)` nodes (API format `inputs.loras.__value__` supported); shows `strength_model / strength_clip` values; drag individual LoRA to place `LoraLoader`; **Multiple LORA** section (appears for 1+ LoRAs) drags all LoRAs into a single `Lora Loader (LoraManager)` node with LoRA syntax pre-filled
|
||||
- **Prompts sub-tab** — POS / NEG badge list; drag a prompt to place `CLIPTextEncode` with text pre-filled; click any entry to view full text + Copy button
|
||||
@@ -500,6 +505,7 @@ Click the **camera icon** (next to the W button) in ComfyUI's top bar to capture
|
||||
- **[Ollama](https://ollama.com/)** — for AI chat assistant, translation, and VLM features
|
||||
- **[LM Studio](https://lmstudio.ai/)** — alternative backend for translation and VLM (OpenAI-compatible API)
|
||||
- **[Lemonade Server](https://lemonade-server.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API)
|
||||
- **[Unsloth](https://unsloth.ai/)** — alternative backend for translation, chat, and VLM (OpenAI-compatible API); always requires an API key, set via `UNSLOTH_API_KEY` in a `.env` file — `pip install -r requirements.txt` for `python-dotenv`
|
||||
- **[Eagle](https://eagle.cool/)** — for auto-saving generated images with metadata
|
||||
- **[comfyui-mask-editor-one](https://github.com/ketle-man/comfyui-mask-editor-one) (v0.1.9+)** — enables BiRefNet background removal, SAM3 text-prompt segmentation, and ABR stamp-brush library in the Image Edit Mask tool; `birefnet.safetensors` must be placed in `ComfyUI/models/background_removal/` for BiRefNet
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,745 @@
|
||||
# Graph Report - ComfyUI-Workflow-Studio (2026-08-20)
|
||||
|
||||
## Corpus Check
|
||||
- 140 files · ~0 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 2627 nodes · 5787 edges · 171 communities (119 shown, 52 thin omitted)
|
||||
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.66)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- Models Tab Frontend
|
||||
- Models API Routes
|
||||
- Tagger API Routes
|
||||
- Image Edit Mask Tools (Code)
|
||||
- Prompt Tab Frontend
|
||||
- Gallery API Routes
|
||||
- Gallery Tab Frontend & Toasts
|
||||
- Image Edit Layer & Draw Tools
|
||||
- Tagger/Prompt i18n Helpers
|
||||
- GenerateUI Feeder Tab
|
||||
- Nodes API Routes
|
||||
- Settings API Routes
|
||||
- App Shell & i18n Init
|
||||
- Nodes Tab Frontend
|
||||
- GenerateUI Lab Tab
|
||||
- Metadata Tab Frontend
|
||||
- Node Sets Menu & Sidebar
|
||||
- AI Tool Tab Frontend
|
||||
- Generate Tab Frontend & Modals
|
||||
- Workflow Tab Frontend & Storage
|
||||
- Wildcard Service & Routes
|
||||
- CivitAI Service & Data Import/Export
|
||||
- GenerateUI Tab & AI Chat Features
|
||||
- ComfyUI Client & Editor
|
||||
- DEVLOG v0.1.x Early Features
|
||||
- AI Chat/VLM Sidebar Helpers
|
||||
- AI TOOL Tab Backends
|
||||
- Node Sets Menu UI Components
|
||||
- Node Sets Draggable Items
|
||||
- Workflow API Routes
|
||||
- Gallery Metadata Store
|
||||
- Workflow Service Metadata/Import
|
||||
- CLAUDE.md Node Dev Rules
|
||||
- Lab Tab & PNG Metadata Utilities
|
||||
- Lab Plan Service & Routes
|
||||
- Skill Service & Routes
|
||||
- Image Edit Select Tool
|
||||
- Image Edit Draw Tool
|
||||
- Image Edit Layer Manager Core
|
||||
- CivitAI Service
|
||||
- Image Edit Mask Tool
|
||||
- Gallery Service Core
|
||||
- Eagle Routes Backend
|
||||
- Style Catalog Tab
|
||||
- Image Prompt Tab
|
||||
- SDXL 3D Pose Editor DWPose Workflow
|
||||
- Gallery Service Core
|
||||
- Image Edit Tab & Mask Editor One
|
||||
- App Shell & Nodes Tab Ecosystem
|
||||
- Node Sets Menu Model Fetch
|
||||
- Image Edit Background Removal
|
||||
- ComfyUI Workflow Utilities
|
||||
- Generate Tab Frontend & Modals
|
||||
- Batch Group UI & Security Fixes
|
||||
- DEVLOG Early History & Manager Registration
|
||||
- App Shell & ComfyUI Ecosystem Refs
|
||||
- GenerateUI Batch & Workflow Editor Core
|
||||
- Image Edit Blur Tool (BlurTool.js)
|
||||
- Image Edit Shape Tool
|
||||
- Tagger Tab & Gallery Bulk Actions
|
||||
- Image Edit Layer Core & Bug Fixes
|
||||
- G'MIC Routes
|
||||
- Ollama Routes
|
||||
- Gallery Metadata/Service Bridge
|
||||
- Image Edit Text Tool
|
||||
- Mask Editor One BiRefNet/SAM3 Integration
|
||||
- Eagle Routes Backend
|
||||
- Prompts API Routes/Service
|
||||
- Workflow Analyzer
|
||||
- Gallery Tab Core & Feeder Group
|
||||
- AI TOOL Backend & Model Tab Updates
|
||||
- Tagger Tab Implementation & Security Fixes
|
||||
- Eagle Routes Backend
|
||||
- Prompts API Routes/Service
|
||||
- Metadata & Nodes Tab Core
|
||||
- AI Tool Tab Frontend
|
||||
- Node Sets Menu & Sidebar
|
||||
- Gallery Feeder Extension (Frontend)
|
||||
- Gallery Speed & Bulk Features
|
||||
- Style Catalog & Batch Loop Core
|
||||
- Metadata Tab Node Support History
|
||||
- Image Edit Background Removal (BgRemove.js)
|
||||
- Gallery Feeder Node (Backend)
|
||||
- App Shell & i18n Init
|
||||
- Generate Tab Frontend & Modals
|
||||
- Image Edit Inpaint/I2I Actions
|
||||
- Image Edit SAM3 Segmentation
|
||||
- Node Sets Metadata Extraction
|
||||
- Node Sets Prompt Extraction
|
||||
- Inpaint Basic Workflow
|
||||
- Gallery Feeder & Batch Features
|
||||
- Gallery Service Background Indexing
|
||||
- Settings/CivitAI Config Resolution
|
||||
- Gallery Service Vault Helpers
|
||||
- GenerateUI Lab Tab
|
||||
- Style Prompt Seed Import Tool
|
||||
- FireRed 3D Pose Image Edit Workflow
|
||||
- Chat Image Generation & Inpaint Bridge
|
||||
- Subgraph Workflow Support & Fixes
|
||||
- KREA-2 & Lab Tab Extensions
|
||||
- LoRA Pane Evolution & Help Tab
|
||||
- Image Edit File Export (FileExport.js)
|
||||
- Image Edit G'MIC Integration
|
||||
- App Shell & i18n Init
|
||||
- DWPose Image Feeder Workflow
|
||||
- AI Tab & RAW JSON Search History
|
||||
- Prompt Extraction Bug Fixes
|
||||
- Comic Creator Bridge & LoRA Batch Fixes
|
||||
- Settings/CivitAI Config Resolution
|
||||
- Node Sets Menu Model Fetch
|
||||
- G'MIC Integration & Security Fixes
|
||||
- Comic Creator Integration Bridges
|
||||
- GenerateUI Model Tab GGUF/Embedding Support
|
||||
- Bypass Node Handling & Advanced Sampling
|
||||
- Prompt Text Node
|
||||
- Gallery Metadata/Service Bridge
|
||||
- Gallery Service Vault Helpers
|
||||
- PNG Metadata Extractor
|
||||
- AI Skills & Help Redesign
|
||||
- Prompt Extraction Bug Fixes
|
||||
- AI TOOL Chat & Wildcard Features
|
||||
- Config & Service Modules
|
||||
- Group Feature Path Bug Fixes
|
||||
- SVG Generation & Display Support
|
||||
- Send to Canvas Feature
|
||||
- Module Split Refactor (v0.4.0)
|
||||
- CivitAI Detail Panel History
|
||||
- GenerateUI Layout & JSON Highlight
|
||||
- Workflow Tab Bug Fixes
|
||||
- CivitAI Batch Fetch Optimization
|
||||
- DEVLOG JSON Highlighting & Subgraph
|
||||
- Mask Tool 5 Sub-tools
|
||||
- Prestartup Script
|
||||
- Workflow Service Dir Update
|
||||
- Project Banner Asset
|
||||
- Project Icon Asset
|
||||
- SDXL 2D Pose Editor I2I Workflow
|
||||
- Tagger Tab Screenshot
|
||||
- Models Tab Screenshot
|
||||
- Topbar Icon Strip Screenshot
|
||||
- GenerateUI LoRA Stack Screenshot
|
||||
- Favicon Asset
|
||||
- FireRed 2D Pose Image Edit Workflow
|
||||
- Flux.2 Klein 3D Pose Image Edit Workflow
|
||||
- SDXL DWPose ControlNet Workflow
|
||||
- SDXL From Metadata Workflow
|
||||
- SDXL I2I FaceDetailer Workflow
|
||||
- SDXL Image Feeder I2I Workflow
|
||||
- SDXL T2I FaceDetailer Workflow
|
||||
- Default View Setting
|
||||
- Latent Resolution Presets
|
||||
- Send to Canvas Toast Fix
|
||||
- ZIT/Lumina2 Prompt Fix
|
||||
- GenerateUI Batch Tab Screenshot
|
||||
- Multiple Select Menu Screenshot
|
||||
- Image Edit Tab Screenshot
|
||||
- Workflow Tab Screenshot
|
||||
- Prompt Input Assistance Screenshot
|
||||
- GenerateUI Feeder Screenshot
|
||||
- Library Panel LoRA Info Screenshot
|
||||
- Library Panel Prompts Screenshot
|
||||
- Settings Tab Screenshot
|
||||
- Project Thumbnail Asset
|
||||
- Project Manifest (pyproject.toml)
|
||||
- Main Stylesheet
|
||||
- SD1.5 T2I Basic Workflow
|
||||
- SDXL T2I Basic Workflow Screenshot
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `t()` - 174 edges
|
||||
2. `showToast()` - 138 edges
|
||||
3. `escapeHtml()` - 57 edges
|
||||
4. `ImageEditTab` - 53 edges
|
||||
5. `GalleryService` - 45 edges
|
||||
6. `initPromptTab()` - 34 edges
|
||||
7. `setup_routes()` - 32 edges
|
||||
8. `initSettingsTab()` - 31 edges
|
||||
9. `add()` - 31 edges
|
||||
10. `renderModelGrid()` - 28 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `Sidepanel Information (I) Tab` --semantically_similar_to--> `Metadata Tab` [INFERRED] [semantically similar]
|
||||
templates/index.html → README.md
|
||||
- `Batch Type: Checkpoint` --shares_data_with--> `Models Tab` [EXTRACTED]
|
||||
templates/index.html → README.md
|
||||
- `Workflow Tab` --shares_data_with--> `Badge System (shared)` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
- `Settings Tab` --conceptually_related_to--> `Settings: Connections (ComfyUI/Ollama/Eagle)` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
- `Settings Tab` --conceptually_related_to--> `Settings: Workflow Defaults` [EXTRACTED]
|
||||
README.md → templates/index.html
|
||||
|
||||
## Import Cycles
|
||||
- 3-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 3-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/image-prompt-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/MaskEditorOneBridge.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BlurTool.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/GmicIntegration.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/FileExport.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/InpaintI2IActions.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/BgRemove.js -> static/js/app.js`
|
||||
- 3-file cycle: `static/js/app.js -> static/js/image-edit-tab.js -> static/js/image-edit/Sam3Segmentation.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/prompt-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/gallery-tab.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/app.js`
|
||||
- 4-file cycle: `static/js/app.js -> static/js/workflow-tab.js -> static/js/models-tab.js -> static/js/models/badges.js -> static/js/app.js`
|
||||
- 5-file cycle: `static/js/ai-tab.js -> static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/ai-tab.js`
|
||||
- 5-file cycle: `static/js/app.js -> static/js/generate-tab.js -> static/js/feeder-tab.js -> static/js/gallery-tab.js -> static/js/style-catalog-tab.js -> static/js/app.js`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **Gallery Feeder System** — devlog_gallery_feeder_node, devlog_gallery_feeder_canvas_controls, devlog_feeder_group_protection, devlog_feeder_tab_gallery_mode [EXTRACTED 0.90]
|
||||
- **LoRA Pane Iterative Evolution (v0.3.29-v0.3.33)** — devlog_v0_3_29_lora_section_integration, devlog_v0_3_30_lora_trigger_bugfix, devlog_v0_3_31_lora_single_stack_tabs, devlog_v0_3_32_loramanager_detection, devlog_v0_3_33_lora_pane_bugfix [EXTRACTED 0.90]
|
||||
- **Shared Badge System Across Workflow and Models Tabs** — templates_index_badge_system, readme_workflow_tab, readme_models_tab [EXTRACTED 1.00]
|
||||
- **ComfyUI Comic Creator Bidirectional Bridge Functions** — devlog_comic_creator_integration, devlog_v0_3_69_send_cc_button, devlog_i2i_bridge_receive_image, devlog_v0_3_76_i2i_run_bridge, devlog_v0_3_72_comic_creator_inpaint_entrypoint, devlog_v0_3_80_comic_creator_bridge [EXTRACTED 1.00]
|
||||
- **Mask Editor One Integration Features (BiRefNet/SAM3/ABR/Icon Fix/Connect Button)** — devlog_mask_editor_one_custom_node, devlog_birefnet_bg_remove_integration, devlog_sam3_segmentation_integration, devlog_mask_tool_abr_brush_support, devlog_bg_image_b64_field_rename_fix, devlog_v0_4_0_mask_editor_one_connect_button [EXTRACTED 1.00]
|
||||
- **Feeder Group Image Queueing Flow** — readme_feeder_group, readme_gallery_tab, templates_index_feeder_gallery_mode, templates_index_wfs_galleryfeeder_node [EXTRACTED 1.00]
|
||||
- **node_sets_menu.js Sidepanel Evolution Across Versions** — devlog_v0_1_7_nodes_tab, devlog_v0_1_8_wf_node_library, devlog_v0_1_9_sidepanel_improvements, devlog_v0_2_0_prompt_presets, devlog_v0_2_3_badge_unification_genui_model, devlog_v0_2_5_category_package_theme [EXTRACTED 1.00]
|
||||
- **AI TOOL Tab multi-backend support** — readme_ai_tool_tab, readme_ollama, readme_lm_studio, readme_lemonade_server, readme_unsloth [EXTRACTED 1.00]
|
||||
- **Feeder generation-loop ecosystem** — readme_feeder_subtab, readme_image_loop_mode, readme_gallery_mode, readme_wfs_galleryfeeder_node, readme_feeder_group [EXTRACTED 1.00]
|
||||
- **GenerateUI 6-tab layout** — readme_generateui, readme_feeder_subtab, readme_batch_tab, readme_lab_subtab [EXTRACTED 1.00]
|
||||
- **py/routes API layer (per-tab route modules)** — readme_workflow_routes_py, readme_models_routes_py, readme_nodes_routes_py, readme_settings_routes_py, readme_gallery_routes_py [EXTRACTED 1.00]
|
||||
- **GenerateUI Style System (shared named-Style registry)** — readme_style_manager, readme_generateui, readme_batch_tab, readme_style_catalog_gallery_subtab, readme_create_catalog [EXTRACTED 1.00]
|
||||
- **Release & Registry Publish Flow** — claude_release_procedure, github_workflows_publish_trigger, readme_version_badge, github_workflows_publish_action [EXTRACTED 1.00]
|
||||
- **Shared Style File System Across Tabs** — templates_index_style_system, templates_index_style_feature, templates_index_style_manager_prompt, templates_index_batch_style_type, templates_index_create_catalog_feature, templates_index_gallery_stylecatalog_panel [EXTRACTED 1.00]
|
||||
- **Path Traversal Protection Pattern (_safe_path)** — devlog_v0_1_6_path_traversal_fix, devlog_v0_3_3_wildcard_panel, devlog_v0_2_8_data_dir_export_import [INFERRED 0.70]
|
||||
- **Shared Badge Palette System (Workflow/Models tabs)** — devlog_v0_2_3_badge_unification_genui_model, devlog_v0_3_0_model_group_display, devlog_v0_2_9_model_enable_disable [INFERRED 0.80]
|
||||
- **Batch Reserved Group System** — devlog_reserved_groups_mechanism, devlog_models_batch_group_ui, devlog_prompt_workflow_batch_group_ui, devlog_v0_3_24 [INFERRED 0.80]
|
||||
- **CivitAI Integration Pipeline** — devlog_v0_3_17, devlog_v0_3_19, devlog_v0_3_20, devlog_v0_3_21 [INFERRED 0.80]
|
||||
- **ComfyUI Custom Node Robustness Patterns** — claude_is_changed_caching, claude_validate_inputs_pitfall, claude_hidden_inputs, claude_lazy_inputs, claude_safe_node_loading [INFERRED 0.85]
|
||||
- **Recurring Dev/Runtime Deployment Sync Failures Across Versions** — devlog_dev_deploy_sync_reminder, devlog_v0_3_69_send_cc_button, devlog_v0_3_70_ollama_404_prompt_weight [INFERRED 0.85]
|
||||
- **LoraManager Integration** — devlog_v0_3_17, devlog_v0_3_25, devlog_v0_3_26, devlog_lora_manager_format [INFERRED 0.85]
|
||||
- **Image Edit Tab Tool Suite** — devlog_v0_3_53_image_edit_tab, devlog_v0_3_54_shape_tool, devlog_v0_3_57_blur_tool, devlog_v0_3_57_bgremove_tool, devlog_v0_3_59_mask_tool, devlog_gmic_qt_filter_tool [INFERRED 0.90]
|
||||
- **Mask Editor One Inpainting Pipeline** — comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_load_checkpoint_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_mask_editor_one_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_positive_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_clip_text_encode_negative_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_vae_encode_for_inpainting_node, comfyui-workflow-studio_workflows_ws_inpaint_mask_editor_one_save_image_node [INFERRED]
|
||||
|
||||
## Communities (171 total, 52 thin omitted)
|
||||
|
||||
### Community 0 - "Models Tab Frontend"
|
||||
Cohesion: 0.10
|
||||
Nodes (77): badgeHtml(), bindBadgeModalEvents(), bindBadgeRowEvents(), getBadgePalette(), modelBadgesHtml(), _onGridChange(), openBadgeEditModal(), renderBadgeFilter() (+69 more)
|
||||
|
||||
### Community 1 - "Models API Routes"
|
||||
Cohesion: 0.06
|
||||
Nodes (55): handle_change_preview(), handle_civitai_batch(), handle_civitai_cache(), handle_civitai_fetch(), handle_delete_models(), handle_get_disabled(), handle_get_filepath(), handle_get_groups() (+47 more)
|
||||
|
||||
### Community 2 - "Tagger API Routes"
|
||||
Cohesion: 0.07
|
||||
Nodes (29): Connection, Image, handle_batch_start(), handle_batch_status(), handle_batch_stop(), handle_db_delete(), handle_db_export(), handle_db_list() (+21 more)
|
||||
|
||||
### Community 3 - "Image Edit Mask Tools (Code)"
|
||||
Cohesion: 0.05
|
||||
Nodes (6): MASK_TEXT_FONTS, MaskAlphaTool, MaskColorTool, MaskShapeTool, MaskTextTool, MaskVectorTool
|
||||
|
||||
### Community 4 - "Prompt Tab Frontend"
|
||||
Cohesion: 0.08
|
||||
Nodes (59): refreshStylesList(), addChatMessage(), apiCreatePreset(), apiDeletePreset(), apiUpdatePreset(), applyToGenerateUI(), attachFile(), chatWithAi() (+51 more)
|
||||
|
||||
### Community 5 - "Gallery API Routes"
|
||||
Cohesion: 0.10
|
||||
Nodes (54): add_to_group(), bulk_favorite(), bulk_group(), clear_group_images(), create_folder_route(), create_group(), delete_folder_route(), delete_group() (+46 more)
|
||||
|
||||
### Community 6 - "Gallery Tab Frontend & Toasts"
|
||||
Cohesion: 0.10
|
||||
Nodes (53): addTag(), API, apiFetch(), _appendNextPage(), _applySelectionToDOM(), _attachScrollSentinel(), bindEvents(), bulkAddToGroup() (+45 more)
|
||||
|
||||
### Community 8 - "Tagger/Prompt i18n Helpers"
|
||||
Cohesion: 0.11
|
||||
Nodes (50): _appendSvgPreview(), closeModal(), showToast(), _applyDefaultCheckpointIfEnabled(), handleGenerate(), loadWorkflowIntoEditor(), openCatalogCreateModal(), saveCurrentWorkflow() (+42 more)
|
||||
|
||||
### Community 9 - "GenerateUI Feeder Tab"
|
||||
Cohesion: 0.10
|
||||
Nodes (50): _applyGalToWorkflow(), _applyPreset(), _applyToGalNode(), _applyToNode(), _applyToWorkflow(), _deletePreset(), _deselectAll(), _feederNodes() (+42 more)
|
||||
|
||||
### Community 10 - "Nodes API Routes"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): handle_create_set(), handle_delete_set(), handle_export_set(), handle_get_groups(), handle_get_metadata(), handle_list_sets(), handle_save_groups(), handle_save_metadata() (+20 more)
|
||||
|
||||
### Community 11 - "Settings API Routes"
|
||||
Cohesion: 0.09
|
||||
Nodes (44): _apply_import_bundle(), _build_export_bundle(), _find_style(), _get_comfyui_output_dir(), handle_create_style(), handle_delete_style(), handle_export(), handle_get() (+36 more)
|
||||
|
||||
### Community 12 - "App Shell & i18n Init"
|
||||
Cohesion: 0.09
|
||||
Nodes (43): getLang(), getLanguageOptions(), getSummaryLang(), getSummaryLanguageOptions(), getSummaryPrompt(), LANGUAGE_OPTIONS, LANGUAGES, setLang() (+35 more)
|
||||
|
||||
### Community 13 - "Nodes Tab Frontend"
|
||||
Cohesion: 0.12
|
||||
Nodes (42): buildPromptItem(), _applyNodeSelectionToDOM(), bulkNodeAddToGroup(), bulkNodeCreateAndAddToGroup(), bulkNodeRemoveFromGroup(), bulkNodeSetFavorite(), categoryBadgeHtml(), createNodeCard() (+34 more)
|
||||
|
||||
### Community 14 - "GenerateUI Lab Tab"
|
||||
Cohesion: 0.12
|
||||
Nodes (41): openModal(), _applyPlanData(), _applyStyleToText(), _buildIndexImageDataUrl(), _buildPlanData(), _buildWorkflowForIteration(), _cellHtml(), _cellLabel() (+33 more)
|
||||
|
||||
### Community 15 - "Metadata Tab Frontend"
|
||||
Cohesion: 0.11
|
||||
Nodes (39): buildLoRAItem(), buildModelItem(), collectAllNodes(), collectUnique(), extractAllMetadata(), fromWorkflow(), extractCheckpoints(), extractDiffusionModels() (+31 more)
|
||||
|
||||
### Community 16 - "Node Sets Menu & Sidebar"
|
||||
Cohesion: 0.09
|
||||
Nodes (38): AI_BACKEND_DEFAULT_URLS, AI_LANG_NAMES, convertApiToUiWorkflow(), _extractAllMetadata(), _extractWorkflowFromEXIF(), fetchGroups(), fetchMetadata(), fetchNodeSets() (+30 more)
|
||||
|
||||
### Community 17 - "AI Tool Tab Frontend"
|
||||
Cohesion: 0.12
|
||||
Nodes (38): appendChatBubble(), _appendSkillSaveButton(), buildTranslationMessages(), cleanTranslationOutput(), _extractSkillBlock(), _extractSvgCode(), fetchModels(), generateImageFromChat() (+30 more)
|
||||
|
||||
### Community 18 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.09
|
||||
Nodes (37): _applyNamedStyle(), _applyStyleToWorkflow(), _batchGroupState, _batchStyleSelected, _blobToDataUrl(), _buildFolderTree(), _ckptBatch, _ckptState (+29 more)
|
||||
|
||||
### Community 19 - "Workflow Tab Frontend & Storage"
|
||||
Cohesion: 0.13
|
||||
Nodes (34): badgeHtml(), _buildStructuredSummaryText(), _buildSummarySourceText(), clearBatch(), closeSidePanel(), deleteWorkflow(), fetchWorkflows(), filterWorkflows() (+26 more)
|
||||
|
||||
### Community 20 - "Wildcard Service & Routes"
|
||||
Cohesion: 0.12
|
||||
Nodes (21): handle_create_link(), handle_delete(), handle_get_content(), handle_link_status(), handle_list(), handle_remove_link(), handle_save(), Application (+13 more)
|
||||
|
||||
### Community 21 - "CivitAI Service & Data Import/Export"
|
||||
Cohesion: 0.06
|
||||
Nodes (36): CivitAI, CivitAI Integration, py/services/civitai_service.py, Data Management (export/import), G'MIC-Qt Integration, Highlight color (v0.3.98), static/js/i18n.js, static/js/json-highlight.js (+28 more)
|
||||
|
||||
### Community 22 - "GenerateUI Tab & AI Chat Features"
|
||||
Cohesion: 0.07
|
||||
Nodes (36): ComfyUI-Impact-Pack (external), Eagle Integration, GenerateUI Tab, Lab Subtab, Wildcard Integration (Settings), Alt+Click Apply & Generate, Batch Type: Checkpoint, Batch Type: Prompt (+28 more)
|
||||
|
||||
### Community 23 - "ComfyUI Client & Editor"
|
||||
Cohesion: 0.09
|
||||
Nodes (26): _applyLoraToNode(), _attachPromptWeightControl(), _buildLoraManagerSyntax(), _buildLoraSyntax(), _buildPresetOptions(), _compositeImageWithMask(), _I2I_PLACEHOLDER_DEFAULT, _imageInputToDataURL() (+18 more)
|
||||
|
||||
### Community 24 - "DEVLOG v0.1.x Early Features"
|
||||
Cohesion: 0.08
|
||||
Nodes (30): Feeder Tab (GenerateUI), Gallery タブ追加 & 全タブ Group UI 統一, node_sets_menu.js (ComfyUI Sidepanel Library), プロジェクトリネーム & アイコン適用(Workflow-Manager→Workflow-Studio), プロンプトタブ 中国語↔英語翻訳追加, テーマカスタマイズ機能・設定タブ改善, トップバーボタンアイコン修正・Appモードバッジ調査, v0.1.0 リリース(GitHub公開・ComfyUI Manager登録) (+22 more)
|
||||
|
||||
### Community 25 - "AI Chat/VLM Sidebar Helpers"
|
||||
Cohesion: 0.11
|
||||
Nodes (28): _aiApplyGenOptions(), aiCallChat(), aiCallLLM(), aiCallVLM(), aiFetchModels(), aiFileToBase64(), aiLooksUntranslated(), aiSkillDeleteFile() (+20 more)
|
||||
|
||||
### Community 26 - "AI TOOL Tab Backends"
|
||||
Cohesion: 0.09
|
||||
Nodes (28): static/js/ai-tab.js, AI TOOL Tab, Lemonade Server, LM Studio, Ollama, py/routes/ollama_routes.py, Prompt Tab, static/js/prompt-tab.js (+20 more)
|
||||
|
||||
### Community 27 - "Node Sets Menu UI Components"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): ../../scripts/ui/components/button.js, ../../scripts/ui/components/buttonGroup.js, getNodeSetsIcon(), NODE_SETS_TOOLTIP, attachTopMenuButton(), beforeRegisterNodeDef(), captureCanvasSnapshot(), compareVersions() (+19 more)
|
||||
|
||||
### Community 28 - "Node Sets Draggable Items"
|
||||
Cohesion: 0.16
|
||||
Nodes (28): createDraggableItem(), createDraggablePromptItem(), createDraggableWfItem(), esc(), extractPackageName(), getNodeCategory(), getNodePackage(), getWfBadges() (+20 more)
|
||||
|
||||
### Community 29 - "Workflow API Routes"
|
||||
Cohesion: 0.15
|
||||
Nodes (26): handle_analyze(), handle_change_thumbnail(), handle_delete(), handle_import(), handle_list(), handle_metadata(), handle_raw(), handle_reanalyze_all() (+18 more)
|
||||
|
||||
### Community 30 - "Gallery Metadata Store"
|
||||
Cohesion: 0.13
|
||||
Nodes (9): GalleryMetadataStore, Path, グループ名を変更し、全画像のgroupsフィールドも更新する, gallery_metadata.json の構造: { "images": { "<abs_path>": { "favorite": bool,…, 存在しないパスをグループから一括削除する(1回の保存で完結), グループメンバーのパスをsetで返す(高速フィルタ用), グループ内の全画像を除外する(グループ自体は残す), 画像のパスキーを変更する(ファイル移動後のメタデータ引継ぎ) (+1 more)
|
||||
|
||||
### Community 31 - "Workflow Service Metadata/Import"
|
||||
Cohesion: 0.13
|
||||
Nodes (13): Get raw workflow JSON content., Save metadata (tags, memo, summary, etc.) for a workflow., Manages workflow files and metadata., Import workflow files. Returns list of results., Rename workflow and its associated thumbnail., Delete workflow JSON and associated thumbnail., Re-analyze a workflow and save results to metadata., Re-analyze all workflows and update metadata. (+5 more)
|
||||
|
||||
### Community 32 - "CLAUDE.md Node Dev Rules"
|
||||
Cohesion: 0.10
|
||||
Nodes (25): CLAUDE.md Development Guidelines, Bounded Module-level Cache Pattern, ComfyUI Restart Required After Python Change, Pre-Commit Checklist, Loose Dependency Pinning Policy, Source Repo vs custom_nodes Deploy Sync, folder_paths-based File Path Handling, Hidden Inputs (UNIQUE_ID/PROMPT/EXTRA_PNGINFO) (+17 more)
|
||||
|
||||
### Community 33 - "Lab Tab & PNG Metadata Utilities"
|
||||
Cohesion: 0.09
|
||||
Nodes (25): wfm:apply-and-generate Custom Event (Circular Import Avoidance), embedPngTextChunk() iTXt PNG Chunk Embedding (util.js), extractAllMetadata() Exported Reusable Prompt Extraction, Gallery Root Folder Label Simplification ("[root]"), Lab Index Image Save-to-Output + Eagle Auto-Save, Lab Keyframe Data Model (atIteration/value/revertToBase), Lab Plan JSON "Open Workflow" Button (Dynamic Import), Lab Tab Plan JSON Sub-tab (Refresh/Apply to Setting) (+17 more)
|
||||
|
||||
### Community 34 - "Lab Plan Service & Routes"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): handle_delete(), handle_get_content(), handle_list(), handle_save(), handle_save_index_to_output(), Application, Request, Response (+6 more)
|
||||
|
||||
### Community 35 - "Skill Service & Routes"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): handle_delete(), handle_get_content(), handle_list(), handle_save(), Application, Request, Response, AI skill (.md system prompt) file management API routes. (+8 more)
|
||||
|
||||
### Community 39 - "CivitAI Service"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): CivitaiService, Build request headers, optionally including Bearer token., Calculate SHA256 hash of a file., Fetch model version info from CivitAI by SHA256 hash (GET). 429/5xx…, POST /model-versions/by-hash でハッシュリストを一括取得(最大100件/リクエスト)。 レスポンスの…, Extract relevant fields from CivitAI API response., Batch fetch CivitAI info for multiple model files. Phase 1: SHA256…, Clear cache for a specific hash or all. (+3 more)
|
||||
|
||||
### Community 41 - "Gallery Service Core"
|
||||
Cohesion: 0.15
|
||||
Nodes (8): Path, パスが許可ルート配下かチェック(パストラバーサル防止)。 Output ギャラリーの _allowed_root、またはComfyUI実outputフォルダの…, ワークフローを抽出する。 優先順位: PNG[workflow] > PNG[prompt] > gallery_metadata.json[workflow], 複数画像のグループ追加 / 削除を一括処理する。action は "add" または "remove"。, 選択フォルダ内に新しいサブフォルダを作成する, 画像ファイルを別フォルダへ移動する(複数対応), 画像のPathオブジェクトを返す(ルートで使用), 縮小サムネイルのPathを返す。 ディスクキャッシュがあればそれを返し、なければPillowで生成して保存する。…
|
||||
|
||||
### Community 42 - "Eagle Routes Backend"
|
||||
Cohesion: 0.14
|
||||
Nodes (20): _eagle_add(), _eagle_add_from_path(), _eagle_test(), handle_add(), handle_test(), Application, Request, Response (+12 more)
|
||||
|
||||
### Community 43 - "Style Catalog Tab"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): selectStyleByName(), _wireLabValueFilter(), activateStyleCatalogTab(), API, apiFetch(), copyText(), createThumbCard(), debounce() (+12 more)
|
||||
|
||||
### Community 44 - "Image Prompt Tab"
|
||||
Cohesion: 0.21
|
||||
Nodes (20): activateImagePromptTab(), addSelectedToChips(), API, apiFetch(), cleanPromptText(), clearAllChips(), copyFinalPrompt(), createThumbCard() (+12 more)
|
||||
|
||||
### Community 45 - "SDXL 3D Pose Editor DWPose Workflow"
|
||||
Cohesion: 0.19
|
||||
Nodes (20): CLIP Text Encode Negative Prompt Node, CLIP Text Encode Positive Prompt Node, Inpaint with Mask Editor One (Workflow Screenshot), Load Checkpoint Node, Mask Editor One Node, Save Image Node, VAE Encode (for Inpainting) Node, 3D Pose Editor Node (comfyui-vrm-pose-editor) (+12 more)
|
||||
|
||||
### Community 46 - "Gallery Service Core"
|
||||
Cohesion: 0.10
|
||||
Nodes (7): GalleryService, 許可するルートパスを更新する(Settings変更時に呼ぶ), ComfyUI実outputフォルダの不変ルートを設定する(起動時に一度だけ呼ぶ), フォルダ内の未キャッシュ画像をバックグラウンドでインデックスする。 フォルダロード時に呼び出す。前回のインデックス処理は自動キャンセル。, outputフォルダのフォルダツリーを返す, ImagePromptギャラリーのルートフォルダ(ComfyUI実output/ws_image_prompt)を 返す。無ければ作成する。, Style Catalogギャラリーのルートフォルダ(ComfyUI実output/ws_style_catalog)を 返す。無ければ作成する。
|
||||
|
||||
### Community 47 - "Image Edit Tab & Mask Editor One"
|
||||
Cohesion: 0.13
|
||||
Nodes (20): Image Edit Tab, Mask Editor One Integration, Mask slot for inpainting (v0.3.71), G'MIC-Qt Standalone, Help Tab, ABR Brush Library (Mask Editor One), Image Edit BG Remove Tool, Image Edit Blur Tool (Whole/Rect Blur & Mosaic) (+12 more)
|
||||
|
||||
### Community 48 - "App Shell & Nodes Tab Ecosystem"
|
||||
Cohesion: 0.13
|
||||
Nodes (19): ComfyUI-Workflow-Studio README, Canvas Snapshot Feature, ComfyUI-Custom-Scripts (external), Node Sets, web/comfyui/node_sets_menu.js, py/routes/nodes_routes.py, py/services/nodes_service.py, Nodes Tab (+11 more)
|
||||
|
||||
### Community 49 - "Node Sets Menu Model Fetch"
|
||||
Cohesion: 0.22
|
||||
Nodes (19): add(), fetchWorkflowRaw(), getCanvasDropPos(), handleInfoFile(), installCanvasDropHandler(), loadWorkflowOnCanvas(), placeClipTextEncodeNode(), placeLoraMgrNode() (+11 more)
|
||||
|
||||
### Community 50 - "Image Edit Background Removal"
|
||||
Cohesion: 0.18
|
||||
Nodes (7): comfyUI, comfyEditor, comfyWorkflow, MaskEditorOneBridge, fitToCanvas(), TOOL_DEFS, TEXT_FONTS
|
||||
|
||||
### Community 51 - "ComfyUI Workflow Utilities"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): _CONTROL_AFTER_GENERATE, _findInjectedWidgetIndex(), _flattenSubgraphs(), _getDynamicComboSubNames(), _getWidgetInputNames(), _getWidgetInputTypes(), _isExtraWidgetValue(), _isLinkedWidgetName() (+5 more)
|
||||
|
||||
### Community 52 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.20
|
||||
Nodes (18): _buildSimpleGroupList(), _getGroupSelCount(), _getGroupSelCountFrom(), initBatchTab(), _loadBatchCheckpointGroups(), _loadBatchLoraGroups(), _loadPromptGroupsForBatch(), _loadWorkflowGroupsForBatch() (+10 more)
|
||||
|
||||
### Community 53 - "Batch Group UI & Security Fixes"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): GenerateUI Batch 4-Type Group Selection (unreleased), Batch Queue Sampler/Scheduler Columns, LoraManager Data Format (__value__ / text), LORA SYNTAX innerHTML Display Bug, Models Batch Group Registration UI (unreleased), Prompt/Workflow Batch Group Registration UI (unreleased), RESERVED_GROUPS Mechanism, SSL CERT_NONE MitM Vulnerability (+9 more)
|
||||
|
||||
### Community 54 - "DEVLOG Early History & Manager Registration"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): comfyui-image-feeder (external node), __Feeder__ reserved group, Feeder Subtab, static/js/feeder-tab.js, web/comfyui/gallery_feeder_extension.js, py/nodes/gallery_feeder_node.py, Gallery mode (Feeder), Help & Support Tab (+9 more)
|
||||
|
||||
### Community 55 - "App Shell & ComfyUI Ecosystem Refs"
|
||||
Cohesion: 0.12
|
||||
Nodes (16): static/js/app.js, ComfyUI, static/js/comfyui-client.js, ComfyUI-Lora-Manager (external), ComfyUI Manager, ComfyUI-Workflow-Studio, py/config.py, data/ (fallback data dir) (+8 more)
|
||||
|
||||
### Community 56 - "GenerateUI Batch & Workflow Editor Core"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): Batch tab (v0.3.18), Bypass/Mute node handling (v0.3.75), static/js/comfyui-editor.js, Create Catalog / Catalog buttons (v0.3.95), Eagle, py/routes/eagle_routes.py, Emphasis weight editing (v0.3.70), Fooocus (external, by lllyasviel) (+8 more)
|
||||
|
||||
### Community 59 - "Tagger Tab & Gallery Bulk Actions"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): prestartup_script.py Early Env Setup, Tagger Tab, static/js/tagger-tab.js, Unsloth Backend (AI TOOL Tab), Tagger Tab requirements.txt, onnxruntime (WD Tagger / SwinV2), piexif (JPEG EXIF tag writing), python-dotenv (Unsloth .env loading) (+7 more)
|
||||
|
||||
### Community 60 - "Image Edit Layer Core & Bug Fixes"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): async Event Handler e.currentTarget Null-After-Await Pattern, Draw/Mask Layer Disappearing Bug Fix, G'MIC-Qt Filter Tool Integration, Image Edit Layer Lock Feature, LayerManager.js (Layer Compositing Core), Image Edit Save to Gallery Feature, MaskTool.js, Models Tab Overlay Button Not-Updating Bug Fix (+7 more)
|
||||
|
||||
### Community 61 - "G'MIC Routes"
|
||||
Cohesion: 0.21
|
||||
Nodes (14): _gmic_run_gui(), handle_open(), handle_result(), handle_status(), Application, Request, Response, G'MIC-Qt integration API routes. (+6 more)
|
||||
|
||||
### Community 62 - "Ollama Routes"
|
||||
Cohesion: 0.23
|
||||
Nodes (14): _get_ollama_config(), handle_chat(), handle_models(), handle_test(), Application, Request, Response, Ollama API proxy routes. (+6 more)
|
||||
|
||||
### Community 63 - "Gallery Metadata/Service Bridge"
|
||||
Cohesion: 0.15
|
||||
Nodes (11): Gallery Metadata Store - ギャラリー画像のメタデータ永続化, _clean_vault_tags(), _decode_image_data_url(), _parse_vault_yaml_leaves(), Gallery Service - outputフォルダの画像管理、メタデータ閲覧, data URL (data:<mime>;base64,<data>) をデコードし (バイト列, 拡張子) を返す。 ヘッダが無ければPNG扱い(旧互換)。, OSで使えない文字を除去し、拡張子が無ければ付与する, 任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。 Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。 (+3 more)
|
||||
|
||||
### Community 65 - "Mask Editor One BiRefNet/SAM3 Integration"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): store_image API bg_image_b64 Field Rename Compatibility Fix, BiRefNet BG Remove (Mask Editor One Integration), Mask Editor One (comfyui-mask-editor-one Custom Node), Mask Layer Add/Subtract Composite Mode (toggleOperation), MaskTool.js ABR Brush Support (spacing/angle/jitter), Plan: Image Edit Extension — ABR Brush + Mask Editor One Integration, SAM3 Text-Prompt Segmentation (Mask Editor One Integration), Top Bar Icon Disappearance Fix (MutationObserver, top_menu_extension.js) (+6 more)
|
||||
|
||||
### Community 66 - "Eagle Routes Backend"
|
||||
Cohesion: 0.23
|
||||
Nodes (11): Request, Response, Serve the main SPA page., Serve files from the current workflows directory (dynamic path)., Serve files (index thumbnails) from the Lab plan directory., Main entry point for Workflow Studio plugin., Register all routes with ComfyUI's server., serve_index_page() (+3 more)
|
||||
|
||||
### Community 67 - "Prompts API Routes/Service"
|
||||
Cohesion: 0.25
|
||||
Nodes (13): handle_create(), handle_delete(), handle_list(), handle_update(), Application, Request, Response, Prompt presets API routes. (+5 more)
|
||||
|
||||
### Community 68 - "Workflow Analyzer"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): analyze_workflow(), _clip_type_from_ui_node(), _collect_all_ui_nodes(), _detect_model_type_from_name(), _model_name_from_api_node(), _model_name_from_ui_node(), Workflow analyzer - detects model types and counts input/output nodes., Analyze workflow JSON and return model types, input/output node counts. (+5 more)
|
||||
|
||||
### Community 69 - "Gallery Tab Core & Feeder Group"
|
||||
Cohesion: 0.15
|
||||
Nodes (14): ComfyUI-Gallery (external, by PanicTitan), Folder tree root label (v0.3.90), py/services/gallery_metadata.py, py/routes/gallery_routes.py, py/services/gallery_service.py, Gallery Tab, static/js/gallery-tab.js, static/js/image-prompt-tab.js (+6 more)
|
||||
|
||||
### Community 70 - "AI TOOL Backend & Model Tab Updates"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): AI_BACKEND_DEFAULT_URLS / getAiBackendDefaultUrl() Helper (util.js), GenerateUI Model Tab Active Highlight Color Feature, ponyxlWildcardsVault Format Live (Un-imported) Support, reasoning_content Field Handling Bug Fix (Unsloth), SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist), AI TOOL Thinking Mode / Max Tokens Setting, Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention), Unsloth Server-Side Proxy (unsloth_routes.py) (+5 more)
|
||||
|
||||
### Community 71 - "Tagger Tab Implementation & Security Fixes"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): Gallery Detail Panel UI Improvement (Tab/Action Row Split), Gallery Tag Save Bug Fix (tags.forEach not a function), Path Traversal Security Fix (Tagger + Gallery), Security Fix: XSS Escaping + Path Validation (2026-06-20), Tagger Batch .txt Output Feature, Tagger Single GenUI:P Button, TaggerService / TaggerDbService (Backend), Tagger Tab New Implementation (+5 more)
|
||||
|
||||
### Community 72 - "Eagle Routes Backend"
|
||||
Cohesion: 0.19
|
||||
Nodes (12): _get_api_key(), handle_proxy(), _is_allowed_base_url(), Application, Request, Response, Unsloth API proxy routes. Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop…, Return the Unsloth API key from the environment (.env), or None. (+4 more)
|
||||
|
||||
### Community 73 - "Prompts API Routes/Service"
|
||||
Cohesion: 0.28
|
||||
Nodes (3): PromptsService, Prompt presets management service., Manages prompt presets (CRUD, categories, favorites).
|
||||
|
||||
### Community 74 - "Metadata & Nodes Tab Core"
|
||||
Cohesion: 0.17
|
||||
Nodes (13): CLIPTextEncodeEditPlus edit+ support (v0.3.92), static/js/comfyui-workflow.js, Metadata Tab, static/js/metadata-tab.js, py/services/png_extractor.py, UI-to-API conversion, py/services/workflow_analyzer.py, Gallery Bulk Actions (Group/Favorite/Compare/Move/Export/Delete) (+5 more)
|
||||
|
||||
### Community 75 - "AI Tool Tab Frontend"
|
||||
Cohesion: 0.26
|
||||
Nodes (12): _applyGenOptions(), callChat(), callLLM(), callVLM(), _clearToolsImage(), fileToBase64(), _formatMessagesForBackend(), initVlmTab() (+4 more)
|
||||
|
||||
### Community 76 - "Node Sets Menu & Sidebar"
|
||||
Cohesion: 0.22
|
||||
Nodes (13): applyTheme(), buildThemePanel(), createPanel(), fetchModelGroups(), fetchModelMetadata(), fetchPrompts(), fetchWorkflows(), injectStyles() (+5 more)
|
||||
|
||||
### Community 77 - "Gallery Feeder Extension (Frontend)"
|
||||
Cohesion: 0.36
|
||||
Nodes (11): beforeRegisterNodeDef(), fetchGroupImages(), getInputWidget(), getOrCreate(), _ns, setWidgetValue(), startLoop(), stopLoop() (+3 more)
|
||||
|
||||
### Community 78 - "Gallery Speed & Bulk Features"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Background Daemon Thread Indexing Pattern (asyncio.to_thread + Cancelable Scan), Select All Button + Gallery Bulk Bar i18n, Bulk API Endpoint Pattern (Single Request vs N Parallel), v0.3.44 Gallery Image Compare Mode, v0.3.44 Gallery Prompt Full-Text Search, v0.3.44 Gallery Speedup (Server Thumbnails + Infinite Scroll), v0.3.45 Gallery Shift+Click Range Selection, v0.3.48 Gallery Image Download / Bulk ZIP Export (+2 more)
|
||||
|
||||
### Community 79 - "Style Catalog & Batch Loop Core"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Catalog Creation "Don't keep copy in Output" Option, Style/Prompt to ImagePrompt Full Rename, Prompt Tab Style Management Sub-tab (CRUD), Reuse Existing Batch/Selection UI Before Building New, _runBatchLoop() Generic Batch Loop, Backend save_image_to_folder() Generalization, Style Catalog Gallery Sub-tab, trackProgress() Timeout & execution_interrupted Handling Fix (+2 more)
|
||||
|
||||
### Community 80 - "Metadata Tab Node Support History"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): CLIPLoader Widget Name Bug (clip_name1 to clip_name), model-and-prompt-from-metadata Custom Node Pack, Top Bar Icon Not Showing Bug, v0.3.10: Information Tab Canvas Drag Support, v0.3.11: Metadata Tab Node Type Expansion + Settings Text Size, v0.3.27: model-and-prompt-from-metadata Node Support, v0.3.6: Metadata Tab Addition, v0.3.7: Metadata Tab Flux2/Qwen/Z-Image Support (+2 more)
|
||||
|
||||
### Community 82 - "Gallery Feeder Node (Backend)"
|
||||
Cohesion: 0.27
|
||||
Nodes (4): Path, WFS_GalleryFeeder – Feeds images from a gallery group into a workflow., Gallery グループ内の画像をインデックス順に1枚ずつ出力するノード。, WFS_GalleryFeeder
|
||||
|
||||
### Community 83 - "App Shell & i18n Init"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): applyI18nToHtml(), initModal(), _onHelpSearch(), initI18n(), applyJsonColors(), applyModelTabActiveColor(), applyTextareaFontSize()
|
||||
|
||||
### Community 84 - "Generate Tab Frontend & Modals"
|
||||
Cohesion: 0.24
|
||||
Nodes (6): initGenerateTab(), updateStatus(), highlightJSON(), syncJsonHighlight(), syncScroll(), refreshLabLiveDefaults()
|
||||
|
||||
### Community 87 - "Node Sets Metadata Extraction"
|
||||
Cohesion: 0.36
|
||||
Nodes (10): _collectAllNodes(), _collectUnique(), fromWorkflow(), _extractCheckpoints(), _extractDiffusionModels(), _extractLoRAs(), _extractMarkdownNoteModels(), addU() (+2 more)
|
||||
|
||||
### Community 88 - "Node Sets Prompt Extraction"
|
||||
Cohesion: 0.33
|
||||
Nodes (10): _extractPrompts(), _extractPromptsAPI(), _extractPromptsFromNodeSet(), _extractPromptsLiteGraph(), _isPromptStylerNode(), _isSamplerNode(), _isTextEncoderNode(), _resolveEditPlusText() (+2 more)
|
||||
|
||||
### Community 89 - "Inpaint Basic Workflow"
|
||||
Cohesion: 0.53
|
||||
Nodes (9): CLIP Text Encode (Prompt) - negative conditioning, CLIP Text Encode (Prompt) - positive conditioning, KSampler node (ddim sampler, 30 steps, cfg 4.0, denoise 1.00), Load Checkpoint node (hayochamixIDXL_v11+_bl checkpoint), Load Image node (clipspace painted mask input), Save Image node (ComfyUI filename prefix), VAE Decode node, VAE Encode (for Inpainting) node with grow_mask_by (+1 more)
|
||||
|
||||
### Community 90 - "Gallery Feeder & Batch Features"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): __Feeder__ Reserved Group Protection + F Button + Seed Bug Fix, Feeder Tab Gallery Mode (vs Image Loop Mode), Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync), WFS_GalleryFeeder ComfyUI Canvas Controls Extension, WFS_GalleryFeeder Node (Gallery Feeder Feature), Models Tab Thumbnail Update Bug Fix (Cache/Corrupt-File/Disabled-Model), v0.3.28 Batch Tab Sampler/Scheduler Support, v0.3.56 Style Feature (GenerateUI Toolbar & Batch Tab) (+1 more)
|
||||
|
||||
### Community 91 - "Gallery Service Background Indexing"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): Event, バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。 10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。, PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す, PNGのtEXtチャンクからメタデータを抽出, JPEGのEXIF/commentからメタデータを抽出(簡易)
|
||||
|
||||
### Community 92 - "Settings/CivitAI Config Resolution"
|
||||
Cohesion: 0.28
|
||||
Nodes (4): Return CivitAI API key. Env var CIVITAI_API_KEY takes priority over…, Merge updates into existing settings., Manages application settings (data/settings.json)., SettingsService
|
||||
|
||||
### Community 93 - "Gallery Service Vault Helpers"
|
||||
Cohesion: 0.22
|
||||
Nodes (4): 画像のプロンプトテキストを返す。 1. 画像と同名の .txt サイドカーがあればそれを使う 2. 無ければ ponyxlWildcardsVault 形式…, image_path の祖先を辿り、直下に thumbnails(_option2) を持つフォルダ…, ponyxlWildcardsVault形式のフォールバック解決。 thumbnails_option2 の "{leaf}.preview3.ext"…, 画像と同名の.txtサイドカーにプロンプトテキストを保存する
|
||||
|
||||
### Community 94 - "GenerateUI Lab Tab"
|
||||
Cohesion: 0.36
|
||||
Nodes (9): _coreGenerate(), _expandWildcardsInWorkflow(), _annotatedImageRef(), _maybeSaveIndexImageOnRun(), _runLabBatch(), _setRunUiState(), _waitIfPaused(), getEagleSettings() (+1 more)
|
||||
|
||||
### Community 95 - "Style Prompt Seed Import Tool"
|
||||
Cohesion: 0.36
|
||||
Nodes (8): clean_tags(), import_category(), main(), parse_yaml_leaves(), Path, Style/Prompt ギャラリー用シードデータ投入スクリプト。 comfyui_prompt_gallery / prompt_builder_proto…, comfyui_prompt_gallery の parseYamlForImages と同じ簡易パーサ(PyYAML不要)。 「key:」行の直後が「-…, 1カテゴリ分を投入する。戻り値: (copied, images_without_prompt, leaves_total)
|
||||
|
||||
### Community 96 - "FireRed 3D Pose Image Edit Workflow"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): 3D Pose Editor node, FireRed-Image-Edit-1.1_fp8mixed_comfy.safetensors, FireRed-Image-Edit-1.0-Lightning-8steps-v1.0.safetensors (LoRA), Image Edit (FireRed Image Edit 1.1) node, qwen_2.5_vl_7b_fp8_scaled.safetensors (CLIP), qwen_image_vae.safetensors (VAE), Save Image node, 3D Pose Editor to Image Edit (Fire Red) Workflow
|
||||
|
||||
### Community 97 - "Chat Image Generation & Inpaint Bridge"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): comfyEditor.applyImageToSlot() Method, "Use Dedicated Workflow" Toggle Pattern (Inpaint/Chat Image Gen Shared Design), generate_image Tool Call (LLM Tool Calling to ComfyUI Generation), tagger_settings.json Missing from Data Export Bug Fix, v0.3.62 Send GenUI Image Button + tagger_settings.json Export Fix, v0.3.71 Inpaint Feature (GenerateUI Image Tab + Image Edit Inpaint Tool), v0.3.73 AI TOOL Chat Tool Calling Image Generation + Checkpoint Safety, v0.3.74 AI TOOL Chat Image Attachment + I2I Bridge
|
||||
|
||||
### Community 98 - "Subgraph Workflow Support & Fixes"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): detectFormat() App vs UI Judgment Criteria Fix (linearMode), DynamicCombo (COMFY_DYNAMICCOMBO_V3) Flat Dot-Key Input Fix, _flattenSubgraphs() Outer Widget Value Injection Into Inner Nodes, Subgraph Fan-out Fix: inputPortTargets Array + _wfmLiteralInputs, v0.3.78 GenerateUI/Metadata Tab Subgraph Workflow Support (Mage-Flow), v0.3.84 Ernie T2I "source" Validation Error / Subgraph Fan-out Bug, workflow_analyzer.py P:/I:/→ Count and Format Badge Fix, Workflow Tab Summarize: Structured Data Instead of Raw JSON Truncation
|
||||
|
||||
### Community 99 - "KREA-2 & Lab Tab Extensions"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): DynamicCombo Multi-Slot Consumption Bug (sampling_mode), GenerateUI Input Image Placeholder Feature (Color/Image mode), KREA-2 Turbo Workflow Support, Lab Tab Bypass Checkbox (Per-Keyframe Row Disable), Lab Tab Clear / Get from Previous Buttons, Lab Tab Keyframe #1 Live Default Reflection, Role Propagation Bug: PreviewAny / StringConcatenate Relay Nodes, v0.3.97 KREA-2 Workflow Support + Lab Tab Extension + Input Image Placeholder
|
||||
|
||||
### Community 100 - "LoRA Pane Evolution & Help Tab"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): GenerateUI Reset Workflow Button + Input Tab Tabify (2026-06-08), Help Tab Search Feature + Font Enlargement + Gallery-mode i18n, Help Tab Sidebar Layout (2-Column), v0.3.29 LoRA Section Integration (Single+Stack Merge), v0.3.30 LoRA Stack Trigger Words Bug Fix, v0.3.31 LoRA Pane Single/Stack Tab Split, v0.3.32 Lora Loader (LoraManager) LoRA Detection Support, v0.3.33 LoRA Pane / Workflow Parse Bug Fixes
|
||||
|
||||
### Community 103 - "App Shell & i18n Init"
|
||||
Cohesion: 0.43
|
||||
Nodes (7): AI_BACKEND_DEFAULT_URLS, _bytesToDataUrl(), _dataUrlToBytes(), embedPngTextChunk(), _joinBytes(), _n2b(), _pngCrc32()
|
||||
|
||||
### Community 104 - "DWPose Image Feeder Workflow"
|
||||
Cohesion: 0.52
|
||||
Nodes (7): DWPose Image Feeder Workflow, Apply ControlNet Node, DWPose Estimator Node (comfyui-controlnet-aux), Image Feeder Node (comfyui-image-feeder), KSampler Node, Load Checkpoint Node (animagineXLRealistic_v6), Load ControlNet Model Node (OpenPoseXL2)
|
||||
|
||||
### Community 105 - "AI Tab & RAW JSON Search History"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): AI TOOL Tab English Localization, RAW JSON Panel Search Feature, v0.3.12: README Screenshot Refresh + workflow_analyzer Extension, v0.3.13: AI Tab (A) Addition, v0.3.14: AI TOOL Tab 3-Pane Redesign + RAW JSON Colors, v0.3.15: Sample Workflow Bundling + CivitAI Auto Preview, v0.3.16: CivitAI Preview Fallback Display
|
||||
|
||||
### Community 106 - "Prompt Extraction Bug Fixes"
|
||||
Cohesion: 0.33
|
||||
Nodes (7): ComfySwitchNode Branch Resolution (Prompt Enhancement Toggle), GenerateUI vs Metadata Tab: Separate Non-Synced Analysis Logic, node_sets_menu.js Local Duplication Pattern (web/comfyui/ import restriction), v0.3.79 Side Panel I-Tab Mage-Flow Subgraph Support, v0.3.82 Metadata Tab / Side Panel I-Tab Prompt Extraction Bug (LongCat/Boogu/HiDream E1/FireRed), v0.3.83 Ernie Image Support + Link-Sourced Prompt Pattern Fix, WFS_PromptText Node GenerateUI Prompt Tab Support + Drop Title Fix
|
||||
|
||||
### Community 107 - "Comic Creator Bridge & LoRA Batch Fixes"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): Development Repo vs Runtime custom_nodes Folder Sync Reminder, _wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS), LoRA Batch Path Separator Mismatch Fix + resolveLoraName(), LoRA Batch Execution Prompt Auto-Reflection + Restore, Prompt Weight Ctrl+Up/Down Editing (A1111-style), v0.3.69 Gallery to Comic Creator "Send CC" Button, v0.3.70 Ollama 404 Fix + Prompt Revert Button + Weight Ctrl+Arrow Editing
|
||||
|
||||
### Community 108 - "Settings/CivitAI Config Resolution"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): _get_ssl_context(), _make_ssl_context(), CivitAI API integration service., Return an SSL context with CA verification. 1. certifi CA bundle (available in…, Download an image from URL and save to save_path. Returns True on success.
|
||||
|
||||
### Community 109 - "Node Sets Menu Model Fetch"
|
||||
Cohesion: 0.57
|
||||
Nodes (7): createModelItem(), fetchModelList(), matchesModelSearch(), renderModelAll(), renderModelByType(), renderModelFavorites(), renderModelGroups()
|
||||
|
||||
### Community 110 - "G'MIC Integration & Security Fixes"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): Draw/Mask Brush Circular Cursor + Off-canvas Drag Support, gmic_qt.exe Argument Order Fix (-o output input), handle_result Path Traversal Fix (gmic/result endpoint), gmic_routes.py (New Backend for G'MIC-Qt Job Handling), v0.3.60 Image Edit Filter Tool (G'MIC-Qt Integration), v0.3.61 G'MIC Bug Fix + Security + Draw/Mask Brush Operability
|
||||
|
||||
### Community 111 - "Comic Creator Integration Bridges"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): ComfyUI Comic Creator (External iframe-embedded Custom Node), _runInpaintWithImages() / runInpaintExternal() Shared Inpaint Logic, v0.3.72 Comic Creator External Inpaint Entry Point, v0.3.76 Comic Creator I2I Run Bridge (_wfmReceiveI2IRunRequest), v0.3.80 Comic Creator Semi-auto Manga Bridge (LLM Prompt Draft + Batch Gen), v0.3.85 Generate UI Bridge (T2I) Default Workflow + Negative Prompt
|
||||
|
||||
### Community 112 - "GenerateUI Model Tab GGUF/Embedding Support"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): GenUI Model LoRA Reset-on-Subtab-Switch Bug Fix, v0.3.51 Embedding GenUI PP/NP Support, v0.3.51 GenerateUI Hypernetwork Support, v0.3.51 Models Tab Side Panel GenUI Model Button, Hypernetwork/Embedding Not-Displayed Bug (V3 object_info Format), v0.3.58 GenerateUI GGUF Diffusion Model / Text Encoder Support
|
||||
|
||||
### Community 113 - "Bypass Node Handling & Advanced Sampling"
|
||||
Cohesion: 0.40
|
||||
Nodes (6): GenerateUI Raw JSON Bypass/Mute Node Indicator, Reroute Node missing_node_type 400 Error Fix (_resolveBypassSource extension), _resolveBypassSource() Bypass Pass-through Link Resolution, _simulateWidgetValues / _stripLegacyLinkedWidgetValues Common Logic Rewrite, v0.3.75 Bypass (mode:4) Node Workflow Generation Failure Fix, v0.3.81 Flux.2 Klein / LongCat / Boogu / HiDream E1 / FireRed Advanced Sampling Support
|
||||
|
||||
### Community 114 - "Prompt Text Node"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): WFS_PromptText – Prompt preset node with positive/negative STRING outputs., Outputs positive and negative prompt strings., WFS_PromptText
|
||||
|
||||
### Community 116 - "Gallery Service Vault Helpers"
|
||||
Cohesion: 0.33
|
||||
Nodes (3): os.scandir() で画像ファイルを1回のシステムコールで列挙。 キャッシュがあればそれを返す。 Returns: [(name,…, os.walk() でフォルダ配下を再帰的に列挙する(サブフォルダの画像も含む)。 ImagePrompt/Style…, 指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)
|
||||
|
||||
### Community 117 - "PNG Metadata Extractor"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): extract_png_workflow(), Extract ComfyUI workflow JSON from PNG metadata., Extract ComfyUI workflow JSON from PNG byte data. Returns dict or None., Workflow CRUD and metadata management service.
|
||||
|
||||
### Community 118 - "AI Skills & Help Redesign"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): AI Skills Mechanism (Selectable System Prompt Library), Help Tab Card-Format Redesign (16 Pages), Skill Creator (Interactive Skill Creation Meta-skill), SVG Icon Generator Skill (Shape Decomposition Prompting), v0.3.79 AI TOOL Chat Skills Mechanism + SVG Generation Quality Improvement
|
||||
|
||||
### Community 119 - "Prompt Extraction Bug Fixes"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Checkpoint Silent Substitution Bug (getLastCheckpointSubstitutions), CLIPTextEncodeEditPlus Metadata Extraction Gap, convertUiToApi forceInput Widget Misclassification (Root Cause), LoRA Stack: Use Stack Group Checkbox, v0.3.92 convertUiToApi forceInput Bug + LoRA Stack Improvements
|
||||
|
||||
### Community 120 - "AI TOOL Chat & Wildcard Features"
|
||||
Cohesion: 0.40
|
||||
Nodes (5): v0.3.41 ImpactWildcardEncode/Processor Prompt Detection Bug Fix, v0.3.40 AI TOOL Tab Chat Pane, v0.3.40 AI TOOL Wildcard Generation Feature, v0.3.46 Prompt Tab AI Assistant LM Studio Support, v0.3.49 A1111-style Wildcard Expansion in GenerateUI
|
||||
|
||||
### Community 121 - "Config & Service Modules"
|
||||
Cohesion: 0.40
|
||||
Nodes (3): Resolve workflows dir from settings, falling back to default., _resolve_workflows_dir(), Settings management service.
|
||||
|
||||
### Community 122 - "Group Feature Path Bug Fixes"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): Windows Path Separator Normalization Pattern, v0.3.45 Group Orphan Entry Auto Cleanup, v0.3.47 Models Tab File-Move Group State Fix, v0.3.55 Group Feature Bug Fixes (All Tabs)
|
||||
|
||||
### Community 123 - "SVG Generation & Display Support"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): AI TOOL Chat Workflow-less SVG Generation/Display/Gallery Save, v0.3.77 Gallery Tab SVG File Display/Management Support, v0.3.78 Eagle Auto-Save SVG Support
|
||||
|
||||
### Community 124 - "Send to Canvas Feature"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): API-Format Workflow Loads Empty on Canvas Bug Fix, Send to Canvas Feature (Workflow Tab / Gallery Tab Title DnD), Send to Canvas via window.opener Direct Load
|
||||
|
||||
### Community 125 - "Module Split Refactor (v0.4.0)"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): Callback Injection Pattern (One-way Dependency), Graphify Community Cohesion Analysis, v0.4.0 image-edit-tab.js / models-tab.js Low-Cohesion Module Split
|
||||
|
||||
### Community 126 - "CivitAI Detail Panel History"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): CivitAI modelUrl Broken Fallback Bug, v0.3.20: CivitAI Detail Panel Enhancement, v0.3.21: CivitAI Host Setting + Sample Subtab
|
||||
|
||||
### Community 127 - "GenerateUI Layout & JSON Highlight"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): JSONシンタックスハイライト, Raw JSON同期修正, v0.2.4 GenerateUIタブ レイアウト再設計
|
||||
|
||||
### Community 128 - "Workflow Tab Bug Fixes"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): v0.3.23: Two Bug Fixes, Workflow Batch 404 Endpoint Bug, Workflow Rename Duplicate Listener Bug
|
||||
|
||||
## Ambiguous Edges - Review These
|
||||
- `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` → `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: references
|
||||
- `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` → `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: semantically_similar_to
|
||||
- `Models Tab Overlay Button Not-Updating Bug Fix` → `v0.3.54 Image Edit Shape Tool` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: conceptually_related_to
|
||||
- `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` → `v0.3.28 Batch Tab Sampler/Scheduler Support` [AMBIGUOUS]
|
||||
DEVLOG.md · relation: conceptually_related_to
|
||||
|
||||
## Knowledge Gaps
|
||||
- **311 isolated node(s):** `AI_BACKEND_DEFAULT_URLS`, `state`, `LANGUAGE_OPTIONS`, `LANGUAGES`, `SUMMARY_LANGUAGE_OPTIONS` (+306 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **52 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **What is the exact relationship between `_wfmReceiveImageForI2I I2I Receive Bridge (Comic Creator to WFS)` and `LoRA Batch Path Separator Mismatch Fix + resolveLoraName()`?**
|
||||
_Edge tagged AMBIGUOUS (relation: references) - confidence is low._
|
||||
- **What is the exact relationship between `SSRF Credential Leak Fix (Unsloth Proxy baseUrl allowlist)` and `Prompt Data as .txt Sidecar Files (Avoiding gallery_metadata.json Contention)`?**
|
||||
_Edge tagged AMBIGUOUS (relation: semantically_similar_to) - confidence is low._
|
||||
- **What is the exact relationship between `Models Tab Overlay Button Not-Updating Bug Fix` and `v0.3.54 Image Edit Shape Tool`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **What is the exact relationship between `Feeder Tab UI Improvements (Root Auto-Select / Preview Pane Move / RUN Preview Sync)` and `v0.3.28 Batch Tab Sampler/Scheduler Support`?**
|
||||
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||
- **Why does `showToast()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `Image Edit Layer & Draw Tools`, `GenerateUI Feeder Tab`, `App Shell & i18n Init`, `Nodes Tab Frontend`, `GenerateUI Lab Tab`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `Workflow Tab Frontend & Storage`, `Image Edit Layer Manager Core`, `Style Catalog Tab`, `Image Prompt Tab`, `Image Edit Background Removal`, `Image Edit Blur Tool (BlurTool.js)`, `AI Tool Tab Frontend`, `Image Edit Background Removal (BgRemove.js)`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Image Edit SAM3 Segmentation`, `GenerateUI Lab Tab`, `Image Edit File Export (FileExport.js)`, `Image Edit G'MIC Integration`?**
|
||||
_High betweenness centrality (0.042) - this node is a cross-community bridge._
|
||||
- **Why does `t()` connect `Tagger/Prompt i18n Helpers` to `Models Tab Frontend`, `Prompt Tab Frontend`, `Gallery Tab Frontend & Toasts`, `GenerateUI Feeder Tab`, `AI Tool Tab Frontend`, `App Shell & i18n Init`, `Image Prompt Tab`, `GenerateUI Lab Tab`, `Style Catalog Tab`, `Metadata Tab Frontend`, `AI Tool Tab Frontend`, `Generate Tab Frontend & Modals`, `App Shell & i18n Init`, `Generate Tab Frontend & Modals`, `Nodes Tab Frontend`, `Workflow Tab Frontend & Storage`, `ComfyUI Client & Editor`, `GenerateUI Lab Tab`?**
|
||||
_High betweenness centrality (0.023) - this node is a cross-community bridge._
|
||||
- **Why does `GalleryService` connect `Gallery Service Core` to `Gallery API Routes`, `Gallery Service Core`, `Gallery Metadata/Service Bridge`, `Gallery Service Vault Helpers`, `Gallery Service Background Indexing`, `Gallery Service Vault Helpers`, `Gallery Metadata Store`, `Gallery Metadata/Service Bridge`?**
|
||||
_High betweenness centrality (0.018) - this node is a cross-community bridge._
|
||||
File diff suppressed because one or more lines are too long
+91986
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,842 @@
|
||||
{
|
||||
"__init__.py": {
|
||||
"mtime": 1781939762.254596,
|
||||
"seen": 1787194338.8008406,
|
||||
"ast_hash": "72d9ef06026dff7be74da81ff98a306a",
|
||||
"semantic_hash": "72d9ef06026dff7be74da81ff98a306a"
|
||||
},
|
||||
"prestartup_script.py": {
|
||||
"mtime": 1787036267.332648,
|
||||
"seen": 1787194338.800846,
|
||||
"ast_hash": "7b5d482ef21a480d1b38356e8d82970b",
|
||||
"semantic_hash": "7b5d482ef21a480d1b38356e8d82970b"
|
||||
},
|
||||
"py/__init__.py": {
|
||||
"mtime": 1773797715.629029,
|
||||
"seen": 1787194338.8008478,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/config.py": {
|
||||
"mtime": 1786245317.3352194,
|
||||
"seen": 1787194338.800849,
|
||||
"ast_hash": "b013c27bb7cd9a79449961539fdcd30f",
|
||||
"semantic_hash": "b013c27bb7cd9a79449961539fdcd30f"
|
||||
},
|
||||
"py/nodes/__init__.py": {
|
||||
"mtime": 1774423045.6646452,
|
||||
"seen": 1787194338.8008502,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/nodes/gallery_feeder_node.py": {
|
||||
"mtime": 1781943269.5205867,
|
||||
"seen": 1787194338.8008513,
|
||||
"ast_hash": "d37c520b0f76e70e60ec7c72db904bf4",
|
||||
"semantic_hash": "d37c520b0f76e70e60ec7c72db904bf4"
|
||||
},
|
||||
"py/nodes/prompt_text.py": {
|
||||
"mtime": 1774423045.6669831,
|
||||
"seen": 1787194338.8008525,
|
||||
"ast_hash": "43cf16f182b9eda5fade2b52c2439372",
|
||||
"semantic_hash": "43cf16f182b9eda5fade2b52c2439372"
|
||||
},
|
||||
"py/routes/__init__.py": {
|
||||
"mtime": 1773797716.663036,
|
||||
"seen": 1787194338.8008535,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/routes/eagle_routes.py": {
|
||||
"mtime": 1785464911.9023821,
|
||||
"seen": 1787194338.800855,
|
||||
"ast_hash": "edd32039935a892317c55b434c9839ab",
|
||||
"semantic_hash": "edd32039935a892317c55b434c9839ab"
|
||||
},
|
||||
"py/routes/gallery_routes.py": {
|
||||
"mtime": 1786682156.6899943,
|
||||
"seen": 1787194338.800856,
|
||||
"ast_hash": "59e4c5d34ee2329ab9e0a66f841d1676",
|
||||
"semantic_hash": "59e4c5d34ee2329ab9e0a66f841d1676"
|
||||
},
|
||||
"py/routes/gmic_routes.py": {
|
||||
"mtime": 1782544784.03979,
|
||||
"seen": 1787194338.8008568,
|
||||
"ast_hash": "b909c65af6590c0ca058136cb0b9234e",
|
||||
"semantic_hash": "b909c65af6590c0ca058136cb0b9234e"
|
||||
},
|
||||
"py/routes/lab_routes.py": {
|
||||
"mtime": 1786322410.571655,
|
||||
"seen": 1787194338.8008578,
|
||||
"ast_hash": "48e94f71b11f2e494bb98bb7113e2baa",
|
||||
"semantic_hash": "48e94f71b11f2e494bb98bb7113e2baa"
|
||||
},
|
||||
"py/routes/models_routes.py": {
|
||||
"mtime": 1782289767.1073005,
|
||||
"seen": 1787194338.8008592,
|
||||
"ast_hash": "067aa9c89085fa3e077fdfacbb6be2cd",
|
||||
"semantic_hash": "067aa9c89085fa3e077fdfacbb6be2cd"
|
||||
},
|
||||
"py/routes/nodes_routes.py": {
|
||||
"mtime": 1774255599.0841086,
|
||||
"seen": 1787194338.8008602,
|
||||
"ast_hash": "dc5f7b12dce346631ff945c31bf03ac9",
|
||||
"semantic_hash": "dc5f7b12dce346631ff945c31bf03ac9"
|
||||
},
|
||||
"py/routes/ollama_routes.py": {
|
||||
"mtime": 1784126582.1613884,
|
||||
"seen": 1787194338.800861,
|
||||
"ast_hash": "80552c0676f7bbd4209eb95cf7a03f5b",
|
||||
"semantic_hash": "80552c0676f7bbd4209eb95cf7a03f5b"
|
||||
},
|
||||
"py/routes/prompts_routes.py": {
|
||||
"mtime": 1774423045.6696556,
|
||||
"seen": 1787194338.8008618,
|
||||
"ast_hash": "53b5641f3ad6f65e8ae665601c4739d7",
|
||||
"semantic_hash": "53b5641f3ad6f65e8ae665601c4739d7"
|
||||
},
|
||||
"py/routes/settings_routes.py": {
|
||||
"mtime": 1786681377.025748,
|
||||
"seen": 1787194338.8008628,
|
||||
"ast_hash": "bb2f332c04474ee39319c2d0b8107db9",
|
||||
"semantic_hash": "bb2f332c04474ee39319c2d0b8107db9"
|
||||
},
|
||||
"py/routes/skill_routes.py": {
|
||||
"mtime": 1785634791.033795,
|
||||
"seen": 1787194338.800864,
|
||||
"ast_hash": "3318c506163c720c5412ec0f8ff3a28a",
|
||||
"semantic_hash": "3318c506163c720c5412ec0f8ff3a28a"
|
||||
},
|
||||
"py/routes/tagger_routes.py": {
|
||||
"mtime": 1781503496.8879485,
|
||||
"seen": 1787194338.8008647,
|
||||
"ast_hash": "9c0559b594a69354882da0b1643e11f0",
|
||||
"semantic_hash": "9c0559b594a69354882da0b1643e11f0"
|
||||
},
|
||||
"py/routes/unsloth_routes.py": {
|
||||
"mtime": 1787036726.267709,
|
||||
"seen": 1787194338.8008657,
|
||||
"ast_hash": "93d4de97937f472f9618a35a0b4211d6",
|
||||
"semantic_hash": "93d4de97937f472f9618a35a0b4211d6"
|
||||
},
|
||||
"py/routes/wildcard_routes.py": {
|
||||
"mtime": 1777370036.4121041,
|
||||
"seen": 1787194338.8008666,
|
||||
"ast_hash": "1d0ea981a44ffab77d62c214951f4536",
|
||||
"semantic_hash": "1d0ea981a44ffab77d62c214951f4536"
|
||||
},
|
||||
"py/routes/workflow_routes.py": {
|
||||
"mtime": 1773896239.095392,
|
||||
"seen": 1787194338.8008673,
|
||||
"ast_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3",
|
||||
"semantic_hash": "e64f3e7b5d0dfe3543ffbfeb137aacb3"
|
||||
},
|
||||
"py/services/__init__.py": {
|
||||
"mtime": 1773797717.739297,
|
||||
"seen": 1787194338.800868,
|
||||
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"py/services/civitai_service.py": {
|
||||
"mtime": 1780233122.7587051,
|
||||
"seen": 1787194338.8008692,
|
||||
"ast_hash": "3b4a2a118bc56dd27e768a2819b401e7",
|
||||
"semantic_hash": "3b4a2a118bc56dd27e768a2819b401e7"
|
||||
},
|
||||
"py/services/gallery_metadata.py": {
|
||||
"mtime": 1786170828.9941547,
|
||||
"seen": 1787194338.80087,
|
||||
"ast_hash": "9d02a71d56b7ca2a88cdf8c56fadf912",
|
||||
"semantic_hash": "9d02a71d56b7ca2a88cdf8c56fadf912"
|
||||
},
|
||||
"py/services/gallery_service.py": {
|
||||
"mtime": 1786638406.9058151,
|
||||
"seen": 1787194338.800871,
|
||||
"ast_hash": "c8b9858f015f7f4a934c4b630e905107",
|
||||
"semantic_hash": "c8b9858f015f7f4a934c4b630e905107"
|
||||
},
|
||||
"py/services/lab_plan_service.py": {
|
||||
"mtime": 1786322396.0192728,
|
||||
"seen": 1787194338.8008716,
|
||||
"ast_hash": "5d2c928c077ad7b788f15419b39ef51f",
|
||||
"semantic_hash": "5d2c928c077ad7b788f15419b39ef51f"
|
||||
},
|
||||
"py/services/models_service.py": {
|
||||
"mtime": 1782393318.5989842,
|
||||
"seen": 1787194338.8008728,
|
||||
"ast_hash": "0885cc6852199fa45271192ea0d6713a",
|
||||
"semantic_hash": "0885cc6852199fa45271192ea0d6713a"
|
||||
},
|
||||
"py/services/nodes_service.py": {
|
||||
"mtime": 1774255599.0862236,
|
||||
"seen": 1787194338.8008735,
|
||||
"ast_hash": "8819590ffdc136fb2912d9c5696e26e0",
|
||||
"semantic_hash": "8819590ffdc136fb2912d9c5696e26e0"
|
||||
},
|
||||
"py/services/png_extractor.py": {
|
||||
"mtime": 1773797967.5810304,
|
||||
"seen": 1787194338.8008747,
|
||||
"ast_hash": "1282b6032d78cba2a4bbbfb876126bc1",
|
||||
"semantic_hash": "1282b6032d78cba2a4bbbfb876126bc1"
|
||||
},
|
||||
"py/services/prompts_service.py": {
|
||||
"mtime": 1774423045.6711988,
|
||||
"seen": 1787194338.8008757,
|
||||
"ast_hash": "79a98e220d2b556106e8530054547155",
|
||||
"semantic_hash": "79a98e220d2b556106e8530054547155"
|
||||
},
|
||||
"py/services/settings_service.py": {
|
||||
"mtime": 1773798498.9270506,
|
||||
"seen": 1787194338.8008769,
|
||||
"ast_hash": "dade5ea667a2c6408434ad3061c76db7",
|
||||
"semantic_hash": "dade5ea667a2c6408434ad3061c76db7"
|
||||
},
|
||||
"py/services/skill_service.py": {
|
||||
"mtime": 1785637306.8654232,
|
||||
"seen": 1787194338.8008778,
|
||||
"ast_hash": "5ebe579995c1c674e4219e58b3bd5855",
|
||||
"semantic_hash": "5ebe579995c1c674e4219e58b3bd5855"
|
||||
},
|
||||
"py/services/tagger_db_service.py": {
|
||||
"mtime": 1781503496.8949444,
|
||||
"seen": 1787194338.8008785,
|
||||
"ast_hash": "de0ca007db5159484e8ff3b8a36a3b84",
|
||||
"semantic_hash": "de0ca007db5159484e8ff3b8a36a3b84"
|
||||
},
|
||||
"py/services/tagger_service.py": {
|
||||
"mtime": 1781503496.8969479,
|
||||
"seen": 1787194338.8008797,
|
||||
"ast_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9",
|
||||
"semantic_hash": "7bfde3ebe93f6fa0062a6aecba3f10d9"
|
||||
},
|
||||
"py/services/wildcard_service.py": {
|
||||
"mtime": 1777380000.0054882,
|
||||
"seen": 1787194338.8008807,
|
||||
"ast_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd",
|
||||
"semantic_hash": "5814ed3cf2e31bf8b4abaa15c697b0cd"
|
||||
},
|
||||
"py/services/workflow_analyzer.py": {
|
||||
"mtime": 1785474766.4736228,
|
||||
"seen": 1787194338.8008814,
|
||||
"ast_hash": "937d6b02a6235795cb22ceb39e81f719",
|
||||
"semantic_hash": "937d6b02a6235795cb22ceb39e81f719"
|
||||
},
|
||||
"py/services/workflow_service.py": {
|
||||
"mtime": 1775439760.4805496,
|
||||
"seen": 1787194338.8008823,
|
||||
"ast_hash": "a2e47c0b8780ec7ca292310b16733a8c",
|
||||
"semantic_hash": "a2e47c0b8780ec7ca292310b16733a8c"
|
||||
},
|
||||
"py/wfm.py": {
|
||||
"mtime": 1787036294.0749958,
|
||||
"seen": 1787194338.8008835,
|
||||
"ast_hash": "cb1c96536f67edb380d1010b6165d74d",
|
||||
"semantic_hash": "cb1c96536f67edb380d1010b6165d74d"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"mtime": 1787193198.5117335,
|
||||
"seen": 1787194338.8008842,
|
||||
"ast_hash": "de0f37033a118144dc86368704dc7a34",
|
||||
"semantic_hash": "de0f37033a118144dc86368704dc7a34"
|
||||
},
|
||||
"static/js/ai-tab.js": {
|
||||
"mtime": 1787041206.037751,
|
||||
"seen": 1787194338.8008852,
|
||||
"ast_hash": "4103e9c646b3011bf5ce42d77487497c",
|
||||
"semantic_hash": "4103e9c646b3011bf5ce42d77487497c"
|
||||
},
|
||||
"static/js/app.js": {
|
||||
"mtime": 1787192745.6783228,
|
||||
"seen": 1787194338.8008862,
|
||||
"ast_hash": "b73d44820900bb125886ea51cf879475",
|
||||
"semantic_hash": "b73d44820900bb125886ea51cf879475"
|
||||
},
|
||||
"static/js/comfyui-client.js": {
|
||||
"mtime": 1786676208.2330563,
|
||||
"seen": 1787194338.8008873,
|
||||
"ast_hash": "bb546a6ae7bd35d1c16fee311681be73",
|
||||
"semantic_hash": "bb546a6ae7bd35d1c16fee311681be73"
|
||||
},
|
||||
"static/js/comfyui-editor.js": {
|
||||
"mtime": 1786975595.7088158,
|
||||
"seen": 1787194338.800888,
|
||||
"ast_hash": "2e378bbfd83ae94e3e068b48bec8676c",
|
||||
"semantic_hash": "2e378bbfd83ae94e3e068b48bec8676c"
|
||||
},
|
||||
"static/js/comfyui-workflow.js": {
|
||||
"mtime": 1786930771.5730827,
|
||||
"seen": 1787194338.8008888,
|
||||
"ast_hash": "c5f980df6dddaa9e5374d7ddc2643098",
|
||||
"semantic_hash": "c5f980df6dddaa9e5374d7ddc2643098"
|
||||
},
|
||||
"static/js/feeder-tab.js": {
|
||||
"mtime": 1781939989.0796032,
|
||||
"seen": 1787194338.80089,
|
||||
"ast_hash": "dcd5977c5fe691b87e2379cd0e018a12",
|
||||
"semantic_hash": "dcd5977c5fe691b87e2379cd0e018a12"
|
||||
},
|
||||
"static/js/gallery-tab.js": {
|
||||
"mtime": 1786638406.9234293,
|
||||
"seen": 1787194338.800891,
|
||||
"ast_hash": "a5469191f6f41638aa43decce8ee252c",
|
||||
"semantic_hash": "a5469191f6f41638aa43decce8ee252c"
|
||||
},
|
||||
"static/js/generate-tab.js": {
|
||||
"mtime": 1786932592.3725004,
|
||||
"seen": 1787194338.8008919,
|
||||
"ast_hash": "58f05ad49f87dfd8cff67290e739c3a8",
|
||||
"semantic_hash": "58f05ad49f87dfd8cff67290e739c3a8"
|
||||
},
|
||||
"static/js/i18n.js": {
|
||||
"mtime": 1787192763.662967,
|
||||
"seen": 1787194338.8008926,
|
||||
"ast_hash": "b626aca2093efe87d899d57bc02ce80d",
|
||||
"semantic_hash": "b626aca2093efe87d899d57bc02ce80d"
|
||||
},
|
||||
"static/js/image-edit-tab.js": {
|
||||
"mtime": 1787192013.6171837,
|
||||
"seen": 1787194338.8008935,
|
||||
"ast_hash": "bda888263a8f3aa325c0da5b688d75cb",
|
||||
"semantic_hash": "bda888263a8f3aa325c0da5b688d75cb"
|
||||
},
|
||||
"static/js/image-edit/DrawTool.js": {
|
||||
"mtime": 1782717315.6436062,
|
||||
"seen": 1787194338.8008964,
|
||||
"ast_hash": "a97a6a97ae28c804a7c5fa50448a2c87",
|
||||
"semantic_hash": "a97a6a97ae28c804a7c5fa50448a2c87"
|
||||
},
|
||||
"static/js/image-edit/LayerManager.js": {
|
||||
"mtime": 1782717943.772222,
|
||||
"seen": 1787194338.8009,
|
||||
"ast_hash": "596cd1906208fe72e1c2953d4d7f7135",
|
||||
"semantic_hash": "596cd1906208fe72e1c2953d4d7f7135"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneTools.js": {
|
||||
"mtime": 1782719665.7021136,
|
||||
"seen": 1787194338.8009021,
|
||||
"ast_hash": "b6b16c43ce528f7c9921eab0f4eda335",
|
||||
"semantic_hash": "b6b16c43ce528f7c9921eab0f4eda335"
|
||||
},
|
||||
"static/js/image-edit/MaskTool.js": {
|
||||
"mtime": 1782699721.4711535,
|
||||
"seen": 1787194338.8009028,
|
||||
"ast_hash": "116469f33829c5b2e6270f4b278b2b9c",
|
||||
"semantic_hash": "116469f33829c5b2e6270f4b278b2b9c"
|
||||
},
|
||||
"static/js/image-edit/SelectTool.js": {
|
||||
"mtime": 1782368193.2572634,
|
||||
"seen": 1787194338.800905,
|
||||
"ast_hash": "cb8db6bd5ee742b15f3beee4a5173377",
|
||||
"semantic_hash": "cb8db6bd5ee742b15f3beee4a5173377"
|
||||
},
|
||||
"static/js/image-edit/ShapeTool.js": {
|
||||
"mtime": 1782375200.0253434,
|
||||
"seen": 1787194338.800906,
|
||||
"ast_hash": "36d00bf6b80ebea13314d2853528c44f",
|
||||
"semantic_hash": "36d00bf6b80ebea13314d2853528c44f"
|
||||
},
|
||||
"static/js/image-edit/TextTool.js": {
|
||||
"mtime": 1782368193.2592669,
|
||||
"seen": 1787194338.800907,
|
||||
"ast_hash": "88f9e60251c2f78957e42c33ed5196f8",
|
||||
"semantic_hash": "88f9e60251c2f78957e42c33ed5196f8"
|
||||
},
|
||||
"static/js/image-prompt-tab.js": {
|
||||
"mtime": 1786638406.9362252,
|
||||
"seen": 1787194338.8009079,
|
||||
"ast_hash": "cf39285a30e3f8b79f6f51dfe13f86b5",
|
||||
"semantic_hash": "cf39285a30e3f8b79f6f51dfe13f86b5"
|
||||
},
|
||||
"static/js/json-highlight.js": {
|
||||
"mtime": 1773834925.1977189,
|
||||
"seen": 1787194338.8009088,
|
||||
"ast_hash": "e83e7a7a507d160b9cd503235a79135c",
|
||||
"semantic_hash": "e83e7a7a507d160b9cd503235a79135c"
|
||||
},
|
||||
"static/js/lab-tab.js": {
|
||||
"mtime": 1786933679.1300104,
|
||||
"seen": 1787194338.80091,
|
||||
"ast_hash": "f04c1d830856c01cc03945857d39bd9b",
|
||||
"semantic_hash": "f04c1d830856c01cc03945857d39bd9b"
|
||||
},
|
||||
"static/js/metadata-tab.js": {
|
||||
"mtime": 1786929497.4063816,
|
||||
"seen": 1787194338.800911,
|
||||
"ast_hash": "989f56e4b0eff94923fc73c75a6ce756",
|
||||
"semantic_hash": "989f56e4b0eff94923fc73c75a6ce756"
|
||||
},
|
||||
"static/js/models-tab.js": {
|
||||
"mtime": 1787145102.2314196,
|
||||
"seen": 1787194338.8009121,
|
||||
"ast_hash": "dcee8a68713cd300d0862b1c376d442f",
|
||||
"semantic_hash": "dcee8a68713cd300d0862b1c376d442f"
|
||||
},
|
||||
"static/js/nodes-tab.js": {
|
||||
"mtime": 1782619197.3828287,
|
||||
"seen": 1787194338.8009202,
|
||||
"ast_hash": "3467d4682a8c20a2b209b5db224ce6e2",
|
||||
"semantic_hash": "3467d4682a8c20a2b209b5db224ce6e2"
|
||||
},
|
||||
"static/js/prompt-tab.js": {
|
||||
"mtime": 1786681377.2302153,
|
||||
"seen": 1787194338.8009212,
|
||||
"ast_hash": "9c66fbb4c615e61c3b3ef1220ab20253",
|
||||
"semantic_hash": "9c66fbb4c615e61c3b3ef1220ab20253"
|
||||
},
|
||||
"static/js/settings-tab.js": {
|
||||
"mtime": 1786975595.7637193,
|
||||
"seen": 1787194338.8009222,
|
||||
"ast_hash": "b88c87057ac42e7cbca22cdf1dc522f7",
|
||||
"semantic_hash": "b88c87057ac42e7cbca22cdf1dc522f7"
|
||||
},
|
||||
"static/js/style-catalog-tab.js": {
|
||||
"mtime": 1786678976.6511285,
|
||||
"seen": 1787194338.8009229,
|
||||
"ast_hash": "a2956544bf800ac34f8084b8acd0714b",
|
||||
"semantic_hash": "a2956544bf800ac34f8084b8acd0714b"
|
||||
},
|
||||
"static/js/tagger-tab.js": {
|
||||
"mtime": 1781503496.933307,
|
||||
"seen": 1787194338.8009238,
|
||||
"ast_hash": "5e1c858a13e95148eeac1d5b5be5e4f3",
|
||||
"semantic_hash": "5e1c858a13e95148eeac1d5b5be5e4f3"
|
||||
},
|
||||
"static/js/util.js": {
|
||||
"mtime": 1787036300.7369778,
|
||||
"seen": 1787194338.8009248,
|
||||
"ast_hash": "29774ed440f2b60a708c70db1b3ae208",
|
||||
"semantic_hash": "29774ed440f2b60a708c70db1b3ae208"
|
||||
},
|
||||
"static/js/workflow-tab.js": {
|
||||
"mtime": 1785474064.011484,
|
||||
"seen": 1787194338.8009257,
|
||||
"ast_hash": "65caea21e8e02d81f2e85c75c7231dca",
|
||||
"semantic_hash": "65caea21e8e02d81f2e85c75c7231dca"
|
||||
},
|
||||
"tools/import_style_prompt_seed.py": {
|
||||
"mtime": 1786616195.405381,
|
||||
"seen": 1787194338.8009267,
|
||||
"ast_hash": "02997888c17a6061b7910cddfdd4d89a",
|
||||
"semantic_hash": "02997888c17a6061b7910cddfdd4d89a"
|
||||
},
|
||||
"web/comfyui/gallery_feeder_extension.js": {
|
||||
"mtime": 1781943835.232655,
|
||||
"seen": 1787194338.8009274,
|
||||
"ast_hash": "0048b031e64a433e29a5f7112e82012d",
|
||||
"semantic_hash": "0048b031e64a433e29a5f7112e82012d"
|
||||
},
|
||||
"web/comfyui/node_sets_menu.js": {
|
||||
"mtime": 1787192023.3791485,
|
||||
"seen": 1787194338.8009284,
|
||||
"ast_hash": "81c0d1363ee69d952ad16a294c6e8a8d",
|
||||
"semantic_hash": "81c0d1363ee69d952ad16a294c6e8a8d"
|
||||
},
|
||||
"web/comfyui/top_menu_extension.js": {
|
||||
"mtime": 1782696309.9055538,
|
||||
"seen": 1787194338.8009293,
|
||||
"ast_hash": "2c74498722b46d42bcc15f61ec35dfc4",
|
||||
"semantic_hash": "2c74498722b46d42bcc15f61ec35dfc4"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).json": {
|
||||
"mtime": 1779256606.5720088,
|
||||
"seen": 1787194338.8009303,
|
||||
"ast_hash": "52801ad2b42eca5e3a5f8fc51332d2c3",
|
||||
"semantic_hash": "52801ad2b42eca5e3a5f8fc51332d2c3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).json": {
|
||||
"mtime": 1779257129.3572881,
|
||||
"seen": 1787194338.8009312,
|
||||
"ast_hash": "8260ca467693c21c497f98113abf37f9",
|
||||
"semantic_hash": "8260ca467693c21c497f98113abf37f9"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).json": {
|
||||
"mtime": 1779257864.2496417,
|
||||
"seen": 1787194338.8009322,
|
||||
"ast_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e",
|
||||
"semantic_hash": "7eba746ab9fdca0aefabdc7c7c7bd13e"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.json": {
|
||||
"mtime": 1779255324.1402953,
|
||||
"seen": 1787194338.8009331,
|
||||
"ast_hash": "282ce14b11b8b88ddc91e1d318756c0d",
|
||||
"semantic_hash": "282ce14b11b8b88ddc91e1d318756c0d"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.json": {
|
||||
"mtime": 1784801838.7836998,
|
||||
"seen": 1787194338.8009338,
|
||||
"ast_hash": "43e83496dec4856ab9d97c8b950148ec",
|
||||
"semantic_hash": "43e83496dec4856ab9d97c8b950148ec"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.json": {
|
||||
"mtime": 1784801946.0336668,
|
||||
"seen": 1787194338.800935,
|
||||
"ast_hash": "ac0a865c1dacd2b30c489f08f8b4df4d",
|
||||
"semantic_hash": "ac0a865c1dacd2b30c489f08f8b4df4d"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.json": {
|
||||
"mtime": 1779253098.1947012,
|
||||
"seen": 1787194338.800936,
|
||||
"ast_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51",
|
||||
"semantic_hash": "cb1b5f84ac1b37af8715bf2ab0b38a51"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.json": {
|
||||
"mtime": 1779256158.4671156,
|
||||
"seen": 1787194338.800937,
|
||||
"ast_hash": "cfe237775d6fb844ac726639e3ae928b",
|
||||
"semantic_hash": "cfe237775d6fb844ac726639e3ae928b"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.json": {
|
||||
"mtime": 1779255768.6302803,
|
||||
"seen": 1787194338.800938,
|
||||
"ast_hash": "0e1955da91149ba6280d39db7a54db6f",
|
||||
"semantic_hash": "0e1955da91149ba6280d39db7a54db6f"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.json": {
|
||||
"mtime": 1779255037.8698418,
|
||||
"seen": 1787194338.800939,
|
||||
"ast_hash": "d670222d71df92e262ce8cb7299fd531",
|
||||
"semantic_hash": "d670222d71df92e262ce8cb7299fd531"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.json": {
|
||||
"mtime": 1779254360.9712114,
|
||||
"seen": 1787194338.8009398,
|
||||
"ast_hash": "463fca53dd0a4030555ff639661f2772",
|
||||
"semantic_hash": "463fca53dd0a4030555ff639661f2772"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.json": {
|
||||
"mtime": 1779253668.0067616,
|
||||
"seen": 1787194338.8009408,
|
||||
"ast_hash": "e2c677ee6670e37b466829c43ff9cd96",
|
||||
"semantic_hash": "e2c677ee6670e37b466829c43ff9cd96"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.json": {
|
||||
"mtime": 1779254714.0149894,
|
||||
"seen": 1787194338.8009415,
|
||||
"ast_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79",
|
||||
"semantic_hash": "8f14f2e0d1ebed40f95a8b728a5ecc79"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.json": {
|
||||
"mtime": 1779253180.3940487,
|
||||
"seen": 1787194338.8009427,
|
||||
"ast_hash": "beacbe857606b44d13aa2fc2f4872b76",
|
||||
"semantic_hash": "beacbe857606b44d13aa2fc2f4872b76"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.json": {
|
||||
"mtime": 1779253367.7975926,
|
||||
"seen": 1787194338.8009434,
|
||||
"ast_hash": "0706c31d93f980b581e0e6eb376c0bcc",
|
||||
"semantic_hash": "0706c31d93f980b581e0e6eb376c0bcc"
|
||||
},
|
||||
"templates/index.html": {
|
||||
"mtime": 1787192740.1538603,
|
||||
"seen": 1787194338.8009465,
|
||||
"ast_hash": "0b51d18f3fa91d4be929d72a499e669f",
|
||||
"semantic_hash": "0b51d18f3fa91d4be929d72a499e669f"
|
||||
},
|
||||
"assets/banner.svg": {
|
||||
"mtime": 1776081443.419675,
|
||||
"seen": 1787114579.030294,
|
||||
"ast_hash": "059ec317eaba9e353d220d883477927a",
|
||||
"semantic_hash": "059ec317eaba9e353d220d883477927a"
|
||||
},
|
||||
"assets/icon.svg": {
|
||||
"mtime": 1776081443.4219253,
|
||||
"seen": 1787114579.030295,
|
||||
"ast_hash": "9689aa0178236c19d5240250e08a303e",
|
||||
"semantic_hash": "9689aa0178236c19d5240250e08a303e"
|
||||
},
|
||||
"docs/10_GenUI_Batch.png": {
|
||||
"mtime": 1781057457.561953,
|
||||
"seen": 1787114579.0302958,
|
||||
"ast_hash": "351c1647aa5becfb4a2dbfa7da043b24",
|
||||
"semantic_hash": "351c1647aa5becfb4a2dbfa7da043b24"
|
||||
},
|
||||
"docs/11_multiple_select_menu.png": {
|
||||
"mtime": 1781057528.0140572,
|
||||
"seen": 1787114579.0302966,
|
||||
"ast_hash": "b3082324b8200a4eac8868f94ec4e6c7",
|
||||
"semantic_hash": "b3082324b8200a4eac8868f94ec4e6c7"
|
||||
},
|
||||
"docs/12_Tagger.png": {
|
||||
"mtime": 1782551031.7107866,
|
||||
"seen": 1787114579.030297,
|
||||
"ast_hash": "42806369c4c08140c87daea348b6fc0f",
|
||||
"semantic_hash": "42806369c4c08140c87daea348b6fc0f"
|
||||
},
|
||||
"docs/13_ImageEdit.png": {
|
||||
"mtime": 1782550946.6263509,
|
||||
"seen": 1787114579.030298,
|
||||
"ast_hash": "25a313806ba3c2396c9361b42599b063",
|
||||
"semantic_hash": "25a313806ba3c2396c9361b42599b063"
|
||||
},
|
||||
"docs/1_workflowtab.png": {
|
||||
"mtime": 1779078100.5980048,
|
||||
"seen": 1787114579.0302987,
|
||||
"ast_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297",
|
||||
"semantic_hash": "5aa53a79b8bd9e7b11a76f1ff89c9297"
|
||||
},
|
||||
"docs/2_modelstab.png": {
|
||||
"mtime": 1779077971.8302422,
|
||||
"seen": 1787114579.0302994,
|
||||
"ast_hash": "2c89e877f01e8de3151e655f305df6aa",
|
||||
"semantic_hash": "2c89e877f01e8de3151e655f305df6aa"
|
||||
},
|
||||
"docs/3_PromptInputAssistance.png": {
|
||||
"mtime": 1779077804.0445502,
|
||||
"seen": 1787114579.0303004,
|
||||
"ast_hash": "6634ec20d561648ce223807d2fb010cc",
|
||||
"semantic_hash": "6634ec20d561648ce223807d2fb010cc"
|
||||
},
|
||||
"docs/4_GenUI_feeder.png": {
|
||||
"mtime": 1779077729.1484556,
|
||||
"seen": 1787114579.0303009,
|
||||
"ast_hash": "e6b3607b7c53a2f601aff487c278a3fb",
|
||||
"semantic_hash": "e6b3607b7c53a2f601aff487c278a3fb"
|
||||
},
|
||||
"docs/5_topbar.png": {
|
||||
"mtime": 1779076905.6012611,
|
||||
"seen": 1787114579.0303018,
|
||||
"ast_hash": "bafc74e7452f83ae9c585aa11f76f208",
|
||||
"semantic_hash": "bafc74e7452f83ae9c585aa11f76f208"
|
||||
},
|
||||
"docs/6_ws_library.png": {
|
||||
"mtime": 1781057870.2308166,
|
||||
"seen": 1787114579.0303028,
|
||||
"ast_hash": "6645a6c3d9ea09d75d34255c76cfc68b",
|
||||
"semantic_hash": "6645a6c3d9ea09d75d34255c76cfc68b"
|
||||
},
|
||||
"docs/7_library_Infomation.png": {
|
||||
"mtime": 1779076410.2629254,
|
||||
"seen": 1787114579.0303032,
|
||||
"ast_hash": "acb54f299c244fd41457f8fe4ccfc1d6",
|
||||
"semantic_hash": "acb54f299c244fd41457f8fe4ccfc1d6"
|
||||
},
|
||||
"docs/8_Customize.png": {
|
||||
"mtime": 1779077388.8494852,
|
||||
"seen": 1787114579.030304,
|
||||
"ast_hash": "c9f979d40753ce41c015b2d594007658",
|
||||
"semantic_hash": "c9f979d40753ce41c015b2d594007658"
|
||||
},
|
||||
"docs/9_GenUI_LoraStack.png": {
|
||||
"mtime": 1781057821.3736062,
|
||||
"seen": 1787114579.030305,
|
||||
"ast_hash": "e2b08e5f2059554d305563c63286bd74",
|
||||
"semantic_hash": "e2b08e5f2059554d305563c63286bd74"
|
||||
},
|
||||
"docs/thumb.png": {
|
||||
"mtime": 1778457744.7955222,
|
||||
"seen": 1787114579.0303059,
|
||||
"ast_hash": "92a0dcf2c3457777f2945f6df2dff017",
|
||||
"semantic_hash": "92a0dcf2c3457777f2945f6df2dff017"
|
||||
},
|
||||
"static/favicon.svg": {
|
||||
"mtime": 1773877378.0368707,
|
||||
"seen": 1787114579.0303063,
|
||||
"ast_hash": "0cd547c25c922d632890be287d6084ff",
|
||||
"semantic_hash": "0cd547c25c922d632890be287d6084ff"
|
||||
},
|
||||
"workflows/ws_2d_pose_editor_to_Image_Edit( FireRed ).png": {
|
||||
"mtime": 1779256606.562932,
|
||||
"seen": 1787114579.0303073,
|
||||
"ast_hash": "79b9d1e5e1303b523349b539d3dc1af3",
|
||||
"semantic_hash": "79b9d1e5e1303b523349b539d3dc1af3"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Fire Red).png": {
|
||||
"mtime": 1779257129.3453796,
|
||||
"seen": 1787114579.0303097,
|
||||
"ast_hash": "02c572ab1c659d71dfd56e6b2b0334a5",
|
||||
"semantic_hash": "02c572ab1c659d71dfd56e6b2b0334a5"
|
||||
},
|
||||
"workflows/ws_3d_pose_editor_to_Image_Edit( Flux2 Klein ).png": {
|
||||
"mtime": 1779257864.2375357,
|
||||
"seen": 1787114579.0303104,
|
||||
"ast_hash": "bb03ed86c7d4460a3568a4a8f90e5f40",
|
||||
"semantic_hash": "bb03ed86c7d4460a3568a4a8f90e5f40"
|
||||
},
|
||||
"workflows/ws_dwpose_image_feeder.png": {
|
||||
"mtime": 1779255324.1356418,
|
||||
"seen": 1787114579.0303113,
|
||||
"ast_hash": "e5db6889f59078e4ead6b89b8c07e91e",
|
||||
"semantic_hash": "e5db6889f59078e4ead6b89b8c07e91e"
|
||||
},
|
||||
"workflows/ws_inpaint_basic.png": {
|
||||
"mtime": 1784801838.7777002,
|
||||
"seen": 1787114579.0303123,
|
||||
"ast_hash": "79321edb85aed7c0a8c777accc005486",
|
||||
"semantic_hash": "79321edb85aed7c0a8c777accc005486"
|
||||
},
|
||||
"workflows/ws_inpaint_mask_editor_one.png": {
|
||||
"mtime": 1784801946.027666,
|
||||
"seen": 1787114579.030313,
|
||||
"ast_hash": "bdd62facd6a8176c50e92264994dbbc2",
|
||||
"semantic_hash": "bdd62facd6a8176c50e92264994dbbc2"
|
||||
},
|
||||
"workflows/ws_sd15_t2i_basic.png": {
|
||||
"mtime": 1779253098.1897035,
|
||||
"seen": 1787114579.0303137,
|
||||
"ast_hash": "9582bb698439fff1a748315145538777",
|
||||
"semantic_hash": "9582bb698439fff1a748315145538777"
|
||||
},
|
||||
"workflows/ws_sdxl_2d_pose_editor_i2i.png": {
|
||||
"mtime": 1779256158.4621174,
|
||||
"seen": 1787114579.0303144,
|
||||
"ast_hash": "bb7b9e26eae992abe62cd50299da9cb2",
|
||||
"semantic_hash": "bb7b9e26eae992abe62cd50299da9cb2"
|
||||
},
|
||||
"workflows/ws_sdxl_3d pose editor_dwpose.png": {
|
||||
"mtime": 1779255768.6191878,
|
||||
"seen": 1787114579.0303156,
|
||||
"ast_hash": "ac0fc8184ff7e42389fd0d562daa88c4",
|
||||
"semantic_hash": "ac0fc8184ff7e42389fd0d562daa88c4"
|
||||
},
|
||||
"workflows/ws_sdxl_dwpose.png": {
|
||||
"mtime": 1779255037.8606594,
|
||||
"seen": 1787114579.0303164,
|
||||
"ast_hash": "564f58d85a0b960154bd0a30ea111599",
|
||||
"semantic_hash": "564f58d85a0b960154bd0a30ea111599"
|
||||
},
|
||||
"workflows/ws_sdxl_from_metadata_t2i.png": {
|
||||
"mtime": 1779254360.9671092,
|
||||
"seen": 1787114579.030317,
|
||||
"ast_hash": "98dfdb3ba374b0952c06995843f45171",
|
||||
"semantic_hash": "98dfdb3ba374b0952c06995843f45171"
|
||||
},
|
||||
"workflows/ws_sdxl_i2i_facedetailer.png": {
|
||||
"mtime": 1779253668.0007572,
|
||||
"seen": 1787114579.0303178,
|
||||
"ast_hash": "7285bb2a06aaf8dc7c5b37b84261be61",
|
||||
"semantic_hash": "7285bb2a06aaf8dc7c5b37b84261be61"
|
||||
},
|
||||
"workflows/ws_sdxl_image_feeder_i2i.png": {
|
||||
"mtime": 1779254714.0099926,
|
||||
"seen": 1787114579.0303185,
|
||||
"ast_hash": "13d4e7f87a87fd4c9a78427b38800cc6",
|
||||
"semantic_hash": "13d4e7f87a87fd4c9a78427b38800cc6"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_basic.png": {
|
||||
"mtime": 1779253180.3890488,
|
||||
"seen": 1787114579.0303195,
|
||||
"ast_hash": "9d8dda899602b8d74c6b23350e58a0d0",
|
||||
"semantic_hash": "9d8dda899602b8d74c6b23350e58a0d0"
|
||||
},
|
||||
"workflows/ws_sdxl_t2i_facedetaller.png": {
|
||||
"mtime": 1779253367.7925913,
|
||||
"seen": 1787114579.0303202,
|
||||
"ast_hash": "51cae742b39316023cf5332f1d794854",
|
||||
"semantic_hash": "51cae742b39316023cf5332f1d794854"
|
||||
},
|
||||
"static/js/image-edit/BgRemove.js": {
|
||||
"mtime": 1787126632.8601232,
|
||||
"seen": 1787194338.8008947,
|
||||
"ast_hash": "add6a58068bdadc0c7641202f36ddb27",
|
||||
"semantic_hash": "add6a58068bdadc0c7641202f36ddb27"
|
||||
},
|
||||
"static/js/image-edit/BlurTool.js": {
|
||||
"mtime": 1787126263.0542421,
|
||||
"seen": 1787194338.8008955,
|
||||
"ast_hash": "1e52bc3757110a271ec17d923784a91b",
|
||||
"semantic_hash": "1e52bc3757110a271ec17d923784a91b"
|
||||
},
|
||||
"static/js/image-edit/FileExport.js": {
|
||||
"mtime": 1787192008.741717,
|
||||
"seen": 1787194338.8008974,
|
||||
"ast_hash": "cf645b775e537ad276c9e070c7849349",
|
||||
"semantic_hash": "cf645b775e537ad276c9e070c7849349"
|
||||
},
|
||||
"static/js/image-edit/GmicIntegration.js": {
|
||||
"mtime": 1787125777.3889306,
|
||||
"seen": 1787194338.8008983,
|
||||
"ast_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca",
|
||||
"semantic_hash": "023d9d19e57d8fa6ebedc3c489ecf7ca"
|
||||
},
|
||||
"static/js/image-edit/InpaintI2IActions.js": {
|
||||
"mtime": 1787127273.4351766,
|
||||
"seen": 1787194338.800899,
|
||||
"ast_hash": "9ea57922985b63d4ec8eb44219a8341b",
|
||||
"semantic_hash": "9ea57922985b63d4ec8eb44219a8341b"
|
||||
},
|
||||
"static/js/image-edit/Sam3Segmentation.js": {
|
||||
"mtime": 1787126893.774897,
|
||||
"seen": 1787194338.800904,
|
||||
"ast_hash": "98c3a330ae7ea3369c00168c54c66d53",
|
||||
"semantic_hash": "98c3a330ae7ea3369c00168c54c66d53"
|
||||
},
|
||||
".github/workflows/publish.yml": {
|
||||
"mtime": 1778460176.5699222,
|
||||
"seen": 1787130571.383575,
|
||||
"ast_hash": "a83dab23e8d09fdc8626689e96dfeb0e",
|
||||
"semantic_hash": "a83dab23e8d09fdc8626689e96dfeb0e"
|
||||
},
|
||||
"CLAUDE.md": {
|
||||
"mtime": 1786327833.871171,
|
||||
"seen": 1787130571.3835762,
|
||||
"ast_hash": "c36546d2d007859555904d89e55a8ed7",
|
||||
"semantic_hash": "c36546d2d007859555904d89e55a8ed7"
|
||||
},
|
||||
"DEVLOG.md": {
|
||||
"mtime": 1787193273.171442,
|
||||
"seen": 1787194338.8009443,
|
||||
"ast_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4",
|
||||
"semantic_hash": "93e263cf3e9dd53c5d8a78dcbbda13f4"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1787193228.4769182,
|
||||
"seen": 1787194338.8009455,
|
||||
"ast_hash": "858c9d43205808ad6a111defeb66859c",
|
||||
"semantic_hash": "858c9d43205808ad6a111defeb66859c"
|
||||
},
|
||||
"requirements.txt": {
|
||||
"mtime": 1787036261.1817627,
|
||||
"seen": 1787130571.3835788,
|
||||
"ast_hash": "5942e1febf9611227a9145b3f9a91c6e",
|
||||
"semantic_hash": "5942e1febf9611227a9145b3f9a91c6e"
|
||||
},
|
||||
"static/js/models/badges.js": {
|
||||
"mtime": 1787143926.3653927,
|
||||
"seen": 1787194338.800913,
|
||||
"ast_hash": "0c1901a90a12f1b23df416d9eef11e1d",
|
||||
"semantic_hash": "0c1901a90a12f1b23df416d9eef11e1d"
|
||||
},
|
||||
"static/js/models/detail-panel.js": {
|
||||
"mtime": 1787144898.6748104,
|
||||
"seen": 1787194338.8009143,
|
||||
"ast_hash": "9907a4af0524b35d80e3b9fc7925324e",
|
||||
"semantic_hash": "9907a4af0524b35d80e3b9fc7925324e"
|
||||
},
|
||||
"static/js/models/filters.js": {
|
||||
"mtime": 1787144201.7602522,
|
||||
"seen": 1787194338.8009152,
|
||||
"ast_hash": "afd754f68d01f64c51995929b3841e56",
|
||||
"semantic_hash": "afd754f68d01f64c51995929b3841e56"
|
||||
},
|
||||
"static/js/models/grid-view.js": {
|
||||
"mtime": 1787144745.5522888,
|
||||
"seen": 1787194338.8009164,
|
||||
"ast_hash": "492e91fff2b8743a5243f85a736eb689",
|
||||
"semantic_hash": "492e91fff2b8743a5243f85a736eb689"
|
||||
},
|
||||
"static/js/models/helpers.js": {
|
||||
"mtime": 1787144193.741004,
|
||||
"seen": 1787194338.8009179,
|
||||
"ast_hash": "1c7b796f2507f55403fa5d11f304580a",
|
||||
"semantic_hash": "1c7b796f2507f55403fa5d11f304580a"
|
||||
},
|
||||
"static/js/models/selection-bulk.js": {
|
||||
"mtime": 1787144787.0092065,
|
||||
"seen": 1787194338.8009186,
|
||||
"ast_hash": "d445ff4dc4e9810afea73ff53c76b05b",
|
||||
"semantic_hash": "d445ff4dc4e9810afea73ff53c76b05b"
|
||||
},
|
||||
"static/js/models/state.js": {
|
||||
"mtime": 1787143674.705312,
|
||||
"seen": 1787194338.8009193,
|
||||
"ast_hash": "172ced5fdc4c96095f2760d7ef12d77d",
|
||||
"semantic_hash": "172ced5fdc4c96095f2760d7ef12d77d"
|
||||
},
|
||||
"static/js/image-edit/MaskEditorOneBridge.js": {
|
||||
"mtime": 1787187872.7481277,
|
||||
"seen": 1787194338.8009012,
|
||||
"ast_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a",
|
||||
"semantic_hash": "d1c9908f4802f28c4b5b9f267a7d6d6a"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Runs before ComfyUI imports any custom node — the only place early enough
|
||||
to populate os.environ for code that reads env vars at import/request time.
|
||||
|
||||
Loads a .env file (if present) from this plugin's root directory so the
|
||||
Unsloth backend's API key (UNSLOTH_API_KEY) can be set without editing
|
||||
settings.json or exposing it to the frontend.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
ENV_FILE = PLUGIN_DIR / ".env"
|
||||
|
||||
if ENV_FILE.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(ENV_FILE)
|
||||
except ImportError:
|
||||
print(
|
||||
"[WARNING] Workflow Studio: .env file found but python-dotenv is not installed "
|
||||
"(pip install -r requirements.txt). UNSLOTH_API_KEY will not be loaded from .env."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to load .env file: {e}")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unsloth API proxy routes.
|
||||
|
||||
Unlike Ollama/LM Studio/Lemonade, Unsloth Desktop requires an API key even
|
||||
for local access (Authorization: Bearer sk-unsloth-...). The key lives in
|
||||
UNSLOTH_API_KEY (loaded from a .env file by prestartup_script.py) and is
|
||||
never sent to the frontend — the frontend calls this proxy with the target
|
||||
path/method/payload, and the server attaches the Authorization header before
|
||||
relaying to Unsloth's OpenAI-compatible API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNSLOTH_DEFAULT_URL = "http://localhost:8888"
|
||||
_ALLOWED_PATHS = {"/v1/models", "/v1/chat/completions"}
|
||||
# baseUrl is client-supplied (so a custom Unsloth port works), but the
|
||||
# Authorization header carries a real secret — restrict the host to loopback
|
||||
# so this proxy can't be used to exfiltrate UNSLOTH_API_KEY to an arbitrary
|
||||
# server (SSRF). Port is unrestricted.
|
||||
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _get_api_key():
|
||||
"""Return the Unsloth API key from the environment (.env), or None."""
|
||||
return os.environ.get("UNSLOTH_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def _is_allowed_base_url(base_url):
|
||||
"""Only relay (and attach the API key) to a loopback host."""
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
return parsed.scheme in ("http", "https") and parsed.hostname in _ALLOWED_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def setup_routes(app: web.Application):
|
||||
"""Register Unsloth API routes."""
|
||||
app.router.add_post("/api/wfm/unsloth/proxy", handle_proxy)
|
||||
|
||||
|
||||
async def handle_proxy(request: web.Request) -> web.Response:
|
||||
"""POST /api/wfm/unsloth/proxy - Relay a request to Unsloth's OpenAI-compatible API.
|
||||
|
||||
Body: { baseUrl, path: "/v1/models" | "/v1/chat/completions", method: "GET" | "POST", payload }
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
body = await request.json()
|
||||
base_url = (body.get("baseUrl") or UNSLOTH_DEFAULT_URL).rstrip("/")
|
||||
path = body.get("path") or "/v1/models"
|
||||
method = (body.get("method") or "GET").upper()
|
||||
payload = body.get("payload")
|
||||
|
||||
if path not in _ALLOWED_PATHS or method not in ("GET", "POST"):
|
||||
return web.json_response({"message": "Unsupported proxy target"}, status=400)
|
||||
|
||||
if not _is_allowed_base_url(base_url):
|
||||
return web.json_response({
|
||||
"message": "Unsloth backend URL must point to localhost/127.0.0.1/::1",
|
||||
}, status=400)
|
||||
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
return web.json_response({
|
||||
"message": "UNSLOTH_API_KEY is not set. Copy .env.example to .env in the "
|
||||
"plugin folder, fill in the key, and restart ComfyUI.",
|
||||
}, status=401)
|
||||
|
||||
def _fetch():
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(f"{base_url}{path}", data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
return web.json_response(data)
|
||||
|
||||
except HTTPError as e:
|
||||
detail = e.read().decode("utf-8", errors="replace") if e.fp else str(e)
|
||||
logger.warning("Unsloth proxy HTTP error: %s %s", e.code, detail)
|
||||
return web.json_response({"message": f"Unsloth API error: HTTP {e.code}"}, status=e.code)
|
||||
except URLError as e:
|
||||
logger.warning("Unsloth proxy connection error: %s", e)
|
||||
return web.json_response({"message": f"Could not reach Unsloth: {e.reason}"}, status=502)
|
||||
except Exception as e:
|
||||
logger.error("Unsloth proxy error: %s", e)
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
@@ -80,11 +80,12 @@ class WorkflowStudio:
|
||||
app.router.add_get("/wfm", serve_index_page)
|
||||
|
||||
# API routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes
|
||||
from .routes import workflow_routes, settings_routes, ollama_routes, unsloth_routes, eagle_routes, nodes_routes, prompts_routes, models_routes, gallery_routes, wildcard_routes, tagger_routes, gmic_routes, skill_routes, lab_routes
|
||||
|
||||
workflow_routes.setup_routes(app)
|
||||
settings_routes.setup_routes(app)
|
||||
ollama_routes.setup_routes(app)
|
||||
unsloth_routes.setup_routes(app)
|
||||
eagle_routes.setup_routes(app)
|
||||
nodes_routes.setup_routes(app)
|
||||
prompts_routes.setup_routes(app)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-workflow-studio"
|
||||
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
|
||||
version = "0.3.97"
|
||||
version = "0.4.3"
|
||||
license = "MIT"
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -19,3 +19,6 @@ piexif>=1.1.3
|
||||
# Omit if you do not use DeepDanbooru models.
|
||||
# TensorFlow is large (~600MB) and conflicts with some CUDA setups.
|
||||
# tensorflow>=2.10
|
||||
|
||||
# -- Required to load UNSLOTH_API_KEY from a local .env file (Unsloth backend, AI TOOL tab) --
|
||||
python-dotenv>=1.0,<2.0
|
||||
|
||||
@@ -163,8 +163,10 @@
|
||||
.wfm-lab-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
row-gap: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -175,6 +177,39 @@
|
||||
|
||||
.wfm-lab-col-btns { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-col-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Model column header: C/L/V toggle that switches which single field its cells
|
||||
summarize (see _modelViewMode in lab-tab.js) — not part of the Plan data. */
|
||||
.wfm-lab-model-view-toggle { display: flex; gap: 2px; }
|
||||
|
||||
.wfm-lab-model-view-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: var(--wfm-bg-tertiary);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: var(--wfm-radius-sm);
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.wfm-lab-model-view-btn:hover { color: var(--wfm-text-primary); border-color: var(--wfm-primary); }
|
||||
|
||||
.wfm-lab-model-view-btn.active {
|
||||
color: #fff;
|
||||
background: var(--wfm-primary);
|
||||
border-color: var(--wfm-primary);
|
||||
}
|
||||
|
||||
.wfm-lab-col-cells {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
+164
-30
@@ -50,33 +50,102 @@ function fileToBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
// ============================================
|
||||
// Thinking mode / Max tokens (shared by callLLM / callChat / callVLM)
|
||||
// ============================================
|
||||
|
||||
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
|
||||
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
|
||||
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
|
||||
// <think>タグ除去でも担保する(stripThinkingTagsを参照)。
|
||||
function _applyGenOptions(body, backend, settings) {
|
||||
const maxTokens = parseInt(settings?.maxTokens, 10);
|
||||
if (backend === "ollama") {
|
||||
body.think = !!settings?.thinkingMode;
|
||||
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
|
||||
} else if (maxTokens > 0) {
|
||||
body.max_tokens = maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function stripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function callVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).response || "";
|
||||
const text = (await res.json()).response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await res.json()).choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Unsloth proxy (server relays the request, attaching the API key from .env
|
||||
// so it never reaches the frontend — see py/routes/unsloth_routes.py)
|
||||
// ============================================
|
||||
|
||||
async function unslothProxy(baseUrl, path, method, payload) {
|
||||
const res = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
|
||||
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
|
||||
// `reasoning_content` field. Fold it back into a <think> block so the
|
||||
// existing Thinking-mode show/strip logic (_applyGenOptions/stripThinkingTags)
|
||||
// still applies uniformly. Without this, a low max_tokens can make the model
|
||||
// spend its whole budget reasoning and return an empty `content` with no
|
||||
// visible explanation.
|
||||
function _unslothContent(message) {
|
||||
const reasoning = message?.reasoning_content;
|
||||
const content = message?.content || "";
|
||||
return reasoning ? `<think>${reasoning}</think>${content}` : content;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// URL validation
|
||||
// ============================================
|
||||
@@ -100,6 +169,9 @@ async function fetchModels(url, backend) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models || []).map((m) => m.name);
|
||||
} else if (backend === "unsloth") {
|
||||
const data = await unslothProxy(url, "/v1/models", "GET");
|
||||
return (data.data || []).map((m) => m.id);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/models`);
|
||||
@@ -114,30 +186,37 @@ async function testConnection(url, backend) {
|
||||
return models.length;
|
||||
}
|
||||
|
||||
async function callLLM(url, backend, model, prompt) {
|
||||
async function callLLM(url, backend, model, prompt, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const res = await fetch(`${url}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
body: JSON.stringify(_applyGenOptions({ model, prompt, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.response || "";
|
||||
const text = data.response || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _unslothContent(data.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
} else {
|
||||
// LM Studio (OpenAI-compatible)
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_applyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content || "";
|
||||
const text = data.choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : stripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,11 +224,41 @@ async function callLLM(url, backend, model, prompt) {
|
||||
// Translation prompt builder
|
||||
// ============================================
|
||||
|
||||
function buildTranslationPrompt(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "Auto") : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English") : LANG_NAMES[dstLang];
|
||||
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
|
||||
return `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
|
||||
// Uses a system role (via callChat) rather than a single raw prompt string —
|
||||
// local models follow "translate only, no commentary" far more reliably
|
||||
// when it's a system instruction instead of buried inside the user text.
|
||||
function buildTranslationMessages(text, srcLang, dstLang, settings) {
|
||||
const srcName = srcLang === "free" ? (settings.freeSrcLang || "").trim() : LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (settings.freeDstLang || "English").trim() || "English" : LANG_NAMES[dstLang];
|
||||
const fromPart = srcName ? ` from ${srcName}` : "";
|
||||
const system = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
|
||||
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
|
||||
+ `no surrounding quotes, and do not repeat or echo the original text.`;
|
||||
return [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: text },
|
||||
];
|
||||
}
|
||||
|
||||
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
|
||||
function cleanTranslationOutput(raw) {
|
||||
let s = (raw || "").trim();
|
||||
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
|
||||
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
|
||||
if (s.length >= 2) {
|
||||
const first = s[0], last = s[s.length - 1];
|
||||
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
|
||||
if (pairs[first] === last) s = s.slice(1, -1).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Heuristic: if the cleaned output is (near-)identical to the input, the
|
||||
// model most likely echoed the source instead of translating it.
|
||||
function looksUntranslated(input, output) {
|
||||
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
|
||||
const a = normalize(input), b = normalize(output);
|
||||
return a.length > 0 && a === b;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -217,11 +326,19 @@ function initTranslateTab() {
|
||||
outputEl.value = "";
|
||||
|
||||
try {
|
||||
const prompt = buildTranslationPrompt(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
outputEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
const messages = buildTranslationMessages(text, srcLangSel.value, dstLangSel.value, settings);
|
||||
const { content } = await callChat(url, backend, model, messages, undefined, settings);
|
||||
const cleaned = cleanTranslationOutput(content);
|
||||
outputEl.value = cleaned;
|
||||
|
||||
if (srcLangSel.value !== dstLangSel.value && looksUntranslated(text, cleaned)) {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
showToast(t("aiToastMaybeNotTranslated"), "error");
|
||||
} else {
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = `${t("aiStatusConnectFail")}${err.message}`;
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-error";
|
||||
@@ -288,6 +405,11 @@ function initSettingsTab() {
|
||||
const urlInput = document.getElementById("wfm-ai-backend-url");
|
||||
if (urlInput && saved.backendUrl) urlInput.value = saved.backendUrl;
|
||||
|
||||
const thinkingModeCheckbox = document.getElementById("wfm-ai-thinking-mode-checkbox");
|
||||
if (thinkingModeCheckbox) thinkingModeCheckbox.checked = !!saved.thinkingMode;
|
||||
const maxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (maxTokensInput && saved.maxTokens) maxTokensInput.value = saved.maxTokens;
|
||||
|
||||
// Switch URL to the new backend's default when the user changes backend
|
||||
backendRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
@@ -395,6 +517,8 @@ function initSettingsTab() {
|
||||
const chatGenDedicatedFilename = chatGenSelect?.value || "";
|
||||
const chatGenI2IDedicatedEnabled = !!chatGenI2ICheckbox?.checked;
|
||||
const chatGenI2IDedicatedFilename = chatGenI2ISelect?.value || "";
|
||||
const thinkingMode = !!thinkingModeCheckbox?.checked;
|
||||
const maxTokens = Math.max(0, parseInt(maxTokensInput?.value, 10) || 0);
|
||||
|
||||
if (url && !isValidBackendUrl(url)) {
|
||||
showToast(t("aiToastInvalidUrl"), "error");
|
||||
@@ -403,6 +527,7 @@ function initSettingsTab() {
|
||||
|
||||
saveAiSettings({
|
||||
backend, backendUrl: url, model, freeSrcLang, freeDstLang,
|
||||
thinkingMode, maxTokens,
|
||||
chatGenDedicatedEnabled, chatGenDedicatedFilename,
|
||||
chatGenI2IDedicatedEnabled, chatGenI2IDedicatedFilename,
|
||||
});
|
||||
@@ -453,10 +578,10 @@ function _formatMessagesForBackend(messages, backend) {
|
||||
});
|
||||
}
|
||||
|
||||
async function callChat(url, backend, model, messages, tools) {
|
||||
async function callChat(url, backend, model, messages, tools, settings = {}) {
|
||||
const formattedMessages = _formatMessagesForBackend(messages, backend);
|
||||
if (backend === "ollama") {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) body.tools = tools;
|
||||
const res = await fetch(`${url}/api/chat`, {
|
||||
method: "POST",
|
||||
@@ -465,9 +590,17 @@ async function callChat(url, backend, model, messages, tools) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const data = await unslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const message = data.choices?.[0]?.message || {};
|
||||
const content = _unslothContent(message);
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
} else {
|
||||
const body = { model, messages: formattedMessages, stream: false };
|
||||
const body = _applyGenOptions({ model, messages: formattedMessages, stream: false }, backend, settings);
|
||||
if (tools) { body.tools = tools; body.tool_choice = "auto"; }
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
@@ -476,7 +609,8 @@ async function callChat(url, backend, model, messages, tools) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const message = (await res.json()).choices?.[0]?.message || {};
|
||||
return { content: message.content || "", toolCalls: message.tool_calls || null };
|
||||
const content = message.content || "";
|
||||
return { content: settings.thinkingMode ? content : stripThinkingTags(content), toolCalls: message.tool_calls || null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,7 +1043,7 @@ function initChatTab() {
|
||||
const messagesToSend = skillPrompt
|
||||
? [{ role: "system", content: skillPrompt }, ...chatHistory]
|
||||
: chatHistory;
|
||||
const reply = await callChat(url, backend, model, messagesToSend, tools);
|
||||
const reply = await callChat(url, backend, model, messagesToSend, tools, settings);
|
||||
const toolCall = reply.toolCalls?.find(
|
||||
(tc) => (tc.function?.name || tc.name) === "generate_image"
|
||||
);
|
||||
@@ -1069,7 +1203,7 @@ function initVlmTab() {
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
|
||||
try {
|
||||
const result = await callLLM(url, backend, model, prompt);
|
||||
const result = await callLLM(url, backend, model, prompt, settings);
|
||||
resultEl.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
@@ -1095,7 +1229,7 @@ function initVlmTab() {
|
||||
resultEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType);
|
||||
const result = await callVLM(url, backend, model, VLM_PROMPTS[task], _toolsImage.base64, _toolsImage.mimeType, settings);
|
||||
resultEl.value = result.trim();
|
||||
statusEl.textContent = t("aiStatusDone");
|
||||
statusEl.className = "wfm-ai-trans-status wfm-ai-status-ok";
|
||||
|
||||
+38
-4
@@ -42,11 +42,32 @@ export function showToast(message, type = "info", duration = 3000) {
|
||||
|
||||
export function openModal(title, contentHtml) {
|
||||
const overlay = document.getElementById("wfm-modal-overlay");
|
||||
const titleEl = document.getElementById("wfm-modal-title");
|
||||
let titleEl = document.getElementById("wfm-modal-title");
|
||||
const bodyEl = document.getElementById("wfm-modal-body");
|
||||
const titleInput = document.getElementById("wfm-modal-title-input");
|
||||
|
||||
if (!overlay || !titleEl || !bodyEl) return;
|
||||
|
||||
// This modal shell is shared by every caller (Workflow/Models/Nodes detail
|
||||
// modals, Lab's keyframe editor, etc.). Some callers (e.g. workflow-tab.js's
|
||||
// openDetailModal) inject extra header chrome after calling openModal() — a
|
||||
// favorite star button, and a click-to-rename listener + tooltip/cursor on the
|
||||
// title — but nothing ever tore that down again. The next caller to reuse this
|
||||
// same DOM inherited it as stale leftovers: a Lab cell modal could show a
|
||||
// favorite star wired to the PREVIOUS caller's workflow object, and clicking it
|
||||
// silently toggled that unrelated workflow's favorite state. Clean slate every
|
||||
// time, before any caller-specific chrome is added back on top.
|
||||
titleEl.parentElement?.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
|
||||
const freshTitleEl = titleEl.cloneNode(false); // drops any addEventListener from a prior caller
|
||||
freshTitleEl.removeAttribute("title");
|
||||
freshTitleEl.removeAttribute("style"); // also clears any leftover display:none from an interrupted rename
|
||||
titleEl.replaceWith(freshTitleEl);
|
||||
titleEl = freshTitleEl;
|
||||
if (titleInput) {
|
||||
titleInput.style.display = "none";
|
||||
titleInput.value = "";
|
||||
}
|
||||
|
||||
titleEl.textContent = title;
|
||||
bodyEl.innerHTML = contentHtml;
|
||||
overlay.style.display = "flex";
|
||||
@@ -364,6 +385,14 @@ function applyI18nToHtml() {
|
||||
}
|
||||
const aiModelRefreshBtn = document.getElementById("wfm-ai-model-refresh-btn");
|
||||
if (aiModelRefreshBtn) aiModelRefreshBtn.textContent = t("aiSettingsRefreshBtn");
|
||||
const aiGenerationTitle = document.getElementById("wfm-ai-settings-generation-title");
|
||||
if (aiGenerationTitle) aiGenerationTitle.textContent = t("aiSettingsGenerationTitle");
|
||||
const aiThinkingModeLabel = document.getElementById("wfm-ai-thinking-mode-label");
|
||||
if (aiThinkingModeLabel) aiThinkingModeLabel.textContent = t("aiSettingsThinkingMode");
|
||||
const aiMaxTokensLabel = document.getElementById("wfm-ai-max-tokens-label");
|
||||
if (aiMaxTokensLabel) aiMaxTokensLabel.textContent = t("aiSettingsMaxTokens");
|
||||
const aiMaxTokensInput = document.getElementById("wfm-ai-max-tokens");
|
||||
if (aiMaxTokensInput) aiMaxTokensInput.placeholder = t("aiSettingsMaxTokensPlaceholder");
|
||||
const aiFreeLangTitle = document.getElementById("wfm-ai-settings-freelang-title");
|
||||
if (aiFreeLangTitle) aiFreeLangTitle.textContent = t("aiSettingsFreeLang");
|
||||
const aiFreeSrcLabel = document.getElementById("wfm-ai-free-src-label");
|
||||
@@ -446,6 +475,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gen-22": "helpGen22", "wfm-help-gen-23": "helpGen23",
|
||||
"wfm-help-gen-24": "helpGen24", "wfm-help-gen-25": "helpGen25",
|
||||
"wfm-help-gen-26": "helpGen26", "wfm-help-gen-27": "helpGen27",
|
||||
"wfm-help-gen-28": "helpGen28",
|
||||
"wfm-help-feeder-title": "helpFeederTitle",
|
||||
"wfm-help-feeder-desc": "helpFeederDesc",
|
||||
"wfm-help-feeder-imgloop-title": "helpFeederImgloopTitle",
|
||||
@@ -469,7 +499,8 @@ function applyI18nToHtml() {
|
||||
"wfm-help-lab-7": "helpLab7", "wfm-help-lab-8": "helpLab8",
|
||||
"wfm-help-lab-9": "helpLab9", "wfm-help-lab-10": "helpLab10",
|
||||
"wfm-help-lab-11": "helpLab11", "wfm-help-lab-12": "helpLab12",
|
||||
"wfm-help-lab-13": "helpLab13",
|
||||
"wfm-help-lab-13": "helpLab13", "wfm-help-lab-14": "helpLab14",
|
||||
"wfm-help-lab-15": "helpLab15",
|
||||
"wfm-help-lab-card-plan-title": "helpLabCardPlanTitle",
|
||||
"wfm-help-lab-plan-1": "helpLabPlan1", "wfm-help-lab-plan-2": "helpLabPlan2",
|
||||
"wfm-help-lab-plan-3": "helpLabPlan3", "wfm-help-lab-plan-4": "helpLabPlan4",
|
||||
@@ -495,7 +526,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-settings-7": "helpSettings7", "wfm-help-settings-8": "helpSettings8",
|
||||
"wfm-help-settings-9": "helpSettings9", "wfm-help-settings-10": "helpSettings10",
|
||||
"wfm-help-settings-11": "helpSettings11", "wfm-help-settings-12": "helpSettings12",
|
||||
"wfm-help-settings-13": "helpSettings13",
|
||||
"wfm-help-settings-13": "helpSettings13", "wfm-help-settings-14": "helpSettings14",
|
||||
"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",
|
||||
@@ -568,6 +599,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-14": "helpImageEdit14", "wfm-help-imageedit-15": "helpImageEdit15",
|
||||
"wfm-help-imageedit-16": "helpImageEdit16", "wfm-help-imageedit-17": "helpImageEdit17",
|
||||
"wfm-help-imageedit-18": "helpImageEdit18", "wfm-help-imageedit-19": "helpImageEdit19",
|
||||
"wfm-help-imageedit-19b": "helpImageEdit19b",
|
||||
"wfm-help-imageedit-20": "helpImageEdit20", "wfm-help-imageedit-21": "helpImageEdit21",
|
||||
"wfm-help-imageedit-22": "helpImageEdit22", "wfm-help-imageedit-23": "helpImageEdit23",
|
||||
"wfm-help-imageedit-24": "helpImageEdit24", "wfm-help-imageedit-25": "helpImageEdit25",
|
||||
@@ -593,6 +625,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-mask-9": "helpImageEditMask9", "wfm-help-imageedit-mask-10": "helpImageEditMask10",
|
||||
"wfm-help-imageedit-mask-11": "helpImageEditMask11", "wfm-help-imageedit-mask-12": "helpImageEditMask12",
|
||||
"wfm-help-imageedit-mask-13": "helpImageEditMask13",
|
||||
"wfm-help-imageedit-mask-14": "helpImageEditMask14",
|
||||
"wfm-help-imageedit-gmic-title": "helpImageEditGmicTitle",
|
||||
"wfm-help-imageedit-gmic-1": "helpImageEditGmic1", "wfm-help-imageedit-gmic-2": "helpImageEditGmic2",
|
||||
"wfm-help-imageedit-gmic-3": "helpImageEditGmic3", "wfm-help-imageedit-gmic-4": "helpImageEditGmic4",
|
||||
@@ -756,7 +789,7 @@ import { initWorkflowTab } from "./workflow-tab.js";
|
||||
import { initNodesTab } from "./nodes-tab.js";
|
||||
import { initGenerateTab } from "./generate-tab.js";
|
||||
import { initPromptTab } from "./prompt-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors } from "./settings-tab.js";
|
||||
import { initSettingsTab, applyTheme, getSavedTheme, applyTextareaFontSize, applyJsonColors, applyModelTabActiveColor } from "./settings-tab.js";
|
||||
import { initModelsTab } from "./models-tab.js";
|
||||
import { initGalleryTab } from "./gallery-tab.js";
|
||||
import { initMetadataTab } from "./metadata-tab.js";
|
||||
@@ -772,6 +805,7 @@ try {
|
||||
const _s = getSettings();
|
||||
if (_s.textareaFontSize) applyTextareaFontSize(_s.textareaFontSize);
|
||||
applyJsonColors(_s.jsonColors);
|
||||
applyModelTabActiveColor(_s.modelTabActiveColor);
|
||||
} catch {}
|
||||
|
||||
function initHelpTab() {
|
||||
|
||||
+63
-39
@@ -616,10 +616,14 @@ export const comfyEditor = {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
const sections = [
|
||||
// 使用頻度の高い順: Checkpoint/VAE/Diffusion Model/Text Encoder は常時表示。
|
||||
// ControlNet/Hypernetwork は意図的に使う場面が限られるため折りたたみにする。
|
||||
const mainSections = [
|
||||
{ label: "Checkpoint", key: "checkpoints", nodes: analysis.checkpoint_nodes, inputKey: "ckpt_name" },
|
||||
{ label: "VAE", key: "vaes", nodes: analysis.vae_nodes, inputKey: "vae_name" },
|
||||
{ label: "Diffusion Model", key: "diffusionModels", nodes: analysis.diffusion_model_nodes, inputKey: "unet_name" },
|
||||
];
|
||||
const collapsibleSections = [
|
||||
{ label: "ControlNet", key: "controlNets", nodes: analysis.controlnet_nodes, inputKey: "control_net_name" },
|
||||
{
|
||||
label: "Hypernetwork", key: "hypernetworks", nodes: analysis.hypernetwork_nodes, inputKey: "hypernetwork_name",
|
||||
@@ -627,46 +631,65 @@ export const comfyEditor = {
|
||||
},
|
||||
];
|
||||
|
||||
el.innerHTML = sections
|
||||
.map((s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
const renderSectionBody = (s) => {
|
||||
const models = this.models[s.key] || [];
|
||||
const currentVal = s.nodes?.[0]?.[s.inputKey] || "";
|
||||
const isMissing = !!currentVal && !models.includes(currentVal);
|
||||
const targetOpts = s.nodes
|
||||
.map((n) => `<option value="${n.id}">ID:${n.id} (${n.title})</option>`)
|
||||
.join("");
|
||||
const extrasHtml = (s.extras || []).map((ex) => {
|
||||
const curVal = s.nodes?.[0]?.[ex.inputKey] ?? ex.defaultVal;
|
||||
return `<div style="display:flex;gap:6px;align-items:center;margin-top:4px;">
|
||||
<label style="font-size:12px;white-space:nowrap;color:var(--wfm-text-secondary);">${ex.label}</label>
|
||||
<input type="${ex.type}" class="wfm-input wfm-model-extra" id="wfm-model-${s.key}-${ex.inputKey}"
|
||||
data-key="${s.key}" data-input-key="${ex.inputKey}"
|
||||
value="${curVal}" step="${ex.step}" min="${ex.min}" max="${ex.max}"
|
||||
style="width:80px;">
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label>${s.label}</label>
|
||||
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
return `
|
||||
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
|
||||
<input type="text" class="wfm-input wfm-search-input wfm-model-filter" id="wfm-model-${s.key}-filter" placeholder="Filter..." data-target="wfm-model-${s.key}">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-model-${s.key}-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select class="wfm-select ${isMissing ? "wfm-select-missing" : ""}" id="wfm-model-${s.key}" style="margin-bottom:4px;">
|
||||
${isMissing ? `<option value="${escapeHtml(currentVal)}" selected>⚠ ${escapeHtml(currentVal)} (${t("modelNotFound")})</option>` : ""}
|
||||
${models.map((m) => `<option value="${m}" ${m === currentVal ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
${isMissing ? `<div class="wfm-model-missing-hint">${t("modelNotFoundHint")}</div>` : ""}
|
||||
${extrasHtml}
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
|
||||
<select class="wfm-select" id="wfm-model-${s.key}-target" style="flex:1;">${targetOpts}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-model-apply" data-key="${s.key}" data-input="${s.inputKey}" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("") + '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
};
|
||||
|
||||
const mainHtml = mainSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<div class="wfm-form-group" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;">
|
||||
<label class="${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</label>
|
||||
${renderSectionBody(s)}
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const teHtml = '<div class="wfm-form-group" id="wfm-te-section" style="border-bottom:1px solid var(--wfm-border);padding-bottom:12px;"><label>Text Encoder</label></div>';
|
||||
|
||||
const collapsibleHtml = collapsibleSections.map((s) => {
|
||||
const hasNodes = (s.nodes?.length || 0) > 0;
|
||||
return `
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary ${hasNodes ? "wfm-model-label-active" : ""}">${s.label}</summary>
|
||||
<div class="wfm-form-group" style="margin-bottom:0;">
|
||||
${renderSectionBody(s)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join("");
|
||||
|
||||
el.innerHTML = mainHtml + teHtml + collapsibleHtml;
|
||||
|
||||
// Filter inputs
|
||||
el.querySelectorAll(".wfm-model-filter").forEach((input) => {
|
||||
@@ -724,6 +747,7 @@ export const comfyEditor = {
|
||||
if (!el) return;
|
||||
|
||||
const loraNodes = analysis.lora_nodes || [];
|
||||
document.getElementById("wfm-gen-lora-col-title")?.classList.toggle("wfm-model-label-active", loraNodes.length > 0);
|
||||
const loras = this.models.loras || [];
|
||||
const defaultStackTarget = (loraNodes.find((n) => n.is_lora_manager) || loraNodes[0])?.id;
|
||||
const hasLoraManager = loraNodes.some((n) => n.is_lora_manager);
|
||||
@@ -2092,7 +2116,7 @@ async function _initTextEncoderSection(analysis, textEncoders, containerEl) {
|
||||
const deviceOpts = ["default", "cpu"].map(d => `<option value="${d}" ${d === currentDevice ? "selected" : ""}>${d}</option>`).join("");
|
||||
|
||||
section.innerHTML = `
|
||||
<label>Text Encoder</label>
|
||||
<label class="wfm-model-label-active">Text Encoder</label>
|
||||
<input type="text" class="wfm-input" id="wfm-te-filter" placeholder="Filter..." style="margin-bottom:4px;">
|
||||
<select class="wfm-select" id="wfm-te-clip1" style="margin-bottom:4px;">${clip1Opts}</select>
|
||||
${isDual ? `<select class="wfm-select" id="wfm-te-clip2" style="margin-bottom:4px;">${clip2Opts}</select>` : ""}
|
||||
|
||||
+78
-30
@@ -236,6 +236,8 @@ const LANGUAGES = {
|
||||
labBatchCountTooSmall: "Increase the Batch count first to add a keyframe beyond it",
|
||||
labKeyframesExceedBatch: (maxIter, batchCount) => `Some keyframes are set up to iteration #${maxIter}, beyond the current Batch count (${batchCount}) — they won't be applied until Batch count is raised again`,
|
||||
labUseWorkflowDefault: "(workflow default)",
|
||||
labInheritsFrom: (val) => `(inherits: ${val})`,
|
||||
labLoraNoNode: "No LoRA node found in the workflow — LoRA setting was skipped",
|
||||
labAppliedAt: (n) => `Applied: ${n}`,
|
||||
labRevertToBase: "↩ back to #1",
|
||||
labApplyFromIteration: "Applies from iteration #",
|
||||
@@ -319,6 +321,9 @@ const LANGUAGES = {
|
||||
jsonColorCyan: "Prompts / Text",
|
||||
jsonColorRed: "Images / Files",
|
||||
jsonColorReset: "Reset to defaults",
|
||||
modelTabColorSection: "GenerateUI Model Tab Highlight",
|
||||
modelTabColorLabel: "Color for items present in the workflow",
|
||||
modelTabColorHint: "In the GenerateUI Model tab, labels for items actually present in the loaded workflow (Checkpoint, VAE, etc.) are shown in this color.",
|
||||
|
||||
// -- Prompt tab --
|
||||
tabPrompt: "Prompt",
|
||||
@@ -682,6 +687,10 @@ const LANGUAGES = {
|
||||
aiSettingsModelSection: "Model",
|
||||
aiSettingsRefreshBtn: "Refresh",
|
||||
aiSettingsModelPlaceholder: "-- Select Model --",
|
||||
aiSettingsGenerationTitle: "Generation",
|
||||
aiSettingsThinkingMode: "Thinking mode",
|
||||
aiSettingsMaxTokens: "Max tokens",
|
||||
aiSettingsMaxTokensPlaceholder: "0 = unlimited",
|
||||
aiSettingsFreeLang: "Free Language",
|
||||
aiSettingsInputLang: "Input",
|
||||
aiSettingsOutputLang: "Output",
|
||||
@@ -697,6 +706,7 @@ const LANGUAGES = {
|
||||
aiStatusConnectOk: "Connected",
|
||||
aiStatusConnectFail: "Connection failed: ",
|
||||
aiToastTransFailed: "Translation failed: ",
|
||||
aiToastMaybeNotTranslated: "The model returned the text unchanged — it may not have translated it. Try a different model.",
|
||||
aiToastNoCopyText: "No text to copy",
|
||||
aiToastCopied: "Copied",
|
||||
aiToastNoModels: "No models found",
|
||||
@@ -752,7 +762,8 @@ const LANGUAGES = {
|
||||
helpGen27: "Input tab → Image sub-panel — each LoadImage card now has three buttons: Apply (unchanged), Clear (resets that card's pending selection/preview back to empty; doesn't touch the workflow node itself, since LoadImage always needs some valid filename), and Placeholder. A Placeholder Image section — collapsed by default since it's a low-frequency feature, click to expand — sits at the top of the panel with a shared default: Color mode (set Width/Height/Color; generates a solid-color PNG on the fly) or Image mode (drop one default image, reused as-is). Clicking a card's own Placeholder button immediately replaces just that card's image with the configured default — no separate Apply click needed, and with several LoadImage cards each can be replaced individually. The Placeholder config (mode, size, color, default image) is remembered across sessions",
|
||||
helpGen3: "5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing",
|
||||
helpGen4: "Input tab: inner tabs — Prompt and Image (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a ↺ button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust its emphasis weight in (text:weight) syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones",
|
||||
helpGen28: "Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab",
|
||||
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)",
|
||||
helpGen8: "Seed control: random, fixed, increment, decrement",
|
||||
@@ -795,21 +806,23 @@ const LANGUAGES = {
|
||||
helpFeederGal6: "WFS_GalleryFeeder canvas controls: the node also has Run / Stop / After Gen widgets added directly on the ComfyUI canvas — these work the same as the WFM UI controls and allow running the gallery feeder loop without opening WFM",
|
||||
|
||||
helpLabTitle: "GenerateUI Tab — Lab subtab",
|
||||
helpLabDesc: "Experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, letting Checkpoint / VAE / Prompt / KSampler each change independently starting at a chosen iteration. Two subtabs: Setting and Results.",
|
||||
helpLabDesc: "Experimental I2I batch generation. Runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA/VAE) / Prompt / KSampler each change independently starting at a chosen iteration. Two subtabs: Setting and Results.",
|
||||
helpLabCardColumnsTitle: "Setting subtab — Image & Columns",
|
||||
helpLab1: "Left pane — Image: drop or click to select an I2I source image; it is uploaded to ComfyUI's input folder and used for every iteration unless \"Use generated image for next\" is checked",
|
||||
helpLab2: "Left pane — Use generated image for next: from iteration 2 onward, feeds the previous iteration's first output image back in as the I2I source instead of the original dropped image (iteration 1 always uses the original)",
|
||||
helpLab3: "Center pane — 4 independent columns: Checkpoint, VAE, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid",
|
||||
helpLab3: "Center pane — 3 independent columns: Model, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid",
|
||||
helpLab4: "+ / − per column: + appends a new keyframe at the next free iteration number (up to the Batch count) and opens its edit modal immediately; − removes the last keyframe (iteration #1 can never be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal via its Delete button, not just the last one",
|
||||
helpLab5: "Click a cell to edit it: iteration #1 sets the baseline value (leave it blank to keep the loaded workflow's own setting); iteration #2+ also lets you choose which iteration it starts applying from, plus a \"revert to #1's setting\" checkbox",
|
||||
helpLab6: "Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; \"revert to #1\" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one; lowering the Batch count (by hand, or by loading/applying a plan) below an existing keyframe's iteration number doesn't delete or renumber that keyframe — a warning toast appears, and the keyframe simply stays dormant (never reached by the run loop) until Batch count is raised again",
|
||||
helpLab6: "Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; \"revert to #1\" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one; lowering the Batch count (by hand, or by loading/applying a plan) below an existing keyframe's iteration number doesn't delete or renumber that keyframe — a warning toast appears, and the keyframe simply stays dormant (never reached by the run loop) until Batch count is raised again. This whole-row resolution applies as-is to Prompt and KSampler; the Model column instead resolves its three fields (Checkpoint/LoRA/VAE) independently — see below",
|
||||
helpLab7: "Lab never edits the workflow shown in Input/Model/Settings — every iteration runs on a fresh clone of the loaded workflow, so keyframes here never affect the other subtabs",
|
||||
helpLab8: "Prompt cell editor extras: Get from GenerateUI fills Positive/Negative from the workflow currently loaded in GenerateUI; Get from Image extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); Style row — the Apply button appends whichever style is currently selected in the Style dropdown at the top of the GenerateUI toolbar to the Positive/Negative text below, comma-separated after any existing text (never clears it), then checks the Style checkbox and shows the applied style's name next to it (not on the button) — the style NAME always comes from that top dropdown (Lab has no picker of its own), and clicking Apply works regardless of the top toolbar's own Style enable checkbox; unchecking the Style checkbox only clears that name label — it does not remove the already-appended text from Positive/Negative, which stays there for you to edit or delete by hand",
|
||||
helpLab9: "Left pane — T2I workflow (no source image): check this when the loaded workflow is text-to-image (no LoadImage node). It skips the \"drop a source image first\" requirement, ignores any dropped image, and the Checkpoint/VAE/Prompt/KSampler keyframes still apply normally; the Image drop zone and \"Use generated image for next\" are greyed out while it's on (any previously dropped image is kept, not deleted, in case you switch back to I2I); the setting is saved with the plan as t2i_mode",
|
||||
helpLab9: "Left pane — T2I workflow (no source image): check this when the loaded workflow is text-to-image (no LoadImage node). It skips the \"drop a source image first\" requirement, ignores any dropped image, and the Model/Prompt/KSampler keyframes still apply normally; the Image drop zone and \"Use generated image for next\" are greyed out while it's on (any previously dropped image is kept, not deleted, in case you switch back to I2I); the setting is saved with the plan as t2i_mode",
|
||||
helpLab10: "Wildcards: Positive/Negative prompt text in Prompt column keyframes supports the same A1111-style __name__ syntax as the main GenerateUI — each keyframe's text is expanded fresh right before that iteration runs, so every iteration can draw a different random line even when reusing the same keyframe",
|
||||
helpLab11: "Prompt cell editor also has Clear (empties both Positive and Negative) and Get from Previous (copies both Positive and Negative from the keyframe immediately before this one in the same column — e.g. editing #3 copies #2's prompt wholesale; not shown on keyframe #1, which has no earlier row to copy from)",
|
||||
helpLab12: "Keyframe #1 in every column shows the currently loaded workflow's own live setting — Checkpoint, VAE, Prompt, and KSampler — instead of a blank placeholder, both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI",
|
||||
helpLab12: "Keyframe #1 in every column shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only (LoRA is never live-reflected, since there's no reliable way to infer \"the\" active LoRA back out of a workflow node) — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI",
|
||||
helpLab13: "Bypass checkbox (every keyframe, including #1): skips that row entirely when picking which setting applies at a given iteration — as if the row didn't exist — so whatever was active before it (an earlier keyframe, or ultimately nothing) just keeps running; Lab-internal only, it never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (the two are mutually exclusive); the cell shows an amber \"⏭ Bypassed\" label, which takes priority over both the Revert and live-value display",
|
||||
helpLab14: "Model column header's C / L / V buttons pick which independent keyframe list — Checkpoint, LoRA (Single-only, no Stack support here), or VAE — the shared cell list, its +/-, and its edit modal all currently operate on. Each of the three is its own timeline with its own keyframes, exactly like Prompt/KSampler; they only ever look merged because they share one column's worth of screen space. Adding, removing, or editing a keyframe while viewing one never touches the other two. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it",
|
||||
helpLab15: "Checkpoint / LoRA / VAE each carry forward independently, the same effective-value rule as every other column (see above): a keyframe in one never resets the other two to the workflow default. A field left blank in the edit modal shows \"(inherits: ...)\" as its placeholder instead of \"(workflow default)\" when an earlier keyframe in that same list already set something, so it's clear what actually applies. \"Revert to #1's setting\" and Bypass — like +/- — only ever affect whichever ONE of the three is currently selected by the C/L/V toggle, never the other two",
|
||||
helpLabCardPlanTitle: "Setting subtab — Run & Plan files",
|
||||
helpLabPlan1: "Right pane — Batch: number of iterations to run; Note: free-text memo saved together with the plan",
|
||||
helpLabPlan2: "Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop",
|
||||
@@ -855,6 +868,7 @@ const LANGUAGES = {
|
||||
helpSettings11: "Text Size: one slider (10–28 px) adjusts font size for all prompt and chat textareas at once — Generate UI positive/negative prompts, AI Assistant chat input, Preset positive/negative, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings",
|
||||
helpSettings12: "RAW JSON Colors: customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI — Default Text, Name/Scheduler, Title, Width/Height, Prompt/Text, Image/File; changes apply immediately on color pick; Reset Defaults restores the original color scheme",
|
||||
helpSettings13: "CivitAI Host: choose between civitai.com (SFW only) and civitai.red (unrestricted) — determines which site opens when clicking a model link in the Models tab CivitAI panel",
|
||||
helpSettings14: "GenerateUI Model Tab Highlight: pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it",
|
||||
helpNodes1: "Node Browser: search by name/description/alias/tag, filter by category, package, tag, group, favorites",
|
||||
helpNodes2: "Card and Table view modes with pagination",
|
||||
helpNodes3: "Favorite nodes and add custom tags for organization",
|
||||
@@ -929,14 +943,14 @@ const LANGUAGES = {
|
||||
helpSidepanel13: "I tab (Information): drop a ComfyUI PNG/WebP/JSON to inspect model, LoRA, and prompt metadata in the side panel — sub-tabs: model / lora / Prompts; supports UnetLoaderGGUF, QuadrupleCLIPLoader, and Lora Loader (LoraManager) node types",
|
||||
helpSidepanel14: "Information tab — drag items to canvas: model sub-tab places the loader node (Checkpoint → CheckpointLoaderSimple, VAE → VAELoader, Diffusion Model → UNETLoader, Text Encoder → CLIPLoader); double-click also places at canvas center",
|
||||
helpSidepanel15: "Information tab — LoRA sub-tab: detects LoRAs from LoraLoader, LoraLoaderModelOnly, and Lora Loader (LoraManager) nodes (API format supported); drag individual LoRA to place LoraLoader; \"Multiple LORA\" section places all LoRAs in a single Lora Loader (LoraManager) node with LoRA syntax pre-filled; Prompts sub-tab: drag a prompt to place CLIPTextEncode node with text pre-filled; click prompt to view full text and copy",
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage",
|
||||
helpSidepanel17: "Send to Canvas: clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab) sends the workflow directly to the ComfyUI canvas — both UI and API formats are supported; if Workflow Studio was not opened from the ComfyUI toolbar (window.opener unavailable), UI-format workflows fall back to title drag (title turns blue with a green ●)",
|
||||
helpAiTitle: "AI Tab (A)",
|
||||
helpAi1: "4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously",
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image (\"generate a cat picture\", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the \"Allow image generation\" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires \"Auto model switching (OpenAI API)\" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a think flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage",
|
||||
helpAi7: "Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a \"Save to Gallery\" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support",
|
||||
helpAi8: "Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, or delete .md skill files, stored in user/default/Workflow-Studio/ai_skills/; a file's first line block between --- markers can define name/description shown in the dropdown; two starter skills are included by default: \"SVG Icon Generator\" (decomposes the subject into parts before drawing, for more recognizable SVG output) and \"Skill Creator\" (interviews you conversationally and drafts a new skill file, ready to save); when a reply contains a ```skill code block, a \"Save as new skill\" button appears below it that opens the manager with the draft pre-filled",
|
||||
helpAi9: "Tips for writing your own skills: give a clear frontmatter name/description so it reads well in the dropdown and tooltip; state the exact output format you want and, if there is a deliverable to extract later (like SVG or another skill file), wrap it in a distinctive fenced code block; write concrete, imperative instructions rather than vague ones - smaller local models follow explicit rules and a short worked example far better than general guidance; state hard constraints (forbidden content, security, length limits) explicitly rather than assuming they are implied; when in doubt, select the \"Skill Creator\" skill in the dropdown and describe what you want - it will interview you and draft the file for you",
|
||||
@@ -1029,7 +1043,8 @@ const LANGUAGES = {
|
||||
helpImageEditExportTitle: "Export & Navigation",
|
||||
helpImageEdit17: "Save PNG: composites all visible layers and downloads the result as a PNG file",
|
||||
helpImageEdit18: "Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root",
|
||||
helpImageEdit19: "Send to ComfyUI: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes",
|
||||
helpImageEdit19: "Save to Input: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes",
|
||||
helpImageEdit19b: "Send to Workflow: uploads the composite PNG (same as Save to Input) and additionally writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable",
|
||||
helpImageEdit20: "Canvas navigation: scroll wheel to zoom, Space + drag to pan",
|
||||
helpImageEdit21: "Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)",
|
||||
helpImageEditKbdTitle: "Image Edit Keyboard Shortcuts",
|
||||
@@ -1062,6 +1077,7 @@ const LANGUAGES = {
|
||||
helpImageEditMask11: "Text sub-tool: click on the canvas to open a text stamp overlay; enter text, configure Font / Size / Bold / Italic / Align, choose Mode (Add = white stamp / Erase = cut mask), then click Stamp or press Ctrl+Enter; multi-line text supported (newline in textarea)",
|
||||
helpImageEditMask12: "Vector sub-tool: click to place control points connected by a Catmull-Rom spline; click the first point again (or press Enter) to close and fill the polygon; Backspace removes the last point; Esc resets all points; Properties pane shows current point count and Mode (Add / Erase)",
|
||||
helpImageEditMask13: "Shape sub-tool: drag to draw a rectangle or ellipse mask; hold Shift for a perfect square or circle; a blue dashed outline previews the shape while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)",
|
||||
helpImageEditMask14: "Edit in Mask Editor One (Paint mode only): click \"Edit in Mask Editor One →\" in the MASK EDITOR ONE section to send the current canvas composite — plus the active mask layer's content, if one is selected — to a MaskEditorOne node on the ComfyUI canvas (the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph) and open its full editor (SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; after clicking Apply there, the result is imported back here as a new mask layer; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable, and at least one MaskEditorOne node to exist in the ComfyUI graph",
|
||||
helpImageEditBlurTitle: "Blur Tool (≈)",
|
||||
helpImageEditBlur1: "Whole Blur: applies a Gaussian blur to the entire active layer — adjust the intensity slider (1–50 px) and click Apply",
|
||||
helpImageEditBlur2: "Whole Mosaic: applies a pixelation mosaic to the entire active layer — adjust the block size slider (5–100 px) and click Apply",
|
||||
@@ -1479,6 +1495,8 @@ const LANGUAGES = {
|
||||
labBatchCountTooSmall: "先にBatch数を増やしてからキーフレームを追加してください",
|
||||
labKeyframesExceedBatch: (maxIter, batchCount) => `${maxIter}回目まで設定されたキーフレームがありますが、現在のBatch数(${batchCount})を超えているため、Batch数を増やすまで適用されません`,
|
||||
labUseWorkflowDefault: "(ワークフローの設定のまま)",
|
||||
labInheritsFrom: (val) => `(引き継ぎ: ${val})`,
|
||||
labLoraNoNode: "ワークフロー内にLoRAノードが見つかりません — LoRA設定はスキップされました",
|
||||
labAppliedAt: (n) => `適用:${n}`,
|
||||
labRevertToBase: "↩ 1回目に戻す",
|
||||
labApplyFromIteration: "何回目から適用するか",
|
||||
@@ -1562,6 +1580,9 @@ const LANGUAGES = {
|
||||
jsonColorCyan: "プロンプト / テキスト",
|
||||
jsonColorRed: "画像 / ファイル",
|
||||
jsonColorReset: "デフォルトに戻す",
|
||||
modelTabColorSection: "GenerateUI Modelタブ ハイライト色",
|
||||
modelTabColorLabel: "ワークフローに含まれる項目の色",
|
||||
modelTabColorHint: "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します。",
|
||||
|
||||
// -- Prompt tab --
|
||||
tabPrompt: "プロンプト",
|
||||
@@ -1925,6 +1946,10 @@ const LANGUAGES = {
|
||||
aiSettingsModelSection: "モデル選択",
|
||||
aiSettingsRefreshBtn: "更新",
|
||||
aiSettingsModelPlaceholder: "-- モデルを選択 --",
|
||||
aiSettingsGenerationTitle: "生成設定",
|
||||
aiSettingsThinkingMode: "Thinking mode(思考モード)",
|
||||
aiSettingsMaxTokens: "Max tokens(最大トークン数)",
|
||||
aiSettingsMaxTokensPlaceholder: "0 = 無制限",
|
||||
aiSettingsFreeLang: "Free 言語設定",
|
||||
aiSettingsInputLang: "入力言語",
|
||||
aiSettingsOutputLang: "翻訳言語",
|
||||
@@ -1940,6 +1965,7 @@ const LANGUAGES = {
|
||||
aiStatusConnectOk: "接続成功",
|
||||
aiStatusConnectFail: "接続失敗: ",
|
||||
aiToastTransFailed: "翻訳に失敗しました: ",
|
||||
aiToastMaybeNotTranslated: "モデルが原文をそのまま返しました。翻訳されていない可能性があります。別のモデルをお試しください。",
|
||||
aiToastNoCopyText: "コピーするテキストがありません",
|
||||
aiToastCopied: "コピーしました",
|
||||
aiToastNoModels: "モデルが見つかりません",
|
||||
@@ -1995,7 +2021,8 @@ const LANGUAGES = {
|
||||
helpGen27: "Inputタブ → Imageサブパネル — 各LoadImageカードにApply(変更なし)・Clear・Placeholderの3ボタンを追加。Clearはそのカードの保留中の選択・プレビューを空に戻す(LoadImageは常に有効なファイル名が必須なため、ワークフローノード自体には触れない)。パネル上部の「Placeholder Image」セクション(使用頻度が低い機能のためデフォルトで折りたたみ、クリックで展開)で共通のデフォルトを設定できる:Colorモード(幅・高さ・色を指定し、その場で単色PNGを生成)またはImageモード(デフォルト画像を1枚ドロップし、そのまま再利用)。各カード自身のPlaceholderボタンを押すと、そのカードの画像だけを設定済みデフォルトへ即座に差し替える(別途Applyクリック不要)— LoadImageが複数ある場合も個別に差し替え可能。Placeholder設定(モード・サイズ・色・デフォルト画像)はセッションをまたいで記憶される",
|
||||
helpGen3: "5タブ構成 — Input(プロンプト+画像)、Model、Settings、Feeder、Batch — Input/Model/SettingsタブにはRaw JSON列を常時表示",
|
||||
helpGen4: "Inputタブ:Prompt・Imageの内部タブ切り替え;PromptタブにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加);各Applyボタンの左隣の↺ボタンはテキストエリアをノードの現在の適用値に戻す;テキストを選択(または単語・括弧内にカーソルを置くだけでも可)してCtrl+↑/↓を押すと(text:weight)形式で強調weightを±0.05単位で調整(weightが1.0に戻ると括弧を自動解除)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/LoRA(Single/Stackタブ)/ControlNet/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)/Hypernetwork(Strength入力付き)のセレクタ(フィルタ検索付き)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)は常時表示;ControlNetとHypernetwork(Strength入力付き)は使用頻度が低いためデフォルトで折りたたみ — 見出しをクリックで展開;LoRA(Single/Stackタブ)は右側に独立した列を表示;すべてのセレクタにフィルタ検索付き;LoRA Stack — Applyで有効な全LoRAを対象ノードに書き込み、同時にLoRA構文とCivitAIトリガーワードをPositiveプロンプトへ挿入;再Applyでは非アクティブなLoRAを削除し、アクティブなLoRAを再追加してプロンプトを更新",
|
||||
helpGen28: "Modelタブ — ハイライト色:読み込んだワークフローに実際に該当ノードが含まれる場合、各モデル種別(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork)のラベルとLoRA列見出しが指定色(デフォルトは明るい緑)で表示される;ControlNet/Hypernetworkが折りたたまれた状態でも色は見えるため、展開しなくても使用有無が一目で分かる;色はSettingsタブでカスタマイズ可能",
|
||||
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ)",
|
||||
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少",
|
||||
@@ -2038,21 +2065,23 @@ const LANGUAGES = {
|
||||
helpFeederGal6: "WFS_GalleryFeeder キャンバスコントロール:このノードには ComfyUI キャンバス上に直接 Run / Stop / After Gen ウィジェットが追加されており、WFM を開かずにギャラリーフィーダーループを実行できます",
|
||||
|
||||
helpLabTitle: "生成UIタブ — Labサブタブ",
|
||||
helpLabDesc: "実験的なI2Iバッチ生成機能です。GenerateUIに現在読み込まれているワークフローをN回実行し、Checkpoint・VAE・Prompt・KSamplerをそれぞれ独立して任意の回数目から変更できます。Setting/Resultsの2つのサブタブがあります。",
|
||||
helpLabDesc: "実験的なI2Iバッチ生成機能です。GenerateUIに現在読み込まれているワークフローをN回実行し、Model(Checkpoint/LoRA/VAE)・Prompt・KSamplerをそれぞれ独立して任意の回数目から変更できます。Setting/Resultsの2つのサブタブがあります。",
|
||||
helpLabCardColumnsTitle: "Settingサブタブ — 画像と列",
|
||||
helpLab1: "左ペイン — Image:I2Iの元になる画像をドロップまたはクリックして選択します。ComfyUIのinputフォルダにアップロードされ、「Use generated image for next」がオフの間は毎回この画像が使われます",
|
||||
helpLab2: "左ペイン — Use generated image for next:オンにすると2回目以降、直前の回で生成された1枚目の画像を次のI2I入力として使用します(1回目は常に元画像を使用)",
|
||||
helpLab3: "中央ペイン — Checkpoint・VAE・Prompt・KSamplerの4列は独立しています。共通の行グリッドではなく、列ごとに個別のキーフレーム(設定変更点)のリストを持ちます",
|
||||
helpLab3: "中央ペイン — Model・Prompt・KSamplerの3列は独立しています。共通の行グリッドではなく、列ごとに個別のキーフレーム(設定変更点)のリストを持ちます",
|
||||
helpLab4: "各列の+/-:+は次に空いている回数目(Batch数まで)にキーフレームを追加し、すぐに編集モーダルを開きます。-は末尾のキーフレームを削除します(1回目は削除できません)。1回目以外のキーフレームは、末尾でなくてもそれぞれの編集モーダル内の「削除」ボタンから個別に削除できます",
|
||||
helpLab5: "セルをクリックすると編集できます。1回目は基準となる設定値です(空欄のままにするとワークフロー本来の設定を使用)。2回目以降はさらに「何回目から適用するか」の指定と「1回目の設定に戻す」チェックボックスがあります",
|
||||
helpLab6: "N回目時点での実効値は、N以下で最も大きい回数目のキーフレームが次のキーフレームまで引き継がれます。「1回目に戻す」を使うと、新しい値を指定する代わりに1回目の値(空欄ならワークフロー本来の設定)がそのまま使われます。既存キーフレームの回数目より小さくBatch数を減らしても(手入力・プラン読込/適用いずれの場合も)、そのキーフレームは削除・繰り上げされず警告トーストが表示されるだけで、Batch数を再び増やすまで実行ループに到達しない「休眠」状態になります",
|
||||
helpLab6: "N回目時点での実効値は、N以下で最も大きい回数目のキーフレームが次のキーフレームまで引き継がれます。「1回目に戻す」を使うと、新しい値を指定する代わりに1回目の値(空欄ならワークフロー本来の設定)がそのまま使われます。既存キーフレームの回数目より小さくBatch数を減らしても(手入力・プラン読込/適用いずれの場合も)、そのキーフレームは削除・繰り上げされず警告トーストが表示されるだけで、Batch数を再び増やすまで実行ループに到達しない「休眠」状態になります。この「行まるごと」の解決方式はPrompt・KSamplerにそのまま当てはまりますが、Model列だけはCheckpoint/LoRA/VAEの3項目をそれぞれ独立して解決します(詳細は後述)",
|
||||
helpLab7: "LabはInput/Model/Settingsに表示されているワークフローを直接編集しません。各回の実行はワークフローの複製に対して行われるため、Labでのキーフレーム設定が他のサブタブに影響することはありません",
|
||||
helpLab8: "Promptセル編集モーダルの追加機能:「Get from GenerateUI」で現在GenerateUIに読み込まれているワークフローからPositive/Negativeを取得、「Get from Image」でLab左ペインのIMAGEに現在読み込まれている画像からプロンプトを抽出(Metadataタブと同じ検出ロジック。別途ファイル選択は不要)、「Style」行の「適用」ボタンをクリックするとGenerateUIツールバー上部のStyleドロップダウンで現在選択されているスタイル名を、既存のPositive/Negativeテキストを消さずにカンマ区切りで追記し、Styleチェックボックスを自動でONにして適用したスタイル名をボタンではなく横に別表示します(スタイル名の選択は上部ドロップダウンのみを使用し、Lab独自のスタイル選択はありません。適用ボタンは上部のStyle有効化チェックボックスの状態に関係なく動作します)。Styleチェックボックスを手動でOFFにすると名前表示のみクリアされます(追記済みのPositive/Negativeテキストは自動削除されないため、不要な場合は手動で編集・削除してください)",
|
||||
helpLab9: "左ペイン — 「T2I workflow (no source image)」:読み込んでいるワークフローがT2I(LoadImageノードなし)の場合にオンにします。「先に元になる画像をドロップしてください」という要求をスキップし、ドロップ済みの画像があっても無視します。Checkpoint/VAE/Prompt/KSamplerのキーフレームは通常どおり適用されます。オンの間はImageドロップ領域と「Use generated image for next」がグレーアウトします(既にドロップ済みの画像は削除されず、I2Iに戻したときのために保持されます)。この設定はプランに t2i_mode として保存されます",
|
||||
helpLab9: "左ペイン — 「T2I workflow (no source image)」:読み込んでいるワークフローがT2I(LoadImageノードなし)の場合にオンにします。「先に元になる画像をドロップしてください」という要求をスキップし、ドロップ済みの画像があっても無視します。Model/Prompt/KSamplerのキーフレームは通常どおり適用されます。オンの間はImageドロップ領域と「Use generated image for next」がグレーアウトします(既にドロップ済みの画像は削除されず、I2Iに戻したときのために保持されます)。この設定はプランに t2i_mode として保存されます",
|
||||
helpLab10: "ワイルドカード:Promptカラムのキーフレームに入力するPositive/Negativeテキストは、通常のGenerateUIと同じA1111形式の __name__ 記法に対応しています。各キーフレームのテキストは、そのイテレーションが実行される直前に毎回展開されるため、同じキーフレームを使い回していてもイテレーションごとに異なるランダム行が選ばれます",
|
||||
helpLab11: "プロンプトセルの編集モーダルにはClear(Positive/Negative両方を空にする)とGet from Previous(同じカラムの直前のキーフレームからPositive/Negative両方をコピー — 例:#3を編集中に押すと#2のプロンプトをそのままコピー)も用意されています。1回目(#1)には直前の行が無いため表示されません",
|
||||
helpLab12: "各カラムの1回目(キーフレーム#1)は、明示的に上書き保存されていない間、現在GenerateUIで読み込まれているワークフロー本来の設定(Checkpoint・VAE・Prompt・KSampler)を、空欄プレースホルダの代わりにグリッドセル(破線枠)と編集モーダルの両方に表示します。何も変更せずに保存しただけでも実際の上書き値として確定し、破線・ライブ表示は解除されます。Labサブタブに切り替えるたびに、その時点でGenerateUIに読み込まれている内容へ再同期されます",
|
||||
helpLab12: "各カラムの1回目(キーフレーム#1)は、明示的に上書き保存されていない間、現在GenerateUIで読み込まれているワークフロー本来の設定を、空欄プレースホルダの代わりにグリッドセル(破線枠)と編集モーダルの両方に表示します(Model列はCheckpointとVAEのみが対象 — LoRAはワークフローのノードから「どれが有効か」を安全に逆算する手段が無いため、ライブ表示の対象外です)。何も変更せずに保存しただけでも実際の上書き値として確定し、破線・ライブ表示は解除されます。Labサブタブに切り替えるたびに、その時点でGenerateUIに読み込まれている内容へ再同期されます",
|
||||
helpLab13: "Bypassチェックボックス(1回目を含む全キーフレームに搭載):そのキーフレームの回数目判定において、行自体が存在しないかのように完全にスキップします — その結果、直前まで有効だった設定(それ以前のキーフレーム、なければ何も適用されない状態)がそのまま使われ続けます。Labタブ内だけの概念で、実際のComfyUIノードのmodeには一切触れません。チェック中はその行の値フィールドと「1回目に戻す」チェックボックスが無効化されます(両者は同時に使えない排他的な関係)。セルには琥珀色の「⏭ Bypassed」ラベルが表示され、Revert表示・ライブ表示のどちらよりも優先されます",
|
||||
helpLab14: "Model列ヘッダーのC/L/Vボタンは、Checkpoint・LoRA(Singleのみ、Stack非対応)・VAEという3つの独立したキーフレームリストのうち、今このセル表示・+/-・編集モーダルがどれを対象にしているかを切り替えます。3つはそれぞれPrompt・KSamplerと同じく完全に独立したタイムラインを持っており、1つの列にまとめて見えているのは表示スペースを共有しているだけです。1つを表示中にキーフレームを追加・削除・編集しても、残り2つには一切影響しません。各ボタンは、読み込み中のワークフローに該当するノードが実際に存在する場合、GenerateUIのModelタブと同じハイライト色(Settingsで変更可)になります。LoRAを設定していてもワークフロー内にLoRAノードが1つも無い場合は、Run実行中に1回だけ警告し、その設定は適用されません — VAEと異なりLoRAノードの自動追加は行いません(CheckpointのModel/CLIP出力の配線を組み替える必要があるため)",
|
||||
helpLab15: "Checkpoint/LoRA/VAEは、他の列と全く同じ実効値ルール(上記参照)でそれぞれ独立して引き継がれます:いずれか1つのキーフレームが、残り2つをワークフロー本来の設定に戻すことはありません。編集モーダルで未設定のままの項目は、同じリスト内の以前のキーフレームで何か設定済みの場合、プレースホルダが「(ワークフローの設定のまま)」ではなく「(引き継ぎ: ...)」と表示され、実際に適用される値が分かるようになっています。「1回目の設定に戻す」やBypassも+/-と同様、現在C/L/Vトグルで選択中の1項目だけに作用し、残り2つには影響しません",
|
||||
helpLabCardPlanTitle: "Settingサブタブ — 実行とプランファイル",
|
||||
helpLabPlan1: "右ペイン — Batch:実行する回数。Note:プランと一緒に保存される自由記述のメモ",
|
||||
helpLabPlan2: "Run/Pause/Cancel:Runでバッチループを開始、Pause/Resumeで回と回の間を一時停止・再開、Cancelで現在の生成を中断してループを止めます",
|
||||
@@ -2098,6 +2127,7 @@ const LANGUAGES = {
|
||||
helpSettings11: "テキストサイズ:スライダー1本(10〜28px)でプロンプト・チャット全テキストエリアのフォントサイズを一括調整 — GenerateUI ポジティブ/ネガティブプロンプト、AIアシスタントチャット入力、プリセット ポジティブ/ネガティブ、ワイルドカードプロンプト・ファイルエディタ、メタデータプロンプト全文プレビュー;即時反映・保存で確定",
|
||||
helpSettings12: "RAW JSON カラー:GenerateUI の Raw JSON エディタの構文ハイライト色(6色)をカスタマイズ — デフォルトテキスト・名前/スケジューラー・タイトル・幅/高さ・プロンプト/テキスト・画像/ファイル;カラーピッカーで即時反映;「デフォルトにリセット」で元の配色に戻す",
|
||||
helpSettings13: "CivitAI ホスト:civitai.com(SFWのみ)と civitai.red(制限なし)を切り替え — ModelsタブのCivitAIパネルでモデルリンクをクリックしたときに開くサイトを指定",
|
||||
helpSettings14: "GenerateUI Modelタブ ハイライト色:GenerateUIのModelタブで、読み込んだワークフローに実際に該当ノードが含まれる場合にCheckpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetworkのラベルおよび LoRA 列見出しを彩色する色を指定;デフォルトは明るい緑;変更は即時反映、「デフォルトにリセット」で元に戻す",
|
||||
helpNodes1: "ノードブラウザ:名前/説明/エイリアス/タグで検索、カテゴリ・パッケージ・タグ・グループ・お気に入りフィルタ",
|
||||
helpNodes2: "カード / テーブル表示モード切り替え(ページネーション付き)",
|
||||
helpNodes3: "ノードのお気に入り登録、カスタムタグの追加で整理",
|
||||
@@ -2171,14 +2201,14 @@ const LANGUAGES = {
|
||||
helpSidepanel13: "I タブ(Information):ComfyUI の PNG / WebP / JSON をドロップするとサイドパネル内でモデル・LoRA・プロンプトのメタデータを確認可能 — サブタブ: model / lora / Prompts;UnetLoaderGGUF・QuadrupleCLIPLoader・Lora Loader (LoraManager) ノードタイプに対応",
|
||||
helpSidepanel14: "Information タブ — キャンバスへドラッグ:model サブタブのアイテムをドラッグすると対応ローダーノードを配置(Checkpoint → CheckpointLoaderSimple、VAE → VAELoader、Diffusion Model → UNETLoader、Text Encoder → CLIPLoader);ダブルクリックでもキャンバス中央に配置可能",
|
||||
helpSidepanel15: "Information タブ — lora サブタブ:LoraLoader・LoraLoaderModelOnly・Lora Loader (LoraManager) ノード(APIフォーマット含む)から LoRA を検出;個別 LoRA をドラッグで LoraLoader を配置;「Multiple LORA」セクションで全 LoRA を Lora Loader (LoraManager) ノードに一括配置(LoRA構文自動入力);Prompts サブタブ:プロンプトをドラッグするとテキスト入力済みの CLIPTextEncode ノードを配置;クリックで全文表示・コピー可能",
|
||||
helpSidepanel16: "A タブ(AI ツール):翻訳・Chat・TOOLS・設定のサブタブ;Chat は LLM とのマルチターン会話;TOOLS は VLM 画像解析とワイルドカード生成に対応;設定は SPA の AI タブと localStorage で共有される",
|
||||
helpSidepanel16: "A タブ(AI ツール):翻訳・Chat・TOOLS・設定のサブタブ。Ollama、LM Studio、Lemonade、Unsloth に対応;Chat は LLM とのマルチターン会話;TOOLS は VLM 画像解析とワイルドカード生成に対応;設定は SPA の AI タブと localStorage で共有される",
|
||||
helpSidepanel17: "Send to Canvas:WorkflowタブのSend to CanvasまたはGalleryタブのCopy & Send Canvasをクリックするとワークフローをキャンバスへ直接送信(UI形式・API形式どちらも対応);ComfyUIのツールバーから開いていない場合はUI形式のみタイトルドラッグのフォールバック(タイトルが青くなり緑の●が表示される — ドラッグで読み込み)",
|
||||
helpAiTitle: "AI タブ(A)",
|
||||
helpAi1: "4ペインレイアウト — Translation | Chat | TOOLS | Settings — 全ペインが常時表示される",
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、または Lemonade を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、Lemonade、または Unsloth を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット;モデルがtool callingに対応している場合、「猫の画像を作って」のように頼むとGenerateUIに読み込み中のワークフローで生成を実行し、結果をチャット内に表示する;ペインヘッダーの「画像生成を許可する」チェックボックスでこの機能のON/OFFを切り替えられる;画像が添付されている場合(TOOLSペインにドロップした画像)はLLMにもビジョン入力として送信され、generate_image呼び出し時はT2Iの代わりにI2I(image-to-image)の元画像として使われる;tool callingとビジョンの動作を確認済みのモデル: Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade)の選択、APIのURL設定、接続テスト、モデル選択、Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade / Unsloth)の選択、APIのURL設定、接続テスト、モデル選択;Unslothはローカルアクセスでも常にAPIキー認証が必須——プラグインフォルダの .env.example を .env にコピーして UNSLOTH_API_KEY を設定し ComfyUI を再起動する(キーはサーバー側でのみ読み込まれ、ブラウザには一切送信されない);Unsloth Desktopアプリ側の設定画面で「モデル自動切り替え (OpenAI API)」もONにする必要がある——OFFのままだと、Unsloth Studio上で事前にモデルを手動ロードしていない限り HTTP 404 で接続に失敗する;Unslothのreasoningモデルは思考内容をインラインの<think>タグではなく別のreasoning_contentフィールドで返すため、Max tokensが少ないとモデルが思考だけでトークンを使い切り、空の返信になることがある——返信が空になる場合はMax tokensを増やす(2048以上を推奨);Generationセクション — Thinking modeトグル(デフォルトOFF;OllamaにはthinkフラグとしてOFF/ONを送信、バックエンドを問わずOFF時は出力中の<think>...</think>ブロックをクライアント側で除去するため、推論モデルの思考過程がTranslation/Chat/TOOLSの結果に混入しない)とMax tokens(Ollama: num_predict、LM Studio/Lemonade/Unsloth: max_tokens;0で無制限);Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi7: "Chat ペイン — SVG:LLMはComfyUIワークフローを一切使わず、SVGグラフィックをテキストとして直接生成することもできる;「SVGで〜を描いて」のように頼み、応答にSVGコードが含まれていれば自動でインラインプレビュー表示され、「Galleryに保存」ボタンでGalleryタブの出力フォルダに.svgファイルとして保存できる;tool calling(function calling)を使用しないため、tool calling非対応のモデルでも動作する",
|
||||
helpAi8: "Chat ペイン — スキル:メッセージ一覧上部のドロップダウンでスキルを選ぶと、その指示内容が system prompt として会話の先頭に付加される(表示上のチャット履歴には含まれない);鉛筆ボタンでスキル管理パネルを開き、.mdスキルファイルを作成・編集・削除できる(保存先: user/default/Workflow-Studio/ai_skills/);ファイル先頭の---で囲まれた部分にname/descriptionを書くとドロップダウンの表示に反映される;初期状態で2つのスキルを同梱: 「SVG Icon Generator」(描く前に対象を部位分解することで認識しやすいSVGを生成)と「Skill Creator」(対話形式で質問しながら新しいスキルファイルを下書きする);応答に```skillコードブロックが含まれる場合、メッセージ下部に「スキルとして保存」ボタンが表示され、内容が入った状態で管理パネルが開く",
|
||||
helpAi9: "スキル作成のヒント:ドロップダウンやツールチップで分かりやすいよう、frontmatterのname/descriptionは明確に書く;期待する出力形式を具体的に指示し、SVGや別のスキルファイルのように後で抽出したい成果物がある場合は専用のコードフェンスで囲むよう指示する;曖昧な指示より具体的で命令形の指示の方がローカルの小型モデルには効きやすく、短い実例を1つ添えるとさらに効果的;禁止事項(不適切な内容、セキュリティ、文字数制限など)は暗黙に期待せず明示的に書く;迷ったらドロップダウンで「Skill Creator」スキルを選び、作りたいスキルの概要を伝えれば対話形式で下書きしてくれる",
|
||||
@@ -2271,7 +2301,8 @@ const LANGUAGES = {
|
||||
helpImageEditExportTitle: "エクスポートとナビゲーション",
|
||||
helpImageEdit17: "Save PNG:全可視レイヤーを合成してPNGファイルとしてダウンロード",
|
||||
helpImageEdit18: "Save to Gallery:ファイル名を入力(デフォルト: wfs-image-YYYYMMDDHHmmss)してギャラリーのルートフォルダに合成PNGを保存(設定 > Gallery Output Folderで設定);Galleryタブを一度開いてルートフォルダを設定する必要あり",
|
||||
helpImageEdit19: "Send to ComfyUI:合成PNGをComfyUIのinputフォルダに /upload/image でアップロードし、Load Imageノードで選択可能にする",
|
||||
helpImageEdit19: "Save to Input:合成PNGをComfyUIのinputフォルダに /upload/image でアップロードし、Load Imageノードで選択可能にする",
|
||||
helpImageEdit19b: "Send to Workflow:合成PNGをアップロードする点はSave to Inputと同じだが、さらにアップロード結果のファイル名をComfyUIグラフ内で選択中のノード(無ければ最初に見つかったLoadImage系ノード)のimageウィジェットへ書き込む;ComfyUIのトップメニューボタンからWorkflow Studioを起動している必要がある",
|
||||
helpImageEdit20: "キャンバスナビゲーション:スクロールホイールでズーム、Space + ドラッグでパン",
|
||||
helpImageEdit21: "Undo / Redo:Ctrl+Z / Ctrl+Y(またはツールバーの ↩ ↪ ボタン)",
|
||||
helpImageEditKbdTitle: "Image Edit キーボードショートカット",
|
||||
@@ -2303,6 +2334,7 @@ const LANGUAGES = {
|
||||
helpImageEditMask11: "Text サブツール:キャンバスをクリックするとテキストスタンプオーバーレイを表示;テキストを入力し Font / Size / Bold / Italic / Align と Mode(Add = 白スタンプ / Erase = マスク削除)を設定して「Stamp」またはCtrl+Enterで確定;テキストエリア内の改行で複数行対応",
|
||||
helpImageEditMask12: "Vector サブツール:クリックでCatmull-Romスプラインの制御点を追加;最初の点を再クリック(またはEnter)で閉じてポリゴンを塗りつぶし;Backspaceで直前の点を削除;Escで全点をリセット;プロパティペインに現在の点数とMode(Add / Erase)を表示",
|
||||
helpImageEditMask13: "Shape サブツール:ドラッグで矩形または楕円のマスクを描画;Shift押下で正方形/正円;ドラッグ中は青の点線でプレビュー表示;プロパティペイン:Mode(Add / Erase)・Shape(Rect / Ellipse)",
|
||||
helpImageEditMask14: "Edit in Mask Editor One(Paint モードのみ):MASK EDITOR ONE セクションの「Edit in Mask Editor One →」をクリックすると、現在のキャンバス合成画像(アクティブレイヤーがマスクレイヤーの場合はその内容も)が、ComfyUI キャンバス上の MaskEditorOne ノード(選択中のノードがそれなら優先、無ければグラフ内で最初に見つかったノード)へ送信され、ComfyUI 側のブラウザタブで実際のエディタ(SAM3・BiRefNet・シェイプなど)が開く;そこで Apply をクリックすると結果がこちら側に新規マスクレイヤーとして取り込まれる;ComfyUI のトップメニューボタンから Workflow Studio を起動していること、ComfyUI のグラフに MaskEditorOne ノードが1つ以上存在することが必要",
|
||||
helpImageEditBlurTitle: "Blur ツール (≈)",
|
||||
helpImageEditBlur1: "Whole Blur:アクティブレイヤー全体にガウスぼかしを適用 — 強度スライダー(1〜50 px)で調整してApplyをクリック",
|
||||
helpImageEditBlur2: "Whole Mosaic:アクティブレイヤー全体にピクセル化モザイクを適用 — ブロックサイズスライダー(5〜100 px)で調整してApplyをクリック",
|
||||
@@ -2720,6 +2752,8 @@ const LANGUAGES = {
|
||||
labBatchCountTooSmall: "请先增加Batch数量再添加关键帧",
|
||||
labKeyframesExceedBatch: (maxIter, batchCount) => `存在设置到第${maxIter}次的关键帧,超过了当前Batch数量(${batchCount}),在增加Batch数量之前不会被应用`,
|
||||
labUseWorkflowDefault: "(保持工作流原设置)",
|
||||
labInheritsFrom: (val) => `(继承: ${val})`,
|
||||
labLoraNoNode: "工作流中未找到LoRA节点 — 已跳过LoRA设置",
|
||||
labAppliedAt: (n) => `应用:${n}`,
|
||||
labRevertToBase: "↩ 恢复第1次的设置",
|
||||
labApplyFromIteration: "从第几次开始应用",
|
||||
@@ -2803,6 +2837,9 @@ const LANGUAGES = {
|
||||
jsonColorCyan: "提示词 / 文本",
|
||||
jsonColorRed: "图像 / 文件",
|
||||
jsonColorReset: "恢复默认",
|
||||
modelTabColorSection: "GenerateUI Model 标签高亮颜色",
|
||||
modelTabColorLabel: "工作流中包含的项目颜色",
|
||||
modelTabColorHint: "在 GenerateUI 的 Model 标签中,当前工作流实际包含的项目(Checkpoint / VAE 等)的标签将以此颜色显示。",
|
||||
|
||||
// -- Prompt tab --
|
||||
tabPrompt: "提示词",
|
||||
@@ -3166,6 +3203,10 @@ const LANGUAGES = {
|
||||
aiSettingsModelSection: "选择模型",
|
||||
aiSettingsRefreshBtn: "刷新",
|
||||
aiSettingsModelPlaceholder: "-- 请选择模型 --",
|
||||
aiSettingsGenerationTitle: "生成设置",
|
||||
aiSettingsThinkingMode: "Thinking mode(思考模式)",
|
||||
aiSettingsMaxTokens: "Max tokens(最大令牌数)",
|
||||
aiSettingsMaxTokensPlaceholder: "0 = 无限制",
|
||||
aiSettingsFreeLang: "Free 语言设置",
|
||||
aiSettingsInputLang: "输入语言",
|
||||
aiSettingsOutputLang: "翻译语言",
|
||||
@@ -3181,6 +3222,7 @@ const LANGUAGES = {
|
||||
aiStatusConnectOk: "连接成功",
|
||||
aiStatusConnectFail: "连接失败:",
|
||||
aiToastTransFailed: "翻译失败:",
|
||||
aiToastMaybeNotTranslated: "模型原样返回了原文,可能未进行翻译。请尝试更换模型。",
|
||||
aiToastNoCopyText: "没有可复制的文字",
|
||||
aiToastCopied: "已复制",
|
||||
aiToastNoModels: "未找到模型",
|
||||
@@ -3236,7 +3278,8 @@ const LANGUAGES = {
|
||||
helpGen27: "Input标签页 → Image子面板 — 每张LoadImage卡片现在有三个按钮:Apply(不变)、Clear(将该卡片待处理的选择/预览重置为空;不会改动工作流节点本身,因为LoadImage始终需要一个有效的文件名)和Placeholder。面板顶部的「Placeholder Image」区域(属于低频功能,默认折叠,点击可展开)用于配置共享的默认值:Color模式(设置宽度/高度/颜色,即时生成纯色PNG)或Image模式(拖入一张默认图片,之后原样复用)。点击某张卡片自己的Placeholder按钮,会立即只替换该卡片的图片为已配置的默认值——无需再点Apply;当有多张LoadImage卡片时,每张都可以单独替换。Placeholder配置(模式、尺寸、颜色、默认图片)会跨会话记住",
|
||||
helpGen3: "5标签布局 — Input(提示词+图像)、Model、Settings、Feeder、Batch — Input/Model/Settings各含实时预览和直接编辑的Raw JSON列",
|
||||
helpGen4: "Input标签:含Prompt和Image子标签(拖放上传);Prompt标签显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive);每个Apply按钮左侧的↺按钮可将文本框恢复为节点当前已应用的值;选中文字(或将光标置于单词/括号内也可)后按Ctrl+↑/↓,即可以(text:weight)语法以±0.05为单位调整强调权重(权重恢复为1.0时自动去除括号)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/LoRA(Single/Stack标签)/ControlNet/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)/Hypernetwork(带Strength输入)选择器(含筛选)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)始终显示;ControlNet和Hypernetwork(带Strength输入)因使用频率较低默认折叠 — 点击标题展开;LoRA(Single/Stack标签)拥有独立的列;所有选择器均含筛选;LoRA Stack — 点击Apply会将所有启用的LoRA写入目标节点,并同时将LoRA语法和CivitAI触发词插入Positive提示词;再次Apply会移除未启用的LoRA并重新添加启用的LoRA以更新提示词",
|
||||
helpGen28: "Model标签 — 高亮颜色:当前工作流实际包含某类型节点时(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork),对应标签及LoRA列标题会以指定颜色(默认亮绿色)显示;即使ControlNet/Hypernetwork处于折叠状态该颜色依然可见,无需展开即可判断是否在使用;颜色可在Settings标签中自定义",
|
||||
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致)",
|
||||
helpGen8: "种子管理:随机/固定/递增/递减",
|
||||
@@ -3279,21 +3322,23 @@ const LANGUAGES = {
|
||||
helpFeederGal6: "WFS_GalleryFeeder 画布控件:该节点在 ComfyUI 画布上直接添加了 Run / Stop / After Gen 组件——与 WFM 界面中的控件功能相同,无需打开 WFM 即可运行图库供给循环",
|
||||
|
||||
helpLabTitle: "GenerateUI标签页 — Lab子标签",
|
||||
helpLabDesc: "实验性的I2I批量生成功能。将GenerateUI当前加载的工作流运行N次,可分别独立地从任意指定次数开始更改Checkpoint、VAE、Prompt、KSampler。包含Setting和Results两个子标签。",
|
||||
helpLabDesc: "实验性的I2I批量生成功能。将GenerateUI当前加载的工作流运行N次,可分别独立地从任意指定次数开始更改Model(Checkpoint/LoRA/VAE)、Prompt、KSampler。包含Setting和Results两个子标签。",
|
||||
helpLabCardColumnsTitle: "Setting子标签 — 图片与列",
|
||||
helpLab1: "左侧面板 — Image:拖入或点击选择作为I2I源图片;会上传到ComfyUI的input文件夹,只要未勾选\"Use generated image for next\",每次迭代都会使用该图片",
|
||||
helpLab2: "左侧面板 — Use generated image for next:勾选后,从第2次开始使用上一次迭代生成的第1张图片作为I2I输入,而不是原始拖入的图片(第1次始终使用原始图片)",
|
||||
helpLab3: "中间面板 — Checkpoint、VAE、Prompt、KSampler这4列相互独立;每列各自维护自己的关键帧列表,而不是共享同一个行网格",
|
||||
helpLab3: "中间面板 — Model、Prompt、KSampler这3列相互独立;每列各自维护自己的关键帧列表,而不是共享同一个行网格",
|
||||
helpLab4: "每列的+/-:+会在下一个空闲的迭代序号(不超过Batch数)添加关键帧并立即打开编辑弹窗;-会删除最后一个关键帧(第1个关键帧无法删除);除第1个之外的任何关键帧,也可以在其自己的编辑弹窗中通过「删除」按钮单独删除,不再限于最后一个",
|
||||
helpLab5: "点击单元格即可编辑:第1个关键帧设置基准值(留空则保持工作流本身的设置);第2个及以后的关键帧还可以选择从第几次开始应用,以及是否勾选\"恢复第1次的设置\"",
|
||||
helpLab6: "第N次迭代的实际生效值:使用迭代序号≤N中序号最大的关键帧,并一直延续到下一个关键帧改变它为止;勾选\"恢复第1次的设置\"时会直接复用第1次的值(若第1次为空则使用工作流默认值),而不是指定新值;将Batch数量减少到低于现有关键帧的迭代序号时(无论是手动修改,还是加载/应用方案),该关键帧不会被删除或重新编号,只会弹出警告提示,并在Batch数量再次提高之前一直处于运行循环无法到达的\"休眠\"状态",
|
||||
helpLab6: "第N次迭代的实际生效值:使用迭代序号≤N中序号最大的关键帧,并一直延续到下一个关键帧改变它为止;勾选\"恢复第1次的设置\"时会直接复用第1次的值(若第1次为空则使用工作流默认值),而不是指定新值;将Batch数量减少到低于现有关键帧的迭代序号时(无论是手动修改,还是加载/应用方案),该关键帧不会被删除或重新编号,只会弹出警告提示,并在Batch数量再次提高之前一直处于运行循环无法到达的\"休眠\"状态。这种「整行」解决方式对Prompt和KSampler完全适用,但Model列例外——它会将Checkpoint/LoRA/VAE三个字段分别独立解决(详见下文)",
|
||||
helpLab7: "Lab不会修改Input/Model/Settings中显示的工作流——每次迭代都在加载工作流的一个副本上运行,因此这里的关键帧设置不会影响其他子标签",
|
||||
helpLab8: "Prompt单元格编辑弹窗的附加功能:「Get from GenerateUI」从GenerateUI当前加载的工作流中获取Positive/Negative;「Get from Image」从Lab左侧IMAGE区域当前加载的图片中提取提示词(与Metadata标签相同的检测逻辑,无需另外选择文件);「Style」行点击「应用」按钮后,会将GenerateUI工具栏顶部Style下拉菜单中当前选中的风格名称,以逗号分隔追加到下方的Positive/Negative文本之后(不会清空已有内容),并自动勾选Style复选框、在按钮旁边(而非按钮上)显示已应用的风格名称——风格名称只能通过顶部下拉菜单选择,Lab没有自己独立的风格选择器;点击应用按钮与顶部工具栏自己的Style启用复选框无关;手动取消勾选Style复选框只会清除该名称显示(不会自动删除已追加到Positive/Negative中的文本,如不需要请手动编辑删除)",
|
||||
helpLab9: "左侧面板 — 「T2I workflow (no source image)」:当加载的工作流是文生图(T2I,没有LoadImage节点)时勾选此项。它会跳过\"请先拖入源图片\"的要求,忽略任何已拖入的图片,Checkpoint/VAE/Prompt/KSampler的关键帧仍会正常应用;开启期间Image拖放区域和「Use generated image for next」会变灰(之前拖入的图片不会被删除,以便切回I2I时使用);该设置会作为 t2i_mode 保存在方案中",
|
||||
helpLab9: "左侧面板 — 「T2I workflow (no source image)」:当加载的工作流是文生图(T2I,没有LoadImage节点)时勾选此项。它会跳过\"请先拖入源图片\"的要求,忽略任何已拖入的图片,Model/Prompt/KSampler的关键帧仍会正常应用;开启期间Image拖放区域和「Use generated image for next」会变灰(之前拖入的图片不会被删除,以便切回I2I时使用);该设置会作为 t2i_mode 保存在方案中",
|
||||
helpLab10: "通配符:Prompt列关键帧中输入的Positive/Negative文本,支持与主GenerateUI相同的A1111风格 __name__ 语法——每个关键帧的文本会在该次迭代运行前才展开,因此即使复用同一个关键帧,每次迭代也可能抽到不同的随机行",
|
||||
helpLab11: "Prompt单元格编辑弹窗中还提供Clear(将Positive和Negative都清空)和Get from Previous(从同一列紧邻的上一个关键帧复制Positive和Negative——例如编辑#3时会整段复制#2的提示词);关键帧#1没有更早的行可复制,因此不显示该按钮",
|
||||
helpLab12: "每一列的关键帧#1,只要还没有被显式保存覆盖,就会用当前GenerateUI中已加载工作流的实际设置——Checkpoint、VAE、Prompt、KSampler——代替空白占位显示,同时体现在其网格单元格(虚线边框)和编辑弹窗的预填内容中;哪怕不做任何修改直接保存,也会将该值锁定为真正的覆盖值,虚线/实时显示随之消失;每次切换到Lab子标签页,都会重新根据当前GenerateUI中加载的内容刷新",
|
||||
helpLab12: "每一列的关键帧#1,只要还没有被显式保存覆盖,就会用当前GenerateUI中已加载工作流的实际设置代替空白占位显示(Model列仅覆盖Checkpoint和VAE两项——LoRA不会被实时反映,因为无法从工作流节点中安全地反推出\"当前生效的\"LoRA),同时体现在其网格单元格(虚线边框)和编辑弹窗的预填内容中;哪怕不做任何修改直接保存,也会将该值锁定为真正的覆盖值,虚线/实时显示随之消失;每次切换到Lab子标签页,都会重新根据当前GenerateUI中加载的内容刷新",
|
||||
helpLab13: "Bypass复选框(包括#1在内的每个关键帧都有):在判定某次迭代应使用哪一行设置时,会把该行完全跳过,如同它不存在——于是它之前生效的设置(更早的关键帧,或最终什么都不应用)会继续保持;这只是Lab内部的概念,绝不会改动实际ComfyUI节点的mode;勾选后会禁用该行的取值字段以及其Revert复选框(二者互斥,不能同时生效);单元格会显示琥珀色的「⏭ Bypassed」标签,其优先级高于Revert显示和实时显示",
|
||||
helpLab14: "Model列表头的C/L/V按钮用于切换Checkpoint、LoRA(仅支持Single,不支持Stack)、VAE这三个相互独立的关键帧列表中,当前单元格显示、+/-以及编辑弹窗针对的是哪一个。三者各自拥有完全独立的时间线,与Prompt、KSampler完全相同,只是共用同一列的显示空间,看起来像是合并在一起。查看其中一项时添加、删除或编辑关键帧,完全不会影响另外两项。当前加载的工作流中若确实存在对应类型的节点,相应按钮会变为与GenerateUI的Model标签页相同的高亮颜色(可在Settings中调整)。若设置了LoRA但工作流中完全没有LoRA节点,Run执行期间只会警告一次并跳过该项设置——与VAE不同,Lab不会自动插入LoRA节点,因为那需要重新连接Checkpoint的Model/CLIP输出线路",
|
||||
helpLab15: "Checkpoint/LoRA/VAE与其他列采用完全相同的实际生效值规则(见上文),各自独立向后延续:其中一项的关键帧不会使另外两项恢复为工作流原始设置。在编辑弹窗中,若某字段留空,且同一列表中更早的关键帧已设置过值,其占位符会显示「(继承: ...)」而不是「(保持工作流原设置)」,方便确认实际会应用的值。「恢复第1次的设置」和Bypass与+/-一样,只会作用于当前C/L/V切换选中的那一项,不会影响另外两项",
|
||||
helpLabCardPlanTitle: "Setting子标签 — 运行与方案文件",
|
||||
helpLabPlan1: "右侧面板 — Batch:要运行的迭代次数;Note:与方案一起保存的自由备注",
|
||||
helpLabPlan2: "Run/Pause/Cancel:Run开始批量循环;Pause/Resume在各次迭代之间暂停/继续;Cancel会中断当前生成并停止循环",
|
||||
@@ -3339,6 +3384,7 @@ const LANGUAGES = {
|
||||
helpSettings11: "文字大小:单个滑块(10–28 px)可一次性调整所有提示词和聊天文本框的字体大小 — GenerateUI正/负向提示词、AI助手聊天输入、预设正/负向提示、通配符提示和文件编辑器、元数据提示词全文预览;即时生效并通过「保存设置」保存",
|
||||
helpSettings12: "RAW JSON 配色:自定义GenerateUI中Raw JSON编辑器的6种语法高亮颜色 — 默认文本、名称/调度器、标题、宽度/高度、提示词/文本、图像/文件;颜色选择即时生效;「重置默认」恢复原始配色",
|
||||
helpSettings13: "CivitAI 站点:在 civitai.com(仅SFW)和 civitai.red(无限制)之间切换 — 决定在Models标签CivitAI面板中点击模型链接时打开的站点",
|
||||
helpSettings14: "GenerateUI Model标签高亮颜色:设置当前工作流实际包含对应类型节点时,GenerateUI Model标签中Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork标签及LoRA列标题的显示颜色;默认为亮绿色;更改即时生效,「重置默认」可恢复",
|
||||
helpNodes1: "节点浏览器:按名称/描述/别名/标签搜索,按分类、包、标签、组、收藏筛选",
|
||||
helpNodes2: "卡片和表格视图模式切换(带分页)",
|
||||
helpNodes3: "收藏节点,添加自定义标签进行整理",
|
||||
@@ -3413,14 +3459,14 @@ const LANGUAGES = {
|
||||
helpSidepanel13: "I 标签(Information):拖放 ComfyUI PNG/WebP/JSON 即可在侧面板内查看模型、LoRA 和提示词元数据 — 子标签:model / lora / Prompts;支持 UnetLoaderGGUF、QuadrupleCLIPLoader 和 Lora Loader (LoraManager) 节点类型",
|
||||
helpSidepanel14: "Information 标签 — 拖拽到画布:model 子标签拖拽可放置对应加载节点(Checkpoint → CheckpointLoaderSimple、VAE → VAELoader、Diffusion Model → UNETLoader、Text Encoder → CLIPLoader);双击也可放置到画布中央",
|
||||
helpSidepanel15: "Information 标签 — lora 子标签:可检测 LoraLoader、LoraLoaderModelOnly 及 Lora Loader (LoraManager) 节点(含API格式)的 LoRA;拖拽单个 LoRA 放置 LoraLoader;「Multiple LORA」区域可将全部 LoRA 一次性放入 Lora Loader (LoraManager) 节点并自动填入LoRA语法;Prompts 子标签:拖拽提示词放置预填文本的 CLIPTextEncode 节点;点击可查看全文并复制",
|
||||
helpSidepanel16: "A 标签(AI 工具):翻译、Chat、TOOLS 和设置子标签;Chat 支持与 LLM 的多轮对话;TOOLS 包含 VLM 图像分析和通配符生成;设置通过 localStorage 与 SPA 的 AI 标签共享",
|
||||
helpSidepanel16: "A 标签(AI 工具):翻译、Chat、TOOLS 和设置子标签,支持 Ollama、LM Studio、Lemonade 或 Unsloth;Chat 支持与 LLM 的多轮对话;TOOLS 包含 VLM 图像分析和通配符生成;设置通过 localStorage 与 SPA 的 AI 标签共享",
|
||||
helpSidepanel17: "Send to Canvas:在Workflow标签点击'Send to Canvas'或Gallery标签点击'Copy & Send Canvas',工作流直接发送到ComfyUI画布(支持UI和API所有格式);若Workflow Studio不是从ComfyUI工具栏打开,UI格式工作流将回退到标题拖拽(标题变蓝并显示绿色●——拖拽到画布加载)",
|
||||
helpAiTitle: "AI 标签页(A)",
|
||||
helpAi1: "4 面板布局 — Translation | Chat | TOOLS | Settings — 所有面板始终可见",
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio 或 Lemonade 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio、Lemonade 或 Unsloth 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史;如果模型支持 tool calling,当你要求生成图像(例如\"生成一张猫的图片\")时,会使用 GenerateUI 中当前加载的工作流执行生成,并在对话中显示结果;面板标题栏的\"允许生成图像\"复选框可开关此功能;如果附加了图像(在 TOOLS 面板中拖放的图像),该图像会作为视觉输入发送给 LLM,调用 generate_image 时会以该图像为基础执行图生图(I2I),而不是文生图;已验证可正常支持 tool calling 与视觉功能的模型:Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条);拖放的图像也会作为 Chat 面板的附件共享(在该处显示并用于图生图生成),直到点击 ✕ 按钮清除",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade)、设置 API URL、测试连接、选择模型、配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade / Unsloth)、设置 API URL、测试连接、选择模型;Unsloth 即使在本地访问时也始终需要 API 密钥认证——将插件文件夹中的 .env.example 复制为 .env 并设置 UNSLOTH_API_KEY,然后重启 ComfyUI(密钥仅在服务器端读取,绝不会发送到浏览器);还需要在 Unsloth Desktop 应用自身的设置界面中开启「Auto model switching (OpenAI API)」——若保持关闭,除非已在 Unsloth Studio 中手动加载过模型,否则请求会以 HTTP 404 失败;Unsloth 的推理模型会将思考内容放在单独的 reasoning_content 字段中,而不是内联的 <think> 标签里,因此 Max tokens 太小会导致模型把全部配额都用在思考上,返回空白回复——如果回复为空,请调高 Max tokens(建议 2048 以上);Generation 部分 — Thinking mode 开关(默认关闭;Ollama会收到think标志,无论使用哪个后端,关闭时都会从输出中去除<think>...</think>思考块,避免推理模型的思考过程混入Translation/Chat/TOOLS的结果)以及 Max tokens(Ollama: num_predict,LM Studio/Lemonade/Unsloth: max_tokens;0为不限);配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi7: "Chat 面板 — SVG:LLM 还可以直接以文本形式生成 SVG 图形,完全不涉及 ComfyUI 工作流;只需要求它用 SVG 绘制内容,如果回复中包含 SVG 代码,会自动渲染为内嵌预览,并提供\"保存到图库\"按钮,将其保存为 .svg 文件到 Gallery 标签页的输出文件夹;该功能不使用 tool calling(函数调用),因此不支持 tool calling 的模型也可以使用",
|
||||
helpAi8: "Chat 面板 — 技能:在消息列表上方的下拉菜单中选择一个技能,其内容会作为 system prompt 添加到对话开头(不会显示在可见的聊天记录中);点击铅笔按钮打开技能管理面板,可创建、编辑、删除 .md 技能文件(保存路径:user/default/Workflow-Studio/ai_skills/);文件开头 --- 之间的 name/description 字段会显示在下拉菜单中;默认内置两个技能:「SVG Icon Generator」(在绘制前先将对象拆解为多个部件,以生成更易识别的 SVG)和「Skill Creator」(通过对话向你提问并起草新的技能文件);当回复中包含```skill代码块时,消息下方会出现「保存为技能」按钮,点击后会打开管理面板并预填内容",
|
||||
helpAi9: "编写技能的小贴士:为 frontmatter 的 name/description 写清楚的内容,便于在下拉菜单和提示中阅读;明确说明期望的输出格式,如果有需要后续提取的成果物(如 SVG 或另一个技能文件),指示它用专门的代码围栏包裹;具体、祈使句式的指令比笼统的说明对本地小模型更有效,附上一个简短示例效果更佳;明确写出硬性约束(禁止内容、安全性、长度限制等),不要指望模型会自行推断;如果不确定怎么写,可以在下拉菜单中选择「Skill Creator」技能并描述你想要的技能,它会通过提问帮你起草",
|
||||
@@ -3513,7 +3559,8 @@ const LANGUAGES = {
|
||||
helpImageEditExportTitle: "导出与导航",
|
||||
helpImageEdit17: "Save PNG:合并所有可见图层并下载为PNG文件",
|
||||
helpImageEdit18: "Save to Gallery:输入文件名(默认:wfs-image-YYYYMMDDHHmmss)将合成PNG保存到Gallery根文件夹(在设置 > Gallery Output Folder中配置);需要至少打开过一次Gallery标签以设置根目录",
|
||||
helpImageEdit19: "Send to ComfyUI:通过/upload/image将合成PNG上传到ComfyUI的输入文件夹,使其可在Load Image节点中选择",
|
||||
helpImageEdit19: "Save to Input:通过/upload/image将合成PNG上传到ComfyUI的输入文件夹,使其可在Load Image节点中选择",
|
||||
helpImageEdit19b: "Send to Workflow:与 Save to Input 一样会上传合成PNG,但还会将上传结果的文件名写入 ComfyUI 图中当前选中节点(若无选中节点则回退到找到的第一个 LoadImage 类节点)的 image 部件;需要通过 ComfyUI 顶部菜单按钮打开 Workflow Studio",
|
||||
helpImageEdit20: "画布导航:滚轮缩放,Space+拖动平移",
|
||||
helpImageEdit21: "撤销/重做:Ctrl+Z / Ctrl+Y(或工具栏的 ↩ ↪ 按钮)",
|
||||
helpImageEditKbdTitle: "图像编辑键盘快捷键",
|
||||
@@ -3545,6 +3592,7 @@ const LANGUAGES = {
|
||||
helpImageEditMask11: "Text子工具:点击画布打开文字图章面板;输入文字,设置Font / Size / Bold / Italic / Align及Mode(Add = 白色图章 / Erase = 消除遮罩),点击Stamp或按Ctrl+Enter确认;支持多行文字(在文本框中换行)",
|
||||
helpImageEditMask12: "Vector子工具:点击添加Catmull-Rom样条控制点;点击第一个控制点(或按Enter)关闭并填充多边形;Backspace删除最后一个点;Esc重置所有点;属性面板显示当前点数及Mode(Add / Erase)",
|
||||
helpImageEditMask13: "Shape子工具:拖动绘制矩形或椭圆遮罩;按住Shift可绘制正方形/正圆;拖动时蓝色虚线预览形状;属性面板:Mode(Add / Erase)、Shape(Rect / Ellipse)",
|
||||
helpImageEditMask14: "Edit in Mask Editor One(仅限 Paint 模式):点击 MASK EDITOR ONE 区域中的“Edit in Mask Editor One →”,会将当前画布合成图像(若当前激活图层是遮罩图层,则一并发送其内容)发送到 ComfyUI 画布上的 MaskEditorOne 节点(优先使用当前选中的节点,否则使用图中找到的第一个该类型节点),并在 ComfyUI 浏览器标签页中打开完整编辑器(SAM3、BiRefNet、形状等);在那里点击 Apply 后,结果会作为新遮罩图层导入到这里;需要已通过 ComfyUI 顶部菜单按钮打开 Workflow Studio,且 ComfyUI 图中至少存在一个 MaskEditorOne 节点",
|
||||
helpImageEditBlurTitle: "Blur 工具 (≈)",
|
||||
helpImageEditBlur1: "Whole Blur:对整个活动图层应用高斯模糊 — 调整强度滑块(1–50 px),点击Apply",
|
||||
helpImageEditBlur2: "Whole Mosaic:对整个活动图层应用像素化马赛克 — 调整块大小滑块(5–100 px),点击Apply",
|
||||
|
||||
+102
-1173
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Image Edit Tab - Background Removal
|
||||
* Two backends: a lightweight in-browser model (@imgly/background-removal)
|
||||
* and BiRefNet via the Mask Editor One server endpoints.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class BgRemove {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(newLayer: object) => void} callbacks.onLayerAdded
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/birefnet/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.birefnetAvailable = json.loaded === true || json.model_found === true;
|
||||
} catch {
|
||||
this.birefnetAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const birefnetDisabled = this.birefnetAvailable ? "" : "disabled";
|
||||
const birefnetLabel = this.birefnetAvailable
|
||||
? "BiRefNet (Mask Editor One)"
|
||||
: "BiRefNet (Mask Editor One required)";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<label>Model</label>
|
||||
<select id="ie-bgremove-model" class="ie-opt-select">
|
||||
<option value="imgly">Lightweight (@imgly)</option>
|
||||
<option value="birefnet" ${birefnetDisabled}>${birefnetLabel}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-bgremove-new-layer" checked> New Layer
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-bgremove-btn">Remove BG</button>
|
||||
</div>
|
||||
<span id="ie-bgremove-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;"></span>
|
||||
`;
|
||||
document.getElementById("ie-bgremove-btn")?.addEventListener("click", () => this.apply());
|
||||
}
|
||||
|
||||
async apply() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
|
||||
const model = document.getElementById("ie-bgremove-model")?.value ?? "imgly";
|
||||
const asNew = document.getElementById("ie-bgremove-new-layer")?.checked ?? true;
|
||||
const statusEl = document.getElementById("ie-bgremove-status");
|
||||
const btn = document.getElementById("ie-bgremove-btn");
|
||||
const setStatus = msg => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
setStatus("Starting...");
|
||||
|
||||
try {
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
let resultDataUrl;
|
||||
if (model === "imgly") {
|
||||
resultDataUrl = await this._removeImgly(dataUrl, setStatus);
|
||||
} else {
|
||||
resultDataUrl = await this._removeBiRefNet(dataUrl, setStatus);
|
||||
}
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Result image load failed"));
|
||||
i.src = resultDataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (asNew) {
|
||||
const newL = layerMgr.addLayer("image", layer.name + " (no bg)", {
|
||||
contentW: img.width, contentH: img.height,
|
||||
displayW: layer.displayW, displayH: layer.displayH,
|
||||
x: layer.x, y: layer.y,
|
||||
});
|
||||
newL.ctx.drawImage(img, 0, 0);
|
||||
layerMgr.setActive(newL.id);
|
||||
this._cb.onLayerAdded(newL);
|
||||
} else {
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
setStatus("Done!");
|
||||
setTimeout(() => setStatus(""), 3000);
|
||||
showToast("Background removed", "success");
|
||||
|
||||
} catch (err) {
|
||||
setStatus("Error: " + err.message);
|
||||
showToast("BG remove failed: " + err.message, "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _removeImgly(dataUrl, onStatus) {
|
||||
if (!window._wfmImglyRemoveBg) {
|
||||
onStatus("Loading model...");
|
||||
const mod = await import("https://esm.sh/@imgly/background-removal@1.5.7?bundle&target=es2022");
|
||||
window._wfmImglyRemoveBg = mod.removeBackground;
|
||||
}
|
||||
onStatus("Processing...");
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const resultBlob = await window._wfmImglyRemoveBg(blob, {
|
||||
publicPath: "https://staticimgly.com/@imgly/background-removal-data/1.5.7/dist/",
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(resultBlob);
|
||||
});
|
||||
}
|
||||
|
||||
async _removeBiRefNet(dataUrl, onStatus) {
|
||||
const NODE_ID = "wfs_bgremove";
|
||||
|
||||
// 1. アクティブレイヤー画像を Mask Editor One のキャッシュに登録
|
||||
onStatus("Sending image...");
|
||||
const storeResp = await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
if (!storeResp.ok) throw new Error("Failed to cache image for BiRefNet");
|
||||
|
||||
// 2. BiRefNet 推論実行
|
||||
onStatus("Running BiRefNet...");
|
||||
const resp = await fetch("/mask_editor/birefnet/remove_bg", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
|
||||
// 3. グレースケールマスク (白=前景) → RGBA PNG に変換して返す
|
||||
onStatus("Compositing...");
|
||||
return await this._applyMaskToImage(dataUrl, json.mask_b64);
|
||||
}
|
||||
|
||||
async _applyMaskToImage(imageB64, maskB64) {
|
||||
const loadImage = src => new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed: " + src.slice(0, 40)));
|
||||
img.src = src;
|
||||
});
|
||||
const [origImg, maskImg] = await Promise.all([loadImage(imageB64), loadImage(maskB64)]);
|
||||
|
||||
const w = origImg.naturalWidth;
|
||||
const h = origImg.naturalHeight;
|
||||
|
||||
// マスク: グレースケール輝度値 → アルファチャンネル (RGBA 白マスク)
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = w;
|
||||
maskCanvas.height = h;
|
||||
const mc = maskCanvas.getContext("2d");
|
||||
mc.drawImage(maskImg, 0, 0, w, h);
|
||||
const maskData = mc.getImageData(0, 0, w, h);
|
||||
const md = maskData.data;
|
||||
for (let i = 0; i < md.length; i += 4) {
|
||||
md[i + 3] = md[i]; // 輝度 → アルファ
|
||||
md[i] = md[i + 1] = md[i + 2] = 255;
|
||||
}
|
||||
mc.putImageData(maskData, 0, 0);
|
||||
|
||||
// 元画像にマスクを destination-in で適用
|
||||
const out = document.createElement("canvas");
|
||||
out.width = w;
|
||||
out.height = h;
|
||||
const ctx = out.getContext("2d");
|
||||
ctx.drawImage(origImg, 0, 0, w, h);
|
||||
ctx.globalCompositeOperation = "destination-in";
|
||||
ctx.drawImage(maskCanvas, 0, 0, w, h);
|
||||
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Image Edit Tab - Blur / Mosaic Tool
|
||||
* Whole-layer blur/mosaic buttons plus a drag-to-select rectangle mode
|
||||
* that applies blur or mosaic to just the dragged region.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
function _applyMosaicToRegion(ctx, x, y, w, h, size) {
|
||||
if (w <= 0 || h <= 0 || size < 1) return;
|
||||
const imgData = ctx.getImageData(x, y, w, h);
|
||||
const d = imgData.data;
|
||||
for (let py = 0; py < h; py += size) {
|
||||
for (let px = 0; px < w; px += size) {
|
||||
const i = (py * w + px) * 4;
|
||||
const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3];
|
||||
for (let by = py; by < Math.min(py + size, h); by++) {
|
||||
for (let bx = px; bx < Math.min(px + size, w); bx++) {
|
||||
const j = (by * w + bx) * 4;
|
||||
d[j] = r; d[j + 1] = g; d[j + 2] = b; d[j + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, x, y);
|
||||
}
|
||||
|
||||
export class BlurTool {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => number} callbacks.getZoom
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.rectMode = null; // null | 'blur' | 'mosaic'
|
||||
this._dragging = false;
|
||||
this._dragStart = null;
|
||||
this._dragCur = null;
|
||||
}
|
||||
|
||||
activate() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.rectMode = null;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) {
|
||||
overlay.style.cursor = "";
|
||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const blurOn = this.rectMode === "blur";
|
||||
const mosaicOn = this.rectMode === "mosaic";
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Whole:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Blur</label>
|
||||
<input type="range" id="ie-whole-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-whole-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-blur-apply">Apply</button>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">Mosaic</label>
|
||||
<input type="range" id="ie-whole-mosaic" min="5" max="100" value="20" style="width:70px;">
|
||||
<span id="ie-whole-mosaic-val" style="font-size:11px;min-width:22px;">20</span>px
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-whole-mosaic-apply">Apply</button>
|
||||
</div>
|
||||
<div style="width:1px;height:22px;background:var(--wfm-border);margin:0 6px;flex-shrink:0;"></div>
|
||||
<div class="ie-opt-group">
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);">Rect:</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${blurOn ? " ie-opt-active" : ""}" id="ie-rect-blur-toggle"
|
||||
style="${blurOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Blur: ${blurOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-blur" min="1" max="50" value="10" style="width:70px;">
|
||||
<span id="ie-rect-blur-val" style="font-size:11px;min-width:22px;">10</span>px
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm${mosaicOn ? " ie-opt-active" : ""}" id="ie-rect-mosaic-toggle"
|
||||
style="${mosaicOn ? "background:var(--wfm-accent,#4682e6);color:#fff;" : ""}">
|
||||
Rect Mosaic: ${mosaicOn ? "ON" : "OFF"}
|
||||
</button>
|
||||
<input type="range" id="ie-rect-mosaic" min="5" max="50" value="15" style="width:70px;">
|
||||
<span id="ie-rect-mosaic-val" style="font-size:11px;min-width:22px;">15</span>px
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-whole-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-whole-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-whole-blur-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeBlur(parseInt(document.getElementById("ie-whole-blur").value));
|
||||
});
|
||||
document.getElementById("ie-whole-mosaic-apply")?.addEventListener("click", () => {
|
||||
this.applyWholeMosaic(parseInt(document.getElementById("ie-whole-mosaic").value));
|
||||
});
|
||||
document.getElementById("ie-rect-blur-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "blur" ? null : "blur";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic-toggle")?.addEventListener("click", () => {
|
||||
this.rectMode = this.rectMode === "mosaic" ? null : "mosaic";
|
||||
this._cb.renderToolOptions("blur");
|
||||
const ov = document.getElementById("ie-canvas-overlay");
|
||||
if (ov) ov.style.cursor = this.rectMode ? "crosshair" : "default";
|
||||
});
|
||||
document.getElementById("ie-rect-blur")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-blur-val").textContent = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-rect-mosaic")?.addEventListener("input", e => {
|
||||
document.getElementById("ie-rect-mosaic-val").textContent = e.target.value;
|
||||
});
|
||||
}
|
||||
|
||||
onMouseDown(pos) {
|
||||
if (!this.rectMode) return;
|
||||
this._dragging = true;
|
||||
this._dragStart = { x: pos.x, y: pos.y };
|
||||
this._dragCur = { x: pos.x, y: pos.y };
|
||||
}
|
||||
|
||||
onMouseMove(pos) {
|
||||
if (!this._dragging) return;
|
||||
this._dragCur = pos;
|
||||
this._drawPreview();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
this._applyRectEffect();
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (overlay) overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
|
||||
applyWholeBlur(amount) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
applyWholeMosaic(size) {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return;
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.saveUndo();
|
||||
_applyMosaicToRegion(layer.ctx, 0, 0, layer.canvas.width, layer.canvas.height, size);
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
|
||||
// canvas座標 (cx,cy) → layer.canvas 座標への逆変換
|
||||
_canvasToLayerCoords(layer, cx, cy) {
|
||||
const centerX = layer.x + layer.displayW / 2;
|
||||
const centerY = layer.y + layer.displayH / 2;
|
||||
const dx = cx - centerX, dy = cy - centerY;
|
||||
const angle = -(layer.rotation || 0) * Math.PI / 180;
|
||||
const rdx = dx * Math.cos(angle) - dy * Math.sin(angle);
|
||||
const rdy = dx * Math.sin(angle) + dy * Math.cos(angle);
|
||||
const scaleX = layer.displayW / layer.canvas.width;
|
||||
const scaleY = layer.displayH / layer.canvas.height;
|
||||
let lx = rdx / scaleX + layer.canvas.width / 2;
|
||||
let ly = rdy / scaleY + layer.canvas.height / 2;
|
||||
if (layer.flipX) lx = layer.canvas.width - lx;
|
||||
if (layer.flipY) ly = layer.canvas.height - ly;
|
||||
return { x: lx, y: ly };
|
||||
}
|
||||
|
||||
_drawPreview() {
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
if (!overlay) return;
|
||||
const ctx = overlay.getContext("2d");
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (!s || !c) return;
|
||||
const x = Math.min(s.x, c.x), y = Math.min(s.y, c.y);
|
||||
const w = Math.abs(c.x - s.x), h = Math.abs(c.y - s.y);
|
||||
const zoom = this._cb.getZoom();
|
||||
ctx.strokeStyle = this.rectMode === "blur" ? "#4af" : "#fa4";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
ctx.setLineDash([4 / zoom, 2 / zoom]);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
_applyRectEffect() {
|
||||
const layer = this._cb.getLayerManager()?.activeLayer;
|
||||
if (!layer || !this._dragStart || !this._dragCur) return;
|
||||
const s = this._dragStart, c = this._dragCur;
|
||||
if (Math.abs(c.x - s.x) < 3 || Math.abs(c.y - s.y) < 3) return;
|
||||
|
||||
const minX = Math.min(s.x, c.x), minY = Math.min(s.y, c.y);
|
||||
const maxX = Math.max(s.x, c.x), maxY = Math.max(s.y, c.y);
|
||||
const p1 = this._canvasToLayerCoords(layer, minX, minY);
|
||||
const p2 = this._canvasToLayerCoords(layer, maxX, maxY);
|
||||
|
||||
const lx = Math.round(Math.max(0, Math.min(p1.x, p2.x)));
|
||||
const ly = Math.round(Math.max(0, Math.min(p1.y, p2.y)));
|
||||
const lw = Math.round(Math.min(layer.canvas.width - lx, Math.abs(p2.x - p1.x)));
|
||||
const lh = Math.round(Math.min(layer.canvas.height - ly, Math.abs(p2.y - p1.y)));
|
||||
if (lw <= 0 || lh <= 0) return;
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
if (this.rectMode === "blur") {
|
||||
const amount = parseInt(document.getElementById("ie-rect-blur")?.value ?? "10");
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = layer.canvas.width; tmp.height = layer.canvas.height;
|
||||
const tc = tmp.getContext("2d");
|
||||
tc.filter = `blur(${amount}px)`;
|
||||
tc.drawImage(layer.canvas, 0, 0);
|
||||
layer.ctx.drawImage(tmp, lx, ly, lw, lh, lx, ly, lw, lh);
|
||||
} else {
|
||||
const size = parseInt(document.getElementById("ie-rect-mosaic")?.value ?? "15");
|
||||
_applyMosaicToRegion(layer.ctx, lx, ly, lw, lh, size);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Image Edit Tab - File Export
|
||||
* Composites the visible layers (respecting mask-apply clipping) into a
|
||||
* flat canvas and offers three destinations: local PNG download, WFS
|
||||
* Gallery, and ComfyUI's own /upload/image endpoint.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { Layer } from "./LayerManager.js";
|
||||
|
||||
export class FileExport {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => {w: number, h: number}} callbacks.getCanvasSize
|
||||
* @param {() => string} callbacks.getBaseName
|
||||
* @param {(ctx: CanvasRenderingContext2D, drawCanvas: HTMLCanvasElement, maskLayer: object, targetLayer: object, showOverlay: boolean) => void} callbacks.renderMaskedLayer
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
}
|
||||
|
||||
_buildCompositeCanvas() {
|
||||
const { w, h } = this._cb.getCanvasSize();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) return canvas;
|
||||
this._compositeForExport(canvas, layerMgr);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// 保存用合成: maskApply=true のクリッピングを適用、マスクオーバーレイは除外
|
||||
_compositeForExport(target, layerMgr) {
|
||||
const ctx = target.getContext("2d");
|
||||
ctx.clearRect(0, 0, target.width, target.height);
|
||||
const layers = layerMgr.layers;
|
||||
const maskedIndices = new Set();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === "mask" && layers[i].maskApply && layers[i].visible && i + 1 < layers.length) {
|
||||
maskedIndices.add(i + 1);
|
||||
}
|
||||
}
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (!layer.visible) continue;
|
||||
if (maskedIndices.has(i)) continue;
|
||||
if (layer.type === "mask") {
|
||||
if (layer.maskApply && i + 1 < layers.length) {
|
||||
this._cb.renderMaskedLayer(ctx, target, layer, layers[i + 1], false);
|
||||
}
|
||||
// maskApply=false のマスクはエクスポートに含めない
|
||||
} else {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
ctx.globalCompositeOperation = layer.blendMode;
|
||||
Layer.applyTransform(ctx, layer);
|
||||
ctx.drawImage(layer.canvas, -layer.canvas.width / 2, -layer.canvas.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savePng() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const a = document.createElement("a");
|
||||
a.href = canvas.toDataURL("image/png");
|
||||
a.download = (this._cb.getBaseName() || "wfs-edit") + "-output.png";
|
||||
a.click();
|
||||
showToast("PNG saved", "success");
|
||||
}
|
||||
|
||||
async saveToGallery() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// デフォルトファイル名: wfs-image-YYYYMMDDHHmmss
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const defaultName = `wfs-image-${ts}`;
|
||||
|
||||
const filename = window.prompt("Save to Gallery — file name (without extension):", defaultName);
|
||||
if (filename === null) return; // キャンセル
|
||||
const safeName = filename.trim() || defaultName;
|
||||
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const imageData = canvas.toDataURL("image/png");
|
||||
|
||||
try {
|
||||
const r = await fetch("/wfm/gallery/image/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: safeName, imageData }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.error || r.statusText);
|
||||
}
|
||||
showToast(`Saved to Gallery: ${safeName}.png`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Gallery save failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async uploadToComfyUI() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("overwrite", "true");
|
||||
try {
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
const data = await r.json();
|
||||
showToast(`Uploaded: ${data.name}`, "success");
|
||||
} catch {
|
||||
showToast("Upload failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads the composite like uploadToComfyUI(), then (via window.opener,
|
||||
// since WFS runs in its own window — same cross-window pattern as
|
||||
// wfmReceiveWorkflow/wfmOpenMaskEditorForNode) writes the resulting
|
||||
// filename into the "image" widget of the currently selected ComfyUI
|
||||
// node (falling back to the first node in the graph with one), mirroring
|
||||
// the chat_TE custom node's "Send to workflow" button.
|
||||
async sendToWorkflow() {
|
||||
if (!this._cb.getLayerManager()) { showToast("No image loaded", "error"); return; }
|
||||
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const canvas = this._buildCompositeCanvas();
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
const file = new File([blob], (this._cb.getBaseName() || "wfs-edit") + "-output.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("overwrite", "true");
|
||||
try {
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
const data = await r.json();
|
||||
const filename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
|
||||
window.opener.wfmSendImageToSelectedNode(filename);
|
||||
showToast(`Sent "${filename}" to workflow`, "success");
|
||||
} catch (err) {
|
||||
showToast(`Send to workflow failed: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Image Edit Tab - G'MIC Integration
|
||||
* Launches the G'MIC GUI on the server for the active layer, polls job
|
||||
* status, and applies the resulting image back onto the layer.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class GmicIntegration {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.syncActiveLayerFromCanvas
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.state = {
|
||||
lastResultJobId: null,
|
||||
processing: false,
|
||||
aborted: false
|
||||
};
|
||||
}
|
||||
|
||||
renderPanel(el) {
|
||||
const openBtnDisabled = this.state.processing ? "disabled" : "";
|
||||
const applyBtnDisabled = (!this.state.lastResultJobId || this.state.processing) ? "disabled" : "";
|
||||
const progressStyle = this.state.processing ? "display:flex" : "display:none";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-gmic-open-btn" ${openBtnDisabled}>G'MIC GUIで編集</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-apply-btn" ${applyBtnDisabled}>結果を反映</button>
|
||||
</div>
|
||||
<div class="ie-opt-group" id="ie-gmic-progress-area" style="${progressStyle}; align-items:center; gap:6px;">
|
||||
<span id="ie-gmic-progress-lbl" style="font-size:11px; color:var(--wfm-text-secondary);">G'MIC GUIを起動中...</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-gmic-abort-btn" style="background:#ea4335;color:#fff;">中断</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("ie-gmic-open-btn")?.addEventListener("click", () => this.openGui());
|
||||
document.getElementById("ie-gmic-apply-btn")?.addEventListener("click", () => this.applyResult());
|
||||
document.getElementById("ie-gmic-abort-btn")?.addEventListener("click", () => this.abort());
|
||||
}
|
||||
|
||||
async openGui() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
const layer = layerMgr.activeLayer;
|
||||
if (!layer) { showToast("No active layer", "error"); return; }
|
||||
this._cb.syncActiveLayerFromCanvas();
|
||||
|
||||
if (this.state.processing) return;
|
||||
|
||||
const dataUrl = layer.canvas.toDataURL("image/png");
|
||||
|
||||
this.state.processing = true;
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
if (progressArea) progressArea.style.display = "flex";
|
||||
if (progressLbl) progressLbl.textContent = "画像をサーバーへ送信中...";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/gmic/open", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64: dataUrl })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
this.state.lastResultJobId = data.job_id;
|
||||
if (progressLbl) progressLbl.textContent = "G'MIC GUIで編集中... フィルターを選択して「OK」を押すと適用可能になります";
|
||||
await this._waitForJob(data.job_id);
|
||||
} catch (e) {
|
||||
if (e.message !== "__aborted__") {
|
||||
showToast("G'MIC Error: " + e.message, "error");
|
||||
}
|
||||
this.state.processing = false;
|
||||
this.state.aborted = false;
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.state.aborted = true;
|
||||
this.state.processing = false;
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
}
|
||||
|
||||
async _waitForJob(jobId) {
|
||||
const progressLbl = document.getElementById("ie-gmic-progress-lbl");
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
const maxWait = 600, interval = 2000;
|
||||
const start = Date.now();
|
||||
this.state.aborted = false;
|
||||
|
||||
while (true) {
|
||||
if (this.state.aborted) throw new Error("__aborted__");
|
||||
if ((Date.now() - start) / 1000 > maxWait) throw new Error("Timeout");
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
if (this.state.aborted) throw new Error("__aborted__");
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/gmic/status/${jobId}`);
|
||||
if (res.status === 404) throw new Error("__aborted__");
|
||||
if (!res.ok) continue;
|
||||
const status = await res.json();
|
||||
if (status.status === "completed") {
|
||||
this.state.lastResultJobId = jobId;
|
||||
if (applyBtn) applyBtn.disabled = false;
|
||||
if (progressLbl) progressLbl.textContent = "処理完了 → 「結果を反映」で画像に適用";
|
||||
this.state.processing = false;
|
||||
showToast("G'MIC filtering complete. Click Apply to insert result.", "success");
|
||||
return;
|
||||
}
|
||||
if (status.status === "failed") {
|
||||
if (progressLbl) progressLbl.textContent = status.error || "G'MIC GUIがキャンセルされました";
|
||||
throw new Error("__aborted__");
|
||||
}
|
||||
if (progressLbl) progressLbl.textContent = status.message || "G'MIC GUIで編集中...";
|
||||
} catch (e) {
|
||||
if (e.message === "__aborted__" || e.message.includes("Timeout")) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyResult() {
|
||||
if (!this.state.lastResultJobId) {
|
||||
showToast("No G'MIC result to apply", "error");
|
||||
return;
|
||||
}
|
||||
const applyBtn = document.getElementById("ie-gmic-apply-btn");
|
||||
const openBtn = document.getElementById("ie-gmic-open-btn");
|
||||
const progressArea = document.getElementById("ie-gmic-progress-area");
|
||||
if (applyBtn) applyBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(`/api/wfm/gmic/status/${this.state.lastResultJobId}`);
|
||||
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
|
||||
const statusData = await statusRes.json();
|
||||
if (!statusData.result_path) throw new Error("No result path found");
|
||||
|
||||
const b64res = await fetch("/api/wfm/gmic/result", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result_path: statusData.result_path })
|
||||
});
|
||||
if (!b64res.ok) throw new Error(`HTTP ${b64res.status}`);
|
||||
const { image_b64: dataUrl } = await b64res.json();
|
||||
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("Failed to load result image"));
|
||||
i.src = dataUrl;
|
||||
});
|
||||
|
||||
this._cb.saveUndo();
|
||||
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
const layer = layerMgr?.activeLayer;
|
||||
if (!layer) throw new Error("No active layer");
|
||||
|
||||
layer.canvas.width = img.width;
|
||||
layer.canvas.height = img.height;
|
||||
layer.ctx = layer.canvas.getContext("2d");
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast("G'MIC filter applied successfully", "success");
|
||||
|
||||
// Reset G'MIC status
|
||||
this.state.lastResultJobId = null;
|
||||
this.state.processing = false;
|
||||
if (openBtn) openBtn.disabled = false;
|
||||
if (progressArea) progressArea.style.display = "none";
|
||||
this._cb.renderToolOptions("filter");
|
||||
} catch (err) {
|
||||
showToast("Failed to apply G'MIC result: " + err.message, "error");
|
||||
if (applyBtn) applyBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Image Edit Tab - Inpaint / I2I Actions
|
||||
* Composites the active mask layer against the background and runs it
|
||||
* through GenerateUI's currently loaded workflow (or a dedicated one) for
|
||||
* inpainting, plus a mask-less I2I variant. Also exposes the external
|
||||
* entry points used by other same-origin panels (e.g. Comic Creator).
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { comfyUI } from "../comfyui-client.js";
|
||||
import { comfyEditor } from "../comfyui-editor.js";
|
||||
import { comfyWorkflow } from "../comfyui-workflow.js";
|
||||
|
||||
export class InpaintI2IActions {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
|
||||
this.positive = "";
|
||||
this.negative = "";
|
||||
this.growMaskBy = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this.denoise = null; // null = 未初期化(ワークフローの現在値から初期化)
|
||||
this.running = false;
|
||||
this.resultUrl = null;
|
||||
this.i2iExternalRunning = false;
|
||||
|
||||
// Inpaint 専用ワークフロー(OFF時は GenerateUI で読み込み中のワークフローを使用)
|
||||
this.useDedicated = false;
|
||||
this.dedicatedFilename = null;
|
||||
this.workflowList = [];
|
||||
}
|
||||
|
||||
async fetchWorkflowList(onLoaded) {
|
||||
try {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows`);
|
||||
if (!resp.ok) return;
|
||||
const list = await resp.json();
|
||||
this.workflowList = (list || []).map((w) => w.filename).filter(Boolean);
|
||||
onLoaded?.();
|
||||
} catch {
|
||||
this.workflowList = [];
|
||||
}
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
const pane = document.getElementById("ie-props-pane");
|
||||
const body = document.getElementById("ie-props-body");
|
||||
const title = document.getElementById("ie-props-title");
|
||||
if (!pane || !body) return;
|
||||
pane.style.display = "flex";
|
||||
if (title) title.textContent = "Inpaint";
|
||||
|
||||
// 未初期化ならワークフローの現在値から初期化(VAEEncodeForInpaint.grow_mask_by / KSampler.denoise)
|
||||
if (this.growMaskBy == null) {
|
||||
this.growMaskBy = comfyUI.currentAnalysis?.inpaint_encode_nodes?.[0]?.grow_mask_by ?? 6;
|
||||
}
|
||||
if (this.denoise == null) {
|
||||
const samplerNode = (comfyUI.currentAnalysis?.sampler_nodes || []).find((n) => n.denoise !== undefined);
|
||||
this.denoise = samplerNode?.denoise ?? 1.0;
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="ie-inpaint-use-dedicated" ${this.useDedicated ? "checked" : ""}>
|
||||
Use dedicated workflow
|
||||
</label>
|
||||
</div>
|
||||
<div class="ie-props-row" id="ie-inpaint-dedicated-row" style="${this.useDedicated ? "" : "display:none;"}">
|
||||
<select id="ie-inpaint-dedicated-select" style="flex:1;">
|
||||
<option value="">-- select workflow --</option>
|
||||
${this.workflowList.map((f) => `<option value="${f}" ${f === this.dedicatedFilename ? "selected" : ""}>${f}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Positive Prompt</label>
|
||||
<textarea id="ie-inpaint-positive" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.positive}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Negative Prompt</label>
|
||||
<textarea id="ie-inpaint-negative" rows="3" style="width:100%;resize:vertical;font-size:12px;">${this.negative}</textarea>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Grow Mask By</label>
|
||||
<input type="number" id="ie-inpaint-grow-mask" min="0" max="200" step="1" value="${this.growMaskBy}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Denoise</label>
|
||||
<input type="number" id="ie-inpaint-denoise" min="0" max="1" step="0.01" value="${this.denoise}" style="width:60px;">
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-inpaint-run-btn" style="flex:1;" ${this.running ? "disabled" : ""}>
|
||||
${this.running ? "Running..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-inpaint-status" style="font-size:11px;color:var(--wfm-text-secondary);"></span>
|
||||
${this.resultUrl ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;">
|
||||
<label>Result</label>
|
||||
<img src="${this.resultUrl}" style="max-width:100%;border-radius:var(--wfm-radius-sm);border:1px solid var(--wfm-border);">
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
|
||||
document.getElementById("ie-inpaint-use-dedicated")?.addEventListener("change", e => {
|
||||
this.useDedicated = e.target.checked;
|
||||
this.renderPanel();
|
||||
});
|
||||
document.getElementById("ie-inpaint-dedicated-select")?.addEventListener("change", e => {
|
||||
this.dedicatedFilename = e.target.value || null;
|
||||
});
|
||||
document.getElementById("ie-inpaint-positive")?.addEventListener("input", e => { this.positive = e.target.value; });
|
||||
document.getElementById("ie-inpaint-negative")?.addEventListener("input", e => { this.negative = e.target.value; });
|
||||
document.getElementById("ie-inpaint-grow-mask")?.addEventListener("input", e => {
|
||||
this.growMaskBy = parseInt(e.target.value) || 0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-denoise")?.addEventListener("input", e => {
|
||||
this.denoise = Math.max(0, Math.min(1, parseFloat(e.target.value)));
|
||||
if (Number.isNaN(this.denoise)) this.denoise = 1.0;
|
||||
});
|
||||
document.getElementById("ie-inpaint-run-btn")?.addEventListener("click", () => this.run());
|
||||
}
|
||||
|
||||
_exportMaskCanvas(maskLayer) {
|
||||
const w = maskLayer.canvas.width, h = maskLayer.canvas.height;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(maskLayer.canvas, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.running) return;
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
// 非表示のマスクレイヤーはInpaint対象から除外する(目玉アイコンOFF=対象外)
|
||||
const maskLayer = (layerMgr.activeLayer?.type === "mask" && layerMgr.activeLayer.visible)
|
||||
? layerMgr.activeLayer
|
||||
: layerMgr.layers.find(l => l.type === "mask" && l.visible);
|
||||
if (!maskLayer) { showToast("Create or show a mask layer first (Mask tool)", "info"); return; }
|
||||
|
||||
if (this.useDedicated) {
|
||||
if (!this.dedicatedFilename) { showToast("Select a dedicated workflow first", "info"); return; }
|
||||
} else if (!comfyUI.currentAnalysis) {
|
||||
showToast("Load a workflow in GenerateUI first", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const runBtn = document.getElementById("ie-inpaint-run-btn");
|
||||
const statusEl = document.getElementById("ie-inpaint-status");
|
||||
const setStatus = (msg) => { if (statusEl) statusEl.textContent = msg; };
|
||||
|
||||
this.running = true;
|
||||
if (runBtn) { runBtn.disabled = true; runBtn.textContent = "Running..."; }
|
||||
setStatus(this.useDedicated ? "Loading dedicated workflow..." : "Compositing...");
|
||||
|
||||
try {
|
||||
// 専用ワークフロー使用時は GenerateUI の comfyUI.currentWorkflow/currentAnalysis には
|
||||
// 一切触れず、ここでロード・解析したローカルなワークフロー/解析結果だけを使う。
|
||||
let overrideOpts = {};
|
||||
|
||||
if (this.useDedicated) {
|
||||
const resp = await fetch(`${comfyUI.baseUrl}/api/wfm/workflows/raw?filename=${encodeURIComponent(this.dedicatedFilename)}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load workflow (HTTP ${resp.status})`);
|
||||
let dedicatedWorkflow = await resp.json();
|
||||
const format = comfyWorkflow.detectFormat(dedicatedWorkflow, this.dedicatedFilename);
|
||||
if (format === "ui") {
|
||||
dedicatedWorkflow = await comfyWorkflow.convertUiToApi(dedicatedWorkflow);
|
||||
} else if (format !== "api") {
|
||||
throw new Error("Unsupported or unrecognized workflow format");
|
||||
}
|
||||
overrideOpts = { workflow: dedicatedWorkflow, analysis: comfyWorkflow.analyzeWorkflow(dedicatedWorkflow) };
|
||||
}
|
||||
|
||||
setStatus("Compositing...");
|
||||
const bgCanvas = this._cb.buildBgCanvas();
|
||||
const imageBlob = await new Promise((resolve) => bgCanvas.toBlob(resolve, "image/png"));
|
||||
const maskCanvas = this._exportMaskCanvas(maskLayer);
|
||||
const maskBlob = await new Promise((resolve) => maskCanvas.toBlob(resolve, "image/png"));
|
||||
|
||||
const resultUrl = await this._runWithImages(imageBlob, maskBlob, {
|
||||
positive: this.positive,
|
||||
negative: this.negative,
|
||||
growMaskBy: this.growMaskBy,
|
||||
denoise: this.denoise,
|
||||
overrideOpts,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
if (resultUrl) this.resultUrl = resultUrl;
|
||||
|
||||
setStatus("Done");
|
||||
showToast("Inpaint generation complete", "success");
|
||||
} catch (err) {
|
||||
setStatus("Error");
|
||||
showToast(`Inpaint failed: ${err.message}`, "error");
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.renderPanel();
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像+マスクBlobを対象ノード(LoadImage/MaskEditorOne)へ反映し、
|
||||
// プロンプト/grow_mask_by/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// UI駆動の run() と外部(Comic Creator等)向けの runExternal() の両方から使う。
|
||||
async _runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
// LoadImage(の MASK 出力が使われている)スロットを優先し、無ければ Mask Editor One
|
||||
// ノード(LoadImage を介さず画像/マスクを内包する custom node)にフォールバックする
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
const slotIndex = (analysisForLookup.load_image_nodes || []).findIndex(n => n.mask_used);
|
||||
const meoNode = (analysisForLookup.mask_editor_one_nodes || [])[0];
|
||||
if (slotIndex === -1 && !meoNode) {
|
||||
throw new Error("No inpaint-capable LoadImage or Mask Editor One node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
if (slotIndex !== -1) {
|
||||
await comfyEditor.applyImageAndMaskToSlot(imageBlob, maskBlob, slotIndex, overrideOpts);
|
||||
} else {
|
||||
await comfyEditor.applyImageAndMaskToMaskEditorOneNode(imageBlob, maskBlob, meoNode.id, overrideOpts);
|
||||
}
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ growMaskBy, denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント。
|
||||
// 専用ワークフロー選択(useDedicated)は扱わず、常にGenerate UIに現在
|
||||
// ロード中のワークフロー(comfyUI.currentWorkflow/currentAnalysis)を対象にする。
|
||||
async runExternal(imageBlob, maskBlob, { positive = "", negative = "", growMaskBy = 6, denoise = 1.0 } = {}) {
|
||||
if (this.running) throw new Error("Inpaint is already running");
|
||||
this.running = true;
|
||||
try {
|
||||
const resultUrl = await this._runWithImages(imageBlob, maskBlob, { positive, negative, growMaskBy, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 共通処理: 画像Blob(マスクなし)を対象LoadImageノード(slot 0)へ反映し、
|
||||
// プロンプト/denoiseを設定してGenerate UIで実行、結果URLを返す。
|
||||
// _runWithImages のマスクなし版(Comic CreatorのSelect I2I連携専用)。
|
||||
async _runI2IWithImage(imageBlob, { positive, negative, denoise, overrideOpts = {}, onStatus } = {}) {
|
||||
const setStatus = onStatus || (() => {});
|
||||
|
||||
const analysisForLookup = overrideOpts.analysis || comfyUI.currentAnalysis;
|
||||
if (!analysisForLookup) throw new Error("No workflow loaded in GenerateUI");
|
||||
if (!(analysisForLookup.load_image_nodes?.length > 0)) {
|
||||
throw new Error("No LoadImage node found in the workflow");
|
||||
}
|
||||
|
||||
setStatus("Uploading...");
|
||||
const file = new File([imageBlob], `i2i_${Math.random().toString(36).slice(2, 10)}.png`, { type: "image/png" });
|
||||
await comfyEditor.applyImageToSlot(file, 0, overrideOpts);
|
||||
|
||||
comfyEditor.setPromptText("positive", positive, overrideOpts);
|
||||
comfyEditor.setPromptText("negative", negative, overrideOpts);
|
||||
comfyEditor.setInpaintParams({ denoise, ...overrideOpts });
|
||||
|
||||
if (!window._wfmGenerateTab?.generate) throw new Error("GenerateUI is not ready yet");
|
||||
|
||||
setStatus("Generating...");
|
||||
await window._wfmGenerateTab.generate(overrideOpts.workflow || undefined);
|
||||
|
||||
const resultImg = document.getElementById("wfm-gen-result-img");
|
||||
return resultImg?.src || null;
|
||||
}
|
||||
|
||||
// Comic Creator等、同一オリジンiframe越しの外部呼び出し専用エントリポイント(I2I版)。
|
||||
// runExternal と対になる、マスク不要のシンプルなI2I実行。
|
||||
async runI2IExternal(imageBlob, { positive = "", negative = "", denoise = 1.0 } = {}) {
|
||||
if (this.i2iExternalRunning) throw new Error("I2I is already running");
|
||||
this.i2iExternalRunning = true;
|
||||
try {
|
||||
const resultUrl = await this._runI2IWithImage(imageBlob, { positive, negative, denoise });
|
||||
if (!resultUrl) throw new Error("No result image produced");
|
||||
return { ok: true, url: resultUrl };
|
||||
} finally {
|
||||
this.i2iExternalRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Image Edit Tab - Mask Editor One 連携ブリッジ
|
||||
* WFS Image Edit タブの合成画像を、ComfyUIキャンバス上のMaskEditorOneノード(選択中のノードを
|
||||
* 優先、無ければグラフ内で最初に見つかったもの)へ送り、window.opener(ComfyUIタブ)側で実際の
|
||||
* モーダルを開いて編集させ、Apply後の結果マスクを新規マスクレイヤーとしてWFS側に取り込む。
|
||||
* 「Send to Workflow」の image ウィジェット探索と同じ「選択中優先・フォールバック」方式のため、
|
||||
* WFS側のワークフロー読み込み状況とは無関係に動作する。
|
||||
* モーダル自体は ComfyUI ページの LiteGraph ノードに強く依存するため、WFS 側からは
|
||||
* window.opener.wfmOpenMaskEditorForNode()(web/comfyui/node_sets_menu.js 側で定義)を介して操作する。
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class MaskEditorOneBridge {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {() => HTMLCanvasElement} callbacks.buildBgCanvas
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
get bridgeReady() {
|
||||
return !!(window.opener && typeof window.opener.wfmOpenMaskEditorForNode === "function");
|
||||
}
|
||||
|
||||
async openEditor() {
|
||||
if (this.running) return;
|
||||
if (!this.bridgeReady) {
|
||||
showToast("Open Workflow Studio from ComfyUI's top menu to use this feature", "error");
|
||||
return;
|
||||
}
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr) { showToast("No image loaded", "error"); return; }
|
||||
|
||||
this.running = true;
|
||||
try {
|
||||
const dataUrl = this._cb.buildBgCanvas().toDataURL("image/png");
|
||||
// アクティブレイヤーが既存のマスクレイヤーなら、その内容もそのまま Mask Editor One の
|
||||
// 初期マスクとして渡す(画像だけでなく、今描いているマスクも引き継ぐ)
|
||||
const activeLayer = layerMgr.activeLayer;
|
||||
const existingMaskDataUrl = activeLayer?.type === "mask" ? activeLayer.canvas.toDataURL("image/png") : null;
|
||||
window.opener.focus();
|
||||
showToast("Switch to the ComfyUI tab, edit the mask, then click Apply", "info");
|
||||
|
||||
const maskDataUrl = await window.opener.wfmOpenMaskEditorForNode(dataUrl, existingMaskDataUrl);
|
||||
if (!maskDataUrl) {
|
||||
showToast("Mask Editor One: cancelled (no changes)", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
this._cb.saveUndo();
|
||||
const maskLayer = layerMgr.addLayer("mask", "Mask Editor One", {
|
||||
contentW: layerMgr.width, contentH: layerMgr.height,
|
||||
displayW: layerMgr.width, displayH: layerMgr.height,
|
||||
x: 0, y: 0,
|
||||
});
|
||||
layerMgr.setActive(maskLayer.id);
|
||||
await this._applyMask(maskLayer, maskDataUrl);
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast("Mask Editor One: mask imported", "success");
|
||||
} catch (err) {
|
||||
showToast("Mask Editor One error: " + err.message, "error");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// グレースケール輝度 → アルファ白マスクに変換して書き込む(Sam3Segmentation._applyMask と同じ変換)
|
||||
_applyMask(maskLayer, maskDataUrl) {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const W = maskLayer.canvas.width;
|
||||
const H = maskLayer.canvas.height;
|
||||
const off = document.createElement("canvas");
|
||||
off.width = W; off.height = H;
|
||||
const mc = off.getContext("2d");
|
||||
mc.drawImage(img, 0, 0, W, H);
|
||||
const imgData = mc.getImageData(0, 0, W, H);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const lum = d[i];
|
||||
d[i] = d[i + 1] = d[i + 2] = 255;
|
||||
d[i + 3] = lum;
|
||||
}
|
||||
mc.putImageData(imgData, 0, 0);
|
||||
maskLayer.ctx.drawImage(off, 0, 0);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = resolve;
|
||||
img.src = maskDataUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Image Edit Tab - SAM3 Segmentation (Mask Editor One integration)
|
||||
* Text-prompted segmentation via the Mask Editor One SAM3 server endpoints.
|
||||
* Rendered as a subtool of the Mask tool; the toolbar shell itself stays in
|
||||
* ImageEditTab (shared with the other mask subtools), but the prompt/run
|
||||
* controls and the result thumbnail grid are owned here.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
|
||||
export class Sam3Segmentation {
|
||||
/**
|
||||
* @param {object} callbacks
|
||||
* @param {() => object|null} callbacks.getLayerManager
|
||||
* @param {() => void} callbacks.saveUndo
|
||||
* @param {() => void} callbacks.updateCompositeView
|
||||
* @param {() => void} callbacks.refreshLayerList
|
||||
* @param {(toolId: string) => void} callbacks.renderToolOptions
|
||||
* @param {(sub: string) => void} callbacks.renderMaskProps
|
||||
*/
|
||||
constructor(callbacks) {
|
||||
this._cb = callbacks;
|
||||
this.available = false;
|
||||
this.results = []; // [{mask_b64, score, area}, ...]
|
||||
this.prompt = "";
|
||||
this.maxMasks = 9;
|
||||
this.loading = false;
|
||||
this.mode = "add"; // "add" | "erase"
|
||||
this.selected = new Set(); // 選択中の結果インデックス
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
try {
|
||||
const resp = await fetch("/mask_editor/sam3/status");
|
||||
if (!resp.ok) return;
|
||||
const json = await resp.json();
|
||||
this.available = json.loaded === true || json.ckpt_found === true;
|
||||
} catch {
|
||||
this.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
// マスクツールバー内、SAM3選択時にのみ表示するprompt/max/runコントロール
|
||||
renderToolbarExtra(activeSub) {
|
||||
if (!this.available || activeSub !== "sam3") return "";
|
||||
return `
|
||||
<div class="ie-opt-group">
|
||||
<input type="text" id="ie-sam3-prompt" class="ie-opt-input"
|
||||
placeholder="e.g. cat, person..."
|
||||
value="${this.prompt}"
|
||||
style="width:160px;font-size:11px;padding:2px 6px;border:1px solid var(--wfm-border);border-radius:3px;background:var(--wfm-surface);color:var(--wfm-text);">
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;color:var(--wfm-text-secondary);">Max</label>
|
||||
<select id="ie-sam3-max" class="ie-opt-select" style="width:44px;">
|
||||
${[3,6,9,12].map(n => `<option value="${n}"${n === this.maxMasks ? " selected" : ""}>${n}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-run-btn" ${this.loading ? "disabled" : ""}>
|
||||
${this.loading ? "Running..." : "Segment"}
|
||||
</button>
|
||||
</div>
|
||||
<span id="ie-sam3-status" style="font-size:11px;color:var(--wfm-text-secondary);margin-left:4px;">
|
||||
${this.results.length > 0 ? `${this.results.length} masks found` : ""}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
bindToolbarEvents() {
|
||||
document.getElementById("ie-sam3-prompt")?.addEventListener("input", e => {
|
||||
this.prompt = e.target.value;
|
||||
});
|
||||
document.getElementById("ie-sam3-max")?.addEventListener("change", e => {
|
||||
this.maxMasks = parseInt(e.target.value);
|
||||
});
|
||||
document.getElementById("ie-sam3-run-btn")?.addEventListener("click", () => this.runSegment());
|
||||
}
|
||||
|
||||
// props ペイン("sam3" サブツール選択時): モード切替 + 結果サムネイルグリッド
|
||||
renderResultsPanel(body) {
|
||||
const modeAdd = this.mode === "add";
|
||||
const selCount = this.selected.size;
|
||||
const hasResult = this.results.length > 0;
|
||||
body.innerHTML = `
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-add" style="flex:1;">Add</button>
|
||||
<button class="wfm-btn wfm-btn-sm${!modeAdd ? " ie-opt-active" : ""}" id="ie-sam3-mode-erase" style="flex:1;">Erase</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasResult ? `
|
||||
<div class="ie-props-row" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);">Click to select / deselect:</div>
|
||||
<div id="ie-sam3-results" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
|
||||
${this.results.map((r, i) => {
|
||||
const sel = this.selected.has(i);
|
||||
const borderColor = sel ? "var(--wfm-primary,#4682e6)" : "var(--wfm-border)";
|
||||
const bg = sel ? "color-mix(in srgb,var(--wfm-primary,#4682e6) 15%,transparent)" : "transparent";
|
||||
return `<div class="ie-sam3-thumb" data-idx="${i}"
|
||||
style="cursor:pointer;border:2px solid ${borderColor};border-radius:4px;overflow:hidden;text-align:center;background:${bg};position:relative;">
|
||||
<img src="${r.mask_b64}" style="width:100%;display:block;background:#000;">
|
||||
<div style="font-size:10px;padding:2px 0;color:var(--wfm-text-secondary);">
|
||||
${Math.round((r.score ?? 0) * 100)}%
|
||||
</div>
|
||||
${sel ? '<div style="position:absolute;top:2px;right:3px;font-size:12px;line-height:1;color:var(--wfm-primary,#4682e6);">✓</div>' : ""}
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-sam3-apply-btn"
|
||||
${selCount === 0 ? "disabled" : ""} style="margin-top:4px;">
|
||||
Apply Selected${selCount > 0 ? ` (${selCount})` : ""}
|
||||
</button>
|
||||
</div>` : `
|
||||
<div style="font-size:11px;color:var(--wfm-text-secondary);padding:4px 0;">
|
||||
${this.loading ? "Segmenting..." : "Enter a prompt and press Segment"}
|
||||
</div>`}
|
||||
`;
|
||||
document.getElementById("ie-sam3-mode-add")?.addEventListener("click", () => {
|
||||
this.mode = "add";
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
document.getElementById("ie-sam3-mode-erase")?.addEventListener("click", () => {
|
||||
this.mode = "erase";
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
body.querySelectorAll(".ie-sam3-thumb").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
const idx = parseInt(el.dataset.idx);
|
||||
if (this.selected.has(idx)) this.selected.delete(idx);
|
||||
else this.selected.add(idx);
|
||||
this._cb.renderMaskProps("sam3");
|
||||
});
|
||||
});
|
||||
document.getElementById("ie-sam3-apply-btn")?.addEventListener("click", () => {
|
||||
this.applySelectedMasks();
|
||||
});
|
||||
}
|
||||
|
||||
async runSegment() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr || this.loading) return;
|
||||
const prompt = this.prompt.trim();
|
||||
if (!prompt) { showToast("Please enter a prompt", "warning"); return; }
|
||||
|
||||
// 推論対象: アクティブレイヤー(マスクなら下のイメージレイヤーを探す)
|
||||
let imageLayer = layerMgr.activeLayer;
|
||||
if (!imageLayer || imageLayer.type === "mask") {
|
||||
imageLayer = layerMgr.layers.find(l => l.type !== "mask" && l.visible);
|
||||
}
|
||||
if (!imageLayer) { showToast("No image layer found", "error"); return; }
|
||||
|
||||
const NODE_ID = "wfs_sam3";
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
this.selected = new Set();
|
||||
this._cb.renderToolOptions("mask");
|
||||
this._cb.renderMaskProps("sam3");
|
||||
|
||||
try {
|
||||
const dataUrl = imageLayer.canvas.toDataURL("image/png");
|
||||
await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, image_b64: dataUrl }),
|
||||
});
|
||||
|
||||
const resp = await fetch("/mask_editor/sam3/segment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: NODE_ID, prompt, max_masks: this.maxMasks }),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
this.results = json.masks || [];
|
||||
if (this.results.length === 0) showToast("No masks found", "warning");
|
||||
else showToast(`${this.results.length} mask(s) found`, "success");
|
||||
} catch (err) {
|
||||
showToast("SAM3 error: " + err.message, "error");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._cb.renderToolOptions("mask");
|
||||
this._cb.renderMaskProps("sam3");
|
||||
}
|
||||
}
|
||||
|
||||
async applySelectedMasks() {
|
||||
const layerMgr = this._cb.getLayerManager();
|
||||
if (!layerMgr || this.selected.size === 0) return;
|
||||
const indices = [...this.selected].sort((a, b) => a - b);
|
||||
const masks = indices.map(i => this.results[i]).filter(Boolean);
|
||||
if (masks.length === 0) return;
|
||||
|
||||
let maskLayer = layerMgr.activeLayer;
|
||||
if (!maskLayer || maskLayer.type !== "mask") {
|
||||
const ref = layerMgr.activeLayer;
|
||||
maskLayer = layerMgr.addLayer("mask", "SAM3 Mask", {
|
||||
contentW: ref?.canvas.width ?? layerMgr.width,
|
||||
contentH: ref?.canvas.height ?? layerMgr.height,
|
||||
displayW: ref?.displayW ?? layerMgr.width,
|
||||
displayH: ref?.displayH ?? layerMgr.height,
|
||||
x: ref?.x ?? 0, y: ref?.y ?? 0,
|
||||
});
|
||||
layerMgr.setActive(maskLayer.id);
|
||||
}
|
||||
|
||||
this._cb.saveUndo();
|
||||
for (const r of masks) {
|
||||
await this._applyMask(maskLayer, r.mask_b64, this.mode);
|
||||
}
|
||||
|
||||
this._cb.updateCompositeView();
|
||||
this._cb.refreshLayerList();
|
||||
showToast(`SAM3: ${masks.length} mask(s) applied (${this.mode})`, "success");
|
||||
}
|
||||
|
||||
_applyMask(maskLayer, maskB64, mode = "add") {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const W = maskLayer.canvas.width;
|
||||
const H = maskLayer.canvas.height;
|
||||
// グレースケール輝度 → アルファ白マスクに変換
|
||||
const off = document.createElement("canvas");
|
||||
off.width = W; off.height = H;
|
||||
const mc = off.getContext("2d");
|
||||
mc.drawImage(img, 0, 0, W, H);
|
||||
const imgData = mc.getImageData(0, 0, W, H);
|
||||
const d = imgData.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const lum = d[i];
|
||||
d[i] = d[i+1] = d[i+2] = 255;
|
||||
d[i+3] = lum;
|
||||
}
|
||||
mc.putImageData(imgData, 0, 0);
|
||||
// モードに合わせてマスクキャンバスに合成
|
||||
maskLayer.ctx.save();
|
||||
maskLayer.ctx.globalCompositeOperation = mode === "erase" ? "destination-out" : "source-over";
|
||||
maskLayer.ctx.drawImage(off, 0, 0);
|
||||
maskLayer.ctx.restore();
|
||||
resolve();
|
||||
};
|
||||
img.onerror = resolve;
|
||||
img.src = maskB64;
|
||||
});
|
||||
}
|
||||
}
|
||||
+257
-23
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Lab Tab - Experimental I2I batch generation.
|
||||
*
|
||||
* Runs the currently loaded workflow N times, letting Checkpoint / VAE / Prompt /
|
||||
* KSampler each be overridden independently starting at a chosen iteration
|
||||
* Runs the currently loaded workflow N times, letting Model (Checkpoint/LoRA/VAE) /
|
||||
* Prompt / KSampler each be overridden independently starting at a chosen iteration
|
||||
* ("keyframe"). Never mutates comfyUI.currentWorkflow — everything is applied to a
|
||||
* fresh deep clone per iteration so it can't interfere with the Input/Model/Settings/
|
||||
* Batch subtabs.
|
||||
@@ -17,27 +17,89 @@ import { extractAllMetadata, readAllPNGTextChunks } from "./metadata-tab.js";
|
||||
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
|
||||
import { _expandWildcardsInWorkflow } from "./generate-tab.js";
|
||||
|
||||
const COLUMN_KEYS = ["checkpoint", "vae", "prompt", "ksampler"];
|
||||
// Checkpoint/LoRA/VAE are three fully independent keyframe timelines — same as
|
||||
// Prompt/KSampler — each with its own +/-, its own atIteration sequence, its own
|
||||
// forward-fill resolution (_resolveKeyframe). The Model column header's C/L/V toggle
|
||||
// only picks which ONE of the three renders into the (single, shared) cell list and
|
||||
// which one +/- and the cell-click modal operate on; it never bundles them together.
|
||||
const COLUMN_KEYS = ["checkpoint", "lora", "vae", "prompt", "ksampler"];
|
||||
const MAX_RESULTS = 9;
|
||||
const RESULTS_GRID_COLS = 3;
|
||||
const LAB_PLAN_PREFIX = "ws_labplan_";
|
||||
const LAB_PLAN_PNG_KEY = "wfm_lab_plan";
|
||||
|
||||
function _defaultValueFor(col) {
|
||||
if (col === "lora") return { name: "", strengthModel: 1.0, strengthClip: 1.0 };
|
||||
if (col === "prompt") return { positive: "", negative: "", styleApplied: false, styleName: "" };
|
||||
if (col === "ksampler") return { steps: null, cfg: null, sampler_name: "", scheduler: "", denoise: null, seed: null };
|
||||
return "";
|
||||
return ""; // checkpoint, vae
|
||||
}
|
||||
|
||||
// Fills in defaults for a possibly-partial LoRA value — both for legacy plan data
|
||||
// migrated by _migrateLabColumns and defensively for any hand-edited Plan JSON.
|
||||
function _normalizeLoraValue(v) {
|
||||
return {
|
||||
name: v?.name || "",
|
||||
strengthModel: v?.strengthModel ?? 1.0,
|
||||
strengthClip: v?.strengthClip ?? 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
function _emptyColumns() {
|
||||
return {
|
||||
checkpoint: [{ atIteration: 1, value: "", revertToBase: false, bypassed: false }],
|
||||
vae: [{ atIteration: 1, value: "", revertToBase: false, bypassed: false }],
|
||||
checkpoint: [{ atIteration: 1, value: _defaultValueFor("checkpoint"), revertToBase: false, bypassed: false }],
|
||||
lora: [{ atIteration: 1, value: _defaultValueFor("lora"), revertToBase: false, bypassed: false }],
|
||||
vae: [{ atIteration: 1, value: _defaultValueFor("vae"), revertToBase: false, bypassed: false }],
|
||||
prompt: [{ atIteration: 1, value: _defaultValueFor("prompt"), revertToBase: false, bypassed: false }],
|
||||
ksampler: [{ atIteration: 1, value: _defaultValueFor("ksampler"), revertToBase: false, bypassed: false }],
|
||||
};
|
||||
}
|
||||
|
||||
// Two prior on-disk Plan JSON shapes need upgrading to today's independent
|
||||
// checkpoint/lora/vae arrays:
|
||||
// - Pre-v0.4.2: separate top-level `checkpoint`/`vae` columns, no `lora` at all.
|
||||
// - v0.4.2's brief "merged Model row" design: `columns.model` was a single array of
|
||||
// {atIteration, value:{checkpoint,lora,vae}, ...} rows bundling all three together.
|
||||
// That bundling caused its own bugs (+/- and the cell modal affected all three
|
||||
// fields at once) and was replaced the same release cycle, but a plan saved during
|
||||
// that window must still load. Values are moved into their own independent arrays;
|
||||
// iteration #1 is always kept (baseline row) even if a field was blank there.
|
||||
function _migrateLabColumns(columns) {
|
||||
if (!columns) return _emptyColumns();
|
||||
if (!columns.model && Array.isArray(columns.lora)) return columns; // already current shape (independent lora array is the marker)
|
||||
|
||||
const defaults = _emptyColumns();
|
||||
|
||||
if (Array.isArray(columns.model)) {
|
||||
const checkpoint = [], lora = [], vae = [];
|
||||
for (const kf of columns.model) {
|
||||
const v = kf.value || {};
|
||||
const base = { atIteration: kf.atIteration, revertToBase: !!kf.revertToBase, bypassed: !!kf.bypassed };
|
||||
if (kf.atIteration === 1 || v.checkpoint) checkpoint.push({ ...base, value: v.checkpoint || "" });
|
||||
if (kf.atIteration === 1 || v.lora?.name) lora.push({ ...base, value: _normalizeLoraValue(v.lora) });
|
||||
if (kf.atIteration === 1 || v.vae) vae.push({ ...base, value: v.vae || "" });
|
||||
}
|
||||
const { model, ...rest } = columns;
|
||||
return {
|
||||
...rest,
|
||||
checkpoint: checkpoint.length ? checkpoint : defaults.checkpoint,
|
||||
lora: lora.length ? lora : defaults.lora,
|
||||
vae: vae.length ? vae : defaults.vae,
|
||||
};
|
||||
}
|
||||
|
||||
if (columns.checkpoint || columns.vae) {
|
||||
return {
|
||||
...columns,
|
||||
checkpoint: columns.checkpoint?.length ? columns.checkpoint : defaults.checkpoint,
|
||||
lora: defaults.lora,
|
||||
vae: columns.vae?.length ? columns.vae : defaults.vae,
|
||||
};
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function _emptyLabState() {
|
||||
return {
|
||||
planFilename: null,
|
||||
@@ -59,6 +121,11 @@ let _aborted = false;
|
||||
let _resumeResolve = null;
|
||||
let _lastGeneratedImageRef = null; // ComfyUI "name [type]" annotated ref of the previous iteration's first output
|
||||
|
||||
// Which single field the Model column's cells currently display (C/L/V toggle in its
|
||||
// header) — purely a display preference, not part of the Plan data, so it's not saved
|
||||
// with the plan and simply resets to "checkpoint" on page load.
|
||||
let _modelViewMode = "checkpoint";
|
||||
|
||||
// ============================================
|
||||
// Init
|
||||
// ============================================
|
||||
@@ -68,6 +135,7 @@ export function initLabTab() {
|
||||
_initPlanDropZone();
|
||||
_initSubtabToggle();
|
||||
_initColumnButtons();
|
||||
_initModelViewToggle();
|
||||
_initRunControls();
|
||||
_initPlanButtons();
|
||||
_initPlanJsonTab();
|
||||
@@ -292,10 +360,21 @@ function _initPlanButtons() {
|
||||
// Column keyframes: render / add / remove / edit
|
||||
// ============================================
|
||||
|
||||
// A click on +/- always targets whichever of checkpoint/lora/vae the Model column
|
||||
// header's C/L/V toggle currently has selected — "model" is a sentinel dataset.col
|
||||
// value on those two shared buttons (there's only one +/- pair in the DOM for the
|
||||
// merged-looking Model column), never a real key into _lab.columns. Resolving it here
|
||||
// means adding/removing a keyframe only ever touches the ONE field being viewed, never
|
||||
// the other two — Prompt/KSampler's own +/- buttons carry their real col directly and
|
||||
// pass through unchanged.
|
||||
function _resolveColumnKey(col) {
|
||||
return col === "model" ? _modelViewMode : col;
|
||||
}
|
||||
|
||||
function _initColumnButtons() {
|
||||
document.querySelectorAll(".wfm-lab-col-add").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const col = _resolveColumnKey(btn.dataset.col);
|
||||
const kfs = _lab.columns[col];
|
||||
const lastIter = kfs[kfs.length - 1].atIteration;
|
||||
if (lastIter >= _lab.batchCount) {
|
||||
@@ -303,27 +382,58 @@ function _initColumnButtons() {
|
||||
return;
|
||||
}
|
||||
kfs.push({ atIteration: lastIter + 1, value: _defaultValueFor(col), revertToBase: false, bypassed: false });
|
||||
_renderColumn(col);
|
||||
_renderColumnForKey(col);
|
||||
_openCellModal(col, kfs.length - 1);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-lab-col-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const col = _resolveColumnKey(btn.dataset.col);
|
||||
const kfs = _lab.columns[col];
|
||||
if (kfs.length <= 1) return; // Row 1 (baseline) cannot be removed
|
||||
kfs.pop();
|
||||
_renderColumn(col);
|
||||
_renderColumnForKey(col);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wires the Model column header's C/L/V buttons — switches which independent field
|
||||
// (checkpoint/lora/vae) the shared cell list, +/-, and cell-click modal all operate on.
|
||||
// Purely a display/routing selector: none of the three keyframe arrays are touched by
|
||||
// switching it.
|
||||
function _initModelViewToggle() {
|
||||
document.querySelectorAll(".wfm-lab-model-view-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
_modelViewMode = btn.dataset.view;
|
||||
document.querySelectorAll(".wfm-lab-model-view-btn").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
_renderModelColumn();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _renderAllColumns() {
|
||||
COLUMN_KEYS.forEach(_renderColumn);
|
||||
_renderModelColumn();
|
||||
_renderColumn("prompt");
|
||||
_renderColumn("ksampler");
|
||||
}
|
||||
|
||||
function _renderColumn(col) {
|
||||
const container = document.getElementById(`wfm-lab-col-cells-${col}`);
|
||||
// Renders the Model column's shared cell list from whichever of checkpoint/lora/vae is
|
||||
// currently toggled (_modelViewMode) — the other two arrays exist in _lab.columns but
|
||||
// have no DOM of their own to render into; they're only ever shown when toggled to.
|
||||
function _renderModelColumn() {
|
||||
_updateModelViewHighlight();
|
||||
_renderColumn(_modelViewMode, "wfm-lab-col-cells-model");
|
||||
}
|
||||
|
||||
// Routes a keyframe-array key to whichever container renders it: checkpoint/lora/vae
|
||||
// all share the Model column's single cell list, everything else has its own.
|
||||
function _renderColumnForKey(col) {
|
||||
if (col === "checkpoint" || col === "lora" || col === "vae") _renderModelColumn();
|
||||
else _renderColumn(col);
|
||||
}
|
||||
|
||||
function _renderColumn(col, containerId = `wfm-lab-col-cells-${col}`) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const keyframes = _lab.columns[col];
|
||||
container.innerHTML = keyframes.map((kf, idx) => _cellHtml(col, kf, idx)).join("");
|
||||
@@ -332,6 +442,22 @@ function _renderColumn(col) {
|
||||
});
|
||||
}
|
||||
|
||||
// Colors the header's C/L/V buttons the same "this node type actually exists in the
|
||||
// loaded workflow" highlight GenerateUI's Model tab uses (wfm-model-label-active,
|
||||
// colored via settings-tab.js applyModelTabActiveColor) — independent of which one is
|
||||
// currently selected/active. Re-run on every model column render so it stays in sync
|
||||
// with whatever's loaded (initial render, keyframe edits, toggle clicks, plan load/
|
||||
// apply, and refreshLabLiveDefaults() when switching into the Lab subtab).
|
||||
function _updateModelViewHighlight() {
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
const hasCheckpoint = (analysis?.checkpoint_nodes?.length || 0) > 0;
|
||||
const hasLora = (analysis?.lora_nodes?.length || 0) > 0;
|
||||
const hasVae = (analysis?.vae_nodes?.length || 0) > 0;
|
||||
document.querySelector('.wfm-lab-model-view-btn[data-view="checkpoint"]')?.classList.toggle("wfm-model-label-active", hasCheckpoint);
|
||||
document.querySelector('.wfm-lab-model-view-btn[data-view="lora"]')?.classList.toggle("wfm-model-label-active", hasLora);
|
||||
document.querySelector('.wfm-lab-model-view-btn[data-view="vae"]')?.classList.toggle("wfm-model-label-active", hasVae);
|
||||
}
|
||||
|
||||
function _cellHtml(col, kf, idx) {
|
||||
const revertClass = kf.revertToBase ? " wfm-lab-cell-revert" : "";
|
||||
const bypassClass = kf.bypassed ? " wfm-lab-cell-bypassed" : "";
|
||||
@@ -360,6 +486,11 @@ function _cellLabel(col, kf) {
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
return kf.value ? escapeHtml(kf.value) : t("labUseWorkflowDefault");
|
||||
}
|
||||
if (col === "lora") {
|
||||
const v = kf.value;
|
||||
if (!v?.name) return t("labUseWorkflowDefault");
|
||||
return escapeHtml(`${v.name} (${v.strengthModel}/${v.strengthClip})`);
|
||||
}
|
||||
if (col === "prompt") {
|
||||
const p = kf.value?.positive || "";
|
||||
const n = kf.value?.negative || "";
|
||||
@@ -380,6 +511,7 @@ function _cellLabel(col, kf) {
|
||||
|
||||
function _isEmptyValue(col, value) {
|
||||
if (col === "checkpoint" || col === "vae") return !value;
|
||||
if (col === "lora") return !value?.name;
|
||||
if (col === "prompt") return !value || (!value.positive && !value.negative);
|
||||
if (col === "ksampler") {
|
||||
return !value || (value.steps == null && value.cfg == null && value.denoise == null &&
|
||||
@@ -397,12 +529,11 @@ function _isEmptyValue(col, value) {
|
||||
function _liveValueFor(col) {
|
||||
const analysis = comfyUI.currentAnalysis;
|
||||
if (!analysis) return null;
|
||||
if (col === "checkpoint") {
|
||||
return analysis.checkpoint_nodes?.[0]?.ckpt_name || null;
|
||||
}
|
||||
if (col === "vae") {
|
||||
return analysis.vae_nodes?.[0]?.vae_name || null;
|
||||
}
|
||||
if (col === "checkpoint") return analysis.checkpoint_nodes?.[0]?.ckpt_name || null;
|
||||
if (col === "vae") return analysis.vae_nodes?.[0]?.vae_name || null;
|
||||
// LoRA is intentionally never live-reflected — there's no safe way to pick "the"
|
||||
// active LoRA back out of a workflow node to prefill with.
|
||||
if (col === "lora") return null;
|
||||
if (col === "prompt") {
|
||||
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
|
||||
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
|
||||
@@ -447,6 +578,12 @@ function _openCellModal(col, idx) {
|
||||
const effectiveValue = live ? _liveValueFor(col) : kf.value;
|
||||
const hasPrevious = col === "prompt" && idx > 0;
|
||||
|
||||
// What this row would inherit if left blank (the latest earlier keyframe in this
|
||||
// SAME field's own array, not touching the other two) — shown as the blank option's
|
||||
// label so it's clear leaving it untouched still carries a value forward, rather
|
||||
// than resetting to the workflow's own default. Only meaningful past #1.
|
||||
const inherited = isFirst ? null : _resolveKeyframe(col, kf.atIteration - 1);
|
||||
|
||||
let valueFieldsHtml = "";
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
|
||||
@@ -458,10 +595,31 @@ function _openCellModal(col, idx) {
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-value-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-lab-modal-value" class="wfm-select">
|
||||
<option value="">${t("labUseWorkflowDefault")}</option>
|
||||
<option value="">${escapeHtml(inherited ? t("labInheritsFrom", inherited) : t("labUseWorkflowDefault"))}</option>
|
||||
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === effectiveValue ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
|
||||
</select>
|
||||
</div>`;
|
||||
} else if (col === "lora") {
|
||||
const v = _normalizeLoraValue(effectiveValue);
|
||||
const options = comfyEditor.models.loras || [];
|
||||
valueFieldsHtml = `
|
||||
<div class="wfm-lab-modal-row">
|
||||
<label>LoRA</label>
|
||||
<div class="wfm-search-wrap" style="width:100%;">
|
||||
<input type="text" id="wfm-lab-modal-lora-filter" class="wfm-input wfm-search-input" placeholder="Filter...">
|
||||
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-lora-filter-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
<select id="wfm-lab-modal-lora-select" class="wfm-select">
|
||||
<option value="">${escapeHtml(inherited?.name ? t("labInheritsFrom", inherited.name) : t("labUseWorkflowDefault"))}</option>
|
||||
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === v.name ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
|
||||
</select>
|
||||
<div class="wfm-lora-strength-single" style="margin-top:6px;">
|
||||
<span>M</span>
|
||||
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strmodel" value="${v.strengthModel}" step="0.05" min="0" max="2">
|
||||
<span>C</span>
|
||||
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strclip" value="${v.strengthClip}" step="0.05" min="0" max="2">
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (col === "prompt") {
|
||||
const v = effectiveValue || {};
|
||||
valueFieldsHtml = `
|
||||
@@ -555,6 +713,7 @@ function _openCellModal(col, idx) {
|
||||
openModal(`${col} #${idx + 1}`, html);
|
||||
if (col === "prompt") _wireLabPromptModalExtras(idx);
|
||||
if (col === "checkpoint" || col === "vae") _wireLabValueFilter(col);
|
||||
if (col === "lora") _wireLabLoraFilter();
|
||||
|
||||
// Bypass disables everything else on the modal (value fields AND the Revert
|
||||
// checkbox) — the two are mutually exclusive since bypass already means "ignore
|
||||
@@ -577,7 +736,7 @@ function _openCellModal(col, idx) {
|
||||
|
||||
document.getElementById("wfm-lab-modal-delete")?.addEventListener("click", () => {
|
||||
_lab.columns[col].splice(idx, 1);
|
||||
_renderColumn(col);
|
||||
_renderColumnForKey(col);
|
||||
closeModal();
|
||||
});
|
||||
|
||||
@@ -594,6 +753,12 @@ function _openCellModal(col, idx) {
|
||||
if (!revert && !bypassed) {
|
||||
if (col === "checkpoint" || col === "vae") {
|
||||
value = document.getElementById("wfm-lab-modal-value")?.value || "";
|
||||
} else if (col === "lora") {
|
||||
value = {
|
||||
name: document.getElementById("wfm-lab-modal-lora-select")?.value || "",
|
||||
strengthModel: parseFloat(document.getElementById("wfm-lab-modal-lora-strmodel")?.value) || 1.0,
|
||||
strengthClip: parseFloat(document.getElementById("wfm-lab-modal-lora-strclip")?.value) || 1.0,
|
||||
};
|
||||
} else if (col === "prompt") {
|
||||
value = {
|
||||
positive: document.getElementById("wfm-lab-modal-positive")?.value || "",
|
||||
@@ -619,7 +784,7 @@ function _openCellModal(col, idx) {
|
||||
|
||||
_lab.columns[col][idx] = { atIteration, value, revertToBase: revert, bypassed };
|
||||
_lab.columns[col].sort((a, b) => a.atIteration - b.atIteration);
|
||||
_renderColumn(col);
|
||||
_renderColumnForKey(col);
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
@@ -662,6 +827,26 @@ function _wireLabValueFilter(col) {
|
||||
setupSearchClearBtn("wfm-lab-modal-value-filter", "wfm-lab-modal-value-filter-clear", applyFilter);
|
||||
}
|
||||
|
||||
// Same filter behavior as _wireLabValueFilter, for the LoRA modal's own select (it
|
||||
// has separate element ids since a LoRA keyframe row also carries Strength M/C
|
||||
// inputs alongside it, unlike the plain single-select Checkpoint/VAE modal).
|
||||
function _wireLabLoraFilter() {
|
||||
const filterInput = document.getElementById("wfm-lab-modal-lora-filter");
|
||||
const select = document.getElementById("wfm-lab-modal-lora-select");
|
||||
if (!filterInput || !select) return;
|
||||
const options = comfyEditor.models.loras || [];
|
||||
|
||||
const applyFilter = () => {
|
||||
const filter = filterInput.value.toLowerCase();
|
||||
select.innerHTML = `<option value="">${t("labUseWorkflowDefault")}</option>` +
|
||||
options.filter((m) => m.toLowerCase().includes(filter))
|
||||
.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join("");
|
||||
};
|
||||
|
||||
filterInput.addEventListener("input", applyFilter);
|
||||
setupSearchClearBtn("wfm-lab-modal-lora-filter", "wfm-lab-modal-lora-filter-clear", applyFilter);
|
||||
}
|
||||
|
||||
// Wires the Prompt-column modal's extra actions: fetch the live GenerateUI prompt,
|
||||
// extract a prompt from a dropped image/workflow file, copy the previous keyframe's
|
||||
// prompt, clear the fields, and apply/toggle a saved Style. idx is this keyframe's own
|
||||
@@ -825,6 +1010,11 @@ function _buildWorkflowForIteration(iteration, chainImageRef) {
|
||||
}
|
||||
}
|
||||
|
||||
const lora = _resolveKeyframe("lora", iteration);
|
||||
if (lora?.name) {
|
||||
_applyLabLoraToWorkflow(wf, analysis, lora);
|
||||
}
|
||||
|
||||
const prompt = _resolveKeyframe("prompt", iteration);
|
||||
if (prompt) {
|
||||
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
|
||||
@@ -853,6 +1043,49 @@ function _buildWorkflowForIteration(iteration, chainImageRef) {
|
||||
return { workflow: wf, seed };
|
||||
}
|
||||
|
||||
// True once a "no LoRA node in this workflow" warning has already been shown during the
|
||||
// current Run — reset at the start of _runLabBatch so the toast fires at most once per
|
||||
// run instead of once per iteration.
|
||||
let _labLoraWarned = false;
|
||||
|
||||
// Applies a Lab-configured LoRA (Single only — no Stack support here) to the given
|
||||
// per-iteration workflow clone. Mirrors models-tab.js's applyToGenUI LoRA branch: prefer
|
||||
// analysis.lora_nodes[0] (correctly picks up "Lora Loader (LoraManager)" nodes via
|
||||
// is_lora_manager), else fall back to any node with a lora_name input. If the workflow
|
||||
// has no LoRA node at all, this warns once and does nothing — unlike VAE above, Lab does
|
||||
// NOT auto-inject a LoraLoader node, since that would require rewiring model/clip links
|
||||
// between the checkpoint and every downstream consumer, not just overwriting one input.
|
||||
function _applyLabLoraToWorkflow(wf, analysis, lora) {
|
||||
let nodeId = analysis.lora_nodes?.[0]?.id;
|
||||
let isLoraManager = !!analysis.lora_nodes?.[0]?.is_lora_manager;
|
||||
if (nodeId == null) {
|
||||
nodeId = Object.keys(wf).find((id) => wf[id]?.inputs && "lora_name" in wf[id].inputs);
|
||||
isLoraManager = false;
|
||||
}
|
||||
|
||||
if (nodeId == null || !wf[nodeId]) {
|
||||
if (!_labLoraWarned) {
|
||||
_labLoraWarned = true;
|
||||
showToast(t("labLoraNoNode"), "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const strModel = lora.strengthModel ?? 1.0;
|
||||
const strClip = lora.strengthClip ?? 1.0;
|
||||
if (isLoraManager) {
|
||||
const stem = lora.name.replace(/\\/g, "/").split("/").pop().replace(/\.[^.]+$/, "");
|
||||
wf[nodeId].inputs.loras = {
|
||||
__value__: [{ name: stem, strength: strModel, active: true, expanded: false, clipStrength: strClip, locked: false }],
|
||||
};
|
||||
wf[nodeId].inputs.text = `<lora:${stem}:${strModel}:${strClip}>`;
|
||||
} else {
|
||||
wf[nodeId].inputs.lora_name = comfyEditor.resolveLoraName(lora.name);
|
||||
wf[nodeId].inputs.strength_model = strModel;
|
||||
wf[nodeId].inputs.strength_clip = strClip;
|
||||
}
|
||||
}
|
||||
|
||||
// ComfyUI's LoadImage accepts "name [type]" (optionally "subfolder/name [type]") to
|
||||
// reference a previously generated output/temp image without re-uploading it.
|
||||
function _annotatedImageRef(img) {
|
||||
@@ -893,6 +1126,7 @@ async function _runLabBatch() {
|
||||
|
||||
_paused = false;
|
||||
_aborted = false;
|
||||
_labLoraWarned = false;
|
||||
_lastGeneratedImageRef = null;
|
||||
_lab.results.images = [];
|
||||
_renderResultsGrid();
|
||||
@@ -1153,7 +1387,7 @@ function _applyPlanData(filename, data) {
|
||||
_lab.saveIndexOnRun = !!data.save_index_on_run;
|
||||
_lab.sourceImageFilename = data.source_image || null;
|
||||
_lab.workflowFilename = data.workflow_filename || null;
|
||||
_lab.columns = data.columns || _emptyColumns();
|
||||
_lab.columns = _migrateLabColumns(data.columns);
|
||||
_lab.results = { images: (data.results?.images || []).slice(0, MAX_RESULTS) };
|
||||
_updatePlanWorkflowUI();
|
||||
_updateT2IModeUI();
|
||||
|
||||
+17
-1947
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Models Tab - Badge palette, badge rendering, badge edit modal, badge filter bar
|
||||
*/
|
||||
|
||||
import { openModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml, readJsonStorage } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
|
||||
// Grid re-render is owned by models-tab.js (grid rendering hasn't been split
|
||||
// out yet); registered once from initModelsTab() to avoid a circular import.
|
||||
let _onGridChange = () => {};
|
||||
export function setGridChangeCallback(fn) { _onGridChange = fn; }
|
||||
|
||||
export function getBadgePalette() {
|
||||
return readJsonStorage("wfm_models_badge_palette");
|
||||
}
|
||||
|
||||
export function saveBadgePalette(palette) {
|
||||
localStorage.setItem("wfm_models_badge_palette", JSON.stringify(palette));
|
||||
}
|
||||
|
||||
export function badgeHtml(label) {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model"${style}>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
export function modelBadgesHtml(modelName, clickable = false) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const badges = meta.badges || [];
|
||||
if (badges.length === 0) return "";
|
||||
return badges.map((label) => {
|
||||
const palette = getBadgePalette();
|
||||
const color = palette[label] || "";
|
||||
const style = color ? ` style="background:${color};color:#fff;"` : "";
|
||||
const dataAttr = clickable ? ` data-badge-filter="${escapeHtml(label)}"` : "";
|
||||
return `<span class="wfm-badge wfm-badge-model${clickable ? " wfm-badge-clickable" : ""}"${style}${dataAttr}>${escapeHtml(label)}</span>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function openBadgeEditModal(onPaletteChange = null) {
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
|
||||
const rowsHtml = labels.map((label) => {
|
||||
const color = palette[label] || "#6366f1";
|
||||
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
|
||||
${badgeHtml(label)}
|
||||
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">×</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<div style="min-width:320px;">
|
||||
<div id="wfm-badge-list">${rowsHtml || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`}</div>
|
||||
<div style="border-top:1px solid var(--wfm-border);margin-top:12px;padding-top:12px;display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" id="wfm-badge-new-label" class="wfm-input" style="flex:1;" placeholder="${t("badgeNewLabel")}">
|
||||
<input type="color" id="wfm-badge-new-color" value="#6366f1" style="width:36px;height:28px;padding:1px;border-radius:4px;cursor:pointer;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-badge-add-btn">${t("badgeAdd")}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(t("badgeManage"), html);
|
||||
bindBadgeModalEvents(onPaletteChange);
|
||||
}
|
||||
|
||||
function bindBadgeModalEvents(onPaletteChange = null) {
|
||||
const afterChange = () => {
|
||||
renderBadgeFilter();
|
||||
_onGridChange();
|
||||
if (onPaletteChange) onPaletteChange();
|
||||
};
|
||||
|
||||
const refreshList = () => {
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
const listEl = document.getElementById("wfm-badge-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = labels.map((label) => {
|
||||
const color = palette[label] || "#6366f1";
|
||||
return `<div class="wfm-badge-color-row" data-badge-label="${escapeHtml(label)}">
|
||||
${badgeHtml(label)}
|
||||
<input type="color" value="${color}" data-badge-label="${escapeHtml(label)}" class="wfm-badge-color-input" title="${t("badgeColorHint")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-badge-delete-btn" data-badge-label="${escapeHtml(label)}" title="${t("badgeDelete")}">×</button>
|
||||
</div>`;
|
||||
}).join("") || `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNone")}</p>`;
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
afterChange();
|
||||
};
|
||||
|
||||
bindBadgeRowEvents(refreshList, afterChange);
|
||||
|
||||
document.getElementById("wfm-badge-add-btn")?.addEventListener("click", () => {
|
||||
const labelInput = document.getElementById("wfm-badge-new-label");
|
||||
const colorInput = document.getElementById("wfm-badge-new-color");
|
||||
const label = labelInput?.value.trim();
|
||||
if (!label) return;
|
||||
const palette = getBadgePalette();
|
||||
palette[label] = colorInput?.value || "#6366f1";
|
||||
saveBadgePalette(palette);
|
||||
if (labelInput) labelInput.value = "";
|
||||
refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
function bindBadgeRowEvents(refreshList, afterChange = null) {
|
||||
document.querySelectorAll(".wfm-badge-color-input").forEach((input) => {
|
||||
input.addEventListener("input", (e) => {
|
||||
const label = e.target.dataset.badgeLabel;
|
||||
const palette = getBadgePalette();
|
||||
palette[label] = e.target.value;
|
||||
saveBadgePalette(palette);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".wfm-badge-delete-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const label = btn.dataset.badgeLabel;
|
||||
const palette = getBadgePalette();
|
||||
delete palette[label];
|
||||
saveBadgePalette(palette);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Badge Filter Bar ──────────────────────────────────────
|
||||
|
||||
export function renderBadgeFilter() {
|
||||
const container = document.getElementById("wfm-models-badge-filter-bar");
|
||||
if (!container) return;
|
||||
const palette = getBadgePalette();
|
||||
const labels = Object.keys(palette).sort();
|
||||
|
||||
if (labels.length === 0) {
|
||||
container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = labels.map((label) => {
|
||||
const color = palette[label] || "";
|
||||
const isActive = state.badgeFilter === label;
|
||||
const style = color ? `background:${color};color:#fff;` : "";
|
||||
return `<button class="wfm-badge-filter-btn${isActive ? " active" : ""}" data-badge="${escapeHtml(label)}" style="${style}">${escapeHtml(label)}</button>`;
|
||||
}).join("");
|
||||
|
||||
container.querySelectorAll(".wfm-badge-filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const label = btn.dataset.badge;
|
||||
state.badgeFilter = state.badgeFilter === label ? "" : label;
|
||||
state.currentPage = 0;
|
||||
renderBadgeFilter();
|
||||
_onGridChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
/**
|
||||
* Models Tab - Detail modal (double-click) and the side panel's
|
||||
* Info / Group / CivitAI tabs.
|
||||
*
|
||||
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
|
||||
* import triangle — see the note at the top of grid-view.js.
|
||||
*/
|
||||
|
||||
import { showToast, openModal, closeModal } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state, RESERVED_GROUPS, GENUI_TYPE_MAP } from "./state.js";
|
||||
import { getCurrentModels, renderTagFilter, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette, modelBadgesHtml, openBadgeEditModal } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage, previewUrl } from "./helpers.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import {
|
||||
applyToGenUI, applyEmbeddingToPrompt, saveModelMetadata, toggleGroupEnable,
|
||||
fetchModelMetadata, saveModelGroups,
|
||||
} from "../models-tab.js";
|
||||
|
||||
// ── Detail Modal (double-click, with badges) ──────────────
|
||||
|
||||
export function openDetailModal(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const isFav = !!meta.favorite;
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const selectedBadges = meta.badges || [];
|
||||
const palette = getBadgePalette();
|
||||
const allBadgeLabels = Object.keys(palette).sort();
|
||||
|
||||
const badgeCheckboxes = allBadgeLabels.map((label) => {
|
||||
const checked = selectedBadges.includes(label) ? " checked" : "";
|
||||
const color = palette[label] || "";
|
||||
const style = color ? `background:${color};color:#fff;` : "";
|
||||
return `<label class="wfm-badge-check-label">
|
||||
<input type="checkbox" class="wfm-badge-checkbox" value="${escapeHtml(label)}"${checked}>
|
||||
<span class="wfm-badge wfm-badge-model" style="${style}">${escapeHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<div class="wfm-modal-thumb-section">
|
||||
<img class="wfm-modal-thumb-img" style="display:none" />
|
||||
<div class="wfm-modal-thumb-placeholder">${t("modelsNoPreview")}</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:8px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-change-thumb">${t("changeThumbnail")}</button>
|
||||
<input type="file" id="wfm-modal-thumb-file" accept="image/*" style="display:none">
|
||||
</div>
|
||||
<div class="wfm-modal-two-col">
|
||||
<div class="wfm-modal-left">
|
||||
<section>
|
||||
<h4>${t("modelsInfo")}</h4>
|
||||
<div><span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>${dir ? ` <span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsBadges")} <button class="wfm-btn wfm-btn-sm" id="wfm-modal-badge-manage" style="margin-left:6px;font-size:10px;">⚙ ${t("badgeManage")}</button></h4>
|
||||
<div id="wfm-modal-badge-checkboxes" class="wfm-badge-checkboxes">
|
||||
${allBadgeLabels.length === 0
|
||||
? `<span style="color:var(--wfm-text-secondary);font-size:12px;">${t("badgeNoneHint")}</span>`
|
||||
: badgeCheckboxes}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsTags")} <span style="font-weight:normal;font-size:11px;">${t("modelsTagsHint")}</span></h4>
|
||||
<input type="text" class="wfm-input" id="wfm-modal-model-tags" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
|
||||
</section>
|
||||
<section>
|
||||
<h4>${t("modelsMemo")}</h4>
|
||||
<textarea class="wfm-textarea" id="wfm-modal-model-memo" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
|
||||
</section>
|
||||
<div class="wfm-modal-actions">
|
||||
<button class="wfm-btn wfm-btn-primary wfm-btn-sm" id="wfm-modal-model-save">${t("modelsSave")}</button>
|
||||
${state.activeModelType === "embedding"
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-pp" title="Positive Promptにエンベッディング追加">GenUI PP</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-np" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
|
||||
: (GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button class="wfm-btn wfm-btn-sm" id="wfm-modal-genui-model" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
|
||||
: "")}
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-modal-model-delete" style="margin-left:auto;">${t("modelsDelete")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-modal-model-close">${t("close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
openModal(getStem(name), html);
|
||||
|
||||
// Load preview image
|
||||
const modalImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
const modalPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
|
||||
if (modalImg) loadPreviewImage(modalImg, modalPlaceholder, modelName);
|
||||
|
||||
// Add favorite button in header
|
||||
const titleEl = document.getElementById("wfm-modal-title");
|
||||
if (titleEl) {
|
||||
titleEl.parentNode.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
|
||||
const favBtn = document.createElement("button");
|
||||
favBtn.className = isFav ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
favBtn.style.cssText = "position:static;font-size:18px;margin-right:8px;";
|
||||
favBtn.textContent = isFav ? "★" : "☆";
|
||||
favBtn.addEventListener("click", async () => {
|
||||
const newVal = !meta.favorite;
|
||||
await saveModelMetadata(modelName, { favorite: newVal });
|
||||
meta.favorite = newVal;
|
||||
favBtn.textContent = newVal ? "★" : "☆";
|
||||
favBtn.classList.toggle("active", newVal);
|
||||
renderModelGrid();
|
||||
});
|
||||
titleEl.parentNode.insertBefore(favBtn, titleEl);
|
||||
}
|
||||
|
||||
// Badge manage button → open badge edit modal
|
||||
document.getElementById("wfm-modal-badge-manage")?.addEventListener("click", () => {
|
||||
openBadgeEditModal();
|
||||
});
|
||||
|
||||
// Save
|
||||
document.getElementById("wfm-modal-model-save")?.addEventListener("click", async () => {
|
||||
const tagsInput = document.getElementById("wfm-modal-model-tags");
|
||||
const memoInput = document.getElementById("wfm-modal-model-memo");
|
||||
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const memo = memoInput ? memoInput.value : "";
|
||||
const badges = [...document.querySelectorAll(".wfm-badge-checkbox:checked")].map((cb) => cb.value);
|
||||
await saveModelMetadata(modelName, { tags, memo, badges });
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons
|
||||
document.getElementById("wfm-modal-genui-model")?.addEventListener("click", () => {
|
||||
applyToGenUI(modelName, state.activeModelType);
|
||||
});
|
||||
document.getElementById("wfm-modal-genui-pp")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "positive");
|
||||
});
|
||||
document.getElementById("wfm-modal-genui-np")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
|
||||
// Close button (連続作業時にXボタンまでマウス移動しなくて済むように)
|
||||
document.getElementById("wfm-modal-model-close")?.addEventListener("click", () => {
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// Delete model button
|
||||
document.getElementById("wfm-modal-model-delete")?.addEventListener("click", async () => {
|
||||
const confirmMsg = t("modelBulkDeleteConfirm").replace("{count}", "1");
|
||||
if (!confirm(confirmMsg)) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names: [modelName] }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.errors?.length > 0) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${data.errors[0].error}`, "error");
|
||||
return;
|
||||
}
|
||||
showToast(`${getStem(name)} ${t("modelBulkDeleteDone")}`, "success");
|
||||
closeModal();
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(modelName);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
delete state.modelMetadata[modelName];
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds) ds.delete(modelName);
|
||||
state.selectedModels.delete(modelName);
|
||||
if (state.selectedModel === modelName) {
|
||||
state.selectedModel = null;
|
||||
const titleEl2 = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl2) titleEl2.textContent = "";
|
||||
}
|
||||
renderModelGrid();
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Change thumbnail
|
||||
const changeBtn = document.getElementById("wfm-modal-change-thumb");
|
||||
const thumbFile = document.getElementById("wfm-modal-thumb-file");
|
||||
if (changeBtn && thumbFile) {
|
||||
changeBtn.addEventListener("click", () => { thumbFile.value = ""; thumbFile.click(); });
|
||||
thumbFile.addEventListener("change", async () => {
|
||||
const file = thumbFile.files?.[0];
|
||||
if (!file) return;
|
||||
changeBtn.disabled = true;
|
||||
changeBtn.textContent = t("uploading");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("type", state.activeModelType);
|
||||
fd.append("name", modelName);
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/wfm/models/change-preview", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
// Reload preview in modal
|
||||
const newUrl = previewUrl(modelName) + "&t=" + Date.now();
|
||||
const mImg = document.querySelector(".wfm-modal-thumb-img");
|
||||
const mPlaceholder = document.querySelector(".wfm-modal-thumb-placeholder");
|
||||
if (mImg) { mImg.src = newUrl; mImg.style.display = ""; }
|
||||
if (mPlaceholder) mPlaceholder.style.display = "none";
|
||||
renderModelGrid();
|
||||
if (state.selectedModel === modelName) renderSideInfo(modelName);
|
||||
showToast(t("thumbnailChanged"), "success");
|
||||
} catch (err) {
|
||||
showToast(t("thumbnailError") + ": " + err.message, "error");
|
||||
} finally {
|
||||
changeBtn.disabled = false;
|
||||
changeBtn.textContent = t("changeThumbnail");
|
||||
thumbFile.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side Panel ────────────────────────────────────────────
|
||||
|
||||
export function showSidePanel(modelName) {
|
||||
state.selectedModel = modelName;
|
||||
|
||||
// Highlight selected
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.toggle("wfm-card-selected", el.dataset.modelName === modelName);
|
||||
});
|
||||
|
||||
const { name } = parseModelPath(modelName);
|
||||
document.getElementById("wfm-models-panel-title").textContent = name;
|
||||
|
||||
// Reset to CivitAI tab
|
||||
document.querySelectorAll(".wfm-models-side-tab-btn").forEach((b) => b.classList.remove("active"));
|
||||
document.querySelector('.wfm-models-side-tab-btn[data-side-tab="civitai"]')?.classList.add("active");
|
||||
document.querySelectorAll(".wfm-models-side-content").forEach((c) => (c.style.display = "none"));
|
||||
const civitaiEl = document.getElementById("wfm-models-side-civitai");
|
||||
if (civitaiEl) civitaiEl.style.display = "block";
|
||||
|
||||
const genuiNavBtn = document.getElementById("wfm-side-genui-nav-btn");
|
||||
const genuiNpBtn = document.getElementById("wfm-side-genui-np-btn");
|
||||
if (genuiNavBtn) {
|
||||
if (state.activeModelType === "embedding") {
|
||||
genuiNavBtn.textContent = "GenUI PP";
|
||||
genuiNavBtn.title = "Positive Promptにエンベッディング追加";
|
||||
genuiNavBtn.style.display = "";
|
||||
if (genuiNpBtn) {
|
||||
genuiNpBtn.textContent = "GenUI NP";
|
||||
genuiNpBtn.title = "Negative Promptにエンベッディング追加";
|
||||
genuiNpBtn.style.display = "";
|
||||
}
|
||||
} else if (GENUI_TYPE_MAP[state.activeModelType]) {
|
||||
genuiNavBtn.textContent = t("modelsGenUIBtn");
|
||||
genuiNavBtn.title = t("modelsGenUITitle");
|
||||
genuiNavBtn.style.display = "";
|
||||
if (genuiNpBtn) genuiNpBtn.style.display = "none";
|
||||
} else {
|
||||
genuiNavBtn.style.display = "none";
|
||||
if (genuiNpBtn) genuiNpBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
renderSideInfo(modelName);
|
||||
renderSideGroup(modelName);
|
||||
renderSideCivitai(modelName);
|
||||
}
|
||||
|
||||
export function closeSidePanel() {
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
document.querySelectorAll("#wfm-models-grid .wfm-card, #wfm-models-grid .wfm-models-table-row").forEach((el) => {
|
||||
el.classList.remove("wfm-card-selected");
|
||||
});
|
||||
const npBtn = document.getElementById("wfm-side-genui-np-btn");
|
||||
if (npBtn) npBtn.style.display = "none";
|
||||
const ppBtn = document.getElementById("wfm-side-genui-nav-btn");
|
||||
if (ppBtn) ppBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Side Panel: Info Tab ──────────────────────────────────
|
||||
|
||||
export function renderSideInfo(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-info");
|
||||
if (!el) return;
|
||||
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const userBadgesHtml = modelBadgesHtml(modelName, false);
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="wfm-side-thumb-container">
|
||||
<div class="wfm-side-thumb-img-wrap">
|
||||
<img style="display:none" />
|
||||
<span class="wfm-side-thumb-placeholder">${t("modelsNoPreview")}</span>
|
||||
</div>
|
||||
<div class="wfm-side-thumb-info">
|
||||
<div class="wfm-side-thumb-name wfm-model-name-copy" title="${t("modelsCopyName")}">${escapeHtml(name)}</div>
|
||||
<div class="wfm-side-thumb-meta">
|
||||
<span class="wfm-badge wfm-badge-sm">${escapeHtml(ext)}</span>
|
||||
${dir ? `<span class="wfm-badge wfm-badge-sm wfm-badge-dir">${escapeHtml(dir)}</span>` : ""}
|
||||
${userBadgesHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsFilePath")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<span id="wfm-models-side-filepath" class="wfm-model-filepath" title="${t("modelsCopyPath")}" style="cursor:pointer;word-break:break-all;font-size:0.85em;color:#aaa;">${t("modelsLoading")}...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsTags")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<input type="text" id="wfm-models-side-tags" class="wfm-search-input" value="${escapeHtml(tagsStr)}" placeholder="${t("modelsTagsPlaceholder")}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section">
|
||||
<div class="wfm-node-detail-label">${t("modelsMemo")}</div>
|
||||
<div class="wfm-node-detail-value">
|
||||
<textarea id="wfm-models-side-memo" class="wfm-textarea" rows="4" placeholder="${t("modelsMemoPlaceholder")}">${escapeHtml(meta.memo || "")}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-node-detail-section" style="display:flex;gap:6px;">
|
||||
<button id="wfm-models-side-save-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">${t("modelsSave")}</button>
|
||||
${state.activeModelType === "embedding"
|
||||
? `<button id="wfm-models-side-genui-pp-btn" class="wfm-btn wfm-btn-sm" title="Positive Promptにエンベッディング追加">GenUI PP</button>
|
||||
<button id="wfm-models-side-genui-np-btn" class="wfm-btn wfm-btn-sm" title="Negative Promptにエンベッディング追加">GenUI NP</button>`
|
||||
: (GENUI_TYPE_MAP[state.activeModelType]
|
||||
? `<button id="wfm-models-side-genui-btn" class="wfm-btn wfm-btn-sm" title="${t("modelsGenUITitle")}">${t("modelsGenUIBtn")}</button>`
|
||||
: "")}
|
||||
</div>`;
|
||||
|
||||
// Load preview image
|
||||
const sideImg = el.querySelector(".wfm-side-thumb-img-wrap img");
|
||||
const sidePlaceholder = el.querySelector(".wfm-side-thumb-placeholder");
|
||||
if (sideImg) loadPreviewImage(sideImg, sidePlaceholder, modelName);
|
||||
|
||||
// Copy model name on click
|
||||
el.querySelector(".wfm-model-name-copy")?.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(modelName).then(() => {
|
||||
showToast(t("modelsCopiedName"), "success");
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch and display file path
|
||||
fetch(`/api/wfm/models/filepath?type=${encodeURIComponent(state.activeModelType)}&name=${encodeURIComponent(modelName)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl && data.path) {
|
||||
fpEl.textContent = data.path;
|
||||
fpEl.title = t("modelsCopyPath");
|
||||
} else if (fpEl) {
|
||||
fpEl.textContent = modelName;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) fpEl.textContent = modelName;
|
||||
});
|
||||
|
||||
// Copy file path on click
|
||||
el.querySelector("#wfm-models-side-filepath")?.addEventListener("click", () => {
|
||||
const fpEl = document.getElementById("wfm-models-side-filepath");
|
||||
if (fpEl) {
|
||||
navigator.clipboard.writeText(fpEl.textContent).then(() => {
|
||||
showToast(t("modelsCopiedPath"), "success");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Save button
|
||||
el.querySelector("#wfm-models-side-save-btn")?.addEventListener("click", () => {
|
||||
const tagsInput = document.getElementById("wfm-models-side-tags");
|
||||
const memoInput = document.getElementById("wfm-models-side-memo");
|
||||
const tags = tagsInput ? tagsInput.value.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const memo = memoInput ? memoInput.value : "";
|
||||
saveModelMetadata(modelName, { tags, memo }).then(() => {
|
||||
showToast(t("modelsSaved"), "success");
|
||||
renderTagFilter();
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// GenUI Model / Embedding buttons
|
||||
el.querySelector("#wfm-models-side-genui-btn")?.addEventListener("click", () => {
|
||||
applyToGenUI(modelName, state.activeModelType);
|
||||
});
|
||||
el.querySelector("#wfm-models-side-genui-pp-btn")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "positive");
|
||||
});
|
||||
el.querySelector("#wfm-models-side-genui-np-btn")?.addEventListener("click", () => {
|
||||
applyEmbeddingToPrompt(modelName, "negative");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Side Panel: Group Tab ─────────────────────────────────
|
||||
|
||||
export function renderSideGroup(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-group");
|
||||
if (!el) return;
|
||||
|
||||
// Find groups this model belongs to
|
||||
const memberOf = [];
|
||||
for (const [gName, members] of Object.entries(state.modelGroups)) {
|
||||
if (members.includes(modelName)) memberOf.push(gName);
|
||||
}
|
||||
|
||||
// All group names for the assign dropdown
|
||||
const allGroups = Object.keys(state.modelGroups).sort();
|
||||
const availableGroups = allGroups.filter((g) => !memberOf.includes(g));
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCurrentGroups")}</div>
|
||||
${memberOf.length === 0
|
||||
? `<p style="color:var(--wfm-text-secondary);font-size:12px;">${t("modelsNoGroup")}</p>`
|
||||
: memberOf.map((g) => `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0;">
|
||||
<span style="font-size:13px;">${escapeHtml(g)}</span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger wfm-group-remove" data-group="${escapeHtml(g)}" title="${t("modelsRemoveFromGroup")}">×</button>
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsAssignGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<select id="wfm-models-group-assign" class="wfm-select" style="flex:1;font-size:12px;">
|
||||
${availableGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: availableGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-assign-btn" ${availableGroups.length === 0 ? "disabled" : ""}>${t("modelsAdd")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsCreateGroup")}</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<input type="text" id="wfm-models-group-new" class="wfm-search-input" style="flex:1;font-size:12px;" placeholder="${t("modelsGroupName")}">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-create-btn">${t("modelsCreate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--wfm-border);padding-top:12px;">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px;">${t("modelsManageGroups")}</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<select id="wfm-models-group-manage-select" class="wfm-select" style="flex:1;font-size:12px;">
|
||||
${allGroups.length === 0
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: allGroups.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-rename-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsRename")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-models-group-delete-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelsDelete")}</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-models-group-enable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupEnableAll")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-models-group-disable-btn" ${allGroups.length === 0 ? "disabled" : ""}>${t("modelGroupDisableAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove from group
|
||||
el.querySelectorAll(".wfm-group-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const g = btn.dataset.group;
|
||||
const members = state.modelGroups[g] || [];
|
||||
state.modelGroups[g] = members.filter((m) => m !== modelName);
|
||||
if (state.modelGroups[g].length === 0) delete state.modelGroups[g];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Assign to group
|
||||
document.getElementById("wfm-models-group-assign-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-assign");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
if (!state.modelGroups[g]) state.modelGroups[g] = [];
|
||||
if (!state.modelGroups[g].includes(modelName)) state.modelGroups[g].push(modelName);
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
// Create new group
|
||||
document.getElementById("wfm-models-group-create-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-models-group-new");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (state.modelGroups[name]) {
|
||||
showToast(t("modelsGroupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.modelGroups[name] = [modelName];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
// Rename group
|
||||
document.getElementById("wfm-models-group-rename-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const oldName = sel?.value;
|
||||
if (!oldName) return;
|
||||
if (RESERVED_GROUPS.includes(oldName)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
const newName = prompt(t("modelsRenamePrompt"), oldName);
|
||||
if (!newName || newName === oldName) return;
|
||||
if (state.modelGroups[newName]) {
|
||||
showToast(t("modelsGroupExists"), "warning");
|
||||
return;
|
||||
}
|
||||
state.modelGroups[newName] = state.modelGroups[oldName];
|
||||
delete state.modelGroups[oldName];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete group
|
||||
document.getElementById("wfm-models-group-delete-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
if (RESERVED_GROUPS.includes(g)) {
|
||||
showToast(t("modelsGroupReserved"), "warning");
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("modelsDeleteGroupConfirm").replace("{name}", g))) return;
|
||||
delete state.modelGroups[g];
|
||||
saveModelGroups(state.modelGroups).then(() => {
|
||||
renderSideGroup(modelName);
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
|
||||
// Enable all models in selected group
|
||||
document.getElementById("wfm-models-group-enable-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
toggleGroupEnable(g, true).then(() => renderSideGroup(modelName));
|
||||
});
|
||||
|
||||
// Disable all models in selected group
|
||||
document.getElementById("wfm-models-group-disable-btn")?.addEventListener("click", () => {
|
||||
const sel = document.getElementById("wfm-models-group-manage-select");
|
||||
const g = sel?.value;
|
||||
if (!g) return;
|
||||
toggleGroupEnable(g, false).then(() => renderSideGroup(modelName));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Side Panel: CivitAI Tab ───────────────────────────────
|
||||
|
||||
export function renderSideCivitai(modelName) {
|
||||
const el = document.getElementById("wfm-models-side-civitai");
|
||||
if (!el) return;
|
||||
|
||||
// Check if we have cached civitai data via sha256
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const cached = sha256 && state.civitaiCache[sha256];
|
||||
|
||||
if (cached) {
|
||||
renderCivitaiInfo(el, cached, modelName);
|
||||
} else if (sha256) {
|
||||
// SHA256 is known but model was not found on CivitAI
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiNotFoundDesc")}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-fetch-btn">
|
||||
${t("civitaiRefetchBtn")}
|
||||
</button>
|
||||
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
|
||||
</div>`;
|
||||
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;text-align:center;">
|
||||
<p style="color:var(--wfm-text-secondary);font-size:13px;margin-bottom:12px;">
|
||||
${t("civitaiFetchDesc")}
|
||||
</p>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-civitai-fetch-btn">
|
||||
${t("civitaiFetch")}
|
||||
</button>
|
||||
<div id="wfm-civitai-status" style="margin-top:8px;font-size:12px;color:var(--wfm-text-secondary);"></div>
|
||||
</div>`;
|
||||
document.getElementById("wfm-civitai-fetch-btn")?.addEventListener("click", () => {
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCivitaiForModel(modelName, el) {
|
||||
const statusEl = document.getElementById("wfm-civitai-status");
|
||||
const fetchBtn = document.getElementById("wfm-civitai-fetch-btn");
|
||||
if (fetchBtn) fetchBtn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = t("civitaiHashing");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/fetch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: state.activeModelType, name: modelName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "ok" && data.civitai) {
|
||||
// Update caches
|
||||
if (data.sha256) {
|
||||
state.civitaiCache[data.sha256] = data.civitai;
|
||||
// Update metadata with sha256
|
||||
if (!state.modelMetadata[modelName]) state.modelMetadata[modelName] = {};
|
||||
state.modelMetadata[modelName].sha256 = data.sha256;
|
||||
}
|
||||
renderCivitaiInfo(el, data.civitai, modelName);
|
||||
showToast(t("civitaiFound"), "success");
|
||||
// Always refresh preview: local file if saved, otherwise civitai image as fallback
|
||||
const sidePanel = document.getElementById("wfm-models-side-panel");
|
||||
if (sidePanel) {
|
||||
const sideImg = sidePanel.querySelector(".wfm-side-thumb-img-wrap img");
|
||||
const sidePh = sidePanel.querySelector(".wfm-side-thumb-placeholder");
|
||||
if (sideImg) {
|
||||
if (data.preview_saved) {
|
||||
sideImg.src = previewUrl(modelName) + "&t=" + Date.now();
|
||||
} else {
|
||||
const civitaiImg = data.civitai.images && data.civitai.images[0];
|
||||
if (civitaiImg) sideImg.src = civitaiImg;
|
||||
}
|
||||
sideImg.style.display = "";
|
||||
if (sidePh) sidePh.style.display = "none";
|
||||
}
|
||||
}
|
||||
renderModelGrid();
|
||||
} else if (data.status === "not_found") {
|
||||
if (statusEl) statusEl.textContent = t("civitaiNotFound");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
} else {
|
||||
if (statusEl) statusEl.textContent = data.error || t("civitaiError");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("CivitAI fetch error:", err);
|
||||
if (statusEl) statusEl.textContent = t("civitaiError");
|
||||
if (fetchBtn) fetchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCivitaiInfo(el, info, modelName) {
|
||||
// URL: ユーザー設定ホスト(localStorage)を使用、modelId なし時は /model-versions/ にフォールバック
|
||||
const civitaiHost = localStorage.getItem("wfm_civitai_host") || "civitai.com";
|
||||
const modelUrl = info.modelId && info.versionId
|
||||
? `https://${civitaiHost}/models/${info.modelId}?modelVersionId=${info.versionId}`
|
||||
: info.versionId
|
||||
? `https://${civitaiHost}/model-versions/${info.versionId}`
|
||||
: (info.modelUrl || "#");
|
||||
|
||||
// Hash: BLAKE3 優先、なければ SHA256
|
||||
const fileHashes = info.fileHashes || {};
|
||||
const blake3 = fileHashes.BLAKE3 || fileHashes.Blake3 || "";
|
||||
const sha256 = fileHashes.SHA256 || (state.modelMetadata[modelName] || {}).sha256 || "";
|
||||
const hashType = blake3 ? "BLAKE3" : (sha256 ? "SHA256" : "");
|
||||
const hashFull = blake3 || sha256;
|
||||
|
||||
// Detail rows
|
||||
const ROW = "display:flex;align-items:center;font-size:12px;margin-bottom:5px;";
|
||||
const LABEL = "color:var(--wfm-text-secondary);min-width:80px;flex-shrink:0;";
|
||||
|
||||
const typeRow = info.type ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiType")}</span>
|
||||
<span style="font-size:10px;font-weight:700;background:var(--wfm-bg-tertiary,rgba(255,255,255,0.1));padding:2px 7px;border-radius:3px;letter-spacing:0.6px;">${escapeHtml(info.type.toUpperCase())}</span>
|
||||
</div>` : "";
|
||||
|
||||
const baseModelRow = info.baseModel ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiBaseModel")}</span>
|
||||
<span>${escapeHtml(info.baseModel)}</span>
|
||||
</div>` : "";
|
||||
|
||||
const hashRow = hashFull ? `
|
||||
<div style="${ROW}">
|
||||
<span style="${LABEL}">${t("civitaiHashLabel")}</span>
|
||||
<code class="wfm-hash-value" data-hash="${escapeHtml(hashFull)}"
|
||||
style="background:var(--wfm-bg-secondary);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 84px);"
|
||||
title="${t("civitaiCopyHash")}">${escapeHtml(hashType)}: ${escapeHtml(hashFull.substring(0, 16).toUpperCase())}…</code>
|
||||
</div>` : "";
|
||||
|
||||
const detailSection = (typeRow || baseModelRow || hashRow)
|
||||
? `<div style="margin-bottom:10px;">${typeRow}${baseModelRow}${hashRow}</div>` : "";
|
||||
|
||||
const tagsHtml = (info.tags || []).map((tag) =>
|
||||
`<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`
|
||||
).join(" ");
|
||||
|
||||
const trainedWordsHtml = (info.trainedWords || []).map((w) =>
|
||||
`<code style="font-size:11px;background:var(--wfm-bg-secondary);padding:1px 4px;border-radius:3px;cursor:pointer;" class="wfm-trained-word" title="${t("civitaiCopyWord")}">${escapeHtml(w)}</code>`
|
||||
).join(" ");
|
||||
|
||||
// Sample images — all go to Sample pane
|
||||
const images = info.images || [];
|
||||
const sampleImagesHtml = images.map((url) =>
|
||||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" title="${t("civitaiOpenImage")}"><img src="${escapeHtml(url)}" style="width:100%;border-radius:4px;margin-bottom:6px;cursor:pointer;display:block;" loading="lazy" /></a>`
|
||||
).join("");
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:0 4px;">
|
||||
<div style="display:flex;border-bottom:1px solid var(--wfm-border);margin-bottom:10px;">
|
||||
<button class="wfm-civitai-subtab-btn" data-pane="info"
|
||||
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid var(--wfm-primary);color:var(--wfm-primary);font-weight:600;margin-bottom:-1px;">
|
||||
${t("civitaiTabInfo")}
|
||||
</button>
|
||||
<button class="wfm-civitai-subtab-btn" data-pane="sample"
|
||||
style="padding:4px 10px;font-size:12px;border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;color:var(--wfm-text-secondary);margin-bottom:-1px;">
|
||||
${t("civitaiTabSample")}${images.length ? ` (${images.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="wfm-civitai-pane-info">
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="font-weight:700;font-size:14px;margin-bottom:2px;">
|
||||
<a href="${escapeHtml(modelUrl)}" target="_blank" style="color:var(--wfm-primary);text-decoration:none;">${escapeHtml(info.modelName)}</a>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--wfm-text-secondary);">
|
||||
${escapeHtml(info.versionName)}${info.creator ? ` · by ${escapeHtml(info.creator)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
${detailSection}
|
||||
${tagsHtml ? `<div style="margin-bottom:8px;">${tagsHtml}</div>` : ""}
|
||||
${trainedWordsHtml ? `<div style="margin-bottom:10px;"><div style="font-weight:600;font-size:12px;margin-bottom:4px;">${t("civitaiTriggerWords")}</div>${trainedWordsHtml}</div>` : ""}
|
||||
${info.description ? `<div style="font-size:12px;color:var(--wfm-text-secondary);line-height:1.5;max-height:120px;overflow-y:auto;">${info.description}</div>` : ""}
|
||||
<div style="margin-top:10px;">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-civitai-refresh-btn">${t("civitaiRefresh")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wfm-civitai-pane-sample" style="display:none;">
|
||||
${sampleImagesHtml || `<p style="color:var(--wfm-text-secondary);font-size:13px;text-align:center;margin-top:20px;">${t("civitaiNoImages")}</p>`}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Sub-tab switching
|
||||
const subtabBtns = el.querySelectorAll(".wfm-civitai-subtab-btn");
|
||||
subtabBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const pane = btn.dataset.pane;
|
||||
subtabBtns.forEach((b) => {
|
||||
const isActive = b === btn;
|
||||
b.style.borderBottom = isActive ? "2px solid var(--wfm-primary)" : "2px solid transparent";
|
||||
b.style.color = isActive ? "var(--wfm-primary)" : "var(--wfm-text-secondary)";
|
||||
b.style.fontWeight = isActive ? "600" : "400";
|
||||
});
|
||||
el.querySelector("#wfm-civitai-pane-info").style.display = pane === "info" ? "" : "none";
|
||||
el.querySelector("#wfm-civitai-pane-sample").style.display = pane === "sample" ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// Copy trigger word on click
|
||||
el.querySelectorAll(".wfm-trained-word").forEach((wordEl) => {
|
||||
wordEl.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(wordEl.textContent).then(() => {
|
||||
showToast(t("civitaiWordCopied"), "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy hash on click
|
||||
el.querySelectorAll(".wfm-hash-value").forEach((hashEl) => {
|
||||
hashEl.addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(hashEl.dataset.hash).then(() => {
|
||||
showToast(t("civitaiHashCopied"), "success");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
document.getElementById("wfm-civitai-refresh-btn")?.addEventListener("click", () => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
if (meta.sha256) delete state.civitaiCache[meta.sha256];
|
||||
delete meta.sha256;
|
||||
fetchCivitaiForModel(modelName, el);
|
||||
});
|
||||
}
|
||||
|
||||
// ── CivitAI cache / batch fetch ───────────────────────────
|
||||
|
||||
export async function fetchCivitaiCache() {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/cache");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export async function batchFetchCivitai() {
|
||||
const models = getCurrentModels();
|
||||
if (models.length === 0) {
|
||||
showToast(t("modelsNoModels"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out models that already have CivitAI data
|
||||
const meta = state.modelMetadata;
|
||||
const uncached = models.filter((m) => {
|
||||
const sha = meta[m]?.sha256;
|
||||
return !sha || !state.civitaiCache[sha];
|
||||
});
|
||||
|
||||
if (uncached.length === 0) {
|
||||
showToast(t("civitaiBatchAllCached"), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById("wfm-models-civitai-batch-btn");
|
||||
const progressEl = document.getElementById("wfm-models-civitai-progress");
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/civitai/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: state.activeModelType, models: uncached }),
|
||||
});
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let eventType = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
eventType = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (eventType === "progress") {
|
||||
const pct = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
|
||||
const statusText = data.status === "hashing" ? t("civitaiHashing2")
|
||||
: data.status === "fetching" ? t("civitaiFetching")
|
||||
: data.status === "cached" ? "✓"
|
||||
: data.status === "found" ? "✓"
|
||||
: data.status === "not_found" ? "—"
|
||||
: "";
|
||||
if (progressEl) progressEl.textContent = `${pct}% (${data.current}/${data.total}) ${statusText}`;
|
||||
} else if (eventType === "done") {
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
const previewNote = data.preview_saved > 0 ? ` (+${data.preview_saved} preview)` : "";
|
||||
showToast(t("civitaiBatchDone", data.found, data.not_found) + previewNote, "success");
|
||||
// Reload caches
|
||||
const [newMeta, newCache] = await Promise.all([fetchModelMetadata(), fetchCivitaiCache()]);
|
||||
// Apply sha256 hashes from batch result directly in case fetchModelMetadata
|
||||
// returns before the server has flushed updated metadata to disk
|
||||
if (data.hashes) {
|
||||
for (const [modelName, sha256] of Object.entries(data.hashes)) {
|
||||
if (!newMeta[modelName]) newMeta[modelName] = {};
|
||||
if (!newMeta[modelName].sha256) newMeta[modelName].sha256 = sha256;
|
||||
}
|
||||
}
|
||||
state.modelMetadata = newMeta;
|
||||
state.civitaiCache = newCache;
|
||||
renderModelGrid();
|
||||
// Refresh side panel if open
|
||||
if (state.selectedModel) renderSideCivitai(state.selectedModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Batch CivitAI error:", err);
|
||||
showToast(t("civitaiError"), "error");
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
if (progressEl) progressEl.textContent = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Models Tab - Filtering, sorting, and the tag/group/directory filter dropdowns
|
||||
*/
|
||||
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { parseModelPath, getExtension } from "./helpers.js";
|
||||
|
||||
export function getCurrentModels() {
|
||||
return state.modelsByType[state.activeModelType] || [];
|
||||
}
|
||||
|
||||
export function isModelDisabled(modelName) {
|
||||
const s = state.disabledModels[state.activeModelType];
|
||||
return s ? s.has(modelName) : false;
|
||||
}
|
||||
|
||||
export function filterModels() {
|
||||
let models = getCurrentModels();
|
||||
|
||||
if (state.statusFilter === "enabled") {
|
||||
models = models.filter((m) => !isModelDisabled(m));
|
||||
} else if (state.statusFilter === "disabled") {
|
||||
models = models.filter((m) => isModelDisabled(m));
|
||||
}
|
||||
|
||||
if (state.showFavoritesOnly) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.favorite;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.showBatchOnly) {
|
||||
const batchMembers = state.modelGroups["Batch"] || [];
|
||||
models = models.filter((m) => batchMembers.includes(m));
|
||||
}
|
||||
|
||||
if (state.tagFilter) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.tags && meta.tags.includes(state.tagFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.badgeFilter) {
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
return meta && meta.badges && meta.badges.includes(state.badgeFilter);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.dirFilter) {
|
||||
models = models.filter((m) => {
|
||||
const { dir } = parseModelPath(m);
|
||||
return dir === state.dirFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (state.groupFilter) {
|
||||
const members = state.modelGroups[state.groupFilter] || [];
|
||||
models = models.filter((m) => members.includes(m));
|
||||
}
|
||||
|
||||
if (state.searchText) {
|
||||
const q = state.searchText.toLowerCase();
|
||||
models = models.filter((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
const searchable = [m, ...(meta?.tags || []), meta?.memo || ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return sortModels(models);
|
||||
}
|
||||
|
||||
export function sortKeyOf(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
switch (state.sortColumn) {
|
||||
case "fav":
|
||||
return meta.favorite ? 1 : 0;
|
||||
case "filename":
|
||||
return parseModelPath(modelName).name.toLowerCase();
|
||||
case "subdir":
|
||||
return parseModelPath(modelName).dir.toLowerCase();
|
||||
case "civtype": {
|
||||
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
|
||||
return (civ?.type || "").toLowerCase();
|
||||
}
|
||||
case "basemodel": {
|
||||
const civ = meta.sha256 && state.civitaiCache[meta.sha256];
|
||||
return (civ?.baseModel || "").toLowerCase();
|
||||
}
|
||||
case "ext":
|
||||
return getExtension(parseModelPath(modelName).name).toLowerCase();
|
||||
case "tags":
|
||||
return (meta.tags || []).join(", ").toLowerCase();
|
||||
case "memo":
|
||||
return (meta.memo || "").toLowerCase();
|
||||
case "enabled":
|
||||
return isModelDisabled(modelName) ? 1 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function sortModels(models) {
|
||||
if (!state.sortColumn) return models;
|
||||
// ソートキーを1モデル1回だけ計算(比較ごとのparseModelPath等の再計算を回避)
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
return models
|
||||
.map((m) => [sortKeyOf(m), m])
|
||||
.sort((a, b) => (a[0] < b[0] ? -dir : a[0] > b[0] ? dir : 0))
|
||||
.map((pair) => pair[1]);
|
||||
}
|
||||
|
||||
export function getAllTags() {
|
||||
const set = new Set();
|
||||
const models = getCurrentModels();
|
||||
models.forEach((m) => {
|
||||
const meta = state.modelMetadata[m];
|
||||
if (meta?.tags) meta.tags.forEach((tag) => set.add(tag));
|
||||
});
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Render: Tag Filter ────────────────────────────────────
|
||||
|
||||
export function renderTagFilter() {
|
||||
const select = document.getElementById("wfm-models-tag-filter");
|
||||
if (!select) return;
|
||||
const tags = getAllTags();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllTags")}</option>` +
|
||||
tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
|
||||
select.value = state.tagFilter;
|
||||
}
|
||||
|
||||
export function renderGroupFilter() {
|
||||
const select = document.getElementById("wfm-models-group-filter");
|
||||
if (!select) return;
|
||||
|
||||
const groups = state.allModelGroups[state.activeModelType] || {};
|
||||
const names = Object.keys(groups).sort();
|
||||
|
||||
// 選択中のグループが削除された場合はフィルターをリセット
|
||||
if (state.groupFilter && !groups[state.groupFilter]) {
|
||||
state.groupFilter = "";
|
||||
}
|
||||
|
||||
const currentValue = state.groupFilter
|
||||
? `${state.activeModelType}::${state.groupFilter}`
|
||||
: "";
|
||||
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllGroups")}</option>` +
|
||||
names.map((name) => {
|
||||
const value = `${state.activeModelType}::${name}`;
|
||||
const isActive = value === currentValue;
|
||||
return `<option value="${escapeHtml(value)}"${isActive ? " selected" : ""}>${escapeHtml(name)}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export function renderDirFilter() {
|
||||
const select = document.getElementById("wfm-models-dir-filter");
|
||||
if (!select) return;
|
||||
const dirs = [...new Set(
|
||||
getCurrentModels()
|
||||
.map((m) => parseModelPath(m).dir)
|
||||
.filter((d) => d !== "")
|
||||
)].sort();
|
||||
select.innerHTML =
|
||||
`<option value="">${t("modelsAllDirs")}</option>` +
|
||||
dirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("");
|
||||
select.value = state.dirFilter;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Models Tab - Grid rendering (Thumbnail / Table views) and per-card
|
||||
* Batch/Stack/Favorite toggles.
|
||||
*
|
||||
* This module and selection-bulk.js / detail-panel.js form a circular
|
||||
* import triangle with each other and with models-tab.js: rendering a card
|
||||
* needs to wire up selection, side-panel, and detail-modal click handlers,
|
||||
* and those in turn re-render the grid after they change state. All the
|
||||
* cross-module calls happen inside event handlers (never at module
|
||||
* evaluation time), and every export here is a `function` declaration
|
||||
* (hoisted), so the cycle resolves safely under ES Modules.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state } from "./state.js";
|
||||
import { filterModels, getCurrentModels, isModelDisabled } from "./filters.js";
|
||||
import { modelBadgesHtml } from "./badges.js";
|
||||
import { parseModelPath, getExtension, getStem, loadPreviewImage } from "./helpers.js";
|
||||
import { toggleModelSelection } from "./selection-bulk.js";
|
||||
import { showSidePanel, openDetailModal, renderSideInfo } from "./detail-panel.js";
|
||||
import { saveModelGroups, saveModelMetadata, toggleModelEnable } from "../models-tab.js";
|
||||
|
||||
export function renderModelGrid() {
|
||||
const grid = document.getElementById("wfm-models-grid");
|
||||
if (!grid) return;
|
||||
|
||||
grid.className = `wfm-grid wfm-view-${state.viewMode}`;
|
||||
const filtered = filterModels();
|
||||
|
||||
// Update count
|
||||
const countEl = document.getElementById("wfm-models-count");
|
||||
if (countEl) {
|
||||
const total = getCurrentModels().length;
|
||||
countEl.textContent = `${filtered.length} / ${total}`;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="wfm-placeholder">${t("modelsNoModels")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.viewMode === "table") {
|
||||
renderTableView(grid, filtered);
|
||||
} else {
|
||||
renderThumbView(grid, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thumbnail View (same as Workflow tab) ─────────────────
|
||||
|
||||
export function renderThumbView(grid, models) {
|
||||
grid.innerHTML = "";
|
||||
models.forEach((modelName) => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { name } = parseModelPath(modelName);
|
||||
const disabled = isModelDisabled(modelName);
|
||||
const userBadges = modelBadgesHtml(modelName);
|
||||
const tagsHtml = (meta.tags || []).map((tag) => `<span class="wfm-badge wfm-badge-sm">${escapeHtml(tag)}</span>`).join("");
|
||||
const favStar = meta.favorite ? "★" : "☆";
|
||||
const favClass = meta.favorite ? "wfm-fav-btn active" : "wfm-fav-btn";
|
||||
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
|
||||
const showStackBtn = state.activeModelType === "lora";
|
||||
const inBatch = showBatchBtn && isInBatch(modelName);
|
||||
const inStack = showStackBtn && isInStack(modelName);
|
||||
const batchClass = inBatch ? "wfm-batch-btn active" : "wfm-batch-btn";
|
||||
const stackClass = inStack ? "wfm-stack-btn active" : "wfm-stack-btn";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-card";
|
||||
if (disabled) card.classList.add("wfm-model-disabled");
|
||||
if (state.selectedModel === modelName) card.classList.add("wfm-card-selected");
|
||||
card.dataset.modelName = modelName;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="wfm-card-thumb">
|
||||
<img style="display:none" />
|
||||
<span class="wfm-card-thumb-placeholder">${t("modelsNoPreview")}</span>
|
||||
${disabled ? `<span class="wfm-disabled-overlay">${t("modelDisabled")}</span>` : ""}
|
||||
</div>
|
||||
<div class="wfm-card-body">
|
||||
<div class="wfm-card-title" title="${escapeHtml(modelName)}">${escapeHtml(getStem(name))}</div>
|
||||
<div class="wfm-card-meta">${userBadges} ${tagsHtml}</div>
|
||||
</div>
|
||||
${showBatchBtn ? `<button class="${batchClass}" title="${t("modelsBatch")}">B</button>` : ""}
|
||||
${showStackBtn ? `<button class="${stackClass}" title="Stack (Lora multi-apply)">S</button>` : ""}
|
||||
<button class="${favClass}" title="${t("modelsFavorite")}">${favStar}</button>
|
||||
<button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${disabled ? t("modelEnable") : t("modelDisable")}">${disabled ? "▶" : "⏸"}</button>`;
|
||||
|
||||
// Load preview without 404 console spam
|
||||
const img = card.querySelector(".wfm-card-thumb img");
|
||||
const placeholder = card.querySelector(".wfm-card-thumb-placeholder");
|
||||
loadPreviewImage(img, placeholder, modelName);
|
||||
|
||||
card.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleBatch(modelName);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
btn.classList.toggle("active", isInBatch(modelName));
|
||||
}
|
||||
});
|
||||
card.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
await toggleStack(modelName);
|
||||
btn.classList.toggle("active", isInStack(modelName));
|
||||
});
|
||||
card.querySelector(".wfm-fav-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(modelName);
|
||||
});
|
||||
card.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(modelName);
|
||||
});
|
||||
if (state.selectMode) {
|
||||
const isChecked = state.selectedModels.has(modelName);
|
||||
card.classList.toggle("wfm-card-checked", isChecked);
|
||||
const checkEl = document.createElement("div");
|
||||
checkEl.className = "wfm-select-check" + (isChecked ? " checked" : "");
|
||||
card.appendChild(checkEl);
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-batch-btn, .wfm-stack-btn, .wfm-fav-btn, .wfm-toggle-btn")) return;
|
||||
toggleModelSelection(modelName);
|
||||
});
|
||||
} else {
|
||||
card.addEventListener("click", () => showSidePanel(modelName));
|
||||
card.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(modelName); });
|
||||
}
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ── Table View ────────────────────────────────────────────
|
||||
|
||||
export function thSortHtml(label, col, extraClass = "", extraStyle = "") {
|
||||
const isActive = state.sortColumn === col;
|
||||
const arrow = isActive ? (state.sortDir === "asc" ? " ▲" : " ▼") : "";
|
||||
const activeStyle = isActive ? "color:var(--wfm-accent,#6366f1);" : "";
|
||||
const cls = ["wfm-table-th-sortable", extraClass].filter(Boolean).join(" ");
|
||||
return `<th class="${cls}" data-sort-col="${col}" style="${activeStyle}${extraStyle}">${label}${arrow}</th>`;
|
||||
}
|
||||
|
||||
export function renderTableView(grid, models) {
|
||||
const showBatchBtn = ["checkpoint", "lora"].includes(state.activeModelType);
|
||||
const showStackBtn = state.activeModelType === "lora";
|
||||
const rows = models
|
||||
.map((modelName) => {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const { dir, name } = parseModelPath(modelName);
|
||||
const ext = getExtension(name);
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiType = civitai ? (civitai.type || "") : "";
|
||||
const civitaiBaseModel = civitai ? (civitai.baseModel || "") : "";
|
||||
const disabled = isModelDisabled(modelName);
|
||||
const isChecked = state.selectMode && state.selectedModels.has(modelName);
|
||||
const favIcon = meta.favorite ? "★" : "☆";
|
||||
const batchIcon = !showBatchBtn ? "" : isInBatch(modelName) ? `<button class="wfm-batch-btn active" title="${t("modelsBatch")}">B</button>` : `<button class="wfm-batch-btn" title="${t("modelsBatch")}">B</button>`;
|
||||
const stackIcon = isInStack(modelName) ? `<button class="wfm-stack-btn active" title="Stack (Lora multi-apply)">S</button>` : `<button class="wfm-stack-btn" title="Stack (Lora multi-apply)">S</button>`;
|
||||
const tagsStr = (meta.tags || []).join(", ");
|
||||
const memo = meta.memo || "";
|
||||
const toggleLabel = disabled ? t("modelEnable") : t("modelDisable");
|
||||
const toggleIcon = disabled ? "▶" : "⏸";
|
||||
const checkCell = state.selectMode
|
||||
? `<td class="wfm-table-td-check"><div class="wfm-select-check${isChecked ? " checked" : ""}"></div></td>`
|
||||
: "";
|
||||
return `<tr class="wfm-models-table-row${state.selectedModel === modelName ? " wfm-card-selected" : ""}${disabled ? " wfm-model-disabled" : ""}${isChecked ? " wfm-card-checked" : ""}" data-model-name="${escapeHtml(modelName)}">
|
||||
${checkCell}
|
||||
<td class="wfm-models-table-fav" title="Favorite">${favIcon}</td>
|
||||
<td class="wfm-table-td-thumb"><img class="wfm-table-thumb" style="display:none" /></td>
|
||||
<td class="wfm-table-td-filename" title="${escapeHtml(modelName)}">${escapeHtml(name)}</td>
|
||||
<td class="wfm-table-td-subdir">${escapeHtml(dir)}</td>
|
||||
<td class="wfm-table-td-civtype" title="${escapeHtml(civitaiType)}">${escapeHtml(civitaiType)}</td>
|
||||
<td class="wfm-table-td-basemodel" title="${escapeHtml(civitaiBaseModel)}">${escapeHtml(civitaiBaseModel)}</td>
|
||||
<td class="wfm-table-td-ext">${escapeHtml(ext)}</td>
|
||||
<td>${escapeHtml(tagsStr)}</td>
|
||||
<td class="wfm-table-td-memo" title="${escapeHtml(memo)}">${escapeHtml(memo)}</td>
|
||||
<td class="wfm-table-td-toggle"><button class="wfm-toggle-btn${disabled ? " wfm-toggle-disabled" : ""}" title="${toggleLabel}">${toggleIcon}</button></td>
|
||||
${showBatchBtn ? `<td class="wfm-table-td-batch">${batchIcon}</td>` : ""}
|
||||
${showStackBtn ? `<td class="wfm-table-td-stack">${stackIcon}</td>` : ""}
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const checkTh = state.selectMode ? `<th style="width:24px;"></th>` : "";
|
||||
grid.innerHTML = `<table class="wfm-models-table"><thead><tr>
|
||||
${checkTh}
|
||||
${thSortHtml("★", "fav", "", "width:30px;text-align:center;")}
|
||||
<th style="width:40px;"></th>
|
||||
${thSortHtml(t("modelsFileName"), "filename", "wfm-table-th-filename")}
|
||||
${thSortHtml(t("modelsSubdir"), "subdir", "wfm-table-th-subdir")}
|
||||
${thSortHtml(t("civitaiType"), "civtype", "wfm-table-th-civtype")}
|
||||
${thSortHtml(t("civitaiBaseModel"), "basemodel", "wfm-table-th-basemodel")}
|
||||
${thSortHtml(t("modelsExt"), "ext", "wfm-table-th-ext")}
|
||||
${thSortHtml(t("modelsTags"), "tags")}
|
||||
${thSortHtml(t("modelsMemo"), "memo")}
|
||||
${thSortHtml("E/D", "enabled", "", "width:50px;text-align:center;")}
|
||||
${showBatchBtn ? `<th style="width:30px;">B</th>` : ""}
|
||||
${showStackBtn ? `<th style="width:30px;">S</th>` : ""}
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
|
||||
grid.querySelectorAll(".wfm-models-table-row").forEach((row) => {
|
||||
const mn = row.dataset.modelName;
|
||||
const img = row.querySelector(".wfm-table-thumb");
|
||||
if (img) loadPreviewImage(img, null, mn);
|
||||
|
||||
if (state.selectMode) {
|
||||
row.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".wfm-models-table-fav, .wfm-toggle-btn, .wfm-batch-btn, .wfm-stack-btn")) return;
|
||||
toggleModelSelection(mn);
|
||||
});
|
||||
} else {
|
||||
row.addEventListener("click", () => showSidePanel(mn));
|
||||
row.addEventListener("dblclick", (e) => { e.stopPropagation(); openDetailModal(mn); });
|
||||
}
|
||||
row.querySelector(".wfm-models-table-fav").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(mn);
|
||||
});
|
||||
row.querySelector(".wfm-toggle-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleModelEnable(mn);
|
||||
});
|
||||
row.querySelector(".wfm-batch-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await toggleBatch(mn);
|
||||
if (state.showBatchOnly) {
|
||||
renderModelGrid();
|
||||
} else {
|
||||
e.currentTarget.classList.toggle("active", isInBatch(mn));
|
||||
}
|
||||
});
|
||||
row.querySelector(".wfm-stack-btn")?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await toggleStack(mn);
|
||||
e.currentTarget.classList.toggle("active", isInStack(mn));
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll(".wfm-table-th-sortable").forEach((th) => {
|
||||
th.addEventListener("click", () => {
|
||||
const col = th.dataset.sortCol;
|
||||
if (state.sortColumn === col) {
|
||||
if (state.sortDir === "asc") {
|
||||
state.sortDir = "desc";
|
||||
} else {
|
||||
state.sortColumn = null;
|
||||
state.sortDir = "asc";
|
||||
}
|
||||
} else {
|
||||
state.sortColumn = col;
|
||||
state.sortDir = "asc";
|
||||
}
|
||||
renderModelGrid();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch group helpers ───────────────────────────────────
|
||||
|
||||
export function isInBatch(modelName) {
|
||||
return (state.modelGroups["Batch"] || []).includes(modelName);
|
||||
}
|
||||
|
||||
export async function toggleBatch(modelName) {
|
||||
const batch = state.modelGroups["Batch"] || [];
|
||||
const idx = batch.indexOf(modelName);
|
||||
if (idx >= 0) {
|
||||
batch.splice(idx, 1);
|
||||
} else {
|
||||
batch.push(modelName);
|
||||
}
|
||||
state.modelGroups["Batch"] = batch;
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
}
|
||||
|
||||
export async function clearBatchGroup() {
|
||||
state.modelGroups["Batch"] = [];
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
if (state.showBatchOnly) {
|
||||
state.showBatchOnly = false;
|
||||
document.getElementById("wfm-models-batch-filter-btn")?.classList.remove("active");
|
||||
}
|
||||
renderModelGrid();
|
||||
showToast(t("modelsBatchClear"), "success");
|
||||
}
|
||||
|
||||
export function isInStack(modelName) {
|
||||
return (state.modelGroups["Stack"] || []).includes(modelName);
|
||||
}
|
||||
|
||||
export async function toggleStack(modelName) {
|
||||
const stack = state.modelGroups["Stack"] || [];
|
||||
const idx = stack.indexOf(modelName);
|
||||
if (idx >= 0) {
|
||||
stack.splice(idx, 1);
|
||||
} else {
|
||||
stack.push(modelName);
|
||||
}
|
||||
state.modelGroups["Stack"] = stack;
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
}
|
||||
|
||||
export async function clearStackGroup() {
|
||||
state.modelGroups["Stack"] = [];
|
||||
state.allModelGroups[state.activeModelType] = state.modelGroups;
|
||||
await saveModelGroups(state.modelGroups);
|
||||
renderModelGrid();
|
||||
showToast(t("stackCleared"), "success");
|
||||
}
|
||||
|
||||
// ── Favorite toggle ───────────────────────────────────────
|
||||
|
||||
export async function toggleFavorite(modelName) {
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const newFav = !meta.favorite;
|
||||
await saveModelMetadata(modelName, { favorite: newFav });
|
||||
renderModelGrid();
|
||||
if (state.selectedModel === modelName) renderSideInfo(modelName);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Models Tab - Small path/preview helpers shared across the models/* modules
|
||||
*/
|
||||
|
||||
import { state } from "./state.js";
|
||||
|
||||
export function parseModelPath(fullName) {
|
||||
const lastSlash = Math.max(fullName.lastIndexOf("/"), fullName.lastIndexOf("\\"));
|
||||
if (lastSlash === -1) return { dir: "", name: fullName };
|
||||
return { dir: fullName.substring(0, lastSlash), name: fullName.substring(lastSlash + 1) };
|
||||
}
|
||||
|
||||
export function getExtension(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.substring(dot) : "";
|
||||
}
|
||||
|
||||
export function getStem(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
|
||||
export function previewUrl(modelName, modelType) {
|
||||
const type = modelType || state.activeModelType;
|
||||
return `/api/wfm/models/preview?type=${encodeURIComponent(type)}&name=${encodeURIComponent(modelName)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preview image. Uses img onload/onerror instead of HEAD request
|
||||
* (aiohttp add_get does not auto-handle HEAD method).
|
||||
* Falls back to CivitAI cached image if no local preview exists.
|
||||
*/
|
||||
export function loadPreviewImage(imgEl, placeholderEl, modelName, modelType) {
|
||||
const url = previewUrl(modelName, modelType);
|
||||
imgEl.onload = () => {
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
};
|
||||
imgEl.onerror = () => {
|
||||
// Fallback: use CivitAI cached image if available
|
||||
const meta = state.modelMetadata[modelName] || {};
|
||||
const sha256 = meta.sha256;
|
||||
const civitai = sha256 && state.civitaiCache[sha256];
|
||||
const civitaiImg = civitai && civitai.images && civitai.images[0];
|
||||
if (civitaiImg) {
|
||||
imgEl.onerror = () => {
|
||||
imgEl.style.display = "none";
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
};
|
||||
imgEl.src = civitaiImg;
|
||||
imgEl.style.display = "";
|
||||
if (placeholderEl) placeholderEl.style.display = "none";
|
||||
} else {
|
||||
imgEl.style.display = "none";
|
||||
if (placeholderEl) placeholderEl.style.display = "";
|
||||
}
|
||||
};
|
||||
imgEl.src = url;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Models Tab - Multi-select mode and bulk group/badge/favorite/move/delete
|
||||
* operations on the current selection.
|
||||
*
|
||||
* Part of the grid-view.js / selection-bulk.js / detail-panel.js circular
|
||||
* import triangle — see the note at the top of grid-view.js.
|
||||
*/
|
||||
|
||||
import { showToast } from "../app.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { escapeHtml } from "../util.js";
|
||||
import { state, RESERVED_GROUPS } from "./state.js";
|
||||
import { filterModels, renderDirFilter } from "./filters.js";
|
||||
import { getBadgePalette } from "./badges.js";
|
||||
import { renderModelGrid } from "./grid-view.js";
|
||||
import { saveModelGroups, saveModelMetadata } from "../models-tab.js";
|
||||
|
||||
export function toggleSelectMode() {
|
||||
state.selectMode = !state.selectMode;
|
||||
if (!state.selectMode) state.selectedModels.clear();
|
||||
const btn = document.getElementById("wfm-models-select-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", state.selectMode);
|
||||
btn.textContent = state.selectMode ? t("modelSelectExit") : t("modelSelectMode");
|
||||
}
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function toggleModelSelection(modelName) {
|
||||
if (state.selectedModels.has(modelName)) {
|
||||
state.selectedModels.delete(modelName);
|
||||
} else {
|
||||
state.selectedModels.add(modelName);
|
||||
}
|
||||
// Update DOM directly (avoid full re-render)
|
||||
document.querySelectorAll("[data-model-name]").forEach((el) => {
|
||||
if (el.dataset.modelName !== modelName) return;
|
||||
const checked = state.selectedModels.has(modelName);
|
||||
el.classList.toggle("wfm-card-checked", checked);
|
||||
const c = el.querySelector(".wfm-select-check");
|
||||
if (c) c.classList.toggle("checked", checked);
|
||||
});
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function clearSelection() {
|
||||
state.selectedModels.clear();
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function selectAll() {
|
||||
filterModels().forEach(m => state.selectedModels.add(m));
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export function renderBulkActionBar() {
|
||||
const bar = document.getElementById("wfm-models-bulk-bar");
|
||||
if (!bar) return;
|
||||
if (!state.selectMode || state.selectedModels.size === 0) {
|
||||
bar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const count = state.selectedModels.size;
|
||||
const groupNames = Object.keys(state.modelGroups).sort();
|
||||
const noGroups = groupNames.length === 0;
|
||||
const groupOptions = noGroups
|
||||
? `<option value="">${t("modelsNoGroupAvailable")}</option>`
|
||||
: groupNames.map((g) => `<option value="${escapeHtml(g)}">${escapeHtml(g)}</option>`).join("");
|
||||
|
||||
const palette = getBadgePalette();
|
||||
const badgeLabels = Object.keys(palette).sort();
|
||||
const noBadges = badgeLabels.length === 0;
|
||||
const badgeOptions = noBadges
|
||||
? `<option value="">${t("modelBulkNoBadge")}</option>`
|
||||
: badgeLabels.map((b) => `<option value="${escapeHtml(b)}">${escapeHtml(b)}</option>`).join("");
|
||||
|
||||
bar.style.display = "flex";
|
||||
bar.innerHTML = `
|
||||
<div class="wfm-bulk-header">
|
||||
<span class="wfm-bulk-count">${count} ${t("modelSelected")}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-deselect-all-btn">${t("modelBulkDeselectAll")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-select-all-btn">${t("modelBulkSelectAll")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-add-btn">${t("modelBulkFavAdd")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-fav-remove-btn">${t("modelBulkFavRemove")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-rows">
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">Group:</span>
|
||||
<select id="wfm-bulk-group-select" class="wfm-select wfm-bulk-select">${groupOptions}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-add-btn"${noGroups ? " disabled" : ""}>${t("modelBulkAddGroup")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-remove-btn"${noGroups ? " disabled" : ""}>${t("modelBulkRemoveGroup")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<input type="text" id="wfm-bulk-new-group-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelsGroupName")}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-create-add-btn">${t("modelBulkCreateAdd")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">Badge:</span>
|
||||
<select id="wfm-bulk-badge-select" class="wfm-select wfm-bulk-select">${badgeOptions}</select>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="wfm-bulk-badge-apply-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeApply")}</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-badge-remove-btn"${noBadges ? " disabled" : ""}>${t("modelBulkBadgeRemove")}</button>
|
||||
</div>
|
||||
<div class="wfm-bulk-row">
|
||||
<span class="wfm-bulk-row-label">File:</span>
|
||||
<select id="wfm-bulk-move-select" class="wfm-select wfm-bulk-select">
|
||||
<option value="">${t("modelBulkMoveRoot")}</option>
|
||||
${state.subdirs.map((d) => `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`).join("")}
|
||||
</select>
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-move-btn">${t("modelBulkMoveBtn")}</button>
|
||||
<span class="wfm-bulk-sep"></span>
|
||||
<input type="text" id="wfm-bulk-new-dir-input" class="wfm-search-input wfm-bulk-input" placeholder="${t("modelBulkMoveNewFolder")}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-bulk-mkdir-move-btn">${t("modelBulkMoveCreateMove")}</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-danger" id="wfm-bulk-delete-btn">${t("modelBulkDelete")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("wfm-bulk-deselect-all-btn")?.addEventListener("click", clearSelection);
|
||||
document.getElementById("wfm-bulk-select-all-btn")?.addEventListener("click", selectAll);
|
||||
document.getElementById("wfm-bulk-fav-add-btn")?.addEventListener("click", () => bulkSetFavorite(true));
|
||||
document.getElementById("wfm-bulk-fav-remove-btn")?.addEventListener("click", () => bulkSetFavorite(false));
|
||||
document.getElementById("wfm-bulk-add-btn")?.addEventListener("click", () => {
|
||||
const g = document.getElementById("wfm-bulk-group-select")?.value;
|
||||
if (g) bulkAddToGroup(g);
|
||||
});
|
||||
document.getElementById("wfm-bulk-remove-btn")?.addEventListener("click", () => {
|
||||
const g = document.getElementById("wfm-bulk-group-select")?.value;
|
||||
if (g) bulkRemoveFromGroup(g);
|
||||
});
|
||||
document.getElementById("wfm-bulk-create-add-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-bulk-new-group-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
if (state.modelGroups[name]) { showToast(t("modelsGroupExists"), "warning"); return; }
|
||||
bulkAddToGroup(name);
|
||||
});
|
||||
document.getElementById("wfm-bulk-badge-apply-btn")?.addEventListener("click", () => {
|
||||
const label = document.getElementById("wfm-bulk-badge-select")?.value;
|
||||
if (label) bulkApplyBadge(label, true);
|
||||
});
|
||||
document.getElementById("wfm-bulk-badge-remove-btn")?.addEventListener("click", () => {
|
||||
const label = document.getElementById("wfm-bulk-badge-select")?.value;
|
||||
if (label) bulkApplyBadge(label, false);
|
||||
});
|
||||
document.getElementById("wfm-bulk-move-btn")?.addEventListener("click", () => {
|
||||
const dest = document.getElementById("wfm-bulk-move-select")?.value || "";
|
||||
bulkMoveModels(dest);
|
||||
});
|
||||
document.getElementById("wfm-bulk-mkdir-move-btn")?.addEventListener("click", () => {
|
||||
const input = document.getElementById("wfm-bulk-new-dir-input");
|
||||
const name = input?.value.trim();
|
||||
if (!name) return;
|
||||
bulkMoveModels(name);
|
||||
});
|
||||
document.getElementById("wfm-bulk-delete-btn")?.addEventListener("click", bulkDeleteModels);
|
||||
}
|
||||
|
||||
export async function bulkAddToGroup(groupName) {
|
||||
const groups = { ...state.modelGroups };
|
||||
if (!groups[groupName]) groups[groupName] = [];
|
||||
const toAdd = [...state.selectedModels].filter((m) => !groups[groupName].includes(m));
|
||||
groups[groupName] = [...groups[groupName], ...toAdd];
|
||||
await saveModelGroups(groups);
|
||||
showToast(`${toAdd.length} ${t("modelBulkAddDone")}`, "success");
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export async function bulkRemoveFromGroup(groupName) {
|
||||
const groups = { ...state.modelGroups };
|
||||
if (!groups[groupName]) return;
|
||||
const before = groups[groupName].length;
|
||||
groups[groupName] = groups[groupName].filter((m) => !state.selectedModels.has(m));
|
||||
const removed = before - groups[groupName].length;
|
||||
// RESERVED_GROUPS (Batch / Stack) は空になってもキーを保持する
|
||||
if (groups[groupName].length === 0 && !RESERVED_GROUPS.includes(groupName)) delete groups[groupName];
|
||||
await saveModelGroups(groups);
|
||||
showToast(`${removed} ${t("modelBulkRemoveDone")}`, "success");
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
|
||||
export async function bulkSetFavorite(isFav) {
|
||||
const models = [...state.selectedModels];
|
||||
let count = 0;
|
||||
for (const mn of models) {
|
||||
const meta = state.modelMetadata[mn] || {};
|
||||
if (meta.favorite === isFav) continue;
|
||||
await saveModelMetadata(mn, { favorite: isFav });
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
showToast(`${count} ${isFav ? t("modelBulkFavDone") : t("modelBulkUnfavDone")}`, "success");
|
||||
renderModelGrid();
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkApplyBadge(badgeLabel, add) {
|
||||
const models = [...state.selectedModels];
|
||||
let count = 0;
|
||||
for (const mn of models) {
|
||||
const meta = state.modelMetadata[mn] || {};
|
||||
const badges = [...(meta.badges || [])];
|
||||
if (add) {
|
||||
if (badges.includes(badgeLabel)) continue;
|
||||
badges.push(badgeLabel);
|
||||
} else {
|
||||
const idx = badges.indexOf(badgeLabel);
|
||||
if (idx === -1) continue;
|
||||
badges.splice(idx, 1);
|
||||
}
|
||||
await saveModelMetadata(mn, { badges });
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
showToast(`${count} ${add ? t("modelBulkBadgeApplyDone") : t("modelBulkBadgeRemoveDone")}`, "success");
|
||||
renderModelGrid();
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteModels() {
|
||||
const count = state.selectedModels.size;
|
||||
if (count === 0) return;
|
||||
const msg = t("modelBulkDeleteConfirm").replace("{count}", count);
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
const model_names = [...state.selectedModels];
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const okCount = (data.ok || []).length;
|
||||
const errCount = (data.errors || []).length;
|
||||
if (errCount > 0) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${errCount} errors`, "error");
|
||||
}
|
||||
if (okCount > 0) {
|
||||
showToast(`${okCount} ${t("modelBulkDeleteDone")}`, "success");
|
||||
// Remove deleted models from local state
|
||||
model_names.forEach((mn) => {
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(mn);
|
||||
if (idx !== -1) list.splice(idx, 1);
|
||||
delete state.modelMetadata[mn];
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds) ds.delete(mn);
|
||||
state.selectedModels.delete(mn);
|
||||
});
|
||||
state.loaded[state.activeModelType] = false;
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkDeleteError")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSubdirs() {
|
||||
try {
|
||||
const res = await fetch(`/api/wfm/models/subdirs?type=${encodeURIComponent(state.activeModelType)}`);
|
||||
state.subdirs = res.ok ? await res.json() : [];
|
||||
} catch { state.subdirs = []; }
|
||||
}
|
||||
|
||||
export async function bulkMoveModels(destSubdir) {
|
||||
const model_names = [...state.selectedModels];
|
||||
if (model_names.length === 0) return;
|
||||
try {
|
||||
const res = await fetch("/api/wfm/models/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_type: state.activeModelType, model_names, dest: destSubdir }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const okCount = data.moved?.length || 0;
|
||||
const errCount = data.errors?.length || 0;
|
||||
|
||||
if (okCount > 0) {
|
||||
data.moved.forEach(({ from, to }) => {
|
||||
const list = state.modelsByType[state.activeModelType];
|
||||
const idx = list.indexOf(from);
|
||||
if (idx !== -1) list[idx] = to;
|
||||
if (state.modelMetadata[from]) {
|
||||
state.modelMetadata[to] = state.modelMetadata[from];
|
||||
delete state.modelMetadata[from];
|
||||
}
|
||||
const ds = state.disabledModels[state.activeModelType];
|
||||
if (ds && ds.has(from)) { ds.delete(from); ds.add(to); }
|
||||
state.selectedModels.delete(from);
|
||||
for (const members of Object.values(state.modelGroups)) {
|
||||
const gi = members.indexOf(from);
|
||||
if (gi !== -1) members[gi] = to;
|
||||
}
|
||||
});
|
||||
if (state.selectedModel && data.moved.some((m) => m.from === state.selectedModel)) {
|
||||
state.selectedModel = null;
|
||||
const titleEl = document.getElementById("wfm-models-panel-title");
|
||||
if (titleEl) titleEl.textContent = "";
|
||||
}
|
||||
showToast(`${okCount} ${t("modelBulkMoveDone")}`, "success");
|
||||
await fetchSubdirs();
|
||||
renderDirFilter();
|
||||
renderModelGrid();
|
||||
renderBulkActionBar();
|
||||
}
|
||||
if (errCount > 0) {
|
||||
showToast(`${t("modelBulkMoveError")}: ${data.errors[0].error}`, "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${t("modelBulkMoveError")}: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Models Tab - Shared state and static lookup tables
|
||||
* `state` is a `const` object never reassigned — modules read/write its
|
||||
* properties directly, which stays within ESM's read-only binding rule
|
||||
* (only the binding itself, not the object it points to, is immutable).
|
||||
*/
|
||||
|
||||
import { comfyUI } from "../comfyui-client.js";
|
||||
|
||||
export const RESERVED_GROUPS = ["Batch", "Stack"];
|
||||
export const BATCH_MODEL_TYPES = ["checkpoint", "lora"];
|
||||
export const STACK_MODEL_TYPES = ["lora"];
|
||||
|
||||
export const state = {
|
||||
modelsByType: {
|
||||
checkpoint: [],
|
||||
lora: [],
|
||||
vae: [],
|
||||
controlnet: [],
|
||||
unet: [],
|
||||
textencoder: [],
|
||||
hypernetwork: [],
|
||||
embedding: [],
|
||||
},
|
||||
modelMetadata: {},
|
||||
modelGroups: {},
|
||||
allModelGroups: {}, // { type: { groupName: [models] } } — all types
|
||||
civitaiCache: {},
|
||||
disabledModels: {}, // { type: Set<modelName> }
|
||||
subdirs: [],
|
||||
selectMode: false,
|
||||
selectedModels: new Set(),
|
||||
searchText: "",
|
||||
tagFilter: "",
|
||||
badgeFilter: "",
|
||||
dirFilter: "",
|
||||
groupFilter: "",
|
||||
statusFilter: "all", // "all" | "enabled" | "disabled"
|
||||
showFavoritesOnly: false,
|
||||
showBatchOnly: false,
|
||||
viewMode: localStorage.getItem("wfm_models_view") === "table" ? "table" : "thumb",
|
||||
activeModelType: "checkpoint",
|
||||
selectedModel: null,
|
||||
loaded: {},
|
||||
currentPage: 0,
|
||||
sortColumn: null, // "fav" | "filename" | "subdir" | "civtype" | "basemodel" | "ext" | "tags" | "memo" | "enabled"
|
||||
sortDir: "asc", // "asc" | "desc"
|
||||
};
|
||||
|
||||
|
||||
export const FETCH_MAP = {
|
||||
checkpoint: () => comfyUI.fetchCheckpoints(),
|
||||
lora: () => comfyUI.fetchLoras(),
|
||||
vae: () => comfyUI.fetchVaes(),
|
||||
controlnet: () => comfyUI.fetchControlNets(),
|
||||
unet: () => comfyUI.fetchDiffusionModels(),
|
||||
textencoder: () => comfyUI.fetchTextEncoders(),
|
||||
hypernetwork: () => comfyUI.fetchHypernetworks(),
|
||||
embedding: () => comfyUI.fetchEmbeddings(),
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
checkpoint: "Checkpoint",
|
||||
lora: "LoRA",
|
||||
vae: "VAE",
|
||||
controlnet: "ControlNet",
|
||||
unet: "UNET",
|
||||
textencoder: "TextEncoder",
|
||||
hypernetwork: "Hypernetwork",
|
||||
embedding: "Embedding",
|
||||
};
|
||||
|
||||
// Mapping from models-tab type → comfyui-editor key + inputKey
|
||||
export const GENUI_TYPE_MAP = {
|
||||
checkpoint: { key: "checkpoints", inputKey: "ckpt_name" },
|
||||
lora: { key: "loras", inputKey: "lora_name" },
|
||||
vae: { key: "vaes", inputKey: "vae_name" },
|
||||
controlnet: { key: "controlNets", inputKey: "control_net_name" },
|
||||
unet: { key: "diffusionModels",inputKey: "unet_name" },
|
||||
textencoder: { key: "textEncoders", inputKey: "clip_name1" },
|
||||
hypernetwork: { key: "hypernetworks", inputKey: "hypernetwork_name" },
|
||||
};
|
||||
@@ -366,6 +366,21 @@ export function applyJsonColors(colors) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// GenerateUI Model tab: highlight color for section labels that match the loaded workflow
|
||||
const MODEL_ACTIVE_COLOR_STYLE_ID = "wfm-model-active-color-style";
|
||||
const MODEL_ACTIVE_COLOR_DEFAULT = "#4ade80";
|
||||
|
||||
export function applyModelTabActiveColor(color) {
|
||||
const val = color || MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
let el = document.getElementById(MODEL_ACTIVE_COLOR_STYLE_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("style");
|
||||
el.id = MODEL_ACTIVE_COLOR_STYLE_ID;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = `.wfm-form-group label.wfm-model-label-active,\n.wfm-settings-summary.wfm-model-label-active,\n#wfm-gen-lora-col-title.wfm-model-label-active,\n.wfm-lab-model-view-btn.wfm-model-label-active { color: ${val}; }`;
|
||||
}
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
if (themeId) {
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
@@ -577,6 +592,21 @@ export async function initSettingsTab() {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- GenerateUI Model Tab Highlight Color -->
|
||||
<details class="wfm-settings-section" open>
|
||||
<summary class="wfm-settings-summary">${t("modelTabColorSection") || "GenerateUI Modelタブ ハイライト色"}</summary>
|
||||
<div class="wfm-form-group">
|
||||
<label>${t("modelTabColorLabel") || "ワークフローに含まれる項目の色"}</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="color" id="wfm-model-active-color" value="${settings.modelTabActiveColor || MODEL_ACTIVE_COLOR_DEFAULT}">
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-model-active-color-reset">${t("jsonColorReset") || "デフォルトに戻す"}</button>
|
||||
</div>
|
||||
<small style="color:var(--wfm-text-secondary);font-size:11px;display:block;margin-top:4px;">
|
||||
${t("modelTabColorHint") || "GenerateUIのModelタブで、読み込んだワークフローに実際に含まれる項目(Checkpoint / VAE など)のラベルをこの色で表示します"}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Workflow Data Folder -->
|
||||
<details class="wfm-settings-section">
|
||||
<summary class="wfm-settings-summary">${t("workflowsDir")}</summary>
|
||||
@@ -1333,6 +1363,22 @@ export async function initSettingsTab() {
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// --- GenerateUI Model tab highlight color ---
|
||||
document.getElementById("wfm-model-active-color")?.addEventListener("input", (e) => {
|
||||
saveLocalSettings({ modelTabActiveColor: e.target.value });
|
||||
applyModelTabActiveColor(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("wfm-model-active-color-reset")?.addEventListener("click", () => {
|
||||
const cur = loadLocalSettings();
|
||||
delete cur.modelTabActiveColor;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cur));
|
||||
applyModelTabActiveColor(undefined);
|
||||
const inp = document.getElementById("wfm-model-active-color");
|
||||
if (inp) inp.value = MODEL_ACTIVE_COLOR_DEFAULT;
|
||||
showToast(t("settingsSaved"), "success");
|
||||
});
|
||||
|
||||
// Save (local settings)
|
||||
document.getElementById("wfm-settings-save")?.addEventListener("click", () => {
|
||||
const taFontSize = parseInt(document.getElementById("wfm-ta-font-size")?.value) || TEXTAREA_FONT_SIZE_DEFAULT;
|
||||
|
||||
@@ -102,6 +102,7 @@ export const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
unsloth: "http://localhost:8888",
|
||||
};
|
||||
|
||||
export function getAiBackendDefaultUrl(backend) {
|
||||
|
||||
+45
-25
@@ -350,7 +350,7 @@
|
||||
</div>
|
||||
<div class="wfm-gen-lora-col">
|
||||
<div class="wfm-gen-lora-col-header">
|
||||
<span>LoRA</span>
|
||||
<span id="wfm-gen-lora-col-title">LoRA</span>
|
||||
<button class="wfm-btn wfm-btn-xs wfm-lora-refresh-btn" id="wfm-lora-stack-refresh" title="Refresh Stack group">🔄</button>
|
||||
</div>
|
||||
<div id="wfm-gen-lora-fields" class="wfm-gen-lora-col-body">
|
||||
@@ -556,25 +556,22 @@
|
||||
<!-- Center: 4 keyframe columns -->
|
||||
<div class="wfm-lab-pane wfm-lab-center">
|
||||
<div class="wfm-lab-columns">
|
||||
<div class="wfm-lab-column" data-col="checkpoint">
|
||||
<div class="wfm-lab-column" data-col="model">
|
||||
<div class="wfm-lab-col-header">
|
||||
<span>checkpoint</span>
|
||||
<div class="wfm-lab-col-header-left">
|
||||
<span>model</span>
|
||||
<div class="wfm-lab-model-view-toggle" id="wfm-lab-model-view-toggle">
|
||||
<button type="button" class="wfm-lab-model-view-btn active" data-view="checkpoint" title="Show Checkpoint">C</button>
|
||||
<button type="button" class="wfm-lab-model-view-btn" data-view="lora" title="Show LoRA">L</button>
|
||||
<button type="button" class="wfm-lab-model-view-btn" data-view="vae" title="Show VAE">V</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-btns">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="checkpoint" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="checkpoint" title="Remove last keyframe">-</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="model" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="model" title="Remove last keyframe">-</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-checkpoint"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-column" data-col="vae">
|
||||
<div class="wfm-lab-col-header">
|
||||
<span>vae</span>
|
||||
<div class="wfm-lab-col-btns">
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-add" data-col="vae" title="Add keyframe">+</button>
|
||||
<button type="button" class="wfm-btn wfm-btn-xs wfm-lab-col-remove" data-col="vae" title="Remove last keyframe">-</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-vae"></div>
|
||||
<div class="wfm-lab-col-cells" id="wfm-lab-col-cells-model"></div>
|
||||
</div>
|
||||
<div class="wfm-lab-column" data-col="prompt">
|
||||
<div class="wfm-lab-col-header">
|
||||
@@ -1330,7 +1327,8 @@
|
||||
<ul>
|
||||
<li id="wfm-help-gen-3">5-tab layout — Input (Prompt + Image), Model, Settings, Feeder, Batch — Input/Model/Settings each include a Raw JSON column for instant preview and direct editing</li>
|
||||
<li id="wfm-help-gen-4">Input tab: inner tabs — <b>Prompt</b> and <b>Image</b> (drag-and-drop upload); Prompt tab has Positive Prompt, Negative Prompt, and an <b>Embeddings</b> selector at the bottom — select a model, set Weight (default 1.0), then click <b>Paste</b> to insert <code>(embedding:Name:weight)</code> at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a <b>↺</b> button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press <b>Ctrl+↑/↓</b> to adjust its emphasis weight in <code>(text:weight)</code> syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, LoRA (Single/Stack tabs), ControlNet, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors), Hypernetwork (with Strength field) selectors with filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-28">Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself)</li>
|
||||
</ul>
|
||||
@@ -1426,14 +1424,16 @@
|
||||
<li id="wfm-help-lab-3">Center pane — 4 independent columns: Checkpoint, VAE, Prompt, KSampler; each column keeps its own list of keyframes rather than sharing a row grid</li>
|
||||
<li id="wfm-help-lab-4">+ / − per column: + appends a new keyframe at the next free iteration number (up to the Batch count) and opens its edit modal immediately; − removes the last keyframe (iteration #1 can never be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal via its <b>Delete</b> button, not just the last one</li>
|
||||
<li id="wfm-help-lab-5">Click a cell to edit it: iteration #1 sets the baseline value (leave it blank to keep the loaded workflow's own setting); iteration #2+ also lets you choose which iteration it starts applying from, plus a "revert to #1's setting" checkbox</li>
|
||||
<li id="wfm-help-lab-6">Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; "revert to #1" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one; lowering the Batch count (by hand, or by loading/applying a plan) below an existing keyframe's iteration number doesn't delete or renumber that keyframe — a warning toast appears, and the keyframe simply stays dormant (never reached by the run loop) until Batch count is raised again</li>
|
||||
<li id="wfm-help-lab-6">Effective value at iteration N: the latest keyframe with iteration ≤ N is used and carries forward until the next keyframe changes it; "revert to #1" reuses iteration #1's value (or the workflow default if #1 is blank) instead of specifying a new one; lowering the Batch count (by hand, or by loading/applying a plan) below an existing keyframe's iteration number doesn't delete or renumber that keyframe — a warning toast appears, and the keyframe simply stays dormant (never reached by the run loop) until Batch count is raised again. This whole-row resolution applies as-is to Prompt and KSampler; the Model column instead resolves its three fields (Checkpoint/LoRA/VAE) independently — see below</li>
|
||||
<li id="wfm-help-lab-7">Lab never edits the workflow shown in Input/Model/Settings — every iteration runs on a fresh clone of the loaded workflow, so keyframes here never affect the other subtabs</li>
|
||||
<li id="wfm-help-lab-8">Prompt cell editor extras: <b>Get from GenerateUI</b> fills Positive/Negative from the workflow currently loaded in GenerateUI; <b>Get from Image</b> extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); <b>Style</b> row — the <b>Apply</b> button appends whichever style is currently selected in the Style dropdown at the top of the GenerateUI toolbar to the Positive/Negative text below, comma-separated after any existing text (never clears it), then checks the Style checkbox and shows the applied style's name next to it (not on the button) — the style NAME always comes from that top dropdown (Lab has no picker of its own), and clicking Apply works regardless of the top toolbar's own Style enable checkbox; unchecking the Style checkbox only clears that name label — it does not remove the already-appended text from Positive/Negative, which stays there for you to edit or delete by hand</li>
|
||||
<li id="wfm-help-lab-9">Left pane — <b>T2I workflow (no source image)</b>: check this when the loaded workflow is text-to-image (no LoadImage node). It skips the "drop a source image first" requirement, ignores any dropped image and the Checkpoint/VAE/Prompt/KSampler keyframes still apply normally; the Image drop zone and "Use generated image for next" are greyed out while it's on (any previously dropped image is kept, not deleted, in case you switch back to I2I); the setting is saved with the plan as <code>t2i_mode</code></li>
|
||||
<li id="wfm-help-lab-9">Left pane — <b>T2I workflow (no source image)</b>: check this when the loaded workflow is text-to-image (no LoadImage node). It skips the "drop a source image first" requirement, ignores any dropped image and the Model/Prompt/KSampler keyframes still apply normally; the Image drop zone and "Use generated image for next" are greyed out while it's on (any previously dropped image is kept, not deleted, in case you switch back to I2I); the setting is saved with the plan as <code>t2i_mode</code></li>
|
||||
<li id="wfm-help-lab-10">Wildcards: Positive/Negative prompt text in Prompt column keyframes supports the same A1111-style <code>__name__</code> syntax as the main GenerateUI (see Prompt tab's Wildcards help above) — each keyframe's text is expanded fresh right before that iteration runs, so every iteration can draw a different random line even when reusing the same keyframe</li>
|
||||
<li id="wfm-help-lab-11">Prompt cell editor also has <b>Clear</b> (empties both Positive and Negative) and <b>Get from Previous</b> (copies both Positive and Negative from the keyframe immediately before this one in the same column — e.g. editing #3 copies #2's prompt wholesale; not shown on keyframe #1, which has no earlier row to copy from)</li>
|
||||
<li id="wfm-help-lab-12">Keyframe #1 in every column shows the currently loaded workflow's own live setting — Checkpoint, VAE, Prompt, and KSampler — instead of a blank placeholder, both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI</li>
|
||||
<li id="wfm-help-lab-12">Keyframe #1 in every column shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only (LoRA is never live-reflected, since there's no reliable way to infer "the" active LoRA back out of a workflow node) — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI</li>
|
||||
<li id="wfm-help-lab-13"><b>Bypass</b> checkbox (every keyframe, including #1): skips that row entirely when picking which setting applies at a given iteration — as if the row didn't exist — so whatever was active before it (an earlier keyframe, or ultimately nothing) just keeps running; Lab-internal only, it never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (the two are mutually exclusive); the cell shows an amber "⏭ Bypassed" label, which takes priority over both the Revert and live-value display</li>
|
||||
<li id="wfm-help-lab-14"><b>Model</b> column header's <b>C / L / V</b> buttons pick which independent keyframe list — Checkpoint, LoRA (Single-only, no Stack support here), or VAE — the shared cell list, its +/-, and its edit modal all currently operate on. Each of the three is its own timeline with its own keyframes, exactly like Prompt/KSampler; they only ever look merged because they share one column's worth of screen space. Adding, removing, or editing a keyframe while viewing one never touches the other two. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it</li>
|
||||
<li id="wfm-help-lab-15">Checkpoint / LoRA / VAE each carry forward independently, the same effective-value rule as every other column (see above): a keyframe in one never resets the other two to the workflow default. A field left blank in the edit modal shows "(inherits: ...)" as its placeholder instead of "(workflow default)" when an earlier keyframe in that same list already set something, so it's clear what actually applies. "Revert to #1's setting" and Bypass — like +/- — only ever affect whichever ONE of the three is currently selected by the C/L/V toggle, never the other two</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1567,6 +1567,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-settings-11">Text Size: one slider (10–28 px) adjusts font size for all prompt and chat textareas at once — Generate UI positive/negative prompts, AI Assistant chat input, Preset positive/negative, Wildcard prompt and file editor, and Metadata prompt full preview; takes effect immediately and saved with Save Settings</li>
|
||||
<li id="wfm-help-settings-12">RAW JSON Colors: customize the 6 syntax highlight colors for the Raw JSON editor in Generate UI — Default Text, Name/Scheduler, Title, Width/Height, Prompt/Text, Image/File; changes apply immediately on color pick; Reset Defaults restores the original color scheme</li>
|
||||
<li id="wfm-help-settings-14">GenerateUI Model Tab Highlight: pick the color used to mark Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork labels and the LoRA column header in GenerateUI's Model tab when the loaded workflow actually contains that type of node; default bright green; changes apply immediately, Reset Defaults restores it</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1754,7 +1755,7 @@
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-sidepanel-card-ai-title">AI TOOL tab</h3>
|
||||
<ul>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, or Lemonade; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
<li id="wfm-help-sidepanel-16">AI TOOL tab: Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1777,7 +1778,7 @@
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-translate-title">Translation pane</h3>
|
||||
<p id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, or Lemonade; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</p>
|
||||
<p id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-chat-title">Chat pane</h3>
|
||||
@@ -1801,7 +1802,7 @@
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-settings-title">Settings pane</h3>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade), set API URL, test connection, select model, configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</p>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model; Unsloth always requires an API key even for local access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI, the key is read server-side and never sent to the browser; Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a `think` flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1916,6 +1917,7 @@
|
||||
<li id="wfm-help-imageedit-mask-11">Text sub-tool: click on the canvas to open a text stamp overlay; enter text, configure Font / Size / Bold / Italic / Align, choose Mode (Add = white stamp / Erase = cut mask), then click Stamp or press Ctrl+Enter; multi-line text supported (newline in textarea)</li>
|
||||
<li id="wfm-help-imageedit-mask-12">Vector sub-tool: click to place control points connected by a Catmull-Rom spline; click the first point again (or press Enter) to close and fill the polygon; Backspace removes the last point; Esc resets all points; Properties pane shows current point count and Mode (Add / Erase)</li>
|
||||
<li id="wfm-help-imageedit-mask-13">Shape sub-tool: drag to draw a rectangle or ellipse mask; hold Shift for a perfect square or circle; a blue dashed outline previews the shape while dragging; Properties pane: Mode (Add / Erase), Shape (Rect / Ellipse)</li>
|
||||
<li id="wfm-help-imageedit-mask-14">Edit in Mask Editor One (Paint mode only): click "Edit in Mask Editor One →" in the MASK EDITOR ONE section to send the current canvas composite — plus the active mask layer's content, if one is selected — to a MaskEditorOne node on the ComfyUI canvas (the currently selected node if it is one, otherwise the first MaskEditorOne node found in the graph) and open its full editor (SAM3, BiRefNet, shapes, etc.) in the ComfyUI browser tab; after clicking Apply there, the result is imported back here as a new mask layer; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable, and at least one MaskEditorOne node to exist in the ComfyUI graph</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
@@ -1948,7 +1950,8 @@
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-17">Save PNG: composites all visible layers and downloads the result as a PNG file</li>
|
||||
<li id="wfm-help-imageedit-18">Save to Gallery: prompts for a filename (default: wfs-image-YYYYMMDDHHmmss) and saves the composite PNG directly to the Gallery root folder (configured in Settings > Gallery Output Folder); requires the Gallery tab to have been opened at least once to set the root</li>
|
||||
<li id="wfm-help-imageedit-19">Send to ComfyUI: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes</li>
|
||||
<li id="wfm-help-imageedit-19">Save to Input: uploads the composite PNG to ComfyUI's input folder via /upload/image, making it selectable in Load Image nodes</li>
|
||||
<li id="wfm-help-imageedit-19b">Send to Workflow: uploads the composite PNG (same as Save to Input) and additionally writes the resulting filename into the image widget of the currently selected node in the ComfyUI graph, falling back to the first LoadImage-type node found; requires Workflow Studio to have been opened from ComfyUI's top menu button so the ComfyUI tab is reachable</li>
|
||||
<li id="wfm-help-imageedit-20">Canvas navigation: scroll wheel to zoom, Space + drag to pan</li>
|
||||
<li id="wfm-help-imageedit-21">Undo / Redo: Ctrl+Z / Ctrl+Y (or toolbar buttons ↩ ↪)</li>
|
||||
</ul>
|
||||
@@ -2400,7 +2403,8 @@
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-redo-btn" title="Redo (Ctrl+Y)">↪</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-btn">Save PNG</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-save-gallery-btn" title="Save to Gallery root folder">Save to Gallery</button>
|
||||
<button class="wfm-btn wfm-btn-sm wfm-btn-primary" id="ie-upload-comfy-btn">Send to ComfyUI</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-upload-comfy-btn">Save to Input</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-send-workflow-btn" title="Upload and write this image's filename into the selected node's image widget (e.g. LoadImage)">Send to Workflow</button>
|
||||
</div>
|
||||
|
||||
<!-- Tool options -->
|
||||
@@ -2809,6 +2813,9 @@
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-ai-backend" value="lemonade"> Lemonade
|
||||
</label>
|
||||
<label class="wfm-ai-radio-label">
|
||||
<input type="radio" name="wfm-ai-backend" value="unsloth"> Unsloth
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
@@ -2831,6 +2838,19 @@
|
||||
<button id="wfm-ai-model-refresh-btn" class="wfm-btn">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-generation-title">Generation</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-ai-thinking-mode-checkbox">
|
||||
<span id="wfm-ai-thinking-mode-label">Thinking mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" id="wfm-ai-max-tokens-label">Max tokens</label>
|
||||
<input type="number" id="wfm-ai-max-tokens" class="wfm-input" min="0" step="1" placeholder="0 = unlimited">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-freelang-title">Free language</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
|
||||
+284
-26
@@ -583,6 +583,136 @@ window.wfmReceiveWorkflow = async (data) => {
|
||||
await loadDataOnCanvas(data);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Cross-window bridge for SPA Mask Editor One integration
|
||||
// ============================================
|
||||
// WFS Image Edit タブから呼ばれる。「Send to Workflow」の image ウィジェット探索
|
||||
// (_wfmFindImageWidget)と同じ「選択中ノード優先・フォールバック」方式で
|
||||
// MaskEditorOne ノードをComfyUIキャンバス上から探す。
|
||||
function _wfmFindMaskEditorOneNode() {
|
||||
const graph = app.graph;
|
||||
if (!graph) return null;
|
||||
|
||||
let selected = [];
|
||||
try {
|
||||
const sel = app.canvas?.selected_nodes;
|
||||
if (sel) selected = Object.values(sel);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const pools = [selected, graph._nodes || []];
|
||||
for (const pool of pools) {
|
||||
for (const node of pool) {
|
||||
if (node?.type === "MaskEditorOne") return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 見つけた MaskEditorOne ノードへ画像を送り込み、そのノード自身の「Edit Mask」ウィジェット
|
||||
// (node._editMaskWidget.callback)を疑似クリックしてモーダルを開く。モーダルの overlay DOM
|
||||
// (.me-overlay) が非表示に戻ったタイミングで Apply 後のマスク(node._maskDataUrl、グレースケール
|
||||
// PNG data URL)を呼び出し元へ返す。Cancel/×で閉じた場合はマスクが更新されていないため null を返す。
|
||||
window.wfmOpenMaskEditorForNode = async (imageDataUrl, maskDataUrl) => {
|
||||
const node = _wfmFindMaskEditorOneNode();
|
||||
if (!node) {
|
||||
throw new Error("No Mask Editor One node found (select one on the canvas, or add one to the workflow)");
|
||||
}
|
||||
if (typeof node._editMaskWidget?.callback !== "function") {
|
||||
throw new Error("MaskEditorOne node is missing its Edit Mask control (unsupported version?)");
|
||||
}
|
||||
const nodeId = node.id;
|
||||
|
||||
const resp = await fetch("/mask_editor/store_image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: String(nodeId), bg_image_b64: imageDataUrl }),
|
||||
});
|
||||
if (!resp.ok) throw new Error("Failed to send image to Mask Editor One");
|
||||
|
||||
// WFS側で既にマスクレイヤーを描いていた場合、それを初期マスクとしてそのまま引き継ぐ
|
||||
// (applyImageAndMaskToMaskEditorOneNode の layer_data 形式と同じスキーマ)
|
||||
if (maskDataUrl) {
|
||||
const layerWidget = node.widgets?.find((w) => w.name === "layer_data");
|
||||
if (layerWidget) {
|
||||
layerWidget.value = JSON.stringify({
|
||||
layers: [{ visible: true, opacity: 1, operation: "add", imageData: maskDataUrl }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ノード側のプレビュー/モーダル背景を、送信した画像に同期させる
|
||||
node._bgDataUrl = imageDataUrl;
|
||||
node._bgImg = await new Promise((r) => {
|
||||
const img = new Image();
|
||||
img.onload = () => r(img);
|
||||
img.onerror = () => r(null);
|
||||
img.src = imageDataUrl;
|
||||
});
|
||||
node._previewMode = "image";
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
|
||||
const prevMaskUrl = node._maskDataUrl;
|
||||
node._editMaskWidget.callback();
|
||||
|
||||
// openMaskEditor() は同期的に overlay DOM を構築・表示するため、callback() 直後に取得できる
|
||||
const overlays = [...document.querySelectorAll(".me-overlay")];
|
||||
const overlay = overlays.find((o) => o.style.display !== "none") || overlays[overlays.length - 1];
|
||||
if (!overlay) throw new Error("Mask Editor One modal did not open");
|
||||
|
||||
window.focus();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (overlay.style.display === "none") {
|
||||
observer.disconnect();
|
||||
resolve(node._maskDataUrl && node._maskDataUrl !== prevMaskUrl ? node._maskDataUrl : null);
|
||||
}
|
||||
});
|
||||
observer.observe(overlay, { attributes: true, attributeFilter: ["style"] });
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Cross-window bridge for SPA "Send to Workflow"
|
||||
// ============================================
|
||||
// WFS Image Edit タブから呼ばれる。アップロード済みの画像ファイル名を、現在ComfyUIキャンバス上で
|
||||
// 選択中のノードの "image" ウィジェット(LoadImage / LoadImage 互換ノード)へ書き込む。
|
||||
// 選択ノードに該当ウィジェットが無ければグラフ内の最初の該当ノードにフォールバックする。
|
||||
// chat_TE カスタムノードの "Send to workflow" ボタンと同じロジック。
|
||||
function _wfmFindImageWidget() {
|
||||
const graph = app.graph;
|
||||
if (!graph) return null;
|
||||
|
||||
let selected = [];
|
||||
try {
|
||||
const sel = app.canvas?.selected_nodes;
|
||||
if (sel) selected = Object.values(sel);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const pools = [selected, graph._nodes || []];
|
||||
for (const pool of pools) {
|
||||
for (const node of pool) {
|
||||
const widget = node?.widgets?.find((w) => w.name === "image");
|
||||
if (widget) return widget;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
window.wfmSendImageToSelectedNode = (filename) => {
|
||||
const widget = _wfmFindImageWidget();
|
||||
if (!widget) {
|
||||
throw new Error("No image widget found (select a LoadImage-type node)");
|
||||
}
|
||||
if (widget.options?.values && !widget.options.values.includes(filename)) {
|
||||
widget.options.values.unshift(filename);
|
||||
}
|
||||
widget.value = filename;
|
||||
widget.callback?.(widget.value);
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadWorkflowOnCanvas = async (filename) => {
|
||||
const displayName = filename.replace(/\.json$/i, "");
|
||||
showToast(`Loading "${displayName}"...`, "info");
|
||||
@@ -3622,11 +3752,38 @@ const AI_BACKEND_DEFAULT_URLS = {
|
||||
ollama: "http://localhost:11434",
|
||||
lmstudio: "http://localhost:1234",
|
||||
lemonade: "http://localhost:13305",
|
||||
unsloth: "http://localhost:8888",
|
||||
};
|
||||
function getAiBackendDefaultUrl(backend) {
|
||||
return AI_BACKEND_DEFAULT_URLS[backend] || AI_BACKEND_DEFAULT_URLS.ollama;
|
||||
}
|
||||
|
||||
// Server relays the request, attaching the Unsloth API key from .env so it
|
||||
// never reaches the frontend — see py/routes/unsloth_routes.py.
|
||||
async function aiUnslothProxy(baseUrl, path, method, payload) {
|
||||
const r = await fetch("/api/wfm/unsloth/proxy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ baseUrl, path, method, payload }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.message || `HTTP ${r.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Unlike Ollama/LM Studio/Lemonade (which inline reasoning as <think> tags
|
||||
// inside content), Unsloth's OpenAI-compatible API returns it in a separate
|
||||
// `reasoning_content` field. Fold it back into a <think> block so the
|
||||
// existing Thinking-mode show/strip logic (_aiApplyGenOptions/aiStripThinkingTags)
|
||||
// still applies uniformly. Without this, a low max_tokens can make the model
|
||||
// spend its whole budget reasoning and return an empty `content` with no
|
||||
// visible explanation.
|
||||
function _aiUnslothContent(message) {
|
||||
const reasoning = message?.reasoning_content;
|
||||
const content = message?.content || "";
|
||||
return reasoning ? `<think>${reasoning}</think>${content}` : content;
|
||||
}
|
||||
|
||||
function isValidAiUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
@@ -3645,22 +3802,51 @@ function saveAiCfg(patch) {
|
||||
return d;
|
||||
}
|
||||
|
||||
async function aiCallLLM(url, backend, model, prompt) {
|
||||
// Ollamaは `think` (bool) と `options.num_predict` で制御できるが、
|
||||
// LM Studio/LemonadeはOpenAI互換APIのため max_tokens のみ標準対応。
|
||||
// thinking mode切替は非対応バックエンド/モデル向けに、出力からの
|
||||
// <think>タグ除去でも担保する(aiStripThinkingTagsを参照)。
|
||||
function _aiApplyGenOptions(body, backend, settings) {
|
||||
const maxTokens = parseInt(settings?.maxTokens, 10);
|
||||
if (backend === "ollama") {
|
||||
body.think = !!settings?.thinkingMode;
|
||||
if (maxTokens > 0) body.options = { ...(body.options || {}), num_predict: maxTokens };
|
||||
} else if (maxTokens > 0) {
|
||||
body.max_tokens = maxTokens;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function aiStripThinkingTags(text) {
|
||||
return (text || "")
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function aiCallLLM(url, backend, model, prompt, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/generate`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, stream: false }),
|
||||
body: JSON.stringify(_aiApplyGenOptions({ model, prompt, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).response || "";
|
||||
const text = (await r.json()).response || "";
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _aiApplyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings);
|
||||
const d = await aiUnslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _aiUnslothContent(d.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
} else {
|
||||
const r = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], stream: false }),
|
||||
body: JSON.stringify(_aiApplyGenOptions({ model, messages: [{ role: "user", content: prompt }], stream: false }, backend, settings)),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json();
|
||||
return d.choices?.[0]?.message?.content || "";
|
||||
const text = d.choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3676,54 +3862,99 @@ function aiFileToBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType) {
|
||||
async function aiCallVLM(url, backend, model, prompt, base64Image, mimeType, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/generate`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, prompt, images: [base64Image], stream: false }),
|
||||
body: JSON.stringify(_aiApplyGenOptions({ model, prompt, images: [base64Image], stream: false }, backend, settings)),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).response || "";
|
||||
const text = (await r.json()).response || "";
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _aiApplyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}, backend, settings);
|
||||
const d = await aiUnslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _aiUnslothContent(d.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
} else {
|
||||
const r = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify(_aiApplyGenOptions({
|
||||
model,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image_url", image_url: { url: `data:${mimeType};base64,${base64Image}` } },
|
||||
]}],
|
||||
stream: false,
|
||||
}),
|
||||
}, backend, settings)),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await r.json()).choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
async function aiCallChat(url, backend, model, messages) {
|
||||
async function aiCallChat(url, backend, model, messages, settings = {}) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/chat`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
body: JSON.stringify(_aiApplyGenOptions({ model, messages, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).message?.content || "";
|
||||
const text = (await r.json()).message?.content || "";
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
} else if (backend === "unsloth") {
|
||||
const body = _aiApplyGenOptions({ model, messages, stream: false }, backend, settings);
|
||||
const d = await aiUnslothProxy(url, "/v1/chat/completions", "POST", body);
|
||||
const text = _aiUnslothContent(d.choices?.[0]?.message);
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
} else {
|
||||
const r = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
body: JSON.stringify(_aiApplyGenOptions({ model, messages, stream: false }, backend, settings)),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return (await r.json()).choices?.[0]?.message?.content || "";
|
||||
const text = (await r.json()).choices?.[0]?.message?.content || "";
|
||||
return settings.thinkingMode ? text : aiStripThinkingTags(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Strips common LLM wrapping/preamble artifacts that survive despite the system prompt.
|
||||
function cleanAiTranslationOutput(raw) {
|
||||
let s = (raw || "").trim();
|
||||
s = s.replace(/^(sure[,.]?\s*)?(here('|)s|here is)?\s*(the\s+)?translation\s*:?\s*/i, "");
|
||||
s = s.replace(/^(翻訳|译文|翻译)\s*[::]\s*/, "");
|
||||
if (s.length >= 2) {
|
||||
const first = s[0], last = s[s.length - 1];
|
||||
const pairs = { '"': '"', "'": "'", "“": "”", "「": "」" };
|
||||
if (pairs[first] === last) s = s.slice(1, -1).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Heuristic: if the cleaned output is (near-)identical to the input, the
|
||||
// model most likely echoed the source instead of translating it.
|
||||
function aiLooksUntranslated(input, output) {
|
||||
const normalize = (s) => s.trim().toLowerCase().replace(/[\s.,!?"'。、!?「」]/g, "");
|
||||
const a = normalize(input), b = normalize(output);
|
||||
return a.length > 0 && a === b;
|
||||
}
|
||||
|
||||
async function aiFetchModels(url, backend) {
|
||||
if (backend === "ollama") {
|
||||
const r = await fetch(`${url}/api/tags`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return ((await r.json()).models || []).map(m => m.name);
|
||||
} else if (backend === "unsloth") {
|
||||
const d = await aiUnslothProxy(url, "/v1/models", "GET");
|
||||
return (d.data || []).map(m => m.id);
|
||||
} else {
|
||||
const r = await fetch(`${url}/v1/models`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
@@ -3875,6 +4106,7 @@ const renderAiTab = (container) => {
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="ollama"> Ollama</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lmstudio"> LM Studio</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="lemonade"> Lemonade</label>
|
||||
<label class="wfm-nlp-ai-radio"><input type="radio" name="wfm-nlp-ai-backend" value="unsloth"> Unsloth</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
@@ -3894,6 +4126,18 @@ const renderAiTab = (container) => {
|
||||
<button id="wfm-nlp-ai-model-refresh" class="wfm-nlp-ai-btn">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
<div class="wfm-nlp-ai-sec-title">Generation</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<label class="wfm-nlp-ai-lbl" style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="wfm-nlp-ai-thinking-mode"> Thinking mode
|
||||
</label>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
<span class="wfm-nlp-ai-lbl">Max tokens</span>
|
||||
<input type="number" id="wfm-nlp-ai-max-tokens" class="wfm-nlp-ai-input" min="0" step="1" placeholder="0 = unlimited" style="flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-nlp-ai-sec">
|
||||
<div class="wfm-nlp-ai-sec-title">Free language</div>
|
||||
<div class="wfm-nlp-ai-row">
|
||||
@@ -3947,6 +4191,11 @@ const setupAiHandlers = (container) => {
|
||||
if (freeSrcInput && cfg.freeSrcLang) freeSrcInput.value = cfg.freeSrcLang;
|
||||
if (freeDstInput && cfg.freeDstLang) freeDstInput.value = cfg.freeDstLang;
|
||||
|
||||
const thinkingModeInput = container.querySelector("#wfm-nlp-ai-thinking-mode");
|
||||
if (thinkingModeInput) thinkingModeInput.checked = !!cfg.thinkingMode;
|
||||
const maxTokensInput = container.querySelector("#wfm-nlp-ai-max-tokens");
|
||||
if (maxTokensInput && cfg.maxTokens) maxTokensInput.value = cfg.maxTokens;
|
||||
|
||||
const srcSel = container.querySelector("#wfm-nlp-ai-src");
|
||||
const dstSel = container.querySelector("#wfm-nlp-ai-dst");
|
||||
if (srcSel && cfg.srcLang) srcSel.value = cfg.srcLang;
|
||||
@@ -3978,10 +4227,13 @@ const setupAiHandlers = (container) => {
|
||||
|
||||
const srcLang = srcSel?.value || "ja";
|
||||
const dstLang = dstSel?.value || "en";
|
||||
const srcName = srcLang === "free" ? (c.freeSrcLang || "Auto") : AI_LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (c.freeDstLang || "English") : AI_LANG_NAMES[dstLang];
|
||||
const fromPart = srcLang === "free" ? "" : `from ${srcName} `;
|
||||
const prompt = `Translate the following text ${fromPart}to ${dstName}. Output only the translated text, nothing else.\n\n${text}`;
|
||||
const srcName = srcLang === "free" ? (c.freeSrcLang || "").trim() : AI_LANG_NAMES[srcLang];
|
||||
const dstName = dstLang === "free" ? (c.freeDstLang || "English").trim() || "English" : AI_LANG_NAMES[dstLang];
|
||||
const fromPart = srcName ? ` from ${srcName}` : "";
|
||||
const systemPrompt = `You are a professional translator. Translate the text the user sends${fromPart} into ${dstName}. `
|
||||
+ `Reply with ONLY the translated text and nothing else: no explanations, no notes, no preamble like "Here is the translation:", `
|
||||
+ `no surrounding quotes, and do not repeat or echo the original text.`;
|
||||
const messages = [{ role: "system", content: systemPrompt }, { role: "user", content: text }];
|
||||
|
||||
const transBtn = container.querySelector("#wfm-nlp-ai-trans-btn");
|
||||
const statusEl = container.querySelector("#wfm-nlp-ai-status");
|
||||
@@ -3992,10 +4244,14 @@ const setupAiHandlers = (container) => {
|
||||
outputEl.value = "";
|
||||
|
||||
try {
|
||||
const result = await aiCallLLM(url, backend, model, prompt);
|
||||
outputEl.value = result.trim();
|
||||
const result = await aiCallChat(url, backend, model, messages, c);
|
||||
const cleaned = cleanAiTranslationOutput(result);
|
||||
outputEl.value = cleaned;
|
||||
statusEl.textContent = "完了";
|
||||
statusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";
|
||||
if (srcLang !== dstLang && aiLooksUntranslated(text, cleaned)) {
|
||||
showToast("モデルが原文をそのまま返しました。翻訳されていない可能性があります。別のモデルをお試しください。", "error");
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = `エラー: ${err.message}`;
|
||||
statusEl.className = "wfm-nlp-ai-status wfm-nlp-ai-err";
|
||||
@@ -4069,11 +4325,13 @@ const setupAiHandlers = (container) => {
|
||||
const model = container.querySelector("#wfm-nlp-ai-model")?.value || "";
|
||||
const freeSrcLang = container.querySelector("#wfm-nlp-ai-free-src")?.value?.trim() || "";
|
||||
const freeDstLang = container.querySelector("#wfm-nlp-ai-free-dst")?.value?.trim() || "";
|
||||
const thinkingMode = !!container.querySelector("#wfm-nlp-ai-thinking-mode")?.checked;
|
||||
const maxTokens = Math.max(0, parseInt(container.querySelector("#wfm-nlp-ai-max-tokens")?.value, 10) || 0);
|
||||
if (url && !isValidAiUrl(url)) {
|
||||
showToast("URLは http:// または https:// で始まる必要があります", "error");
|
||||
return;
|
||||
}
|
||||
saveAiCfg({ backend, backendUrl: url, model, freeSrcLang, freeDstLang });
|
||||
saveAiCfg({ backend, backendUrl: url, model, freeSrcLang, freeDstLang, thinkingMode, maxTokens });
|
||||
showToast("Settings saved", "success");
|
||||
});
|
||||
|
||||
@@ -4119,7 +4377,7 @@ const setupAiHandlers = (container) => {
|
||||
const messagesToSend = skillPrompt
|
||||
? [{ role: "system", content: skillPrompt }, ...chatHistory]
|
||||
: chatHistory;
|
||||
const reply = await aiCallChat(url, backend, model, messagesToSend);
|
||||
const reply = await aiCallChat(url, backend, model, messagesToSend, c);
|
||||
chatHistory.push({ role: "assistant", content: reply });
|
||||
appendChatBubble("assistant", reply);
|
||||
chatStatusEl.textContent = "";
|
||||
@@ -4351,7 +4609,7 @@ const setupAiHandlers = (container) => {
|
||||
|
||||
const prompt = `Generate ${count} wildcard entries for the category "${name}". Output only plain text in English, one entry per line, no numbers, no markdown, no asterisks, no bold, nothing else.`;
|
||||
try {
|
||||
const result = await aiCallLLM(url, backend, model, prompt);
|
||||
const result = await aiCallLLM(url, backend, model, prompt, c);
|
||||
vlmResult.value = result.trim()
|
||||
.split("\n")
|
||||
.map(l => l.replace(/\*\*/g, "").replace(/^\*\s*/, "").replace(/^\d+\.\s*/, "").trim())
|
||||
@@ -4376,7 +4634,7 @@ const setupAiHandlers = (container) => {
|
||||
vlmResult.value = "";
|
||||
|
||||
try {
|
||||
const result = await aiCallVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType);
|
||||
const result = await aiCallVLM(url, backend, model, VLM_PROMPTS[task], vlmImage.base64, vlmImage.mimeType, c);
|
||||
vlmResult.value = result.trim();
|
||||
vlmStatus.textContent = "完了";
|
||||
vlmStatus.className = "wfm-nlp-ai-status wfm-nlp-ai-ok";
|
||||
|
||||
Reference in New Issue
Block a user